Skip to content

Fix start-work retries on conclusive external blockers #144

Description

@johnnyisme

Problem Situation

The start-work Stop hook forced automatic continuation after the agent had already identified a conclusive external blocker such as missing hardware or authorization. The injected directive required three same-failure cycles and reviewer escalation, so the agent repeated an unchanged external-state check instead of reporting the blocker and stopping.

Reproduction Logs

With active Boulder work, one unchecked plan item, and the first line of last_assistant_message set to <start-work-blocked-external>, the released hook exited 0 but returned:

{"decision":"block", ...}

Failing-first regression:

Test Files  1 failed (1)
Tests  1 failed | 10 passed (11)
AssertionError: expected decision=block output to be empty

Root Cause

runStopHook ignored last_assistant_message and returned decision: "block" for every active tracked plan. The directive separately applied its three-cycle reviewer rule to all failures, without distinguishing agent-controllable remediation failures from immutable external blockers.

Verified Fix

The directive now requires a one-check user handoff for conclusive external blockers and reserves reviewer escalation for three materially different agent-controllable remediation attempts. The hook consumes a structural first-line marker and allows that turn to stop, while unmarked active work continues normally.

diff --git a/plugins/omo/components/start-work-continuation/directive.md b/plugins/omo/components/start-work-continuation/directive.md
index 518d6a6..bc35d43 100644
--- a/plugins/omo/components/start-work-continuation/directive.md
+++ b/plugins/omo/components/start-work-continuation/directive.md
@@ -45,7 +45,8 @@ Before completion, satisfy all five `review-work` lanes and a `debugging` runtim
 # Stop conditions for THIS turn
 
 - A top-level checkbox flipped to `- [x]` after the 5-phase QA gate (Phase 1 read, Phase 2 automated, Phase 3 channel scenario, Phase 4 adversarial-class probing, Phase 5 gate decision). Then the Stop hook will re-evaluate; if more checkboxes remain you will be continued again.
-- 3 same-failure cycles on one sub-task → escalate via `multi_agent_v1.spawn_agent({"message":"TASK: act as a rigorous reviewer. DELIVERABLE: diagnose the repeated sub-task failure and recommend the next safe action. VERIFY: cite the failing evidence.","fork_context":false})` and stop dispatch.
+- A conclusive external blocker that only the user or external state can change (missing hardware, authorization, credential, permission, or unavailable service) → after one authoritative check, stop all retries and reviewer dispatch. Start the user-facing handoff with `<start-work-blocked-external>` on its own first line, then state the exact blocker and observable resume condition. The Stop hook recognizes this marker and lets the turn end immediately.
+- 3 materially different failed remediation approaches on an agent-controllable sub-task → escalate via `multi_agent_v1.spawn_agent({"message":"TASK: act as a rigorous reviewer. DELIVERABLE: diagnose the repeated sub-task failure and recommend the next safe action. VERIFY: cite the failing evidence.","fork_context":false})` and stop dispatch.
 - Safety boundary (destructive command, secret exfiltration, production write) → stop and surface a safe substitute.
 - All top-level checkboxes `- [x]` AND the Global Review and Debugging Gate passed → print the ORCHESTRATION COMPLETE block and end.
 
diff --git a/plugins/omo/components/start-work-continuation/dist/cli.js b/plugins/omo/components/start-work-continuation/dist/cli.js
index f33460c..a4d7e40 100755
--- a/plugins/omo/components/start-work-continuation/dist/cli.js
+++ b/plugins/omo/components/start-work-continuation/dist/cli.js
@@ -302,11 +302,14 @@ import { readFileSync as readFileSync3 } from "node:fs";
 var START_WORK_CONTINUATION_DIRECTIVE = readFileSync3(new URL("../directive.md", import.meta.url), "utf8");
 
 // components/start-work-continuation/src/codex-hook.ts
+var EXTERNAL_BLOCKER_HANDOFF_PATTERN = /^\s*<start-work-blocked-external>(?:\r?\n|$)/;
 function runStopHook(input, fs) {
   if (!isStopInput(input))
     return "";
   if (input.stop_hook_active)
     return "";
+  if (EXTERNAL_BLOCKER_HANDOFF_PATTERN.test(input.last_assistant_message ?? ""))
+    return "";
   if (transcriptHasContextPressureMarker(input.transcript_path, fs))
     return "";
   const state = readContinuationState(input.cwd, input.session_id);
diff --git a/plugins/omo/components/start-work-continuation/src/codex-hook.ts b/plugins/omo/components/start-work-continuation/src/codex-hook.ts
index f794047..f6b93e3 100644
--- a/plugins/omo/components/start-work-continuation/src/codex-hook.ts
+++ b/plugins/omo/components/start-work-continuation/src/codex-hook.ts
@@ -3,9 +3,12 @@ import { readContinuationState } from "./boulder-reader.js";
 import { START_WORK_CONTINUATION_DIRECTIVE } from "./directive.js";
 import type { ReadonlyFileSystem, StopHookEventName, StopHookOutput, StopInput } from "./types.js";
 
+const EXTERNAL_BLOCKER_HANDOFF_PATTERN = /^\s*<start-work-blocked-external>(?:\r?\n|$)/;
+
 export function runStopHook(input: unknown, fs: ReadonlyFileSystem): string {
 	if (!isStopInput(input)) return "";
 	if (input.stop_hook_active) return "";
+	if (EXTERNAL_BLOCKER_HANDOFF_PATTERN.test(input.last_assistant_message ?? "")) return "";
 	if (transcriptHasContextPressureMarker(input.transcript_path, fs)) return "";
 	const state = readContinuationState(input.cwd, input.session_id);
 	if (state === null) return "";
diff --git a/plugins/omo/components/start-work-continuation/test/codex-hook.test.ts b/plugins/omo/components/start-work-continuation/test/codex-hook.test.ts
index 14b4a24..b1ac870 100644
--- a/plugins/omo/components/start-work-continuation/test/codex-hook.test.ts
+++ b/plugins/omo/components/start-work-continuation/test/codex-hook.test.ts
@@ -90,6 +90,29 @@ describe("start-work Stop hook", () => {
 		expect(parsed.reason).toContain("re-read the ledger record and verify the exact lane/SHA pair");
 	});
 
+	it("#given an external blocker handoff #when hook runs #then it allows the turn to stop immediately", () => {
+		// given
+		const workspace = createWorkspace({
+			boulderJson: createBoulderJson({ sessionIds: ["codex:sess_abc"], status: "active" }),
+			planMarkdown: ["# Plan", "", "## TODOs", "- [ ] 1. Run device QA"].join("\n"),
+		});
+		const fs = createMemoryFs();
+		const input = {
+			...createStopInput(workspace),
+			last_assistant_message: [
+				"<start-work-blocked-external>",
+				"Blocker: no authorized Android device.",
+				"Resume when adb reports one device.",
+			].join("\n"),
+		};
+
+		// when
+		const output = runStopHook(input, fs);
+
+		// then
+		expect(output).toBe("");
+	});
+
 	it("#given context-window pressure in transcript #when hook runs #then it does not inject continuation text", () => {
 		// given
 		const transcriptPath = "/repo/transcript.jsonl";

Verification

  • RED: npx vitest --run test/codex-hook.test.ts → 1 failed, 10 passed before the fix.
  • GREEN: the same command → 11 passed.
  • Full component suite: npx vitest --run → 38 passed.
  • Typecheck: npx tsc --noEmit → exit 0.
  • Lint: npx biome check . → exit 0.
  • Build: aggregate-path Bun bundle → exit 0.
  • Manual CLI QA against the built hook: marked handoff → exit 0 and 0 output bytes; identical unmarked active work → decision=block.

This fix was debugged, implemented, and verified with LazyCodex.
Tag: lazycodex-generated

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions