@dougharness/cli 1.3.0 → 1.5.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 (51) hide show
  1. package/dist/ablate.js +2 -2
  2. package/dist/ablate.js.map +1 -1
  3. package/dist/bin.js +13 -4
  4. package/dist/bin.js.map +1 -1
  5. package/dist/board-page.d.ts +5 -1
  6. package/dist/board-page.js +19 -5
  7. package/dist/board-page.js.map +1 -1
  8. package/dist/board-serve.js +34 -10
  9. package/dist/board-serve.js.map +1 -1
  10. package/dist/board.d.ts +1 -1
  11. package/dist/board.js +160 -13
  12. package/dist/board.js.map +1 -1
  13. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
  14. package/node_modules/@dougharness/flow/agents/planner.md +1 -0
  15. package/node_modules/@dougharness/flow/hooks/register.tsx +18 -2
  16. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +94 -7
  17. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +31 -4
  18. package/node_modules/@dougharness/flow/lib/board.mjs +375 -30
  19. package/node_modules/@dougharness/flow/lib/card-loop.mjs +237 -24
  20. package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
  21. package/node_modules/@dougharness/flow/lib/decisions.mjs +2 -4
  22. package/node_modules/@dougharness/flow/lib/learn.mjs +95 -54
  23. package/node_modules/@dougharness/flow/lib/memory.mjs +1403 -44
  24. package/node_modules/@dougharness/flow/lib/plan.mjs +40 -0
  25. package/node_modules/@dougharness/flow/package.json +1 -1
  26. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +355 -13
  27. package/node_modules/@dougharness/flow/scripts/board.mjs +160 -27
  28. package/node_modules/@dougharness/flow/scripts/cost.mjs +6 -3
  29. package/node_modules/@dougharness/flow/scripts/learn.mjs +1 -1
  30. package/node_modules/@dougharness/flow/scripts/memory.mjs +668 -39
  31. package/node_modules/@dougharness/flow/scripts/plan.mjs +3 -3
  32. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +11 -4
  33. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +7 -3
  34. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  35. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +11 -2
  36. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +9 -8
  37. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
  38. package/node_modules/@dougharness/flow/skills/research/SKILL.md +1 -1
  39. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +3 -3
  40. package/node_modules/@dougharness/flow/workflows/card-queen.js +238 -25
  41. package/node_modules/@dougharness/flow/workflows/doug-implement.js +55 -37
  42. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  43. package/node_modules/@dougharness/gates/lib/baseline.mjs +32 -0
  44. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +45 -5
  45. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +6 -2
  46. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +33 -0
  47. package/node_modules/@dougharness/gates/package.json +1 -1
  48. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +130 -42
  49. package/node_modules/@dougharness/gates/scripts/verify.mjs +20 -4
  50. package/package.json +3 -3
  51. package/templates/board-app.js +240 -30
@@ -3,7 +3,7 @@ export const meta = {
3
3
  description: 'Implement an approved Doug plan: one implementer per task in its own worktree, then verify, review, and integrate',
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
- { 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' },
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 rebuilds the own commits of its recorded branch on the current base 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
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' },
@@ -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.'
@@ -114,7 +117,7 @@ const roleModels = (plan.models && plan.models.roles) || {}
114
117
  // The adversary row may name "codex": the review runs on Codex (plan.adversary.command) and the Claude agent
115
118
  // that launches it and relays the verdict is only a relay, so it runs on haiku. `relay` is that agent's model.
116
119
  const ADVERSARY_RELAY_MODEL = 'haiku'
117
- // Checking out a reused branch is mechanical, so it runs on the same small model as the adversary relay.
120
+ // Rebuilding a reused branch is a scripted run of listed git commands, so it runs on the same small model as the adversary relay.
118
121
  const CHECKOUT_MODEL = ADVERSARY_RELAY_MODEL
119
122
  // The fix-loop supervisor (card fix-loop-supervisor) runs on the `cheap` tier of the Models table; a table without
120
123
  // one puts it on the relay model, since reading a task's passes and writing a brief is small work.
@@ -703,6 +706,10 @@ function droppedWhy(f, task) {
703
706
  // Applies one pass to the ledger, stage by stage in the order the stages ran, and returns the ids that opened,
704
707
  // were confirmed fixed, and came back. A stage confirms an open entry by running and not reporting it: its own
705
708
  // stage, the focused check, and the confirmation adversary all count, and only on a pass that made a new commit.
709
+ function explicitMinor(f) {
710
+ return ['minor', 'nit', 'note'].includes(String(f.severity == null ? '' : f.severity).trim().toLowerCase())
711
+ }
712
+
706
713
  function updateLedger(ledger, r, pass, newCommit) {
707
714
  const opened = []
708
715
  const fixed = []
@@ -731,7 +738,7 @@ function updateLedger(ledger, r, pass, newCommit) {
731
738
  if (e.status !== 'open' || e.openedPass === pass || touched.has(e.id)) continue
732
739
  const covers = e.stage === stage || stage === 'check' || (stage === 'adversary' && pass > 1)
733
740
  if (!covers) continue
734
- if (reReports.some(f => matchFinding(ledger, f) === e)) continue
741
+ if (reReports.some(f => !explicitMinor(f) && matchFinding(ledger, f) === e)) continue
735
742
  e.status = 'fixed'
736
743
  e.fixedPass = pass
737
744
  e.fixedCommit = r.commit || null
@@ -742,6 +749,10 @@ function updateLedger(ledger, r, pass, newCommit) {
742
749
  for (const f of reReports) {
743
750
  const entry = matchFinding(ledger, f)
744
751
  if (entry) {
752
+ // Card stall-on-repeat-minor (2026-10-25 decision, run wf_d9199b24-d9e): a re-report whose severity is
753
+ // explicitly minor/nit/note neither reopens a fixed entry nor keeps an open one open. Free text and
754
+ // blocker/major re-reports (and severity-less ones) still do.
755
+ if (explicitMinor(f)) continue
745
756
  touched.add(entry.id)
746
757
  if (f.evidence) entry.evidence = f.evidence
747
758
  if (entry.status === 'fixed') {
@@ -837,7 +848,7 @@ function implementPrompt(task, baseBranch, opts) {
837
848
  ? ` 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
849
  : ` 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
850
  return [
840
- `You are implementing exactly one task from an approved plan. Work only in your own git worktree.`,
851
+ `You are implementing exactly one task from an approved plan. Work only in your own git worktree. ${RELAYED_MESSAGE}`,
841
852
  ``,
842
853
  `Plan: ${plan.title}`,
843
854
  `Goal: ${plan.goal}`,
@@ -845,7 +856,7 @@ function implementPrompt(task, baseBranch, opts) {
845
856
  `Task ${task.id}: ${task.title}`,
846
857
  `Spec:`,
847
858
  task.spec,
848
- ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, String(task.lessons).slice(0, 2000)] : []),
859
+ ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, String(task.lessons)] : []),
849
860
  ...(typeof task.codeContext === 'string' && task.codeContext ? ['', `Code the semantic index found relevant to this task (path:lines; read these first, they are not the files you own):`, String(task.codeContext).slice(0, 2000)] : []),
850
861
  ``,
851
862
  `Files you own (you may create or edit ONLY these): ${task.files.join(', ')}`,
@@ -865,7 +876,7 @@ function implementPrompt(task, baseBranch, opts) {
865
876
 
866
877
  function reusePrompt(task, baseBranch) {
867
878
  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.`,
879
+ `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
880
  ``,
870
881
  `Plan: ${plan.title}`,
871
882
  `Task ${task.id}: ${task.title}`,
@@ -873,11 +884,17 @@ function reusePrompt(task, baseBranch) {
873
884
  `Files the task owns: ${task.files.join(', ')}`,
874
885
  ``,
875
886
  `Steps, all inside your own worktree:`,
876
- `1. git checkout ${task.reuse}. If git refuses because the branch is checked out in another worktree, that worktree is a leftover of the earlier pass under .claude/worktrees: find it with git worktree list --porcelain, remove it with git worktree remove --force <path>, and check out again.`,
887
+ `1. Put ${task.reuse} on the current base ${baseBranch}, because the earlier pass may have been cut from an integration branch that no longer exists. If any git checkout below refuses because the branch is checked out in another worktree, that worktree is a leftover of the earlier pass under .claude/worktrees: find it with git worktree list --porcelain, remove it with git worktree remove --force <path>, and run the checkout again.`,
888
+ ` a. List the branch's own commits: git log --no-merges --reverse --format=%H ${baseBranch}..${task.reuse}`,
889
+ ` b. For each, read its files with git show --name-only --format= <sha>. A commit with no files is dropped. A commit touching only files the task does not own belongs to another task: drop it. A commit touching both files the task owns and files it does not own means the branch carries work the task does not own: change nothing and return blocked=true with a reason naming the commit and the file. A commit touching only owned files is kept.`,
890
+ ` Then run git merge-base --is-ancestor ${baseBranch} ${task.reuse}. If it fails with exit 128 (the branch or the base does not exist), return blocked=true naming the missing one. When it exits 0 and step b kept every listed commit, the branch already sits on the current base: just git checkout ${task.reuse} and go to step 2. Otherwise rebuild it:`,
891
+ ` c. git checkout --detach ${baseBranch}`,
892
+ ` d. git cherry-pick -x each kept commit, in order. When a pick is empty because the base already has the change, run git cherry-pick --skip. On a conflict, run git cherry-pick --abort and return blocked=true with a reason naming the commit and the conflicting files (the branch ref was never moved, so the reviewed work stays intact).`,
893
+ ` e. git checkout -B ${task.reuse} to point the branch at the rebuilt HEAD (no base argument).`,
877
894
  plan.install
878
895
  ? `2. Run the project's install command (${plan.install}) before the checks run. Installing is not a file change; do not commit anything it changes. Should you commit at all, ${NO_TRAILERS}`
879
896
  : `2. If the task's verification command needs dependencies, run the project's install command. Installing is not a file change; do not commit anything it changes. Should you commit at all, ${NO_TRAILERS}`,
880
- `3. Return taskId="${task.id}", branch="${task.reuse}", worktreePath = the absolute path of your worktree (git rev-parse --show-toplevel), filesTouched = the output of git diff --name-only ${baseBranch}...HEAD, commandsRun with real exit statuses, commit = the output of git rev-parse HEAD, summary "reused ${task.reuse} at <commit>", blocked=false. Report blocked=true with the reason only if the branch does not exist or cannot be checked out.`,
897
+ `3. Return taskId="${task.id}", branch="${task.reuse}", worktreePath = the absolute path of your worktree (git rev-parse --show-toplevel), filesTouched = the output of git diff --name-only ${baseBranch}...HEAD, commandsRun with real exit statuses, commit = the output of git rev-parse HEAD, summary "reused ${task.reuse} at <commit>", blocked=false. Report blocked=true with the reason only if the branch does not exist, cannot be checked out, or step 1 told you to.`,
881
898
  ].join('\n')
882
899
  }
883
900
 
@@ -891,7 +908,7 @@ function leadPrompt(task, baseBranch, opts) {
891
908
  ? ` 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
909
  : ` 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
910
  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.`,
911
+ `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
912
  ``,
896
913
  `Plan: ${plan.title}`,
897
914
  `Goal: ${plan.goal}`,
@@ -899,7 +916,7 @@ function leadPrompt(task, baseBranch, opts) {
899
916
  `Task ${task.id}: ${task.title}`,
900
917
  `Spec:`,
901
918
  task.spec,
902
- ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, String(task.lessons).slice(0, 2000)] : []),
919
+ ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, String(task.lessons)] : []),
903
920
  ...(typeof task.codeContext === 'string' && task.codeContext ? ['', `Code the semantic index found relevant to this task (path:lines; read these first, they are not the files you own):`, String(task.codeContext).slice(0, 2000)] : []),
904
921
  ``,
905
922
  `Files the task owns: ${task.files.join(', ')}`,
@@ -923,7 +940,7 @@ function workerPrompt(task, brief, n, lead) {
923
940
  // to and what is not its business; the task it belongs to still owns only the files its own brief lists below.
924
941
  const siblings = (Array.isArray(lead.briefs) ? lead.briefs : []).filter(b => b.id !== brief.id)
925
942
  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.`,
943
+ `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
944
  ``,
928
945
  `Plan: ${plan.title}`,
929
946
  `Goal: ${plan.goal}`,
@@ -934,7 +951,7 @@ function workerPrompt(task, brief, n, lead) {
934
951
  `Brief ${brief.id}: ${brief.title}`,
935
952
  `Spec:`,
936
953
  brief.spec,
937
- ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, String(task.lessons).slice(0, 2000)] : []),
954
+ ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, String(task.lessons)] : []),
938
955
  ...(typeof task.codeContext === 'string' && task.codeContext ? ['', `Code the semantic index found relevant to this task (path:lines; read these first, they are not the files you own):`, String(task.codeContext).slice(0, 2000)] : []),
939
956
  ...(typeof lead.interfaces === 'string' && lead.interfaces ? ['', `Interfaces the briefs export or consume (the lead's design; conform to it, do not redesign it):`, lead.interfaces.slice(0, 2000)] : []),
940
957
  ...(siblings.length ? ['', `Sibling briefs of this task (their files are not yours to touch; conform to what they export):`, ...siblings.map(s => ` - ${s.id}: ${s.title} (${(Array.isArray(s.files) ? s.files : []).join(', ')})`)] : []),
@@ -958,7 +975,7 @@ function workerPrompt(task, brief, n, lead) {
958
975
  // left an orphan - it defaults to `workers` for the plain, no-rebrief case where the two are the same list.
959
976
  function leadMergePrompt(task, lead, workers, baseBranch, allWorkers = workers) {
960
977
  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).`,
978
+ `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
979
  PARTIAL_NEVER_MERGED,
963
980
  ``,
964
981
  `Task ${task.id}: ${task.title}`,
@@ -985,7 +1002,7 @@ function leadMergePrompt(task, lead, workers, baseBranch, allWorkers = workers)
985
1002
  function leadRebriefPrompt(task, lead, workers, blockedWorkers) {
986
1003
  const finished = workers.filter(w => !w.blocked)
987
1004
  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>\`.`,
1005
+ `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
1006
  ``,
990
1007
  `Plan: ${plan.title}`,
991
1008
  `Goal: ${plan.goal}`,
@@ -1020,13 +1037,13 @@ function fixPrompt(task, r, pass, baseBranch, ledger, brief) {
1020
1037
  ? `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
1038
  : `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
1039
  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}`,
1040
+ `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
1041
  ``,
1025
1042
  `Plan: ${plan.title}`,
1026
1043
  `Task ${task.id}: ${task.title}`,
1027
1044
  `Spec:`,
1028
1045
  task.spec,
1029
- ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, String(task.lessons).slice(0, 2000)] : []),
1046
+ ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, String(task.lessons)] : []),
1030
1047
  ...(typeof task.codeContext === 'string' && task.codeContext ? ['', `Code the semantic index found relevant to this task (path:lines; read these first, they are not the files you own):`, String(task.codeContext).slice(0, 2000)] : []),
1031
1048
  ``,
1032
1049
  `Files you own (you may create or edit ONLY these): ${task.files.join(', ')}`,
@@ -1068,7 +1085,7 @@ function resumePrompt(task, prev, handoff, baseBranch, brief) {
1068
1085
  const completed = (Array.isArray(handoff.completed) ? handoff.completed : []).join('; ') || '(none)'
1069
1086
  const remaining = (Array.isArray(handoff.remaining) ? handoff.remaining : []).join('; ')
1070
1087
  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.`,
1088
+ `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
1089
  ``,
1073
1090
  `Plan: ${plan.title}`,
1074
1091
  brief ? `Brief ${brief.id}: ${brief.title}` : `Task ${task.id}: ${task.title}`,
@@ -1104,7 +1121,7 @@ function supervisePrompt(task, r, attempts, ledger, signals) {
1104
1121
  ].join('\n')
1105
1122
  })
1106
1123
  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.`,
1124
+ `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
1125
  ``,
1109
1126
  `Plan: ${plan.title}`,
1110
1127
  `Task ${task.id}: ${task.title}`,
@@ -1130,13 +1147,13 @@ function supervisePrompt(task, r, attempts, ledger, signals) {
1130
1147
 
1131
1148
  function verifyPrompt(task, impl, baseBranch, seat = 1) {
1132
1149
  return [
1133
- `You are a verifier. You did not write this code. Your job is to run it, not to read and approve it.`,
1150
+ `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
1151
  ...(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
1152
  ``,
1136
1153
  `Task ${task.id}: ${task.title}`,
1137
1154
  `Spec:`,
1138
1155
  task.spec,
1139
- ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []),
1156
+ ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons)] : []),
1140
1157
  ``,
1141
1158
  `Plan acceptance criteria (the spec must be consistent with these; a line starting with $ is the command that checks the criterion above it and exits 0 when it holds):`,
1142
1159
  ...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
@@ -1164,12 +1181,12 @@ function verifyPrompt(task, impl, baseBranch, seat = 1) {
1164
1181
  function checkPrompt(task, impl, prevCommit, baseBranch, ledger) {
1165
1182
  const open = ledgerText(ledger, 'open')
1166
1183
  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.`,
1184
+ `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
1185
  ``,
1169
1186
  `Task ${task.id}: ${task.title}`,
1170
1187
  `Spec:`,
1171
1188
  task.spec,
1172
- ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []),
1189
+ ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons)] : []),
1173
1190
  ``,
1174
1191
  `Plan acceptance criteria (the spec must be consistent with these; a line starting with $ is the command that checks the criterion above it and exits 0 when it holds):`,
1175
1192
  ...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
@@ -1198,7 +1215,7 @@ function checkPrompt(task, impl, prevCommit, baseBranch, ledger) {
1198
1215
  // 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
1216
  function sizeCheckPrompt(task, impl, baseBranch) {
1200
1217
  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.`,
1218
+ `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
1219
  ``,
1203
1220
  `Task ${task.id}: ${task.title}`,
1204
1221
  `Spec:`,
@@ -1249,7 +1266,7 @@ function levelAdversaryPrompt(li, sReady, levelBase, confirming) {
1249
1266
  `DOUG_SPEC_EOF`,
1250
1267
  ].join('\n')
1251
1268
  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.`,
1269
+ `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
1270
  ``,
1254
1271
  `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
1272
  '```',
@@ -1265,7 +1282,7 @@ function levelAdversaryPrompt(li, sReady, levelBase, confirming) {
1265
1282
  function levelFallbackAdversaryPrompt(li, sReady, levelBase, why, confirming) {
1266
1283
  // Same scope as levelAdversaryPrompt (card flow-fallback-verify-scope): no verify list, the suite already ran at integrate.
1267
1284
  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}.`,
1285
+ `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
1286
  ``,
1270
1287
  ...levelSpecLines(sReady),
1271
1288
  `Plan acceptance criteria:`,
@@ -1288,7 +1305,7 @@ function levelFallbackAdversaryPrompt(li, sReady, levelBase, why, confirming) {
1288
1305
  // reviewer reading spec line by line is likely to miss, so two reviewers do not file the same finding twice.
1289
1306
  function reviewPrompt(task, impl, baseBranch, seat = 1) {
1290
1307
  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.`,
1308
+ `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
1309
  ...(seat > 1 ? [
1293
1310
  ``,
1294
1311
  `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.`,
@@ -1297,7 +1314,7 @@ function reviewPrompt(task, impl, baseBranch, seat = 1) {
1297
1314
  `Task ${task.id}: ${task.title}`,
1298
1315
  `Spec:`,
1299
1316
  task.spec,
1300
- ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []),
1317
+ ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons)] : []),
1301
1318
  `Owned files: ${task.files.join(', ')}`,
1302
1319
  `Plan acceptance criteria:`,
1303
1320
  ...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
@@ -1340,7 +1357,7 @@ function adversaryPrompt(task, impl, baseBranch, pass, ledger) {
1340
1357
  `Task ${task.id}: ${task.title}`,
1341
1358
  ``,
1342
1359
  guard(task.spec),
1343
- ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []).map(guard),
1360
+ ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons)] : []).map(guard),
1344
1361
  ``,
1345
1362
  ...(pass > 1 ? confirmationLines(pass, ledger, impl).map(guard) : [`This is the first pass of this task: explore freely and try to refute the change however you can.`]),
1346
1363
  ``,
@@ -1350,7 +1367,7 @@ function adversaryPrompt(task, impl, baseBranch, pass, ledger) {
1350
1367
  `DOUG_SPEC_EOF`,
1351
1368
  ].join('\n')
1352
1369
  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.`,
1370
+ `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
1371
  ``,
1355
1372
  `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
1373
  '```',
@@ -1398,12 +1415,12 @@ function fallbackAdversaryPrompt(task, impl, baseBranch, why, pass, ledger) {
1398
1415
  // Same scope as adversaryPrompt's Codex seat (card flow-fallback-verify-scope): the task's own verify only, not plan.verify.
1399
1416
  const verify = [task.verify].filter(Boolean)
1400
1417
  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.`,
1418
+ `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
1419
  ``,
1403
1420
  `Task ${task.id}: ${task.title}`,
1404
1421
  `Spec:`,
1405
1422
  task.spec,
1406
- ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []),
1423
+ ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons)] : []),
1407
1424
  `Owned files: ${task.files.join(', ')}`,
1408
1425
  `Plan acceptance criteria:`,
1409
1426
  ...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
@@ -1429,7 +1446,7 @@ function integratePrompt(levelIndex, results, baseBranch, isLastLevel, keepWorkt
1429
1446
  const branches = results.map(r => r.impl.branch)
1430
1447
  const worktrees = results.map(r => r.impl.worktreePath)
1431
1448
  return [
1432
- `You are the integration owner for level ${levelIndex} of plan "${plan.title}".`,
1449
+ `You are the integration owner for level ${levelIndex} of plan "${plan.title}". ${RELAYED_MESSAGE}`,
1433
1450
  `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
1451
  PARTIAL_NEVER_MERGED,
1435
1452
  `Merge these task branches into it, in this order: ${branches.join(', ')}.`,
@@ -1863,11 +1880,12 @@ function isReady(r) {
1863
1880
  if (r.impl && r.impl.partial) return false
1864
1881
  // A finding the ledger already recorded as fixed and that came back keeps the task out even when every stage
1865
1882
  // passed. This is deliberately unconditional: updateLedger reopens on any re-report matched by id or fingerprint,
1866
- // whatever that re-report's own `blocking` flag or severity says - a passing check's free-text "F1: still returns
1867
- // 1..." reopens F1 ("still reopens and keeps entries on a real re-report"), and so does a passing-verdict
1868
- // adversary's *minor*-severity issue naming the same id ("blocks the pass when a finding the ledger recorded as
1869
- // fixed comes back"). Gating this on the reporting finding's own blocking flag would silently let either of those
1870
- // real reappearances through. A 'waived' report only stays out of the re-report list (card d-waiver-structural-rule)
1883
+ // whatever that re-report's own `blocking` flag says, except that an explicit minor/nit/note severity no longer
1884
+ // reopens (card stall-on-repeat-minor, 2026-10-25) - a passing check's free-text "F1: still returns
1885
+ // 1..." reopens F1 ("still reopens and keeps entries on a real re-report"), and so does an adversary's
1886
+ // blocker-severity issue naming the same id ("blocks the pass when a finding the ledger recorded as fixed comes
1887
+ // back"). Gating this on the reporting finding's own blocking flag would silently let those real reappearances
1888
+ // through. A 'waived' report only stays out of the re-report list (card d-waiver-structural-rule)
1871
1889
  // when the ledger's own entry for that id is itself a self-declared non-blocking note (confirmsFinding above); a
1872
1890
  // disputed waiver against a blocking entry reaches here as an ordinary re-report and can reopen or keep an entry
1873
1891
  // open exactly the same way.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "doug-gates",
3
3
  "displayName": "Doug Gates",
4
- "version": "1.3.0",
4
+ "version": "1.5.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"
@@ -258,6 +258,15 @@ export function writeAgentStart(dir, cfg, sessionId, agentId) {
258
258
  try {
259
259
  const fp = treeFingerprint(dir, cfg, changedFiles(dir));
260
260
  if (!fp) return;
261
+ // card worktree-skip-origin-at-start: origin/HEAD's sha at start (null when unset), for the worktree skip.
262
+ let originHead = null;
263
+ try {
264
+ const o = execFileSync("git", ["rev-parse", "--verify", "-q", "refs/remotes/origin/HEAD^{commit}"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
265
+ if (/^[0-9a-f]{7,64}$/.test(o)) originHead = o;
266
+ } catch {
267
+ originHead = null;
268
+ }
269
+ fp.originHead = originHead;
261
270
  const file = agentStartFile(dir, sessionId, agentId);
262
271
  mkdirSync(join(file, ".."), { recursive: true });
263
272
  writeFileSync(file, JSON.stringify(fp));
@@ -323,3 +332,26 @@ export function turnTreeStatus(cfg, start, current) {
323
332
  return null;
324
333
  }
325
334
  }
335
+
336
+ // card stop-gate-read-only-turn-commits: turnTreeStatus for a turn that moved HEAD. Applies only when every path
337
+ // committed between the record's head and HEAD is on preCommit.noTestPaths (same match rule as turnTreeStatus),
338
+ // and then compares the remaining code paths as if HEAD had not moved. Fails closed (null: the caller verifies)
339
+ // on a missing, empty or malformed list, a record head that is not a plausible sha or not an ancestor of HEAD
340
+ // (rewritten history), a git failure, an empty committed set, or any committed path off the list. Never throws.
341
+ export function turnTreeStatusHeadMoved(dir, cfg, start, current) {
342
+ try {
343
+ if (!start || !current || typeof start !== "object" || typeof current !== "object") return null;
344
+ const list = cfg && cfg.preCommit && cfg.preCommit.noTestPaths;
345
+ if (!Array.isArray(list) || list.length === 0 || !list.every((e) => typeof e === "string" && e !== "")) return null;
346
+ if (typeof start.head !== "string" || !GIT_SHA_RE.test(start.head)) return null;
347
+ if (typeof current.head !== "string" || !GIT_SHA_RE.test(current.head) || start.head === current.head) return null;
348
+ execFileSync("git", ["merge-base", "--is-ancestor", start.head, current.head], { cwd: dir, stdio: "ignore", timeout: 5000 }); // throws on exit != 0
349
+ const committed = committedSince(dir, start.head);
350
+ if (!Array.isArray(committed) || committed.length === 0) return null;
351
+ if (!committed.every((p) => list.some((e) => (e.endsWith("/") ? p.startsWith(e) : p === e)))) return null;
352
+ const status = turnTreeStatus(cfg, start, { ...current, head: start.head });
353
+ return status ? { ...status, exact: false } : null;
354
+ } catch {
355
+ return null;
356
+ }
357
+ }
@@ -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
 
@@ -109,19 +110,21 @@ export function failingTestFilesFrom(output, dir) {
109
110
  // them is in the standing tests_red_by_design claim (`redByDesignClaimFiles`).
110
111
  // 2. 'load-timeout' — at least one command timed out or was dropped for budget, and no failed command's
111
112
  // captured output names a failing test file.
112
- // 3. 'race' — the working tree (`changedFiles(dir)`, as a set) differs now from `changedAtStart`. Either
113
+ // 3. 'test-count' — the test-count ratchet is the block's only problem (`countProblems > 0` and it equals
114
+ // `totalProblems`).
115
+ // 4. 'race' — the working tree (`changedFiles(dir)`, as a set) differs now from `changedAtStart`. Either
113
116
  // side being unknown (null: no git, or git unavailable) is never a race. Skipped entirely once
114
117
  // `budgetDeadline` (the gate's own hook-timeout deadline, gateBudget() in stop-gate.mjs) has already
115
118
  // passed (review pass 2, item 4): a second `git status` here is exactly the kind of extra work that
116
119
  // could push a slow gate past the platform's own hook timeout, discarding its block message (F1) — a
117
120
  // deadline already blown is not worth spending on a label, so this falls through to 'real' instead.
118
- // 4. 'real' — otherwise.
121
+ // 5. 'real' — otherwise.
119
122
  //
120
123
  // Never throws (review pass 2, item 2 MAJOR): this call sits between the gate's decision to block and the
121
124
  // blockStop call that carries it out (stop-gate.mjs), so an unguarded throw here would fail the *whole gate*
122
125
  // open — the one outcome every hook reader must never risk (CLAUDE.md gotchas). Any failure classifies as
123
126
  // 'real', the least specific label, rather than ever propagating.
124
- export function classifyFinalBlock({ preVerificationProblems, evidenceProblemAdded, commandFailures, postCommandProblems, results, redByDesignClaimFiles, changedAtStart, dir, budgetDeadline }) {
127
+ export function classifyFinalBlock({ countProblems, totalProblems, preVerificationProblems, evidenceProblemAdded, commandFailures, postCommandProblems, results, redByDesignClaimFiles, changedAtStart, dir, budgetDeadline }) {
125
128
  try {
126
129
  const failures = Array.isArray(commandFailures) ? commandFailures : [];
127
130
  const namedByCommand = failures.map((cf) => failingTestFilesFrom(cf.output, dir));
@@ -143,6 +146,9 @@ export function classifyFinalBlock({ preVerificationProblems, evidenceProblemAdd
143
146
  const timedOutOrDropped = (Array.isArray(results) ? results : []).some((r) => r.timedOut || r.skipped === "budget");
144
147
  if (timedOutOrDropped && allNamed.length === 0) return "load-timeout";
145
148
 
149
+ // card block-ledger-failing-output: the test-count ratchet is the block's only problem.
150
+ if (typeof countProblems === "number" && countProblems > 0 && totalProblems === countProblems) return "test-count";
151
+
146
152
  if (Array.isArray(changedAtStart) && !(typeof budgetDeadline === "number" && Date.now() >= budgetDeadline)) {
147
153
  const changedNow = changedFiles(dir);
148
154
  if (Array.isArray(changedNow)) {
@@ -159,14 +165,47 @@ export function classifyFinalBlock({ preVerificationProblems, evidenceProblemAdd
159
165
  }
160
166
  }
161
167
 
162
- // Appends one line to .doug/.state/blocks.jsonl: { hook, at, agent, reason, class, session }. `reason` is
168
+ // card block-ledger-failing-output: the last 40 lines of a command's output, secret-scanned (a hit records
169
+ // "[redacted: <rule>]", a scanner throw "[redacted: scan-error]"; skipped when secrets.enabled === false),
170
+ // then capped at 4096 UTF-8 bytes keeping the end, never splitting a character. Never throws.
171
+ export function redactText(text, secrets) {
172
+ try {
173
+ if (!(secrets && secrets.enabled === false)) {
174
+ const hit = scanText(text, secrets || {});
175
+ if (hit) return "[redacted: " + hit.rule + "]";
176
+ }
177
+ return text;
178
+ } catch {
179
+ return "[redacted: scan-error]";
180
+ }
181
+ }
182
+
183
+ export function outputTail(output, secrets) {
184
+ try {
185
+ let tail = String(output ?? "").trimEnd().split("\n").slice(-40).join("\n");
186
+ tail = redactText(tail, secrets);
187
+ const buf = Buffer.from(tail, "utf8");
188
+ if (buf.length > 4096) {
189
+ let start = buf.length - 4096;
190
+ while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++;
191
+ tail = buf.subarray(start).toString("utf8");
192
+ }
193
+ return tail;
194
+ } catch {
195
+ return "";
196
+ }
197
+ }
198
+
199
+ // Appends one line to .doug/.state/blocks.jsonl: { hook, at, agent, reason, class, session }, plus the keys of
200
+ // `record` ({ failures, cwd, tree }, card block-ledger-failing-output) when it is a plain object. `reason` is
163
201
  // reduced to its own first line here (message.split("\n")[0]) whatever the caller passes, so a multi-line
164
202
  // block message never leaks past its headline into the ledger. Call synchronously, right before each
165
203
  // blockStop/systemMessage in stop-gate.mjs — those each call process.exit(0) themselves, so anything queued
166
204
  // after them never runs. Never throws: an unwritable .doug/.state, or a blocks.jsonl path that is itself a
167
205
  // 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 }) {
206
+ export function appendBlockLine(dir, { hook, agent, reason, class: cls, session, record }) {
169
207
  try {
208
+ const plain = record && typeof record === "object" && !Array.isArray(record) ? record : {};
170
209
  const line =
171
210
  JSON.stringify({
172
211
  hook,
@@ -175,6 +214,7 @@ export function appendBlockLine(dir, { hook, agent, reason, class: cls, session
175
214
  reason: String(reason).split("\n")[0],
176
215
  class: cls,
177
216
  session: session ?? null,
217
+ ...plain,
178
218
  }) + "\n";
179
219
  mkdirSync(join(dir, ".doug/.state"), { recursive: true });
180
220
  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);
@@ -17,6 +17,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rename
17
17
  import { join, resolve } from "node:path";
18
18
  import { randomBytes } from "node:crypto";
19
19
  import { pathToFileURL } from "node:url";
20
+ import { readSummaries } from "./test-totals.mjs";
20
21
 
21
22
  export const RECORD_RELPATH = ".doug/.state/verified-tree.json";
22
23
  export const EXCLUDED = [".doug/.state", ".claude/worktrees", ".doug/anchor.md"];
@@ -108,6 +109,37 @@ export function validRecord(r) {
108
109
  }
109
110
  }
110
111
 
112
+ // Test totals depend on the environment (a worktree without a built dist skips tests), not only the tree. A record
113
+ // stamps `ranIn` (realpath of the directory its commands ran in); its totals count as evidence only for that directory.
114
+ // Never throws: any failure, or a record with no ranIn (legacy), is false (the commands run).
115
+ export function totalsUsable(record, dir) {
116
+ try {
117
+ return !!record && typeof record.ranIn === "string" && realpathSync(record.ranIn) === realpathSync(dir);
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ // A record from another checkout (or a legacy one with no ranIn) credits exit codes only when none of its recorded
124
+ // totals reports skipped tests: a dist-less worktree skips tests that could fail here. Absent or unreadable entries
125
+ // do not count. Never throws: any failure is false.
126
+ export function exitCreditUsable(record, dir) {
127
+ try {
128
+ if (!record || typeof record !== "object") return false;
129
+ if (totalsUsable(record, dir)) return true;
130
+ const totals = record.totals;
131
+ if (!totals || typeof totals !== "object") return true;
132
+ for (const entry of Object.values(totals)) {
133
+ const sums = readSummaries(entry);
134
+ if (!sums) continue;
135
+ if (sums.reduce((n, s) => n + (s.skipped || 0), 0) > 0) return false;
136
+ }
137
+ return true;
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+
111
143
  export function readRecord(dir) {
112
144
  try {
113
145
  const r = JSON.parse(readFileSync(join(dir, RECORD_RELPATH), "utf8"));
@@ -214,6 +246,7 @@ if (isMain()) {
214
246
  node: process.version,
215
247
  at: new Date().toISOString(),
216
248
  by: "pre-commit",
249
+ ranIn: realpathSync(dir),
217
250
  commands: commands.map((cm) => ({ name: cm.name, command: cm.command, exit: cm.exit, durationMs: cm.durationMs })),
218
251
  totals,
219
252
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dougharness/gates",
3
- "version": "1.3.0",
3
+ "version": "1.5.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",