@mmerterden/multi-agent-pipeline 17.4.0 → 17.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +31 -5
  3. package/README.tr.md +31 -5
  4. package/docs/adr/0013-lsp-code-intelligence.md +102 -0
  5. package/docs/adr/README.md +1 -0
  6. package/docs/features.md +20 -3
  7. package/docs/token-budget-history.md +1 -1
  8. package/install/templates/copilot-instructions.md +9 -3
  9. package/package.json +1 -1
  10. package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -3
  11. package/pipeline/commands/multi-agent/autopilot/SKILL.md +3 -3
  12. package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +5 -3
  13. package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +1 -1
  14. package/pipeline/commands/multi-agent/local/SKILL.md +17 -6
  15. package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +3 -3
  16. package/pipeline/lib/multi-repo-pipeline.sh +26 -0
  17. package/pipeline/multi-agent-refs/features/base-branch-evidence.md +222 -0
  18. package/pipeline/multi-agent-refs/features/code-intelligence.md +80 -0
  19. package/pipeline/multi-agent-refs/phases/modes.md +23 -3
  20. package/pipeline/multi-agent-refs/phases/phase-0-init.md +96 -71
  21. package/pipeline/multi-agent-refs/phases/phase-7-report.md +1 -1
  22. package/pipeline/multi-agent-refs/phases.md +7 -2
  23. package/pipeline/multi-agent-refs/picker-contract.md +37 -5
  24. package/pipeline/multi-agent-refs/tracker-contract.md +25 -14
  25. package/pipeline/schemas/agent-state.schema.json +88 -4
  26. package/pipeline/schemas/prefs.schema.json +22 -0
  27. package/pipeline/schemas/token-budget.json +2 -2
  28. package/pipeline/scripts/autopilot-runner.mjs +292 -45
  29. package/pipeline/scripts/base-branch-candidates.mjs +599 -0
  30. package/pipeline/scripts/gc-abandoned.sh +5 -3
  31. package/pipeline/scripts/gen-mode-dispatch.mjs +39 -16
  32. package/pipeline/scripts/phase-tracker.sh +39 -2
  33. package/pipeline/scripts/phase0-exit-gate.mjs +128 -0
  34. package/pipeline/scripts/verify-citations.mjs +84 -2
  35. package/pipeline/skills/.skill-manifest.json +2 -2
  36. package/pipeline/skills/shared/core/multi-agent/SKILL.md +1 -1
@@ -0,0 +1,599 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * base-branch-candidates.mjs - collect every plausible base branch WITH the
4
+ * evidence behind it, rank them, and hand the picker a list it can show.
5
+ *
6
+ * Two failures this replaces, both reported from real runs.
7
+ *
8
+ * 1. The fetch can fail and the answer degrades silently. Step 3 ran
9
+ * `git fetch origin` and then `git branch -r`. On a restricted network the
10
+ * fetch fails and `git branch -r` still prints a full, confident list - the
11
+ * remote-tracking cache, which may be weeks old or empty. Nothing in the
12
+ * output said so, so a stale local guess was presented as the remote's
13
+ * answer. Here the provenance is a required input, it rides on every
14
+ * candidate, and the picker question has to say it.
15
+ *
16
+ * 2. The right base branch is usually derivable from the issue, and nothing
17
+ * derived it. An issue that carries a target version, or that links a
18
+ * separate issue representing the release, already names the branch on a
19
+ * repo whose release branches encode the version. That is evidence, not a
20
+ * rule: this module collects it, scores it, and stops. A human (or, in
21
+ * autopilot, a recorded resolution) still chooses.
22
+ *
23
+ * WHY THE CONVENTION IS LEARNED RATHER THAN TABLED. Every repo spells its
24
+ * release branches differently and the spellings change. A table of prefixes
25
+ * would be wrong for most repos on the day it was written, and silently wrong
26
+ * afterwards. So the template is inferred from the branch names actually on the
27
+ * remote: find the branches that contain a version token, replace the token
28
+ * with a hole, and the most common resulting shape is this repo's convention.
29
+ * The inference is reported with its member count so a reader can see how much
30
+ * evidence it rests on.
31
+ *
32
+ * A PREDICTED BRANCH IS NEVER A CANDIDATE. If the learned template predicts a
33
+ * name that is not on the ref list, that fact is a note, never an option. An
34
+ * option the user picks has to be checkoutable; offering a name that does not
35
+ * exist moves the failure to Step 8 where it reads as a git error instead of a
36
+ * wrong answer here.
37
+ *
38
+ * Usage:
39
+ * node base-branch-candidates.mjs --input <file.json> [--json]
40
+ *
41
+ * Exit codes: 0 = candidates produced, 1 = bad input, 2 = usage
42
+ *
43
+ * @module pipeline/scripts/base-branch-candidates
44
+ */
45
+
46
+ import { existsSync, readFileSync } from "fs";
47
+ import { pathToFileURL } from "url";
48
+
49
+ /**
50
+ * A version token in a branch name or a field value. Two, three or four
51
+ * numeric segments, separated by `.`, `_` or `-`, with an optional trailing
52
+ * qualifier that is NOT captured (a `-rc1` suffix still identifies the release).
53
+ */
54
+ const VERSION_TOKEN = /(\d+)[._-](\d+)(?:[._-](\d+))?(?:[._-](\d+))?/;
55
+
56
+ /** Weights. Kept as one table so the ranking is readable rather than derived. */
57
+ export const WEIGHTS = {
58
+ issueVersionExact: 100,
59
+ linkedReleaseExact: 90,
60
+ linkedReleaseAuthoritative: 110,
61
+ versionPartial: 60,
62
+ recent: 40,
63
+ recentRecencyMax: 10,
64
+ repoDefault: 12,
65
+ sortDevelop: 30,
66
+ sortRelease: 20,
67
+ sortMain: 10,
68
+ };
69
+
70
+ /**
71
+ * Branch families the legacy sort order knew about - the floor, below every piece of
72
+ * real evidence. The three stay ABOVE `repoDefault` on purpose: Step 3 has always led
73
+ * with `develop` on a repo that has one, and a weights table that quietly reordered
74
+ * that would change the recommended row on every repo whose default is `main`.
75
+ */
76
+ const SORT_FAMILIES = [
77
+ [/(^|\/)develop/i, WEIGHTS.sortDevelop, "sorts under the develop family"],
78
+ [/(^|\/)releases?([./_-]|$)/i, WEIGHTS.sortRelease, "sorts under the release family"],
79
+ [/(^|\/)(main|master)$/i, WEIGHTS.sortMain, "sorts under the main/master family"],
80
+ ];
81
+
82
+ /**
83
+ * Strip a `refs/remotes/<remote>/` or `<remote>/` prefix so remote and local
84
+ * names compare as the same branch.
85
+ *
86
+ * @param {string} ref
87
+ * @param {string} remote
88
+ * @returns {string}
89
+ */
90
+ export function shortName(ref, remote = "origin") {
91
+ let s = String(ref || "").trim();
92
+ s = s.replace(/^refs\/(remotes|heads)\//, "");
93
+ if (s.startsWith(`${remote}/`)) s = s.slice(remote.length + 1);
94
+ return s;
95
+ }
96
+
97
+ /**
98
+ * Normalise a version string to its numeric segments. `v1.51.0`, `1_51_0` and
99
+ * `Release 1.51` all reduce to the same segment list, which is what lets a
100
+ * field value match a branch name spelled with a different separator.
101
+ *
102
+ * @param {string} raw
103
+ * @returns {string[]} numeric segments, empty when nothing version-shaped is present
104
+ */
105
+ export function versionSegments(raw) {
106
+ const m = String(raw || "").match(VERSION_TOKEN);
107
+ if (!m) return [];
108
+ return m.slice(1).filter((x) => x !== undefined);
109
+ }
110
+
111
+ /**
112
+ * Every spelling of a version that could appear inside a branch name.
113
+ *
114
+ * @param {string[]} segments
115
+ * @returns {string[]}
116
+ */
117
+ export function versionSpellings(segments) {
118
+ if (segments.length < 2) return [];
119
+ const out = new Set();
120
+ for (const sep of [".", "_", "-"]) {
121
+ out.add(segments.join(sep));
122
+ if (segments.length > 2) out.add(segments.slice(0, 2).join(sep));
123
+ }
124
+ return [...out];
125
+ }
126
+
127
+ /**
128
+ * Does this branch name carry this version?
129
+ *
130
+ * @param {string} branch
131
+ * @param {string[]} segments
132
+ * @returns {"exact"|"partial"|null}
133
+ */
134
+ export function versionMatch(branch, segments) {
135
+ if (segments.length < 2) return null;
136
+ const found = versionSegments(branch);
137
+ if (found.length < 2) return null;
138
+ const sameHead = found[0] === segments[0] && found[1] === segments[1];
139
+ if (!sameHead) return null;
140
+ // Both sides carry a patch segment and they differ -> a different release.
141
+ if (found.length > 2 && segments.length > 2 && found[2] !== segments[2]) return null;
142
+ // Exact means the two version strings name the same release, segment for segment.
143
+ // A field value of `1.51` against a branch `...1.51.0` is the release LINE, which is
144
+ // real evidence and a weaker claim - so it is partial, not exact.
145
+ return found.join(".") === segments.join(".") ? "exact" : "partial";
146
+ }
147
+
148
+ /**
149
+ * Infer this repo's release-branch template from the refs that exist.
150
+ *
151
+ * Every branch carrying a version token is reduced to its shape by replacing
152
+ * the token with `<version>`; the most common shape wins. Ties break on the
153
+ * longer template, because a longer shape is the more specific claim and a
154
+ * shorter one is usually its prefix.
155
+ *
156
+ * @param {string[]} branches - short names
157
+ * @returns {{template: string, members: string[], separator: string}|null}
158
+ */
159
+ export function learnVersionConvention(branches) {
160
+ /** @type {Map<string, {members: string[], separator: string}>} */
161
+ const shapes = new Map();
162
+ for (const b of branches) {
163
+ const m = b.match(VERSION_TOKEN);
164
+ if (!m) continue;
165
+ const separator = m[0].includes("_") ? "_" : m[0].includes("-") ? "-" : ".";
166
+ const template = b.slice(0, m.index) + "<version>" + b.slice(m.index + m[0].length);
167
+ if (!shapes.has(template)) shapes.set(template, { members: [], separator });
168
+ shapes.get(template).members.push(b);
169
+ }
170
+ if (shapes.size === 0) return null;
171
+ let best = null;
172
+ for (const [template, info] of shapes) {
173
+ if (
174
+ best === null ||
175
+ info.members.length > best.members.length ||
176
+ (info.members.length === best.members.length && template.length > best.template.length)
177
+ ) {
178
+ best = { template, members: info.members, separator: info.separator };
179
+ }
180
+ }
181
+ return best;
182
+ }
183
+
184
+ /**
185
+ * Render a version into a learned template.
186
+ *
187
+ * @param {{template: string, separator: string}} convention
188
+ * @param {string[]} segments
189
+ * @returns {string}
190
+ */
191
+ export function applyConvention(convention, segments) {
192
+ return convention.template.replace("<version>", segments.join(convention.separator));
193
+ }
194
+
195
+ /**
196
+ * Pull version and release-link evidence out of an already-fetched issue.
197
+ *
198
+ * NOTHING HERE IS HARDCODED TO A FIELD ID. Jira's only system version fields
199
+ * are `fixVersions` and `versions`; a board's own "target version" is a custom
200
+ * field whose id differs per instance, so it is discovered by its declared
201
+ * schema type instead. `fieldMeta` is the `GET /rest/api/2/field` response
202
+ * (or the `names`/`schema` maps from `GET /rest/api/2/issue/<key>?expand=names`);
203
+ * any field whose schema resolves to a version - directly or as array items -
204
+ * is read, whatever it is called and whatever its id.
205
+ *
206
+ * GitHub's analogue is the milestone, which is a plain title string.
207
+ *
208
+ * @param {object} issue - `{fields: {...}}` (Jira) or `{milestone, ...}` (GitHub)
209
+ * @param {Array<object>} fieldMeta - `[{id, name, schema: {type, items}}]`
210
+ * @returns {{versions: Array<object>, releaseLinks: Array<object>}}
211
+ */
212
+ export function extractIssueEvidence(issue, fieldMeta = []) {
213
+ const versions = [];
214
+ const releaseLinks = [];
215
+ if (!issue || typeof issue !== "object") return { versions, releaseLinks };
216
+
217
+ const isVersionSchema = (schema) =>
218
+ !!schema &&
219
+ (schema.type === "version" ||
220
+ (schema.type === "array" && schema.items === "version") ||
221
+ schema.custom === "version" ||
222
+ schema.items === "version");
223
+
224
+ const metaById = new Map();
225
+ for (const f of Array.isArray(fieldMeta) ? fieldMeta : []) {
226
+ if (f && typeof f.id === "string") metaById.set(f.id, f);
227
+ }
228
+
229
+ const pushVersion = (fieldId, fieldName, value, weightHint) => {
230
+ const raw =
231
+ value && typeof value === "object" ? value.name || value.value || value.id : String(value);
232
+ const segments = versionSegments(raw);
233
+ if (segments.length < 2) return;
234
+ versions.push({
235
+ field: fieldId,
236
+ fieldName: fieldName || fieldId,
237
+ raw: String(raw),
238
+ segments,
239
+ weightHint,
240
+ });
241
+ };
242
+
243
+ const fields = issue.fields && typeof issue.fields === "object" ? issue.fields : {};
244
+
245
+ for (const [id, value] of Object.entries(fields)) {
246
+ if (value === null || value === undefined) continue;
247
+ const meta = metaById.get(id);
248
+ const name = meta ? meta.name : id;
249
+ const schemaSaysVersion = meta ? isVersionSchema(meta.schema) : false;
250
+ const isSystemFix = id === "fixVersions";
251
+ const isSystemAffects = id === "versions";
252
+ if (!schemaSaysVersion && !isSystemFix && !isSystemAffects) continue;
253
+ const hint = isSystemAffects ? "affects" : "fix";
254
+ if (Array.isArray(value)) for (const v of value) pushVersion(id, name, v, hint);
255
+ else pushVersion(id, name, value, hint);
256
+ }
257
+
258
+ const linkFrom = (other, relation) => {
259
+ if (!other || typeof other !== "object") return;
260
+ const of = other.fields && typeof other.fields === "object" ? other.fields : {};
261
+ const segments = [];
262
+ for (const v of Array.isArray(of.fixVersions) ? of.fixVersions : []) {
263
+ const s = versionSegments(v && v.name);
264
+ if (s.length >= 2) segments.push(s);
265
+ }
266
+ if (segments.length === 0) {
267
+ const s = versionSegments(of.summary || "");
268
+ if (s.length >= 2) segments.push(s);
269
+ }
270
+ if (segments.length === 0) return;
271
+ releaseLinks.push({
272
+ key: other.key || null,
273
+ summary: of.summary || null,
274
+ relation,
275
+ segments: segments[0],
276
+ });
277
+ };
278
+
279
+ for (const link of Array.isArray(fields.issuelinks) ? fields.issuelinks : []) {
280
+ const type = link && link.type ? link.type : {};
281
+ if (link && link.inwardIssue) linkFrom(link.inwardIssue, type.inward || "links to");
282
+ if (link && link.outwardIssue) linkFrom(link.outwardIssue, type.outward || "links to");
283
+ }
284
+ if (fields.parent) linkFrom(fields.parent, "parent of this sub-task");
285
+
286
+ // GitHub: the milestone is the version field, and a title is all there is.
287
+ if (issue.milestone && issue.milestone.title) {
288
+ const segments = versionSegments(issue.milestone.title);
289
+ if (segments.length >= 2) {
290
+ versions.push({
291
+ field: "milestone",
292
+ fieldName: "milestone",
293
+ raw: String(issue.milestone.title),
294
+ segments,
295
+ weightHint: "fix",
296
+ });
297
+ }
298
+ }
299
+
300
+ return { versions, releaseLinks };
301
+ }
302
+
303
+ /**
304
+ * Collect and rank candidates.
305
+ *
306
+ * @param {object} input
307
+ * @param {string[]} input.remoteBranches - refs as git printed them
308
+ * @param {string[]} [input.localBranches]
309
+ * @param {"remote"|"local"} input.refProvenance - where the list above came from
310
+ * @param {string} [input.fetchStatus] - `fresh` | `cached-stale` | `local-branch` | `aborted`
311
+ * @param {string|null} [input.defaultBranch]
312
+ * @param {Array<object>} [input.recentBranches] - `[{branch, lastUsed, count}]`
313
+ * @param {number} [input.branchTtlDays]
314
+ * @param {string} [input.now] - ISO timestamp, for the TTL filter
315
+ * @param {{versions?: Array<object>, releaseLinks?: Array<object>}} [input.issue]
316
+ * @param {boolean} [input.preferLinkedRelease]
317
+ * @returns {object}
318
+ */
319
+ export function collect(input) {
320
+ const remote = String(input.remote || "origin");
321
+ const provenance = input.refProvenance === "local" ? "local" : "remote";
322
+ const degraded = provenance === "local";
323
+ const notes = [];
324
+
325
+ const seen = new Map();
326
+ const addRef = (ref, where) => {
327
+ const name = shortName(ref, remote);
328
+ if (!name || name === "HEAD" || name.includes("->")) return;
329
+ if (!seen.has(name)) seen.set(name, new Set());
330
+ seen.get(name).add(where);
331
+ };
332
+ for (const r of input.remoteBranches || [])
333
+ addRef(r, provenance === "local" ? "local-cache" : "remote");
334
+ for (const r of input.localBranches || []) addRef(r, "local");
335
+
336
+ const names = [...seen.keys()];
337
+ const convention = learnVersionConvention(names);
338
+
339
+ /** @type {Map<string, {branch: string, score: number, evidence: Array<object>, refs: string[]}>} */
340
+ const cands = new Map();
341
+ const candidate = (branch) => {
342
+ if (!seen.has(branch)) return null;
343
+ if (!cands.has(branch))
344
+ cands.set(branch, { branch, score: 0, evidence: [], refs: [...seen.get(branch)] });
345
+ return cands.get(branch);
346
+ };
347
+ const award = (branch, points, kind, detail) => {
348
+ const c = candidate(branch);
349
+ if (!c) return false;
350
+ c.score += points;
351
+ c.evidence.push({ kind, detail });
352
+ return true;
353
+ };
354
+
355
+ // --- issue version fields -------------------------------------------------
356
+ const issue = input.issue || {};
357
+ for (const v of issue.versions || []) {
358
+ const hits = names.filter((n) => versionMatch(n, v.segments));
359
+ for (const n of hits) {
360
+ const kind = versionMatch(n, v.segments);
361
+ const points = kind === "exact" ? WEIGHTS.issueVersionExact : WEIGHTS.versionPartial;
362
+ const weight = v.weightHint === "affects" ? Math.round(points * 0.6) : points;
363
+ award(
364
+ n,
365
+ weight,
366
+ "issue-version",
367
+ `matches ${kind === "exact" ? "" : "the line of "}version ${v.raw} from the issue field "${v.fieldName}"`,
368
+ );
369
+ }
370
+ if (hits.length === 0 && convention) {
371
+ const predicted = applyConvention(convention, v.segments);
372
+ notes.push(
373
+ `version ${v.raw} (field "${v.fieldName}") has no branch on the ${provenance === "local" ? "local refs" : "remote"}; ` +
374
+ `the convention ${convention.template} learned from ${convention.members.length} branch(es) would spell it ${predicted}`,
375
+ );
376
+ } else if (hits.length === 0) {
377
+ notes.push(
378
+ `version ${v.raw} (field "${v.fieldName}") matched no branch and no version convention could be learned`,
379
+ );
380
+ }
381
+ }
382
+
383
+ // --- linked release issues ------------------------------------------------
384
+ const authoritative = input.preferLinkedRelease === true;
385
+ for (const link of issue.releaseLinks || []) {
386
+ const hits = names.filter((n) => versionMatch(n, link.segments));
387
+ for (const n of hits) {
388
+ const kind = versionMatch(n, link.segments);
389
+ const base = authoritative ? WEIGHTS.linkedReleaseAuthoritative : WEIGHTS.linkedReleaseExact;
390
+ const points = kind === "exact" ? base : WEIGHTS.versionPartial;
391
+ award(
392
+ n,
393
+ points,
394
+ "linked-release",
395
+ `the linked release issue ${link.key || "(no key)"} (${link.relation}) names version ` +
396
+ `${link.segments.join(".")}${authoritative ? "; this board treats the release link as authoritative" : ""}`,
397
+ );
398
+ }
399
+ if (hits.length === 0) {
400
+ notes.push(
401
+ `linked release issue ${link.key || "(no key)"} names version ${link.segments.join(".")}, which matched no branch`,
402
+ );
403
+ }
404
+ }
405
+
406
+ // --- the learned convention itself ---------------------------------------
407
+ if (convention) {
408
+ for (const m of convention.members) {
409
+ const c = cands.get(m);
410
+ if (c)
411
+ c.evidence.push({
412
+ kind: "version-convention",
413
+ detail: `this remote spells release branches ${convention.template} (learned from ${convention.members.length} branch(es), no built-in table)`,
414
+ });
415
+ }
416
+ }
417
+
418
+ // --- recently used for this repo -----------------------------------------
419
+ const ttlDays = Number.isFinite(input.branchTtlDays) ? input.branchTtlDays : 15;
420
+ const now = input.now ? Date.parse(input.now) : Date.now();
421
+ for (const entry of input.recentBranches || []) {
422
+ if (!entry || typeof entry.branch !== "string") continue;
423
+ const used = entry.lastUsed ? Date.parse(entry.lastUsed) : NaN;
424
+ const ageDays = Number.isFinite(used) ? (now - used) / 86400000 : Infinity;
425
+ if (Number.isFinite(ageDays) && ageDays > ttlDays) continue;
426
+ const recency = Number.isFinite(ageDays)
427
+ ? Math.max(0, Math.round(WEIGHTS.recentRecencyMax * (1 - ageDays / Math.max(ttlDays, 1))))
428
+ : 0;
429
+ award(
430
+ entry.branch,
431
+ WEIGHTS.recent + recency,
432
+ "recent",
433
+ Number.isFinite(ageDays)
434
+ ? `used as the base for this repo ${Math.max(0, Math.round(ageDays))} day(s) ago`
435
+ : "used as the base for this repo before",
436
+ );
437
+ }
438
+
439
+ // --- repo default ---------------------------------------------------------
440
+ if (input.defaultBranch) {
441
+ award(
442
+ shortName(input.defaultBranch, remote),
443
+ WEIGHTS.repoDefault,
444
+ "repo-default",
445
+ "the repository's default branch",
446
+ );
447
+ }
448
+
449
+ // --- the legacy sort order, as the floor ---------------------------------
450
+ for (const n of names) {
451
+ for (const [re, points, detail] of SORT_FAMILIES) {
452
+ if (re.test(n)) {
453
+ award(n, points, "sort-order", detail);
454
+ break;
455
+ }
456
+ }
457
+ }
458
+
459
+ // --- provenance rides on every candidate ---------------------------------
460
+ const provenanceDetail = degraded
461
+ ? "listed from local refs because the fetch failed; this may be stale or incomplete"
462
+ : "listed from the remote after a successful fetch";
463
+ for (const c of cands.values())
464
+ c.evidence.push({ kind: "ref-provenance", detail: provenanceDetail });
465
+
466
+ const candidates = [...cands.values()].sort(
467
+ (a, b) => b.score - a.score || a.branch.localeCompare(b.branch),
468
+ );
469
+ const top = candidates[0];
470
+ const ambiguous = candidates.length > 1 && top !== undefined && candidates[1].score === top.score;
471
+
472
+ const issueDerived = new Set(["issue-version", "linked-release"]);
473
+ const derivedTop =
474
+ top !== undefined && top.evidence.some((e) => issueDerived.has(e.kind)) && !ambiguous;
475
+
476
+ return {
477
+ refProvenance: provenance,
478
+ fetchStatus: input.fetchStatus || (degraded ? "cached-stale" : "fresh"),
479
+ degraded,
480
+ convention: convention
481
+ ? { template: convention.template, members: convention.members.length }
482
+ : null,
483
+ candidates,
484
+ ambiguous,
485
+ derivable: derivedTop,
486
+ notes,
487
+ };
488
+ }
489
+
490
+ /**
491
+ * Turn the ranked list into picker rows.
492
+ *
493
+ * THE TWO-OPTION FLOOR IS ENFORCED HERE, not left to the caller. Claude Code's
494
+ * `AskUserQuestion` refuses a question with fewer than two declared options and
495
+ * discards every question batched with it, and the host's injected "Other" row
496
+ * does not count towards the schema's minimum. A filtered list that leaves one
497
+ * row has narrowed the world, not decided anything - so the escape row that
498
+ * re-opens the unfiltered list is what turns it back into a question. This
499
+ * function never returns fewer than two rows.
500
+ *
501
+ * @param {object} result - the return value of `collect`
502
+ * @param {number} [visible] - how many ranked rows to show
503
+ * @returns {{question: string, options: Array<object>}}
504
+ */
505
+ export function toPickerOptions(result, visible = 4) {
506
+ const options = result.candidates.slice(0, Math.max(1, visible)).map((c, i) => ({
507
+ branch: c.branch,
508
+ label: c.branch,
509
+ recommended: i === 0,
510
+ // The evidence IS the description: "matches the target version on the
511
+ // remote" and "repo default" are different answers to the same question,
512
+ // and a row that hides which one it is cannot be chosen on its merits.
513
+ description: c.evidence.map((e) => e.detail).join("; "),
514
+ kind: "candidate",
515
+ }));
516
+
517
+ options.push({
518
+ branch: null,
519
+ label: "Show all branches",
520
+ recommended: false,
521
+ description: result.degraded
522
+ ? "re-open with every ref in the local cache, unfiltered"
523
+ : "re-open with every remote branch, unfiltered",
524
+ kind: "escape",
525
+ });
526
+
527
+ if (result.degraded) {
528
+ options.push({
529
+ branch: null,
530
+ label: "Retry the fetch",
531
+ recommended: false,
532
+ description: "re-run git fetch and rebuild this list from the remote",
533
+ kind: "retry",
534
+ });
535
+ }
536
+
537
+ // The floor is enforced here rather than left to the caller, and the empty
538
+ // case is the one that breaks it: no candidate leaves only "Show all
539
+ // branches", and a one-option AskUserQuestion is refused by the host along
540
+ // with every question batched with it. Abort is the honest second row when
541
+ // there is nothing to recommend - the run cannot pick a base it could not
542
+ // find, and "OK" would manufacture consent to a list with nothing in it.
543
+ if (options.length < 2) {
544
+ options.push({
545
+ branch: null,
546
+ label: "Abort",
547
+ recommended: false,
548
+ description: "no branch could be found to base this work on; stop before a worktree exists",
549
+ kind: "abort",
550
+ });
551
+ }
552
+
553
+ const question = result.degraded
554
+ ? "The fetch failed, so these branches come from local refs and may be stale. Which base branch?"
555
+ : "Which base branch should this work target?";
556
+
557
+ return { question, options };
558
+ }
559
+
560
+ /** @param {string[]} argv */
561
+ function main(argv) {
562
+ const args = argv.slice(2);
563
+ const idx = args.indexOf("--input");
564
+ if (idx < 0 || !args[idx + 1]) {
565
+ console.error("usage: base-branch-candidates.mjs --input <file.json> [--json]");
566
+ return 2;
567
+ }
568
+ const path = args[idx + 1];
569
+ if (!existsSync(path)) {
570
+ console.error(`base-branch-candidates: no such input file: ${path}`);
571
+ return 1;
572
+ }
573
+ let input;
574
+ try {
575
+ input = JSON.parse(readFileSync(path, "utf-8"));
576
+ } catch (e) {
577
+ console.error(`base-branch-candidates: ${path} is not valid JSON: ${e.message}`);
578
+ return 1;
579
+ }
580
+ const result = collect(input);
581
+ if (args.includes("--json")) {
582
+ console.log(JSON.stringify({ ...result, picker: toPickerOptions(result) }, null, 2));
583
+ } else {
584
+ console.log(
585
+ `base-branch-candidates: ${result.candidates.length} candidate(s), refs from ${result.refProvenance}` +
586
+ `${result.degraded ? " (DEGRADED - fetch failed)" : ""}`,
587
+ );
588
+ for (const c of result.candidates) {
589
+ console.log(` ${c.score.toString().padStart(4)} ${c.branch}`);
590
+ for (const e of c.evidence) console.log(` - ${e.kind}: ${e.detail}`);
591
+ }
592
+ for (const n of result.notes) console.log(` note: ${n}`);
593
+ }
594
+ return 0;
595
+ }
596
+
597
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
598
+ process.exit(main(process.argv));
599
+ }
@@ -18,9 +18,11 @@
18
18
  # This is not theoretical: four state files on that machine record
19
19
  # `worktreePath` as the REPO ROOT, so a sweep that trusted the field would
20
20
  # have deleted the checkout.
21
- # 3. Uncommitted work is never destroyed. The branch is stashed to
22
- # `autopilot/abandoned/<task-id>` and the directory is left in place, which
23
- # costs disk and keeps the work. Losing a day of edits is worse than 750 MB.
21
+ # 3. Uncommitted work is never destroyed. It goes into a STASH ENTRY labelled
22
+ # `autopilot/abandoned/<task-id>` - `git stash list` finds it - and the
23
+ # directory is left in place, which costs disk and keeps the work. Losing a
24
+ # day of edits is worse than 750 MB. No branch is created: a branch made
25
+ # after `stash push` points at HEAD and holds none of the stashed work.
24
26
  #
25
27
  # TWO PASSES, because the state files are not where the disk is. Measured across
26
28
  # 28 worktrees holding 23 GB: only 5 of them (1.5 GB) belong to a run that still