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.
Files changed (32) hide show
  1. package/.agents/docs/configuration.md +1 -0
  2. package/.agents/docs/quality-gates.md +48 -0
  3. package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
  4. package/.agents/scripts/lib/baselines/kernel.js +19 -0
  5. package/.agents/scripts/lib/baselines/kinds/bundle-size.js +12 -0
  6. package/.agents/scripts/lib/baselines/kinds/coverage.js +1 -0
  7. package/.agents/scripts/lib/baselines/kinds/crap.js +21 -5
  8. package/.agents/scripts/lib/baselines/kinds/duplication.js +1 -0
  9. package/.agents/scripts/lib/baselines/kinds/kind-factory.js +26 -1
  10. package/.agents/scripts/lib/baselines/kinds/lighthouse.js +1 -0
  11. package/.agents/scripts/lib/baselines/kinds/lint.js +12 -0
  12. package/.agents/scripts/lib/baselines/kinds/maintainability.js +1 -0
  13. package/.agents/scripts/lib/baselines/kinds/mutation.js +1 -0
  14. package/.agents/scripts/lib/baselines/merge-envelopes.js +272 -0
  15. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +175 -0
  16. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +8 -2
  17. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  18. package/.agents/scripts/lib/orchestration/column-sync.js +26 -2
  19. package/.agents/scripts/lib/orchestration/epic-container.js +56 -21
  20. package/.agents/scripts/lib/orchestration/epic-expansion.js +28 -6
  21. package/.agents/scripts/lib/orchestration/epic-rollup.js +460 -0
  22. package/.agents/scripts/lib/orchestration/run-epilogue.js +44 -104
  23. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +60 -1
  24. package/.agents/scripts/merge-baseline.js +238 -0
  25. package/.agents/scripts/providers/github/errors.js +66 -10
  26. package/.agents/scripts/providers/github/sub-issues.js +8 -1
  27. package/.agents/scripts/single-story-init.js +35 -0
  28. package/.agents/workflows/helpers/deliver-reference.md +31 -9
  29. package/.agents/workflows/mandrel-deliver.md +3 -3
  30. package/docs/CHANGELOG.md +19 -0
  31. package/lib/cli/registry.js +63 -0
  32. package/package.json +1 -1
@@ -7,8 +7,8 @@
7
7
  * 2. Rolls up friction follow-ups across every Story in the run and
8
8
  * files/posts them on the primary Story.
9
9
  * 3. Checks sibling Spec/acceptance coherence across Story bodies.
10
- * 4. Closes any container Epic whose children all landed (Story #5139) —
11
- * the only completion cascade v2 reintroduces.
10
+ * 4. Reports the container Epics whose children all landed (Story #5139),
11
+ * delegating the derivation and the close to `epic-rollup.js`.
12
12
  *
13
13
  * There is no inert planner-only path: `planRunEpilogue` enumerates steps
14
14
  * and `runPlanRunEpilogue` executes them. Single-Story runs skip the
@@ -21,8 +21,7 @@ import { selectAudits } from '../audit-suite/index.js';
21
21
  import { graduateRetroProposals } from '../feedback-loop/retro-proposals-graduator.js';
22
22
  import { gitSpawn } from '../git-utils.js';
23
23
  import { Logger } from '../Logger.js';
24
- import { AGENT_LABELS, TYPE_LABELS } from '../label-constants.js';
25
- import { isEpicTicket, readEpicChildIds } from './epic-container.js';
24
+ import { rollUpEpicForStory } from './epic-rollup.js';
26
25
  import { composeRoutedProposals } from './retro-proposals.js';
27
26
  import {
28
27
  assessRollupOutcome,
@@ -45,118 +44,59 @@ export const RUN_EPILOGUE_STEP_KINDS = Object.freeze([
45
44
  ]);
46
45
 
47
46
  /**
48
- * Close a container Epic once every child Story has landed.
47
+ * Close every container Epic whose children all landed in this run.
49
48
  *
50
- * This is the **only** completion cascade v2 reintroduces (Story #5139), and
51
- * it is deliberately one-directional: closing the container, never touching a
52
- * child's state, never reopening.
49
+ * Delegates to `epic-rollup.js` (Story #5205) rather than deriving anything
50
+ * itself. That module owns the child→parent scan, the body-checklist-union-
51
+ * native-sub-issue child read, and the one-way closure rule, and it is also
52
+ * invoked from the per-Story land tail — which is what closes a container
53
+ * whose last open child was a single Story, a case this epilogue never
54
+ * reaches because a one-Story run reports `applicable: false`.
53
55
  *
54
- * The lookup runs child→parent by scanning open Epics, because linkage is
55
- * parent→child only — a Story body carries no pointer back. That is the
56
- * price of leaving Story bodies untouched, and it is cheap: open Epics are
57
- * few, and the scan is scoped to Epics that actually contain one of this
58
- * run's delivered Stories, so an unrelated Epic is never swept.
56
+ * The step survives for its report: this is the surface an operator reads to
57
+ * see which containers a multi-Story run closed and which are still pending.
59
58
  *
60
59
  * Non-fatal throughout: the epilogue is a reporting tail, and a container
61
60
  * left open costs tidiness, not correctness.
62
61
  *
63
- * @param {{ stories: string[], provider: object }} opts
62
+ * @param {{ stories: string[], provider: object, config?: object }} opts
64
63
  * @returns {Promise<{ kind: string, closed: number[], pending: number[] }>}
65
64
  */
66
- async function executeEpicClose({ stories, provider }) {
67
- const result = { kind: 'epic-close', closed: [], pending: [] };
68
- if (
69
- typeof provider?.listIssuesByLabel !== 'function' ||
70
- typeof provider?.updateTicket !== 'function'
71
- ) {
72
- return result;
73
- }
65
+ async function executeEpicClose({ stories, provider, config }) {
66
+ const closed = new Set();
67
+ const pending = new Set();
68
+ // Siblings share a container, so an Epic resolved by one Story's rollup is
69
+ // withheld from the next one's — otherwise the second Story would re-derive
70
+ // and re-close what the first already closed.
71
+ const seen = new Set();
74
72
 
75
- const delivered = new Set(stories.map((id) => Number(id)));
76
- let epics;
77
- try {
78
- epics = await provider.listIssuesByLabel({
79
- state: 'open',
80
- labels: TYPE_LABELS.EPIC,
73
+ // `rollUpEpicForStory` never throws and always returns the full envelope,
74
+ // so its three lists are read directly — a `?? []` guard here would be an
75
+ // unreachable branch asserting a contract the module already keeps.
76
+ for (const raw of stories) {
77
+ const storyId = Number(raw);
78
+ if (!Number.isInteger(storyId) || storyId <= 0) continue;
79
+ const outcome = await rollUpEpicForStory({
80
+ storyId,
81
+ provider,
82
+ config,
83
+ skipEpicIds: seen,
81
84
  });
82
- } catch (err) {
83
- Logger.warn(
84
- `[run-epilogue] Could not list open Epics (${err?.message ?? err}); skipping the Epic close.`,
85
- );
86
- return result;
87
- }
88
-
89
- for (const epic of Array.isArray(epics) ? epics : []) {
90
- if (!isEpicTicket(epic)) continue;
91
- const epicId = Number(epic?.number ?? epic?.id);
92
- if (!Number.isInteger(epicId)) continue;
93
-
94
- const childIds = readEpicChildIds(epic?.body);
95
- if (childIds.length === 0) continue;
96
- // Only Epics this run actually advanced. Sweeping every open Epic would
97
- // make a delivery close containers it had nothing to do with.
98
- if (!childIds.some((c) => delivered.has(c))) continue;
99
-
100
- let allLanded = true;
101
- for (const childId of childIds) {
102
- try {
103
- const child = await provider.getTicket(childId);
104
- if (!isSatisfiedChild(child)) {
105
- allLanded = false;
106
- break;
107
- }
108
- } catch (err) {
109
- Logger.warn(
110
- `[run-epilogue] Epic #${epicId}: could not read child #${childId} ` +
111
- `(${err?.message ?? err}) — leaving the Epic open.`,
112
- );
113
- allLanded = false;
114
- break;
115
- }
116
- }
117
-
118
- if (!allLanded) {
119
- result.pending.push(epicId);
120
- continue;
121
- }
122
-
123
- try {
124
- await provider.updateTicket(epicId, {
125
- state: 'closed',
126
- state_reason: 'completed',
127
- });
128
- Logger.info(
129
- `[run-epilogue] Closed container Epic #${epicId} — all ${childIds.length} child Story(ies) landed.`,
130
- );
131
- result.closed.push(epicId);
132
- } catch (err) {
133
- Logger.warn(
134
- `[run-epilogue] Could not close Epic #${epicId} (${err?.message ?? err}).`,
135
- );
136
- result.pending.push(epicId);
137
- }
85
+ for (const epic of outcome.epics) seen.add(epic.epicId);
86
+ for (const epicId of outcome.closed) closed.add(epicId);
87
+ for (const epicId of outcome.pending) pending.add(epicId);
138
88
  }
139
89
 
140
- return result;
141
- }
90
+ // An Epic this run closed can also have been reported pending by an
91
+ // earlier Story's rollup, when a sibling had not landed yet. The close is
92
+ // the later, truer answer.
93
+ for (const epicId of closed) pending.delete(epicId);
142
94
 
143
- /**
144
- * A child no longer holds its Epic open once it is closed or `agent::done`.
145
- *
146
- * Mirrors `isSatisfiedBlocker` in `lib/orchestration/resolve-stories.js`
147
- * rather than importing it: that module is the delivery-resolution path and
148
- * pulling it in here would drag the whole story-body parser into the
149
- * epilogue for a two-line predicate.
150
- *
151
- * @param {{ state?: string, labels?: unknown }} issue
152
- * @returns {boolean}
153
- */
154
- function isSatisfiedChild(issue) {
155
- if (String(issue?.state ?? '').toLowerCase() === 'closed') return true;
156
- const labels = Array.isArray(issue?.labels)
157
- ? issue.labels.map((l) => (typeof l === 'string' ? l : l?.name))
158
- : [];
159
- return labels.includes(AGENT_LABELS.DONE);
95
+ return {
96
+ kind: 'epic-close',
97
+ closed: [...closed],
98
+ pending: [...pending],
99
+ };
160
100
  }
161
101
 
162
102
  /**
@@ -952,7 +892,7 @@ export async function runPlanRunEpilogue({
952
892
  );
953
893
  } else if (step.kind === 'epic-close') {
954
894
  results.push(
955
- await executeEpicClose({ stories: plan.stories, provider }),
895
+ await executeEpicClose({ stories: plan.stories, provider, config }),
956
896
  );
957
897
  }
958
898
  } catch (err) {
@@ -40,6 +40,7 @@ import {
40
40
  } from '../../../observability/runtime-friction.js';
41
41
  import { acquireLockWithWait as defaultAcquireLockWithWait } from '../../../single-story-sweep/sweep-lock.js';
42
42
  import { purgeStoryTempArtifacts as defaultPurgeStoryTempArtifacts } from '../../../temp-retention.js';
43
+ import { rollUpEpicForStory as defaultRollUpEpicForStory } from '../../epic-rollup.js';
43
44
  import {
44
45
  executeFastForward as defaultExecuteFastForward,
45
46
  planFastForward as defaultPlanFastForward,
@@ -298,6 +299,45 @@ async function stepLeaseRelease({
298
299
  */
299
300
  const REAP_SWEEP_REMEDY = 'node .agents/scripts/prune-plan-run-labels.js';
300
301
 
302
+ /**
303
+ * Roll the closing Story's container Epic up from its children (Story #5205).
304
+ *
305
+ * Wired here for the same reason the cohort-label reap is: this is the only
306
+ * seam both a single- and a multi-Story run reach. The run epilogue that used
307
+ * to own the Epic close runs at N>1 only, so a container whose last open
308
+ * child was one Story stayed open forever.
309
+ *
310
+ * Unlike the reap, the outcome IS reported in the returned `tail`. The
311
+ * distinction is what an operator can act on: a stale cohort label is read by
312
+ * nothing, whereas an Epic left open or showing the wrong column is a visible
313
+ * board state someone will otherwise correct by hand, so a false here earns
314
+ * its line in the envelope.
315
+ *
316
+ * @returns {Promise<{ ok: boolean, detail: string|null }>}
317
+ */
318
+ async function stepEpicRollup({
319
+ storyId,
320
+ provider,
321
+ config,
322
+ progress,
323
+ rollUpEpicForStoryFn,
324
+ }) {
325
+ const outcome = await rollUpEpicForStoryFn({ storyId, provider, config });
326
+ for (const epicId of outcome?.closed ?? []) {
327
+ progress?.(
328
+ 'POST-LAND',
329
+ `🗃️ Closed container Epic #${epicId} — every child Story landed.`,
330
+ );
331
+ }
332
+ const failures = (outcome?.epics ?? []).filter((e) => e?.detail);
333
+ return {
334
+ ok: failures.length === 0,
335
+ detail: failures.length
336
+ ? failures.map((e) => `#${e.epicId}: ${e.detail}`).join('; ')
337
+ : null,
338
+ };
339
+ }
340
+
301
341
  /**
302
342
  * Reap the cohort labels the closing Story carried (Story #5189).
303
343
  *
@@ -396,7 +436,8 @@ async function stepPlanRunLabelReap({
396
436
  * @param {Function} [args.purgeStoryTempArtifactsFn] Test seam.
397
437
  * @param {Function} [args.releaseStoryLeaseFn] Test seam.
398
438
  * @param {Function} [args.reapPlanRunLabelsForStoryFn] Test seam.
399
- * @returns {Promise<{ followUps: boolean, statusResync: boolean, refCleanup: boolean, baseFastForward: boolean, tempPurge: boolean, leaseRelease: boolean, details: Record<string, string|null> }>}
439
+ * @param {Function} [args.rollUpEpicForStoryFn] Test seam.
440
+ * @returns {Promise<{ followUps: boolean, statusResync: boolean, refCleanup: boolean, baseFastForward: boolean, tempPurge: boolean, leaseRelease: boolean, epicRollup: boolean, details: Record<string, string|null> }>}
400
441
  */
401
442
  export async function runPostLandTail({
402
443
  storyId,
@@ -417,6 +458,7 @@ export async function runPostLandTail({
417
458
  purgeStoryTempArtifactsFn = defaultPurgeStoryTempArtifacts,
418
459
  releaseStoryLeaseFn = defaultReleaseStoryLease,
419
460
  reapPlanRunLabelsForStoryFn = defaultReapPlanRunLabelsForStory,
461
+ rollUpEpicForStoryFn = defaultRollUpEpicForStory,
420
462
  }) {
421
463
  progress?.('POST-LAND', `🧾 Running land tail for Story #${storyId}...`);
422
464
 
@@ -483,6 +525,21 @@ export async function runPostLandTail({
483
525
  { name: 'plan-run label reap', progress },
484
526
  );
485
527
 
528
+ // Story #5205 — the container Epic's state is derived from its children, so
529
+ // the child reaching `agent::done` is the edge that can close it. Runs with
530
+ // the other GitHub-touching steps, outside the checkout lock.
531
+ const epicRollup = await step(
532
+ () =>
533
+ stepEpicRollup({
534
+ storyId,
535
+ provider,
536
+ config,
537
+ progress,
538
+ rollUpEpicForStoryFn,
539
+ }),
540
+ { name: 'epic rollup', progress },
541
+ );
542
+
486
543
  // Local-checkout mutations: serialized behind a best-effort cross-process
487
544
  // lock (Story #4622). Acquire once, run both steps, release in `finally`.
488
545
  const lockCfg = config?.delivery?.postLandLock ?? {};
@@ -555,6 +612,7 @@ export async function runPostLandTail({
555
612
  baseFastForward: baseFastForward.ok,
556
613
  tempPurge: tempPurge.ok,
557
614
  leaseRelease: leaseRelease.ok,
615
+ epicRollup: epicRollup.ok,
558
616
  details: {
559
617
  followUps: followUps.detail,
560
618
  statusResync: statusResync.detail,
@@ -562,6 +620,7 @@ export async function runPostLandTail({
562
620
  baseFastForward: baseFastForward.detail,
563
621
  tempPurge: tempPurge.detail,
564
622
  leaseRelease: leaseRelease.detail,
623
+ epicRollup: epicRollup.detail,
565
624
  },
566
625
  };
567
626
  const degraded = Object.entries(tail)
@@ -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
  }