@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.
- package/dist/bin.js +10 -3
- package/dist/bin.js.map +1 -1
- package/dist/board-serve.js +45 -10
- package/dist/board-serve.js.map +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/board.js +33 -8
- package/dist/board.js.map +1 -1
- package/dist/init.js +1 -1
- package/dist/init.js.map +1 -1
- package/dist/memory-setup.d.ts +7 -0
- package/dist/memory-setup.js +75 -48
- package/dist/memory-setup.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
- package/node_modules/@dougharness/flow/agents/coder.md +23 -0
- package/node_modules/@dougharness/flow/agents/lead.md +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +2 -2
- package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
- package/node_modules/@dougharness/flow/agents/tester.md +21 -0
- package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
- package/node_modules/@dougharness/flow/hooks/register.tsx +75 -0
- package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
- package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +107 -10
- package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +746 -0
- package/node_modules/@dougharness/flow/lib/coordinator.mjs +368 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +44 -11
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +147 -4
- package/node_modules/@dougharness/flow/lib/land.mjs +33 -9
- package/node_modules/@dougharness/flow/lib/memory.mjs +96 -33
- package/node_modules/@dougharness/flow/lib/plan.mjs +34 -5
- package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +407 -0
- package/node_modules/@dougharness/flow/scripts/board.mjs +128 -32
- package/node_modules/@dougharness/flow/scripts/context.mjs +4 -2
- package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +29 -14
- package/node_modules/@dougharness/flow/scripts/plan.mjs +122 -30
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +10 -4
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +34 -12
- package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +2 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +12 -10
- package/node_modules/@dougharness/flow/workflows/card-queen.js +807 -0
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +33 -21
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +217 -20
- package/node_modules/@dougharness/gates/lib/gate-lock.mjs +126 -0
- package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
- package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +148 -97
- package/node_modules/@dougharness/gates/scripts/verify.mjs +64 -5
- package/package.json +4 -4
- 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
|
-
|
|
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
|
|
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
|
-
|
|
103
|
+
byId.set(id, (byId.get(id) ?? 0) + Number(w[1]));
|
|
96
104
|
seen = true;
|
|
97
105
|
}
|
|
98
106
|
}
|
|
99
|
-
return
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
?
|
|
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
|
+
}
|