@dougharness/cli 1.0.1 → 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 (89) 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 -1
  19. package/dist/generate/config.js +5 -12
  20. package/dist/generate/config.js.map +1 -1
  21. package/dist/generate/proposal.js +24 -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/data/pairs.json +103 -0
  41. package/node_modules/@dougharness/flow/lib/decisions.mjs +2 -2
  42. package/node_modules/@dougharness/flow/lib/embeddings.mjs +189 -27
  43. package/node_modules/@dougharness/flow/lib/land.mjs +69 -10
  44. package/node_modules/@dougharness/flow/lib/learn.mjs +61 -4
  45. package/node_modules/@dougharness/flow/lib/memory-import.mjs +22 -4
  46. package/node_modules/@dougharness/flow/lib/memory.mjs +326 -35
  47. package/node_modules/@dougharness/flow/lib/plan.mjs +9 -0
  48. package/node_modules/@dougharness/flow/package.json +2 -1
  49. package/node_modules/@dougharness/flow/scripts/board.mjs +60 -20
  50. package/node_modules/@dougharness/flow/scripts/context.mjs +48 -0
  51. package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
  52. package/node_modules/@dougharness/flow/scripts/memory.mjs +196 -39
  53. package/node_modules/@dougharness/flow/scripts/plan.mjs +32 -64
  54. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
  55. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +105 -0
  56. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  57. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +44 -10
  58. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +5 -5
  59. package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
  60. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +10 -11
  61. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
  62. package/node_modules/@dougharness/flow/workflows/doug-implement.js +119 -72
  63. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  64. package/node_modules/@dougharness/gates/hooks/hooks.json +11 -0
  65. package/node_modules/@dougharness/gates/lib/baseline.mjs +166 -1
  66. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +58 -1
  67. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +1 -1
  68. package/node_modules/@dougharness/gates/lib/config.mjs +20 -0
  69. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +95 -0
  70. package/node_modules/@dougharness/gates/lib/evidence.mjs +2 -0
  71. package/node_modules/@dougharness/gates/lib/run.mjs +4 -0
  72. package/node_modules/@dougharness/gates/lib/state.mjs +5 -0
  73. package/node_modules/@dougharness/gates/lib/test-totals.mjs +139 -0
  74. package/node_modules/@dougharness/gates/lib/trace.mjs +24 -7
  75. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +225 -0
  76. package/node_modules/@dougharness/gates/package.json +1 -1
  77. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +133 -5
  78. package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +14 -0
  79. package/node_modules/@dougharness/gates/scripts/statusline.mjs +33 -7
  80. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +794 -67
  81. package/node_modules/@dougharness/gates/scripts/trace.mjs +5 -1
  82. package/node_modules/@dougharness/gates/scripts/turn-start.mjs +17 -0
  83. package/node_modules/@dougharness/gates/scripts/verify.mjs +146 -0
  84. package/package.json +3 -3
  85. package/templates/board-app.js +2 -2
  86. package/node_modules/@dougharness/flow/lib/rehearse.mjs +0 -825
  87. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +0 -129
  88. package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +0 -92
  89. 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,8 +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 { 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";
71
80
  import { runHook, allow, blockStop, systemMessage } from "../lib/io.mjs";
72
81
  import { loadConfig, projectDir } from "../lib/config.mjs";
73
82
  import { loadState, saveState } from "../lib/state.mjs";
@@ -76,12 +85,15 @@ import { runCommand, failureSummary } from "../lib/run.mjs";
76
85
  import { loadScopePlan, scopeViolations, describeViolations, PLAN_RELPATH } from "../lib/scope.mjs";
77
86
  import { checkpoint, describeCheckpoint } from "../lib/checkpoint.mjs";
78
87
  import { verificationEvidence, describeMissingEvidence } from "../lib/evidence.mjs";
79
- 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";
80
89
  import { tracePath, readTrace, traceLine, appendTrace, agentLabel } from "../lib/trace.mjs";
81
90
  import { isAppliedContent } from "../lib/proposals.mjs";
82
91
  import { gitHead, mergeSnapshot, readDecisions, readPlanSummary, snapshotSection } from "../lib/anchor.mjs";
83
92
  import { contextReading, recordAgentContext } from "../lib/agent-context.mjs";
84
- 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";
85
97
 
86
98
  // Only the last this many characters of last_assistant_message are ever scanned for a partial claim
87
99
  // (review, scan cost): a structured result lives at the end of a worker's message, and the balanced-span
@@ -90,6 +102,11 @@ import { failingTestFilesFrom, classifyFinalBlock, appendBlockLine } from "../li
90
102
  // that large. Bounding the input bounds the cost regardless of how long the message is.
91
103
  const PARTIAL_SCAN_TAIL_CHARS = 32 * 1024;
92
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
+
93
110
  // Every balanced {...} span that starts at some "{" in `text`, matched independently from that position
94
111
  // (review MAJOR 1): a stray unmatched "{" earlier in the prose, or an odd number of quote characters
95
112
  // before it, only ever breaks a scan that tracks nesting from the start of the whole string. Starting a
@@ -295,6 +312,15 @@ function stripLeadingDotSlash(f) {
295
312
  // otherwise `{ covered: false, reasonLine }` — one line, no newline, saying why.
296
313
  function redByDesignCoverage(claimFiles, commandFailures, changed, dir) {
297
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
+ }
298
324
  const namedByCommand = commandFailures.map((cf) => ({ command: cf.command, files: failingTestFilesFrom(cf.output, dir) }));
299
325
  const noFile = namedByCommand.filter((c) => c.files.length === 0);
300
326
  if (noFile.length > 0) {
@@ -385,6 +411,50 @@ const CHANGE_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
385
411
  // succeeded, so any Pre or PostToolUse line naming one counts as a change whatever its ok.
386
412
  const SHELL_TOOLS = new Set(["Bash", "Monitor"]);
387
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
+
388
458
  // True when this SubagentStop's own agent made no change during its life (card
389
459
  // stop-gate-read-only-subagent-skip), so the caller can skip the verification commands and evidence check for
390
460
  // it. Never throws (a hook reader must never throw): any failure here reads as false, the conservative answer
@@ -406,12 +476,7 @@ function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
406
476
  const lines = readTrace(file);
407
477
  const started = lines.some((l) => l.event === "SubagentStart" && l.agent === subagent.id);
408
478
  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;
479
+ return !traceShowsChange(lines, subagent.id);
415
480
  }
416
481
  }
417
482
  }
@@ -421,6 +486,161 @@ function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
421
486
  }
422
487
  }
423
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
+
424
644
  // card stop-gate-skips-orphan-subagent-stops: true when a SubagentStop's own event carries none of the three
425
645
  // signals of a real subagent of this session (docs/research/gate-output-names-failures.md Q1: Claude Code
426
646
  // fires SubagentStop for agents that never had a SubagentStart, carry no agent_type, and have no readable
@@ -517,46 +737,203 @@ function contextNotice(dir, cfg, state, sessionId, boundary) {
517
737
  }
518
738
  }
519
739
 
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 });
740
+ // ---- Test-count ratchet (card test-count-ratchet; the pure logic and its known limits are in lib/test-totals.mjs).
741
+ // Every reader below degrades to "no check" or "no waiver"; none may throw (runHook would fail open).
742
+ const LAST_GREEN_RELPATH = ".doug/.state/test-totals.json";
743
+ const WAIVER_FILE_RELPATH = ".doug/.state/test-count-waivers";
744
+
745
+ // Waiver sources: commit messages since the session's baseline HEAD, and the waiver file's text.
746
+ function waiverSources(dir, baselineHead) {
747
+ const sources = [];
748
+ try {
749
+ if (typeof baselineHead === "string" && /^[0-9a-f]{7,64}$/.test(baselineHead)) {
750
+ const out = execFileSync("git", ["log", "--format=%B%x00", `${baselineHead}..HEAD`], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
751
+ sources.push(...out.split("\0"));
752
+ }
753
+ } catch {
754
+ // no git, or a base git no longer knows: no commit waivers
526
755
  }
527
- return out;
756
+ try {
757
+ sources.push(readFileSync(join(dir, WAIVER_FILE_RELPATH), "utf8"));
758
+ } catch {
759
+ // no waiver file, or unreadable: no file waivers
760
+ }
761
+ return sources;
528
762
  }
529
763
 
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() };
764
+ // The count check for the commands that ran green by exit code. Returns { problems, parsed }: `problems` are
765
+ // block reasons, `parsed` the per-command summary lists of every run that was counted (for the last-green
766
+ // record). A missing or unparseable summary, or no baseline, is a trace line and no check.
767
+ function countCheck(dir, cfg, state, input, ran) {
768
+ const problems = [];
769
+ const parsed = {};
770
+ // The note carries its own event name, never the stop event's: a decision-less SubagentStop line would read to
771
+ // subagentsInFlight (more undecided stops than blocks) as "the agent is gone" (review B1). No reader keys on
772
+ // "CountCheck"; it only adds one to attribution's per-agent event count. Honours trace.enabled false (review m2).
773
+ const note = (reason) => {
774
+ try {
775
+ if (!cfg || !cfg.trace || cfg.trace.enabled === false) return;
776
+ appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: "CountCheck", reason });
777
+ } catch {
778
+ // logging must never fail the gate
779
+ }
780
+ };
781
+ // Each entry is `{ command, stdout }` (a real run: parsed here) or `{ command, result }` (a credited pre-commit
782
+ // run: the parseTotals-shaped result the hook recorded). Both take the identical path below.
783
+ for (const { command, stdout, result } of ran) {
784
+ try {
785
+ const p = result !== undefined ? result : parseTotals(stdout);
786
+ const summaries = p.status === "ok" ? readSummaries(p) : null;
787
+ if (!summaries) {
788
+ note(`count check did not run for \`${command}\`: no test summary in its output`);
789
+ continue;
790
+ }
791
+ const all = state.testTotals && typeof state.testTotals === "object" ? state.testTotals : null;
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.`);
800
+ continue;
801
+ }
802
+ parsed[command] = summaries;
803
+ const base = sumTotals(baseList);
804
+ const now = sumTotals(summaries);
805
+ const found = compareTotals(base, now);
806
+ if (found.length === 0) continue;
807
+ const delta = deltaTotals(base, now);
808
+ if (waiverCovers(waiverSources(dir, state.baselineHead), delta)) continue;
809
+ const lines = found.map((f) => `${f.kind} ${f.kind === "total" ? "dropped" : "rose"} from ${f.baseline} (baseline) to ${f.now} (now)`);
810
+ problems.push(
811
+ `\`${command}\` test count regressed: ${lines.join("; ")}. Deleting or skipping tests does not make a change green. Restore them, or waive it with a line \`test-count-waiver: total=${delta.total} skipped=${delta.skipped} todo=${delta.todo}; <reason>\` in the commit message or ${WAIVER_FILE_RELPATH}.`,
812
+ );
813
+ } catch {
814
+ note(`count check did not run for \`${command}\`: error while checking`);
815
+ }
816
+ }
817
+ return { problems, parsed };
541
818
  }
542
819
 
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) {
820
+ // A green run's totals become the last-green record the next session starts from. Merged per command.
821
+ function writeLastGreen(dir, parsed) {
547
822
  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]);
823
+ if (Object.keys(parsed).length === 0) return;
824
+ let record = {};
825
+ try {
826
+ const old = JSON.parse(readFileSync(join(dir, LAST_GREEN_RELPATH), "utf8"));
827
+ if (old && typeof old === "object" && !Array.isArray(old)) record = old;
828
+ } catch {
829
+ // absent or unreadable: start a fresh record
830
+ }
831
+ mkdirSync(join(dir, ".doug/.state"), { recursive: true });
832
+ writeFileSync(join(dir, LAST_GREEN_RELPATH), JSON.stringify({ ...record, ...parsed }));
558
833
  } catch {
559
- return false;
834
+ // never fail the gate over the record
835
+ }
836
+ }
837
+
838
+ // card stop-gate-credits-pre-commit: `git rev-parse HEAD^{tree}`, the tree sha of the current commit — what
839
+ // the pre-commit hook's own record (.doug/.state/pre-commit/last-run.json) compares its `tree` field against.
840
+ // Null on any failure (no git, no HEAD yet, ...): a reader must never throw.
841
+ function headTreeSha(dir) {
842
+ try {
843
+ const out = execFileSync("git", ["rev-parse", "HEAD^{tree}"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
844
+ const sha = out.trim();
845
+ return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
846
+ } catch {
847
+ return null;
848
+ }
849
+ }
850
+
851
+ // card ratchet-pre-commit-credit: a recorded parseTotals result, normalised, or null when it is not readable.
852
+ function readableRecordedTotals(t) {
853
+ try {
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 } : {}) });
882
+ }
883
+ return out;
884
+ } catch {
885
+ return null;
886
+ }
887
+ }
888
+
889
+ // card stop-gate-credits-pre-commit: the pre-commit hook (.githooks/pre-commit) writes a machine-readable
890
+ // record of its own run to .doug/.state/pre-commit/last-run.json (version, tree, ok, at, commands: [{ name,
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`
892
+ // once stopGate.ignoreChangedPaths paths are removed) and that record is a passing run, against HEAD's own
893
+ // tree, of exactly today's configured commands in order, the Stop gate credits it instead of re-running the
894
+ // same commands a second time — returning `{ name, command, ok: true, credited: "pre-commit" }` per command,
895
+ // same length and order as resolveCommands(cfg). Returns null (run as today) on a missing, unparsable,
896
+ // wrong-shape, failing, stale-tree, or mismatched-commands record, or a dirty tree — fail closed to running,
897
+ // never to passing. A reader must never throw: every path here is wrapped, so a corrupt record never crashes
898
+ // the hook (`runHook` fails open on a throw, which here would mean passing without running — forbidden).
899
+ function preCommitCredit(dir, cfg, changed, state) {
900
+ try {
901
+ if (!Array.isArray(changed)) return null;
902
+ const ignore = cfg.stopGate.ignoreChangedPaths || [];
903
+ const remaining = changed.filter((f) => !matchAny(ignore, f));
904
+ if (remaining.length > 0) return null;
905
+ const raw = readFileSync(join(dir, ".doug/.state/pre-commit/last-run.json"), "utf8");
906
+ const record = JSON.parse(raw);
907
+ if (!record || typeof record !== "object") return null;
908
+ if (record.version !== 1) return null;
909
+ if (record.ok !== true) return null;
910
+ if (typeof record.tree !== "string") return null;
911
+ if (!Array.isArray(record.commands)) return null;
912
+ const head = headTreeSha(dir);
913
+ if (!head || record.tree !== head) return null;
914
+ for (const c of record.commands) {
915
+ if (!c || typeof c !== "object" || c.exit !== 0) return null;
916
+ }
917
+ const configured = resolveCommands(cfg);
918
+ if (record.commands.length !== configured.length) return null;
919
+ for (let i = 0; i < configured.length; i++) {
920
+ if (!record.commands[i] || record.commands[i].command !== configured[i].command) return null;
921
+ }
922
+ // card ratchet-pre-commit-credit: a command with a baseline in state.testTotals is credited only when the
923
+ // record carries readable totals for it (a list of valid summaries, or an old ok or conflict shape);
924
+ // otherwise null and the commands really run. `recorded` is the parseTotals-shaped result for the count check.
925
+ const all = state && state.testTotals && typeof state.testTotals === "object" ? state.testTotals : null;
926
+ const out = [];
927
+ for (let i = 0; i < configured.length; i++) {
928
+ const { name, command } = configured[i];
929
+ const recorded = readableRecordedTotals(record.commands[i].totals);
930
+ const hasBaseline = all ? readSummaries(all[command]) !== null : false;
931
+ if (hasBaseline && (!recorded || recorded.status === "none")) return null;
932
+ out.push({ name, command, ok: true, credited: "pre-commit", ...(recorded ? { recorded } : {}) });
933
+ }
934
+ return out;
935
+ } catch {
936
+ return null;
560
937
  }
561
938
  }
562
939
 
@@ -575,7 +952,7 @@ function verifiedUnchangedAllow(dir, cfg, state, input, subagent, capNote) {
575
952
  // exits (F4); the `decision` a SubagentStop's own line now always carries folds in here, alongside its
576
953
  // `reason`, rather than a second line being written for the same event. Only for a SubagentStop (subagent
577
954
  // set) — a main Stop's own trace line here is not an agent-stop decision.
578
- if (cfg.trace && cfg.trace.enabled !== false) {
955
+ if (cfg.trace && cfg.trace.enabled !== false && !isTreeChild(dir, subagent)) {
579
956
  appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: input.hook_event_name, reason: "verified-unchanged", ...(subagent ? { decision: "allow" } : {}) });
580
957
  }
581
958
  if (subagent) {
@@ -659,14 +1036,17 @@ function resolveHookTimeoutSec(cfg) {
659
1036
  // discarded), less a margin so the gate's own block reaches Claude Code before that happens. gateStart
660
1037
  // is this process's own start time, not the moment this function runs, so the budget covers everything
661
1038
  // this invocation has done so far (config/state loads, earlier checks), not just what follows.
662
- function gateBudget(cfg) {
1039
+ function gateBudget(cfg, inherited) {
663
1040
  const hookTimeoutSec = resolveHookTimeoutSec(cfg);
664
1041
  const hookTimeoutMs = hookTimeoutSec * 1000;
665
1042
  const marginMs = Math.min(30000, hookTimeoutMs / 10);
666
1043
  // process.uptime() is fractional seconds; runCommand's timeoutMs must be an unsigned integer (Node's
667
1044
  // spawnSync throws otherwise), so every value derived from this deadline is rounded down.
668
1045
  const gateStart = Date.now() - Math.floor(process.uptime() * 1000);
669
- 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 };
670
1050
  }
671
1051
 
672
1052
  // A lock whose numeric `at` is older than the hook-timeout budget is stale regardless of a live pid: no
@@ -705,6 +1085,8 @@ function normalizedDeferrals(state) {
705
1085
  return typeof d === "number" && Number.isFinite(d) && d >= 0 ? Math.floor(d) : 0;
706
1086
  }
707
1087
 
1088
+ const READ_ONLY_LINE = "[doug] Read-only turn: verification skipped; the working tree holds unverified changes from other agents.";
1089
+
708
1090
  runHook("stop-gate", async (input) => {
709
1091
  if (input.stop_hook_active) return allow();
710
1092
  const dir = projectDir(input);
@@ -715,11 +1097,30 @@ runHook("stop-gate", async (input) => {
715
1097
  // main Stops the counter (R1) is tracking — reset right here, after loadState, so every SubagentStop exit
716
1098
  // below that saves state (including the one that reloads state again mid-lock-wait, R6 note there) persists
717
1099
  // the reset.
718
- if (subagent) state.deferrals = 0;
1100
+ if (subagent) {
1101
+ state.deferrals = 0;
1102
+ delete state.lastDeferral;
1103
+ }
719
1104
  if (!subagent) state.turns += 1;
720
1105
  const maxBlocks = cfg.stopGate.maxBlocks ?? 3;
721
1106
  const blocksSoFar = subagent ? (state.agentBlocks && state.agentBlocks[subagent.id]) || 0 : state.stopBlocks;
722
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
+ }
723
1124
  // D1 (card stop-gate-defers-while-subagent-in-flight): one choke point for the `decision` every SubagentStop
724
1125
  // exit's own trace line now carries, so no exit (present or future) can miss it — except the
725
1126
  // `stop_hook_active` early return above, which runs before `subagent` exists and so writes no decision line
@@ -734,7 +1135,7 @@ runHook("stop-gate", async (input) => {
734
1135
  // tests_red_by_design, its `detail`) into this same line instead of a second one being written. Wrapped so a
735
1136
  // trace failure never blocks the real exit (a hook reader must never throw).
736
1137
  function traceSubagentDecision(decision, traceReason, extra) {
737
- if (!subagent) return;
1138
+ if (!subagent || treeChild) return;
738
1139
  try {
739
1140
  if (!cfg.trace || cfg.trace.enabled === false) return;
740
1141
  appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: "SubagentStop", decision, reason: traceReason ?? null, ...(extra || {}) });
@@ -777,7 +1178,7 @@ runHook("stop-gate", async (input) => {
777
1178
  };
778
1179
  // Card stop-gate-budget-under-hook-timeout: the whole gate (wait plus commands) must fit under the
779
1180
  // 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);
1181
+ const { hookTimeoutSec, hookTimeoutMs, deadline: budgetDeadline } = gateBudget(cfg, forced ? Number(process.env.DOUG_STOP_GATE_DEADLINE) : NaN);
781
1182
 
782
1183
  // card stop-gate-skips-orphan-subagent-stops: checked before the partial-claim block, the tree scans, the
783
1184
  // deferral logic, gate.lock, skipVerification, and runVerification — an orphan SubagentStop is not a real
@@ -863,6 +1264,9 @@ runHook("stop-gate", async (input) => {
863
1264
 
864
1265
  const edited = state.editedFiles.length > 0;
865
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);
866
1270
  // Snapshot whatever was already dirty so it is never mistaken for this session's own work (card
867
1271
  // stop-gate-waiting-on-subagents). Ideally this happens on SessionStart (scripts/session-start-baseline.mjs),
868
1272
  // before the session can have dirtied anything itself; this is the fallback for a session where that
@@ -940,6 +1344,58 @@ runHook("stop-gate", async (input) => {
940
1344
  (state.baselineSource === "session-start" ? sessionMadeChanges : edited || (Array.isArray(changed) && changed.length > 0)) ||
941
1345
  (Array.isArray(committed) && committed.length > 0) ||
942
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
+ }
943
1399
  if (cfg.stopGate.onlyIfEdited && !anyChange) {
944
1400
  const notice = subagent ? null : contextNotice(dir, cfg, state, input.session_id, "skip");
945
1401
  saveState(dir, input.session_id, state);
@@ -949,7 +1405,7 @@ runHook("stop-gate", async (input) => {
949
1405
  // card stop-gate-process-storm, case 2: a tree already verified by an earlier green gate this session
950
1406
  // needs no recheck at all — placed ahead of maxBlocks and every problems check below, since a red gate
951
1407
  // never records `verified` and there is nothing left here to recheck when nothing has changed since.
952
- const currentFingerprint = treeFingerprint(dir, cfg, changed);
1408
+ const currentFingerprint = treeFingerprint(vdir, cfg, vchanged);
953
1409
  if (sameTree(state.verified, currentFingerprint)) {
954
1410
  return verifiedUnchangedAllow(dir, cfg, state, input, subagent);
955
1411
  }
@@ -1034,7 +1490,8 @@ runHook("stop-gate", async (input) => {
1034
1490
  // (that block is real and counts as today); only with no such problem does this Stop defer, skipping
1035
1491
  // gate.lock, the verification commands, the evidence check, state.verified, and the blocks.jsonl ledger
1036
1492
  // 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
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
1038
1495
  // untouched — this Stop verified nothing, so there is nothing honest to record there.
1039
1496
  // R1/R3 (card stop-gate-deferral-cap): state.deferrals counts consecutive deferred main Stops; unbounded,
1040
1497
  // a dead or forged subagent (the module header's "Known limit") would defer forever. deferralCapNote is
@@ -1043,7 +1500,13 @@ runHook("stop-gate", async (input) => {
1043
1500
  // falls through to ends up returning. The cap only ever widens when the full verification runs (R7): it
1044
1501
  // never lets a Stop skip verification that would otherwise have run.
1045
1502
  let deferralCapNote = null;
1046
- 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));
1047
1510
  if (inFlight.length > 0) {
1048
1511
  const { problems: deferProblems } = scanTreeProblems();
1049
1512
  if (deferProblems.length === 0) {
@@ -1051,10 +1514,21 @@ runHook("stop-gate", async (input) => {
1051
1514
  const n = normalizedDeferrals(state) + 1;
1052
1515
  const names = inFlight.map((a) => agentLabel({ agent: a.id, agentType: a.type })).join(", ");
1053
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);
1054
1526
  state.deferrals = n;
1527
+ state.lastDeferral = { ids, fingerprint: currentFingerprint };
1055
1528
  saveState(dir, input.session_id, state);
1529
+ if (repeat) return allow();
1056
1530
  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.`,
1531
+ `[doug] Verification deferred: waits for ${names} to stop; the full gate runs at the first Stop after they stop.`,
1058
1532
  );
1059
1533
  }
1060
1534
  // R3: the cap is hit on this Stop — do not defer again. Reset the counter (R5) and fall through to
@@ -1064,22 +1538,130 @@ runHook("stop-gate", async (input) => {
1064
1538
  // value itself (stopGate.maxDeferrals) is named alongside it so the message stays informative once the
1065
1539
  // deferred count is no longer always the cap.
1066
1540
  state.deferrals = 0;
1541
+ delete state.lastDeferral;
1067
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.`;
1068
1543
  } else {
1069
1544
  state.deferrals = 0; // R5: a real tree problem — this Stop does not defer
1545
+ delete state.lastDeferral;
1070
1546
  }
1071
1547
  // A real problem, or the cap was just hit: fall through to the ordinary path below, which finds the
1072
1548
  // same problem again inside runVerification and blocks on it exactly as today (deferral only ever
1073
1549
  // widens "nothing to check yet", never what counts as a real problem).
1074
1550
  } else if (!subagent) {
1075
1551
  state.deferrals = 0; // R5: no agent in flight — this Stop runs the gate
1552
+ delete state.lastDeferral;
1076
1553
  }
1077
1554
 
1078
1555
  // card stop-gate-read-only-subagent-skip: computed once, ahead of the gate.lock acquire just below, so a
1079
1556
  // 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;
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;
1589
+
1590
+ // card stop-gate-no-change-restop: a stop in the same scope on a tree unchanged since a red block whose
1591
+ // commands all really ran (state.lastRed, recorded at the final block below) repeats that block without
1592
+ // running the commands or touching gate.lock, counted against maxBlocks like any final block, logged with the
1593
+ // recorded block's class and carrying the deferral cap note like blockStopReleasing. One reuse per record: the
1594
+ // record is cleared by the reuse, so the next stop runs the commands (a new red records afresh). Anything
1595
+ // unreadable, different, or not plainly a command-failure red degrades to running (fail closed to running).
1596
+ if (!skipVerification) {
1597
+ let lastRed = null;
1598
+ let redViaNoTestPaths = false; // card stop-gate-board-edit-reuses-red: reused although the tree moved, only on noTestPaths
1599
+ try {
1600
+ const rec = state.lastRed;
1601
+ const scopeNow = subagent ? subagent.id : "main";
1602
+ const commandsNow = resolveCommands(cfg).map((c) => c.command);
1603
+ if (
1604
+ rec &&
1605
+ typeof rec === "object" &&
1606
+ !Array.isArray(rec) &&
1607
+ typeof rec.scope === "string" &&
1608
+ typeof rec.summary === "string" &&
1609
+ Array.isArray(rec.commands) &&
1610
+ rec.commands.every((c) => typeof c === "string") &&
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) &&
1614
+ rec.commands.length === commandsNow.length &&
1615
+ rec.commands.every((c, i) => c === commandsNow[i]) &&
1616
+ rec.fingerprint &&
1617
+ typeof rec.fingerprint === "object" &&
1618
+ currentFingerprint &&
1619
+ (sameTree(rec.fingerprint, currentFingerprint) || (rec.scope === scopeNow && (redViaNoTestPaths = onlyNoTestPathsChanged(vdir, cfg, rec.fingerprint, vchanged)))) &&
1620
+ !(subagent && (redByDesignClaim(input.last_assistant_message) ?? redByDesignClaim(lastHandbackMessage(input.agent_transcript_path)))) &&
1621
+ !preCommitCredit(vdir, cfg, vchanged, state) &&
1622
+ !treeCredit(vdir, cfg, state, computeTree(vdir))
1623
+ ) {
1624
+ lastRed = rec;
1625
+ }
1626
+ } catch {
1627
+ lastRed = null;
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;
1631
+ if (lastRed) {
1632
+ if (blocksSoFar >= maxBlocks) {
1633
+ const alreadyStoodDown = markStoodDown("block-cap");
1634
+ saveState(dir, input.session_id, state);
1635
+ const reason = who
1636
+ ? `[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.`
1637
+ : `[doug] Stop gate blocked ${state.stopBlocks} times this session and is standing down. Verification is still failing; do not treat this work as done.`;
1638
+ if (!alreadyStoodDown) logBlock(reason, "stood-down");
1639
+ return systemMessageDecided(reason);
1640
+ }
1641
+ const { problems: treeProblems } = scanTreeProblems();
1642
+ const blocks = blocksSoFar + 1;
1643
+ if (subagent) state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: blocks };
1644
+ else state.stopBlocks = blocks;
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;
1648
+ saveState(dir, input.session_id, state);
1649
+ const reason =
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. `) +
1651
+ `Do not claim the task is done while this fails.\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`) +
1658
+ [...treeProblems, lastRed.summary].join("\n\n");
1659
+ const reuseClass = typeof lastRed.class === "string" ? lastRed.class : "real";
1660
+ const capped = deferralCapNote && !subagent ? `${deferralCapNote}\n\n${reason}` : reason;
1661
+ logBlock(reason, reuseClass);
1662
+ return blockStopDecided(capped);
1663
+ }
1664
+ }
1083
1665
 
1084
1666
  // card stop-gate-process-storm, case 3: only one gate runs the verification commands at a time per
1085
1667
  // project (the 2026-09-16 process storm: four concurrent SubagentStops each ran the full suite, each
@@ -1159,6 +1741,7 @@ runHook("stop-gate", async (input) => {
1159
1741
  const blocks = blocksSoFar + 1;
1160
1742
  if (subagent) state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: blocks };
1161
1743
  else state.stopBlocks = blocks;
1744
+ state.lastRed = null; // card stop-gate-no-change-restop: a lock refusal is load-caused, never reused
1162
1745
  saveState(dir, input.session_id, state);
1163
1746
  const reason =
1164
1747
  (who ? `[doug] ${who} cannot report done` : `[doug] Stop gate`) +
@@ -1175,7 +1758,8 @@ runHook("stop-gate", async (input) => {
1175
1758
  // to even get here, so it already passed that check point and always intends 0). Unconditional:
1176
1759
  // every path that reaches here, subagent or not, means 0.
1177
1760
  state.deferrals = 0;
1178
- verifiedCandidate = treeFingerprint(dir, cfg, changed);
1761
+ delete state.lastDeferral;
1762
+ verifiedCandidate = treeFingerprint(vdir, cfg, vchanged);
1179
1763
  if (sameTree(state.verified, verifiedCandidate)) {
1180
1764
  // Review round 1 minor 5: this exit returns straight from verifiedUnchangedAllow, bypassing the
1181
1765
  // *Releasing wrappers (R4) below — release the lock here (a no-op when this gate never took it)
@@ -1257,8 +1841,25 @@ runHook("stop-gate", async (input) => {
1257
1841
  // One entry per failed command, `{ command, output }` (stderr + stdout, untruncated), for the
1258
1842
  // tests_red_by_design coverage check below (design point 2, card subagent-stop-gate-tester-red).
1259
1843
  const commandFailures = [];
1260
- if (!skipVerification) {
1261
- for (const { name, command } of resolveCommands(cfg)) {
1844
+ const ranGreen = []; // count check input: { command, stdout } of each command that really ran and exited 0, plus { command, result } of each credited pre-commit command whose recorded totals are readable
1845
+ // card stop-gate-credits-pre-commit: a passing pre-commit record for this exact tree and command set
1846
+ // stands in for actually running them — see preCommitCredit above. Every other check below (evidence,
1847
+ // red-by-design, state.verified, the block ledger) runs exactly as it would after a real run.
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;
1854
+ if (!skipVerification && credited) {
1855
+ for (const { recorded, ...r } of credited) {
1856
+ results.push(r);
1857
+ if (recorded) ranGreen.push({ command: r.command, result: recorded });
1858
+ }
1859
+ } else if (!skipVerification) {
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;
1262
1863
  // Card stop-gate-budget-under-hook-timeout: a command that cannot even start before the platform
1263
1864
  // would kill this hook's own process is dropped rather than let the platform cut it off mid-run,
1264
1865
  // silently, with its block message discarded (F1). One still runs with what budget is left, clamped
@@ -1277,8 +1878,9 @@ runHook("stop-gate", async (input) => {
1277
1878
  // never bypass the budget: normalizedTimeoutMs floors and clamps up to at least 1ms; unset uses the
1278
1879
  // remaining budget outright. The timed-out message below reports this same value.
1279
1880
  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 });
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 });
1282
1884
  if (!r.ok) {
1283
1885
  const why = r.timedOut ? `timed out after ${commandTimeoutMs} ms` : `exit ${r.status}`;
1284
1886
  const output = r.stderr + "\n" + r.stdout;
@@ -1289,6 +1891,38 @@ runHook("stop-gate", async (input) => {
1289
1891
  }
1290
1892
  }
1291
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
+ }
1292
1926
  const postCommandProblems = problems.length;
1293
1927
 
1294
1928
  // 3b. Evidence: the session must have run a test or verify command itself (lib/evidence.mjs), so a session that
@@ -1305,29 +1939,52 @@ runHook("stop-gate", async (input) => {
1305
1939
  }
1306
1940
  const evidenceProblemAdded = problems.length > postCommandProblems;
1307
1941
 
1308
- state.lastGate = { ok: problems.length === 0, at: Date.now(), results, scope, evidence, ...(subagent ? { subagent } : {}), ...(skipVerification ? { skipped: "no-agent-changes" } : {}) };
1942
+ // 3c. Test-count ratchet (card test-count-ratchet): its own kind of problem, not a failed command.
1943
+ const counted = countCheck(dir, cfg, state, input, ranGreen);
1944
+ problems.push(...counted.problems);
1945
+ const countProblemAdded = counted.problems.length > 0;
1946
+
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 } : {}) };
1309
1957
  // card stop-gate-process-storm, case 2: a green gate records the tree it just verified, so a later
1310
1958
  // Stop/SubagentStop whose tree still matches can skip re-running these commands (the sameTree check
1311
1959
  // above). A red gate never reaches here, so it records nothing (goal 2). Never on a no-agent-changes skip
1312
1960
  // (card stop-gate-read-only-subagent-skip): nothing was actually verified.
1313
- 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;
1964
+ if (problems.length === 0 && !skipVerification) writeLastGreen(dir, counted.parsed); // empty when nothing really ran
1314
1965
  if (problems.length === 0 && subagent) {
1315
1966
  // A subagent's green gate: its counter resets; the checkpoint, if any, belongs to the session's own Stop.
1316
1967
  state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: 0 };
1317
1968
  saveState(dir, input.session_id, state);
1318
1969
  // card stop-gate-read-only-subagent-skip: the "no-agent-changes" reason folds into this exit's own
1319
1970
  // decision-tagged trace line (D1) rather than a second one being written.
1320
- return allowReleasing(skipVerification ? "no-agent-changes" : null);
1971
+ return allowReleasing(skipReason);
1321
1972
  }
1322
1973
  if (problems.length === 0) {
1323
- 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;
1324
1976
  // 4. Checkpoint on green (off unless checkpoint.enabled): record the working tree now that the
1325
1977
  // gate passed with changes present, so a later regression can be undone with git.
1326
- const ck = checkpoint({ dir, cfg, changed, gateResults: results });
1978
+ const ck = skipReason === "read-only-turn" ? { skipped: "disabled" } : checkpoint({ dir, cfg, changed, gateResults: results });
1327
1979
  const silent = ck.skipped === "disabled" || ck.skipped === "no changes";
1328
1980
  if (!silent) state.lastCheckpoint = { ...ck, at: Date.now() };
1329
1981
  const notice = contextNotice(dir, cfg, state, input.session_id, "verification");
1330
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
+ }
1331
1988
  if (notice) return systemMessageReleasing(silent ? notice : `${describeCheckpoint(ck)}\n\n${notice}`);
1332
1989
  return silent ? allowReleasing() : systemMessageReleasing(describeCheckpoint(ck));
1333
1990
  }
@@ -1353,6 +2010,7 @@ runHook("stop-gate", async (input) => {
1353
2010
  } else if (
1354
2011
  preVerificationProblems === 0 &&
1355
2012
  !evidenceProblemAdded &&
2013
+ !countProblemAdded &&
1356
2014
  commandFailures.length > 0 &&
1357
2015
  // R1 (pass 2 review): a command dropped for budget also adds one problem in step 3 (line ~892),
1358
2016
  // but is not in commandFailures — that part of the verification never ran at all, so the claim
@@ -1368,6 +2026,46 @@ runHook("stop-gate", async (input) => {
1368
2026
  // Card stop-gate-block-ledger: the standing claim, distinct from state.lastGate above (which a
1369
2027
  // later gate run replaces wholesale, line ~942) — this is what a later block's classifier reads.
1370
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
+ }
1371
2069
  saveState(dir, input.session_id, state);
1372
2070
  // Card stop-gate-defers-while-subagent-in-flight: the "tests_red_by_design" reason (and its
1373
2071
  // covered-files detail) folds into this exit's own decision-tagged trace line (D1) rather than a
@@ -1414,6 +2112,35 @@ runHook("stop-gate", async (input) => {
1414
2112
  } catch {
1415
2113
  finalClass = "real";
1416
2114
  }
2115
+ // card stop-gate-no-change-restop: record this red for reuse only when every configured command really
2116
+ // ran (not credited, none dropped for budget or timed out) and at least one failed, every failure by a
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
2118
+ // class so a reuse logs the same class. state.lastRed was cleared above, so every other path leaves none.
2119
+ try {
2120
+ const configured = resolveCommands(cfg);
2121
+ if (
2122
+ !skipVerification &&
2123
+ !credited &&
2124
+ !targeted &&
2125
+ commandFailures.length > 0 &&
2126
+ results.length === configured.length &&
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)))) &&
2128
+ postCommandProblems - preVerificationProblems === commandFailures.length &&
2129
+ verifiedCandidate
2130
+ ) {
2131
+ state.lastRed = {
2132
+ fingerprint: verifiedCandidate,
2133
+ scope: subagent ? subagent.id : "main",
2134
+ commands: configured.map((c) => c.command),
2135
+ summary: problems.slice(preVerificationProblems, postCommandProblems).join("\n\n"),
2136
+ class: finalClass,
2137
+ who,
2138
+ };
2139
+ saveState(dir, input.session_id, state);
2140
+ }
2141
+ } catch {
2142
+ state.lastRed = null;
2143
+ }
1417
2144
  logBlock(reason, finalClass);
1418
2145
  return blockStopReleasing(reason);
1419
2146
  };