@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,215 @@
1
+ // Shared helper for removing finished task worktrees, used by plan.mjs land and plan.mjs replan.
2
+ // A worktree is a candidate only when it is registered under `git worktree list --porcelain`, its
3
+ // real path (fs.realpathSync, so /var vs /private/var on macOS never causes a miss) sits strictly
4
+ // inside realpath(realpath(dir)/.claude/worktrees) — itself realpath'd, and never equal to that
5
+ // worktrees directory — it is not the main worktree or a path in `keep`, and it has a branch
6
+ // (detached worktrees are never touched). It qualifies for removal when its branch is one of
7
+ // `branches`, or `mergedInto` is a string and the branch is an ancestor of it. Qualifying worktrees
8
+ // are removed with `git worktree remove` (no --force); a refusal (modified/untracked files, or any
9
+ // other error) leaves the worktree in place and reports it as kept. This helper never deletes branches.
10
+
11
+ import { execFileSync, spawnSync } from "node:child_process";
12
+ import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
13
+ import { join, resolve, sep } from "node:path";
14
+
15
+ function git(dir, args) {
16
+ return execFileSync("git", args, { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60000 }).trim();
17
+ }
18
+
19
+ function tryGit(dir, args) {
20
+ try {
21
+ return git(dir, args);
22
+ } catch (err) {
23
+ return { error: err };
24
+ }
25
+ }
26
+
27
+ function lastNonEmptyLine(text) {
28
+ const lines = String(text || "").trimEnd().split("\n").filter((l) => l.trim());
29
+ return lines.length ? lines[lines.length - 1].trim() : "git worktree remove failed";
30
+ }
31
+
32
+ function realpathOrSelf(p) {
33
+ try {
34
+ return realpathSync(p);
35
+ } catch {
36
+ return p;
37
+ }
38
+ }
39
+
40
+ // Parses the plain (newline-delimited) output of `git worktree list --porcelain`. Deliberately not
41
+ // the -z form: that needs git 2.36+ and this helper must work on older git too. Each block is a
42
+ // run of non-blank lines ("worktree <path>", "HEAD <sha>", then either "branch refs/heads/<name>"
43
+ // or "detached"), and blocks are separated by a blank line. A "worktree " line always starts a new
44
+ // block. Every path Doug's own workflow creates under .claude/worktrees never contains a newline,
45
+ // so this parser does not attempt to reassemble a path that spans multiple raw lines: a worktree
46
+ // whose pathname contains a literal newline is unsupported (not a defect) and is simply not handled.
47
+ function parsePorcelain(text) {
48
+ const lines = String(text || "").split("\n");
49
+ const out = [];
50
+ let cur = null;
51
+ const flush = () => {
52
+ if (cur && cur.path) out.push({ path: cur.path, branch: cur.detached ? null : cur.branch || null });
53
+ cur = null;
54
+ };
55
+ for (const line of lines) {
56
+ if (line.startsWith("worktree ")) {
57
+ flush();
58
+ cur = { path: line.slice("worktree ".length) };
59
+ continue;
60
+ }
61
+ if (line === "") {
62
+ flush();
63
+ continue;
64
+ }
65
+ if (!cur) continue;
66
+ if (line.startsWith("branch ")) cur.branch = line.slice("branch refs/heads/".length);
67
+ else if (line === "detached") cur.detached = true;
68
+ }
69
+ flush();
70
+ return out;
71
+ }
72
+
73
+ // Returns { removed: string[], kept: { path, reason }[] }.
74
+ export function removeFinishedWorktrees(dir, { branches = [], mergedInto = null, keep = [] } = {}) {
75
+ const result = { removed: [], kept: [] };
76
+ const listing = tryGit(dir, ["worktree", "list", "--porcelain"]);
77
+ if (listing && listing.error) return result;
78
+
79
+ const worktrees = parsePorcelain(listing);
80
+
81
+ const rootReal = realpathOrSelf(dir);
82
+ const worktreesDirReal = realpathOrSelf(join(rootReal, ".claude", "worktrees"));
83
+ const baseDir = worktreesDirReal + sep;
84
+ const mainPath = worktrees.length ? worktrees[0].path : null;
85
+ const mainReal = mainPath ? realpathOrSelf(mainPath) : null;
86
+ // Resolve each keep entry against dir (never process.cwd()) before realpath'ing it.
87
+ const keepReal = new Set(keep.map((p) => realpathOrSelf(resolve(dir, p))));
88
+
89
+ for (const wt of worktrees) {
90
+ if (!wt.branch) continue; // detached, never touched
91
+ const real = realpathOrSelf(wt.path);
92
+ if (real === mainReal) continue;
93
+ if (real === worktreesDirReal) continue; // the worktrees dir itself is not inside itself
94
+ if (!(real + sep).startsWith(baseDir)) continue;
95
+ if (keepReal.has(real)) continue;
96
+
97
+ const inBranches = branches.includes(wt.branch);
98
+ const isMerged = typeof mergedInto === "string" && tryGit(dir, ["merge-base", "--is-ancestor", wt.branch, mergedInto]) === "";
99
+ if (!inBranches && !isMerged) continue;
100
+
101
+ const removal = tryGit(dir, ["worktree", "remove", wt.path]);
102
+ if (removal && removal.error) {
103
+ const stderr = removal.error.stderr || removal.error.message || "";
104
+ result.kept.push({ path: wt.path, reason: lastNonEmptyLine(stderr) });
105
+ } else {
106
+ result.removed.push(wt.path);
107
+ }
108
+ }
109
+
110
+ tryGit(dir, ["worktree", "prune"]);
111
+ return result;
112
+ }
113
+
114
+ // The worktrees registered under .claude/worktrees that sit on a branch, as { path, branch }.
115
+ function taskWorktrees(dir) {
116
+ const listing = tryGit(dir, ["worktree", "list", "--porcelain"]);
117
+ if (listing && listing.error) return [];
118
+ const rootReal = realpathOrSelf(dir);
119
+ const worktreesDirReal = realpathOrSelf(join(rootReal, ".claude", "worktrees"));
120
+ const baseDir = worktreesDirReal + sep;
121
+ const worktrees = parsePorcelain(listing);
122
+ const mainReal = worktrees.length ? realpathOrSelf(worktrees[0].path) : null;
123
+ return worktrees.filter((wt) => {
124
+ if (!wt.branch) return false;
125
+ const real = realpathOrSelf(wt.path);
126
+ return real !== mainReal && real !== worktreesDirReal && (real + sep).startsWith(baseDir);
127
+ });
128
+ }
129
+
130
+ // Everything uncommitted in a worktree as one patch: tracked changes against HEAD, then each untracked file as an
131
+ // addition. Empty when the worktree is clean.
132
+ function uncommittedDiff(wtPath) {
133
+ const parts = [];
134
+ const tracked = tryGit(wtPath, ["diff", "HEAD"]);
135
+ if (typeof tracked === "string" && tracked) parts.push(tracked);
136
+ const untracked = tryGit(wtPath, ["ls-files", "--others", "--exclude-standard"]);
137
+ for (const f of (typeof untracked === "string" ? untracked : "").split("\n").filter(Boolean)) {
138
+ // diff --no-index exits 1 when the files differ, which is the normal case here.
139
+ const r = spawnSync("git", ["diff", "--no-index", "--", "/dev/null", f], { cwd: wtPath, encoding: "utf8", timeout: 60000 });
140
+ if (r.stdout && r.stdout.trim()) parts.push(r.stdout.trim());
141
+ }
142
+ return parts.length ? parts.join("\n") + "\n" : "";
143
+ }
144
+
145
+ // The head commit of a local branch, or null when it does not exist. A resolver for lib/plan.mjs reusePlan and
146
+ // staleReuseErrors (their optional `git.branchHead`), used to find a recorded reuse branch that a prior replan
147
+ // renamed to `<branch>-stale-<n>`.
148
+ export function branchHead(dir, name) {
149
+ const r = tryGit(dir, ["rev-parse", "--verify", "--quiet", `refs/heads/${name}`]);
150
+ return typeof r === "string" && r ? r : null;
151
+ }
152
+
153
+ // Whether `dir` is inside a git working tree at all, by git's own test (`git rev-parse --git-dir`) rather than
154
+ // checking for a `.git` entry: that check misses a repo whose GIT_DIR is set elsewhere, or a `dir` that is inside
155
+ // a repo but not at its root (a subdirectory has no `.git` of its own). scripts/plan.mjs uses this to decide
156
+ // whether to hand reusePlan/staleReuseErrors a branchHead resolver at all.
157
+ export function isGitRepo(dir) {
158
+ const r = tryGit(dir, ["rev-parse", "--git-dir"]);
159
+ return typeof r === "string" && !!r;
160
+ }
161
+
162
+ // The smallest n >= 1 for which <branch>-stale-<n> is not a branch yet.
163
+ function staleName(dir, branch) {
164
+ for (let n = 1; ; n++) {
165
+ const name = `${branch}-stale-${n}`;
166
+ if (tryGit(dir, ["rev-parse", "--verify", "--quiet", `refs/heads/${name}`]).error) return name;
167
+ }
168
+ }
169
+
170
+ // Retires the worktree and branch of every task replan marks fresh (card replan-clean-worktrees, 2026-09-07): the
171
+ // worktree's uncommitted work is saved to <saveDir>/<task id>.diff first (only when there is any), the worktree is
172
+ // removed with --force, and the branch is renamed to <branch>-stale-<n> so the next run's git checkout -b finds the
173
+ // name free. Nothing is ever deleted: a branch is renamed, never removed. A task whose branch no longer exists, or
174
+ // whose worktree is already gone, is skipped without error.
175
+ // Returns { removed: string[], saved: { task, path }[], renamed: { task, from, to }[] }.
176
+ export function retireTaskWorktrees(dir, { tasks = [], saveDir }) {
177
+ const result = { removed: [], saved: [], renamed: [] };
178
+ const worktrees = taskWorktrees(dir);
179
+ for (const t of tasks) {
180
+ if (!t || typeof t.branch !== "string" || !t.branch) continue;
181
+ for (const wt of worktrees.filter((w) => w.branch === t.branch)) {
182
+ const diff = uncommittedDiff(wt.path);
183
+ if (diff) {
184
+ mkdirSync(saveDir, { recursive: true });
185
+ const file = join(saveDir, `${t.id}.diff`);
186
+ writeFileSync(file, diff);
187
+ result.saved.push({ task: t.id, path: file });
188
+ }
189
+ const removal = tryGit(dir, ["worktree", "remove", "--force", wt.path]);
190
+ if (!(removal && removal.error)) result.removed.push(wt.path);
191
+ }
192
+ if (tryGit(dir, ["rev-parse", "--verify", "--quiet", `refs/heads/${t.branch}`]).error) continue;
193
+ const to = staleName(dir, t.branch);
194
+ const rename = tryGit(dir, ["branch", "-m", t.branch, to]);
195
+ if (!(rename && rename.error)) result.renamed.push({ task: t.id, from: t.branch, to });
196
+ }
197
+ tryGit(dir, ["worktree", "prune"]);
198
+ return result;
199
+ }
200
+
201
+ // Retires the integration worktree at .claude/worktrees/doug-integration, if one is registered (card
202
+ // integration-worktree-stale, 2026-09-08): a leftover from an abandoned run must not survive into the next one,
203
+ // since the workflow's own leftover-vs-this-run's-own check (integratePrompt's firstIntegration) only runs inside
204
+ // a run already underway. Removed with --force; the branch is never renamed or deleted (the workflow's
205
+ // `git worktree add -B` on the next run's first integration resets it from the base branch). A worktree that is
206
+ // not there is not an error.
207
+ // Returns { removed: string | null } - the path removed, or null when none was registered.
208
+ export function retireIntegrationWorktree(dir) {
209
+ const target = join(realpathOrSelf(dir), ".claude", "worktrees", "doug-integration");
210
+ const match = taskWorktrees(dir).find((wt) => realpathOrSelf(wt.path) === realpathOrSelf(target));
211
+ if (!match) return { removed: null };
212
+ const removal = tryGit(dir, ["worktree", "remove", "--force", match.path]);
213
+ tryGit(dir, ["worktree", "prune"]);
214
+ return { removed: removal && removal.error ? null : match.path };
215
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@dougharness/flow",
3
+ "version": "1.0.1",
4
+ "private": true,
5
+ "description": "Plan, approve, implement one task per worktree, verify, review: a Workflow-tool template and subagents for Claude Code.",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "files": [
9
+ "agents",
10
+ "skills",
11
+ "workflows",
12
+ "scripts",
13
+ "lib",
14
+ ".claude-plugin"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22.16"
18
+ }
19
+ }
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env node
2
+ // CLI for the development board, used by the /doug-next and /core-next skills. Deterministic; no agents.
3
+ // board.mjs next [dir] [--track flow|hand] [--tag <tag>]
4
+ // the first Ready card on that track (default flow) whose deps are
5
+ // Done, as JSON (exit 1 if none); --tag filters to cards carrying it
6
+ // board.mjs next --batch <n> [dir] [--track flow|hand] [--tag <tag>]
7
+ // the first n runnable Ready cards, in board order, as a JSON array
8
+ // (exit 1 if none): the cards /doug-next plans together
9
+ // board.mjs card <id> [dir] one card as JSON
10
+ // board.mjs reorder <id> [dir] --before <other> | --after <other> | --top | --bottom
11
+ // reorder a card within its column
12
+ // board.mjs move <id> <column> [dir] [--source <text>]
13
+ // move a card
14
+ // board.mjs edit <id> [dir] [--title <t>] [--goal <g> | --goal-file <path>] [--size S|M|L] [--component <c>]
15
+ // [--track flow|hand] [--class <c>] [--deps a,b] [--tag a,b] [--source <s>] [--force]
16
+ // changes a card's fields through the record's own validation
17
+ // (never the id or the column; move stays the way to change that);
18
+ // --tag "" clears a card's tags
19
+ // board.mjs remove <id> [dir] [--force] removes a card from the record: refused always when another card
20
+ // lists it in deps; refused without --force when it is done, in
21
+ // flow, or named by .doug/plan.json (each with --force removes it
22
+ // and prints a warning to stderr); prints the removed card as JSON
23
+ // to stdout so a mistaken removal can be re-added by hand
24
+ // board.mjs record <id> <report.json> [dir] [--cost <usd>] [--codex-cost <usd>] [--shared-cost <usd>] [--wall <text>] [--commit <sha>] [--adversary "<id>=<class>[: <reason>]"]... [--note <text>] [--rehearsal <scenario>]
25
+ // append a run entry for the card to docs/live-runs.md; every adversary block
26
+ // in the report is classified real, marginal, or false (repeat the flag per block).
27
+ // When .doug/plan.json lists the card among its `cards` (a batch), the entry
28
+ // shows only that card's tasks, --cost is that card's tasks' cost, and
29
+ // --shared-cost <usd> the shared integration agents', counted once
30
+ // board.mjs record <id> --hand [dir] [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario>]
31
+ // append a hand-track entry (no report) for the card
32
+ // --rehearsal <scenario> (either form) marks the entry as a rehearse.mjs run on the ts-basic fixture: the first
33
+ // prose line and the commit sha (abbreviated to 7 characters) read as the fixture's, not this repository's.
34
+ // Either record form also promotes .doug/.state/research/<id>.md to docs/research/<id>.md when that note
35
+ // exists and differs from the docs copy, copying it and printing one line saying so; a card with no note, or
36
+ // whose docs copy is already byte-identical, is untouched, silently.
37
+ // board.mjs summary <report.json> [--cost <usd>] [--codex-cost <usd>] [--wall <text>] [--commit <sha>] [--card <id>]
38
+ // the three-line chat summary of a report (--card: that card's tasks only)
39
+
40
+ import { readFileSync } from "node:fs";
41
+ import { relative, resolve } from "node:path";
42
+ import { loadBoard, saveBoard, findCard, nextReadyCard, nextReadyCards, moveCard, editCard, removeCard, reorderCard, runEntry, handEntry, runSummary, boardPath, parseAdversaryClasses, filterReportForCard, recordLanding } from "../lib/board.mjs";
43
+ import { loadPlan, unwrapReport } from "../lib/plan.mjs";
44
+
45
+ const EDIT_FIELD_ORDER = ["title", "goal", "size", "component", "track", "class", "tags", "deps", "source"];
46
+ const EDIT_USAGE = "usage: board.mjs edit <id> [dir] [--title <t>] [--goal <g> | --goal-file <path>] [--size S|M|L] [--component <c>] [--track flow|hand] [--class <c>] [--tag a,b] [--deps a,b] [--source <s>] [--force]";
47
+
48
+ const argv = process.argv.slice(2);
49
+ const FLAGS = new Set(["hand", "top", "bottom", "force"]);
50
+ const REPEATABLE = new Set(["adversary"]);
51
+
52
+ // Each subcommand's allowed flags, derived from what that case actually reads (card board-cli-unknown-flags).
53
+ // `record` has two forms (--hand or not) with different allowed sets, handled separately below.
54
+ const SUBCOMMAND_FLAGS = {
55
+ next: ["track", "batch", "tag"],
56
+ card: [],
57
+ move: ["source"],
58
+ edit: ["title", "goal", "goal-file", "size", "component", "track", "class", "deps", "tag", "source", "force"],
59
+ remove: ["force"],
60
+ reorder: ["before", "after", "top", "bottom"],
61
+ summary: ["cost", "codex-cost", "wall", "commit", "card"],
62
+ };
63
+ const RECORD_HAND_FLAGS = ["hand", "commit", "wall", "gate", "note", "rehearsal"];
64
+ const RECORD_REPORT_FLAGS = ["cost", "codex-cost", "wall", "commit", "adversary", "shared-cost", "note", "rehearsal"];
65
+ const opts = {};
66
+ const positional = [];
67
+ for (let i = 0; i < argv.length; i++) {
68
+ if (!argv[i].startsWith("--")) {
69
+ positional.push(argv[i]);
70
+ continue;
71
+ }
72
+ const name = argv[i].slice(2);
73
+ if (FLAGS.has(name)) {
74
+ opts[name] = true;
75
+ continue;
76
+ }
77
+ if (REPEATABLE.has(name)) (opts[name] ||= []).push(argv[i + 1] ?? "");
78
+ else opts[name] = argv[i + 1] ?? "";
79
+ i++;
80
+ }
81
+ const [cmd, ...rest] = positional;
82
+
83
+ function fail(msg, code = 1) {
84
+ process.stderr.write(msg + "\n");
85
+ process.exit(code);
86
+ }
87
+
88
+ const USAGE_FOR = {
89
+ next: "usage: board.mjs next [dir] [--track flow|hand] [--batch <n>] [--tag <tag>]",
90
+ card: "usage: board.mjs card <id> [dir]",
91
+ move: "usage: board.mjs move <id> <column> [dir] [--source <text>]",
92
+ edit: EDIT_USAGE,
93
+ remove: "usage: board.mjs remove <id> [dir] [--force]",
94
+ reorder: "usage: board.mjs reorder <id> [dir] --before <other> | --after <other> | --top | --bottom",
95
+ summary: "usage: board.mjs summary <report.json> [--cost <usd>] [--codex-cost <usd>] [--wall <text>] [--commit <sha>] [--card <id>]",
96
+ };
97
+ const RECORD_HAND_USAGE = "usage: board.mjs record <id> --hand [dir] [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario>]";
98
+ const RECORD_REPORT_USAGE = "usage: board.mjs record <id> <report.json> [dir] [--cost <usd>] [--codex-cost <usd>] [--shared-cost <usd>] [--wall <text>] [--commit <sha>] [--adversary \"<id>=<class>[: <reason>]\"]... [--note <text>] [--rehearsal <scenario>]";
99
+
100
+ // An unknown --flag for the given subcommand: exit 2, name the flag and the subcommand's usage.
101
+ // Runs on flag names only (parse order doesn't matter), before any work or write.
102
+ function unknownFlagFor(cmd, opts) {
103
+ const allowed = new Set(cmd === "record" ? (opts.hand ? RECORD_HAND_FLAGS : RECORD_REPORT_FLAGS) : SUBCOMMAND_FLAGS[cmd] || []);
104
+ for (const name of Object.keys(opts)) {
105
+ if (!allowed.has(name)) return name;
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ function dirFrom(args, n) {
111
+ return resolve(process.env.CLAUDE_PROJECT_DIR || args[n] || process.cwd());
112
+ }
113
+
114
+ if (cmd === "record" || Object.prototype.hasOwnProperty.call(SUBCOMMAND_FLAGS, cmd)) {
115
+ const unknown = unknownFlagFor(cmd, opts);
116
+ if (unknown !== undefined) {
117
+ const usageLine = cmd === "record" ? (opts.hand ? RECORD_HAND_USAGE : RECORD_REPORT_USAGE) : USAGE_FOR[cmd];
118
+ fail(`unknown option --${unknown} for board.mjs ${cmd}\n${usageLine}`, 2);
119
+ }
120
+ }
121
+
122
+ try {
123
+ switch (cmd) {
124
+ case "next": {
125
+ const dir = dirFrom(rest, 0);
126
+ const track = opts.track === "hand" ? "hand" : "flow";
127
+ if (opts.track !== undefined && opts.track !== "flow" && opts.track !== "hand") fail(`unknown track "${opts.track}"; tracks are flow, hand`, 2);
128
+ const batch = opts.batch !== undefined ? Number(opts.batch) : null;
129
+ if (batch !== null && !(Number.isInteger(batch) && batch > 0)) fail(`--batch must be a positive integer, got ${JSON.stringify(opts.batch)}`, 2);
130
+ if (opts.tag !== undefined && opts.tag.includes(",")) fail(`--tag takes one tag here; got "${opts.tag}"`, 2);
131
+ const board = loadBoard(dir);
132
+ const { card, cards, skipped } = batch === null ? nextReadyCard(board, { track, tag: opts.tag }) : nextReadyCards(board, { track, batch, tag: opts.tag });
133
+ for (const s of skipped) process.stderr.write(s.hand ? `skipping ${s.id}: hand track (built by hand with /core-next, not by doug-next)\n` : s.flow ? `skipping ${s.id}: flow track (run it with /doug-next)\n` : `skipping ${s.id}: waiting on ${s.waitingOn.join(", ")}\n`);
134
+ if (batch === null ? !card : !cards.length) fail(track === "hand" ? "No Ready hand-track card whose dependencies are Done." : "No Ready card whose dependencies are Done.");
135
+ process.stdout.write(JSON.stringify(batch === null ? card : cards, null, 2) + "\n");
136
+ break;
137
+ }
138
+ case "card": {
139
+ const dir = dirFrom(rest, 1);
140
+ process.stdout.write(JSON.stringify(findCard(loadBoard(dir), rest[0]), null, 2) + "\n");
141
+ break;
142
+ }
143
+ case "move": {
144
+ const [id, column] = rest;
145
+ if (!id || !column) fail("usage: board.mjs move <id> <column> [dir] [--source <text>]", 2);
146
+ const dir = dirFrom(rest, 2);
147
+ saveBoard(dir, moveCard(loadBoard(dir), id, column, { source: opts.source }));
148
+ process.stdout.write(`Moved ${id} to ${column} in ${relative(dir, boardPath(dir))}.\n`);
149
+ break;
150
+ }
151
+ case "edit": {
152
+ const [id] = rest;
153
+ if (!id) fail(EDIT_USAGE, 2);
154
+ if (opts.goal !== undefined && opts["goal-file"] !== undefined) fail(EDIT_USAGE, 2);
155
+ const fields = {};
156
+ if (opts.title !== undefined) fields.title = opts.title;
157
+ if (opts.goal !== undefined) fields.goal = opts.goal;
158
+ if (opts["goal-file"] !== undefined) fields.goal = readFileSync(resolve(opts["goal-file"]), "utf8").trimEnd();
159
+ if (opts.size !== undefined) fields.size = opts.size;
160
+ if (opts.component !== undefined) fields.component = opts.component;
161
+ if (opts.track !== undefined) fields.track = opts.track;
162
+ if (opts.class !== undefined) fields.class = opts.class;
163
+ if (opts.deps !== undefined) fields.deps = opts.deps.split(",").map((d) => d.trim()).filter(Boolean);
164
+ if (opts.tag !== undefined) fields.tags = opts.tag.split(",").map((t) => t.trim()).filter(Boolean);
165
+ if (opts.source !== undefined) fields.source = opts.source;
166
+ const changed = EDIT_FIELD_ORDER.filter((k) => Object.prototype.hasOwnProperty.call(fields, k));
167
+ if (!changed.length) fail(EDIT_USAGE, 2);
168
+ const dir = dirFrom(rest, 1);
169
+ const plan = loadPlan(dir);
170
+ const { board, warnings } = editCard(loadBoard(dir), id, fields, { force: !!opts.force, plan });
171
+ saveBoard(dir, board);
172
+ for (const w of warnings) process.stderr.write(`warning: ${w}\n`);
173
+ process.stdout.write(`Edited ${id} (${changed.join(", ")}) in ${relative(dir, boardPath(dir))}.\n`);
174
+ break;
175
+ }
176
+ case "remove": {
177
+ const [id] = rest;
178
+ if (!id) fail("usage: board.mjs remove <id> [dir] [--force]", 2);
179
+ const dir = dirFrom(rest, 1);
180
+ const plan = loadPlan(dir);
181
+ const { board, removed, warnings } = removeCard(loadBoard(dir), id, { force: !!opts.force, plan });
182
+ saveBoard(dir, board);
183
+ for (const w of warnings) process.stderr.write(`warning: ${w}\n`);
184
+ process.stdout.write(`Removed ${id} (${removed.column}) from ${relative(dir, boardPath(dir))}.\n`);
185
+ process.stdout.write(JSON.stringify(removed, null, 2) + "\n");
186
+ break;
187
+ }
188
+ case "reorder": {
189
+ const [id] = rest;
190
+ const optionCount = ["before", "after", "top", "bottom"].filter((k) => opts[k] !== undefined).length;
191
+ const validValue = (v) => typeof v === "string" && v !== "" && !v.startsWith("--");
192
+ const badValue = (opts.before !== undefined && !validValue(opts.before)) || (opts.after !== undefined && !validValue(opts.after));
193
+ if (!id || optionCount !== 1 || badValue) fail("usage: board.mjs reorder <id> [dir] --before <other> | --after <other> | --top | --bottom", 2);
194
+ const dir = dirFrom(rest, 1);
195
+ const board = loadBoard(dir);
196
+ const card = findCard(board, id);
197
+ const record = relative(dir, boardPath(dir));
198
+ let placement, message;
199
+ if (opts.before !== undefined) {
200
+ placement = { before: opts.before };
201
+ message = `Reordered ${id} before ${opts.before} in ${record}.\n`;
202
+ } else if (opts.after !== undefined) {
203
+ placement = { after: opts.after };
204
+ message = `Reordered ${id} after ${opts.after} in ${record}.\n`;
205
+ } else if (opts.top) {
206
+ placement = { index: 0 };
207
+ message = `Reordered ${id} to the top of ${card.column} in ${record}.\n`;
208
+ } else {
209
+ placement = { index: board.cards.filter((c) => c.column === card.column).length };
210
+ message = `Reordered ${id} to the bottom of ${card.column} in ${record}.\n`;
211
+ }
212
+ saveBoard(dir, reorderCard(board, id, placement));
213
+ process.stdout.write(message);
214
+ break;
215
+ }
216
+ case "record": {
217
+ if (opts.hand) {
218
+ const [id] = rest;
219
+ if (!id) fail("usage: board.mjs record <id> --hand [dir] [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario>]", 2);
220
+ const dir = dirFrom(rest, 1);
221
+ const card = findCard(loadBoard(dir), id);
222
+ const entry = handEntry({ card, commit: opts.commit ?? null, wallClock: opts.wall ?? null, gate: opts.gate ?? null, note: opts.note ?? null, record: relative(dir, boardPath(dir)), rehearsal: opts.rehearsal ?? null });
223
+ const { file, promoted } = recordLanding(dir, id, entry);
224
+ if (promoted) process.stdout.write(`Promoted the research note for ${id} to ${promoted}.\n`);
225
+ process.stdout.write(`Appended a hand-track entry for ${id} to ${file}.\n`);
226
+ break;
227
+ }
228
+ const [id, reportFile] = rest;
229
+ if (!id || !reportFile) fail("usage: board.mjs record <id> <report.json> [dir] [--cost <usd>] [--codex-cost <usd>] [--shared-cost <usd>] [--wall <text>] [--commit <sha>] [--adversary \"<id>=<class>[: <reason>]\"]... [--note <text>] [--rehearsal <scenario>]", 2);
230
+ const dir = dirFrom(rest, 2);
231
+ const card = findCard(loadBoard(dir), id);
232
+ const report = unwrapReport(JSON.parse(readFileSync(resolve(reportFile), "utf8")));
233
+ // A batch plan (its `cards` list this card) makes the entry that card's part of the run.
234
+ const plan = loadPlan(dir);
235
+ const batch = plan && Array.isArray(plan.cards) && plan.cards.includes(id) ? plan.cards : null;
236
+ const taskIds = batch ? plan.tasks.filter((t) => t.card === id).map((t) => t.id) : [];
237
+ const entry = runEntry({ card, report, cost: opts.cost !== undefined ? Number(opts.cost) : null, codexCost: opts["codex-cost"] !== undefined ? Number(opts["codex-cost"]) : null, wallClock: opts.wall ?? null, mergeCommit: opts.commit ?? null, record: relative(dir, boardPath(dir)), adversary: parseAdversaryClasses(opts.adversary || []), batch, taskIds, sharedCost: opts["shared-cost"] !== undefined ? Number(opts["shared-cost"]) : null, note: opts.note ?? null, rehearsal: opts.rehearsal ?? null });
238
+ const { file, promoted } = recordLanding(dir, id, entry);
239
+ if (promoted) process.stdout.write(`Promoted the research note for ${id} to ${promoted}.\n`);
240
+ process.stdout.write(`Appended a run entry for ${id} to ${file}.\n`);
241
+ break;
242
+ }
243
+ case "summary": {
244
+ const [reportFile] = rest;
245
+ if (!reportFile) fail("usage: board.mjs summary <report.json> [--cost <usd>] [--codex-cost <usd>] [--wall <text>] [--commit <sha>] [--card <id>]", 2);
246
+ let report = unwrapReport(JSON.parse(readFileSync(resolve(reportFile), "utf8")));
247
+ if (opts.card !== undefined) {
248
+ // With the plan at hand the filter also covers a report whose tasks carry no card.
249
+ const plan = loadPlan(dirFrom(rest, 1));
250
+ report = filterReportForCard(report, opts.card, plan && Array.isArray(plan.tasks) ? plan.tasks.filter((t) => t.card === opts.card).map((t) => t.id) : []);
251
+ }
252
+ process.stdout.write(runSummary({ report, cost: opts.cost !== undefined ? Number(opts.cost) : null, codexCost: opts["codex-cost"] !== undefined ? Number(opts["codex-cost"]) : null, wallClock: opts.wall ?? null, mergeCommit: opts.commit ?? null }));
253
+ break;
254
+ }
255
+ default:
256
+ fail("usage: board.mjs <next|card|reorder|move|edit|remove|record|summary> ...", 2);
257
+ }
258
+ } catch (err) {
259
+ fail(err.message);
260
+ }
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ // Real cost of one doug-implement run from the local Claude Code transcripts; no network, nothing estimated.
3
+ // cost.mjs <run-id> [dir] [--json] [--claude-dir <path>]
4
+ // <run-id> the Workflow run id (wf_...)
5
+ // [dir] the project directory whose ~/.claude/projects slug is searched first (default cwd)
6
+ // --json print the full result as JSON instead of the text tables
7
+ // --by-card a batch run (the plan at [dir] lists `cards`): add the per-card table, each card's tasks'
8
+ // agents summed for that card and the shared integration agents listed once (`byCard` in --json)
9
+ // --claude-dir <path> where ~/.claude lives (tests point it at a fixture)
10
+ // --report <path> a saved workflow report (card adversary-usage-in-report): prices its adversary relays'
11
+ // Codex usage as a separate line/`codex` key, never mixed into the Claude total; default
12
+ // <dir>/REPORT_RELPATH (the last report any run wrote there, not necessarily this run's -
13
+ // the report carries no run id) when no --report is given and that file is readable
14
+ // Exit 1 when the run's journal cannot be found, or an explicit --report cannot be read/parsed; the text output ends
15
+ // with the run total and any unpriced agent. An unreadable/invalid default report is ignored, not a usage error.
16
+ import { readFileSync } from "node:fs";
17
+ import { resolve, join } from "node:path";
18
+ import { costRun, renderCost, costByCard, renderCostByCard } from "../lib/cost.mjs";
19
+ import { loadPlan, REPORT_RELPATH, unwrapReport } from "../lib/plan.mjs";
20
+
21
+ const argv = process.argv.slice(2);
22
+ const FLAGS = new Set(["json", "by-card"]);
23
+ const opts = {};
24
+ const positional = [];
25
+ for (let i = 0; i < argv.length; i++) {
26
+ if (!argv[i].startsWith("--")) {
27
+ positional.push(argv[i]);
28
+ continue;
29
+ }
30
+ const name = argv[i].slice(2);
31
+ if (FLAGS.has(name)) {
32
+ opts[name] = true;
33
+ continue;
34
+ }
35
+ opts[name] = argv[i + 1] ?? "";
36
+ i++;
37
+ }
38
+ const [runId, dirArg] = positional;
39
+ if (!runId) {
40
+ process.stderr.write("usage: cost.mjs <run-id> [dir] [--json] [--claude-dir <path>]\n");
41
+ process.exit(2);
42
+ }
43
+ try {
44
+ const projectDir = resolve(process.env.CLAUDE_PROJECT_DIR || dirArg || process.cwd());
45
+ let reportPath = opts.report ? resolve(opts.report) : join(projectDir, REPORT_RELPATH);
46
+ let report = null;
47
+ if (opts.report) {
48
+ report = unwrapReport(JSON.parse(readFileSync(reportPath, "utf8")));
49
+ } else {
50
+ // The default report is whichever run last wrote it, not necessarily this run's; missing, unreadable, or
51
+ // invalid JSON there is not this command's problem, so it is ignored rather than failing the whole run.
52
+ try {
53
+ report = unwrapReport(JSON.parse(readFileSync(reportPath, "utf8")));
54
+ } catch {
55
+ report = null;
56
+ reportPath = null;
57
+ }
58
+ }
59
+ const result = costRun({ runId, projectDir, claudeDir: opts["claude-dir"] ? resolve(opts["claude-dir"]) : undefined, report, reportPath });
60
+ const byCard = opts["by-card"] ? costByCard(result, loadPlan(projectDir)) : null;
61
+ if (opts.json) process.stdout.write(JSON.stringify(byCard ? { ...result, byCard } : result, null, 2) + "\n");
62
+ else process.stdout.write(renderCost(result) + (byCard ? "\n" + renderCostByCard(byCard) : ""));
63
+ } catch (err) {
64
+ process.stderr.write(err.message + "\n");
65
+ process.exit(1);
66
+ }