feat(observer): Stop-hook v2 episode + observer_error marker
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-05-19T07:23:18.404Z
|
||||
Last updated: 2026-05-19T07:27:33.112Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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')}`;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user