@dougharness/cli 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/bin.js +8 -0
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +9 -3
  4. package/dist/board-serve.js.map +1 -1
  5. package/dist/board.js +10 -2
  6. package/dist/board.js.map +1 -1
  7. package/dist/detect/repo.js +14 -1
  8. package/dist/detect/repo.js.map +1 -1
  9. package/dist/detect/types.d.ts +2 -0
  10. package/dist/doctor.d.ts +46 -0
  11. package/dist/doctor.js +491 -0
  12. package/dist/doctor.js.map +1 -0
  13. package/dist/generate/agents.d.ts +7 -0
  14. package/dist/generate/agents.js +62 -12
  15. package/dist/generate/agents.js.map +1 -1
  16. package/dist/generate/claude-md.js +4 -0
  17. package/dist/generate/claude-md.js.map +1 -1
  18. package/dist/generate/config.d.ts +8 -0
  19. package/dist/generate/config.js +4 -1
  20. package/dist/generate/config.js.map +1 -1
  21. package/dist/generate/proposal.js +15 -6
  22. package/dist/generate/proposal.js.map +1 -1
  23. package/dist/generate/settings.d.ts +2 -0
  24. package/dist/generate/settings.js +17 -2
  25. package/dist/generate/settings.js.map +1 -1
  26. package/dist/init.d.ts +14 -0
  27. package/dist/init.js +94 -8
  28. package/dist/init.js.map +1 -1
  29. package/dist/memory-setup.d.ts +28 -0
  30. package/dist/memory-setup.js +323 -0
  31. package/dist/memory-setup.js.map +1 -0
  32. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +16 -4
  33. package/node_modules/@dougharness/flow/agents/planner.md +2 -2
  34. package/node_modules/@dougharness/flow/agents/researcher.md +2 -2
  35. package/node_modules/@dougharness/flow/hooks/hooks.json +16 -0
  36. package/node_modules/@dougharness/flow/lib/board.mjs +154 -12
  37. package/node_modules/@dougharness/flow/lib/code-index.mjs +18 -3
  38. package/node_modules/@dougharness/flow/lib/context.mjs +57 -0
  39. package/node_modules/@dougharness/flow/lib/cost.mjs +129 -4
  40. package/node_modules/@dougharness/flow/lib/embeddings.mjs +185 -23
  41. package/node_modules/@dougharness/flow/lib/land.mjs +69 -10
  42. package/node_modules/@dougharness/flow/lib/learn.mjs +4 -4
  43. package/node_modules/@dougharness/flow/lib/memory.mjs +326 -35
  44. package/node_modules/@dougharness/flow/lib/plan.mjs +9 -0
  45. package/node_modules/@dougharness/flow/package.json +2 -1
  46. package/node_modules/@dougharness/flow/scripts/board.mjs +60 -20
  47. package/node_modules/@dougharness/flow/scripts/context.mjs +48 -0
  48. package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
  49. package/node_modules/@dougharness/flow/scripts/memory.mjs +190 -34
  50. package/node_modules/@dougharness/flow/scripts/plan.mjs +32 -64
  51. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
  52. package/node_modules/@dougharness/flow/skills/{core-next → doug-hand}/SKILL.md +31 -18
  53. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  54. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +44 -10
  55. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +5 -5
  56. package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
  57. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +8 -9
  58. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
  59. package/node_modules/@dougharness/flow/workflows/doug-implement.js +119 -72
  60. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  61. package/node_modules/@dougharness/gates/hooks/hooks.json +11 -0
  62. package/node_modules/@dougharness/gates/lib/baseline.mjs +166 -1
  63. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +58 -1
  64. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +1 -1
  65. package/node_modules/@dougharness/gates/lib/config.mjs +20 -0
  66. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +95 -0
  67. package/node_modules/@dougharness/gates/lib/evidence.mjs +2 -0
  68. package/node_modules/@dougharness/gates/lib/run.mjs +4 -0
  69. package/node_modules/@dougharness/gates/lib/test-totals.mjs +40 -7
  70. package/node_modules/@dougharness/gates/lib/trace.mjs +24 -7
  71. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +225 -0
  72. package/node_modules/@dougharness/gates/package.json +1 -1
  73. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +133 -5
  74. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +551 -113
  75. package/node_modules/@dougharness/gates/scripts/trace.mjs +5 -1
  76. package/node_modules/@dougharness/gates/scripts/turn-start.mjs +17 -0
  77. package/node_modules/@dougharness/gates/scripts/verify.mjs +146 -0
  78. package/package.json +3 -3
  79. package/templates/board-app.js +2 -2
  80. package/node_modules/@dougharness/flow/lib/rehearse.mjs +0 -825
  81. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +0 -129
  82. package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +0 -85
@@ -15,7 +15,7 @@ The swarm pays on a task with independent deliverables that share no new symbol;
15
15
 
16
16
  Start from a clean working tree on the base branch (`git status --porcelain` empty apart from `.doug/` and `.claude/worktrees/`).
17
17
 
18
- - A card id: `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" card <id>`. A card with `"track": "hand"` is harness work built in the checkout (decision 0005), not a swarm; say so and end. Move the card to In flow exactly as `/doug-next` step 1 does (`board.mjs move <id> flow`), leaving the move uncommitted working state that the served page (`doug board serve`) shows live.
18
+ - A card id: `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" card <id>`. A card with `"track": "hand"` is a by-hand card, not a swarm; say so, name `/doug-next <id>` as the command that takes it, and end. Move the card to In flow exactly as `/doug-next` step 1 does (`board.mjs move <id> flow`), leaving the move uncommitted working state that the served page (`doug board serve`) shows live.
19
19
  - A goal: no board record changes; the plan carries no `card`.
20
20
 
21
21
  Put the card's title and goal (or the goal text) in the chat verbatim, in a fenced code block. If it depends on a fact outside the repository, invoke the `research` skill first.
@@ -40,13 +40,13 @@ Invoke the `swarm-launch` skill: it checks the plan is approved with swarm on an
40
40
 
41
41
  ## 6. Follow-up cards
42
42
 
43
- `/doug-next` step 6 verbatim: gather the run's follow-up candidates, ask once with `AskUserQuestion` when there are any, and add accepted ones with `doug board add`, then one commit.
43
+ `/doug-next` step 6 verbatim: gather the run's follow-up candidates and file each straight to Backlog with `doug board add`, placed by priority, then one commit.
44
44
 
45
- Before the continue gate, gather the follow-up candidates this landing produced: the report's review issues, the adversary blocks classified marginal or false whose reason names a hole, the integrate stage's conflicts and failed verify tails, and the landing summary. Suggestions come only from what the run actually reported, never invented; each carries the one-line reason and cites the pass that found it. A reviewer's out-of-scope finding is offered only when it is realistic drift (a form a model or a person would plausibly write) or when one card would close the whole class; otherwise it goes in the landing note and the lead recommends none. The suggestion's goal text carries both: the goal names the pass that found it (the reviewer's finding, the implementer's blockedReason or handoff, the verifier's findings, the adversary block id) and the landed commit.
45
+ Before the continue gate, gather the follow-up candidates this landing produced: the report's review issues, the adversary blocks classified marginal or false whose reason names a hole, the integrate stage's conflicts and failed verify tails, and the landing summary. Suggestions come only from what the run actually reported, never invented; each carries the one-line reason and cites the pass that found it. A reviewer's out-of-scope finding is filed only when it is realistic drift (a form a model or a person would plausibly write) or when one card would close the whole class; otherwise it goes in the landing note and is not filed. The card's goal text carries both: the goal names the pass that found it (the reviewer's finding, the implementer's blockedReason or handoff, the verifier's findings, the adversary block id) and the landed commit.
46
46
 
47
- A landing with no candidates skips the question. Otherwise ask once with `AskUserQuestion`, `multiSelect: true`, one option per suggestion with a proposed id, title, column, size, class, track, and deps on the landed card, plus a "none" option.
47
+ Follow-ups are filed without a question. A landing with no candidates files nothing. Each realistic candidate goes straight to Backlog with `doug board add` (never a script that edits the record): `doug board add <id> --title "<title>" --goal "<goal>" --component <c> --size <S|M|L> --class <tests-only|prose|gate-script|code|docs|eval|decision> --track <hand|flow> --deps <landed-id> --column backlog`. Then the lead places it in Backlog by priority with `doug board reorder`: `--top` for a defect that blocks a commit, a gate, or a user; `--after <id>` of the last such card for drift a user would hit; `--bottom` otherwise. Only the user promotes a card to Ready; the lead never files a follow-up to Ready or Decide.
48
48
 
49
- Each accepted suggestion is added with `doug board add` (never a script that edits the record): `doug board add <id> --title "<title>" --goal "<goal>" --component <c> --size <S|M|L> --class <tests-only|prose|gate-script|code|docs|eval|decision> --track <hand|flow> --deps <landed-id> --column <ready|decide>` (or `node packages/doug-cli/dist/bin.js board add ...` when `doug` is not on PATH; build the CLI first if `dist/` is missing). An S hand card goes to the top of Ready (`doug board reorder <id> --top`) when it is mechanical and to Decide when it needs a decision. Then one commit `"Board: <id> follow-ups added: <new-id>, <new-id>"`, whatever the count; the served page (`doug board serve`) shows the new cards without a republish. Batch: each suggestion's deps name the card of the batch it came from; still one question and one commit for the whole batch.
49
+ The lead says in the chat what it filed and where: each new card's id, title, and its Backlog position with the reason. Then one commit `"Board: <id> follow-ups added: <new-id>, <new-id>"`, whatever the count; the served page (`doug board serve`) shows the new cards without a republish. Batch: each card's deps name the card of the batch it came from; still one commit for the whole batch.
50
50
 
51
51
  A goal without a card (section 1's second bullet) skips this step, the way section 5 says such a run skips the board steps.
52
52
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: research
3
- description: The research step before a plan, for a card whose goal depends on facts outside the repository (a Claude Code hook contract, a CLI's output format, a third-party API). One researcher, or several in parallel with distinct questions, write one cited note the planner and the builder work from; uncertainty is stated, never guessed. Invoked by /core-next, /doug-plan, and /doug-next before planning, or on its own.
3
+ description: The research step before a plan, for a card whose goal depends on facts outside the repository (a Claude Code hook contract, a CLI's output format, a third-party API). One researcher, or several in parallel with distinct questions, write one cited note the planner and the builder work from; uncertainty is stated, never guessed. Invoked by /doug-next, doug-hand, and /doug-plan before planning, or on its own.
4
4
  allowed-tools: Agent, Read, Write, Bash(mkdir *), Bash(cat *), Bash(node *plan.mjs *)
5
5
  ---
6
6
 
@@ -31,11 +31,13 @@ Write the findings to `.doug/.state/research/<card-id>.md` (session state, ignor
31
31
  - the sources: a URL, a file path with a line, or a command and its output, one per fact;
32
32
  - what stayed unverified, marked `unverified`, with what was tried. Never guess a fact into the note; an unverified line is what keeps the planner from building on it.
33
33
 
34
+ A fact the design depends on is marked verified only when it is quoted verbatim from the raw source with its line number, fetched and searched (for example `curl -sL <url>` then `grep -n`) by the plugin's researcher agent or by a claude-code-guide asked for exact quotes; a guide answer that paraphrases, or cannot give the line, stays `unverified` in the note. The confirming fetch counts against the same research.maxFetches budget. On 2026-10-01 a guide answer quoted a sentence the raw sub-agents page does not contain, and only the grep caught it.
35
+
34
36
  The note is written under the card's id, not the question's, because the id is also the promotion key: a note for a card that lands is promoted to `docs/research/<card-id>.md`, `board.mjs record` copying it there when it records the landing so a commit that cites the note has something to read afterward. A note for a card that never lands (dropped, replanned under another id) stays in state and is eventually swept with the rest of `.doug/.state/`.
35
37
 
36
38
  Then hand it on:
37
39
 
38
40
  - Flow track (`/doug-plan`, `/doug-next`): give the planner the note's path in its prompt. The planner writes the facts it relied on into the plan's goal with their sources and marks any unverified one.
39
- - Hand track (`/core-next`): the builder reads the note before the test, cites the sources in the commit message where a fact decided the design, and names the note in the landing's `--note`.
41
+ - Hand track (doug-hand): the builder reads the note before the test, cites the sources in the commit message where a fact decided the design, and names the note in the landing's `--note`.
40
42
 
41
43
  The step spends agents only when invoked and publishes nothing.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: run-report
3
- description: End a card's run with one command: append the docs/live-runs.md entry for a workflow report (or a hand-track landing), and print the three-line summary that goes in the chat. Called by /doug-next after land and by /core-next after the commit; usable on any saved report.
3
+ description: End a card's run with one command: append the docs/live-runs.md entry for a workflow report (or a hand-track landing), and print the three-line summary that goes in the chat. Called by /doug-next after land and by doug-hand after the commit; usable on any saved report.
4
4
  allowed-tools: Agent, Read, Bash
5
5
  ---
6
6
 
@@ -14,11 +14,11 @@ With the card id, the report (`.doug/.state/last-report.json` unless another pat
14
14
 
15
15
  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
16
  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> --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`.
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`.
18
18
  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
19
  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
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/harmful counters to any lesson a task's `memoryUsed` names, then 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.
21
- 7. `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" summary <report.json> --wall "<m> min" --commit <sha> [--codex-cost <usd>]` (same `--cost` rule) and put its three lines in the chat verbatim, in a fenced code block: the outcome, the tasks, and what stopped or was not met.
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 three lines in the chat verbatim, in a fenced code block: the outcome, the tasks, and what stopped or was not met.
22
22
 
23
23
  A run that stopped is recorded the same way, without `--commit`; the summary's third line quotes each task's stop reason.
24
24
 
@@ -28,24 +28,23 @@ When `.doug/plan.json` carries `cards` (made by `plan.mjs merge`), the run is re
28
28
 
29
29
  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
30
  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> --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.
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.
32
32
  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
33
  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
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> --wall "<m> min" --commit <sha>` 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.
35
+ 7. `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" summary <report.json> --card <id> --run <run-id> --wall "<m> min" --commit <sha>` 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
36
 
37
37
  ## A hand-track landing
38
38
 
39
- 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`):
39
+ 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
40
 
41
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`.
42
42
  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
43
  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
-
45
- `memory.mjs reflect` does not run on a hand-track landing: there is no report for the cheap-row pass to read.
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/harmful counters to the card's memoryUsed, 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.
46
45
 
47
46
  Say the commit, the wall clock, and the gate line in the chat.
48
47
 
49
48
  ## Then
50
49
 
51
- Moving the card and committing the record and the log belong to the calling skill (`/doug-next` step 5, `/core-next` step 4); a record change shows on the served page (`doug board serve`).
50
+ Moving the card and committing the record and the log belong to the calling skill (`/doug-next` step 5, doug-hand step 4); a record change shows on the served page (`doug board serve`).
@@ -12,7 +12,7 @@ The swarm lives inside a plan task (decision 0001 as amended 2026-09-06): the pl
12
12
 
13
13
  1. `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" validate`: the plan must be `approved`. Nothing runs on a draft; approval is the user's act (`/doug-approve`), never this skill's.
14
14
  2. `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" show` prints `Swarm: on` when the plan opted in with `plan.mjs set swarm on`. Without it the same workflow runs the fixed pipeline; do not set it here on your own, because the opt-in is the user's (each swarm task spends a lead, N workers, and a merge instead of one implementer). The swarm pays on a task with independent deliverables that share no new symbol; a plan of small coupled tasks runs as the pipeline (`docs/decisions/0001-hierarchical-swarms.md`, Amendment 2026-09-20: one brief per task at +2.67 USD and +6.1 min against the pipeline on 2026-09-11; five briefs, 8/8 hidden tests, 8.31 USD, 8.5 min on 2026-09-19).
15
- 3. The caller is a user-invoked skill or command (`/doug-implement`, `/doug-next`, `/doug-swarm`, `/core-next` for a size M or L hand-track card the user chose to swarm): that invocation is the opt-in the Workflow tool requires. No swarm runs in the background or on a schedule.
15
+ 3. The caller is a user-invoked skill or command (`/doug-implement`, `/doug-next`, `/doug-swarm`, doug-hand for a size M or L hand-track card the user chose to swarm): that invocation is the opt-in the Workflow tool requires. No swarm runs in the background or on a schedule.
16
16
 
17
17
  ## Launch
18
18
 
@@ -52,6 +52,9 @@ const taskBudget = { ...DEFAULT_BUDGET, ...(plan.budget && typeof plan.budget ==
52
52
  // The phrase the verifier and reviewer put at the start of a finding when the spec cannot satisfy the plan's
53
53
  // acceptance criteria. Such a block goes back to the planner and is never retried.
54
54
  const CONTRADICTION_MARKER = 'SPEC CONTRADICTS ACCEPTANCE'
55
+ // Why a task stopped: every halt site sets one of these as stopClass beside its prose stopReason. Kept equal to
56
+ // STOP_CLASSES in lib/board.mjs by a template test (this file may not import).
57
+ const STOP_CLASSES = ['implementer-blocked', 'partial', 'stage-missing', 'spec-contradiction', 'environment', 'adversary-not-run', 'outside-owned', 'new-blockers-twice', 'fix-attempts-exhausted', 'stalled', 'budget', 'no-new-commit', 'dependency-skipped', 'stage-threw', 'level-adversary']
55
58
  // The phrase a verifier, checker, or size-check puts at the start of a finding when a command could not run for
56
59
  // an environment reason (a tool missing from the worktree even after the install step, a sandbox denial) rather
57
60
  // than a defect in the diff. The fix loop never retries such a finding (card reused-s-task-worktree-install):
@@ -284,8 +287,11 @@ const INTEGRATE_SCHEMA = {
284
287
  branch: { type: 'string' },
285
288
  merged: { type: 'array', items: { type: 'string' } },
286
289
  conflicts: { type: 'array', items: { type: 'string' } },
287
- verify: { type: 'array', items: { type: 'object', required: ['command', 'ok'], properties: { command: { type: 'string' }, ok: { type: 'boolean' }, outputTail: { type: 'string' } } } },
290
+ verify: { type: 'array', items: { type: 'object', required: ['command', 'ok'], properties: { command: { type: 'string' }, ok: { type: 'boolean' }, outputTail: { type: 'string' }, credited: { type: 'string' } } } },
288
291
  ok: { type: 'boolean' },
292
+ // Card level-adversary-empty-diff: the integration branch's HEAD sha before this stage's merges; the level
293
+ // adversary diffs from it. Optional: a missing or malformed value falls back to the level's base branch.
294
+ baseCommit: { type: 'string' },
289
295
  // Card integration-acceptance-recorded: only the last level's integrate stage fills this in (step 3b of
290
296
  // integratePrompt); not required, since an earlier level has not built the whole plan yet.
291
297
  acceptance: { type: 'array', description: 'one entry per acceptance command listed in the prompt, [] when none', items: { type: 'object', required: ['text', 'command', 'ok', 'exitCode'], properties: { text: { type: 'string' }, command: { type: 'string' }, ok: { type: 'boolean' }, exitCode: { type: ['integer', 'null'] } } } },
@@ -300,7 +306,7 @@ const INTEGRATE_SCHEMA = {
300
306
  // (empty, prose, or unrecognised) is dropped rather than trusted or blamed. A plan command with no matching
301
307
  // report is appended as { ..., reported: false } - unreported and failed are different causes and get different
302
308
  // reason clauses. Pure: never mutates its argument, never throws on odd shapes.
303
- function enforceIntegrationAcceptance(integration, isLastLevel, entries) {
309
+ function enforceIntegrationAcceptance(integration, isLastLevel, entries, verifyCommands = []) {
304
310
  if (!integration || typeof integration !== 'object') return integration
305
311
  if (!isLastLevel) {
306
312
  const rest = { ...integration }
@@ -309,10 +315,20 @@ function enforceIntegrationAcceptance(integration, isLastLevel, entries) {
309
315
  }
310
316
  const reported = Array.isArray(integration.acceptance) ? integration.acceptance.filter(a => a && typeof a === 'object') : []
311
317
  const acceptance = []
318
+ const verifyList = Array.isArray(integration.verify) ? integration.verify.filter(v => v && typeof v === 'object') : []
319
+ const repeated = new Set((Array.isArray(verifyCommands) ? verifyCommands : []).map(c => String(c).trim()))
312
320
  for (const e of entries) {
313
321
  if (!e.command) continue
314
322
  const wantCommand = String(e.command).trim()
315
323
  if (!wantCommand) continue
324
+ // Card flow-suite-dedupe: a command that repeats a plan.verify command takes its result from the verify item.
325
+ if (repeated.has(wantCommand)) {
326
+ const v = verifyList.find(x => typeof x.command === 'string' && String(x.command).trim() === wantCommand)
327
+ acceptance.push(v
328
+ ? { text: e.text, command: e.command, ok: v.ok === true, exitCode: v.ok === true ? 0 : null, from: 'verify' }
329
+ : { text: e.text, command: e.command, ok: false, exitCode: null, reported: false })
330
+ continue
331
+ }
316
332
  const match = reported.find(a => typeof a.command === 'string' && String(a.command).trim() === wantCommand)
317
333
  acceptance.push(match
318
334
  ? { text: match.text, command: e.command, ok: match.ok, exitCode: match.exitCode }
@@ -1212,10 +1228,9 @@ function levelSpecLines(sReady) {
1212
1228
  // codex-review over a level's size-S tasks on the integration branch, once, after the merge (and once more to confirm
1213
1229
  // a fix pass). Every blocker must name a file, because that is how it finds the task it goes back to.
1214
1230
  function levelAdversaryPrompt(li, sReady, levelBase, confirming) {
1215
- const verify = (plan.verify || []).filter(Boolean)
1216
1231
  const guard = t => String(t).replace(/^DOUG_SPEC_EOF$/gm, 'DOUG_SPEC_EOF ')
1217
1232
  const command = [
1218
- `cat <<'DOUG_SPEC_EOF' | ${adversary.command} --base ${shellQuote(levelBase)} --head ${shellQuote(integrationBranch)} --dir ${shellQuote(INTEGRATION_WORKTREE)} --spec - --timeout-ms ${adversary.timeoutMs}${typeof adversary.effort === 'string' && adversary.effort ? ` --effort ${adversary.effort}` : ''}${verify.map(v => ` --verify ${shellQuote(v)}`).join('')}`,
1233
+ `cat <<'DOUG_SPEC_EOF' | ${adversary.command} --base ${shellQuote(levelBase)} --head ${shellQuote(integrationBranch)} --dir ${shellQuote(INTEGRATION_WORKTREE)} --spec - --timeout-ms ${adversary.timeoutMs}${typeof adversary.effort === 'string' && adversary.effort ? ` --effort ${adversary.effort}` : ''}`,
1219
1234
  `Level ${li} of plan "${guard(plan.title)}": the size-S tasks below were merged into ${integrationBranch} with one focused check each and no adversary of their own. Review them together against their specs. Every blocker must name the file it is about, so it can go back to the task that owns that file.`,
1220
1235
  ...(confirming ? [`A fix pass answered the blockers you raised on this level; confirm each one is fixed, name it as fixed or still open, and check the fix for regressions rather than exploring afresh.`] : [`This is the first review of this level: explore freely and try to refute the changes however you can.`]),
1221
1236
  ``,
@@ -1310,7 +1325,7 @@ function confirmationLines(pass, ledger, impl) {
1310
1325
  }
1311
1326
 
1312
1327
  function adversaryPrompt(task, impl, baseBranch, pass, ledger) {
1313
- const verify = [task.verify, ...(plan.verify || [])].filter(Boolean)
1328
+ const verify = [task.verify].filter(Boolean)
1314
1329
  const guard = s => String(s).replace(/^DOUG_SPEC_EOF$/gm, 'DOUG_SPEC_EOF ')
1315
1330
  const command = [
1316
1331
  `cat <<'DOUG_SPEC_EOF' | ${adversary.command} --base ${shellQuote(baseBranch)} --head ${shellQuote(impl.branch)} --dir ${shellQuote(impl.worktreePath)} --spec - --timeout-ms ${adversary.timeoutMs}${typeof adversary.effort === 'string' && adversary.effort ? ` --effort ${adversary.effort}` : ''}${verify.map(v => ` --verify ${shellQuote(v)}`).join('')}`,
@@ -1417,14 +1432,26 @@ function integratePrompt(levelIndex, results, baseBranch, isLastLevel, keepWorkt
1417
1432
  ` if it exists (a previous level made it): use it as is; it already has ${integrationBranch} checked out.`,
1418
1433
  ]),
1419
1434
  ` Do every following git and verify command inside that worktree (git -C or cd into it).`,
1435
+ ` Before any merge of this stage, run git rev-parse HEAD in that worktree and report the full 40-character sha as baseCommit.`,
1420
1436
  `2. Merge each branch with a merge commit (git merge --no-ff <branch>). If a merge conflicts, resolve it only if the resolution is mechanical and obviously correct; otherwise git merge --abort and list the branch in conflicts. ${NO_TRAILERS} That includes the merge commits you make here.`,
1421
1437
  `3. Install dependencies there if the verify commands need them and they are missing${plan.install ? ` (${plan.install})` : ''}. Then run every command in: ${plan.verify.length ? plan.verify.map(v => `"${v}"`).join(', ') : 'the project test command'} and record real exit statuses.`,
1438
+ ...(plan.verify.length
1439
+ ? [
1440
+ ` 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:`,
1441
+ ` node .doug/hooks/scripts/verify.mjs --json --by integrate --cwd .claude/worktrees/doug-integration --record-dir . ${worktrees.map(w => `--credit-dir ${shellQuote(w)}`).join(' ')} ${plan.verify.map(v => `--command ${shellQuote(v)}`).join(' ')}`,
1442
+ ` 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.`,
1443
+ ]
1444
+ : []),
1422
1445
  // Card integration-acceptance-recorded: only the last level runs the plan's acceptance commands, and only
1423
1446
  // after the merges above - the plan is not complete at an earlier level, so its prompt never mentions this.
1424
1447
  ...(isLastLevel
1425
1448
  ? [
1426
1449
  `3b. Plan acceptance criteria (a line starting with $ is the command that checks the criterion above it and exits 0 when it holds; the plan is complete at this level, so run every one now):`,
1427
- ...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
1450
+ ...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command
1451
+ ? ((plan.verify || []).some(v => String(v).trim() === String(a.command).trim())
1452
+ ? [`- ${a.text} is checked by step 3's \`${a.command}\`; do not run it again and do not report it in acceptance; the workflow takes its result from verify.`]
1453
+ : [`- ${a.text}`, ` $ ${a.command}`])
1454
+ : [`- ${a.text}`]),
1428
1455
  `Run every $ command above inside this integration worktree, exactly as written, after the merges, and report each one in \`acceptance\` as { text, command, ok, exitCode } with its real exit code (ok means exit code 0; exitCode null only if the command could not start). Never report one you did not run; return acceptance: [] when the plan lists no commands.`,
1429
1456
  `Report each command string exactly as it is written above, character for character, even when you ran it behind a cd or git -C prefix: the workflow matches your report to the plan by that string, and a command it cannot match counts as not run and fails the integration.`,
1430
1457
  `A criterion with no $ line is not yours to judge here: do not report it.`,
@@ -1441,8 +1468,8 @@ function integratePrompt(levelIndex, results, baseBranch, isLastLevel, keepWorkt
1441
1468
  // ---- Task loop -------------------------------------------------------------------
1442
1469
  // One task from start to a verdict: implement (or check out a reused branch), then verify, review and the
1443
1470
  // adversary. While the block is one the checks described inside the task's own files, relaunch the implementer in
1444
- // the same worktree; a fix pass reruns the stage that blocked, then one focused check, then the adversary, and the
1445
- // loop stops as soon as it stops converging (no new commit, two consecutive novel blockers, or the task budget).
1471
+ // the same worktree; a fix pass reruns the verifier or reviewer that blocked, then one focused check and the
1472
+ // adversary together, and the loop stops as soon as it stops converging (no new commit, two consecutive novel blockers, or the task budget).
1446
1473
  async function startTask(task, baseBranch, launch = agent) {
1447
1474
  if (task.reuse) return launch(reusePrompt(task, baseBranch), { label: `reuse:${task.id}`, phase: 'Implement', isolation: 'worktree', schema: IMPLEMENT_SCHEMA, model: CHECKOUT_MODEL })
1448
1475
  if (swarmApplies(task)) return runSwarm(task, baseBranch, launch)
@@ -1790,27 +1817,27 @@ function ownedBySibling(task, p) {
1790
1817
 
1791
1818
  // Can this block be fixed by the same implementer, or must a human see it?
1792
1819
  function retriable(task, r) {
1793
- if (!r.impl) return { ok: false, reason: 'implementer returned nothing' }
1820
+ if (!r.impl) return { ok: false, reason: 'implementer returned nothing', stopClass: 'stage-missing' }
1794
1821
  // A fix pass that returned no structured result changed nothing the loop knows of: the branch and worktree it
1795
1822
  // had are still there, so the same implementer is asked again.
1796
1823
  if (r.noResult) return { ok: true, reason: r.noResult }
1797
- if (r.impl.blocked) return { ok: false, reason: `implementer blocked: ${r.impl.blockedReason || 'no reason given'}` }
1824
+ if (r.impl.blocked) return { ok: false, reason: `implementer blocked: ${r.impl.blockedReason || 'no reason given'}`, stopClass: 'implementer-blocked' }
1798
1825
  // A partial result is not a check failure, whether it came from the initial worker's one resume or from a fix
1799
1826
  // pass: the fix loop never retries it (card worker-context-handoff).
1800
- if (r.impl.partial) return { ok: false, reason: 'a partial result is not retried by the fix loop' }
1827
+ if (r.impl.partial) return { ok: false, reason: 'a partial result is not retried by the fix loop', stopClass: 'partial' }
1801
1828
  // Only a stage that actually ran can be missing; a fix pass deliberately skips the stages that did not block.
1802
1829
  const ran = r.stages || []
1803
- if (ran.includes('verify') && !r.ver) return { ok: false, reason: 'verifier returned nothing' }
1804
- if (ran.includes('review') && !r.rev) return { ok: false, reason: 'reviewer returned nothing' }
1805
- if (ran.includes('check') && !r.check) return { ok: false, reason: 'check returned nothing' }
1830
+ if (ran.includes('verify') && !r.ver) return { ok: false, reason: 'verifier returned nothing', stopClass: 'stage-missing' }
1831
+ if (ran.includes('review') && !r.rev) return { ok: false, reason: 'reviewer returned nothing', stopClass: 'stage-missing' }
1832
+ if (ran.includes('check') && !r.check) return { ok: false, reason: 'check returned nothing', stopClass: 'stage-missing' }
1806
1833
  const texts = [...((r.ver && r.ver.findings) || []), ...(((r.rev && r.rev.issues) || []).map(i => i.description))]
1807
1834
  const contradiction = texts.find(t => typeof t === 'string' && t.includes(CONTRADICTION_MARKER))
1808
- if (contradiction) return { ok: false, reason: `spec contradicts acceptance: ${contradiction}` }
1835
+ if (contradiction) return { ok: false, reason: `spec contradicts acceptance: ${contradiction}`, stopClass: 'spec-contradiction' }
1809
1836
  const environmentOnly = texts.find(t => typeof t === 'string' && t.includes(ENVIRONMENT_MARKER))
1810
- if (environmentOnly) return { ok: false, reason: `environment, not the code: ${environmentOnly}` }
1811
- if (adversary && r.adv && !r.adv.ran) return { ok: false, reason: `adversary did not run: ${r.adv.error || 'no reason given'}` }
1837
+ if (environmentOnly) return { ok: false, reason: `environment, not the code: ${environmentOnly}`, stopClass: 'environment' }
1838
+ if (adversary && r.adv && !r.adv.ran && !r.blockingStage) return { ok: false, reason: `adversary did not run: ${r.adv.error || 'no reason given'}`, stopClass: 'adversary-not-run' }
1812
1839
  const outside = [...new Set(findingPaths(r).filter(p => !ownsPath(task, p) && !ownedBySibling(task, p)))]
1813
- if (outside.length) return { ok: false, reason: `findings name files outside the owned set: ${outside.join(', ')}` }
1840
+ if (outside.length) return { ok: false, reason: `findings name files outside the owned set: ${outside.join(', ')}`, stopClass: 'outside-owned' }
1814
1841
  return { ok: true, reason: 'every finding is within the owned files' }
1815
1842
  }
1816
1843
 
@@ -2094,9 +2121,9 @@ function stageHead(ver, check) {
2094
2121
  }
2095
2122
 
2096
2123
  // baseBranch is a parameter, not the outer binding, so this block can be built and exercised on its own.
2097
- // ctx: { ledger, launch, lead, prevCommit, blocking }. On pass 1 the verifier and the reviewer both run and the
2098
- // adversary follows when both passed. On a fix pass only the stage that blocked reruns, then the focused check,
2099
- // then the adversary; the first stage that blocks ends the pass.
2124
+ // ctx: { ledger, launch, lead, prevCommit, blocking }. On pass 1 the verifier, the reviewer, and the adversary run
2125
+ // together. On a fix pass the verifier or the reviewer reruns first when it is the stage that blocked, and ends the
2126
+ // pass if it blocks again; then the focused check and the adversary run together.
2100
2127
  async function runChecks(task, impl, pass, baseBranch, ctx) {
2101
2128
  const c = ctx || {}
2102
2129
  const ledger = c.ledger || []
@@ -2117,57 +2144,68 @@ async function runChecks(task, impl, pass, baseBranch, ctx) {
2117
2144
  if (check) ({ ver, rev } = readCheck(check))
2118
2145
  blockingStage = !(check && check.passed && rev && rev.approve) ? 'check' : null
2119
2146
  } else if (pass === 1) {
2120
- // The verifier and the reviewer are both read-only and neither reads the other's result, so a first pass
2121
- // launches them together; a null from either (skipped, or dead after retries) is that stage returning nothing.
2147
+ // The verifier, the reviewer, and the adversary are read-only and none reads another's result, so a first pass
2148
+ // launches them together; a null from any (skipped, or dead after retries) is that stage returning nothing.
2122
2149
  stages.push('verify')
2123
2150
  stages.push('review')
2124
2151
  const crew = crewOf(task)
2125
2152
  for (let seat = 2; seat <= crew.reviewers; seat++) stages.push(`review-${seat}`)
2153
+ const withAdversary = adversaryRequired(task)
2154
+ const advStages = []
2126
2155
  const pair = await parallel([
2127
2156
  () => launch(verifyPrompt(task, impl, baseBranch), agentOpts('verify', task, { label: `verify:${task.id}`, phase: 'Verify', agentType: 'doug-flow:verifier', schema: VERIFY_SCHEMA })),
2128
2157
  () => launch(reviewPrompt(task, impl, baseBranch), agentOpts('review', task, { label: `review:${task.id}`, phase: 'Review', agentType: 'doug-flow:reviewer', schema: REVIEW_SCHEMA })),
2129
2158
  ...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 }))),
2159
+ ...(withAdversary ? [() => runAdversaryCrew(task, impl, pass, baseBranch, ledger, launch, advStages)] : []),
2130
2160
  ])
2131
2161
  ver = (pair && pair[0]) || null
2132
- rev = pair ? mergeReviews(pair.slice(1)) : null
2162
+ rev = pair ? mergeReviews(pair.slice(1, 1 + crew.reviewers)) : null
2133
2163
  stageResults.verify = ver
2134
2164
  stageResults.review = rev
2135
- // Only changes that already passed verification and review are worth a second model's time.
2136
- if (adversary && ver && ver.passed && rev && rev.approve) {
2137
- adv = await runAdversaryCrew(task, impl, pass, baseBranch, ledger, launch, stages)
2165
+ // The adversary no longer waits for verify and review (card flow-adversary-parallel, 2026-10-07): waiting
2166
+ // added about five minutes to every full task. The cost is a Codex review of trees verify or review rejects;
2167
+ // integration still needs all three verdicts (isReady), and the ledger reads each stage's findings.
2168
+ if (withAdversary) {
2169
+ stages.push(...advStages)
2170
+ adv = (pair && pair[1 + crew.reviewers]) || null
2138
2171
  stageResults.adversary = adv
2139
2172
  }
2140
2173
  blockingStage = !(ver && ver.passed) ? 'verify' : !(rev && rev.approve) ? 'review' : adversaryBlocked(adv) ? 'adversary' : null
2141
2174
  } else {
2142
2175
  const first = c.blocking || 'verify'
2143
- const order = [first, ...['check', 'adversary'].filter(s => s !== first)]
2144
- for (const stage of order) {
2145
- if (stage === 'adversary' && !adversaryRequired(task)) continue
2146
- if (stage === 'verify') {
2147
- stages.push('verify')
2148
- ver = await launch(verifyPrompt(task, impl, baseBranch), agentOpts('verify', task, { label: `verify:${task.id}${suffix}`, phase: 'Verify', agentType: 'doug-flow:verifier', schema: VERIFY_SCHEMA }))
2149
- stageResults.verify = ver
2150
- if (!(ver && ver.passed)) { blockingStage = 'verify'; break }
2151
- } else if (stage === 'review') {
2152
- stages.push('review')
2153
- const crew = crewOf(task)
2154
- for (let seat = 2; seat <= crew.reviewers; seat++) stages.push(`review-${seat}`)
2155
- const seats = await parallel(Array.from({ length: crew.reviewers }, (_, i) => () => launch(reviewPrompt(task, impl, baseBranch, i + 1), agentOpts('review', task, { label: `review${i ? `-${i + 1}` : ''}:${task.id}${suffix}`, phase: 'Review', agentType: 'doug-flow:reviewer', schema: REVIEW_SCHEMA }))))
2156
- rev = mergeReviews(seats || [])
2157
- stageResults.review = rev
2158
- if (!(rev && rev.approve)) { blockingStage = 'review'; break }
2159
- } else if (stage === 'check') {
2160
- stages.push('check')
2161
- check = await launch(checkPrompt(task, impl, c.prevCommit || null, baseBranch, ledger), agentOpts('verify', task, { label: `check:${task.id}:${pass}`, phase: 'Verify', agentType: 'doug-flow:verifier', schema: CHECK_SCHEMA }))
2162
- stageResults.check = check
2163
- // The one check stands in for both stages, so the rest of the loop and the report read it as they read them.
2164
- if (check) ({ ver, rev } = readCheck(check))
2165
- if (!(check && check.passed && rev && rev.approve)) { blockingStage = 'check'; break }
2166
- } else {
2167
- adv = await runAdversaryCrew(task, impl, pass, baseBranch, ledger, launch, stages)
2168
- stageResults.adversary = adv
2169
- if (!adversaryOk(adv, !!adversary)) { if (adversaryBlocked(adv)) blockingStage = 'adversary'; break }
2170
- }
2176
+ if (first === 'verify') {
2177
+ stages.push('verify')
2178
+ ver = await launch(verifyPrompt(task, impl, baseBranch), agentOpts('verify', task, { label: `verify:${task.id}${suffix}`, phase: 'Verify', agentType: 'doug-flow:verifier', schema: VERIFY_SCHEMA }))
2179
+ stageResults.verify = ver
2180
+ if (!(ver && ver.passed)) blockingStage = 'verify'
2181
+ } else if (first === 'review') {
2182
+ stages.push('review')
2183
+ const crew = crewOf(task)
2184
+ for (let seat = 2; seat <= crew.reviewers; seat++) stages.push(`review-${seat}`)
2185
+ const seats = await parallel(Array.from({ length: crew.reviewers }, (_, i) => () => launch(reviewPrompt(task, impl, baseBranch, i + 1), agentOpts('review', task, { label: `review${i ? `-${i + 1}` : ''}:${task.id}${suffix}`, phase: 'Review', agentType: 'doug-flow:reviewer', schema: REVIEW_SCHEMA }))))
2186
+ rev = mergeReviews(seats || [])
2187
+ stageResults.review = rev
2188
+ if (!(rev && rev.approve)) blockingStage = 'review'
2189
+ }
2190
+ if (!blockingStage) {
2191
+ // The focused check and the adversary are both read-only, so they run together (card flow-adversary-parallel).
2192
+ // The stages are recorded in the order the serial loop ran them (the adversary first when it blocked the last
2193
+ // pass), since updateLedger reads them in order to tell a stage confirming a fix from one re-reporting it.
2194
+ const withAdversary = adversaryRequired(task)
2195
+ const adversaryFirst = withAdversary && first === 'adversary'
2196
+ const advStages = []
2197
+ const runCheck = () => launch(checkPrompt(task, impl, c.prevCommit || null, baseBranch, ledger), agentOpts('verify', task, { label: `check:${task.id}:${pass}`, phase: 'Verify', agentType: 'doug-flow:verifier', schema: CHECK_SCHEMA }))
2198
+ const runAdv = () => runAdversaryCrew(task, impl, pass, baseBranch, ledger, launch, advStages)
2199
+ const both = !withAdversary ? [await runCheck()] : await parallel(adversaryFirst ? [runAdv, runCheck] : [runCheck, runAdv])
2200
+ check = (both && both[adversaryFirst ? 1 : 0]) || null
2201
+ stageResults.check = check
2202
+ // The one check stands in for both stages, so the rest of the loop and the report read it as they read them.
2203
+ if (check) ({ ver, rev } = readCheck(check))
2204
+ if (withAdversary) { adv = (both && both[adversaryFirst ? 0 : 1]) || null; stageResults.adversary = adv }
2205
+ stages.push(...(adversaryFirst ? [...advStages, 'check'] : ['check', ...advStages]))
2206
+ const checkBlocked = !(check && check.passed && rev && rev.approve)
2207
+ const advBlocked = withAdversary && adversaryBlocked(adv)
2208
+ blockingStage = adversaryFirst && advBlocked ? 'adversary' : checkBlocked ? 'check' : advBlocked ? 'adversary' : null
2171
2209
  }
2172
2210
  }
2173
2211
  const head = stageHead(ver, check)
@@ -2338,20 +2376,20 @@ async function runTask(task, baseBranch, resume) {
2338
2376
  })
2339
2377
  // The last supervisor that ran on this task, for the report; null when none did.
2340
2378
  const lastSupervisor = () => { for (let i = attempts.length - 1; i >= 0; i--) if (attempts[i].supervisor) return attempts[i].supervisor; return null }
2341
- if (ready) return { ...r, attempts, ledger, budget: budgetOf(), stopReason: null, supervisor: lastSupervisor() }
2379
+ if (ready) return { ...r, attempts, ledger, budget: budgetOf(), stopReason: null, stopClass: null, supervisor: lastSupervisor() }
2342
2380
  const why = notReadyWhy(r)
2343
- const halt = reason => {
2381
+ const halt = (stopClass, reason) => {
2344
2382
  for (const e of ledger) if (e.status === 'open') log(`${task.id} open finding ${e.id} [${e.stage}] ${e.file || '(no file)'}: ${String(e.description).slice(0, 120)}`)
2345
- return { ...r, attempts, ledger, budget: budgetOf(), stopReason: reason, supervisor: lastSupervisor() }
2383
+ return { ...r, attempts, ledger, budget: budgetOf(), stopReason: reason, stopClass, supervisor: lastSupervisor() }
2346
2384
  }
2347
- if (!decision.ok) return halt(`${why}; not retried: ${decision.reason}`)
2385
+ if (!decision.ok) return halt(decision.stopClass, `${why}; not retried: ${decision.reason}`)
2348
2386
  // Two passes in a row that each raise a blocker nobody had seen means the checks are exploring, not converging.
2349
2387
  const last = attempts[attempts.length - 1]
2350
2388
  const prior = attempts[attempts.length - 2]
2351
2389
  if (prior && last.newFindings.length && prior.newFindings.length) {
2352
- return halt(`${why}; stopped: two consecutive passes raised new blockers (${prior.newFindings.join(', ')}; ${last.newFindings.join(', ')})`)
2390
+ return halt('new-blockers-twice', `${why}; stopped: two consecutive passes raised new blockers (${prior.newFindings.join(', ')}; ${last.newFindings.join(', ')})`)
2353
2391
  }
2354
- if (attempts.length > fixAttempts) return halt(`${why}; fix attempts exhausted (${fixAttempts} of ${fixAttempts})`)
2392
+ if (attempts.length > fixAttempts) return halt('fix-attempts-exhausted', `${why}; fix attempts exhausted (${fixAttempts} of ${fixAttempts})`)
2355
2393
  // The supervisor (card fix-loop-supervisor): before a fix pass after the first, when the stall signals fire. A
2356
2394
  // pass that returned no result attacked nothing, so it is not a stall. A task stalled a second time stops here
2357
2395
  // instead of spending the remaining fix budget; otherwise the supervisor runs after the budget check below, on
@@ -2362,7 +2400,7 @@ async function runTask(task, baseBranch, resume) {
2362
2400
  if (signals.length && attempts.some(a => a.supervisor && a.supervisor.stalled)) {
2363
2401
  const findings = [...new Set(signals.filter(x => x.kind === 'repeat').map(x => x.finding))]
2364
2402
  lastAttempt.supervisor = { ran: false, stalled: true, brief: null, signals, stop: { kind: 'stalled', attempts: attempts.length, findings } }
2365
- return halt(`${why}; stopped: stalled twice (${describeStall(signals)})`)
2403
+ return halt('stalled', `${why}; stopped: stalled twice (${describeStall(signals)})`)
2366
2404
  }
2367
2405
  const blocking = r.blockingStage || 'verify'
2368
2406
  // What the next pass would cost: the fix agent plus the stages rule 3 would run. The adversary stage spends two
@@ -2391,7 +2429,7 @@ async function runTask(task, baseBranch, resume) {
2391
2429
  const projected = spent.elapsedMs + spent.elapsedMs / passesDone
2392
2430
  if (projected > limit) over = `elapsedMs ${show(projected)} > ${limit}`
2393
2431
  }
2394
- if (over) return halt(`${why}; stopped: next attempt would exceed the task budget (${over})`)
2432
+ if (over) return halt('budget', `${why}; stopped: next attempt would exceed the task budget (${over})`)
2395
2433
  const pass = r.pass + 1
2396
2434
  let brief = null
2397
2435
  if (signals.length) {
@@ -2447,7 +2485,7 @@ async function runTask(task, baseBranch, resume) {
2447
2485
  r = record({ task, impl, ver: null, rev: null, adv: null, check: null, pass, stages: ['fix'], stageResults: {}, blockingStage: null, commit: fix.commit || null }, prevCommit)
2448
2486
  const decided = fixBlocked ? retriable(task, r) : null
2449
2487
  attempts.push({ pass, stages: ['fix'], commit: r.commit, blockingStage: null, verified: false, reviewed: false, adversary: null, newFindings: r.newFindings || [], fixedFindings: r.fixedFindings || [], notes: r.droppedFindings || [], ready: false, retriable: decided, spent: measure(), commands: passCommands(r) })
2450
- return halt(fixBlocked ? `${notReadyWhy(r)}; not retried: ${decided.reason}` : `${why}; stopped: fix pass ${pass} made no new commit on ${impl.branch}`)
2488
+ return fixBlocked ? halt(decided.stopClass, `${notReadyWhy(r)}; not retried: ${decided.reason}`) : halt('no-new-commit', `${why}; stopped: fix pass ${pass} made no new commit on ${impl.branch}`)
2451
2489
  }
2452
2490
  const next = { ...fix, branch: r.impl.branch, worktreePath, prevCommit, ...(r.impl.workers ? { workers: r.impl.workers, swarmStages: r.impl.swarmStages, splitReason: r.impl.splitReason, rebrief: r.impl.rebrief, checkFailed: r.impl.checkFailed } : {}) }
2453
2491
  r = record(await runChecks(task, next, pass, baseBranch, { ledger, launch, lead: 'fix', prevCommit, blocking }), prevCommit)
@@ -2507,7 +2545,7 @@ for (let li = 0; li < levels.length; li++) {
2507
2545
  }
2508
2546
  const reason = missing.length > 1 ? `dependencies ${missing.join(', ')} were not integrated` : `dependency ${missing[0]} was not integrated`
2509
2547
  log(`${t.id} not launched: ${reason}`)
2510
- skipped.set(t, { task: t, impl: null, ver: null, rev: null, adv: null, check: null, attempts: [], ledger: [], budget: null, stages: [], stopReason: reason, skippedReason: reason })
2548
+ skipped.set(t, { task: t, impl: null, ver: null, rev: null, adv: null, check: null, attempts: [], ledger: [], budget: null, stages: [], stopReason: reason, stopClass: 'dependency-skipped', skippedReason: reason })
2511
2549
  }
2512
2550
 
2513
2551
  // Implement (or reuse) -> verify -> review -> adversary -> fix, per task, no barrier between tasks.
@@ -2523,7 +2561,7 @@ for (let li = 0; li < levels.length; li++) {
2523
2561
  // was ever made; blocked:true keeps `implemented` false so reusePlan still refuses to reuse it.
2524
2562
  const threwFallback = task => {
2525
2563
  const reason = 'task stage threw (agent error, unknown agent type, or user skip)'
2526
- return { task, impl: { taskId: task.id, branch: task.reuse || `doug/task-${task.id}`, worktreePath: '', filesTouched: [], commandsRun: [], summary: reason, blocked: true, blockedReason: reason, commit: null }, ver: null, rev: null, adv: null, check: null, attempts: [], ledger: [], budget: null, stopReason: reason }
2564
+ return { task, impl: { taskId: task.id, branch: task.reuse || `doug/task-${task.id}`, worktreePath: '', filesTouched: [], commandsRun: [], summary: reason, blocked: true, blockedReason: reason, commit: null }, ver: null, rev: null, adv: null, check: null, attempts: [], ledger: [], budget: null, stopReason: reason, stopClass: 'stage-threw' }
2527
2565
  }
2528
2566
  const byTask = new Map(runnable.map((task, i) => [task, results[i] || threwFallback(task)]))
2529
2567
  const ready = level.map(task => byTask.get(task) || skipped.get(task)).filter(isReady)
@@ -2537,7 +2575,7 @@ for (let li = 0; li < levels.length; li++) {
2537
2575
  const levelBase = baseBranch
2538
2576
  const sReady = adversary ? ready.filter(r => taskShape(r.task) === 'S') : []
2539
2577
  if (ready.length) {
2540
- integration = enforceIntegrationAcceptance(await retryOnce(integratePrompt(li, ready, baseBranch, li === levels.length - 1, sReady.length > 0, !integratedOnce), agentOpts('integrate', null, { label: `integrate:level-${li}`, phase: 'Integrate', schema: INTEGRATE_SCHEMA }), `level-${li}`), li === levels.length - 1, acceptanceEntries(plan.acceptance || []))
2578
+ integration = enforceIntegrationAcceptance(await retryOnce(integratePrompt(li, ready, baseBranch, li === levels.length - 1, sReady.length > 0, !integratedOnce), agentOpts('integrate', null, { label: `integrate:level-${li}`, phase: 'Integrate', schema: INTEGRATE_SCHEMA }), `level-${li}`), li === levels.length - 1, acceptanceEntries(plan.acceptance || []), plan.verify || [])
2541
2579
  integratedOnce = true
2542
2580
  if (integration && integration.ok) {
2543
2581
  baseBranch = integrationBranch
@@ -2548,7 +2586,12 @@ for (let li = 0; li < levels.length; li++) {
2548
2586
  // branch, after the merge. A blocker goes back to the task that owns its file as a fix pass, the fixed branches are
2549
2587
  // merged again, and the adversary confirms once; a level still blocked after that stops the run.
2550
2588
  if (integration && integration.ok && sReady.length) {
2551
- levelAdversary = await runLevelAdversary(li, sReady, levelBase, false)
2589
+ // Card level-adversary-empty-diff: the sha the integrator read before the level's first merge is the diff base;
2590
+ // the branch name would equal --head on every level >= 1. The re-integration never replaces it.
2591
+ const reportedBase = integration && integration.baseCommit
2592
+ const adversaryBase = typeof reportedBase === 'string' && /^[0-9a-f]{40}$/.test(reportedBase) ? reportedBase : levelBase
2593
+ if (adversaryBase === levelBase) log(`level ${li} level adversary falls back to ${levelBase} because the integrator reported no base commit`)
2594
+ levelAdversary = await runLevelAdversary(li, sReady, adversaryBase, false)
2552
2595
  if (levelAdversary.blocked) {
2553
2596
  const blockers = levelAdversary.issues.filter(i => i.severity === 'blocker')
2554
2597
  const items = blockers.length ? blockers : [{ severity: 'blocker', file: '', description: levelAdversary.summary || 'the level adversary blocked without an issue', evidence: null }]
@@ -2570,25 +2613,28 @@ for (let li = 0; li < levels.length; li++) {
2570
2613
  const adv = { ran: true, verdict: 'fail', summary: levelAdversary.summary, issues, commandsRun: levelAdversary.commandsRun, error: null }
2571
2614
  const again = await runTask(r.task, levelBase, { impl: r.impl, ver: r.ver, rev: r.rev, adv, commit: r.commit, pass: r.pass, attempts: r.attempts, ledger: r.ledger })
2572
2615
  byTask.set(r.task, again)
2573
- levelAdversary.fixed.push({ task: r.task.id, ready: isReady(again), stopReason: again.stopReason })
2616
+ levelAdversary.fixed.push({ task: r.task.id, ready: isReady(again), stopReason: again.stopReason, stopClass: again.stopClass || null })
2574
2617
  }
2575
2618
  const fixedReady = [...perTask.keys()].map(r => byTask.get(r.task)).filter(isReady)
2576
2619
  if (fixedReady.length === perTask.size) {
2577
- const again = enforceIntegrationAcceptance(await retryOnce(integratePrompt(li, fixedReady, levelBase, li === levels.length - 1, true, false), agentOpts('integrate', null, { label: `integrate:level-${li}:2`, phase: 'Integrate', schema: INTEGRATE_SCHEMA }), `level-${li}`), li === levels.length - 1, acceptanceEntries(plan.acceptance || []))
2620
+ const again = enforceIntegrationAcceptance(await retryOnce(integratePrompt(li, fixedReady, levelBase, li === levels.length - 1, true, false), agentOpts('integrate', null, { label: `integrate:level-${li}:2`, phase: 'Integrate', schema: INTEGRATE_SCHEMA }), `level-${li}`), li === levels.length - 1, acceptanceEntries(plan.acceptance || []), plan.verify || [])
2578
2621
  levelAdversary.reintegration = again
2579
2622
  // The re-integration is the final state of the branch, so its acceptance (not the first integration's)
2580
2623
  // is what the level report carries (card integration-acceptance-recorded, behaviour 4). Only
2581
2624
  // `acceptance` is copied onto the level's `integration`; a failed re-integration's own `ok` and
2582
2625
  // `reason` stay on `levelAdversary.reintegration`, not on the level report.
2583
2626
  if (integration && li === levels.length - 1 && again && typeof again === 'object') integration.acceptance = again.acceptance
2584
- if (again && again.ok) levelAdversary.confirm = await runLevelAdversary(li, sReady.map(r => byTask.get(r.task) || r), levelBase, true)
2627
+ if (again && again.ok) levelAdversary.confirm = await runLevelAdversary(li, sReady.map(r => byTask.get(r.task) || r), adversaryBase, true)
2585
2628
  }
2586
2629
  }
2587
2630
  const cleared = !!(levelAdversary.confirm && !levelAdversary.confirm.blocked)
2588
2631
  if (!cleared) {
2589
2632
  for (const r of new Set([...sReady, ...perTask.keys()])) {
2590
2633
  const cur = byTask.get(r.task) || r
2591
- if (!cur.stopReason) cur.stopReason = levelAdversary.unowned ? `level adversary blocked on ${levelAdversary.unowned.join(', ')}: ${levelAdversary.summary}` : levelAdversary.confirm ? `level adversary still blocked after a fix pass: ${levelAdversary.confirm.summary}` : levelAdversary.reintegration ? 'the fixed branches did not integrate again' : `level adversary blocked: ${levelAdversary.summary}`
2634
+ if (!cur.stopReason) {
2635
+ cur.stopReason = levelAdversary.unowned ? `level adversary blocked on ${levelAdversary.unowned.join(', ')}: ${levelAdversary.summary}` : levelAdversary.confirm ? `level adversary still blocked after a fix pass: ${levelAdversary.confirm.summary}` : levelAdversary.reintegration ? 'the fixed branches did not integrate again' : `level adversary blocked: ${levelAdversary.summary}`
2636
+ cur.stopClass = 'level-adversary'
2637
+ }
2592
2638
  byTask.set(r.task, cur)
2593
2639
  }
2594
2640
  for (const r of ready) integrated.delete(r.task.id)
@@ -2642,6 +2688,7 @@ for (let li = 0; li < levels.length; li++) {
2642
2688
  attempts: r.attempts,
2643
2689
  passes: r.attempts.length,
2644
2690
  stopReason: r.stopReason,
2691
+ stopClass: r.stopClass || null,
2645
2692
  // The fix-loop supervisor's last word on the task: ran, stalled, the brief it injected, and the stop when the
2646
2693
  // task stalled twice ({ kind: 'stalled', attempts, findings }); null when the loop never stalled.
2647
2694
  supervisor: r.supervisor || null,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "doug-gates",
3
3
  "displayName": "Doug Gates",
4
- "version": "1.0.2",
4
+ "version": "1.1.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"
@@ -110,6 +110,17 @@
110
110
  ]
111
111
  }
112
112
  ],
113
+ "UserPromptSubmit": [
114
+ {
115
+ "hooks": [
116
+ {
117
+ "type": "command",
118
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/turn-start.mjs\"",
119
+ "timeout": 5
120
+ }
121
+ ]
122
+ }
123
+ ],
113
124
  "SessionStart": [
114
125
  {
115
126
  "matcher": "compact|resume",