@dougharness/cli 1.0.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.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/dist/ablate.d.ts +96 -0
  3. package/dist/ablate.js +440 -0
  4. package/dist/ablate.js.map +1 -0
  5. package/dist/apply.d.ts +2 -0
  6. package/dist/apply.js +19 -0
  7. package/dist/apply.js.map +1 -0
  8. package/dist/bin.d.ts +2 -0
  9. package/dist/bin.js +80 -0
  10. package/dist/bin.js.map +1 -0
  11. package/dist/board-page.d.ts +15 -0
  12. package/dist/board-page.js +27 -0
  13. package/dist/board-page.js.map +1 -0
  14. package/dist/board-serve-process.d.ts +24 -0
  15. package/dist/board-serve-process.js +227 -0
  16. package/dist/board-serve-process.js.map +1 -0
  17. package/dist/board-serve.d.ts +9 -0
  18. package/dist/board-serve.js +302 -0
  19. package/dist/board-serve.js.map +1 -0
  20. package/dist/board.d.ts +6 -0
  21. package/dist/board.js +515 -0
  22. package/dist/board.js.map +1 -0
  23. package/dist/detect/fs.d.ts +6 -0
  24. package/dist/detect/fs.js +50 -0
  25. package/dist/detect/fs.js.map +1 -0
  26. package/dist/detect/index.d.ts +3 -0
  27. package/dist/detect/index.js +13 -0
  28. package/dist/detect/index.js.map +1 -0
  29. package/dist/detect/node.d.ts +3 -0
  30. package/dist/detect/node.js +179 -0
  31. package/dist/detect/node.js.map +1 -0
  32. package/dist/detect/python.d.ts +2 -0
  33. package/dist/detect/python.js +388 -0
  34. package/dist/detect/python.js.map +1 -0
  35. package/dist/detect/repo.d.ts +2 -0
  36. package/dist/detect/repo.js +102 -0
  37. package/dist/detect/repo.js.map +1 -0
  38. package/dist/detect/types.d.ts +98 -0
  39. package/dist/detect/types.js +2 -0
  40. package/dist/detect/types.js.map +1 -0
  41. package/dist/diff.d.ts +12 -0
  42. package/dist/diff.js +105 -0
  43. package/dist/diff.js.map +1 -0
  44. package/dist/generate/agents.d.ts +19 -0
  45. package/dist/generate/agents.js +184 -0
  46. package/dist/generate/agents.js.map +1 -0
  47. package/dist/generate/claude-md.d.ts +9 -0
  48. package/dist/generate/claude-md.js +111 -0
  49. package/dist/generate/claude-md.js.map +1 -0
  50. package/dist/generate/config.d.ts +51 -0
  51. package/dist/generate/config.js +155 -0
  52. package/dist/generate/config.js.map +1 -0
  53. package/dist/generate/proposal.d.ts +11 -0
  54. package/dist/generate/proposal.js +175 -0
  55. package/dist/generate/proposal.js.map +1 -0
  56. package/dist/generate/settings.d.ts +35 -0
  57. package/dist/generate/settings.js +213 -0
  58. package/dist/generate/settings.js.map +1 -0
  59. package/dist/generate/skills.d.ts +8 -0
  60. package/dist/generate/skills.js +264 -0
  61. package/dist/generate/skills.js.map +1 -0
  62. package/dist/init.d.ts +10 -0
  63. package/dist/init.js +69 -0
  64. package/dist/init.js.map +1 -0
  65. package/dist/trace.d.ts +9 -0
  66. package/dist/trace.js +106 -0
  67. package/dist/trace.js.map +1 -0
  68. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +10 -0
  69. package/node_modules/@dougharness/flow/LICENSE +21 -0
  70. package/node_modules/@dougharness/flow/agents/adversary-claude.md +33 -0
  71. package/node_modules/@dougharness/flow/agents/adversary.md +29 -0
  72. package/node_modules/@dougharness/flow/agents/implementer.md +23 -0
  73. package/node_modules/@dougharness/flow/agents/lead.md +35 -0
  74. package/node_modules/@dougharness/flow/agents/planner.md +70 -0
  75. package/node_modules/@dougharness/flow/agents/researcher.md +31 -0
  76. package/node_modules/@dougharness/flow/agents/reviewer.md +26 -0
  77. package/node_modules/@dougharness/flow/agents/verifier.md +24 -0
  78. package/node_modules/@dougharness/flow/lib/board.mjs +840 -0
  79. package/node_modules/@dougharness/flow/lib/code-index.mjs +629 -0
  80. package/node_modules/@dougharness/flow/lib/cost.mjs +392 -0
  81. package/node_modules/@dougharness/flow/lib/decisions.mjs +314 -0
  82. package/node_modules/@dougharness/flow/lib/embeddings.mjs +539 -0
  83. package/node_modules/@dougharness/flow/lib/land.mjs +219 -0
  84. package/node_modules/@dougharness/flow/lib/learn.mjs +742 -0
  85. package/node_modules/@dougharness/flow/lib/memory-import.mjs +145 -0
  86. package/node_modules/@dougharness/flow/lib/memory.mjs +1641 -0
  87. package/node_modules/@dougharness/flow/lib/models.mjs +148 -0
  88. package/node_modules/@dougharness/flow/lib/plan.mjs +773 -0
  89. package/node_modules/@dougharness/flow/lib/rehearse.mjs +825 -0
  90. package/node_modules/@dougharness/flow/lib/worktrees.mjs +215 -0
  91. package/node_modules/@dougharness/flow/package.json +19 -0
  92. package/node_modules/@dougharness/flow/scripts/board.mjs +260 -0
  93. package/node_modules/@dougharness/flow/scripts/cost.mjs +66 -0
  94. package/node_modules/@dougharness/flow/scripts/learn.mjs +161 -0
  95. package/node_modules/@dougharness/flow/scripts/memory.mjs +1274 -0
  96. package/node_modules/@dougharness/flow/scripts/plan.mjs +547 -0
  97. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +129 -0
  98. package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +92 -0
  99. package/node_modules/@dougharness/flow/skills/doug-approve/SKILL.md +15 -0
  100. package/node_modules/@dougharness/flow/skills/doug-decide/SKILL.md +26 -0
  101. package/node_modules/@dougharness/flow/skills/doug-implement/SKILL.md +22 -0
  102. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +20 -0
  103. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +87 -0
  104. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +22 -0
  105. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +55 -0
  106. package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +85 -0
  107. package/node_modules/@dougharness/flow/skills/research/SKILL.md +41 -0
  108. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +51 -0
  109. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +27 -0
  110. package/node_modules/@dougharness/flow/workflows/doug-implement.js +2687 -0
  111. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +10 -0
  112. package/node_modules/@dougharness/gates/LICENSE +21 -0
  113. package/node_modules/@dougharness/gates/hooks/hooks.json +184 -0
  114. package/node_modules/@dougharness/gates/lib/agent-context.mjs +111 -0
  115. package/node_modules/@dougharness/gates/lib/anchor.mjs +110 -0
  116. package/node_modules/@dougharness/gates/lib/baseline.mjs +160 -0
  117. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +1522 -0
  118. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +127 -0
  119. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +97 -0
  120. package/node_modules/@dougharness/gates/lib/config.mjs +168 -0
  121. package/node_modules/@dougharness/gates/lib/evidence.mjs +59 -0
  122. package/node_modules/@dougharness/gates/lib/glob.mjs +86 -0
  123. package/node_modules/@dougharness/gates/lib/io.mjs +91 -0
  124. package/node_modules/@dougharness/gates/lib/paths.mjs +101 -0
  125. package/node_modules/@dougharness/gates/lib/proposals.mjs +201 -0
  126. package/node_modules/@dougharness/gates/lib/run.mjs +147 -0
  127. package/node_modules/@dougharness/gates/lib/scope.mjs +117 -0
  128. package/node_modules/@dougharness/gates/lib/secret-rules.mjs +268 -0
  129. package/node_modules/@dougharness/gates/lib/state.mjs +114 -0
  130. package/node_modules/@dougharness/gates/lib/trace.mjs +196 -0
  131. package/node_modules/@dougharness/gates/package.json +17 -0
  132. package/node_modules/@dougharness/gates/scripts/edit-loop.mjs +39 -0
  133. package/node_modules/@dougharness/gates/scripts/format-on-edit.mjs +45 -0
  134. package/node_modules/@dougharness/gates/scripts/guard-bash.mjs +32 -0
  135. package/node_modules/@dougharness/gates/scripts/protect-paths.mjs +49 -0
  136. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +81 -0
  137. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +295 -0
  138. package/node_modules/@dougharness/gates/scripts/secret-scan.mjs +26 -0
  139. package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +57 -0
  140. package/node_modules/@dougharness/gates/scripts/statusline.mjs +133 -0
  141. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +1422 -0
  142. package/node_modules/@dougharness/gates/scripts/trace.mjs +75 -0
  143. package/package.json +33 -0
  144. package/templates/board-app.js +533 -0
@@ -0,0 +1,2687 @@
1
+ export const meta = {
2
+ name: 'doug-implement',
3
+ description: 'Implement an approved Doug plan: one implementer per task in its own worktree, then verify, review, and integrate',
4
+ whenToUse: 'After /doug-plan produced .doug/plan.json and the user approved it with /doug-approve. Pass the plan JSON as args.',
5
+ phases: [
6
+ { title: 'Implement', detail: 'one implementer per task, fresh context, own worktree (with swarm on, a lead splits a full-shape task into worker briefs, workers implement them in parallel, and the lead merges); a task marked reuse checks out its recorded branch instead; a task whose dependency did not integrate is never launched; a block the checks can describe is fixed in place until the task budget, the fix-attempt ceiling, a pass that made no new commit, or two consecutive passes raising a new blocker stops it' },
7
+ { title: 'Verify', detail: 'a separate verifier runs the task command in the worktree on the first pass; a fix pass runs one focused check over the fix delta instead, and the full verifier again only when the verifier is the stage that blocked' },
8
+ { title: 'Review', detail: 'spec compliance, then quality, against the diff on the first pass; on a fix pass the focused check carries scope and regression risk unless the reviewer is the stage that blocked; a task whose crew names two reviewers gets a second one in parallel with a distinct brief' },
9
+ { title: 'Adversary', detail: 'codex-review (a different model, read-only) explores freely on the first pass and confirms the open findings on later passes, under one material blocker gate; when it cannot run, a Claude adversary on plan.adversary.fallback stands in; blockers stop integration' },
10
+ { title: 'Integrate', detail: 'merge the level into the integration branch and run plan.verify' },
11
+ ],
12
+ }
13
+
14
+ // ---- Guards ---------------------------------------------------------------
15
+ const plan = args
16
+ if (!plan || typeof plan !== 'object' || !Array.isArray(plan.tasks)) {
17
+ throw new Error('doug-implement needs the plan object as args. Run: node <doug-flow>/scripts/plan.mjs json')
18
+ }
19
+ if (plan.status !== 'approved') {
20
+ throw new Error(`Plan status is "${plan.status}", not "approved". Nothing implemented. Approve it first with /doug-approve.`)
21
+ }
22
+ const integrationBranch = plan.integrationBranch || `doug/${plan.tasks.map(t => t.id).join('-').slice(0, 40)}`
23
+ // Adversarial review by a second model. plan.adversary: false disables it; { command, timeoutMs, fallback } overrides the defaults.
24
+ // The command is @dougharness/codex's `codex-review` (on PATH when the package is installed; or "node <path>/dist/bin.js").
25
+ // When codex-review cannot review at all (Codex not installed, `codex exec` failed such as on a usage limit, timed out,
26
+ // or printed nothing parseable), a Claude adversary on adversary.fallback ({ model, effort }, default opus / high)
27
+ // reviews the change instead, so a missing Codex does not block the plan. fallback: false keeps the strict rule: a
28
+ // configured review that did not run blocks. For a review that ran, only a "fail" verdict or a blocker issue blocks.
29
+ const ADVERSARY_FALLBACK_MODEL = 'opus'
30
+ const ADVERSARY_FALLBACK_EFFORT = 'high'
31
+ // adversary.fallback resolved: false, or { model, effort } where "inherit" means the session model / the agent's own default effort.
32
+ function resolveFallback(cfg) {
33
+ if (cfg === false) return false
34
+ const o = cfg && typeof cfg === 'object' ? cfg : {}
35
+ const model = o.model === 'inherit' ? undefined : o.model || ADVERSARY_FALLBACK_MODEL
36
+ const effort = o.effort === 'inherit' ? undefined : o.effort || ADVERSARY_FALLBACK_EFFORT
37
+ return { model, effort }
38
+ }
39
+ const adversaryConfig = plan.adversary && typeof plan.adversary === 'object' ? plan.adversary : {}
40
+ // A crew with two adversaries (card crew-sizing) seats the Claude adversary next to Codex: a different model, on the
41
+ // fallback row when the plan names one, on the fallback defaults otherwise (fallback: false only says a Codex that
42
+ // cannot run blocks; it does not choose the second seat's model).
43
+ const adversary = plan.adversary === false ? null : { command: 'codex-review', timeoutMs: 900000, ...adversaryConfig, fallback: resolveFallback(adversaryConfig.fallback), crewModel: resolveFallback(adversaryConfig.fallback === false ? {} : adversaryConfig.fallback) }
44
+ // How many times a blocked task may relaunch its implementer in the same worktree before the level stops. 0 disables the loop.
45
+ const fixAttempts = Number.isInteger(plan.fixAttempts) && plan.fixAttempts >= 0 ? plan.fixAttempts : 5
46
+ // Per-task ceilings for the whole loop, so a task that will not converge stops cheaply instead of spending a run on
47
+ // one card. Agents are counted here and always enforced; tokens and wall time come from the runtime's budget
48
+ // primitive and are enforced only when it reports them. plan.budget overrides any of the three. Keep these three
49
+ // numbers equal to lib/plan.mjs's DEFAULT_BUDGET.
50
+ const DEFAULT_BUDGET = { agents: 12, tokens: 400000, wallMinutes: 40 }
51
+ const taskBudget = { ...DEFAULT_BUDGET, ...(plan.budget && typeof plan.budget === 'object' ? plan.budget : {}) }
52
+ // The phrase the verifier and reviewer put at the start of a finding when the spec cannot satisfy the plan's
53
+ // acceptance criteria. Such a block goes back to the planner and is never retried.
54
+ const CONTRADICTION_MARKER = 'SPEC CONTRADICTS ACCEPTANCE'
55
+ // The phrase a verifier, checker, or size-check puts at the start of a finding when a command could not run for
56
+ // an environment reason (a tool missing from the worktree even after the install step, a sandbox denial) rather
57
+ // than a defect in the diff. The fix loop never retries such a finding (card reused-s-task-worktree-install):
58
+ // retriable() stops on it the same way it stops on CONTRADICTION_MARKER.
59
+ const ENVIRONMENT_MARKER = 'ENVIRONMENT ONLY'
60
+ // The one rule about commit messages, in every prompt that commits or checks commits: this repository records
61
+ // attribution nowhere in git, and plan.mjs land refuses a branch that carries either trailer.
62
+ 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.'
63
+ // The one rule about a partial result, in every prompt a worker (or a resumed worker) can end with one from
64
+ // (card worker-context-handoff): quotes the harness's context notice (a PostToolUse hook in plugins/doug-gates) so
65
+ // a worker knows partial=true is only for a worker the harness itself told to stop, never a first resort, and only
66
+ // at a boundary, with a handoff. Keep this identical everywhere it appears; the template tests assert it.
67
+ const PARTIAL_RULE = 'Return partial=true only after a hook told you "[doug] Worker context at <pct>% of <window> tokens": stop at a boundary (finish the file you are on and its named test, then commit), then return partial=true with a handoff (completed, remaining, next, verify). Do not start another file. Without that notice, partial=true is refused at your stop.'
68
+ // The one sentence about a partial branch, in the two prompts that merge or integrate branches: a partial is
69
+ // graceful degradation, never a finished result, so it is never the thing that lands.
70
+ const PARTIAL_NEVER_MERGED = 'A branch whose worker or task result was partial is never merged; if you find one in the list below, stop and report it as a block.'
71
+
72
+ // Pipeline shape by task size (decision 0005 point 3). An S task runs implement, then one focused check on the verify
73
+ // row that does the verifier's and the reviewer's work, and no adversary of its own; the adversary reviews a level's S
74
+ // tasks together, on the integration branch, after the merge, and a blocker there goes back to the task that owns the
75
+ // file as a fix pass. M, L, and an unsized task keep the full shape.
76
+ function taskShape(task) {
77
+ return task && task.size === 'S' ? 'S' : 'full'
78
+ }
79
+ function adversaryRequired(task) {
80
+ return !!adversary && taskShape(task) !== 'S'
81
+ }
82
+ const INTEGRATION_WORKTREE = '.claude/worktrees/doug-integration'
83
+
84
+ // What an adversarial reviewer may call a blocker. The same text is in the two adversary agent files and in
85
+ // codex-review's own prompt; change all four together.
86
+ const BLOCKER_GATE = [
87
+ 'What counts as a blocker: a blocker must demonstrate either a failure of a spec sentence or acceptance criterion, which you quote, or a violation of a repository invariant: safety, security, data integrity, public compatibility, or required verification (a verify or acceptance command that exits non-zero).',
88
+ 'It must be caused by this diff, on a supported or reasonably foreseeable input, and carry a reproduction: a command you ran (an entry in commandsRun) whose exit code or quoted output shows the failure; a finding from static inspection alone, with no command that demonstrates it, is major at most.',
89
+ 'A test-coverage gap against the spec (a requirement without a test, an assertion missing) is major and never a blocker: the reviewer owns spec compliance, so say so in the issue.',
90
+ 'An input the spec names as unsupported, or a pathological input no supported caller produces, is at most minor.',
91
+ 'This is not a downgrade of destructive behavior: a change that deletes, overwrites, or corrupts data the spec did not name is a blocker under data integrity even when no spec sentence forbids it.',
92
+ 'A `fail` verdict requires at least one `blocker` issue: an issue that is only `major` or `minor` is reported with a `pass` verdict, and a review whose findings are all advisory passes with notes.',
93
+ ]
94
+
95
+ // Normalizes plan.acceptance (prose strings or { text, command } objects) into { text, command } pairs,
96
+ // command null when the entry carries none. Mirrors lib/plan.mjs's export; inlined because the workflow cannot import it.
97
+ function acceptanceEntries(list) {
98
+ return Array.isArray(list) ? list.map(a => (typeof a === 'string' ? { text: a, command: null } : a && typeof a === 'object' ? { text: String(a.text), command: typeof a.command === 'string' && a.command.trim() ? a.command : null } : { text: String(a), command: null })) : []
99
+ }
100
+
101
+ // ---- Model tiers -------------------------------------------------------------
102
+ // `plan.mjs json` resolves the CLAUDE.md Models table into plan.models.roles and each task's model/effort.
103
+ // "inherit" means: model = the session model, effort = this workflow's default for the role.
104
+ const DEFAULT_EFFORT = { lead: undefined, worker: undefined, implement: undefined, verify: 'high', review: 'high', adversary: 'low', integrate: 'high', supervise: 'low' }
105
+ const roleModels = (plan.models && plan.models.roles) || {}
106
+ // The adversary row may name "codex": the review runs on Codex (plan.adversary.command) and the Claude agent
107
+ // that launches it and relays the verdict is only a relay, so it runs on haiku. `relay` is that agent's model.
108
+ const ADVERSARY_RELAY_MODEL = 'haiku'
109
+ // Checking out a reused branch is mechanical, so it runs on the same small model as the adversary relay.
110
+ const CHECKOUT_MODEL = ADVERSARY_RELAY_MODEL
111
+ // The fix-loop supervisor (card fix-loop-supervisor) runs on the `cheap` tier of the Models table; a table without
112
+ // one puts it on the relay model, since reading a task's passes and writing a brief is small work.
113
+ const SUPERVISOR_FALLBACK_MODEL = ADVERSARY_RELAY_MODEL
114
+ function tierFor(role, task) {
115
+ const src = role === 'implement' && task
116
+ ? { model: task.model, effort: task.effort }
117
+ : role === 'supervise'
118
+ ? (plan.models && plan.models.tiers && plan.models.tiers.cheap) || { model: SUPERVISOR_FALLBACK_MODEL }
119
+ : roleModels[role] || {}
120
+ const model = src.model && src.model !== 'inherit' ? src.model : undefined
121
+ const effort = src.effort && src.effort !== 'inherit' ? src.effort : DEFAULT_EFFORT[role]
122
+ if (role === 'adversary' && model === 'codex') return { model, effort, relay: ADVERSARY_RELAY_MODEL }
123
+ return { model, effort }
124
+ }
125
+ function agentOpts(role, task, extra) {
126
+ const t = tierFor(role, task)
127
+ const opts = { ...extra }
128
+ if (t.relay) opts.model = t.relay
129
+ else if (t.model) opts.model = t.model
130
+ if (t.effort) opts.effort = t.effort
131
+ return opts
132
+ }
133
+ function describeTier(role, task) {
134
+ const t = tierFor(role, task)
135
+ const out = { model: t.model || 'inherit', effort: t.effort || 'inherit' }
136
+ if (t.relay) out.relay = t.relay
137
+ return out
138
+ }
139
+ // codex-review accepts minimal|low|medium|high|xhigh (packages/doug-codex/src/schema.ts REASONING_EFFORTS); the Models
140
+ // table also allows max, which maps to xhigh, Codex's highest documented level. A plan's own adversary.effort wins over
141
+ // the row; a value outside this map passes no --effort, so the review runs at Codex's configured default.
142
+ const CODEX_REASONING_EFFORT = { minimal: 'minimal', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'xhigh' }
143
+ if (adversary) adversary.effort = CODEX_REASONING_EFFORT[adversary.effort || tierFor('adversary').effort]
144
+
145
+ // ---- Levelization (same algorithm as lib/plan.mjs; scripts cannot import files) ----
146
+ function levelize(tasks) {
147
+ const byId = new Map(tasks.map(t => [t.id, t]))
148
+ const level = new Map()
149
+ const visiting = new Set()
150
+ function depth(id, chain) {
151
+ if (level.has(id)) return level.get(id)
152
+ if (visiting.has(id)) throw new Error(`dependency cycle: ${[...chain, id].join(' -> ')}`)
153
+ visiting.add(id)
154
+ let d = 0
155
+ for (const dep of byId.get(id).dependsOn || []) d = Math.max(d, depth(dep, [...chain, id]) + 1)
156
+ visiting.delete(id)
157
+ level.set(id, d)
158
+ return d
159
+ }
160
+ for (const t of tasks) depth(t.id, [])
161
+ const max = Math.max(...level.values())
162
+ const out = []
163
+ for (let i = 0; i <= max; i++) out.push(tasks.filter(t => level.get(t.id) === i))
164
+ return out
165
+ }
166
+
167
+ // ---- Schemas ----------------------------------------------------------------
168
+ const IMPLEMENT_SCHEMA = {
169
+ type: 'object',
170
+ required: ['taskId', 'branch', 'worktreePath', 'filesTouched', 'commandsRun', 'summary', 'blocked', 'commit'],
171
+ properties: {
172
+ taskId: { type: 'string' },
173
+ branch: { type: 'string', description: 'git branch name holding the work' },
174
+ worktreePath: { type: 'string', description: 'absolute path of the worktree' },
175
+ filesTouched: { type: 'array', items: { type: 'string' } },
176
+ commandsRun: { type: 'array', items: { type: 'object', required: ['command', 'ok'], properties: { command: { type: 'string' }, ok: { type: 'boolean' } } } },
177
+ summary: { type: 'string' },
178
+ blocked: { type: 'boolean' },
179
+ blockedReason: { type: 'string' },
180
+ commit: { type: 'string', description: 'git rev-parse HEAD in the worktree after committing' },
181
+ partial: { type: 'boolean', description: 'true when the worker stopped at a boundary rather than finishing, after the harness measured its context and told it to' },
182
+ handoff: {
183
+ type: 'object',
184
+ description: 'present when partial is true: what a fresh worker needs to pick the remaining work up',
185
+ properties: {
186
+ completed: { type: 'array', items: { type: 'string' } },
187
+ remaining: { type: 'array', items: { type: 'string' } },
188
+ next: { type: 'string' },
189
+ verify: { type: 'string' },
190
+ },
191
+ },
192
+ },
193
+ }
194
+ // The lead's split of one task into worker briefs (card swarm-lead): each brief owns a disjoint subset of the task's
195
+ // files; the workflow refuses a brief that names anything else, because the lead may split, never widen.
196
+ const LEAD_SCHEMA = {
197
+ type: 'object',
198
+ required: ['taskId', 'branch', 'worktreePath', 'briefs', 'blocked', 'splitReason'],
199
+ properties: {
200
+ taskId: { type: 'string' },
201
+ branch: { type: 'string', description: 'the task branch the lead created; the workers branch from it and the lead merges back into it' },
202
+ worktreePath: { type: 'string', description: 'absolute path of the lead worktree' },
203
+ briefs: { type: 'array', items: { type: 'object', required: ['id', 'title', 'spec', 'files'], properties: { id: { type: 'string' }, title: { type: 'string' }, spec: { type: 'string' }, files: { type: 'array', items: { type: 'string' } }, verify: { type: ['string', 'null'] } } } },
204
+ blocked: { type: 'boolean' },
205
+ blockedReason: { type: 'string' },
206
+ // Card swarm-topology (candidate 2): why the task split the way it did, or why one brief is right when it did
207
+ // not split. Required so the report can tell "unsplittable" from "did not try".
208
+ splitReason: { type: 'string', description: 'why the task split into these briefs, or why one brief is right when it did not split' },
209
+ // Card swarm-topology (candidate 3): the names, signatures, and files each brief exports or consumes, written
210
+ // once by the lead at split time; optional because a single-brief task has nothing to cross-reference.
211
+ interfaces: { type: 'string', description: 'the interfaces each brief exports or consumes, for every worker prompt' },
212
+ },
213
+ }
214
+ const VERIFY_SCHEMA = {
215
+ type: 'object',
216
+ required: ['taskId', 'passed', 'commandsRun', 'findings', 'acceptance', 'head'],
217
+ properties: {
218
+ taskId: { type: 'string' },
219
+ passed: { type: 'boolean' },
220
+ commandsRun: { type: 'array', items: { type: 'object', required: ['command', 'ok'], properties: { command: { type: 'string' }, ok: { type: 'boolean' }, outputTail: { type: 'string' } } } },
221
+ findings: { type: 'array', items: { type: 'string' } },
222
+ findingFiles: { type: 'array', description: 'one entry per finding that fails the task and names a file: the finding text and the worktree-relative paths of existing repository files it concerns; never a fixture, scratch, or temp path; [] when none', items: { type: 'object', required: ['finding', 'files'], properties: { finding: { type: 'string' }, files: { type: 'array', items: { type: 'string' } } } } },
223
+ 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'] } } } },
224
+ head: { type: 'string', description: 'the full 40-character output of git rev-parse HEAD in the worktree on the task branch, read before anything else runs' },
225
+ },
226
+ }
227
+ const REVIEW_SCHEMA = {
228
+ type: 'object',
229
+ required: ['taskId', 'specCompliant', 'inScope', 'approve', 'issues'],
230
+ properties: {
231
+ taskId: { type: 'string' },
232
+ specCompliant: { type: 'boolean' },
233
+ inScope: { type: 'boolean', description: 'every changed file is in the task\'s owned files' },
234
+ approve: { type: 'boolean' },
235
+ issues: { type: 'array', items: { type: 'object', required: ['severity', 'file', 'description'], properties: { severity: { type: 'string', enum: ['blocker', 'major', 'minor'] }, file: { type: 'string' }, description: { type: 'string' } } } },
236
+ },
237
+ }
238
+ // The one focused check that stands in for the verifier and the reviewer on a fix pass: it runs the commands and
239
+ // reviews only the fix delta, so its result carries both a verifier's and a reviewer's fields.
240
+ const CHECK_SCHEMA = {
241
+ type: 'object',
242
+ required: ['taskId', 'passed', 'commandsRun', 'acceptance', 'findings', 'issues', 'inScope', 'head'],
243
+ properties: {
244
+ taskId: { type: 'string' },
245
+ passed: { type: 'boolean' },
246
+ commandsRun: { type: 'array', items: { type: 'object', required: ['command', 'ok'], properties: { command: { type: 'string' }, ok: { type: 'boolean' }, outputTail: { type: 'string' } } } },
247
+ 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'] } } } },
248
+ findings: { type: 'array', items: { type: 'string' } },
249
+ findingFiles: { type: 'array', description: 'one entry per finding that fails the task and names a file: the finding text and the worktree-relative paths of existing repository files it concerns; never a fixture, scratch, or temp path; [] when none', items: { type: 'object', required: ['finding', 'files'], properties: { finding: { type: 'string' }, files: { type: 'array', items: { type: 'string' } } } } },
250
+ issues: { type: 'array', items: { type: 'object', required: ['severity', 'file', 'description'], properties: { severity: { type: 'string', enum: ['blocker', 'major', 'minor'] }, file: { type: 'string' }, line: { type: ['integer', 'null'] }, description: { type: 'string' }, evidence: { type: ['string', 'null'] } } } },
251
+ inScope: { type: 'boolean', description: 'every file the fix delta changed is in the task\'s owned files' },
252
+ head: { type: 'string', description: 'the full 40-character output of git rev-parse HEAD in the worktree on the task branch, read before anything else runs' },
253
+ },
254
+ }
255
+ // The supervisor's brief for a stalled fix loop. No verdict field: the supervisor cannot pass or fail anything.
256
+ const SUPERVISE_SCHEMA = {
257
+ type: 'object',
258
+ required: ['taskId', 'brief', 'directions'],
259
+ properties: {
260
+ taskId: { type: 'string' },
261
+ brief: { type: 'string', description: 'what the passes tried and what failed each time, from the evidence given; a few sentences' },
262
+ directions: { type: 'array', minItems: 2, maxItems: 3, items: { type: 'string' }, description: 'two or three alternative directions for the next fix pass, each different from what the passes tried' },
263
+ },
264
+ }
265
+ const ADVERSARY_SCHEMA = {
266
+ type: 'object',
267
+ required: ['taskId', 'ran', 'verdict', 'summary', 'issues', 'commandsRun', 'error'],
268
+ properties: {
269
+ taskId: { type: 'string' },
270
+ ran: { type: 'boolean', description: 'true only if codex-review printed parseable JSON, or the fallback adversary reviewed the change itself' },
271
+ verdict: { type: 'string', enum: ['pass', 'fail', 'inconclusive'] },
272
+ summary: { type: 'string' },
273
+ issues: { type: 'array', items: { type: 'object', required: ['severity', 'file', 'description'], properties: { severity: { type: 'string', enum: ['blocker', 'major', 'minor'] }, file: { type: 'string' }, line: { type: ['integer', 'null'] }, description: { type: 'string' }, evidence: { type: ['string', 'null'] } } } },
274
+ commandsRun: { type: 'array', items: { type: 'object', required: ['command', 'ok'], properties: { command: { type: 'string' }, ok: { type: 'boolean' }, exitCode: { type: ['integer', 'null'] }, outputTail: { type: 'string', description: 'the last lines of the command output, so a blocker can quote one' } } } },
275
+ error: { type: ['string', 'null'], description: 'error.kind and message from codex-review when the review could not run; null otherwise' },
276
+ usage: { type: ['object', 'null'], properties: { inputTokens: { type: 'integer' }, outputTokens: { type: 'integer' } }, description: 'ReviewResult.usage copied from codex-review; null when it printed none or the fallback reviewed' },
277
+ durationMs: { type: ['integer', 'null'], description: 'ReviewResult.durationMs copied from codex-review; null otherwise' },
278
+ },
279
+ }
280
+ const INTEGRATE_SCHEMA = {
281
+ type: 'object',
282
+ required: ['branch', 'merged', 'conflicts', 'verify', 'ok'],
283
+ properties: {
284
+ branch: { type: 'string' },
285
+ merged: { type: 'array', items: { type: 'string' } },
286
+ 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' } } } },
288
+ ok: { type: 'boolean' },
289
+ // Card integration-acceptance-recorded: only the last level's integrate stage fills this in (step 3b of
290
+ // integratePrompt); not required, since an earlier level has not built the whole plan yet.
291
+ 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'] } } } },
292
+ },
293
+ }
294
+
295
+ // Card integration-acceptance-recorded (round 2): routes an integrate stage's raw result through the same
296
+ // acceptance rule the verifier applies per task, but only on the last level (earlier levels have not built the
297
+ // whole plan yet). Matching is by trimmed command string, not object identity or spelling, since an agent may
298
+ // legitimately report a command behind a cd or git -C prefix; the result's `command` is always the plan's own
299
+ // spelling (its `text` is whatever the matched item reported). A reported item that matches no plan command
300
+ // (empty, prose, or unrecognised) is dropped rather than trusted or blamed. A plan command with no matching
301
+ // report is appended as { ..., reported: false } - unreported and failed are different causes and get different
302
+ // reason clauses. Pure: never mutates its argument, never throws on odd shapes.
303
+ function enforceIntegrationAcceptance(integration, isLastLevel, entries) {
304
+ if (!integration || typeof integration !== 'object') return integration
305
+ if (!isLastLevel) {
306
+ const rest = { ...integration }
307
+ delete rest.acceptance
308
+ return rest
309
+ }
310
+ const reported = Array.isArray(integration.acceptance) ? integration.acceptance.filter(a => a && typeof a === 'object') : []
311
+ const acceptance = []
312
+ for (const e of entries) {
313
+ if (!e.command) continue
314
+ const wantCommand = String(e.command).trim()
315
+ if (!wantCommand) continue
316
+ const match = reported.find(a => typeof a.command === 'string' && String(a.command).trim() === wantCommand)
317
+ acceptance.push(match
318
+ ? { text: match.text, command: e.command, ok: match.ok, exitCode: match.exitCode }
319
+ : { text: e.text, command: e.command, ok: false, exitCode: null, reported: false })
320
+ }
321
+ const failed = acceptance.filter(a => a.reported === undefined && a.ok !== true)
322
+ const notReported = acceptance.filter(a => a.reported === false)
323
+ const parts = []
324
+ if (failed.length) parts.push('acceptance command failed: ' + failed.map(a => `${a.text} ($ ${a.command}, exit ${a.exitCode})`).join('; '))
325
+ if (notReported.length) parts.push('acceptance command not reported: ' + notReported.map(a => `${a.text} ($ ${a.command})`).join('; '))
326
+ if (!parts.length) return { ...integration, acceptance }
327
+ return { ...integration, acceptance, ok: false, reason: parts.join('; ') }
328
+ }
329
+
330
+ // ---- Finding ledger ----------------------------------------------------------
331
+ // One list per task of every blocking finding the stages raised, with a stable id. It is what the fix agent is
332
+ // given (so a later pass cannot undo an earlier fix without noticing), what the confirmation adversary is asked to
333
+ // confirm, and how the loop decides whether a pass raised anything new.
334
+ // Card ledger-ignores-not-a-defect-notes: a checking stage's own words can say a note is not a defect, or that it
335
+ // names a file outside this task's owned list; such a note is dropped before matching, never becomes a ledger
336
+ // entry, and is recorded on the attempt instead, so it can no longer keep a converged task out of integration.
337
+ const LEDGER_CHAR_BUDGET = 8000
338
+
339
+ // Stage, file and the shape of the text, so the same complaint worded slightly differently still matches.
340
+ function fingerprint(stage, file, text) {
341
+ const path = String(file == null ? '' : file).trim().replace(/^\.\//, '')
342
+ const body = String(text == null ? '' : text)
343
+ .toLowerCase()
344
+ .replace(/:\d+/g, ' ')
345
+ .replace(/[^a-z0-9]+/g, ' ')
346
+ .trim()
347
+ .replace(/\s+/g, ' ')
348
+ .slice(0, 80)
349
+ return `${stage}|${path}|${body}`
350
+ }
351
+
352
+ // The repository invariant an adversary issue is about, from its own words.
353
+ function adversaryInvariant(text) {
354
+ const t = String(text == null ? '' : text).toLowerCase()
355
+ if (t.includes('security')) return 'security'
356
+ if (/data loss|data integrity|corrupt|destructive|delete|overwrite/.test(t)) return 'data-integrity'
357
+ if (t.includes('compatibility')) return 'compatibility'
358
+ if (t.includes('verification')) return 'verification'
359
+ return 'spec'
360
+ }
361
+
362
+ // The first repo-relative path a free-text finding names, so a verifier finding still lands on a file.
363
+ function findingFile(text) {
364
+ const m = String(text == null ? '' : text).match(/(?<![\w./-])(?:[\w.-]+\/)+[\w.-]+\.[A-Za-z0-9]+/)
365
+ return m ? m[0].replace(/^\.\//, '') : ''
366
+ }
367
+
368
+ // Every repo-relative path a free-text finding names, for the ledger's file rule (round 2 rule 2, MAJ2: the file
369
+ // rule must read every path the finding names, not just findingFile's first match). When `findingFiles` carries an
370
+ // entry for this exact finding (trimmed match) whose `files` array is non-empty, that entry's files are
371
+ // authoritative and win outright over the prose (round 3 rule 2, MIN1: an empty files array is not authoritative -
372
+ // it falls back to the prose scan below, the same as no entry at all); otherwise every slash-separated token
373
+ // ending in an extension in the text, leading './' stripped, deduped.
374
+ function findingTextPaths(text, findingFilesList) {
375
+ const trimmed = String(text == null ? '' : text).trim()
376
+ const listed = (findingFilesList || []).find(e => e.finding.trim() === trimmed)
377
+ if (listed && listed.files.length) return listed.files.map(p => String(p).trim().replace(/^\.\//, '')).filter(Boolean)
378
+ const seen = new Set()
379
+ for (const m of String(text == null ? '' : text).matchAll(/(?<![\w./-])(?:[\w.-]+\/)+[\w.-]+\.[A-Za-z0-9]+/g)) seen.add(m[0].replace(/^\.\//, ''))
380
+ return [...seen]
381
+ }
382
+
383
+ // What one stage reported on a pass, or null when that stage produced nothing to read. `blocking` marks the
384
+ // findings that keep the task out of integration; the rest are recorded only so a re-report can reopen a fixed entry.
385
+ // 'level-adversary' is the level's review of a size-S task, read and recorded as the adversary stage.
386
+ function stageFindings(r, stageKey) {
387
+ const stage = stageKey === 'level-adversary' ? 'adversary' : stageKey
388
+ const res = (r.stageResults || {})[stageKey]
389
+ if (!res) return null
390
+ const out = []
391
+ if (stage === 'verify' || stage === 'check') {
392
+ // The check stands in for both the verifier and the reviewer, so it blocks on everything the projected pair
393
+ // blocks on: a failed command run, a file outside the owned list, or a blocker issue. Reading only `passed`
394
+ // would drop a blocker issue raised on an otherwise passing run out of the ledger, and the next fix pass
395
+ // would never be told about the very thing that blocked it.
396
+ const blocking = stage === 'check'
397
+ ? !res.passed || res.inScope === false || (res.issues || []).some(i => i.severity === 'blocker')
398
+ : !res.passed
399
+ const findingFilesList = Array.isArray(res.findingFiles) ? res.findingFiles.filter(e => e && typeof e.finding === 'string' && Array.isArray(e.files)) : []
400
+ for (const f of res.findings || []) {
401
+ if (typeof f !== 'string') continue
402
+ // source 'finding' (card ledger-check-notes-as-blockers): marks a check's free-text finding, as opposed to a
403
+ // check `issues` entry below (which carries no source), so droppedWhy can gate only the former on failure wording.
404
+ out.push({ stage, blocking, severity: 'blocker', invariant: 'verification', file: findingFile(f), line: null, description: f, evidence: null, confirms: !blocking, paths: findingTextPaths(f, findingFilesList), source: 'finding' })
405
+ }
406
+ if (stage === 'check') {
407
+ // Round 3 rule 2, MIN2: the check stands in for the reviewer, so a check issue gets invariant 'scope' when
408
+ // the check itself read the run as out of scope, the same mark a reviewer's own scope finding carries.
409
+ const checkInvariant = res.inScope === false ? 'scope' : 'verification'
410
+ for (const i of res.issues || []) out.push({ stage, blocking: blocking && i.severity === 'blocker', severity: i.severity, invariant: checkInvariant, file: i.file || '', line: i.line || null, description: i.description, evidence: i.evidence || null, paths: i.file ? [i.file] : [] })
411
+ }
412
+ return out
413
+ }
414
+ if (stage === 'review') {
415
+ const blocking = !res.approve
416
+ const invariant = res.specCompliant === false ? 'spec' : res.inScope === false ? 'scope' : 'quality'
417
+ for (const i of res.issues || []) out.push({ stage, blocking, severity: i.severity, invariant, file: i.file || '', line: i.line || null, description: i.description, evidence: i.evidence || null, paths: i.file ? [i.file] : [] })
418
+ return out
419
+ }
420
+ if (stage === 'adversary') {
421
+ if (!res.ran) return null
422
+ const blockers = (res.issues || []).filter(i => i.severity === 'blocker')
423
+ // A fail verdict blocks only with a blocker issue, or with no issue at all (card fix-loop-minor-verdict).
424
+ // Before this, a fail with only major or minor issues still synthesized a blocking finding from the summary
425
+ // below (the same synthesis the empty-issues case still uses), so the fix pass was briefed with one
426
+ // un-actionable prose finding naming no file or line, not zero findings; it could not act on it, made no
427
+ // commit, and the task never converged. Such a fail is pass-with-notes instead and does not block.
428
+ const blocked = blockers.length > 0 || (res.verdict === 'fail' && !(res.issues || []).length)
429
+ for (const i of res.issues || []) out.push({ stage, blocking: blocked && i.severity === 'blocker', severity: i.severity, invariant: adversaryInvariant(i.description), file: i.file || '', line: i.line || null, description: i.description, evidence: i.evidence || null })
430
+ if (blocked && !blockers.length) out.push({ stage, blocking: true, severity: 'blocker', invariant: adversaryInvariant(res.summary), file: '', line: null, description: res.summary, evidence: null })
431
+ return out
432
+ }
433
+ return null
434
+ }
435
+
436
+ // An explicit id prefix wins over the fingerprint: that is how a stage re-reports another stage's finding.
437
+ function matchFinding(ledger, f) {
438
+ const m = /^(F\d+)\b/.exec(String(f.description == null ? '' : f.description).trim())
439
+ if (m) {
440
+ const byId = ledger.find(e => e.id === m[1])
441
+ if (byId) return byId
442
+ }
443
+ const fp = fingerprint(f.stage, f.file, f.description)
444
+ return ledger.find(e => e.fingerprint === fp) || null
445
+ }
446
+
447
+ // What a finding that starts with a ledger id says about it, read from its first clause: 'fixed' ("F3 fixed: ...",
448
+ // "F3 is FIXED.", "F3: no longer returns 1"), 'waived', 'open' ("F3: still returns 1", "F3 is not fixed"), or null
449
+ // when the text names the id without saying any of those. A confirmation is never a re-report; without this, a
450
+ // checking stage that confirms a fix in the words the prompts ask for reopens the very entry it confirmed.
451
+ //
452
+ // 'waived' (round 2, R2) is read after stripping, from the start of the clause, any leading ':'/','/'-'/whitespace,
453
+ // then an optional copula ('is still', 'is', 'was', 'remains', 'stays', or bare 'still'), then an optional article
454
+ // ('a'/'an'). What remains must then itself START with one of: 'waived', 'advisory', 'non-blocking' (hyphen or
455
+ // space optional, or none: 'non blocking'/'nonblocking'), 'not counted open', or 'unchanged (' - the last only when
456
+ // the clause also names 'non-blocking' or 'nit' somewhere else (the real string with 'waived' swapped for
457
+ // 'unchanged'), so a bare, unrelated "unchanged (...)" note never reads as a waiver. Only that exact start counts:
458
+ // "F2 was wrongly waived: ..." and "F2: the implementer waived this, but I disagree - ..." say the word only
459
+ // mid-clause and never reach this branch (mutation MR2a: dropping the anchor and matching 'waived' anywhere would
460
+ // misread both as waived, and a live blocker would integrate). A negation of 'waived' itself ('is not waived',
461
+ // 'cannot be waived', 'should not be waived', 'not waived') needs no separate handling: 'not'/'cannot'/'should' are
462
+ // none of the copulas this strips, so the remainder never begins with a recognised word and the clause falls through
463
+ // to the open-word test on its own 'not'.
464
+ //
465
+ // Card d-waiver-structural-rule: idReport reads 'waived' from the clause's own wording alone and is otherwise a pure
466
+ // text reader - it does not know the ledger, so it cannot itself tell a disputed waiver from an honoured one. A
467
+ // wording veto (mutation MR2c) once cancelled the waived reading back to 'open' when the clause also contained
468
+ // 'but', 'wrongly', 'disagree', or 'now required'; the reviewer then found 12 of 12 other disputing phrasings
469
+ // ("F2 waived incorrectly: still returns 1.") that veto never covered. Whether a 'waived' verdict is honoured is now
470
+ // decided structurally, against the ledger entry's own recorded description (confirmsFinding below), never by any
471
+ // word idReport can see.
472
+ function idReport(text) {
473
+ const m = /^\s*(F\d+)\b(.*)$/s.exec(String(text == null ? '' : text))
474
+ if (!m) return null
475
+ // A sentence ends at a period followed by whitespace or the end, so a file name like agents.ts does not end it.
476
+ const clause = m[2].split(/[;\n]|\.(?=\s|$)/)[0].toLowerCase()
477
+ const rest = clause.replace(/^[\s:,-]*(?:(?:is\s+still|is|was|remains|stays|still)\s+)?(?:(?:a|an)\s+)?/, '')
478
+ const waivedStart = /^(?:waived\b|advisory\b|non[\s-]?blocking\b|not\s+counted\s+open\b|unchanged\s*\()/
479
+ const unchangedMissingContext = /^unchanged\s*\(/.test(rest) && !/non[\s-]?blocking|\bnit\b/.test(clause)
480
+ if (waivedStart.test(rest) && !unchangedMissingContext) return { id: m[1], verdict: 'waived' }
481
+ const open = /\b(not|still|unfixed|partially|remains|regress\w*|reappear\w*|broken|fails?|isn't|wasn't|hasn't)\b/
482
+ const done = /\b(fixed|resolved|addressed|no longer)\b/
483
+ const at = clause.search(done)
484
+ if (at >= 0 && !open.test(clause.slice(0, at))) return { id: m[1], verdict: 'fixed' }
485
+ if (open.test(clause)) return { id: m[1], verdict: 'open' }
486
+ return { id: m[1], verdict: null }
487
+ }
488
+
489
+ // A stage confirms a finding when it says so, when it passed and its free-text findings name the id without saying
490
+ // the finding is still open, when the finding's own text says 'fixed', or when it says 'waived' AND the ledger's own
491
+ // recorded entry for that id is itself a self-declared non-blocking note (card d-waiver-structural-rule:
492
+ // selfDeclaredNonBlocking, checked against the entry's own description, never the finding's own wording). Any other
493
+ // waived claim - the entry blocking, the entry missing, or no ledger passed at all - does not confirm: it is a
494
+ // re-report exactly like an 'open' verdict, so it can reopen a fixed entry or hold an open one open, whatever the
495
+ // reporting finding's own `confirms`/`blocking` flags say. Only a re-report can reopen or keep an entry.
496
+ function confirmsFinding(f, ledger) {
497
+ const r = idReport(f.description)
498
+ if (r && r.verdict === 'fixed') return true
499
+ if (r && r.verdict === 'waived') {
500
+ const entry = Array.isArray(ledger) ? ledger.find(e => e.id === r.id) : null
501
+ return !!entry && selfDeclaredNonBlocking(entry.description)
502
+ }
503
+ return !!f.confirms && !(r && r.verdict === 'open')
504
+ }
505
+
506
+ // Two paths name the same file when they are equal, when one is the other shortened from the left (a verifier
507
+ // writes lib/secret-rules.mjs for plugins/doug-gates/lib/secret-rules.mjs), or when the first is a directory the
508
+ // second is under. Copied verbatim from samePath (workflow line ~1447): the ledger section is evaluated on its
509
+ // own by ledgerModule() and cannot import it.
510
+ function ledgerSamePath(owned, p) {
511
+ return owned === p || owned.endsWith('/' + p) || p.endsWith('/' + owned) || p.startsWith(owned + '/')
512
+ }
513
+
514
+ // True when p (leading './' stripped) is one of task's owned files, by ledgerSamePath.
515
+ function pathOwned(p, task) {
516
+ if (!task || !Array.isArray(task.files)) return false
517
+ return task.files.some(owned => ledgerSamePath(String(owned).trim().replace(/^\.\//, ''), p))
518
+ }
519
+
520
+ // True when p is owned, or under the two directories every task may always touch.
521
+ function pathProtected(p, task) {
522
+ return p.startsWith('.doug/') || p.startsWith('.claude/') || pathOwned(p, task)
523
+ }
524
+
525
+ // The failure words a text-rule match must be checked against (round 3 rule 5 guard): idReport's own `open` word
526
+ // list, reused, plus 'never' (idReport never needed it - a re-report never says a fix "never" held - but a fresh
527
+ // criterion-failure or waits-on report often does: "the process never exits.").
528
+ const FAILURE_WORD = /\b(not|still|unfixed|partially|remains|regress\w*|reappear\w*|broken|fails?|isn't|wasn't|hasn't|never)\b/i
529
+
530
+ // Card ledger-check-notes-as-blockers (run wf_2f62b9d5-3b5, task release-script pass 2): the failure words a
531
+ // check-stage `finding` (never an `issues` entry - those open on severity alone) must carry to open a blocker at
532
+ // all. Two real, positive check notes ('Fix delta ... touches only tests/release.test.mjs, which is owned' and
533
+ // 'Nothing is published: no npm publish or pnpm publish invocation ...') carried none of these and still opened
534
+ // phantom blockers F3/F4, stopping the run after two of five fix attempts. FAILURE_WORD above plus the failure
535
+ // vocabulary a real check report uses that list lacks: failed/failing/failure, throws/threw/throw, error(s),
536
+ // exit(s|ed) (a non-zero exit is always reported as a number after this word), non-zero/nonzero, missing, wrong,
537
+ // crash*, expected (as in "Expected: a problem line, never a throw."), return(s|ed) (a check reporting a wrong
538
+ // return value, "src/x.ts returns 1" with no other qualifier, is a bare defect report, not a confirmation - the
539
+ // pre-existing size-S loop-mechanics fixture at that exact text). Round 2 (reviewer: real check failure reports
540
+ // without a listed word were being dropped) widens it again with: doesn't/don't/didn't/can't/cannot/won't, lacks/
541
+ // lacking, unmet, hangs/hung, dead. Deliberately NOT added: 'no', 'red', or 'trailer' - T2's f6 ("carries no
542
+ // attribution trailer") and TA's own dropped notes ("No assertions were removed", "Nothing is published: no npm
543
+ // publish...") are genuine confirmations that must stay dropped as check-notes, and any of those three words would
544
+ // wrongly keep them open. A separate regex, not an extension of FAILURE_WORD itself: idReport and
545
+ // hasUnguardedMatch's own guard read FAILURE_WORD and must keep its narrower, re-report-tuned list unchanged.
546
+ const CHECK_NOTE_FAILURE_WORD = /\b(not|still|unfixed|partially|remains|regress\w*|reappear\w*|broken|fails?|isn't|wasn't|hasn't|never|failed|failing|failure|throws?|threw|errors?|exit(?:s|ed)?|non-?zero|missing|wrong|crash\w*|expected|returns?|returned|doesn't|don't|didn't|can't|cannot|won't|lacks?|lacking|unmet|hangs?|hung|dead)\b/i
547
+
548
+ // The sentence containing text[atIndex], bounded the way idReport bounds a clause: ';', a newline, or a period
549
+ // followed by whitespace or the end. Used by the round 3 failure-word guard, which reads the whole sentence a
550
+ // candidate match sits in, not just the text up to the match, so a failure word appearing after the match (a
551
+ // waits-on clause followed by "...and the process never exits.") still guards it.
552
+ function sentenceContaining(text, atIndex) {
553
+ const BOUNDARY = /[;\n]|\.(?=\s|$)/g
554
+ let start = 0
555
+ let m
556
+ while ((m = BOUNDARY.exec(text))) {
557
+ if (atIndex <= m.index) return text.slice(start, m.index)
558
+ start = m.index + m[0].length
559
+ }
560
+ return text.slice(start)
561
+ }
562
+
563
+ // The verify/check prompts only ask a stage to name the criterion in a finding (2b: "a finding that names the
564
+ // criterion"), not to quote it - the three 2026-09-18 run fixtures just happen to show stages single-quoting the
565
+ // criterion name in a verdict ("Acceptance criterion 'X' holds: ..."), a stage habit this blanking relies on, not a
566
+ // prompt contract. A failure word that happens to sit inside that quoted name (a criterion about something that
567
+ // "never" happens) describes the criterion, not the stage's verdict on it, and must not guard a drop against the
568
+ // stage's own "holds:" (wf_9fec46f1 F3: 'Every step ... states both what it writes and what it never does'). Only
569
+ // the single-quoted span immediately after the word 'criterion' is blanked, so a contraction like "isn't" elsewhere
570
+ // in the sentence is untouched; a criterion name in double quotes or with none at all is not blanked, so a failure
571
+ // word inside it still guards the match and the note is kept - the safe direction when the habit does not hold.
572
+ function stripCriterionQuote(text) {
573
+ return text.replace(/(\bcriterion\b[^'\n]{0,60})'[^'\n]*'/gi, "$1''")
574
+ }
575
+
576
+ // True when some match of `regex` in `text` sits in a sentence carrying no FAILURE_WORD outside a quoted criterion
577
+ // name - the case rule 5b/5c drops on. A match whose sentence does carry one (a criterion FAILURE report that also
578
+ // says 'holds', a real waits-on report that also says the process 'never' exits) is guarded and never counted here.
579
+ function hasUnguardedMatch(text, regex) {
580
+ const g = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : regex.flags + 'g')
581
+ let m
582
+ while ((m = g.exec(text))) {
583
+ if (!FAILURE_WORD.test(stripCriterionQuote(sentenceContaining(text, m.index)))) return true
584
+ if (m.index === g.lastIndex) g.lastIndex += 1
585
+ }
586
+ return false
587
+ }
588
+
589
+ // True when text itself opens by declaring the issue non-blocking, in one of the shapes droppedWhy recognises: a
590
+ // leading "Non-blocking nit/note/observation/comment/remark", "Non-blocking: ..." or "Non-blocking - ...", "Minor,
591
+ // non-blocking nit: ...", "Nit (non-blocking): ...", or "[non-blocking] ..." (hyphen, space, or nothing:
592
+ // "non-blocking", "non blocking", "nonblocking"). Lifted out of droppedWhy (card d-waiver-structural-rule) so
593
+ // confirmsFinding can also check a ledger entry's own recorded description against the same rule, structurally,
594
+ // instead of the word-list veto idReport used to carry.
595
+ function selfDeclaredNonBlocking(text) {
596
+ const NB = 'non[\\s-]?blocking'
597
+ const NON_BLOCKING = new RegExp(
598
+ `^(?:(?:minor|note|nit)\\s*[:,]\\s*)?(?:${NB}\\s+(?:nit|note|observation|comment|remark)\\b|${NB}(?::|\\s-\\s))` +
599
+ `|^(?:nit|note|minor)\\s*\\(\\s*${NB}\\s*\\)\\s*:` +
600
+ `|^\\[\\s*${NB}\\s*\\]`,
601
+ 'i',
602
+ )
603
+ return NON_BLOCKING.test(String(text == null ? '' : text))
604
+ }
605
+
606
+ // Card ledger-ignores-not-a-defect-notes, round 3: the reason a candidate finding is a note, not a defect, or null
607
+ // to keep it. Only verify, check and review findings are candidates at all - an adversary blocker is never dropped,
608
+ // by either rule, since the card is about the verifier's and checker's own notes (round 2 rule 3). A finding that
609
+ // starts with a ledger id (idReport non-null, whatever the verdict - open, fixed, waived, or null, as in "F1: the
610
+ // deadlock holds.") is exempt from every rule below: it is a re-report or a confirmation of an entry that already
611
+ // exists, and dropping it would confirm that entry by absence (round 2 rule 4, MAJ1). Otherwise the non-blocking
612
+ // rule runs first: the finding is dropped when its text LEADS with a self-declared non-blocking label, not merely
613
+ // when it starts with the bare adjective (round 2, R1) - "non-blocking"/"non blocking"/"nonblocking" (hyphen
614
+ // optional), case-insensitive, in one of five shapes: (a) the phrase followed by nit/note/observation/comment/remark
615
+ // ("Non-blocking nit (no fix required...): ...", "Non-blocking nit: ..."); (b) the phrase directly followed by ':'
616
+ // or ' - ' (round 3: not ',' or '(' - "Non-blocking, buffered writes lose the last chunk when the stream closes."
617
+ // and "Non-blocking (async) reads return 0 bytes at EOF, truncating the file." are real defects a comma or a bare
618
+ // paren would have wrongly dropped; "Non-blocking: the comment is stale" still drops on ':'); (c) a Minor/Note/Nit
619
+ // label (':' or ',') ahead of shape a or b ("Minor, non-blocking nit: ...", "Note: non-blocking: ..."); (d)
620
+ // Nit/Note/Minor '(' the phrase ')' ':' ("Nit (non-blocking): ..."); (e) '[' the phrase ']' at the very start
621
+ // ("[non-blocking] the note above is cosmetic."). Dropped even from a failed stage, because it is the stage's own
622
+ // report that its finding does not need to hold up integration, not a criterion result the ledger should ever gate
623
+ // readiness on. Anchored at the very start, and requiring one of those shapes rather than the bare word, so a real
624
+ // defect whose subject merely starts with "Non-blocking" still opens: "Non-blocking reads return 0 bytes and the
625
+ // caller treats that as EOF, truncating the file.", "Non-blocking IO path drops the error when the socket closes
626
+ // early.", and "Non-blocking in name only: this crashes on an empty array." (the colon here is not adjacent to the
627
+ // phrase, so shape b does not fire) all stay real, as does a real defect that merely contains the words mid-sentence
628
+ // ("The non-blocking path in fetch() still drops the error.", mutation M1b) or after a label the rule does not
629
+ // recognise ("BLOCKER: ... the earlier non-blocking nit is now a crash."). Loosening the anchor to the bare
630
+ // adjective (mutation MR1) reopens all three "stays real" examples above; dropping shapes d/e (mutation MR1d) stops
631
+ // their own two examples from dropping; restoring ',' in shape b (round 3 mutation) reopens the comma example
632
+ // above. Then the existing text rule runs, case-insensitively: (a) 'not a defect', 'not charged against this task',
633
+ // 'do(es) not fail this task' anywhere in the text, ungated - "they wait on the onboarding-docs task and do not fail this task"
634
+ // drops on (a) alone, whatever the rest of the sentence says; (b) 'holds' only as a criterion verdict - 'criterion'
635
+ // earlier in the same sentence and 'holds' followed by ':', '.', ',', ';' or the end - unless the match's own
636
+ // sentence carries a FAILURE_WORD, so "Acceptance criterion 3 (mutex released) fails: the mutex still holds." stays
637
+ // real (round 3 rule 5 guard, MAJ1); (c) 'wait(s) on (the )?<word> task' only when none of the finding's paths is
638
+ // owned and the match's sentence carries no FAILURE_WORD - "The request handler waits on the cleanup task and the
639
+ // process never exits." stays real on the 'never' alone, even though it names no path at all (round 3 rule 5 guard,
640
+ // MAJ2). The file rule then drops a finding whose `paths` (stageFindings attaches every path the finding names, not
641
+ // just the first) is non-empty and none of those paths is protected - owned by ledgerSamePath, or under
642
+ // '.doug/'/'.claude/' - unless the reviewer or the check marked it out-of-scope drift (stage 'review' or 'check'
643
+ // with invariant 'scope', the mark for inScope=false, round 3 rule 2 MIN2: the check stands in for the reviewer);
644
+ // a finding naming no path, or a run whose task carries no files array, is never outside (round 2 rule 1 samePath /
645
+ // rule 2 paths, B1/MAJ2).
646
+ //
647
+ // Praise ("Everything else I probed matches the spec: ...", the real F4 string from wf_cb484cb2-93d) is
648
+ // deliberately left out of this rule (card ledger-nonblocking-notes-hold-integration, P1): it names no
649
+ // self-declaring word at all, so covering it would need a wide heuristic (a leading "Everything else...matches the
650
+ // spec" phrase, or similar) with no anchor as narrow as "non-blocking", and a wide one risks swallowing a real
651
+ // defect phrased the same way. Left as an ordinary finding, it opens a ledger entry like any other and is closed by
652
+ // the covering-stage absence rule the next time nothing re-reports it - which is what the E2E test below exercises.
653
+ function droppedWhy(f, task) {
654
+ if (f.stage !== 'verify' && f.stage !== 'check' && f.stage !== 'review') return null
655
+ const text = String(f.description == null ? '' : f.description)
656
+ if (idReport(text)) return null
657
+ // Card ledger-check-notes-as-blockers: a check's free-text finding (source 'finding', stageFindings above -
658
+ // never a check `issues` entry, TC) that is blocking but whose text carries no CHECK_NOTE_FAILURE_WORD is the
659
+ // check confirming something holds, not reporting a defect, so it opens no ledger entry. Scoped to the check
660
+ // stage alone (TD: a verify finding with the same wording still opens); a passing check's findings are never
661
+ // `blocking` (stageFindings), so this never reaches TE's confirm-only note either way.
662
+ if (f.stage === 'check' && f.source === 'finding' && f.blocking && !CHECK_NOTE_FAILURE_WORD.test(text)) return 'check-note'
663
+ if (selfDeclaredNonBlocking(text)) return 'non-blocking'
664
+ const paths = (Array.isArray(f.paths) ? f.paths : []).map(p => String(p).trim().replace(/^\.\//, '')).filter(Boolean)
665
+ const NOT_A_DEFECT = /\bnot a defect\b|\bnot charged against this task\b|\bdo(?:es)?\s+not\s+fail\s+this\s+task\b/i
666
+ if (NOT_A_DEFECT.test(text)) return 'not-a-defect'
667
+ // [^.\n] alone would stop at the first '.' in a bare filename mid-sentence (docs/onboarding.md); the lookahead
668
+ // excludes only a sentence-ending period (one followed by whitespace or the end), the same rule idReport uses.
669
+ // Round 3 had also excluded '\n' from the middle group to keep 5b within one line, so a criterion FAILURE report
670
+ // on one line couldn't reach an unrelated 'holds' on the next; round 3b drops that restriction as redundant with
671
+ // the sentence-bounded failure-word guard below (hasUnguardedMatch's own sentence lookup already stops at a
672
+ // newline, so a match that crosses one is judged by the sentence its match *starts* in, same as before).
673
+ const CRITERION_HOLDS = /\bcriterion\b(?:(?!\.(?:\s|$))[\s\S])*?\bholds(?=[:.,;]|\s*$)/i
674
+ if (hasUnguardedMatch(text, CRITERION_HOLDS)) return 'not-a-defect'
675
+ const WAITS_ON_TASK = /\bwaits?\s+on\s+(?:the\s+)?[\w-]+\s+task\b/i
676
+ if (!paths.some(p => pathOwned(p, task)) && hasUnguardedMatch(text, WAITS_ON_TASK)) return 'not-a-defect'
677
+ const outOfScopeDrift = (f.stage === 'review' || f.stage === 'check') && f.invariant === 'scope'
678
+ if (paths.length && task && Array.isArray(task.files) && !outOfScopeDrift && !paths.some(p => pathProtected(p, task))) return 'outside-owned-files'
679
+ return null
680
+ }
681
+
682
+ // Applies one pass to the ledger, stage by stage in the order the stages ran, and returns the ids that opened,
683
+ // were confirmed fixed, and came back. A stage confirms an open entry by running and not reporting it: its own
684
+ // stage, the focused check, and the confirmation adversary all count, and only on a pass that made a new commit.
685
+ function updateLedger(ledger, r, pass, newCommit) {
686
+ const opened = []
687
+ const fixed = []
688
+ const reopened = []
689
+ const dropped = []
690
+ const touched = new Set()
691
+ for (const stage of r.stages || []) {
692
+ const reported = stageFindings(r, stage)
693
+ if (reported === null) continue
694
+ // Card ledger-ignores-not-a-defect-notes, round 2 rule 6: droppedWhy runs on every finding the stage reported,
695
+ // before the confirms-filter below, so a passing stage's own not-a-defect note (which would otherwise satisfy
696
+ // confirmsFinding and vanish silently, MIN1) is still recorded as a dropped note. With the id exemption in
697
+ // droppedWhy, no id-prefixed finding is ever dropped, so a drop can never confirm an entry by absence.
698
+ const kept = []
699
+ for (const f of reported) {
700
+ const why = droppedWhy(f, r.task)
701
+ if (why) dropped.push({ stage: f.stage, file: f.file, description: f.description, why })
702
+ else kept.push(f)
703
+ }
704
+ // A finding that confirms an entry fixed is not a re-report of it: it must neither keep the entry open nor
705
+ // stop a later stage from confirming it. Two runs of 2026-09-06 stalled on exactly that. Dropped findings are
706
+ // excluded before this filter runs, so they can never confirm, reopen, or open a ledger entry either.
707
+ const reReports = kept.filter(f => !confirmsFinding(f, ledger))
708
+ if (newCommit) {
709
+ for (const e of ledger) {
710
+ if (e.status !== 'open' || e.openedPass === pass || touched.has(e.id)) continue
711
+ const covers = e.stage === stage || stage === 'check' || (stage === 'adversary' && pass > 1)
712
+ if (!covers) continue
713
+ if (reReports.some(f => matchFinding(ledger, f) === e)) continue
714
+ e.status = 'fixed'
715
+ e.fixedPass = pass
716
+ e.fixedCommit = r.commit || null
717
+ e.confirmedBy = stage
718
+ fixed.push(e.id)
719
+ }
720
+ }
721
+ for (const f of reReports) {
722
+ const entry = matchFinding(ledger, f)
723
+ if (entry) {
724
+ touched.add(entry.id)
725
+ if (f.evidence) entry.evidence = f.evidence
726
+ if (entry.status === 'fixed') {
727
+ // Reopening drops the record of the fix that did not hold: keeping fixedPass, fixedCommit and confirmedBy
728
+ // would show an open finding as fixed in a commit and confirmed by a stage that has just contradicted itself.
729
+ entry.status = 'open'
730
+ entry.reappeared = true
731
+ entry.fixedPass = null
732
+ entry.fixedCommit = null
733
+ entry.confirmedBy = null
734
+ const i = fixed.indexOf(entry.id)
735
+ if (i >= 0) fixed.splice(i, 1)
736
+ if (!reopened.includes(entry.id)) reopened.push(entry.id)
737
+ }
738
+ continue
739
+ }
740
+ if (!f.blocking) continue
741
+ const created = {
742
+ id: `F${ledger.length + 1}`,
743
+ fingerprint: fingerprint(f.stage, f.file, f.description),
744
+ stage: f.stage,
745
+ invariant: f.invariant,
746
+ severity: f.severity,
747
+ file: f.file,
748
+ line: f.line,
749
+ description: f.description,
750
+ evidence: f.evidence,
751
+ status: 'open',
752
+ openedPass: pass,
753
+ fixedPass: null,
754
+ fixedCommit: null,
755
+ confirmedBy: null,
756
+ reappeared: false,
757
+ }
758
+ ledger.push(created)
759
+ touched.add(created.id)
760
+ opened.push(created.id)
761
+ }
762
+ }
763
+ return { opened, fixed, reopened, dropped }
764
+ }
765
+
766
+ // The ledger as prompt text: open entries in full, fixed ones as a line each, inside a character budget.
767
+ // The two parts are shrunk together against the one budget and only then split, so a prompt that prints them
768
+ // under separate headings still costs no more than LEDGER_CHAR_BUDGET in total.
769
+ // kind is 'open', 'fixed', or anything else for both.
770
+ function ledgerText(ledger, kind) {
771
+ const open = (ledger || []).filter(e => e.status === 'open')
772
+ const done = (ledger || []).filter(e => e.status === 'fixed')
773
+ const clip = (s, n) => {
774
+ const t = String(s == null ? '' : s)
775
+ return n && t.length > n ? `${t.slice(0, n)}...` : t
776
+ }
777
+ const openBlock = (e, descCap, evCap) => [
778
+ `${e.id} [${e.stage}/${e.invariant}] ${e.severity} ${e.file || '(no file)'}${e.line ? `:${e.line}` : ''}${e.reappeared ? ' (reappeared after being fixed)' : ''}`,
779
+ ` ${clip(e.description, descCap)}`,
780
+ ` evidence: ${e.evidence ? clip(e.evidence, evCap) : 'none'}`,
781
+ ]
782
+ const fixedLine = e => `${e.id} [${e.stage}/${e.invariant}] fixed in ${String(e.fixedCommit || 'unknown').slice(0, 7)}, confirmed by ${e.confirmedBy || 'unknown'} (pass ${e.fixedPass})`
783
+ const render = (descCap, evCap, dropped) => {
784
+ const openLines = []
785
+ for (const e of open) openLines.push(...openBlock(e, descCap, evCap))
786
+ const doneLines = []
787
+ if (dropped > 0) doneLines.push(`... and ${dropped} earlier fixed findings omitted (${done[0].id}-${done[dropped - 1].id})`)
788
+ for (const e of done.slice(dropped)) doneLines.push(fixedLine(e))
789
+ return { open: openLines.join('\n'), fixed: doneLines.join('\n') }
790
+ }
791
+ const both = s => [s.open, s.fixed].filter(Boolean).join('\n')
792
+ const fits = s => both(s).length <= LEDGER_CHAR_BUDGET
793
+ let text = render(0, 0, 0)
794
+ if (!fits(text)) text = render(0, 240, 0)
795
+ if (!fits(text)) {
796
+ text = render(400, 240, 0)
797
+ for (let dropped = 1; dropped <= done.length && !fits(text); dropped++) text = render(400, 240, dropped)
798
+ }
799
+ if (!fits(text)) {
800
+ // Clipping the fields and dropping the fixed lines is not a bound: enough open entries still overrun the
801
+ // budget. The cap is what every prompt built from the ledger is sized against, so the last step is a hard
802
+ // clip of the open part, keeping the (already minimal) fixed part inside the same budget.
803
+ const note = '\n... ledger truncated at the character budget'
804
+ const room = Math.max(0, LEDGER_CHAR_BUDGET - text.fixed.length - (text.fixed ? 1 : 0) - note.length)
805
+ text = { open: text.open.slice(0, room) + note, fixed: text.fixed }
806
+ }
807
+ if (kind === 'open') return text.open
808
+ if (kind === 'fixed') return text.fixed
809
+ return both(text)
810
+ }
811
+
812
+ // ---- Prompts -----------------------------------------------------------------
813
+ function implementPrompt(task, baseBranch, opts) {
814
+ const resume = !!(opts && opts.resume)
815
+ const branchCheck = resume
816
+ ? ` Before that, if git rev-parse --verify --quiet refs/heads/doug/task-${task.id} succeeds, the branch doug/task-${task.id} was created by the previous agent for this task in this same run, which stopped without returning a result; it is yours, not stale. Find that agent's worktree with git worktree list --porcelain (the one with branch refs/heads/doug/task-${task.id}); if none is listed, go straight to the checkout. If it has uncommitted changes to owned files, commit them there first with a message starting "${task.id}: ". ${NO_TRAILERS} Then remove that worktree with git worktree remove --force <path>, then git checkout doug/task-${task.id} in your own worktree and continue the task from that branch's state (do not recreate the branch or reset it). Never return blocked for the branch existing.`
817
+ : ` Before that, if git rev-parse --verify --quiet refs/heads/doug/task-${task.id} succeeds, the branch is a leftover of an earlier run: do not check it out or build on it; change nothing and return blocked=true with blockedReason "stale branch doug/task-${task.id} already exists; run plan.mjs replan, which renames it".`
818
+ return [
819
+ `You are implementing exactly one task from an approved plan. Work only in your own git worktree.`,
820
+ ``,
821
+ `Plan: ${plan.title}`,
822
+ `Goal: ${plan.goal}`,
823
+ ``,
824
+ `Task ${task.id}: ${task.title}`,
825
+ `Spec:`,
826
+ task.spec,
827
+ ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, String(task.lessons).slice(0, 2000)] : []),
828
+ ...(typeof task.codeContext === 'string' && task.codeContext ? ['', `Code the semantic index found relevant to this task (path:lines; read these first, they are not the files you own):`, String(task.codeContext).slice(0, 2000)] : []),
829
+ ``,
830
+ `Files you own (you may create or edit ONLY these): ${task.files.join(', ')}`,
831
+ task.verify ? `Verification command for this task: ${task.verify}` : `Verification: run the project's test command.`,
832
+ ``,
833
+ `Rules:`,
834
+ `1. First run: git checkout -b doug/task-${task.id} ${baseBranch} (or start from ${baseBranch} if the worktree already has it). If ${baseBranch} does not exist, use the current branch.`,
835
+ branchCheck,
836
+ ` A fresh worktree has no installed dependencies: if the verification command needs them, run the project's install command first${plan.install ? ` (${plan.install})` : ''}. Installing is not a file change you own; do not commit lockfile changes it makes.`,
837
+ `2. Touch no file outside your owned list. If the task cannot be done without another file, stop and report blocked=true with the reason.`,
838
+ `3. Run the verification command before finishing and report each command with its real exit status. Never report a command you did not run.`,
839
+ `4. Commit your work on the branch with a message starting "${task.id}: ". ${NO_TRAILERS}`,
840
+ `5. Return the structured result. filesTouched must come from git, not memory, and commit must be the output of git rev-parse HEAD in your worktree after that commit.`,
841
+ `6. ${PARTIAL_RULE}`,
842
+ ].join('\n')
843
+ }
844
+
845
+ function reusePrompt(task, baseBranch) {
846
+ return [
847
+ `You are starting a task from a branch that already passed verification, review, and the adversary on an earlier pass of this plan. Do not implement anything and do not edit any file; the checks run again on this branch after you.`,
848
+ ``,
849
+ `Plan: ${plan.title}`,
850
+ `Task ${task.id}: ${task.title}`,
851
+ `Branch to reuse: ${task.reuse}`,
852
+ `Files the task owns: ${task.files.join(', ')}`,
853
+ ``,
854
+ `Steps, all inside your own worktree:`,
855
+ `1. git checkout ${task.reuse}. If git refuses because the branch is checked out in another worktree, that worktree is a leftover of the earlier pass under .claude/worktrees: find it with git worktree list --porcelain, remove it with git worktree remove --force <path>, and check out again.`,
856
+ plan.install
857
+ ? `2. Run the project's install command (${plan.install}) before the checks run. Installing is not a file change; do not commit anything it changes. Should you commit at all, ${NO_TRAILERS}`
858
+ : `2. If the task's verification command needs dependencies, run the project's install command. Installing is not a file change; do not commit anything it changes. Should you commit at all, ${NO_TRAILERS}`,
859
+ `3. Return taskId="${task.id}", branch="${task.reuse}", worktreePath = the absolute path of your worktree (git rev-parse --show-toplevel), filesTouched = the output of git diff --name-only ${baseBranch}...HEAD, commandsRun with real exit statuses, commit = the output of git rev-parse HEAD, summary "reused ${task.reuse} at <commit>", blocked=false. Report blocked=true with the reason only if the branch does not exist or cannot be checked out.`,
860
+ ].join('\n')
861
+ }
862
+
863
+ // ---- Swarm prompts (card swarm-lead) --------------------------------------------
864
+ // The lead splits one full-shape task into worker briefs inside that task's owned files; workers implement their
865
+ // briefs in parallel on the worker row; the lead merges their branches into the task branch, and the verifier,
866
+ // reviewer, and adversary read the merged branch exactly as they read a single implementer's.
867
+ function leadPrompt(task, baseBranch, opts) {
868
+ const resume = !!(opts && opts.resume)
869
+ const branchCheck = resume
870
+ ? ` Before that, if git rev-parse --verify --quiet refs/heads/doug/task-${task.id} succeeds, the branch doug/task-${task.id} was created by the previous agent for this task in this same run, which stopped without returning a result; it is yours, not stale. Find that agent's worktree with git worktree list --porcelain (the one with branch refs/heads/doug/task-${task.id}); if none is listed, go straight to the checkout. If it has uncommitted changes to owned files, commit them there first with a message starting "${task.id}: ". ${NO_TRAILERS} Then remove that worktree with git worktree remove --force <path>, then git checkout doug/task-${task.id} in your own worktree and continue the task from that branch's state (do not recreate the branch or reset it). Never return blocked for the branch existing.`
871
+ : ` Before that, if git rev-parse --verify --quiet refs/heads/doug/task-${task.id} succeeds, the branch is a leftover of an earlier run: do not check it out or build on it; change nothing and return blocked=true with blockedReason "stale branch doug/task-${task.id} already exists; run plan.mjs replan, which renames it".`
872
+ return [
873
+ `You are the lead of a swarm for exactly one task of an approved plan. You split the task into worker briefs; you do not implement anything and you edit no file. Work only in your own git worktree.`,
874
+ ``,
875
+ `Plan: ${plan.title}`,
876
+ `Goal: ${plan.goal}`,
877
+ ``,
878
+ `Task ${task.id}: ${task.title}`,
879
+ `Spec:`,
880
+ task.spec,
881
+ ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, String(task.lessons).slice(0, 2000)] : []),
882
+ ...(typeof task.codeContext === 'string' && task.codeContext ? ['', `Code the semantic index found relevant to this task (path:lines; read these first, they are not the files you own):`, String(task.codeContext).slice(0, 2000)] : []),
883
+ ``,
884
+ `Files the task owns: ${task.files.join(', ')}`,
885
+ task.verify ? `Verification command for the task: ${task.verify}` : `Verification: the project's test command.`,
886
+ ``,
887
+ `Steps:`,
888
+ `1. git checkout -b doug/task-${task.id} ${baseBranch} (if ${baseBranch} does not exist, use the current branch). This is the task branch; the workers branch from it and you merge them back into it.`,
889
+ branchCheck,
890
+ `2. Read the spec and the owned files, then decide whether the task splits. The trigger is positive, not file count: it splits when it has two or more deliverables that each have their own test (or can be verified on its own) and share no new symbol beyond what you write in interfaces below; a brief owns one deliverable and its test. File count alone is not a reason to split: independence of deliverables is.`,
891
+ `3. One brief is right only when the deliverables cannot be separated; say why in splitReason, so the report can tell an unsplittable task from one you did not try to split. More than four briefs is rarely right. A brief that would need another brief's file is the wrong split.`,
892
+ `4. Every file in a brief is one of the task's owned files, and no file is in two briefs: the lead may split, never widen, and two workers never share a file or a worktree.`,
893
+ `5. Give every brief this checklist, adapted from a research lead's brief: an objective, the test to write and how to run it, the interfaces it exposes or consumes, its scope boundaries (its files, nothing else), and the expected result shape. Give a brief its own verify command only when one exists for its files; otherwise leave verify null and the task command applies.`,
894
+ `6. When you split into more than one brief, also return interfaces: the names, signatures, and files each brief exports or consumes, written once here, so every worker prompt carries the same design.`,
895
+ `7. Return taskId="${task.id}", branch="doug/task-${task.id}", worktreePath = the absolute path of your worktree (git rev-parse --show-toplevel), briefs, splitReason (required on every result, split or not), blocked=false. Return blocked=true with the reason only when the task cannot be split without a file outside the owned list; then the plan runs it with a single implementer instead (plan.mjs set swarm off).`,
896
+ ].join('\n')
897
+ }
898
+
899
+ function workerPrompt(task, brief, n, lead) {
900
+ const verify = brief.verify || task.verify
901
+ // Card swarm-topology (candidate 3): every sibling brief of this task, so the worker knows what it must conform
902
+ // to and what is not its business; the task it belongs to still owns only the files its own brief lists below.
903
+ const siblings = (Array.isArray(lead.briefs) ? lead.briefs : []).filter(b => b.id !== brief.id)
904
+ return [
905
+ `You are worker ${n} of a swarm implementing one part of task ${task.id} from an approved plan. You get one brief, the files it owns, and a verification command; the other parts are other workers' business. Work only in your own git worktree.`,
906
+ ``,
907
+ `Plan: ${plan.title}`,
908
+ `Goal: ${plan.goal}`,
909
+ ``,
910
+ `Task ${task.id}: ${task.title}. Its full spec, for background only - your brief below is your part of it:`,
911
+ task.spec,
912
+ ``,
913
+ `Brief ${brief.id}: ${brief.title}`,
914
+ `Spec:`,
915
+ brief.spec,
916
+ ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, String(task.lessons).slice(0, 2000)] : []),
917
+ ...(typeof task.codeContext === 'string' && task.codeContext ? ['', `Code the semantic index found relevant to this task (path:lines; read these first, they are not the files you own):`, String(task.codeContext).slice(0, 2000)] : []),
918
+ ...(typeof lead.interfaces === 'string' && lead.interfaces ? ['', `Interfaces the briefs export or consume (the lead's design; conform to it, do not redesign it):`, lead.interfaces.slice(0, 2000)] : []),
919
+ ...(siblings.length ? ['', `Sibling briefs of this task (their files are not yours to touch; conform to what they export):`, ...siblings.map(s => ` - ${s.id}: ${s.title} (${(Array.isArray(s.files) ? s.files : []).join(', ')})`)] : []),
920
+ ``,
921
+ `Files you own (you may create or edit ONLY these): ${brief.files.join(', ')}`,
922
+ verify ? `Verification command: ${verify}` : `Verification: run the project's test command.`,
923
+ ``,
924
+ `Rules:`,
925
+ `1. First run: git checkout -b doug/task-${task.id}-w${n} ${lead.branch} (the task branch the lead created; it exists in this repository).`,
926
+ ` A fresh worktree has no installed dependencies: if the verification command needs them, run the project's install command first${plan.install ? ` (${plan.install})` : ''}. Installing is not a file change you own; do not commit lockfile changes it makes.`,
927
+ `2. Touch no file outside your owned list. If the brief cannot be done without another file, stop and report blocked=true with a blockedReason that names exactly what you need - the file, the symbol or interface, or the decision - because the lead may re-brief you once on it; a vague reason wastes that chance.`,
928
+ `3. Run the verification command before finishing and report each command with its real exit status. Never report a command you did not run.`,
929
+ `4. Commit your work on the branch with a message starting "${task.id}: w${n} ". ${NO_TRAILERS}`,
930
+ `5. Return the structured result. filesTouched must come from git diff --name-only ${lead.branch}...HEAD, and commit must be the output of git rev-parse HEAD in your worktree after that commit.`,
931
+ `6. ${PARTIAL_RULE}`,
932
+ ].join('\n')
933
+ }
934
+
935
+ // `workers` is who gets merged (finished workers only); `allWorkers` (candidate 2 review, minor 5) is every worker
936
+ // that ever ran, blocked ones included, so a blocked round-one worker's worktree is still cleaned up instead of
937
+ // left an orphan - it defaults to `workers` for the plain, no-rebrief case where the two are the same list.
938
+ function leadMergePrompt(task, lead, workers, baseBranch, allWorkers = workers) {
939
+ return [
940
+ `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 your worktree ${lead.worktreePath} on branch ${lead.branch}: cd there first, and never edit anything under the repository root.`,
941
+ PARTIAL_NEVER_MERGED,
942
+ ``,
943
+ `Task ${task.id}: ${task.title}`,
944
+ `Owned files: ${task.files.join(', ')}`,
945
+ task.verify ? `Verification command for the task: ${task.verify}` : `Verification: run the project's test command.`,
946
+ ``,
947
+ `Worker branches, in order:`,
948
+ ...workers.map(w => ` ${w.n}. ${w.branch} (brief ${w.id}: ${w.files.join(', ')})${w.worktreePath ? `, worktree ${w.worktreePath}` : ''}`),
949
+ ``,
950
+ `Steps:`,
951
+ `1. git merge --no-ff each worker branch in that order. Resolve a conflict only inside the owned files and only by keeping both workers' intent; a conflict that needs any other file is a block, not a fix.`,
952
+ `2. If the verification command needs dependencies, run the project's install command${plan.install ? ` (${plan.install})` : ''}; do not commit what it changes. Then run the verification command; it must pass. Report every command with its real exit status and never one you did not run.`,
953
+ `3. Do not implement missing pieces yourself: a brief a worker did not finish is a block with its name. ${NO_TRAILERS}`,
954
+ `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.`,
955
+ ...allWorkers.filter(w => w.worktreePath).map(w => ` - ${w.worktreePath} (worker ${w.n}: ${w.id})`),
956
+ `5. Return taskId="${task.id}", branch="${lead.branch}", worktreePath="${lead.worktreePath}", 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.`,
957
+ ].join('\n')
958
+ }
959
+
960
+ // Card swarm-topology (candidate 2): one worker blocked does not end the task - the lead gets one chance to
961
+ // re-brief the unfinished pieces, in its existing worktree (no new worktree, no isolation key). `workers` is every
962
+ // round-one outcome (so the lead sees a finished sibling's commit, not just the blocked ones); `blockedWorkers` is
963
+ // the subset that needs a revised brief.
964
+ function leadRebriefPrompt(task, lead, workers, blockedWorkers) {
965
+ const finished = workers.filter(w => !w.blocked)
966
+ return [
967
+ `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 in your existing worktree ${lead.worktreePath} on branch ${lead.branch}: cd there first, create no new branch, and never edit anything under the repository root.`,
968
+ ``,
969
+ `Plan: ${plan.title}`,
970
+ `Goal: ${plan.goal}`,
971
+ ``,
972
+ `Task ${task.id}: ${task.title}`,
973
+ `Spec:`,
974
+ task.spec,
975
+ ...(typeof lead.interfaces === 'string' && lead.interfaces ? ['', `Interfaces from the first split:`, lead.interfaces.slice(0, 2000)] : []),
976
+ ``,
977
+ `Finished workers (their files and commits are done; not yours to reopen):`,
978
+ ...(finished.length ? finished.map(w => ` - ${w.id}: ${w.title} (${w.files.join(', ')}), commit ${w.commit}`) : [' - (none)']),
979
+ ``,
980
+ `Blocked briefs, and what each worker said it needs:`,
981
+ ...blockedWorkers.map(w => ` - ${w.id}: ${w.title} (${w.files.join(', ')}): ${w.blockedReason}`),
982
+ ``,
983
+ `Steps:`,
984
+ `1. Return revised briefs for the unfinished pieces only. Every file in a revised brief must be a file of one of the blocked briefs above: never a file a finished worker already committed, never a file outside the task.`,
985
+ `2. Revised briefs are disjoint, the same rule as the first split.`,
986
+ `3. You may re-split a blocked brief into smaller briefs, or re-brief it as one brief carrying the missing decision or interface the worker named - most blocks are a missing piece of information, not a missing file. Revised briefs are never more than the blocked briefs plus one, and more than four is never right.`,
987
+ `4. Return splitReason saying what changed and why, required as on the first split.`,
988
+ `5. Return blocked=true with the reason only when the block genuinely needs a file outside the ones listed above; the task then blocks with the original worker's reason.`,
989
+ `6. Return taskId="${task.id}", branch="${lead.branch}", worktreePath="${lead.worktreePath}".`,
990
+ ].join('\n')
991
+ }
992
+
993
+ function fixPrompt(task, r, pass, baseBranch, ledger, brief) {
994
+ const open = ledgerText(ledger, 'open')
995
+ const done = ledgerText(ledger, 'fixed')
996
+ // After integration the task's worktree is gone (the integrator removed it), so the pass starts in a fresh one
997
+ // the runtime made and checks the task branch out there, the way a reused task does.
998
+ const where = r.worktreeGone
999
+ ? `Work only in the git worktree you were started in, on branch ${r.impl.branch}: the task's earlier worktree (${r.impl.worktreePath}) was removed when its level integrated. First run git checkout ${r.impl.branch} there; if git refuses because the branch is checked out in another worktree, that worktree is a leftover under .claude/worktrees: find it with git worktree list --porcelain, remove it with git worktree remove --force <path>, and check out again. Create no branch, and never edit anything under the repository root.`
1000
+ : `Work only in the existing worktree ${r.impl.worktreePath} on branch ${r.impl.branch}: cd there first, create no worktree and no branch, and never edit anything under the repository root.`
1001
+ const lines = [
1002
+ `You are fixing your own task after its checks blocked it. This is fix attempt ${pass - 1} of ${fixAttempts} for task ${task.id}. ${where}`,
1003
+ ``,
1004
+ `Plan: ${plan.title}`,
1005
+ `Task ${task.id}: ${task.title}`,
1006
+ `Spec:`,
1007
+ task.spec,
1008
+ ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, String(task.lessons).slice(0, 2000)] : []),
1009
+ ...(typeof task.codeContext === 'string' && task.codeContext ? ['', `Code the semantic index found relevant to this task (path:lines; read these first, they are not the files you own):`, String(task.codeContext).slice(0, 2000)] : []),
1010
+ ``,
1011
+ `Files you own (you may create or edit ONLY these): ${task.files.join(', ')}`,
1012
+ task.verify ? `Verification command for this task: ${task.verify}` : `Verification: run the project's test command.`,
1013
+ ``,
1014
+ `Open findings (fix every one; a checking stage decides when one is fixed):`,
1015
+ open || `- (none listed)`,
1016
+ ]
1017
+ // Both sections always render: the heading is the standing instruction not to undo earlier fixes, and it has to
1018
+ // be in front of the fix agent on every pass, not only once something has already been fixed.
1019
+ lines.push(``)
1020
+ lines.push(`Fixed earlier in this task (do not undo these; bringing one back blocks):`)
1021
+ lines.push(done || `- (none yet)`)
1022
+ if (brief) {
1023
+ // The supervisor found the loop stalled: the same finding blocked twice, or a pass re-attacked cleared code.
1024
+ lines.push(``)
1025
+ lines.push(`Supervisor brief (this task is stalled: change direction, do not repeat what the earlier passes tried):`)
1026
+ lines.push(brief)
1027
+ }
1028
+ lines.push(``)
1029
+ lines.push(`Rules:`)
1030
+ lines.push(`1. Touch no file outside your owned list. If a finding can only be met by changing another file, stop and return blocked=true with the reason.`)
1031
+ lines.push(`2. Do not delete, skip, or weaken any test to make a check pass; make the code satisfy it. If that is impossible within the spec, return blocked=true and say why.`)
1032
+ lines.push(`3. Run the verification command before finishing; it must pass. Report every command with its real exit status and never one you did not run.`)
1033
+ lines.push(`4. Commit on ${r.impl.branch} with a message starting "${task.id}: fix ". Do not amend or rewrite earlier commits. A pass that ends with no new commit stops the task. ${NO_TRAILERS}`)
1034
+ lines.push(`5. Return the structured result with taskId="${task.id}", branch="${r.impl.branch}", worktreePath${r.worktreeGone ? ' = the absolute path of your worktree (git rev-parse --show-toplevel)' : `="${r.impl.worktreePath}"`}, filesTouched from git diff --name-only ${baseBranch}...HEAD, commit from git rev-parse HEAD after your commit, blocked=false.`)
1035
+ lines.push(`6. ${PARTIAL_RULE}`)
1036
+ return lines.join('\n')
1037
+ }
1038
+
1039
+ // A fresh worker picking up a usable partial (card worker-context-handoff, rule 2): same worktree and branch as a
1040
+ // fix pass (no new worktree, no new branch), briefed with the original spec plus the handoff verbatim. brief, when
1041
+ // given, is the swarm worker's own brief (id, title, spec, files, verify); its absence means this is the task's own
1042
+ // single implementer resuming.
1043
+ function resumePrompt(task, prev, handoff, baseBranch, brief) {
1044
+ const owner = brief || task
1045
+ const who = brief ? `worker ${brief.id} of task ${task.id}'s swarm` : `task ${task.id}`
1046
+ const verify = brief ? (brief.verify || task.verify) : task.verify
1047
+ const completed = (Array.isArray(handoff.completed) ? handoff.completed : []).join('; ') || '(none)'
1048
+ const remaining = (Array.isArray(handoff.remaining) ? handoff.remaining : []).join('; ')
1049
+ return [
1050
+ `You are resuming ${who} after the previous worker stopped partway through at the harness's context notice. Work only in the existing worktree ${prev.worktreePath} on branch ${prev.branch}: cd there first, create no worktree and no branch, and never edit anything under the repository root.`,
1051
+ ``,
1052
+ `Plan: ${plan.title}`,
1053
+ brief ? `Brief ${brief.id}: ${brief.title}` : `Task ${task.id}: ${task.title}`,
1054
+ `Spec:`,
1055
+ owner.spec,
1056
+ ``,
1057
+ `Files you own (you may create or edit ONLY these): ${owner.files.join(', ')}`,
1058
+ verify ? `Verification command: ${verify}` : `Verification: run the project's test command.`,
1059
+ ``,
1060
+ `Handoff from the previous worker: completed: ${completed}; remaining: ${remaining}; next: ${handoff.next}; verify: ${handoff.verify}`,
1061
+ `Do not redo completed items; continue from "next" and finish what "remaining" lists.`,
1062
+ ``,
1063
+ `Rules:`,
1064
+ `1. Touch no file outside your owned list. If the work cannot be done without another file, stop and report blocked=true with the reason.`,
1065
+ `2. Run the verification command before finishing and report each command with its real exit status. Never report a command you did not run.`,
1066
+ `3. Commit your work on ${prev.branch} with a message starting "${task.id}: resume ". ${NO_TRAILERS}`,
1067
+ `4. ${PARTIAL_RULE}`,
1068
+ `5. Return the structured result. filesTouched must come from git diff --name-only ${baseBranch}...HEAD, and commit must be the output of git rev-parse HEAD in your worktree after that commit.`,
1069
+ ].join('\n')
1070
+ }
1071
+
1072
+ // The supervisor's brief (card fix-loop-supervisor, after NVIDIA AVO's self-supervision loop): the task's passes so
1073
+ // far, the ledger, and the stall signals the loop detected. It edits nothing and decides nothing about findings.
1074
+ function supervisePrompt(task, r, attempts, ledger, signals) {
1075
+ const stalls = signals.map(x => x.kind === 're-attack' ? `pass ${x.pass} re-attacked ${x.file}, which pass ${x.clearedPass} had already cleared` : `${x.finding} blocked twice${x.reappeared ? ' (reappeared after being fixed)' : ''} (open after passes ${x.passes.join(', ')})`)
1076
+ const passes = attempts.map(a => {
1077
+ const adv = a.adversary ? (a.adversary.ran ? `${a.adversary.verdict}${a.adversary.blocked ? ' (blocked)' : ''}` : 'did not run') : 'none'
1078
+ const cmds = (a.commands || []).map(c => ` ${c.command} (${c.exitCode === null || c.exitCode === undefined ? 'exit unknown' : `exit ${c.exitCode}`})`)
1079
+ return [
1080
+ `Pass ${a.pass}: ${(a.stages || []).join(', ')}; commit ${a.commit ? String(a.commit).slice(0, 7) : 'none'}; verify ${a.verified ? 'yes' : 'no'}, review ${a.reviewed ? 'yes' : 'no'}, adversary ${adv}; blocked at ${a.blockingStage || 'no stage'}; new findings: ${(a.newFindings || []).join(', ') || 'none'}; fixed: ${(a.fixedFindings || []).join(', ') || 'none'}`,
1081
+ ` commands:`,
1082
+ ...(cmds.length ? cmds : [' (none recorded)']),
1083
+ ].join('\n')
1084
+ })
1085
+ return [
1086
+ `You are the supervisor of task ${task.id}'s fix loop. The loop is stalled: ${stalls.join('; ')}. Read the passes and the ledger below and write a brief for the next fix pass: what was tried, what failed each time, and two or three alternative directions drawn from this evidence, each different from what the passes tried.`,
1087
+ ``,
1088
+ `Plan: ${plan.title}`,
1089
+ `Task ${task.id}: ${task.title}`,
1090
+ `Spec:`,
1091
+ task.spec,
1092
+ ``,
1093
+ `Files the task owns: ${task.files.join(', ')}`,
1094
+ `Branch ${r.impl && r.impl.branch ? r.impl.branch : 'unknown'}${r.impl && r.impl.worktreePath ? `, worktree ${r.impl.worktreePath} (read it if you need to; never write there)` : ''}`,
1095
+ ``,
1096
+ `Passes so far (${attempts.length} of a possible ${fixAttempts + 1}):`,
1097
+ ...passes,
1098
+ ``,
1099
+ `Ledger (open findings in full, fixed ones as a line each):`,
1100
+ ledgerText(ledger, 'both') || '(empty)',
1101
+ ``,
1102
+ `Rules:`,
1103
+ `1. You edit nothing and run no command that changes the worktree or the repository; you may read files and run read-only commands.`,
1104
+ `2. Your brief is not a verdict: every open finding above stays open until a checking stage confirms it, and you never say a finding is fixed, wrong, or unimportant.`,
1105
+ `3. Directions must be concrete and different from what the passes tried (a different file, a different approach, a test written first, a re-read of the failing command's output), never a restatement.`,
1106
+ `4. Return taskId="${task.id}", brief, and directions.`,
1107
+ ].join('\n')
1108
+ }
1109
+
1110
+ function verifyPrompt(task, impl, baseBranch) {
1111
+ return [
1112
+ `You are a verifier. You did not write this code. Your job is to run it, not to read and approve it.`,
1113
+ ``,
1114
+ `Task ${task.id}: ${task.title}`,
1115
+ `Spec:`,
1116
+ task.spec,
1117
+ ``,
1118
+ `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):`,
1119
+ ...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
1120
+ ``,
1121
+ `Worktree: ${impl.worktreePath}`,
1122
+ `Branch: ${impl.branch}`,
1123
+ `Implementer says it ran: ${impl.commandsRun.map(c => `${c.command} (${c.ok ? 'ok' : 'FAILED'})`).join('; ') || 'nothing'}`,
1124
+ ``,
1125
+ `Do all of this inside the worktree directory:`,
1126
+ `0. Run git rev-parse HEAD and return its full output as head; the loop records the task's commit from this, not from the implementer.`,
1127
+ `1. Run ${task.verify || plan.verify[0] || 'the project test command'} and record the real exit status and the last lines of output.`,
1128
+ `2. Run every acceptance command above (the $ lines) inside the worktree, exactly as written, with the worktree as the working directory, 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. For criteria without a command, exercise the behavior directly (a script, a CLI call, a test file) rather than reading code and reasoning that it works.`,
1129
+ `2b. An acceptance command that exits non-zero because it checks a criterion this task's spec covers makes passed=false, with a finding that names the criterion. One that checks a criterion only another task of this plan can satisfy (its files are not in this task's owned list: ${task.files.join(', ')}) does not fail this task: report it with ok=false and say in findings which task it waits on.`,
1130
+ `3. Check git diff ${baseBranch}...${impl.branch} for files outside the owned list: ${task.files.join(', ')}. ${baseBranch} is this branch's base (an integration branch when the task follows an earlier level), so files that already exist there are not this task's changes.`,
1131
+ `3b. Check git log ${baseBranch}..${impl.branch} --format=%h%x20%B: ${NO_TRAILERS} Report such a commit as a finding that names it, and passed=false.`,
1132
+ `4. passed=true only if the verification command succeeded, every acceptance command this task is responsible for exited 0, and the behavior matches the spec. List concrete findings otherwise.`,
1133
+ `4b. For each finding that fails the task and names a file, add { finding, files } to findingFiles: the finding's text and the worktree-relative paths of the existing repository files it concerns (${task.files.join(', ')} are this task's). Never list a fixture, scratch, or temp path. A finding with an entry is attributed by that list, not by the paths in its text, so name the files a fix would touch.`,
1134
+ `5. If the spec and an acceptance criterion cannot both hold, do not pick one: add a finding that starts with "${CONTRADICTION_MARKER}: " and names the conflict. The workflow never retries such a finding; it goes back to the planner.`,
1135
+ ...(plan.install ? [`If the worktree has no installed dependencies (e.g. no node_modules), run the project's install command first (${plan.install}); installing writes no tracked file and is not an edit.`] : []),
1136
+ `Use only tools already installed in the worktree (its test runner, its compiler, plain node). Do not npx-install or download anything else; a command that cannot run for an environment reason and not a code reason (a tool not found after the install, a sandbox denial) is reported as a finding that starts with "${ENVIRONMENT_MARKER}: " naming the command and what the shell said. The workflow does not send such a finding to a fix pass. A command that runs and fails, or cannot start because of anything in the diff (a missing module the diff should provide, a compile error), is a code finding, never ${ENVIRONMENT_MARKER}.`,
1137
+ `Do not edit any file. Put scratch scripts under the system temp directory, never in the worktree.`,
1138
+ ].join('\n')
1139
+ }
1140
+
1141
+ function checkPrompt(task, impl, prevCommit, baseBranch, ledger) {
1142
+ const open = ledgerText(ledger, 'open')
1143
+ return [
1144
+ `You are the single check on a fix pass for task ${task.id}. You did not write this code. The full verifier and reviewer already ran on an earlier pass, so your job is to run the commands again and to read only what the fix changed.`,
1145
+ ``,
1146
+ `Task ${task.id}: ${task.title}`,
1147
+ `Spec:`,
1148
+ task.spec,
1149
+ ``,
1150
+ `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):`,
1151
+ ...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
1152
+ ``,
1153
+ `Worktree: ${impl.worktreePath}`,
1154
+ `Branch: ${impl.branch}`,
1155
+ `The fix delta is git diff ${prevCommit || baseBranch}..${impl.branch}.`,
1156
+ ``,
1157
+ `Do all of this inside the worktree directory:`,
1158
+ `0. Run git rev-parse HEAD and return its full output as head; the loop records the task's commit from this, not from the implementer.`,
1159
+ `1. Run ${task.verify || plan.verify[0] || 'the project test command'} and record the real exit status and the last lines of output.`,
1160
+ `2. Run every acceptance command above (the $ lines) inside the worktree, exactly as written, with the worktree as the working directory, 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. For criteria without a command, exercise the behavior directly (a script, a CLI call, a test file) rather than reading code and reasoning that it works.`,
1161
+ `2b. An acceptance command that exits non-zero because it checks a criterion this task's spec covers makes passed=false, with a finding that names the criterion. One that checks a criterion only another task of this plan can satisfy (its files are not in this task's owned list: ${task.files.join(', ')}) does not fail this task: report it with ok=false and say in findings which task it waits on.`,
1162
+ `3. Review only the fix delta, not the whole change again: regression risk in what it touched, scope (any file it changed outside the owned list ${task.files.join(', ')} makes inScope=false), and tests it weakened, skipped, or deleted. Report those as issues.`,
1163
+ `4. Confirm each open finding below, one by one, and say for each whether it is fixed. Name each listed finding once, starting with its id: "F3 fixed: <what shows it>" when it is fixed, or "F3: still returns 1" when it is not; the workflow reads the first words after the id, and a finding that names an id is never counted as open unless it says so.`,
1164
+ open || `(no open findings; confirm only the delta)`,
1165
+ `5. passed=true only when the verification command exited 0, every acceptance command this task is responsible for exited 0, no open finding above remains, and you have no blocker issue.`,
1166
+ `5b. For each finding that fails the task and names a file, add { finding, files } to findingFiles: the finding's text and the worktree-relative paths of the existing repository files it concerns (${task.files.join(', ')} are this task's). Never list a fixture, scratch, or temp path. A finding with an entry is attributed by that list, not by the paths in its text, so name the files a fix would touch.`,
1167
+ `6. If the spec and an acceptance criterion cannot both hold, do not pick one: add a finding that starts with "${CONTRADICTION_MARKER}: " and names the conflict. The workflow never retries such a finding; it goes back to the planner.`,
1168
+ ...(plan.install ? [`If the worktree has no installed dependencies (e.g. no node_modules), run the project's install command first (${plan.install}); installing writes no tracked file and is not an edit.`] : []),
1169
+ `Use only tools already installed in the worktree (its test runner, its compiler, plain node). Do not npx-install or download anything else; a command that cannot run for an environment reason and not a code reason (a tool not found after the install, a sandbox denial) is reported as a finding that starts with "${ENVIRONMENT_MARKER}: " naming the command and what the shell said. The workflow does not send such a finding to a fix pass. A command that runs and fails, or cannot start because of anything in the diff (a missing module the diff should provide, a compile error), is a code finding, never ${ENVIRONMENT_MARKER}.`,
1170
+ `Do not edit any file. Put scratch scripts under the system temp directory, never in the worktree.`,
1171
+ ].join('\n')
1172
+ }
1173
+
1174
+ // The one check of a size-S task: the verifier's job and the reviewer's job in one agent on the verify row.
1175
+ function sizeCheckPrompt(task, impl, baseBranch) {
1176
+ return [
1177
+ `You are the one check on size-S task ${task.id}: the verifier's job and the reviewer's job in one pass. You did not write this code. Run it, then read the diff ${baseBranch}...${impl.branch}. No separate verifier, reviewer, or adversary runs for this task; the adversary reviews the level's S tasks together on the integration branch after the merge, so what you miss reaches it.`,
1178
+ ``,
1179
+ `Task ${task.id}: ${task.title}`,
1180
+ `Spec:`,
1181
+ task.spec,
1182
+ ``,
1183
+ `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):`,
1184
+ ...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
1185
+ ``,
1186
+ `Worktree: ${impl.worktreePath}`,
1187
+ `Branch: ${impl.branch}`,
1188
+ `Implementer says it ran: ${impl.commandsRun.map(c => `${c.command} (${c.ok ? 'ok' : 'FAILED'})`).join('; ') || 'nothing'}`,
1189
+ ``,
1190
+ `Do all of this inside the worktree directory:`,
1191
+ `0. Run git rev-parse HEAD and return its full output as head; the loop records the task's commit from this, not from the implementer.`,
1192
+ `1. Run ${task.verify || plan.verify[0] || 'the project test command'} and record the real exit status and the last lines of output.`,
1193
+ `2. Run every acceptance command above (the $ lines) inside the worktree, exactly as written, 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. For criteria without a command, exercise the behavior directly rather than reading code and reasoning that it works.`,
1194
+ `2b. An acceptance command that exits non-zero because it checks a criterion this task's spec covers makes passed=false, with a finding that names the criterion. One that checks a criterion only another task of this plan can satisfy (its files are not in this task's owned list: ${task.files.join(', ')}) does not fail this task: report it with ok=false and say in findings which task it waits on.`,
1195
+ `3. Check git diff ${baseBranch}...${impl.branch} for files outside the owned list: ${task.files.join(', ')}. Any such file makes inScope=false. ${baseBranch} is this branch's base, so files that already exist there are not this task's changes.`,
1196
+ `3b. Check git log ${baseBranch}..${impl.branch} --format=%h%x20%B: ${NO_TRAILERS} Report such a commit as a finding that names it, and passed=false.`,
1197
+ `4. Review the diff as the reviewer would, in two passes: spec compliance first (does it do what the spec says, all of it, and nothing else?), then quality (correctness risks, missing tests for new behavior, error handling, naming consistent with surrounding code; do not request refactors beyond the task). Report those as issues with severity blocker, major, or minor, a file, and a line.`,
1198
+ `4b. For each finding that fails the task and names a file, add { finding, files } to findingFiles: the finding's text and the worktree-relative paths of the existing repository files it concerns. Never list a fixture, scratch, or temp path.`,
1199
+ `5. passed=true only when the verification command exited 0, every acceptance command this task is responsible for exited 0, the behavior matches the spec, and you have no blocker issue.`,
1200
+ `6. If the spec and an acceptance criterion cannot both hold, do not pick one: add a finding that starts with "${CONTRADICTION_MARKER}: " and names the conflict. The workflow never retries such a finding; it goes back to the planner.`,
1201
+ ...(plan.install ? [`If the worktree has no installed dependencies (e.g. no node_modules), run the project's install command first (${plan.install}); installing writes no tracked file and is not an edit.`] : []),
1202
+ `Use only tools already installed in the worktree (its test runner, its compiler, plain node). Do not npx-install or download anything else; a command that cannot run for an environment reason and not a code reason (a tool not found after the install, a sandbox denial) is reported as a finding that starts with "${ENVIRONMENT_MARKER}: " naming the command and what the shell said. The workflow does not send such a finding to a fix pass. A command that runs and fails, or cannot start because of anything in the diff (a missing module the diff should provide, a compile error), is a code finding, never ${ENVIRONMENT_MARKER}.`,
1203
+ `Do not edit any file. Put scratch scripts under the system temp directory, never in the worktree.`,
1204
+ ].join('\n')
1205
+ }
1206
+
1207
+ // The spec the level adversary reviews against: every size-S task merged into the level, with its owned files.
1208
+ function levelSpecLines(sReady) {
1209
+ return sReady.flatMap(r => [`Task ${r.task.id}: ${r.task.title}`, `Owned files: ${r.task.files.join(', ')}`, r.task.spec, ``])
1210
+ }
1211
+
1212
+ // codex-review over a level's size-S tasks on the integration branch, once, after the merge (and once more to confirm
1213
+ // a fix pass). Every blocker must name a file, because that is how it finds the task it goes back to.
1214
+ function levelAdversaryPrompt(li, sReady, levelBase, confirming) {
1215
+ const verify = (plan.verify || []).filter(Boolean)
1216
+ const guard = t => String(t).replace(/^DOUG_SPEC_EOF$/gm, 'DOUG_SPEC_EOF ')
1217
+ 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('')}`,
1219
+ `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
+ ...(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
+ ``,
1222
+ ...levelSpecLines(sReady).map(guard),
1223
+ `Note to the reviewer: codex-review sets DOUG_CODEX_REVIEW=1 in your environment. A check that would spawn codex-review cannot run inside it, so tests that spawn it skip when that variable is set and name it as the reason. Such a skip is expected and is not a weakened, disabled, or skipped test; do not report it as an issue.`,
1224
+ `A check that fails only because your sandbox denied it something the code needs from the environment, such as EPERM when a test listens on 127.0.0.1, is an environment denial: it is inconclusive, not a blocker, so name it in the summary and do not report it as an issue or fail the review on it.`,
1225
+ ...BLOCKER_GATE,
1226
+ `DOUG_SPEC_EOF`,
1227
+ ].join('\n')
1228
+ return [
1229
+ `Run the adversarial reviewer for level ${li} of the plan (its size-S tasks, merged on ${integrationBranch}) and relay its verdict. Do not review the code yourself and do not edit anything.`,
1230
+ ``,
1231
+ `Run exactly this command with the Bash tool from the repository root (it pipes the spec on stdin; the timeout is ${adversary.timeoutMs} ms, so set the tool timeout at least that high):`,
1232
+ '```',
1233
+ command,
1234
+ '```',
1235
+ ``,
1236
+ `The command prints one JSON object and exits 0 (no blockers), 1 (fail verdict or blocker issue), or 2 (could not review). All three are normal; report what it printed.`,
1237
+ `Return: taskId="level-${li}", ran=true if JSON was printed, and verdict, summary, issues, commandsRun, error, usage, durationMs copied from the JSON (error = "<kind>: <message>" or null; usage and durationMs copied as printed, or null when the JSON carried none).`,
1238
+ `If the command itself is not found, return ran=false, verdict="inconclusive", error="codex-review not found: <what the shell said>".`,
1239
+ ].join('\n')
1240
+ }
1241
+
1242
+ function levelFallbackAdversaryPrompt(li, sReady, levelBase, why, confirming) {
1243
+ const verify = (plan.verify || []).filter(Boolean)
1244
+ return [
1245
+ `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}.`,
1246
+ ``,
1247
+ ...levelSpecLines(sReady),
1248
+ `Plan acceptance criteria:`,
1249
+ ...acceptanceEntries(plan.acceptance || []).map(a => `- ${a.text}`),
1250
+ ``,
1251
+ `Run every one of these commands in the worktree and record each in commandsRun with its real exit code:`,
1252
+ ...verify.map(v => ` $ ${v}`),
1253
+ confirming
1254
+ ? `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.`
1255
+ : `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.`,
1256
+ `Every blocker must name the file it is about, so it can go back to the task that owns that file.`,
1257
+ `A check that fails only because the environment denied it something the code needs (EPERM on a local listener, or a test that needs the same Codex that could not run for you) is an environment denial: it is inconclusive, not a blocker; name it in the summary and do not report it as an issue.`,
1258
+ ...BLOCKER_GATE,
1259
+ `Return: taskId="level-${li}", ran=true, error=null; verdict "fail" only when at least one issue is a "blocker" (a spec or an acceptance criterion not met, or a verify command exiting non-zero, for a reason in the code) - an issue that is only "major" or "minor" passes with notes; "pass" when you could not refute the changes, "inconclusive" only when the environment kept you from checking; issues with severity "blocker" for anything that must keep the level out, each with file, line, and evidence. Do not edit any file.`,
1260
+ ].join('\n')
1261
+ }
1262
+
1263
+ // The reviewer does not see the verifier's result: the two run together on a first pass and each judges the diff on
1264
+ // its own; the focused check on a fix pass is the one that reads both.
1265
+ // seat > 1 is a second pair of eyes launched in parallel with the first (card crew-sizing): it is told what a first
1266
+ // reviewer reading spec line by line is likely to miss, so two reviewers do not file the same finding twice.
1267
+ function reviewPrompt(task, impl, baseBranch, seat = 1) {
1268
+ return [
1269
+ `You are a reviewer. Review the diff ${baseBranch}...${impl.branch} in worktree ${impl.worktreePath} against the task spec. Two passes: spec compliance first, then quality. ${baseBranch} is this branch's base (an integration branch when the task follows an earlier level); files that already exist there are not this task's changes.`,
1270
+ ...(seat > 1 ? [
1271
+ ``,
1272
+ `You are reviewer ${seat} of ${seat}: another reviewer is reading this diff at the same time with the standard brief and will check the spec sentence by sentence and the owned-file list. Do not repeat that work. Concentrate on what a first reviewer is likely to miss: behavior on inputs the spec does not name, a test that passes for the wrong reason or asserts less than its name says, error paths and partial failure, concurrency and ordering, a call site elsewhere in the repository that the change silently breaks, and anything the spec implies but does not state. File only what you have shown; your findings are merged with the other reviewer's, and a duplicate collapses to one entry.`,
1273
+ ] : []),
1274
+ ``,
1275
+ `Task ${task.id}: ${task.title}`,
1276
+ `Spec:`,
1277
+ task.spec,
1278
+ `Owned files: ${task.files.join(', ')}`,
1279
+ `Plan acceptance criteria:`,
1280
+ ...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
1281
+ ``,
1282
+ `Pass 1, spec compliance: does the diff do what the spec says, all of it, and nothing else? Any file outside the owned list makes inScope=false.`,
1283
+ `Pass 2, quality: correctness risks, missing tests for new behavior, error handling, naming consistent with surrounding code. Do not request refactors beyond the task.`,
1284
+ `approve=true only if specCompliant, inScope, and there are no blocker issues. Do not edit any file.`,
1285
+ `An issue whose description starts with "${CONTRADICTION_MARKER}: " means the spec and an acceptance criterion cannot both hold; use that prefix only for that case, because the workflow sends it back to the planner instead of relaunching the implementer.`,
1286
+ ].join('\n')
1287
+ }
1288
+
1289
+ function shellQuote(s) {
1290
+ return `'${String(s).replace(/'/g, `'\\''`)}'`
1291
+ }
1292
+
1293
+ // The confirmation section a fix pass adds to an adversary prompt: the reviewer stops exploring and confirms what
1294
+ // the ledger already holds, so pass after pass cannot each raise a fresh unrelated blocker.
1295
+ function confirmationLines(pass, ledger, impl) {
1296
+ const open = ledgerText(ledger, 'open')
1297
+ const done = ledgerText(ledger, 'fixed')
1298
+ return [
1299
+ ``,
1300
+ `Confirmation pass ${pass}`,
1301
+ `This is not a fresh exploration. Earlier passes of this task already raised the findings below; your job is to say whether they are fixed and whether the fix broke anything.`,
1302
+ `Open findings, in full:`,
1303
+ open || `(none)`,
1304
+ `Already fixed on an earlier pass (report one only if it came back):`,
1305
+ done || `(none)`,
1306
+ `Confirm each open finding one by one, and check the fix delta git diff ${impl.prevCommit || 'the previous pass commit'}..${impl.branch} for regressions.`,
1307
+ `Name each listed finding once, starting with its id: "F3 fixed: <what shows it>" when it is fixed, or "F3: still returns 1" when it is not; the workflow reads the first words after the id, and a finding that names an id is never counted as open unless it says so. A finding you re-report at any severity keeps its entry open.`,
1308
+ `A new blocker that is not in the list above is allowed only for a demonstrated security, data-loss, destructive, or corruption defect. Report every other new observation as minor.`,
1309
+ ]
1310
+ }
1311
+
1312
+ function adversaryPrompt(task, impl, baseBranch, pass, ledger) {
1313
+ const verify = [task.verify, ...(plan.verify || [])].filter(Boolean)
1314
+ const guard = s => String(s).replace(/^DOUG_SPEC_EOF$/gm, 'DOUG_SPEC_EOF ')
1315
+ const command = [
1316
+ `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('')}`,
1317
+ `Task ${task.id}: ${task.title}`,
1318
+ ``,
1319
+ guard(task.spec),
1320
+ ``,
1321
+ ...(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.`]),
1322
+ ``,
1323
+ `Note to the reviewer: codex-review sets DOUG_CODEX_REVIEW=1 in your environment. A check that would spawn codex-review cannot run inside it, so tests that spawn it skip when that variable is set and name it as the reason. Such a skip is expected and is not a weakened, disabled, or skipped test; do not report it as an issue. Any other skip is still suspect.`,
1324
+ `A check that fails only because your sandbox denied it something the code needs from the environment, such as EPERM when a test listens on 127.0.0.1, is an environment denial: it is inconclusive, not a blocker, so name it in the summary and do not report it as an issue or fail the review on it.`,
1325
+ ...BLOCKER_GATE,
1326
+ `DOUG_SPEC_EOF`,
1327
+ ].join('\n')
1328
+ return [
1329
+ `Run the adversarial reviewer for task ${task.id} and relay its verdict. Do not review the code yourself and do not edit anything.`,
1330
+ ``,
1331
+ `Run exactly this command with the Bash tool (it pipes the spec on stdin; the timeout is ${adversary.timeoutMs} ms, so set the tool timeout at least that high):`,
1332
+ '```',
1333
+ command,
1334
+ '```',
1335
+ ``,
1336
+ `The command prints one JSON object and exits 0 (no blockers), 1 (fail verdict or blocker issue), or 2 (could not review). All three are normal; report what it printed.`,
1337
+ `Return: taskId="${task.id}", ran=true if JSON was printed, and verdict, summary, issues, commandsRun, error, usage, durationMs copied from the JSON (error = "<kind>: <message>" or null; usage and durationMs copied as printed, or null when the JSON carried none).`,
1338
+ `If the command itself is not found, return ran=false, verdict="inconclusive", error="codex-review not found: <what the shell said>".`,
1339
+ ].join('\n')
1340
+ }
1341
+
1342
+ // What an adversary review that ran counts as blocking (card fix-loop-minor-verdict): a blocker issue, or a fail
1343
+ // verdict with an empty issues array - the one case that leaves the fix loop nothing else to point at, so
1344
+ // stageFindings synthesizes a blocker finding from the summary. Before this, a fail with only major or minor
1345
+ // issues synthesized that same summary-only finding too, so the fix pass was briefed with one un-actionable
1346
+ // finding - the reviewer's prose, naming no file or line - not zero: it could not act on it, made no commit, and
1347
+ // the task never converged (three runs on card memory-outcomes, 2026-09-07). Such a fail is now pass-with-notes
1348
+ // instead and does not block.
1349
+ function adversaryBlocking(adv) {
1350
+ const issues = (adv && adv.issues) || []
1351
+ if (issues.some(i => i.severity === 'blocker')) return true
1352
+ return !!(adv && adv.verdict === 'fail' && issues.length === 0)
1353
+ }
1354
+
1355
+ function adversaryOk(adv, configured = false) {
1356
+ // With an adversary configured, a review that did not run blocks: the plan promised a second model's
1357
+ // verdict before integration, and "absent" must not read as "passed". plan.adversary: false is the only
1358
+ // way to skip it. An inconclusive verdict that ran does not block; only a blocking verdict does (adversaryBlocking).
1359
+ if (configured && (!adv || !adv.ran)) return false
1360
+ if (!adv || !adv.ran) return true
1361
+ return !adversaryBlocking(adv)
1362
+ }
1363
+
1364
+ function adversaryUnavailable(adv) {
1365
+ // codex-review exits 2 and sets error when it could not review at all (not installed, codex failed, timed out,
1366
+ // unparseable output); that is what the fallback stands in for. A review that ran but could not decide (an
1367
+ // environment denial, say) has error null and is not a reason to fall back. A worktree the reviewer modified is
1368
+ // corrupted evidence, not unavailability: it stays blocked.
1369
+ if (!adv || !adv.ran) return !(adv && adv.error && /^worktree-modified\b/.test(adv.error))
1370
+ return adv.verdict === 'inconclusive' && !!adv.error && !/^worktree-modified\b/.test(adv.error)
1371
+ }
1372
+
1373
+ function fallbackAdversaryPrompt(task, impl, baseBranch, why, pass, ledger) {
1374
+ const verify = [task.verify, ...(plan.verify || [])].filter(Boolean)
1375
+ return [
1376
+ `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.`,
1377
+ ``,
1378
+ `Task ${task.id}: ${task.title}`,
1379
+ `Spec:`,
1380
+ task.spec,
1381
+ `Owned files: ${task.files.join(', ')}`,
1382
+ `Plan acceptance criteria:`,
1383
+ ...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
1384
+ ...(pass > 1 ? confirmationLines(pass, ledger, impl) : []),
1385
+ ``,
1386
+ `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):`,
1387
+ ...verify.map(v => ` $ ${v}`),
1388
+ `A blocker's evidence must name one of those commands, and either that command failed or the evidence quotes a line of its outputTail; otherwise the loop records it as major.`,
1389
+ pass > 1
1390
+ ? `Then confirm the open findings above and check the fix delta for regressions rather than exploring afresh.`
1391
+ : `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.`,
1392
+ `A check that fails only because the environment denied it something the code needs (EPERM on a local listener, or a test that needs the same Codex that could not run for you) is an environment denial: it is inconclusive, not a blocker; name it in the summary and do not report it as an issue.`,
1393
+ ...BLOCKER_GATE,
1394
+ `Return: taskId="${task.id}", ran=true, error=null; verdict "fail" only when at least one issue is a "blocker" (the spec or an acceptance criterion not met, or a verify command exiting non-zero, for a reason in the code) - an issue that is only "major" or "minor" passes with notes; "pass" when you could not refute the change, "inconclusive" only when the environment kept you from checking; issues with severity "blocker" for anything that must keep the change out of integration, each with file, line, and evidence (the command and output that shows it).`,
1395
+ ].join('\n')
1396
+ }
1397
+
1398
+ function integratePrompt(levelIndex, results, baseBranch, isLastLevel, keepWorktree, firstIntegration) {
1399
+ const branches = results.map(r => r.impl.branch)
1400
+ const worktrees = results.map(r => r.impl.worktreePath)
1401
+ return [
1402
+ `You are the integration owner for level ${levelIndex} of plan "${plan.title}".`,
1403
+ `Integration branch: ${integrationBranch}. Never check it out in the user's main working tree; use a dedicated worktree so the user's checkout is left exactly as it was.`,
1404
+ PARTIAL_NEVER_MERGED,
1405
+ `Merge these task branches into it, in this order: ${branches.join(', ')}.`,
1406
+ `They are local branches of this repository (each was made in its own worktree); no fetch is needed.`,
1407
+ ``,
1408
+ `Steps:`,
1409
+ `1. From the repository root, find or create the integration worktree at .claude/worktrees/doug-integration:`,
1410
+ ...(firstIntegration
1411
+ ? [
1412
+ ` this is the run's first integration; the worktree may be a leftover of an earlier run and is never reused as is: if it exists, git worktree remove --force .claude/worktrees/doug-integration (then git worktree prune).`,
1413
+ ` In every case create it fresh: git worktree add -B ${integrationBranch} .claude/worktrees/doug-integration ${baseBranch}`,
1414
+ ]
1415
+ : [
1416
+ ` if it does not exist: git worktree add -B ${integrationBranch} .claude/worktrees/doug-integration ${baseBranch}`,
1417
+ ` if it exists (a previous level made it): use it as is; it already has ${integrationBranch} checked out.`,
1418
+ ]),
1419
+ ` Do every following git and verify command inside that worktree (git -C or cd into it).`,
1420
+ `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
+ `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.`,
1422
+ // Card integration-acceptance-recorded: only the last level runs the plan's acceptance commands, and only
1423
+ // after the merges above - the plan is not complete at an earlier level, so its prompt never mentions this.
1424
+ ...(isLastLevel
1425
+ ? [
1426
+ `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}`]),
1428
+ `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
+ `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
+ `A criterion with no $ line is not yours to judge here: do not report it.`,
1431
+ ]
1432
+ : []),
1433
+ `4. If every merge succeeded, remove the merged task worktrees to leave the repository tidy: git worktree remove --force <path> for each of ${worktrees.join(', ')}. Keep their branches.`,
1434
+ isLastLevel && !keepWorktree ? `5. This is the last level. Also remove the integration worktree (git worktree remove --force .claude/worktrees/doug-integration); the branch ${integrationBranch} keeps the result.` : isLastLevel ? `5. This is the last level, but keep the integration worktree: the adversary reviews the merged size-S tasks there, and plan.mjs land reuses and removes it.` : `5. Keep the integration worktree; the next level merges into it.`,
1435
+ isLastLevel
1436
+ ? `6. ok=true only if all merges succeeded, all verify commands passed, and every acceptance command exited 0. Do not push. Do not check out anything in the main working tree.`
1437
+ : `6. ok=true only if all merges succeeded and all verify commands passed. Do not push. Do not check out anything in the main working tree.`,
1438
+ ].join('\n')
1439
+ }
1440
+
1441
+ // ---- Task loop -------------------------------------------------------------------
1442
+ // One task from start to a verdict: implement (or check out a reused branch), then verify, review and the
1443
+ // 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).
1446
+ async function startTask(task, baseBranch, launch = agent) {
1447
+ if (task.reuse) return launch(reusePrompt(task, baseBranch), { label: `reuse:${task.id}`, phase: 'Implement', isolation: 'worktree', schema: IMPLEMENT_SCHEMA, model: CHECKOUT_MODEL })
1448
+ if (swarmApplies(task)) return runSwarm(task, baseBranch, launch)
1449
+ const first = await launch(implementPrompt(task, baseBranch), agentOpts('implement', task, { label: `implement:${task.id}`, phase: 'Implement', isolation: 'worktree', agentType: 'doug-flow:implementer', schema: IMPLEMENT_SCHEMA }), implementPrompt(task, baseBranch, { resume: true }))
1450
+ if (!first || first.blocked || first.partial !== true) return first
1451
+ if (!partialOf(first)) return blockedPartial(first)
1452
+ return resumePartialOnce(first, handoff => {
1453
+ log(`${task.id} returned partial (next: ${handoff.next}); resuming once`)
1454
+ return launch(resumePrompt(task, first, handoff, baseBranch), agentOpts('implement', task, { label: `resume:${task.id}`, phase: 'Implement', agentType: 'doug-flow:implementer', schema: IMPLEMENT_SCHEMA }))
1455
+ })
1456
+ }
1457
+
1458
+ // ---- Partial results (card worker-context-handoff) -------------------------------
1459
+ // The handoff a partial result carries, only when it is usable (rule 3): present, and remaining/next/verify are
1460
+ // non-empty. null means the claim cannot be resumed - the caller blocks instead of retrying it.
1461
+ function partialOf(res) {
1462
+ if (!res || res.partial !== true) return null
1463
+ const h = res.handoff
1464
+ if (!h || typeof h !== 'object') return null
1465
+ const remaining = Array.isArray(h.remaining) ? h.remaining.filter(s => typeof s === 'string' && s.trim()) : []
1466
+ if (!remaining.length) return null
1467
+ if (typeof h.next !== 'string' || !h.next.trim()) return null
1468
+ if (typeof h.verify !== 'string' || !h.verify.trim()) return null
1469
+ return h
1470
+ }
1471
+
1472
+ // Rule 3: a partial with no usable handoff is never resumed; it is blocked like any other implementer block, not
1473
+ // retried by the fix loop.
1474
+ function blockedPartial(res) {
1475
+ return { ...res, partial: false, blocked: true, blockedReason: 'partial without a handoff' }
1476
+ }
1477
+
1478
+ // Rule 2: a usable partial is resumed exactly once, by `doResume` (which the caller wires to the right prompt: the
1479
+ // task's own, or a swarm worker's). A result with no usable partial passes through unchanged. `resumed` and
1480
+ // `partialHandoff` land on the object the rest of the pipeline reads as the implementer's result; a still-partial
1481
+ // resumed result keeps its own `partial`/`handoff` so the caller can recognize and block it (rule 2's second half).
1482
+ async function resumePartialOnce(res, doResume) {
1483
+ const handoff = partialOf(res)
1484
+ if (!handoff) return res
1485
+ const resumed = await doResume(handoff)
1486
+ if (!resumed) return { ...res, resumed: 1, blocked: true, blockedReason: 'resume returned nothing (spawn failure or no structured output)' }
1487
+ return { ...resumed, resumed: 1, ...(resumed.partial === true ? {} : { partialHandoff: handoff }) }
1488
+ }
1489
+ // ---- Swarm inside a task (card swarm-lead) --------------------------------------
1490
+ // With plan.swarm on, a full-shape task's implementer stage becomes: a lead splits the task into worker briefs, the
1491
+ // workers implement them in parallel in their own worktrees on the worker row, and the lead merges their branches
1492
+ // into the task branch. What comes out is an implementer result, so the verify, review, adversary, ledger, budget,
1493
+ // and integrate stages run unchanged on it; a fix pass is one implementer in the lead's worktree, as it is today.
1494
+ // Scope never widens: a brief that names a file the task does not own blocks the task before any worker runs.
1495
+
1496
+ // A test file, by path: a test/tests/__tests__/spec directory segment, a tests/ prefix, or a .test./.spec./_test./
1497
+ // Test. basename infix. Card swarm-topology's shape gate counts everything else as a source file.
1498
+ function isTestFile(p) {
1499
+ const path = String(p)
1500
+ if (/(^|\/)(test|tests|__tests__|spec)\//.test(path)) return true
1501
+ const base = path.slice(path.lastIndexOf('/') + 1)
1502
+ return /\.test\.|\.spec\.|_test\.|Test\./.test(base)
1503
+ }
1504
+
1505
+ // Card swarm-topology (candidate 1), the shape gate: a task splits only when it owns two or more source files (not
1506
+ // counting their tests). One source file plus its tests is a single deliverable no lead can usefully divide, so it
1507
+ // runs as a single implementer even with swarm on. Deterministic: no tokens spent deciding.
1508
+ function splittable(task) {
1509
+ const files = Array.isArray(task.files) ? task.files : []
1510
+ return files.filter(f => !isTestFile(f)).length >= 2
1511
+ }
1512
+
1513
+ function swarmApplies(task) {
1514
+ if (plan.swarm !== true || task.reuse || taskShape(task) === 'S') return false
1515
+ if (!splittable(task)) {
1516
+ log(`${task.id} shape gate: one source file and its tests; runs as a single implementer, not a lead`)
1517
+ return false
1518
+ }
1519
+ return true
1520
+ }
1521
+
1522
+ // The report's per-task record of why a task did or did not run as a swarm (card swarm-topology): reason mirrors
1523
+ // the shape gate's own precedence (a reused task never reaches the gate, a size-S task keeps its own shape, then
1524
+ // the file count), and splitReason/briefs are the lead's own words and count, only when the task actually swarmed.
1525
+ function swarmReport(task, impl) {
1526
+ if (plan.swarm !== true) return null
1527
+ const reason = task.reuse ? 'reuse' : taskShape(task) === 'S' ? 'size S' : !splittable(task) ? 'one source file and its tests' : 'two or more source files'
1528
+ // Read off reason rather than calling swarmApplies again: swarmApplies logs the shape-gate line itself, and this
1529
+ // report is built for every task after startTask already ran it once - a second call would double the log.
1530
+ const applies = reason === 'two or more source files'
1531
+ return {
1532
+ applies,
1533
+ reason,
1534
+ splitReason: applies && impl ? impl.splitReason || null : null,
1535
+ // The first split's own brief count (candidate 2 review, minor 6): round-one workers only, so a re-brief that
1536
+ // adds round-two workers does not inflate what was actually split at the start; a result with no `round` field
1537
+ // (older shape) falls back to every worker, as briefs did before rounds existed.
1538
+ briefs: applies && impl && Array.isArray(impl.workers)
1539
+ ? (impl.workers.some(w => w && w.round !== undefined) ? impl.workers.filter(w => w.round === 1).length : impl.workers.length)
1540
+ : null,
1541
+ // Card swarm-topology (candidate 2): the lead's one re-brief, when the first split left a worker blocked; null
1542
+ // when the task never needed one.
1543
+ rebrief: applies && impl ? impl.rebrief || null : null,
1544
+ // Card swarm-topology (candidate 3): whether the plan opted into the deterministic per-worker check, and which
1545
+ // worker ids (if any) failed it; [] when the check never ran or found nothing.
1546
+ workerCheck: plan.workerCheck === true,
1547
+ checkFailed: applies && impl && Array.isArray(impl.checkFailed) ? impl.checkFailed : [],
1548
+ }
1549
+ }
1550
+
1551
+ async function runSwarm(task, baseBranch, launch) {
1552
+ const stages = ['lead']
1553
+ const workers = []
1554
+ // Card swarm-topology (candidate 2, review): `blocked` reads `lead` and `rebrief` at call time (both declared
1555
+ // with `let` below), so a block after the lead has answered - including a second-round block - still carries
1556
+ // the lead's own branch/worktree and splitReason, and the re-brief record when there was one, into the result
1557
+ // swarmReport reads.
1558
+ let lead = null
1559
+ let rebrief = null
1560
+ const blocked = (reason, extra) => ({ taskId: task.id, branch: `doug/task-${task.id}`, worktreePath: '', filesTouched: [], commandsRun: [], summary: reason, blocked: true, blockedReason: reason, commit: null, swarmStages: stages, workers, ...(lead && lead.splitReason ? { splitReason: lead.splitReason } : {}), ...(rebrief ? { rebrief } : {}), checkFailed: workers.filter(w => w.checkFailed).map(w => w.id), ...(extra || {}) })
1561
+ lead = await launch(leadPrompt(task, baseBranch), agentOpts('lead', null, { label: `lead:${task.id}`, phase: 'Implement', isolation: 'worktree', agentType: 'doug-flow:lead', schema: LEAD_SCHEMA }), leadPrompt(task, baseBranch, { resume: true }))
1562
+ if (!lead) return blocked('lead returned nothing (spawn failure or no structured output)')
1563
+ if (lead.blocked) return blocked(`lead blocked: ${lead.blockedReason || 'no reason given'}`)
1564
+ if (!lead.splitReason || !String(lead.splitReason).trim()) return blocked('lead gave no splitReason')
1565
+ const briefs = Array.isArray(lead.briefs) ? lead.briefs : []
1566
+ if (!briefs.length) return blocked('lead returned no worker briefs')
1567
+ const seen = new Set()
1568
+ for (const b of briefs) {
1569
+ const files = Array.isArray(b.files) ? b.files : []
1570
+ if (!files.length) return blocked(`lead brief ${b.id} owns no file`)
1571
+ for (const f of files) {
1572
+ if (!task.files.some(o => samePath(o, f))) return blocked(`lead brief ${b.id} names a file the task does not own: ${f}`)
1573
+ if ([...seen].some(s => samePath(s, f))) return blocked(`lead briefs overlap on ${f}`)
1574
+ seen.add(f)
1575
+ }
1576
+ }
1577
+ log(`${task.id} lead split the task into ${briefs.length} brief${briefs.length === 1 ? '' : 's'}: ${briefs.map(b => b.id).join(', ')}`)
1578
+
1579
+ // One round of workers for a list of briefs, numbered from startN (card swarm-topology candidate 2): shared by
1580
+ // the first split and the one re-brief round, partial-resume included, so a re-briefed brief runs exactly like
1581
+ // an original one. `leadView` is what workerPrompt/resumePrompt see as "the lead": round one passes the lead
1582
+ // itself; round two (review MAJOR 1) passes a view whose briefs/interfaces are the surviving picture - the
1583
+ // finished round-one briefs plus the revised ones, never a replaced brief - so a round-two worker's siblings and
1584
+ // interfaces reflect what is actually still running, not the abandoned first split.
1585
+ const runRound = async (briefList, round, startN, leadView = lead) => {
1586
+ const results = await parallel(briefList.map((b, i) => async () => {
1587
+ const n = startN + i
1588
+ stages.push(`worker-${n}`)
1589
+ const w = await launch(workerPrompt(task, b, n, leadView), agentOpts('worker', null, { label: `worker:${task.id}:${n}`, phase: 'Implement', isolation: 'worktree', agentType: 'doug-flow:implementer', schema: IMPLEMENT_SCHEMA }))
1590
+ if (!w || w.blocked || w.partial !== true) return w
1591
+ if (!partialOf(w)) return blockedPartial(w)
1592
+ return resumePartialOnce(w, handoff => {
1593
+ log(`${task.id} worker ${n} (${b.id}) returned partial (next: ${handoff.next}); resuming once`)
1594
+ // The worker's own basis is the lead's branch (workerPrompt's own filesTouched line uses it, not the
1595
+ // task's baseBranch), so a resumed worker's filesTouched is computed the same way.
1596
+ return launch(resumePrompt(task, w, handoff, leadView.branch, b), agentOpts('worker', null, { label: `resume:${task.id}:${n}`, phase: 'Implement', agentType: 'doug-flow:implementer', schema: IMPLEMENT_SCHEMA }))
1597
+ })
1598
+ }))
1599
+ return briefList.map((b, i) => {
1600
+ const n = startN + i
1601
+ const w = results ? results[i] : null
1602
+ // Rule 4: a worker still partial after its one resume blocks the task like any other worker block, so the
1603
+ // merge never launches on it, and it never reaches the re-brief either.
1604
+ const stillPartial = !!(w && w.partial === true)
1605
+ const stillPartialReason = () => `partial after resume: ${(w.handoff && Array.isArray(w.handoff.remaining) ? w.handoff.remaining.join('; ') : '')}`
1606
+ return {
1607
+ n,
1608
+ id: b.id,
1609
+ title: b.title,
1610
+ files: b.files,
1611
+ round,
1612
+ branch: w && w.branch ? w.branch : null,
1613
+ worktreePath: w && w.worktreePath ? w.worktreePath : null,
1614
+ commit: w ? w.commit || null : null,
1615
+ filesTouched: w ? w.filesTouched || [] : [],
1616
+ commandsRun: w ? w.commandsRun || [] : [],
1617
+ blocked: !w || !!w.blocked || stillPartial,
1618
+ blockedReason: !w ? 'worker returned nothing (spawn failure or no structured output)' : stillPartial ? stillPartialReason() : w.blocked ? w.blockedReason || 'no reason given' : null,
1619
+ partial: stillPartial,
1620
+ handoff: stillPartial ? w.handoff || null : null,
1621
+ resumed: w ? w.resumed || 0 : 0,
1622
+ }
1623
+ })
1624
+ }
1625
+
1626
+ // Card swarm-topology (candidate 3): a deterministic per-worker check before the merge, behind plan.workerCheck -
1627
+ // no agent spawned, no tokens spent. Every file a worker touched must be one of its own brief's files, and it
1628
+ // must have committed; a failure is marked blocked the same way an implementer's own block is, so it takes the
1629
+ // same one-re-brief-then-block path as any other block. Runs after every round, not only the first, so a
1630
+ // round-two worker that widens is caught before the merge as well.
1631
+ const checkWorkers = round => {
1632
+ stages.push('worker-check')
1633
+ for (const w of round) {
1634
+ if (w.blocked || w.partial) continue
1635
+ const outside = (w.filesTouched || []).find(f => !w.files.some(o => samePath(o, f)))
1636
+ const reason = outside !== undefined ? `worker check: touched a file outside its brief: ${outside}` : !w.commit ? 'worker check: no commit' : null
1637
+ if (!reason) continue
1638
+ w.blocked = true
1639
+ w.blockedReason = reason
1640
+ w.checkFailed = true
1641
+ }
1642
+ }
1643
+
1644
+ const round1 = await runRound(briefs, 1, 1)
1645
+ workers.push(...round1)
1646
+ if (plan.workerCheck === true) checkWorkers(round1)
1647
+ const round1Blocked = round1.filter(w => w.blocked)
1648
+ if (round1Blocked.length) {
1649
+ const reason = `worker${round1Blocked.length === 1 ? '' : 's'} blocked: ${round1Blocked.map(w => `${w.n} (${w.id}): ${w.blockedReason}`).join('; ')}`
1650
+ // Rule 4: a worker still partial after its resume blocks the task exactly as before - no re-brief attempt.
1651
+ if (round1Blocked.some(w => w.partial)) return blocked(reason, { worktreePath: lead.worktreePath, branch: lead.branch })
1652
+ // Rule 2: one re-brief, in the lead's existing worktree (no isolation key, no new worktree), same schema as the
1653
+ // first split.
1654
+ stages.push('lead-rebrief')
1655
+ 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 }))
1656
+ const leadFail = !revised
1657
+ ? 'lead rebrief returned nothing (spawn failure or no structured output)'
1658
+ : revised.blocked
1659
+ ? `lead rebrief blocked: ${revised.blockedReason || 'no reason given'}`
1660
+ : !Array.isArray(revised.briefs) || !revised.briefs.length
1661
+ ? 'lead rebrief returned no worker briefs'
1662
+ : !revised.splitReason || !String(revised.splitReason).trim()
1663
+ ? 'lead rebrief gave no splitReason'
1664
+ : null
1665
+ // Rule 4: a lead-rebrief that cannot be used blocks the task with the original workers' reasons plus the
1666
+ // lead's own, and no second-round worker ever launches. Neither `rebriefed` nor `rebrief` is set here (review
1667
+ // minor 3 and 4): the lead never actually produced a usable re-brief, so there is nothing to record as one.
1668
+ if (leadFail) return blocked(`${reason}; ${leadFail}`, { worktreePath: lead.worktreePath, branch: lead.branch })
1669
+ // Rule 3: the revised briefs are checked the same deterministic way as the first split, plus the one rule the
1670
+ // first split has no need of - every file in a revised brief is a file of a blocked brief, never a file a
1671
+ // finished worker already committed and never a file outside the task (both implied by that one check, since
1672
+ // the first split already kept the blocked briefs' files disjoint from the finished ones').
1673
+ const blockedFiles = round1Blocked.flatMap(w => w.files)
1674
+ const seen2 = new Set()
1675
+ for (const b of revised.briefs) {
1676
+ const files = Array.isArray(b.files) ? b.files : []
1677
+ if (!files.length) return blocked(`${reason}; lead rebrief ${b.id} owns no file`, { worktreePath: lead.worktreePath, branch: lead.branch })
1678
+ for (const f of files) {
1679
+ if (!blockedFiles.some(o => samePath(o, f))) return blocked(`${reason}; lead rebrief ${b.id} names a file not from a blocked brief: ${f}`, { worktreePath: lead.worktreePath, branch: lead.branch })
1680
+ if ([...seen2].some(s => samePath(s, f))) return blocked(`${reason}; lead rebrief briefs overlap on ${f}`, { worktreePath: lead.worktreePath, branch: lead.branch })
1681
+ seen2.add(f)
1682
+ }
1683
+ }
1684
+ // The revised briefs passed every check: this is now a real re-brief, on the record whatever round two does
1685
+ // (review minor 3 and 4) - a round-two block still reports why the task re-briefed at all.
1686
+ for (const w of round1Blocked) w.rebriefed = true
1687
+ rebrief = { reason: revised.splitReason, briefs: revised.briefs.length }
1688
+ log(`${task.id} lead re-briefed ${round1Blocked.length} blocked brief${round1Blocked.length === 1 ? '' : 's'} into ${revised.briefs.length} revised brief${revised.briefs.length === 1 ? '' : 's'}: ${revised.briefs.map(b => b.id).join(', ')}`)
1689
+ const finishedBriefs = briefs.filter(b => round1.some(w => !w.blocked && w.id === b.id))
1690
+ const round2Lead = { ...lead, briefs: [...finishedBriefs, ...revised.briefs], interfaces: typeof revised.interfaces === 'string' && revised.interfaces ? revised.interfaces : lead.interfaces }
1691
+ const round2 = await runRound(revised.briefs, 2, workers.length + 1, round2Lead)
1692
+ workers.push(...round2)
1693
+ if (plan.workerCheck === true) checkWorkers(round2)
1694
+ const round2Blocked = round2.filter(w => w.blocked)
1695
+ // Rule 4: a second block ends the task as today - no merge launches.
1696
+ if (round2Blocked.length) return blocked(`worker${round2Blocked.length === 1 ? '' : 's'} blocked: ${round2Blocked.map(w => `${w.n} (${w.id}): ${w.blockedReason}`).join('; ')}`, { worktreePath: lead.worktreePath, branch: lead.branch })
1697
+ }
1698
+ stages.push('lead-merge')
1699
+ // Rule 3: the merge prompt lists only workers that are not blocked - finished round-one workers plus round-two
1700
+ // workers, in the order they were pushed. `workers` itself (review minor 5) still goes to leadMergePrompt as the
1701
+ // worktree-removal list, blocked ones included, so a blocked worker's worktree is not left an orphan.
1702
+ const finished = workers.filter(w => !w.blocked)
1703
+ const merged = await launch(leadMergePrompt(task, lead, finished, baseBranch, workers), agentOpts('lead', null, { label: `lead-merge:${task.id}`, phase: 'Implement', agentType: 'doug-flow:lead', schema: IMPLEMENT_SCHEMA }))
1704
+ if (!merged) return blocked('lead merge returned nothing (spawn failure or no structured output)', { worktreePath: lead.worktreePath, branch: lead.branch })
1705
+ 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) }
1706
+ }
1707
+
1708
+
1709
+ // Tokens and wall time as the Workflow runtime reports them, or nulls. The primitive is optional and its shape is
1710
+ // not this workflow's to guarantee, so every reading is guarded and nothing here ever throws: the binding may be
1711
+ // missing and each property read may itself throw (a getter, a proxy), so the read sits inside the try as well.
1712
+ function runtimeSpend() {
1713
+ const num = key => {
1714
+ try {
1715
+ const b = typeof budget !== 'undefined' ? budget : null
1716
+ if (!b || typeof b !== 'object') return null
1717
+ const v = b[key]
1718
+ return typeof v === 'number' && Number.isFinite(v) ? v : null
1719
+ } catch (e) {
1720
+ return null
1721
+ }
1722
+ }
1723
+ let tokens = num('spent')
1724
+ if (tokens === null) {
1725
+ const total = num('total')
1726
+ const remaining = num('remaining')
1727
+ tokens = total !== null && remaining !== null ? total - remaining : null
1728
+ }
1729
+ return { tokens, elapsedMs: num('elapsedMs') }
1730
+ }
1731
+
1732
+ // Two paths name the same file when they are equal, when one is the other shortened from the left (a verifier
1733
+ // writes lib/secret-rules.mjs for plugins/doug-gates/lib/secret-rules.mjs), or when the first is a directory the
1734
+ // second is under.
1735
+ function samePath(owned, p) {
1736
+ return owned === p || owned.endsWith('/' + p) || p.endsWith('/' + owned) || p.startsWith(owned + '/')
1737
+ }
1738
+
1739
+ // a/b/{c.js, d/e.js} in prose is a/b/c.js a/b/d/e.js.
1740
+ function expandBraces(text) {
1741
+ return String(text).replace(/((?:[\w.-]+\/)+)\{([^{}]+)\}/g, (m, prefix, list) => list.split(',').map(s => prefix + s.trim()).join(' '))
1742
+ }
1743
+
1744
+ // The repo-relative paths the blocking findings name. Structured first: review and adversary issue files, and the
1745
+ // verifier's findingFiles list, which it fills with existing repository files, so a finding with an entry is read
1746
+ // from the list, never from its text. Prose second: slash-separated tokens ending in an extension in the remaining
1747
+ // verifier findings, braces expanded; a token preceded by /, ., - or a word character belongs to a larger token (an
1748
+ // absolute worktree path, say) and is skipped. The workflow cannot look at the disk, so a prose path counts only
1749
+ // when the run already knows the file: a plan task owns it or the implementer touched it. Anything else in prose,
1750
+ // a fixture inside a temp project or a scratch file, is ignored rather than treated as outside the owned set.
1751
+ function findingPaths(r) {
1752
+ const out = []
1753
+ const add = p => {
1754
+ if (typeof p !== 'string') return
1755
+ const trimmed = p.trim()
1756
+ if (!trimmed) return
1757
+ out.push(trimmed.replace(/^\.\//, ''))
1758
+ }
1759
+ // Only the stages that blocked count: a passing verifier or reviewer may mention any path in prose. Same rule
1760
+ // as adversaryOk (a fail with only major/minor issues does not block); configured is irrelevant here since
1761
+ // r.adv.ran is already true.
1762
+ const advBlocked = r.adv && r.adv.ran && !adversaryOk(r.adv, true)
1763
+ for (const issue of (r.rev && !r.rev.approve ? r.rev.issues : []) || []) add(issue.file)
1764
+ for (const issue of (advBlocked ? r.adv.issues : []) || []) add(issue.file)
1765
+ if (!(r.ver && !r.ver.passed)) return out
1766
+ const listed = Array.isArray(r.ver.findingFiles) ? r.ver.findingFiles.filter(e => e && typeof e.finding === 'string' && Array.isArray(e.files)) : []
1767
+ for (const e of listed) for (const f of e.files) add(f)
1768
+ const known = [...(plan.tasks || []).flatMap(t => (Array.isArray(t.files) ? t.files : [])), ...((r.impl && r.impl.filesTouched) || [])].filter(f => typeof f === 'string')
1769
+ for (const finding of r.ver.findings || []) {
1770
+ if (typeof finding !== 'string' || listed.some(e => e.finding.trim() === finding.trim())) continue
1771
+ for (const m of expandBraces(finding).matchAll(/(?<![\w./-])(?:[\w.-]+\/)+[\w.-]+\.[A-Za-z0-9]+/g)) {
1772
+ const p = m[0].replace(/^\.\//, '')
1773
+ if (known.some(f => samePath(f, p))) add(p)
1774
+ }
1775
+ }
1776
+ return out
1777
+ }
1778
+
1779
+ function ownsPath(task, p) {
1780
+ // Plan state and worktree paths are never code a task owns.
1781
+ return task.files.some(f => samePath(f, p)) || p.startsWith('.doug/') || p.startsWith('.claude/')
1782
+ }
1783
+
1784
+ // A path another task of this plan owns. A verifier that names a sibling's file is describing work this plan has
1785
+ // already assigned to someone else - an acceptance criterion waiting on that task, most often - not this task
1786
+ // leaving its lane, so such a path must not turn a fixable block into a replan.
1787
+ function ownedBySibling(task, p) {
1788
+ return (plan.tasks || []).some(t => t.id !== task.id && Array.isArray(t.files) && ownsPath(t, p))
1789
+ }
1790
+
1791
+ // Can this block be fixed by the same implementer, or must a human see it?
1792
+ function retriable(task, r) {
1793
+ if (!r.impl) return { ok: false, reason: 'implementer returned nothing' }
1794
+ // A fix pass that returned no structured result changed nothing the loop knows of: the branch and worktree it
1795
+ // had are still there, so the same implementer is asked again.
1796
+ 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'}` }
1798
+ // A partial result is not a check failure, whether it came from the initial worker's one resume or from a fix
1799
+ // 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' }
1801
+ // Only a stage that actually ran can be missing; a fix pass deliberately skips the stages that did not block.
1802
+ 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' }
1806
+ const texts = [...((r.ver && r.ver.findings) || []), ...(((r.rev && r.rev.issues) || []).map(i => i.description))]
1807
+ const contradiction = texts.find(t => typeof t === 'string' && t.includes(CONTRADICTION_MARKER))
1808
+ if (contradiction) return { ok: false, reason: `spec contradicts acceptance: ${contradiction}` }
1809
+ 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'}` }
1812
+ 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(', ')}` }
1814
+ return { ok: true, reason: 'every finding is within the owned files' }
1815
+ }
1816
+
1817
+ function isReady(r) {
1818
+ // A task still partial after its one resume is never ready, whatever a check would say (card
1819
+ // worker-context-handoff, rule 2): graceful degradation is its own outcome, not a pass.
1820
+ if (r.impl && r.impl.partial) return false
1821
+ // A finding the ledger already recorded as fixed and that came back keeps the task out even when every stage
1822
+ // passed. This is deliberately unconditional: updateLedger reopens on any re-report matched by id or fingerprint,
1823
+ // whatever that re-report's own `blocking` flag or severity says - a passing check's free-text "F1: still returns
1824
+ // 1..." reopens F1 ("still reopens and keeps entries on a real re-report"), and so does a passing-verdict
1825
+ // adversary's *minor*-severity issue naming the same id ("blocks the pass when a finding the ledger recorded as
1826
+ // fixed comes back"). Gating this on the reporting finding's own blocking flag would silently let either of those
1827
+ // real reappearances through. A 'waived' report only stays out of the re-report list (card d-waiver-structural-rule)
1828
+ // when the ledger's own entry for that id is itself a self-declared non-blocking note (confirmsFinding above); a
1829
+ // disputed waiver against a blocking entry reaches here as an ordinary re-report and can reopen or keep an entry
1830
+ // open exactly the same way.
1831
+ if (r.reopened && r.reopened.length) return false
1832
+ // So does one no stage has confirmed fixed. A minor re-report keeps an entry open without blocking any stage, so
1833
+ // without this a task could integrate carrying a blocker the ledger still holds against it.
1834
+ if (r.openFindings && r.openFindings.length) return false
1835
+ return !!(r.impl && !r.impl.blocked && r.ver && r.ver.passed && r.rev && r.rev.approve && adversaryOk(r.adv, adversaryRequired(r.task)))
1836
+ }
1837
+
1838
+ function notReadyWhy(r) {
1839
+ if (!r.impl) return 'implementer returned nothing (agent errored, was skipped, or its agent type is not loaded)'
1840
+ if (r.impl.blocked) return `blocked: ${r.impl.blockedReason}`
1841
+ // A resumed worker's second partial names itself "after resume"; a partial declared mid-fix-loop was never
1842
+ // resumed at all (no fresh worker was ever launched for it), so it gets its own wording (card
1843
+ // worker-context-handoff, reviewer follow-up).
1844
+ if (r.impl.partial) {
1845
+ const remaining = r.impl.handoff && Array.isArray(r.impl.handoff.remaining) ? r.impl.handoff.remaining.join('; ') : ''
1846
+ return r.impl.resumed ? `partial after resume: ${remaining}` : `partial on a fix pass: ${remaining}`
1847
+ }
1848
+ if (r.noResult) return r.noResult
1849
+ if (r.reopened && r.reopened.length) return `a fixed finding reappeared: ${r.reopened.join(', ')}`
1850
+ if ((r.stages || []).includes('check') && !r.check) return 'focused check returned nothing'
1851
+ if (r.check && !(r.check.passed && r.rev && r.rev.approve)) return 'focused check failed'
1852
+ if (!r.ver || !r.ver.passed) return 'verification failed'
1853
+ if (!(r.rev && r.rev.approve)) return 'review rejected'
1854
+ if (!adversaryOk(r.adv, adversaryRequired(r.task))) return `adversary blocked: ${r.adv ? r.adv.summary : 'no adversary result'}`
1855
+ if (r.openFindings && r.openFindings.length) return `open finding: ${r.openFindings.join(', ')}`
1856
+ return 'not ready and no stage said why'
1857
+ }
1858
+
1859
+ // card fallback-adversary-blocker-evidence: R12 mirrored for the fallback adversary, whose structured result never
1860
+ // passes through packages/doug-codex/src/contract.ts (lines 34-99, the source of truth for the Codex-side check;
1861
+ // change both together). Differences from contract.ts, and only these: a commandsRun entry counts as failed when
1862
+ // `ok === false` or `exitCode` is a non-zero integer (the fallback's commandsRun requires ok but not exitCode), and
1863
+ // the description prefix names adversary-claude, not codex-review. Applied to the fallback's own result in
1864
+ // runAdversary, below, and to each crew seat 2+ in runAdversaryCrew (card crew-seat-blocker-evidence); never to
1865
+ // the primary Codex seat.
1866
+ function innerCommand(command) {
1867
+ const m = /^\/bin\/(?:ba|z)?sh\s+-l?c\s+'([\s\S]*)'$/.exec(String(command).trim())
1868
+ return (m ? m[1] : String(command)).trim()
1869
+ }
1870
+ function squash(s) {
1871
+ return String(s).replace(/\s+/g, ' ').trim()
1872
+ }
1873
+ // card fallback-evidence-output-citation: OUTPUT_CITATION_MIN_CHARS, citedCommands' third arm below, and the new
1874
+ // reason-B text in blockerEvidenceProblem mirror packages/doug-codex/src/contract.ts's output-citation rule, the
1875
+ // source of truth; change both together.
1876
+ // The floor for citing a command by its output alone (contract.ts's comment: a bare 4-char floor would let any
1877
+ // blocker cite almost any command once a command need not be named at all).
1878
+ const OUTPUT_CITATION_MIN_CHARS = 16
1879
+ // A commandsRun entry can be any odd shape the agent reported (R3 never throws): not an object, or command not a
1880
+ // string, counts as uncited rather than a crash (mirrors passCommands' own `if (!c || typeof c !== 'object')`).
1881
+ function citedCommands(evidence, commandsRun) {
1882
+ const ev = squash(evidence)
1883
+ return commandsRun.filter(c => {
1884
+ if (!c || typeof c !== 'object' || typeof c.command !== 'string') return false
1885
+ const whole = squash(c.command)
1886
+ const inner = squash(innerCommand(c.command))
1887
+ return (whole.length > 0 && ev.includes(whole)) || (inner.length >= 4 && ev.includes(inner)) || quotesOutput(evidence, c.outputTail, OUTPUT_CITATION_MIN_CHARS)
1888
+ })
1889
+ }
1890
+ function quotesOutput(evidence, outputTail, minChars = 4) {
1891
+ if (!outputTail) return false
1892
+ const ev = squash(evidence)
1893
+ return String(outputTail).split('\n').map(l => squash(l)).some(l => l.length >= minChars && ev.includes(l))
1894
+ }
1895
+ function commandFailed(c) {
1896
+ return !!c && (c.ok === false || (typeof c.exitCode === 'number' && Number.isInteger(c.exitCode) && c.exitCode !== 0))
1897
+ }
1898
+ function blockerEvidenceProblem(issue, commandsRun) {
1899
+ const evidence = typeof issue.evidence === 'string' ? issue.evidence.trim() : ''
1900
+ if (!evidence) return 'is a blocker with no evidence; a blocker cites a command in commandsRun that shows the failure'
1901
+ const cited = citedCommands(evidence, commandsRun)
1902
+ if (!cited.length) return "is a blocker whose evidence names no command in commandsRun and quotes no line of any command's output; static inspection alone is major at most"
1903
+ if (!cited.some(c => commandFailed(c) || quotesOutput(evidence, c.outputTail))) {
1904
+ // c.ok !== false is redundant here (commandFailed already ruled that out above); kept for defensive symmetry.
1905
+ const named = cited.map(c => `${JSON.stringify(innerCommand(c.command))}, which ${c.exitCode === null && c.ok !== false ? 'did not complete' : 'exited 0'}`).join(', ')
1906
+ return `cites ${named}, and quotes none of its output`
1907
+ }
1908
+ return null
1909
+ }
1910
+ function enforceFallbackEvidence(result) {
1911
+ const commandsRun = Array.isArray(result.commandsRun) ? result.commandsRun : []
1912
+ const downgraded = []
1913
+ const issues = (Array.isArray(result.issues) ? result.issues : []).map((issue, index) => {
1914
+ if (issue.severity !== 'blocker') return issue
1915
+ const reason = blockerEvidenceProblem(issue, commandsRun)
1916
+ if (!reason) return issue
1917
+ downgraded.push({ index, reason })
1918
+ return { ...issue, severity: 'major', description: `[adversary-claude: downgraded from blocker, R12: ${reason}] ${issue.description}` }
1919
+ })
1920
+ const stillBlocked = issues.some(it => it.severity === 'blocker')
1921
+ const verdict = result.verdict === 'fail' && downgraded.length > 0 && !stillBlocked ? 'pass' : result.verdict
1922
+ return { ...result, issues, verdict, ...(downgraded.length ? { downgraded } : {}) }
1923
+ }
1924
+
1925
+ // The second model's verdict, with the Claude fallback when codex-review could not review at all. Pushes what it
1926
+ // launched onto `stages` so the pass records every agent it spent.
1927
+ // subject, when given, replaces the task: { id, label, prompt(), fallbackPrompt(why) } for a level review.
1928
+ async function runAdversary(task, impl, pass, baseBranch, ledger, launch, stages, subject) {
1929
+ const suffix = pass > 1 ? `:${pass}` : ''
1930
+ const id = subject ? subject.id : task.id
1931
+ stages.push('adversary')
1932
+ let adv = await launch(subject ? subject.prompt() : adversaryPrompt(task, impl, baseBranch, pass, ledger), agentOpts('adversary', task, { label: subject ? subject.label : `adversary:${task.id}${suffix}`, phase: 'Adversary', agentType: 'doug-flow:adversary', schema: ADVERSARY_SCHEMA }))
1933
+ // A null result means the agent itself failed (could not spawn, or returned nothing): record that, do not pass it.
1934
+ if (adv === null) adv = { ran: false, verdict: 'inconclusive', blocked: true, summary: '', issues: [], commandsRun: [], error: 'adversary agent returned nothing (spawn failure or no structured output)' }
1935
+ // codex-review could not review: a Claude adversary stands in when the plan allows it, so a missing Codex does not block.
1936
+ if (adversary.fallback && adversaryUnavailable(adv)) {
1937
+ const why = adv.error || 'no reason given'
1938
+ log(`${id} adversary could not run (${why}); falling back to a Claude adversary on ${adversary.fallback.model || 'the session model'}`)
1939
+ const opts = { label: subject ? `${subject.label}-fallback` : `adversary-fallback:${task.id}${suffix}`, phase: 'Adversary', agentType: 'doug-flow:adversary-claude', schema: ADVERSARY_SCHEMA }
1940
+ if (adversary.fallback.model) opts.model = adversary.fallback.model
1941
+ if (adversary.fallback.effort) opts.effort = adversary.fallback.effort
1942
+ stages.push('adversary-fallback')
1943
+ const fb = await launch(subject ? subject.fallbackPrompt(why) : fallbackAdversaryPrompt(task, impl, baseBranch, why, pass, ledger), opts)
1944
+ const used = { model: adversary.fallback.model || 'inherit', effort: adversary.fallback.effort || 'inherit', reason: why }
1945
+ // A fallback that returns nothing is still an absent review: record it, do not pass it.
1946
+ adv = fb ? { ...enforceFallbackEvidence(fb), fallback: used } : { ...adv, ran: false, fallback: used, error: `${why}; the fallback adversary returned nothing (spawn failure or no structured output)` }
1947
+ }
1948
+ if (adv && !adv.ran) log(`${id} adversary did not run: ${adv.error || 'no reason given'}`)
1949
+ else if (adv && adv.verdict === 'inconclusive') log(`${id} adversary inconclusive: ${adv.error || adv.summary}`)
1950
+ return adv
1951
+ }
1952
+
1953
+ // ---- Crew (card crew-sizing) ---------------------------------------------------
1954
+ // A task (or the plan) declares how many reviewers and adversaries read it; default one each. More coders is a
1955
+ // split into more tasks, never a shared worktree, so the crew has no coders row. Researchers run before planning
1956
+ // (the research skill) and are not launched here.
1957
+ function crewOf(task) {
1958
+ const src = { ...((plan.crew && typeof plan.crew === 'object') ? plan.crew : {}), ...((task && task.crew && typeof task.crew === 'object') ? task.crew : {}) }
1959
+ const n = v => (Number.isInteger(v) && v > 0 ? v : 1)
1960
+ return { reviewers: n(src.reviewers), adversaries: n(src.adversaries) }
1961
+ }
1962
+
1963
+ // One review out of several: every seat must approve, and every seat's issues are kept, so a finding one reviewer
1964
+ // filed reaches the ledger where the fingerprint collapses a duplicate to one id. A seat that returned nothing is
1965
+ // dropped; when no seat returned anything the stage returned nothing.
1966
+ function mergeReviews(seats) {
1967
+ const got = seats.filter(Boolean)
1968
+ if (!got.length) return null
1969
+ if (got.length === 1 && seats.length === 1) return got[0]
1970
+ return {
1971
+ taskId: got[0].taskId,
1972
+ specCompliant: got.every(r => r.specCompliant),
1973
+ inScope: got.every(r => r.inScope),
1974
+ approve: got.every(r => r.approve),
1975
+ issues: got.flatMap(r => r.issues || []),
1976
+ seats: got.length,
1977
+ }
1978
+ }
1979
+
1980
+ // One verdict out of several adversaries: a fail or a blocker from any seat blocks; the verdict is pass only when
1981
+ // every seat that ran passed; a seat that could not run leaves ran=false only when none ran.
1982
+ // Card adversary-usage-in-report: normalises an adversary result's usage/durationMs for the report (both the task
1983
+ // entry and the level entry call this, so the shape can only drift in one place). usage is kept only when it is an
1984
+ // object with numeric inputTokens/outputTokens (a fallback or crew seat that never touched codex-review has none);
1985
+ // durationMs only when it is a number. Anything else normalises to null rather than being copied through as is.
1986
+ function adversaryUsage(adv) {
1987
+ const u = adv && adv.usage && typeof adv.usage === 'object' && typeof adv.usage.inputTokens === 'number' && typeof adv.usage.outputTokens === 'number' ? adv.usage : null
1988
+ const d = adv && typeof adv.durationMs === 'number' ? adv.durationMs : null
1989
+ return { usage: u, durationMs: d }
1990
+ }
1991
+
1992
+ function mergeAdversaries(seats) {
1993
+ const got = seats.filter(Boolean)
1994
+ if (!got.length) return null
1995
+ if (got.length === 1 && seats.length === 1) return got[0]
1996
+ const ran = got.filter(a => a.ran)
1997
+ const verdict = ran.some(a => a.verdict === 'fail') ? 'fail' : ran.length && ran.every(a => a.verdict === 'pass') ? 'pass' : 'inconclusive'
1998
+ const first = got[0]
1999
+ // A seat's blocking rule (card fix-loop-minor-verdict) is evaluated on its own before the issues are merged: a
2000
+ // seat that ran and would block alone (adversaryOk) but raised no blocker issue of its own (a fail with an
2001
+ // empty issues array) gets one synthesized here from its summary, the same one stageFindings would synthesize
2002
+ // for a lone adversary. Without this, flattening its empty issues alongside another seat's non-blocking minor
2003
+ // issue leaves the merged array non-empty with no blocker, and adversaryBlocking reads that as pass-with-notes
2004
+ // even though this seat alone should have blocked.
2005
+ const issues = got.flatMap((a, i) => {
2006
+ const own = a.issues || []
2007
+ if (a.ran && !adversaryOk(a, true) && !own.some(x => x.severity === 'blocker')) {
2008
+ return [...own, { severity: 'blocker', file: '', description: a.summary || `seat ${i + 1} failed with no issue reported`, evidence: null }]
2009
+ }
2010
+ return own
2011
+ })
2012
+ // Card adversary-usage-in-report: usage/durationMs are per-agent, not additive across seats reviewing the same
2013
+ // diff on different models, so the merged result carries the values of the first seat that has usage (the
2014
+ // configured review, seat 1, when codex-review actually ran for it) rather than summing or dropping them.
2015
+ // adversaryUsage(usageSeat || {}) always yields explicit usage/durationMs keys (null, null when no seat has
2016
+ // usage), so the merged shape never drops them the way a conditional spread would.
2017
+ const usageSeat = got.find(a => adversaryUsage(a).usage)
2018
+ return {
2019
+ taskId: first.taskId,
2020
+ ran: ran.length > 0,
2021
+ verdict,
2022
+ summary: got.map((a, i) => `seat ${i + 1}: ${a.summary || (a.ran ? a.verdict : 'did not run')}`).join(' | '),
2023
+ issues,
2024
+ commandsRun: got.flatMap(a => a.commandsRun || []),
2025
+ error: ran.length ? null : (got.find(a => a.error) || {}).error || null,
2026
+ ...(first.fallback ? { fallback: first.fallback } : {}),
2027
+ ...adversaryUsage(usageSeat || {}),
2028
+ seats: got.length,
2029
+ }
2030
+ }
2031
+
2032
+ // The crew's adversaries: seat 1 is the configured review (Codex through its relay, with the Claude fallback when it
2033
+ // cannot run); each further seat is the Claude adversary on adversary.crewModel, a different model reading the same
2034
+ // diff at the same time. Every seat is pushed onto `stages` so the pass records every agent it spent.
2035
+ async function runAdversaryCrew(task, impl, pass, baseBranch, ledger, launch, stages) {
2036
+ const crew = crewOf(task)
2037
+ const suffix = pass > 1 ? `:${pass}` : ''
2038
+ const seatModel = (adversary && adversary.crewModel) || {}
2039
+ const thunks = [() => runAdversary(task, impl, pass, baseBranch, ledger, launch, stages)]
2040
+ for (let seat = 2; seat <= crew.adversaries; seat++) {
2041
+ const opts = { label: `adversary-${seat}:${task.id}${suffix}`, phase: 'Adversary', agentType: 'doug-flow:adversary-claude', schema: ADVERSARY_SCHEMA }
2042
+ if (seatModel.model) opts.model = seatModel.model
2043
+ if (seatModel.effort) opts.effort = seatModel.effort
2044
+ // Pushed inside the thunk, after seat 1 has pushed its own stage, so the pass records the seats in launch order.
2045
+ thunks.push(() => {
2046
+ stages.push(`adversary-${seat}`)
2047
+ // card crew-seat-blocker-evidence: this seat's result gets the same R12 evidence check as the fallback in
2048
+ // runAdversary, before mergeAdversaries sees it, so an evidence-free blocker downgrades here too.
2049
+ return launch(fallbackAdversaryPrompt(task, impl, baseBranch, `crew seat ${seat} of ${crew.adversaries}: a second adversary on a different model, reading the diff alongside codex-review`, pass, ledger), opts)
2050
+ .then(res => (res ? enforceFallbackEvidence(res) : res))
2051
+ })
2052
+ }
2053
+ if (thunks.length === 1) return thunks[0]()
2054
+ const seats = await parallel(thunks)
2055
+ return mergeAdversaries(seats || [])
2056
+ }
2057
+
2058
+ // The one check stands in for both stages, so the rest of the loop and the report read it as they read them.
2059
+ function readCheck(check) {
2060
+ const blocker = (check.issues || []).some(i => i.severity === 'blocker')
2061
+ return {
2062
+ ver: { taskId: check.taskId, passed: check.passed, commandsRun: check.commandsRun || [], findings: check.findings || [], findingFiles: Array.isArray(check.findingFiles) ? check.findingFiles : [], acceptance: Array.isArray(check.acceptance) ? check.acceptance : [] },
2063
+ rev: { taskId: check.taskId, specCompliant: check.passed, inScope: check.inScope, approve: !!(check.passed && check.inScope && !blocker), issues: check.issues || [] },
2064
+ }
2065
+ }
2066
+
2067
+ // A pass is blocked by the adversary only when a review that actually ran returned a blocking verdict. An absent
2068
+ // review (codex missing, the agent returned nothing) still keeps the task out of integration through isReady, but
2069
+ // there is no adversarial review to point a fix pass at, so it is not recorded as the blocking stage.
2070
+ function adversaryBlocked(adv) {
2071
+ return !!(adv && adv.ran) && !adversaryOk(adv, !!adversary)
2072
+ }
2073
+
2074
+ // card workflow-reads-commit-from-git: the implementer's claimed commit is not trusted (it can invent a hash whose
2075
+ // short prefix matches the real head, and replan's full-hash compare then treats a passed branch as stale). The
2076
+ // verifier and the focused check both run `git rev-parse HEAD` in the worktree as their first step and report it as
2077
+ // `head`; stageHead picks the one from whichever stage ran last on this pass (the check, when there is one, since a
2078
+ // fix pass runs it after the verifier) as the recorded commit. Only a canonical 40-hex head counts: git's own output
2079
+ // carries a trailing newline, and an agent could paste the abbreviated form instead of the full one, and either one
2080
+ // recorded verbatim would make lib/plan.mjs's verbatim replan compare print the very "is not the recorded commit;
2081
+ // re-implementing" this card exists to delete. A candidate that is not exactly 40 hex characters once trimmed is no
2082
+ // head from that stage (falls through to the next, then to the implementer's claimed commit for display only), and
2083
+ // the mismatch log below fires only for a canonical head. It is `head` being required in both schemas (R1), not the
2084
+ // absence of a verifier result, that keeps a passing verifier or check from reaching this fallback: a stage that ran
2085
+ // and returned a result always carries a head field, canonical or not. Only a stage that returned nothing at all
2086
+ // (older fakes, a dead agent) or returned a non-canonical head falls back, and a pass on the fallback is not reused
2087
+ // by lib/plan.mjs's replan (`passedAttempt`) unless the implementer's claimed commit happens to equal the real head.
2088
+ function stageHead(ver, check) {
2089
+ const canonical = x => {
2090
+ const h = x && typeof x.head === 'string' ? x.head.trim() : ''
2091
+ return /^[0-9a-f]{40}$/.test(h) ? h : null
2092
+ }
2093
+ return canonical(check) || canonical(ver) || null
2094
+ }
2095
+
2096
+ // 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.
2100
+ async function runChecks(task, impl, pass, baseBranch, ctx) {
2101
+ const c = ctx || {}
2102
+ const ledger = c.ledger || []
2103
+ const launch = c.launch || agent
2104
+ const stages = Array.isArray(c.lead) ? [...c.lead] : c.lead ? [c.lead] : []
2105
+ const stageResults = {}
2106
+ let ver = null
2107
+ let rev = null
2108
+ let adv = null
2109
+ let check = null
2110
+ let blockingStage = null
2111
+ const suffix = pass > 1 ? `:${pass}` : ''
2112
+ if (pass === 1 && taskShape(task) === 'S') {
2113
+ // Size S: one focused check on the verify row does the verifier's and the reviewer's work; no adversary here.
2114
+ stages.push('check')
2115
+ check = await launch(sizeCheckPrompt(task, impl, baseBranch), agentOpts('verify', task, { label: `check:${task.id}`, phase: 'Verify', agentType: 'doug-flow:verifier', schema: CHECK_SCHEMA }))
2116
+ stageResults.check = check
2117
+ if (check) ({ ver, rev } = readCheck(check))
2118
+ blockingStage = !(check && check.passed && rev && rev.approve) ? 'check' : null
2119
+ } 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.
2122
+ stages.push('verify')
2123
+ stages.push('review')
2124
+ const crew = crewOf(task)
2125
+ for (let seat = 2; seat <= crew.reviewers; seat++) stages.push(`review-${seat}`)
2126
+ const pair = await parallel([
2127
+ () => launch(verifyPrompt(task, impl, baseBranch), agentOpts('verify', task, { label: `verify:${task.id}`, phase: 'Verify', agentType: 'doug-flow:verifier', schema: VERIFY_SCHEMA })),
2128
+ () => launch(reviewPrompt(task, impl, baseBranch), agentOpts('review', task, { label: `review:${task.id}`, phase: 'Review', agentType: 'doug-flow:reviewer', schema: REVIEW_SCHEMA })),
2129
+ ...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 }))),
2130
+ ])
2131
+ ver = (pair && pair[0]) || null
2132
+ rev = pair ? mergeReviews(pair.slice(1)) : null
2133
+ stageResults.verify = ver
2134
+ 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)
2138
+ stageResults.adversary = adv
2139
+ }
2140
+ blockingStage = !(ver && ver.passed) ? 'verify' : !(rev && rev.approve) ? 'review' : adversaryBlocked(adv) ? 'adversary' : null
2141
+ } else {
2142
+ 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
+ }
2171
+ }
2172
+ }
2173
+ const head = stageHead(ver, check)
2174
+ if (head && impl.commit && head !== impl.commit) log(`${task.id} pass ${pass}: implementer reported commit ${impl.commit} but the worktree head is ${head}; recording the head`)
2175
+ return { task, impl, ver, rev, adv, check, pass, stages, stageResults, blockingStage, commit: head || impl.commit || null }
2176
+ }
2177
+
2178
+ // resume, when given, is a task result the level adversary blocked after integration: { impl, ver, rev, adv, commit,
2179
+ // pass, attempts, ledger }. Its blockers enter the ledger as the adversary stage of a new pass and the loop below fixes
2180
+ // them the way it fixes any adversary block.
2181
+ // ---- The fix-loop supervisor (card fix-loop-supervisor) ----------------------
2182
+ // The two stall signals, read from the ledger and the pass that just ended (r): a finding still open after a fix
2183
+ // pass targeted it (or one that came back), and a fix pass that touched a file whose findings an earlier pass had
2184
+ // already cleared while nothing on that file was open. Deterministic, so the report can be checked against it.
2185
+ function stallSignals(ledger, r) {
2186
+ const pass = r.pass
2187
+ const entries = ledger || []
2188
+ const range = (from, to) => { const out = []; for (let i = from; i <= to; i++) out.push(i); return out }
2189
+ const out = []
2190
+ for (const e of entries) {
2191
+ if (e.status !== 'open') continue
2192
+ if (e.reappeared) out.push({ kind: 'repeat', finding: e.id, passes: range(Math.min(e.openedPass, pass), pass), reappeared: true })
2193
+ else if (e.openedPass < pass) out.push({ kind: 'repeat', finding: e.id, passes: range(e.openedPass, pass) })
2194
+ }
2195
+ const touched = r.impl && Array.isArray(r.impl.filesTouched) ? r.impl.filesTouched : []
2196
+ for (const f of touched) {
2197
+ const cleared = entries.filter(e => e.file === f && Number.isInteger(e.fixedPass) && e.fixedPass < pass)
2198
+ if (!cleared.length) continue
2199
+ const openGoingIn = entries.some(e => e.file === f && e.openedPass < pass && (e.status === 'open' || (Number.isInteger(e.fixedPass) && e.fixedPass >= pass)))
2200
+ if (openGoingIn) continue
2201
+ out.push({ kind: 're-attack', file: f, clearedPass: Math.max(...cleared.map(e => e.fixedPass)), pass })
2202
+ }
2203
+ return out
2204
+ }
2205
+
2206
+ // The commands a pass ran, across its stages, as { command, exitCode } for the supervisor to read.
2207
+ function passCommands(r) {
2208
+ const out = []
2209
+ for (const stage of [r.impl, r.ver, r.rev, r.check, r.adv]) {
2210
+ for (const c of (stage && Array.isArray(stage.commandsRun) ? stage.commandsRun : [])) {
2211
+ if (!c || typeof c !== 'object') continue
2212
+ const exitCode = Number.isInteger(c.exitCode) ? c.exitCode : typeof c.ok === 'boolean' ? (c.ok ? 0 : 1) : null
2213
+ out.push({ command: String(c.command == null ? '' : c.command), exitCode })
2214
+ }
2215
+ }
2216
+ return out
2217
+ }
2218
+
2219
+ // One line per signal for a stop reason.
2220
+ function describeStall(signals) {
2221
+ return signals.map(x => x.kind === 're-attack' ? `${x.file} re-attacked on pass ${x.pass} after being cleared on pass ${x.clearedPass}` : `${x.finding} still open after ${x.passes.length} passes`).join('; ')
2222
+ }
2223
+
2224
+ // The brief the loop writes itself when the supervisor returned nothing: the evidence, and directions that at least
2225
+ // change what the next pass does first.
2226
+ function fallbackBrief(signals, attempts, ledger) {
2227
+ const lines = [`The supervisor returned no brief; this is the loop's own reading of the passes.`]
2228
+ for (const x of signals) {
2229
+ if (x.kind === 're-attack') lines.push(`- ${x.file} was cleared on pass ${x.clearedPass} and pass ${x.pass} changed it again with nothing open on it.`)
2230
+ else {
2231
+ const e = (ledger || []).find(y => y.id === x.finding)
2232
+ lines.push(`- ${x.finding} open after passes ${x.passes.join(', ')}${e ? ` [${e.stage}] ${e.file || '(no file)'}: ${String(e.description).slice(0, 120)}` : ''}${x.reappeared ? ' (reappeared after being fixed)' : ''}`)
2233
+ }
2234
+ }
2235
+ const tried = attempts.filter(a => (a.stages || []).includes('fix')).map(a => `pass ${a.pass} (commit ${a.commit ? String(a.commit).slice(0, 7) : 'none'}, blocked at ${a.blockingStage || 'no stage'})`)
2236
+ if (tried.length) lines.push(`Fix passes so far: ${tried.join('; ')}.`)
2237
+ lines.push(`Directions:`)
2238
+ lines.push(`1. Re-read the failing command's output and the finding's evidence before changing any code, and state in the commit message what the earlier fix misread.`)
2239
+ lines.push(`2. Change a different part of the code than the last pass touched; if the finding is in a test, make the code satisfy the test rather than adjusting how it is exercised.`)
2240
+ lines.push(`3. Write the smallest failing check for the open finding first, run it, then fix it.`)
2241
+ return lines.join('\n')
2242
+ }
2243
+
2244
+ // The single retry every stage agent() call gets, wherever it is launched from (a task's own pipeline or the level
2245
+ // loop's integrate and level-adversary calls). A schema'd agent that ends without calling StructuredOutput makes
2246
+ // agent() reject (the runtime's own five attempts already exhausted); a stopped, blocked, or API-failed agent
2247
+ // resolves null instead and is never retried here, since a null resolution never reaches this catch. One retry with
2248
+ // the same prompt and the same opts except `label`, which gets a `:retry` suffix so the progress view tells the two
2249
+ // apart, unless the caller passes `retryPrompt` (card implementer-retry-own-branch: the implement and lead launches
2250
+ // pass their own resume prompt, so the relaunch picks up its predecessor's own-run branch instead of the first
2251
+ // prompt's stale-branch refusal); a second rejection propagates unchanged for the caller's own handling. `who` names
2252
+ // the task or level the line is logged under; `onRetry`, when given, runs once the retry is decided (a task counts
2253
+ // the extra agent).
2254
+ async function retryOnce(prompt, opts, who, onRetry, retryPrompt) {
2255
+ try {
2256
+ return await agent(prompt, opts)
2257
+ } catch (e) {
2258
+ const message = e && e.message ? e.message : e
2259
+ log(`${who} ${opts.label}: stage agent threw (${message}); retrying once`)
2260
+ if (onRetry) onRetry()
2261
+ return agent(retryPrompt !== undefined ? retryPrompt : prompt, { ...opts, label: `${opts.label}:retry` })
2262
+ }
2263
+ }
2264
+
2265
+ async function runTask(task, baseBranch, resume) {
2266
+ const attempts = resume ? resume.attempts : []
2267
+ const ledger = resume ? resume.ledger : []
2268
+ const startedAt = runtimeSpend()
2269
+ const counted = { agents: 0 }
2270
+ // Every agent this task launches goes through here, so the budget counts what the task actually cost.
2271
+ const launch = (prompt, opts, retryPrompt) => {
2272
+ counted.agents += 1
2273
+ return retryOnce(prompt, opts, task.id, () => { counted.agents += 1 }, retryPrompt)
2274
+ }
2275
+ const measure = () => {
2276
+ const now = runtimeSpend()
2277
+ return {
2278
+ agents: counted.agents,
2279
+ tokens: now.tokens === null || startedAt.tokens === null ? null : now.tokens - startedAt.tokens,
2280
+ elapsedMs: now.elapsedMs === null || startedAt.elapsedMs === null ? null : now.elapsedMs - startedAt.elapsedMs,
2281
+ }
2282
+ }
2283
+ const budgetOf = () => {
2284
+ const spent = measure()
2285
+ const enforced = ['agents']
2286
+ if (spent.tokens !== null) enforced.push('tokens')
2287
+ if (spent.elapsedMs !== null) enforced.push('elapsedMs')
2288
+ return { limits: taskBudget, spent, enforced }
2289
+ }
2290
+ const record = (res, prev) => {
2291
+ const u = updateLedger(ledger, res, res.pass, !!(res.commit && res.commit !== prev))
2292
+ res.newFindings = u.opened
2293
+ res.fixedFindings = u.fixed
2294
+ res.reopened = u.reopened
2295
+ // Every entry still open after this pass, in the order they were raised: what keeps the task out of integration.
2296
+ res.openFindings = ledger.filter(e => e.status === 'open').map(e => e.id)
2297
+ // Card ledger-ignores-not-a-defect-notes: notes dropped this pass, so the report still shows them (attempts.push).
2298
+ res.droppedFindings = u.dropped
2299
+ return res
2300
+ }
2301
+ const lead = task.reuse ? 'reuse' : 'implement'
2302
+ let r
2303
+ if (resume) {
2304
+ // The integrator removed the task's worktree when the level merged (integrate step 4), so a fix pass here
2305
+ // cannot be sent back to it: worktreeGone makes the pass start in a fresh worktree on the task branch.
2306
+ r = { task, impl: resume.impl, ver: resume.ver, rev: resume.rev, adv: resume.adv, check: null, pass: resume.pass + 1, stages: ['level-adversary'], stageResults: { 'level-adversary': resume.adv }, blockingStage: 'adversary', commit: resume.commit, worktreeGone: true }
2307
+ record(r, resume.commit)
2308
+ } else {
2309
+ const first = await startTask(task, baseBranch, launch)
2310
+ if (first && first.blocked) log(`${task.id} blocked: ${first.blockedReason || 'no reason given'}`)
2311
+ // A swarm's first pass spent a lead, its workers, and a merge; the pass records each of them.
2312
+ const leadStages = first && Array.isArray(first.swarmStages) ? first.swarmStages : [lead]
2313
+ r = !first
2314
+ ? { task, impl: null, ver: null, rev: null, adv: null, check: null, pass: 1, stages: leadStages, stageResults: {}, blockingStage: null, commit: null }
2315
+ : first.blocked || first.partial
2316
+ ? { task, impl: first, ver: null, rev: null, adv: null, check: null, pass: 1, stages: leadStages, stageResults: {}, blockingStage: null, commit: first.commit || null }
2317
+ : await runChecks(task, first, 1, baseBranch, { ledger, launch, lead: leadStages })
2318
+ record(r, null)
2319
+ }
2320
+ while (true) {
2321
+ const ready = isReady(r)
2322
+ const decision = ready ? null : retriable(task, r)
2323
+ attempts.push({
2324
+ pass: r.pass,
2325
+ stages: r.stages,
2326
+ commit: r.commit,
2327
+ blockingStage: r.blockingStage,
2328
+ verified: !!(r.ver && r.ver.passed),
2329
+ reviewed: !!(r.rev && r.rev.approve),
2330
+ adversary: r.adv ? { ran: r.adv.ran, verdict: r.adv.verdict, blocked: !adversaryOk(r.adv, adversaryRequired(task)), summary: r.adv.summary, ...(r.adv.fallback ? { fallback: r.adv.fallback } : {}) } : null,
2331
+ newFindings: r.newFindings || [],
2332
+ fixedFindings: r.fixedFindings || [],
2333
+ notes: r.droppedFindings || [],
2334
+ ready,
2335
+ retriable: decision,
2336
+ spent: measure(),
2337
+ commands: passCommands(r),
2338
+ })
2339
+ // The last supervisor that ran on this task, for the report; null when none did.
2340
+ 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() }
2342
+ const why = notReadyWhy(r)
2343
+ const halt = reason => {
2344
+ 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() }
2346
+ }
2347
+ if (!decision.ok) return halt(`${why}; not retried: ${decision.reason}`)
2348
+ // Two passes in a row that each raise a blocker nobody had seen means the checks are exploring, not converging.
2349
+ const last = attempts[attempts.length - 1]
2350
+ const prior = attempts[attempts.length - 2]
2351
+ 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(', ')})`)
2353
+ }
2354
+ if (attempts.length > fixAttempts) return halt(`${why}; fix attempts exhausted (${fixAttempts} of ${fixAttempts})`)
2355
+ // The supervisor (card fix-loop-supervisor): before a fix pass after the first, when the stall signals fire. A
2356
+ // pass that returned no result attacked nothing, so it is not a stall. A task stalled a second time stops here
2357
+ // instead of spending the remaining fix budget; otherwise the supervisor runs after the budget check below, on
2358
+ // the supervise tier, spending one agent the projection counts, and its brief goes into the next fix prompt. It
2359
+ // changes no verdict: the ledger is untouched, and the stop is the loop's.
2360
+ const signals = r.pass >= 2 && !r.noResult ? stallSignals(ledger, r) : []
2361
+ const lastAttempt = attempts[attempts.length - 1]
2362
+ if (signals.length && attempts.some(a => a.supervisor && a.supervisor.stalled)) {
2363
+ const findings = [...new Set(signals.filter(x => x.kind === 'repeat').map(x => x.finding))]
2364
+ 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)})`)
2366
+ }
2367
+ const blocking = r.blockingStage || 'verify'
2368
+ // What the next pass would cost: the fix agent plus the stages rule 3 would run. The adversary stage spends two
2369
+ // agents only when the previous pass actually fell back to the Claude adversary (codex-review could not review,
2370
+ // so the stand-in launched too); a fallback that is merely configured costs nothing, and counting it would stop
2371
+ // fix passes the budget can pay for.
2372
+ const fellBack = (r.stages || []).includes('adversary-fallback')
2373
+ const crew = crewOf(task)
2374
+ const advAgents = adversaryRequired(task) ? (fellBack ? 1 : 0) + crew.adversaries : 0
2375
+ const firstStage = blocking === 'adversary' ? advAgents : blocking === 'review' ? crew.reviewers : 1
2376
+ const nextStages = firstStage + (blocking === 'check' ? 0 : 1) + (blocking === 'adversary' ? 0 : advAgents)
2377
+ const spent = measure()
2378
+ const passesDone = attempts.length
2379
+ let over = null
2380
+ const projectedAgents = spent.agents + 1 + nextStages + (signals.length ? 1 : 0)
2381
+ if (projectedAgents > taskBudget.agents) over = `agents ${projectedAgents} > ${taskBudget.agents}`
2382
+ // The mean per completed pass is added unrounded: rounding it down lets a projection that clears the limit
2383
+ // only by a fraction launch a pass the budget cannot pay for. Only the printed number is rounded.
2384
+ const show = n => (Number.isInteger(n) ? String(n) : n.toFixed(2))
2385
+ if (!over && spent.tokens !== null) {
2386
+ const projected = spent.tokens + spent.tokens / passesDone
2387
+ if (projected > taskBudget.tokens) over = `tokens ${show(projected)} > ${taskBudget.tokens}`
2388
+ }
2389
+ if (!over && spent.elapsedMs !== null) {
2390
+ const limit = taskBudget.wallMinutes * 60000
2391
+ const projected = spent.elapsedMs + spent.elapsedMs / passesDone
2392
+ if (projected > limit) over = `elapsedMs ${show(projected)} > ${limit}`
2393
+ }
2394
+ if (over) return halt(`${why}; stopped: next attempt would exceed the task budget (${over})`)
2395
+ const pass = r.pass + 1
2396
+ let brief = null
2397
+ if (signals.length) {
2398
+ let result = null
2399
+ try {
2400
+ result = await launch(supervisePrompt(task, r, attempts, ledger, signals), agentOpts('supervise', task, { label: `supervise:${task.id}:${pass}`, phase: 'Implement', schema: SUPERVISE_SCHEMA }))
2401
+ } catch (e) {
2402
+ log(`${task.id} supervisor threw: ${e && e.message ? e.message : e}`)
2403
+ }
2404
+ const ran = !!(result && typeof result === 'object')
2405
+ const directions = ran && Array.isArray(result.directions) ? result.directions.filter(d => typeof d === 'string' && d.trim()) : []
2406
+ brief = ran && typeof result.brief === 'string' && result.brief.trim()
2407
+ ? [result.brief.trim(), ...(directions.length ? ['Directions:', ...directions.map((d, i) => `${i + 1}. ${d.trim()}`)] : [])].join('\n')
2408
+ : fallbackBrief(signals, attempts, ledger)
2409
+ lastAttempt.supervisor = { ran, stalled: true, brief, signals, stop: null }
2410
+ log(`${task.id} stalled (${describeStall(signals)}); supervisor ${ran ? 'briefed' : 'returned nothing, loop briefed'} the next fix pass`)
2411
+ }
2412
+ const prevCommit = r.commit
2413
+ // The worktree is gone once the level integrated: then the pass gets a fresh one from the runtime, the way the
2414
+ // first implement pass does, and the prompt checks the task branch out in it. Otherwise no isolation key:
2415
+ // `isolation: 'worktree'` would make a fresh worktree. Without it the agent runs from the repository root like
2416
+ // the verifier and reviewer, and the prompt sends it into the existing worktree.
2417
+ const fresh = !!r.worktreeGone
2418
+ log(`${task.id} ${why}; fix attempt ${pass - 1} of ${fixAttempts} in ${fresh ? `a new worktree on ${r.impl.branch} (${r.impl.worktreePath} went with the level's integration)` : r.impl.worktreePath}`)
2419
+ let fix = null
2420
+ let failure = null
2421
+ try {
2422
+ fix = await launch(fixPrompt(task, r, pass, baseBranch, ledger, brief), agentOpts('implement', task, { label: `fix:${task.id}:${pass}`, phase: 'Implement', ...(fresh ? { isolation: 'worktree' } : {}), agentType: 'doug-flow:implementer', schema: IMPLEMENT_SCHEMA }))
2423
+ } catch (e) {
2424
+ // An implementer that ends without calling StructuredOutput makes agent() throw. Letting that out of the
2425
+ // task would drop the whole task to null (branch lost, attempts lost, 'task stage threw'); it is a pass.
2426
+ failure = e
2427
+ }
2428
+ if (!fix) {
2429
+ // No structured result: nothing for a check to look at, but the branch and worktree the loop knows are
2430
+ // untouched, so this is a retriable block on the pass, not the end of the task. The next pass sees the same
2431
+ // open findings; the report keeps the branch for replan.
2432
+ const reason = 'implementer returned no structured result'
2433
+ log(`${task.id} fix pass ${pass}: ${reason}${failure ? ` (${failure && failure.message ? failure.message : failure})` : ''}; keeping branch ${r.impl.branch}`)
2434
+ r = record({ task, impl: r.impl, ver: null, rev: null, adv: null, check: null, pass, stages: ['fix'], stageResults: {}, blockingStage: r.blockingStage, commit: prevCommit, noResult: reason, worktreeGone: r.worktreeGone }, prevCommit)
2435
+ continue
2436
+ }
2437
+ const worktreePath = fresh && fix.worktreePath ? fix.worktreePath : r.impl.worktreePath
2438
+ // The fix result decides the pass on its own: a blocked fix or a branch still on the previous commit both mean
2439
+ // the pass produced nothing for a check to look at. Run no stage and stop here. A blocked fix keeps the
2440
+ // implementer-blocked reason; the other reports the empty pass.
2441
+ const fixBlocked = !!fix.blocked
2442
+ if (fixBlocked || !fix.commit || fix.commit === prevCommit) {
2443
+ // The swarm (if any) ran in the first pass; a fix pass is one implementer, so `fix` itself carries none of
2444
+ // workers/swarmStages/splitReason/rebrief. Without carrying them forward, a fixed swarmed task's report loses
2445
+ // its swarm record on the very pass that made it converge (card swarm-topology review, then candidate 2).
2446
+ const impl = { ...fix, branch: r.impl.branch, worktreePath, ...(r.impl.workers ? { workers: r.impl.workers, swarmStages: r.impl.swarmStages, splitReason: r.impl.splitReason, rebrief: r.impl.rebrief, checkFailed: r.impl.checkFailed } : {}) }
2447
+ r = record({ task, impl, ver: null, rev: null, adv: null, check: null, pass, stages: ['fix'], stageResults: {}, blockingStage: null, commit: fix.commit || null }, prevCommit)
2448
+ const decided = fixBlocked ? retriable(task, r) : null
2449
+ 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}`)
2451
+ }
2452
+ 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
+ r = record(await runChecks(task, next, pass, baseBranch, { ledger, launch, lead: 'fix', prevCommit, blocking }), prevCommit)
2454
+ }
2455
+ }
2456
+
2457
+ // One adversarial review of a level's size-S tasks on the integration branch; the same relay, fallback, and verdict
2458
+ // rules as a task's adversary. `tasks` names what it reviewed.
2459
+ async function runLevelAdversary(li, sReady, levelBase, confirming) {
2460
+ const subject = {
2461
+ id: `level-${li}`,
2462
+ label: `adversary:level-${li}${confirming ? ':confirm' : ''}`,
2463
+ prompt: () => levelAdversaryPrompt(li, sReady, levelBase, confirming),
2464
+ fallbackPrompt: why => levelFallbackAdversaryPrompt(li, sReady, levelBase, why, confirming),
2465
+ }
2466
+ const adv = await runAdversary(null, null, 1, levelBase, [], (p, o) => retryOnce(p, o, `level-${li}`), [], subject)
2467
+ return { ran: !!(adv && adv.ran), verdict: adv ? adv.verdict : 'inconclusive', blocked: !adversaryOk(adv, true), summary: adv ? adv.summary : '', issues: (adv && adv.issues) || [], commandsRun: (adv && adv.commandsRun) || [], error: (adv && adv.error) || null, tasks: sReady.map(r => r.task.id), ...adversaryUsage(adv), ...(adv && adv.fallback ? { fallback: adv.fallback } : {}) }
2468
+ }
2469
+
2470
+ // ---- Run -----------------------------------------------------------------------
2471
+ const levels = levelize(plan.tasks)
2472
+ log(`Plan "${plan.title}": ${plan.tasks.length} tasks in ${levels.length} level(s). Integration branch ${integrationBranch}.`)
2473
+ log(`Per-task budget: ${taskBudget.agents} agents, ${taskBudget.tokens} tokens, ${taskBudget.wallMinutes} minutes; up to ${fixAttempts} fix attempts.`)
2474
+
2475
+ const report = { plan: plan.title, integrationBranch, modelsSource: (plan.models && plan.models.source) || null, budget: taskBudget, swarm: { on: plan.swarm === true, workerCheck: plan.workerCheck === true }, levels: [] }
2476
+
2477
+ // A task counts towards a successful run only when the loop let it through: a stopped task carries a stopReason (an
2478
+ // unfixed blocker, a fix that changed nothing, a reappeared finding, an exhausted budget) and never reached
2479
+ // integration, so the stage booleans alone - which a reopened finding leaves all true - must not read as success.
2480
+ function taskSucceeded(t) {
2481
+ return !t.stopReason && t.implemented && t.verified && t.reviewed && !(t.adversary && t.adversary.blocked)
2482
+ }
2483
+ let baseBranch = plan.baseBranch || 'HEAD'
2484
+ // The ids that actually reached the integration branch. A task whose dependency is not in here is building on
2485
+ // ground that does not exist, so it is not launched at all.
2486
+ const integrated = new Set()
2487
+ // Whether this run has integrated yet, so integratePrompt knows when to treat the worktree as a possible
2488
+ // leftover from an earlier run rather than trusting it. Tracked explicitly rather than assumed from li === 0
2489
+ // so it stays right regardless of level shape, and so the :2 re-integration (always passed false) never recreates.
2490
+ let integratedOnce = false
2491
+
2492
+ for (let li = 0; li < levels.length; li++) {
2493
+ const level = levels[li]
2494
+ log(`Level ${li}: ${level.map(t => t.id).join(', ')}`)
2495
+
2496
+ for (const t of level) if (t.reuse) log(`${t.id}: reusing ${t.reuse}; no implementer`)
2497
+
2498
+ // A dependency that did not integrate makes the dependent task unrunnable: its base does not carry the work it
2499
+ // was written against, so launching it only spends agents to fail. Record it and let the level integrate the rest.
2500
+ const runnable = []
2501
+ const skipped = new Map()
2502
+ for (const t of level) {
2503
+ const missing = (t.dependsOn || []).filter(d => !integrated.has(d))
2504
+ if (!missing.length) {
2505
+ runnable.push(t)
2506
+ continue
2507
+ }
2508
+ const reason = missing.length > 1 ? `dependencies ${missing.join(', ')} were not integrated` : `dependency ${missing[0]} was not integrated`
2509
+ 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 })
2511
+ }
2512
+
2513
+ // Implement (or reuse) -> verify -> review -> adversary -> fix, per task, no barrier between tasks.
2514
+ const results = await pipeline(
2515
+ runnable,
2516
+ task => runTask(task, baseBranch),
2517
+ )
2518
+
2519
+ // A stage that throws (agent error, unknown agent type, user skip) drops the item to null; keep the task in the report.
2520
+ // The fallback's impl carries the branch the task would have used (task.reuse when the task carries one, since a
2521
+ // reuse checkout works on that branch, not doug/task-<id>; doug/task-<id> otherwise, the convention every fresh
2522
+ // implement/lead prompt follows) so replan can find and retire it even though nothing here confirms a branch
2523
+ // was ever made; blocked:true keeps `implemented` false so reusePlan still refuses to reuse it.
2524
+ const threwFallback = task => {
2525
+ 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 }
2527
+ }
2528
+ const byTask = new Map(runnable.map((task, i) => [task, results[i] || threwFallback(task)]))
2529
+ const ready = level.map(task => byTask.get(task) || skipped.get(task)).filter(isReady)
2530
+ const notReady = level.map(task => byTask.get(task) || skipped.get(task)).filter(r => !ready.includes(r))
2531
+ // A task the dependency gate skipped already logged its one line there; logging it again here would say the
2532
+ // same thing twice for a task that was never launched.
2533
+ for (const r of notReady) if (!r.skippedReason) log(`${r.task.id} not integrated: ${r.stopReason}`)
2534
+
2535
+ let integration = null
2536
+ let levelAdversary = null
2537
+ const levelBase = baseBranch
2538
+ const sReady = adversary ? ready.filter(r => taskShape(r.task) === 'S') : []
2539
+ 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 || []))
2541
+ integratedOnce = true
2542
+ if (integration && integration.ok) {
2543
+ baseBranch = integrationBranch
2544
+ for (const r of ready) integrated.add(r.task.id)
2545
+ }
2546
+ }
2547
+ // The size-S tasks of this level had no adversary of their own: one review of them together, on the integration
2548
+ // branch, after the merge. A blocker goes back to the task that owns its file as a fix pass, the fixed branches are
2549
+ // merged again, and the adversary confirms once; a level still blocked after that stops the run.
2550
+ if (integration && integration.ok && sReady.length) {
2551
+ levelAdversary = await runLevelAdversary(li, sReady, levelBase, false)
2552
+ if (levelAdversary.blocked) {
2553
+ const blockers = levelAdversary.issues.filter(i => i.severity === 'blocker')
2554
+ const items = blockers.length ? blockers : [{ severity: 'blocker', file: '', description: levelAdversary.summary || 'the level adversary blocked without an issue', evidence: null }]
2555
+ const perTask = new Map()
2556
+ const unowned = []
2557
+ for (const i of items) {
2558
+ const file = String(i.file || '').trim().replace(/^\.\//, '')
2559
+ const owner = file ? ready.find(r => ownsPath(r.task, file)) : null
2560
+ if (!owner) unowned.push(file || '(no file)')
2561
+ else perTask.set(owner, [...(perTask.get(owner) || []), i])
2562
+ }
2563
+ levelAdversary.fixed = []
2564
+ if (unowned.length) {
2565
+ levelAdversary.unowned = unowned
2566
+ log(`level ${li} adversary blocked on files no task of this level owns: ${unowned.join(', ')}; no fix pass`)
2567
+ } else {
2568
+ for (const [r, issues] of perTask) {
2569
+ log(`${r.task.id}: the level adversary blocked it (${issues.length} blocker${issues.length === 1 ? '' : 's'}); fix pass`)
2570
+ const adv = { ran: true, verdict: 'fail', summary: levelAdversary.summary, issues, commandsRun: levelAdversary.commandsRun, error: null }
2571
+ 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
+ byTask.set(r.task, again)
2573
+ levelAdversary.fixed.push({ task: r.task.id, ready: isReady(again), stopReason: again.stopReason })
2574
+ }
2575
+ const fixedReady = [...perTask.keys()].map(r => byTask.get(r.task)).filter(isReady)
2576
+ 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 || []))
2578
+ levelAdversary.reintegration = again
2579
+ // The re-integration is the final state of the branch, so its acceptance (not the first integration's)
2580
+ // is what the level report carries (card integration-acceptance-recorded, behaviour 4). Only
2581
+ // `acceptance` is copied onto the level's `integration`; a failed re-integration's own `ok` and
2582
+ // `reason` stay on `levelAdversary.reintegration`, not on the level report.
2583
+ 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)
2585
+ }
2586
+ }
2587
+ const cleared = !!(levelAdversary.confirm && !levelAdversary.confirm.blocked)
2588
+ if (!cleared) {
2589
+ for (const r of new Set([...sReady, ...perTask.keys()])) {
2590
+ 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}`
2592
+ byTask.set(r.task, cur)
2593
+ }
2594
+ for (const r of ready) integrated.delete(r.task.id)
2595
+ }
2596
+ }
2597
+ }
2598
+ const done = level.map(task => byTask.get(task) || skipped.get(task))
2599
+
2600
+ report.levels.push({
2601
+ index: li,
2602
+ tasks: done.map(r => ({
2603
+ id: r.task.id,
2604
+ implemented: !!r.impl && !r.impl.blocked,
2605
+ blockedReason: r.skippedReason || (r.impl && r.impl.blocked ? r.impl.blockedReason : null),
2606
+ // What the last pass ran, [] for a task that was never launched.
2607
+ stages: r.stages || [],
2608
+ verified: !!(r.ver && r.ver.passed),
2609
+ verifierFindings: r.ver ? r.ver.findings : [],
2610
+ acceptance: r.ver && Array.isArray(r.ver.acceptance) ? r.ver.acceptance : [],
2611
+ reviewed: !!(r.rev && r.rev.approve),
2612
+ reviewIssues: r.rev ? r.rev.issues : [],
2613
+ inScope: r.rev ? r.rev.inScope : null,
2614
+ // The ids of the lessons plan.mjs json recalled for this task and actually put in front of an implementer
2615
+ // (card memory-recall #2): [] when none matched, when the task reused an earlier branch (reusePrompt
2616
+ // carries no lessons), or when no implementer ever launched.
2617
+ memoryUsed: r.task.reuse || !r.impl ? [] : Array.isArray(r.task.lessonIds) ? r.task.lessonIds : [],
2618
+ // The ids of the code chunks plan.mjs json's semantic index search actually put in front of an implementer
2619
+ // (card semantic-index, brief B): [] when none matched, when the task reused an earlier branch (reusePrompt
2620
+ // carries no code context), or when no implementer ever launched.
2621
+ indexUsed: r.task.reuse || !r.impl ? [] : Array.isArray(r.task.codeContextIds) ? r.task.codeContextIds : [],
2622
+ adversary: r.adv ? { ran: r.adv.ran, verdict: r.adv.verdict, blocked: !adversaryOk(r.adv, adversaryRequired(r.task)), summary: r.adv.summary, issues: r.adv.issues, commandsRun: r.adv.commandsRun, error: r.adv.error, ...adversaryUsage(r.adv), ...(r.adv.fallback ? { fallback: r.adv.fallback } : {}) } : null,
2623
+ size: r.task.size || null,
2624
+ shape: taskShape(r.task),
2625
+ gate: r.task.gate || 'auto',
2626
+ branch: r.impl ? r.impl.branch : null,
2627
+ commit: r.commit || null,
2628
+ // Graceful degradation (card worker-context-handoff): true only when the task's own outcome is still partial
2629
+ // after its one resume; handoff is what a fresh worker (or plan.mjs replan) needs; resumed counts the extra
2630
+ // agent the resume itself spent.
2631
+ partial: !!(r.impl && r.impl.partial),
2632
+ handoff: (r.impl && r.impl.handoff) || null,
2633
+ resumed: (r.impl && r.impl.resumed) || 0,
2634
+ reused: r.task.reuse || null,
2635
+ card: r.task.card || null,
2636
+ crew: crewOf(r.task),
2637
+ // The swarm's workers when the plan opted in (brief, files, branch, commit, block per worker); null otherwise.
2638
+ workers: r.impl && Array.isArray(r.impl.workers) ? r.impl.workers : null,
2639
+ // Why this task did or did not run as a swarm (card swarm-topology); null on a plan that never opted in.
2640
+ swarm: swarmReport(r.task, r.impl),
2641
+ specHash: r.task.specHash || null,
2642
+ attempts: r.attempts,
2643
+ passes: r.attempts.length,
2644
+ stopReason: r.stopReason,
2645
+ // The fix-loop supervisor's last word on the task: ran, stalled, the brief it injected, and the stop when the
2646
+ // task stalled twice ({ kind: 'stalled', attempts, findings }); null when the loop never stalled.
2647
+ supervisor: r.supervisor || null,
2648
+ // What the task cost and what the checks are still holding against it.
2649
+ budget: r.budget || null,
2650
+ ledger: r.ledger || [],
2651
+ // What each stage ran on, so cost can be attributed to a tier. "inherit" = session model / role default effort.
2652
+ models: { implement: describeTier('implement', r.task), verify: describeTier('verify'), review: describeTier('review'), adversary: describeTier('adversary'), supervise: describeTier('supervise'), tier: r.task.tier || null, ...(r.task.reuse ? { checkout: { model: CHECKOUT_MODEL, effort: 'inherit' } } : {}), ...(plan.swarm === true ? { lead: describeTier('lead'), worker: describeTier('worker') } : {}) },
2653
+ })),
2654
+ integration,
2655
+ integrationModel: ready.length ? describeTier('integrate') : null,
2656
+ levelAdversary,
2657
+ })
2658
+
2659
+ // A level that failed integration stops the run: later levels would build on broken ground. So does a level
2660
+ // adversary that is still blocked after its one fix pass.
2661
+ if (levelAdversary && levelAdversary.blocked && !(levelAdversary.confirm && !levelAdversary.confirm.blocked)) {
2662
+ log(`Level ${li} adversary blocked the level; stopping before level ${li + 1}.`)
2663
+ report.stoppedAtLevel = li
2664
+ break
2665
+ }
2666
+ if (ready.length && !(integration && integration.ok)) {
2667
+ log(`Level ${li} integration failed; stopping before level ${li + 1}.${integration && integration.reason ? ` ${integration.reason}` : ''}`)
2668
+ report.stoppedAtLevel = li
2669
+ break
2670
+ }
2671
+ if (!ready.length && level.length) {
2672
+ log(`Nothing in level ${li} was ready; stopping.`)
2673
+ report.stoppedAtLevel = li
2674
+ break
2675
+ }
2676
+ // A human gate (decision 0002 #2): a task with gate: 'human' pauses the run after its level has integrated, until
2677
+ // the user opens it (plan.gatesOpened names the level, plan.mjs gate open <n>) and resumes the run with its id;
2678
+ // the levels already run replay from the runtime's cache. The gate after the last level is the landing itself.
2679
+ if (li < levels.length - 1 && level.some(t => t.gate === 'human') && !(Array.isArray(plan.gatesOpened) && plan.gatesOpened.includes(li))) {
2680
+ log(`Level ${li} ends at a human gate; pausing before level ${li + 1} (${levels[li + 1].map(t => t.id).join(', ')}). Open it with plan.mjs gate open ${li} and resume the run with its id.`)
2681
+ report.paused = { level: li, gate: 'human', next: levels[li + 1].map(t => t.id) }
2682
+ break
2683
+ }
2684
+ }
2685
+
2686
+ report.ok = report.stoppedAtLevel === undefined && !report.paused && report.levels.every(l => l.tasks.every(taskSucceeded))
2687
+ return report