@dougharness/cli 1.0.2 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/bin.js +18 -3
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +14 -7
  4. package/dist/board-serve.js.map +1 -1
  5. package/dist/board.d.ts +1 -1
  6. package/dist/board.js +42 -9
  7. package/dist/board.js.map +1 -1
  8. package/dist/detect/repo.js +14 -1
  9. package/dist/detect/repo.js.map +1 -1
  10. package/dist/detect/types.d.ts +2 -0
  11. package/dist/doctor.d.ts +46 -0
  12. package/dist/doctor.js +491 -0
  13. package/dist/doctor.js.map +1 -0
  14. package/dist/generate/agents.d.ts +7 -0
  15. package/dist/generate/agents.js +62 -12
  16. package/dist/generate/agents.js.map +1 -1
  17. package/dist/generate/claude-md.js +4 -0
  18. package/dist/generate/claude-md.js.map +1 -1
  19. package/dist/generate/config.d.ts +8 -0
  20. package/dist/generate/config.js +4 -1
  21. package/dist/generate/config.js.map +1 -1
  22. package/dist/generate/proposal.js +15 -6
  23. package/dist/generate/proposal.js.map +1 -1
  24. package/dist/generate/settings.d.ts +2 -0
  25. package/dist/generate/settings.js +17 -2
  26. package/dist/generate/settings.js.map +1 -1
  27. package/dist/init.d.ts +14 -0
  28. package/dist/init.js +94 -8
  29. package/dist/init.js.map +1 -1
  30. package/dist/memory-setup.d.ts +28 -0
  31. package/dist/memory-setup.js +326 -0
  32. package/dist/memory-setup.js.map +1 -0
  33. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +16 -4
  34. package/node_modules/@dougharness/flow/agents/lead.md +1 -1
  35. package/node_modules/@dougharness/flow/agents/planner.md +3 -3
  36. package/node_modules/@dougharness/flow/agents/researcher.md +2 -2
  37. package/node_modules/@dougharness/flow/hooks/hooks.json +16 -0
  38. package/node_modules/@dougharness/flow/lib/board.mjs +171 -13
  39. package/node_modules/@dougharness/flow/lib/code-index.mjs +18 -3
  40. package/node_modules/@dougharness/flow/lib/context.mjs +57 -0
  41. package/node_modules/@dougharness/flow/lib/cost.mjs +155 -7
  42. package/node_modules/@dougharness/flow/lib/embeddings.mjs +188 -24
  43. package/node_modules/@dougharness/flow/lib/land.mjs +74 -10
  44. package/node_modules/@dougharness/flow/lib/learn.mjs +4 -4
  45. package/node_modules/@dougharness/flow/lib/memory.mjs +368 -50
  46. package/node_modules/@dougharness/flow/lib/plan.mjs +11 -1
  47. package/node_modules/@dougharness/flow/package.json +2 -1
  48. package/node_modules/@dougharness/flow/scripts/board.mjs +86 -25
  49. package/node_modules/@dougharness/flow/scripts/context.mjs +50 -0
  50. package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
  51. package/node_modules/@dougharness/flow/scripts/memory.mjs +197 -37
  52. package/node_modules/@dougharness/flow/scripts/plan.mjs +54 -64
  53. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
  54. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +105 -0
  55. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  56. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +50 -14
  57. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
  58. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +7 -7
  59. package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
  60. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +14 -13
  61. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
  62. package/node_modules/@dougharness/flow/workflows/doug-implement.js +145 -86
  63. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  64. package/node_modules/@dougharness/gates/hooks/hooks.json +11 -0
  65. package/node_modules/@dougharness/gates/lib/baseline.mjs +166 -1
  66. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +21 -14
  67. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +58 -1
  68. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +1 -1
  69. package/node_modules/@dougharness/gates/lib/config.mjs +20 -0
  70. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +95 -0
  71. package/node_modules/@dougharness/gates/lib/evidence.mjs +2 -0
  72. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +94 -0
  73. package/node_modules/@dougharness/gates/lib/run.mjs +4 -0
  74. package/node_modules/@dougharness/gates/lib/test-totals.mjs +40 -7
  75. package/node_modules/@dougharness/gates/lib/trace.mjs +24 -7
  76. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +225 -0
  77. package/node_modules/@dougharness/gates/package.json +1 -1
  78. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +133 -5
  79. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +558 -197
  80. package/node_modules/@dougharness/gates/scripts/trace.mjs +5 -1
  81. package/node_modules/@dougharness/gates/scripts/turn-start.mjs +17 -0
  82. package/node_modules/@dougharness/gates/scripts/verify.mjs +201 -0
  83. package/package.json +4 -4
  84. package/templates/board-app.js +4 -3
  85. package/node_modules/@dougharness/flow/lib/rehearse.mjs +0 -825
  86. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +0 -129
  87. package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +0 -92
  88. package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +0 -85
@@ -47,11 +47,15 @@ const fixAttempts = Number.isInteger(plan.fixAttempts) && plan.fixAttempts >= 0
47
47
  // one card. Agents are counted here and always enforced; tokens and wall time come from the runtime's budget
48
48
  // primitive and are enforced only when it reports them. plan.budget overrides any of the three. Keep these three
49
49
  // numbers equal to lib/plan.mjs's DEFAULT_BUDGET.
50
- const DEFAULT_BUDGET = { agents: 12, tokens: 400000, wallMinutes: 40 }
50
+ // agents 26 is the minimum that lets the default fixAttempts 5 run with one adversary seat when Codex is down and the Claude fallback runs on every pass (card flow-budget-after-parallel-adversary); a supervisor seat or a fix retry is not covered.
51
+ const DEFAULT_BUDGET = { agents: 26, tokens: 400000, wallMinutes: 40 }
51
52
  const taskBudget = { ...DEFAULT_BUDGET, ...(plan.budget && typeof plan.budget === 'object' ? plan.budget : {}) }
52
53
  // The phrase the verifier and reviewer put at the start of a finding when the spec cannot satisfy the plan's
53
54
  // acceptance criteria. Such a block goes back to the planner and is never retried.
54
55
  const CONTRADICTION_MARKER = 'SPEC CONTRADICTS ACCEPTANCE'
56
+ // Why a task stopped: every halt site sets one of these as stopClass beside its prose stopReason. Kept equal to
57
+ // STOP_CLASSES in lib/board.mjs by a template test (this file may not import).
58
+ const STOP_CLASSES = ['implementer-blocked', 'partial', 'stage-missing', 'spec-contradiction', 'environment', 'adversary-not-run', 'outside-owned', 'new-blockers-twice', 'fix-attempts-exhausted', 'stalled', 'budget', 'no-new-commit', 'dependency-skipped', 'stage-threw', 'level-adversary']
55
59
  // The phrase a verifier, checker, or size-check puts at the start of a finding when a command could not run for
56
60
  // an environment reason (a tool missing from the worktree even after the install step, a sandbox denial) rather
57
61
  // than a defect in the diff. The fix loop never retries such a finding (card reused-s-task-worktree-install):
@@ -60,6 +64,7 @@ const ENVIRONMENT_MARKER = 'ENVIRONMENT ONLY'
60
64
  // The one rule about commit messages, in every prompt that commits or checks commits: this repository records
61
65
  // attribution nowhere in git, and plan.mjs land refuses a branch that carries either trailer.
62
66
  const NO_TRAILERS = 'Commit messages carry no attribution trailers: no Co-Authored-By line and no Claude-Session line, whatever your defaults say. A commit with either is a finding and the plan cannot land.'
67
+ const LESSONS_NOT_SPEC = 'These are context from earlier runs, not part of the spec or the acceptance criteria; never report a finding, a spec contradiction, or a blocker against a lesson.'
63
68
  // The one rule about a partial result, in every prompt a worker (or a resumed worker) can end with one from
64
69
  // (card worker-context-handoff): quotes the harness's context notice (a PostToolUse hook in plugins/doug-gates) so
65
70
  // a worker knows partial=true is only for a worker the harness itself told to stop, never a first resort, and only
@@ -284,8 +289,11 @@ const INTEGRATE_SCHEMA = {
284
289
  branch: { type: 'string' },
285
290
  merged: { type: 'array', items: { type: 'string' } },
286
291
  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' } } } },
292
+ verify: { type: 'array', items: { type: 'object', required: ['command', 'ok'], properties: { command: { type: 'string' }, ok: { type: 'boolean' }, outputTail: { type: 'string' }, credited: { type: 'string' } } } },
288
293
  ok: { type: 'boolean' },
294
+ // Card level-adversary-empty-diff: the integration branch's HEAD sha before this stage's merges; the level
295
+ // adversary diffs from it. Optional: a missing or malformed value falls back to the level's base branch.
296
+ baseCommit: { type: 'string' },
289
297
  // Card integration-acceptance-recorded: only the last level's integrate stage fills this in (step 3b of
290
298
  // integratePrompt); not required, since an earlier level has not built the whole plan yet.
291
299
  acceptance: { type: 'array', description: 'one entry per acceptance command listed in the prompt, [] when none', items: { type: 'object', required: ['text', 'command', 'ok', 'exitCode'], properties: { text: { type: 'string' }, command: { type: 'string' }, ok: { type: 'boolean' }, exitCode: { type: ['integer', 'null'] } } } },
@@ -300,7 +308,7 @@ const INTEGRATE_SCHEMA = {
300
308
  // (empty, prose, or unrecognised) is dropped rather than trusted or blamed. A plan command with no matching
301
309
  // report is appended as { ..., reported: false } - unreported and failed are different causes and get different
302
310
  // reason clauses. Pure: never mutates its argument, never throws on odd shapes.
303
- function enforceIntegrationAcceptance(integration, isLastLevel, entries) {
311
+ function enforceIntegrationAcceptance(integration, isLastLevel, entries, verifyCommands = []) {
304
312
  if (!integration || typeof integration !== 'object') return integration
305
313
  if (!isLastLevel) {
306
314
  const rest = { ...integration }
@@ -309,10 +317,20 @@ function enforceIntegrationAcceptance(integration, isLastLevel, entries) {
309
317
  }
310
318
  const reported = Array.isArray(integration.acceptance) ? integration.acceptance.filter(a => a && typeof a === 'object') : []
311
319
  const acceptance = []
320
+ const verifyList = Array.isArray(integration.verify) ? integration.verify.filter(v => v && typeof v === 'object') : []
321
+ const repeated = new Set((Array.isArray(verifyCommands) ? verifyCommands : []).map(c => String(c).trim()))
312
322
  for (const e of entries) {
313
323
  if (!e.command) continue
314
324
  const wantCommand = String(e.command).trim()
315
325
  if (!wantCommand) continue
326
+ // Card flow-suite-dedupe: a command that repeats a plan.verify command takes its result from the verify item.
327
+ if (repeated.has(wantCommand)) {
328
+ const v = verifyList.find(x => typeof x.command === 'string' && String(x.command).trim() === wantCommand)
329
+ acceptance.push(v
330
+ ? { text: e.text, command: e.command, ok: v.ok === true, exitCode: v.ok === true ? 0 : null, from: 'verify' }
331
+ : { text: e.text, command: e.command, ok: false, exitCode: null, reported: false })
332
+ continue
333
+ }
316
334
  const match = reported.find(a => typeof a.command === 'string' && String(a.command).trim() === wantCommand)
317
335
  acceptance.push(match
318
336
  ? { text: match.text, command: e.command, ok: match.ok, exitCode: match.exitCode }
@@ -937,7 +955,7 @@ function workerPrompt(task, brief, n, lead) {
937
955
  // left an orphan - it defaults to `workers` for the plain, no-rebrief case where the two are the same list.
938
956
  function leadMergePrompt(task, lead, workers, baseBranch, allWorkers = workers) {
939
957
  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.`,
958
+ `You are the lead of the swarm for task ${task.id}. The workers have committed; merge their branches into the task branch and hand it to the checks. Work only in the git worktree you were started in, on branch ${lead.branch}, and never edit anything under the repository root. The lead's worktree (${lead.worktreePath}) may have been removed, so do not cd into it. First git checkout ${lead.branch} there; if git refuses because the branch is checked out in another worktree, that worktree is a leftover under .claude/worktrees: find it with git worktree list --porcelain, remove it with git worktree remove --force <path>, and check out again. If the branch does not exist, create it from ${baseBranch} with git checkout -b ${lead.branch} ${baseBranch} (the lead commits nothing, so the task branch is the base).`,
941
959
  PARTIAL_NEVER_MERGED,
942
960
  ``,
943
961
  `Task ${task.id}: ${task.title}`,
@@ -953,18 +971,18 @@ function leadMergePrompt(task, lead, workers, baseBranch, allWorkers = workers)
953
971
  `3. Do not implement missing pieces yourself: a brief a worker did not finish is a block with its name. ${NO_TRAILERS}`,
954
972
  `4. Remove every worker worktree to leave the repository tidy, merged or not (a blocked worker's is an orphan otherwise): git worktree remove --force <path> for each one below. Keep their branches.`,
955
973
  ...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.`,
974
+ `5. Return taskId="${task.id}", branch="${lead.branch}", worktreePath=the absolute path of the worktree you ran in (git rev-parse --show-toplevel), filesTouched from git diff --name-only ${baseBranch}...HEAD, commandsRun, commit from git rev-parse HEAD after the last merge, summary naming the briefs merged, blocked=false; blocked=true with the reason when a merge or the verification failed for a reason in the code.`,
957
975
  ].join('\n')
958
976
  }
959
977
 
960
978
  // 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
979
+ // re-brief the unfinished pieces, reading from the repository root (no worktree, no isolation key). `workers` is every
962
980
  // round-one outcome (so the lead sees a finished sibling's commit, not just the blocked ones); `blockedWorkers` is
963
981
  // the subset that needs a revised brief.
964
982
  function leadRebriefPrompt(task, lead, workers, blockedWorkers) {
965
983
  const finished = workers.filter(w => !w.blocked)
966
984
  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.`,
985
+ `You are the lead of the swarm for task ${task.id}. One or more workers are blocked; you get one chance to re-brief the unfinished pieces. You implement nothing and edit no file. Work read-only from the repository root you were started in. Do not cd into the lead's worktree (it may have been removed), create no branch, and edit nothing. Read a finished worker's committed files with \`git show <commit>:<path>\` or \`git show <commit>\`.`,
968
986
  ``,
969
987
  `Plan: ${plan.title}`,
970
988
  `Goal: ${plan.goal}`,
@@ -1114,6 +1132,7 @@ function verifyPrompt(task, impl, baseBranch) {
1114
1132
  `Task ${task.id}: ${task.title}`,
1115
1133
  `Spec:`,
1116
1134
  task.spec,
1135
+ ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []),
1117
1136
  ``,
1118
1137
  `Plan acceptance criteria (the spec must be consistent with these; a line starting with $ is the command that checks the criterion above it and exits 0 when it holds):`,
1119
1138
  ...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
@@ -1146,6 +1165,7 @@ function checkPrompt(task, impl, prevCommit, baseBranch, ledger) {
1146
1165
  `Task ${task.id}: ${task.title}`,
1147
1166
  `Spec:`,
1148
1167
  task.spec,
1168
+ ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []),
1149
1169
  ``,
1150
1170
  `Plan acceptance criteria (the spec must be consistent with these; a line starting with $ is the command that checks the criterion above it and exits 0 when it holds):`,
1151
1171
  ...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
@@ -1212,10 +1232,9 @@ function levelSpecLines(sReady) {
1212
1232
  // codex-review over a level's size-S tasks on the integration branch, once, after the merge (and once more to confirm
1213
1233
  // a fix pass). Every blocker must name a file, because that is how it finds the task it goes back to.
1214
1234
  function levelAdversaryPrompt(li, sReady, levelBase, confirming) {
1215
- const verify = (plan.verify || []).filter(Boolean)
1216
1235
  const guard = t => String(t).replace(/^DOUG_SPEC_EOF$/gm, 'DOUG_SPEC_EOF ')
1217
1236
  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('')}`,
1237
+ `cat <<'DOUG_SPEC_EOF' | ${adversary.command} --base ${shellQuote(levelBase)} --head ${shellQuote(integrationBranch)} --dir ${shellQuote(INTEGRATION_WORKTREE)} --spec - --timeout-ms ${adversary.timeoutMs}${typeof adversary.effort === 'string' && adversary.effort ? ` --effort ${adversary.effort}` : ''}`,
1219
1238
  `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
1239
  ...(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
1240
  ``,
@@ -1240,7 +1259,7 @@ function levelAdversaryPrompt(li, sReady, levelBase, confirming) {
1240
1259
  }
1241
1260
 
1242
1261
  function levelFallbackAdversaryPrompt(li, sReady, levelBase, why, confirming) {
1243
- const verify = (plan.verify || []).filter(Boolean)
1262
+ // Same scope as levelAdversaryPrompt (card flow-fallback-verify-scope): no verify list, the suite already ran at integrate.
1244
1263
  return [
1245
1264
  `You are the adversarial reviewer for level ${li} of plan "${plan.title}", standing in for codex-review, which could not run (${why}). The size-S tasks below were merged into ${integrationBranch} with one focused check each and no adversary of their own. Your job is to refute them together, in worktree ${INTEGRATION_WORKTREE} (relative to the repository root); the diff is ${levelBase}...${integrationBranch}.`,
1246
1265
  ``,
@@ -1248,8 +1267,7 @@ function levelFallbackAdversaryPrompt(li, sReady, levelBase, why, confirming) {
1248
1267
  `Plan acceptance criteria:`,
1249
1268
  ...acceptanceEntries(plan.acceptance || []).map(a => `- ${a.text}`),
1250
1269
  ``,
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}`),
1270
+ `Record every command you run, your own probe checks included, in commandsRun with its real exit code and outputTail (the last lines of its output); a blocker's evidence must cite one of them, and either that command failed or the evidence quotes a line of its outputTail, otherwise the loop records it as major.`,
1253
1271
  confirming
1254
1272
  ? `Then confirm the blockers you raised on this level are fixed, name each as fixed or still open, and check the fix for regressions rather than exploring afresh.`
1255
1273
  : `Then try to break the changes: inputs the tests do not cover, a spec sentence or acceptance criterion a diff does not meet, a test that was weakened, skipped, or deleted, and behavior outside the owned files. When a suspicion needs a check, write a throwaway one, run it, and remove it before you finish.`,
@@ -1275,6 +1293,7 @@ function reviewPrompt(task, impl, baseBranch, seat = 1) {
1275
1293
  `Task ${task.id}: ${task.title}`,
1276
1294
  `Spec:`,
1277
1295
  task.spec,
1296
+ ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []),
1278
1297
  `Owned files: ${task.files.join(', ')}`,
1279
1298
  `Plan acceptance criteria:`,
1280
1299
  ...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
@@ -1310,13 +1329,14 @@ function confirmationLines(pass, ledger, impl) {
1310
1329
  }
1311
1330
 
1312
1331
  function adversaryPrompt(task, impl, baseBranch, pass, ledger) {
1313
- const verify = [task.verify, ...(plan.verify || [])].filter(Boolean)
1332
+ const verify = [task.verify].filter(Boolean)
1314
1333
  const guard = s => String(s).replace(/^DOUG_SPEC_EOF$/gm, 'DOUG_SPEC_EOF ')
1315
1334
  const command = [
1316
1335
  `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
1336
  `Task ${task.id}: ${task.title}`,
1318
1337
  ``,
1319
1338
  guard(task.spec),
1339
+ ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []).map(guard),
1320
1340
  ``,
1321
1341
  ...(pass > 1 ? confirmationLines(pass, ledger, impl).map(guard) : [`This is the first pass of this task: explore freely and try to refute the change however you can.`]),
1322
1342
  ``,
@@ -1371,21 +1391,27 @@ function adversaryUnavailable(adv) {
1371
1391
  }
1372
1392
 
1373
1393
  function fallbackAdversaryPrompt(task, impl, baseBranch, why, pass, ledger) {
1374
- const verify = [task.verify, ...(plan.verify || [])].filter(Boolean)
1394
+ // Same scope as adversaryPrompt's Codex seat (card flow-fallback-verify-scope): the task's own verify only, not plan.verify.
1395
+ const verify = [task.verify].filter(Boolean)
1375
1396
  return [
1376
1397
  `You are the adversarial reviewer for task ${task.id}, standing in for codex-review, which could not run (${why}). Your job is to refute the change on branch ${impl.branch} in worktree ${impl.worktreePath}; the diff is ${baseBranch}...${impl.branch}. ${baseBranch} is this branch's base (an integration branch when the task follows an earlier level); files that already exist there are not this task's changes. Do not edit any file.`,
1377
1398
  ``,
1378
1399
  `Task ${task.id}: ${task.title}`,
1379
1400
  `Spec:`,
1380
1401
  task.spec,
1402
+ ...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []),
1381
1403
  `Owned files: ${task.files.join(', ')}`,
1382
1404
  `Plan acceptance criteria:`,
1383
1405
  ...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
1384
1406
  ...(pass > 1 ? confirmationLines(pass, ledger, impl) : []),
1385
1407
  ``,
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.`,
1408
+ ...(verify.length
1409
+ ? [
1410
+ `Run every one of these commands in the worktree and record each in commandsRun with its real exit code and outputTail (the last lines of its output):`,
1411
+ ...verify.map(v => ` $ ${v}`),
1412
+ ]
1413
+ : [`Record every command you run, your own probe checks included, in commandsRun with its real exit code and outputTail (the last lines of its output).`]),
1414
+ `A blocker's evidence must name one of ${verify.length ? 'those commands' : 'the commands in commandsRun'}, and either that command failed or the evidence quotes a line of its outputTail; otherwise the loop records it as major.`,
1389
1415
  pass > 1
1390
1416
  ? `Then confirm the open findings above and check the fix delta for regressions rather than exploring afresh.`
1391
1417
  : `Then try to break the change: inputs the tests do not cover, a spec sentence or acceptance criterion the diff does not meet, a test that was weakened, skipped, or deleted, and behavior outside the owned files. When a suspicion needs a check, write a throwaway one, run it, and remove it before you finish.`,
@@ -1417,14 +1443,26 @@ function integratePrompt(levelIndex, results, baseBranch, isLastLevel, keepWorkt
1417
1443
  ` if it exists (a previous level made it): use it as is; it already has ${integrationBranch} checked out.`,
1418
1444
  ]),
1419
1445
  ` Do every following git and verify command inside that worktree (git -C or cd into it).`,
1446
+ ` Before any merge of this stage, run git rev-parse HEAD in that worktree and report the full 40-character sha as baseCommit.`,
1420
1447
  `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
1448
  `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.`,
1449
+ ...(plan.verify.length
1450
+ ? [
1451
+ ` When .doug/hooks/scripts/verify.mjs exists, run them from the repository root (not inside the worktree) as one command, so a tree already verified is credited instead of re-run:`,
1452
+ ` node .doug/hooks/scripts/verify.mjs --json --by integrate --cwd .claude/worktrees/doug-integration --record-dir . ${worktrees.map(w => `--credit-dir ${shellQuote(w)}`).join(' ')} ${plan.verify.map(v => `--command ${shellQuote(v)}`).join(' ')}`,
1453
+ ` Copy each entry of its JSON results[] into verify as { command, ok, outputTail }, and add credited: "verified-tree" to each when the JSON's credited is not null. When verify.mjs does not exist, run the commands one by one as above.`,
1454
+ ]
1455
+ : []),
1422
1456
  // Card integration-acceptance-recorded: only the last level runs the plan's acceptance commands, and only
1423
1457
  // after the merges above - the plan is not complete at an earlier level, so its prompt never mentions this.
1424
1458
  ...(isLastLevel
1425
1459
  ? [
1426
1460
  `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}`]),
1461
+ ...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command
1462
+ ? ((plan.verify || []).some(v => String(v).trim() === String(a.command).trim())
1463
+ ? [`- ${a.text} is checked by step 3's \`${a.command}\`; do not run it again and do not report it in acceptance; the workflow takes its result from verify.`]
1464
+ : [`- ${a.text}`, ` $ ${a.command}`])
1465
+ : [`- ${a.text}`]),
1428
1466
  `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
1467
  `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
1468
  `A criterion with no $ line is not yours to judge here: do not report it.`,
@@ -1441,8 +1479,8 @@ function integratePrompt(levelIndex, results, baseBranch, isLastLevel, keepWorkt
1441
1479
  // ---- Task loop -------------------------------------------------------------------
1442
1480
  // One task from start to a verdict: implement (or check out a reused branch), then verify, review and the
1443
1481
  // 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).
1482
+ // the same worktree; a fix pass reruns the verifier or reviewer that blocked, then one focused check and the
1483
+ // adversary together, and the loop stops as soon as it stops converging (no new commit, two consecutive novel blockers, or the task budget).
1446
1484
  async function startTask(task, baseBranch, launch = agent) {
1447
1485
  if (task.reuse) return launch(reusePrompt(task, baseBranch), { label: `reuse:${task.id}`, phase: 'Implement', isolation: 'worktree', schema: IMPLEMENT_SCHEMA, model: CHECKOUT_MODEL })
1448
1486
  if (swarmApplies(task)) return runSwarm(task, baseBranch, launch)
@@ -1649,7 +1687,7 @@ async function runSwarm(task, baseBranch, launch) {
1649
1687
  const reason = `worker${round1Blocked.length === 1 ? '' : 's'} blocked: ${round1Blocked.map(w => `${w.n} (${w.id}): ${w.blockedReason}`).join('; ')}`
1650
1688
  // Rule 4: a worker still partial after its resume blocks the task exactly as before - no re-brief attempt.
1651
1689
  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
1690
+ // Rule 2: one re-brief, reading from the repository root (no isolation key, no new worktree), same schema as the
1653
1691
  // first split.
1654
1692
  stages.push('lead-rebrief')
1655
1693
  const revised = await launch(leadRebriefPrompt(task, lead, round1, round1Blocked), agentOpts('lead', null, { label: `lead-rebrief:${task.id}`, phase: 'Implement', agentType: 'doug-flow:lead', schema: LEAD_SCHEMA }))
@@ -1700,7 +1738,8 @@ async function runSwarm(task, baseBranch, launch) {
1700
1738
  // workers, in the order they were pushed. `workers` itself (review minor 5) still goes to leadMergePrompt as the
1701
1739
  // worktree-removal list, blocked ones included, so a blocked worker's worktree is not left an orphan.
1702
1740
  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 }))
1741
+ // Card swarm-lead-worktree-missing: the lead's own worktree is gone by now (rehearsal swarm-2026-10-09T15-20-40-679Z), so the merge gets a fresh one.
1742
+ const merged = await launch(leadMergePrompt(task, lead, finished, baseBranch, workers), agentOpts('lead', null, { label: `lead-merge:${task.id}`, phase: 'Implement', isolation: 'worktree', agentType: 'doug-flow:lead', schema: IMPLEMENT_SCHEMA }))
1704
1743
  if (!merged) return blocked('lead merge returned nothing (spawn failure or no structured output)', { worktreePath: lead.worktreePath, branch: lead.branch })
1705
1744
  return { ...merged, branch: merged.branch || lead.branch, worktreePath: merged.worktreePath || lead.worktreePath, swarmStages: stages, workers, splitReason: lead.splitReason, rebrief, checkFailed: workers.filter(w => w.checkFailed).map(w => w.id) }
1706
1745
  }
@@ -1790,27 +1829,27 @@ function ownedBySibling(task, p) {
1790
1829
 
1791
1830
  // Can this block be fixed by the same implementer, or must a human see it?
1792
1831
  function retriable(task, r) {
1793
- if (!r.impl) return { ok: false, reason: 'implementer returned nothing' }
1832
+ if (!r.impl) return { ok: false, reason: 'implementer returned nothing', stopClass: 'stage-missing' }
1794
1833
  // A fix pass that returned no structured result changed nothing the loop knows of: the branch and worktree it
1795
1834
  // had are still there, so the same implementer is asked again.
1796
1835
  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'}` }
1836
+ if (r.impl.blocked) return { ok: false, reason: `implementer blocked: ${r.impl.blockedReason || 'no reason given'}`, stopClass: 'implementer-blocked' }
1798
1837
  // A partial result is not a check failure, whether it came from the initial worker's one resume or from a fix
1799
1838
  // 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' }
1839
+ if (r.impl.partial) return { ok: false, reason: 'a partial result is not retried by the fix loop', stopClass: 'partial' }
1801
1840
  // Only a stage that actually ran can be missing; a fix pass deliberately skips the stages that did not block.
1802
1841
  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' }
1842
+ if (ran.includes('verify') && !r.ver) return { ok: false, reason: 'verifier returned nothing', stopClass: 'stage-missing' }
1843
+ if (ran.includes('review') && !r.rev) return { ok: false, reason: 'reviewer returned nothing', stopClass: 'stage-missing' }
1844
+ if (ran.includes('check') && !r.check) return { ok: false, reason: 'check returned nothing', stopClass: 'stage-missing' }
1806
1845
  const texts = [...((r.ver && r.ver.findings) || []), ...(((r.rev && r.rev.issues) || []).map(i => i.description))]
1807
1846
  const contradiction = texts.find(t => typeof t === 'string' && t.includes(CONTRADICTION_MARKER))
1808
- if (contradiction) return { ok: false, reason: `spec contradicts acceptance: ${contradiction}` }
1847
+ if (contradiction) return { ok: false, reason: `spec contradicts acceptance: ${contradiction}`, stopClass: 'spec-contradiction' }
1809
1848
  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'}` }
1849
+ if (environmentOnly) return { ok: false, reason: `environment, not the code: ${environmentOnly}`, stopClass: 'environment' }
1850
+ if (adversary && r.adv && !r.adv.ran && !r.blockingStage) return { ok: false, reason: `adversary did not run: ${r.adv.error || 'no reason given'}`, stopClass: 'adversary-not-run' }
1812
1851
  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(', ')}` }
1852
+ if (outside.length) return { ok: false, reason: `findings name files outside the owned set: ${outside.join(', ')}`, stopClass: 'outside-owned' }
1814
1853
  return { ok: true, reason: 'every finding is within the owned files' }
1815
1854
  }
1816
1855
 
@@ -2094,9 +2133,9 @@ function stageHead(ver, check) {
2094
2133
  }
2095
2134
 
2096
2135
  // 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.
2136
+ // ctx: { ledger, launch, lead, prevCommit, blocking }. On pass 1 the verifier, the reviewer, and the adversary run
2137
+ // together. On a fix pass the verifier or the reviewer reruns first when it is the stage that blocked, and ends the
2138
+ // pass if it blocks again; then the focused check and the adversary run together.
2100
2139
  async function runChecks(task, impl, pass, baseBranch, ctx) {
2101
2140
  const c = ctx || {}
2102
2141
  const ledger = c.ledger || []
@@ -2117,57 +2156,68 @@ async function runChecks(task, impl, pass, baseBranch, ctx) {
2117
2156
  if (check) ({ ver, rev } = readCheck(check))
2118
2157
  blockingStage = !(check && check.passed && rev && rev.approve) ? 'check' : null
2119
2158
  } 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.
2159
+ // The verifier, the reviewer, and the adversary are read-only and none reads another's result, so a first pass
2160
+ // launches them together; a null from any (skipped, or dead after retries) is that stage returning nothing.
2122
2161
  stages.push('verify')
2123
2162
  stages.push('review')
2124
2163
  const crew = crewOf(task)
2125
2164
  for (let seat = 2; seat <= crew.reviewers; seat++) stages.push(`review-${seat}`)
2165
+ const withAdversary = adversaryRequired(task)
2166
+ const advStages = []
2126
2167
  const pair = await parallel([
2127
2168
  () => launch(verifyPrompt(task, impl, baseBranch), agentOpts('verify', task, { label: `verify:${task.id}`, phase: 'Verify', agentType: 'doug-flow:verifier', schema: VERIFY_SCHEMA })),
2128
2169
  () => launch(reviewPrompt(task, impl, baseBranch), agentOpts('review', task, { label: `review:${task.id}`, phase: 'Review', agentType: 'doug-flow:reviewer', schema: REVIEW_SCHEMA })),
2129
2170
  ...Array.from({ length: crew.reviewers - 1 }, (_, i) => () => launch(reviewPrompt(task, impl, baseBranch, i + 2), agentOpts('review', task, { label: `review-${i + 2}:${task.id}`, phase: 'Review', agentType: 'doug-flow:reviewer', schema: REVIEW_SCHEMA }))),
2171
+ ...(withAdversary ? [() => runAdversaryCrew(task, impl, pass, baseBranch, ledger, launch, advStages)] : []),
2130
2172
  ])
2131
2173
  ver = (pair && pair[0]) || null
2132
- rev = pair ? mergeReviews(pair.slice(1)) : null
2174
+ rev = pair ? mergeReviews(pair.slice(1, 1 + crew.reviewers)) : null
2133
2175
  stageResults.verify = ver
2134
2176
  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)
2177
+ // The adversary no longer waits for verify and review (card flow-adversary-parallel, 2026-10-07): waiting
2178
+ // added about five minutes to every full task. The cost is a Codex review of trees verify or review rejects;
2179
+ // integration still needs all three verdicts (isReady), and the ledger reads each stage's findings.
2180
+ if (withAdversary) {
2181
+ stages.push(...advStages)
2182
+ adv = (pair && pair[1 + crew.reviewers]) || null
2138
2183
  stageResults.adversary = adv
2139
2184
  }
2140
2185
  blockingStage = !(ver && ver.passed) ? 'verify' : !(rev && rev.approve) ? 'review' : adversaryBlocked(adv) ? 'adversary' : null
2141
2186
  } else {
2142
2187
  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
- }
2188
+ if (first === 'verify') {
2189
+ stages.push('verify')
2190
+ ver = await launch(verifyPrompt(task, impl, baseBranch), agentOpts('verify', task, { label: `verify:${task.id}${suffix}`, phase: 'Verify', agentType: 'doug-flow:verifier', schema: VERIFY_SCHEMA }))
2191
+ stageResults.verify = ver
2192
+ if (!(ver && ver.passed)) blockingStage = 'verify'
2193
+ } else if (first === 'review') {
2194
+ stages.push('review')
2195
+ const crew = crewOf(task)
2196
+ for (let seat = 2; seat <= crew.reviewers; seat++) stages.push(`review-${seat}`)
2197
+ 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 }))))
2198
+ rev = mergeReviews(seats || [])
2199
+ stageResults.review = rev
2200
+ if (!(rev && rev.approve)) blockingStage = 'review'
2201
+ }
2202
+ if (!blockingStage) {
2203
+ // The focused check and the adversary are both read-only, so they run together (card flow-adversary-parallel).
2204
+ // The stages are recorded in the order the serial loop ran them (the adversary first when it blocked the last
2205
+ // pass), since updateLedger reads them in order to tell a stage confirming a fix from one re-reporting it.
2206
+ const withAdversary = adversaryRequired(task)
2207
+ const adversaryFirst = withAdversary && first === 'adversary'
2208
+ const advStages = []
2209
+ const runCheck = () => launch(checkPrompt(task, impl, c.prevCommit || null, baseBranch, ledger), agentOpts('verify', task, { label: `check:${task.id}:${pass}`, phase: 'Verify', agentType: 'doug-flow:verifier', schema: CHECK_SCHEMA }))
2210
+ const runAdv = () => runAdversaryCrew(task, impl, pass, baseBranch, ledger, launch, advStages)
2211
+ const both = !withAdversary ? [await runCheck()] : await parallel(adversaryFirst ? [runAdv, runCheck] : [runCheck, runAdv])
2212
+ check = (both && both[adversaryFirst ? 1 : 0]) || null
2213
+ stageResults.check = check
2214
+ // The one check stands in for both stages, so the rest of the loop and the report read it as they read them.
2215
+ if (check) ({ ver, rev } = readCheck(check))
2216
+ if (withAdversary) { adv = (both && both[adversaryFirst ? 0 : 1]) || null; stageResults.adversary = adv }
2217
+ stages.push(...(adversaryFirst ? [...advStages, 'check'] : ['check', ...advStages]))
2218
+ const checkBlocked = !(check && check.passed && rev && rev.approve)
2219
+ const advBlocked = withAdversary && adversaryBlocked(adv)
2220
+ blockingStage = adversaryFirst && advBlocked ? 'adversary' : checkBlocked ? 'check' : advBlocked ? 'adversary' : null
2171
2221
  }
2172
2222
  }
2173
2223
  const head = stageHead(ver, check)
@@ -2338,20 +2388,20 @@ async function runTask(task, baseBranch, resume) {
2338
2388
  })
2339
2389
  // The last supervisor that ran on this task, for the report; null when none did.
2340
2390
  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() }
2391
+ if (ready) return { ...r, attempts, ledger, budget: budgetOf(), stopReason: null, stopClass: null, supervisor: lastSupervisor() }
2342
2392
  const why = notReadyWhy(r)
2343
- const halt = reason => {
2393
+ const halt = (stopClass, reason) => {
2344
2394
  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() }
2395
+ return { ...r, attempts, ledger, budget: budgetOf(), stopReason: reason, stopClass, supervisor: lastSupervisor() }
2346
2396
  }
2347
- if (!decision.ok) return halt(`${why}; not retried: ${decision.reason}`)
2397
+ if (!decision.ok) return halt(decision.stopClass, `${why}; not retried: ${decision.reason}`)
2348
2398
  // Two passes in a row that each raise a blocker nobody had seen means the checks are exploring, not converging.
2349
2399
  const last = attempts[attempts.length - 1]
2350
2400
  const prior = attempts[attempts.length - 2]
2351
2401
  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(', ')})`)
2402
+ return halt('new-blockers-twice', `${why}; stopped: two consecutive passes raised new blockers (${prior.newFindings.join(', ')}; ${last.newFindings.join(', ')})`)
2353
2403
  }
2354
- if (attempts.length > fixAttempts) return halt(`${why}; fix attempts exhausted (${fixAttempts} of ${fixAttempts})`)
2404
+ if (attempts.length > fixAttempts) return halt('fix-attempts-exhausted', `${why}; fix attempts exhausted (${fixAttempts} of ${fixAttempts})`)
2355
2405
  // The supervisor (card fix-loop-supervisor): before a fix pass after the first, when the stall signals fire. A
2356
2406
  // pass that returned no result attacked nothing, so it is not a stall. A task stalled a second time stops here
2357
2407
  // instead of spending the remaining fix budget; otherwise the supervisor runs after the budget check below, on
@@ -2362,7 +2412,7 @@ async function runTask(task, baseBranch, resume) {
2362
2412
  if (signals.length && attempts.some(a => a.supervisor && a.supervisor.stalled)) {
2363
2413
  const findings = [...new Set(signals.filter(x => x.kind === 'repeat').map(x => x.finding))]
2364
2414
  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)})`)
2415
+ return halt('stalled', `${why}; stopped: stalled twice (${describeStall(signals)})`)
2366
2416
  }
2367
2417
  const blocking = r.blockingStage || 'verify'
2368
2418
  // What the next pass would cost: the fix agent plus the stages rule 3 would run. The adversary stage spends two
@@ -2391,7 +2441,7 @@ async function runTask(task, baseBranch, resume) {
2391
2441
  const projected = spent.elapsedMs + spent.elapsedMs / passesDone
2392
2442
  if (projected > limit) over = `elapsedMs ${show(projected)} > ${limit}`
2393
2443
  }
2394
- if (over) return halt(`${why}; stopped: next attempt would exceed the task budget (${over})`)
2444
+ if (over) return halt('budget', `${why}; stopped: next attempt would exceed the task budget (${over})`)
2395
2445
  const pass = r.pass + 1
2396
2446
  let brief = null
2397
2447
  if (signals.length) {
@@ -2447,7 +2497,7 @@ async function runTask(task, baseBranch, resume) {
2447
2497
  r = record({ task, impl, ver: null, rev: null, adv: null, check: null, pass, stages: ['fix'], stageResults: {}, blockingStage: null, commit: fix.commit || null }, prevCommit)
2448
2498
  const decided = fixBlocked ? retriable(task, r) : null
2449
2499
  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}`)
2500
+ return fixBlocked ? halt(decided.stopClass, `${notReadyWhy(r)}; not retried: ${decided.reason}`) : halt('no-new-commit', `${why}; stopped: fix pass ${pass} made no new commit on ${impl.branch}`)
2451
2501
  }
2452
2502
  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
2503
  r = record(await runChecks(task, next, pass, baseBranch, { ledger, launch, lead: 'fix', prevCommit, blocking }), prevCommit)
@@ -2507,7 +2557,7 @@ for (let li = 0; li < levels.length; li++) {
2507
2557
  }
2508
2558
  const reason = missing.length > 1 ? `dependencies ${missing.join(', ')} were not integrated` : `dependency ${missing[0]} was not integrated`
2509
2559
  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 })
2560
+ skipped.set(t, { task: t, impl: null, ver: null, rev: null, adv: null, check: null, attempts: [], ledger: [], budget: null, stages: [], stopReason: reason, stopClass: 'dependency-skipped', skippedReason: reason })
2511
2561
  }
2512
2562
 
2513
2563
  // Implement (or reuse) -> verify -> review -> adversary -> fix, per task, no barrier between tasks.
@@ -2523,7 +2573,7 @@ for (let li = 0; li < levels.length; li++) {
2523
2573
  // was ever made; blocked:true keeps `implemented` false so reusePlan still refuses to reuse it.
2524
2574
  const threwFallback = task => {
2525
2575
  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 }
2576
+ return { task, impl: { taskId: task.id, branch: task.reuse || `doug/task-${task.id}`, worktreePath: '', filesTouched: [], commandsRun: [], summary: reason, blocked: true, blockedReason: reason, commit: null }, ver: null, rev: null, adv: null, check: null, attempts: [], ledger: [], budget: null, stopReason: reason, stopClass: 'stage-threw' }
2527
2577
  }
2528
2578
  const byTask = new Map(runnable.map((task, i) => [task, results[i] || threwFallback(task)]))
2529
2579
  const ready = level.map(task => byTask.get(task) || skipped.get(task)).filter(isReady)
@@ -2537,7 +2587,7 @@ for (let li = 0; li < levels.length; li++) {
2537
2587
  const levelBase = baseBranch
2538
2588
  const sReady = adversary ? ready.filter(r => taskShape(r.task) === 'S') : []
2539
2589
  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 || []))
2590
+ integration = enforceIntegrationAcceptance(await retryOnce(integratePrompt(li, ready, baseBranch, li === levels.length - 1, sReady.length > 0, !integratedOnce), agentOpts('integrate', null, { label: `integrate:level-${li}`, phase: 'Integrate', schema: INTEGRATE_SCHEMA }), `level-${li}`), li === levels.length - 1, acceptanceEntries(plan.acceptance || []), plan.verify || [])
2541
2591
  integratedOnce = true
2542
2592
  if (integration && integration.ok) {
2543
2593
  baseBranch = integrationBranch
@@ -2548,7 +2598,12 @@ for (let li = 0; li < levels.length; li++) {
2548
2598
  // branch, after the merge. A blocker goes back to the task that owns its file as a fix pass, the fixed branches are
2549
2599
  // merged again, and the adversary confirms once; a level still blocked after that stops the run.
2550
2600
  if (integration && integration.ok && sReady.length) {
2551
- levelAdversary = await runLevelAdversary(li, sReady, levelBase, false)
2601
+ // Card level-adversary-empty-diff: the sha the integrator read before the level's first merge is the diff base;
2602
+ // the branch name would equal --head on every level >= 1. The re-integration never replaces it.
2603
+ const reportedBase = integration && integration.baseCommit
2604
+ const adversaryBase = typeof reportedBase === 'string' && /^[0-9a-f]{40}$/.test(reportedBase) ? reportedBase : levelBase
2605
+ if (adversaryBase === levelBase) log(`level ${li} level adversary falls back to ${levelBase} because the integrator reported no base commit`)
2606
+ levelAdversary = await runLevelAdversary(li, sReady, adversaryBase, false)
2552
2607
  if (levelAdversary.blocked) {
2553
2608
  const blockers = levelAdversary.issues.filter(i => i.severity === 'blocker')
2554
2609
  const items = blockers.length ? blockers : [{ severity: 'blocker', file: '', description: levelAdversary.summary || 'the level adversary blocked without an issue', evidence: null }]
@@ -2570,25 +2625,28 @@ for (let li = 0; li < levels.length; li++) {
2570
2625
  const adv = { ran: true, verdict: 'fail', summary: levelAdversary.summary, issues, commandsRun: levelAdversary.commandsRun, error: null }
2571
2626
  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
2627
  byTask.set(r.task, again)
2573
- levelAdversary.fixed.push({ task: r.task.id, ready: isReady(again), stopReason: again.stopReason })
2628
+ levelAdversary.fixed.push({ task: r.task.id, ready: isReady(again), stopReason: again.stopReason, stopClass: again.stopClass || null })
2574
2629
  }
2575
2630
  const fixedReady = [...perTask.keys()].map(r => byTask.get(r.task)).filter(isReady)
2576
2631
  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 || []))
2632
+ const again = enforceIntegrationAcceptance(await retryOnce(integratePrompt(li, fixedReady, levelBase, li === levels.length - 1, true, false), agentOpts('integrate', null, { label: `integrate:level-${li}:2`, phase: 'Integrate', schema: INTEGRATE_SCHEMA }), `level-${li}`), li === levels.length - 1, acceptanceEntries(plan.acceptance || []), plan.verify || [])
2578
2633
  levelAdversary.reintegration = again
2579
2634
  // The re-integration is the final state of the branch, so its acceptance (not the first integration's)
2580
2635
  // is what the level report carries (card integration-acceptance-recorded, behaviour 4). Only
2581
2636
  // `acceptance` is copied onto the level's `integration`; a failed re-integration's own `ok` and
2582
2637
  // `reason` stay on `levelAdversary.reintegration`, not on the level report.
2583
2638
  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)
2639
+ if (again && again.ok) levelAdversary.confirm = await runLevelAdversary(li, sReady.map(r => byTask.get(r.task) || r), adversaryBase, true)
2585
2640
  }
2586
2641
  }
2587
2642
  const cleared = !!(levelAdversary.confirm && !levelAdversary.confirm.blocked)
2588
2643
  if (!cleared) {
2589
2644
  for (const r of new Set([...sReady, ...perTask.keys()])) {
2590
2645
  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}`
2646
+ if (!cur.stopReason) {
2647
+ 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}`
2648
+ cur.stopClass = 'level-adversary'
2649
+ }
2592
2650
  byTask.set(r.task, cur)
2593
2651
  }
2594
2652
  for (const r of ready) integrated.delete(r.task.id)
@@ -2642,6 +2700,7 @@ for (let li = 0; li < levels.length; li++) {
2642
2700
  attempts: r.attempts,
2643
2701
  passes: r.attempts.length,
2644
2702
  stopReason: r.stopReason,
2703
+ stopClass: r.stopClass || null,
2645
2704
  // The fix-loop supervisor's last word on the task: ran, stalled, the brief it injected, and the stop when the
2646
2705
  // task stalled twice ({ kind: 'stalled', attempts, findings }); null when the loop never stalled.
2647
2706
  supervisor: r.supervisor || null,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "doug-gates",
3
3
  "displayName": "Doug Gates",
4
- "version": "1.0.2",
4
+ "version": "1.1.1",
5
5
  "description": "Deterministic guardrails for Claude Code: protected paths, bash guard, secret scan on edits and commands, format on edit, edit-loop detection, a verification gate on Stop and on SubagentStop, a snapshot of the plan and session into the anchor before compaction and re-anchoring after it, and a run trace of subagents and tool calls. Configured by .doug/config.json.",
6
6
  "author": {
7
7
  "name": "Doug contributors"
@@ -110,6 +110,17 @@
110
110
  ]
111
111
  }
112
112
  ],
113
+ "UserPromptSubmit": [
114
+ {
115
+ "hooks": [
116
+ {
117
+ "type": "command",
118
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/turn-start.mjs\"",
119
+ "timeout": 5
120
+ }
121
+ ]
122
+ }
123
+ ],
113
124
  "SessionStart": [
114
125
  {
115
126
  "matcher": "compact|resume",