diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 51c84ea9..90d49dc5 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-05-19T07:23:18.404Z +Last updated: 2026-05-19T07:27:33.112Z | Контролёр | Состояние | Детали | |---|---|---| diff --git a/tools/observer-stop-hook.mjs b/tools/observer-stop-hook.mjs index 496c76d9..d3388c5f 100644 --- a/tools/observer-stop-hook.mjs +++ b/tools/observer-stop-hook.mjs @@ -20,6 +20,8 @@ import { sanitize } from './observer-pii-filter.mjs'; import { parseTranscript } from './observer-transcript-parser.mjs'; const REQUIRED_FIELDS = ['task_id', 'timestamps', 'path_type', 'outcome', 'primary_rationale']; +const V2_FIELDS = ['schema_version', 'decision_provenance', 'environment', 'task_size', 'task_ref']; +const OBSERVER_ERROR_FIELDS = ['schema_version', 'error_message', 'timestamps', 'task_id']; const RATIONALE_FIELDS = [ 'step', @@ -41,60 +43,87 @@ function validateRationale(rationale) { /** * Append a single episode to the monthly JSONL file. - * @param {object} episode - The episode object (5 mandatory top-level fields required). + * Validates either a full schema-v2 episode or a minimal observer_error marker. + * @param {object} episode - The episode object. * @param {string} baseDir - Repository root (default: process.cwd()). * @param {string} month - YYYY-MM string for the file name (default: current UTC month). */ export function appendEpisode(episode, baseDir = process.cwd(), month = currentMonth()) { - // Validate required top-level fields + const dir = join(baseDir, 'docs', 'observer'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const file = join(dir, `episodes-${month}.jsonl`); + + if (episode && episode.observer_error === true) { + for (const f of OBSERVER_ERROR_FIELDS) { + if (episode[f] === undefined) { + throw new Error(`observer_error marker field missing: ${f}`); + } + } + appendFileSync(file, JSON.stringify(sanitize(episode)) + '\n', 'utf-8'); + return; + } + for (const f of REQUIRED_FIELDS) { if (episode[f] === undefined) { throw new Error(`required field missing: ${f}`); } } - // Validate primary_rationale sub-fields + for (const f of V2_FIELDS) { + if (episode[f] === undefined) { + throw new Error(`schema v2 field missing: ${f}`); + } + } + if (episode.schema_version !== 2) { + throw new Error(`schema_version must be 2 (got ${episode.schema_version})`); + } validateRationale(episode.primary_rationale); - // Sanitize before write - const sanitized = sanitize(episode); - - const dir = join(baseDir, 'docs', 'observer'); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - - const file = join(dir, `episodes-${month}.jsonl`); - appendFileSync(file, JSON.stringify(sanitized) + '\n', 'utf-8'); + appendFileSync(file, JSON.stringify(sanitize(episode)) + '\n', 'utf-8'); } /** - * Build a well-formed episode object from a Claude Code Stop-event context. + * Build a well-formed schema-v2 episode from a Claude Code Stop-event context. * Preferred path: when `transcriptText` is supplied, the episode is derived - * from the real session transcript via parseTranscript. Fallback path: when - * no transcript is available, best-effort defaults are read from `ctx` - * (and an explicit ctx.primary_rationale is preserved verbatim). + * from the real session transcript via parseTranscript. Fallback path: v2 + * defaults from `ctx` (an explicit ctx.primary_rationale is preserved verbatim). * @param {object} ctx - Raw context from stdin (may be partial). * @param {string|null} transcriptText - Raw transcript JSONL, if readable. - * @returns {object} Episode with 5 mandatory fields. + * @returns {object} v2 episode. */ export function buildEpisodeFromContext(ctx = {}, transcriptText = null) { if (transcriptText) { return parseTranscript(transcriptText, ctx.session_id || ctx.sessionId || ctx.task_id); } + const sid = ctx.session_id || ctx.sessionId || ctx.task_id || `unknown-${Date.now()}`; + const now = new Date().toISOString(); return { - task_id: ctx.session_id || ctx.sessionId || ctx.task_id || `unknown-${Date.now()}`, + schema_version: 2, + task_id: sid, + task_ref: sid, timestamps: { - started_at: ctx.started || ctx.started_at || new Date().toISOString(), - ended_at: ctx.ended || ctx.ended_at || new Date().toISOString(), + started_at: ctx.started || ctx.started_at || now, + ended_at: ctx.ended || ctx.ended_at || now, }, path_type: ctx.path_type || 'regulated', - outcome: ctx.result || ctx.outcome || 'success', + outcome: ctx.result || ctx.outcome || 'unknown', + prompt_signal: ctx.prompt_signal || 'neutral', + decision_provenance: ctx.decision_provenance || { kind: 'autonomous', claude_would_have_chosen: null }, + environment: ctx.environment || { + economy_level: null, + model: null, + post_compaction: false, + session_turn: 0, + parallel_session: false, + }, + task_size: ctx.task_size || { tool_calls: 0, files_touched: 0, files: [] }, primary_rationale: ctx.primary_rationale || { step: 1, node_chosen: ctx.node_chosen || ctx.skill_id || 'unknown', - triggers_matched: ctx.triggers_matched || [], - candidates_considered: ctx.candidates_considered || [], - boundaries_applied: ctx.boundaries_applied || [], + triggers_matched: [], + candidates_considered: [], + boundaries_applied: [], hard_floor: ctx.hard_floor || { invoked: false, rules: [] }, task_classification: ctx.task_classification || 'other', }, @@ -102,6 +131,21 @@ export function buildEpisodeFromContext(ctx = {}, transcriptText = null) { }; } +/** + * Build a minimal observer_error marker — written instead of a silent skip + * when the Stop-hook fails internally (spec §3 / §5.2). + */ +export function buildObserverError(ctx = {}, err) { + const now = new Date().toISOString(); + return { + schema_version: 2, + observer_error: true, + error_message: String((err && err.message) || err), + timestamps: { started_at: now, ended_at: now }, + task_id: ctx.session_id || ctx.sessionId || ctx.task_id || `unknown-${Date.now()}`, + }; +} + function currentMonth() { const d = new Date(); return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`; diff --git a/tools/observer-stop-hook.test.mjs b/tools/observer-stop-hook.test.mjs index a07335db..ac1cad00 100644 --- a/tools/observer-stop-hook.test.mjs +++ b/tools/observer-stop-hook.test.mjs @@ -2,7 +2,7 @@ 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'; +import { appendEpisode, buildEpisodeFromContext, buildObserverError } from './observer-stop-hook.mjs'; let workdir; @@ -15,7 +15,6 @@ afterEach(() => { rmSync(workdir, { recursive: true, force: true }); }); -// Helper for v1.1 — primary_rationale fixture (factor analysis amendment) const defaultRat = () => ({ step: 1, node_chosen: '#1', @@ -26,139 +25,138 @@ const defaultRat = () => ({ task_classification: 'other', }); +// Full schema-v2 episode fixture. +const v2Episode = (overrides = {}) => ({ + schema_version: 2, + task_id: 'abc-123', + task_ref: '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: 'unknown', + prompt_signal: 'neutral', + decision_provenance: { kind: 'autonomous', claude_would_have_chosen: null }, + environment: { economy_level: 0, model: 'claude-opus-4-7', post_compaction: false, session_turn: 1, parallel_session: false }, + task_size: { tool_calls: 0, files_touched: 0, files: [] }, + primary_rationale: defaultRat(), + events: [], + ...overrides, +}); + 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'); + it('appends one JSONL line to the monthly file', () => { + appendEpisode(v2Episode(), workdir, '2026-05'); + const content = readFileSync(join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl'), 'utf-8'); expect(content).toContain('"task_id":"abc-123"'); - expect(content).toContain('"primary_rationale"'); + expect(content).toContain('"schema_version":2'); 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'); + it('appends to an existing file without overwrite', () => { + appendEpisode(v2Episode({ task_id: 'a' }), workdir, '2026-05'); + appendEpisode(v2Episode({ task_id: 'b', outcome: 'partial' }), 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'); + it('applies the PII filter before write (including events[])', () => { + appendEpisode( + v2Episode({ 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', () => { + it('throws on a missing required field', () => { 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('throws on a missing schema-v2 field', () => { + const ep = v2Episode(); + delete ep.decision_provenance; + expect(() => appendEpisode(ep, workdir, '2026-05')).toThrow(/schema v2 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'); + it('throws when schema_version is not 2', () => { + expect(() => appendEpisode(v2Episode({ schema_version: 1 }), workdir, '2026-05')).toThrow(/schema_version/i); + }); + + it('throws when a primary_rationale sub-field is missing', () => { + expect(() => + appendEpisode(v2Episode({ primary_rationale: { step: 1, node_chosen: '#1' } }), workdir, '2026-05') + ).toThrow(/primary_rationale field missing/i); + }); + + it('accepts a minimal observer_error marker', () => { + appendEpisode( + { + schema_version: 2, + observer_error: true, + error_message: 'parser blew up', + timestamps: { started_at: '2026-05-19T10:00:00Z', ended_at: '2026-05-19T10:00:00Z' }, + task_id: 'err-1', + }, + 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']); + expect(line.observer_error).toBe(true); + expect(line.error_message).toBe('parser blew up'); + }); + + it('throws when an observer_error marker is missing a field', () => { + expect(() => + appendEpisode({ schema_version: 2, observer_error: true, task_id: 'x' }, workdir, '2026-05') + ).toThrow(/observer_error marker field missing/i); }); }); 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); + it('builds a v2 episode on the fallback path (no transcript)', () => { + const ep = buildEpisodeFromContext({ session_id: 'sess-1', result: 'success' }); + expect(ep.schema_version).toBe(2); expect(ep.task_id).toBe('sess-1'); - expect(ep.timestamps.started_at).toBe(ctx.started); + expect(ep.task_ref).toBe('sess-1'); 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: [] }); + expect(ep.decision_provenance).toEqual({ kind: 'autonomous', claude_would_have_chosen: null }); + expect(ep.environment).toEqual({ + economy_level: null, + model: null, + post_compaction: false, + session_turn: 0, + parallel_session: false, + }); + expect(ep.task_size).toEqual({ tool_calls: 0, files_touched: 0, files: [] }); }); - 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); + it('defaults outcome to unknown when none supplied', () => { + expect(buildEpisodeFromContext({ session_id: 'x' }).outcome).toBe('unknown'); }); - it('derives the episode from transcriptText when provided', () => { + it('derives a v2 episode from transcriptText when provided', () => { const transcript = [ - JSON.stringify({ - type: 'user', - message: { role: 'user', content: 'fix the bug' }, - timestamp: '2026-05-19T10:00:00Z', - sessionId: 'sess-t', - }), - JSON.stringify({ - type: 'assistant', - message: { - role: 'assistant', - content: [ - { type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:systematic-debugging' } }, - ], - }, - timestamp: '2026-05-19T10:01:00Z', - sessionId: 'sess-t', - }), + JSON.stringify({ type: 'user', message: { role: 'user', content: 'fix the bug' }, timestamp: '2026-05-19T10:00:00Z', sessionId: 'sess-t' }), + JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:systematic-debugging' } }] }, timestamp: '2026-05-19T10:01:00Z', sessionId: 'sess-t' }), ].join('\n'); const ep = buildEpisodeFromContext({ session_id: 'sess-t' }, transcript); + expect(ep.schema_version).toBe(2); expect(ep.task_id).toBe('sess-t'); expect(ep.primary_rationale.node_chosen).toBe('superpowers:systematic-debugging'); - expect(ep.primary_rationale.hard_floor.invoked).toBe(true); - expect(ep.events.some((e) => e.kind === 'skill_invoked')).toBe(true); + }); +}); + +describe('buildObserverError', () => { + it('produces a minimal valid observer_error marker', () => { + const marker = buildObserverError({ session_id: 'sess-e' }, new Error('boom')); + expect(marker.observer_error).toBe(true); + expect(marker.schema_version).toBe(2); + expect(marker.task_id).toBe('sess-e'); + expect(marker.error_message).toContain('boom'); + expect(marker.timestamps.started_at).toBeTruthy(); }); });