@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.
- package/LICENSE +21 -0
- package/dist/ablate.d.ts +96 -0
- package/dist/ablate.js +440 -0
- package/dist/ablate.js.map +1 -0
- package/dist/apply.d.ts +2 -0
- package/dist/apply.js +19 -0
- package/dist/apply.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +80 -0
- package/dist/bin.js.map +1 -0
- package/dist/board-page.d.ts +15 -0
- package/dist/board-page.js +27 -0
- package/dist/board-page.js.map +1 -0
- package/dist/board-serve-process.d.ts +24 -0
- package/dist/board-serve-process.js +227 -0
- package/dist/board-serve-process.js.map +1 -0
- package/dist/board-serve.d.ts +9 -0
- package/dist/board-serve.js +302 -0
- package/dist/board-serve.js.map +1 -0
- package/dist/board.d.ts +6 -0
- package/dist/board.js +515 -0
- package/dist/board.js.map +1 -0
- package/dist/detect/fs.d.ts +6 -0
- package/dist/detect/fs.js +50 -0
- package/dist/detect/fs.js.map +1 -0
- package/dist/detect/index.d.ts +3 -0
- package/dist/detect/index.js +13 -0
- package/dist/detect/index.js.map +1 -0
- package/dist/detect/node.d.ts +3 -0
- package/dist/detect/node.js +179 -0
- package/dist/detect/node.js.map +1 -0
- package/dist/detect/python.d.ts +2 -0
- package/dist/detect/python.js +388 -0
- package/dist/detect/python.js.map +1 -0
- package/dist/detect/repo.d.ts +2 -0
- package/dist/detect/repo.js +102 -0
- package/dist/detect/repo.js.map +1 -0
- package/dist/detect/types.d.ts +98 -0
- package/dist/detect/types.js +2 -0
- package/dist/detect/types.js.map +1 -0
- package/dist/diff.d.ts +12 -0
- package/dist/diff.js +105 -0
- package/dist/diff.js.map +1 -0
- package/dist/generate/agents.d.ts +19 -0
- package/dist/generate/agents.js +184 -0
- package/dist/generate/agents.js.map +1 -0
- package/dist/generate/claude-md.d.ts +9 -0
- package/dist/generate/claude-md.js +111 -0
- package/dist/generate/claude-md.js.map +1 -0
- package/dist/generate/config.d.ts +51 -0
- package/dist/generate/config.js +155 -0
- package/dist/generate/config.js.map +1 -0
- package/dist/generate/proposal.d.ts +11 -0
- package/dist/generate/proposal.js +175 -0
- package/dist/generate/proposal.js.map +1 -0
- package/dist/generate/settings.d.ts +35 -0
- package/dist/generate/settings.js +213 -0
- package/dist/generate/settings.js.map +1 -0
- package/dist/generate/skills.d.ts +8 -0
- package/dist/generate/skills.js +264 -0
- package/dist/generate/skills.js.map +1 -0
- package/dist/init.d.ts +10 -0
- package/dist/init.js +69 -0
- package/dist/init.js.map +1 -0
- package/dist/trace.d.ts +9 -0
- package/dist/trace.js +106 -0
- package/dist/trace.js.map +1 -0
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +10 -0
- package/node_modules/@dougharness/flow/LICENSE +21 -0
- package/node_modules/@dougharness/flow/agents/adversary-claude.md +33 -0
- package/node_modules/@dougharness/flow/agents/adversary.md +29 -0
- package/node_modules/@dougharness/flow/agents/implementer.md +23 -0
- package/node_modules/@dougharness/flow/agents/lead.md +35 -0
- package/node_modules/@dougharness/flow/agents/planner.md +70 -0
- package/node_modules/@dougharness/flow/agents/researcher.md +31 -0
- package/node_modules/@dougharness/flow/agents/reviewer.md +26 -0
- package/node_modules/@dougharness/flow/agents/verifier.md +24 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +840 -0
- package/node_modules/@dougharness/flow/lib/code-index.mjs +629 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +392 -0
- package/node_modules/@dougharness/flow/lib/decisions.mjs +314 -0
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +539 -0
- package/node_modules/@dougharness/flow/lib/land.mjs +219 -0
- package/node_modules/@dougharness/flow/lib/learn.mjs +742 -0
- package/node_modules/@dougharness/flow/lib/memory-import.mjs +145 -0
- package/node_modules/@dougharness/flow/lib/memory.mjs +1641 -0
- package/node_modules/@dougharness/flow/lib/models.mjs +148 -0
- package/node_modules/@dougharness/flow/lib/plan.mjs +773 -0
- package/node_modules/@dougharness/flow/lib/rehearse.mjs +825 -0
- package/node_modules/@dougharness/flow/lib/worktrees.mjs +215 -0
- package/node_modules/@dougharness/flow/package.json +19 -0
- package/node_modules/@dougharness/flow/scripts/board.mjs +260 -0
- package/node_modules/@dougharness/flow/scripts/cost.mjs +66 -0
- package/node_modules/@dougharness/flow/scripts/learn.mjs +161 -0
- package/node_modules/@dougharness/flow/scripts/memory.mjs +1274 -0
- package/node_modules/@dougharness/flow/scripts/plan.mjs +547 -0
- package/node_modules/@dougharness/flow/scripts/rehearse.mjs +129 -0
- package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +92 -0
- package/node_modules/@dougharness/flow/skills/doug-approve/SKILL.md +15 -0
- package/node_modules/@dougharness/flow/skills/doug-decide/SKILL.md +26 -0
- package/node_modules/@dougharness/flow/skills/doug-implement/SKILL.md +22 -0
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +20 -0
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +87 -0
- package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +22 -0
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +55 -0
- package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +85 -0
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +41 -0
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +51 -0
- package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +27 -0
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +2687 -0
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +10 -0
- package/node_modules/@dougharness/gates/LICENSE +21 -0
- package/node_modules/@dougharness/gates/hooks/hooks.json +184 -0
- package/node_modules/@dougharness/gates/lib/agent-context.mjs +111 -0
- package/node_modules/@dougharness/gates/lib/anchor.mjs +110 -0
- package/node_modules/@dougharness/gates/lib/baseline.mjs +160 -0
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +1522 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +127 -0
- package/node_modules/@dougharness/gates/lib/checkpoint.mjs +97 -0
- package/node_modules/@dougharness/gates/lib/config.mjs +168 -0
- package/node_modules/@dougharness/gates/lib/evidence.mjs +59 -0
- package/node_modules/@dougharness/gates/lib/glob.mjs +86 -0
- package/node_modules/@dougharness/gates/lib/io.mjs +91 -0
- package/node_modules/@dougharness/gates/lib/paths.mjs +101 -0
- package/node_modules/@dougharness/gates/lib/proposals.mjs +201 -0
- package/node_modules/@dougharness/gates/lib/run.mjs +147 -0
- package/node_modules/@dougharness/gates/lib/scope.mjs +117 -0
- package/node_modules/@dougharness/gates/lib/secret-rules.mjs +268 -0
- package/node_modules/@dougharness/gates/lib/state.mjs +114 -0
- package/node_modules/@dougharness/gates/lib/trace.mjs +196 -0
- package/node_modules/@dougharness/gates/package.json +17 -0
- package/node_modules/@dougharness/gates/scripts/edit-loop.mjs +39 -0
- package/node_modules/@dougharness/gates/scripts/format-on-edit.mjs +45 -0
- package/node_modules/@dougharness/gates/scripts/guard-bash.mjs +32 -0
- package/node_modules/@dougharness/gates/scripts/protect-paths.mjs +49 -0
- package/node_modules/@dougharness/gates/scripts/reanchor.mjs +81 -0
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +295 -0
- package/node_modules/@dougharness/gates/scripts/secret-scan.mjs +26 -0
- package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +57 -0
- package/node_modules/@dougharness/gates/scripts/statusline.mjs +133 -0
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +1422 -0
- package/node_modules/@dougharness/gates/scripts/trace.mjs +75 -0
- package/package.json +33 -0
- 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
|
+
});
|