@dougharness/cli 1.3.0 → 1.4.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 (38) hide show
  1. package/dist/bin.js +13 -4
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-page.d.ts +5 -1
  4. package/dist/board-page.js +19 -5
  5. package/dist/board-page.js.map +1 -1
  6. package/dist/board-serve.js +34 -10
  7. package/dist/board-serve.js.map +1 -1
  8. package/dist/board.d.ts +1 -1
  9. package/dist/board.js +160 -13
  10. package/dist/board.js.map +1 -1
  11. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
  12. package/node_modules/@dougharness/flow/hooks/register.tsx +18 -2
  13. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +94 -7
  14. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +31 -4
  15. package/node_modules/@dougharness/flow/lib/board.mjs +366 -21
  16. package/node_modules/@dougharness/flow/lib/card-loop.mjs +194 -20
  17. package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
  18. package/node_modules/@dougharness/flow/lib/memory.mjs +24 -1
  19. package/node_modules/@dougharness/flow/package.json +1 -1
  20. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +193 -12
  21. package/node_modules/@dougharness/flow/scripts/board.mjs +122 -13
  22. package/node_modules/@dougharness/flow/scripts/cost.mjs +6 -3
  23. package/node_modules/@dougharness/flow/scripts/memory.mjs +14 -7
  24. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +11 -4
  25. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +1 -1
  26. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +9 -2
  27. package/node_modules/@dougharness/flow/skills/research/SKILL.md +1 -1
  28. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +2 -2
  29. package/node_modules/@dougharness/flow/workflows/card-queen.js +195 -21
  30. package/node_modules/@dougharness/flow/workflows/doug-implement.js +21 -18
  31. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  32. package/node_modules/@dougharness/gates/lib/baseline.mjs +23 -0
  33. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +41 -3
  34. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +6 -2
  35. package/node_modules/@dougharness/gates/package.json +1 -1
  36. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +91 -18
  37. package/package.json +3 -3
  38. package/templates/board-app.js +240 -30
@@ -64,6 +64,9 @@ const ENVIRONMENT_MARKER = 'ENVIRONMENT ONLY'
64
64
  // The one rule about commit messages, in every prompt that commits or checks commits: this repository records
65
65
  // attribution nowhere in git, and plan.mjs land refuses a branch that carries either trailer.
66
66
  const NO_TRAILERS = 'Commit messages carry no attribution trailers: no Co-Authored-By line and no Claude-Session line, whatever your defaults say. A commit with either is a finding and the plan cannot land.'
67
+ // The one sentence about a relayed user message, in every stage prompt: Claude Code relays the user's mid-turn message to every
68
+ // seat of a Workflow run, and a stage must not stop for a message about other work (card queen-seats-relayed-user-message).
69
+ const RELAYED_MESSAGE = 'Your task is this plan task. A relayed user message about other work is outside this task: it is never a reason to stop or to return blocked; mention it in your summary if the result has one.'
67
70
  // The one rule about how a commit runs, in every implement prompt that commits: the pre-commit hook runs the full
68
71
  // unit suite, which outlives Bash's default timeout, and a background commit dies with the agent that started it.
69
72
  const FOREGROUND_COMMIT = 'Run git commit in the foreground with the Bash tool timeout set to 600000 ms (the pre-commit hook runs the full suite): never run_in_background, a Monitor, or a sleep loop. Take the sha only from git rev-parse HEAD after git commit has returned.'
@@ -837,7 +840,7 @@ function implementPrompt(task, baseBranch, opts) {
837
840
  ? ` Before that, if git rev-parse --verify --quiet refs/heads/doug/task-${task.id} succeeds, the branch doug/task-${task.id} was created by the previous agent for this task in this same run, which stopped without returning a result; it is yours, not stale. Find that agent's worktree with git worktree list --porcelain (the one with branch refs/heads/doug/task-${task.id}); if none is listed, go straight to the checkout. If it has uncommitted changes to owned files, commit them there first with a message starting "${task.id}: ". ${NO_TRAILERS} ${FOREGROUND_COMMIT} Then remove that worktree with git worktree remove --force <path>, then git checkout doug/task-${task.id} in your own worktree and continue the task from that branch's state (do not recreate the branch or reset it). Never return blocked for the branch existing.`
838
841
  : ` Before that, if git rev-parse --verify --quiet refs/heads/doug/task-${task.id} succeeds, the branch is a leftover of an earlier run: do not check it out or build on it; change nothing and return blocked=true with blockedReason "stale branch doug/task-${task.id} already exists; run plan.mjs replan, which renames it".`
839
842
  return [
840
- `You are implementing exactly one task from an approved plan. Work only in your own git worktree.`,
843
+ `You are implementing exactly one task from an approved plan. Work only in your own git worktree. ${RELAYED_MESSAGE}`,
841
844
  ``,
842
845
  `Plan: ${plan.title}`,
843
846
  `Goal: ${plan.goal}`,
@@ -865,7 +868,7 @@ function implementPrompt(task, baseBranch, opts) {
865
868
 
866
869
  function reusePrompt(task, baseBranch) {
867
870
  return [
868
- `You are starting a task from a branch that already passed verification, review, and the adversary on an earlier pass of this plan. Do not implement anything and do not edit any file; the checks run again on this branch after you.`,
871
+ `You are starting a task from a branch that already passed verification, review, and the adversary on an earlier pass of this plan. Do not implement anything and do not edit any file; the checks run again on this branch after you. ${RELAYED_MESSAGE}`,
869
872
  ``,
870
873
  `Plan: ${plan.title}`,
871
874
  `Task ${task.id}: ${task.title}`,
@@ -891,7 +894,7 @@ function leadPrompt(task, baseBranch, opts) {
891
894
  ? ` Before that, if git rev-parse --verify --quiet refs/heads/doug/task-${task.id} succeeds, the branch doug/task-${task.id} was created by the previous agent for this task in this same run, which stopped without returning a result; it is yours, not stale. Find that agent's worktree with git worktree list --porcelain (the one with branch refs/heads/doug/task-${task.id}); if none is listed, go straight to the checkout. If it has uncommitted changes to owned files, commit them there first with a message starting "${task.id}: ". ${NO_TRAILERS} ${FOREGROUND_COMMIT} Then remove that worktree with git worktree remove --force <path>, then git checkout doug/task-${task.id} in your own worktree and continue the task from that branch's state (do not recreate the branch or reset it). Never return blocked for the branch existing.`
892
895
  : ` Before that, if git rev-parse --verify --quiet refs/heads/doug/task-${task.id} succeeds, the branch is a leftover of an earlier run: do not check it out or build on it; change nothing and return blocked=true with blockedReason "stale branch doug/task-${task.id} already exists; run plan.mjs replan, which renames it".`
893
896
  return [
894
- `You are the lead of a swarm for exactly one task of an approved plan. You split the task into worker briefs; you do not implement anything and you edit no file. Work only in your own git worktree.`,
897
+ `You are the lead of a swarm for exactly one task of an approved plan. You split the task into worker briefs; you do not implement anything and you edit no file. Work only in your own git worktree. ${RELAYED_MESSAGE}`,
895
898
  ``,
896
899
  `Plan: ${plan.title}`,
897
900
  `Goal: ${plan.goal}`,
@@ -923,7 +926,7 @@ function workerPrompt(task, brief, n, lead) {
923
926
  // to and what is not its business; the task it belongs to still owns only the files its own brief lists below.
924
927
  const siblings = (Array.isArray(lead.briefs) ? lead.briefs : []).filter(b => b.id !== brief.id)
925
928
  return [
926
- `You are worker ${n} of a swarm implementing one part of task ${task.id} from an approved plan. You get one brief, the files it owns, and a verification command; the other parts are other workers' business. Work only in your own git worktree.`,
929
+ `You are worker ${n} of a swarm implementing one part of task ${task.id} from an approved plan. You get one brief, the files it owns, and a verification command; the other parts are other workers' business. Work only in your own git worktree. ${RELAYED_MESSAGE}`,
927
930
  ``,
928
931
  `Plan: ${plan.title}`,
929
932
  `Goal: ${plan.goal}`,
@@ -958,7 +961,7 @@ function workerPrompt(task, brief, n, lead) {
958
961
  // left an orphan - it defaults to `workers` for the plain, no-rebrief case where the two are the same list.
959
962
  function leadMergePrompt(task, lead, workers, baseBranch, allWorkers = workers) {
960
963
  return [
961
- `You are the lead of the swarm for task ${task.id}. The workers have committed; merge their branches into the task branch and hand it to the checks. Work only in the git worktree you were started in, on branch ${lead.branch}, and never edit anything under the repository root. The lead's worktree (${lead.worktreePath}) may have been removed, so do not cd into it. First git checkout ${lead.branch} there; if git refuses because the branch is checked out in another worktree, that worktree is a leftover under .claude/worktrees: find it with git worktree list --porcelain, remove it with git worktree remove --force <path>, and check out again. If the branch does not exist, create it from ${baseBranch} with git checkout -b ${lead.branch} ${baseBranch} (the lead commits nothing, so the task branch is the base).`,
964
+ `You are the lead of the swarm for task ${task.id}. The workers have committed; merge their branches into the task branch and hand it to the checks. Work only in the git worktree you were started in, on branch ${lead.branch}, and never edit anything under the repository root. The lead's worktree (${lead.worktreePath}) may have been removed, so do not cd into it. First git checkout ${lead.branch} there; if git refuses because the branch is checked out in another worktree, that worktree is a leftover under .claude/worktrees: find it with git worktree list --porcelain, remove it with git worktree remove --force <path>, and check out again. If the branch does not exist, create it from ${baseBranch} with git checkout -b ${lead.branch} ${baseBranch} (the lead commits nothing, so the task branch is the base). ${RELAYED_MESSAGE}`,
962
965
  PARTIAL_NEVER_MERGED,
963
966
  ``,
964
967
  `Task ${task.id}: ${task.title}`,
@@ -985,7 +988,7 @@ function leadMergePrompt(task, lead, workers, baseBranch, allWorkers = workers)
985
988
  function leadRebriefPrompt(task, lead, workers, blockedWorkers) {
986
989
  const finished = workers.filter(w => !w.blocked)
987
990
  return [
988
- `You are the lead of the swarm for task ${task.id}. One or more workers are blocked; you get one chance to re-brief the unfinished pieces. You implement nothing and edit no file. Work read-only from the repository root you were started in. Do not cd into the lead's worktree (it may have been removed), create no branch, and edit nothing. Read a finished worker's committed files with \`git show <commit>:<path>\` or \`git show <commit>\`.`,
991
+ `You are the lead of the swarm for task ${task.id}. One or more workers are blocked; you get one chance to re-brief the unfinished pieces. You implement nothing and edit no file. Work read-only from the repository root you were started in. Do not cd into the lead's worktree (it may have been removed), create no branch, and edit nothing. Read a finished worker's committed files with \`git show <commit>:<path>\` or \`git show <commit>\`. ${RELAYED_MESSAGE}`,
989
992
  ``,
990
993
  `Plan: ${plan.title}`,
991
994
  `Goal: ${plan.goal}`,
@@ -1020,7 +1023,7 @@ function fixPrompt(task, r, pass, baseBranch, ledger, brief) {
1020
1023
  ? `Work only in the git worktree you were started in, on branch ${r.impl.branch}: the task's earlier worktree (${r.impl.worktreePath}) was removed when its level integrated. First run git checkout ${r.impl.branch} there; if git refuses because the branch is checked out in another worktree, that worktree is a leftover under .claude/worktrees: find it with git worktree list --porcelain, remove it with git worktree remove --force <path>, and check out again. Create no branch, and never edit anything under the repository root.`
1021
1024
  : `Work only in the existing worktree ${r.impl.worktreePath} on branch ${r.impl.branch}: cd there first, create no worktree and no branch, and never edit anything under the repository root.`
1022
1025
  const lines = [
1023
- `You are fixing your own task after its checks blocked it. This is fix attempt ${pass - 1} of ${fixAttempts} for task ${task.id}. ${where}`,
1026
+ `You are fixing your own task after its checks blocked it. This is fix attempt ${pass - 1} of ${fixAttempts} for task ${task.id}. ${where} ${RELAYED_MESSAGE}`,
1024
1027
  ``,
1025
1028
  `Plan: ${plan.title}`,
1026
1029
  `Task ${task.id}: ${task.title}`,
@@ -1068,7 +1071,7 @@ function resumePrompt(task, prev, handoff, baseBranch, brief) {
1068
1071
  const completed = (Array.isArray(handoff.completed) ? handoff.completed : []).join('; ') || '(none)'
1069
1072
  const remaining = (Array.isArray(handoff.remaining) ? handoff.remaining : []).join('; ')
1070
1073
  return [
1071
- `You are resuming ${who} after the previous worker stopped partway through at the harness's context notice. Work only in the existing worktree ${prev.worktreePath} on branch ${prev.branch}: cd there first, create no worktree and no branch, and never edit anything under the repository root.`,
1074
+ `You are resuming ${who} after the previous worker stopped partway through at the harness's context notice. Work only in the existing worktree ${prev.worktreePath} on branch ${prev.branch}: cd there first, create no worktree and no branch, and never edit anything under the repository root. ${RELAYED_MESSAGE}`,
1072
1075
  ``,
1073
1076
  `Plan: ${plan.title}`,
1074
1077
  brief ? `Brief ${brief.id}: ${brief.title}` : `Task ${task.id}: ${task.title}`,
@@ -1104,7 +1107,7 @@ function supervisePrompt(task, r, attempts, ledger, signals) {
1104
1107
  ].join('\n')
1105
1108
  })
1106
1109
  return [
1107
- `You are the supervisor of task ${task.id}'s fix loop. The loop is stalled: ${stalls.join('; ')}. Read the passes and the ledger below and write a brief for the next fix pass: what was tried, what failed each time, and two or three alternative directions drawn from this evidence, each different from what the passes tried.`,
1110
+ `You are the supervisor of task ${task.id}'s fix loop. The loop is stalled: ${stalls.join('; ')}. Read the passes and the ledger below and write a brief for the next fix pass: what was tried, what failed each time, and two or three alternative directions drawn from this evidence, each different from what the passes tried. ${RELAYED_MESSAGE}`,
1108
1111
  ``,
1109
1112
  `Plan: ${plan.title}`,
1110
1113
  `Task ${task.id}: ${task.title}`,
@@ -1130,7 +1133,7 @@ function supervisePrompt(task, r, attempts, ledger, signals) {
1130
1133
 
1131
1134
  function verifyPrompt(task, impl, baseBranch, seat = 1) {
1132
1135
  return [
1133
- `You are a verifier. You did not write this code. Your job is to run it, not to read and approve it.`,
1136
+ `You are a verifier. You did not write this code. Your job is to run it, not to read and approve it. ${RELAYED_MESSAGE}`,
1134
1137
  ...(seat > 1 ? [`You are crew seat ${seat} of ${crewOf(task).testers} on this task: another verifier runs the same checks at the same time. Run the verification and acceptance commands yourself, and beyond them probe the behavior the spec implies that those commands may not cover: edge cases (empty, missing, boundary, and repeated input) and error paths (invalid input, a failing dependency). A defect you find is a finding even when every command passes.`] : []),
1135
1138
  ``,
1136
1139
  `Task ${task.id}: ${task.title}`,
@@ -1164,7 +1167,7 @@ function verifyPrompt(task, impl, baseBranch, seat = 1) {
1164
1167
  function checkPrompt(task, impl, prevCommit, baseBranch, ledger) {
1165
1168
  const open = ledgerText(ledger, 'open')
1166
1169
  return [
1167
- `You are the single check on a fix pass for task ${task.id}. You did not write this code. The full verifier and reviewer already ran on an earlier pass, so your job is to run the commands again and to read only what the fix changed.`,
1170
+ `You are the single check on a fix pass for task ${task.id}. You did not write this code. The full verifier and reviewer already ran on an earlier pass, so your job is to run the commands again and to read only what the fix changed. ${RELAYED_MESSAGE}`,
1168
1171
  ``,
1169
1172
  `Task ${task.id}: ${task.title}`,
1170
1173
  `Spec:`,
@@ -1198,7 +1201,7 @@ function checkPrompt(task, impl, prevCommit, baseBranch, ledger) {
1198
1201
  // The one check of a size-S task: the verifier's job and the reviewer's job in one agent on the verify row.
1199
1202
  function sizeCheckPrompt(task, impl, baseBranch) {
1200
1203
  return [
1201
- `You are the one check on size-S task ${task.id}: the verifier's job and the reviewer's job in one pass. You did not write this code. Run it, then read the diff ${baseBranch}...${impl.branch}. No separate verifier, reviewer, or adversary runs for this task; the adversary reviews the level's S tasks together on the integration branch after the merge, so what you miss reaches it.`,
1204
+ `You are the one check on size-S task ${task.id}: the verifier's job and the reviewer's job in one pass. You did not write this code. Run it, then read the diff ${baseBranch}...${impl.branch}. No separate verifier, reviewer, or adversary runs for this task; the adversary reviews the level's S tasks together on the integration branch after the merge, so what you miss reaches it. ${RELAYED_MESSAGE}`,
1202
1205
  ``,
1203
1206
  `Task ${task.id}: ${task.title}`,
1204
1207
  `Spec:`,
@@ -1249,7 +1252,7 @@ function levelAdversaryPrompt(li, sReady, levelBase, confirming) {
1249
1252
  `DOUG_SPEC_EOF`,
1250
1253
  ].join('\n')
1251
1254
  return [
1252
- `Run the adversarial reviewer for level ${li} of the plan (its size-S tasks, merged on ${integrationBranch}) and relay its verdict. Do not review the code yourself and do not edit anything.`,
1255
+ `Run the adversarial reviewer for level ${li} of the plan (its size-S tasks, merged on ${integrationBranch}) and relay its verdict. Do not review the code yourself and do not edit anything. ${RELAYED_MESSAGE}`,
1253
1256
  ``,
1254
1257
  `Run exactly this command with the Bash tool from the repository root (it pipes the spec on stdin; the timeout is ${adversary.timeoutMs} ms, so set the tool timeout at least that high):`,
1255
1258
  '```',
@@ -1265,7 +1268,7 @@ function levelAdversaryPrompt(li, sReady, levelBase, confirming) {
1265
1268
  function levelFallbackAdversaryPrompt(li, sReady, levelBase, why, confirming) {
1266
1269
  // Same scope as levelAdversaryPrompt (card flow-fallback-verify-scope): no verify list, the suite already ran at integrate.
1267
1270
  return [
1268
- `You are the adversarial reviewer for level ${li} of plan "${plan.title}", standing in for codex-review, which could not run (${why}). The size-S tasks below were merged into ${integrationBranch} with one focused check each and no adversary of their own. Your job is to refute them together, in worktree ${INTEGRATION_WORKTREE} (relative to the repository root); the diff is ${levelBase}...${integrationBranch}.`,
1271
+ `You are the adversarial reviewer for level ${li} of plan "${plan.title}", standing in for codex-review, which could not run (${why}). The size-S tasks below were merged into ${integrationBranch} with one focused check each and no adversary of their own. Your job is to refute them together, in worktree ${INTEGRATION_WORKTREE} (relative to the repository root); the diff is ${levelBase}...${integrationBranch}. ${RELAYED_MESSAGE}`,
1269
1272
  ``,
1270
1273
  ...levelSpecLines(sReady),
1271
1274
  `Plan acceptance criteria:`,
@@ -1288,7 +1291,7 @@ function levelFallbackAdversaryPrompt(li, sReady, levelBase, why, confirming) {
1288
1291
  // reviewer reading spec line by line is likely to miss, so two reviewers do not file the same finding twice.
1289
1292
  function reviewPrompt(task, impl, baseBranch, seat = 1) {
1290
1293
  return [
1291
- `You are a reviewer. Review the diff ${baseBranch}...${impl.branch} in worktree ${impl.worktreePath} against the task spec. Two passes: spec compliance first, then quality. ${baseBranch} is this branch's base (an integration branch when the task follows an earlier level); files that already exist there are not this task's changes.`,
1294
+ `You are a reviewer. Review the diff ${baseBranch}...${impl.branch} in worktree ${impl.worktreePath} against the task spec. Two passes: spec compliance first, then quality. ${baseBranch} is this branch's base (an integration branch when the task follows an earlier level); files that already exist there are not this task's changes. ${RELAYED_MESSAGE}`,
1292
1295
  ...(seat > 1 ? [
1293
1296
  ``,
1294
1297
  `You are reviewer ${seat} of ${seat}: another reviewer is reading this diff at the same time with the standard brief and will check the spec sentence by sentence and the owned-file list. Do not repeat that work. Concentrate on what a first reviewer is likely to miss: behavior on inputs the spec does not name, a test that passes for the wrong reason or asserts less than its name says, error paths and partial failure, concurrency and ordering, a call site elsewhere in the repository that the change silently breaks, and anything the spec implies but does not state. File only what you have shown; your findings are merged with the other reviewer's, and a duplicate collapses to one entry.`,
@@ -1350,7 +1353,7 @@ function adversaryPrompt(task, impl, baseBranch, pass, ledger) {
1350
1353
  `DOUG_SPEC_EOF`,
1351
1354
  ].join('\n')
1352
1355
  return [
1353
- `Run the adversarial reviewer for task ${task.id} and relay its verdict. Do not review the code yourself and do not edit anything.`,
1356
+ `Run the adversarial reviewer for task ${task.id} and relay its verdict. Do not review the code yourself and do not edit anything. ${RELAYED_MESSAGE}`,
1354
1357
  ``,
1355
1358
  `Run exactly this command with the Bash tool (it pipes the spec on stdin; the timeout is ${adversary.timeoutMs} ms, so set the tool timeout at least that high):`,
1356
1359
  '```',
@@ -1398,7 +1401,7 @@ function fallbackAdversaryPrompt(task, impl, baseBranch, why, pass, ledger) {
1398
1401
  // Same scope as adversaryPrompt's Codex seat (card flow-fallback-verify-scope): the task's own verify only, not plan.verify.
1399
1402
  const verify = [task.verify].filter(Boolean)
1400
1403
  return [
1401
- `You are the adversarial reviewer for task ${task.id}, standing in for codex-review, which could not run (${why}). Your job is to refute the change on branch ${impl.branch} in worktree ${impl.worktreePath}; the diff is ${baseBranch}...${impl.branch}. ${baseBranch} is this branch's base (an integration branch when the task follows an earlier level); files that already exist there are not this task's changes. Do not edit any file.`,
1404
+ `You are the adversarial reviewer for task ${task.id}, standing in for codex-review, which could not run (${why}). Your job is to refute the change on branch ${impl.branch} in worktree ${impl.worktreePath}; the diff is ${baseBranch}...${impl.branch}. ${baseBranch} is this branch's base (an integration branch when the task follows an earlier level); files that already exist there are not this task's changes. Do not edit any file. ${RELAYED_MESSAGE}`,
1402
1405
  ``,
1403
1406
  `Task ${task.id}: ${task.title}`,
1404
1407
  `Spec:`,
@@ -1429,7 +1432,7 @@ function integratePrompt(levelIndex, results, baseBranch, isLastLevel, keepWorkt
1429
1432
  const branches = results.map(r => r.impl.branch)
1430
1433
  const worktrees = results.map(r => r.impl.worktreePath)
1431
1434
  return [
1432
- `You are the integration owner for level ${levelIndex} of plan "${plan.title}".`,
1435
+ `You are the integration owner for level ${levelIndex} of plan "${plan.title}". ${RELAYED_MESSAGE}`,
1433
1436
  `Integration branch: ${integrationBranch}. Never check it out in the user's main working tree; use a dedicated worktree so the user's checkout is left exactly as it was.`,
1434
1437
  PARTIAL_NEVER_MERGED,
1435
1438
  `Merge these task branches into it, in this order: ${branches.join(', ')}.`,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "doug-gates",
3
3
  "displayName": "Doug Gates",
4
- "version": "1.3.0",
4
+ "version": "1.4.0",
5
5
  "description": "Deterministic guardrails for Claude Code: protected paths, bash guard, secret scan on edits and commands, format on edit, edit-loop detection, a verification gate on Stop and on SubagentStop, a snapshot of the plan and session into the anchor before compaction and re-anchoring after it, and a run trace of subagents and tool calls. Configured by .doug/config.json.",
6
6
  "author": {
7
7
  "name": "Doug contributors"
@@ -323,3 +323,26 @@ export function turnTreeStatus(cfg, start, current) {
323
323
  return null;
324
324
  }
325
325
  }
326
+
327
+ // card stop-gate-read-only-turn-commits: turnTreeStatus for a turn that moved HEAD. Applies only when every path
328
+ // committed between the record's head and HEAD is on preCommit.noTestPaths (same match rule as turnTreeStatus),
329
+ // and then compares the remaining code paths as if HEAD had not moved. Fails closed (null: the caller verifies)
330
+ // on a missing, empty or malformed list, a record head that is not a plausible sha or not an ancestor of HEAD
331
+ // (rewritten history), a git failure, an empty committed set, or any committed path off the list. Never throws.
332
+ export function turnTreeStatusHeadMoved(dir, cfg, start, current) {
333
+ try {
334
+ if (!start || !current || typeof start !== "object" || typeof current !== "object") return null;
335
+ const list = cfg && cfg.preCommit && cfg.preCommit.noTestPaths;
336
+ if (!Array.isArray(list) || list.length === 0 || !list.every((e) => typeof e === "string" && e !== "")) return null;
337
+ if (typeof start.head !== "string" || !GIT_SHA_RE.test(start.head)) return null;
338
+ if (typeof current.head !== "string" || !GIT_SHA_RE.test(current.head) || start.head === current.head) return null;
339
+ execFileSync("git", ["merge-base", "--is-ancestor", start.head, current.head], { cwd: dir, stdio: "ignore", timeout: 5000 }); // throws on exit != 0
340
+ const committed = committedSince(dir, start.head);
341
+ if (!Array.isArray(committed) || committed.length === 0) return null;
342
+ if (!committed.every((p) => list.some((e) => (e.endsWith("/") ? p.startsWith(e) : p === e)))) return null;
343
+ const status = turnTreeStatus(cfg, start, { ...current, head: start.head });
344
+ return status ? { ...status, exact: false } : null;
345
+ } catch {
346
+ return null;
347
+ }
348
+ }
@@ -9,6 +9,7 @@
9
9
  import { appendFileSync, mkdirSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { changedFiles } from "./baseline.mjs";
12
+ import { scanText } from "./secret-rules.mjs";
12
13
 
13
14
  export const BLOCKS_RELPATH = ".doug/.state/blocks.jsonl";
14
15
 
@@ -121,7 +122,7 @@ export function failingTestFilesFrom(output, dir) {
121
122
  // blockStop call that carries it out (stop-gate.mjs), so an unguarded throw here would fail the *whole gate*
122
123
  // open — the one outcome every hook reader must never risk (CLAUDE.md gotchas). Any failure classifies as
123
124
  // 'real', the least specific label, rather than ever propagating.
124
- export function classifyFinalBlock({ preVerificationProblems, evidenceProblemAdded, commandFailures, postCommandProblems, results, redByDesignClaimFiles, changedAtStart, dir, budgetDeadline }) {
125
+ export function classifyFinalBlock({ countProblems, totalProblems, preVerificationProblems, evidenceProblemAdded, commandFailures, postCommandProblems, results, redByDesignClaimFiles, changedAtStart, dir, budgetDeadline }) {
125
126
  try {
126
127
  const failures = Array.isArray(commandFailures) ? commandFailures : [];
127
128
  const namedByCommand = failures.map((cf) => failingTestFilesFrom(cf.output, dir));
@@ -140,9 +141,12 @@ export function classifyFinalBlock({ preVerificationProblems, evidenceProblemAdd
140
141
  if (allNamed.every((f) => claimSet.has(f))) return "expected-red";
141
142
  }
142
143
 
144
+ // card block-ledger-failing-output: the test-count ratchet is the block's only problem.
143
145
  const timedOutOrDropped = (Array.isArray(results) ? results : []).some((r) => r.timedOut || r.skipped === "budget");
144
146
  if (timedOutOrDropped && allNamed.length === 0) return "load-timeout";
145
147
 
148
+ if (typeof countProblems === "number" && countProblems > 0 && totalProblems === countProblems) return "test-count";
149
+
146
150
  if (Array.isArray(changedAtStart) && !(typeof budgetDeadline === "number" && Date.now() >= budgetDeadline)) {
147
151
  const changedNow = changedFiles(dir);
148
152
  if (Array.isArray(changedNow)) {
@@ -159,14 +163,47 @@ export function classifyFinalBlock({ preVerificationProblems, evidenceProblemAdd
159
163
  }
160
164
  }
161
165
 
162
- // Appends one line to .doug/.state/blocks.jsonl: { hook, at, agent, reason, class, session }. `reason` is
166
+ // card block-ledger-failing-output: the last 40 lines of a command's output, secret-scanned (a hit records
167
+ // "[redacted: <rule>]", a scanner throw "[redacted: scan-error]"; skipped when secrets.enabled === false),
168
+ // then capped at 4096 UTF-8 bytes keeping the end, never splitting a character. Never throws.
169
+ export function redactText(text, secrets) {
170
+ try {
171
+ if (!(secrets && secrets.enabled === false)) {
172
+ const hit = scanText(text, secrets || {});
173
+ if (hit) return "[redacted: " + hit.rule + "]";
174
+ }
175
+ return text;
176
+ } catch {
177
+ return "[redacted: scan-error]";
178
+ }
179
+ }
180
+
181
+ export function outputTail(output, secrets) {
182
+ try {
183
+ let tail = String(output ?? "").trimEnd().split("\n").slice(-40).join("\n");
184
+ tail = redactText(tail, secrets);
185
+ const buf = Buffer.from(tail, "utf8");
186
+ if (buf.length > 4096) {
187
+ let start = buf.length - 4096;
188
+ while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++;
189
+ tail = buf.subarray(start).toString("utf8");
190
+ }
191
+ return tail;
192
+ } catch {
193
+ return "";
194
+ }
195
+ }
196
+
197
+ // Appends one line to .doug/.state/blocks.jsonl: { hook, at, agent, reason, class, session }, plus the keys of
198
+ // `record` ({ failures, cwd, tree }, card block-ledger-failing-output) when it is a plain object. `reason` is
163
199
  // reduced to its own first line here (message.split("\n")[0]) whatever the caller passes, so a multi-line
164
200
  // block message never leaks past its headline into the ledger. Call synchronously, right before each
165
201
  // blockStop/systemMessage in stop-gate.mjs — those each call process.exit(0) themselves, so anything queued
166
202
  // after them never runs. Never throws: an unwritable .doug/.state, or a blocks.jsonl path that is itself a
167
203
  // directory, just skips the line silently; the gate's own decision is unaffected either way.
168
- export function appendBlockLine(dir, { hook, agent, reason, class: cls, session }) {
204
+ export function appendBlockLine(dir, { hook, agent, reason, class: cls, session, record }) {
169
205
  try {
206
+ const plain = record && typeof record === "object" && !Array.isArray(record) ? record : {};
170
207
  const line =
171
208
  JSON.stringify({
172
209
  hook,
@@ -175,6 +212,7 @@ export function appendBlockLine(dir, { hook, agent, reason, class: cls, session
175
212
  reason: String(reason).split("\n")[0],
176
213
  class: cls,
177
214
  session: session ?? null,
215
+ ...plain,
178
216
  }) + "\n";
179
217
  mkdirSync(join(dir, ".doug/.state"), { recursive: true });
180
218
  appendFileSync(join(dir, BLOCKS_RELPATH), line);
@@ -2,7 +2,8 @@
2
2
  // (.githooks/pre-commit). A changed docs/**/*.md file does not by itself run the suite. When every changed path
3
3
  // is on preCommit.noTestPaths or is a docs .md (and at least one is a docs .md not on the list), the tracked
4
4
  // test files whose text names that doc (its repo-relative path, or its basename, which also covers a
5
- // join("docs", "decisions", "<basename>") spelling) decide: none -> { kind: "skip" }; some -> { kind:
5
+ // join("docs", "decisions", "<basename>") spelling) decide; non-test code outside a tests/ directory counts a
6
+ // doc as named only by its full repo-relative path (a basename in a comment is ignored): none -> { kind: "skip" }; some -> { kind:
6
7
  // "targeted", files } (repo-relative, sorted). Anything else, or any doubt, returns null: run the full gate.
7
8
  // Never throws (a throw would reach runHook, which fails open and would skip the gate).
8
9
  // CLI (used by .githooks/pre-commit): staged paths on stdin, the config JSON in DOUG_NO_TEST_CONFIG, cwd =
@@ -34,7 +35,8 @@ function validList(cfg) {
34
35
  // -> sorted test files naming any of `docs`, or null (unreadable tree, or a file that cannot be mapped to test
35
36
  // files names a doc).
36
37
  function namingTests(dir, docs, tracked) {
37
- const needles = [...new Set(docs.flatMap((d) => [d, basename(d)]))];
38
+ const pathNeedles = [...new Set(docs)];
39
+ const wideNeedles = [...new Set(docs.flatMap((d) => [d, basename(d)]))];
38
40
  const found = [];
39
41
  for (const f of tracked) {
40
42
  const isTest = TEST_FILE.test(f);
@@ -43,6 +45,8 @@ function namingTests(dir, docs, tracked) {
43
45
  if (!isTest && !underTests && !code) continue;
44
46
  if (f.startsWith("docs/")) continue;
45
47
  const text = readFileSync(join(dir, f), "utf8");
48
+ // code outside tests/ names a doc only by its full repo-relative path; tests and test helpers also by basename
49
+ const needles = isTest || underTests ? wideNeedles : pathNeedles;
46
50
  if (!needles.some((n) => text.includes(n))) continue;
47
51
  if (!isTest) return null; // a helper or source file reads the doc: cannot be mapped to test files
48
52
  found.push(f);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dougharness/gates",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "private": true,
5
5
  "description": "Deterministic Claude Code hooks: protected paths, bash guard, secret scan on edits and commands, format on edit, edit-loop detection, stop gate, re-anchor after compaction.",
6
6
  "type": "module",
@@ -85,13 +85,13 @@ import { runCommand, failureSummary } from "../lib/run.mjs";
85
85
  import { loadScopePlan, scopeViolations, describeViolations, PLAN_RELPATH } from "../lib/scope.mjs";
86
86
  import { checkpoint, describeCheckpoint } from "../lib/checkpoint.mjs";
87
87
  import { verificationEvidence, describeMissingEvidence } from "../lib/evidence.mjs";
88
- import { changedFiles, captureBaseline, sinceBaseline, headAt, committedSince, walkProposalPathFiles, fileIdentity, treeFingerprint, sameTree, readAgentStart, readTurnStart, turnTreeStatus, onlyNoTestPathsChanged, movedSinceVerified } from "../lib/baseline.mjs";
88
+ import { changedFiles, captureBaseline, sinceBaseline, headAt, committedSince, walkProposalPathFiles, fileIdentity, treeFingerprint, sameTree, readAgentStart, readTurnStart, turnTreeStatus, turnTreeStatusHeadMoved, onlyNoTestPathsChanged, movedSinceVerified } from "../lib/baseline.mjs";
89
89
  import { tracePath, readTrace, traceLine, appendTrace, agentLabel } from "../lib/trace.mjs";
90
90
  import { isAppliedContent } from "../lib/proposals.mjs";
91
91
  import { gitHead, mergeSnapshot, readDecisions, readPlanSummary, readPlanSummaries, snapshotSection } from "../lib/anchor.mjs";
92
92
  import { contextReading, recordAgentContext } from "../lib/agent-context.mjs";
93
93
  import { parseTotals, readSummaries, sumTotals, compareTotals, deltaTotals, waiverCovers } from "../lib/test-totals.mjs";
94
- import { failingTestFilesFrom, nonTestTscPathsFrom, classifyFinalBlock, appendBlockLine } from "../lib/block-ledger.mjs";
94
+ import { failingTestFilesFrom, nonTestTscPathsFrom, classifyFinalBlock, appendBlockLine, outputTail, redactText } from "../lib/block-ledger.mjs";
95
95
  import { docsGateDecision } from "../lib/docs-gate.mjs";
96
96
  import { gateLockPath, readGateLock, pidIsLive, writeGateLock, takeOverGateLock, resolveHookTimeoutSec, lockStaleByAge, normalizedTimeoutMs, lockWaitBoundMs } from "../lib/gate-lock.mjs";
97
97
  import { resolveCommands, computeTree, readRecord, writeRecord as writeTreeRecord, removeRecord as removeTreeRecord, creditable } from "../lib/verified-tree.mjs";
@@ -468,6 +468,21 @@ function countingInFlight(dir, cfg, sessionId, inFlight) {
468
468
  // and whose ok is not exactly false (a null ok counts as success — conservative). No change found -> true.
469
469
  //
470
470
  // Without trace evidence, true only when subagent.type is in READ_ONLY_AGENT_TYPES.
471
+ // card ratchet-stale-session-baseline: true only when the trace vouches for the agent's whole life (its
472
+ // SubagentStart line is there) and shows no successful Edit-type PostToolUse of its own. Bash does not count.
473
+ function agentMadeNoEditCalls(dir, cfg, sessionId, subagent) {
474
+ try {
475
+ if (!subagent || !cfg.trace || cfg.trace.enabled === false) return false;
476
+ const file = tracePath(dir, sessionId);
477
+ if (!existsSync(file)) return false;
478
+ const lines = readTrace(file);
479
+ if (!lines.some((l) => l.event === "SubagentStart" && l.agent === subagent.id)) return false;
480
+ return !lines.some((l) => l.agent === subagent.id && l.event === "PostToolUse" && CHANGE_TOOLS.has(l.tool) && l.ok !== false);
481
+ } catch {
482
+ return false;
483
+ }
484
+ }
485
+
471
486
  function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
472
487
  try {
473
488
  if (!subagent) return false;
@@ -819,7 +834,15 @@ function countCheck(dir, cfg, state, input, ran, vdir) {
819
834
  // card worktree-count-baseline: a worktree run compares against its own earlier green runs, never main's record
820
835
  // (a worktree can lack the dist build main has, so its skipped count differs). No record yet means no check.
821
836
  const inWorktree = typeof vdir === "string" && vdir !== dir;
822
- const baseline = inWorktree ? readLastGreen(vdir) : state.testTotals;
837
+ // card ratchet-stale-session-baseline: a main-checkout run compares against the main tree's live last-green
838
+ // record too (per command), not the session-start copy; the copy is the fallback when the record has no
839
+ // readable entry for the command, so a deleted record does not remove the check.
840
+ const worktreeBaseline = inWorktree ? readLastGreen(vdir) : null;
841
+ const mainRecord = inWorktree ? null : readLastGreen(dir);
842
+ const baselineFor = (command) => {
843
+ if (inWorktree) return worktreeBaseline;
844
+ return mainRecord && readSummaries(mainRecord[command]) ? mainRecord : state.testTotals;
845
+ };
823
846
  const problems = [];
824
847
  const parsed = {};
825
848
  // The note carries its own event name, never the stop event's: a decision-less SubagentStop line would read to
@@ -843,6 +866,7 @@ function countCheck(dir, cfg, state, input, ran, vdir) {
843
866
  note(`count check did not run for \`${command}\`: no test summary in its output`);
844
867
  continue;
845
868
  }
869
+ const baseline = baselineFor(command);
846
870
  const all = baseline && typeof baseline === "object" ? baseline : null;
847
871
  const baseList = all ? readSummaries(all[command]) : null;
848
872
  if (!baseList) {
@@ -1209,8 +1233,8 @@ runHook("stop-gate", async (input) => {
1209
1233
  traceSubagentDecision("allow", traceReason, extra);
1210
1234
  return allow();
1211
1235
  };
1212
- const blockStopDecided = (reason) => {
1213
- traceSubagentDecision("block", null);
1236
+ const blockStopDecided = (reason, extra) => {
1237
+ traceSubagentDecision("block", null, extra);
1214
1238
  return blockStop(reason);
1215
1239
  };
1216
1240
  const systemMessageDecided = (message, traceReason, extra) => {
@@ -1220,8 +1244,19 @@ runHook("stop-gate", async (input) => {
1220
1244
  // Card stop-gate-block-ledger: one ledger line per block/stand-down below, written synchronously right
1221
1245
  // before the blockStop/systemMessage call it belongs to (those call process.exit(0) themselves, so
1222
1246
  // nothing queued after them ever runs). appendBlockLine never throws.
1223
- const logBlock = (reason, cls) =>
1224
- appendBlockLine(dir, { hook: subagent ? "subagent-stop" : "stop-gate", agent: subagent ? subagent.type : null, reason, class: cls, session: input.session_id });
1247
+ const logBlock = (reason, cls, record) =>
1248
+ appendBlockLine(dir, { hook: subagent ? "subagent-stop" : "stop-gate", agent: subagent ? subagent.type : null, reason, class: cls, session: input.session_id, record });
1249
+ // card block-ledger-failing-output: a main Stop verification block has no decision trace line of its own;
1250
+ // append one carrying the record. A SubagentStop's line comes from traceSubagentDecision. Never throws.
1251
+ const traceMainBlock = (record) => {
1252
+ if (subagent) return;
1253
+ try {
1254
+ if (!cfg.trace || cfg.trace.enabled === false) return;
1255
+ appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: "Stop", decision: "block", reason: null, ...record });
1256
+ } catch {
1257
+ // never block the real exit on a trace failure
1258
+ }
1259
+ };
1225
1260
  // Card block-ledger-stood-down-repeats: a stood-down line is written at most once per session per
1226
1261
  // cause, so a session standing down at its cap does not append a fresh identical line on every later
1227
1262
  // Stop/SubagentStop past it. Cause key: "turn-budget" (lead only), or "block-cap:<lead|agent id>" — the
@@ -1418,7 +1453,10 @@ runHook("stop-gate", async (input) => {
1418
1453
  let readOnly = null;
1419
1454
  try {
1420
1455
  const start = readTurnStart(dir, input.session_id);
1421
- const status = turnTreeStatus(cfg, start, treeFingerprint(dir, cfg, changed));
1456
+ const currentFp = treeFingerprint(dir, cfg, changed);
1457
+ let status = turnTreeStatus(cfg, start, currentFp);
1458
+ // A turn that only committed noTestPaths files moved HEAD but changed no code (card stop-gate-read-only-turn-commits).
1459
+ if (status && !status.same) status = turnTreeStatusHeadMoved(dir, cfg, start, currentFp) || status;
1422
1460
  if (status && status.same) {
1423
1461
  let editedThisTurn = false;
1424
1462
  if (cfg.trace && cfg.trace.enabled !== false) {
@@ -1723,8 +1761,14 @@ runHook("stop-gate", async (input) => {
1723
1761
  [...treeProblems, lastRed.summary].join("\n\n");
1724
1762
  const reuseClass = typeof lastRed.class === "string" ? lastRed.class : "real";
1725
1763
  const capped = deferralCapNote && !subagent ? `${deferralCapNote}\n\n${reason}` : reason;
1726
- logBlock(reason, reuseClass);
1727
- return blockStopDecided(capped);
1764
+ const reuseRecord = {
1765
+ failures: Array.isArray(lastRed.failures) ? lastRed.failures : [],
1766
+ cwd: typeof lastRed.cwd === "string" ? lastRed.cwd : null,
1767
+ tree: typeof lastRed.tree === "string" ? lastRed.tree : null,
1768
+ };
1769
+ logBlock(reason, reuseClass, reuseRecord);
1770
+ traceMainBlock(reuseRecord);
1771
+ return blockStopDecided(capped, reuseRecord);
1728
1772
  }
1729
1773
  }
1730
1774
 
@@ -1869,9 +1913,9 @@ runHook("stop-gate", async (input) => {
1869
1913
  if (deferralCapNote && !subagent) return systemMessageDecided(deferralCapNote, traceReason, extra);
1870
1914
  return allowDecided(traceReason, extra);
1871
1915
  };
1872
- const blockStopReleasing = (reason) => {
1916
+ const blockStopReleasing = (reason, extra) => {
1873
1917
  releaseLock();
1874
- return blockStopDecided(deferralCapNote && !subagent ? `${deferralCapNote}\n\n${reason}` : reason);
1918
+ return blockStopDecided(deferralCapNote && !subagent ? `${deferralCapNote}\n\n${reason}` : reason, extra);
1875
1919
  };
1876
1920
  const systemMessageReleasing = (message, traceReason, extra) => {
1877
1921
  releaseLock();
@@ -1906,6 +1950,19 @@ runHook("stop-gate", async (input) => {
1906
1950
  // One entry per failed command, `{ command, output }` (stderr + stdout, untruncated), for the
1907
1951
  // tests_red_by_design coverage check below (design point 2, card subagent-stop-gate-tester-red).
1908
1952
  const commandFailures = [];
1953
+ // card block-ledger-failing-output: the failing commands, the directory and the tree this block verified.
1954
+ const buildBlockRecord = () => {
1955
+ try {
1956
+ const failures = commandFailures.map((cf, i) => {
1957
+ const r = failedRuns[i] || {};
1958
+ return { command: redactText(cf.command, cfg.secrets), exit: typeof r.status === "number" ? r.status : null, timedOut: !!r.timedOut, tail: outputTail(cf.output, cfg.secrets) };
1959
+ });
1960
+ return { failures, cwd: vdir, tree: treeBefore ?? null };
1961
+ } catch {
1962
+ return { failures: [], cwd: vdir, tree: null }; // the line is written without the failures; the decision is unchanged
1963
+ }
1964
+ };
1965
+ const failedRuns = []; // parallel to commandFailures: { status, timedOut } of each failed run (card block-ledger-failing-output)
1909
1966
  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
1910
1967
  // card stop-gate-credits-pre-commit: a passing pre-commit record for this exact tree and command set
1911
1968
  // stands in for actually running them — see preCommitCredit above. Every other check below (evidence,
@@ -1953,6 +2010,7 @@ runHook("stop-gate", async (input) => {
1953
2010
  // commandFailures below keeps the untruncated output for the tests_red_by_design coverage check.
1954
2011
  problems.push(`\`${command}\` failed (${why}):\n${failureSummary(output, 40)}`);
1955
2012
  commandFailures.push({ command, output });
2013
+ failedRuns.push({ status: r.status, timedOut: r.timedOut });
1956
2014
  }
1957
2015
  }
1958
2016
  }
@@ -2006,8 +2064,17 @@ runHook("stop-gate", async (input) => {
2006
2064
 
2007
2065
  // 3c. Test-count ratchet (card test-count-ratchet): its own kind of problem, not a failed command.
2008
2066
  const counted = countCheck(dir, cfg, state, input, ranGreen, vdir);
2009
- problems.push(...counted.problems);
2010
- const countProblemAdded = counted.problems.length > 0;
2067
+ // card ratchet-stale-session-baseline: a seat with no Edit-type call is never blocked by a count change it did
2068
+ // not cause. Its count problems become one trace note, and that stop writes neither the record nor state.verified.
2069
+ const countExempt = counted.problems.length > 0 && !!subagent && agentMadeNoEditCalls(dir, cfg, input.session_id, subagent);
2070
+ if (countExempt) {
2071
+ try {
2072
+ if (cfg.trace && cfg.trace.enabled !== false) appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: "CountCheck", reason: `count change not blocking a seat that made no Edit-type call: ${counted.problems[0].split("\n")[0].slice(0, 300)}` });
2073
+ } catch {
2074
+ // logging must never fail the gate
2075
+ }
2076
+ } else problems.push(...counted.problems);
2077
+ const countProblemAdded = !countExempt && counted.problems.length > 0;
2011
2078
 
2012
2079
  // A main Stop's skip has no decision-tagged exit line like a SubagentStop's (allowReleasing folds the reason
2013
2080
  // into that); record the reason here so the trace carries it (card stop-gate-no-test-paths).
@@ -2025,8 +2092,8 @@ runHook("stop-gate", async (input) => {
2025
2092
  // (card stop-gate-read-only-subagent-skip): nothing was actually verified.
2026
2093
  // A read-only-turn skip verified nothing: it leaves lastRed (and stopBlocks, below) as they were.
2027
2094
  if (skipReason !== "read-only-turn") state.lastRed = null; // card stop-gate-no-change-restop: re-set at the final block below when it qualifies
2028
- if (problems.length === 0 && !skipVerification && !targeted) state.verified = verifiedCandidate;
2029
- if (problems.length === 0 && !skipVerification) writeLastGreen(vdir || dir, counted.parsed); // empty when nothing really ran
2095
+ if (problems.length === 0 && !skipVerification && !targeted && !countExempt) state.verified = verifiedCandidate;
2096
+ if (problems.length === 0 && !skipVerification && !countExempt) writeLastGreen(vdir || dir, counted.parsed); // empty when nothing really ran
2030
2097
  if (problems.length === 0 && subagent) {
2031
2098
  // A subagent's green gate: its counter resets; the checkpoint, if any, belongs to the session's own Stop.
2032
2099
  state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: 0 };
@@ -2126,6 +2193,7 @@ runHook("stop-gate", async (input) => {
2126
2193
  summary: problems.slice(preVerificationProblems, postCommandProblems).join("\n\n"),
2127
2194
  class: redClass,
2128
2195
  who,
2196
+ ...buildBlockRecord(),
2129
2197
  };
2130
2198
  }
2131
2199
  } catch {
@@ -2164,6 +2232,8 @@ runHook("stop-gate", async (input) => {
2164
2232
  let finalClass = "real";
2165
2233
  try {
2166
2234
  finalClass = classifyFinalBlock({
2235
+ countProblems: countExempt ? 0 : counted.problems.length,
2236
+ totalProblems: problems.length,
2167
2237
  preVerificationProblems,
2168
2238
  evidenceProblemAdded,
2169
2239
  commandFailures,
@@ -2177,6 +2247,7 @@ runHook("stop-gate", async (input) => {
2177
2247
  } catch {
2178
2248
  finalClass = "real";
2179
2249
  }
2250
+ const blockRecord = buildBlockRecord();
2180
2251
  // card stop-gate-no-change-restop: record this red for reuse only when every configured command really
2181
2252
  // ran (not credited, none dropped for budget or timed out) and at least one failed, every failure by a
2182
2253
  // 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
@@ -2200,14 +2271,16 @@ runHook("stop-gate", async (input) => {
2200
2271
  summary: problems.slice(preVerificationProblems, postCommandProblems).join("\n\n"),
2201
2272
  class: finalClass,
2202
2273
  who,
2274
+ ...blockRecord,
2203
2275
  };
2204
2276
  saveState(dir, input.session_id, state);
2205
2277
  }
2206
2278
  } catch {
2207
2279
  state.lastRed = null;
2208
2280
  }
2209
- logBlock(reason, finalClass);
2210
- return blockStopReleasing(reason);
2281
+ logBlock(reason, finalClass, blockRecord);
2282
+ traceMainBlock(blockRecord);
2283
+ return blockStopReleasing(reason, blockRecord);
2211
2284
  };
2212
2285
 
2213
2286
  return runVerification();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dougharness/cli",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "doug: tailored, approval-gated harness install for Claude Code.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,8 +23,8 @@
23
23
  "node": ">=22.16"
24
24
  },
25
25
  "dependencies": {
26
- "@dougharness/flow": "1.3.0",
27
- "@dougharness/gates": "1.3.0"
26
+ "@dougharness/flow": "1.4.0",
27
+ "@dougharness/gates": "1.4.0"
28
28
  },
29
29
  "bundleDependencies": [
30
30
  "@dougharness/flow",