@mmerterden/multi-agent-pipeline 17.4.0 → 17.5.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/CHANGELOG.md +136 -0
- package/README.md +23 -5
- package/README.tr.md +23 -5
- package/docs/adr/0013-lsp-code-intelligence.md +102 -0
- package/docs/adr/README.md +1 -0
- package/docs/token-budget-history.md +1 -1
- package/install/templates/copilot-instructions.md +9 -3
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/autopilot/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +5 -3
- package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/local/SKILL.md +17 -6
- package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +3 -3
- package/pipeline/lib/multi-repo-pipeline.sh +26 -0
- package/pipeline/multi-agent-refs/features/base-branch-evidence.md +222 -0
- package/pipeline/multi-agent-refs/features/code-intelligence.md +80 -0
- package/pipeline/multi-agent-refs/phases/modes.md +23 -3
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +96 -71
- package/pipeline/multi-agent-refs/phases/phase-7-report.md +1 -1
- package/pipeline/multi-agent-refs/phases.md +7 -2
- package/pipeline/multi-agent-refs/picker-contract.md +37 -5
- package/pipeline/multi-agent-refs/tracker-contract.md +25 -14
- package/pipeline/schemas/agent-state.schema.json +88 -4
- package/pipeline/schemas/prefs.schema.json +22 -0
- package/pipeline/schemas/token-budget.json +2 -2
- package/pipeline/scripts/autopilot-runner.mjs +292 -45
- package/pipeline/scripts/base-branch-candidates.mjs +599 -0
- package/pipeline/scripts/gc-abandoned.sh +5 -3
- package/pipeline/scripts/gen-mode-dispatch.mjs +39 -16
- package/pipeline/scripts/phase-tracker.sh +39 -2
- package/pipeline/scripts/phase0-exit-gate.mjs +128 -0
- package/pipeline/scripts/verify-citations.mjs +84 -2
- package/pipeline/skills/.skill-manifest.json +2 -2
- 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.
|
|
22
|
-
# `autopilot/abandoned/<task-id>`
|
|
23
|
-
# costs disk and keeps the work. Losing a
|
|
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
|