@dougharness/cli 1.1.0 → 1.2.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 (59) hide show
  1. package/dist/bin.js +10 -3
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +45 -10
  4. package/dist/board-serve.js.map +1 -1
  5. package/dist/board.d.ts +1 -1
  6. package/dist/board.js +33 -8
  7. package/dist/board.js.map +1 -1
  8. package/dist/init.js +1 -1
  9. package/dist/init.js.map +1 -1
  10. package/dist/memory-setup.d.ts +7 -0
  11. package/dist/memory-setup.js +75 -48
  12. package/dist/memory-setup.js.map +1 -1
  13. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
  14. package/node_modules/@dougharness/flow/agents/coder.md +23 -0
  15. package/node_modules/@dougharness/flow/agents/lead.md +1 -1
  16. package/node_modules/@dougharness/flow/agents/planner.md +2 -2
  17. package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
  18. package/node_modules/@dougharness/flow/agents/tester.md +21 -0
  19. package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
  20. package/node_modules/@dougharness/flow/hooks/register.tsx +75 -0
  21. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
  22. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
  23. package/node_modules/@dougharness/flow/lib/board.mjs +107 -10
  24. package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
  25. package/node_modules/@dougharness/flow/lib/card-loop.mjs +746 -0
  26. package/node_modules/@dougharness/flow/lib/coordinator.mjs +368 -0
  27. package/node_modules/@dougharness/flow/lib/cost.mjs +44 -11
  28. package/node_modules/@dougharness/flow/lib/embeddings.mjs +147 -4
  29. package/node_modules/@dougharness/flow/lib/land.mjs +33 -9
  30. package/node_modules/@dougharness/flow/lib/memory.mjs +96 -33
  31. package/node_modules/@dougharness/flow/lib/plan.mjs +34 -5
  32. package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
  33. package/node_modules/@dougharness/flow/package.json +1 -1
  34. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +407 -0
  35. package/node_modules/@dougharness/flow/scripts/board.mjs +128 -32
  36. package/node_modules/@dougharness/flow/scripts/context.mjs +4 -2
  37. package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
  38. package/node_modules/@dougharness/flow/scripts/memory.mjs +29 -14
  39. package/node_modules/@dougharness/flow/scripts/plan.mjs +122 -30
  40. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +10 -4
  41. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +34 -12
  42. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
  43. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
  44. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +2 -2
  45. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +12 -10
  46. package/node_modules/@dougharness/flow/workflows/card-queen.js +807 -0
  47. package/node_modules/@dougharness/flow/workflows/doug-implement.js +33 -21
  48. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  49. package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
  50. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +217 -20
  51. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +126 -0
  52. package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
  53. package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
  54. package/node_modules/@dougharness/gates/package.json +1 -1
  55. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
  56. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +148 -97
  57. package/node_modules/@dougharness/gates/scripts/verify.mjs +64 -5
  58. package/package.json +4 -4
  59. package/templates/board-app.js +48 -1
@@ -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(
@@ -777,24 +857,41 @@ export function runEntry({ card, report, cost = null, codexCost = null, wallCloc
777
857
  return lines.join("\n") + "\n";
778
858
  }
779
859
 
860
+ // A hand landing's wall judged against the card's p80 budget (card hand-wall-flag). `over` is true only when the wall
861
+ // reads as whole minutes and exceeds the budget; `text` is the Wall budget row's value. Per-seat times are not
862
+ // recorded on the hand track, so the verdict always says so.
863
+ export function handWallVerdict(card, wallClock, table = WALL_BUDGET_MIN) {
864
+ const m = /^(\d+) min\b/.exec(typeof wallClock === "string" ? wallClock : "");
865
+ if (!m) return { over: false, text: "not checked (wall not in whole minutes)" };
866
+ const n = Number(m[1]);
867
+ const budget = cardBudgetMinutes(card, table);
868
+ const size = card && (card.size === "S" || card.size === "M" || card.size === "L") ? card.size : "M";
869
+ const cls = card && card.class;
870
+ const cell = typeof cls === "string" && Object.prototype.hasOwnProperty.call(table[size], cls) && cls !== "all" ? cls : "all";
871
+ const over = n > budget;
872
+ return { over, text: `${over ? "over" : "within"}: ${n} min vs ${budget} min (${size}/${cell}, p80); per-seat times unverified` };
873
+ }
874
+
780
875
  // The live-runs entry for a hand-track card (decision 0005): no workflow report and no levels, so it records what
781
876
  // doug-hand can measure: the landing commit, the wall clock, and the gate result, plus an optional note.
782
- export function handEntry({ card, commit = null, wallClock = null, gate = null, note = null, record = null, date = new Date().toISOString().slice(0, 10), rehearsal = null }) {
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 }) {
783
878
  const lines = [];
784
879
  lines.push(`## ${date}, ${card.id}: ${card.title}`);
785
880
  lines.push("");
786
881
  lines.push(
787
882
  rehearsal
788
- ? `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)
789
884
  : `Built by hand through doug-hand from card \`${card.id}\`${record ? ` in \`${record}\`` : ""} (hand track: a gated by-hand change); no workflow run.`,
790
885
  );
791
886
  lines.push("");
792
887
  lines.push("| Measure | Value |");
793
888
  lines.push("|---|---|");
794
- lines.push(`| Outcome | ${commit ? "landed" : "not landed"} |`);
889
+ lines.push(`| Outcome | ${rehearsal ? `rehearsal ${rehearsal} ${rehearsalOutcome}` : commit ? "landed" : "not landed"} |`);
795
890
  const shownCommit = rehearsal && commit ? String(commit).slice(0, 7) : commit;
796
891
  lines.push(`| Commit | ${shownCommit ? `\`${shownCommit}\`` : "none"} |`);
797
892
  lines.push(`| Wall clock | ${wallClock === null ? "not measured" : wallClock} |`);
893
+ if (cost !== null && cost !== undefined) lines.push(`| Cost | $${Number(cost).toFixed(2)} |`);
894
+ if (!rehearsal) lines.push(`| Wall budget | ${handWallVerdict(card, wallClock, table).text} |`);
798
895
  lines.push(`| Gate | ${gate === null ? "not recorded" : gate} |`);
799
896
  lines.push("");
800
897
  if (note) {
@@ -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
+ }