feat(observer): Stop-hook routing-gate enforcement

This commit is contained in:
Дмитрий
2026-05-19 10:34:57 +03:00
parent 682e4068ea
commit f2d04f6afa
3 changed files with 74 additions and 6 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-05-19T07:27:33.112Z
Last updated: 2026-05-19T07:31:46.792Z
| Контролёр | Состояние | Детали |
|---|---|---|
+45 -4
View File
@@ -17,7 +17,8 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'fs';
import { join } from 'path';
import { sanitize } from './observer-pii-filter.mjs';
import { parseTranscript } from './observer-transcript-parser.mjs';
import { parseTranscript, extractLastUserPromptText } from './observer-transcript-parser.mjs';
import { detectMethodDirected, loadKnownNodes } from './observer-routing-detector.mjs';
const REQUIRED_FIELDS = ['task_id', 'timestamps', 'path_type', 'outcome', 'primary_rationale'];
const V2_FIELDS = ['schema_version', 'decision_provenance', 'environment', 'task_size', 'task_ref'];
@@ -146,6 +147,30 @@ export function buildObserverError(ctx = {}, err) {
};
}
/**
* Routing-gate decision (spec §5.1, 3a). Pure — the CLI calls this.
* Blocks the Stop-event (decision: block) when the user dictated a method
* but the turn carries no routing tag. Skipped when stop_hook_active is true
* (the gate fires at most once per turn — no infinite loop).
* @returns {{block: boolean, reason: string|null}}
*/
export function routingGateDecision(episode, promptText, knownNodes, stopHookActive) {
if (stopHookActive) return { block: false, reason: null };
const det = detectMethodDirected(promptText, knownNodes);
if (!det.directed) return { block: false, reason: null };
if (episode && episode.decision_provenance && episode.decision_provenance.kind === 'user_directed_method') {
return { block: false, reason: null };
}
return {
block: true,
reason:
`[observer routing-gate] Похоже, метод навязан пользователем (узел "${det.node}"), ` +
`но routing-тег в этом ходе отсутствует. Добавь в свой ответ ровно одну строку:\n` +
`<!-- routing: provenance=user_directed_method node=${det.node} ` +
`counterfactual=<узел, который ты выбрал бы автономно> -->`,
};
}
function currentMonth() {
const d = new Date();
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
@@ -161,7 +186,7 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-s
const raw = Buffer.concat(chunks).toString('utf-8');
if (raw.trim()) ctx = JSON.parse(raw);
} catch (_e) {
// best-effort: write minimal episode even if stdin is malformed
// best-effort: build a minimal episode even if stdin is malformed
}
// Claude Code's Stop-event supplies transcript_path — the real source of
// session data. Read it best-effort; fall back to ctx-only on any error.
@@ -174,13 +199,29 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-s
transcriptText = null;
}
}
const ep = buildEpisodeFromContext(ctx, transcriptText);
try {
const ep = buildEpisodeFromContext(ctx, transcriptText);
// Always write the episode first — exit-0-safe (spec §5.1 step 1).
appendEpisode(ep);
// Then the routing-gate (spec §5.1 steps 2-4).
if (transcriptText) {
const promptText = extractLastUserPromptText(transcriptText);
const gate = routingGateDecision(ep, promptText, loadKnownNodes(), ctx.stop_hook_active === true);
if (gate.block) {
process.stdout.write(JSON.stringify({ decision: 'block', reason: gate.reason }));
process.exit(0);
}
}
process.exit(0);
} catch (err) {
// Visible failure (spec §5.2): write an observer_error marker, never a silent skip.
try {
appendEpisode(buildObserverError(ctx, err));
} catch (_e2) {
// last-resort: even the marker failed — do not crash the Stop-event
}
console.error(`[observer-stop-hook] error: ${err.message}`);
process.exit(0); // never block Stop-event
process.exit(0); // never block the Stop-event on an internal error
}
});
}
+28 -1
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, buildObserverError } from './observer-stop-hook.mjs';
import { appendEpisode, buildEpisodeFromContext, buildObserverError, routingGateDecision } from './observer-stop-hook.mjs';
let workdir;
@@ -160,3 +160,30 @@ describe('buildObserverError', () => {
expect(marker.timestamps.started_at).toBeTruthy();
});
});
describe('routingGateDecision', () => {
const NODES = ['discovery-interview', 'brainstorming'];
const autonomousEp = v2Episode();
const taggedEp = v2Episode({ decision_provenance: { kind: 'user_directed_method', claude_would_have_chosen: 'brainstorming' } });
it('blocks when a method was directed but no routing tag is present', () => {
const gate = routingGateDecision(autonomousEp, 'запусти discovery-interview', NODES, false);
expect(gate.block).toBe(true);
expect(gate.reason).toContain('discovery-interview');
});
it('does not block when the routing tag is present', () => {
const gate = routingGateDecision(taggedEp, 'запусти discovery-interview', NODES, false);
expect(gate.block).toBe(false);
});
it('does not block when no method was directed', () => {
const gate = routingGateDecision(autonomousEp, 'добавь колонку Город', NODES, false);
expect(gate.block).toBe(false);
});
it('does not block when stop_hook_active is true (loop guard)', () => {
const gate = routingGateDecision(autonomousEp, 'запусти discovery-interview', NODES, true);
expect(gate.block).toBe(false);
});
});