@dougharness/cli 1.1.1 → 1.3.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/board-serve.js +40 -6
- package/dist/board-serve.js.map +1 -1
- package/dist/init.js +1 -1
- package/dist/init.js.map +1 -1
- package/dist/memory-setup.d.ts +7 -0
- package/dist/memory-setup.js +70 -46
- package/dist/memory-setup.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
- package/node_modules/@dougharness/flow/agents/coder.md +23 -0
- package/node_modules/@dougharness/flow/agents/implementer.md +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +1 -1
- package/node_modules/@dougharness/flow/agents/researcher.md +2 -1
- package/node_modules/@dougharness/flow/agents/reviewer.md +3 -1
- package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
- package/node_modules/@dougharness/flow/agents/tester.md +21 -0
- package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
- package/node_modules/@dougharness/flow/hooks/register.tsx +94 -0
- package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
- package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +91 -10
- package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +778 -0
- package/node_modules/@dougharness/flow/lib/coordinator.mjs +393 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +15 -5
- package/node_modules/@dougharness/flow/lib/decisions.mjs +4 -2
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +144 -3
- package/node_modules/@dougharness/flow/lib/land.mjs +27 -8
- package/node_modules/@dougharness/flow/lib/memory.mjs +406 -76
- package/node_modules/@dougharness/flow/lib/plan.mjs +33 -5
- package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +440 -0
- package/node_modules/@dougharness/flow/scripts/board.mjs +107 -32
- package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +33 -10
- package/node_modules/@dougharness/flow/scripts/plan.mjs +104 -30
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +8 -2
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +30 -10
- package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
- 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 +6 -6
- package/node_modules/@dougharness/flow/workflows/card-queen.js +846 -0
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +63 -23
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +196 -6
- package/node_modules/@dougharness/gates/lib/gate-lock.mjs +40 -8
- package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
- package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +119 -15
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +156 -14
- package/node_modules/@dougharness/gates/scripts/verify.mjs +8 -4
- package/package.json +3 -3
- package/templates/board-app.js +46 -0
|
@@ -4,7 +4,7 @@ export const meta = {
|
|
|
4
4
|
whenToUse: 'After /doug-plan produced .doug/plan.json and the user approved it with /doug-approve. Pass the plan JSON as args.',
|
|
5
5
|
phases: [
|
|
6
6
|
{ title: 'Implement', detail: 'one implementer per task, fresh context, own worktree (with swarm on, a lead splits a full-shape task into worker briefs, workers implement them in parallel, and the lead merges); a task marked reuse checks out its recorded branch instead; a task whose dependency did not integrate is never launched; a block the checks can describe is fixed in place until the task budget, the fix-attempt ceiling, a pass that made no new commit, or two consecutive passes raising a new blocker stops it' },
|
|
7
|
-
{ title: 'Verify', detail: 'a separate verifier runs the task command in the worktree on the first pass; a fix pass runs one focused check over the fix delta instead, and the full verifier again only when the verifier is the stage that blocked' },
|
|
7
|
+
{ title: 'Verify', detail: 'a separate verifier runs the task command in the worktree on the first pass; a fix pass runs one focused check over the fix delta instead, and the full verifier again only when the verifier is the stage that blocked; a task whose crew names more testers gets extra verifiers in parallel' },
|
|
8
8
|
{ title: 'Review', detail: 'spec compliance, then quality, against the diff on the first pass; on a fix pass the focused check carries scope and regression risk unless the reviewer is the stage that blocked; a task whose crew names two reviewers gets a second one in parallel with a distinct brief' },
|
|
9
9
|
{ title: 'Adversary', detail: 'codex-review (a different model, read-only) explores freely on the first pass and confirms the open findings on later passes, under one material blocker gate; when it cannot run, a Claude adversary on plan.adversary.fallback stands in; blockers stop integration' },
|
|
10
10
|
{ title: 'Integrate', detail: 'merge the level into the integration branch and run plan.verify' },
|
|
@@ -64,6 +64,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 rule about how a commit runs, in every implement prompt that commits: the pre-commit hook runs the full
|
|
68
|
+
// unit suite, which outlives Bash's default timeout, and a background commit dies with the agent that started it.
|
|
69
|
+
const FOREGROUND_COMMIT = 'Run git commit in the foreground with the Bash tool timeout set to 600000 ms (the pre-commit hook runs the full suite): never run_in_background, a Monitor, or a sleep loop. Take the sha only from git rev-parse HEAD after git commit has returned.'
|
|
67
70
|
const LESSONS_NOT_SPEC = 'These are context from earlier runs, not part of the spec or the acceptance criteria; never report a finding, a spec contradiction, or a blocker against a lesson.'
|
|
68
71
|
// The one rule about a partial result, in every prompt a worker (or a resumed worker) can end with one from
|
|
69
72
|
// (card worker-context-handoff): quotes the harness's context notice (a PostToolUse hook in plugins/doug-gates) so
|
|
@@ -84,7 +87,7 @@ function taskShape(task) {
|
|
|
84
87
|
function adversaryRequired(task) {
|
|
85
88
|
return !!adversary && taskShape(task) !== 'S'
|
|
86
89
|
}
|
|
87
|
-
const INTEGRATION_WORKTREE = '.claude/worktrees/doug-integration'
|
|
90
|
+
const INTEGRATION_WORKTREE = plan.integrationWorktree || '.claude/worktrees/doug-integration'
|
|
88
91
|
|
|
89
92
|
// What an adversarial reviewer may call a blocker. The same text is in the two adversary agent files and in
|
|
90
93
|
// codex-review's own prompt; change all four together.
|
|
@@ -831,7 +834,7 @@ function ledgerText(ledger, kind) {
|
|
|
831
834
|
function implementPrompt(task, baseBranch, opts) {
|
|
832
835
|
const resume = !!(opts && opts.resume)
|
|
833
836
|
const branchCheck = resume
|
|
834
|
-
? ` Before that, if git rev-parse --verify --quiet refs/heads/doug/task-${task.id} succeeds, the branch doug/task-${task.id} was created by the previous agent for this task in this same run, which stopped without returning a result; it is yours, not stale. Find that agent's worktree with git worktree list --porcelain (the one with branch refs/heads/doug/task-${task.id}); if none is listed, go straight to the checkout. If it has uncommitted changes to owned files, commit them there first with a message starting "${task.id}: ". ${NO_TRAILERS} Then remove that worktree with git worktree remove --force <path>, then git checkout doug/task-${task.id} in your own worktree and continue the task from that branch's state (do not recreate the branch or reset it). Never return blocked for the branch existing.`
|
|
837
|
+
? ` Before that, if git rev-parse --verify --quiet refs/heads/doug/task-${task.id} succeeds, the branch doug/task-${task.id} was created by the previous agent for this task in this same run, which stopped without returning a result; it is yours, not stale. Find that agent's worktree with git worktree list --porcelain (the one with branch refs/heads/doug/task-${task.id}); if none is listed, go straight to the checkout. If it has uncommitted changes to owned files, commit them there first with a message starting "${task.id}: ". ${NO_TRAILERS} ${FOREGROUND_COMMIT} Then remove that worktree with git worktree remove --force <path>, then git checkout doug/task-${task.id} in your own worktree and continue the task from that branch's state (do not recreate the branch or reset it). Never return blocked for the branch existing.`
|
|
835
838
|
: ` Before that, if git rev-parse --verify --quiet refs/heads/doug/task-${task.id} succeeds, the branch is a leftover of an earlier run: do not check it out or build on it; change nothing and return blocked=true with blockedReason "stale branch doug/task-${task.id} already exists; run plan.mjs replan, which renames it".`
|
|
836
839
|
return [
|
|
837
840
|
`You are implementing exactly one task from an approved plan. Work only in your own git worktree.`,
|
|
@@ -854,7 +857,7 @@ function implementPrompt(task, baseBranch, opts) {
|
|
|
854
857
|
` A fresh worktree has no installed dependencies: if the verification command needs them, run the project's install command first${plan.install ? ` (${plan.install})` : ''}. Installing is not a file change you own; do not commit lockfile changes it makes.`,
|
|
855
858
|
`2. Touch no file outside your owned list. If the task cannot be done without another file, stop and report blocked=true with the reason.`,
|
|
856
859
|
`3. Run the verification command before finishing and report each command with its real exit status. Never report a command you did not run.`,
|
|
857
|
-
`4. Commit your work on the branch with a message starting "${task.id}: ". ${NO_TRAILERS}`,
|
|
860
|
+
`4. Commit your work on the branch with a message starting "${task.id}: ". ${NO_TRAILERS} ${FOREGROUND_COMMIT}`,
|
|
858
861
|
`5. Return the structured result. filesTouched must come from git, not memory, and commit must be the output of git rev-parse HEAD in your worktree after that commit.`,
|
|
859
862
|
`6. ${PARTIAL_RULE}`,
|
|
860
863
|
].join('\n')
|
|
@@ -885,7 +888,7 @@ function reusePrompt(task, baseBranch) {
|
|
|
885
888
|
function leadPrompt(task, baseBranch, opts) {
|
|
886
889
|
const resume = !!(opts && opts.resume)
|
|
887
890
|
const branchCheck = resume
|
|
888
|
-
? ` Before that, if git rev-parse --verify --quiet refs/heads/doug/task-${task.id} succeeds, the branch doug/task-${task.id} was created by the previous agent for this task in this same run, which stopped without returning a result; it is yours, not stale. Find that agent's worktree with git worktree list --porcelain (the one with branch refs/heads/doug/task-${task.id}); if none is listed, go straight to the checkout. If it has uncommitted changes to owned files, commit them there first with a message starting "${task.id}: ". ${NO_TRAILERS} Then remove that worktree with git worktree remove --force <path>, then git checkout doug/task-${task.id} in your own worktree and continue the task from that branch's state (do not recreate the branch or reset it). Never return blocked for the branch existing.`
|
|
891
|
+
? ` Before that, if git rev-parse --verify --quiet refs/heads/doug/task-${task.id} succeeds, the branch doug/task-${task.id} was created by the previous agent for this task in this same run, which stopped without returning a result; it is yours, not stale. Find that agent's worktree with git worktree list --porcelain (the one with branch refs/heads/doug/task-${task.id}); if none is listed, go straight to the checkout. If it has uncommitted changes to owned files, commit them there first with a message starting "${task.id}: ". ${NO_TRAILERS} ${FOREGROUND_COMMIT} Then remove that worktree with git worktree remove --force <path>, then git checkout doug/task-${task.id} in your own worktree and continue the task from that branch's state (do not recreate the branch or reset it). Never return blocked for the branch existing.`
|
|
889
892
|
: ` Before that, if git rev-parse --verify --quiet refs/heads/doug/task-${task.id} succeeds, the branch is a leftover of an earlier run: do not check it out or build on it; change nothing and return blocked=true with blockedReason "stale branch doug/task-${task.id} already exists; run plan.mjs replan, which renames it".`
|
|
890
893
|
return [
|
|
891
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.`,
|
|
@@ -944,7 +947,7 @@ function workerPrompt(task, brief, n, lead) {
|
|
|
944
947
|
` A fresh worktree has no installed dependencies: if the verification command needs them, run the project's install command first${plan.install ? ` (${plan.install})` : ''}. Installing is not a file change you own; do not commit lockfile changes it makes.`,
|
|
945
948
|
`2. Touch no file outside your owned list. If the brief cannot be done without another file, stop and report blocked=true with a blockedReason that names exactly what you need - the file, the symbol or interface, or the decision - because the lead may re-brief you once on it; a vague reason wastes that chance.`,
|
|
946
949
|
`3. Run the verification command before finishing and report each command with its real exit status. Never report a command you did not run.`,
|
|
947
|
-
`4. Commit your work on the branch with a message starting "${task.id}: w${n} ". ${NO_TRAILERS}`,
|
|
950
|
+
`4. Commit your work on the branch with a message starting "${task.id}: w${n} ". ${NO_TRAILERS} ${FOREGROUND_COMMIT}`,
|
|
948
951
|
`5. Return the structured result. filesTouched must come from git diff --name-only ${lead.branch}...HEAD, and commit must be the output of git rev-parse HEAD in your worktree after that commit.`,
|
|
949
952
|
`6. ${PARTIAL_RULE}`,
|
|
950
953
|
].join('\n')
|
|
@@ -1048,7 +1051,7 @@ function fixPrompt(task, r, pass, baseBranch, ledger, brief) {
|
|
|
1048
1051
|
lines.push(`1. Touch no file outside your owned list. If a finding can only be met by changing another file, stop and return blocked=true with the reason.`)
|
|
1049
1052
|
lines.push(`2. Do not delete, skip, or weaken any test to make a check pass; make the code satisfy it. If that is impossible within the spec, return blocked=true and say why.`)
|
|
1050
1053
|
lines.push(`3. Run the verification command before finishing; it must pass. Report every command with its real exit status and never one you did not run.`)
|
|
1051
|
-
lines.push(`4. Commit on ${r.impl.branch} with a message starting "${task.id}: fix ". Do not amend or rewrite earlier commits. A pass that ends with no new commit stops the task. ${NO_TRAILERS}`)
|
|
1054
|
+
lines.push(`4. Commit on ${r.impl.branch} with a message starting "${task.id}: fix ". Do not amend or rewrite earlier commits. A pass that ends with no new commit stops the task. ${NO_TRAILERS} ${FOREGROUND_COMMIT}`)
|
|
1052
1055
|
lines.push(`5. Return the structured result with taskId="${task.id}", branch="${r.impl.branch}", worktreePath${r.worktreeGone ? ' = the absolute path of your worktree (git rev-parse --show-toplevel)' : `="${r.impl.worktreePath}"`}, filesTouched from git diff --name-only ${baseBranch}...HEAD, commit from git rev-parse HEAD after your commit, blocked=false.`)
|
|
1053
1056
|
lines.push(`6. ${PARTIAL_RULE}`)
|
|
1054
1057
|
return lines.join('\n')
|
|
@@ -1081,7 +1084,7 @@ function resumePrompt(task, prev, handoff, baseBranch, brief) {
|
|
|
1081
1084
|
`Rules:`,
|
|
1082
1085
|
`1. Touch no file outside your owned list. If the work cannot be done without another file, stop and report blocked=true with the reason.`,
|
|
1083
1086
|
`2. Run the verification command before finishing and report each command with its real exit status. Never report a command you did not run.`,
|
|
1084
|
-
`3. Commit your work on ${prev.branch} with a message starting "${task.id}: resume ". ${NO_TRAILERS}`,
|
|
1087
|
+
`3. Commit your work on ${prev.branch} with a message starting "${task.id}: resume ". ${NO_TRAILERS} ${FOREGROUND_COMMIT}`,
|
|
1085
1088
|
`4. ${PARTIAL_RULE}`,
|
|
1086
1089
|
`5. Return the structured result. filesTouched must come from git diff --name-only ${baseBranch}...HEAD, and commit must be the output of git rev-parse HEAD in your worktree after that commit.`,
|
|
1087
1090
|
].join('\n')
|
|
@@ -1125,9 +1128,10 @@ function supervisePrompt(task, r, attempts, ledger, signals) {
|
|
|
1125
1128
|
].join('\n')
|
|
1126
1129
|
}
|
|
1127
1130
|
|
|
1128
|
-
function verifyPrompt(task, impl, baseBranch) {
|
|
1131
|
+
function verifyPrompt(task, impl, baseBranch, seat = 1) {
|
|
1129
1132
|
return [
|
|
1130
1133
|
`You are a verifier. You did not write this code. Your job is to run it, not to read and approve it.`,
|
|
1134
|
+
...(seat > 1 ? [`You are crew seat ${seat} of ${crewOf(task).testers} on this task: another verifier runs the same checks at the same time. Run the verification and acceptance commands yourself, and beyond them probe the behavior the spec implies that those commands may not cover: edge cases (empty, missing, boundary, and repeated input) and error paths (invalid input, a failing dependency). A defect you find is a finding even when every command passes.`] : []),
|
|
1131
1135
|
``,
|
|
1132
1136
|
`Task ${task.id}: ${task.title}`,
|
|
1133
1137
|
`Spec:`,
|
|
@@ -1432,14 +1436,14 @@ function integratePrompt(levelIndex, results, baseBranch, isLastLevel, keepWorkt
|
|
|
1432
1436
|
`They are local branches of this repository (each was made in its own worktree); no fetch is needed.`,
|
|
1433
1437
|
``,
|
|
1434
1438
|
`Steps:`,
|
|
1435
|
-
`1. From the repository root, find or create the integration worktree at
|
|
1439
|
+
`1. From the repository root, find or create the integration worktree at ${INTEGRATION_WORKTREE}:`,
|
|
1436
1440
|
...(firstIntegration
|
|
1437
1441
|
? [
|
|
1438
|
-
` this is the run's first integration; the worktree may be a leftover of an earlier run and is never reused as is: if it exists, git worktree remove --force
|
|
1439
|
-
` In every case create it fresh: git worktree add -B ${integrationBranch}
|
|
1442
|
+
` this is the run's first integration; the worktree may be a leftover of an earlier run and is never reused as is: if it exists, git worktree remove --force ${INTEGRATION_WORKTREE} (then git worktree prune).`,
|
|
1443
|
+
` In every case create it fresh: git worktree add -B ${integrationBranch} ${INTEGRATION_WORKTREE} ${baseBranch}`,
|
|
1440
1444
|
]
|
|
1441
1445
|
: [
|
|
1442
|
-
` if it does not exist: git worktree add -B ${integrationBranch}
|
|
1446
|
+
` if it does not exist: git worktree add -B ${integrationBranch} ${INTEGRATION_WORKTREE} ${baseBranch}`,
|
|
1443
1447
|
` if it exists (a previous level made it): use it as is; it already has ${integrationBranch} checked out.`,
|
|
1444
1448
|
]),
|
|
1445
1449
|
` Do every following git and verify command inside that worktree (git -C or cd into it).`,
|
|
@@ -1449,7 +1453,7 @@ function integratePrompt(levelIndex, results, baseBranch, isLastLevel, keepWorkt
|
|
|
1449
1453
|
...(plan.verify.length
|
|
1450
1454
|
? [
|
|
1451
1455
|
` When .doug/hooks/scripts/verify.mjs exists, run them from the repository root (not inside the worktree) as one command, so a tree already verified is credited instead of re-run:`,
|
|
1452
|
-
` node .doug/hooks/scripts/verify.mjs --json --by integrate --cwd
|
|
1456
|
+
` node .doug/hooks/scripts/verify.mjs --json --by integrate --cwd ${INTEGRATION_WORKTREE} --record-dir . ${worktrees.map(w => `--credit-dir ${shellQuote(w)}`).join(' ')} ${plan.verify.map(v => `--command ${shellQuote(v)}`).join(' ')}`,
|
|
1453
1457
|
` Copy each entry of its JSON results[] into verify as { command, ok, outputTail }, and add credited: "verified-tree" to each when the JSON's credited is not null. When verify.mjs does not exist, run the commands one by one as above.`,
|
|
1454
1458
|
]
|
|
1455
1459
|
: []),
|
|
@@ -1469,7 +1473,7 @@ function integratePrompt(levelIndex, results, baseBranch, isLastLevel, keepWorkt
|
|
|
1469
1473
|
]
|
|
1470
1474
|
: []),
|
|
1471
1475
|
`4. If every merge succeeded, remove the merged task worktrees to leave the repository tidy: git worktree remove --force <path> for each of ${worktrees.join(', ')}. Keep their branches.`,
|
|
1472
|
-
isLastLevel && !keepWorktree ? `5. This is the last level. Also remove the integration worktree (git worktree remove --force
|
|
1476
|
+
isLastLevel && !keepWorktree ? `5. This is the last level. Also remove the integration worktree (git worktree remove --force ${INTEGRATION_WORKTREE}); the branch ${integrationBranch} keeps the result.` : isLastLevel ? `5. This is the last level, but keep the integration worktree: the adversary reviews the merged size-S tasks there, and plan.mjs land reuses and removes it.` : `5. Keep the integration worktree; the next level merges into it.`,
|
|
1473
1477
|
isLastLevel
|
|
1474
1478
|
? `6. ok=true only if all merges succeeded, all verify commands passed, and every acceptance command exited 0. Do not push. Do not check out anything in the main working tree.`
|
|
1475
1479
|
: `6. ok=true only if all merges succeeded and all verify commands passed. Do not push. Do not check out anything in the main working tree.`,
|
|
@@ -1990,13 +1994,41 @@ async function runAdversary(task, impl, pass, baseBranch, ledger, launch, stages
|
|
|
1990
1994
|
}
|
|
1991
1995
|
|
|
1992
1996
|
// ---- Crew (card crew-sizing) ---------------------------------------------------
|
|
1993
|
-
// A task (or the plan) declares how many reviewers and
|
|
1997
|
+
// A task (or the plan) declares how many reviewers, adversaries and testers read it; default one each. More coders is a
|
|
1994
1998
|
// split into more tasks, never a shared worktree, so the crew has no coders row. Researchers run before planning
|
|
1995
1999
|
// (the research skill) and are not launched here.
|
|
1996
2000
|
function crewOf(task) {
|
|
1997
2001
|
const src = { ...((plan.crew && typeof plan.crew === 'object') ? plan.crew : {}), ...((task && task.crew && typeof task.crew === 'object') ? task.crew : {}) }
|
|
1998
2002
|
const n = v => (Number.isInteger(v) && v > 0 ? v : 1)
|
|
1999
|
-
return { reviewers: n(src.reviewers), adversaries: n(src.adversaries) }
|
|
2003
|
+
return { reviewers: n(src.reviewers), adversaries: n(src.adversaries), testers: n(src.testers) }
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// One verification out of several tester seats (card crew-tester-seat): passed only when every seat that returned
|
|
2007
|
+
// passed, findings and commands concatenated so the ledger's fingerprint collapses a duplicate, and an acceptance
|
|
2008
|
+
// criterion (matched by text) ok only when every seat reporting it ok. A seat that returned nothing is dropped; when
|
|
2009
|
+
// no seat returned anything the stage returned nothing; one seat comes back unchanged.
|
|
2010
|
+
function mergeVerifies(seats) {
|
|
2011
|
+
const got = seats.filter(Boolean)
|
|
2012
|
+
if (!got.length) return null
|
|
2013
|
+
if (got.length === 1 && seats.length === 1) return got[0]
|
|
2014
|
+
const acceptance = []
|
|
2015
|
+
for (const v of got) {
|
|
2016
|
+
for (const a of (v.acceptance || [])) {
|
|
2017
|
+
const at = acceptance.findIndex(x => x.text === a.text)
|
|
2018
|
+
if (at < 0) acceptance.push({ ...a })
|
|
2019
|
+
else if (acceptance[at].ok && !a.ok) acceptance[at] = { ...a }
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
return {
|
|
2023
|
+
taskId: got[0].taskId,
|
|
2024
|
+
passed: got.every(v => v.passed),
|
|
2025
|
+
commandsRun: got.flatMap(v => v.commandsRun || []),
|
|
2026
|
+
findings: got.flatMap(v => v.findings || []),
|
|
2027
|
+
findingFiles: got.flatMap(v => v.findingFiles || []),
|
|
2028
|
+
acceptance,
|
|
2029
|
+
head: got[0].head,
|
|
2030
|
+
seats: got.length,
|
|
2031
|
+
}
|
|
2000
2032
|
}
|
|
2001
2033
|
|
|
2002
2034
|
// One review out of several: every seat must approve, and every seat's issues are kept, so a finding one reviewer
|
|
@@ -2159,19 +2191,21 @@ async function runChecks(task, impl, pass, baseBranch, ctx) {
|
|
|
2159
2191
|
// The verifier, the reviewer, and the adversary are read-only and none reads another's result, so a first pass
|
|
2160
2192
|
// launches them together; a null from any (skipped, or dead after retries) is that stage returning nothing.
|
|
2161
2193
|
stages.push('verify')
|
|
2162
|
-
stages.push('review')
|
|
2163
2194
|
const crew = crewOf(task)
|
|
2195
|
+
for (let seat = 2; seat <= crew.testers; seat++) stages.push(`verify-${seat}`)
|
|
2196
|
+
stages.push('review')
|
|
2164
2197
|
for (let seat = 2; seat <= crew.reviewers; seat++) stages.push(`review-${seat}`)
|
|
2165
2198
|
const withAdversary = adversaryRequired(task)
|
|
2166
2199
|
const advStages = []
|
|
2167
2200
|
const pair = await parallel([
|
|
2168
2201
|
() => launch(verifyPrompt(task, impl, baseBranch), agentOpts('verify', task, { label: `verify:${task.id}`, phase: 'Verify', agentType: 'doug-flow:verifier', schema: VERIFY_SCHEMA })),
|
|
2202
|
+
...Array.from({ length: crew.testers - 1 }, (_, i) => () => launch(verifyPrompt(task, impl, baseBranch, i + 2), agentOpts('verify', task, { label: `verify-${i + 2}:${task.id}`, phase: 'Verify', agentType: 'doug-flow:verifier', schema: VERIFY_SCHEMA }))),
|
|
2169
2203
|
() => launch(reviewPrompt(task, impl, baseBranch), agentOpts('review', task, { label: `review:${task.id}`, phase: 'Review', agentType: 'doug-flow:reviewer', schema: REVIEW_SCHEMA })),
|
|
2170
2204
|
...Array.from({ length: crew.reviewers - 1 }, (_, i) => () => launch(reviewPrompt(task, impl, baseBranch, i + 2), agentOpts('review', task, { label: `review-${i + 2}:${task.id}`, phase: 'Review', agentType: 'doug-flow:reviewer', schema: REVIEW_SCHEMA }))),
|
|
2171
2205
|
...(withAdversary ? [() => runAdversaryCrew(task, impl, pass, baseBranch, ledger, launch, advStages)] : []),
|
|
2172
2206
|
])
|
|
2173
|
-
ver =
|
|
2174
|
-
rev = pair ? mergeReviews(pair.slice(
|
|
2207
|
+
ver = pair ? mergeVerifies(pair.slice(0, crew.testers)) : null
|
|
2208
|
+
rev = pair ? mergeReviews(pair.slice(crew.testers, crew.testers + crew.reviewers)) : null
|
|
2175
2209
|
stageResults.verify = ver
|
|
2176
2210
|
stageResults.review = rev
|
|
2177
2211
|
// The adversary no longer waits for verify and review (card flow-adversary-parallel, 2026-10-07): waiting
|
|
@@ -2179,7 +2213,7 @@ async function runChecks(task, impl, pass, baseBranch, ctx) {
|
|
|
2179
2213
|
// integration still needs all three verdicts (isReady), and the ledger reads each stage's findings.
|
|
2180
2214
|
if (withAdversary) {
|
|
2181
2215
|
stages.push(...advStages)
|
|
2182
|
-
adv = (pair && pair[
|
|
2216
|
+
adv = (pair && pair[crew.testers + crew.reviewers]) || null
|
|
2183
2217
|
stageResults.adversary = adv
|
|
2184
2218
|
}
|
|
2185
2219
|
blockingStage = !(ver && ver.passed) ? 'verify' : !(rev && rev.approve) ? 'review' : adversaryBlocked(adv) ? 'adversary' : null
|
|
@@ -2187,7 +2221,13 @@ async function runChecks(task, impl, pass, baseBranch, ctx) {
|
|
|
2187
2221
|
const first = c.blocking || 'verify'
|
|
2188
2222
|
if (first === 'verify') {
|
|
2189
2223
|
stages.push('verify')
|
|
2190
|
-
|
|
2224
|
+
const testers = crewOf(task).testers
|
|
2225
|
+
for (let seat = 2; seat <= testers; seat++) stages.push(`verify-${seat}`)
|
|
2226
|
+
const verifyOpts = seat => agentOpts('verify', task, { label: `verify${seat > 1 ? `-${seat}` : ''}:${task.id}${suffix}`, phase: 'Verify', agentType: 'doug-flow:verifier', schema: VERIFY_SCHEMA })
|
|
2227
|
+
if (testers > 1) {
|
|
2228
|
+
const seats = await parallel(Array.from({ length: testers }, (_, i) => () => launch(verifyPrompt(task, impl, baseBranch, i + 1), verifyOpts(i + 1))))
|
|
2229
|
+
ver = mergeVerifies(seats || [])
|
|
2230
|
+
} else ver = await launch(verifyPrompt(task, impl, baseBranch), verifyOpts(1))
|
|
2191
2231
|
stageResults.verify = ver
|
|
2192
2232
|
if (!(ver && ver.passed)) blockingStage = 'verify'
|
|
2193
2233
|
} else if (first === 'review') {
|
|
@@ -2422,7 +2462,7 @@ async function runTask(task, baseBranch, resume) {
|
|
|
2422
2462
|
const fellBack = (r.stages || []).includes('adversary-fallback')
|
|
2423
2463
|
const crew = crewOf(task)
|
|
2424
2464
|
const advAgents = adversaryRequired(task) ? (fellBack ? 1 : 0) + crew.adversaries : 0
|
|
2425
|
-
const firstStage = blocking === 'adversary' ? advAgents : blocking === 'review' ? crew.reviewers : 1
|
|
2465
|
+
const firstStage = blocking === 'adversary' ? advAgents : blocking === 'review' ? crew.reviewers : blocking === 'verify' ? crew.testers : 1
|
|
2426
2466
|
const nextStages = firstStage + (blocking === 'check' ? 0 : 1) + (blocking === 'adversary' ? 0 : advAgents)
|
|
2427
2467
|
const spent = measure()
|
|
2428
2468
|
const passesDone = attempts.length
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "doug-gates",
|
|
3
3
|
"displayName": "Doug Gates",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.3.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"
|
|
@@ -23,6 +23,28 @@ export function readPlanSummary(dir) {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Every active (enforced, not landed) per-card plan under .doug/plans/, as readPlanSummary shapes them.
|
|
27
|
+
export function readPlanSummaries(dir) {
|
|
28
|
+
const out = [];
|
|
29
|
+
try {
|
|
30
|
+
const d = join(dir, ".doug/plans");
|
|
31
|
+
if (!existsSync(d)) return out;
|
|
32
|
+
for (const name of readdirSync(d).filter((f) => f.endsWith(".json")).sort()) {
|
|
33
|
+
try {
|
|
34
|
+
const p = JSON.parse(readFileSync(join(d, name), "utf8"));
|
|
35
|
+
if (!p || typeof p !== "object" || !Array.isArray(p.tasks) || p.landed) continue;
|
|
36
|
+
if (p.status !== "approved" && p.status !== "done") continue;
|
|
37
|
+
out.push({ title: p.title || "(untitled)", status: p.status, card: typeof p.card === "string" ? p.card : null, tasks: p.tasks.map((t) => ({ id: t.id, files: Array.isArray(t.files) ? t.files : [] })) });
|
|
38
|
+
} catch {
|
|
39
|
+
// an unreadable per-card plan is skipped
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
// Short HEAD sha and current branch, tolerant of a non-repo or a git failure (moved from stop-gate.mjs; used by
|
|
27
49
|
// both the Stop gate's own handoff and the PreCompact snapshot).
|
|
28
50
|
export function gitHead(dir) {
|
|
@@ -55,7 +77,7 @@ export function readDecisions(dir) {
|
|
|
55
77
|
// plan/decisions/edited-files lines (card precompact-keeps-handoff, review MAJOR): those grow unbounded with a
|
|
56
78
|
// session's edited-file count, and SessionStart's re-injection caps the whole anchor at 4000 characters, so a few
|
|
57
79
|
// short handoff lines must be near the top to survive that cap on a long session.
|
|
58
|
-
export function snapshotSection({ plan, decisions, state, now, handoff }) {
|
|
80
|
+
export function snapshotSection({ plan, plans, decisions, state, now, handoff }) {
|
|
59
81
|
const lines = [`## Compaction snapshot (doug, ${now.toISOString()})`];
|
|
60
82
|
if (handoff) {
|
|
61
83
|
lines.push("");
|
|
@@ -70,9 +92,12 @@ export function snapshotSection({ plan, decisions, state, now, handoff }) {
|
|
|
70
92
|
if (typeof handoff.contextPct === "number") lines.push(`Context: ${handoff.contextPct}%`);
|
|
71
93
|
}
|
|
72
94
|
lines.push("");
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
for (const
|
|
95
|
+
const shown = plan ? [plan] : plans || [];
|
|
96
|
+
if (shown.length) {
|
|
97
|
+
for (const pl of shown) {
|
|
98
|
+
lines.push(`Plan: ${pl.title} [${pl.status}]${pl.card ? ` (card ${pl.card})` : ""}`);
|
|
99
|
+
for (const t of pl.tasks) lines.push(`- ${t.id} owns ${t.files.length ? t.files.join(", ") : "(no files)"}`);
|
|
100
|
+
}
|
|
76
101
|
} else lines.push("Plan: none in .doug/plan.json");
|
|
77
102
|
lines.push("");
|
|
78
103
|
lines.push(decisions.length ? `Decisions (docs/decisions): ${decisions.join(", ")}` : "Decisions: none under docs/decisions");
|
|
@@ -13,8 +13,8 @@ const PM_INSTALL_VERBS = {
|
|
|
13
13
|
// "|"/"&"/"|&"/newline, and the body of every unquoted-or-double-quoted "$(...)" or backtick substitution
|
|
14
14
|
// really executes, so its pieces are split out too, recursively (research Q1.5; design decided by the lead,
|
|
15
15
|
// point 3). Falls back to today's quote-blind split - checking MORE than a real shell would, never less - when
|
|
16
|
-
// the scanner hits an unterminated quote or substitution (a heredoc body looks the same to it
|
|
17
|
-
//
|
|
16
|
+
// the scanner hits an unterminated quote or substitution (a heredoc body looks the same to it; only the
|
|
17
|
+
// package-manager rule strips heredoc bodies - stripHeredocBodies, card bash-rules-heredoc-body) or throws for any other reason (a hook reader must never throw - CLAUDE.md Gotchas).
|
|
18
18
|
// A bare "&" (backgrounds the command before it, redirection forms excepted) and bash's "|&" (a pipe connector,
|
|
19
19
|
// treated the same as a bare "|") joined this set in review round 5 (third review) - see scanTop's comment.
|
|
20
20
|
const ALL_SEPS = new Set(["&&", "||", ";", "|", "&", "|&", "\n"]);
|
|
@@ -155,8 +155,8 @@ function words(cmd) {
|
|
|
155
155
|
// itself sits inside (a stack, not a boolean: scanParenBody below starts its own body scan fresh in the "top"
|
|
156
156
|
// state). "$((...))" is read as a substitution too (its body is "(...)" verbatim) - over-checking is fine.
|
|
157
157
|
//
|
|
158
|
-
// Known limits, stated as limits, not bugs: a word-initial "#" comment and a heredoc body are not recognised
|
|
159
|
-
// (over-report only, never under-report - research Q1.7); zsh's RC_QUOTES, bash 5.x, and dash's lack of
|
|
158
|
+
// Known limits, stated as limits, not bugs: a word-initial "#" comment and a heredoc body are not recognised by this scanner
|
|
159
|
+
// (the package-manager rule alone strips heredoc bodies first - stripHeredocBodies; over-report only, never under-report - research Q1.7); zsh's RC_QUOTES, bash 5.x, and dash's lack of
|
|
160
160
|
// $'...' are unverified or divergent, and the bash/zsh reading is the one this scanner takes; a "(...)" subshell
|
|
161
161
|
// or "{ ...; }" group is not unwrapped (unchanged from today).
|
|
162
162
|
//
|
|
@@ -1060,7 +1060,7 @@ function pipeStages(group) {
|
|
|
1060
1060
|
// group's stages at "|" or "|&" (pipeStages above) - and, design point 5, recursion into every substitution
|
|
1061
1061
|
// body's own groups too, so a curl|sh chain hidden inside a "$(...)" or backtick is still caught. Because
|
|
1062
1062
|
// scanTop finds a substitution wherever it sits - inside an ordinary quoted argument or inside what looks like
|
|
1063
|
-
// inert text such as a heredoc body (
|
|
1063
|
+
// inert text such as a heredoc body (this check keeps no heredoc state, a known limit) - a "$(... curl
|
|
1064
1064
|
// ... | sh)" written inside either is still denied even where a real shell would just treat it as literal
|
|
1065
1065
|
// text: an over-denial on the safe side, not a miss. Falls back to the old quote-blind group split (unchanged -
|
|
1066
1066
|
// review round 5 did not extend this fallback regex to "&"/"|&", see the coder's report) on an unterminated
|
|
@@ -1485,6 +1485,184 @@ export function commandForms(cmd, depth = 0) {
|
|
|
1485
1485
|
}
|
|
1486
1486
|
}
|
|
1487
1487
|
|
|
1488
|
+
// Card bash-rules-heredoc-body: the body of a heredoc (<<WORD, <<'WORD', <<"WORD", <<-WORD, up to the terminator
|
|
1489
|
+
// line) is data fed to a command's stdin, not commands, so the package-manager rule must not match prose inside
|
|
1490
|
+
// it (a research note quoting the npm CLI). stripHeredocBodies returns the command with those bodies removed;
|
|
1491
|
+
// evaluateBash feeds it to the package-manager rule ONLY - every other rule still sees the original text
|
|
1492
|
+
// (checks more, never less). Its own small quote-aware walk (not scanTop: a body with an apostrophe would
|
|
1493
|
+
// leave scanTop in an unterminated quote and send it to the quote-blind fallback). A body is kept, i.e. still
|
|
1494
|
+
// checked, when: its terminator line never appears (unterminated); ANY pipeline stage anywhere in the whole
|
|
1495
|
+
// command is a shell or source/"." (a shell reading the heredoc runs it as a script), or any group ends in a
|
|
1496
|
+
// trailing "|" (the receiving stage is on a later line): then nothing is stripped; or the delimiter is unquoted
|
|
1497
|
+
// and the body holds "$(" or a backtick (an unquoted heredoc expands substitutions, which run).
|
|
1498
|
+
// Known limits (accepted, not caught): a heredoc written to a file later run (cat > s.sh <<EOF ... bash s.sh),
|
|
1499
|
+
// xargs ... sh -c, tee >(sh), while read l; do $l; done <<EOF, ssh/docker exec (remote). "<<<" is a
|
|
1500
|
+
// here-string, not a heredoc; an operator inside a quote, a substitution, or a word-initial "#" comment is not
|
|
1501
|
+
// one either. Never throws: any error returns the command unchanged (today's behaviour).
|
|
1502
|
+
function heredocReadsAsScript(stage) {
|
|
1503
|
+
// Compared on the raw stage and on the stage with quotes and backslashes removed ("sh", s\h): conservative.
|
|
1504
|
+
for (const text of [stage, stage.replace(/["'\\]/g, "")]) {
|
|
1505
|
+
if (matchesShellStage(text)) return true;
|
|
1506
|
+
const r = resolveVerb(words(text), 0);
|
|
1507
|
+
const verb = r.w[r.i];
|
|
1508
|
+
if (verb === "source" || verb === ".") return true;
|
|
1509
|
+
}
|
|
1510
|
+
return false;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// Whole-command rule (round 2): ANY pipeline stage anywhere in the command that reads as a shell script consumer,
|
|
1514
|
+
// or any group ending in a trailing "|", keeps every body. A per-operator or per-group relation misses a heredoc
|
|
1515
|
+
// inside a compound (if/for/while/case/subshell/brace group) whose closing keyword is piped to a shell.
|
|
1516
|
+
function commandHasShellConsumer(str) {
|
|
1517
|
+
const cut = cutOnSeparators(str, GROUP_SEPS);
|
|
1518
|
+
const groups = cut ? cut.pieces : str.split(/\s*(?:&&|\|\||;)\s*|\n/).map((s) => s.trim()).filter(Boolean);
|
|
1519
|
+
for (const group of groups) {
|
|
1520
|
+
if (/\|&?\s*$/.test(group)) return true; // the receiving stage is on a later line: unknown, so conservative
|
|
1521
|
+
if (pipeStages(group).some((st) => heredocReadsAsScript(st))) return true;
|
|
1522
|
+
}
|
|
1523
|
+
return false;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
export function stripHeredocBodies(command) {
|
|
1527
|
+
const str = String(command);
|
|
1528
|
+
try {
|
|
1529
|
+
if (!str.includes("<<")) return str;
|
|
1530
|
+
if (commandHasShellConsumer(str)) return str;
|
|
1531
|
+
const removals = [];
|
|
1532
|
+
let pending = [];
|
|
1533
|
+
let state = "top";
|
|
1534
|
+
let i = 0;
|
|
1535
|
+
while (i < str.length) {
|
|
1536
|
+
const c = str[i];
|
|
1537
|
+
if (state === "squote") { if (c === "'") state = "top"; i += 1; continue; }
|
|
1538
|
+
if (state === "dquote") {
|
|
1539
|
+
if (c === "\\" && isDquoteEscapable(str[i + 1])) { i += 2; continue; }
|
|
1540
|
+
if (c === '"') { state = "top"; i += 1; continue; }
|
|
1541
|
+
if (c === "$" && str[i + 1] === "(") {
|
|
1542
|
+
const close = scanParenBody(str, i + 1);
|
|
1543
|
+
if (close < 0) return str;
|
|
1544
|
+
i = close + 1;
|
|
1545
|
+
continue;
|
|
1546
|
+
}
|
|
1547
|
+
if (c === "`") {
|
|
1548
|
+
const close = scanBacktickBody(str, i);
|
|
1549
|
+
if (close < 0) return str;
|
|
1550
|
+
i = close + 1;
|
|
1551
|
+
continue;
|
|
1552
|
+
}
|
|
1553
|
+
i += 1;
|
|
1554
|
+
continue;
|
|
1555
|
+
}
|
|
1556
|
+
if (state === "ansic") {
|
|
1557
|
+
if (c === "\\") { i += 2; continue; }
|
|
1558
|
+
if (c === "'") state = "top";
|
|
1559
|
+
i += 1;
|
|
1560
|
+
continue;
|
|
1561
|
+
}
|
|
1562
|
+
// top
|
|
1563
|
+
if (c === "\\") { i += 2; continue; }
|
|
1564
|
+
if (c === "'") { state = "squote"; i += 1; continue; }
|
|
1565
|
+
if (c === '"') { state = "dquote"; i += 1; continue; }
|
|
1566
|
+
if (c === "$" && str[i + 1] === "'") { state = "ansic"; i += 2; continue; }
|
|
1567
|
+
if (c === "$" && str[i + 1] === '"') { state = "dquote"; i += 2; continue; }
|
|
1568
|
+
if (c === "$" && str[i + 1] === "(") {
|
|
1569
|
+
const close = scanParenBody(str, i + 1);
|
|
1570
|
+
if (close < 0) return str;
|
|
1571
|
+
i = close + 1;
|
|
1572
|
+
continue;
|
|
1573
|
+
}
|
|
1574
|
+
if (c === "`") {
|
|
1575
|
+
const close = scanBacktickBody(str, i);
|
|
1576
|
+
if (close < 0) return str;
|
|
1577
|
+
i = close + 1;
|
|
1578
|
+
continue;
|
|
1579
|
+
}
|
|
1580
|
+
if (c === "#" && (i === 0 || /[\s;&|()]/.test(str[i - 1]))) {
|
|
1581
|
+
while (i < str.length && str[i] !== "\n") i += 1;
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
if (c === "<" && str[i + 1] === "<") {
|
|
1585
|
+
if (str[i + 2] === "<") { i += 3; continue; } // here-string
|
|
1586
|
+
let j = i + 2;
|
|
1587
|
+
const dash = str[j] === "-";
|
|
1588
|
+
if (dash) j += 1;
|
|
1589
|
+
while (str[j] === " " || str[j] === "\t") j += 1;
|
|
1590
|
+
let word = "";
|
|
1591
|
+
let quoted = false;
|
|
1592
|
+
let dollarQuoted = false;
|
|
1593
|
+
while (j < str.length && !/[\s;&|<>()]/.test(str[j])) {
|
|
1594
|
+
const ch = str[j];
|
|
1595
|
+
if (ch === "\\") { quoted = true; word += str[j + 1] ?? ""; j += 2; continue; }
|
|
1596
|
+
if (ch === "$" && (str[j + 1] === "'" || str[j + 1] === '"')) { dollarQuoted = true; j += 1; continue; }
|
|
1597
|
+
if (ch === "'" || ch === '"') {
|
|
1598
|
+
const close = str.indexOf(ch, j + 1);
|
|
1599
|
+
if (close < 0) return str;
|
|
1600
|
+
quoted = true;
|
|
1601
|
+
word += str.slice(j + 1, close);
|
|
1602
|
+
j = close + 1;
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1605
|
+
word += ch;
|
|
1606
|
+
j += 1;
|
|
1607
|
+
}
|
|
1608
|
+
if (word) pending.push({ word, dash, quoted, shellFed: dollarQuoted || word[0] === "$" });
|
|
1609
|
+
i = Math.max(j, i + 2);
|
|
1610
|
+
continue;
|
|
1611
|
+
}
|
|
1612
|
+
const sep =
|
|
1613
|
+
(c === "&" && str[i + 1] === "&") || (c === "|" && str[i + 1] === "|") ? 2
|
|
1614
|
+
: c === ";" ? 1
|
|
1615
|
+
: c === "&" && str[i + 1] !== ">" && str[i - 1] !== ">" && str[i - 1] !== "<" && str[i - 1] !== "|" ? 1
|
|
1616
|
+
: 0;
|
|
1617
|
+
if (sep) {
|
|
1618
|
+
i += sep;
|
|
1619
|
+
continue;
|
|
1620
|
+
}
|
|
1621
|
+
if (c === "\n") {
|
|
1622
|
+
const ops = pending;
|
|
1623
|
+
pending = [];
|
|
1624
|
+
let pos = i + 1;
|
|
1625
|
+
let lastEnd = -1;
|
|
1626
|
+
for (const op of ops) {
|
|
1627
|
+
const bodyStart = pos;
|
|
1628
|
+
let found = -1;
|
|
1629
|
+
while (pos <= str.length) {
|
|
1630
|
+
const nl = str.indexOf("\n", pos);
|
|
1631
|
+
const lineEnd = nl < 0 ? str.length : nl;
|
|
1632
|
+
const line = str.slice(pos, lineEnd);
|
|
1633
|
+
if ((op.dash ? line.replace(/^\t+/, "") : line) === op.word) { found = lineEnd; break; }
|
|
1634
|
+
if (lineEnd >= str.length) break;
|
|
1635
|
+
pos = lineEnd + 1;
|
|
1636
|
+
}
|
|
1637
|
+
if (found < 0) return removals.length ? applyRemovals(str, removals) : str; // unterminated: stop here
|
|
1638
|
+
const body = str.slice(bodyStart, found);
|
|
1639
|
+
const expands = !op.quoted && /\$\(|`/.test(body);
|
|
1640
|
+
if (!op.shellFed && !expands) removals.push([bodyStart, found]);
|
|
1641
|
+
lastEnd = found;
|
|
1642
|
+
pos = found + 1;
|
|
1643
|
+
}
|
|
1644
|
+
if (lastEnd >= 0) { i = lastEnd; continue; }
|
|
1645
|
+
i += 1;
|
|
1646
|
+
continue;
|
|
1647
|
+
}
|
|
1648
|
+
i += 1;
|
|
1649
|
+
}
|
|
1650
|
+
return removals.length ? applyRemovals(str, removals) : str;
|
|
1651
|
+
} catch {
|
|
1652
|
+
return str;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
function applyRemovals(str, removals) {
|
|
1657
|
+
let out = "";
|
|
1658
|
+
let at = 0;
|
|
1659
|
+
for (const [a, b] of removals) {
|
|
1660
|
+
out += str.slice(at, a);
|
|
1661
|
+
at = b;
|
|
1662
|
+
}
|
|
1663
|
+
return out + str.slice(at);
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1488
1666
|
export function checkPackageManager(cmd, expected) {
|
|
1489
1667
|
if (!expected) return null;
|
|
1490
1668
|
const m = /^(npm|pnpm|yarn|bun)\s*(.*)$/.exec(cmd);
|
|
@@ -1506,6 +1684,8 @@ export function evaluateBash(command, { config, currentBranch }) {
|
|
|
1506
1684
|
const pipeReason = checkPipeToShell(command);
|
|
1507
1685
|
if (pipeReason) reasons.push(pipeReason);
|
|
1508
1686
|
}
|
|
1687
|
+
const pmCommand = config.bash.packageManagerGuard ? stripHeredocBodies(command) : command;
|
|
1688
|
+
const pmSeparate = pmCommand !== command;
|
|
1509
1689
|
for (const cmd of splitCommands(command)) {
|
|
1510
1690
|
// Card bash-rules-prefixed-commands: run the five per-command checks (each anchored on the start of the
|
|
1511
1691
|
// string it's given) on every form commandForms resolves for this split command - cmd itself, a prefix
|
|
@@ -1521,9 +1701,19 @@ export function evaluateBash(command, { config, currentBranch }) {
|
|
|
1521
1701
|
checks.push(checkResetHardOnProtected(form, config.bash.denyForcePushTo, currentBranch));
|
|
1522
1702
|
}
|
|
1523
1703
|
if (config.bash.denyDestructive) checks.push(checkDestructive(form));
|
|
1524
|
-
if (config.bash.packageManagerGuard) checks.push(checkPackageManager(form, config.packageManager));
|
|
1704
|
+
if (config.bash.packageManagerGuard && !pmSeparate) checks.push(checkPackageManager(form, config.packageManager));
|
|
1525
1705
|
for (const r of checks) if (r && !reasons.includes(r)) reasons.push(r);
|
|
1526
1706
|
}
|
|
1527
1707
|
}
|
|
1708
|
+
// Card bash-rules-heredoc-body: with a data heredoc body present, the package-manager rule reads the command
|
|
1709
|
+
// with those bodies stripped (stripHeredocBodies); the other rules above already ran on the original text.
|
|
1710
|
+
if (pmSeparate) {
|
|
1711
|
+
for (const cmd of splitCommands(pmCommand)) {
|
|
1712
|
+
for (const form of commandForms(cmd)) {
|
|
1713
|
+
const r = checkPackageManager(form, config.packageManager);
|
|
1714
|
+
if (r && !reasons.includes(r)) reasons.push(r);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1528
1718
|
return reasons;
|
|
1529
1719
|
}
|
|
@@ -1,15 +1,45 @@
|
|
|
1
1
|
// The gate.lock helpers shared by the Stop gate (scripts/stop-gate.mjs) and verify.mjs (card
|
|
2
2
|
// verify-takes-gate-lock). Moved verbatim from stop-gate.mjs; the Stop gate's behaviour is unchanged.
|
|
3
3
|
|
|
4
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
-
import {
|
|
4
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
7
|
|
|
7
8
|
// Single-runner lock (card stop-gate-process-storm, goal 3): only one gate runs the verification commands
|
|
8
9
|
// at a time per project, so concurrent SubagentStops share one suite instead of each starting its own (the
|
|
9
|
-
// 2026-09-16 process storm).
|
|
10
|
-
//
|
|
10
|
+
// 2026-09-16 process storm). gate.lock holds `{ pid, session, at }` while a gate's commands run. Card
|
|
11
|
+
// gate-lock-shared (ADR 0015): it lives at `<git common dir>/doug/gate.lock`, a path every worktree of the
|
|
12
|
+
// repo shares, so gates in parallel worktrees queue instead of running full suites concurrently. It falls
|
|
13
|
+
// back to the per-checkout `.doug/.state/gate.lock` when git is unavailable, `dir` is not in a repo, or the
|
|
14
|
+
// common dir is not writable; resolution never throws.
|
|
11
15
|
const GATE_LOCK_RELPATH = ".doug/.state/gate.lock";
|
|
16
|
+
// Memoized per dir for the life of the process (a fallback result too): one gate run must see one path,
|
|
17
|
+
// or a poll that fell back under load would read no lock and take over a live holder's shared lock.
|
|
18
|
+
const lockPathMemo = new Map();
|
|
12
19
|
export function gateLockPath(dir) {
|
|
20
|
+
if (lockPathMemo.has(dir)) return lockPathMemo.get(dir);
|
|
21
|
+
const p = resolveGateLockPath(dir);
|
|
22
|
+
lockPathMemo.set(dir, p);
|
|
23
|
+
return p;
|
|
24
|
+
}
|
|
25
|
+
function resolveGateLockPath(dir) {
|
|
26
|
+
try {
|
|
27
|
+
const out = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
|
|
28
|
+
cwd: dir,
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
31
|
+
timeout: 5000,
|
|
32
|
+
}).trim();
|
|
33
|
+
if (out) {
|
|
34
|
+
const common = resolve(dir, out);
|
|
35
|
+
const lockDir = join(common, "doug");
|
|
36
|
+
accessSync(common, constants.W_OK);
|
|
37
|
+
if (existsSync(lockDir)) accessSync(lockDir, constants.W_OK);
|
|
38
|
+
return join(lockDir, "gate.lock");
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// fall through to the per-checkout path
|
|
42
|
+
}
|
|
13
43
|
return join(dir, GATE_LOCK_RELPATH);
|
|
14
44
|
}
|
|
15
45
|
|
|
@@ -43,8 +73,9 @@ export function pidIsLive(pid) {
|
|
|
43
73
|
// Creates gate.lock exclusively (throws EEXIST when one is already there, or any other fs error). The
|
|
44
74
|
// caller decides what a failure means.
|
|
45
75
|
export function writeGateLock(dir, sessionId) {
|
|
46
|
-
|
|
47
|
-
|
|
76
|
+
const p = gateLockPath(dir);
|
|
77
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
78
|
+
writeFileSync(p, JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }), { flag: "wx" });
|
|
48
79
|
}
|
|
49
80
|
|
|
50
81
|
// Overwrites gate.lock with this gate's own pid/session, whether it was stale (a dead pid) or its holder
|
|
@@ -53,8 +84,9 @@ export function writeGateLock(dir, sessionId) {
|
|
|
53
84
|
// does not hold).
|
|
54
85
|
export function takeOverGateLock(dir, sessionId) {
|
|
55
86
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
87
|
+
const p = gateLockPath(dir);
|
|
88
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
89
|
+
writeFileSync(p, JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }));
|
|
58
90
|
} catch {
|
|
59
91
|
// best-effort takeover only
|
|
60
92
|
}
|