@dougharness/cli 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/bin.js +8 -0
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +9 -3
  4. package/dist/board-serve.js.map +1 -1
  5. package/dist/board.js +10 -2
  6. package/dist/board.js.map +1 -1
  7. package/dist/detect/repo.js +14 -1
  8. package/dist/detect/repo.js.map +1 -1
  9. package/dist/detect/types.d.ts +2 -0
  10. package/dist/doctor.d.ts +46 -0
  11. package/dist/doctor.js +491 -0
  12. package/dist/doctor.js.map +1 -0
  13. package/dist/generate/agents.d.ts +7 -0
  14. package/dist/generate/agents.js +62 -12
  15. package/dist/generate/agents.js.map +1 -1
  16. package/dist/generate/claude-md.js +4 -0
  17. package/dist/generate/claude-md.js.map +1 -1
  18. package/dist/generate/config.d.ts +8 -0
  19. package/dist/generate/config.js +4 -1
  20. package/dist/generate/config.js.map +1 -1
  21. package/dist/generate/proposal.js +15 -6
  22. package/dist/generate/proposal.js.map +1 -1
  23. package/dist/generate/settings.d.ts +2 -0
  24. package/dist/generate/settings.js +17 -2
  25. package/dist/generate/settings.js.map +1 -1
  26. package/dist/init.d.ts +14 -0
  27. package/dist/init.js +94 -8
  28. package/dist/init.js.map +1 -1
  29. package/dist/memory-setup.d.ts +28 -0
  30. package/dist/memory-setup.js +323 -0
  31. package/dist/memory-setup.js.map +1 -0
  32. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +16 -4
  33. package/node_modules/@dougharness/flow/agents/planner.md +2 -2
  34. package/node_modules/@dougharness/flow/agents/researcher.md +2 -2
  35. package/node_modules/@dougharness/flow/hooks/hooks.json +16 -0
  36. package/node_modules/@dougharness/flow/lib/board.mjs +154 -12
  37. package/node_modules/@dougharness/flow/lib/code-index.mjs +18 -3
  38. package/node_modules/@dougharness/flow/lib/context.mjs +57 -0
  39. package/node_modules/@dougharness/flow/lib/cost.mjs +129 -4
  40. package/node_modules/@dougharness/flow/lib/embeddings.mjs +185 -23
  41. package/node_modules/@dougharness/flow/lib/land.mjs +69 -10
  42. package/node_modules/@dougharness/flow/lib/learn.mjs +4 -4
  43. package/node_modules/@dougharness/flow/lib/memory.mjs +326 -35
  44. package/node_modules/@dougharness/flow/lib/plan.mjs +9 -0
  45. package/node_modules/@dougharness/flow/package.json +2 -1
  46. package/node_modules/@dougharness/flow/scripts/board.mjs +60 -20
  47. package/node_modules/@dougharness/flow/scripts/context.mjs +48 -0
  48. package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
  49. package/node_modules/@dougharness/flow/scripts/memory.mjs +190 -34
  50. package/node_modules/@dougharness/flow/scripts/plan.mjs +32 -64
  51. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
  52. package/node_modules/@dougharness/flow/skills/{core-next → doug-hand}/SKILL.md +31 -18
  53. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  54. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +44 -10
  55. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +5 -5
  56. package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
  57. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +8 -9
  58. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
  59. package/node_modules/@dougharness/flow/workflows/doug-implement.js +119 -72
  60. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  61. package/node_modules/@dougharness/gates/hooks/hooks.json +11 -0
  62. package/node_modules/@dougharness/gates/lib/baseline.mjs +166 -1
  63. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +58 -1
  64. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +1 -1
  65. package/node_modules/@dougharness/gates/lib/config.mjs +20 -0
  66. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +95 -0
  67. package/node_modules/@dougharness/gates/lib/evidence.mjs +2 -0
  68. package/node_modules/@dougharness/gates/lib/run.mjs +4 -0
  69. package/node_modules/@dougharness/gates/lib/test-totals.mjs +40 -7
  70. package/node_modules/@dougharness/gates/lib/trace.mjs +24 -7
  71. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +225 -0
  72. package/node_modules/@dougharness/gates/package.json +1 -1
  73. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +133 -5
  74. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +551 -113
  75. package/node_modules/@dougharness/gates/scripts/trace.mjs +5 -1
  76. package/node_modules/@dougharness/gates/scripts/turn-start.mjs +17 -0
  77. package/node_modules/@dougharness/gates/scripts/verify.mjs +146 -0
  78. package/package.json +3 -3
  79. package/templates/board-app.js +2 -2
  80. package/node_modules/@dougharness/flow/lib/rehearse.mjs +0 -825
  81. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +0 -129
  82. package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +0 -85
@@ -8,6 +8,12 @@
8
8
  // checkpoint commit or tag (lib/checkpoint.mjs). Honors stop_hook_active and a per-session block
9
9
  // cap so it can never loop forever.
10
10
  //
11
+ // card stop-gate-runs-only-on-code-turns: a main Stop verifies only when the finishing turn changed code: it skips when
12
+ // the tree still equals the UserPromptSubmit turn-start fingerprint (scripts/turn-start.mjs) and no main-agent Edit was
13
+ // traced since, noting unverified changes left by other agents; a missing record verifies as today. When only
14
+ // preCommit.noTestPaths paths moved, the protected-path, proposal-path and plan-scope scans still run and only the
15
+ // verification commands are skipped.
16
+ //
11
17
  // card stop-gate-read-only-subagent-skip: a SubagentStop whose own agent made no change skips the
12
18
  // verification commands (step 3) and the evidence check (step 3b) entirely, and never takes or waits for
13
19
  // gate.lock — the protected-path, proposal-path, and scope scans still run. The run trace, when it can vouch
@@ -21,7 +27,8 @@
21
27
  // session in flight defers instead of verifying: it still runs the cheap tree-state scans (protected paths,
22
28
  // proposal paths, plan scope — steps 1, 1b, 2) and blocks on a real hit exactly as before, but skips the
23
29
  // 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
30
+ // returns one systemMessage naming the agents still running (card stop-gate-defer-read-only-quiet: only agents that can change the tree count, and a repeat deferral is silent; the full verification then follows at the first
31
+ // Stop after they stop, except that a read-only lead turn skips it: card stop-gate-runs-only-on-code-turns). "In flight" (subagentsInFlight below) is read off
25
32
  // this session's run trace: an agent has stopped when some SubagentStop line for it after its last
26
33
  // SubagentStart carries `decision: "allow"` (rule A), or when more of those lines carry no `decision` field at
27
34
  // all than carry `decision: "block"` (rule B) — a decision-less line is the trace hook's own SubagentStop line
@@ -66,9 +73,10 @@
66
73
  // Fails closed exactly like subagentsInFlight: with trace.enabled false, or the trace file missing or
67
74
  // unreadable, there is no evidence either way, so the gate runs as today.
68
75
 
69
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, unlinkSync, openSync, closeSync, readSync } from "node:fs";
70
- import { execFileSync } from "node:child_process";
71
- import { join } from "node:path";
76
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync, unlinkSync, openSync, closeSync, readSync } from "node:fs";
77
+ import { execFileSync, spawnSync } from "node:child_process";
78
+ import { fileURLToPath } from "node:url";
79
+ import { join, dirname, isAbsolute, relative, resolve, sep } from "node:path";
72
80
  import { runHook, allow, blockStop, systemMessage } from "../lib/io.mjs";
73
81
  import { loadConfig, projectDir } from "../lib/config.mjs";
74
82
  import { loadState, saveState } from "../lib/state.mjs";
@@ -77,13 +85,15 @@ import { runCommand, failureSummary } from "../lib/run.mjs";
77
85
  import { loadScopePlan, scopeViolations, describeViolations, PLAN_RELPATH } from "../lib/scope.mjs";
78
86
  import { checkpoint, describeCheckpoint } from "../lib/checkpoint.mjs";
79
87
  import { verificationEvidence, describeMissingEvidence } from "../lib/evidence.mjs";
80
- import { changedFiles, captureBaseline, sinceBaseline, headAt, committedSince, walkProposalPathFiles, fileIdentity } from "../lib/baseline.mjs";
88
+ import { changedFiles, captureBaseline, sinceBaseline, headAt, committedSince, walkProposalPathFiles, fileIdentity, treeFingerprint, sameTree, readAgentStart, readTurnStart, turnTreeStatus, onlyNoTestPathsChanged, movedSinceVerified } from "../lib/baseline.mjs";
81
89
  import { tracePath, readTrace, traceLine, appendTrace, agentLabel } from "../lib/trace.mjs";
82
90
  import { isAppliedContent } from "../lib/proposals.mjs";
83
91
  import { gitHead, mergeSnapshot, readDecisions, readPlanSummary, snapshotSection } from "../lib/anchor.mjs";
84
92
  import { contextReading, recordAgentContext } from "../lib/agent-context.mjs";
85
- import { parseTotals, validTotals, compareTotals, deltaTotals, waiverCovers } from "../lib/test-totals.mjs";
86
- import { failingTestFilesFrom, classifyFinalBlock, appendBlockLine } from "../lib/block-ledger.mjs";
93
+ import { parseTotals, readSummaries, sumTotals, compareTotals, deltaTotals, waiverCovers } from "../lib/test-totals.mjs";
94
+ import { failingTestFilesFrom, nonTestTscPathsFrom, classifyFinalBlock, appendBlockLine } from "../lib/block-ledger.mjs";
95
+ import { docsGateDecision } from "../lib/docs-gate.mjs";
96
+ import { resolveCommands, computeTree, readRecord, writeRecord as writeTreeRecord, removeRecord as removeTreeRecord, creditable } from "../lib/verified-tree.mjs";
87
97
 
88
98
  // Only the last this many characters of last_assistant_message are ever scanned for a partial claim
89
99
  // (review, scan cost): a structured result lives at the end of a worker's message, and the balanced-span
@@ -92,6 +102,11 @@ import { failingTestFilesFrom, classifyFinalBlock, appendBlockLine } from "../li
92
102
  // that large. Bounding the input bounds the cost regardless of how long the message is.
93
103
  const PARTIAL_SCAN_TAIL_CHARS = 32 * 1024;
94
104
 
105
+ // Card ci-n4b-signal-rerun: a shell that forks reports a child's death by signal as exit 128 + signal number
106
+ // (POSIX shell convention, signals 1..64), so a status in this range is load-suspect like a signal kill.
107
+ const SIGNAL_EXIT_MIN = 129;
108
+ const SIGNAL_EXIT_MAX = 192;
109
+
95
110
  // Every balanced {...} span that starts at some "{" in `text`, matched independently from that position
96
111
  // (review MAJOR 1): a stray unmatched "{" earlier in the prose, or an odd number of quote characters
97
112
  // before it, only ever breaks a scan that tracks nesting from the start of the whole string. Starting a
@@ -297,6 +312,15 @@ function stripLeadingDotSlash(f) {
297
312
  // otherwise `{ covered: false, reasonLine }` — one line, no newline, saying why.
298
313
  function redByDesignCoverage(claimFiles, commandFailures, changed, dir) {
299
314
  const changedSet = new Set(Array.isArray(changed) ? changed : []);
315
+ for (const cf of commandFailures) {
316
+ const bad = nonTestTscPathsFrom(cf.output, dir);
317
+ if (bad.length > 0) {
318
+ return {
319
+ covered: false,
320
+ reasonLine: `The tests_red_by_design claim does not cover this: \`${cf.command}\` reported a type error in ${bad.join(", ")}, which is not a test file.`,
321
+ };
322
+ }
323
+ }
300
324
  const namedByCommand = commandFailures.map((cf) => ({ command: cf.command, files: failingTestFilesFrom(cf.output, dir) }));
301
325
  const noFile = namedByCommand.filter((c) => c.files.length === 0);
302
326
  if (noFile.length > 0) {
@@ -387,6 +411,50 @@ const CHANGE_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
387
411
  // succeeded, so any Pre or PostToolUse line naming one counts as a change whatever its ok.
388
412
  const SHELL_TOOLS = new Set(["Bash", "Monitor"]);
389
413
 
414
+ // The change rule shared by agentMadeNoChanges (at an agent's stop) and the in-flight filter (card
415
+ // stop-gate-defer-read-only-quiet, `countingInFlight` below): true when any line of agent `id` is a Pre or
416
+ // PostToolUse line whose tool is in SHELL_TOOLS (whatever ok), or a PostToolUse line whose tool is in
417
+ // CHANGE_TOOLS and whose ok is not exactly false.
418
+ function traceShowsChange(lines, id) {
419
+ return lines
420
+ .filter((l) => l.agent === id)
421
+ .some((l) => {
422
+ if (SHELL_TOOLS.has(l.tool)) return true;
423
+ return l.event === "PostToolUse" && CHANGE_TOOLS.has(l.tool) && l.ok !== false;
424
+ });
425
+ }
426
+
427
+ // Agent types that write by design (card stop-gate-defer-read-only-quiet): one of these in flight defers the
428
+ // main Stop even when its trace so far shows no change (a coder that has only read so far still has a
429
+ // tester's red tests on disk). Exact, case-sensitive match.
430
+ const WRITING_AGENT_TYPES = new Set(["tester", "coder", "implementer", "general-purpose", "doug-flow:implementer", "doug-flow:lead", "claude"]);
431
+
432
+ // The in-flight agents that count toward the deferral (card stop-gate-defer-read-only-quiet): a type in
433
+ // READ_ONLY_AGENT_TYPES never counts; a type in WRITING_AGENT_TYPES always counts; any other type counts only
434
+ // when its trace so far shows a change (traceShowsChange). Never throws: any failure keeps the agent (the
435
+ // conservative answer, as today's deferral).
436
+ function countingInFlight(dir, cfg, sessionId, inFlight) {
437
+ try {
438
+ let lines = null;
439
+ try {
440
+ lines = cfg.trace && cfg.trace.enabled !== false ? readTrace(tracePath(dir, sessionId)) : null;
441
+ } catch {
442
+ lines = null;
443
+ }
444
+ return inFlight.filter((a) => {
445
+ try {
446
+ if (READ_ONLY_AGENT_TYPES.has(a.type)) return false;
447
+ if (WRITING_AGENT_TYPES.has(a.type)) return true;
448
+ return !lines || traceShowsChange(lines, a.id);
449
+ } catch {
450
+ return true;
451
+ }
452
+ });
453
+ } catch {
454
+ return inFlight;
455
+ }
456
+ }
457
+
390
458
  // True when this SubagentStop's own agent made no change during its life (card
391
459
  // stop-gate-read-only-subagent-skip), so the caller can skip the verification commands and evidence check for
392
460
  // it. Never throws (a hook reader must never throw): any failure here reads as false, the conservative answer
@@ -408,12 +476,7 @@ function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
408
476
  const lines = readTrace(file);
409
477
  const started = lines.some((l) => l.event === "SubagentStart" && l.agent === subagent.id);
410
478
  if (started) {
411
- const mine = lines.filter((l) => l.agent === subagent.id);
412
- const changed = mine.some((l) => {
413
- if (SHELL_TOOLS.has(l.tool)) return true;
414
- return l.event === "PostToolUse" && CHANGE_TOOLS.has(l.tool) && l.ok !== false;
415
- });
416
- return !changed;
479
+ return !traceShowsChange(lines, subagent.id);
417
480
  }
418
481
  }
419
482
  }
@@ -423,6 +486,161 @@ function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
423
486
  }
424
487
  }
425
488
 
489
+ // card gate-skip-readonly-researchers: true when this SubagentStop's agent changed nothing in the working tree
490
+ // since it started: the trace hook recorded a start fingerprint on SubagentStart (lib/baseline.mjs
491
+ // readAgentStart) and it equals the current one (same HEAD, same dirty set, same identities), and the agent's
492
+ // final message carries no tests_red_by_design claim, and its trace shows no successful Edit-type call. Catches read-only agents that run Bash, which
493
+ // agentMadeNoChanges counts as a change. No record, no git, any failure -> false (run the gate).
494
+ function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFingerprint) {
495
+ try {
496
+ if (!subagent || !currentFingerprint) return false;
497
+ if (redByDesignClaim(input.last_assistant_message) ?? redByDesignClaim(lastHandbackMessage(input.agent_transcript_path))) return false;
498
+ // An agent whose own trace shows a successful Edit/Write-type call did change something (an edit that was
499
+ // reverted, or a no-op, leaves the tree equal): never skip it on the fingerprint alone.
500
+ if (cfg.trace && cfg.trace.enabled !== false) {
501
+ const file = tracePath(dir, sessionId);
502
+ if (existsSync(file) && readTrace(file).some((l) => l.agent === subagent.id && l.event === "PostToolUse" && CHANGE_TOOLS.has(l.tool) && l.ok !== false)) return false;
503
+ }
504
+ const start = readAgentStart(dir, sessionId, subagent.id);
505
+ return sameTree(start, currentFingerprint);
506
+ } catch {
507
+ return false;
508
+ }
509
+ }
510
+
511
+ // card worktree-agent-gate, card worktree-gate-mixed-tree-agent: the trees a SubagentStop verifies, in
512
+ // first-touched order. A worktree-isolated subagent edits under <dir>/.claude/worktrees/<wt>/ (input.cwd inside
513
+ // it, or its own successful Edit-type trace lines carry absolute paths there); a path inside <dir> but outside
514
+ // .claude/worktrees names the main checkout; any other path names nothing. The set is the cwd's worktree plus
515
+ // every tree its trace paths name (a worktree that no longer exists as a directory is skipped). Empty, a main
516
+ // Stop, or any failure resolves to [dir]. Only the verification target varies per tree: state, trace,
517
+ // gate.lock, the ledger, and evidence stay under `dir`. Never throws.
518
+ function verifyDirs(dir, cfg, sessionId, input, subagent) {
519
+ try {
520
+ if (!subagent) return [dir];
521
+ const base = join(dir, ".claude/worktrees");
522
+ const wtName = (p) => {
523
+ if (typeof p !== "string" || !isAbsolute(p)) return null;
524
+ const rel = relative(base, resolve(p));
525
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) return null;
526
+ return rel.split(sep)[0] || null;
527
+ };
528
+ const inMain = (p) => {
529
+ if (typeof p !== "string" || !isAbsolute(p)) return false;
530
+ const rel = relative(dir, resolve(p));
531
+ return !!rel && !rel.startsWith("..") && !isAbsolute(rel);
532
+ };
533
+ const trees = [];
534
+ const add = (t) => {
535
+ if (!trees.includes(t)) trees.push(t);
536
+ };
537
+ const addWt = (name) => {
538
+ const wt = join(base, name);
539
+ try {
540
+ if (statSync(wt).isDirectory()) add(wt);
541
+ } catch {
542
+ // a worktree that is gone adds nothing
543
+ }
544
+ };
545
+ const cwdName = wtName(input && input.cwd);
546
+ if (cwdName) addWt(cwdName);
547
+ if (cfg.trace && cfg.trace.enabled !== false) {
548
+ const file = tracePath(dir, sessionId);
549
+ if (existsSync(file)) {
550
+ for (const l of readTrace(file)) {
551
+ if (l.agent !== subagent.id || l.event !== "PostToolUse" || !CHANGE_TOOLS.has(l.tool) || l.ok === false) continue;
552
+ const name = wtName(l.detail);
553
+ if (name) addWt(name);
554
+ else if (inMain(l.detail)) add(dir);
555
+ }
556
+ }
557
+ }
558
+ return trees.length ? trees : [dir];
559
+ } catch {
560
+ return [dir];
561
+ }
562
+ }
563
+
564
+ // card worktree-gate-mixed-tree-agent: the single verification target, for a gate process that was handed one
565
+ // (DOUG_STOP_GATE_TREE, set by runPerTree below). Honoured only for a SubagentStop and only when it is `dir`
566
+ // itself or an existing directory directly under <dir>/.claude/worktrees (realpaths compared, so a symlink
567
+ // cannot escape); anything else is ignored as if unset. Never throws.
568
+ function forcedTree(dir, subagent) {
569
+ try {
570
+ const t = process.env.DOUG_STOP_GATE_TREE;
571
+ if (!subagent || !t || !statSync(t).isDirectory()) return null;
572
+ const real = realpathSync(t);
573
+ if (real === realpathSync(dir)) return t;
574
+ return dirname(real) === realpathSync(join(dir, ".claude/worktrees")) ? t : null;
575
+ } catch {
576
+ return null;
577
+ }
578
+ }
579
+
580
+ // A child gate process (handed a valid tree and the child flag) leaves SubagentStop decision lines to its parent.
581
+ function isTreeChild(dir, subagent) {
582
+ return !!forcedTree(dir, subagent) && process.env.DOUG_STOP_GATE_CHILD === "1";
583
+ }
584
+
585
+ // card worktree-gate-mixed-tree-agent: a SubagentStop whose agent touched several trees. Every exit of the gate
586
+ // is process.exit, so each tree's verification runs as this same script in a child process (DOUG_STOP_GATE_TREE,
587
+ // the parent's deadline in DOUG_STOP_GATE_DEADLINE, DOUG_STOP_GATE_CHILD so the child writes no decision line).
588
+ // The first child that blocks decides (its reason prefixed with the failing tree). A child that gives no verdict
589
+ // (killed, timed out, non-zero exit, unparseable output) is a block naming the tree, never an allow; its process
590
+ // group is killed. `record` writes the one combined decision line.
591
+ function runPerTree(input, trees, dir, deadline, record) {
592
+ const labelOf = (tree) => {
593
+ const rel = relative(dir, tree) || ".";
594
+ return rel === "." ? "the main checkout" : rel;
595
+ };
596
+ const fail = (tree, why) => {
597
+ record("block");
598
+ return blockStop(`[doug] Verification failed in ${labelOf(tree)} (${tree}):\n${why}`);
599
+ };
600
+ let last = "";
601
+ for (const tree of trees) {
602
+ let r = null;
603
+ try {
604
+ r = spawnSync(process.execPath, [fileURLToPath(import.meta.url)], {
605
+ input: JSON.stringify(input),
606
+ env: { ...process.env, DOUG_STOP_GATE_TREE: tree, DOUG_STOP_GATE_DEADLINE: String(deadline), DOUG_STOP_GATE_CHILD: "1" },
607
+ encoding: "utf8",
608
+ stdio: ["pipe", "pipe", "inherit"],
609
+ detached: true,
610
+ timeout: Math.max(1000, deadline - Date.now()) + 2000,
611
+ maxBuffer: 16 * 1024 * 1024,
612
+ });
613
+ } catch (e) {
614
+ return fail(tree, `the verification process could not start (${e && e.message ? e.message : e}).`);
615
+ }
616
+ if (r.error || r.signal || r.status !== 0) {
617
+ if (r.pid) {
618
+ try {
619
+ process.kill(-r.pid, "SIGKILL");
620
+ } catch {
621
+ // the group is already gone
622
+ }
623
+ }
624
+ const why = r.error ? String(r.error.code || r.error.message) : r.signal ? `killed by ${r.signal}` : `exit status ${r.status}`;
625
+ return fail(tree, `the verification process gave no verdict (${why}).`);
626
+ }
627
+ const out = typeof r.stdout === "string" ? r.stdout.trim() : "";
628
+ let parsed = null;
629
+ if (out) {
630
+ try {
631
+ parsed = JSON.parse(out);
632
+ } catch {
633
+ return fail(tree, "the verification process gave no verdict (unparseable output).");
634
+ }
635
+ }
636
+ if (parsed && parsed.decision === "block") return fail(tree, parsed.reason);
637
+ if (out) last = out;
638
+ }
639
+ record("allow");
640
+ if (last) process.stdout.write(last + "\n");
641
+ return allow();
642
+ }
643
+
426
644
  // card stop-gate-skips-orphan-subagent-stops: true when a SubagentStop's own event carries none of the three
427
645
  // signals of a real subagent of this session (docs/research/gate-output-names-failures.md Q1: Claude Code
428
646
  // fires SubagentStop for agents that never had a SubagentStart, carry no agent_type, and have no readable
@@ -544,7 +762,7 @@ function waiverSources(dir, baselineHead) {
544
762
  }
545
763
 
546
764
  // The count check for the commands that ran green by exit code. Returns { problems, parsed }: `problems` are
547
- // block reasons, `parsed` the per-command totals of every run with a clean single summary (for the last-green
765
+ // block reasons, `parsed` the per-command summary lists of every run that was counted (for the last-green
548
766
  // record). A missing or unparseable summary, or no baseline, is a trace line and no check.
549
767
  function countCheck(dir, cfg, state, input, ran) {
550
768
  const problems = [];
@@ -565,24 +783,28 @@ function countCheck(dir, cfg, state, input, ran) {
565
783
  for (const { command, stdout, result } of ran) {
566
784
  try {
567
785
  const p = result !== undefined ? result : parseTotals(stdout);
568
- if (p.status === "conflict") {
569
- problems.push(`\`${command}\` printed conflicting test summaries (${p.summaries.map((x) => `${x.total} total, ${x.skipped} skipped, ${x.todo} todo`).join(" vs ")}); the test count cannot be trusted. A waiver does not cover this.`);
570
- continue;
571
- }
572
- if (p.status !== "ok") {
786
+ const summaries = p.status === "ok" ? readSummaries(p) : null;
787
+ if (!summaries) {
573
788
  note(`count check did not run for \`${command}\`: no test summary in its output`);
574
789
  continue;
575
790
  }
576
- parsed[command] = p.totals;
577
791
  const all = state.testTotals && typeof state.testTotals === "object" ? state.testTotals : null;
578
- const base = all ? validTotals(all[command]) : null;
579
- if (!base) {
580
- note(`count check did not run for \`${command}\`: no baseline totals for it`);
792
+ const baseList = all ? readSummaries(all[command]) : null;
793
+ if (!baseList) {
794
+ parsed[command] = summaries;
795
+ note(`count check did not run for \`${command}\`: no baseline totals for it (${summaries.length} test summar${summaries.length === 1 ? "y" : "ies"} printed: ${summaries.map((s) => `total ${s.total}, skipped ${s.skipped}, todo ${s.todo}`).join("; ")})`);
796
+ continue;
797
+ }
798
+ if (baseList.length !== summaries.length) {
799
+ problems.push(`\`${command}\` printed ${summaries.length} test summar${summaries.length === 1 ? "y" : "ies"}; the baseline has ${baseList.length}. The test count cannot be trusted. A waiver does not cover this; take a new baseline if a package was added or removed.`);
581
800
  continue;
582
801
  }
583
- const found = compareTotals(base, p.totals);
802
+ parsed[command] = summaries;
803
+ const base = sumTotals(baseList);
804
+ const now = sumTotals(summaries);
805
+ const found = compareTotals(base, now);
584
806
  if (found.length === 0) continue;
585
- const delta = deltaTotals(base, p.totals);
807
+ const delta = deltaTotals(base, now);
586
808
  if (waiverCovers(waiverSources(dir, state.baselineHead), delta)) continue;
587
809
  const lines = found.map((f) => `${f.kind} ${f.kind === "total" ? "dropped" : "rose"} from ${f.baseline} (baseline) to ${f.now} (now)`);
588
810
  problems.push(
@@ -613,16 +835,6 @@ function writeLastGreen(dir, parsed) {
613
835
  }
614
836
  }
615
837
 
616
- function resolveCommands(cfg) {
617
- const out = [];
618
- for (const entry of cfg.stopGate.commands || []) {
619
- if (typeof entry !== "string") continue;
620
- const named = cfg.commands && cfg.commands[entry];
621
- out.push({ name: entry, command: typeof named === "string" ? named : entry });
622
- }
623
- return out;
624
- }
625
-
626
838
  // card stop-gate-credits-pre-commit: `git rev-parse HEAD^{tree}`, the tree sha of the current commit — what
627
839
  // the pre-commit hook's own record (.doug/.state/pre-commit/last-run.json) compares its `tree` field against.
628
840
  // Null on any failure (no git, no HEAD yet, ...): a reader must never throw.
@@ -639,17 +851,36 @@ function headTreeSha(dir) {
639
851
  // card ratchet-pre-commit-credit: a recorded parseTotals result, normalised, or null when it is not readable.
640
852
  function readableRecordedTotals(t) {
641
853
  try {
642
- if (!t || typeof t !== "object") return null;
643
- if (t.status === "ok") {
644
- const totals = validTotals(t.totals);
645
- return totals ? { status: "ok", totals } : null;
646
- }
647
- if (t.status === "conflict") {
648
- if (!Array.isArray(t.summaries) || t.summaries.length === 0) return null;
649
- const summaries = t.summaries.map(validTotals);
650
- return summaries.every((x) => x) ? { status: "conflict", summaries } : null;
854
+ if (t && typeof t === "object" && t.status === "none") return { status: "none" };
855
+ const summaries = readSummaries(t);
856
+ return summaries ? { status: "ok", summaries, totals: sumTotals(summaries) } : null;
857
+ } catch {
858
+ return null;
859
+ }
860
+ }
861
+
862
+ // card verified-tree-record: a verified-tree record (lib/verified-tree.mjs) of `tree` (the working tree now), same
863
+ // Node and command set, credits the commands instead of re-running them. Same return shape as preCommitCredit:
864
+ // one `{ name, command, ok: true, credited: "verified-tree", recorded? }` per resolveCommands entry in config
865
+ // order. A command with a baseline in state.testTotals and no readable recorded totals returns null (the commands
866
+ // run), the same rule as preCommitCredit. Never throws: any failure is null, and null runs the commands.
867
+ function treeCredit(dir, cfg, state, tree) {
868
+ try {
869
+ if (!tree) return null;
870
+ const record = readRecord(dir);
871
+ if (!record) return null;
872
+ const configured = resolveCommands(cfg);
873
+ if (configured.length === 0) return null;
874
+ if (!creditable(dir, cfg, record, tree, configured.map((c) => c.command)).ok) return null;
875
+ const all = state && state.testTotals && typeof state.testTotals === "object" ? state.testTotals : null;
876
+ const out = [];
877
+ for (const { name, command } of configured) {
878
+ const recorded = readableRecordedTotals(record.totals[command]);
879
+ const hasBaseline = all ? readSummaries(all[command]) !== null : false;
880
+ if (hasBaseline && (!recorded || recorded.status === "none")) return null;
881
+ out.push({ name, command, ok: true, credited: "verified-tree", ...(recorded ? { recorded } : {}) });
651
882
  }
652
- return t.status === "none" ? { status: "none" } : null;
883
+ return out;
653
884
  } catch {
654
885
  return null;
655
886
  }
@@ -657,7 +888,7 @@ function readableRecordedTotals(t) {
657
888
 
658
889
  // card stop-gate-credits-pre-commit: the pre-commit hook (.githooks/pre-commit) writes a machine-readable
659
890
  // record of its own run to .doug/.state/pre-commit/last-run.json (version, tree, ok, at, commands: [{ name,
660
- // command, exit, durationMs, totals? }], totals being the hook's parseTotals result for that command; a command with a baseline in state.testTotals is credited only if its totals are readable ok or conflict, else the commands run). When the working tree has no changes beyond HEAD (nothing left in `changed`
891
+ // command, exit, durationMs, totals? }], totals being the hook's parseTotals result for that command; a command with a baseline in state.testTotals is credited only if its totals are readable (a list of summaries, or an old ok or conflict shape), else the commands run). When the working tree has no changes beyond HEAD (nothing left in `changed`
661
892
  // once stopGate.ignoreChangedPaths paths are removed) and that record is a passing run, against HEAD's own
662
893
  // tree, of exactly today's configured commands in order, the Stop gate credits it instead of re-running the
663
894
  // same commands a second time — returning `{ name, command, ok: true, credited: "pre-commit" }` per command,
@@ -689,14 +920,14 @@ function preCommitCredit(dir, cfg, changed, state) {
689
920
  if (!record.commands[i] || record.commands[i].command !== configured[i].command) return null;
690
921
  }
691
922
  // card ratchet-pre-commit-credit: a command with a baseline in state.testTotals is credited only when the
692
- // record carries readable totals for it (ok with valid counts, or a conflict with valid summaries);
923
+ // record carries readable totals for it (a list of valid summaries, or an old ok or conflict shape);
693
924
  // otherwise null and the commands really run. `recorded` is the parseTotals-shaped result for the count check.
694
925
  const all = state && state.testTotals && typeof state.testTotals === "object" ? state.testTotals : null;
695
926
  const out = [];
696
927
  for (let i = 0; i < configured.length; i++) {
697
928
  const { name, command } = configured[i];
698
929
  const recorded = readableRecordedTotals(record.commands[i].totals);
699
- const hasBaseline = all ? validTotals(all[command]) !== null : false;
930
+ const hasBaseline = all ? readSummaries(all[command]) !== null : false;
700
931
  if (hasBaseline && (!recorded || recorded.status === "none")) return null;
701
932
  out.push({ name, command, ok: true, credited: "pre-commit", ...(recorded ? { recorded } : {}) });
702
933
  }
@@ -706,39 +937,6 @@ function preCommitCredit(dir, cfg, changed, state) {
706
937
  }
707
938
  }
708
939
 
709
- // The verified-tree fingerprint (card stop-gate-process-storm, goal 2): the HEAD sha plus the content
710
- // identity of every dirty path and every proposal-path file on disk, at the moment it is computed. Null
711
- // when `changed` is not the array shape changedFiles returns (no git, or git unavailable) — there is
712
- // nothing to fingerprint reliably, so the tree can never be judged "unchanged" on this path.
713
- function treeFingerprint(dir, cfg, changed) {
714
- if (!Array.isArray(changed)) return null;
715
- const walked = walkProposalPathFiles(dir, cfg.proposalPaths);
716
- const paths = [...new Set([...changed, ...walked])];
717
- // `at` (state.mjs's comment for state.verified) is when this fingerprint was taken; sameTree below never
718
- // reads it, so recomputing it on the after-wait path (a fresh Date.now()) never affects equality.
719
- return { head: headAt(dir), tree: captureBaseline(dir, paths), at: Date.now() };
720
- }
721
-
722
- // Whether two treeFingerprint results describe the same tree: both present, the same HEAD, the same set of
723
- // paths, and an identical identity for each. Never throws (a hook reader must never throw): a malformed
724
- // fingerprint — e.g. a hand-edited state.verified — just compares unequal rather than blocking the gate.
725
- function sameTree(a, b) {
726
- try {
727
- if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
728
- if (a.head !== b.head) return false;
729
- const at = a.tree && typeof a.tree === "object" ? a.tree : null;
730
- const bt = b.tree && typeof b.tree === "object" ? b.tree : null;
731
- if (!at || !bt) return false;
732
- const ak = Object.keys(at).sort();
733
- const bk = Object.keys(bt).sort();
734
- if (ak.length !== bk.length) return false;
735
- for (let i = 0; i < ak.length; i++) if (ak[i] !== bk[i]) return false;
736
- return ak.every((k) => at[k] === bt[k]);
737
- } catch {
738
- return false;
739
- }
740
- }
741
-
742
940
  // card stop-gate-process-storm, case 2 (second tester pass, reviewer-found gap): a verified-unchanged skip
743
941
  // is a green gate in every way that matters, not a bare allow — it resets the same counters an ordinary
744
942
  // green gate resets, and on the main Stop it still surfaces the context-window notice, exactly like the
@@ -754,7 +952,7 @@ function verifiedUnchangedAllow(dir, cfg, state, input, subagent, capNote) {
754
952
  // exits (F4); the `decision` a SubagentStop's own line now always carries folds in here, alongside its
755
953
  // `reason`, rather than a second line being written for the same event. Only for a SubagentStop (subagent
756
954
  // set) — a main Stop's own trace line here is not an agent-stop decision.
757
- if (cfg.trace && cfg.trace.enabled !== false) {
955
+ if (cfg.trace && cfg.trace.enabled !== false && !isTreeChild(dir, subagent)) {
758
956
  appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: input.hook_event_name, reason: "verified-unchanged", ...(subagent ? { decision: "allow" } : {}) });
759
957
  }
760
958
  if (subagent) {
@@ -838,14 +1036,17 @@ function resolveHookTimeoutSec(cfg) {
838
1036
  // discarded), less a margin so the gate's own block reaches Claude Code before that happens. gateStart
839
1037
  // is this process's own start time, not the moment this function runs, so the budget covers everything
840
1038
  // this invocation has done so far (config/state loads, earlier checks), not just what follows.
841
- function gateBudget(cfg) {
1039
+ function gateBudget(cfg, inherited) {
842
1040
  const hookTimeoutSec = resolveHookTimeoutSec(cfg);
843
1041
  const hookTimeoutMs = hookTimeoutSec * 1000;
844
1042
  const marginMs = Math.min(30000, hookTimeoutMs / 10);
845
1043
  // process.uptime() is fractional seconds; runCommand's timeoutMs must be an unsigned integer (Node's
846
1044
  // spawnSync throws otherwise), so every value derived from this deadline is rounded down.
847
1045
  const gateStart = Date.now() - Math.floor(process.uptime() * 1000);
848
- return { hookTimeoutSec, hookTimeoutMs, deadline: Math.floor(gateStart + hookTimeoutMs - marginMs) };
1046
+ const own = Math.floor(gateStart + hookTimeoutMs - marginMs);
1047
+ // card worktree-gate-mixed-tree-agent: a per-tree child never outlives its parent's deadline.
1048
+ const deadline = typeof inherited === "number" && Number.isFinite(inherited) && inherited <= own ? inherited : own;
1049
+ return { hookTimeoutSec, hookTimeoutMs, deadline };
849
1050
  }
850
1051
 
851
1052
  // A lock whose numeric `at` is older than the hook-timeout budget is stale regardless of a live pid: no
@@ -884,6 +1085,8 @@ function normalizedDeferrals(state) {
884
1085
  return typeof d === "number" && Number.isFinite(d) && d >= 0 ? Math.floor(d) : 0;
885
1086
  }
886
1087
 
1088
+ const READ_ONLY_LINE = "[doug] Read-only turn: verification skipped; the working tree holds unverified changes from other agents.";
1089
+
887
1090
  runHook("stop-gate", async (input) => {
888
1091
  if (input.stop_hook_active) return allow();
889
1092
  const dir = projectDir(input);
@@ -894,11 +1097,30 @@ runHook("stop-gate", async (input) => {
894
1097
  // main Stops the counter (R1) is tracking — reset right here, after loadState, so every SubagentStop exit
895
1098
  // below that saves state (including the one that reloads state again mid-lock-wait, R6 note there) persists
896
1099
  // the reset.
897
- if (subagent) state.deferrals = 0;
1100
+ if (subagent) {
1101
+ state.deferrals = 0;
1102
+ delete state.lastDeferral;
1103
+ }
898
1104
  if (!subagent) state.turns += 1;
899
1105
  const maxBlocks = cfg.stopGate.maxBlocks ?? 3;
900
1106
  const blocksSoFar = subagent ? (state.agentBlocks && state.agentBlocks[subagent.id]) || 0 : state.stopBlocks;
901
1107
  const who = subagent ? `Subagent ${subagent.type} (${subagent.id})` : null;
1108
+ // card worktree-gate-mixed-tree-agent: an agent that touched several trees is verified in each, one gate process per tree.
1109
+ const forced = forcedTree(dir, subagent);
1110
+ const treeChild = isTreeChild(dir, subagent);
1111
+ if (subagent && !forced) {
1112
+ const trees = verifyDirs(dir, cfg, input.session_id, input, subagent);
1113
+ if (trees.length > 1) {
1114
+ const record = (decision) => {
1115
+ try {
1116
+ if (cfg.trace && cfg.trace.enabled !== false) appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: "SubagentStop", decision, reason: null });
1117
+ } catch {
1118
+ // never block the real exit on a trace failure
1119
+ }
1120
+ };
1121
+ return runPerTree(input, trees, dir, gateBudget(cfg).deadline, record);
1122
+ }
1123
+ }
902
1124
  // D1 (card stop-gate-defers-while-subagent-in-flight): one choke point for the `decision` every SubagentStop
903
1125
  // exit's own trace line now carries, so no exit (present or future) can miss it — except the
904
1126
  // `stop_hook_active` early return above, which runs before `subagent` exists and so writes no decision line
@@ -913,7 +1135,7 @@ runHook("stop-gate", async (input) => {
913
1135
  // tests_red_by_design, its `detail`) into this same line instead of a second one being written. Wrapped so a
914
1136
  // trace failure never blocks the real exit (a hook reader must never throw).
915
1137
  function traceSubagentDecision(decision, traceReason, extra) {
916
- if (!subagent) return;
1138
+ if (!subagent || treeChild) return;
917
1139
  try {
918
1140
  if (!cfg.trace || cfg.trace.enabled === false) return;
919
1141
  appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: "SubagentStop", decision, reason: traceReason ?? null, ...(extra || {}) });
@@ -956,7 +1178,7 @@ runHook("stop-gate", async (input) => {
956
1178
  };
957
1179
  // Card stop-gate-budget-under-hook-timeout: the whole gate (wait plus commands) must fit under the
958
1180
  // platform's own hook timeout. Computed once so the wait loop and the command loop share one clock.
959
- const { hookTimeoutSec, hookTimeoutMs, deadline: budgetDeadline } = gateBudget(cfg);
1181
+ const { hookTimeoutSec, hookTimeoutMs, deadline: budgetDeadline } = gateBudget(cfg, forced ? Number(process.env.DOUG_STOP_GATE_DEADLINE) : NaN);
960
1182
 
961
1183
  // card stop-gate-skips-orphan-subagent-stops: checked before the partial-claim block, the tree scans, the
962
1184
  // deferral logic, gate.lock, skipVerification, and runVerification — an orphan SubagentStop is not a real
@@ -1042,6 +1264,9 @@ runHook("stop-gate", async (input) => {
1042
1264
 
1043
1265
  const edited = state.editedFiles.length > 0;
1044
1266
  const changed = changedFiles(dir);
1267
+ // card worktree-agent-gate: the verification target (the agent's worktree, else `dir`) and its changed paths.
1268
+ const vdir = forced || verifyDirs(dir, cfg, input.session_id, input, subagent)[0];
1269
+ const vchanged = vdir === dir ? changed : changedFiles(vdir);
1045
1270
  // Snapshot whatever was already dirty so it is never mistaken for this session's own work (card
1046
1271
  // stop-gate-waiting-on-subagents). Ideally this happens on SessionStart (scripts/session-start-baseline.mjs),
1047
1272
  // before the session can have dirtied anything itself; this is the fallback for a session where that
@@ -1119,6 +1344,58 @@ runHook("stop-gate", async (input) => {
1119
1344
  (state.baselineSource === "session-start" ? sessionMadeChanges : edited || (Array.isArray(changed) && changed.length > 0)) ||
1120
1345
  (Array.isArray(committed) && committed.length > 0) ||
1121
1346
  walkedProposalHits.length > 0;
1347
+ // card stop-gate-runs-only-on-code-turns: a main Stop whose turn changed no code skips verification. The turn-start
1348
+ // record (scripts/turn-start.mjs, UserPromptSubmit) must equal the current tree once preCommit.noTestPaths paths are
1349
+ // dropped from both (same HEAD, same code paths, same identities), and the trace must show no successful main-agent
1350
+ // Edit-type call since the record (an edit reverted by content leaves the tree equal). Placed here, at the
1351
+ // onlyIfEdited skip and ahead of it, because the protected-path, proposal-path and plan-scope scans below are about
1352
+ // files a turn changed and a read-only turn changed none; a missing, malformed or stale record, no git, or any throw
1353
+ // reads as "cannot tell" and falls through to today's path. Never for a SubagentStop.
1354
+ let readOnlyTurn = null;
1355
+ if (!subagent) {
1356
+ let readOnly = null;
1357
+ try {
1358
+ const start = readTurnStart(dir, input.session_id);
1359
+ const status = turnTreeStatus(cfg, start, treeFingerprint(dir, cfg, changed));
1360
+ if (status && status.same) {
1361
+ let editedThisTurn = false;
1362
+ if (cfg.trace && cfg.trace.enabled !== false) {
1363
+ const file = tracePath(dir, input.session_id);
1364
+ if (existsSync(file)) {
1365
+ const since = typeof start.at === "number" ? start.at : 0;
1366
+ editedThisTurn = readTrace(file).some((l) => !l.agent && l.event === "PostToolUse" && CHANGE_TOOLS.has(l.tool) && l.ok !== false && !(Date.parse(l.t) < since));
1367
+ }
1368
+ }
1369
+ if (!editedThisTurn) readOnly = status;
1370
+ }
1371
+ } catch {
1372
+ readOnly = null;
1373
+ }
1374
+ // Only an UNFILTERED match (nothing at all changed) exits here. When only noTestPaths paths moved (a Bash write
1375
+ // to a board or docs file), the scans below still run, so such a file cannot escape the plan-scope and
1376
+ // proposal-path checks; only the verification commands are skipped (skipReason "read-only-turn").
1377
+ if (readOnly && !readOnly.exact) {
1378
+ readOnlyTurn = readOnly;
1379
+ } else if (readOnly) {
1380
+ // The tree-problem scans (protected, proposal, plan scope) still run on the exact path: a file already
1381
+ // dirty before the record is not this turn's, but it must not be hidden from them. Any problem, or a throw,
1382
+ // falls through to the normal path, which finds and blocks on it.
1383
+ let treeClean = false;
1384
+ try {
1385
+ treeClean = scanTreeProblems().problems.length === 0;
1386
+ } catch {
1387
+ treeClean = false;
1388
+ }
1389
+ if (!treeClean) readOnly = null;
1390
+ }
1391
+ if (readOnly && readOnly.exact) {
1392
+ const notice = contextNotice(dir, cfg, state, input.session_id, "skip");
1393
+ saveState(dir, input.session_id, state);
1394
+ const line = readOnly.dirty ? READ_ONLY_LINE : null;
1395
+ const msg = [line, notice].filter(Boolean).join("\n\n");
1396
+ return msg ? systemMessage(msg) : allowDecided();
1397
+ }
1398
+ }
1122
1399
  if (cfg.stopGate.onlyIfEdited && !anyChange) {
1123
1400
  const notice = subagent ? null : contextNotice(dir, cfg, state, input.session_id, "skip");
1124
1401
  saveState(dir, input.session_id, state);
@@ -1128,7 +1405,7 @@ runHook("stop-gate", async (input) => {
1128
1405
  // card stop-gate-process-storm, case 2: a tree already verified by an earlier green gate this session
1129
1406
  // needs no recheck at all — placed ahead of maxBlocks and every problems check below, since a red gate
1130
1407
  // never records `verified` and there is nothing left here to recheck when nothing has changed since.
1131
- const currentFingerprint = treeFingerprint(dir, cfg, changed);
1408
+ const currentFingerprint = treeFingerprint(vdir, cfg, vchanged);
1132
1409
  if (sameTree(state.verified, currentFingerprint)) {
1133
1410
  return verifiedUnchangedAllow(dir, cfg, state, input, subagent);
1134
1411
  }
@@ -1213,7 +1490,8 @@ runHook("stop-gate", async (input) => {
1213
1490
  // (that block is real and counts as today); only with no such problem does this Stop defer, skipping
1214
1491
  // gate.lock, the verification commands, the evidence check, state.verified, and the blocks.jsonl ledger
1215
1492
  // entirely, and touching neither state.stopBlocks nor state.agentBlocks. The next Stop after the last
1216
- // SubagentStop (subagentsInFlight then returns `[]`) runs the full gate as today. state.lastGate is left
1493
+ // SubagentStop (subagentsInFlight then returns `[]`) runs the full gate as today, unless that lead turn is read-only
1494
+ // (card stop-gate-runs-only-on-code-turns), which skips verification. state.lastGate is left
1217
1495
  // untouched — this Stop verified nothing, so there is nothing honest to record there.
1218
1496
  // R1/R3 (card stop-gate-deferral-cap): state.deferrals counts consecutive deferred main Stops; unbounded,
1219
1497
  // a dead or forged subagent (the module header's "Known limit") would defer forever. deferralCapNote is
@@ -1222,7 +1500,13 @@ runHook("stop-gate", async (input) => {
1222
1500
  // falls through to ends up returning. The cap only ever widens when the full verification runs (R7): it
1223
1501
  // never lets a Stop skip verification that would otherwise have run.
1224
1502
  let deferralCapNote = null;
1225
- const inFlight = subagent ? [] : subagentsInFlight(dir, cfg, input.session_id);
1503
+ // Card stop-gate-defer-read-only-quiet: only agents that can change the tree count (countingInFlight: not a
1504
+ // read-only type, and a non-writing type only once its trace shows a change). With none left this Stop runs
1505
+ // the ordinary path exactly as with no agent in flight. A repeat deferral (same counting set and same tree
1506
+ // fingerprint as the last one, state.lastDeferral) is silent: no systemMessage, but it still counts toward
1507
+ // stopGate.maxDeferrals. state.lastDeferral is cleared wherever state.deferrals resets to 0, so the first
1508
+ // deferral of a streak always speaks.
1509
+ const inFlight = subagent ? [] : countingInFlight(dir, cfg, input.session_id, subagentsInFlight(dir, cfg, input.session_id));
1226
1510
  if (inFlight.length > 0) {
1227
1511
  const { problems: deferProblems } = scanTreeProblems();
1228
1512
  if (deferProblems.length === 0) {
@@ -1230,10 +1514,21 @@ runHook("stop-gate", async (input) => {
1230
1514
  const n = normalizedDeferrals(state) + 1;
1231
1515
  const names = inFlight.map((a) => agentLabel({ agent: a.id, agentType: a.type })).join(", ");
1232
1516
  if (n < maxDeferrals) {
1517
+ const ids = inFlight.map((a) => a.id).sort();
1518
+ const prev = state.lastDeferral;
1519
+ const repeat =
1520
+ !!prev &&
1521
+ typeof prev === "object" &&
1522
+ Array.isArray(prev.ids) &&
1523
+ prev.ids.length === ids.length &&
1524
+ prev.ids.every((id, i) => id === ids[i]) &&
1525
+ sameTree(prev.fingerprint, currentFingerprint);
1233
1526
  state.deferrals = n;
1527
+ state.lastDeferral = { ids, fingerprint: currentFingerprint };
1234
1528
  saveState(dir, input.session_id, state);
1529
+ if (repeat) return allow();
1235
1530
  return systemMessage(
1236
- `[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.`,
1531
+ `[doug] Verification deferred: waits for ${names} to stop; the full gate runs at the first Stop after they stop.`,
1237
1532
  );
1238
1533
  }
1239
1534
  // R3: the cap is hit on this Stop — do not defer again. Reset the counter (R5) and fall through to
@@ -1243,22 +1538,54 @@ runHook("stop-gate", async (input) => {
1243
1538
  // value itself (stopGate.maxDeferrals) is named alongside it so the message stays informative once the
1244
1539
  // deferred count is no longer always the cap.
1245
1540
  state.deferrals = 0;
1541
+ delete state.lastDeferral;
1246
1542
  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.`;
1247
1543
  } else {
1248
1544
  state.deferrals = 0; // R5: a real tree problem — this Stop does not defer
1545
+ delete state.lastDeferral;
1249
1546
  }
1250
1547
  // A real problem, or the cap was just hit: fall through to the ordinary path below, which finds the
1251
1548
  // same problem again inside runVerification and blocks on it exactly as today (deferral only ever
1252
1549
  // widens "nothing to check yet", never what counts as a real problem).
1253
1550
  } else if (!subagent) {
1254
1551
  state.deferrals = 0; // R5: no agent in flight — this Stop runs the gate
1552
+ delete state.lastDeferral;
1255
1553
  }
1256
1554
 
1257
1555
  // card stop-gate-read-only-subagent-skip: computed once, ahead of the gate.lock acquire just below, so a
1258
1556
  // SubagentStop whose own agent made no change never takes or waits for the lock at all — waiting up to
1259
- // timeoutMs on a suite it will never even need is the other half of the 2026-09-16 process storm. Only ever
1260
- // true for a SubagentStop; the main Stop always runs the full gate.
1261
- const skipVerification = subagent ? agentMadeNoChanges(dir, cfg, input.session_id, subagent) : false;
1557
+ // timeoutMs on a suite it will never even need is the other half of the 2026-09-16 process storm. The two
1558
+ // agent reasons are only ever true for a SubagentStop; the main Stop skips only for no-test-paths (below).
1559
+ // card gate-skip-readonly-researchers: a second skip reason, "tree-unchanged", for an agent whose tree
1560
+ // fingerprint equals its SubagentStart one even though its trace shows shell calls.
1561
+ // card stop-gate-no-test-paths: a third reason, for both stops: every path changed since the last green
1562
+ // verification is on preCommit.noTestPaths (onlyNoTestPathsChanged fails closed on anything doubtful).
1563
+ // card gate-docs-skip-unless-named: a fourth reason, "no-test-paths-docs", when every changed path is on the
1564
+ // list or a docs .md that no tracked test names; when some tests name it, `targetedFiles` makes the
1565
+ // verification below run only `test:unit <those files>`. Fail closed to the full gate (docs-gate.mjs).
1566
+ let docsDecision = null;
1567
+ const docsRule = () => {
1568
+ try {
1569
+ if (!resolveCommands(cfg).some((c) => c.name === "test:unit")) return null;
1570
+ const moved = movedSinceVerified(vdir, state.verified, vchanged);
1571
+ return moved && moved.size > 0 ? docsGateDecision(vdir, cfg, moved) : null;
1572
+ } catch {
1573
+ return null;
1574
+ }
1575
+ };
1576
+ const skipReason = subagent && agentMadeNoChanges(dir, cfg, input.session_id, subagent)
1577
+ ? "no-agent-changes"
1578
+ : subagent && treeUnchangedSinceStart(dir, cfg, input.session_id, subagent, input, currentFingerprint)
1579
+ ? "tree-unchanged"
1580
+ : readOnlyTurn
1581
+ ? "read-only-turn"
1582
+ : onlyNoTestPathsChanged(vdir, cfg, state.verified, vchanged)
1583
+ ? "no-test-paths"
1584
+ : (docsDecision = docsRule()) && docsDecision.kind === "skip"
1585
+ ? "no-test-paths-docs"
1586
+ : null;
1587
+ const skipVerification = skipReason !== null;
1588
+ const targetedFiles = !skipVerification && docsDecision && docsDecision.kind === "targeted" ? docsDecision.files : null;
1262
1589
 
1263
1590
  // card stop-gate-no-change-restop: a stop in the same scope on a tree unchanged since a red block whose
1264
1591
  // commands all really ran (state.lastRed, recorded at the final block below) repeats that block without
@@ -1268,6 +1595,7 @@ runHook("stop-gate", async (input) => {
1268
1595
  // unreadable, different, or not plainly a command-failure red degrades to running (fail closed to running).
1269
1596
  if (!skipVerification) {
1270
1597
  let lastRed = null;
1598
+ let redViaNoTestPaths = false; // card stop-gate-board-edit-reuses-red: reused although the tree moved, only on noTestPaths
1271
1599
  try {
1272
1600
  const rec = state.lastRed;
1273
1601
  const scopeNow = subagent ? subagent.id : "main";
@@ -1280,21 +1608,26 @@ runHook("stop-gate", async (input) => {
1280
1608
  typeof rec.summary === "string" &&
1281
1609
  Array.isArray(rec.commands) &&
1282
1610
  rec.commands.every((c) => typeof c === "string") &&
1283
- rec.scope === scopeNow &&
1611
+ // card stop-gate-lead-reuses-subagent-red: a main Stop also takes a subagent's record, but only on the exact
1612
+ // same tree (never via the board-only path below); a SubagentStop still takes only its own scope's.
1613
+ (rec.scope === scopeNow || !subagent) &&
1284
1614
  rec.commands.length === commandsNow.length &&
1285
1615
  rec.commands.every((c, i) => c === commandsNow[i]) &&
1286
1616
  rec.fingerprint &&
1287
1617
  typeof rec.fingerprint === "object" &&
1288
1618
  currentFingerprint &&
1289
- sameTree(rec.fingerprint, currentFingerprint) &&
1619
+ (sameTree(rec.fingerprint, currentFingerprint) || (rec.scope === scopeNow && (redViaNoTestPaths = onlyNoTestPathsChanged(vdir, cfg, rec.fingerprint, vchanged)))) &&
1290
1620
  !(subagent && (redByDesignClaim(input.last_assistant_message) ?? redByDesignClaim(lastHandbackMessage(input.agent_transcript_path)))) &&
1291
- !preCommitCredit(dir, cfg, changed, state)
1621
+ !preCommitCredit(vdir, cfg, vchanged, state) &&
1622
+ !treeCredit(vdir, cfg, state, computeTree(vdir))
1292
1623
  ) {
1293
1624
  lastRed = rec;
1294
1625
  }
1295
1626
  } catch {
1296
1627
  lastRed = null;
1297
1628
  }
1629
+ if (!lastRed) redViaNoTestPaths = false;
1630
+ const crossWho = lastRed && lastRed.scope !== (subagent ? subagent.id : "main") ? (typeof lastRed.who === "string" && lastRed.who ? lastRed.who : `Subagent ${lastRed.scope}`) : null;
1298
1631
  if (lastRed) {
1299
1632
  if (blocksSoFar >= maxBlocks) {
1300
1633
  const alreadyStoodDown = markStoodDown("block-cap");
@@ -1309,12 +1642,19 @@ runHook("stop-gate", async (input) => {
1309
1642
  const blocks = blocksSoFar + 1;
1310
1643
  if (subagent) state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: blocks };
1311
1644
  else state.stopBlocks = blocks;
1312
- state.lastRed = null; // one reuse per record: the next stop on this tree really runs
1645
+ // one reuse per record on the exact same tree; a board-only reuse keeps the record (user decision
1646
+ // 2026-10-05), so consecutive board-only turns keep reusing the red, bounded by maxBlocks.
1647
+ if (!redViaNoTestPaths) state.lastRed = null;
1313
1648
  saveState(dir, input.session_id, state);
1314
1649
  const reason =
1315
1650
  (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. `) +
1316
1651
  `Do not claim the task is done while this fails.\n\n` +
1317
- `Nothing changed since the last block, so the commands were not re-run. The last run's failure:\n\n` +
1652
+ (crossWho
1653
+ ? `Nothing changed since ${crossWho}'s block, so the commands were not re-run. ${crossWho}'s run failed:`
1654
+ : redViaNoTestPaths
1655
+ ? `Only paths on preCommit.noTestPaths changed since the last block, so the commands were not re-run.`
1656
+ : `Nothing changed since the last block, so the commands were not re-run.`) +
1657
+ (crossWho ? `\n\n` : ` The last run's failure:\n\n`) +
1318
1658
  [...treeProblems, lastRed.summary].join("\n\n");
1319
1659
  const reuseClass = typeof lastRed.class === "string" ? lastRed.class : "real";
1320
1660
  const capped = deferralCapNote && !subagent ? `${deferralCapNote}\n\n${reason}` : reason;
@@ -1418,7 +1758,8 @@ runHook("stop-gate", async (input) => {
1418
1758
  // to even get here, so it already passed that check point and always intends 0). Unconditional:
1419
1759
  // every path that reaches here, subagent or not, means 0.
1420
1760
  state.deferrals = 0;
1421
- verifiedCandidate = treeFingerprint(dir, cfg, changed);
1761
+ delete state.lastDeferral;
1762
+ verifiedCandidate = treeFingerprint(vdir, cfg, vchanged);
1422
1763
  if (sameTree(state.verified, verifiedCandidate)) {
1423
1764
  // Review round 1 minor 5: this exit returns straight from verifiedUnchangedAllow, bypassing the
1424
1765
  // *Releasing wrappers (R4) below — release the lock here (a no-op when this gate never took it)
@@ -1504,14 +1845,21 @@ runHook("stop-gate", async (input) => {
1504
1845
  // card stop-gate-credits-pre-commit: a passing pre-commit record for this exact tree and command set
1505
1846
  // stands in for actually running them — see preCommitCredit above. Every other check below (evidence,
1506
1847
  // red-by-design, state.verified, the block ledger) runs exactly as it would after a real run.
1507
- const credited = skipVerification ? null : preCommitCredit(dir, cfg, changed, state);
1848
+ // card verified-tree-record: treeBefore is the working tree immediately before the first command; a record of
1849
+ // it (any writer: Stop, verify.mjs, land, pre-commit) is tried before preCommitCredit's last-run.json.
1850
+ const treeBefore = skipVerification ? null : computeTree(vdir);
1851
+ const credited = skipVerification ? null : (treeCredit(vdir, cfg, state, treeBefore) ?? preCommitCredit(vdir, cfg, vchanged, state));
1852
+ // card gate-docs-skip-unless-named: a targeted run is never a full run (no record, no ratchet, no state.verified).
1853
+ const targeted = !credited && targetedFiles ? targetedFiles : null;
1508
1854
  if (!skipVerification && credited) {
1509
1855
  for (const { recorded, ...r } of credited) {
1510
1856
  results.push(r);
1511
1857
  if (recorded) ranGreen.push({ command: r.command, result: recorded });
1512
1858
  }
1513
1859
  } else if (!skipVerification) {
1514
- for (const { name, command } of resolveCommands(cfg)) {
1860
+ for (const { name, command: fullCommand } of resolveCommands(cfg)) {
1861
+ if (targeted && name !== "test:unit") continue; // a targeted run is test:unit alone, no typecheck
1862
+ const command = targeted ? `${fullCommand} ${targeted.join(" ")}` : fullCommand;
1515
1863
  // Card stop-gate-budget-under-hook-timeout: a command that cannot even start before the platform
1516
1864
  // would kill this hook's own process is dropped rather than let the platform cut it off mid-run,
1517
1865
  // silently, with its block message discarded (F1). One still runs with what budget is left, clamped
@@ -1530,9 +1878,9 @@ runHook("stop-gate", async (input) => {
1530
1878
  // never bypass the budget: normalizedTimeoutMs floors and clamps up to at least 1ms; unset uses the
1531
1879
  // remaining budget outright. The timed-out message below reports this same value.
1532
1880
  const commandTimeoutMs = Math.min(normalizedTimeoutMs(cfg) ?? remaining, remaining);
1533
- const r = runCommand(command, { cwd: dir, timeoutMs: commandTimeoutMs });
1534
- results.push({ name, command, ok: r.ok, durationMs: r.durationMs, timedOut: r.timedOut, status: r.status });
1535
- if (r.ok) ranGreen.push({ command, stdout: r.stdout });
1881
+ const r = runCommand(command, { cwd: vdir, timeoutMs: commandTimeoutMs });
1882
+ results.push({ name, command, ok: r.ok, durationMs: r.durationMs, timedOut: r.timedOut, status: r.status, ...(targeted ? { targeted: targeted.length } : {}) });
1883
+ if (r.ok && !targeted) ranGreen.push({ command, stdout: r.stdout });
1536
1884
  if (!r.ok) {
1537
1885
  const why = r.timedOut ? `timed out after ${commandTimeoutMs} ms` : `exit ${r.status}`;
1538
1886
  const output = r.stderr + "\n" + r.stdout;
@@ -1543,6 +1891,38 @@ runHook("stop-gate", async (input) => {
1543
1891
  }
1544
1892
  }
1545
1893
  }
1894
+ // card verified-tree-record: record "these commands exited 0 on this tree" after a real all-green run on a tree
1895
+ // unchanged across it; remove the record on any command failure (fail closed). A credited run never writes.
1896
+ if (!skipVerification && !credited && !targeted) {
1897
+ try {
1898
+ const configured = resolveCommands(cfg);
1899
+ if (configured.length === 0) {
1900
+ // nothing ran, nothing to record
1901
+ } else if (results.length === configured.length && results.every((r) => r.ok)) {
1902
+ const treeAfter = treeBefore ? computeTree(vdir) : null;
1903
+ if (treeBefore && treeAfter === treeBefore) {
1904
+ const totals = {};
1905
+ for (const g of ranGreen) {
1906
+ const parsed = parseTotals(g.stdout);
1907
+ if (parsed.status === "ok") totals[g.command] = parsed;
1908
+ }
1909
+ writeTreeRecord(vdir, {
1910
+ version: 1,
1911
+ tree: treeAfter,
1912
+ node: process.version,
1913
+ at: new Date().toISOString(),
1914
+ by: subagent ? "subagent-stop" : "stop",
1915
+ commands: results.map((r) => ({ name: r.name, command: r.command, exit: 0, durationMs: typeof r.durationMs === "number" ? r.durationMs : 0 })),
1916
+ totals,
1917
+ });
1918
+ }
1919
+ } else {
1920
+ removeTreeRecord(vdir);
1921
+ }
1922
+ } catch {
1923
+ // never throw from the writer
1924
+ }
1925
+ }
1546
1926
  const postCommandProblems = problems.length;
1547
1927
 
1548
1928
  // 3b. Evidence: the session must have run a test or verify command itself (lib/evidence.mjs), so a session that
@@ -1564,13 +1944,23 @@ runHook("stop-gate", async (input) => {
1564
1944
  problems.push(...counted.problems);
1565
1945
  const countProblemAdded = counted.problems.length > 0;
1566
1946
 
1567
- state.lastGate = { ok: problems.length === 0, at: Date.now(), results, scope, evidence, ...(subagent ? { subagent } : {}), ...(skipVerification ? { skipped: "no-agent-changes" } : {}) };
1947
+ // A main Stop's skip has no decision-tagged exit line like a SubagentStop's (allowReleasing folds the reason
1948
+ // into that); record the reason here so the trace carries it (card stop-gate-no-test-paths).
1949
+ if (skipVerification && !subagent && cfg.trace && cfg.trace.enabled !== false) {
1950
+ try {
1951
+ appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: input.hook_event_name, reason: skipReason });
1952
+ } catch {
1953
+ // never block the real exit on a trace failure
1954
+ }
1955
+ }
1956
+ state.lastGate = { ok: problems.length === 0, at: Date.now(), results, scope, evidence, ...(subagent ? { subagent } : {}), ...(skipVerification ? { skipped: skipReason } : {}) };
1568
1957
  // card stop-gate-process-storm, case 2: a green gate records the tree it just verified, so a later
1569
1958
  // Stop/SubagentStop whose tree still matches can skip re-running these commands (the sameTree check
1570
1959
  // above). A red gate never reaches here, so it records nothing (goal 2). Never on a no-agent-changes skip
1571
1960
  // (card stop-gate-read-only-subagent-skip): nothing was actually verified.
1572
- state.lastRed = null; // card stop-gate-no-change-restop: re-set at the final block below when it qualifies
1573
- if (problems.length === 0 && !skipVerification) state.verified = verifiedCandidate;
1961
+ // A read-only-turn skip verified nothing: it leaves lastRed (and stopBlocks, below) as they were.
1962
+ if (skipReason !== "read-only-turn") state.lastRed = null; // card stop-gate-no-change-restop: re-set at the final block below when it qualifies
1963
+ if (problems.length === 0 && !skipVerification && !targeted) state.verified = verifiedCandidate;
1574
1964
  if (problems.length === 0 && !skipVerification) writeLastGreen(dir, counted.parsed); // empty when nothing really ran
1575
1965
  if (problems.length === 0 && subagent) {
1576
1966
  // A subagent's green gate: its counter resets; the checkpoint, if any, belongs to the session's own Stop.
@@ -1578,17 +1968,23 @@ runHook("stop-gate", async (input) => {
1578
1968
  saveState(dir, input.session_id, state);
1579
1969
  // card stop-gate-read-only-subagent-skip: the "no-agent-changes" reason folds into this exit's own
1580
1970
  // decision-tagged trace line (D1) rather than a second one being written.
1581
- return allowReleasing(skipVerification ? "no-agent-changes" : null);
1971
+ return allowReleasing(skipReason);
1582
1972
  }
1583
1973
  if (problems.length === 0) {
1584
- state.stopBlocks = 0;
1974
+ // A read-only-turn skip changed nothing this turn: no stopBlocks reset and no checkpoint commit.
1975
+ if (skipReason !== "read-only-turn") state.stopBlocks = 0;
1585
1976
  // 4. Checkpoint on green (off unless checkpoint.enabled): record the working tree now that the
1586
1977
  // gate passed with changes present, so a later regression can be undone with git.
1587
- const ck = checkpoint({ dir, cfg, changed, gateResults: results });
1978
+ const ck = skipReason === "read-only-turn" ? { skipped: "disabled" } : checkpoint({ dir, cfg, changed, gateResults: results });
1588
1979
  const silent = ck.skipped === "disabled" || ck.skipped === "no changes";
1589
1980
  if (!silent) state.lastCheckpoint = { ...ck, at: Date.now() };
1590
1981
  const notice = contextNotice(dir, cfg, state, input.session_id, "verification");
1591
1982
  saveState(dir, input.session_id, state);
1983
+ if (skipReason === "read-only-turn") {
1984
+ const line = readOnlyTurn && readOnlyTurn.dirty ? READ_ONLY_LINE : null;
1985
+ const msg = [line, silent ? null : describeCheckpoint(ck), notice].filter(Boolean).join("\n\n");
1986
+ return msg ? systemMessageReleasing(msg) : allowReleasing();
1987
+ }
1592
1988
  if (notice) return systemMessageReleasing(silent ? notice : `${describeCheckpoint(ck)}\n\n${notice}`);
1593
1989
  return silent ? allowReleasing() : systemMessageReleasing(describeCheckpoint(ck));
1594
1990
  }
@@ -1630,6 +2026,46 @@ runHook("stop-gate", async (input) => {
1630
2026
  // Card stop-gate-block-ledger: the standing claim, distinct from state.lastGate above (which a
1631
2027
  // later gate run replaces wholesale, line ~942) — this is what a later block's classifier reads.
1632
2028
  state.redByDesignClaim = { files: coverage.files, at: new Date().toISOString(), agent: subagent.id };
2029
+ // card stop-gate-lead-reuses-subagent-red: the tester just ran every command on this tree; record the
2030
+ // red so the lead's Stop on the same tree reuses it (same conditions as the final-block recorder).
2031
+ try {
2032
+ const configured = resolveCommands(cfg);
2033
+ if (
2034
+ !skipVerification &&
2035
+ !credited &&
2036
+ !targeted &&
2037
+ results.length === configured.length &&
2038
+ results.every((r) => !r.timedOut && !r.skipped && (r.ok || (typeof r.status === "number" && r.status !== 0 && !(r.status >= SIGNAL_EXIT_MIN && r.status <= SIGNAL_EXIT_MAX)))) &&
2039
+ verifiedCandidate
2040
+ ) {
2041
+ let redClass = "real";
2042
+ try {
2043
+ redClass = classifyFinalBlock({
2044
+ preVerificationProblems,
2045
+ evidenceProblemAdded,
2046
+ commandFailures,
2047
+ postCommandProblems,
2048
+ results,
2049
+ redByDesignClaimFiles: coverage.files,
2050
+ changedAtStart: changed,
2051
+ dir,
2052
+ budgetDeadline,
2053
+ });
2054
+ } catch {
2055
+ redClass = "real";
2056
+ }
2057
+ state.lastRed = {
2058
+ fingerprint: verifiedCandidate,
2059
+ scope: subagent.id,
2060
+ commands: configured.map((c) => c.command),
2061
+ summary: problems.slice(preVerificationProblems, postCommandProblems).join("\n\n"),
2062
+ class: redClass,
2063
+ who,
2064
+ };
2065
+ }
2066
+ } catch {
2067
+ state.lastRed = null;
2068
+ }
1633
2069
  saveState(dir, input.session_id, state);
1634
2070
  // Card stop-gate-defers-while-subagent-in-flight: the "tests_red_by_design" reason (and its
1635
2071
  // covered-files detail) folds into this exit's own decision-tagged trace line (D1) rather than a
@@ -1678,16 +2114,17 @@ runHook("stop-gate", async (input) => {
1678
2114
  }
1679
2115
  // card stop-gate-no-change-restop: record this red for reuse only when every configured command really
1680
2116
  // ran (not credited, none dropped for budget or timed out) and at least one failed, every failure by a
1681
- // numeric non-zero exit (a signal kill has status null and is load-suspect). Stored with the block's ledger
2117
+ // numeric non-zero exit outside 129..192 (a signal kill has status null, a shell-reported one 128+signal; both load-suspect). Stored with the block's ledger
1682
2118
  // class so a reuse logs the same class. state.lastRed was cleared above, so every other path leaves none.
1683
2119
  try {
1684
2120
  const configured = resolveCommands(cfg);
1685
2121
  if (
1686
2122
  !skipVerification &&
1687
2123
  !credited &&
2124
+ !targeted &&
1688
2125
  commandFailures.length > 0 &&
1689
2126
  results.length === configured.length &&
1690
- results.every((r) => !r.timedOut && !r.skipped && (r.ok || (typeof r.status === "number" && r.status !== 0))) &&
2127
+ results.every((r) => !r.timedOut && !r.skipped && (r.ok || (typeof r.status === "number" && r.status !== 0 && !(r.status >= SIGNAL_EXIT_MIN && r.status <= SIGNAL_EXIT_MAX)))) &&
1691
2128
  postCommandProblems - preVerificationProblems === commandFailures.length &&
1692
2129
  verifiedCandidate
1693
2130
  ) {
@@ -1697,6 +2134,7 @@ runHook("stop-gate", async (input) => {
1697
2134
  commands: configured.map((c) => c.command),
1698
2135
  summary: problems.slice(preVerificationProblems, postCommandProblems).join("\n\n"),
1699
2136
  class: finalClass,
2137
+ who,
1700
2138
  };
1701
2139
  saveState(dir, input.session_id, state);
1702
2140
  }