@dougharness/cli 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/board-serve.js +40 -6
  2. package/dist/board-serve.js.map +1 -1
  3. package/dist/init.js +1 -1
  4. package/dist/init.js.map +1 -1
  5. package/dist/memory-setup.d.ts +7 -0
  6. package/dist/memory-setup.js +70 -46
  7. package/dist/memory-setup.js.map +1 -1
  8. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
  9. package/node_modules/@dougharness/flow/agents/coder.md +23 -0
  10. package/node_modules/@dougharness/flow/agents/implementer.md +1 -1
  11. package/node_modules/@dougharness/flow/agents/planner.md +1 -1
  12. package/node_modules/@dougharness/flow/agents/researcher.md +2 -1
  13. package/node_modules/@dougharness/flow/agents/reviewer.md +3 -1
  14. package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
  15. package/node_modules/@dougharness/flow/agents/tester.md +21 -0
  16. package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
  17. package/node_modules/@dougharness/flow/hooks/register.tsx +94 -0
  18. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
  19. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
  20. package/node_modules/@dougharness/flow/lib/board.mjs +91 -10
  21. package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
  22. package/node_modules/@dougharness/flow/lib/card-loop.mjs +778 -0
  23. package/node_modules/@dougharness/flow/lib/coordinator.mjs +393 -0
  24. package/node_modules/@dougharness/flow/lib/cost.mjs +15 -5
  25. package/node_modules/@dougharness/flow/lib/decisions.mjs +4 -2
  26. package/node_modules/@dougharness/flow/lib/embeddings.mjs +144 -3
  27. package/node_modules/@dougharness/flow/lib/land.mjs +27 -8
  28. package/node_modules/@dougharness/flow/lib/memory.mjs +406 -76
  29. package/node_modules/@dougharness/flow/lib/plan.mjs +33 -5
  30. package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
  31. package/node_modules/@dougharness/flow/package.json +1 -1
  32. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +440 -0
  33. package/node_modules/@dougharness/flow/scripts/board.mjs +107 -32
  34. package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
  35. package/node_modules/@dougharness/flow/scripts/memory.mjs +33 -10
  36. package/node_modules/@dougharness/flow/scripts/plan.mjs +104 -30
  37. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +8 -2
  38. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +30 -10
  39. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
  40. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
  41. package/node_modules/@dougharness/flow/skills/research/SKILL.md +1 -1
  42. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +6 -6
  43. package/node_modules/@dougharness/flow/workflows/card-queen.js +846 -0
  44. package/node_modules/@dougharness/flow/workflows/doug-implement.js +63 -23
  45. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  46. package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
  47. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +196 -6
  48. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +40 -8
  49. package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
  50. package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
  51. package/node_modules/@dougharness/gates/package.json +1 -1
  52. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
  53. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +119 -15
  54. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +156 -14
  55. package/node_modules/@dougharness/gates/scripts/verify.mjs +8 -4
  56. package/package.json +3 -3
  57. package/templates/board-app.js +46 -0
@@ -0,0 +1,94 @@
1
+ // The live autopilot pane (card autopilot-live-queue). It opens itself when a run starts (card autopilot-pane-auto-open); /doug-autopilot reopens it. It polls
2
+ // .doug/.state/autopilot.json (written by scripts/autopilot.mjs) and shows the same lines the board's panel shows.
3
+ // It also refreshes on prompt submit, turn end, and after a Bash call, because a reload drops the session.start timer (card autopilot-pane-current).
4
+ import { atom, read, update } from 'claude-code'
5
+ import type { Register } from 'claude-code'
6
+
7
+ import { AUTOPILOT_FILE, autopilotLines, autopilotView, parseAutopilot, shouldOpenPane } from '../lib/autopilot-core.mjs'
8
+
9
+ const PANE = 'doug-autopilot'
10
+ const POLL_MS = 5000
11
+ const lines = atom({ plugin: 'doug-flow', key: 'autopilot' } as const, [] as string[])
12
+ // The startedAt of the run the pane was last opened for, so a pane the user closed stays closed for that run.
13
+ const opened = atom({ plugin: 'doug-flow', key: 'autopilot-opened' } as const, null as string | null)
14
+
15
+ const refresh = async ($: any) => {
16
+ try {
17
+ const root = await $.session.root()
18
+ let text = ''
19
+ try {
20
+ text = await $.fs.read(`${root}/${AUTOPILOT_FILE}`)
21
+ } catch {
22
+ // no file: no run
23
+ }
24
+ const state = parseAutopilot(text)
25
+ const fresh = autopilotLines(autopilotView(state, new Date()))
26
+ await update($, lines, old => (JSON.stringify(old) === JSON.stringify(fresh) ? old : fresh))
27
+ // Only `autopilot.autoOpenPane: false` in .doug/config.json turns the auto-open off.
28
+ let autoOpen = true
29
+ try {
30
+ autoOpen = JSON.parse(await $.fs.read(`${root}/.doug/config.json`))?.autopilot?.autoOpenPane !== false
31
+ } catch {
32
+ // missing or unparsable config: flag on
33
+ }
34
+ const last = await read($, opened)
35
+ if (state && shouldOpenPane(state, last, autoOpen, new Date())) {
36
+ await update($, opened, () => state.startedAt as string)
37
+ await $.ui.open({ id: PANE, title: 'Autopilot' })
38
+ }
39
+ } catch {
40
+ // a failed poll keeps the last drawing
41
+ }
42
+ }
43
+
44
+ export const register: Register = on => {
45
+ on('session.start', async ($, e, next) => {
46
+ await $.command.register({
47
+ name: 'doug-autopilot',
48
+ description: 'Show the autopilot queue live in a pane',
49
+ })
50
+
51
+ // The timer starts only in an interactive session; the event hooks run in any session (two small reads per prompt, turn end, and Bash call).
52
+ if (e.isInteractive) {
53
+ await refresh($)
54
+ $.clock.every(POLL_MS, () => refresh($))
55
+ }
56
+
57
+ return next(e)
58
+ })
59
+
60
+ // A reload drops the session.start timer, so these hooks also refresh the pane (card autopilot-pane-current).
61
+ on('prompt.submit', async ($, e, next) => {
62
+ await refresh($)
63
+ return next(e)
64
+ })
65
+
66
+ on('turn.complete', async ($, e, next) => {
67
+ await refresh($)
68
+ return next(e)
69
+ })
70
+
71
+ on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
72
+ const ran = await next(e)
73
+ await refresh($)
74
+ return ran
75
+ })
76
+
77
+ on('command.run', { command: 'doug-autopilot' }, async $ => {
78
+ await $.ui.open({ id: PANE, title: 'Autopilot' })
79
+ return { text: 'Autopilot pane opened.' }
80
+ })
81
+
82
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
83
+ const { Box, Text } = $.ui.resolve(e)
84
+ const shown = await read($, lines)
85
+ return (
86
+ <Box flexDirection="column">
87
+ {shown.length === 0 && <Text dimColor>No autopilot run.</Text>}
88
+ {shown.map(line => (
89
+ <Text>{line}</Text>
90
+ ))}
91
+ </Box>
92
+ )
93
+ })
94
+ }
@@ -0,0 +1,117 @@
1
+ // The pure half of the autopilot view (card autopilot-live-queue): no Node imports, so the live pane's hooks module
2
+ // (which may import only its own files) can use it. lib/autopilot-view.mjs re-exports it and adds the file read.
3
+ export const AUTOPILOT_FILE = ".doug/.state/autopilot.json";
4
+ export const STEPS = ["research", "tester", "coder", "reviewer", "gate", "commit", "record"];
5
+ // A running file untouched for this long is no longer a live run (the lead died or the session closed). The lead's
6
+ // conservative choice where the card is silent.
7
+ export const INACTIVE_MS = 2 * 60 * 60 * 1000;
8
+
9
+ // The state parsed from file text, or null for invalid JSON, a wrong version, or a non-object. Never throws.
10
+ export function parseAutopilot(text) {
11
+ try {
12
+ const s = JSON.parse(text);
13
+ if (!s || typeof s !== "object" || Array.isArray(s) || s.version !== 1) return null;
14
+ if (s.cards !== undefined && !Array.isArray(s.cards)) return null;
15
+ return s;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ const hhmm = (iso) => {
22
+ const t = Date.parse(iso);
23
+ if (Number.isNaN(t)) return "";
24
+ const d = new Date(t);
25
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
26
+ };
27
+
28
+ const usd = (n) => (typeof n === "number" ? `$${n.toFixed(2)}` : "-");
29
+ const num = (n, suffix = "") => (typeof n === "number" ? `${n}${suffix}` : "-");
30
+
31
+ // The view of a state at `now` (a Date or a millisecond count), or null for a null state.
32
+ export function autopilotView(state, now = new Date()) {
33
+ if (!state || typeof state !== "object") return null;
34
+ const nowMs = new Date(now).getTime();
35
+ const ended = state.status === "ended";
36
+ const updated = Date.parse(state.updatedAt);
37
+ const stale = !ended && !(nowMs - updated <= INACTIVE_MS);
38
+ const mode = ended ? "ended" : stale ? "inactive" : "running";
39
+ const live = mode === "running";
40
+
41
+ const cards = (Array.isArray(state.cards) ? state.cards : [])
42
+ .filter((c) => c && typeof c === "object")
43
+ .map((c) => {
44
+ let cardState = c.state;
45
+ let detail = "";
46
+ if (cardState === "running" && !live) {
47
+ cardState = "interrupted";
48
+ detail = "interrupted";
49
+ } else if (cardState === "running") {
50
+ detail = c.step ? `${c.step} since ${hhmm(c.stepStartedAt)}`.trim() : "started";
51
+ } else if (cardState === "landed") {
52
+ detail = c.commit ? String(c.commit) : "";
53
+ } else if (cardState === "stopped") {
54
+ detail = c.reason ? String(c.reason) : "";
55
+ }
56
+ return { id: c.id, title: c.title ?? "", size: c.size ?? "", class: c.class ?? "", state: cardState, detail };
57
+ });
58
+
59
+ const runningCard = live ? (Array.isArray(state.cards) ? state.cards : []).find((c) => c && c.state === "running") : null;
60
+ const running = live
61
+ ? (Array.isArray(state.cards) ? state.cards : []).filter((c) => c && c.state === "running").map((c) => ({ id: c.id, runId: c.runId ?? null }))
62
+ : [];
63
+ const current = runningCard ? { id: runningCard.id, step: runningCard.step ?? null, since: runningCard.step ? hhmm(runningCard.stepStartedAt) : null } : null;
64
+
65
+ const landed = cards.filter((c) => c.state === "landed").length;
66
+ const stopped = cards.filter((c) => c.state === "stopped").length;
67
+ const header =
68
+ mode === "ended"
69
+ ? `Autopilot ended ${hhmm(state.endedAt ?? state.updatedAt)}: ${landed} landed, ${stopped} stopped`.replace(/\s+:/, ":")
70
+ : mode === "inactive"
71
+ ? `Autopilot inactive since ${hhmm(state.updatedAt)}`
72
+ : `Autopilot running since ${hhmm(state.startedAt)}`;
73
+
74
+ const caps = state.caps && typeof state.caps === "object" ? state.caps : {};
75
+ const used = state.used && typeof state.used === "object" ? state.used : {};
76
+ return {
77
+ mode,
78
+ header,
79
+ cards,
80
+ current,
81
+ running,
82
+ caps: {
83
+ usd: `${usd(used.usd)} / ${usd(caps.usd)}`,
84
+ wall: `${num(used.wallMin)} / ${num(caps.wallMin)} min`,
85
+ context: `${num(used.contextPct, "%")} / ${num(caps.contextPct, "%")}`,
86
+ },
87
+ filed: (Array.isArray(state.filed) ? state.filed : []).filter((f) => f && typeof f === "object").map((f) => ({ ...f })),
88
+ };
89
+ }
90
+
91
+ // Whether the live pane should open on its own: a running, still-active run whose startedAt is not the one already
92
+ // opened for (a closed pane is not reopened for the same run). `autoOpen` false (autopilot.autoOpenPane) turns it off.
93
+ export function shouldOpenPane(state, lastOpenedStartedAt, autoOpen = true, now = new Date()) {
94
+ if (autoOpen === false) return false;
95
+ if (!state || typeof state !== "object") return false;
96
+ if (state.status !== "running") return false;
97
+ if (typeof state.startedAt !== "string" || state.startedAt === "") return false;
98
+ if (state.startedAt === lastOpenedStartedAt) return false;
99
+ return autopilotView(state, now)?.mode === "running";
100
+ }
101
+
102
+ const MARKS = { landed: "✓", running: "▶", stopped: "✗", queued: "·", interrupted: "!" };
103
+
104
+ // The view as text lines for a text surface: the header, one line per card, a caps line, one line per filed card.
105
+ export function autopilotLines(view) {
106
+ if (!view) return [];
107
+ const lines = [view.header];
108
+ for (const c of view.cards) {
109
+ const shape = [c.size, c.class].filter(Boolean).join("/");
110
+ lines.push(`${MARKS[c.state] ?? "?"} ${c.id}${c.title ? ` ${c.title}` : ""}${shape ? ` (${shape})` : ""}${c.detail ? ` ${c.detail}` : ""}`);
111
+ }
112
+ lines.push(`caps: usd ${view.caps.usd}, wall ${view.caps.wall}, context ${view.caps.context}`);
113
+ for (const f of view.filed) {
114
+ lines.push(`+ ${f.id} (${f.kind}${f.from ? ` from ${f.from}` : ""}) ${f.position ?? ""}`.trimEnd());
115
+ }
116
+ return lines;
117
+ }
@@ -0,0 +1,17 @@
1
+ // The autopilot state file (.doug/.state/autopilot.json, card autopilot-live-queue) as the pane and the served board
2
+ // page show it. The pure view lives in autopilot-core.mjs (no Node imports, so the pane can use it); this module adds
3
+ // the file read. Neither throws on a missing, unreadable, or malformed file; a view of "no run" is null.
4
+ import { readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { AUTOPILOT_FILE, parseAutopilot } from "./autopilot-core.mjs";
7
+
8
+ export { AUTOPILOT_FILE, STEPS, INACTIVE_MS, parseAutopilot, autopilotView, autopilotLines, shouldOpenPane } from "./autopilot-core.mjs";
9
+
10
+ // The parsed state, or null for a missing or unreadable file, invalid JSON, a wrong version, or a non-object.
11
+ export function readAutopilot(dir) {
12
+ try {
13
+ return parseAutopilot(readFileSync(join(dir, AUTOPILOT_FILE), "utf8"));
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
@@ -62,7 +62,9 @@ export function readyCapWarning(board) {
62
62
  // autopilot-wall-cap-by-size, wall-budget-size-class). Each size has `all`; a class key is present only when its
63
63
  // cell had WALL_BUDGET_MIN_N or more landings. `board.mjs budgets --from docs/live-runs.md` recomputes it.
64
64
  export const WALL_BUDGET_MIN_N = 5;
65
- export const WALL_BUDGET_MIN = {"S":{"all":57,"code":53,"docs":29,"gate-script":116,"prose":50,"tests-only":66},"M":{"all":102,"code":112,"gate-script":289},"L":{"all":274}};
65
+ // Source: `board.mjs budgets --from docs/live-runs.md` on 2026-10-12 over 373 cards with a Wall clock row (S 274, M 83, L 16;
66
+ // rehearsal entries skipped, repeated ids summed).
67
+ export const WALL_BUDGET_MIN = {"S":{"all":52,"code":46,"docs":29,"gate-script":112,"prose":41,"tests-only":66},"M":{"all":106,"code":90,"gate-script":157},"L":{"all":170}};
66
68
 
67
69
  // A card's budget: its size's class cell when it has one, else the size's `all`. No or an unknown size counts as M.
68
70
  export function cardBudgetMinutes(card, table = WALL_BUDGET_MIN) {
@@ -78,8 +80,9 @@ export function wallCapMinutes(cards, table = WALL_BUDGET_MIN) {
78
80
 
79
81
  // The landings in a docs/live-runs.md text: one { id, minutes } per `## <date>, <id>: <title>` section that has a
80
82
  // `| Wall clock | <n> min |` row (text after min is allowed; a decimal n is skipped) (the first such row). Older multi-column tables have no such row and are skipped.
83
+ // Rehearsal entries are skipped, and a card with several entries is one sample, their sum.
81
84
  export function parseLiveRunWalls(text) {
82
- const out = [];
85
+ const byId = new Map();
83
86
  let id = null;
84
87
  let seen = false;
85
88
  for (const line of String(text).split("\n")) {
@@ -90,13 +93,18 @@ export function parseLiveRunWalls(text) {
90
93
  continue;
91
94
  }
92
95
  if (id === null || seen) continue;
96
+ // A rehearsal entry (the fixture's run, not this repository's) is no landing of the card.
97
+ if (/^Rehearsal /.test(line)) {
98
+ id = null;
99
+ continue;
100
+ }
93
101
  const w = /^\| Wall clock \| (\d+) min(?:\s[^|]*)? \|\s*$/.exec(line);
94
102
  if (w) {
95
- out.push({ id, minutes: Number(w[1]) });
103
+ byId.set(id, (byId.get(id) ?? 0) + Number(w[1]));
96
104
  seen = true;
97
105
  }
98
106
  }
99
- return out;
107
+ return [...byId].map(([k, minutes]) => ({ id: k, minutes }));
100
108
  }
101
109
 
102
110
  // The table recomputed from samples joined to their cards: p80 (sorted ascending, index min(floor(0.8 n), n - 1)) per size and per size/class cell. A
@@ -126,6 +134,71 @@ export function wallBudgetTable(samples, board, { minN = WALL_BUDGET_MIN_N, fall
126
134
  return { table, cells };
127
135
  }
128
136
 
137
+ // USD budget per card in dollars, a size x class table at p80 of measured cost over flow and hand landings (card
138
+ // usd-budget-size-class), shaped like WALL_BUDGET_MIN. Each size has `all`; a class key is present only when its cell
139
+ // had USD_BUDGET_MIN_N or more landings with a Cost row. `board.mjs budgets --usd --from docs/live-runs.md`
140
+ // recomputes it. There is no floor: the autopilot USD cap is the sum of these over the queue.
141
+ export const USD_BUDGET_MIN_N = 5;
142
+ // Source: `board.mjs budgets --usd --from docs/live-runs.md` on 2026-10-11 over 19 cards with a measured Cost (rehearsal entries
143
+ // skipped, repeated ids summed): S 5, M 10, L 4, so no class cell reached USD_BUDGET_MIN_N and each size is its all-class p80.
144
+ // Every row so far is a flow entry, since hand entries only gain a Cost row with this card. L rests on 4 cards.
145
+ export const USD_BUDGET = {"S":{"all":3.32},"M":{"all":15.79},"L":{"all":14.51}};
146
+
147
+ // A card's USD budget: its size's class cell when it has one, else the size's `all`. No or an unknown size counts as M.
148
+ export function cardBudgetDollars(card, table = USD_BUDGET) {
149
+ const size = card && (card.size === "S" || card.size === "M" || card.size === "L") ? card.size : "M";
150
+ const row = table[size];
151
+ const cls = card && card.class;
152
+ return typeof cls === "string" && Object.prototype.hasOwnProperty.call(row, cls) && cls !== "all" ? row[cls] : row.all;
153
+ }
154
+
155
+ export function usdCapDollars(cards, table = USD_BUDGET) {
156
+ return cards.reduce((sum, c) => sum + cardBudgetDollars(c, table), 0);
157
+ }
158
+
159
+ // The measured costs in a docs/live-runs.md text: one { id, usd } per `## <date>, <id>: <title>` section that has a
160
+ // `| Cost | $<n> |` row (text after the amount is allowed; the first such row; `not measured` is skipped). Flow and hand
161
+ // entries share the row. Rehearsal entries are skipped, and a card with several entries is one sample, their sum.
162
+ export function parseLiveRunCosts(text) {
163
+ const byId = new Map();
164
+ let id = null;
165
+ let seen = false;
166
+ let tableSeen = false;
167
+ for (const line of String(text).split("\n")) {
168
+ if (line.startsWith("## ")) {
169
+ const m = /^## \d{4}-\d{2}-\d{2}, ([^:\s]+): /.exec(line);
170
+ id = m ? m[1] : null;
171
+ seen = false;
172
+ tableSeen = false;
173
+ continue;
174
+ }
175
+ if (id === null || seen) continue;
176
+ // A rehearsal entry (the fixture's run, not this repository's) is no landing of the card.
177
+ if (/^Rehearsal /.test(line)) {
178
+ id = null;
179
+ continue;
180
+ }
181
+ if (line.trim() !== "" && !line.startsWith("|") && tableSeen) {
182
+ // prose after the section's table ends the landing: a later Cost row belongs to no landing
183
+ id = null;
184
+ continue;
185
+ }
186
+ if (line.startsWith("|")) tableSeen = true;
187
+ const c = /^\| Cost \| \$(\d+(?:\.\d+)?)(?:\s[^|]*)? \|\s*$/.exec(line);
188
+ if (c) {
189
+ byId.set(id, Math.round(((byId.get(id) ?? 0) + Number(c[1])) * 100) / 100);
190
+ seen = true;
191
+ }
192
+ }
193
+ return [...byId].map(([k, usd]) => ({ id: k, usd }));
194
+ }
195
+
196
+ // usdBudgetTable is wallBudgetTable's twin over { id, usd } samples: p80 per size and per size/class cell, a class
197
+ // cell under minN left out, an empty size keeping the fallback's `all`.
198
+ export function usdBudgetTable(samples, board, { minN = USD_BUDGET_MIN_N, fallback = USD_BUDGET } = {}) {
199
+ return wallBudgetTable(samples.map((s) => ({ id: s.id, minutes: s.usd })), board, { minN, fallback });
200
+ }
201
+
129
202
  function today() {
130
203
  return new Date().toISOString().slice(0, 10);
131
204
  }
@@ -707,7 +780,14 @@ function wallRow(wall, flags) {
707
780
  return `within budget (${wall.wallMinutes ?? wallMin(longest)} min a task)${unverified}`;
708
781
  }
709
782
 
710
- export function runEntry({ card, report, cost = null, codexCost = null, wallClock = null, wall = null, mergeCommit = null, record = null, adversary = [], date = new Date().toISOString().slice(0, 10), batch = null, sharedCost = null, taskIds = [], note = null, rehearsal = null }) {
783
+ // The first prose line of a rehearsal entry. The start scenario runs in an empty git repo; every other scenario
784
+ // (start-feature included) on the ts-basic fixture, the rule rehearse-lib's rehearsalNote uses.
785
+ function rehearsalLine(rehearsal, cardId) {
786
+ const where = rehearsal === "start" ? "an empty git repo" : "the ts-basic fixture";
787
+ return `Rehearsal ${rehearsal} for card \`${cardId}\` on ${where} (scripts/rehearse.mjs); the commit and gate below are the fixture's, not this repository's.`;
788
+ }
789
+
790
+ export function runEntry({ card, report, cost = null, codexCost = null, wallClock = null, wall = null, mergeCommit = null, record = null, adversary = [], date = new Date().toISOString().slice(0, 10), batch = null, sharedCost = null, taskIds = [], note = null, rehearsal = null, rehearsalOutcome = null }) {
711
791
  const inBatch = Array.isArray(batch) && batch.length > 1;
712
792
  if (inBatch) report = filterReportForCard(report, card.id, taskIds);
713
793
  const lines = [];
@@ -716,13 +796,13 @@ export function runEntry({ card, report, cost = null, codexCost = null, wallCloc
716
796
  const batchNote = inBatch ? `, in one plan with ${listOthers(batch, card.id)} (one approval, one run, one landing)` : "";
717
797
  lines.push(
718
798
  rehearsal
719
- ? `Rehearsal ${rehearsal} for card \`${card.id}\` on the ts-basic fixture (scripts/rehearse.mjs); the commit and gate below are the fixture's, not this repository's.`
799
+ ? rehearsalLine(rehearsal, card.id)
720
800
  : `Ran through /doug-next on this repo from card \`${card.id}\`${record ? ` in \`${record}\`` : ""}${batchNote}. Plan "${report.plan}", integration branch \`${report.integrationBranch}\`${report.modelsSource ? `, models from ${report.modelsSource}` : ""}.`,
721
801
  );
722
802
  lines.push("");
723
803
  lines.push("| Measure | Value |");
724
804
  lines.push("|---|---|");
725
- lines.push(`| Outcome | ${outcomeOf(report)}${pausedAt(report) ? `; next: ${listNext(report)}` : ""} |`);
805
+ lines.push(`| Outcome | ${rehearsal ? `rehearsal ${rehearsal} ${rehearsalOutcome}` : outcomeOf(report)}${!rehearsal && pausedAt(report) ? `; next: ${listNext(report)}` : ""} |`);
726
806
  lines.push(`| Cost | ${cost === null ? "not measured" : `$${Number(cost).toFixed(2)}${inBatch ? " for this card's tasks" : ""}`} |`);
727
807
  if (!inBatch || batch[0] === card.id) {
728
808
  lines.push(
@@ -794,22 +874,23 @@ export function handWallVerdict(card, wallClock, table = WALL_BUDGET_MIN) {
794
874
 
795
875
  // The live-runs entry for a hand-track card (decision 0005): no workflow report and no levels, so it records what
796
876
  // doug-hand can measure: the landing commit, the wall clock, and the gate result, plus an optional note.
797
- export function handEntry({ card, commit = null, wallClock = null, gate = null, note = null, record = null, date = new Date().toISOString().slice(0, 10), rehearsal = null, table = WALL_BUDGET_MIN }) {
877
+ export function handEntry({ card, commit = null, wallClock = null, gate = null, note = null, record = null, date = new Date().toISOString().slice(0, 10), rehearsal = null, rehearsalOutcome = null, table = WALL_BUDGET_MIN, cost = null }) {
798
878
  const lines = [];
799
879
  lines.push(`## ${date}, ${card.id}: ${card.title}`);
800
880
  lines.push("");
801
881
  lines.push(
802
882
  rehearsal
803
- ? `Rehearsal ${rehearsal} for card \`${card.id}\` on the ts-basic fixture (scripts/rehearse.mjs); the commit and gate below are the fixture's, not this repository's.`
883
+ ? rehearsalLine(rehearsal, card.id)
804
884
  : `Built by hand through doug-hand from card \`${card.id}\`${record ? ` in \`${record}\`` : ""} (hand track: a gated by-hand change); no workflow run.`,
805
885
  );
806
886
  lines.push("");
807
887
  lines.push("| Measure | Value |");
808
888
  lines.push("|---|---|");
809
- lines.push(`| Outcome | ${commit ? "landed" : "not landed"} |`);
889
+ lines.push(`| Outcome | ${rehearsal ? `rehearsal ${rehearsal} ${rehearsalOutcome}` : commit ? "landed" : "not landed"} |`);
810
890
  const shownCommit = rehearsal && commit ? String(commit).slice(0, 7) : commit;
811
891
  lines.push(`| Commit | ${shownCommit ? `\`${shownCommit}\`` : "none"} |`);
812
892
  lines.push(`| Wall clock | ${wallClock === null ? "not measured" : wallClock} |`);
893
+ if (cost !== null && cost !== undefined) lines.push(`| Cost | $${Number(cost).toFixed(2)} |`);
813
894
  if (!rehearsal) lines.push(`| Wall budget | ${handWallVerdict(card, wallClock, table).text} |`);
814
895
  lines.push(`| Gate | ${gate === null ? "not recorded" : gate} |`);
815
896
  lines.push("");
@@ -0,0 +1,115 @@
1
+ // landCard: land a hand card's branch (card parallel-card-queens B6, ADR 0015). The card's worktree is rebased on the
2
+ // base branch, the rebased tree is gated through the vendored verify.mjs (--json; its gate.lock is shared by every
3
+ // card), main fast-forwards to the branch, and only the card's own worktree is removed. A refusal returns
4
+ // { ok: false, reason } with main untouched; it never throws (landPlan's contract, lib/land.mjs).
5
+ //
6
+ // 1. the base branch is the one checked out in dir, and no tracked file outside .doug/ is modified
7
+ // 2. no commit on the branch since the base carries a Co-Authored-By or Claude-Session line
8
+ // 3. git rebase <base> in the worktree; a conflict is aborted and refused as "rebase-conflict: <files>", any
9
+ // other rebase failure is refused under its own reason
10
+ // 4. gate: .doug/hooks/scripts/verify.mjs --json with its cwd at the worktree; red refuses, nothing merges
11
+ // 5. git merge --ff-only <branch> in dir
12
+ // 6. removeFinishedWorktrees(dir, { branches: [branch] }) only, never mergedInto
13
+
14
+ import { execFileSync, spawnSync } from "node:child_process";
15
+ import { existsSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { commandEnv, trailerCommits, vendoredVerifySpawnTimeoutMs } from "./land.mjs";
18
+ import { removeFinishedWorktrees } from "./worktrees.mjs";
19
+
20
+ function git(dir, args) {
21
+ return execFileSync("git", args, { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60000 }).trim();
22
+ }
23
+
24
+ function tryGit(dir, args) {
25
+ try {
26
+ return git(dir, args);
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function tail(text, lines = 30) {
33
+ return String(text || "").trimEnd().split("\n").slice(-lines).join("\n");
34
+ }
35
+
36
+ const GATE_TIMEOUT_MS = 600000;
37
+
38
+ // Returns { ok: true, commit, gateLine, lockWaitMs, removed, kept } or { ok: false, reason }.
39
+ export function landCard(dir, { id, branch, worktree, base = "main" } = {}) {
40
+ try {
41
+ return land(dir, { id, branch, worktree, base });
42
+ } catch (err) {
43
+ return { ok: false, reason: `land-card failed: ${tail((err && (err.stderr || err.message)) || err, 5)}` };
44
+ }
45
+ }
46
+
47
+ function land(dir, { id, branch, worktree, base }) {
48
+ if (!branch || !worktree) return { ok: false, reason: "land-card needs a branch and a worktree" };
49
+ if (tryGit(dir, ["rev-parse", "--verify", "-q", `refs/heads/${branch}`]) === null) return { ok: false, reason: `branch ${branch} does not exist; nothing to land` };
50
+ if (!existsSync(worktree)) return { ok: false, reason: `worktree ${worktree} does not exist` };
51
+ if (tryGit(worktree, ["symbolic-ref", "--short", "-q", "HEAD"]) !== branch) return { ok: false, reason: `worktree ${worktree} does not have ${branch} checked out` };
52
+
53
+ const current = tryGit(dir, ["symbolic-ref", "--short", "-q", "HEAD"]);
54
+ if (!current) return { ok: false, reason: "detached HEAD; check out the base branch first" };
55
+ if (current !== base) return { ok: false, reason: `base branch is ${base} but ${current} is checked out; check out ${base} first (land never switches your branch)` };
56
+
57
+ // Not through git(): its trim() would eat the leading status column of the first line.
58
+ const dirty = execFileSync("git", ["status", "--porcelain", "--untracked-files=no"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
59
+ .split("\n")
60
+ .filter(Boolean)
61
+ .map((l) => l.slice(3).replace(/^"|"$/g, ""))
62
+ .filter((p) => !p.startsWith(".doug/"));
63
+ if (dirty.length) return { ok: false, reason: `tracked files are modified: ${dirty.join(", ")}; commit or stash them first` };
64
+
65
+ const trailered = trailerCommits(dir, base, branch);
66
+ if (trailered.length) return { ok: false, reason: `commits on ${branch} carry attribution trailers (Co-Authored-By or Claude-Session), which this repository does not record; rewrite them first:\n${trailered.map((c) => ` ${c.sha} ${c.subject} (${c.trailers.join(", ")})`).join("\n")}` };
67
+
68
+ try {
69
+ git(worktree, ["rebase", base]);
70
+ } catch (err) {
71
+ const files = (tryGit(worktree, ["diff", "--name-only", "--diff-filter=U"]) || "").split("\n").filter(Boolean);
72
+ if (files.length) {
73
+ tryGit(worktree, ["rebase", "--abort"]);
74
+ return { ok: false, reason: `rebase-conflict: ${files.join(", ")}` };
75
+ }
76
+ tryGit(worktree, ["rebase", "--abort"]);
77
+ return { ok: false, reason: `rebase-failed: ${tail(err.stderr || err.message, 3)}` };
78
+ }
79
+
80
+ const vendored = join(dir, ".doug/hooks/scripts/verify.mjs");
81
+ if (!existsSync(vendored)) return { ok: false, reason: `gate not run: ${vendored} does not exist` };
82
+ const r = spawnSync(process.execPath, [vendored, "--json", "--by", "land-card", "--cwd", worktree, "--record-dir", dir, "--timeout-ms", String(GATE_TIMEOUT_MS)], {
83
+ cwd: worktree,
84
+ encoding: "utf8",
85
+ timeout: vendoredVerifySpawnTimeoutMs(GATE_TIMEOUT_MS, 2),
86
+ maxBuffer: 64 * 1024 * 1024,
87
+ env: commandEnv(),
88
+ });
89
+ let out = null;
90
+ try {
91
+ out = JSON.parse(String(r.stdout || "").trim().split("\n").pop());
92
+ } catch {
93
+ out = null;
94
+ }
95
+ if (!out || !Array.isArray(out.results) || out.results.length === 0) {
96
+ const why = r.error && r.error.code === "ETIMEDOUT" ? "timed out" : "gave no readable result";
97
+ return { ok: false, reason: `gate failed on ${branch}: the vendored verify.mjs ${why}` };
98
+ }
99
+ const gateLine = "gate: " + out.results.map((x) => `${x.name || x.command} ${x.ok === true ? "ok" : `exit ${x.status}`}`).join("; ");
100
+ const lockWaitMs = typeof out.lockWaitMs === "number" ? out.lockWaitMs : 0;
101
+ if (out.ok !== true) {
102
+ const bad = out.results.find((x) => x.ok !== true) || out.results[0];
103
+ return { ok: false, reason: `gate red on ${branch}: \`${bad.command}\` (${bad.timedOut ? "timed out" : `exit ${bad.status}`})\n${bad.outputTail || ""}`, gateLine, lockWaitMs };
104
+ }
105
+
106
+ try {
107
+ git(dir, ["merge", "--ff-only", branch]);
108
+ } catch (err) {
109
+ tryGit(dir, ["merge", "--abort"]);
110
+ return { ok: false, reason: `fast-forward of ${base} to ${branch} failed:\n${tail((err.stdout || "") + "\n" + (err.stderr || ""), 10)}`, gateLine, lockWaitMs };
111
+ }
112
+ const commit = git(dir, ["rev-parse", "--short", "HEAD"]);
113
+ const wt = removeFinishedWorktrees(dir, { branches: [branch] });
114
+ return { ok: true, id, commit, gateLine, lockWaitMs, removed: wt.removed, kept: wt.kept };
115
+ }