feat(observer): Stop-hook v2 episode + observer_error marker

This commit is contained in:
Дмитрий
2026-05-19 10:31:37 +03:00
parent 71c6845653
commit 682e4068ea
3 changed files with 166 additions and 124 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-05-19T07:23:18.404Z
Last updated: 2026-05-19T07:27:33.112Z
| Контролёр | Состояние | Детали |
|---|---|---|
+69 -25
View File
@@ -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')}`;
+96 -98
View File
@@ -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();
});
});