From 1596ed524289f9b9794e6e8fe354a46a257b942d Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:50:11 +0200 Subject: [PATCH 1/3] test(watchdog): reproduce descriptive running stop false positive --- ...x-watchdog-stop-intent-regression.test.mjs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/unit/codex-watchdog-stop-intent-regression.test.mjs diff --git a/tests/unit/codex-watchdog-stop-intent-regression.test.mjs b/tests/unit/codex-watchdog-stop-intent-regression.test.mjs new file mode 100644 index 00000000..cebb8f9a --- /dev/null +++ b/tests/unit/codex-watchdog-stop-intent-regression.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { evaluateCodexHook } from "../../scripts/lib/codex-watchdog-hook.mjs"; + +test("Stop does not treat descriptive Running clauses as a selected next tool action", () => { + const report = [ + "I reran the scanner independently and the authority suite is green.", + "Running the candidate scanner with the historical four-entry registry fails closed with the expected retained-path error.", + "My fresh candidate scans are identical to the recorded artifacts.", + "Running with no registry changed no class, so the registry adds dispositions only.", + "I made no edits, commits, or pull requests.", + ].join("\n"); + + const result = evaluateCodexHook( + { + hook_event_name: "Stop", + session_id: "incident-2026-09-16", + turn_id: "turn-1", + stop_hook_active: false, + last_assistant_message: report, + }, + {}, + ); + + assert.equal(result.output, null); + assert.equal(result.state.narrationRecoveryAttempts, 0); + // The broad stream detector intentionally still sees these phrases; hook-mode + // stop recovery must not equate that signal with an explicit action commitment. + assert.equal(result.state.watchdog.toolEmissionIntentCount, 2); +}); + +test("Stop still recovers when the assistant explicitly commits to a tool action", () => { + const result = evaluateCodexHook( + { + hook_event_name: "Stop", + session_id: "explicit-tool-commitment", + turn_id: "turn-1", + stop_hook_active: false, + last_assistant_message: "I will run the canonical gate now.", + }, + {}, + ); + + assert.equal(result.output?.decision, "block"); + assert.match(result.output?.reason || "", /recovery 1\/3/i); + assert.equal(result.state.narrationRecoveryAttempts, 1); +}); From fa501d7a0b8c6de8bd2303b826e81c659346d774 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:54:30 +0200 Subject: [PATCH 2/3] fix(watchdog): require explicit stop action commitments --- scripts/lib/codex-watchdog-hook.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/lib/codex-watchdog-hook.mjs b/scripts/lib/codex-watchdog-hook.mjs index db1b4057..95e6a1ca 100644 --- a/scripts/lib/codex-watchdog-hook.mjs +++ b/scripts/lib/codex-watchdog-hook.mjs @@ -16,6 +16,7 @@ const TERMINAL_STOP_DISPOSITION_PATTERNS = [ ]; const STRUCTURED_STOP_RECOMMENDATION_HEADING = /(?:^|\n)\s*#{1,6}\s+(?:[A-Z]\.\s*)?Recommendation\s*$/im; const STRUCTURED_STOP_RECOMMENDATION_VALUE = /(?:^|\n)\s*`?NEXT_ACTION\s*=\s*[A-Z][A-Z0-9_]*`?\s*$/i; +const EXPLICIT_STOP_ACTION_COMMITMENT = /^\s*(?:(?:let me|i(?:'|’)ll|i will|i need to|i(?:'|’)m going to|i am going to)\s+(?:just\s+|actually\s+)?(?:run|execute|invoke|call|issue|emit|grep|search|read|open|inspect|apply|patch|use|add|wire|edit|write|modify|update|remove|delete|fix|change)\b|(?:now|next|then|finally)[,:.!]?\s+(?:(?:let me|i(?:'|’)ll|i will|i need to|i(?:'|’)m going to|i am going to)\s+)?(?:just\s+|actually\s+)?(?:run|execute|invoke|call|issue|emit|grep|search|read|open|inspect|apply|patch|use|add|wire|edit|write|modify|update|remove|delete|fix|change)\b)/i; export function classifyCodexTool(toolName, toolInput = {}) { const classification = classifyHookTool({ tool_name: toolName, tool_input: toolInput }); @@ -147,9 +148,14 @@ function hasStopFinalizationDisposition(message) { return hasTerminalStopDisposition(message) || hasStructuredStopRecommendation(message); } +function hasExplicitStopActionCommitment(message) { + return String(message || "") + .split(/\r?\n/) + .some((line) => EXPLICIT_STOP_ACTION_COMMITMENT.test(line)); +} + function stopDecision(watchdog, input, recoveryAttempts, maxRecoveryAttempts) { const message = input.last_assistant_message || ""; - const priorToolIntentCount = watchdog.snapshot().toolEmissionIntentCount; const decision = watchdog.observeAssistantDelta(message); if (decision.reason === "tool_protocol_emission_stall") { return { @@ -162,7 +168,7 @@ function stopDecision(watchdog, input, recoveryAttempts, maxRecoveryAttempts) { }; } - const announcedToolAction = watchdog.snapshot().toolEmissionIntentCount > priorToolIntentCount; + const announcedToolAction = hasExplicitStopActionCommitment(message); const finalizationDisposition = hasStopFinalizationDisposition(message); if ( finalizationDisposition From 7e666d5d4a90c885ce25fb16023b7b9324ebca1e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:07:59 +0200 Subject: [PATCH 3/3] fix(watchdog): preserve explicit stop commitments --- scripts/lib/codex-watchdog-hook.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/lib/codex-watchdog-hook.mjs b/scripts/lib/codex-watchdog-hook.mjs index 95e6a1ca..8da756a5 100644 --- a/scripts/lib/codex-watchdog-hook.mjs +++ b/scripts/lib/codex-watchdog-hook.mjs @@ -150,8 +150,9 @@ function hasStopFinalizationDisposition(message) { function hasExplicitStopActionCommitment(message) { return String(message || "") - .split(/\r?\n/) - .some((line) => EXPLICIT_STOP_ACTION_COMMITMENT.test(line)); + .split(/\r?\n+/) + .flatMap((line) => line.split(/(?<=[.!?])\s+/)) + .some((clause) => EXPLICIT_STOP_ACTION_COMMITMENT.test(clause)); } function stopDecision(watchdog, input, recoveryAttempts, maxRecoveryAttempts) { @@ -179,11 +180,10 @@ function stopDecision(watchdog, input, recoveryAttempts, maxRecoveryAttempts) { } const recoveryActive = recoveryAttempts > 0; - const contradictoryFinalizationAction = finalizationDisposition && announcedToolAction; if ( decision.action !== "interrupt" && !recoveryActive - && !contradictoryFinalizationAction + && !announcedToolAction ) { return { output: null, recoveryAttempts: 0 }; }