mandrel 1.85.0 → 1.86.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/rules/git-conventions.md +33 -0
- package/.agents/scripts/boot-sweep.js +183 -0
- package/.agents/scripts/epic-deliver-prepare.js +55 -0
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +289 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +83 -30
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/branch-cleaner.js +8 -3
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/gate-failure.js +54 -6
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +35 -4
- package/.agents/scripts/lib/single-story-sweep/protection-ctx.js +75 -0
- package/.agents/scripts/lib/single-story-sweep.js +181 -54
- package/.agents/scripts/single-story-init.js +7 -51
- package/.agents/workflows/git-deliver.md +36 -0
- package/.agents/workflows/helpers/deliver-epic-reference.md +19 -13
- package/.agents/workflows/helpers/deliver-epic.md +25 -0
- package/.agents/workflows/helpers/deliver-stories.md +16 -3
- package/.agents/workflows/plan.md +32 -4
- package/docs/CHANGELOG.md +12 -0
- package/package.json +1 -1
|
@@ -34,54 +34,107 @@ export function isWorktreeLockFailure(stderr) {
|
|
|
34
34
|
|
|
35
35
|
/* node:coverage ignore next */
|
|
36
36
|
export function isWorkingTreeClean(cwd) {
|
|
37
|
-
|
|
38
|
-
if (res.status !== 0) return false;
|
|
39
|
-
return res.stdout.trim() === '';
|
|
37
|
+
return defaultFfProbes.isClean(cwd);
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
/* node:coverage ignore next */
|
|
43
41
|
export function fetchRef(cwd, remoteName, ref) {
|
|
44
|
-
|
|
45
|
-
if (res.status !== 0) return { ok: false, stderr: res.stderr };
|
|
46
|
-
return { ok: true };
|
|
42
|
+
return defaultFfProbes.fetch(cwd, remoteName, ref);
|
|
47
43
|
}
|
|
48
44
|
|
|
49
45
|
/* node:coverage ignore next */
|
|
50
46
|
export function canFastForward(cwd, baseBranch, remoteName) {
|
|
51
|
-
|
|
52
|
-
const ahead = gitSpawn(
|
|
53
|
-
cwd,
|
|
54
|
-
'rev-list',
|
|
55
|
-
'--left-right',
|
|
56
|
-
'--count',
|
|
57
|
-
`${baseBranch}...${ref}`,
|
|
58
|
-
);
|
|
59
|
-
if (ahead.status !== 0) {
|
|
60
|
-
return { ok: false, behind: 0, reason: 'rev-list-failed' };
|
|
61
|
-
}
|
|
62
|
-
const parts = ahead.stdout.trim().split(/\s+/);
|
|
63
|
-
const localAhead = Number(parts[0]) || 0;
|
|
64
|
-
const remoteAhead = Number(parts[1]) || 0;
|
|
65
|
-
if (localAhead > 0) {
|
|
66
|
-
return { ok: false, behind: remoteAhead, reason: 'not-fast-forward' };
|
|
67
|
-
}
|
|
68
|
-
return { ok: true, behind: remoteAhead };
|
|
47
|
+
return defaultFfProbes.canFastForward(cwd, baseBranch, remoteName);
|
|
69
48
|
}
|
|
70
49
|
|
|
71
50
|
/* node:coverage ignore next */
|
|
72
51
|
export function checkoutBranch(cwd, branch) {
|
|
73
|
-
|
|
74
|
-
if (res.status !== 0) return { ok: false, stderr: res.stderr };
|
|
75
|
-
return { ok: true };
|
|
52
|
+
return defaultFfProbes.checkout(cwd, branch);
|
|
76
53
|
}
|
|
77
54
|
|
|
78
55
|
/* node:coverage ignore next */
|
|
79
56
|
export function mergeFastForward(cwd, ref) {
|
|
80
|
-
|
|
81
|
-
if (res.status !== 0) return { ok: false, stderr: res.stderr };
|
|
82
|
-
return { ok: true };
|
|
57
|
+
return defaultFfProbes.merge(cwd, ref);
|
|
83
58
|
}
|
|
84
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Build the fast-forward probe bundle bound to a `gitSpawn`.
|
|
62
|
+
*
|
|
63
|
+
* This is the **single implementation** of the FF/base-sync git wrappers.
|
|
64
|
+
* The standalone exports above delegate to a default instance bound to the
|
|
65
|
+
* shared `gitSpawn`; callers that need to inject their own spawn for testing
|
|
66
|
+
* (e.g. the epic-cleanup runner) call this factory directly instead of
|
|
67
|
+
* hand-rolling a parallel copy (framework-gap #4379). The bundle also carries
|
|
68
|
+
* `currentBranch` so an injecting caller gets the whole FF surface from one
|
|
69
|
+
* place.
|
|
70
|
+
*
|
|
71
|
+
* @param {(cwd: string, ...args: string[]) => { status: number, stdout: string, stderr: string }} [spawn]
|
|
72
|
+
* @returns {{
|
|
73
|
+
* isClean: (cwd: string) => boolean,
|
|
74
|
+
* currentBranch: (cwd: string) => string|null,
|
|
75
|
+
* fetch: (cwd: string, remoteName: string, ref: string) => { ok: boolean, stderr?: string },
|
|
76
|
+
* canFastForward: (cwd: string, baseBranch: string, remoteName: string) => { ok: boolean, behind: number, reason?: string },
|
|
77
|
+
* checkout: (cwd: string, branch: string) => { ok: boolean, stderr?: string },
|
|
78
|
+
* merge: (cwd: string, ref: string) => { ok: boolean, stderr?: string },
|
|
79
|
+
* }}
|
|
80
|
+
*/
|
|
81
|
+
export function makeFfProbes(spawn = gitSpawn) {
|
|
82
|
+
return {
|
|
83
|
+
isClean: (cwd) => {
|
|
84
|
+
const res = spawn(cwd, 'status', '--porcelain');
|
|
85
|
+
return res.status === 0 && String(res.stdout ?? '').trim() === '';
|
|
86
|
+
},
|
|
87
|
+
currentBranch: (cwd) => {
|
|
88
|
+
const res = spawn(cwd, 'symbolic-ref', '--quiet', '--short', 'HEAD');
|
|
89
|
+
return res.status !== 0 ? null : String(res.stdout ?? '').trim() || null;
|
|
90
|
+
},
|
|
91
|
+
fetch: (cwd, remoteName, ref) => {
|
|
92
|
+
const res = spawn(cwd, 'fetch', '--quiet', remoteName, ref);
|
|
93
|
+
return res.status === 0
|
|
94
|
+
? { ok: true }
|
|
95
|
+
: { ok: false, stderr: res.stderr };
|
|
96
|
+
},
|
|
97
|
+
canFastForward: (cwd, baseBranch, remoteName) => {
|
|
98
|
+
const ref = `${remoteName}/${baseBranch}`;
|
|
99
|
+
const ahead = spawn(
|
|
100
|
+
cwd,
|
|
101
|
+
'rev-list',
|
|
102
|
+
'--left-right',
|
|
103
|
+
'--count',
|
|
104
|
+
`${baseBranch}...${ref}`,
|
|
105
|
+
);
|
|
106
|
+
if (ahead.status !== 0) {
|
|
107
|
+
return { ok: false, behind: 0, reason: 'rev-list-failed' };
|
|
108
|
+
}
|
|
109
|
+
const parts = String(ahead.stdout ?? '')
|
|
110
|
+
.trim()
|
|
111
|
+
.split(/\s+/);
|
|
112
|
+
const localAhead = Number(parts[0]) || 0;
|
|
113
|
+
const remoteAhead = Number(parts[1]) || 0;
|
|
114
|
+
if (localAhead > 0) {
|
|
115
|
+
return { ok: false, behind: remoteAhead, reason: 'not-fast-forward' };
|
|
116
|
+
}
|
|
117
|
+
return { ok: true, behind: remoteAhead };
|
|
118
|
+
},
|
|
119
|
+
checkout: (cwd, branch) => {
|
|
120
|
+
const res = spawn(cwd, 'checkout', branch);
|
|
121
|
+
return res.status === 0
|
|
122
|
+
? { ok: true }
|
|
123
|
+
: { ok: false, stderr: res.stderr };
|
|
124
|
+
},
|
|
125
|
+
merge: (cwd, ref) => {
|
|
126
|
+
const res = spawn(cwd, 'merge', '--ff-only', ref);
|
|
127
|
+
return res.status === 0
|
|
128
|
+
? { ok: true }
|
|
129
|
+
: { ok: false, stderr: res.stderr };
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Default instance bound to the shared gitSpawn; the standalone wrappers
|
|
135
|
+
// above delegate to it so there is exactly one FF-probe implementation.
|
|
136
|
+
const defaultFfProbes = makeFfProbes(gitSpawn);
|
|
137
|
+
|
|
85
138
|
/* node:coverage ignore next */
|
|
86
139
|
export function removeWorktree(worktreePath, cwd) {
|
|
87
140
|
const plain = gitSpawn(cwd, 'worktree', 'remove', worktreePath);
|
|
@@ -40,7 +40,11 @@
|
|
|
40
40
|
* - runs `git remote prune` to drop stale `<remote>/...` tracking
|
|
41
41
|
* refs left behind by `gh pr merge --delete-branch`;
|
|
42
42
|
* - deletes the `wt-branch` scratch ref left by `story-close.js`'s
|
|
43
|
-
* internal merge worktree when it is no longer checked out
|
|
43
|
+
* internal merge worktree when it is no longer checked out;
|
|
44
|
+
* - fast-forwards the base branch to `<remote>/<baseBranch>` after the
|
|
45
|
+
* confirmed merge so the local checkout converges to origin with no
|
|
46
|
+
* manual `git pull` (skipped when the epic branch is kept for an
|
|
47
|
+
* open PR).
|
|
44
48
|
* 3. Record one classification entry per invocation (`reaped`,
|
|
45
49
|
* `no-state`, `failed`, or `skipped-duplicate`) so failures surface
|
|
46
50
|
* in the lifecycle ledger alongside Cleaner's archival outcome.
|
|
@@ -243,8 +247,8 @@ export class BranchCleaner {
|
|
|
243
247
|
* Pure: condense a `reapEpicBranches()` result into the counts that the
|
|
244
248
|
* classification log carries. Exported for tests.
|
|
245
249
|
*
|
|
246
|
-
* @param {{ reaped: Array<object>, pruned: { pruned: string[] }|null, wtBranch: { deleted: boolean }|null }} result
|
|
247
|
-
* @returns {{ branchesDeleted: number, worktreesRemoved: number, tracksPruned: number, wtBranchDeleted: boolean }}
|
|
250
|
+
* @param {{ reaped: Array<object>, pruned: { pruned: string[] }|null, wtBranch: { deleted: boolean }|null, fastForward: { applied: boolean }|null }} result
|
|
251
|
+
* @returns {{ branchesDeleted: number, worktreesRemoved: number, tracksPruned: number, wtBranchDeleted: boolean, fastForwarded: boolean }}
|
|
248
252
|
*/
|
|
249
253
|
export function summarizeReap(result) {
|
|
250
254
|
const reaped = Array.isArray(result?.reaped) ? result.reaped : [];
|
|
@@ -255,5 +259,6 @@ export function summarizeReap(result) {
|
|
|
255
259
|
).length,
|
|
256
260
|
tracksPruned: result?.pruned?.pruned?.length ?? 0,
|
|
257
261
|
wtBranchDeleted: result?.wtBranch?.deleted === true,
|
|
262
|
+
fastForwarded: result?.fastForward?.applied === true,
|
|
258
263
|
};
|
|
259
264
|
}
|
package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/gate-failure.js
CHANGED
|
@@ -47,6 +47,51 @@ export const DEFAULT_GATE_REGISTRY = {
|
|
|
47
47
|
},
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
+
/** Gate names that fan out to per-kind refreshers via `_gateKind` tags. */
|
|
51
|
+
const COMPOSITE_GATE_KINDS = new Set(['check-baselines']);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the refresh metadata + the regression subset a single failure
|
|
55
|
+
* cycle should act on.
|
|
56
|
+
*
|
|
57
|
+
* A direct per-kind gate (`check-maintainability` / `check-crap`) maps
|
|
58
|
+
* straight through. The unified `check-baselines` gate is a **composite**:
|
|
59
|
+
* its projected regressions are tagged with `_gateKind`, and each attribution
|
|
60
|
+
* cycle picks the first regressed kind not already refreshed this cycle
|
|
61
|
+
* (`cycleState.refreshedKinds`), scoping classify + refresh to that one kind.
|
|
62
|
+
* The retry loop re-drives for any remaining kinds — so a close that regresses
|
|
63
|
+
* both maintainability and CRAP converges in two cycles instead of dead-ending
|
|
64
|
+
* on an unrecognised gate name (framework-gap #4377).
|
|
65
|
+
*
|
|
66
|
+
* @returns {{ meta: object, regressions: Array } | null}
|
|
67
|
+
*/
|
|
68
|
+
export function resolveGateMeta({
|
|
69
|
+
gateName,
|
|
70
|
+
regressions,
|
|
71
|
+
cycleState = null,
|
|
72
|
+
gateRegistry = DEFAULT_GATE_REGISTRY,
|
|
73
|
+
}) {
|
|
74
|
+
if (!Array.isArray(regressions) || regressions.length === 0) return null;
|
|
75
|
+
|
|
76
|
+
const direct = gateRegistry[gateName];
|
|
77
|
+
if (direct) return { meta: direct, regressions };
|
|
78
|
+
|
|
79
|
+
if (COMPOSITE_GATE_KINDS.has(gateName)) {
|
|
80
|
+
const refreshed = cycleState?.refreshedKinds ?? new Set();
|
|
81
|
+
for (const row of regressions) {
|
|
82
|
+
const kind = row?._gateKind;
|
|
83
|
+
const subMeta = kind ? gateRegistry[`check-${kind}`] : null;
|
|
84
|
+
if (subMeta && !refreshed.has(subMeta.kind)) {
|
|
85
|
+
return {
|
|
86
|
+
meta: subMeta,
|
|
87
|
+
regressions: regressions.filter((r) => r?._gateKind === kind),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
50
95
|
/**
|
|
51
96
|
* Top-level: handle a baseline gate failure by classifying drift and
|
|
52
97
|
* either auto-refreshing (attributable-only) or posting friction (any
|
|
@@ -88,11 +133,14 @@ export async function handleBaselineGateFailure({
|
|
|
88
133
|
gateRegistry = DEFAULT_GATE_REGISTRY,
|
|
89
134
|
deps = {},
|
|
90
135
|
} = {}) {
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
136
|
+
const resolved = resolveGateMeta({
|
|
137
|
+
gateName,
|
|
138
|
+
regressions,
|
|
139
|
+
cycleState,
|
|
140
|
+
gateRegistry,
|
|
141
|
+
});
|
|
142
|
+
if (!resolved) return { action: 'rethrow' };
|
|
143
|
+
const { meta, regressions: scopedRegressions } = resolved;
|
|
96
144
|
|
|
97
145
|
const classify = deps.classifyBaselineDrift ?? defaultClassifyBaselineDrift;
|
|
98
146
|
const renderBody =
|
|
@@ -114,7 +162,7 @@ export async function handleBaselineGateFailure({
|
|
|
114
162
|
|
|
115
163
|
const epicRef = `origin/${epicBranch}`;
|
|
116
164
|
const { attributable, nonAttributable } = classify({
|
|
117
|
-
regressions,
|
|
165
|
+
regressions: scopedRegressions,
|
|
118
166
|
storyDiffPaths,
|
|
119
167
|
epicRef,
|
|
120
168
|
cwd,
|
|
@@ -244,6 +244,24 @@ export const PROJECTORS = {
|
|
|
244
244
|
'check-crap': projectCrapForGate,
|
|
245
245
|
};
|
|
246
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Composite gates fan out to per-kind projectors. The baseline pipeline was
|
|
249
|
+
* unified behind a single `check-baselines` gate (per-kind pipeline: schema →
|
|
250
|
+
* floor → tolerance), but the attribution layer still keys on the original
|
|
251
|
+
* per-kind gate names. Without this map a `check-baselines` failure projects
|
|
252
|
+
* zero regressions, the gate-failure handler sees an empty list, and the
|
|
253
|
+
* auto-refresh path silently no-ops — so a legitimate MI/CRAP regression
|
|
254
|
+
* hard-fails the close instead of self-healing (framework-gap #4377).
|
|
255
|
+
*/
|
|
256
|
+
export const COMPOSITE_SUBGATES = {
|
|
257
|
+
'check-baselines': ['check-maintainability', 'check-crap'],
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/** `check-maintainability` → `maintainability`. */
|
|
261
|
+
function gateKind(gateName) {
|
|
262
|
+
return gateName.replace(/^check-/, '');
|
|
263
|
+
}
|
|
264
|
+
|
|
247
265
|
export function projectRegressionsForGate({
|
|
248
266
|
gateName,
|
|
249
267
|
cwd,
|
|
@@ -253,14 +271,27 @@ export function projectRegressionsForGate({
|
|
|
253
271
|
projectMaintainability = defaultProjectMaintainabilityRegressions,
|
|
254
272
|
getBaselines = defaultGetBaselines,
|
|
255
273
|
}) {
|
|
256
|
-
const
|
|
257
|
-
if (!project) return [];
|
|
258
|
-
return project({
|
|
274
|
+
const ctx = {
|
|
259
275
|
cwd,
|
|
260
276
|
epicBranch,
|
|
261
277
|
storyBranch,
|
|
262
278
|
config,
|
|
263
279
|
projectMaintainability,
|
|
264
280
|
getBaselines,
|
|
265
|
-
}
|
|
281
|
+
};
|
|
282
|
+
const subGates = COMPOSITE_SUBGATES[gateName];
|
|
283
|
+
if (subGates) {
|
|
284
|
+
// Union the per-kind regressions, tagging each row with its baseline
|
|
285
|
+
// kind so the gate-failure handler can refresh the right baseline (one
|
|
286
|
+
// kind per attribution cycle; the retry loop converges the rest).
|
|
287
|
+
return subGates.flatMap((sub) => {
|
|
288
|
+
const project = PROJECTORS[sub];
|
|
289
|
+
if (!project) return [];
|
|
290
|
+
const kind = gateKind(sub);
|
|
291
|
+
return project(ctx).map((row) => ({ _gateKind: kind, ...row }));
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
const project = PROJECTORS[gateName];
|
|
295
|
+
if (!project) return [];
|
|
296
|
+
return project(ctx);
|
|
266
297
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* single-story-sweep/protection-ctx.js
|
|
3
|
+
*
|
|
4
|
+
* Shared builder for the `evaluateProtection` context the boot-sweep
|
|
5
|
+
* engine ([`sweepMergedBranches`](../single-story-sweep.js)) threads into
|
|
6
|
+
* every candidate protection check. Single-homed here so the three boot
|
|
7
|
+
* callers — `single-story-init.js`, `epic-deliver-prepare.js`, and the
|
|
8
|
+
* `boot-sweep.js` CLI — build an identical ctx instead of each re-wiring
|
|
9
|
+
* the git/gh/ticket ports.
|
|
10
|
+
*
|
|
11
|
+
* Story #2990: the sweep protection-ctx `ghRunner` stays on raw
|
|
12
|
+
* `spawnSync('gh', …)` (not the `lib/gh-exec.js` async facade) because
|
|
13
|
+
* `executeCleanup` invokes the protection checks inside a synchronous
|
|
14
|
+
* candidate-filter loop. The runner contract is the legacy
|
|
15
|
+
* `(args, opts) => stdout string` shape.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawnSync as defaultSpawnSync } from 'node:child_process';
|
|
19
|
+
import { gitSpawn } from '../git-utils.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the synchronous `gh` runner the sweep uses for its
|
|
23
|
+
* candidate-protection checks.
|
|
24
|
+
*
|
|
25
|
+
* Story #4073: the `spawnImpl` seam injects the `spawnSync` boundary so
|
|
26
|
+
* the runner's success/error handling can be unit-tested without a live
|
|
27
|
+
* `gh` binary. It defaults to `child_process.spawnSync`, so the
|
|
28
|
+
* production CLI path is unchanged.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} cwd Repo root used as the default spawn cwd.
|
|
31
|
+
* @param {typeof defaultSpawnSync} [spawnImpl] Injectable spawn boundary —
|
|
32
|
+
* defaults to `child_process.spawnSync`.
|
|
33
|
+
* @returns {(args: string[], opts?: { cwd?: string }) => string}
|
|
34
|
+
*/
|
|
35
|
+
export function makeGhRunner(cwd, spawnImpl = defaultSpawnSync) {
|
|
36
|
+
return (args, opts) => {
|
|
37
|
+
const result = spawnImpl('gh', args, {
|
|
38
|
+
cwd: opts?.cwd ?? cwd,
|
|
39
|
+
encoding: 'utf-8',
|
|
40
|
+
shell: false,
|
|
41
|
+
});
|
|
42
|
+
if (result.status !== 0) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`gh ${args.join(' ')} exit ${result.status}: ${result.stderr ?? ''}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return result.stdout ?? '';
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build the `evaluateProtection` ctx bag: the repo root, the `gitSpawn`
|
|
53
|
+
* port, the synchronous `gh` runner, and a `getTicket` port bound to the
|
|
54
|
+
* supplied provider.
|
|
55
|
+
*
|
|
56
|
+
* @param {{
|
|
57
|
+
* cwd: string,
|
|
58
|
+
* provider: { getTicket: (id: number) => Promise<object> },
|
|
59
|
+
* spawnImpl?: typeof defaultSpawnSync,
|
|
60
|
+
* }} args
|
|
61
|
+
* @returns {{
|
|
62
|
+
* repoRoot: string,
|
|
63
|
+
* gitSpawn: typeof gitSpawn,
|
|
64
|
+
* ghRunner: (args: string[], opts?: { cwd?: string }) => string,
|
|
65
|
+
* getTicket: (id: number) => Promise<object>,
|
|
66
|
+
* }}
|
|
67
|
+
*/
|
|
68
|
+
export function buildProtectionCtx({ cwd, provider, spawnImpl }) {
|
|
69
|
+
return {
|
|
70
|
+
repoRoot: cwd,
|
|
71
|
+
gitSpawn,
|
|
72
|
+
ghRunner: makeGhRunner(cwd, spawnImpl),
|
|
73
|
+
getTicket: (id) => provider.getTicket(id),
|
|
74
|
+
};
|
|
75
|
+
}
|