@dougharness/cli 1.2.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 (47) 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/agents/implementer.md +1 -1
  13. package/node_modules/@dougharness/flow/agents/planner.md +1 -1
  14. package/node_modules/@dougharness/flow/agents/researcher.md +2 -1
  15. package/node_modules/@dougharness/flow/agents/reviewer.md +3 -1
  16. package/node_modules/@dougharness/flow/hooks/register.tsx +67 -32
  17. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +94 -7
  18. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +31 -4
  19. package/node_modules/@dougharness/flow/lib/board.mjs +366 -21
  20. package/node_modules/@dougharness/flow/lib/card-loop.mjs +229 -23
  21. package/node_modules/@dougharness/flow/lib/coordinator.mjs +25 -0
  22. package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
  23. package/node_modules/@dougharness/flow/lib/decisions.mjs +4 -2
  24. package/node_modules/@dougharness/flow/lib/memory.mjs +382 -65
  25. package/node_modules/@dougharness/flow/lib/plan.mjs +1 -1
  26. package/node_modules/@dougharness/flow/package.json +1 -1
  27. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +228 -14
  28. package/node_modules/@dougharness/flow/scripts/board.mjs +122 -13
  29. package/node_modules/@dougharness/flow/scripts/cost.mjs +6 -3
  30. package/node_modules/@dougharness/flow/scripts/memory.mjs +27 -8
  31. package/node_modules/@dougharness/flow/scripts/plan.mjs +5 -1
  32. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +11 -4
  33. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +1 -1
  34. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +9 -2
  35. package/node_modules/@dougharness/flow/skills/research/SKILL.md +2 -2
  36. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +2 -2
  37. package/node_modules/@dougharness/flow/workflows/card-queen.js +238 -25
  38. package/node_modules/@dougharness/flow/workflows/doug-implement.js +77 -34
  39. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  40. package/node_modules/@dougharness/gates/lib/baseline.mjs +23 -0
  41. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +41 -3
  42. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +6 -2
  43. package/node_modules/@dougharness/gates/package.json +1 -1
  44. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +119 -15
  45. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +107 -20
  46. package/package.json +3 -3
  47. package/templates/board-app.js +240 -30
@@ -4,7 +4,7 @@ export const meta = {
4
4
  whenToUse: 'After /doug-plan produced .doug/plan.json and the user approved it with /doug-approve. Pass the plan JSON as args.',
5
5
  phases: [
6
6
  { title: 'Implement', detail: 'one implementer per task, fresh context, own worktree (with swarm on, a lead splits a full-shape task into worker briefs, workers implement them in parallel, and the lead merges); a task marked reuse checks out its recorded branch instead; a task whose dependency did not integrate is never launched; a block the checks can describe is fixed in place until the task budget, the fix-attempt ceiling, a pass that made no new commit, or two consecutive passes raising a new blocker stops it' },
7
- { title: 'Verify', detail: 'a separate verifier runs the task command in the worktree on the first pass; a fix pass runs one focused check over the fix delta instead, and the full verifier again only when the verifier is the stage that blocked' },
7
+ { title: 'Verify', detail: 'a separate verifier runs the task command in the worktree on the first pass; a fix pass runs one focused check over the fix delta instead, and the full verifier again only when the verifier is the stage that blocked; a task whose crew names more testers gets extra verifiers in parallel' },
8
8
  { title: 'Review', detail: 'spec compliance, then quality, against the diff on the first pass; on a fix pass the focused check carries scope and regression risk unless the reviewer is the stage that blocked; a task whose crew names two reviewers gets a second one in parallel with a distinct brief' },
9
9
  { title: 'Adversary', detail: 'codex-review (a different model, read-only) explores freely on the first pass and confirms the open findings on later passes, under one material blocker gate; when it cannot run, a Claude adversary on plan.adversary.fallback stands in; blockers stop integration' },
10
10
  { title: 'Integrate', detail: 'merge the level into the integration branch and run plan.verify' },
@@ -64,6 +64,12 @@ 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.'
70
+ // The one rule about how a commit runs, in every implement prompt that commits: the pre-commit hook runs the full
71
+ // unit suite, which outlives Bash's default timeout, and a background commit dies with the agent that started it.
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.'
67
73
  const LESSONS_NOT_SPEC = 'These are context from earlier runs, not part of the spec or the acceptance criteria; never report a finding, a spec contradiction, or a blocker against a lesson.'
68
74
  // The one rule about a partial result, in every prompt a worker (or a resumed worker) can end with one from
69
75
  // (card worker-context-handoff): quotes the harness's context notice (a PostToolUse hook in plugins/doug-gates) so
@@ -831,10 +837,10 @@ function ledgerText(ledger, kind) {
831
837
  function implementPrompt(task, baseBranch, opts) {
832
838
  const resume = !!(opts && opts.resume)
833
839
  const branchCheck = resume
834
- ? ` 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} 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.`
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.`
835
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".`
836
842
  return [
837
- `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}`,
838
844
  ``,
839
845
  `Plan: ${plan.title}`,
840
846
  `Goal: ${plan.goal}`,
@@ -854,7 +860,7 @@ function implementPrompt(task, baseBranch, opts) {
854
860
  ` A fresh worktree has no installed dependencies: if the verification command needs them, run the project's install command first${plan.install ? ` (${plan.install})` : ''}. Installing is not a file change you own; do not commit lockfile changes it makes.`,
855
861
  `2. Touch no file outside your owned list. If the task cannot be done without another file, stop and report blocked=true with the reason.`,
856
862
  `3. Run the verification command before finishing and report each command with its real exit status. Never report a command you did not run.`,
857
- `4. Commit your work on the branch with a message starting "${task.id}: ". ${NO_TRAILERS}`,
863
+ `4. Commit your work on the branch with a message starting "${task.id}: ". ${NO_TRAILERS} ${FOREGROUND_COMMIT}`,
858
864
  `5. Return the structured result. filesTouched must come from git, not memory, and commit must be the output of git rev-parse HEAD in your worktree after that commit.`,
859
865
  `6. ${PARTIAL_RULE}`,
860
866
  ].join('\n')
@@ -862,7 +868,7 @@ function implementPrompt(task, baseBranch, opts) {
862
868
 
863
869
  function reusePrompt(task, baseBranch) {
864
870
  return [
865
- `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}`,
866
872
  ``,
867
873
  `Plan: ${plan.title}`,
868
874
  `Task ${task.id}: ${task.title}`,
@@ -885,10 +891,10 @@ function reusePrompt(task, baseBranch) {
885
891
  function leadPrompt(task, baseBranch, opts) {
886
892
  const resume = !!(opts && opts.resume)
887
893
  const branchCheck = resume
888
- ? ` 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} 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.`
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.`
889
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".`
890
896
  return [
891
- `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}`,
892
898
  ``,
893
899
  `Plan: ${plan.title}`,
894
900
  `Goal: ${plan.goal}`,
@@ -920,7 +926,7 @@ function workerPrompt(task, brief, n, lead) {
920
926
  // to and what is not its business; the task it belongs to still owns only the files its own brief lists below.
921
927
  const siblings = (Array.isArray(lead.briefs) ? lead.briefs : []).filter(b => b.id !== brief.id)
922
928
  return [
923
- `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}`,
924
930
  ``,
925
931
  `Plan: ${plan.title}`,
926
932
  `Goal: ${plan.goal}`,
@@ -944,7 +950,7 @@ function workerPrompt(task, brief, n, lead) {
944
950
  ` A fresh worktree has no installed dependencies: if the verification command needs them, run the project's install command first${plan.install ? ` (${plan.install})` : ''}. Installing is not a file change you own; do not commit lockfile changes it makes.`,
945
951
  `2. Touch no file outside your owned list. If the brief cannot be done without another file, stop and report blocked=true with a blockedReason that names exactly what you need - the file, the symbol or interface, or the decision - because the lead may re-brief you once on it; a vague reason wastes that chance.`,
946
952
  `3. Run the verification command before finishing and report each command with its real exit status. Never report a command you did not run.`,
947
- `4. Commit your work on the branch with a message starting "${task.id}: w${n} ". ${NO_TRAILERS}`,
953
+ `4. Commit your work on the branch with a message starting "${task.id}: w${n} ". ${NO_TRAILERS} ${FOREGROUND_COMMIT}`,
948
954
  `5. Return the structured result. filesTouched must come from git diff --name-only ${lead.branch}...HEAD, and commit must be the output of git rev-parse HEAD in your worktree after that commit.`,
949
955
  `6. ${PARTIAL_RULE}`,
950
956
  ].join('\n')
@@ -955,7 +961,7 @@ function workerPrompt(task, brief, n, lead) {
955
961
  // left an orphan - it defaults to `workers` for the plain, no-rebrief case where the two are the same list.
956
962
  function leadMergePrompt(task, lead, workers, baseBranch, allWorkers = workers) {
957
963
  return [
958
- `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}`,
959
965
  PARTIAL_NEVER_MERGED,
960
966
  ``,
961
967
  `Task ${task.id}: ${task.title}`,
@@ -982,7 +988,7 @@ function leadMergePrompt(task, lead, workers, baseBranch, allWorkers = workers)
982
988
  function leadRebriefPrompt(task, lead, workers, blockedWorkers) {
983
989
  const finished = workers.filter(w => !w.blocked)
984
990
  return [
985
- `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}`,
986
992
  ``,
987
993
  `Plan: ${plan.title}`,
988
994
  `Goal: ${plan.goal}`,
@@ -1017,7 +1023,7 @@ function fixPrompt(task, r, pass, baseBranch, ledger, brief) {
1017
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.`
1018
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.`
1019
1025
  const lines = [
1020
- `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}`,
1021
1027
  ``,
1022
1028
  `Plan: ${plan.title}`,
1023
1029
  `Task ${task.id}: ${task.title}`,
@@ -1048,7 +1054,7 @@ function fixPrompt(task, r, pass, baseBranch, ledger, brief) {
1048
1054
  lines.push(`1. Touch no file outside your owned list. If a finding can only be met by changing another file, stop and return blocked=true with the reason.`)
1049
1055
  lines.push(`2. Do not delete, skip, or weaken any test to make a check pass; make the code satisfy it. If that is impossible within the spec, return blocked=true and say why.`)
1050
1056
  lines.push(`3. Run the verification command before finishing; it must pass. Report every command with its real exit status and never one you did not run.`)
1051
- lines.push(`4. Commit on ${r.impl.branch} with a message starting "${task.id}: fix ". Do not amend or rewrite earlier commits. A pass that ends with no new commit stops the task. ${NO_TRAILERS}`)
1057
+ lines.push(`4. Commit on ${r.impl.branch} with a message starting "${task.id}: fix ". Do not amend or rewrite earlier commits. A pass that ends with no new commit stops the task. ${NO_TRAILERS} ${FOREGROUND_COMMIT}`)
1052
1058
  lines.push(`5. Return the structured result with taskId="${task.id}", branch="${r.impl.branch}", worktreePath${r.worktreeGone ? ' = the absolute path of your worktree (git rev-parse --show-toplevel)' : `="${r.impl.worktreePath}"`}, filesTouched from git diff --name-only ${baseBranch}...HEAD, commit from git rev-parse HEAD after your commit, blocked=false.`)
1053
1059
  lines.push(`6. ${PARTIAL_RULE}`)
1054
1060
  return lines.join('\n')
@@ -1065,7 +1071,7 @@ function resumePrompt(task, prev, handoff, baseBranch, brief) {
1065
1071
  const completed = (Array.isArray(handoff.completed) ? handoff.completed : []).join('; ') || '(none)'
1066
1072
  const remaining = (Array.isArray(handoff.remaining) ? handoff.remaining : []).join('; ')
1067
1073
  return [
1068
- `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}`,
1069
1075
  ``,
1070
1076
  `Plan: ${plan.title}`,
1071
1077
  brief ? `Brief ${brief.id}: ${brief.title}` : `Task ${task.id}: ${task.title}`,
@@ -1081,7 +1087,7 @@ function resumePrompt(task, prev, handoff, baseBranch, brief) {
1081
1087
  `Rules:`,
1082
1088
  `1. Touch no file outside your owned list. If the work cannot be done without another file, stop and report blocked=true with the reason.`,
1083
1089
  `2. Run the verification command before finishing and report each command with its real exit status. Never report a command you did not run.`,
1084
- `3. Commit your work on ${prev.branch} with a message starting "${task.id}: resume ". ${NO_TRAILERS}`,
1090
+ `3. Commit your work on ${prev.branch} with a message starting "${task.id}: resume ". ${NO_TRAILERS} ${FOREGROUND_COMMIT}`,
1085
1091
  `4. ${PARTIAL_RULE}`,
1086
1092
  `5. Return the structured result. filesTouched must come from git diff --name-only ${baseBranch}...HEAD, and commit must be the output of git rev-parse HEAD in your worktree after that commit.`,
1087
1093
  ].join('\n')
@@ -1101,7 +1107,7 @@ function supervisePrompt(task, r, attempts, ledger, signals) {
1101
1107
  ].join('\n')
1102
1108
  })
1103
1109
  return [
1104
- `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}`,
1105
1111
  ``,
1106
1112
  `Plan: ${plan.title}`,
1107
1113
  `Task ${task.id}: ${task.title}`,
@@ -1125,9 +1131,10 @@ function supervisePrompt(task, r, attempts, ledger, signals) {
1125
1131
  ].join('\n')
1126
1132
  }
1127
1133
 
1128
- function verifyPrompt(task, impl, baseBranch) {
1134
+ function verifyPrompt(task, impl, baseBranch, seat = 1) {
1129
1135
  return [
1130
- `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}`,
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.`] : []),
1131
1138
  ``,
1132
1139
  `Task ${task.id}: ${task.title}`,
1133
1140
  `Spec:`,
@@ -1160,7 +1167,7 @@ function verifyPrompt(task, impl, baseBranch) {
1160
1167
  function checkPrompt(task, impl, prevCommit, baseBranch, ledger) {
1161
1168
  const open = ledgerText(ledger, 'open')
1162
1169
  return [
1163
- `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}`,
1164
1171
  ``,
1165
1172
  `Task ${task.id}: ${task.title}`,
1166
1173
  `Spec:`,
@@ -1194,7 +1201,7 @@ function checkPrompt(task, impl, prevCommit, baseBranch, ledger) {
1194
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.
1195
1202
  function sizeCheckPrompt(task, impl, baseBranch) {
1196
1203
  return [
1197
- `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}`,
1198
1205
  ``,
1199
1206
  `Task ${task.id}: ${task.title}`,
1200
1207
  `Spec:`,
@@ -1245,7 +1252,7 @@ function levelAdversaryPrompt(li, sReady, levelBase, confirming) {
1245
1252
  `DOUG_SPEC_EOF`,
1246
1253
  ].join('\n')
1247
1254
  return [
1248
- `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}`,
1249
1256
  ``,
1250
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):`,
1251
1258
  '```',
@@ -1261,7 +1268,7 @@ function levelAdversaryPrompt(li, sReady, levelBase, confirming) {
1261
1268
  function levelFallbackAdversaryPrompt(li, sReady, levelBase, why, confirming) {
1262
1269
  // Same scope as levelAdversaryPrompt (card flow-fallback-verify-scope): no verify list, the suite already ran at integrate.
1263
1270
  return [
1264
- `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}`,
1265
1272
  ``,
1266
1273
  ...levelSpecLines(sReady),
1267
1274
  `Plan acceptance criteria:`,
@@ -1284,7 +1291,7 @@ function levelFallbackAdversaryPrompt(li, sReady, levelBase, why, confirming) {
1284
1291
  // reviewer reading spec line by line is likely to miss, so two reviewers do not file the same finding twice.
1285
1292
  function reviewPrompt(task, impl, baseBranch, seat = 1) {
1286
1293
  return [
1287
- `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}`,
1288
1295
  ...(seat > 1 ? [
1289
1296
  ``,
1290
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.`,
@@ -1346,7 +1353,7 @@ function adversaryPrompt(task, impl, baseBranch, pass, ledger) {
1346
1353
  `DOUG_SPEC_EOF`,
1347
1354
  ].join('\n')
1348
1355
  return [
1349
- `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}`,
1350
1357
  ``,
1351
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):`,
1352
1359
  '```',
@@ -1394,7 +1401,7 @@ function fallbackAdversaryPrompt(task, impl, baseBranch, why, pass, ledger) {
1394
1401
  // Same scope as adversaryPrompt's Codex seat (card flow-fallback-verify-scope): the task's own verify only, not plan.verify.
1395
1402
  const verify = [task.verify].filter(Boolean)
1396
1403
  return [
1397
- `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}`,
1398
1405
  ``,
1399
1406
  `Task ${task.id}: ${task.title}`,
1400
1407
  `Spec:`,
@@ -1425,7 +1432,7 @@ function integratePrompt(levelIndex, results, baseBranch, isLastLevel, keepWorkt
1425
1432
  const branches = results.map(r => r.impl.branch)
1426
1433
  const worktrees = results.map(r => r.impl.worktreePath)
1427
1434
  return [
1428
- `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}`,
1429
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.`,
1430
1437
  PARTIAL_NEVER_MERGED,
1431
1438
  `Merge these task branches into it, in this order: ${branches.join(', ')}.`,
@@ -1990,13 +1997,41 @@ async function runAdversary(task, impl, pass, baseBranch, ledger, launch, stages
1990
1997
  }
1991
1998
 
1992
1999
  // ---- Crew (card crew-sizing) ---------------------------------------------------
1993
- // A task (or the plan) declares how many reviewers and adversaries read it; default one each. More coders is a
2000
+ // A task (or the plan) declares how many reviewers, adversaries and testers read it; default one each. More coders is a
1994
2001
  // split into more tasks, never a shared worktree, so the crew has no coders row. Researchers run before planning
1995
2002
  // (the research skill) and are not launched here.
1996
2003
  function crewOf(task) {
1997
2004
  const src = { ...((plan.crew && typeof plan.crew === 'object') ? plan.crew : {}), ...((task && task.crew && typeof task.crew === 'object') ? task.crew : {}) }
1998
2005
  const n = v => (Number.isInteger(v) && v > 0 ? v : 1)
1999
- return { reviewers: n(src.reviewers), adversaries: n(src.adversaries) }
2006
+ return { reviewers: n(src.reviewers), adversaries: n(src.adversaries), testers: n(src.testers) }
2007
+ }
2008
+
2009
+ // One verification out of several tester seats (card crew-tester-seat): passed only when every seat that returned
2010
+ // passed, findings and commands concatenated so the ledger's fingerprint collapses a duplicate, and an acceptance
2011
+ // criterion (matched by text) ok only when every seat reporting it ok. A seat that returned nothing is dropped; when
2012
+ // no seat returned anything the stage returned nothing; one seat comes back unchanged.
2013
+ function mergeVerifies(seats) {
2014
+ const got = seats.filter(Boolean)
2015
+ if (!got.length) return null
2016
+ if (got.length === 1 && seats.length === 1) return got[0]
2017
+ const acceptance = []
2018
+ for (const v of got) {
2019
+ for (const a of (v.acceptance || [])) {
2020
+ const at = acceptance.findIndex(x => x.text === a.text)
2021
+ if (at < 0) acceptance.push({ ...a })
2022
+ else if (acceptance[at].ok && !a.ok) acceptance[at] = { ...a }
2023
+ }
2024
+ }
2025
+ return {
2026
+ taskId: got[0].taskId,
2027
+ passed: got.every(v => v.passed),
2028
+ commandsRun: got.flatMap(v => v.commandsRun || []),
2029
+ findings: got.flatMap(v => v.findings || []),
2030
+ findingFiles: got.flatMap(v => v.findingFiles || []),
2031
+ acceptance,
2032
+ head: got[0].head,
2033
+ seats: got.length,
2034
+ }
2000
2035
  }
2001
2036
 
2002
2037
  // One review out of several: every seat must approve, and every seat's issues are kept, so a finding one reviewer
@@ -2159,19 +2194,21 @@ async function runChecks(task, impl, pass, baseBranch, ctx) {
2159
2194
  // The verifier, the reviewer, and the adversary are read-only and none reads another's result, so a first pass
2160
2195
  // launches them together; a null from any (skipped, or dead after retries) is that stage returning nothing.
2161
2196
  stages.push('verify')
2162
- stages.push('review')
2163
2197
  const crew = crewOf(task)
2198
+ for (let seat = 2; seat <= crew.testers; seat++) stages.push(`verify-${seat}`)
2199
+ stages.push('review')
2164
2200
  for (let seat = 2; seat <= crew.reviewers; seat++) stages.push(`review-${seat}`)
2165
2201
  const withAdversary = adversaryRequired(task)
2166
2202
  const advStages = []
2167
2203
  const pair = await parallel([
2168
2204
  () => launch(verifyPrompt(task, impl, baseBranch), agentOpts('verify', task, { label: `verify:${task.id}`, phase: 'Verify', agentType: 'doug-flow:verifier', schema: VERIFY_SCHEMA })),
2205
+ ...Array.from({ length: crew.testers - 1 }, (_, i) => () => launch(verifyPrompt(task, impl, baseBranch, i + 2), agentOpts('verify', task, { label: `verify-${i + 2}:${task.id}`, phase: 'Verify', agentType: 'doug-flow:verifier', schema: VERIFY_SCHEMA }))),
2169
2206
  () => launch(reviewPrompt(task, impl, baseBranch), agentOpts('review', task, { label: `review:${task.id}`, phase: 'Review', agentType: 'doug-flow:reviewer', schema: REVIEW_SCHEMA })),
2170
2207
  ...Array.from({ length: crew.reviewers - 1 }, (_, i) => () => launch(reviewPrompt(task, impl, baseBranch, i + 2), agentOpts('review', task, { label: `review-${i + 2}:${task.id}`, phase: 'Review', agentType: 'doug-flow:reviewer', schema: REVIEW_SCHEMA }))),
2171
2208
  ...(withAdversary ? [() => runAdversaryCrew(task, impl, pass, baseBranch, ledger, launch, advStages)] : []),
2172
2209
  ])
2173
- ver = (pair && pair[0]) || null
2174
- rev = pair ? mergeReviews(pair.slice(1, 1 + crew.reviewers)) : null
2210
+ ver = pair ? mergeVerifies(pair.slice(0, crew.testers)) : null
2211
+ rev = pair ? mergeReviews(pair.slice(crew.testers, crew.testers + crew.reviewers)) : null
2175
2212
  stageResults.verify = ver
2176
2213
  stageResults.review = rev
2177
2214
  // The adversary no longer waits for verify and review (card flow-adversary-parallel, 2026-10-07): waiting
@@ -2179,7 +2216,7 @@ async function runChecks(task, impl, pass, baseBranch, ctx) {
2179
2216
  // integration still needs all three verdicts (isReady), and the ledger reads each stage's findings.
2180
2217
  if (withAdversary) {
2181
2218
  stages.push(...advStages)
2182
- adv = (pair && pair[1 + crew.reviewers]) || null
2219
+ adv = (pair && pair[crew.testers + crew.reviewers]) || null
2183
2220
  stageResults.adversary = adv
2184
2221
  }
2185
2222
  blockingStage = !(ver && ver.passed) ? 'verify' : !(rev && rev.approve) ? 'review' : adversaryBlocked(adv) ? 'adversary' : null
@@ -2187,7 +2224,13 @@ async function runChecks(task, impl, pass, baseBranch, ctx) {
2187
2224
  const first = c.blocking || 'verify'
2188
2225
  if (first === 'verify') {
2189
2226
  stages.push('verify')
2190
- ver = await launch(verifyPrompt(task, impl, baseBranch), agentOpts('verify', task, { label: `verify:${task.id}${suffix}`, phase: 'Verify', agentType: 'doug-flow:verifier', schema: VERIFY_SCHEMA }))
2227
+ const testers = crewOf(task).testers
2228
+ for (let seat = 2; seat <= testers; seat++) stages.push(`verify-${seat}`)
2229
+ const verifyOpts = seat => agentOpts('verify', task, { label: `verify${seat > 1 ? `-${seat}` : ''}:${task.id}${suffix}`, phase: 'Verify', agentType: 'doug-flow:verifier', schema: VERIFY_SCHEMA })
2230
+ if (testers > 1) {
2231
+ const seats = await parallel(Array.from({ length: testers }, (_, i) => () => launch(verifyPrompt(task, impl, baseBranch, i + 1), verifyOpts(i + 1))))
2232
+ ver = mergeVerifies(seats || [])
2233
+ } else ver = await launch(verifyPrompt(task, impl, baseBranch), verifyOpts(1))
2191
2234
  stageResults.verify = ver
2192
2235
  if (!(ver && ver.passed)) blockingStage = 'verify'
2193
2236
  } else if (first === 'review') {
@@ -2422,7 +2465,7 @@ async function runTask(task, baseBranch, resume) {
2422
2465
  const fellBack = (r.stages || []).includes('adversary-fallback')
2423
2466
  const crew = crewOf(task)
2424
2467
  const advAgents = adversaryRequired(task) ? (fellBack ? 1 : 0) + crew.adversaries : 0
2425
- const firstStage = blocking === 'adversary' ? advAgents : blocking === 'review' ? crew.reviewers : 1
2468
+ const firstStage = blocking === 'adversary' ? advAgents : blocking === 'review' ? crew.reviewers : blocking === 'verify' ? crew.testers : 1
2426
2469
  const nextStages = firstStage + (blocking === 'check' ? 0 : 1) + (blocking === 'adversary' ? 0 : advAgents)
2427
2470
  const spent = measure()
2428
2471
  const passesDone = attempts.length
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "doug-gates",
3
3
  "displayName": "Doug Gates",
4
- "version": "1.2.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.2.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",