diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 90d49dc5..9809728a 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-05-19T07:27:33.112Z +Last updated: 2026-05-19T07:31:46.792Z | Контролёр | Состояние | Детали | |---|---|---| diff --git a/tools/observer-stop-hook.mjs b/tools/observer-stop-hook.mjs index d3388c5f..f3bbb5fb 100644 --- a/tools/observer-stop-hook.mjs +++ b/tools/observer-stop-hook.mjs @@ -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` + + ``, + }; +} + 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 } }); } diff --git a/tools/observer-stop-hook.test.mjs b/tools/observer-stop-hook.test.mjs index ac1cad00..9372b066 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, 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); + }); +});