@dougharness/cli 1.1.0 → 1.1.1
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/bin.js +10 -3
- package/dist/bin.js.map +1 -1
- package/dist/board-serve.js +5 -4
- package/dist/board-serve.js.map +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/board.js +33 -8
- package/dist/board.js.map +1 -1
- package/dist/memory-setup.js +5 -2
- package/dist/memory-setup.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/flow/agents/lead.md +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +2 -2
- package/node_modules/@dougharness/flow/lib/board.mjs +17 -1
- package/node_modules/@dougharness/flow/lib/cost.mjs +29 -6
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +3 -1
- package/node_modules/@dougharness/flow/lib/land.mjs +6 -1
- package/node_modules/@dougharness/flow/lib/memory.mjs +49 -22
- package/node_modules/@dougharness/flow/lib/plan.mjs +2 -1
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/board.mjs +35 -14
- package/node_modules/@dougharness/flow/scripts/context.mjs +4 -2
- package/node_modules/@dougharness/flow/scripts/memory.mjs +10 -6
- package/node_modules/@dougharness/flow/scripts/plan.mjs +22 -0
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +4 -4
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +8 -6
- package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +2 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +11 -9
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +26 -14
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +21 -14
- package/node_modules/@dougharness/gates/lib/gate-lock.mjs +94 -0
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +10 -87
- package/node_modules/@dougharness/gates/scripts/verify.mjs +58 -3
- package/package.json +4 -4
- package/templates/board-app.js +2 -1
|
@@ -36,12 +36,12 @@ If the card's goal depends on a fact outside the repository (a Claude Code hook
|
|
|
36
36
|
The planner runs on the `plan` row (see "Who does the work" above): read it with the `plan.mjs models` command, `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" models` (`roles.plan.model`, `roles.plan.effort`), and pass that row's model on every planner spawn below (omit `model` when the row says `inherit`).
|
|
37
37
|
|
|
38
38
|
Spawn the planner with the card's goal text verbatim as the request:
|
|
39
|
-
`Agent({ subagent_type: "doug-flow:planner", model: "<the plan row's model>", prompt: "Board card <id>, title \"<card.title>\": <card.goal>. Write .doug/plan.json and return the rendered plan." })`
|
|
39
|
+
`Agent({ subagent_type: "doug-flow:planner", model: "<the plan row's model>", prompt: "Board card <id>, title \"<card.title>\": <card.goal>. Scripts: ${CLAUDE_PLUGIN_ROOT}/scripts. Write .doug/plan.json and return the rendered plan." })`
|
|
40
40
|
|
|
41
41
|
Then record the card on the plan: `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" set card <id>` (the planner also writes it; setting it here is what guarantees the plan file carries the id even if the planner forgot).
|
|
42
42
|
|
|
43
43
|
Batch: spawn one planner per card in the same message so they run in parallel, each told where its draft goes, each carrying the same plan row's model:
|
|
44
|
-
`Agent({ subagent_type: "doug-flow:planner", model: "<the plan row's model>", prompt: "Board card <id>, title \"<card.title>\": <card.goal>. Write the plan to .doug/.state/drafts/<id>.json (a draft for a batch; not .doug/plan.json), validate it with plan.mjs validate --file .doug/.state/drafts/<id>.json, and return the rendered plan." })`
|
|
44
|
+
`Agent({ subagent_type: "doug-flow:planner", model: "<the plan row's model>", prompt: "Board card <id>, title \"<card.title>\": <card.goal>. Scripts: ${CLAUDE_PLUGIN_ROOT}/scripts. Write the plan to .doug/.state/drafts/<id>.json (a draft for a batch; not .doug/plan.json), validate it with plan.mjs validate --file .doug/.state/drafts/<id>.json, and return the rendered plan." })`
|
|
45
45
|
When all have returned, `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" merge <id> <id>...` joins the drafts into `.doug/plan.json`: one draft plan, `cards` listing the batch in board order, every task carrying its `card`, level k of the batch being the union of each card's level k. A file two cards own is not refused: the later card's task (board order) gets a dependency edge to the earlier card's, so the file is edited in sequence and each card keeps its own spec, verify, and acceptance; merge prints each such edge (`<file>: <card>/<task> runs after <card>/<task>`), any task id it had to rename, and any plan-level setting the drafts disagreed on. Only a dependency cycle across cards is refused, by card name; then edit the drafts (or drop a card from the batch) and merge again. Put merge's output in the chat verbatim. Do not run `plan.mjs set card` for a batch; `plan.mjs show` prints `Cards:` and each task's card.
|
|
46
46
|
|
|
47
47
|
Then `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" show` and put its complete output in the chat, verbatim, in a fenced code block, before anything else: the title and goal, every acceptance line, the verify commands, every level with each task's owned files and verify command, and the models table. The approval prompt that follows is a small dialog and hides the plan, so the user must be able to read the whole plan in the conversation above it. After the block, add at most a few lines of your own: any assumption the planner stated, any task that owns many files, and any task whose implementer is not on the implement row (a `tier`, `model`, or `effort` on the task): the planner sets none on its own, so one that is there came from the plan file and the user decides whether it stays. Do not summarize the plan instead of showing it.
|
|
@@ -69,10 +69,10 @@ If `ok` is not true: the workflow has already relaunched the implementer for eve
|
|
|
69
69
|
Then, with the merge commit it printed:
|
|
70
70
|
|
|
71
71
|
1. `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" move <id> done --source "commit <sha>"`
|
|
72
|
-
2. Invoke the `run-report` skill for the card with `.doug/.state/last-report.json`, the run id the Workflow tool printed (`wf_...`), the wall clock, and the merge commit: it measures the run's cost with `cost.mjs` from the local transcripts (the workflow does not report cost; never estimate one), passes `cost.mjs`'s Codex line, when present, as `--codex-cost` to `board.mjs record` and `board.mjs summary` (never folded into `--cost`), appends the run entry to `docs/live-runs.md` with `board.mjs record`, and prints the three-line summary with `board.mjs summary
|
|
72
|
+
2. Invoke the `run-report` skill for the card with `.doug/.state/last-report.json`, the run id the Workflow tool printed (`wf_...`), the wall clock, and the merge commit: it measures the run's cost with `cost.mjs` from the local transcripts (the workflow does not report cost; never estimate one), passes `cost.mjs`'s Codex line, when present, as `--codex-cost` to `board.mjs record` and `board.mjs summary` (never folded into `--cost`), appends the run entry to `docs/live-runs.md` with `board.mjs record`, and prints the three-line summary with `board.mjs summary`, each given the lead's `--summary "<one sentence>"` (run-report); put the sentence the commands print first in the chat, then those three lines verbatim.
|
|
73
73
|
3. `git add <record> docs/live-runs.md .doug/plan.json && git commit -m "Board: <id> landed as <sha>"`, with `<record>` the path `board.mjs move` printed in item 1; when item 2's `run-report` invocation printed that it promoted a research note, add `docs/research/<id>.md` too. No trailers.
|
|
74
74
|
|
|
75
|
-
Batch: every card of the batch moves to Done with the same `--source "commit <sha>"` (item 1, once per card), the `run-report` skill's batch section records one entry per card (that card's tasks only, its own measured cost, the shared integration agents counted once) and prints one summary per card, and item 3 is one commit: `"Board: <id>, <id> landed as <sha>"`.
|
|
75
|
+
Batch: every card of the batch moves to Done with the same `--source "commit <sha>"` (item 1, once per card), the `run-report` skill's batch section records one entry per card (that card's tasks only, its own measured cost, the shared integration agents counted once) and prints one summary per card, and item 3 is one commit: `"Board: <id>, <id> landed as <sha>"`. A batch ends with one overall sentence, in plain words, of what the batch completed, after each card's own summary line.
|
|
76
76
|
|
|
77
77
|
## 6. Follow-up cards
|
|
78
78
|
|
|
@@ -84,7 +84,7 @@ The lead says in the chat what it filed and where: each new card's id, title, an
|
|
|
84
84
|
|
|
85
85
|
## Cards the user asks for
|
|
86
86
|
|
|
87
|
-
A card the user asks for is a mini ADR, not a one-line goal. Before filing it, the lead asks the scoping questions in one `AskUserQuestion` of up to 4 questions, each with a Recommended option, and writes the answers into the goal. The goal carries the fixed headings Context / Decision / Options / Questions; `docs/decisions` stays for real ADRs. Only a user-requested card takes this form: a follow-up the lead files keeps section 6's lighter form, so autopilot follow-ups stay question-free.
|
|
87
|
+
A card the user asks for is a mini ADR, not a one-line goal. Before filing it, the lead asks the scoping questions in one `AskUserQuestion` of up to 4 questions, each with a Recommended option, and writes the answers into the goal. The goal carries the fixed headings Context / Decision / Options / Questions; `docs/decisions` stays for real ADRs. Only a user-requested card takes this form: a follow-up the lead files keeps section 6's lighter form, so autopilot follow-ups stay question-free. A card the user asks for while autopilot runs is scoped at once with the same questions, filed to Backlog, and the queue resumes at the card it was on: the request is input, not a gate, and the running queue is unchanged.
|
|
88
88
|
|
|
89
89
|
## 7. Human gate: continue
|
|
90
90
|
|
|
@@ -94,6 +94,8 @@ Ask with `AskUserQuestion` whether to take the next Ready card. Yes: go to step
|
|
|
94
94
|
|
|
95
95
|
`/doug-next --auto [n]` takes the first n runnable Ready cards of either track in board order (`board.mjs next --batch <n> --track any`). With n, the queue is fixed at the approval. Without n, autopilot is unlimited: before each card it re-reads Ready with `board.mjs next --track any` and takes the next runnable Ready card in board order. It never takes a Backlog card: follow-ups still go to Backlog, and a card the user promotes to Ready during the run is picked up. It shows the queue once, each card's id, size, track, and class, with the caps: cards, wall clock, and USD. The caps default to the queue's length in cards, the wall cap that board.mjs next --batch prints (the sum of each card's budget from the size x class wall budget table in board.mjs, at the p80 of measured landings: a size/class cell with fewer than 5 landings falls back to its size's all-class figure, a card with no class uses its size's all-class figure, and a card with no size counts as M), and $40, and the user may change them at the approval. Unlimited autopilot shows the runnable Ready cards at the approval, and the approval covers every card it later takes from Ready. Unlimited autopilot has no card cap: its USD cap defaults to $40, and its wall cap grows as each card is taken, by that card's budget from the same size x class table (`board.mjs budgets` prints it); only the totals are checked between cards. When the measured walls drift, the lead reruns `board.mjs budgets --from docs/live-runs.md`, which recomputes the table and prints each cell's n, and replaces the table in the `board.mjs` source with the printed one. Autopilot asks one approval, for the whole queue, and no other gate: no start gate, no shape question, and no continue gate.
|
|
96
96
|
|
|
97
|
+
A card the user asks for during the run is not a gate: the lead scopes and files it as "Cards the user asks for" says, and the queue resumes.
|
|
98
|
+
|
|
97
99
|
Each card still runs its track unchanged (research when needed, tester, coder, reviewer, mutations, gate, commit, record, Done; a lean-lane card has no tester and no mutation list). A hand card follows doug-hand from step 3 when it runs by hand, with step 2's start gate question, 2b's question, and step 6's continue gate skipped; a hand card that asks for a swarm takes 2b's swarm branch instead of step 3, as below. A flow card follows sections 2 to 6 above, with section 3's approval replaced as below and section 7 skipped. Only the questions are skipped, not the actions behind them. Under autopilot a card still gets everything the skipped gates' Start or Approve branches do: a hand card gets doug-hand step 2's class, move to flow, and condition open, and the wall clock is noted when the card starts; a flow card gets section 1's move and section 3's Approve steps.
|
|
98
100
|
|
|
99
101
|
The queue approval stands for each flow-track card's plan approval: the lead puts plan.mjs show in the chat and runs plan.mjs approve itself. A card runs by hand unless its goal asks for a swarm. A hand card whose goal asks for a swarm follows doug-hand's 2b swarm branch, with the queue approval standing for its plan approval. Follow-ups are filed to Backlog as section 6 says, without a question. Between cards the lead measures the session's cost with cost.mjs --session latest and checks every cap.
|
|
@@ -118,4 +120,4 @@ It ends with the summary below, one condition per line:
|
|
|
118
120
|
|
|
119
121
|
An edge case or input the goal does not name is not a design decision: the lead takes the conservative behaviour, rejecting the input or, where rejecting is not possible, preserving it, never guessing, has whoever writes the test pin it, and names it in the card's landing note and the closing summary. A design decision is a choice, for inputs the goal names, between behaviours the goal's acceptance would both satisfy and a user would notice, or one that changes another card's or module's contract.
|
|
120
122
|
|
|
121
|
-
It waits for the answer, then resumes the queue at the card it stopped on. It ends with one summary: each landed card's commit, wall clock, and gate line; each stopped card and why; each edge case taken conservatively; and the follow-ups filed.
|
|
123
|
+
It waits for the answer, then resumes the queue at the card it stopped on. Its closing summary starts with one overall sentence, in plain words, of what the run completed; each landed card's own summary line was printed at its landing. It ends with one summary: each landed card's commit, wall clock, and gate line; each stopped card and why; each edge case taken conservatively; and the follow-ups filed.
|
|
@@ -12,7 +12,7 @@ Produce a plan file the user can approve. No code is written by this skill.
|
|
|
12
12
|
1. If the request depends on a fact outside the repository (a Claude Code hook contract, a CLI's output format, a third-party API), invoke the `research` skill first: it writes one cited note to `.doug/.state/research/<id>.md` (one researcher, or several in parallel with distinct questions).
|
|
13
13
|
|
|
14
14
|
The planner runs on the `plan` row: read it with the `plan.mjs models` command, `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" models`, which prints the parsed table as JSON (`roles.plan.model`, `roles.plan.effort`). Spawn the planner subagent with the user's request verbatim plus any constraints they gave, and the note's path when there is one, passing that row's model (omit `model` when the row says `inherit`):
|
|
15
|
-
`Agent({ subagent_type: "doug-flow:planner", model: "<the plan row's model>", prompt: "<request and constraints>. Research note: <path>. Write .doug/plan.json and return the rendered plan." })`
|
|
15
|
+
`Agent({ subagent_type: "doug-flow:planner", model: "<the plan row's model>", prompt: "<request and constraints>. Research note: <path>. Scripts: ${CLAUDE_PLUGIN_ROOT}/scripts. Write .doug/plan.json and return the rendered plan." })`
|
|
16
16
|
The Agent tool carries no effort parameter; the row's effort applies through the planner agent's own frontmatter (`effort: high`). The session model never plans itself: it hands the request over and shows the plan.
|
|
17
17
|
2. When it returns, validate and render the plan:
|
|
18
18
|
`node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" show`
|
|
@@ -22,7 +22,7 @@ Put the card's title and goal (or the goal text) in the chat verbatim, in a fenc
|
|
|
22
22
|
|
|
23
23
|
## 2. Plan on the plan row
|
|
24
24
|
|
|
25
|
-
The planner runs on the `plan` row, the same row `doug-plan` uses, not the `lead` row (the `lead` row is the swarm's own lead agent inside the workflow, a different job). Read it with the `plan.mjs models` command, `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" models` (`roles.plan.model`, `roles.plan.effort`). Spawn the planner on it: `Agent({ subagent_type: "doug-flow:planner", model: "<the plan row's model>", prompt: "Board card <id>, title \"<title>\": <goal>. This plan runs as a swarm: set \"swarm\": true in the plan file, and size every task M or L unless it is truly one bounded change (a size-S task keeps a single implementer). Write .doug/plan.json and return the rendered plan." })` (for a goal, the prompt is the goal text with the same swarm sentence); omit `model` when the row says `inherit`. The planner's rules apply unchanged: disjoint file ownership per task, a verify command per task, acceptance criteria with commands, no tier set on its own.
|
|
25
|
+
The planner runs on the `plan` row, the same row `doug-plan` uses, not the `lead` row (the `lead` row is the swarm's own lead agent inside the workflow, a different job). Read it with the `plan.mjs models` command, `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" models` (`roles.plan.model`, `roles.plan.effort`). Spawn the planner on it: `Agent({ subagent_type: "doug-flow:planner", model: "<the plan row's model>", prompt: "Board card <id>, title \"<title>\": <goal>. This plan runs as a swarm: set \"swarm\": true in the plan file, and size every task M or L unless it is truly one bounded change (a size-S task keeps a single implementer). Scripts: ${CLAUDE_PLUGIN_ROOT}/scripts. Write .doug/plan.json and return the rendered plan." })` (for a goal, the prompt is the goal text with the same swarm sentence); omit `model` when the row says `inherit`. The planner's rules apply unchanged: disjoint file ownership per task, a verify command per task, acceptance criteria with commands, no tier set on its own.
|
|
26
26
|
|
|
27
27
|
Then guarantee the opt-in and the card (`plan.mjs set swarm on`): `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" set swarm on`, and `plan.mjs set card <id>` for a card. `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" show` and put its complete output in the chat verbatim, in a fenced code block, before anything else; it prints `Swarm: on`. Name any task the planner sized S (it will not swarm) and any assumption it stated.
|
|
28
28
|
|
|
@@ -36,7 +36,7 @@ Invoke the `swarm-launch` skill: it checks the plan is approved with swarm on an
|
|
|
36
36
|
|
|
37
37
|
## 5. Land and record
|
|
38
38
|
|
|
39
|
-
`/doug-next` step 5 verbatim: `plan.mjs done`, `plan.mjs land`, the card's move to Done with the merge commit, the `run-report` skill (its cost table lists every worker by label), and one commit for the record and the log. A goal without a card lands the same way and skips the board steps; its run entry is not written, since the log is per card.
|
|
39
|
+
`/doug-next` step 5 verbatim: `plan.mjs done`, `plan.mjs land`, the card's move to Done with the merge commit, the `run-report` skill (its cost table lists every worker by label), and one commit for the record and the log. The landing's first chat line is the one-sentence summary `run-report` prints. A goal without a card lands the same way and skips the board steps; its run entry is not written, since the log is per card.
|
|
40
40
|
|
|
41
41
|
## 6. Follow-up cards
|
|
42
42
|
|
|
@@ -8,17 +8,19 @@ allowed-tools: Agent, Read, Bash
|
|
|
8
8
|
|
|
9
9
|
One card's landing, recorded and summarized. The entry and the summary are rendered by `scripts/board.mjs`, never written by hand, so every run is recorded in the same shape.
|
|
10
10
|
|
|
11
|
+
Every `board.mjs record` and `board.mjs summary` takes a required `--summary "<one sentence>"`: one sentence, in plain words, of what changed for the user, which the command prints as the first line of its output; put that line first in the chat. The lead writes it; the command refuses a missing or multi-sentence value and does not write it to `docs/live-runs.md` or the board.
|
|
12
|
+
|
|
11
13
|
## A workflow run (flow track)
|
|
12
14
|
|
|
13
15
|
With the card id, the report (`.doug/.state/last-report.json` unless another path is given), the run id the Workflow tool printed (`wf_...`), the wall clock, and the landing commit:
|
|
14
16
|
|
|
15
17
|
1. `node "${CLAUDE_PLUGIN_ROOT}/scripts/cost.mjs" <run-id>`: the run's real cost, summed from the local Claude Code transcripts of every agent in the run and priced from the dated table in the script (no network). Its last lines are the run total and any agent it could not price. The number after `Run total:` is the cost; when the run cannot be found or every agent is unpriced, there is no cost. The workflow itself does not report cost; never estimate one. The Codex line after it, when present, is the Codex adversary's cost: pass its USD as --codex-cost <usd> to record and summary; it is never added to --cost.
|
|
16
18
|
2. Classify every adversary block in the report. A block is a finding the adversary raised as a blocker (its ledger id, `F1`; for a report from before the ledger, `pass-<n>`); `board.mjs record` refuses an id the report does not have and lists the ones it has. This is judgment work and runs on the `review` row: read it with the `plan.mjs models` command, `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" models` (`roles.review.model`, `roles.review.effort`), and spawn `Agent({ subagent_type: "doug-flow:reviewer", model: "<review row model>", prompt: "There is no worktree and no diff for this job: read the report at <report path> (repository root, read-only) and classify every adversary block in it as real (a defect a user would hit), marginal (true to the spec, no user impact), or false (wrong), each with a one-line reason drawn from the fix passes and the checks in the report, not from the adversary's summary. Return exactly one line per block, nothing else: F1=<real|marginal|false>: <one-line reason> (pass-<n>=... for a pre-ledger report). Edit nothing." })`. The lead passes each returned line into `--adversary` verbatim and never reclassifies.
|
|
17
|
-
3. `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" record <id> <report.json> --run <run-id> --wall "<m> min" --commit <sha> --cost <usd> [--codex-cost <usd>] --adversary "F1=<class>: <reason>"` with `--adversary` repeated once per block, leaving `--cost` out when step 1 gave no number and `--codex-cost` out when step 1 showed no Codex line. This appends the entry to `docs/live-runs.md`: the measures table with the precision line (n real / n marginal / n false), the per-level, per-task table, and one line per classified block. It also promotes `.doug/.state/research/<id>.md` to `docs/research/<id>.md` when that note exists, printing one line saying so; add that path to the landing commit alongside `docs/live-runs.md`.
|
|
19
|
+
3. `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" record <id> <report.json> --run <run-id> --wall "<m> min" --commit <sha> --cost <usd> --summary "<one sentence>" [--codex-cost <usd>] --adversary "F1=<class>: <reason>"` with `--adversary` repeated once per block, leaving `--cost` out when step 1 gave no number and `--codex-cost` out when step 1 showed no Codex line. This appends the entry to `docs/live-runs.md`: the measures table with the precision line (n real / n marginal / n false), the per-level, per-task table, and one line per classified block. It also promotes `.doug/.state/research/<id>.md` to `docs/research/<id>.md` when that note exists, printing one line saying so; add that path to the landing commit alongside `docs/live-runs.md`.
|
|
18
20
|
4. `node "${CLAUDE_PLUGIN_ROOT}/scripts/memory.mjs" import`: rolls any auto-memory file written since the last import into the lessons store — it is idempotent, a rewritten file supersedes its old row, and it never writes under the auto-memory directory. Its one-line output goes in the chat; an exit 1 naming Node is said in the chat and the landing goes on, the same as record below.
|
|
19
21
|
5. `node "${CLAUDE_PLUGIN_ROOT}/scripts/memory.mjs" record <report.json> --run <run-id> --card <id> --commit <sha> --adversary "F1=<class>: <reason>"` with `--adversary` repeated once per block, the same values step 3 used, and `--commit` left out for a run that stopped. It appends one row per task of the report to the local outcome log `.doug/.state/memory/memory.db` (uncommitted; run id, card, spec hash, owned files from the plan, size, shape, models, verify and review results, the classified adversary blocks, fix passes, budget, tokens and USD measured from the same transcripts `cost.mjs` read, or null where nothing was measured). It needs Node >=22.16 (node:sqlite). When it exits 1 naming Node, say so in the chat and go on, since the record is a log, not a gate.
|
|
20
|
-
6. `node "${CLAUDE_PLUGIN_ROOT}/scripts/memory.mjs" reflect <report.json> --run <run-id> --card <id> --commit <sha> --adversary "F1=<class>: <reason>"` with `--adversary` repeated once per block, the same values step 5 used. It applies deterministic helpful
|
|
21
|
-
7. `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" summary <report.json> --run <run-id> --wall "<m> min" --commit <sha> [--codex-cost <usd>]` (same `--cost` rule) and put its
|
|
22
|
+
6. `node "${CLAUDE_PLUGIN_ROOT}/scripts/memory.mjs" reflect <report.json> --run <run-id> --card <id> --commit <sha> --adversary "F1=<class>: <reason>"` with `--adversary` repeated once per block, the same values step 5 used. It applies deterministic helpful counters to any lesson a task's `memoryUsed` names. A real block no longer bumps harmful; pass `--harmful <id>` once per recalled lesson the lead judges actually misled the work (it must be in a task's `memoryUsed`, else exit 1). Give `--harmful` on the report's first reflect: a rerun is "Already reflected" and ignores it. Then it runs a cheap-row `claude -p --json-schema` pass that proposes and appends at most three lessons from the report, filtered in code. Its one-line summary (with the measured cost) goes in the chat. An exit 1 naming the LLM pass is said in the chat and the landing goes on regardless — reflect is a log, not a gate, the same as record.
|
|
23
|
+
7. `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" summary <report.json> --run <run-id> --wall "<m> min" --commit <sha> --summary "<one sentence>" [--codex-cost <usd>]` (same `--cost` rule) and put its sentence first in the chat, outside the fence (it is the same sentence step 3's record printed, so it goes in the chat once), then its three lines verbatim in a fenced code block: the outcome, the tasks, and what stopped or was not met.
|
|
22
24
|
|
|
23
25
|
A run that stopped is recorded the same way, without `--commit`; the summary's third line quotes each task's stop reason.
|
|
24
26
|
|
|
@@ -28,22 +30,22 @@ When `.doug/plan.json` carries `cards` (made by `plan.mjs merge`), the run is re
|
|
|
28
30
|
|
|
29
31
|
1. `node "${CLAUDE_PLUGIN_ROOT}/scripts/cost.mjs" <run-id> --by-card`: after the usual tables, a per-card table with each card's tasks' agents summed for that card and one row for the shared integration agents (the integrate and level-adversary agents, task `level-<n>`). The card rows are each card's `--cost`; the shared row is `--shared-cost`, the same number for every card. Pass the Codex line's USD as --codex-cost to the first card's record only.
|
|
30
32
|
2. Classify the adversary blocks per card: `board.mjs record` lists only that card's tasks' blocks and refuses the others' ids.
|
|
31
|
-
3. For each card, in the plan's `cards` order: `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" record <id> <report.json> --run <run-id> --wall "<m> min" --commit <sha> --cost <that card's usd> --shared-cost <shared usd> [--codex-cost <usd>, first card only] --adversary ...`. Record reads the batch from the plan file: the entry names the other cards, shows only that card's tasks and the integration rows of their levels, and counts the shared cost once, under the first card.
|
|
33
|
+
3. For each card, in the plan's `cards` order: `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" record <id> <report.json> --run <run-id> --wall "<m> min" --commit <sha> --summary "<one sentence>" --cost <that card's usd> --shared-cost <shared usd> [--codex-cost <usd>, first card only] --adversary ...`. Record reads the batch from the plan file: the entry names the other cards, shows only that card's tasks and the integration rows of their levels, and counts the shared cost once, under the first card.
|
|
32
34
|
4. `node "${CLAUDE_PLUGIN_ROOT}/scripts/memory.mjs" import` once for the whole report, the same as the workflow-run form's step 4: its one-line output goes in the chat, an exit 1 naming Node is said in the chat and the landing goes on, and it is idempotent, a rewritten file supersedes its old row, and it never writes under the auto-memory directory.
|
|
33
35
|
5. `node "${CLAUDE_PLUGIN_ROOT}/scripts/memory.mjs" record <report.json> --run <run-id> --commit <sha> --adversary ...` once for the whole report, with every card's block classes (each task carries its card in a batch report; no `--card`).
|
|
34
|
-
6. `node "${CLAUDE_PLUGIN_ROOT}/scripts/memory.mjs" reflect <report.json> --run <run-id> --commit <sha> --adversary ...` once for the whole report, no `--card` (each appended lesson's card comes from its own task).
|
|
35
|
-
7. `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" summary <report.json> --card <id> --run <run-id> --wall "<m> min" --commit <sha
|
|
36
|
+
6. `node "${CLAUDE_PLUGIN_ROOT}/scripts/memory.mjs" reflect <report.json> --run <run-id> --commit <sha> --adversary ...` once for the whole report, no `--card` (each appended lesson's card comes from its own task). A real block no longer bumps harmful; pass `--harmful <id>` once per recalled lesson the lead judges actually misled the work (it must be in a task's `memoryUsed`, else exit 1). Give `--harmful` on the report's first reflect: a rerun is "Already reflected" and ignores it.
|
|
37
|
+
7. `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" summary <report.json> --card <id> --run <run-id> --wall "<m> min" --commit <sha> --summary "<one sentence>"` per card (same `--cost` rule), and put each card's three lines in the chat. Its "Acceptance not met" line reads only that card's own tasks by their `[<card>] ` tag (or the final integration level's acceptance, when the report carries it), so another card's failing command, or a command that depends on a level not yet run, never shows as this card's; the line names which source it used.
|
|
36
38
|
|
|
37
39
|
## A hand-track landing
|
|
38
40
|
|
|
39
41
|
With the card id, the landing commit, the wall clock, and the gate line — one `<command> <exit>` clause per entry of `.doug/config.json` `stopGate.commands`, in that order, joined by `; ` (a test command may add its pass count in parentheses; Doug's own is `typecheck 0; test:unit 0 (<n> passed)`; a pytest project's might be `pytest 0`). Step 4 alone also takes the coder rounds (how many until the reviewer passed) and the lead's classed reviewer blockers:
|
|
40
42
|
|
|
41
|
-
1. `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" record <id> --hand --commit <sha> --wall "<m> min" --gate "<gate line>"`, with `--note "<one line>"` when something about the build is worth keeping. This appends the hand-track entry to `docs/live-runs.md`; its table is the summary. It also promotes `.doug/.state/research/<id>.md` to `docs/research/<id>.md` when that note exists, printing one line saying so; add that path to the landing commit alongside `docs/live-runs.md`.
|
|
43
|
+
1. `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" record <id> --hand --commit <sha> --wall "<m> min" --gate "<gate line>" --summary "<one sentence>"`, with `--note "<one line>"` when something about the build is worth keeping. This appends the hand-track entry to `docs/live-runs.md`; its table is the summary. It also promotes `.doug/.state/research/<id>.md` to `docs/research/<id>.md` when that note exists, printing one line saying so; add that path to the landing commit alongside `docs/live-runs.md`.
|
|
42
44
|
2. `node "${CLAUDE_PLUGIN_ROOT}/scripts/memory.mjs" import`, the same as above: its one-line output goes in the chat, an exit 1 naming Node is said in the chat and the landing goes on, and it is idempotent, supersedes a rewritten file, and never writes under the auto-memory directory.
|
|
43
45
|
3. `node "${CLAUDE_PLUGIN_ROOT}/scripts/memory.mjs" record <id> --hand --commit <sha> --wall "<m> min" --gate "<gate line>" [--note "<one line>"]`, the same values step 1 used. It records one row in the local outcome log `.doug/.state/memory/memory.db` (uncommitted), keyed `hand:<sha>` so re-recording the same commit updates that row and track `hand`; a card that lands twice, in two commits, gets two rows. Its workflow-only columns (verified, review counts, adversary verdict, blocks, fix passes, budget, tokens, USD, the report itself) are null because they do not exist on this track, never because they measured zero: no independent verifier ran, and the gate's own text is the `gate` column instead. It needs Node >=22.16 (node:sqlite). When it exits 1 naming Node, say so in the chat and go on, since the record is a log, not a gate.
|
|
44
|
-
4. `node "${CLAUDE_PLUGIN_ROOT}/scripts/memory.mjs" reflect --hand <id> --commit <sha> --rounds <n> [--review "R1=<class>: <reason>"]... [--note "<one line>"]`. It reads the briefs, reviewer reports and lessons.json doug-hand saved, applies helpful
|
|
46
|
+
4. `node "${CLAUDE_PLUGIN_ROOT}/scripts/memory.mjs" reflect --hand <id> --commit <sha> --rounds <n> [--review "R1=<class>: <reason>"]... [--harmful <id>]... [--note "<one line>"]`. It reads the briefs, reviewer reports and lessons.json doug-hand saved, applies helpful counters to the card's memoryUsed. A real block no longer bumps harmful; pass `--harmful <id>` once per recalled lesson the lead judges actually misled the work (it must be in a task's `memoryUsed`, else exit 1). Give `--harmful` on the report's first reflect: a rerun is "Already reflected" and ignores it. It proposes at most three lessons on the cheap row, filtered in code. Its one-line summary (with the measured cost) goes in the chat. An exit 1 naming the LLM pass is said in the chat and the landing goes on regardless — reflect is a log, not a gate, the same as record.
|
|
45
47
|
|
|
46
|
-
Say the commit, the wall clock, and the gate line in the chat.
|
|
48
|
+
Say the one-sentence summary record printed first, then the commit, the wall clock, and the gate line in the chat.
|
|
47
49
|
|
|
48
50
|
## Then
|
|
49
51
|
|
|
@@ -47,7 +47,8 @@ const fixAttempts = Number.isInteger(plan.fixAttempts) && plan.fixAttempts >= 0
|
|
|
47
47
|
// one card. Agents are counted here and always enforced; tokens and wall time come from the runtime's budget
|
|
48
48
|
// primitive and are enforced only when it reports them. plan.budget overrides any of the three. Keep these three
|
|
49
49
|
// numbers equal to lib/plan.mjs's DEFAULT_BUDGET.
|
|
50
|
-
|
|
50
|
+
// agents 26 is the minimum that lets the default fixAttempts 5 run with one adversary seat when Codex is down and the Claude fallback runs on every pass (card flow-budget-after-parallel-adversary); a supervisor seat or a fix retry is not covered.
|
|
51
|
+
const DEFAULT_BUDGET = { agents: 26, tokens: 400000, wallMinutes: 40 }
|
|
51
52
|
const taskBudget = { ...DEFAULT_BUDGET, ...(plan.budget && typeof plan.budget === 'object' ? plan.budget : {}) }
|
|
52
53
|
// The phrase the verifier and reviewer put at the start of a finding when the spec cannot satisfy the plan's
|
|
53
54
|
// acceptance criteria. Such a block goes back to the planner and is never retried.
|
|
@@ -63,6 +64,7 @@ const ENVIRONMENT_MARKER = 'ENVIRONMENT ONLY'
|
|
|
63
64
|
// The one rule about commit messages, in every prompt that commits or checks commits: this repository records
|
|
64
65
|
// attribution nowhere in git, and plan.mjs land refuses a branch that carries either trailer.
|
|
65
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
|
+
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.'
|
|
66
68
|
// The one rule about a partial result, in every prompt a worker (or a resumed worker) can end with one from
|
|
67
69
|
// (card worker-context-handoff): quotes the harness's context notice (a PostToolUse hook in plugins/doug-gates) so
|
|
68
70
|
// a worker knows partial=true is only for a worker the harness itself told to stop, never a first resort, and only
|
|
@@ -953,7 +955,7 @@ function workerPrompt(task, brief, n, lead) {
|
|
|
953
955
|
// left an orphan - it defaults to `workers` for the plain, no-rebrief case where the two are the same list.
|
|
954
956
|
function leadMergePrompt(task, lead, workers, baseBranch, allWorkers = workers) {
|
|
955
957
|
return [
|
|
956
|
-
`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
|
|
958
|
+
`You are the lead of the swarm for task ${task.id}. The workers have committed; merge their branches into the task branch and hand it to the checks. Work only in the git worktree you were started in, on branch ${lead.branch}, and never edit anything under the repository root. The lead's worktree (${lead.worktreePath}) may have been removed, so do not cd into it. First git checkout ${lead.branch} there; if git refuses because the branch is checked out in another worktree, that worktree is a leftover under .claude/worktrees: find it with git worktree list --porcelain, remove it with git worktree remove --force <path>, and check out again. If the branch does not exist, create it from ${baseBranch} with git checkout -b ${lead.branch} ${baseBranch} (the lead commits nothing, so the task branch is the base).`,
|
|
957
959
|
PARTIAL_NEVER_MERGED,
|
|
958
960
|
``,
|
|
959
961
|
`Task ${task.id}: ${task.title}`,
|
|
@@ -969,18 +971,18 @@ function leadMergePrompt(task, lead, workers, baseBranch, allWorkers = workers)
|
|
|
969
971
|
`3. Do not implement missing pieces yourself: a brief a worker did not finish is a block with its name. ${NO_TRAILERS}`,
|
|
970
972
|
`4. Remove every worker worktree to leave the repository tidy, merged or not (a blocked worker's is an orphan otherwise): git worktree remove --force <path> for each one below. Keep their branches.`,
|
|
971
973
|
...allWorkers.filter(w => w.worktreePath).map(w => ` - ${w.worktreePath} (worker ${w.n}: ${w.id})`),
|
|
972
|
-
`5. Return taskId="${task.id}", branch="${lead.branch}", worktreePath=
|
|
974
|
+
`5. Return taskId="${task.id}", branch="${lead.branch}", worktreePath=the absolute path of the worktree you ran in (git rev-parse --show-toplevel), filesTouched from git diff --name-only ${baseBranch}...HEAD, commandsRun, commit from git rev-parse HEAD after the last merge, summary naming the briefs merged, blocked=false; blocked=true with the reason when a merge or the verification failed for a reason in the code.`,
|
|
973
975
|
].join('\n')
|
|
974
976
|
}
|
|
975
977
|
|
|
976
978
|
// Card swarm-topology (candidate 2): one worker blocked does not end the task - the lead gets one chance to
|
|
977
|
-
// re-brief the unfinished pieces,
|
|
979
|
+
// re-brief the unfinished pieces, reading from the repository root (no worktree, no isolation key). `workers` is every
|
|
978
980
|
// round-one outcome (so the lead sees a finished sibling's commit, not just the blocked ones); `blockedWorkers` is
|
|
979
981
|
// the subset that needs a revised brief.
|
|
980
982
|
function leadRebriefPrompt(task, lead, workers, blockedWorkers) {
|
|
981
983
|
const finished = workers.filter(w => !w.blocked)
|
|
982
984
|
return [
|
|
983
|
-
`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 only
|
|
985
|
+
`You are the lead of the swarm for task ${task.id}. One or more workers are blocked; you get one chance to re-brief the unfinished pieces. You implement nothing and edit no file. Work read-only from the repository root you were started in. Do not cd into the lead's worktree (it may have been removed), create no branch, and edit nothing. Read a finished worker's committed files with \`git show <commit>:<path>\` or \`git show <commit>\`.`,
|
|
984
986
|
``,
|
|
985
987
|
`Plan: ${plan.title}`,
|
|
986
988
|
`Goal: ${plan.goal}`,
|
|
@@ -1130,6 +1132,7 @@ function verifyPrompt(task, impl, baseBranch) {
|
|
|
1130
1132
|
`Task ${task.id}: ${task.title}`,
|
|
1131
1133
|
`Spec:`,
|
|
1132
1134
|
task.spec,
|
|
1135
|
+
...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []),
|
|
1133
1136
|
``,
|
|
1134
1137
|
`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):`,
|
|
1135
1138
|
...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
|
|
@@ -1162,6 +1165,7 @@ function checkPrompt(task, impl, prevCommit, baseBranch, ledger) {
|
|
|
1162
1165
|
`Task ${task.id}: ${task.title}`,
|
|
1163
1166
|
`Spec:`,
|
|
1164
1167
|
task.spec,
|
|
1168
|
+
...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []),
|
|
1165
1169
|
``,
|
|
1166
1170
|
`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):`,
|
|
1167
1171
|
...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
|
|
@@ -1255,7 +1259,7 @@ function levelAdversaryPrompt(li, sReady, levelBase, confirming) {
|
|
|
1255
1259
|
}
|
|
1256
1260
|
|
|
1257
1261
|
function levelFallbackAdversaryPrompt(li, sReady, levelBase, why, confirming) {
|
|
1258
|
-
|
|
1262
|
+
// Same scope as levelAdversaryPrompt (card flow-fallback-verify-scope): no verify list, the suite already ran at integrate.
|
|
1259
1263
|
return [
|
|
1260
1264
|
`You are the adversarial reviewer for level ${li} of plan "${plan.title}", standing in for codex-review, which could not run (${why}). The size-S tasks below were merged into ${integrationBranch} with one focused check each and no adversary of their own. Your job is to refute them together, in worktree ${INTEGRATION_WORKTREE} (relative to the repository root); the diff is ${levelBase}...${integrationBranch}.`,
|
|
1261
1265
|
``,
|
|
@@ -1263,8 +1267,7 @@ function levelFallbackAdversaryPrompt(li, sReady, levelBase, why, confirming) {
|
|
|
1263
1267
|
`Plan acceptance criteria:`,
|
|
1264
1268
|
...acceptanceEntries(plan.acceptance || []).map(a => `- ${a.text}`),
|
|
1265
1269
|
``,
|
|
1266
|
-
`
|
|
1267
|
-
...verify.map(v => ` $ ${v}`),
|
|
1270
|
+
`Record every command you run, your own probe checks included, in commandsRun with its real exit code and outputTail (the last lines of its output); a blocker's evidence must cite one of them, and either that command failed or the evidence quotes a line of its outputTail, otherwise the loop records it as major.`,
|
|
1268
1271
|
confirming
|
|
1269
1272
|
? `Then confirm the blockers you raised on this level are fixed, name each as fixed or still open, and check the fix for regressions rather than exploring afresh.`
|
|
1270
1273
|
: `Then try to break the changes: inputs the tests do not cover, a spec sentence or acceptance criterion a diff does not meet, a test that was weakened, skipped, or deleted, and behavior outside the owned files. When a suspicion needs a check, write a throwaway one, run it, and remove it before you finish.`,
|
|
@@ -1290,6 +1293,7 @@ function reviewPrompt(task, impl, baseBranch, seat = 1) {
|
|
|
1290
1293
|
`Task ${task.id}: ${task.title}`,
|
|
1291
1294
|
`Spec:`,
|
|
1292
1295
|
task.spec,
|
|
1296
|
+
...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []),
|
|
1293
1297
|
`Owned files: ${task.files.join(', ')}`,
|
|
1294
1298
|
`Plan acceptance criteria:`,
|
|
1295
1299
|
...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
|
|
@@ -1332,6 +1336,7 @@ function adversaryPrompt(task, impl, baseBranch, pass, ledger) {
|
|
|
1332
1336
|
`Task ${task.id}: ${task.title}`,
|
|
1333
1337
|
``,
|
|
1334
1338
|
guard(task.spec),
|
|
1339
|
+
...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []).map(guard),
|
|
1335
1340
|
``,
|
|
1336
1341
|
...(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.`]),
|
|
1337
1342
|
``,
|
|
@@ -1386,21 +1391,27 @@ function adversaryUnavailable(adv) {
|
|
|
1386
1391
|
}
|
|
1387
1392
|
|
|
1388
1393
|
function fallbackAdversaryPrompt(task, impl, baseBranch, why, pass, ledger) {
|
|
1389
|
-
|
|
1394
|
+
// Same scope as adversaryPrompt's Codex seat (card flow-fallback-verify-scope): the task's own verify only, not plan.verify.
|
|
1395
|
+
const verify = [task.verify].filter(Boolean)
|
|
1390
1396
|
return [
|
|
1391
1397
|
`You are the adversarial reviewer for task ${task.id}, standing in for codex-review, which could not run (${why}). Your job is to refute the change on branch ${impl.branch} in worktree ${impl.worktreePath}; the diff is ${baseBranch}...${impl.branch}. ${baseBranch} is this branch's base (an integration branch when the task follows an earlier level); files that already exist there are not this task's changes. Do not edit any file.`,
|
|
1392
1398
|
``,
|
|
1393
1399
|
`Task ${task.id}: ${task.title}`,
|
|
1394
1400
|
`Spec:`,
|
|
1395
1401
|
task.spec,
|
|
1402
|
+
...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []),
|
|
1396
1403
|
`Owned files: ${task.files.join(', ')}`,
|
|
1397
1404
|
`Plan acceptance criteria:`,
|
|
1398
1405
|
...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
|
|
1399
1406
|
...(pass > 1 ? confirmationLines(pass, ledger, impl) : []),
|
|
1400
1407
|
``,
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1408
|
+
...(verify.length
|
|
1409
|
+
? [
|
|
1410
|
+
`Run every one of these commands in the worktree and record each in commandsRun with its real exit code and outputTail (the last lines of its output):`,
|
|
1411
|
+
...verify.map(v => ` $ ${v}`),
|
|
1412
|
+
]
|
|
1413
|
+
: [`Record every command you run, your own probe checks included, in commandsRun with its real exit code and outputTail (the last lines of its output).`]),
|
|
1414
|
+
`A blocker's evidence must name one of ${verify.length ? 'those commands' : 'the commands in commandsRun'}, and either that command failed or the evidence quotes a line of its outputTail; otherwise the loop records it as major.`,
|
|
1404
1415
|
pass > 1
|
|
1405
1416
|
? `Then confirm the open findings above and check the fix delta for regressions rather than exploring afresh.`
|
|
1406
1417
|
: `Then try to break the change: inputs the tests do not cover, a spec sentence or acceptance criterion the diff does not meet, a test that was weakened, skipped, or deleted, and behavior outside the owned files. When a suspicion needs a check, write a throwaway one, run it, and remove it before you finish.`,
|
|
@@ -1676,7 +1687,7 @@ async function runSwarm(task, baseBranch, launch) {
|
|
|
1676
1687
|
const reason = `worker${round1Blocked.length === 1 ? '' : 's'} blocked: ${round1Blocked.map(w => `${w.n} (${w.id}): ${w.blockedReason}`).join('; ')}`
|
|
1677
1688
|
// Rule 4: a worker still partial after its resume blocks the task exactly as before - no re-brief attempt.
|
|
1678
1689
|
if (round1Blocked.some(w => w.partial)) return blocked(reason, { worktreePath: lead.worktreePath, branch: lead.branch })
|
|
1679
|
-
// Rule 2: one re-brief,
|
|
1690
|
+
// Rule 2: one re-brief, reading from the repository root (no isolation key, no new worktree), same schema as the
|
|
1680
1691
|
// first split.
|
|
1681
1692
|
stages.push('lead-rebrief')
|
|
1682
1693
|
const revised = await launch(leadRebriefPrompt(task, lead, round1, round1Blocked), agentOpts('lead', null, { label: `lead-rebrief:${task.id}`, phase: 'Implement', agentType: 'doug-flow:lead', schema: LEAD_SCHEMA }))
|
|
@@ -1727,7 +1738,8 @@ async function runSwarm(task, baseBranch, launch) {
|
|
|
1727
1738
|
// workers, in the order they were pushed. `workers` itself (review minor 5) still goes to leadMergePrompt as the
|
|
1728
1739
|
// worktree-removal list, blocked ones included, so a blocked worker's worktree is not left an orphan.
|
|
1729
1740
|
const finished = workers.filter(w => !w.blocked)
|
|
1730
|
-
|
|
1741
|
+
// Card swarm-lead-worktree-missing: the lead's own worktree is gone by now (rehearsal swarm-2026-10-09T15-20-40-679Z), so the merge gets a fresh one.
|
|
1742
|
+
const merged = await launch(leadMergePrompt(task, lead, finished, baseBranch, workers), agentOpts('lead', null, { label: `lead-merge:${task.id}`, phase: 'Implement', isolation: 'worktree', agentType: 'doug-flow:lead', schema: IMPLEMENT_SCHEMA }))
|
|
1731
1743
|
if (!merged) return blocked('lead merge returned nothing (spawn failure or no structured output)', { worktreePath: lead.worktreePath, branch: lead.branch })
|
|
1732
1744
|
return { ...merged, branch: merged.branch || lead.branch, worktreePath: merged.worktreePath || lead.worktreePath, swarmStages: stages, workers, splitReason: lead.splitReason, rebrief, checkFailed: workers.filter(w => w.checkFailed).map(w => w.id) }
|
|
1733
1745
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "doug-gates",
|
|
3
3
|
"displayName": "Doug Gates",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.1",
|
|
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"
|
|
@@ -726,8 +726,8 @@ function skipPrefixOptions(w, i, argFlags, onArgFlag) {
|
|
|
726
726
|
// exactly as before. For the separate-token form the value is already its own token, so this returns { next }
|
|
727
727
|
// pointing at it and lets matchesShellWords's loop continue from there. For an attached form (whose value can't
|
|
728
728
|
// be pulled back out as its own token) there is no token to point { next } at, so this returns { splice: value,
|
|
729
|
-
// next } instead: the caller
|
|
730
|
-
// loop at
|
|
729
|
+
// next } instead: the caller overwrites the attached token's slot (next - 1) with the single token `value` and resumes its own
|
|
730
|
+
// loop at that slot - a linear overwrite, not a recursive call, so nested attached values (env -Senv -Senv ... sh) cost no
|
|
731
731
|
// call-stack depth no matter how many there are. Otherwise (the value is missing, empty, or itself option-
|
|
732
732
|
// shaped) it isn't the command, so env keeps parsing options from there: for the separate-token form the walk
|
|
733
733
|
// re-examines that same value token as the next option (advancing by 1, not 2, past just the flag), and for an
|
|
@@ -892,10 +892,13 @@ function redirWordSpan(tok) {
|
|
|
892
892
|
// Walks a stage's already-split words from index i, resolving prefixes (env assignments, sudo, env, nice,
|
|
893
893
|
// time, nohup, exec, command, busybox) until it lands on the candidate verb. Split out of matchesShellStage so
|
|
894
894
|
// scanEnvOptions can hand an -S/--split-string value back into this same walk when that value qualifies as
|
|
895
|
-
// env's command. That handoff is a
|
|
896
|
-
// handoff replaces one or more consumed tokens with the single value token, so
|
|
897
|
-
// one token each time, and the loop below runs no deeper than one stack frame no matter how
|
|
898
|
-
// nested (env -Senv -Senv ... sh).
|
|
895
|
+
// env's command. That handoff is an in-place overwrite of one slot of a single copy of `w`, not a recursive call (see the "splice" case below): every
|
|
896
|
+
// handoff replaces one or more consumed tokens with the single value token, so the walk's unread tail strictly
|
|
897
|
+
// shrinks by at least one token each time, and the loop below runs no deeper than one stack frame no matter how
|
|
898
|
+
// many wrappers are nested (env -Senv -Senv ... sh). The handoff is linear (card bash-rules-env-splice-quadratic):
|
|
899
|
+
// the value overwrites the attached token's slot in a single copy of `w` and the walk resumes there, instead of
|
|
900
|
+
// rebuilding the array per handoff (quadratic: 25 s at 80000 nestings). No token cap is applied: a cap would deny
|
|
901
|
+
// commands that are allowed today, and the linear walk removes the timeout risk that a cap would guard.
|
|
899
902
|
// Card bash-rules-prefixed-commands: also shared with commandForms, which needs the resolved word array and
|
|
900
903
|
// verb index (not just a shell/not-shell verdict), so the walk itself no longer decides an outcome - it returns
|
|
901
904
|
// { w, i } once it lands on a candidate verb (i may be w.length when nothing followed the prefixes), or
|
|
@@ -920,7 +923,7 @@ function redirWordSpan(tok) {
|
|
|
920
923
|
// still reads the RAW token, unchanged - a quoted FLAG is out of this card's scope, and dequoting it too
|
|
921
924
|
// would risk changing which flag a cluster or an attached form is read as, for no fixture that needs it.
|
|
922
925
|
// The returned `{w, i}` itself is unaffected by this: `w` is still the array the caller passed in (or
|
|
923
|
-
// resolveVerb's own env
|
|
926
|
+
// resolveVerb's own single copy after an env splice, unrelated to this round), and `i` is just an index into it - the
|
|
924
927
|
// caller (isShellVerb by way of commandForms/matchesShellWords) dequotes `w[i]` itself before comparing it
|
|
925
928
|
// against a shell name, the same way this loop now does for a prefix name.
|
|
926
929
|
//
|
|
@@ -936,6 +939,7 @@ function redirWordSpan(tok) {
|
|
|
936
939
|
// ...) reads the prefix/verb token's text again - everything after it operates on the RAW words that follow,
|
|
937
940
|
// unaffected by which reading recognised the name in front of them.
|
|
938
941
|
function resolveVerb(w, i) {
|
|
942
|
+
const original = w;
|
|
939
943
|
while (i < w.length) {
|
|
940
944
|
const tok = w[i];
|
|
941
945
|
const span = redirWordSpan(tok);
|
|
@@ -970,11 +974,14 @@ function resolveVerb(w, i) {
|
|
|
970
974
|
if (isBase("env")) {
|
|
971
975
|
const result = scanEnvOptions(w, i + 1);
|
|
972
976
|
if ("splice" in result) {
|
|
973
|
-
// An attached -S/--split-string value qualified as env's command:
|
|
974
|
-
//
|
|
975
|
-
//
|
|
976
|
-
|
|
977
|
-
|
|
977
|
+
// An attached -S/--split-string value qualified as env's command: the value becomes the walk's next word.
|
|
978
|
+
// It overwrites the slot of the attached token just before result.next (copying w once, on the first
|
|
979
|
+
// splice, so the caller's array is never mutated) and the walk resumes AT that slot - the same words,
|
|
980
|
+
// from the same start, the old rebuilt array [value, ...rest] held at its index 0. Each handoff consumes at
|
|
981
|
+
// least one token and costs O(1), so n nested values cost O(n), and no recursion either.
|
|
982
|
+
if (w === original) w = original.slice();
|
|
983
|
+
i = result.next - 1;
|
|
984
|
+
w[i] = result.splice;
|
|
978
985
|
continue;
|
|
979
986
|
}
|
|
980
987
|
i = result.next; // separate-token -S/--split-string value, or no qualifying value: continue the walk
|
|
@@ -1335,8 +1342,8 @@ function dequoteScript(word, reading = "bash") {
|
|
|
1335
1342
|
// behind a resolvable prefix (sudo, an env assignment, env, nice, time, nohup, exec, command, busybox) or a
|
|
1336
1343
|
// shell's -c string. Always includes cmd itself. When resolveVerb's walk consumed or rebuilt anything - i > 0
|
|
1337
1344
|
// (an ordinary prefix was skipped) or the returned word array isn't the one passed in (an attached env
|
|
1338
|
-
// -S/--split-string value spliced
|
|
1339
|
-
//
|
|
1345
|
+
// -S/--split-string value was spliced into a single copy of the array, overwriting the attached token's slot;
|
|
1346
|
+
// the walk resumes there, so i stays >= 1 after a splice) - also includes the resolved word array from the verb
|
|
1340
1347
|
// onward. When that verb's basename is a shell, findScriptCandidates (above) locates 0, 1, or 2 candidate -c
|
|
1341
1348
|
// script words; each candidate is dequoted (dequoteScript) - the ONLY word this function dequotes - twice, once
|
|
1342
1349
|
// per shell reading (review round 3: bash and zsh decode an unrecognised ANSI-C escape differently, see
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The gate.lock helpers shared by the Stop gate (scripts/stop-gate.mjs) and verify.mjs (card
|
|
2
|
+
// verify-takes-gate-lock). Moved verbatim from stop-gate.mjs; the Stop gate's behaviour is unchanged.
|
|
3
|
+
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
// Single-runner lock (card stop-gate-process-storm, goal 3): only one gate runs the verification commands
|
|
8
|
+
// at a time per project, so concurrent SubagentStops share one suite instead of each starting its own (the
|
|
9
|
+
// 2026-09-16 process storm). `.doug/.state/gate.lock` holds `{ pid, session, at }` while a gate's commands
|
|
10
|
+
// run.
|
|
11
|
+
const GATE_LOCK_RELPATH = ".doug/.state/gate.lock";
|
|
12
|
+
export function gateLockPath(dir) {
|
|
13
|
+
return join(dir, GATE_LOCK_RELPATH);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Reads gate.lock, tolerating garbage (a hook reader must never throw): a missing file, unreadable file, or
|
|
17
|
+
// content that fails to parse or carries no numeric pid all read as null — the same as "no lock", which the
|
|
18
|
+
// caller treats exactly like a stale one.
|
|
19
|
+
export function readGateLock(dir) {
|
|
20
|
+
try {
|
|
21
|
+
const o = JSON.parse(readFileSync(gateLockPath(dir), "utf8"));
|
|
22
|
+
// A pid only counts when it is a positive integer (reviewer-found gap): 0 means "this process's own
|
|
23
|
+
// group" and -1 broadcasts to every process the caller may signal, so process.kill(pid, 0) does not
|
|
24
|
+
// throw for either and a liveness check that hands them straight to it reads a garbage lock as live.
|
|
25
|
+
return o && typeof o === "object" && Number.isInteger(o.pid) && o.pid > 0 ? o : null;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function pidIsLive(pid) {
|
|
32
|
+
try {
|
|
33
|
+
process.kill(pid, 0);
|
|
34
|
+
return true;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
// EPERM means the pid exists but is owned by another user (permission denied, not "no such process") —
|
|
37
|
+
// that holder is still alive, just not signallable by us. Every other error (ESRCH: no such process, or
|
|
38
|
+
// anything else) reads as dead.
|
|
39
|
+
return err && err.code === "EPERM";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Creates gate.lock exclusively (throws EEXIST when one is already there, or any other fs error). The
|
|
44
|
+
// caller decides what a failure means.
|
|
45
|
+
export function writeGateLock(dir, sessionId) {
|
|
46
|
+
mkdirSync(join(dir, ".doug/.state"), { recursive: true });
|
|
47
|
+
writeFileSync(gateLockPath(dir), JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }), { flag: "wx" });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Overwrites gate.lock with this gate's own pid/session, whether it was stale (a dead pid) or its holder
|
|
51
|
+
// simply never let go inside the wait bound — either way, never lets a lock the gate cannot even write hang
|
|
52
|
+
// it (best-effort; a failure here just leaves lockOwned false to the caller, which never releases a lock it
|
|
53
|
+
// does not hold).
|
|
54
|
+
export function takeOverGateLock(dir, sessionId) {
|
|
55
|
+
try {
|
|
56
|
+
mkdirSync(join(dir, ".doug/.state"), { recursive: true });
|
|
57
|
+
writeFileSync(gateLockPath(dir), JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }));
|
|
58
|
+
} catch {
|
|
59
|
+
// best-effort takeover only
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Card stop-gate-budget-under-hook-timeout: cfg.stopGate.hookTimeoutSec must equal the platform's own
|
|
64
|
+
// Stop/SubagentStop hook `timeout` (hooks.json/settings.json), but a hook reader never throws (F6), so a
|
|
65
|
+
// missing, non-number, or non-positive value reads as the platform's own default, 600.
|
|
66
|
+
export function resolveHookTimeoutSec(cfg) {
|
|
67
|
+
const sec = cfg && cfg.stopGate && cfg.stopGate.hookTimeoutSec;
|
|
68
|
+
return typeof sec === "number" && Number.isFinite(sec) && sec > 0 ? sec : 600;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A lock whose numeric `at` is older than the hook-timeout budget is stale regardless of a live pid: no
|
|
72
|
+
// legitimate holder can still be running that long, since the platform itself would have killed its hook
|
|
73
|
+
// by then (F1). A missing or non-numeric `at` changes nothing here; the pid liveness check still decides.
|
|
74
|
+
export function lockStaleByAge(lock, hookTimeoutMs) {
|
|
75
|
+
return !!lock && typeof lock.at === "number" && Number.isFinite(lock.at) && Date.now() - lock.at > hookTimeoutMs;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Card stop-gate-timeout-normalise (moved here by card verify-lock-wait-bound so verify.mjs shares it):
|
|
79
|
+
// cfg.stopGate.timeoutMs normalised once, shared by the per-command timeout and the wait bound, so the two
|
|
80
|
+
// never disagree on what a garbage or fractional value means. A finite number above 0 floors to an integer,
|
|
81
|
+
// clamped up to at least 1ms (0.5 must not floor to 0 — spawnSync reads a timeout of 0 as none at all,
|
|
82
|
+
// letting a command run past the budget unclamped; 2.5 must not reach spawnSync un-floored — it throws
|
|
83
|
+
// ERR_OUT_OF_RANGE there, and a hook reader must never throw, F6). Anything else (non-number, NaN, Infinity,
|
|
84
|
+
// 0, negative, null) is "unset" (null), read by each call site as its own default.
|
|
85
|
+
export function normalizedTimeoutMs(cfg) {
|
|
86
|
+
const t = cfg.stopGate.timeoutMs;
|
|
87
|
+
return typeof t === "number" && Number.isFinite(t) && t > 0 ? Math.max(1, Math.floor(t)) : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// How long a gate waits on a live gate.lock: the per-command timeout times the command count, the longest a
|
|
91
|
+
// holder legitimately runs.
|
|
92
|
+
export function lockWaitBoundMs(perCommandMs, commandCount) {
|
|
93
|
+
return perCommandMs * Math.max(1, commandCount);
|
|
94
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dougharness/gates",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
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",
|