mandrel 2.46.0 → 2.48.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/.agents/docs/configuration.md +1 -0
- package/.agents/docs/quality-gates.md +48 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/lib/baselines/kernel.js +19 -0
- package/.agents/scripts/lib/baselines/kinds/bundle-size.js +12 -0
- package/.agents/scripts/lib/baselines/kinds/coverage.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +21 -5
- package/.agents/scripts/lib/baselines/kinds/duplication.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/kind-factory.js +26 -1
- package/.agents/scripts/lib/baselines/kinds/lighthouse.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/lint.js +12 -0
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/mutation.js +1 -0
- package/.agents/scripts/lib/baselines/merge-envelopes.js +272 -0
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +175 -0
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +8 -2
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/column-sync.js +26 -2
- package/.agents/scripts/lib/orchestration/epic-container.js +56 -21
- package/.agents/scripts/lib/orchestration/epic-expansion.js +28 -6
- package/.agents/scripts/lib/orchestration/epic-rollup.js +460 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +44 -104
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +60 -1
- package/.agents/scripts/merge-baseline.js +238 -0
- package/.agents/scripts/providers/github/errors.js +66 -10
- package/.agents/scripts/providers/github/sub-issues.js +8 -1
- package/.agents/scripts/single-story-init.js +35 -0
- package/.agents/workflows/helpers/deliver-reference.md +31 -9
- package/.agents/workflows/mandrel-deliver.md +3 -3
- package/docs/CHANGELOG.md +19 -0
- package/lib/cli/registry.js +63 -0
- package/package.json +1 -1
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* merge-envelopes.js — pure 3-way merge of baseline envelopes by row
|
|
3
|
+
* identity (Story #5215).
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* Every baseline write stamps `generatedAt`, and the stamp sits on line 4 of
|
|
8
|
+
* every envelope. Two branches that each refresh a baseline therefore always
|
|
9
|
+
* differ on that line, even when they moved completely disjoint rows — so
|
|
10
|
+
* git's LINE-based merge has to reconcile it. Whether it can separate that
|
|
11
|
+
* hunk from the moved rows is an accident of proximity, and both outcomes
|
|
12
|
+
* are bad:
|
|
13
|
+
*
|
|
14
|
+
* - it cannot → a conflict on work that never actually overlapped;
|
|
15
|
+
* - it can → it splices both sides' row lines together into a row set
|
|
16
|
+
* NEITHER side ever scored. That silent one is the worse failure: the
|
|
17
|
+
* ratchet then guards a number no scorer produced.
|
|
18
|
+
*
|
|
19
|
+
* A baseline is not a text file. It is a set of rows keyed by identity plus
|
|
20
|
+
* a rollup DERIVED from those rows, so merging it as text is a category
|
|
21
|
+
* error. This module merges it as what it is.
|
|
22
|
+
*
|
|
23
|
+
* ## Contract
|
|
24
|
+
*
|
|
25
|
+
* Pure: no filesystem, no process, no clock. `assertEnvelope` is deliberately
|
|
26
|
+
* NOT called here (it compiles schemas off disk on first use) — the driver
|
|
27
|
+
* validates what this returns.
|
|
28
|
+
*
|
|
29
|
+
* Per row identity, the standard 3-way rule: the side that differs from base
|
|
30
|
+
* wins; when both sides differ from base AND from each other, that identity
|
|
31
|
+
* is a conflict. Absence is a value, so a row deleted on one side and
|
|
32
|
+
* untouched on the other merges to deleted.
|
|
33
|
+
*
|
|
34
|
+
* Two invariants are load-bearing:
|
|
35
|
+
*
|
|
36
|
+
* 1. **The rollup is recomputed, never merged.** Merging two rollups is
|
|
37
|
+
* the same splice hazard compressed into a single number, and unlike a
|
|
38
|
+
* spliced row set it leaves no evidence. It is always derived from the
|
|
39
|
+
* merged rows via the kind's own `rollup()`.
|
|
40
|
+
* 2. **Identity comes from the kind module** (`rowIdentity`), never from
|
|
41
|
+
* `keyField`. CRAP groups by file and identifies by method; keying on
|
|
42
|
+
* `keyField` would drop every method in a file but one.
|
|
43
|
+
*
|
|
44
|
+
* @module lib/baselines/merge-envelopes
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { deepEqual } from '../json-utils.js';
|
|
48
|
+
import { KNOWN_KINDS } from './envelope.js';
|
|
49
|
+
import { getKindModule } from './kernel.js';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Envelope keys that are NOT merged side-by-side: `rows` merge by identity,
|
|
53
|
+
* `rollup` is recomputed from them, and `generatedAt` resolves to the later
|
|
54
|
+
* of the two stamps rather than conflicting (it is metadata about when a
|
|
55
|
+
* scorer ran, not a scored value — treating it as content is the whole bug).
|
|
56
|
+
*/
|
|
57
|
+
const DERIVED_KEYS = Object.freeze(['rows', 'rollup', 'generatedAt']);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Identify an envelope's kind from its `$schema` reference.
|
|
61
|
+
*
|
|
62
|
+
* Derived from `KNOWN_KINDS` rather than pattern-matched, so a file that is
|
|
63
|
+
* not a known per-kind envelope answers `null` — which is how the driver
|
|
64
|
+
* knows to hand it back to git's text merge instead of guessing at a shape
|
|
65
|
+
* it does not understand. `baselines/*.json` also matches several
|
|
66
|
+
* non-envelope baselines (arch-cycles, cyclomatic, dead-exports, …).
|
|
67
|
+
*
|
|
68
|
+
* @param {unknown} envelope
|
|
69
|
+
* @returns {string|null}
|
|
70
|
+
*/
|
|
71
|
+
export function kindFromEnvelope(envelope) {
|
|
72
|
+
const ref = envelope?.$schema;
|
|
73
|
+
if (typeof ref !== 'string') return null;
|
|
74
|
+
const base = ref.split('/').pop();
|
|
75
|
+
for (const kind of KNOWN_KINDS) {
|
|
76
|
+
if (base === `${kind}.schema.json`) return kind;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The 3-way choice for one value. `undefined` means "absent on this side",
|
|
83
|
+
* which makes deletion just another value rather than a special case.
|
|
84
|
+
*
|
|
85
|
+
* @param {unknown} base
|
|
86
|
+
* @param {unknown} ours
|
|
87
|
+
* @param {unknown} theirs
|
|
88
|
+
* @returns {{ conflict: boolean, value?: unknown }}
|
|
89
|
+
*/
|
|
90
|
+
function choose(base, ours, theirs) {
|
|
91
|
+
if (deepEqual(ours, theirs)) return { conflict: false, value: ours };
|
|
92
|
+
if (deepEqual(ours, base)) return { conflict: false, value: theirs };
|
|
93
|
+
if (deepEqual(theirs, base)) return { conflict: false, value: ours };
|
|
94
|
+
return { conflict: true };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Index rows by the kind's `rowIdentity`. A duplicate identity within one
|
|
99
|
+
* side is fatal rather than last-write-wins: it means the incoming file
|
|
100
|
+
* already violates the identity contract, and merging it would silently
|
|
101
|
+
* drop a row.
|
|
102
|
+
*
|
|
103
|
+
* @param {Array<object>} rows
|
|
104
|
+
* @param {(row: object) => string} rowIdentity
|
|
105
|
+
* @param {string} side
|
|
106
|
+
* @returns {Map<string, object>}
|
|
107
|
+
*/
|
|
108
|
+
function indexRows(rows, rowIdentity, side) {
|
|
109
|
+
const out = new Map();
|
|
110
|
+
for (const [idx, row] of (rows ?? []).entries()) {
|
|
111
|
+
if (!row || typeof row !== 'object') {
|
|
112
|
+
throw new TypeError(
|
|
113
|
+
`mergeEnvelopes: ${side} row at index ${idx} is not an object`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const id = rowIdentity(row);
|
|
117
|
+
if (out.has(id)) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`mergeEnvelopes: ${side} carries two rows with identity "${id}" — the baseline violates the identity contract and cannot be merged safely`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
out.set(id, row);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve `generatedAt` to the later of the two sides. A stamp is metadata,
|
|
129
|
+
* so it never conflicts: the merged file describes a tree scored as recently
|
|
130
|
+
* as the newer of its inputs.
|
|
131
|
+
*
|
|
132
|
+
* @param {string|undefined} ours
|
|
133
|
+
* @param {string|undefined} theirs
|
|
134
|
+
* @returns {string|undefined}
|
|
135
|
+
*/
|
|
136
|
+
function laterStamp(ours, theirs) {
|
|
137
|
+
if (typeof ours !== 'string') return theirs;
|
|
138
|
+
if (typeof theirs !== 'string') return ours;
|
|
139
|
+
const a = Date.parse(ours);
|
|
140
|
+
const b = Date.parse(theirs);
|
|
141
|
+
if (Number.isNaN(a) || Number.isNaN(b)) return ours > theirs ? ours : theirs;
|
|
142
|
+
return a >= b ? ours : theirs;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Merge the envelope-level stamps (`$schema`, `kernelVersion`, and per-kind
|
|
147
|
+
* extras like `scoringSemantics`) by the same 3-way rule as rows. A double
|
|
148
|
+
* bump to different values is a genuine conflict — two branches disagreeing
|
|
149
|
+
* about which scorer produced the file.
|
|
150
|
+
*
|
|
151
|
+
* @param {object} base
|
|
152
|
+
* @param {object} ours
|
|
153
|
+
* @param {object} theirs
|
|
154
|
+
* @returns {{ merged: object, conflicts: Array<object> }}
|
|
155
|
+
*/
|
|
156
|
+
function mergeStamps(base, ours, theirs) {
|
|
157
|
+
const keys = new Set(
|
|
158
|
+
[...Object.keys(ours), ...Object.keys(theirs), ...Object.keys(base)].filter(
|
|
159
|
+
(k) => !DERIVED_KEYS.includes(k),
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
const merged = {};
|
|
163
|
+
const conflicts = [];
|
|
164
|
+
for (const key of keys) {
|
|
165
|
+
const pick = choose(base[key], ours[key], theirs[key]);
|
|
166
|
+
if (pick.conflict) {
|
|
167
|
+
conflicts.push({
|
|
168
|
+
scope: 'envelope',
|
|
169
|
+
identity: key,
|
|
170
|
+
base: base[key],
|
|
171
|
+
ours: ours[key],
|
|
172
|
+
theirs: theirs[key],
|
|
173
|
+
});
|
|
174
|
+
merged[key] = ours[key];
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (pick.value !== undefined) merged[key] = pick.value;
|
|
178
|
+
}
|
|
179
|
+
return { merged, conflicts };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 3-way merge two baseline envelopes against their common ancestor.
|
|
184
|
+
*
|
|
185
|
+
* @param {{
|
|
186
|
+
* base: object|null,
|
|
187
|
+
* ours: object,
|
|
188
|
+
* theirs: object,
|
|
189
|
+
* kind?: string,
|
|
190
|
+
* components?: Array<object>,
|
|
191
|
+
* }} params
|
|
192
|
+
* - `base` — the merge ancestor; `null` (or a rowless object) when the
|
|
193
|
+
* file was added on both sides.
|
|
194
|
+
* - `components` — passed straight to the kind's `rollup()`. Defaults to
|
|
195
|
+
* `[]`, which is what `refreshBaseline` effectively uses, so a merged
|
|
196
|
+
* envelope carries the same `{'*': …}` rollup shape a refresh writes.
|
|
197
|
+
* @returns {{
|
|
198
|
+
* kind: string,
|
|
199
|
+
* envelope: object,
|
|
200
|
+
* conflicts: Array<{scope: string, identity: string, base?: unknown, ours?: unknown, theirs?: unknown}>,
|
|
201
|
+
* }}
|
|
202
|
+
*/
|
|
203
|
+
export function mergeEnvelopes({
|
|
204
|
+
base,
|
|
205
|
+
ours,
|
|
206
|
+
theirs,
|
|
207
|
+
kind,
|
|
208
|
+
components = [],
|
|
209
|
+
} = {}) {
|
|
210
|
+
const resolvedKind =
|
|
211
|
+
kind ?? kindFromEnvelope(ours) ?? kindFromEnvelope(theirs);
|
|
212
|
+
if (!resolvedKind) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
'mergeEnvelopes: could not resolve a known baseline kind from the envelopes',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
const mod = getKindModule(resolvedKind);
|
|
218
|
+
const baseEnv = base && typeof base === 'object' ? base : { rows: [] };
|
|
219
|
+
|
|
220
|
+
const baseRows = indexRows(baseEnv.rows, mod.rowIdentity, 'base');
|
|
221
|
+
const ourRows = indexRows(ours?.rows, mod.rowIdentity, 'ours');
|
|
222
|
+
const theirRows = indexRows(theirs?.rows, mod.rowIdentity, 'theirs');
|
|
223
|
+
|
|
224
|
+
const conflicts = [];
|
|
225
|
+
const rows = [];
|
|
226
|
+
const identities = new Set([
|
|
227
|
+
...ourRows.keys(),
|
|
228
|
+
...theirRows.keys(),
|
|
229
|
+
...baseRows.keys(),
|
|
230
|
+
]);
|
|
231
|
+
for (const id of identities) {
|
|
232
|
+
const b = baseRows.get(id);
|
|
233
|
+
const o = ourRows.get(id);
|
|
234
|
+
const t = theirRows.get(id);
|
|
235
|
+
const pick = choose(b, o, t);
|
|
236
|
+
if (pick.conflict) {
|
|
237
|
+
conflicts.push({
|
|
238
|
+
scope: 'row',
|
|
239
|
+
identity: id,
|
|
240
|
+
base: b,
|
|
241
|
+
ours: o,
|
|
242
|
+
theirs: t,
|
|
243
|
+
});
|
|
244
|
+
// Keep the ours-side value so the row set stays well-formed; the
|
|
245
|
+
// driver renders the conflict markers around it from this record.
|
|
246
|
+
if (o !== undefined) rows.push(o);
|
|
247
|
+
else if (t !== undefined) rows.push(t);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (pick.value !== undefined) rows.push(pick.value);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const sortedRows = mod.sortRows(rows);
|
|
254
|
+
const { merged: stamps, conflicts: stampConflicts } = mergeStamps(
|
|
255
|
+
baseEnv,
|
|
256
|
+
ours ?? {},
|
|
257
|
+
theirs ?? {},
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const envelope = {
|
|
261
|
+
...stamps,
|
|
262
|
+
generatedAt: laterStamp(ours?.generatedAt, theirs?.generatedAt),
|
|
263
|
+
rollup: mod.rollup(sortedRows, components),
|
|
264
|
+
rows: sortedRows,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
kind: resolvedKind,
|
|
269
|
+
envelope,
|
|
270
|
+
conflicts: [...stampConflicts, ...conflicts],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bootstrap/baseline-merge-driver — register the `baselines/*.json` merge
|
|
3
|
+
* driver on a consumer clone (Story #5215).
|
|
4
|
+
*
|
|
5
|
+
* Registration has two halves that live in different places, and conflating
|
|
6
|
+
* them is why this needs a doctor check rather than just an installer:
|
|
7
|
+
*
|
|
8
|
+
* 1. **`.gitattributes`** says which files use the driver. It is a tracked
|
|
9
|
+
* file, so installing the line once ships it to everyone.
|
|
10
|
+
* 2. **`git config merge.mandrel-baseline.driver`** says what the driver
|
|
11
|
+
* actually is. Git deliberately keeps this out of tracked config —
|
|
12
|
+
* otherwise a clone would execute a command chosen by whoever wrote the
|
|
13
|
+
* repo — so it is **per clone**, and a fresh clone silently falls back
|
|
14
|
+
* to git's text merge with no error at all.
|
|
15
|
+
*
|
|
16
|
+
* That silence is the whole reason `mandrel doctor` carries a check: an
|
|
17
|
+
* unregistered clone is not broken in any way it can report on its own, it
|
|
18
|
+
* just quietly goes back to conflicting (or worse, splicing) baselines.
|
|
19
|
+
*
|
|
20
|
+
* This module owns both halves plus the strings they share, so the installer
|
|
21
|
+
* and the doctor check cannot drift apart on the exact command.
|
|
22
|
+
*
|
|
23
|
+
* @module lib/bootstrap/baseline-merge-driver
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { spawnCapture } from '../child-exec.js';
|
|
29
|
+
|
|
30
|
+
/** Driver name, as it appears on both sides of the registration. */
|
|
31
|
+
const BASELINE_MERGE_DRIVER_NAME = 'mandrel-baseline';
|
|
32
|
+
|
|
33
|
+
/** The `.gitattributes` line that routes baselines through the driver. */
|
|
34
|
+
const BASELINE_MERGE_ATTRIBUTE = `baselines/*.json merge=${BASELINE_MERGE_DRIVER_NAME}`;
|
|
35
|
+
|
|
36
|
+
/** Git config key holding the driver command. */
|
|
37
|
+
export const BASELINE_MERGE_DRIVER_CONFIG_KEY = `merge.${BASELINE_MERGE_DRIVER_NAME}.driver`;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The driver command. Relative to the worktree root, which is where git runs
|
|
41
|
+
* a merge driver from, and where `mandrel sync` materializes `.agents/`.
|
|
42
|
+
*/
|
|
43
|
+
const BASELINE_MERGE_DRIVER_COMMAND =
|
|
44
|
+
'node .agents/scripts/merge-baseline.js %O %A %B %P';
|
|
45
|
+
|
|
46
|
+
/** The exact command an operator runs to complete registration. */
|
|
47
|
+
export const BASELINE_MERGE_DRIVER_REMEDY = `git config ${BASELINE_MERGE_DRIVER_CONFIG_KEY} "${BASELINE_MERGE_DRIVER_COMMAND}"`;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Does this `.gitattributes` content route baselines through the driver?
|
|
51
|
+
* Comment lines do not count — a commented-out registration is not one.
|
|
52
|
+
*
|
|
53
|
+
* @param {string|null|undefined} gitattributes
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
export function declaresBaselineMergeDriver(gitattributes) {
|
|
57
|
+
return String(gitattributes ?? '')
|
|
58
|
+
.split('\n')
|
|
59
|
+
.some((line) => {
|
|
60
|
+
const trimmed = line.trim();
|
|
61
|
+
if (trimmed === '' || trimmed.startsWith('#')) return false;
|
|
62
|
+
return trimmed.includes(`merge=${BASELINE_MERGE_DRIVER_NAME}`);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Add the attribute line, preserving every existing line verbatim.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} projectRoot
|
|
70
|
+
* @param {typeof fs} [fsImpl]
|
|
71
|
+
* @returns {{ action: 'created'|'appended'|'already-present', path: string }}
|
|
72
|
+
*/
|
|
73
|
+
function ensureGitattributesLine(projectRoot, fsImpl = fs) {
|
|
74
|
+
const target = path.join(projectRoot, '.gitattributes');
|
|
75
|
+
if (!fsImpl.existsSync(target)) {
|
|
76
|
+
fsImpl.writeFileSync(target, `${BASELINE_MERGE_ATTRIBUTE}\n`, 'utf8');
|
|
77
|
+
return { action: 'created', path: target };
|
|
78
|
+
}
|
|
79
|
+
const existing = fsImpl.readFileSync(target, 'utf8');
|
|
80
|
+
if (declaresBaselineMergeDriver(existing)) {
|
|
81
|
+
return { action: 'already-present', path: target };
|
|
82
|
+
}
|
|
83
|
+
// A file not ending in a newline would otherwise glue our line onto the
|
|
84
|
+
// last existing one, silently rewriting it.
|
|
85
|
+
const separator = existing === '' || existing.endsWith('\n') ? '' : '\n';
|
|
86
|
+
fsImpl.writeFileSync(
|
|
87
|
+
target,
|
|
88
|
+
`${existing}${separator}${BASELINE_MERGE_ATTRIBUTE}\n`,
|
|
89
|
+
'utf8',
|
|
90
|
+
);
|
|
91
|
+
return { action: 'appended', path: target };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Point `merge.mandrel-baseline.driver` at the driver in THIS clone.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} projectRoot
|
|
98
|
+
* @param {typeof spawnCapture} [spawnImpl]
|
|
99
|
+
* @returns {{ action: 'set'|'already-present'|'not-a-repo'|'failed' }}
|
|
100
|
+
*/
|
|
101
|
+
function ensureDriverGitConfig(projectRoot, spawnImpl = spawnCapture) {
|
|
102
|
+
const opts = {
|
|
103
|
+
cwd: projectRoot,
|
|
104
|
+
encoding: 'utf-8',
|
|
105
|
+
stdio: 'pipe',
|
|
106
|
+
shell: false,
|
|
107
|
+
};
|
|
108
|
+
const inRepo = spawnImpl('git', ['rev-parse', '--git-dir'], opts);
|
|
109
|
+
if ((inRepo?.status ?? 1) !== 0) return { action: 'not-a-repo' };
|
|
110
|
+
|
|
111
|
+
const current = spawnImpl(
|
|
112
|
+
'git',
|
|
113
|
+
['config', '--local', '--get', BASELINE_MERGE_DRIVER_CONFIG_KEY],
|
|
114
|
+
opts,
|
|
115
|
+
);
|
|
116
|
+
if (
|
|
117
|
+
(current?.status ?? 1) === 0 &&
|
|
118
|
+
String(current.stdout ?? '').trim() === BASELINE_MERGE_DRIVER_COMMAND
|
|
119
|
+
) {
|
|
120
|
+
return { action: 'already-present' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const set = spawnImpl(
|
|
124
|
+
'git',
|
|
125
|
+
[
|
|
126
|
+
'config',
|
|
127
|
+
'--local',
|
|
128
|
+
BASELINE_MERGE_DRIVER_CONFIG_KEY,
|
|
129
|
+
BASELINE_MERGE_DRIVER_COMMAND,
|
|
130
|
+
],
|
|
131
|
+
opts,
|
|
132
|
+
);
|
|
133
|
+
spawnImpl(
|
|
134
|
+
'git',
|
|
135
|
+
[
|
|
136
|
+
'config',
|
|
137
|
+
'--local',
|
|
138
|
+
`merge.${BASELINE_MERGE_DRIVER_NAME}.name`,
|
|
139
|
+
'mandrel baseline merge by row identity',
|
|
140
|
+
],
|
|
141
|
+
opts,
|
|
142
|
+
);
|
|
143
|
+
return { action: (set?.status ?? 1) === 0 ? 'set' : 'failed' };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Install both halves. Idempotent: a second run reports `already-present`
|
|
148
|
+
* and changes no bytes.
|
|
149
|
+
*
|
|
150
|
+
* @param {object} ctx
|
|
151
|
+
* @param {string} ctx.projectRoot
|
|
152
|
+
* @param {typeof spawnCapture} [ctx.spawnImpl]
|
|
153
|
+
* @param {typeof fs} [ctx.fsImpl]
|
|
154
|
+
* @returns {{
|
|
155
|
+
* action: 'already-present'|'updated',
|
|
156
|
+
* attributes: string,
|
|
157
|
+
* config: string,
|
|
158
|
+
* path: string,
|
|
159
|
+
* line: string,
|
|
160
|
+
* }}
|
|
161
|
+
*/
|
|
162
|
+
export function ensureBaselineMergeDriver(ctx) {
|
|
163
|
+
const attributes = ensureGitattributesLine(ctx.projectRoot, ctx.fsImpl ?? fs);
|
|
164
|
+
const config = ensureDriverGitConfig(ctx.projectRoot, ctx.spawnImpl);
|
|
165
|
+
const settled =
|
|
166
|
+
attributes.action === 'already-present' &&
|
|
167
|
+
(config.action === 'already-present' || config.action === 'not-a-repo');
|
|
168
|
+
return {
|
|
169
|
+
action: settled ? 'already-present' : 'updated',
|
|
170
|
+
attributes: attributes.action,
|
|
171
|
+
config: config.action,
|
|
172
|
+
path: attributes.path,
|
|
173
|
+
line: BASELINE_MERGE_ATTRIBUTE,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
* 4. Seeds `delivery.quality.codingGuardrails` and
|
|
16
16
|
* `delivery.quality.autoRefresh` defaults in `.agentrc.json` when
|
|
17
17
|
* the keys are absent. Existing values are preserved.
|
|
18
|
-
* 5.
|
|
18
|
+
* 5. Registers the `baselines/*.json` merge driver (Story #5215) — the
|
|
19
|
+
* `.gitattributes` line plus this clone's `merge.mandrel-baseline.driver`
|
|
20
|
+
* config, so concurrent baseline refreshes merge by row identity instead
|
|
21
|
+
* of conflicting on the `generatedAt` stamp.
|
|
22
|
+
* 6. Prunes a committed pre-v2 `baselines/epic/` tree (Story #5007). The
|
|
19
23
|
* v2 model is Story-only — nothing writes, reads, or reaps per-Epic
|
|
20
24
|
* ratchet snapshots — so an upgrading consumer is left carrying a
|
|
21
25
|
* committed directory no gate consults. Absent on every repo that never
|
|
@@ -35,6 +39,7 @@ import fs from 'node:fs';
|
|
|
35
39
|
import path from 'node:path';
|
|
36
40
|
import { getAgentrcDefaults, lookupPath } from '../config/defaults.js';
|
|
37
41
|
import { deepEqual } from '../json-utils.js';
|
|
42
|
+
import { ensureBaselineMergeDriver } from './baseline-merge-driver.js';
|
|
38
43
|
|
|
39
44
|
/**
|
|
40
45
|
* The exact pre-commit body the framework ships. Kept as a single string so
|
|
@@ -407,7 +412,7 @@ export function pruneLegacyEpicBaselines(ctx) {
|
|
|
407
412
|
}
|
|
408
413
|
|
|
409
414
|
/**
|
|
410
|
-
* Run all
|
|
415
|
+
* Run all six steps in order. Composable wrapper used by the bootstrap
|
|
411
416
|
* and update workflows. Each step's outcome is returned under its own key
|
|
412
417
|
* so callers can render a per-action summary.
|
|
413
418
|
*
|
|
@@ -423,6 +428,7 @@ export function applyQualityBootstrap(ctx) {
|
|
|
423
428
|
hook: ensurePreCommitHook(ctx),
|
|
424
429
|
scripts: ensureQualityNpmScripts(ctx),
|
|
425
430
|
config: ensureQualityConfigDefaults(ctx),
|
|
431
|
+
mergeDriver: ensureBaselineMergeDriver(ctx),
|
|
426
432
|
legacyBaselines: pruneLegacyEpicBaselines(ctx),
|
|
427
433
|
};
|
|
428
434
|
}
|
|
@@ -108,8 +108,9 @@ export class ColumnSync {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
/**
|
|
111
|
-
* Sync a single issue to its
|
|
112
|
-
* (`synced | skipped | failed`) so callers can log
|
|
111
|
+
* Sync a single issue to the column its `agent::*` labels imply. Returns a
|
|
112
|
+
* result descriptor (`synced | skipped | failed`) so callers can log
|
|
113
|
+
* without parsing errors.
|
|
113
114
|
*
|
|
114
115
|
* @param {number} issueId
|
|
115
116
|
* @param {string[]} labels
|
|
@@ -117,6 +118,29 @@ export class ColumnSync {
|
|
|
117
118
|
async sync(issueId, labels) {
|
|
118
119
|
const column = columnForLabels(labels);
|
|
119
120
|
if (!column) return { status: 'skipped', reason: 'no-matching-label' };
|
|
121
|
+
return this.setColumn(issueId, column);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Push one issue to a column named **directly**, skipping the label
|
|
126
|
+
* derivation {@link sync} performs.
|
|
127
|
+
*
|
|
128
|
+
* Split out for the one caller whose target column cannot come from labels:
|
|
129
|
+
* a container Epic carries no `agent::*` label by construction, so
|
|
130
|
+
* {@link columnForLabels} returns `null` for it and `sync` can never move
|
|
131
|
+
* it. The Epic's column is derived from its children instead
|
|
132
|
+
* (`epic-rollup.js`) and handed here — which keeps that derivation from
|
|
133
|
+
* having to fabricate a label on the container just to reach the board, the
|
|
134
|
+
* one thing the container invariant forbids.
|
|
135
|
+
*
|
|
136
|
+
* Every skip path, the metadata cache and the stale-cache self-heal are
|
|
137
|
+
* shared with `sync` because they live here rather than in it.
|
|
138
|
+
*
|
|
139
|
+
* @param {number} issueId
|
|
140
|
+
* @param {string} column Board column name (`Todo` | `In Progress` | `Done`).
|
|
141
|
+
*/
|
|
142
|
+
async setColumn(issueId, column) {
|
|
143
|
+
if (!column) return { status: 'skipped', reason: 'no-column' };
|
|
120
144
|
if (!this.projectNumber) {
|
|
121
145
|
return { status: 'skipped', reason: 'no-project' };
|
|
122
146
|
}
|
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
* `resolve-stories` (which expands one) — import from here so the written
|
|
19
19
|
* shape and the read shape cannot drift apart.
|
|
20
20
|
*
|
|
21
|
+
* **The container's lifecycle is derived, never labelled.** It still carries
|
|
22
|
+
* no `agent::*` label — that absence is what keeps it out of the bare
|
|
23
|
+
* `/mandrel-deliver` ready list and outside `lint-issue-body.js`. What it does
|
|
24
|
+
* carry is a Projects v2 Status column, an owner while its children run, and
|
|
25
|
+
* eventually a closed state, all computed from the children by
|
|
26
|
+
* `epic-rollup.js` and written directly (Story #5205). Deriving rather than
|
|
27
|
+
* labelling is the whole reason those two facts can coexist.
|
|
28
|
+
*
|
|
21
29
|
* @module lib/orchestration/epic-container
|
|
22
30
|
* @see Story #5139
|
|
23
31
|
*/
|
|
@@ -25,23 +33,35 @@
|
|
|
25
33
|
import { TYPE_LABELS } from '../label-constants.js';
|
|
26
34
|
|
|
27
35
|
/**
|
|
28
|
-
* The checklist grammar
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
36
|
+
* The checklist grammar: a checklist row, and the **first** issue reference
|
|
37
|
+
* anywhere on it. The checklist is a durable mirror of the native sub-issue
|
|
38
|
+
* edges rather than a second, competing representation — when the sub-issues
|
|
39
|
+
* API is unavailable (an older GHES, a revoked scope, a rate-limit burst) the
|
|
40
|
+
* children are still discoverable from the body alone.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately the **loosest** of the three grammars that read this shape, and
|
|
43
|
+
* looser than it was: it used to require the id to be the whole row
|
|
44
|
+
* (`- [ ] #123`), which matched none of the annotated rows a hand-maintained
|
|
45
|
+
* rollout tracker actually carries — `- [ ] Design sign-off (#1897): pending`,
|
|
46
|
+
* `- [ ] 1.4 #1909 (Part B blocked)`. An Epic with 58 children presented three
|
|
47
|
+
* to the rollup, which closed it with 23 still open (Story #5210).
|
|
48
|
+
*
|
|
49
|
+
* It is NOT in sync with `_getChecklistChildren` (`providers/github/issues.js`)
|
|
50
|
+
* and no longer claims to be: that one is a general parent→child strategy for
|
|
51
|
+
* any issue and still requires `#N` immediately after the checkbox. Reading a
|
|
52
|
+
* superset here is safe in the direction that matters — a spurious id costs a
|
|
53
|
+
* skipped non-Story child, while a missed id costs a container closed over open
|
|
54
|
+
* work. The union with the native edges keeps both honest, and after #5210
|
|
55
|
+
* nothing irreversible is decided on this grammar alone.
|
|
37
56
|
*/
|
|
38
|
-
const CHECKLIST_ITEM_RE = /^-\s*\[[ xX]\]\s
|
|
57
|
+
const CHECKLIST_ITEM_RE = /^-\s*\[[ xX]\]\s+.*?#(\d+)\b/gm;
|
|
39
58
|
|
|
40
59
|
/**
|
|
41
60
|
* The same grammar, unanchored to a global cursor — for callers testing one
|
|
42
|
-
* line at a time. Kept beside its `/g` twin so the two cannot drift
|
|
61
|
+
* line at a time. Kept beside its `/g` twin so the two cannot drift; the pair
|
|
62
|
+
* MUST accept the same line set, which `epic-container.test.js` pins.
|
|
43
63
|
*/
|
|
44
|
-
export const CHECKLIST_ITEM_LINE_RE = /^-\s*\[[ xX]\]\s
|
|
64
|
+
export const CHECKLIST_ITEM_LINE_RE = /^-\s*\[[ xX]\]\s+.*?#\d+\b/;
|
|
45
65
|
|
|
46
66
|
/** Heading the container's one prose section renders under. */
|
|
47
67
|
const GOAL_HEADING = '## Goal';
|
|
@@ -172,12 +192,23 @@ export function readEpicChildIds(body) {
|
|
|
172
192
|
* degrades to the checklist rather than propagating, since a body-derived
|
|
173
193
|
* child list is a strictly better answer than an error.
|
|
174
194
|
*
|
|
195
|
+
* **The degrade is reported, not hidden.** The result carries
|
|
196
|
+
* `nativeReadFailed`, because a truncated list and a genuinely small Epic are
|
|
197
|
+
* otherwise indistinguishable downstream — and one caller
|
|
198
|
+
* (`epic-rollup.js`) decides an irreversible close on the difference. A caller
|
|
199
|
+
* that only needs "who are the children" reads `ids` and ignores the flag;
|
|
200
|
+
* a caller about to do something it cannot undo MUST NOT.
|
|
201
|
+
*
|
|
202
|
+
* `nativeReadFailed` is false when no reader was injected: a caller that
|
|
203
|
+
* supplied none never asked for authority and is not degraded relative to what
|
|
204
|
+
* it requested.
|
|
205
|
+
*
|
|
175
206
|
* @param {{
|
|
176
207
|
* epic: { number?: number, id?: number, body?: string, nodeId?: string },
|
|
177
208
|
* readNativeChildIds?: (epic: object) => Promise<number[]>,
|
|
178
209
|
* onWarn?: (message: string) => void,
|
|
179
210
|
* }} opts
|
|
180
|
-
* @returns {Promise<number[]>}
|
|
211
|
+
* @returns {Promise<{ ids: number[], nativeReadFailed: boolean }>}
|
|
181
212
|
*/
|
|
182
213
|
export async function readEpicChildIdsFrom({
|
|
183
214
|
epic,
|
|
@@ -185,18 +216,22 @@ export async function readEpicChildIdsFrom({
|
|
|
185
216
|
onWarn,
|
|
186
217
|
} = {}) {
|
|
187
218
|
const fromBody = readEpicChildIds(epic?.body);
|
|
188
|
-
if (typeof readNativeChildIds !== 'function')
|
|
219
|
+
if (typeof readNativeChildIds !== 'function') {
|
|
220
|
+
return { ids: fromBody, nativeReadFailed: false };
|
|
221
|
+
}
|
|
189
222
|
|
|
190
|
-
let native = [];
|
|
191
223
|
try {
|
|
192
|
-
native = normalizeChildIds(await readNativeChildIds(epic));
|
|
224
|
+
const native = normalizeChildIds(await readNativeChildIds(epic));
|
|
225
|
+
return {
|
|
226
|
+
ids: normalizeChildIds([...native, ...fromBody]),
|
|
227
|
+
nativeReadFailed: false,
|
|
228
|
+
};
|
|
193
229
|
} catch (err) {
|
|
194
|
-
const id = epic?.number ?? epic?.id ?? '?';
|
|
195
230
|
onWarn?.(
|
|
196
|
-
`[epic-container] native sub-issue read failed for Epic
|
|
197
|
-
|
|
231
|
+
`[epic-container] native sub-issue read failed for Epic ` +
|
|
232
|
+
`#${epic?.number ?? epic?.id ?? '?'} (${err?.message ?? String(err)}); ` +
|
|
233
|
+
'using the body checklist alone — the child list may be incomplete.',
|
|
198
234
|
);
|
|
235
|
+
return { ids: fromBody, nativeReadFailed: true };
|
|
199
236
|
}
|
|
200
|
-
|
|
201
|
-
return normalizeChildIds([...native, ...fromBody]);
|
|
202
237
|
}
|