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
|
@@ -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.
|
|
11
|
-
* the
|
|
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 {
|
|
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
|
|
47
|
+
* Close every container Epic whose children all landed in this run.
|
|
49
48
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* child
|
|
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
|
|
55
|
-
*
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
* @
|
|
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
|
-
*
|
|
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
|
}
|