mandrel 1.84.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/docs/agentrc-reference.json +8 -2
- package/.agents/docs/configuration.md +7 -2
- package/.agents/instructions.md +4 -0
- package/.agents/rules/ci-remediation.md +131 -0
- package/.agents/rules/git-conventions.md +33 -0
- package/.agents/schemas/agentrc.schema.json +29 -6
- package/.agents/schemas/lifecycle/epic.watch.end.schema.json +2 -1
- package/.agents/scripts/boot-sweep.js +183 -0
- package/.agents/scripts/epic-deliver-prepare.js +55 -0
- package/.agents/scripts/git-pr-quality-gate.js +7 -5
- package/.agents/scripts/lib/config/ci.js +24 -3
- package/.agents/scripts/lib/config/explain.js +11 -3
- package/.agents/scripts/lib/config/github.js +11 -7
- package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
- package/.agents/scripts/lib/config-settings-schema.js +6 -6
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +289 -1
- package/.agents/scripts/lib/orchestration/finalize/open-or-locate-pr.js +65 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +83 -30
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +401 -84
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/branch-cleaner.js +8 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +48 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +6 -1
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +172 -58
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +19 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -0
- 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/orchestration/ticket-validator-sizing.js +17 -16
- 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/lib/templates/decomposer-prompts.js +17 -3
- package/.agents/scripts/pr-watch-with-update.js +324 -37
- package/.agents/scripts/run-verify.js +18 -3
- package/.agents/scripts/single-story-confirm-merge.js +1 -1
- package/.agents/scripts/single-story-init.js +7 -51
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +32 -1
- package/.agents/skills/core/scope-triage/SKILL.md +5 -4
- package/.agents/workflows/git-deliver.md +36 -0
- package/.agents/workflows/helpers/deliver-epic-reference.md +41 -21
- package/.agents/workflows/helpers/deliver-epic.md +148 -28
- package/.agents/workflows/helpers/deliver-stories.md +18 -5
- package/.agents/workflows/helpers/single-story-deliver-reference.md +3 -3
- package/.agents/workflows/helpers/single-story-deliver.md +56 -19
- package/.agents/workflows/plan.md +32 -4
- package/docs/CHANGELOG.md +21 -0
- package/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
* `.agents/scripts/lib/config-settings-schema.js`.
|
|
18
18
|
*
|
|
19
19
|
* Sizing model (Story #3760 — profile-matrix collapse; Story #3874 — one
|
|
20
|
-
* uniform relaxed profile
|
|
21
|
-
*
|
|
20
|
+
* uniform relaxed profile; the hard acceptance ceiling was removed after the
|
|
21
|
+
* Epic #4355 decomposition experiment showed it forced fragmentation):
|
|
22
|
+
* - Flat knobs: `softFiles` (~15), `hardFiles` (~30),
|
|
22
23
|
* `softAcceptanceCount` (~10). No per-profile ceiling map, no parallel
|
|
23
|
-
* `testSurface` axis, no selector and no second profile.
|
|
24
|
+
* `testSurface` axis, no selector and no second profile. Acceptance
|
|
25
|
+
* mass is advisory-only.
|
|
24
26
|
* - The four-profile `sizingProfile` enum is replaced by a single optional
|
|
25
27
|
* `wide` declaration carrying a one-line human-readable reason. Declaring
|
|
26
28
|
* `wide` with a reason lifts the `hardFiles` rejection; no Story is
|
|
@@ -119,9 +121,12 @@ export const DEFAULT_TASK_SIZING = Object.freeze({
|
|
|
119
121
|
// The hard `hardFiles` rejection (30) is unchanged.
|
|
120
122
|
softFiles: 15,
|
|
121
123
|
softAcceptanceCount: 10,
|
|
122
|
-
// Hard
|
|
124
|
+
// Hard ceiling (rejection unless lifted via `wide`). Acceptance mass has
|
|
125
|
+
// no hard ceiling: the former `maxAcceptance` rejection forced careful,
|
|
126
|
+
// fine-grained specs to fragment one coherent capability into dependent
|
|
127
|
+
// slices (observed on Epic #4355), so it was removed — the delivery-
|
|
128
|
+
// schedule simulation in the decomposer prompt owns that judgment now.
|
|
123
129
|
hardFiles: 30,
|
|
124
|
-
maxAcceptance: 14,
|
|
125
130
|
// Under-size (merge-candidate) thresholds (Story #4312). A Story with a
|
|
126
131
|
// footprint at or below BOTH ceilings that also carries at least one
|
|
127
132
|
// `depends_on` edge to a sibling looks like a dependent fragment rather than
|
|
@@ -528,17 +533,13 @@ function computeStorySizingFindings(story, sizing) {
|
|
|
528
533
|
),
|
|
529
534
|
);
|
|
530
535
|
|
|
531
|
-
// Acceptance
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
sizing.maxAcceptance,
|
|
539
|
-
),
|
|
540
|
-
);
|
|
541
|
-
} else if (acceptance.length > sizing.softAcceptanceCount) {
|
|
536
|
+
// Acceptance mass is advisory-only (Story #4312's under-size heuristic is
|
|
537
|
+
// the merge signal; the former hard `maxAcceptance` rejection is removed).
|
|
538
|
+
// A long binding contract is a re-check-cohesion nudge, never by itself a
|
|
539
|
+
// decomposition error — cohesion and the delivery envelope govern Story
|
|
540
|
+
// size, and the delivery-schedule simulation in the decomposer prompt owns
|
|
541
|
+
// the fragmentation/consolidation judgment.
|
|
542
|
+
if (acceptance.length > sizing.softAcceptanceCount) {
|
|
542
543
|
out.push(
|
|
543
544
|
makeSoftWidth(
|
|
544
545
|
story.slug,
|
|
@@ -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
|
+
}
|
|
@@ -1,27 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* single-story-sweep.js —
|
|
2
|
+
* single-story-sweep.js — the scope-agnostic merged-branch sweep engine
|
|
3
|
+
* plus the `story-*` boot-sweep preset.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
5
|
+
* `sweepMergedBranches` is the single reap engine every boot cleanup
|
|
6
|
+
* path routes through. It sits directly over the `git-cleanup` phase
|
|
7
|
+
* library (`planCleanup` / `executeCleanup` / `executeFastForward` /
|
|
8
|
+
* `buildGlobFilter`) and the shared `evaluateProtection` guard set, so
|
|
9
|
+
* no reap path re-implements the `git branch -D` / `git merge --ff-only`
|
|
10
|
+
* primitives:
|
|
6
11
|
*
|
|
7
|
-
* - Scope: `
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
12
|
+
* - Scope: caller-supplied `include` / `exclude` globs (via
|
|
13
|
+
* `buildGlobFilter`). The story preset pins `story-*`.
|
|
14
|
+
* - Reap: merged local branches whose PR HEAD SHA equals the merged
|
|
15
|
+
* `headRefOid`, deleted local + origin with tracking-ref
|
|
16
|
+
* prune (`executeCleanup` in `--remote` mode).
|
|
11
17
|
* - Protection (Story #2011): each candidate is filtered through
|
|
12
18
|
* `evaluateProtection` before reaching `executeCleanup`. A
|
|
13
19
|
* candidate is protected (not reaped) when its branch HEAD
|
|
14
20
|
* differs from the PR's `headRefOid` (unpushed work), when
|
|
15
21
|
* its worktree has uncommitted edits, or when the parent
|
|
16
22
|
* Story ticket is not in a terminal state. Protected
|
|
17
|
-
* candidates surface
|
|
18
|
-
*
|
|
23
|
+
* candidates surface under `protected`.
|
|
24
|
+
* - Fast-forward (opt-in via `fastForward: true`): fast-forward the
|
|
25
|
+
* base branch through `executeFastForward` after the reap.
|
|
26
|
+
* Best-effort — a failed fast-forward never fails the sweep.
|
|
19
27
|
* - Concurrency (Story #2011): the sweep acquires a process-scoped
|
|
20
28
|
* lockfile around plan + execute. On lock contention the
|
|
21
|
-
* sweep is skipped (
|
|
29
|
+
* sweep is skipped (the host continues — same contract as a
|
|
22
30
|
* plan failure).
|
|
23
|
-
* -
|
|
24
|
-
*
|
|
31
|
+
* - Never touches the stash stack.
|
|
32
|
+
* - Errors are caught and surfaced in the envelope. Callers MUST NOT
|
|
33
|
+
* propagate sweep failures — the host proceeds either way.
|
|
34
|
+
*
|
|
35
|
+
* `sweepMergedStoryBranches` is a thin preset over the engine tuned for
|
|
36
|
+
* the boot path (`include: story-*`, `exclude: <current story branch>`,
|
|
37
|
+
* `fastForward: false`). Its exported name, signature, and result
|
|
38
|
+
* envelope are unchanged from the pre-engine implementation.
|
|
25
39
|
*
|
|
26
40
|
* Re-exports the same `planCleanup` / `executeCleanup` injection seams so
|
|
27
41
|
* tests can stub git/`gh` without touching the CLI.
|
|
@@ -30,7 +44,9 @@
|
|
|
30
44
|
import {
|
|
31
45
|
buildGlobFilter,
|
|
32
46
|
executeCleanup as defaultExecuteCleanup,
|
|
47
|
+
executeFastForward as defaultExecuteFastForward,
|
|
33
48
|
planCleanup as defaultPlanCleanup,
|
|
49
|
+
planFastForward as defaultPlanFastForward,
|
|
34
50
|
} from '../git-cleanup.js';
|
|
35
51
|
import { evaluateProtection as defaultEvaluateProtection } from './single-story-sweep/protection.js';
|
|
36
52
|
import { acquireSweepLock as defaultAcquireSweepLock } from './single-story-sweep/sweep-lock.js';
|
|
@@ -38,15 +54,20 @@ import { acquireSweepLock as defaultAcquireSweepLock } from './single-story-swee
|
|
|
38
54
|
const STORY_BRANCH_INCLUDE = 'story-*';
|
|
39
55
|
|
|
40
56
|
/**
|
|
41
|
-
*
|
|
57
|
+
* Scope-agnostic merged-branch sweep engine.
|
|
42
58
|
*
|
|
43
59
|
* @param {{
|
|
44
60
|
* cwd: string,
|
|
45
61
|
* baseBranch: string,
|
|
46
|
-
*
|
|
62
|
+
* include?: string[],
|
|
63
|
+
* exclude?: string[],
|
|
64
|
+
* fastForward?: boolean,
|
|
47
65
|
* logger?: { info?: (m: string) => void, warn?: (m: string) => void },
|
|
66
|
+
* logTag?: string,
|
|
48
67
|
* planCleanupFn?: typeof defaultPlanCleanup,
|
|
49
68
|
* executeCleanupFn?: typeof defaultExecuteCleanup,
|
|
69
|
+
* planFastForwardFn?: typeof defaultPlanFastForward,
|
|
70
|
+
* executeFastForwardFn?: typeof defaultExecuteFastForward,
|
|
50
71
|
* protectionFn?: typeof defaultEvaluateProtection,
|
|
51
72
|
* protectionCtx?: object,
|
|
52
73
|
* acquireLockFn?: typeof defaultAcquireSweepLock,
|
|
@@ -61,17 +82,23 @@ const STORY_BRANCH_INCLUDE = 'story-*';
|
|
|
61
82
|
* remoteDeleted: number,
|
|
62
83
|
* protected: Array<{ branch: string, reason: string, worktreePath?: string|null }>,
|
|
63
84
|
* failures: Array<{ branch: string|null, scope: string, stderr?: string }>,
|
|
85
|
+
* fastForward?: object,
|
|
64
86
|
* error?: string,
|
|
65
87
|
* reason?: string,
|
|
66
88
|
* }>}
|
|
67
89
|
*/
|
|
68
|
-
export async function
|
|
90
|
+
export async function sweepMergedBranches({
|
|
69
91
|
cwd,
|
|
70
92
|
baseBranch,
|
|
71
|
-
|
|
93
|
+
include = ['*'],
|
|
94
|
+
exclude = [],
|
|
95
|
+
fastForward = false,
|
|
72
96
|
logger = {},
|
|
97
|
+
logTag = '[sweep]',
|
|
73
98
|
planCleanupFn = defaultPlanCleanup,
|
|
74
99
|
executeCleanupFn = defaultExecuteCleanup,
|
|
100
|
+
planFastForwardFn = defaultPlanFastForward,
|
|
101
|
+
executeFastForwardFn = defaultExecuteFastForward,
|
|
75
102
|
protectionFn = defaultEvaluateProtection,
|
|
76
103
|
protectionCtx = null,
|
|
77
104
|
acquireLockFn = defaultAcquireSweepLock,
|
|
@@ -92,16 +119,13 @@ export async function sweepMergedStoryBranches({
|
|
|
92
119
|
|
|
93
120
|
// Optional lock acquisition. Skip silently when no lockPath is
|
|
94
121
|
// supplied (e.g. unit tests, callers that opt out). Contention is
|
|
95
|
-
// non-fatal — return a skipped result and let
|
|
122
|
+
// non-fatal — return a skipped result and let the host continue.
|
|
96
123
|
let releaseLock = () => {};
|
|
97
124
|
if (lockPath) {
|
|
98
|
-
const lockResult = acquireLockFn({
|
|
99
|
-
lockPath,
|
|
100
|
-
timeoutMs: lockTimeoutMs,
|
|
101
|
-
});
|
|
125
|
+
const lockResult = acquireLockFn({ lockPath, timeoutMs: lockTimeoutMs });
|
|
102
126
|
if (!lockResult.acquired) {
|
|
103
127
|
log.warn(
|
|
104
|
-
|
|
128
|
+
`${logTag} lock not acquired (${lockResult.reason}${
|
|
105
129
|
lockResult.detail ? `: ${lockResult.detail}` : ''
|
|
106
130
|
}); skipping sweep.`,
|
|
107
131
|
);
|
|
@@ -120,16 +144,28 @@ export async function sweepMergedStoryBranches({
|
|
|
120
144
|
}
|
|
121
145
|
|
|
122
146
|
try {
|
|
123
|
-
|
|
147
|
+
const reap = await runSweepUnderLock({
|
|
124
148
|
cwd,
|
|
125
149
|
baseBranch,
|
|
126
|
-
|
|
150
|
+
include,
|
|
151
|
+
exclude,
|
|
127
152
|
log,
|
|
153
|
+
logTag,
|
|
128
154
|
planCleanupFn,
|
|
129
155
|
executeCleanupFn,
|
|
130
156
|
protectionFn,
|
|
131
157
|
protectionCtx,
|
|
132
158
|
});
|
|
159
|
+
if (!fastForward) return reap;
|
|
160
|
+
const ff = runFastForwardStep({
|
|
161
|
+
cwd,
|
|
162
|
+
baseBranch,
|
|
163
|
+
log,
|
|
164
|
+
logTag,
|
|
165
|
+
planFastForwardFn,
|
|
166
|
+
executeFastForwardFn,
|
|
167
|
+
});
|
|
168
|
+
return { ...reap, fastForward: ff };
|
|
133
169
|
} finally {
|
|
134
170
|
try {
|
|
135
171
|
releaseLock();
|
|
@@ -139,41 +175,73 @@ export async function sweepMergedStoryBranches({
|
|
|
139
175
|
}
|
|
140
176
|
}
|
|
141
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Sweep merged `story-*` branches in `cwd`. Preset over
|
|
180
|
+
* {@link sweepMergedBranches} for the boot path: it pins the `story-*`
|
|
181
|
+
* include glob, excludes the current run's `currentStoryBranch`, and
|
|
182
|
+
* keeps `fastForward` off (the boot caller fast-forwards the base branch
|
|
183
|
+
* separately). Exported name, signature, and result-envelope shape are
|
|
184
|
+
* unchanged from the pre-engine implementation.
|
|
185
|
+
*
|
|
186
|
+
* @param {{
|
|
187
|
+
* cwd: string,
|
|
188
|
+
* baseBranch: string,
|
|
189
|
+
* currentStoryBranch: string,
|
|
190
|
+
* logger?: { info?: (m: string) => void, warn?: (m: string) => void },
|
|
191
|
+
* planCleanupFn?: typeof defaultPlanCleanup,
|
|
192
|
+
* executeCleanupFn?: typeof defaultExecuteCleanup,
|
|
193
|
+
* protectionFn?: typeof defaultEvaluateProtection,
|
|
194
|
+
* protectionCtx?: object,
|
|
195
|
+
* acquireLockFn?: typeof defaultAcquireSweepLock,
|
|
196
|
+
* lockPath?: string|null,
|
|
197
|
+
* lockTimeoutMs?: number,
|
|
198
|
+
* }} args
|
|
199
|
+
* @returns {Promise<object>} the {@link sweepMergedBranches} envelope.
|
|
200
|
+
*/
|
|
201
|
+
export function sweepMergedStoryBranches(args = {}) {
|
|
202
|
+
const { currentStoryBranch } = args;
|
|
203
|
+
const exclude =
|
|
204
|
+
typeof currentStoryBranch === 'string' && currentStoryBranch.length > 0
|
|
205
|
+
? [currentStoryBranch]
|
|
206
|
+
: [];
|
|
207
|
+
return sweepMergedBranches({
|
|
208
|
+
...args,
|
|
209
|
+
include: [STORY_BRANCH_INCLUDE],
|
|
210
|
+
exclude,
|
|
211
|
+
fastForward: false,
|
|
212
|
+
logTag: '[single-story-sweep]',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
142
216
|
/**
|
|
143
217
|
* Inner: the plan + protect + execute pipeline. Kept separate so the
|
|
144
|
-
* outer
|
|
145
|
-
* acquire/release wrapper.
|
|
218
|
+
* outer engine can stay focused on the lock and fast-forward wrappers.
|
|
146
219
|
*/
|
|
147
220
|
async function runSweepUnderLock({
|
|
148
221
|
cwd,
|
|
149
222
|
baseBranch,
|
|
150
|
-
|
|
223
|
+
include,
|
|
224
|
+
exclude,
|
|
151
225
|
log,
|
|
226
|
+
logTag,
|
|
152
227
|
planCleanupFn,
|
|
153
228
|
executeCleanupFn,
|
|
154
229
|
protectionFn,
|
|
155
230
|
protectionCtx,
|
|
156
231
|
}) {
|
|
157
|
-
const exclude
|
|
158
|
-
typeof currentStoryBranch === 'string' && currentStoryBranch.length > 0
|
|
159
|
-
? [currentStoryBranch]
|
|
160
|
-
: [];
|
|
161
|
-
const filter = buildGlobFilter({
|
|
162
|
-
include: [STORY_BRANCH_INCLUDE],
|
|
163
|
-
exclude,
|
|
164
|
-
});
|
|
232
|
+
const filter = buildGlobFilter({ include, exclude });
|
|
165
233
|
|
|
166
234
|
let plan;
|
|
167
235
|
try {
|
|
168
236
|
plan = planCleanupFn({ cwd, baseBranch, filter });
|
|
169
237
|
} catch (err) {
|
|
170
238
|
const msg = err?.message ?? String(err);
|
|
171
|
-
log.warn(
|
|
239
|
+
log.warn(`${logTag} plan failed: ${msg}`);
|
|
172
240
|
return zeroResult({ error: `plan: ${msg}` });
|
|
173
241
|
}
|
|
174
242
|
|
|
175
243
|
if (plan.candidates.length === 0) {
|
|
176
|
-
log.info(
|
|
244
|
+
log.info(`${logTag} no merged branches to reap.`);
|
|
177
245
|
return {
|
|
178
246
|
ok: true,
|
|
179
247
|
skipped: false,
|
|
@@ -190,11 +258,12 @@ async function runSweepUnderLock({
|
|
|
190
258
|
protectionFn,
|
|
191
259
|
protectionCtx,
|
|
192
260
|
log,
|
|
261
|
+
logTag,
|
|
193
262
|
});
|
|
194
263
|
|
|
195
264
|
if (reapable.length === 0) {
|
|
196
265
|
log.info(
|
|
197
|
-
|
|
266
|
+
`${logTag} all ${plan.candidates.length} candidate(s) protected; no reap.`,
|
|
198
267
|
);
|
|
199
268
|
return {
|
|
200
269
|
ok: true,
|
|
@@ -207,20 +276,41 @@ async function runSweepUnderLock({
|
|
|
207
276
|
};
|
|
208
277
|
}
|
|
209
278
|
|
|
279
|
+
return executeReap({
|
|
280
|
+
reapable,
|
|
281
|
+
protectedList,
|
|
282
|
+
candidateCount: plan.candidates.length,
|
|
283
|
+
cwd,
|
|
284
|
+
executeCleanupFn,
|
|
285
|
+
log,
|
|
286
|
+
logTag,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Execute the reap plan for the reapable candidates and shape the result
|
|
292
|
+
* envelope. Split out of {@link runSweepUnderLock} so each function keeps
|
|
293
|
+
* a single responsibility.
|
|
294
|
+
*/
|
|
295
|
+
function executeReap({
|
|
296
|
+
reapable,
|
|
297
|
+
protectedList,
|
|
298
|
+
candidateCount,
|
|
299
|
+
cwd,
|
|
300
|
+
executeCleanupFn,
|
|
301
|
+
log,
|
|
302
|
+
logTag,
|
|
303
|
+
}) {
|
|
210
304
|
let result;
|
|
211
305
|
try {
|
|
212
|
-
result = executeCleanupFn({
|
|
213
|
-
candidates: reapable,
|
|
214
|
-
cwd,
|
|
215
|
-
remote: true,
|
|
216
|
-
});
|
|
306
|
+
result = executeCleanupFn({ candidates: reapable, cwd, remote: true });
|
|
217
307
|
} catch (err) {
|
|
218
308
|
const msg = err?.message ?? String(err);
|
|
219
|
-
log.warn(
|
|
309
|
+
log.warn(`${logTag} execute failed: ${msg}`);
|
|
220
310
|
return {
|
|
221
311
|
ok: false,
|
|
222
312
|
skipped: false,
|
|
223
|
-
candidates:
|
|
313
|
+
candidates: candidateCount,
|
|
224
314
|
localDeleted: 0,
|
|
225
315
|
remoteDeleted: 0,
|
|
226
316
|
protected: protectedList,
|
|
@@ -241,18 +331,18 @@ async function runSweepUnderLock({
|
|
|
241
331
|
const summary = `${localDeleted} local + ${remoteDeleted} remote${protectedSummary}`;
|
|
242
332
|
if (result.ok) {
|
|
243
333
|
log.info(
|
|
244
|
-
|
|
334
|
+
`${logTag} reaped ${summary}${reapedBranches ? ` [${reapedBranches}]` : ''}.`,
|
|
245
335
|
);
|
|
246
336
|
} else {
|
|
247
337
|
log.warn(
|
|
248
|
-
|
|
338
|
+
`${logTag} reaped ${summary} with ${result.failures.length} failure(s) — host continues.`,
|
|
249
339
|
);
|
|
250
340
|
}
|
|
251
341
|
|
|
252
342
|
return {
|
|
253
343
|
ok: result.ok,
|
|
254
344
|
skipped: false,
|
|
255
|
-
candidates:
|
|
345
|
+
candidates: candidateCount,
|
|
256
346
|
localDeleted,
|
|
257
347
|
remoteDeleted,
|
|
258
348
|
protected: protectedList,
|
|
@@ -260,6 +350,44 @@ async function runSweepUnderLock({
|
|
|
260
350
|
};
|
|
261
351
|
}
|
|
262
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Best-effort fast-forward of the base branch through the git-cleanup
|
|
355
|
+
* fast-forward phase. Never throws — a failed fast-forward is logged and
|
|
356
|
+
* returned as `{ ok: false, error }` but must never fail the sweep.
|
|
357
|
+
*/
|
|
358
|
+
function runFastForwardStep({
|
|
359
|
+
cwd,
|
|
360
|
+
baseBranch,
|
|
361
|
+
log,
|
|
362
|
+
logTag,
|
|
363
|
+
planFastForwardFn,
|
|
364
|
+
executeFastForwardFn,
|
|
365
|
+
}) {
|
|
366
|
+
try {
|
|
367
|
+
const plan = planFastForwardFn({ cwd, baseBranch });
|
|
368
|
+
const ff = executeFastForwardFn({
|
|
369
|
+
cwd,
|
|
370
|
+
baseBranch,
|
|
371
|
+
plan,
|
|
372
|
+
logger: {
|
|
373
|
+
info: (m) => log.info(m.replace(/^\[git-cleanup\]\s*/, `${logTag} `)),
|
|
374
|
+
warn: (m) => log.warn(m.replace(/^\[git-cleanup\]\s*/, `${logTag} `)),
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
return {
|
|
378
|
+
ok: ff.ok !== false,
|
|
379
|
+
applied: !!ff.applied,
|
|
380
|
+
skipped: !!ff.skipped,
|
|
381
|
+
behind: ff.behind ?? null,
|
|
382
|
+
reason: ff.reason ?? null,
|
|
383
|
+
};
|
|
384
|
+
} catch (err) {
|
|
385
|
+
const msg = err?.message ?? String(err);
|
|
386
|
+
log.warn(`${logTag} fast-forward failed: ${msg}`);
|
|
387
|
+
return { ok: false, applied: false, skipped: false, error: msg };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
263
391
|
/**
|
|
264
392
|
* Iterate plan candidates and split them into `reapable` (safe to pass
|
|
265
393
|
* to executeCleanup) and `protectedList` (skipped, with a reason).
|
|
@@ -270,14 +398,15 @@ async function runSweepUnderLock({
|
|
|
270
398
|
*
|
|
271
399
|
* When no `protectionCtx` is supplied (legacy callers, unit tests),
|
|
272
400
|
* the protection check is bypassed entirely and every candidate is
|
|
273
|
-
* reapable. The CLI
|
|
274
|
-
*
|
|
401
|
+
* reapable. The boot-path CLI surfaces always supply a ctx, so this
|
|
402
|
+
* fallback never fires in production.
|
|
275
403
|
*/
|
|
276
404
|
async function partitionCandidates({
|
|
277
405
|
candidates,
|
|
278
406
|
protectionFn,
|
|
279
407
|
protectionCtx,
|
|
280
408
|
log,
|
|
409
|
+
logTag,
|
|
281
410
|
}) {
|
|
282
411
|
const reapable = [];
|
|
283
412
|
const protectedList = [];
|
|
@@ -291,7 +420,7 @@ async function partitionCandidates({
|
|
|
291
420
|
verdict = await protectionFn({ candidate, ctx: protectionCtx });
|
|
292
421
|
} catch (err) {
|
|
293
422
|
const reason = `protection-eval-error: ${err?.message ?? err}`;
|
|
294
|
-
log.warn(
|
|
423
|
+
log.warn(`${logTag} protected ${candidate.branch}: ${reason}`);
|
|
295
424
|
protectedList.push({
|
|
296
425
|
branch: candidate.branch,
|
|
297
426
|
reason,
|
|
@@ -300,9 +429,7 @@ async function partitionCandidates({
|
|
|
300
429
|
continue;
|
|
301
430
|
}
|
|
302
431
|
if (verdict?.protected) {
|
|
303
|
-
log.info(
|
|
304
|
-
`[single-story-sweep] protected ${candidate.branch}: ${verdict.reason}`,
|
|
305
|
-
);
|
|
432
|
+
log.info(`${logTag} protected ${candidate.branch}: ${verdict.reason}`);
|
|
306
433
|
protectedList.push({
|
|
307
434
|
branch: candidate.branch,
|
|
308
435
|
reason: verdict.reason ?? 'unknown',
|
|
@@ -50,8 +50,7 @@ export function renderDecomposerSystemPrompt({
|
|
|
50
50
|
function render2TierPrompt({ maxTickets, maxTokenBudget, epicId = null }) {
|
|
51
51
|
// Sizing thresholds are sourced from the single DEFAULT_TASK_SIZING constant
|
|
52
52
|
// (ticket-validator-sizing.js) so the prompt and the validator cannot drift.
|
|
53
|
-
const { softFiles, hardFiles,
|
|
54
|
-
DEFAULT_TASK_SIZING;
|
|
53
|
+
const { softFiles, hardFiles, softAcceptanceCount } = DEFAULT_TASK_SIZING;
|
|
55
54
|
// Deliverable-granularity definition + single-consumer merge rule + the
|
|
56
55
|
// soft envelope-floor heuristic are sourced from the single
|
|
57
56
|
// DELIVERABLE_GRANULARITY_GUIDANCE constant (ticket-validator-sizing.js) so
|
|
@@ -177,7 +176,22 @@ ${envelopeFloor}
|
|
|
177
176
|
|
|
178
177
|
- A Story touching more than **${softFiles} files** (\`softFiles\`) emits an advisory width finding — a nudge to check cohesion or declare \`wide\`.
|
|
179
178
|
- A Story touching more than **${hardFiles} files** (\`hardFiles\`) is **rejected** unless it declares \`wide\` with a reason.
|
|
180
|
-
-
|
|
179
|
+
- Acceptance mass is **advisory only**: more than **${softAcceptanceCount} acceptance items** (\`softAcceptanceCount\`) emits an advisory warning. There is NO hard acceptance ceiling — a long binding contract is a signal to re-check cohesion, never a reason to fragment one coherent capability into dependent slices.
|
|
180
|
+
|
|
181
|
+
#### DELIVERY-SCHEDULE SIMULATION — the story count must earn itself:
|
|
182
|
+
|
|
183
|
+
Before emitting, simulate the delivery schedule your plan implies, and judge the plan by its schedule — not by how tidy the taxonomy looks:
|
|
184
|
+
|
|
185
|
+
1. **Build the wave schedule.** A Story runs only after every \`depends_on\` completes, and two Stories that name the same file in \`changes[]\` cannot run in the same wave (the scheduler serializes file-overlapping Stories even when no \`depends_on\` edge links them).
|
|
186
|
+
2. **Compute the parallelism yield**: story count ÷ critical-path length in waves. A yield near 1.0 means the plan is a serial chain — N Stories that deliver no faster than one Story while paying N delivery sessions (hydration, branch, PR, review, CI).
|
|
187
|
+
3. **Every Story must earn its slot** by at least one of:
|
|
188
|
+
- **(a) parallelism** — it actually runs concurrently with a sibling in the schedule you just built ("logically independent" does not count; *schedule*-independent does);
|
|
189
|
+
- **(b) risk isolation** — it isolates a consumer-facing behavior change or high-risk cutover into its own reviewable, revertable unit;
|
|
190
|
+
- **(c) envelope pressure** — merged into its neighbor it would exceed the one-pass delivery envelope (\`maxTokenBudget\`).
|
|
191
|
+
4. **A dependent link with none of those justifications merges into its consumer.** This generalizes the single-consumer merge rule from pairs to chains.
|
|
192
|
+
5. **Hot-file rule.** When one file appears in the \`changes[]\` of more than a third of your Stories, the slicing axis cuts across a shared seam — merge the Stories that co-edit it, or re-slice along the seam so each Story owns its files.
|
|
193
|
+
|
|
194
|
+
End each Story's \`reason_to_exist\` with its justification letter and one clause, e.g. "… (a: runs in wave 1 alongside <slug>)" or "(b: isolates the auto-merge default change)". A reason that names only a topic ("config work", "docs") with no justification is a merge signal.
|
|
181
195
|
|
|
182
196
|
#### \`wide\` DECLARATION (optional — for legitimately broad changes):
|
|
183
197
|
|