@dougharness/cli 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/dist/ablate.d.ts +96 -0
  3. package/dist/ablate.js +440 -0
  4. package/dist/ablate.js.map +1 -0
  5. package/dist/apply.d.ts +2 -0
  6. package/dist/apply.js +19 -0
  7. package/dist/apply.js.map +1 -0
  8. package/dist/bin.d.ts +2 -0
  9. package/dist/bin.js +80 -0
  10. package/dist/bin.js.map +1 -0
  11. package/dist/board-page.d.ts +15 -0
  12. package/dist/board-page.js +27 -0
  13. package/dist/board-page.js.map +1 -0
  14. package/dist/board-serve-process.d.ts +24 -0
  15. package/dist/board-serve-process.js +227 -0
  16. package/dist/board-serve-process.js.map +1 -0
  17. package/dist/board-serve.d.ts +9 -0
  18. package/dist/board-serve.js +302 -0
  19. package/dist/board-serve.js.map +1 -0
  20. package/dist/board.d.ts +6 -0
  21. package/dist/board.js +515 -0
  22. package/dist/board.js.map +1 -0
  23. package/dist/detect/fs.d.ts +6 -0
  24. package/dist/detect/fs.js +50 -0
  25. package/dist/detect/fs.js.map +1 -0
  26. package/dist/detect/index.d.ts +3 -0
  27. package/dist/detect/index.js +13 -0
  28. package/dist/detect/index.js.map +1 -0
  29. package/dist/detect/node.d.ts +3 -0
  30. package/dist/detect/node.js +179 -0
  31. package/dist/detect/node.js.map +1 -0
  32. package/dist/detect/python.d.ts +2 -0
  33. package/dist/detect/python.js +388 -0
  34. package/dist/detect/python.js.map +1 -0
  35. package/dist/detect/repo.d.ts +2 -0
  36. package/dist/detect/repo.js +102 -0
  37. package/dist/detect/repo.js.map +1 -0
  38. package/dist/detect/types.d.ts +98 -0
  39. package/dist/detect/types.js +2 -0
  40. package/dist/detect/types.js.map +1 -0
  41. package/dist/diff.d.ts +12 -0
  42. package/dist/diff.js +105 -0
  43. package/dist/diff.js.map +1 -0
  44. package/dist/generate/agents.d.ts +19 -0
  45. package/dist/generate/agents.js +184 -0
  46. package/dist/generate/agents.js.map +1 -0
  47. package/dist/generate/claude-md.d.ts +9 -0
  48. package/dist/generate/claude-md.js +111 -0
  49. package/dist/generate/claude-md.js.map +1 -0
  50. package/dist/generate/config.d.ts +51 -0
  51. package/dist/generate/config.js +155 -0
  52. package/dist/generate/config.js.map +1 -0
  53. package/dist/generate/proposal.d.ts +11 -0
  54. package/dist/generate/proposal.js +175 -0
  55. package/dist/generate/proposal.js.map +1 -0
  56. package/dist/generate/settings.d.ts +35 -0
  57. package/dist/generate/settings.js +213 -0
  58. package/dist/generate/settings.js.map +1 -0
  59. package/dist/generate/skills.d.ts +8 -0
  60. package/dist/generate/skills.js +264 -0
  61. package/dist/generate/skills.js.map +1 -0
  62. package/dist/init.d.ts +10 -0
  63. package/dist/init.js +69 -0
  64. package/dist/init.js.map +1 -0
  65. package/dist/trace.d.ts +9 -0
  66. package/dist/trace.js +106 -0
  67. package/dist/trace.js.map +1 -0
  68. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +10 -0
  69. package/node_modules/@dougharness/flow/LICENSE +21 -0
  70. package/node_modules/@dougharness/flow/agents/adversary-claude.md +33 -0
  71. package/node_modules/@dougharness/flow/agents/adversary.md +29 -0
  72. package/node_modules/@dougharness/flow/agents/implementer.md +23 -0
  73. package/node_modules/@dougharness/flow/agents/lead.md +35 -0
  74. package/node_modules/@dougharness/flow/agents/planner.md +70 -0
  75. package/node_modules/@dougharness/flow/agents/researcher.md +31 -0
  76. package/node_modules/@dougharness/flow/agents/reviewer.md +26 -0
  77. package/node_modules/@dougharness/flow/agents/verifier.md +24 -0
  78. package/node_modules/@dougharness/flow/lib/board.mjs +840 -0
  79. package/node_modules/@dougharness/flow/lib/code-index.mjs +629 -0
  80. package/node_modules/@dougharness/flow/lib/cost.mjs +392 -0
  81. package/node_modules/@dougharness/flow/lib/decisions.mjs +314 -0
  82. package/node_modules/@dougharness/flow/lib/embeddings.mjs +539 -0
  83. package/node_modules/@dougharness/flow/lib/land.mjs +219 -0
  84. package/node_modules/@dougharness/flow/lib/learn.mjs +742 -0
  85. package/node_modules/@dougharness/flow/lib/memory-import.mjs +145 -0
  86. package/node_modules/@dougharness/flow/lib/memory.mjs +1641 -0
  87. package/node_modules/@dougharness/flow/lib/models.mjs +148 -0
  88. package/node_modules/@dougharness/flow/lib/plan.mjs +773 -0
  89. package/node_modules/@dougharness/flow/lib/rehearse.mjs +825 -0
  90. package/node_modules/@dougharness/flow/lib/worktrees.mjs +215 -0
  91. package/node_modules/@dougharness/flow/package.json +19 -0
  92. package/node_modules/@dougharness/flow/scripts/board.mjs +260 -0
  93. package/node_modules/@dougharness/flow/scripts/cost.mjs +66 -0
  94. package/node_modules/@dougharness/flow/scripts/learn.mjs +161 -0
  95. package/node_modules/@dougharness/flow/scripts/memory.mjs +1274 -0
  96. package/node_modules/@dougharness/flow/scripts/plan.mjs +547 -0
  97. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +129 -0
  98. package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +92 -0
  99. package/node_modules/@dougharness/flow/skills/doug-approve/SKILL.md +15 -0
  100. package/node_modules/@dougharness/flow/skills/doug-decide/SKILL.md +26 -0
  101. package/node_modules/@dougharness/flow/skills/doug-implement/SKILL.md +22 -0
  102. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +20 -0
  103. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +87 -0
  104. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +22 -0
  105. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +55 -0
  106. package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +85 -0
  107. package/node_modules/@dougharness/flow/skills/research/SKILL.md +41 -0
  108. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +51 -0
  109. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +27 -0
  110. package/node_modules/@dougharness/flow/workflows/doug-implement.js +2687 -0
  111. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +10 -0
  112. package/node_modules/@dougharness/gates/LICENSE +21 -0
  113. package/node_modules/@dougharness/gates/hooks/hooks.json +184 -0
  114. package/node_modules/@dougharness/gates/lib/agent-context.mjs +111 -0
  115. package/node_modules/@dougharness/gates/lib/anchor.mjs +110 -0
  116. package/node_modules/@dougharness/gates/lib/baseline.mjs +160 -0
  117. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +1522 -0
  118. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +127 -0
  119. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +97 -0
  120. package/node_modules/@dougharness/gates/lib/config.mjs +168 -0
  121. package/node_modules/@dougharness/gates/lib/evidence.mjs +59 -0
  122. package/node_modules/@dougharness/gates/lib/glob.mjs +86 -0
  123. package/node_modules/@dougharness/gates/lib/io.mjs +91 -0
  124. package/node_modules/@dougharness/gates/lib/paths.mjs +101 -0
  125. package/node_modules/@dougharness/gates/lib/proposals.mjs +201 -0
  126. package/node_modules/@dougharness/gates/lib/run.mjs +147 -0
  127. package/node_modules/@dougharness/gates/lib/scope.mjs +117 -0
  128. package/node_modules/@dougharness/gates/lib/secret-rules.mjs +268 -0
  129. package/node_modules/@dougharness/gates/lib/state.mjs +114 -0
  130. package/node_modules/@dougharness/gates/lib/trace.mjs +196 -0
  131. package/node_modules/@dougharness/gates/package.json +17 -0
  132. package/node_modules/@dougharness/gates/scripts/edit-loop.mjs +39 -0
  133. package/node_modules/@dougharness/gates/scripts/format-on-edit.mjs +45 -0
  134. package/node_modules/@dougharness/gates/scripts/guard-bash.mjs +32 -0
  135. package/node_modules/@dougharness/gates/scripts/protect-paths.mjs +49 -0
  136. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +81 -0
  137. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +295 -0
  138. package/node_modules/@dougharness/gates/scripts/secret-scan.mjs +26 -0
  139. package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +57 -0
  140. package/node_modules/@dougharness/gates/scripts/statusline.mjs +133 -0
  141. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +1422 -0
  142. package/node_modules/@dougharness/gates/scripts/trace.mjs +75 -0
  143. package/package.json +33 -0
  144. package/templates/board-app.js +533 -0
@@ -0,0 +1,1422 @@
1
+ #!/usr/bin/env node
2
+ // Stop, and SubagentStop: before Claude ends its turn, or a subagent reports its task done, run the project's
3
+ // verification commands and block the stop while they fail, or while the session never ran a test or verify
4
+ // command itself (lib/evidence.mjs). A subagent's blocks are counted apart from the session's, against the same
5
+ // cap; a subagent's green gate records no checkpoint and no turn (proposal B, card task-completed-gate). Also blocks if a protected path was changed through Bash (the edit hook cannot
6
+ // see those), and, when .doug/plan.json is approved or done, if any changed file falls outside the
7
+ // files the plan's tasks own. When everything passes and checkpoint.enabled is set, records a
8
+ // checkpoint commit or tag (lib/checkpoint.mjs). Honors stop_hook_active and a per-session block
9
+ // cap so it can never loop forever.
10
+ //
11
+ // card stop-gate-read-only-subagent-skip: a SubagentStop whose own agent made no change skips the
12
+ // verification commands (step 3) and the evidence check (step 3b) entirely, and never takes or waits for
13
+ // gate.lock — the protected-path, proposal-path, and scope scans still run. The run trace, when it can vouch
14
+ // for the whole life of the agent (a SubagentStart line for its id), decides this on its own lines: any call to
15
+ // a tool that runs a shell command (SHELL_TOOLS: Bash, Monitor), or a successful Edit/Write/MultiEdit/NotebookEdit,
16
+ // is a change, and the trace wins over the agent's declared type either way. Only when the trace cannot vouch
17
+ // for it does the type fall back to a fixed list of read-only-by-policy agents (agentMadeNoChanges below).
18
+ //
19
+ // card stop-gate-defers-while-subagent-in-flight: the Stop hook fires whenever the lead's turn ends, even when
20
+ // it ends only to wait for a background subagent. A main Stop (never a SubagentStop) with a subagent of this
21
+ // session in flight defers instead of verifying: it still runs the cheap tree-state scans (protected paths,
22
+ // proposal paths, plan scope — steps 1, 1b, 2) and blocks on a real hit exactly as before, but skips the
23
+ // verification commands, the evidence check, gate.lock, state.verified, and the blocks.jsonl ledger, and
24
+ // returns one systemMessage naming the agents still running. "In flight" (subagentsInFlight below) is read off
25
+ // this session's run trace: an agent has stopped when some SubagentStop line for it after its last
26
+ // SubagentStart carries `decision: "allow"` (rule A), or when more of those lines carry no `decision` field at
27
+ // all than carry `decision: "block"` (rule B) — a decision-less line is the trace hook's own SubagentStop line
28
+ // (F2: it knows nothing of the gate's verdict), so more stop events than blocks means some stop the gate did
29
+ // not block, whether from a fail-open (a hook reader must never throw) or a project whose gate is not wired to
30
+ // SubagentStop at all; either way the agent is gone. A blocked SubagentStop still gets a trace line (Claude
31
+ // Code runs a BLOCKED SubagentStop's hooks same as an allowed one), so "any SubagentStop line at all" is never
32
+ // enough on its own — that is exactly what rule A/B replace. `decision` is written by the gate itself, once,
33
+ // at every SubagentStop exit (allow()/systemMessage() write "allow", blockStop() writes "block") — never by
34
+ // the trace hook, which cannot know the verdict. Transient window (F3, reviewer probe: trace [SubagentStart
35
+ // ag1, {event:"SubagentStop", agent:"ag1"}] + a main Stop → the commands ran): a main Stop that lands after
36
+ // the trace hook's own SubagentStop line for an event but before the gate's own decision-tagged line for that
37
+ // same event sees, for that agent, one decision-less line and zero block lines — 1 is more than 0, so rule B
38
+ // already reads the agent as stopped. This Stop runs the full gate (today's behaviour), never a skip: the
39
+ // window only ever makes the gate treat the agent as gone a little early, and running the full gate is always
40
+ // safe. Known limit, bounded (card stop-gate-deferral-cap): a project whose trace hook is wired to
41
+ // SubagentStart but not SubagentStop would defer on an agent that never shows a stop line; `doug init` wires
42
+ // both, and this is documented, not solved. A hand-appended SubagentStart line under .doug/.state/trace is
43
+ // possible too — that path is not a protected path, and (like state.verified) the trace is an honest-path
44
+ // record, not proof (decision 0006): a forged start line, or a subagent that dies without ever writing a
45
+ // SubagentStop line, no longer leaves the session deferring every Stop forever — state.deferrals counts
46
+ // consecutive deferred main Stops, and at stopGate.maxDeferrals (default 10) the gate runs the full
47
+ // verification anyway rather than skip it indefinitely; running the full gate is always safe, only costly.
48
+ // Background shells are out of scope — the gate cannot
49
+ // see their liveness, trace or no trace. Fails closed onto today's behaviour (subagentsInFlight returns [])
50
+ // when trace.enabled is false, the trace file is missing or unreadable, or anything throws: there is no
51
+ // config that turns the deferral on, and no way for the lead to skip verification by disabling the trace.
52
+ //
53
+ // card stop-gate-skips-orphan-subagent-stops: Claude Code also fires SubagentStop events for agents that never
54
+ // had a SubagentStart in this session's trace, carry no agent_type, and have no readable
55
+ // agent_transcript_path, on a roughly 31-second cadence while a background Agent-tool subagent is running
56
+ // (docs/research/gate-output-names-failures.md Q1: 88 such events in one session, 170 in another, zero in
57
+ // sessions with no spawns). The source component is unverified — the build depends only on the observed
58
+ // signature, never on the source's name. Each one that reached the verification path used to run the full
59
+ // verification suite concurrently with the real subagent (11 did in one session, the load a prior card
60
+ // suspected). A SubagentStop whose event matches that signature (isOrphanSubagentStop below) now allows
61
+ // immediately, with no verification commands, no evidence check, no gate.lock, no state.verified/agentBlocks
62
+ // write, and no blocks.jsonl line — only one stderr line naming the id and the usual decision-tagged trace
63
+ // line (D1). A SubagentStop with a SubagentStart line, an agent_type, or an agent_transcript_path naming an
64
+ // existing regular file is not an orphan and runs as today; the lead's main Stop is untouched (the check only
65
+ // ever runs on a SubagentStop).
66
+ // Fails closed exactly like subagentsInFlight: with trace.enabled false, or the trace file missing or
67
+ // unreadable, there is no evidence either way, so the gate runs as today.
68
+
69
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, unlinkSync, openSync, closeSync, readSync } from "node:fs";
70
+ import { join } from "node:path";
71
+ import { runHook, allow, blockStop, systemMessage } from "../lib/io.mjs";
72
+ import { loadConfig, projectDir } from "../lib/config.mjs";
73
+ import { loadState, saveState } from "../lib/state.mjs";
74
+ import { matchAny } from "../lib/glob.mjs";
75
+ import { runCommand, failureSummary } from "../lib/run.mjs";
76
+ import { loadScopePlan, scopeViolations, describeViolations, PLAN_RELPATH } from "../lib/scope.mjs";
77
+ import { checkpoint, describeCheckpoint } from "../lib/checkpoint.mjs";
78
+ import { verificationEvidence, describeMissingEvidence } from "../lib/evidence.mjs";
79
+ import { changedFiles, captureBaseline, sinceBaseline, headAt, committedSince, walkProposalPathFiles, fileIdentity } from "../lib/baseline.mjs";
80
+ import { tracePath, readTrace, traceLine, appendTrace, agentLabel } from "../lib/trace.mjs";
81
+ import { isAppliedContent } from "../lib/proposals.mjs";
82
+ import { gitHead, mergeSnapshot, readDecisions, readPlanSummary, snapshotSection } from "../lib/anchor.mjs";
83
+ import { contextReading, recordAgentContext } from "../lib/agent-context.mjs";
84
+ import { failingTestFilesFrom, classifyFinalBlock, appendBlockLine } from "../lib/block-ledger.mjs";
85
+
86
+ // Only the last this many characters of last_assistant_message are ever scanned for a partial claim
87
+ // (review, scan cost): a structured result lives at the end of a worker's message, and the balanced-span
88
+ // scan below is quadratic in the length of what it scans (each "{" starts its own forward search), which
89
+ // measured 1.6s at 130KB and 6.6s at 260KB of brace-heavy prose — real, if a worker's message ever grows
90
+ // that large. Bounding the input bounds the cost regardless of how long the message is.
91
+ const PARTIAL_SCAN_TAIL_CHARS = 32 * 1024;
92
+
93
+ // Every balanced {...} span that starts at some "{" in `text`, matched independently from that position
94
+ // (review MAJOR 1): a stray unmatched "{" earlier in the prose, or an odd number of quote characters
95
+ // before it, only ever breaks a scan that tracks nesting from the start of the whole string. Starting a
96
+ // fresh scan at every "{" means the real object's own span still balances correctly on its own, no matter
97
+ // what came before it. `quoteAware` skips over quoted string content while matching (so a literal "{" or
98
+ // "}" inside a JSON string value never affects that span's nesting); the caller falls back to a
99
+ // quote-blind pass (quoteAware: false) only when the quote-aware one finds nothing usable. Returns each
100
+ // span's offsets alongside its text, so the caller can tell a span nested inside another from a top-level
101
+ // one (review, nested-key shadow).
102
+ function balancedSpansFrom(text, quoteAware) {
103
+ const spans = [];
104
+ for (let start = 0; start < text.length; start++) {
105
+ if (text[start] !== "{") continue;
106
+ let depth = 0, inStr = false, esc = false, end = -1;
107
+ for (let i = start; i < text.length; i++) {
108
+ const c = text[i];
109
+ if (quoteAware) {
110
+ if (inStr) {
111
+ if (esc) esc = false;
112
+ else if (c === "\\") esc = true;
113
+ else if (c === '"') inStr = false;
114
+ continue;
115
+ }
116
+ if (c === '"') {
117
+ inStr = true;
118
+ continue;
119
+ }
120
+ }
121
+ if (c === "{") depth++;
122
+ else if (c === "}") {
123
+ depth--;
124
+ if (depth === 0) {
125
+ end = i;
126
+ break;
127
+ }
128
+ }
129
+ }
130
+ if (end !== -1) spans.push({ start, end, text: text.slice(start, end + 1) });
131
+ }
132
+ return spans;
133
+ }
134
+
135
+ // Every span from balancedSpansFrom that actually parses as a JSON object (never an array or a scalar),
136
+ // each carrying its own start/end offsets, in the order their "{" appears in `text`.
137
+ function parsedCandidates(text, quoteAware) {
138
+ const out = [];
139
+ for (const span of balancedSpansFrom(text, quoteAware)) {
140
+ try {
141
+ const obj = JSON.parse(span.text);
142
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) out.push({ start: span.start, end: span.end, obj });
143
+ } catch {
144
+ // not valid JSON on its own (e.g. a partial span this scan matched by coincidence); skip it
145
+ }
146
+ }
147
+ return out;
148
+ }
149
+
150
+ // True when `cand`'s span sits strictly inside some other candidate's span in `all` (review, nested-key
151
+ // shadow): a JSON value nested inside a larger object always starts later and ends earlier than its
152
+ // parent's own span, so containment on offsets alone tells top-level candidates from nested ones.
153
+ function isNestedWithin(cand, all) {
154
+ return all.some((o) => o !== cand && o.start <= cand.start && o.end >= cand.end);
155
+ }
156
+
157
+ // The last candidate in `list` (by where its "{" appears) that carries `key` at all (generalised, card
158
+ // subagent-stop-gate-tester-red, from a `partial`-only helper: the tests_red_by_design claim below reuses
159
+ // this same scan for a different key).
160
+ function lastWithKey(list, key) {
161
+ for (let i = list.length - 1; i >= 0; i--) {
162
+ if (Object.prototype.hasOwnProperty.call(list[i].obj, key)) return list[i].obj;
163
+ }
164
+ return null;
165
+ }
166
+
167
+ // One scan of `text`: the last TOP-LEVEL candidate carrying `key`, falling back to a nested one (review,
168
+ // nested-key shadow) only when no top-level candidate carries the key at all — a nested object happening
169
+ // to have its own matching key must never shadow its parent's, since the parent, not the value nested
170
+ // somewhere inside it, is what a worker's own top-level result object actually is. Falling back to a
171
+ // nested one when nothing top-level qualifies stays fail-closed: an object that could plausibly be read
172
+ // as a claim is still treated as one, rather than silently waved through.
173
+ function candidateOnePass(text, quoteAware, key) {
174
+ const all = parsedCandidates(text, quoteAware);
175
+ const topLevel = all.filter((c) => !isNestedWithin(c, all));
176
+ return lastWithKey(topLevel, key) || lastWithKey(all, key);
177
+ }
178
+
179
+ // The object in a SubagentStop's last_assistant_message the harness should evaluate as a possible claim
180
+ // carrying `key` (review MAJOR 1; generalised to a key name for card subagent-stop-gate-tester-red):
181
+ // prefers the quote-aware scan; only when that scan finds no candidate carrying the key at all does it
182
+ // fall back to a quote-blind one (recovers the real object when an odd number of quotes, or a stray
183
+ // unmatched brace, in the surrounding prose broke the quote-aware scan's view of where the object even
184
+ // starts or ends). Only the message's last PARTIAL_SCAN_TAIL_CHARS are ever looked at.
185
+ function candidateForKey(text, key) {
186
+ if (typeof text !== "string") return null;
187
+ const tail = text.length > PARTIAL_SCAN_TAIL_CHARS ? text.slice(-PARTIAL_SCAN_TAIL_CHARS) : text;
188
+ return candidateOnePass(tail, true, key) || candidateOnePass(tail, false, key);
189
+ }
190
+
191
+ // A partial claim (card worker-context-handoff, brief A): candidateForKey's object for "partial", only
192
+ // when it carries `partial: true` exactly. Anything else — no such object, or `partial` present but not
193
+ // exactly `true` (e.g. the string "true") — is not a claim.
194
+ function partialClaim(lastAssistantMessage) {
195
+ const obj = candidateForKey(lastAssistantMessage, "partial");
196
+ return obj && obj.partial === true ? obj : null;
197
+ }
198
+
199
+ // Only the last this many bytes of a subagent transcript are ever read by lastHandbackMessage below (same
200
+ // cost reasoning as PARTIAL_SCAN_TAIL_CHARS: a huge transcript must not stall the hook).
201
+ const HANDBACK_TAIL_BYTES = 2 * 1024 * 1024;
202
+
203
+ // Card tester-claim-missed-in-handback, pass 2: the `input.message` string of the LAST SubagentHandback
204
+ // tool_use in a subagent's transcript, or null. A fallback source for a claim when last_assistant_message
205
+ // carries none — a tester that puts its tests_red_by_design JSON only inside the SubagentHandback call
206
+ // (never on a plain-text line) used to have that claim go unseen (2026-09-24 evidence: transcript
207
+ // agent-ae0d6b0881e6539f8.jsonl, trace line 2026-09-24T14:14:48, SubagentStop decision "block"). Reads at
208
+ // most the file's last HANDBACK_TAIL_BYTES; when that cuts into the file, the first (possibly half) line
209
+ // of what was read is dropped, since a JSONL line broken at an arbitrary byte offset never parses. Never
210
+ // throws (a hook reader must never throw): a missing or unreadable file, a line that fails to parse, or a
211
+ // tool_use whose input.message is not a string are all skipped, same as returning null when there is no
212
+ // hand-back at all.
213
+ function lastHandbackMessage(transcriptPath) {
214
+ if (typeof transcriptPath !== "string" || !transcriptPath) return null;
215
+ let text;
216
+ let truncated = false;
217
+ try {
218
+ if (!existsSync(transcriptPath)) return null;
219
+ const size = statSync(transcriptPath).size;
220
+ const start = Math.max(0, size - HANDBACK_TAIL_BYTES);
221
+ truncated = start > 0;
222
+ const fd = openSync(transcriptPath, "r");
223
+ try {
224
+ const length = size - start;
225
+ const buffer = Buffer.alloc(length);
226
+ readSync(fd, buffer, 0, length, start);
227
+ text = buffer.toString("utf8");
228
+ } finally {
229
+ closeSync(fd);
230
+ }
231
+ } catch {
232
+ return null;
233
+ }
234
+ let lastMessage = null;
235
+ const lines = text.split("\n");
236
+ for (let i = truncated ? 1 : 0; i < lines.length; i++) {
237
+ const line = lines[i];
238
+ if (!line.trim()) continue;
239
+ let o;
240
+ try {
241
+ o = JSON.parse(line);
242
+ } catch {
243
+ continue;
244
+ }
245
+ const content = o && o.type === "assistant" && o.message && Array.isArray(o.message.content) ? o.message.content : null;
246
+ if (!content) continue;
247
+ for (const item of content) {
248
+ if (item && item.type === "tool_use" && item.name === "SubagentHandback" && item.input && typeof item.input.message === "string") {
249
+ lastMessage = item.input.message;
250
+ }
251
+ }
252
+ }
253
+ return lastMessage;
254
+ }
255
+
256
+ // A red-by-design claim (design point 1, card subagent-stop-gate-tester-red): candidateForKey's object for
257
+ // "tests_red_by_design", only when that key's value is a non-empty array of non-empty strings. Any other
258
+ // shape — missing key, an empty array, a string, an object, or an array holding a non-string or empty
259
+ // string — is not a claim. Never throws (a hook reader must never throw): any failure here reads as no
260
+ // claim, same as the partial scan it reuses. This shape check is defense-in-depth (fix pass, reviewer
261
+ // minor 6): redByDesignCoverage's checks (ii)/(iii) already reject a malformed claim on their own (an
262
+ // empty or garbled list can never name the real failing file), so a bug here alone cannot let a stop
263
+ // through — but a clean claim shape is still validated up front rather than relied on implicitly. Called
264
+ // both on last_assistant_message and, as a fallback (card tester-claim-missed-in-handback, pass 2), on
265
+ // lastHandbackMessage's result — the object shape it validates is the same either way.
266
+ function redByDesignClaim(lastAssistantMessage) {
267
+ try {
268
+ const obj = candidateForKey(lastAssistantMessage, "tests_red_by_design");
269
+ if (!obj) return null;
270
+ const files = obj.tests_red_by_design;
271
+ if (!Array.isArray(files) || files.length === 0) return null;
272
+ if (!files.every((f) => typeof f === "string" && f.length > 0)) return null;
273
+ return files.map(stripLeadingDotSlash);
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+
279
+ // A leading "./" a claim entry or a parsed path token might carry is not part of its identity (fix pass,
280
+ // minor 3): both sides are normalised the same way before comparison, so "./tests/a.test.mjs" and
281
+ // "tests/a.test.mjs" name the same file.
282
+ function stripLeadingDotSlash(f) {
283
+ return typeof f === "string" && f.startsWith("./") ? f.slice(2) : f;
284
+ }
285
+
286
+ // failingTestFilesFrom (design point 2, card subagent-stop-gate-tester-red) now lives in
287
+ // ../lib/block-ledger.mjs (card stop-gate-block-ledger), imported above: the block ledger's own classifier
288
+ // needs the same test-file extraction this coverage check does, so both share one implementation.
289
+
290
+ // Whether a tests_red_by_design claim covers this SubagentStop's failed verification commands (design
291
+ // point 2, card subagent-stop-gate-tester-red). `commandFailures` is one entry per failed command from
292
+ // step 3, `{ command, output }` (stderr + stdout, untruncated). `{ covered: true, files }` when every
293
+ // failed command's output names at least one test file, every named file is in the claim, and every
294
+ // claimed file is changed or untracked in the working tree (`changed`, from changedFiles(dir) above);
295
+ // otherwise `{ covered: false, reasonLine }` — one line, no newline, saying why.
296
+ function redByDesignCoverage(claimFiles, commandFailures, changed, dir) {
297
+ const changedSet = new Set(Array.isArray(changed) ? changed : []);
298
+ const namedByCommand = commandFailures.map((cf) => ({ command: cf.command, files: failingTestFilesFrom(cf.output, dir) }));
299
+ const noFile = namedByCommand.filter((c) => c.files.length === 0);
300
+ if (noFile.length > 0) {
301
+ return {
302
+ covered: false,
303
+ reasonLine: `The tests_red_by_design claim does not cover this: ${noFile.map((c) => `\`${c.command}\``).join(", ")} named no test file in its output.`,
304
+ };
305
+ }
306
+ const allNamed = [...new Set(namedByCommand.flatMap((c) => c.files))];
307
+ const claimSet = new Set(claimFiles);
308
+ const uncovered = allNamed.filter((f) => !claimSet.has(f));
309
+ if (uncovered.length > 0) {
310
+ return {
311
+ covered: false,
312
+ reasonLine: `The tests_red_by_design claim does not cover this: ${uncovered.join(", ")} is failing but not listed in the claim.`,
313
+ };
314
+ }
315
+ const unmodified = claimFiles.filter((f) => !changedSet.has(f));
316
+ if (unmodified.length > 0) {
317
+ return {
318
+ covered: false,
319
+ reasonLine: `The tests_red_by_design claim does not cover this: ${unmodified.join(", ")} is claimed but not modified in the working tree.`,
320
+ };
321
+ }
322
+ return { covered: true, files: claimFiles };
323
+ }
324
+
325
+ // D2 (card stop-gate-defers-while-subagent-in-flight): the agents of this session (`{ id, type }`, in start
326
+ // order) whose run trace (lib/trace.mjs) shows a SubagentStart with no later SubagentStop that counts as a
327
+ // stop. For each agent, only the SubagentStop lines after its own last SubagentStart are considered. It has
328
+ // stopped when either:
329
+ // - rule A: any of those lines carries `decision: "allow"` (the gate itself only ever writes that when the
330
+ // agent is let go — D1); or
331
+ // - rule B: strictly more of those lines carry no `decision` field at all than carry `decision: "block"`.
332
+ // A decision-less line is the trace hook's own SubagentStop line (F2 — the trace hook appends one per stop
333
+ // event, whatever the gate later decides, and knows nothing of that verdict), so more stop events than
334
+ // blocks means some stop the gate did not block — a fail-open (a hook reader must never throw) or a
335
+ // project whose gate is not wired to SubagentStop at all — and the agent is gone either way.
336
+ // Otherwise the agent is still in flight (a BLOCKED SubagentStop still gets a trace line, so it is still
337
+ // working, not gone). `cfg` is the caller's already-loaded config (avoids loading it twice); a disabled trace,
338
+ // a missing or unreadable trace file, or any thrown error all read as `[]` — fail closed onto today's
339
+ // behaviour (D4). `contextNotice` below reuses this same reader (`.length > 0`) for its own suppression, so
340
+ // the notice and the deferral share one definition of "in flight".
341
+ function subagentsInFlight(dir, cfg, sessionId) {
342
+ try {
343
+ if (!cfg.trace || cfg.trace.enabled === false) return [];
344
+ const file = tracePath(dir, sessionId);
345
+ if (!existsSync(file)) return [];
346
+ const lines = readTrace(file);
347
+ const order = [];
348
+ const lastStart = new Map(); // agent id -> { type, index: last SubagentStart line's index }
349
+ lines.forEach((line, i) => {
350
+ if (line.event !== "SubagentStart" || !line.agent) return;
351
+ if (!lastStart.has(line.agent)) order.push(line.agent);
352
+ lastStart.set(line.agent, { type: line.agentType || "agent", index: i });
353
+ });
354
+ const inFlight = [];
355
+ for (const agent of order) {
356
+ const { type, index } = lastStart.get(agent);
357
+ const stopLines = lines.filter((l, i) => i > index && l.agent === agent && l.event === "SubagentStop");
358
+ const allowed = stopLines.some((l) => l.decision === "allow");
359
+ const blocked = stopLines.filter((l) => l.decision === "block").length;
360
+ const undecided = stopLines.filter((l) => l.decision !== "allow" && l.decision !== "block").length;
361
+ const stopped = allowed || undecided > blocked;
362
+ if (!stopped) inFlight.push({ id: agent, type });
363
+ }
364
+ return inFlight;
365
+ } catch {
366
+ return [];
367
+ }
368
+ }
369
+
370
+ // Agent types read-only by tool policy (card stop-gate-read-only-subagent-skip): the plugin's researcher,
371
+ // reviewer, architect, and flow-debugger agents, plus Claude Code's own Explore and Plan. Exact,
372
+ // case-sensitive match against subagent.type; only the fallback when the trace cannot vouch for the agent's
373
+ // whole life (agentMadeNoChanges below) — the trace, when it can, wins over this list either way.
374
+ const READ_ONLY_AGENT_TYPES = new Set(["researcher", "reviewer", "architect", "flow-debugger", "doug-flow:researcher", "doug-flow:reviewer", "Explore", "Plan"]);
375
+
376
+ // Tools whose successful PostToolUse counts as a change (card stop-gate-read-only-subagent-skip). Tools that
377
+ // run a shell command (SHELL_TOOLS below) are judged separately, on any line at all (Pre or Post, whatever
378
+ // ok): a shell command can change files whether or not its tool call itself is ever recorded as having
379
+ // succeeded.
380
+ const CHANGE_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
381
+
382
+ // Tools that run a shell command (card stop-gate-monitor-is-a-change): Bash runs one directly, and Monitor
383
+ // (seen 4 times in this repo's real subagent traces, per the reviewer of e710a25) runs one to poll a
384
+ // background process. Either can change files whether or not its own tool call is ever recorded as having
385
+ // succeeded, so any Pre or PostToolUse line naming one counts as a change whatever its ok.
386
+ const SHELL_TOOLS = new Set(["Bash", "Monitor"]);
387
+
388
+ // True when this SubagentStop's own agent made no change during its life (card
389
+ // stop-gate-read-only-subagent-skip), so the caller can skip the verification commands and evidence check for
390
+ // it. Never throws (a hook reader must never throw): any failure here reads as false, the conservative answer
391
+ // (run the gate as today).
392
+ //
393
+ // With trace evidence — the trace is enabled, the file is readable, and it holds a SubagentStart line whose
394
+ // agent equals subagent.id (without that the trace cannot vouch for the whole life of the agent) — every line
395
+ // whose agent equals subagent.id is considered, and the type does not matter: a change is any line (Pre or
396
+ // PostToolUse, whatever ok) whose tool is in SHELL_TOOLS, or a PostToolUse line whose tool is in CHANGE_TOOLS
397
+ // and whose ok is not exactly false (a null ok counts as success — conservative). No change found -> true.
398
+ //
399
+ // Without trace evidence, true only when subagent.type is in READ_ONLY_AGENT_TYPES.
400
+ function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
401
+ try {
402
+ if (!subagent) return false;
403
+ if (cfg.trace && cfg.trace.enabled !== false) {
404
+ const file = tracePath(dir, sessionId);
405
+ if (existsSync(file)) {
406
+ const lines = readTrace(file);
407
+ const started = lines.some((l) => l.event === "SubagentStart" && l.agent === subagent.id);
408
+ if (started) {
409
+ const mine = lines.filter((l) => l.agent === subagent.id);
410
+ const changed = mine.some((l) => {
411
+ if (SHELL_TOOLS.has(l.tool)) return true;
412
+ return l.event === "PostToolUse" && CHANGE_TOOLS.has(l.tool) && l.ok !== false;
413
+ });
414
+ return !changed;
415
+ }
416
+ }
417
+ }
418
+ return READ_ONLY_AGENT_TYPES.has(subagent.type);
419
+ } catch {
420
+ return false;
421
+ }
422
+ }
423
+
424
+ // card stop-gate-skips-orphan-subagent-stops: true when a SubagentStop's own event carries none of the three
425
+ // signals of a real subagent of this session (docs/research/gate-output-names-failures.md Q1: Claude Code
426
+ // fires SubagentStop for agents that never had a SubagentStart, carry no agent_type, and have no readable
427
+ // agent_transcript_path, on a roughly 31-second cadence while a background Agent-tool subagent is running; the
428
+ // source component is unverified — this depends only on the observed signature, never on the source's name).
429
+ // All three must hold: no SubagentStart line for subagent.id in this session's trace, input.agent_type absent
430
+ // (undefined, null, or empty string), and input.agent_transcript_path absent, not a string, or naming a path
431
+ // that is not an existing regular file. `subagent` is unused when the event itself already carries everything
432
+ // needed (input.agent_id, mirroring how `subagent.id` itself is derived, keeps this correct even if ever
433
+ // called without a `subagent` object) — it is only the caller's `subagent &&` guard (main() below) that keeps
434
+ // this off the main Stop at all, so that guard is the one and only thing standing between this check and a
435
+ // main Stop; there is deliberately no second, redundant guard in here that would silently paper over its
436
+ // removal. Never throws (a hook reader must never throw): any failure here reads as false, the conservative
437
+ // answer. Fails closed exactly like subagentsInFlight: with trace.enabled false, or the trace file missing or
438
+ // unreadable, there is no evidence either way, so this returns false and the gate runs as today — there is no
439
+ // way to skip verification by turning the trace off.
440
+ function isOrphanSubagentStop(dir, cfg, sessionId, input) {
441
+ try {
442
+ if (!cfg.trace || cfg.trace.enabled === false) return false;
443
+ const file = tracePath(dir, sessionId);
444
+ if (!existsSync(file)) return false;
445
+ const lines = readTrace(file);
446
+ const id = String(input.agent_id || "unknown");
447
+ if (lines.some((l) => l.event === "SubagentStart" && l.agent === id)) return false;
448
+ if (input.agent_type) return false;
449
+ const transcriptPath = input.agent_transcript_path;
450
+ if (typeof transcriptPath === "string" && transcriptPath) {
451
+ try {
452
+ if (statSync(transcriptPath).isFile()) return false; // a readable transcript vouches for the agent: not an orphan
453
+ } catch {
454
+ // missing or unreadable: falls through, still counts as "no readable transcript"
455
+ }
456
+ }
457
+ return true;
458
+ } catch {
459
+ return false;
460
+ }
461
+ }
462
+
463
+ // The two green boundaries the Stop gate calls contextNotice from (card context-window-handoff, minor 3): the
464
+ // anchor's "Boundary:" line, the message clause, and whether the Gate line is meaningful for that boundary.
465
+ // On the verification path this Stop actually ran the gate, so its own just-computed state.lastGate is honest;
466
+ // on the skip path no gate ran this Stop at all, so the Gate line is omitted rather than reading a possibly
467
+ // stale state.lastGate from an earlier turn.
468
+ const CONTEXT_BOUNDARIES = {
469
+ verification: { id: "verification passed", text: "verification passed this turn, no subagent running, nothing outside the plan", gate: true },
470
+ skip: { id: "nothing changed", text: "nothing changed this turn, no subagent running", gate: false },
471
+ };
472
+
473
+ // Context-window handoff (card context-window-handoff): the harness only ever advises here, never compacts.
474
+ // At a green Stop, with contextWindow enabled, the run trace enabled (major 2: the in-flight subagent check
475
+ // below depends on it — a disabled trace must not let the notice claim "no subagent running" when it cannot
476
+ // know), and state.context past its threshold, and no subagent in flight, refresh the anchor snapshot with a
477
+ // handoff block and return the systemMessage text (mutating state.context.notifiedAt so the notice does not
478
+ // repeat until pct grows by repeatAfter). Returns null, and touches nothing, for every other case — disabled,
479
+ // the trace disabled, no reading, below threshold, a subagent in flight, or already notified at this pct. Never
480
+ // throws: any failure here is silent, exactly like an absent reading. `boundary` is "verification" or "skip"
481
+ // (CONTEXT_BOUNDARIES above), naming which green path called it.
482
+ function contextNotice(dir, cfg, state, sessionId, boundary) {
483
+ try {
484
+ if (!cfg.contextWindow || !cfg.contextWindow.enabled) return null;
485
+ if (!cfg.trace || cfg.trace.enabled === false) return null;
486
+ const b = CONTEXT_BOUNDARIES[boundary];
487
+ if (!b) return null;
488
+ const ctx = state.context;
489
+ if (!ctx || typeof ctx.pct !== "number" || !Number.isFinite(ctx.pct)) return null;
490
+ const threshold = cfg.contextWindow.threshold ?? 80;
491
+ if (ctx.pct < threshold) return null;
492
+ if (subagentsInFlight(dir, cfg, sessionId).length > 0) return null;
493
+ const repeatAfter = cfg.contextWindow.repeatAfter ?? 5;
494
+ if (typeof ctx.notifiedAt === "number" && ctx.pct < ctx.notifiedAt + repeatAfter) return null;
495
+
496
+ const anchorFile = join(dir, ".doug/anchor.md");
497
+ const section = snapshotSection({
498
+ plan: readPlanSummary(dir),
499
+ decisions: readDecisions(dir),
500
+ state,
501
+ now: new Date(),
502
+ handoff: {
503
+ boundary: b.id,
504
+ gate: b.gate && state.lastGate ? { ok: state.lastGate.ok, at: state.lastGate.at } : null,
505
+ head: gitHead(dir),
506
+ contextPct: ctx.pct,
507
+ },
508
+ });
509
+ mkdirSync(join(dir, ".doug"), { recursive: true });
510
+ const before = existsSync(anchorFile) ? readFileSync(anchorFile, "utf8") : "";
511
+ writeFileSync(anchorFile, mergeSnapshot(before, section));
512
+
513
+ state.context = { ...ctx, notifiedAt: ctx.pct };
514
+ return `[doug] Context at ${ctx.pct}% (threshold ${threshold}). This is a convenient point: ${b.text}. Run /compact; .doug/anchor.md holds the handoff.`;
515
+ } catch {
516
+ return null;
517
+ }
518
+ }
519
+
520
+ function resolveCommands(cfg) {
521
+ const out = [];
522
+ for (const entry of cfg.stopGate.commands || []) {
523
+ if (typeof entry !== "string") continue;
524
+ const named = cfg.commands && cfg.commands[entry];
525
+ out.push({ name: entry, command: typeof named === "string" ? named : entry });
526
+ }
527
+ return out;
528
+ }
529
+
530
+ // The verified-tree fingerprint (card stop-gate-process-storm, goal 2): the HEAD sha plus the content
531
+ // identity of every dirty path and every proposal-path file on disk, at the moment it is computed. Null
532
+ // when `changed` is not the array shape changedFiles returns (no git, or git unavailable) — there is
533
+ // nothing to fingerprint reliably, so the tree can never be judged "unchanged" on this path.
534
+ function treeFingerprint(dir, cfg, changed) {
535
+ if (!Array.isArray(changed)) return null;
536
+ const walked = walkProposalPathFiles(dir, cfg.proposalPaths);
537
+ const paths = [...new Set([...changed, ...walked])];
538
+ // `at` (state.mjs's comment for state.verified) is when this fingerprint was taken; sameTree below never
539
+ // reads it, so recomputing it on the after-wait path (a fresh Date.now()) never affects equality.
540
+ return { head: headAt(dir), tree: captureBaseline(dir, paths), at: Date.now() };
541
+ }
542
+
543
+ // Whether two treeFingerprint results describe the same tree: both present, the same HEAD, the same set of
544
+ // paths, and an identical identity for each. Never throws (a hook reader must never throw): a malformed
545
+ // fingerprint — e.g. a hand-edited state.verified — just compares unequal rather than blocking the gate.
546
+ function sameTree(a, b) {
547
+ try {
548
+ if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
549
+ if (a.head !== b.head) return false;
550
+ const at = a.tree && typeof a.tree === "object" ? a.tree : null;
551
+ const bt = b.tree && typeof b.tree === "object" ? b.tree : null;
552
+ if (!at || !bt) return false;
553
+ const ak = Object.keys(at).sort();
554
+ const bk = Object.keys(bt).sort();
555
+ if (ak.length !== bk.length) return false;
556
+ for (let i = 0; i < ak.length; i++) if (ak[i] !== bk[i]) return false;
557
+ return ak.every((k) => at[k] === bt[k]);
558
+ } catch {
559
+ return false;
560
+ }
561
+ }
562
+
563
+ // card stop-gate-process-storm, case 2 (second tester pass, reviewer-found gap): a verified-unchanged skip
564
+ // is a green gate in every way that matters, not a bare allow — it resets the same counters an ordinary
565
+ // green gate resets, and on the main Stop it still surfaces the context-window notice, exactly like the
566
+ // onlyIfEdited (nothing changed) skip just above it does. Shared by the immediate skip and the after-wait,
567
+ // reloaded-state skip inside the lock section below, so both behave identically. `capNote` (card
568
+ // stop-gate-deferral-cap, review round 1 minor 5): the deferral cap sentence, non-null only when this Stop's
569
+ // own deferral just hit the cap and this call is reached from the lock-wait's after-reload skip below — the
570
+ // only site this can ever be non-null from, since the immediate call above runs before this Stop's deferral
571
+ // check even exists. That exit returns straight from here, bypassing the *Releasing wrappers (R4), so the
572
+ // prepend happens inline instead of through them.
573
+ function verifiedUnchangedAllow(dir, cfg, state, input, subagent, capNote) {
574
+ // D1 (card stop-gate-defers-while-subagent-in-flight): this is one of the gate's existing reason-carrying
575
+ // exits (F4); the `decision` a SubagentStop's own line now always carries folds in here, alongside its
576
+ // `reason`, rather than a second line being written for the same event. Only for a SubagentStop (subagent
577
+ // set) — a main Stop's own trace line here is not an agent-stop decision.
578
+ if (cfg.trace && cfg.trace.enabled !== false) {
579
+ appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: input.hook_event_name, reason: "verified-unchanged", ...(subagent ? { decision: "allow" } : {}) });
580
+ }
581
+ if (subagent) {
582
+ state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: 0 };
583
+ saveState(dir, input.session_id, state);
584
+ return allow();
585
+ }
586
+ state.stopBlocks = 0;
587
+ const notice = contextNotice(dir, cfg, state, input.session_id, "skip");
588
+ saveState(dir, input.session_id, state);
589
+ if (capNote) return systemMessage(notice ? `${capNote}\n\n${notice}` : capNote);
590
+ return notice ? systemMessage(notice) : allow();
591
+ }
592
+
593
+ // Single-runner lock (card stop-gate-process-storm, goal 3): only one gate runs the verification commands
594
+ // at a time per project, so concurrent SubagentStops share one suite instead of each starting its own (the
595
+ // 2026-09-16 process storm). `.doug/.state/gate.lock` holds `{ pid, session, at }` while a gate's commands
596
+ // run.
597
+ const GATE_LOCK_RELPATH = ".doug/.state/gate.lock";
598
+ function gateLockPath(dir) {
599
+ return join(dir, GATE_LOCK_RELPATH);
600
+ }
601
+
602
+ // Reads gate.lock, tolerating garbage (a hook reader must never throw): a missing file, unreadable file, or
603
+ // content that fails to parse or carries no numeric pid all read as null — the same as "no lock", which the
604
+ // caller treats exactly like a stale one.
605
+ function readGateLock(dir) {
606
+ try {
607
+ const o = JSON.parse(readFileSync(gateLockPath(dir), "utf8"));
608
+ // A pid only counts when it is a positive integer (reviewer-found gap): 0 means "this process's own
609
+ // group" and -1 broadcasts to every process the caller may signal, so process.kill(pid, 0) does not
610
+ // throw for either and a liveness check that hands them straight to it reads a garbage lock as live.
611
+ return o && typeof o === "object" && Number.isInteger(o.pid) && o.pid > 0 ? o : null;
612
+ } catch {
613
+ return null;
614
+ }
615
+ }
616
+
617
+ function pidIsLive(pid) {
618
+ try {
619
+ process.kill(pid, 0);
620
+ return true;
621
+ } catch (err) {
622
+ // EPERM means the pid exists but is owned by another user (permission denied, not "no such process") —
623
+ // that holder is still alive, just not signallable by us. Every other error (ESRCH: no such process, or
624
+ // anything else) reads as dead.
625
+ return err && err.code === "EPERM";
626
+ }
627
+ }
628
+
629
+ // Creates gate.lock exclusively (throws EEXIST when one is already there, or any other fs error). The
630
+ // caller decides what a failure means.
631
+ function writeGateLock(dir, sessionId) {
632
+ mkdirSync(join(dir, ".doug/.state"), { recursive: true });
633
+ writeFileSync(gateLockPath(dir), JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }), { flag: "wx" });
634
+ }
635
+
636
+ // Overwrites gate.lock with this gate's own pid/session, whether it was stale (a dead pid) or its holder
637
+ // simply never let go inside the wait bound — either way, never lets a lock the gate cannot even write hang
638
+ // it (best-effort; a failure here just leaves lockOwned false to the caller, which never releases a lock it
639
+ // does not hold).
640
+ function takeOverGateLock(dir, sessionId) {
641
+ try {
642
+ mkdirSync(join(dir, ".doug/.state"), { recursive: true });
643
+ writeFileSync(gateLockPath(dir), JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }));
644
+ } catch {
645
+ // best-effort takeover only
646
+ }
647
+ }
648
+
649
+ // Card stop-gate-budget-under-hook-timeout: cfg.stopGate.hookTimeoutSec must equal the platform's own
650
+ // Stop/SubagentStop hook `timeout` (hooks.json/settings.json), but a hook reader never throws (F6), so a
651
+ // missing, non-number, or non-positive value reads as the platform's own default, 600.
652
+ function resolveHookTimeoutSec(cfg) {
653
+ const sec = cfg && cfg.stopGate && cfg.stopGate.hookTimeoutSec;
654
+ return typeof sec === "number" && Number.isFinite(sec) && sec > 0 ? sec : 600;
655
+ }
656
+
657
+ // Computed once per gate run: the wall-clock instant past which the platform itself would cancel this
658
+ // hook (F1 — a timed-out hook is killed, fails open, and its output, including a block message, is
659
+ // discarded), less a margin so the gate's own block reaches Claude Code before that happens. gateStart
660
+ // is this process's own start time, not the moment this function runs, so the budget covers everything
661
+ // this invocation has done so far (config/state loads, earlier checks), not just what follows.
662
+ function gateBudget(cfg) {
663
+ const hookTimeoutSec = resolveHookTimeoutSec(cfg);
664
+ const hookTimeoutMs = hookTimeoutSec * 1000;
665
+ const marginMs = Math.min(30000, hookTimeoutMs / 10);
666
+ // process.uptime() is fractional seconds; runCommand's timeoutMs must be an unsigned integer (Node's
667
+ // spawnSync throws otherwise), so every value derived from this deadline is rounded down.
668
+ const gateStart = Date.now() - Math.floor(process.uptime() * 1000);
669
+ return { hookTimeoutSec, hookTimeoutMs, deadline: Math.floor(gateStart + hookTimeoutMs - marginMs) };
670
+ }
671
+
672
+ // A lock whose numeric `at` is older than the hook-timeout budget is stale regardless of a live pid: no
673
+ // legitimate holder can still be running that long, since the platform itself would have killed its hook
674
+ // by then (F1). A missing or non-numeric `at` changes nothing here; the pid liveness check still decides.
675
+ function lockStaleByAge(lock, hookTimeoutMs) {
676
+ return !!lock && typeof lock.at === "number" && Number.isFinite(lock.at) && Date.now() - lock.at > hookTimeoutMs;
677
+ }
678
+
679
+ // Card stop-gate-timeout-normalise: cfg.stopGate.timeoutMs normalised once, shared by the per-command timeout
680
+ // and the wait bound below, so the two never disagree on what a garbage or fractional value means. A finite
681
+ // number above 0 floors to an integer, clamped up to at least 1ms (0.5 must not floor to 0 — spawnSync reads a
682
+ // timeout of 0 as none at all, letting a command run past the budget unclamped; 2.5 must not reach spawnSync
683
+ // un-floored — it throws ERR_OUT_OF_RANGE there, and a hook reader must never throw, F6). Anything else
684
+ // (non-number, NaN, Infinity, 0, negative, null) is "unset" (null), read by each call site as its own default.
685
+ function normalizedTimeoutMs(cfg) {
686
+ const t = cfg.stopGate.timeoutMs;
687
+ return typeof t === "number" && Number.isFinite(t) && t > 0 ? Math.max(1, Math.floor(t)) : null;
688
+ }
689
+
690
+ // Card stop-gate-deferral-cap (R2): cfg.stopGate.maxDeferrals normalised the same shape as
691
+ // normalizedTimeoutMs above — a hook reader must never throw (F6/F10), so a finite number >= 1 is floored
692
+ // and used; anything else (missing, 0, negative, NaN, a string, null, an object) reads as the default, 10.
693
+ function normalizedMaxDeferrals(cfg) {
694
+ const m = cfg && cfg.stopGate && cfg.stopGate.maxDeferrals;
695
+ return typeof m === "number" && Number.isFinite(m) && m >= 1 ? Math.floor(m) : 10;
696
+ }
697
+
698
+ // Card stop-gate-deferral-cap, review round 1 minor 4: state.deferrals read the same defensive way as
699
+ // stopGate.maxDeferrals above — a hook reader must never throw, and a hand-edited or otherwise non-numeric
700
+ // state file value (e.g. "deferrals": "1", which `(state.deferrals || 0) + 1` would concatenate into "11")
701
+ // must not misbehave. A finite number >= 0 floors to an integer; anything else (missing, negative, NaN, a
702
+ // string, null, an object) reads as 0.
703
+ function normalizedDeferrals(state) {
704
+ const d = state && state.deferrals;
705
+ return typeof d === "number" && Number.isFinite(d) && d >= 0 ? Math.floor(d) : 0;
706
+ }
707
+
708
+ runHook("stop-gate", async (input) => {
709
+ if (input.stop_hook_active) return allow();
710
+ const dir = projectDir(input);
711
+ const cfg = loadConfig(dir);
712
+ let state = loadState(dir, input.session_id);
713
+ const subagent = input.hook_event_name === "SubagentStop" ? { id: String(input.agent_id || "unknown"), type: input.agent_type || "subagent" } : null;
714
+ // R6 (card stop-gate-deferral-cap): a SubagentStop of this session ends any run of consecutive deferred
715
+ // main Stops the counter (R1) is tracking — reset right here, after loadState, so every SubagentStop exit
716
+ // below that saves state (including the one that reloads state again mid-lock-wait, R6 note there) persists
717
+ // the reset.
718
+ if (subagent) state.deferrals = 0;
719
+ if (!subagent) state.turns += 1;
720
+ const maxBlocks = cfg.stopGate.maxBlocks ?? 3;
721
+ const blocksSoFar = subagent ? (state.agentBlocks && state.agentBlocks[subagent.id]) || 0 : state.stopBlocks;
722
+ const who = subagent ? `Subagent ${subagent.type} (${subagent.id})` : null;
723
+ // D1 (card stop-gate-defers-while-subagent-in-flight): one choke point for the `decision` every SubagentStop
724
+ // exit's own trace line now carries, so no exit (present or future) can miss it — except the
725
+ // `stop_hook_active` early return above, which runs before `subagent` exists and so writes no decision line
726
+ // at all; that is benign, since it leaves only the trace hook's own decision-less line for the event, and
727
+ // rule B (below) reads a decision-less line with no matching block as the agent already gone, the right
728
+ // answer. `decision` is "block"
729
+ // exactly when the exit is blockStop, "allow" for allow()/systemMessage() (either lets the agent stop, so it
730
+ // is gone). Never fires for a main Stop (subagent is null there — a main Stop's exits are not agent-stop
731
+ // decisions). `traceReason`/`extra` let the three exits that already wrote a reason-carrying line (F4:
732
+ // verified-unchanged is handled directly in verifiedUnchangedAllow below since it is a shared, non-closure
733
+ // function; no-agent-changes and tests_red_by_design use these) fold their reason (and, for
734
+ // tests_red_by_design, its `detail`) into this same line instead of a second one being written. Wrapped so a
735
+ // trace failure never blocks the real exit (a hook reader must never throw).
736
+ function traceSubagentDecision(decision, traceReason, extra) {
737
+ if (!subagent) return;
738
+ try {
739
+ if (!cfg.trace || cfg.trace.enabled === false) return;
740
+ appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: "SubagentStop", decision, reason: traceReason ?? null, ...(extra || {}) });
741
+ } catch {
742
+ // never block the real exit on a trace failure
743
+ }
744
+ }
745
+ const allowDecided = (traceReason, extra) => {
746
+ traceSubagentDecision("allow", traceReason, extra);
747
+ return allow();
748
+ };
749
+ const blockStopDecided = (reason) => {
750
+ traceSubagentDecision("block", null);
751
+ return blockStop(reason);
752
+ };
753
+ const systemMessageDecided = (message, traceReason, extra) => {
754
+ traceSubagentDecision("allow", traceReason, extra);
755
+ return systemMessage(message);
756
+ };
757
+ // Card stop-gate-block-ledger: one ledger line per block/stand-down below, written synchronously right
758
+ // before the blockStop/systemMessage call it belongs to (those call process.exit(0) themselves, so
759
+ // nothing queued after them ever runs). appendBlockLine never throws.
760
+ const logBlock = (reason, cls) =>
761
+ appendBlockLine(dir, { hook: subagent ? "subagent-stop" : "stop-gate", agent: subagent ? subagent.type : null, reason, class: cls, session: input.session_id });
762
+ // Card block-ledger-stood-down-repeats: a stood-down line is written at most once per session per
763
+ // cause, so a session standing down at its cap does not append a fresh identical line on every later
764
+ // Stop/SubagentStop past it. Cause key: "turn-budget" (lead only), or "block-cap:<lead|agent id>" — the
765
+ // partial-claim cap and the verification/lock-refusal cap of one agent are the same block-cap cause for
766
+ // that agent. The record lives in state.stoodDownLogged, mutated in place before the saveState that
767
+ // already precedes every stand-down site below (so it survives across hook processes). A wrong-shaped
768
+ // value there (not a plain object) is treated as nothing written yet rather than thrown on.
769
+ const markStoodDown = (cause) => {
770
+ const key = cause === "turn-budget" ? "turn-budget" : `block-cap:${subagent ? subagent.id : "lead"}`;
771
+ if (!state.stoodDownLogged || typeof state.stoodDownLogged !== "object" || Array.isArray(state.stoodDownLogged)) {
772
+ state.stoodDownLogged = {};
773
+ }
774
+ const already = !!state.stoodDownLogged[key];
775
+ if (!already) state.stoodDownLogged[key] = true;
776
+ return already;
777
+ };
778
+ // Card stop-gate-budget-under-hook-timeout: the whole gate (wait plus commands) must fit under the
779
+ // platform's own hook timeout. Computed once so the wait loop and the command loop share one clock.
780
+ const { hookTimeoutSec, hookTimeoutMs, deadline: budgetDeadline } = gateBudget(cfg);
781
+
782
+ // card stop-gate-skips-orphan-subagent-stops: checked before the partial-claim block, the tree scans, the
783
+ // deferral logic, gate.lock, skipVerification, and runVerification — an orphan SubagentStop is not a real
784
+ // agent of this session, so none of that runs for it. No saveState (R6's in-memory `state.deferrals = 0`
785
+ // above is left unpersisted), no logBlock, no gate.lock, state.verified/agentBlocks untouched; the trace
786
+ // still gets one decision-tagged SubagentStop line (D1) via allowDecided.
787
+ if (subagent && isOrphanSubagentStop(dir, cfg, input.session_id, input)) {
788
+ process.stderr.write(
789
+ `[doug] SubagentStop for orphan agent ${subagent.id} (no SubagentStart in the trace, no agent_type, no readable transcript): verification skipped\n`,
790
+ );
791
+ return allowDecided("orphan-subagent-stop");
792
+ }
793
+
794
+ // Partial claim (card worker-context-handoff, brief A): a subagent cannot be observed compacting, so a
795
+ // partial=true claim is only honoured when the harness itself measured this agent's context at or above
796
+ // contextWindow.threshold at some point (state.agents[id].context.maxPct) — never on the agent's say-so, and
797
+ // never when contextWindow is disabled (there can be no reading). Refused before the onlyIfEdited early
798
+ // return below: a claim with nothing to verify must still be checked. Counted and capped through the same
799
+ // agentBlocks map and cap as every other SubagentStop block; never runs on the main Stop.
800
+ if (subagent) {
801
+ const claim = partialClaim(input.last_assistant_message);
802
+ if (claim) {
803
+ if (blocksSoFar >= maxBlocks) {
804
+ const alreadyStoodDown = markStoodDown("block-cap");
805
+ saveState(dir, input.session_id, state);
806
+ const reason = `[doug] ${who}'s partial claim was refused ${blocksSoFar} times and the gate is standing down for it. Do not treat its work as done.`;
807
+ if (!alreadyStoodDown) logBlock(reason, "stood-down");
808
+ return systemMessageDecided(reason);
809
+ }
810
+ // (review minor 10) trace disabled means no reading was ever possible for this agent, which is a
811
+ // different fact than "contextWindow is off" or "nothing crossed the threshold yet" — say so plainly
812
+ // rather than a refusal reason that implies a measurement was attempted.
813
+ if (!cfg.trace || cfg.trace.enabled === false) {
814
+ const blocks = blocksSoFar + 1;
815
+ state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: blocks };
816
+ saveState(dir, input.session_id, state);
817
+ const reason =
818
+ `[doug] Partial result refused: context readings need trace.enabled (it is off), so no reading could ever be recorded for this agent. ` +
819
+ `A partial is only for a worker the harness told to stop. Finish the brief, or return blocked=true with the reason.`;
820
+ logBlock(reason, "real");
821
+ return blockStopDecided(reason);
822
+ }
823
+ const threshold = cfg.contextWindow.threshold ?? 80;
824
+ const enabled = !!(cfg.contextWindow && cfg.contextWindow.enabled);
825
+ let entry = enabled ? state.agents && state.agents[subagent.id] && state.agents[subagent.id].context : null;
826
+ let maxPct = entry && typeof entry.maxPct === "number" ? entry.maxPct : null;
827
+ // (review minor 11) a fresh, one-off reading: the last recorded PostToolUse can be stale by the time a
828
+ // worker actually stops, so when nothing recorded so far clears the threshold, take one more reading
829
+ // from the transcript SubagentStop itself carries. A worker that crossed the threshold after its last
830
+ // tool call is then still accepted, instead of refused on a reading that predates its own stop.
831
+ if (enabled && (maxPct === null || maxPct < threshold) && typeof input.agent_transcript_path === "string" && input.agent_transcript_path) {
832
+ const fresh = contextReading(input.agent_transcript_path);
833
+ if (fresh) {
834
+ entry = recordAgentContext(state, subagent.id, fresh, new Date());
835
+ maxPct = entry.maxPct;
836
+ }
837
+ }
838
+ if (maxPct === null || maxPct < threshold) {
839
+ const blocks = blocksSoFar + 1;
840
+ state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: blocks };
841
+ saveState(dir, input.session_id, state);
842
+ const last = entry ? `${entry.pct}%` : "none";
843
+ const reason =
844
+ `[doug] Partial result refused: no context reading at or above ${threshold}% was recorded for this agent (${last}). ` +
845
+ `A partial is only for a worker the harness told to stop. Finish the brief, or return blocked=true with the reason.`;
846
+ logBlock(reason, "real");
847
+ return blockStopDecided(reason);
848
+ }
849
+ // Accepted: the recorded maxPct clears the threshold, so this falls through to the normal gate below.
850
+ }
851
+ }
852
+
853
+ // Budget: when a turn cap is set and exceeded, stop blocking and say so, so loops can end.
854
+ if (!subagent && cfg.budget && cfg.budget.maxTurns && state.turns > cfg.budget.maxTurns) {
855
+ const alreadyStoodDown = markStoodDown("turn-budget");
856
+ saveState(dir, input.session_id, state);
857
+ // Card stop-gate-block-ledger, review pass 2 item 3: a stand-down whatever its cause (lead's ruling:
858
+ // every stand-down is recorded), not only the counter-cap ones.
859
+ const reason = `[doug] Turn budget of ${cfg.budget.maxTurns} exceeded (${state.turns}). Gates are no longer blocking; review before continuing.`;
860
+ if (!alreadyStoodDown) logBlock(reason, "stood-down");
861
+ return systemMessage(reason);
862
+ }
863
+
864
+ const edited = state.editedFiles.length > 0;
865
+ const changed = changedFiles(dir);
866
+ // Snapshot whatever was already dirty so it is never mistaken for this session's own work (card
867
+ // stop-gate-waiting-on-subagents). Ideally this happens on SessionStart (scripts/session-start-baseline.mjs),
868
+ // before the session can have dirtied anything itself; this is the fallback for a session where that
869
+ // never fired, capturing on the stop gate's own first run instead (card stop-gate-session-start). Whichever
870
+ // captured it is recorded in state.baselineSource. Step 1 (protected paths) and step 2 (plan scope) below
871
+ // still read the full `changed` set, unfiltered: they are about the state of the tree, not who dirtied it.
872
+ // True only for the one Stop that repairs an old-format baseline (Minor 4 below): the walk-based check must
873
+ // sit out for exactly that run, even though state.baselineWalked is set true within it.
874
+ let baselineJustRepaired = false;
875
+ if (state.baseline === null && Array.isArray(changed)) {
876
+ const walked = walkProposalPathFiles(dir, cfg.proposalPaths);
877
+ state.baseline = captureBaseline(dir, [...new Set([...changed, ...walked])]);
878
+ state.baselineSource = "stop";
879
+ // Recorded in the same write as state.baseline (card stop-scan-committed-changes). Never overwrite a
880
+ // non-null baselineHead: an old state file's baseline predates this key and loads it as null via
881
+ // emptyState's spread, so this null check, rather than an unconditional set, is what "never overwrite"
882
+ // means in practice on this path.
883
+ if (state.baselineHead === null) state.baselineHead = headAt(dir);
884
+ // This capture always performs the proposalPaths walk above, so the baseline it just wrote can be
885
+ // trusted for the walk-based check below (state.baselineWalked; see its definition in lib/state.mjs).
886
+ state.baselineWalked = true;
887
+ } else if (state.baseline !== null && !state.baselineWalked && Array.isArray(changed)) {
888
+ // One-time repair (Minor 4, card stop-scan-committed-changes): a baseline captured before this walk
889
+ // existed (an old state file, or - the way this actually surfaced live - a session whose baseline was
890
+ // captured under an older stop-gate.mjs/session-start-baseline.mjs before this card's vendored copies
891
+ // were redeployed mid-session) has no entries at all for most proposalPaths files. Rather than sitting
892
+ // out the walk-based check for the rest of the session, the very first Stop to see such a baseline folds
893
+ // the walked files' current identities into it (only for a path not already a key - never overwriting an
894
+ // entry a real capture already recorded) and marks it walked, so the walk is exact again from the next
895
+ // Stop on. This one Stop still cannot trust what it just backfilled (a file could have been sitting there
896
+ // dirty since before this repair, not truly "untouched"), so it alone skips the walk-based check -
897
+ // baselineJustRepaired below, not state.baselineWalked, is what this run's check reads.
898
+ const walked = walkProposalPathFiles(dir, cfg.proposalPaths);
899
+ for (const f of walked) {
900
+ if (!(f in state.baseline)) state.baseline[f] = fileIdentity(join(dir, f));
901
+ }
902
+ state.baselineWalked = true;
903
+ baselineJustRepaired = true;
904
+ // Same repair, same reasoning, for the committed-changes scan (card stop-scan-committed-changes): an old
905
+ // state file predating baselineHead has recorded no HEAD either, so committedSince has been sitting out
906
+ // for this session exactly like the walk was. Nothing before this moment is attributed to the session
907
+ // either way (the walk repair above makes the same tradeoff), so recording HEAD now is equally safe, and
908
+ // brings the committed scan on from the next Stop the same way the walk comes back on.
909
+ if (state.baselineHead === null) state.baselineHead = headAt(dir);
910
+ }
911
+ // Paths committed since the session's baseline HEAD (card stop-scan-committed-changes): a protected- or
912
+ // proposal-path change the session commits before it stops leaves a clean tree, invisible to `changed`
913
+ // alone. Null exactly like `changed` when unknown — no baselineHead was ever recorded (no git, or an old
914
+ // state file predating this key), there is no HEAD yet, or git no longer knows the baseline sha (a
915
+ // rewritten base) — so steps 1 and 1b below fall back to scanning nothing extra, today's behavior.
916
+ const committed = committedSince(dir, state.baselineHead);
917
+ // Gitignored proposal-path files, on disk right now, whose identity has moved since the baseline (card
918
+ // stop-scan-committed-changes): computed here, ahead of onlyIfEdited below, and reused by step 1b, so a
919
+ // project that gitignores a proposalPaths prefix (e.g. .claude/) does not need a git-visible or committed
920
+ // change to reach the scan that catches it (see the anyChange comment just below for why). Gated on
921
+ // state.baselineWalked and !baselineJustRepaired: an untrustworthy or just-backfilled baseline sits out
922
+ // this one check rather than false-blocking on files it cannot yet vouch for (see the repair branch above).
923
+ const walkedProposalHits = Array.isArray(changed) && state.baselineWalked && !baselineJustRepaired
924
+ ? sinceBaseline(dir, walkProposalPathFiles(dir, cfg.proposalPaths), state.baseline || {}).filter((f) => !matchAny(cfg.stopGate.ignoreChangedPaths || [], f))
925
+ : [];
926
+ // A path counts as this session's own work only when it is absent from the baseline, or present but
927
+ // moved since: a path still identical to its baseline entry is not this session's work.
928
+ const sessionChanged = Array.isArray(changed) ? sinceBaseline(dir, changed, state.baseline || {}) : changed;
929
+ const sessionMadeChanges = edited || (Array.isArray(sessionChanged) && sessionChanged.length > 0);
930
+ // With a baseline captured at SessionStart — before the session could have dirtied anything — an
931
+ // already-dirty tree the session never touched can safely skip verification entirely: narrow anyChange
932
+ // to the baseline-relative set. A baseline captured by the stop gate's own fallback above cannot make
933
+ // that promise (the session may have made Bash changes before this gate's first run ever captured it),
934
+ // so it keeps reading the full `changed` set, exactly as before this card. `committed` and
935
+ // `walkedProposalHits` also count on either path (card stop-scan-committed-changes): otherwise
936
+ // onlyIfEdited's early return below would make the new scans in steps 1 and 1b unreachable for exactly the
937
+ // bypasses they close — a session that only committed by Bash and left a clean tree, or one that wrote a
938
+ // gitignored proposal-path file by Bash, which neither `git status` nor the Edit-tool tracking ever sees.
939
+ const anyChange =
940
+ (state.baselineSource === "session-start" ? sessionMadeChanges : edited || (Array.isArray(changed) && changed.length > 0)) ||
941
+ (Array.isArray(committed) && committed.length > 0) ||
942
+ walkedProposalHits.length > 0;
943
+ if (cfg.stopGate.onlyIfEdited && !anyChange) {
944
+ const notice = subagent ? null : contextNotice(dir, cfg, state, input.session_id, "skip");
945
+ saveState(dir, input.session_id, state);
946
+ return notice ? systemMessage(notice) : allowDecided();
947
+ }
948
+
949
+ // card stop-gate-process-storm, case 2: a tree already verified by an earlier green gate this session
950
+ // needs no recheck at all — placed ahead of maxBlocks and every problems check below, since a red gate
951
+ // never records `verified` and there is nothing left here to recheck when nothing has changed since.
952
+ const currentFingerprint = treeFingerprint(dir, cfg, changed);
953
+ if (sameTree(state.verified, currentFingerprint)) {
954
+ return verifiedUnchangedAllow(dir, cfg, state, input, subagent);
955
+ }
956
+
957
+ // D3 (card stop-gate-defers-while-subagent-in-flight): steps 1, 1b, and 2 (protected paths, committed
958
+ // protected paths, proposal paths, and plan scope) below are about the state of the tree, not about who is
959
+ // running — the deferral just below and the ordinary verification path (inside runVerification) both need
960
+ // them, so they are extracted here rather than kept as two copies that could drift apart.
961
+ function scanTreeProblems() {
962
+ const problems = [];
963
+
964
+ // 1. Protected paths changed outside the Edit tool (e.g. via Bash), still dirty in the working tree now.
965
+ const protectedHits = Array.isArray(changed) ? changed.filter((f) => matchAny(cfg.protectedPaths, f) && !matchAny(cfg.stopGate.ignoreChangedPaths || [], f)) : [];
966
+ if (protectedHits.length) {
967
+ problems.push(`Protected files were modified: ${protectedHits.join(", ")}. Revert them or ask the user; these paths are protected in .doug/config.json.`);
968
+ }
969
+
970
+ // 1 (committed). The same check for paths the session committed since its baseline HEAD (card
971
+ // stop-scan-committed-changes): `committed` already excludes the same housekeeping paths `changed` does
972
+ // (baseline.mjs's committedSince), and respects stopGate.ignoreChangedPaths the same way. Pushed as its own
973
+ // problem, with the same leading phrase plus a "(committed this session)" suffix, so a hit found only by
974
+ // this scan reads as the same kind of problem the working-tree scan above reports, not a different one.
975
+ // Minor 6: a path that is both a dirty hit above AND a committed hit here (committed, then modified again
976
+ // uncommitted) is dropped from this list - it is already named once by the working-tree problem above, and
977
+ // must not read as two separate problems.
978
+ if (Array.isArray(committed)) {
979
+ const ignore = cfg.stopGate.ignoreChangedPaths || [];
980
+ const committedProtectedHits = committed.filter((f) => matchAny(cfg.protectedPaths, f) && !matchAny(ignore, f) && !protectedHits.includes(f));
981
+ if (committedProtectedHits.length) {
982
+ problems.push(`Protected files were modified: ${committedProtectedHits.join(", ")} (committed this session). Revert them or ask the user; these paths are protected in .doug/config.json.`);
983
+ }
984
+ }
985
+
986
+ // 1b. Decisions/rules changed outside the proposal path (cards memory-decisions, proposal-ledger-forgeable,
987
+ // stop-scan-committed-changes): changedFiles already includes untracked paths (--untracked-files=all), so a
988
+ // newly created ADR/rule is a hit here too, not just a modified one. `committed` adds paths the session
989
+ // committed since its baseline HEAD, which leave a clean tree invisible to `changed` alone; a direct
990
+ // filesystem walk under each proposalPaths pattern's fixed prefix (walkProposalPathFiles) adds paths a
991
+ // project's own .gitignore hides from `git status` entirely, narrowed to those whose identity moved since
992
+ // the baseline (sinceBaseline) so a pre-existing, untouched file under a gitignored proposal path is never
993
+ // mistaken for this session's work. A hit from any of the three is fine only when the applied-proposal
994
+ // ledger (.doug/.state/proposals/applied.jsonl) has a line for it whose content sha256 matches AND whose
995
+ // named diff (under .doug/.state/learn/), applied onto the session's baseline HEAD (or HEAD when there is
996
+ // none - lib/proposals.mjs's `base` option), re-derives those same bytes - a hand-written ledger line alone
997
+ // no longer satisfies this (isAppliedContent in ../lib/proposals.mjs). Decided (card d-proposal-authorization,
998
+ // decision 0006, amendment 2026-09-13): this ledger is an honest-path record, not proof the user said yes -
999
+ // it stops the honest path and raises a forgery's cost, but a determined agent can still write all three
1000
+ // files by hand, so the message below says so rather than implying the ledger proves consent.
1001
+ if (Array.isArray(changed)) {
1002
+ const ignore = cfg.stopGate.ignoreChangedPaths || [];
1003
+ const changedHits = changed.filter((f) => matchAny(cfg.proposalPaths, f) && !matchAny(ignore, f));
1004
+ const committedHits = Array.isArray(committed) ? committed.filter((f) => matchAny(cfg.proposalPaths, f) && !matchAny(ignore, f)) : [];
1005
+ const proposalHits = [...new Set([...changedHits, ...committedHits, ...walkedProposalHits])];
1006
+ const unapproved = proposalHits.filter((f) => !isAppliedContent(dir, f, { base: state.baselineHead }));
1007
+ if (unapproved.length) {
1008
+ problems.push(
1009
+ `Decisions/rules were written outside the proposal path: ${unapproved.join(", ")}. These paths (proposalPaths in .doug/config.json) change only through an approved proposal (/doug-decide, then learn.mjs apply). Revert them or ask the user. The ledger records the honest path; it is not proof the user approved (decision 0006, amendment 2026-09-13).`,
1010
+ );
1011
+ }
1012
+ }
1013
+
1014
+ // 2. Plan scope: with an approved or done plan, every changed file must belong to a task.
1015
+ // Draft and rejected plans are not contracts yet, so they are ignored.
1016
+ let scope = null;
1017
+ if (Array.isArray(changed) && cfg.stopGate.planScope !== false) {
1018
+ const { plan, reason, error } = loadScopePlan(dir);
1019
+ if (reason === "unreadable") process.stderr.write(`[doug] ${PLAN_RELPATH} is unreadable (${error}); scope not checked\n`);
1020
+ if (plan) {
1021
+ const violations = scopeViolations(changed, plan, { ignore: cfg.stopGate.ignoreChangedPaths || [] });
1022
+ scope = { plan: plan.title, status: plan.status, outOfScope: violations.map((v) => v.file) };
1023
+ if (violations.length) problems.push(describeViolations(violations, plan));
1024
+ }
1025
+ }
1026
+
1027
+ return { problems, scope };
1028
+ }
1029
+
1030
+ // D3 (card stop-gate-defers-while-subagent-in-flight): a main Stop (subagent is null there, so this is
1031
+ // always `[]` on a SubagentStop) with a subagent of this session still in flight defers instead of claiming
1032
+ // this turn's tree is verified. The tree-state scans above still run — cheap, and about the tree, not about
1033
+ // who is running — so a real protected-path, proposal-path, or scope problem still blocks exactly as today
1034
+ // (that block is real and counts as today); only with no such problem does this Stop defer, skipping
1035
+ // gate.lock, the verification commands, the evidence check, state.verified, and the blocks.jsonl ledger
1036
+ // entirely, and touching neither state.stopBlocks nor state.agentBlocks. The next Stop after the last
1037
+ // SubagentStop (subagentsInFlight then returns `[]`) runs the full gate as today. state.lastGate is left
1038
+ // untouched — this Stop verified nothing, so there is nothing honest to record there.
1039
+ // R1/R3 (card stop-gate-deferral-cap): state.deferrals counts consecutive deferred main Stops; unbounded,
1040
+ // a dead or forged subagent (the module header's "Known limit") would defer forever. deferralCapNote is
1041
+ // set only when this Stop's own deferral just hit stopGate.maxDeferrals, and is read by the releasing
1042
+ // wrappers below (R4) to prepend the cap sentence to whatever the ordinary verification path this Stop
1043
+ // falls through to ends up returning. The cap only ever widens when the full verification runs (R7): it
1044
+ // never lets a Stop skip verification that would otherwise have run.
1045
+ let deferralCapNote = null;
1046
+ const inFlight = subagent ? [] : subagentsInFlight(dir, cfg, input.session_id);
1047
+ if (inFlight.length > 0) {
1048
+ const { problems: deferProblems } = scanTreeProblems();
1049
+ if (deferProblems.length === 0) {
1050
+ const maxDeferrals = normalizedMaxDeferrals(cfg);
1051
+ const n = normalizedDeferrals(state) + 1;
1052
+ const names = inFlight.map((a) => agentLabel({ agent: a.id, agentType: a.type })).join(", ");
1053
+ if (n < maxDeferrals) {
1054
+ state.deferrals = n;
1055
+ saveState(dir, input.session_id, state);
1056
+ return systemMessage(
1057
+ `[doug] Verification deferred: ${inFlight.length} subagent(s) of this session still in flight (${names}). The stop gate runs the full verification at the first Stop after they stop. Do not treat this work as done.`,
1058
+ );
1059
+ }
1060
+ // R3: the cap is hit on this Stop — do not defer again. Reset the counter (R5) and fall through to
1061
+ // the ordinary verification path below exactly as a Stop with no agent in flight, remembering the cap
1062
+ // sentence for the releasing wrappers (R4) to prepend to whatever that path returns. Review round 1
1063
+ // minor 3: `n` counts this Stop too, so the number actually deferred before it is `n - 1`; the cap
1064
+ // value itself (stopGate.maxDeferrals) is named alongside it so the message stays informative once the
1065
+ // deferred count is no longer always the cap.
1066
+ state.deferrals = 0;
1067
+ deferralCapNote = `[doug] Deferral cap reached: ${n - 1} consecutive Stops deferred (stopGate.maxDeferrals ${maxDeferrals}) while subagents of this session were still in flight (${names}); running the full verification anyway.`;
1068
+ } else {
1069
+ state.deferrals = 0; // R5: a real tree problem — this Stop does not defer
1070
+ }
1071
+ // A real problem, or the cap was just hit: fall through to the ordinary path below, which finds the
1072
+ // same problem again inside runVerification and blocks on it exactly as today (deferral only ever
1073
+ // widens "nothing to check yet", never what counts as a real problem).
1074
+ } else if (!subagent) {
1075
+ state.deferrals = 0; // R5: no agent in flight — this Stop runs the gate
1076
+ }
1077
+
1078
+ // card stop-gate-read-only-subagent-skip: computed once, ahead of the gate.lock acquire just below, so a
1079
+ // SubagentStop whose own agent made no change never takes or waits for the lock at all — waiting up to
1080
+ // timeoutMs on a suite it will never even need is the other half of the 2026-09-16 process storm. Only ever
1081
+ // true for a SubagentStop; the main Stop always runs the full gate.
1082
+ const skipVerification = subagent ? agentMadeNoChanges(dir, cfg, input.session_id, subagent) : false;
1083
+
1084
+ // card stop-gate-process-storm, case 3: only one gate runs the verification commands at a time per
1085
+ // project (the 2026-09-16 process storm: four concurrent SubagentStops each ran the full suite, each
1086
+ // 300s timeout orphaning ten vitest workers). A gate holds .doug/.state/gate.lock while its commands run;
1087
+ // a second gate that finds a live lock waits for it, bounded by stopGate.timeoutMs, then reloads state and
1088
+ // reapplies the skip above — the holder's own green run may have just recorded the very `verified` tree
1089
+ // this gate needs, letting it skip instead of also running the full suite.
1090
+ let verifiedCandidate = currentFingerprint;
1091
+ let lockOwned = false;
1092
+ // card stop-gate-lock-release-on-throw: an exception anywhere below (e.g. inside runVerification, after
1093
+ // the lock is taken) reaches runHook, which fails open via process.exit(0) — skipping every return path
1094
+ // here, including the *Releasing wrappers below. Registered once, right where lockOwned exists, so it is
1095
+ // in place before anything that can throw while this gate holds the lock; `releaseLock` is a hoisted
1096
+ // function declaration (not a const) so it is safe to call here even though it is defined later in this
1097
+ // scope. Synchronous and never throws, matching the `process.on("exit", ...)` contract.
1098
+ process.on("exit", () => {
1099
+ try {
1100
+ releaseLock();
1101
+ } catch {
1102
+ // a hook reader must never throw, including from here
1103
+ }
1104
+ });
1105
+ if (!skipVerification) {
1106
+ try {
1107
+ writeGateLock(dir, input.session_id);
1108
+ lockOwned = true;
1109
+ } catch (err) {
1110
+ if (!err || err.code !== "EEXIST") {
1111
+ // Some other failure writing the lock (e.g. an unwritable .doug/.state): never let a lock the gate
1112
+ // cannot even write hang it. Proceed unlocked, exactly like before this card.
1113
+ lockOwned = false;
1114
+ } else {
1115
+ const lock = readGateLock(dir);
1116
+ if (!lock || !pidIsLive(lock.pid) || lockStaleByAge(lock, hookTimeoutMs)) {
1117
+ // Stale (a dead pid, a lock file that fails to parse, or one older than the hook-timeout budget —
1118
+ // card stop-gate-budget-under-hook-timeout: no legitimate holder can still be running that long):
1119
+ // take it over at once, no wait.
1120
+ takeOverGateLock(dir, input.session_id);
1121
+ lockOwned = true;
1122
+ } else {
1123
+ // The bound scales with the number of configured commands (reviewer-found gap): a holder legitimately
1124
+ // runs each of resolveCommands(cfg) with its own timeoutMs budget, one after another, so a flat
1125
+ // timeoutMs would give up on — and duplicate — a holder still legitimately partway through its own
1126
+ // (later) command. Card stop-gate-timeout-normalise: an unset (garbage or fractional-to-zero)
1127
+ // timeoutMs no longer defaults to a flat 300000ms here — the budget deadline below is the real bound
1128
+ // either way, so an unset value waits only for what remains of it, unmultiplied.
1129
+ const normalizedTimeout = normalizedTimeoutMs(cfg);
1130
+ const waitBoundMs = normalizedTimeout !== null ? normalizedTimeout * Math.max(1, resolveCommands(cfg).length) : budgetDeadline - Date.now();
1131
+ const waitDeadline = Date.now() + waitBoundMs;
1132
+ // Card stop-gate-budget-under-hook-timeout: waitDeadline alone can outrun the platform's own hook
1133
+ // timeout (F1), so a waiter also stops at budgetDeadline — but there it refuses the stop instead of
1134
+ // taking the lock over, since taking over and running past the platform's own timeout would just get
1135
+ // this gate killed mid-command too (an ungated stop, its block message discarded).
1136
+ let refusedLock = null;
1137
+ while (true) {
1138
+ const live = readGateLock(dir);
1139
+ if (!live || !pidIsLive(live.pid) || lockStaleByAge(live, hookTimeoutMs)) break; // released, died, or stale by age
1140
+ if (Date.now() >= budgetDeadline) {
1141
+ refusedLock = live;
1142
+ break;
1143
+ }
1144
+ if (Date.now() >= waitDeadline) break; // bounded: never hang the gate on someone else's lock
1145
+ await new Promise((r) => setTimeout(r, 200));
1146
+ }
1147
+ if (refusedLock) {
1148
+ // Refused: not a pass. Counted against maxBlocks like any other block (design point 3), including
1149
+ // the same standing-down cap as an ordinary failed-verification block.
1150
+ if (blocksSoFar >= maxBlocks) {
1151
+ const alreadyStoodDown = markStoodDown("block-cap");
1152
+ saveState(dir, input.session_id, state);
1153
+ const reason = who
1154
+ ? `[doug] ${who} was blocked ${blocksSoFar} times and the gate is standing down for it. Verification is still failing; do not treat its work as done.`
1155
+ : `[doug] Stop gate blocked ${state.stopBlocks} times this session and is standing down. Verification is still failing; do not treat this work as done.`;
1156
+ if (!alreadyStoodDown) logBlock(reason, "stood-down");
1157
+ return systemMessageDecided(reason);
1158
+ }
1159
+ const blocks = blocksSoFar + 1;
1160
+ if (subagent) state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: blocks };
1161
+ else state.stopBlocks = blocks;
1162
+ saveState(dir, input.session_id, state);
1163
+ const reason =
1164
+ (who ? `[doug] ${who} cannot report done` : `[doug] Stop gate`) +
1165
+ `: another gate (session ${refusedLock.session || "unknown"}) is still running the verification commands, and this hook's own budget (hookTimeoutSec ${hookTimeoutSec} s) ran out while waiting for it (${blocks}/${maxBlocks}). Stop again once it finishes.`;
1166
+ logBlock(reason, "load-timeout");
1167
+ return blockStopDecided(reason);
1168
+ }
1169
+ // The holder may have just finished its own green run of this same session while we waited.
1170
+ state = loadState(dir, input.session_id);
1171
+ // R6 (card stop-gate-deferral-cap; review round 1 MAJOR 1): this reload just replaced `state` with
1172
+ // a fresh disk load, discarding whatever reset this Stop had already decided before reaching the
1173
+ // lock section — the R6 SubagentStop reset above, or (on a main Stop) the R3/R5 reset the deferral
1174
+ // check just above made in memory but never got to save (this Stop had to reach the lock section
1175
+ // to even get here, so it already passed that check point and always intends 0). Unconditional:
1176
+ // every path that reaches here, subagent or not, means 0.
1177
+ state.deferrals = 0;
1178
+ verifiedCandidate = treeFingerprint(dir, cfg, changed);
1179
+ if (sameTree(state.verified, verifiedCandidate)) {
1180
+ // Review round 1 minor 5: this exit returns straight from verifiedUnchangedAllow, bypassing the
1181
+ // *Releasing wrappers (R4) below — release the lock here (a no-op when this gate never took it)
1182
+ // and hand the cap note through so a cap Stop landing on this path still carries it.
1183
+ releaseLock();
1184
+ return verifiedUnchangedAllow(dir, cfg, state, input, subagent, deferralCapNote);
1185
+ }
1186
+ // Still different, or the wait hit its bound with the lock still held: take it over and run.
1187
+ takeOverGateLock(dir, input.session_id);
1188
+ lockOwned = true;
1189
+ }
1190
+ }
1191
+ }
1192
+ }
1193
+
1194
+ // card stop-gate-process-storm, case 3: the lock is held only around the commands actually running.
1195
+ // io.mjs's allow/blockStop/systemMessage each call process.exit(0) themselves — a wrapping try/finally
1196
+ // around runVerification below would never run, since process.exit() cuts the call stack off before any
1197
+ // enclosing finally gets a turn — so the lock must be released just before each of those calls instead,
1198
+ // through these wrappers used only from here down, once lockOwned may be true. Card
1199
+ // stop-gate-lock-release-on-throw: the same release also runs from the `process.on("exit", ...)` handler
1200
+ // registered above, which covers the path these wrappers cannot — an exception that reaches runHook's own
1201
+ // fail-open `process.exit(0)` without passing through any of them.
1202
+ function releaseLock() {
1203
+ if (!lockOwned) return;
1204
+ const lock = readGateLock(dir);
1205
+ if (lock && lock.pid === process.pid) {
1206
+ try {
1207
+ unlinkSync(gateLockPath(dir));
1208
+ } catch {
1209
+ // already gone
1210
+ }
1211
+ }
1212
+ }
1213
+ // R4 (card stop-gate-deferral-cap): when this Stop's own deferral just hit the cap (deferralCapNote set,
1214
+ // main Stop only — it is never set for a SubagentStop, but the `!subagent` guard is kept explicit to
1215
+ // match the rule), the cap sentence is prepended to whatever this Stop's ordinary verification path
1216
+ // returns: a green that would otherwise allow silently now allows with a systemMessage carrying it, a
1217
+ // green that already carries a message gets it prepended, and a red block gets it prepended to the reason.
1218
+ const allowReleasing = (traceReason, extra) => {
1219
+ releaseLock();
1220
+ if (deferralCapNote && !subagent) return systemMessageDecided(deferralCapNote, traceReason, extra);
1221
+ return allowDecided(traceReason, extra);
1222
+ };
1223
+ const blockStopReleasing = (reason) => {
1224
+ releaseLock();
1225
+ return blockStopDecided(deferralCapNote && !subagent ? `${deferralCapNote}\n\n${reason}` : reason);
1226
+ };
1227
+ const systemMessageReleasing = (message, traceReason, extra) => {
1228
+ releaseLock();
1229
+ return systemMessageDecided(deferralCapNote && !subagent ? `${deferralCapNote}\n\n${message}` : message, traceReason, extra);
1230
+ };
1231
+
1232
+ const runVerification = async () => {
1233
+ if (blocksSoFar >= maxBlocks) {
1234
+ const alreadyStoodDown = markStoodDown("block-cap");
1235
+ saveState(dir, input.session_id, state);
1236
+ const reason = who
1237
+ ? `[doug] ${who} was blocked ${blocksSoFar} times and the gate is standing down for it. Verification is still failing; do not treat its work as done.`
1238
+ : `[doug] Stop gate blocked ${state.stopBlocks} times this session and is standing down. Verification is still failing; do not treat this work as done.`;
1239
+ if (!alreadyStoodDown) logBlock(reason, "stood-down");
1240
+ return systemMessageReleasing(reason);
1241
+ }
1242
+
1243
+ // D3 (card stop-gate-defers-while-subagent-in-flight): steps 1, 1b, and 2 (protected paths, committed
1244
+ // protected paths, proposal paths, and plan scope) now live in scanTreeProblems above, shared with the
1245
+ // deferral check — the deferral already ran this same scan once when a subagent was in flight, and finding
1246
+ // a real problem there falls through to here, which finds it again and blocks on it exactly as today.
1247
+ const { problems, scope } = scanTreeProblems();
1248
+
1249
+ // Precondition for a tests_red_by_design claim (design point 2): "the gate is otherwise about to block
1250
+ // on failed commands only" means no protected-path, committed-protected, proposal-path, or scope problem
1251
+ // — everything pushed to `problems` up to here.
1252
+ const preVerificationProblems = problems.length;
1253
+
1254
+ // 3. Verification commands. Skipped entirely (card stop-gate-read-only-subagent-skip) when this
1255
+ // SubagentStop's own agent made no change: `results` stays [] and no command runs.
1256
+ const results = [];
1257
+ // One entry per failed command, `{ command, output }` (stderr + stdout, untruncated), for the
1258
+ // tests_red_by_design coverage check below (design point 2, card subagent-stop-gate-tester-red).
1259
+ const commandFailures = [];
1260
+ if (!skipVerification) {
1261
+ for (const { name, command } of resolveCommands(cfg)) {
1262
+ // Card stop-gate-budget-under-hook-timeout: a command that cannot even start before the platform
1263
+ // would kill this hook's own process is dropped rather than let the platform cut it off mid-run,
1264
+ // silently, with its block message discarded (F1). One still runs with what budget is left, clamped
1265
+ // to it, so the existing timed-out message below reports the timeout actually used.
1266
+ const remaining = budgetDeadline - Date.now();
1267
+ if (remaining <= 0) {
1268
+ results.push({ name, command, ok: false, skipped: "budget" });
1269
+ problems.push(
1270
+ `\`${command}\` not run: the stop gate's budget (hook timeout ${hookTimeoutSec} s) was used up before it could start.`,
1271
+ );
1272
+ continue;
1273
+ }
1274
+ // R2 (pass 2 review; card stop-gate-timeout-normalise): a garbage or non-positive cfg.stopGate.timeoutMs
1275
+ // (0, null, "abc", 2.5, 0.5 — any of which would otherwise reach spawnSync unclamped, floored to 0 (read
1276
+ // as no timeout), or un-floored (throws ERR_OUT_OF_RANGE there, and a throw fails open per F6)) must
1277
+ // never bypass the budget: normalizedTimeoutMs floors and clamps up to at least 1ms; unset uses the
1278
+ // remaining budget outright. The timed-out message below reports this same value.
1279
+ const commandTimeoutMs = Math.min(normalizedTimeoutMs(cfg) ?? remaining, remaining);
1280
+ const r = runCommand(command, { cwd: dir, timeoutMs: commandTimeoutMs });
1281
+ results.push({ name, command, ok: r.ok, durationMs: r.durationMs, timedOut: r.timedOut });
1282
+ if (!r.ok) {
1283
+ const why = r.timedOut ? `timed out after ${commandTimeoutMs} ms` : `exit ${r.status}`;
1284
+ const output = r.stderr + "\n" + r.stdout;
1285
+ // card gate-output-names-failures: the block reason leads with the failing test names (R1/R2);
1286
+ // commandFailures below keeps the untruncated output for the tests_red_by_design coverage check.
1287
+ problems.push(`\`${command}\` failed (${why}):\n${failureSummary(output, 40)}`);
1288
+ commandFailures.push({ command, output });
1289
+ }
1290
+ }
1291
+ }
1292
+ const postCommandProblems = problems.length;
1293
+
1294
+ // 3b. Evidence: the session must have run a test or verify command itself (lib/evidence.mjs), so a session that
1295
+ // says done without ever looking is told so, in different words from one whose verification failed. A path
1296
+ // that was already dirty when the session's baseline was captured, and is still identical to it, is not this
1297
+ // session's work, so it cannot trigger a demand to verify a change the session never made. Also skipped on a
1298
+ // no-agent-changes skip (card stop-gate-read-only-subagent-skip): 3b demands the session itself ran a test,
1299
+ // and a no-change agent blocked by it would satisfy that only by rerunning the suite — exactly what this
1300
+ // card stops.
1301
+ let evidence = null;
1302
+ if (!skipVerification && cfg.stopGate.requireEvidence !== false && sessionMadeChanges) {
1303
+ evidence = verificationEvidence(state.commands || [], cfg);
1304
+ if (evidence.ran.length === 0) problems.push(describeMissingEvidence(evidence.looked, results.every((r) => r.ok)));
1305
+ }
1306
+ const evidenceProblemAdded = problems.length > postCommandProblems;
1307
+
1308
+ state.lastGate = { ok: problems.length === 0, at: Date.now(), results, scope, evidence, ...(subagent ? { subagent } : {}), ...(skipVerification ? { skipped: "no-agent-changes" } : {}) };
1309
+ // card stop-gate-process-storm, case 2: a green gate records the tree it just verified, so a later
1310
+ // Stop/SubagentStop whose tree still matches can skip re-running these commands (the sameTree check
1311
+ // above). A red gate never reaches here, so it records nothing (goal 2). Never on a no-agent-changes skip
1312
+ // (card stop-gate-read-only-subagent-skip): nothing was actually verified.
1313
+ if (problems.length === 0 && !skipVerification) state.verified = verifiedCandidate;
1314
+ if (problems.length === 0 && subagent) {
1315
+ // A subagent's green gate: its counter resets; the checkpoint, if any, belongs to the session's own Stop.
1316
+ state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: 0 };
1317
+ saveState(dir, input.session_id, state);
1318
+ // card stop-gate-read-only-subagent-skip: the "no-agent-changes" reason folds into this exit's own
1319
+ // decision-tagged trace line (D1) rather than a second one being written.
1320
+ return allowReleasing(skipVerification ? "no-agent-changes" : null);
1321
+ }
1322
+ if (problems.length === 0) {
1323
+ state.stopBlocks = 0;
1324
+ // 4. Checkpoint on green (off unless checkpoint.enabled): record the working tree now that the
1325
+ // gate passed with changes present, so a later regression can be undone with git.
1326
+ const ck = checkpoint({ dir, cfg, changed, gateResults: results });
1327
+ const silent = ck.skipped === "disabled" || ck.skipped === "no changes";
1328
+ if (!silent) state.lastCheckpoint = { ...ck, at: Date.now() };
1329
+ const notice = contextNotice(dir, cfg, state, input.session_id, "verification");
1330
+ saveState(dir, input.session_id, state);
1331
+ if (notice) return systemMessageReleasing(silent ? notice : `${describeCheckpoint(ck)}\n\n${notice}`);
1332
+ return silent ? allowReleasing() : systemMessageReleasing(describeCheckpoint(ck));
1333
+ }
1334
+
1335
+ // Tests red by design (design points 1-4, card subagent-stop-gate-tester-red): a SubagentStop whose
1336
+ // last_assistant_message, or (card tester-claim-missed-in-handback, pass 2, when the plain text carries
1337
+ // no claim) whose transcript's last SubagentHandback call, carries a tests_red_by_design claim lets the
1338
+ // tester stop once instead of being blocked and retrying the suite, but only when every problem so far
1339
+ // comes from a failed verification command (no protected-path, scope, or evidence problem) and the claim
1340
+ // exactly accounts for the test files those commands' output names. The plain-text claim always wins
1341
+ // when it is present and valid; the hand-back is read only as a fallback. Never on the main Stop (point
1342
+ // 4): gated on `subagent`, which is null there. Never throws: any failure here falls through to the
1343
+ // ordinary block below, unevaluated.
1344
+ if (subagent && problems.length > 0) {
1345
+ try {
1346
+ const claimFiles = redByDesignClaim(input.last_assistant_message) ?? redByDesignClaim(lastHandbackMessage(input.agent_transcript_path));
1347
+ if (claimFiles) {
1348
+ // Fix pass MAJOR (T11): the claim is honoured only for the tester agent exactly — the same
1349
+ // claim from any other agent_type (a coder relaying it, or an unset type) still blocks, naming
1350
+ // the type seen, rather than being silently evaluated for coverage.
1351
+ if (subagent.type !== "tester") {
1352
+ problems.push(`The tests_red_by_design claim is only honoured for the tester agent; this SubagentStop's agent_type is "${subagent.type}".`);
1353
+ } else if (
1354
+ preVerificationProblems === 0 &&
1355
+ !evidenceProblemAdded &&
1356
+ commandFailures.length > 0 &&
1357
+ // R1 (pass 2 review): a command dropped for budget also adds one problem in step 3 (line ~892),
1358
+ // but is not in commandFailures — that part of the verification never ran at all, so the claim
1359
+ // cannot honestly account for it. Requiring every step-3 problem to be an accounted-for failure
1360
+ // (none of them a dropped command) closes that gap.
1361
+ postCommandProblems - preVerificationProblems === commandFailures.length
1362
+ ) {
1363
+ const coverage = redByDesignCoverage(claimFiles, commandFailures, changed, dir);
1364
+ if (coverage.covered) {
1365
+ // Same as a subagent's green gate: counter resets, no checkpoint, no turn.
1366
+ state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: 0 };
1367
+ state.lastGate = { ...state.lastGate, redByDesign: coverage.files };
1368
+ // Card stop-gate-block-ledger: the standing claim, distinct from state.lastGate above (which a
1369
+ // later gate run replaces wholesale, line ~942) — this is what a later block's classifier reads.
1370
+ state.redByDesignClaim = { files: coverage.files, at: new Date().toISOString(), agent: subagent.id };
1371
+ saveState(dir, input.session_id, state);
1372
+ // Card stop-gate-defers-while-subagent-in-flight: the "tests_red_by_design" reason (and its
1373
+ // covered-files detail) folds into this exit's own decision-tagged trace line (D1) rather than a
1374
+ // second one being written.
1375
+ return systemMessageReleasing(
1376
+ `[doug] ${who} stopped with tests red by design: ${coverage.files.join(", ")}. The coder's change is expected to turn them green; do not treat the suite as passing.`,
1377
+ "tests_red_by_design",
1378
+ { detail: coverage.files.join(", ") },
1379
+ );
1380
+ }
1381
+ if (coverage.reasonLine) problems.push(coverage.reasonLine);
1382
+ }
1383
+ }
1384
+ } catch {
1385
+ // A broken scan must never let a stop through incorrectly; fall through to the ordinary block.
1386
+ }
1387
+ }
1388
+
1389
+ const blocks = blocksSoFar + 1;
1390
+ if (subagent) state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: blocks };
1391
+ else state.stopBlocks = blocks;
1392
+ saveState(dir, input.session_id, state);
1393
+ const reason =
1394
+ (who ? `[doug] ${who} cannot report done: verification failed (${blocks}/${maxBlocks}). Fix the following before finishing the task. ` : `[doug] Verification failed (${blocks}/${maxBlocks}). Fix the following before finishing. `) +
1395
+ `Do not claim the task is done while this fails.\n\n` +
1396
+ problems.join("\n\n");
1397
+ // card stop-gate-block-ledger, review pass 2 item 2 (MAJOR): classifyFinalBlock never throws on its own
1398
+ // (it has its own internal try/catch), but this call sits between the gate's decision to block and the
1399
+ // blockStopReleasing call that carries it out — guarded again here so nothing between them can ever fail
1400
+ // the whole gate open, whatever future change touches either side.
1401
+ let finalClass = "real";
1402
+ try {
1403
+ finalClass = classifyFinalBlock({
1404
+ preVerificationProblems,
1405
+ evidenceProblemAdded,
1406
+ commandFailures,
1407
+ postCommandProblems,
1408
+ results,
1409
+ redByDesignClaimFiles: state.redByDesignClaim && Array.isArray(state.redByDesignClaim.files) ? state.redByDesignClaim.files : null,
1410
+ changedAtStart: changed,
1411
+ dir,
1412
+ budgetDeadline,
1413
+ });
1414
+ } catch {
1415
+ finalClass = "real";
1416
+ }
1417
+ logBlock(reason, finalClass);
1418
+ return blockStopReleasing(reason);
1419
+ };
1420
+
1421
+ return runVerification();
1422
+ });