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.
@@ -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
- * Extract `{ lower, status, code }` from an error in the shape `gh-exec`
92
- * throws. Pure — exported style for unit-testability without instantiating
93
- * the provider. Defensive on shape: errors arrive as `Error` objects, plain
94
- * `{message,status,code}` bags, or non-Errors stringified into `String(err)`.
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: message.toLowerCase(),
100
- status: typeof err.status === 'number' ? err.status : undefined,
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
- const { lower, status, code } = extractErrorFields(err);
131
- if (matchesAny(lower, FEATURE_DISABLED_MESSAGES)) return 'feature-disabled';
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, lower)) return 'transient';
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, lower)) return 'permission';
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}, category=${category}): ${err.message}`,
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
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.47.0",
3
+ "version": "2.48.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",