@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.
- package/dist/ablate.js +2 -2
- package/dist/ablate.js.map +1 -1
- package/dist/bin.js +13 -4
- package/dist/bin.js.map +1 -1
- package/dist/board-page.d.ts +5 -1
- package/dist/board-page.js +19 -5
- package/dist/board-page.js.map +1 -1
- package/dist/board-serve.js +34 -10
- package/dist/board-serve.js.map +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/board.js +160 -13
- package/dist/board.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +1 -0
- package/node_modules/@dougharness/flow/hooks/register.tsx +18 -2
- package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +94 -7
- package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +31 -4
- package/node_modules/@dougharness/flow/lib/board.mjs +375 -30
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +237 -24
- package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
- package/node_modules/@dougharness/flow/lib/decisions.mjs +2 -4
- package/node_modules/@dougharness/flow/lib/learn.mjs +95 -54
- package/node_modules/@dougharness/flow/lib/memory.mjs +1403 -44
- package/node_modules/@dougharness/flow/lib/plan.mjs +40 -0
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +355 -13
- package/node_modules/@dougharness/flow/scripts/board.mjs +160 -27
- package/node_modules/@dougharness/flow/scripts/cost.mjs +6 -3
- package/node_modules/@dougharness/flow/scripts/learn.mjs +1 -1
- package/node_modules/@dougharness/flow/scripts/memory.mjs +668 -39
- package/node_modules/@dougharness/flow/scripts/plan.mjs +3 -3
- package/node_modules/@dougharness/flow/scripts/session-recall.mjs +11 -4
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +7 -3
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +11 -2
- package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +9 -8
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +3 -3
- package/node_modules/@dougharness/flow/workflows/card-queen.js +238 -25
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +55 -37
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/baseline.mjs +32 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +45 -5
- package/node_modules/@dougharness/gates/lib/docs-gate.mjs +6 -2
- package/node_modules/@dougharness/gates/lib/verified-tree.mjs +33 -0
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +130 -42
- package/node_modules/@dougharness/gates/scripts/verify.mjs +20 -4
- package/package.json +3 -3
- 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
|
|
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
|
-
//
|
|
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)
|
|
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.
|
|
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
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
|
1867
|
-
//
|
|
1868
|
-
//
|
|
1869
|
-
//
|
|
1870
|
-
//
|
|
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.
|
|
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. '
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
+
"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",
|