@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,547 @@
1
+ #!/usr/bin/env node
2
+ // CLI for the plan file. Used by the doug-plan, doug-approve, and doug-implement skills.
3
+ // plan.mjs validate [dir] [--file <path>]
4
+ // exit 0 if .doug/plan.json (or the plan at --file, a card's draft) is valid, 1 with errors otherwise
5
+ // plan.mjs show [dir] [--file <path>]
6
+ // human-readable summary, including resolved model tiers
7
+ // plan.mjs merge <card> <card>... [dir]
8
+ // several cards in one plan: join the drafts .doug/.state/drafts/<card>.json (one per
9
+ // card, written by that card's planner) into .doug/plan.json as one draft whose tasks
10
+ // carry their card, in board order when .doug/board.json is present. A file two cards
11
+ // own is sequenced (the later card's task depends on the earlier card's), printed, never
12
+ // refused; a cycle across cards is refused by name. Refuses to replace an approved plan
13
+ // that has not landed
14
+ // plan.mjs json [dir] the plan as JSON with model tiers resolved (for the Workflow tool as args)
15
+ // plan.mjs models [dir] the parsed CLAUDE.md Models table as JSON
16
+ // plan.mjs approve [dir] draft -> approved (validates first); also retires the doug/task-<id> branch of
17
+ // any task with no reuse mark, renaming it -stale-<n> the way replan does
18
+ // plan.mjs reject [dir] -> rejected
19
+ // plan.mjs done [--force] [dir]
20
+ // -> done; refuses when .doug/.state/last-report.json shows the last run of this
21
+ // plan paused at a human gate or stopped without finishing ok; --force overrides
22
+ // (not the paused refusal) and prints a warning
23
+ // plan.mjs anchor [dir] writes .doug/anchor.md from the plan (survives compaction)
24
+ // plan.mjs land [--delete-branches] [dir]
25
+ // merge the integration branch into the base branch with --no-ff, only when
26
+ // the plan is done and its verify commands pass on that branch; exit 1
27
+ // otherwise. Also removes finished task worktrees under .claude/worktrees
28
+ // (kept, and named, when one has local changes). --delete-branches (or
29
+ // land.deleteBranches: true in .doug/config.json) also deletes the plan's
30
+ // task branches after the merge with `git branch -d`; the integration
31
+ // branch is always kept
32
+ // plan.mjs set <key> <value> [dir]
33
+ // set install, baseBranch, integrationBranch, adversary.command,
34
+ // adversary.fallback (<model>[/<effort>], or off), card, fixAttempts, swarm (on|off),
35
+ // or workerCheck (on|off) in the plan file
36
+ // (the file is what the workflow and land read; never pass these as launch args only)
37
+ // plan.mjs replan [dir] set the plan back to draft and mark tasks that passed verify, review, and
38
+ // adversary in .doug/.state/last-report.json with reuse: <branch>. A fresh task's
39
+ // worktree under .claude/worktrees is removed (its uncommitted work saved first to
40
+ // .doug/.state/replan/<stamp>/<task>.diff) and its branch renamed <branch>-stale-<n>;
41
+ // a reused task keeps both. The integration worktree and branch are untouched.
42
+ // json and land fill `install` from .doug/config.json commands.install and `adversary.command` from
43
+ // commands.adversary (`{root}` = the project directory) when the plan has none.
44
+
45
+ import { writeFileSync } from "node:fs";
46
+ import { join, resolve } from "node:path";
47
+ import { loadPlan, savePlan, validatePlan, setStatus, renderPlan, levelize, withSpecHashes, loadReport, reusePlan, staleReuseErrors, REPORT_RELPATH, acceptanceEntries, humanGateLevels, planWarnings, mergePlans, draftPath, PLAN_RELPATH } from "../lib/plan.mjs";
48
+ import { loadBoard, boardPath } from "../lib/board.mjs";
49
+ import { loadModels, validateModelRefs, resolvePlanModels, renderModels, isModelName, EFFORTS } from "../lib/models.mjs";
50
+ import { landPlan, INTEGRATION_WORKTREE } from "../lib/land.mjs";
51
+ import { removeFinishedWorktrees, retireTaskWorktrees, retireIntegrationWorktree, branchHead, isGitRepo } from "../lib/worktrees.mjs";
52
+ import { readMemoryConfig, readMemoryStaleDays, readIndexConfig, createProvider } from "../lib/embeddings.mjs";
53
+ import { existsSync, readFileSync, statSync } from "node:fs";
54
+
55
+ // The project's install command from .doug/config.json, used when the plan does not name one.
56
+ function configInstall(d) {
57
+ const file = join(d, ".doug/config.json");
58
+ if (!existsSync(file)) return null;
59
+ try {
60
+ const c = JSON.parse(readFileSync(file, "utf8"));
61
+ return c && c.commands && typeof c.commands.install === "string" ? c.commands.install : null;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+ // The adversary command from .doug/config.json commands.adversary, with `{root}` replaced by the project
67
+ // directory, used when the plan does not name one (plan.adversary === false still disables the adversary).
68
+ function configAdversary(d) {
69
+ const file = join(d, ".doug/config.json");
70
+ if (!existsSync(file)) return null;
71
+ try {
72
+ const c = JSON.parse(readFileSync(file, "utf8"));
73
+ const cmd = c && c.commands && typeof c.commands.adversary === "string" ? c.commands.adversary.trim() : "";
74
+ return cmd ? cmd.replace(/\{root\}/g, d) : null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+ // Whether plan.mjs land should delete task branches after merging, from .doug/config.json land.deleteBranches.
80
+ // A missing or unparsable config, or anything other than boolean true, means false.
81
+ function configDeleteBranches(d) {
82
+ const file = join(d, ".doug/config.json");
83
+ if (!existsSync(file)) return false;
84
+ try {
85
+ const c = JSON.parse(readFileSync(file, "utf8"));
86
+ return !!(c && c.land && c.land.deleteBranches === true);
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+ function withDefaults(p, d) {
92
+ let out = p;
93
+ if (!out.install && configInstall(d)) out = { ...out, install: configInstall(d) };
94
+ const hasCommand = out.adversary && typeof out.adversary === "object" && typeof out.adversary.command === "string" && out.adversary.command.trim();
95
+ if (out.adversary !== false && !hasCommand && configAdversary(d)) out = { ...out, adversary: { ...(out.adversary && typeof out.adversary === "object" ? out.adversary : {}), command: configAdversary(d) } };
96
+ return out;
97
+ }
98
+ // Renders recalled lessons as one line per lesson, in rank order. Each lesson's own text gets a fixed budget
99
+ // (LESSON_CHAR_BUDGET): text over budget is cut at the last sentence boundary (". ", "! ", "? ", or a newline)
100
+ // within it, falling back to the last word boundary, then a hard cut, with an ellipsis ("…") appended; the
101
+ // citation (or kind, when there is none) stays on the line either way. A sentence/newline boundary this close
102
+ // to the start (MIN_CUT_KEPT) keeps too little to be worth it — a lesson opening with a heading-then-newline,
103
+ // or a stray early period, would otherwise collapse to almost nothing — so that case falls through to the word
104
+ // boundary (then the hard cut) instead, same as when no such boundary exists at all. That keeps any one lesson
105
+ // — the memory store holds whole files up to ~12k characters, not just short summaries — from ever crowding
106
+ // out the rest: the running total is kept under LESSONS_CHAR_CAP by skipping (not stopping at) a line that
107
+ // would overflow it, so a later, shorter lesson still renders even after an earlier one didn't fit. lessonIds
108
+ // carries exactly the ids of the lines rendered. { text: null, ids: [] } when nothing rendered, the caller's
109
+ // cue to omit the fields entirely.
110
+ const LESSONS_CHAR_CAP = 2000;
111
+ const LESSON_CHAR_BUDGET = 400;
112
+ const MIN_CUT_KEPT = 40;
113
+ function cutLessonText(text, budget) {
114
+ if (text.length <= budget) return text;
115
+ const slice = text.slice(0, budget);
116
+ let cut = -1;
117
+ for (const sep of [". ", "! ", "? "]) {
118
+ const idx = slice.lastIndexOf(sep);
119
+ if (idx !== -1) cut = Math.max(cut, idx + sep.length - 1);
120
+ }
121
+ const nl = slice.lastIndexOf("\n");
122
+ if (nl !== -1) cut = Math.max(cut, nl);
123
+ if (cut < MIN_CUT_KEPT) cut = -1;
124
+ if (cut === -1) {
125
+ const sp = slice.lastIndexOf(" ");
126
+ cut = sp !== -1 ? sp : budget;
127
+ }
128
+ return text.slice(0, cut).trimEnd() + "…";
129
+ }
130
+ function renderLessons(lessons) {
131
+ const text = [];
132
+ const ids = [];
133
+ let length = 0;
134
+ for (const lesson of Array.isArray(lessons) ? lessons : []) {
135
+ const line = `- [${lesson.id}] ${cutLessonText(lesson.text, LESSON_CHAR_BUDGET)} (${lesson.citation || lesson.kind})`;
136
+ const nextLength = length + (text.length ? 1 : 0) + line.length;
137
+ if (nextLength > LESSONS_CHAR_CAP) continue;
138
+ text.push(line);
139
+ ids.push(lesson.id);
140
+ length = nextLength;
141
+ }
142
+ return text.length ? { text: text.join("\n"), ids } : { text: null, ids: [] };
143
+ }
144
+
145
+ // Mirrors lib/memory.mjs's exported MEMORY_DB_RELPATH by value, not by import: importing that module loads
146
+ // node:sqlite at its top level (review MAJOR/MINOR 4), so the mere existence check below must not pull it in
147
+ // on a Node that lacks node:sqlite, or every plan.mjs json without a database would eat that module's load
148
+ // error. Keep this in sync with lib/memory.mjs if that constant ever moves.
149
+ const MEMORY_DB_RELPATH = ".doug/.state/memory/memory.db";
150
+
151
+ // Wraps a provider so a down or slow embeddings endpoint is ever hit once per `plan.mjs json` run: the first
152
+ // embed() failure is cached and handed to every later caller with no further request, and the run prints
153
+ // exactly one stderr line about it. Without this, a plan with many tasks rediscovers the same dead endpoint
154
+ // once per task, each paying its own request or timeout (review MAJOR: measured 26.8s for 8 tasks against a
155
+ // black-holed baseUrl). null in, null out: recallLessons already treats a null provider as keyword-only.
156
+ function cachingProvider(provider) {
157
+ if (!provider) return null;
158
+ let failure = null;
159
+ let warned = false;
160
+ return {
161
+ name: provider.name,
162
+ model: provider.model,
163
+ dims: provider.dims,
164
+ async embed(texts, opts) {
165
+ if (failure) return failure;
166
+ const result = await provider.embed(texts, opts);
167
+ if (!result.ok) {
168
+ failure = result;
169
+ if (!warned) {
170
+ warned = true;
171
+ process.stderr.write(`[doug] memory recall: embeddings provider unavailable (${result.reason}); keyword-only\n`);
172
+ }
173
+ }
174
+ return result;
175
+ },
176
+ };
177
+ }
178
+
179
+ // Attaches per-task recalled lessons (card memory-recall #2) to the plan JSON `plan.mjs json` emits, never to
180
+ // the plan file. Nothing reads a plan-level recall (the planner recalls on its own, before writing the plan),
181
+ // so only tasks are queried, one provider round trip saved per run. node:sqlite (via lib/memory.mjs) is loaded
182
+ // lazily, and only after confirming the database file exists, so this still works on an older Node with no
183
+ // database: any failure past that point (a broken database file, node:sqlite missing despite a database being
184
+ // present, ...) prints one stderr line and the plan comes back with no lessons fields, never a nonzero exit.
185
+ // `provider` is shared with attachCodeContext (card semantic-index, brief B) so a down or slow embeddings
186
+ // endpoint is discovered once per run across both attach steps, not once per step.
187
+ async function attachLessons(plan, dir, provider) {
188
+ if (!existsSync(join(dir, MEMORY_DB_RELPATH))) return plan;
189
+ try {
190
+ const { openMemory, recallLessons } = await import("../lib/memory.mjs");
191
+ const staleDays = readMemoryStaleDays(dir);
192
+ const m = openMemory(dir);
193
+ try {
194
+ const tasks = [];
195
+ for (const task of plan.tasks) {
196
+ const taskRecall = await recallLessons(m, `${task.title}\n${task.spec}`, { files: task.files, k: 8, checkoutDir: dir, provider, staleDays });
197
+ const taskLessons = renderLessons(taskRecall.lessons);
198
+ tasks.push(taskLessons.text === null ? task : { ...task, lessons: taskLessons.text, lessonIds: taskLessons.ids });
199
+ }
200
+ return { ...plan, tasks };
201
+ } finally {
202
+ m.close();
203
+ }
204
+ } catch (err) {
205
+ process.stderr.write(`[doug] memory recall skipped: ${err.message}\n`);
206
+ return plan;
207
+ }
208
+ }
209
+
210
+ // Renders code chunks the same skip-not-stop way renderLessons renders lessons, one line per chunk:
211
+ // "- <path>:<start>-<end> <first non-blank line of the chunk, trimmed to 120 chars>". codeContextIds carries
212
+ // exactly the ids of the lines kept, in rank order. { text: null, ids: [] } when nothing rendered, the caller's
213
+ // cue to omit the fields entirely.
214
+ const CODE_CONTEXT_CHAR_CAP = 2000;
215
+ function firstNonBlankLine(text) {
216
+ const line = String(text ?? "")
217
+ .split("\n")
218
+ .find((l) => l.trim()) || "";
219
+ return line.trim().slice(0, 120);
220
+ }
221
+ function renderCodeContext(chunks) {
222
+ const text = [];
223
+ const ids = [];
224
+ let length = 0;
225
+ for (const c of Array.isArray(chunks) ? chunks : []) {
226
+ const line = `- ${c.path}:${c.startLine}-${c.endLine} ${firstNonBlankLine(c.text)}`;
227
+ const nextLength = length + (text.length ? 1 : 0) + line.length;
228
+ if (nextLength > CODE_CONTEXT_CHAR_CAP) continue;
229
+ text.push(line);
230
+ ids.push(c.id);
231
+ length = nextLength;
232
+ }
233
+ return text.length ? { text: text.join("\n"), ids } : { text: null, ids: [] };
234
+ }
235
+
236
+ // Attaches per-task code context from the opt-in semantic code index (card semantic-index, brief B) to the plan
237
+ // JSON `plan.mjs json` emits, never to the plan file and never at plan level. Runs only when memory.index.enabled
238
+ // is true and the store holds at least one chunk; otherwise the plan comes back unchanged and nothing is
239
+ // printed. `provider` is the same cachingProvider instance attachLessons uses, so the endpoint is probed once
240
+ // per run, not twice. Any failure past that point (a broken database, node:sqlite missing) prints one stderr
241
+ // line and the plan comes back without the fields, never a nonzero exit.
242
+ async function attachCodeContext(plan, dir, provider) {
243
+ if (!readIndexConfig(dir).enabled) return plan;
244
+ if (!existsSync(join(dir, MEMORY_DB_RELPATH))) return plan;
245
+ try {
246
+ const { openMemory } = await import("../lib/memory.mjs");
247
+ const { searchIndex } = await import("../lib/code-index.mjs");
248
+ const m = openMemory(dir);
249
+ try {
250
+ const chunkCount = m.prepare("SELECT COUNT(*) AS n FROM code_chunks").get().n;
251
+ if (chunkCount === 0) return plan;
252
+ const tasks = [];
253
+ for (const task of plan.tasks) {
254
+ const result = await searchIndex(m, `${task.title}\n${task.spec}`, { k: 6, provider });
255
+ const rendered = renderCodeContext(result.chunks);
256
+ tasks.push(rendered.text === null ? task : { ...task, codeContext: rendered.text, codeContextIds: rendered.ids });
257
+ }
258
+ return { ...plan, tasks };
259
+ } finally {
260
+ m.close();
261
+ }
262
+ } catch (err) {
263
+ process.stderr.write(`[doug] code index skipped: ${err.message}\n`);
264
+ return plan;
265
+ }
266
+ }
267
+
268
+ const SETTABLE = ["install", "baseBranch", "integrationBranch", "adversary.command", "adversary.fallback", "card", "fixAttempts", "swarm", "workerCheck"];
269
+
270
+ const [cmd, ...rawArgs] = process.argv.slice(2);
271
+ // --file <path>: validate or show a plan file other than .doug/plan.json (a card's draft), relative to the project dir.
272
+ const fileFlag = rawArgs.indexOf("--file");
273
+ const planFile = fileFlag >= 0 && (cmd === "validate" || cmd === "show") ? rawArgs[fileFlag + 1] : undefined;
274
+ const restArgs = fileFlag >= 0 ? rawArgs.filter((_, i) => i !== fileFlag && i !== fileFlag + 1) : rawArgs;
275
+ const firstNonFlag = (args) => args.find((a) => !a.startsWith("--"));
276
+ // For merge the card ids come first and the optional dir last: the last positional counts as the dir only when it is one.
277
+ const lastDirArg = (args) => {
278
+ const positional = args.filter((a) => !a.startsWith("--"));
279
+ const last = positional[positional.length - 1];
280
+ return last && existsSync(last) && statSync(last).isDirectory() ? last : undefined;
281
+ };
282
+ const dirArg = cmd === "set" || cmd === "gate" ? restArgs.slice(2).find((a) => !a.startsWith("--")) : cmd === "merge" ? lastDirArg(restArgs) : firstNonFlag(restArgs);
283
+ const dir = resolve(process.env.CLAUDE_PROJECT_DIR || dirArg || process.cwd());
284
+ const deleteBranchesFlag = cmd === "land" && restArgs.includes("--delete-branches");
285
+ const forceDoneFlag = cmd === "done" && restArgs.includes("--force");
286
+ // Lets reusePlan/staleReuseErrors resolve a recorded reuse branch that a prior replan renamed to
287
+ // <branch>-stale-<n> (card fix-loop-reuse-after-fix-pass): both replan (which decides fresh reuse marks) and
288
+ // approve (which recomputes them to check a hand-set mark isn't stale) must agree, or approve would refuse a
289
+ // plan replan just wrote. Undefined outside a git working tree, checked with git's own `rev-parse --git-dir`
290
+ // (not a `.git`-presence check, which misses a relocated GIT_DIR or a `dir` inside a repo but not at its root),
291
+ // so reusePlan falls back to its pure default there instead of treating every recorded branch as missing.
292
+ const gitResolver = isGitRepo(dir) ? { branchHead: (name) => branchHead(dir, name) } : undefined;
293
+
294
+ function fail(msg, code = 1) {
295
+ process.stderr.write(msg + "\n");
296
+ process.exit(code);
297
+ }
298
+
299
+ const plan = loadPlan(dir, planFile);
300
+ if (!plan && cmd !== undefined && cmd !== "models" && cmd !== "merge") fail(`No plan at ${join(dir, planFile || PLAN_RELPATH)}. Run /doug-plan first.`, 2);
301
+ const models = loadModels(dir);
302
+
303
+ function allErrors(p) {
304
+ return [...validatePlan(p), ...models.errors, ...validateModelRefs(p, models)];
305
+ }
306
+
307
+ // Shape warnings are printed, never refused: the planner may have a reason, and the user sees them at approval.
308
+ function printWarnings(p) {
309
+ const warnings = planWarnings(p);
310
+ if (warnings.length) process.stdout.write("Warnings:\n" + warnings.map((w) => " ! " + w).join("\n") + "\n");
311
+ }
312
+
313
+ switch (cmd) {
314
+ case "validate": {
315
+ const errors = allErrors(plan);
316
+ if (errors.length) fail("Plan is invalid:\n" + errors.map((e) => " - " + e).join("\n"));
317
+ process.stdout.write(`Plan is valid: ${plan.tasks.length} tasks in ${levelize(plan.tasks).length} levels, status ${plan.status}.\n`);
318
+ printWarnings(plan);
319
+ break;
320
+ }
321
+ case "show": {
322
+ const errors = allErrors(plan);
323
+ process.stdout.write(renderPlan(plan) + "\n");
324
+ const resolved = resolvePlanModels(plan, models);
325
+ process.stdout.write(`\nModels${models.source ? ` (from ${models.source})` : ""}:\n${renderModels(resolved.models).join("\n")}\n`);
326
+ for (const t of resolved.tasks) if (t.model !== "inherit" || t.effort !== "inherit") process.stdout.write(` ${t.id}: ${t.model}${t.effort !== "inherit" ? ` / ${t.effort}` : ""}${t.tier ? ` (tier ${t.tier})` : ""}\n`);
327
+ if (errors.length) fail("\nPlan is invalid:\n" + errors.map((e) => " - " + e).join("\n"));
328
+ printWarnings(plan);
329
+ break;
330
+ }
331
+ case "json": {
332
+ const errors = models.errors.concat(validateModelRefs(plan, models));
333
+ if (errors.length) fail("Cannot resolve models:\n" + errors.map((e) => " - " + e).join("\n"));
334
+ const provider = cachingProvider(createProvider(readMemoryConfig(dir)));
335
+ const withLessons = await attachLessons(withDefaults(plan, dir), dir, provider);
336
+ const withCodeContext = await attachCodeContext(withLessons, dir, provider);
337
+ process.stdout.write(JSON.stringify(resolvePlanModels(withSpecHashes(withCodeContext), models)) + "\n");
338
+ break;
339
+ }
340
+ case "models": {
341
+ process.stdout.write(JSON.stringify(models, null, 2) + "\n");
342
+ if (models.errors.length) fail("Models table has errors:\n" + models.errors.map((e) => " - " + e).join("\n"));
343
+ break;
344
+ }
345
+ case "approve": {
346
+ const errors = allErrors(plan);
347
+ if (errors.length) fail("Refusing to approve an invalid plan:\n" + errors.map((e) => " - " + e).join("\n"));
348
+ if (plan.tasks.some((t) => t.reuse !== undefined)) {
349
+ const reuseErrors = staleReuseErrors(plan, loadReport(dir), { git: gitResolver });
350
+ if (reuseErrors.length) fail("Refusing to approve: stale reuse marks:\n" + reuseErrors.map((e) => " - " + e).join("\n"));
351
+ }
352
+ if (plan.status === "approved") {
353
+ process.stdout.write("Plan is already approved.\n");
354
+ break;
355
+ }
356
+ // A replan can mark a task reusable and keep its doug/task-<id> branch; if the reuse mark is then removed by
357
+ // hand (the spec changed), that branch is left behind for the next launch's checkout to collide with. Retire
358
+ // (same as replan's retireTaskWorktrees: worktree removed, uncommitted work saved first, branch renamed
359
+ // -stale-<n>) the branch of every task with no reuse mark. This runs exactly once, on the real draft ->
360
+ // approved transition, never on a re-approve of an already-approved plan and never on a landed plan (its
361
+ // land --delete-branches looks up task branches by name): a re-approve typed while a run is in flight (a
362
+ // second terminal, or someone re-approving before the gate opens and the relaunch) must not force-remove
363
+ // the running implementer's worktree or rename its live branch out from under it. Tasks marked reuse are
364
+ // never passed in. The integration worktree is untouched here; replan retires that.
365
+ if (gitResolver && !plan.landed) {
366
+ const fresh = plan.tasks.filter((t) => t.reuse === undefined).map((t) => ({ id: t.id, branch: `doug/task-${t.id}` }));
367
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
368
+ const retired = retireTaskWorktrees(dir, { tasks: fresh, saveDir: join(dir, ".doug/.state/replan", stamp) });
369
+ for (const s of retired.saved) process.stdout.write(` saved diff ${s.path}\n`);
370
+ for (const p of retired.removed) process.stdout.write(` removed worktree ${p}\n`);
371
+ for (const r of retired.renamed) process.stdout.write(` renamed branch ${r.from} to ${r.to}\n`);
372
+ }
373
+ printWarnings(plan);
374
+ savePlan(dir, setStatus(plan, "approved", new Date().toISOString()));
375
+ process.stdout.write(`Approved: ${plan.title}. Run /doug-implement to execute it.\n`);
376
+ break;
377
+ }
378
+ case "reject": {
379
+ savePlan(dir, setStatus(plan, "rejected"));
380
+ process.stdout.write("Plan rejected. Edit it or run /doug-plan again.\n");
381
+ break;
382
+ }
383
+ case "done": {
384
+ // A run paused at a human gate has levels still to run; landing it would merge half a plan. --force does not
385
+ // override this: a paused run genuinely has work left, unlike a stopped one that merely failed.
386
+ const lastReport = loadReport(dir);
387
+ if (lastReport && lastReport.paused && typeof lastReport.paused.level === "number" && lastReport.plan === plan.title && plan.status === "approved") fail(`Refusing to mark the plan done: the last run is paused at the human gate after level ${lastReport.paused.level} (next: ${(lastReport.paused.next || []).join(", ")}). Open it with plan.mjs gate open ${lastReport.paused.level} and resume the run with its id, or reject the plan.`);
388
+ // A run that stopped short (a failed level, a level adversary still blocked) or otherwise finished not-ok must
389
+ // not be markable done: that would let land merge a broken run. --force overrides with a printed warning.
390
+ if (lastReport && lastReport.plan === plan.title && plan.status === "approved" && lastReport.ok !== true) {
391
+ const stopReasons = [];
392
+ for (const level of Array.isArray(lastReport.levels) ? lastReport.levels : []) {
393
+ for (const t of Array.isArray(level.tasks) ? level.tasks : []) {
394
+ if (typeof t.stopReason === "string" && t.stopReason.trim()) stopReasons.push(`${t.id}: ${t.stopReason}`);
395
+ }
396
+ }
397
+ const levelPart = typeof lastReport.stoppedAtLevel === "number" ? `it stopped at level ${lastReport.stoppedAtLevel}` : "it finished not ok with no stopped level";
398
+ const reasonsPart = stopReasons.length ? ` (${stopReasons.join("; ")})` : " (no task recorded a stopReason)";
399
+ if (!forceDoneFlag) fail(`Refusing to mark the plan done: the last run is not ok; ${levelPart}${reasonsPart}. Re-plan with plan.mjs replan and run again, or plan.mjs done --force to override.`);
400
+ process.stderr.write(`Warning: marking the plan done although the last run is not ok (${typeof lastReport.stoppedAtLevel === "number" ? `stopped at level ${lastReport.stoppedAtLevel}` : "finished not ok with no stopped level"}).\n`);
401
+ }
402
+ savePlan(dir, setStatus(plan, "done", new Date().toISOString()));
403
+ process.stdout.write("Plan marked done.\n");
404
+ break;
405
+ }
406
+ case "anchor": {
407
+ const cardLines = typeof plan.card === "string" && plan.card.trim() ? [`Card: ${plan.card}`, ""] : Array.isArray(plan.cards) && plan.cards.length ? [`Cards: ${plan.cards.join(", ")}`, ""] : [];
408
+ const acceptanceLines = acceptanceEntries(plan.acceptance).flatMap(({ text, command }) => (command ? [`- ${text}`, ` $ ${command}`] : [`- ${text}`]));
409
+ const lines = [`# ${plan.title}`, "", ...cardLines, plan.goal, "", "Acceptance:", ...acceptanceLines, "", "Tasks and owned files:"];
410
+ for (const t of plan.tasks) lines.push(`- ${t.id}${t.card ? ` [${t.card}]` : ""}: ${t.title} -> ${t.files.join(", ")}${t.reuse ? ` (reuse ${t.reuse})` : ""}`);
411
+ writeFileSync(join(dir, ".doug/anchor.md"), lines.join("\n") + "\n");
412
+ process.stdout.write("Wrote .doug/anchor.md\n");
413
+ break;
414
+ }
415
+ case "land": {
416
+ const deleteBranches = deleteBranchesFlag || configDeleteBranches(dir);
417
+ const r = landPlan(dir, withDefaults(plan, dir), { deleteBranches });
418
+ if (!r.ok) fail(`Refusing to land: ${r.reason}`);
419
+ process.stdout.write(`Landed ${r.branch} into ${r.base} as ${r.mergeCommit}.\n`);
420
+ for (const v of r.verify) process.stdout.write(` verify ${v.ok ? "ok" : "failed"}: ${v.command} (${v.durationMs} ms)\n`);
421
+ for (const a of r.acceptance) process.stdout.write(` acceptance ${a.ok ? "ok" : "failed"}: ${a.text} (${a.durationMs} ms)\n`);
422
+ for (const p of r.worktrees.removed) process.stdout.write(` removed worktree ${p}\n`);
423
+ for (const k of r.worktrees.kept) process.stdout.write(` kept worktree ${k.path}: ${k.reason}\n`);
424
+ for (const n of r.branches.deleted) process.stdout.write(` deleted branch ${n}\n`);
425
+ for (const k of r.branches.kept) process.stdout.write(` kept branch ${k.name}: ${k.reason}\n`);
426
+ process.stdout.write(`The branch ${r.branch} is kept; delete it when you no longer need it. Recorded in .doug/plan.json as landed.\n`);
427
+ break;
428
+ }
429
+ case "gate": {
430
+ // plan.mjs gate open <level>: release the human gate after that level so a resumed run continues past it.
431
+ const [, action, levelArg] = process.argv.slice(2);
432
+ const gated = humanGateLevels(plan);
433
+ if (action !== "open" || !/^\d+$/.test(String(levelArg))) fail(`usage: plan.mjs gate open <level> [dir]${gated.length ? `\nlevels with a human gate: ${gated.join(", ")}` : "\nthis plan has no human gate"}`, 2);
434
+ if (plan.landed) fail("Refusing to change a landed plan.");
435
+ const level = Number(levelArg);
436
+ if (!gated.includes(level)) fail(`plan.mjs gate open: level ${level} has no human gate${gated.length ? ` (levels with one: ${gated.join(", ")})` : " (this plan has none)"}`, 2);
437
+ const opened = [...new Set([...(plan.gatesOpened || []), level])].sort((a, b) => a - b);
438
+ savePlan(dir, { ...plan, gatesOpened: opened });
439
+ process.stdout.write(`Opened the human gate after level ${level}. Resume the run with its id; levels already run replay, and the run continues with level ${level + 1}.\n`);
440
+ break;
441
+ }
442
+ case "set": {
443
+ const [, key, value] = process.argv.slice(2);
444
+ if (!SETTABLE.includes(key) || typeof value !== "string" || !value.trim()) fail(`usage: plan.mjs set <${SETTABLE.join("|")}> <value> [dir]`, 2);
445
+ if (existsSync(value) && statSync(value).isDirectory()) fail(`plan.mjs set ${key}: the value ${JSON.stringify(value)} is a directory; the value comes before the optional [dir] argument.\nusage: plan.mjs set <${SETTABLE.join("|")}> <value> [dir]`, 2);
446
+ if (plan.landed) fail("Refusing to change a landed plan.");
447
+ const next = { ...plan };
448
+ if (key === "adversary.command") next.adversary = { ...(plan.adversary && typeof plan.adversary === "object" ? plan.adversary : {}), command: value };
449
+ else if (key === "adversary.fallback") {
450
+ // The Claude adversary that stands in when codex-review cannot run: "<model>[/<effort>]", or "off" to block instead.
451
+ if (plan.adversary === false) fail("plan.mjs set adversary.fallback: the adversary is off (adversary: false); the fallback only stands in for a configured adversary.", 2);
452
+ const base = plan.adversary && typeof plan.adversary === "object" ? plan.adversary : {};
453
+ if (/^(off|false|none)$/i.test(value.trim())) next.adversary = { ...base, fallback: false };
454
+ else {
455
+ const [model, effort, extra] = value.split("/").map((s) => s.trim());
456
+ if (extra !== undefined || !isModelName(model) || (effort !== undefined && !EFFORTS.includes(effort))) fail(`plan.mjs set adversary.fallback: the value must be "off" or <model>[/<effort>] with effort one of ${EFFORTS.join(", ")}, got ${JSON.stringify(value)}`, 2);
457
+ next.adversary = { ...base, fallback: effort ? { model, effort } : { model } };
458
+ }
459
+ } else if (key === "swarm") {
460
+ // The swarm opt-in: a lead splits each full-shape task into worker briefs and merges the workers' branches.
461
+ if (!/^(on|off|true|false)$/i.test(value.trim())) fail(`plan.mjs set swarm: the value must be on or off, got ${JSON.stringify(value)}`, 2);
462
+ next.swarm = /^(on|true)$/i.test(value.trim());
463
+ } else if (key === "workerCheck") {
464
+ // Card swarm-topology (candidate 3): a deterministic per-worker check before the merge, behind the swarm opt-in.
465
+ if (!/^(on|off|true|false)$/i.test(value.trim())) fail(`plan.mjs set workerCheck: the value must be on or off, got ${JSON.stringify(value)}`, 2);
466
+ next.workerCheck = /^(on|true)$/i.test(value.trim());
467
+ } else if (key === "fixAttempts") {
468
+ if (!/^\d+$/.test(value)) fail(`plan.mjs set fixAttempts: the value must be a non-negative integer, got ${JSON.stringify(value)}`, 2);
469
+ next.fixAttempts = Number(value);
470
+ } else next[key] = value;
471
+ savePlan(dir, next);
472
+ process.stdout.write(`Set ${key} in .doug/plan.json.\n`);
473
+ break;
474
+ }
475
+ case "merge": {
476
+ const cardIds = restArgs.filter((a) => !a.startsWith("--") && a !== dirArg);
477
+ if (cardIds.length < 2) fail("usage: plan.mjs merge <card> <card>... [dir] (at least two cards)", 2);
478
+ if (plan && plan.status === "approved" && !plan.landed) fail(`Refusing to replace an approved plan that has not landed ("${plan.title}"); land it with plan.mjs done and plan.mjs land, or reject it.`);
479
+ const drafts = [];
480
+ for (const card of cardIds) {
481
+ const d = loadPlan(dir, draftPath(card));
482
+ if (!d) fail(`no draft for card ${card} at ${draftPath(card)}; its planner writes it (one planner per card, in parallel).`);
483
+ drafts.push({ card, plan: d });
484
+ }
485
+ // Board order decides the sequence when the board is there; otherwise the order given.
486
+ let order = null;
487
+ if (existsSync(boardPath(dir))) {
488
+ try {
489
+ order = loadBoard(dir).cards.map((c) => c.id);
490
+ } catch {
491
+ order = null;
492
+ }
493
+ }
494
+ let merged;
495
+ try {
496
+ merged = mergePlans(drafts, { order });
497
+ } catch (e) {
498
+ fail(e.message);
499
+ }
500
+ const errors = allErrors(merged.plan);
501
+ if (errors.length) fail("The merged plan is invalid:\n" + errors.map((e) => " - " + e).join("\n"));
502
+ savePlan(dir, merged.plan);
503
+ process.stdout.write(`Merged ${merged.plan.cards.length} cards into ${PLAN_RELPATH}: ${merged.plan.cards.join(", ")}${order ? " (board order)" : ""}.\n`);
504
+ for (const r of merged.renamed) process.stdout.write(` task ${r.card}/${r.from} renamed ${r.to} (the id was taken by an earlier card)\n`);
505
+ for (const s of merged.sequenced) process.stdout.write(` ${s.file}: ${s.task.card}/${s.task.task} runs after ${s.after.card}/${s.after.task} (both own it)\n`);
506
+ for (const n of merged.notes) process.stdout.write(` ${n}\n`);
507
+ if (!merged.sequenced.length) process.stdout.write(" no file is owned by two cards; every level runs its cards' tasks in parallel\n");
508
+ process.stdout.write(`Plan is valid: ${merged.plan.tasks.length} tasks in ${levelize(merged.plan.tasks).length} levels, status draft.\n`);
509
+ printWarnings(merged.plan);
510
+ break;
511
+ }
512
+ case "replan": {
513
+ if (plan.landed) fail("Refusing to replan a landed plan.");
514
+ const report = loadReport(dir);
515
+ if (report === null) fail(`No report at ${REPORT_RELPATH}; run the workflow first (doug-next saves its report there).`);
516
+ const { plan: next, decisions } = reusePlan(plan, report, { git: gitResolver });
517
+ savePlan(dir, next);
518
+ process.stdout.write(`Plan "${plan.title}" is a draft again.\n`);
519
+ for (const d of decisions) {
520
+ if (d.reuse) process.stdout.write(d.renamed ? ` ${d.id}: reuse ${d.reuse} (${d.reason})\n` : ` ${d.id}: reuse ${d.reuse}\n`);
521
+ else process.stdout.write(` ${d.id}: fresh (${d.reason})\n`);
522
+ }
523
+ // A fresh task's worktree and branch are retired so the next run starts clean (its uncommitted work is saved
524
+ // first); a reused task keeps both, since the checkout agent takes over its branch.
525
+ const reused = new Set(decisions.filter((d) => d.reuse).map((d) => d.id));
526
+ const fresh = [];
527
+ for (const level of (report && Array.isArray(report.levels) ? report.levels : [])) {
528
+ for (const t of (level && Array.isArray(level.tasks) ? level.tasks : [])) {
529
+ if (t && typeof t.branch === "string" && t.branch && !reused.has(t.id)) fresh.push({ id: t.id, branch: t.branch });
530
+ }
531
+ }
532
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
533
+ const retired = retireTaskWorktrees(dir, { tasks: fresh, saveDir: join(dir, ".doug/.state/replan", stamp) });
534
+ for (const s of retired.saved) process.stdout.write(` saved diff ${s.path}\n`);
535
+ for (const p of retired.removed) process.stdout.write(` removed worktree ${p}\n`);
536
+ for (const r of retired.renamed) process.stdout.write(` renamed branch ${r.from} to ${r.to}\n`);
537
+ // The integration worktree is cleared too: a leftover from the plan's last run must not be reused as is by
538
+ // the next one (card integration-worktree-stale). Its branch is untouched; the workflow's first integration
539
+ // recreates the worktree from the base branch.
540
+ const integrationRetired = retireIntegrationWorktree(dir);
541
+ if (integrationRetired.removed) process.stdout.write(` removed integration worktree ${integrationRetired.removed}\n`);
542
+ process.stdout.write("Review it with plan.mjs show, edit .doug/plan.json if a fresh task needs a new spec, then approve.\n");
543
+ break;
544
+ }
545
+ default:
546
+ fail("usage: plan.mjs <validate|show|json|models|approve|reject|done|anchor|land|set|replan|gate|merge> [dir]", 2);
547
+ }