@haiyangbg/buildbeat 2.0.0-beta.2 → 2.0.0-beta.4

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 (62) hide show
  1. package/CHANGELOG.md +42 -6
  2. package/SKILL.md +76 -2
  3. package/bin/buildbeat-v2.js +13 -1
  4. package/docs/CLI-PILOT-2026-08-23.md +1 -1
  5. package/docs/CLI.md +1 -1
  6. package/docs/EXECUTION-PLAN.md +2 -2
  7. package/docs/PHASE2-PILOT-PREFLIGHT-2026-08-25.md +2 -2
  8. package/docs/PHASE4-V1.20-PILOT-2026-08-25.md +2 -2
  9. package/docs/RELEASING.md +1 -1
  10. package/docs/V2-D2-DECISION-CARD.md +2 -2
  11. package/docs/V2-DECISIONS.md +2 -2
  12. package/docs/V2-ITERATION-01.md +13 -13
  13. package/docs/V2-ITERATION-06.md +2 -2
  14. package/docs/V2-ITERATION-08.md +62 -0
  15. package/docs/V2-PLAN.md +6 -6
  16. package/docs/V2-PROPOSAL.md +2 -2
  17. package/docs/V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md +1 -1
  18. package/docs/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md +8 -0
  19. package/docs/V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md +8 -0
  20. package/docs/v2/M4-EXTERNAL-PILOT-2026-08-28.md +11 -11
  21. package/docs/v2/{M4-CHICKAI-PILOT-2026-08-28.md → M4-PILOT-APP-2026-08-28.md} +4 -4
  22. package/docs/v2/M4-SELFHOST-2026-08-28.md +1 -1
  23. package/docs/v2/RFC-0001-product-definition.md +2 -2
  24. package/docs/v2/SPEC-0001-events-v1.md +2 -2
  25. package/docs/v2/guide/00-how-to-talk.md +57 -0
  26. package/docs/v2/guide/01-quickstart.md +6 -0
  27. package/docs/v2/guide/02-workflow-guide.md +31 -0
  28. package/docs/v2/guide/04-adapter-guide.md +4 -0
  29. package/docs/v2/guide/05-worker-contract.md +10 -0
  30. package/docs/v2/guide/06-evidence-guide.md +10 -0
  31. package/docs/v2/guide/07-approval-guide.md +50 -0
  32. package/docs/v2/guide/10-recovery.md +25 -2
  33. package/docs/v2/guide/README.md +3 -0
  34. package/example/.buildbeat/manifest.json +1 -1
  35. package/lessons.md +12 -0
  36. package/package.json +1 -1
  37. package/src/v2/adapters/shell.js +87 -14
  38. package/src/v2/cli/run.js +602 -30
  39. package/src/v2/domain/event-registry.js +24 -0
  40. package/src/v2/engine/reducer.js +5 -0
  41. package/src/v2/engine/workflow.js +8 -1
  42. package/src/v2/evidence/collector.js +14 -3
  43. package/src/v2/observe/observe.js +9 -2
  44. package/src/v2/presets/release-readback.yaml +36 -0
  45. package/src/v2/presets/risk/release.yaml +21 -0
  46. package/src/v2/presets/software-delivery.yaml +5 -0
  47. package/src/v2/runtime/cache.js +124 -0
  48. package/src/v2/runtime/decisions.js +6 -4
  49. package/src/v2/runtime/env-contract.js +135 -0
  50. package/src/v2/runtime/envelope.js +183 -0
  51. package/src/v2/runtime/findings.js +158 -0
  52. package/src/v2/runtime/gc.js +182 -0
  53. package/src/v2/runtime/liveness.js +193 -0
  54. package/src/v2/runtime/metrics.js +8 -0
  55. package/src/v2/runtime/notify.js +223 -0
  56. package/src/v2/runtime/orchestrator.js +252 -24
  57. package/src/v2/runtime/overview.js +264 -0
  58. package/src/v2/runtime/repo-ref.js +38 -0
  59. package/src/v2/runtime/run-record.js +18 -3
  60. package/src/v2/workspace/workspace-manager.js +4 -1
  61. package/templates/v2/AGENTS.md +72 -0
  62. package/templates/v2//346/214/207/346/214/245/345/217/260.md +36 -0
@@ -0,0 +1,183 @@
1
+ // Envelope as a first-class run-config section (iteration 08, C6). The deploy
2
+ // campaign paid for every Run with a 34-line yaml, a 10 KB worker.sh and four
3
+ // prompt files, and each worker line was a `git show <meta sha>:path | bash`
4
+ // incantation. The kernel now does that part: it reads the prompt for the
5
+ // step's worker (optionally pinned to a commit), substitutes declared vars,
6
+ // materialises it into the run directory and hands the path to the worker as
7
+ // BUILDBEAT_PROMPT. The envelope digest is recorded on RUN_CREATED so "which
8
+ // prompts did this run see" is answerable from the ledger.
9
+ //
10
+ // envelope:
11
+ // prompts: run-envelope/prompts # dir, relative to the run config
12
+ // pin: <commit sha> # optional: read prompts from that commit
13
+ // vars:
14
+ // component: auth # {vars.component} in prompts and args
15
+ //
16
+ // Prompt lookup per worker: <component>-<worker>.md (when vars.component is
17
+ // set) then <worker>.md. Missing prompt for a configured worker is not an
18
+ // error — that worker simply gets no BUILDBEAT_PROMPT.
19
+
20
+ import { execFileSync } from "node:child_process";
21
+ import { createHash } from "node:crypto";
22
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
23
+ import { join, relative, resolve } from "node:path";
24
+
25
+ import { canonicalJson } from "../storage/event-ledger.js";
26
+
27
+ export class EnvelopeError extends Error {
28
+ constructor(message) {
29
+ super(message);
30
+ this.name = "EnvelopeError";
31
+ }
32
+ }
33
+
34
+ function gitToplevel(dir) {
35
+ try {
36
+ return execFileSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
37
+ encoding: "utf8",
38
+ stdio: ["ignore", "pipe", "ignore"],
39
+ }).trim();
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ function readPinned(toplevel, pin, relPath) {
46
+ try {
47
+ return execFileSync("git", ["-C", toplevel, "show", `${pin}:${relPath}`], {
48
+ encoding: "utf8",
49
+ stdio: ["ignore", "pipe", "ignore"],
50
+ });
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export function substituteVars(text, vars) {
57
+ let out = String(text);
58
+ for (const [key, value] of Object.entries(vars ?? {})) {
59
+ out = out.replaceAll(`{vars.${key}}`, String(value));
60
+ }
61
+ return out;
62
+ }
63
+
64
+ // Loads every prompt the configured workers could use. Returns null when the
65
+ // run config declares no envelope.
66
+ export function loadEnvelope(config, configDir, workerNames) {
67
+ const spec = config.envelope;
68
+ if (spec === undefined) {
69
+ return null;
70
+ }
71
+ if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
72
+ throw new EnvelopeError("envelope must be a map");
73
+ }
74
+ if (typeof spec.prompts !== "string" || spec.prompts.length === 0) {
75
+ throw new EnvelopeError("envelope.prompts (a directory) is required");
76
+ }
77
+ const vars = spec.vars ?? {};
78
+ if (!vars || typeof vars !== "object" || Array.isArray(vars)) {
79
+ throw new EnvelopeError("envelope.vars must be a map");
80
+ }
81
+ for (const [key, value] of Object.entries(vars)) {
82
+ if (!/^[A-Za-z0-9_.-]+$/.test(key) || (typeof value !== "string" && typeof value !== "number")) {
83
+ throw new EnvelopeError(`envelope.vars.${key} must be a scalar`);
84
+ }
85
+ }
86
+ const pin = spec.pin ?? null;
87
+ if (pin !== null && !/^[0-9a-f]{7,40}$/.test(String(pin))) {
88
+ throw new EnvelopeError(`envelope.pin must be a commit sha, got: ${pin}`);
89
+ }
90
+ // realpath: git reports the toplevel through resolved symlinks (macOS
91
+ // /var → /private/var), so the config dir must be resolved the same way
92
+ // before a repository-relative prompt path can be computed.
93
+ const configReal = existsSync(configDir) ? realpathSync(configDir) : resolve(configDir);
94
+ const promptsDir = resolve(configReal, spec.prompts);
95
+ let toplevel = null;
96
+ let promptsRel = null;
97
+ if (pin) {
98
+ toplevel = gitToplevel(configReal);
99
+ if (!toplevel) {
100
+ throw new EnvelopeError("envelope.pin needs the run config to live inside a git repository");
101
+ }
102
+ promptsRel = relative(toplevel, promptsDir).split("\\").join("/");
103
+ if (promptsRel.startsWith("..")) {
104
+ throw new EnvelopeError("envelope.prompts must be inside the repository that holds the run config when pinned");
105
+ }
106
+ }
107
+ const component = vars.component !== undefined ? String(vars.component) : null;
108
+ const prompts = {};
109
+ for (const worker of workerNames) {
110
+ const candidates = [];
111
+ if (component) {
112
+ candidates.push(`${component}-${worker}.md`);
113
+ }
114
+ candidates.push(`${worker}.md`);
115
+ for (const name of candidates) {
116
+ let text = null;
117
+ if (pin) {
118
+ text = readPinned(toplevel, String(pin), `${promptsRel}/${name}`);
119
+ } else if (existsSync(join(promptsDir, name))) {
120
+ text = readFileSync(join(promptsDir, name), "utf8");
121
+ }
122
+ if (text !== null) {
123
+ prompts[worker] = { file: name, text: substituteVars(text, vars) };
124
+ break;
125
+ }
126
+ }
127
+ }
128
+ if (Object.keys(prompts).length === 0) {
129
+ throw new EnvelopeError(
130
+ `envelope.prompts ${spec.prompts} holds no prompt for any configured worker (${workerNames.join(", ") || "none"})${pin ? ` at ${pin}` : ""}`,
131
+ );
132
+ }
133
+ const digest = `sha256:${createHash("sha256")
134
+ .update(canonicalJson({ prompts, vars, pin }), "utf8")
135
+ .digest("hex")}`;
136
+ return { prompts, vars, pin: pin ? String(pin) : null, digest, source: pin ? `${promptsRel}@${pin}` : spec.prompts };
137
+ }
138
+
139
+ // Writes the step's prompt into the run directory and returns its absolute
140
+ // path plus the repository-relative reference for the worker input.
141
+ export function materialisePrompt({ envelope, worker, runtimeDir, runId, step, attempt, repoRoot }) {
142
+ const prompt = envelope?.prompts?.[worker];
143
+ if (!prompt) {
144
+ return null;
145
+ }
146
+ const dir = join(runtimeDir, "runs", runId, "prompts");
147
+ mkdirSync(dir, { recursive: true });
148
+ const path = join(dir, `${step}-${attempt}.md`);
149
+ writeFileSync(path, prompt.text, "utf8");
150
+ return { path, ref: relative(repoRoot, path).split("\\").join("/"), file: prompt.file };
151
+ }
152
+
153
+ // Next attempt id for a run family: RUN-X → RUN-X-01, RUN-X-02, ... scanning
154
+ // both the runtime plane and the Git plane (run-records survive a runtime
155
+ // wipe, so numbering never collides with a compacted run).
156
+ export function nextAttemptId(repoRoot, workId, family) {
157
+ const pattern = new RegExp(`^${family.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-(\\d{2,})$`);
158
+ let max = 0;
159
+ const dirs = [
160
+ join(repoRoot, ".buildbeat", "runtime", "runs"),
161
+ join(repoRoot, "delivery", "work", workId, "runs"),
162
+ ];
163
+ for (const dir of dirs) {
164
+ if (!existsSync(dir)) {
165
+ continue;
166
+ }
167
+ for (const entry of readdirSafe(dir)) {
168
+ const match = entry.match(pattern);
169
+ if (match) {
170
+ max = Math.max(max, Number(match[1]));
171
+ }
172
+ }
173
+ }
174
+ return `${family}-${String(max + 1).padStart(2, "0")}`;
175
+ }
176
+
177
+ function readdirSafe(dir) {
178
+ try {
179
+ return readdirSync(dir);
180
+ } catch {
181
+ return [];
182
+ }
183
+ }
@@ -0,0 +1,158 @@
1
+ // Review findings account per Work (Git plane, invariant 23: runtime stays
2
+ // deletable). Every reviewer finding lands as a row keyed by a fingerprint;
3
+ // human adjudications (accept/dismiss) append rows bound to that fingerprint.
4
+ // A dismissed fingerprint no longer blocks and later reviewers receive the
5
+ // adjudicated history as an anchor — re-litigating a settled verdict takes a
6
+ // human decision, not a louder fresh reviewer. (Absorbed from the 30-run
7
+ // deploy campaign: memoryless fresh reviewers oscillated between mutually
8
+ // exclusive prescriptions and re-litigated accepted designs.)
9
+
10
+ import { createHash } from "node:crypto";
11
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ export const ADJUDICATION_ACTIONS = ["accept", "dismiss"];
15
+
16
+ export class FindingsError extends Error {
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = "FindingsError";
20
+ }
21
+ }
22
+
23
+ export function findingsAccountRef(workId) {
24
+ return `delivery/work/${workId}/review-findings.jsonl`;
25
+ }
26
+
27
+ function accountPath(repoRoot, workId) {
28
+ return join(repoRoot, "delivery", "work", workId, "review-findings.jsonl");
29
+ }
30
+
31
+ // Same-text findings from different fresh reviewers must collide; severity is
32
+ // part of the identity so an escalation (P2 -> P0) reopens on its own.
33
+ export function fingerprintFinding(finding) {
34
+ const normalized = `${finding.severity}|${finding.summary}`
35
+ .toLowerCase()
36
+ .replace(/\s+/g, " ")
37
+ .trim();
38
+ return createHash("sha256").update(normalized, "utf8").digest("hex").slice(0, 16);
39
+ }
40
+
41
+ export function readFindingsAccount(repoRoot, workId) {
42
+ const filePath = accountPath(repoRoot, workId);
43
+ if (!existsSync(filePath)) {
44
+ return [];
45
+ }
46
+ const rows = [];
47
+ for (const line of readFileSync(filePath, "utf8").split("\n")) {
48
+ if (line.length === 0) {
49
+ continue;
50
+ }
51
+ try {
52
+ rows.push(JSON.parse(line));
53
+ } catch {
54
+ throw new FindingsError(`findings account has an invalid line: ${filePath}`);
55
+ }
56
+ }
57
+ return rows;
58
+ }
59
+
60
+ function appendRow(repoRoot, workId, row) {
61
+ const dir = join(repoRoot, "delivery", "work", workId);
62
+ mkdirSync(dir, { recursive: true });
63
+ appendFileSync(join(dir, "review-findings.jsonl"), `${JSON.stringify(row)}\n`, "utf8");
64
+ }
65
+
66
+ // Latest adjudication wins per fingerprint.
67
+ export function latestAdjudications(rows) {
68
+ const map = new Map();
69
+ for (const row of rows) {
70
+ if (row.kind === "adjudication") {
71
+ map.set(row.fingerprint, row);
72
+ }
73
+ }
74
+ return map;
75
+ }
76
+
77
+ // Records one review attempt's findings into the account. Fingerprints that
78
+ // already have a finding row are not duplicated; a finding whose fingerprint
79
+ // a human dismissed is recorded as a re-raise attempt so the oscillation is
80
+ // visible in the account, not just absent from the routing.
81
+ export function recordReviewFindings(repoRoot, workId, { run, step, attempt, findings, ts }) {
82
+ const rows = readFindingsAccount(repoRoot, workId);
83
+ const adjudicated = latestAdjudications(rows);
84
+ const known = new Set(rows.filter((row) => row.kind === "finding").map((row) => row.fingerprint));
85
+ const recorded = [];
86
+ for (const [index, finding] of findings.entries()) {
87
+ const fingerprint = fingerprintFinding(finding);
88
+ const dismissed = adjudicated.get(fingerprint)?.action === "dismiss";
89
+ if (known.has(fingerprint) && !dismissed) {
90
+ continue;
91
+ }
92
+ const row = {
93
+ ts,
94
+ kind: "finding",
95
+ run,
96
+ step,
97
+ attempt,
98
+ id: `F-${run}-${step}-${attempt}-${index + 1}`,
99
+ severity: finding.severity,
100
+ summary: finding.summary,
101
+ fingerprint,
102
+ ...(dismissed ? { reRaised: true } : {}),
103
+ };
104
+ appendRow(repoRoot, workId, row);
105
+ known.add(fingerprint);
106
+ recorded.push(row);
107
+ }
108
+ return recorded;
109
+ }
110
+
111
+ export function adjudicateFinding(repoRoot, workId, { fingerprint, action, by, note, ts }) {
112
+ if (!ADJUDICATION_ACTIONS.includes(action)) {
113
+ throw new FindingsError(`action must be one of ${ADJUDICATION_ACTIONS.join("|")}, got: ${action}`);
114
+ }
115
+ const rows = readFindingsAccount(repoRoot, workId);
116
+ const finding = rows.find((row) => row.kind === "finding" && row.fingerprint === fingerprint);
117
+ if (!finding) {
118
+ throw new FindingsError(
119
+ `no recorded finding with fingerprint ${fingerprint}; adjudications bind to recorded findings only`,
120
+ );
121
+ }
122
+ const row = {
123
+ ts: ts ?? new Date().toISOString(),
124
+ kind: "adjudication",
125
+ fingerprint,
126
+ action,
127
+ by: by ?? "human",
128
+ ...(note ? { note } : {}),
129
+ };
130
+ appendRow(repoRoot, workId, row);
131
+ return { ...row, summary: finding.summary, severity: finding.severity };
132
+ }
133
+
134
+ // Compact anchor for worker input (BUILDBEAT_INPUT): the full adjudicated
135
+ // history plus open findings, capped so the env payload stays small.
136
+ const ANCHOR_CAP = 50;
137
+ const SUMMARY_CAP = 300;
138
+
139
+ export function buildAnchor(repoRoot, workId) {
140
+ const rows = readFindingsAccount(repoRoot, workId);
141
+ if (rows.length === 0) {
142
+ return null;
143
+ }
144
+ const adjudicated = latestAdjudications(rows);
145
+ const entries = rows
146
+ .filter((row) => row.kind === "finding")
147
+ .map((row) => ({
148
+ fingerprint: row.fingerprint,
149
+ severity: row.severity,
150
+ summary:
151
+ row.summary.length > SUMMARY_CAP ? `${row.summary.slice(0, SUMMARY_CAP)}…` : row.summary,
152
+ adjudication: adjudicated.get(row.fingerprint)?.action ?? "open",
153
+ }));
154
+ return {
155
+ account: findingsAccountRef(workId),
156
+ findings: entries.slice(-ANCHOR_CAP),
157
+ };
158
+ }
@@ -0,0 +1,182 @@
1
+ // Runtime garbage collection (iteration 08, C3): terminal runs leave a
2
+ // worktree, a run/* branch and sometimes a lock behind. Sixteen of them had
3
+ // piled up in the deploy campaign before the owner asked for "打扫卫生".
4
+ //
5
+ // Rules (fail-closed toward keeping things):
6
+ // - only terminal AND compacted runs are candidates; a run-record in the Git
7
+ // plane must exist before anything in the runtime plane goes;
8
+ // - the worktree is removable (commits live on the branch);
9
+ // - the branch goes only when its candidate is reachable from some other
10
+ // ref (merged, tagged, on a remote) or the run produced no candidate;
11
+ // otherwise the branch is the only thing keeping evidence reachable and
12
+ // stays, listed as such;
13
+ // - a dirty worktree is never removed without --force.
14
+ // The ledger is not touched: after RUN_COMPACTED nothing may be appended.
15
+
16
+ import { existsSync, readdirSync, rmSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { execFileSync } from "node:child_process";
19
+
20
+ import { EventLedger } from "../storage/event-ledger.js";
21
+ import { readback } from "../workspace/workspace-manager.js";
22
+ import { resolveRepoRef } from "./repo-ref.js";
23
+
24
+ function git(cwd, args) {
25
+ return execFileSync("git", ["-C", cwd, ...args], {
26
+ encoding: "utf8",
27
+ stdio: ["ignore", "pipe", "pipe"],
28
+ }).trimEnd();
29
+ }
30
+
31
+ function branchExists(repoRoot, branch) {
32
+ try {
33
+ git(repoRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ function worktreeRegistered(repoRoot, worktreePath) {
41
+ try {
42
+ const listing = git(repoRoot, ["worktree", "list", "--porcelain"]);
43
+ return listing.split("\n").some((line) => line === `worktree ${worktreePath}`);
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ // Refs (other than the run's own branch and other run/* branches) that
50
+ // already contain the candidate commit.
51
+ function otherRefsContaining(repoRoot, sha, ownBranch) {
52
+ try {
53
+ const out = git(repoRoot, ["for-each-ref", "--contains", sha, "--format=%(refname)"]);
54
+ return out
55
+ .split("\n")
56
+ .filter(Boolean)
57
+ .filter((ref) => ref !== `refs/heads/${ownBranch}` && !ref.startsWith("refs/heads/run/"));
58
+ } catch {
59
+ return [];
60
+ }
61
+ }
62
+
63
+ export function planGc(repoRoot) {
64
+ const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
65
+ const locksDir = join(repoRoot, ".buildbeat", "runtime", "locks");
66
+ const rows = [];
67
+ if (!existsSync(runsDir)) {
68
+ return rows;
69
+ }
70
+ for (const entry of readdirSync(runsDir).sort()) {
71
+ const ledgerPath = join(runsDir, entry, "events.jsonl");
72
+ if (!existsSync(ledgerPath)) {
73
+ continue;
74
+ }
75
+ const ledger = EventLedger.open(ledgerPath);
76
+ const state = ledger.state;
77
+ const row = { run: entry, status: state.run?.status ?? "(no run)", actions: [], keep: [] };
78
+ rows.push(row);
79
+ if (ledger.corruption) {
80
+ row.keep.push(`ledger corrupted after seq=${ledger.corruption.afterSeq}; human decision required`);
81
+ continue;
82
+ }
83
+ if (!state.run) {
84
+ row.keep.push("ledger has no run");
85
+ continue;
86
+ }
87
+ if (!state.terminal) {
88
+ row.keep.push(`run not terminal (${state.run.status})`);
89
+ continue;
90
+ }
91
+ if (!state.compacted) {
92
+ row.keep.push("terminal but not compacted (no run-record in the Git plane)");
93
+ continue;
94
+ }
95
+ const lockPath = join(locksDir, `${entry}.lock`);
96
+ if (existsSync(lockPath)) {
97
+ row.actions.push({ kind: "remove-lock", path: lockPath });
98
+ }
99
+ const workspace = state.workspaces[entry];
100
+ if (!workspace) {
101
+ continue;
102
+ }
103
+ const worktreePath = resolveRepoRef(repoRoot, workspace.worktreePath);
104
+ const registered = worktreeRegistered(repoRoot, worktreePath);
105
+ const present = existsSync(worktreePath);
106
+ row.candidate = workspace.candidate;
107
+ row.branch = workspace.branch;
108
+ if (present || registered) {
109
+ let dirty = false;
110
+ if (present) {
111
+ try {
112
+ dirty = readback(worktreePath).dirty;
113
+ } catch {
114
+ dirty = false;
115
+ }
116
+ }
117
+ row.actions.push({ kind: "remove-worktree", path: worktreePath, dirty, registered, present });
118
+ }
119
+ if (workspace.branch && branchExists(repoRoot, workspace.branch)) {
120
+ if (!workspace.candidate) {
121
+ row.actions.push({ kind: "delete-branch", branch: workspace.branch, reason: "run produced no candidate" });
122
+ } else {
123
+ const elsewhere = otherRefsContaining(repoRoot, workspace.candidate, workspace.branch);
124
+ if (elsewhere.length > 0) {
125
+ row.actions.push({
126
+ kind: "delete-branch",
127
+ branch: workspace.branch,
128
+ reason: `candidate ${workspace.candidate.slice(0, 7)} also on ${elsewhere.slice(0, 3).join(", ")}`,
129
+ });
130
+ } else {
131
+ row.keep.push(
132
+ `branch ${workspace.branch} kept: candidate ${workspace.candidate.slice(0, 7)} is reachable only there (evidence)`,
133
+ );
134
+ }
135
+ }
136
+ }
137
+ }
138
+ return rows;
139
+ }
140
+
141
+ export function applyGc(repoRoot, rows, { force = false } = {}) {
142
+ const results = [];
143
+ for (const row of rows) {
144
+ for (const action of row.actions) {
145
+ const result = { run: row.run, ...action, done: false, error: null };
146
+ results.push(result);
147
+ try {
148
+ if (action.kind === "remove-lock") {
149
+ rmSync(action.path, { recursive: true, force: true });
150
+ result.done = true;
151
+ } else if (action.kind === "remove-worktree") {
152
+ if (action.dirty && !force) {
153
+ result.error = "worktree dirty; rerun with --force true to discard";
154
+ continue;
155
+ }
156
+ if (action.registered) {
157
+ const args = ["worktree", "remove"];
158
+ if (force || action.dirty) {
159
+ args.push("--force");
160
+ }
161
+ args.push(action.path);
162
+ git(repoRoot, args);
163
+ } else if (action.present) {
164
+ rmSync(action.path, { recursive: true, force: true });
165
+ }
166
+ try {
167
+ git(repoRoot, ["worktree", "prune"]);
168
+ } catch {
169
+ // prune is best-effort
170
+ }
171
+ result.done = true;
172
+ } else if (action.kind === "delete-branch") {
173
+ git(repoRoot, ["branch", "-D", action.branch]);
174
+ result.done = true;
175
+ }
176
+ } catch (error) {
177
+ result.error = error.stderr ? String(error.stderr).trim() : error.message;
178
+ }
179
+ }
180
+ }
181
+ return results;
182
+ }