Files
portal/tools/observer-stop-hook.test.mjs
T
Дмитрий a8257001a7 feat(observer): Stop-event hook — JSONL append with PII filter + primary_rationale validation
Hook contract: reads JSON ctx from stdin (Claude Code Stop-event),
builds episode with 5 mandatory fields including primary_rationale
(7 sub-fields per spec v1.1 §5.2.1), sanitizes via observer-pii-filter,
appends to docs/observer/episodes-YYYY-MM.jsonl. Never blocks
Stop-event (exit 0 on error).

8 Vitest tests verified GREEN (6 in appendEpisode + 2 in
buildEpisodeFromContext): append/append-existing/PII-filter/
missing-required/missing-rationale-field/routing_decision-preserved
+ buildEpisode 5-field extraction + user-rationale-preserved.

Vitest config for tools/ already covers via glob ../tools/observer-*.test.mjs
(extended in B2 commit 4616308).

Per Pravila §16.2 + ADR-011 + spec v1.1 §5.2.1 (factor analysis).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 06:16:36 +03:00

138 lines
5.6 KiB
JavaScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync, mkdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { appendEpisode, buildEpisodeFromContext } from './observer-stop-hook.mjs';
let workdir;
beforeEach(() => {
workdir = mkdtempSync(join(tmpdir(), 'observer-test-'));
mkdirSync(join(workdir, 'docs', 'observer'), { recursive: true });
});
afterEach(() => {
rmSync(workdir, { recursive: true, force: true });
});
// Helper for v1.1 — primary_rationale fixture (factor analysis amendment)
const defaultRat = () => ({
step: 1,
node_chosen: '#1',
triggers_matched: [],
candidates_considered: [],
boundaries_applied: [],
hard_floor: { invoked: false, rules: [] },
task_classification: 'other',
});
describe('appendEpisode', () => {
it('appends one JSONL line to monthly file', () => {
const ep = {
task_id: 'abc-123',
timestamps: { started_at: '2026-05-19T10:00:00+03:00', ended_at: '2026-05-19T10:05:00+03:00' },
path_type: 'regulated',
outcome: 'success',
primary_rationale: defaultRat(),
};
appendEpisode(ep, workdir, '2026-05');
const file = join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl');
const content = readFileSync(file, 'utf-8');
expect(content).toContain('"task_id":"abc-123"');
expect(content).toContain('"primary_rationale"');
expect(content.endsWith('\n')).toBe(true);
});
it('appends to existing file without overwrite', () => {
appendEpisode({ task_id: 'a', timestamps: {}, path_type: 'regulated', outcome: 'success', primary_rationale: defaultRat() }, workdir, '2026-05');
appendEpisode({ task_id: 'b', timestamps: {}, path_type: 'improvised', outcome: 'partial', primary_rationale: defaultRat() }, workdir, '2026-05');
const lines = readFileSync(join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl'), 'utf-8').trim().split('\n');
expect(lines).toHaveLength(2);
expect(JSON.parse(lines[0]).task_id).toBe('a');
expect(JSON.parse(lines[1]).task_id).toBe('b');
});
it('applies PII filter before write (including events[])', () => {
appendEpisode({
task_id: 'c',
timestamps: {},
path_type: 'regulated',
outcome: 'success',
primary_rationale: defaultRat(),
events: [{ kind: 'error', message: 'call +79991234567 / mail x@y.com' }],
}, workdir, '2026-05');
const content = readFileSync(join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl'), 'utf-8');
expect(content).toContain('+7XXXXXXXXXX');
expect(content).toContain('***@***');
expect(content).not.toContain('79991234567');
});
it('throws on missing required top-level fields', () => {
expect(() => appendEpisode({}, workdir, '2026-05')).toThrow(/required/i);
expect(() => appendEpisode({ task_id: 'x' }, workdir, '2026-05')).toThrow(/required/i);
expect(() => appendEpisode({ task_id: 'x', timestamps: {}, path_type: 'regulated', outcome: 'success' }, workdir, '2026-05')).toThrow(/primary_rationale/i);
});
it('throws when primary_rationale field is missing', () => {
const ep = {
task_id: 'd',
timestamps: {},
path_type: 'regulated',
outcome: 'success',
primary_rationale: { step: 1, node_chosen: '#1' }, // missing other 5 fields
};
expect(() => appendEpisode(ep, workdir, '2026-05')).toThrow(/primary_rationale field missing/i);
});
it('persists routing_decision events with structured fields', () => {
appendEpisode({
task_id: 'e',
timestamps: {},
path_type: 'regulated',
outcome: 'success',
primary_rationale: defaultRat(),
events: [
{ kind: 'routing_decision', step: 1, node_chosen: '#55', triggers_matched: ['discovery'],
candidates_considered: [{ node_id: '#53', dropped_because: 'ADR-009' }],
boundaries_applied: ['ADR-009'], hard_floor: { invoked: false, rules: [] },
task_classification: 'discovery' },
],
}, workdir, '2026-05');
const line = JSON.parse(readFileSync(join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl'), 'utf-8').trim());
expect(line.events[0].kind).toBe('routing_decision');
expect(line.events[0].triggers_matched).toEqual(['discovery']);
expect(line.events[0].candidates_considered[0].dropped_because).toBe('ADR-009');
expect(line.events[0].boundaries_applied).toEqual(['ADR-009']);
});
});
describe('buildEpisodeFromContext', () => {
it('extracts 5 mandatory fields from context object', () => {
const ctx = {
sessionId: 'sess-1',
started: '2026-05-19T09:00:00+03:00',
ended: '2026-05-19T09:30:00+03:00',
result: 'success',
};
const ep = buildEpisodeFromContext(ctx);
expect(ep.task_id).toBe('sess-1');
expect(ep.timestamps.started_at).toBe(ctx.started);
expect(ep.outcome).toBe('success');
expect(['regulated', 'improvised', 'alternative', 'mixed']).toContain(ep.path_type);
expect(ep.primary_rationale).toBeDefined();
expect(ep.primary_rationale.step).toBe(1);
expect(ep.primary_rationale.hard_floor).toEqual({ invoked: false, rules: [] });
});
it('preserves user-provided primary_rationale unchanged', () => {
const rat = {
step: 1, node_chosen: '#55', triggers_matched: ['discovery'],
candidates_considered: [], boundaries_applied: ['ADR-009'],
hard_floor: { invoked: true, rules: ['Pravila §12'] },
task_classification: 'discovery',
};
const ep = buildEpisodeFromContext({ sessionId: 'x', primary_rationale: rat });
expect(ep.primary_rationale).toEqual(rat);
});
});