mandrel 2.47.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/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/epic-container.js +48 -21
- package/.agents/scripts/lib/orchestration/epic-expansion.js +28 -6
- package/.agents/scripts/lib/orchestration/epic-rollup.js +66 -7
- 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/docs/CHANGELOG.md +12 -0
- package/lib/cli/registry.js +63 -0
- package/package.json +1 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* merge-baseline.js — git merge driver for `baselines/*.json` (Story #5215).
|
|
5
|
+
*
|
|
6
|
+
* ## The failure it replaces
|
|
7
|
+
*
|
|
8
|
+
* Every baseline write stamps `generatedAt` on line 4, so two branches that
|
|
9
|
+
* each refresh a baseline ALWAYS differ there — even when they moved
|
|
10
|
+
* completely disjoint rows. Git merges JSON as text, so whether it can
|
|
11
|
+
* separate that hunk from the moved rows is an accident of proximity:
|
|
12
|
+
*
|
|
13
|
+
* - it cannot → a conflict on work that never overlapped (the observed
|
|
14
|
+
* `coverage.json` / `maintainability.json` "always conflicts" pattern);
|
|
15
|
+
* - it can → it splices both sides' row lines into a row set neither side
|
|
16
|
+
* scored, and the ratchet then guards a number no scorer produced (the
|
|
17
|
+
* observed `crap.json` "silently auto-merges" pattern).
|
|
18
|
+
*
|
|
19
|
+
* The quiet one is the worse one. A baseline is a set of rows keyed by
|
|
20
|
+
* identity plus a rollup derived from them, so this driver merges it as
|
|
21
|
+
* that — see `lib/baselines/merge-envelopes.js` for the semantics.
|
|
22
|
+
*
|
|
23
|
+
* ## Contract
|
|
24
|
+
*
|
|
25
|
+
* node .agents/scripts/merge-baseline.js %O %A %B %P
|
|
26
|
+
*
|
|
27
|
+
* git's merge-driver calling convention: `%O` ancestor, `%A` ours (and the
|
|
28
|
+
* file the driver MUST leave its result in), `%B` theirs, `%P` the real
|
|
29
|
+
* pathname being merged. Exit 0 merged clean, non-zero conflicted.
|
|
30
|
+
*
|
|
31
|
+
* Registered per clone (registration is per-clone, so `mandrel doctor` is
|
|
32
|
+
* the guard that it happened, not `mandrel sync`):
|
|
33
|
+
*
|
|
34
|
+
* .gitattributes: baselines/*.json merge=mandrel-baseline
|
|
35
|
+
* git config: merge.mandrel-baseline.driver
|
|
36
|
+
*
|
|
37
|
+
* ## Not every `baselines/*.json` is an envelope
|
|
38
|
+
*
|
|
39
|
+
* That glob also matches arch-cycles, cyclomatic, dead-exports, audit-ledger,
|
|
40
|
+
* context-budget and workflow-citations — files with their own shapes and no
|
|
41
|
+
* row identity. Anything whose `$schema` is not a known per-kind envelope is
|
|
42
|
+
* handed straight back to `git merge-file`, so registering the driver cannot
|
|
43
|
+
* change their behaviour.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import fs from 'node:fs';
|
|
47
|
+
import path from 'node:path';
|
|
48
|
+
|
|
49
|
+
import { assertEnvelope } from './lib/baselines/envelope.js';
|
|
50
|
+
import {
|
|
51
|
+
kindFromEnvelope,
|
|
52
|
+
mergeEnvelopes,
|
|
53
|
+
} from './lib/baselines/merge-envelopes.js';
|
|
54
|
+
import { writeFile as writeEnvelopeFile } from './lib/baselines/writer.js';
|
|
55
|
+
import { spawnChild } from './lib/child-exec.js';
|
|
56
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
57
|
+
|
|
58
|
+
/** Indent one row's canonical JSON to its position inside `rows`. */
|
|
59
|
+
function rowBlock(row) {
|
|
60
|
+
return JSON.stringify(row, null, 2)
|
|
61
|
+
.split('\n')
|
|
62
|
+
.map((line) => ` ${line}`)
|
|
63
|
+
.join('\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Wrap each conflicting row in git conflict markers, leaving every other row
|
|
68
|
+
* merged. Operates on the canonical text the writer already produced, so the
|
|
69
|
+
* non-conflicting remainder of the file is byte-identical to what a clean
|
|
70
|
+
* merge would have written.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} text Canonical serialization of the merged envelope.
|
|
73
|
+
* @param {Array<object>} conflicts Row-scoped conflict records.
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
export function renderConflictMarkers(text, conflicts) {
|
|
77
|
+
let out = text;
|
|
78
|
+
for (const conflict of conflicts) {
|
|
79
|
+
const placed = conflict.ours ?? conflict.theirs;
|
|
80
|
+
if (placed === undefined) continue;
|
|
81
|
+
const block = rowBlock(placed);
|
|
82
|
+
// The row may or may not be the last element of `rows`; keep whichever
|
|
83
|
+
// separator follows it on both sides so the markers wrap whole lines.
|
|
84
|
+
const withComma = `${block},`;
|
|
85
|
+
const [needle, suffix] = out.includes(withComma)
|
|
86
|
+
? [withComma, ',']
|
|
87
|
+
: [block, ''];
|
|
88
|
+
if (!out.includes(needle)) continue;
|
|
89
|
+
const ourSide =
|
|
90
|
+
conflict.ours === undefined
|
|
91
|
+
? ''
|
|
92
|
+
: `${rowBlock(conflict.ours)}${suffix}\n`;
|
|
93
|
+
const theirSide =
|
|
94
|
+
conflict.theirs === undefined
|
|
95
|
+
? ''
|
|
96
|
+
: `${rowBlock(conflict.theirs)}${suffix}\n`;
|
|
97
|
+
out = out.replace(
|
|
98
|
+
needle,
|
|
99
|
+
`<<<<<<< ours\n${ourSide}=======\n${theirSide}>>>>>>> theirs`.replace(
|
|
100
|
+
/\n$/,
|
|
101
|
+
'',
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Read and parse a merge input; a missing or empty side is `null`. */
|
|
109
|
+
function readSide(file) {
|
|
110
|
+
if (!file || !fs.existsSync(file)) return null;
|
|
111
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
112
|
+
if (raw.trim() === '') return null;
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(raw);
|
|
115
|
+
} catch {
|
|
116
|
+
return undefined; // present but unparseable — caller falls back to git
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Hand the merge back to git's own text merge. Used for every
|
|
122
|
+
* `baselines/*.json` that is not a known per-kind envelope, and for one that
|
|
123
|
+
* is too damaged to parse — in both cases the driver must not invent a
|
|
124
|
+
* result, and git's behaviour is exactly what the repo had before.
|
|
125
|
+
*
|
|
126
|
+
* @returns {number} git merge-file's own exit code.
|
|
127
|
+
*/
|
|
128
|
+
function delegateToGit(basePath, oursPath, theirsPath) {
|
|
129
|
+
// `stdio: 'inherit'` so git's own conflict reporting reaches the operator
|
|
130
|
+
// exactly as it would have with no driver registered. `spawnChild` returns
|
|
131
|
+
// the RAW result deliberately: a `status` of null means the child was
|
|
132
|
+
// killed, and that must never be read as a clean merge.
|
|
133
|
+
const result = spawnChild(
|
|
134
|
+
'git',
|
|
135
|
+
['merge-file', oursPath, basePath, theirsPath],
|
|
136
|
+
{ stdio: 'inherit' },
|
|
137
|
+
);
|
|
138
|
+
if (result.error) {
|
|
139
|
+
process.stderr.write(
|
|
140
|
+
`merge-baseline: could not run git merge-file: ${result.error.message}\n`,
|
|
141
|
+
);
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
return result.status ?? 1;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {string[]} argv Positional arguments: %O %A %B [%P].
|
|
149
|
+
* @returns {number} Process exit code.
|
|
150
|
+
*/
|
|
151
|
+
export function runMergeBaseline(argv) {
|
|
152
|
+
const [baseArg, oursArg, theirsArg, mergedPath] = argv;
|
|
153
|
+
if (!baseArg || !oursArg || !theirsArg) {
|
|
154
|
+
process.stderr.write(
|
|
155
|
+
'merge-baseline: expected the git merge-driver arguments %O %A %B %P\n',
|
|
156
|
+
);
|
|
157
|
+
return 2;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Git hands the driver temp filenames RELATIVE to the worktree root it
|
|
161
|
+
// invokes us from (`.merge_file_xxxxxx`), so every path is resolved before
|
|
162
|
+
// use — the shared writer refuses a relative path, and that refusal only
|
|
163
|
+
// shows up under a real `git merge`, never when the driver is called
|
|
164
|
+
// directly with absolute paths.
|
|
165
|
+
const [basePath, oursPath, theirsPath] = [baseArg, oursArg, theirsArg].map(
|
|
166
|
+
(p) => path.resolve(p),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const ours = readSide(oursPath);
|
|
170
|
+
const theirs = readSide(theirsPath);
|
|
171
|
+
const base = readSide(basePath);
|
|
172
|
+
|
|
173
|
+
const kind = kindFromEnvelope(ours) ?? kindFromEnvelope(theirs);
|
|
174
|
+
if (!kind || ours === undefined || theirs === undefined) {
|
|
175
|
+
return delegateToGit(basePath, oursPath, theirsPath);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let merged;
|
|
179
|
+
try {
|
|
180
|
+
merged = mergeEnvelopes({ base, ours, theirs, kind });
|
|
181
|
+
} catch (err) {
|
|
182
|
+
process.stderr.write(`merge-baseline: ${kind}: ${err.message}\n`);
|
|
183
|
+
return delegateToGit(basePath, oursPath, theirsPath);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const rowConflicts = merged.conflicts.filter((c) => c.scope === 'row');
|
|
187
|
+
const envelopeConflicts = merged.conflicts.filter(
|
|
188
|
+
(c) => c.scope === 'envelope',
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// Write the canonical projection first even when conflicted: the marker
|
|
192
|
+
// rendering operates on exactly the bytes a clean merge would have left,
|
|
193
|
+
// so the merged remainder of a conflicted file is identical to it.
|
|
194
|
+
writeEnvelopeFile(oursPath, merged.envelope);
|
|
195
|
+
|
|
196
|
+
if (merged.conflicts.length === 0) {
|
|
197
|
+
assertEnvelope(merged.envelope);
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const label = mergedPath || oursPath;
|
|
202
|
+
for (const conflict of envelopeConflicts) {
|
|
203
|
+
process.stderr.write(
|
|
204
|
+
`merge-baseline: conflict ${kind} envelope key "${conflict.identity}" in ${label} — ours ${JSON.stringify(conflict.ours)}, theirs ${JSON.stringify(conflict.theirs)}\n`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
for (const conflict of rowConflicts) {
|
|
208
|
+
process.stderr.write(
|
|
209
|
+
`merge-baseline: conflict ${kind} row "${conflict.identity}" in ${label}\n`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (rowConflicts.length > 0) {
|
|
214
|
+
const text = fs.readFileSync(oursPath, 'utf8');
|
|
215
|
+
fs.writeFileSync(oursPath, renderConflictMarkers(text, rowConflicts));
|
|
216
|
+
}
|
|
217
|
+
return 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function main() {
|
|
221
|
+
return runMergeBaseline(process.argv.slice(2));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
runAsCli(import.meta.url, main, {
|
|
225
|
+
source: 'merge-baseline',
|
|
226
|
+
propagateExitCode: true,
|
|
227
|
+
usage: {
|
|
228
|
+
invocation: 'node .agents/scripts/merge-baseline.js %O %A %B %P',
|
|
229
|
+
summary:
|
|
230
|
+
'Git merge driver for baselines/*.json. Merges per-kind envelopes by ROW IDENTITY — disjoint refreshes merge clean, the rollup is recomputed from the merged rows, and generatedAt resolves to the later stamp instead of conflicting. A baselines file that is not a known per-kind envelope is handed back to git merge-file unchanged. Exit 0 clean, 1 conflicted.',
|
|
231
|
+
flags: [
|
|
232
|
+
['%O', 'Merge ancestor (git supplies this).'],
|
|
233
|
+
['%A', 'Our version — the driver writes its result here.'],
|
|
234
|
+
['%B', 'Their version.'],
|
|
235
|
+
['%P', 'Real pathname being merged; used in conflict messages.'],
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
});
|
|
@@ -87,17 +87,67 @@ function matchesAny(haystack, needles) {
|
|
|
87
87
|
return false;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** `gh` renders the HTTP status onto stderr as `HTTP 403: <reason>`. */
|
|
91
|
+
const GH_STDERR_STATUS_RE = /\bHTTP (\d{3})\b/i;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The captured `gh` stderr, or `''` when the error carries none.
|
|
95
|
+
*
|
|
96
|
+
* Its own function so {@link extractErrorFields} keeps the cyclomatic weight it
|
|
97
|
+
* had before stderr became a classification input — the field is read twice
|
|
98
|
+
* there, and inlining the guard twice is what pushed the CRAP ratchet.
|
|
99
|
+
*
|
|
100
|
+
* @param {unknown} err
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
function stderrText(err) {
|
|
104
|
+
return typeof err?.stderr === 'string' ? err.stderr : '';
|
|
105
|
+
}
|
|
106
|
+
|
|
90
107
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
108
|
+
* Recover the HTTP status from a `gh`-CLI failure's stderr.
|
|
109
|
+
*
|
|
110
|
+
* The `fetch` transport sets `err.status`; the `gh` transport does not — it
|
|
111
|
+
* has only an exit code, and puts the status in the text it printed. Without
|
|
112
|
+
* this, every `gh`-path failure reached the status rules as `undefined` and a
|
|
113
|
+
* 403 or a 429 was indistinguishable from an unclassifiable error (Story
|
|
114
|
+
* #5210).
|
|
115
|
+
*
|
|
116
|
+
* @param {unknown} stderr
|
|
117
|
+
* @returns {number|undefined}
|
|
118
|
+
*/
|
|
119
|
+
function statusFromStderr(stderr) {
|
|
120
|
+
if (typeof stderr !== 'string') return undefined;
|
|
121
|
+
const m = GH_STDERR_STATUS_RE.exec(stderr);
|
|
122
|
+
return m ? Number.parseInt(m[1], 10) : undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Extract `{ lower, detail, status, code }` from an error in the shape
|
|
127
|
+
* `gh-exec` throws. Pure — exported style for unit-testability without
|
|
128
|
+
* instantiating the provider. Defensive on shape: errors arrive as `Error`
|
|
129
|
+
* objects, plain `{message,status,code}` bags, or non-Errors stringified into
|
|
130
|
+
* `String(err)`.
|
|
131
|
+
*
|
|
132
|
+
* `lower` is the message alone. `detail` is the message **plus** any captured
|
|
133
|
+
* `stderr`, and is what the keyword rules read: on the `gh` path the message
|
|
134
|
+
* is the classified summary (`gh-exec: gh exited with code 1`) and every
|
|
135
|
+
* actionable word — the status line, `secondary rate limit`, the missing
|
|
136
|
+
* GraphQL field — lives only on stderr. Matching the keyword lists against the
|
|
137
|
+
* message alone is what flattened a retryable 403 to `permanent` and let the
|
|
138
|
+
* Epic rollup treat a rate-limit burst as a settled answer (Story #5210).
|
|
95
139
|
*/
|
|
96
140
|
export function extractErrorFields(err) {
|
|
97
141
|
const message = typeof err.message === 'string' ? err.message : String(err);
|
|
142
|
+
const stderr = stderrText(err);
|
|
143
|
+
const lower = message.toLowerCase();
|
|
98
144
|
return {
|
|
99
|
-
lower
|
|
100
|
-
|
|
145
|
+
lower,
|
|
146
|
+
// Unconditional concatenation: with no stderr this is the message plus a
|
|
147
|
+
// trailing space, which every `includes` rule below reads identically.
|
|
148
|
+
detail: `${lower} ${stderr.toLowerCase()}`,
|
|
149
|
+
status:
|
|
150
|
+
typeof err.status === 'number' ? err.status : statusFromStderr(stderr),
|
|
101
151
|
code: typeof err.code === 'string' ? err.code : undefined,
|
|
102
152
|
};
|
|
103
153
|
}
|
|
@@ -127,17 +177,23 @@ export function classifyGithubError(err) {
|
|
|
127
177
|
// no `.status` / `.code`. Match by `err.name` to avoid a circular import
|
|
128
178
|
// between this module and `lib/gh-exec.js`. Story #2860.
|
|
129
179
|
if (err.name === 'GhExecTimeoutError') return 'transient';
|
|
130
|
-
|
|
131
|
-
|
|
180
|
+
// Every keyword rule below reads `detail` (message + stderr), never `lower`
|
|
181
|
+
// alone: the `gh` transport carries its reason exclusively on stderr, so a
|
|
182
|
+
// message-only match sees nothing but the exit code. Rule ORDER is
|
|
183
|
+
// load-bearing and unchanged — a secondary rate limit is delivered as HTTP
|
|
184
|
+
// 403, so the transient rules must stay ahead of the permission rule or it
|
|
185
|
+
// would bucket as 'permission' and never retry.
|
|
186
|
+
const { detail, status, code } = extractErrorFields(err);
|
|
187
|
+
if (matchesAny(detail, FEATURE_DISABLED_MESSAGES)) return 'feature-disabled';
|
|
132
188
|
if (isTransientStatus(status)) return 'transient';
|
|
133
|
-
if (isTransientByCodeOrMessage(code,
|
|
189
|
+
if (isTransientByCodeOrMessage(code, detail)) return 'transient';
|
|
134
190
|
// Union with the former `transient-retry.js` predicate (Story #4298):
|
|
135
191
|
// retry on network/connectivity blips the status/code checks above miss
|
|
136
192
|
// (e.g. a `dial tcp ... i/o timeout` on `err.stderr` from the gh-CLI path,
|
|
137
193
|
// or `ECONNREFUSED` / `ENETUNREACH`). Checked before the permission rule so
|
|
138
194
|
// a transient network failure never masquerades as a permanent denial.
|
|
139
195
|
if (isTransientNetworkError(err)) return 'transient';
|
|
140
|
-
if (isPermissionSignal(status,
|
|
196
|
+
if (isPermissionSignal(status, detail)) return 'permission';
|
|
141
197
|
return 'permanent';
|
|
142
198
|
}
|
|
143
199
|
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* @see Story #2462 — Split GitHubProvider god class into seven composed gateways.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
import { describeGhFailure } from '../../lib/gh-exec.js';
|
|
26
27
|
import { Logger } from '../../lib/Logger.js';
|
|
27
28
|
import {
|
|
28
29
|
classifyGithubError as defaultClassifyGithubError,
|
|
@@ -104,8 +105,14 @@ export class SubIssueGateway {
|
|
|
104
105
|
);
|
|
105
106
|
return [];
|
|
106
107
|
}
|
|
108
|
+
// `describeGhFailure`, not `err.message`: on the gh transport the
|
|
109
|
+
// message is only the classified summary (`gh exited with code 1`) and
|
|
110
|
+
// the actionable sentence — the HTTP status, the rate-limit notice — is
|
|
111
|
+
// on stderr. Three identical opaque lines are what made the Epic-rollup
|
|
112
|
+
// incident unreadable until the API was queried by hand (Story #5210).
|
|
107
113
|
Logger.error(
|
|
108
|
-
`[GitHubProvider] sub-issues GraphQL failed (parent #${parentId},
|
|
114
|
+
`[GitHubProvider] sub-issues GraphQL failed (parent #${parentId}, ` +
|
|
115
|
+
`category=${category}): ${describeGhFailure(err)}`,
|
|
109
116
|
);
|
|
110
117
|
throw err;
|
|
111
118
|
}
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,18 @@ All notable changes to this project will be documented in this file.
|
|
|
15
15
|
-->
|
|
16
16
|
<!-- markdownlint-disable-file MD004 MD012 MD037 -->
|
|
17
17
|
|
|
18
|
+
## [2.48.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.47.0...mandrel-v2.48.0) (2026-09-08)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
* baselines: merge concurrent refreshes by row identity with a git merge driver ([#5215](https://github.com/dsj1984/mandrel/issues/5215)) ([#5216](https://github.com/dsj1984/mandrel/issues/5216)) ([bcc7057](https://github.com/dsj1984/mandrel/commit/bcc7057d8aad647ec4dae0dc859dd35865fe40a5))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
* never close a container Epic on a degraded child read, and stop flattening gh transport failures to permanent ([#5210](https://github.com/dsj1984/mandrel/issues/5210)) ([#5212](https://github.com/dsj1984/mandrel/issues/5212)) ([08520d7](https://github.com/dsj1984/mandrel/commit/08520d7b5ca0962f98042ca4b103cf0c295516cf))
|
|
29
|
+
|
|
18
30
|
## [2.47.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.46.0...mandrel-v2.47.0) (2026-09-07)
|
|
19
31
|
|
|
20
32
|
|
package/lib/cli/registry.js
CHANGED
|
@@ -25,6 +25,11 @@ import fs from 'node:fs';
|
|
|
25
25
|
import { createRequire } from 'node:module';
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import {
|
|
29
|
+
BASELINE_MERGE_DRIVER_CONFIG_KEY,
|
|
30
|
+
BASELINE_MERGE_DRIVER_REMEDY,
|
|
31
|
+
declaresBaselineMergeDriver,
|
|
32
|
+
} from '../../.agents/scripts/lib/bootstrap/baseline-merge-driver.js';
|
|
28
33
|
import {
|
|
29
34
|
REQUIRED_NODE_CEILING_MAJOR,
|
|
30
35
|
REQUIRED_NODE_FLOOR,
|
|
@@ -1074,6 +1079,60 @@ function runVersionCurrent({ cachePath, installedVersion, fsImpl = fs } = {}) {
|
|
|
1074
1079
|
// Registry
|
|
1075
1080
|
// ---------------------------------------------------------------------------
|
|
1076
1081
|
|
|
1082
|
+
/**
|
|
1083
|
+
* Is this clone's `baselines/*.json` merge driver actually registered?
|
|
1084
|
+
*
|
|
1085
|
+
* The two halves of that registration live in different places on purpose.
|
|
1086
|
+
* `.gitattributes` is tracked, so the "use the driver" half ships with the
|
|
1087
|
+
* repo; the driver COMMAND is per-clone `git config`, because git will not
|
|
1088
|
+
* execute a command chosen by whoever wrote the repository. A fresh clone
|
|
1089
|
+
* therefore has the first half and not the second — and git reports nothing
|
|
1090
|
+
* at all, it just quietly falls back to text-merging baselines, which is the
|
|
1091
|
+
* behaviour the driver exists to replace.
|
|
1092
|
+
*
|
|
1093
|
+
* Silent degradation is why this is a doctor check rather than a one-time
|
|
1094
|
+
* install step. It is scoped to repos that opted in: when `.gitattributes`
|
|
1095
|
+
* does not declare the driver, the check passes as skipped, so a consumer
|
|
1096
|
+
* who never installed the quality surface is not told to fix something they
|
|
1097
|
+
* did not ask for.
|
|
1098
|
+
*
|
|
1099
|
+
* @param {{cwd?: () => string, fsImpl?: typeof fs, runner?: typeof spawn}} [opts]
|
|
1100
|
+
* @returns {{ ok: boolean, detail: string, remedy?: string }}
|
|
1101
|
+
*/
|
|
1102
|
+
export function runMergeDriver({ cwd, fsImpl = fs, runner = spawn } = {}) {
|
|
1103
|
+
const projectRoot = (cwd ?? (() => process.cwd()))();
|
|
1104
|
+
const attributesPath = path.join(projectRoot, '.gitattributes');
|
|
1105
|
+
|
|
1106
|
+
let attributes = '';
|
|
1107
|
+
try {
|
|
1108
|
+
attributes = fsImpl.readFileSync(attributesPath, 'utf8');
|
|
1109
|
+
} catch {
|
|
1110
|
+
attributes = '';
|
|
1111
|
+
}
|
|
1112
|
+
if (!declaresBaselineMergeDriver(attributes)) {
|
|
1113
|
+
return {
|
|
1114
|
+
ok: true,
|
|
1115
|
+
detail:
|
|
1116
|
+
'skipped — .gitattributes does not route baselines/*.json through the mandrel merge driver',
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const configured = runner('git', [
|
|
1121
|
+
'config',
|
|
1122
|
+
'--get',
|
|
1123
|
+
BASELINE_MERGE_DRIVER_CONFIG_KEY,
|
|
1124
|
+
]);
|
|
1125
|
+
if (configured.status === 0 && configured.stdout.trim() !== '') {
|
|
1126
|
+
return { ok: true, detail: configured.stdout.trim() };
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
return {
|
|
1130
|
+
ok: false,
|
|
1131
|
+
detail: `${BASELINE_MERGE_DRIVER_CONFIG_KEY} is unset — baselines/*.json will fall back to git's text merge, which conflicts on the generatedAt stamp and can splice rows neither branch scored`,
|
|
1132
|
+
remedy: BASELINE_MERGE_DRIVER_REMEDY,
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1077
1136
|
/**
|
|
1078
1137
|
* Ordered array of doctor checks. Each entry follows the
|
|
1079
1138
|
* `{ name: string, run(opts?): { ok: boolean, detail: string, remedy?: string } }` contract.
|
|
@@ -1123,6 +1182,10 @@ export const registry = [
|
|
|
1123
1182
|
name: 'agents-drift',
|
|
1124
1183
|
run: (opts) => runAgentsDrift(opts),
|
|
1125
1184
|
},
|
|
1185
|
+
{
|
|
1186
|
+
name: 'merge-driver',
|
|
1187
|
+
run: (opts) => runMergeDriver(opts),
|
|
1188
|
+
},
|
|
1126
1189
|
{
|
|
1127
1190
|
name: 'pin-current',
|
|
1128
1191
|
// Fatal, unlike version-current below (Story #4525/#4530): a pin/install
|
package/package.json
CHANGED