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