mandrel 1.85.0 → 1.87.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/instructions.md +7 -0
- package/.agents/rules/git-conventions.md +45 -0
- package/.agents/scripts/boot-sweep.js +215 -0
- package/.agents/scripts/epic-deliver-prepare.js +55 -0
- package/.agents/scripts/git-cleanup.js +8 -0
- package/.agents/scripts/lib/checks/subagent-agent-tool-required.js +107 -30
- package/.agents/scripts/lib/epic-plan-ideation.js +24 -3
- package/.agents/scripts/lib/framework-version.js +210 -0
- package/.agents/scripts/lib/orchestration/context-hydration-engine.js +7 -22
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +330 -6
- package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +34 -3
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +102 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +83 -30
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +85 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +34 -3
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +71 -4
- 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 +239 -57
- package/.agents/scripts/lib/story-body/story-body.js +81 -4
- package/.agents/scripts/providers/github/tickets.js +18 -1
- package/.agents/scripts/single-story-init.js +7 -51
- package/.agents/skills/core/epic-plan-consolidate/SKILL.md +7 -2
- package/.agents/skills/core/epic-plan-premortem/SKILL.md +8 -2
- package/.agents/skills/skills.index.json +3 -3
- package/.agents/skills/stack/architecture/subagent-orchestration/SKILL.md +36 -8
- package/.agents/workflows/git-cleanup.md +72 -18
- package/.agents/workflows/git-deliver.md +36 -0
- package/.agents/workflows/helpers/acceptance-self-eval.md +23 -1
- package/.agents/workflows/helpers/deliver-epic-reference.md +19 -13
- package/.agents/workflows/helpers/deliver-epic.md +47 -3
- package/.agents/workflows/helpers/deliver-stories.md +16 -3
- package/.agents/workflows/helpers/epic-audit.md +60 -2
- package/.agents/workflows/helpers/parallel-tooling.md +9 -2
- package/.agents/workflows/helpers/plan-epic.md +32 -14
- package/.agents/workflows/loops/nightly-audit.md +9 -1
- package/.agents/workflows/plan.md +32 -4
- package/docs/CHANGELOG.md +27 -0
- package/package.json +1 -1
|
@@ -1,27 +1,51 @@
|
|
|
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
|
+
* - Content-merged (Story #4396, report-only): a plan candidate the
|
|
32
|
+
* `git-cleanup` planner classified `detectedBy: 'content-merged'`
|
|
33
|
+
* (Story #4395's `git merge-tree --write-tree` content-equivalence
|
|
34
|
+
* probe) is a **weaker** signal than a merged PR or git ancestry —
|
|
35
|
+
* no CI/GitHub merge check ever validated its exact diff. This
|
|
36
|
+
* engine never reaps on that signal alone: content-merged
|
|
37
|
+
* candidates are pulled out of the plan before protection +
|
|
38
|
+
* execute and surfaced under `contentMerged` in the envelope so
|
|
39
|
+
* the operator can route them to `/git-cleanup` for a confirmed,
|
|
40
|
+
* eyeballed reap.
|
|
41
|
+
* - Never touches the stash stack.
|
|
42
|
+
* - Errors are caught and surfaced in the envelope. Callers MUST NOT
|
|
43
|
+
* propagate sweep failures — the host proceeds either way.
|
|
44
|
+
*
|
|
45
|
+
* `sweepMergedStoryBranches` is a thin preset over the engine tuned for
|
|
46
|
+
* the boot path (`include: story-*`, `exclude: <current story branch>`,
|
|
47
|
+
* `fastForward: false`). Its exported name, signature, and result
|
|
48
|
+
* envelope are unchanged from the pre-engine implementation.
|
|
25
49
|
*
|
|
26
50
|
* Re-exports the same `planCleanup` / `executeCleanup` injection seams so
|
|
27
51
|
* tests can stub git/`gh` without touching the CLI.
|
|
@@ -30,7 +54,9 @@
|
|
|
30
54
|
import {
|
|
31
55
|
buildGlobFilter,
|
|
32
56
|
executeCleanup as defaultExecuteCleanup,
|
|
57
|
+
executeFastForward as defaultExecuteFastForward,
|
|
33
58
|
planCleanup as defaultPlanCleanup,
|
|
59
|
+
planFastForward as defaultPlanFastForward,
|
|
34
60
|
} from '../git-cleanup.js';
|
|
35
61
|
import { evaluateProtection as defaultEvaluateProtection } from './single-story-sweep/protection.js';
|
|
36
62
|
import { acquireSweepLock as defaultAcquireSweepLock } from './single-story-sweep/sweep-lock.js';
|
|
@@ -38,15 +64,20 @@ import { acquireSweepLock as defaultAcquireSweepLock } from './single-story-swee
|
|
|
38
64
|
const STORY_BRANCH_INCLUDE = 'story-*';
|
|
39
65
|
|
|
40
66
|
/**
|
|
41
|
-
*
|
|
67
|
+
* Scope-agnostic merged-branch sweep engine.
|
|
42
68
|
*
|
|
43
69
|
* @param {{
|
|
44
70
|
* cwd: string,
|
|
45
71
|
* baseBranch: string,
|
|
46
|
-
*
|
|
72
|
+
* include?: string[],
|
|
73
|
+
* exclude?: string[],
|
|
74
|
+
* fastForward?: boolean,
|
|
47
75
|
* logger?: { info?: (m: string) => void, warn?: (m: string) => void },
|
|
76
|
+
* logTag?: string,
|
|
48
77
|
* planCleanupFn?: typeof defaultPlanCleanup,
|
|
49
78
|
* executeCleanupFn?: typeof defaultExecuteCleanup,
|
|
79
|
+
* planFastForwardFn?: typeof defaultPlanFastForward,
|
|
80
|
+
* executeFastForwardFn?: typeof defaultExecuteFastForward,
|
|
50
81
|
* protectionFn?: typeof defaultEvaluateProtection,
|
|
51
82
|
* protectionCtx?: object,
|
|
52
83
|
* acquireLockFn?: typeof defaultAcquireSweepLock,
|
|
@@ -60,18 +91,25 @@ const STORY_BRANCH_INCLUDE = 'story-*';
|
|
|
60
91
|
* localDeleted: number,
|
|
61
92
|
* remoteDeleted: number,
|
|
62
93
|
* protected: Array<{ branch: string, reason: string, worktreePath?: string|null }>,
|
|
94
|
+
* contentMerged: Array<{ branch: string, worktreePath: string|null }>,
|
|
63
95
|
* failures: Array<{ branch: string|null, scope: string, stderr?: string }>,
|
|
96
|
+
* fastForward?: object,
|
|
64
97
|
* error?: string,
|
|
65
98
|
* reason?: string,
|
|
66
99
|
* }>}
|
|
67
100
|
*/
|
|
68
|
-
export async function
|
|
101
|
+
export async function sweepMergedBranches({
|
|
69
102
|
cwd,
|
|
70
103
|
baseBranch,
|
|
71
|
-
|
|
104
|
+
include = ['*'],
|
|
105
|
+
exclude = [],
|
|
106
|
+
fastForward = false,
|
|
72
107
|
logger = {},
|
|
108
|
+
logTag = '[sweep]',
|
|
73
109
|
planCleanupFn = defaultPlanCleanup,
|
|
74
110
|
executeCleanupFn = defaultExecuteCleanup,
|
|
111
|
+
planFastForwardFn = defaultPlanFastForward,
|
|
112
|
+
executeFastForwardFn = defaultExecuteFastForward,
|
|
75
113
|
protectionFn = defaultEvaluateProtection,
|
|
76
114
|
protectionCtx = null,
|
|
77
115
|
acquireLockFn = defaultAcquireSweepLock,
|
|
@@ -92,16 +130,13 @@ export async function sweepMergedStoryBranches({
|
|
|
92
130
|
|
|
93
131
|
// Optional lock acquisition. Skip silently when no lockPath is
|
|
94
132
|
// supplied (e.g. unit tests, callers that opt out). Contention is
|
|
95
|
-
// non-fatal — return a skipped result and let
|
|
133
|
+
// non-fatal — return a skipped result and let the host continue.
|
|
96
134
|
let releaseLock = () => {};
|
|
97
135
|
if (lockPath) {
|
|
98
|
-
const lockResult = acquireLockFn({
|
|
99
|
-
lockPath,
|
|
100
|
-
timeoutMs: lockTimeoutMs,
|
|
101
|
-
});
|
|
136
|
+
const lockResult = acquireLockFn({ lockPath, timeoutMs: lockTimeoutMs });
|
|
102
137
|
if (!lockResult.acquired) {
|
|
103
138
|
log.warn(
|
|
104
|
-
|
|
139
|
+
`${logTag} lock not acquired (${lockResult.reason}${
|
|
105
140
|
lockResult.detail ? `: ${lockResult.detail}` : ''
|
|
106
141
|
}); skipping sweep.`,
|
|
107
142
|
);
|
|
@@ -113,6 +148,7 @@ export async function sweepMergedStoryBranches({
|
|
|
113
148
|
localDeleted: 0,
|
|
114
149
|
remoteDeleted: 0,
|
|
115
150
|
protected: [],
|
|
151
|
+
contentMerged: [],
|
|
116
152
|
failures: [],
|
|
117
153
|
};
|
|
118
154
|
}
|
|
@@ -120,16 +156,28 @@ export async function sweepMergedStoryBranches({
|
|
|
120
156
|
}
|
|
121
157
|
|
|
122
158
|
try {
|
|
123
|
-
|
|
159
|
+
const reap = await runSweepUnderLock({
|
|
124
160
|
cwd,
|
|
125
161
|
baseBranch,
|
|
126
|
-
|
|
162
|
+
include,
|
|
163
|
+
exclude,
|
|
127
164
|
log,
|
|
165
|
+
logTag,
|
|
128
166
|
planCleanupFn,
|
|
129
167
|
executeCleanupFn,
|
|
130
168
|
protectionFn,
|
|
131
169
|
protectionCtx,
|
|
132
170
|
});
|
|
171
|
+
if (!fastForward) return reap;
|
|
172
|
+
const ff = runFastForwardStep({
|
|
173
|
+
cwd,
|
|
174
|
+
baseBranch,
|
|
175
|
+
log,
|
|
176
|
+
logTag,
|
|
177
|
+
planFastForwardFn,
|
|
178
|
+
executeFastForwardFn,
|
|
179
|
+
});
|
|
180
|
+
return { ...reap, fastForward: ff };
|
|
133
181
|
} finally {
|
|
134
182
|
try {
|
|
135
183
|
releaseLock();
|
|
@@ -139,41 +187,109 @@ export async function sweepMergedStoryBranches({
|
|
|
139
187
|
}
|
|
140
188
|
}
|
|
141
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Sweep merged `story-*` branches in `cwd`. Preset over
|
|
192
|
+
* {@link sweepMergedBranches} for the boot path: it pins the `story-*`
|
|
193
|
+
* include glob, excludes the current run's `currentStoryBranch`, and
|
|
194
|
+
* keeps `fastForward` off (the boot caller fast-forwards the base branch
|
|
195
|
+
* separately). Exported name, signature, and result-envelope shape are
|
|
196
|
+
* unchanged from the pre-engine implementation.
|
|
197
|
+
*
|
|
198
|
+
* @param {{
|
|
199
|
+
* cwd: string,
|
|
200
|
+
* baseBranch: string,
|
|
201
|
+
* currentStoryBranch: string,
|
|
202
|
+
* logger?: { info?: (m: string) => void, warn?: (m: string) => void },
|
|
203
|
+
* planCleanupFn?: typeof defaultPlanCleanup,
|
|
204
|
+
* executeCleanupFn?: typeof defaultExecuteCleanup,
|
|
205
|
+
* protectionFn?: typeof defaultEvaluateProtection,
|
|
206
|
+
* protectionCtx?: object,
|
|
207
|
+
* acquireLockFn?: typeof defaultAcquireSweepLock,
|
|
208
|
+
* lockPath?: string|null,
|
|
209
|
+
* lockTimeoutMs?: number,
|
|
210
|
+
* }} args
|
|
211
|
+
* @returns {Promise<object>} the {@link sweepMergedBranches} envelope.
|
|
212
|
+
*/
|
|
213
|
+
export function sweepMergedStoryBranches(args = {}) {
|
|
214
|
+
const { currentStoryBranch } = args;
|
|
215
|
+
const exclude =
|
|
216
|
+
typeof currentStoryBranch === 'string' && currentStoryBranch.length > 0
|
|
217
|
+
? [currentStoryBranch]
|
|
218
|
+
: [];
|
|
219
|
+
return sweepMergedBranches({
|
|
220
|
+
...args,
|
|
221
|
+
include: [STORY_BRANCH_INCLUDE],
|
|
222
|
+
exclude,
|
|
223
|
+
fastForward: false,
|
|
224
|
+
logTag: '[single-story-sweep]',
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Split a plan's candidates into the reapable set and the report-only
|
|
230
|
+
* `content-merged` set (Story #4396). A candidate the `git-cleanup`
|
|
231
|
+
* planner classified `detectedBy: 'content-merged'` (Story #4395's
|
|
232
|
+
* `git merge-tree --write-tree` probe) never reaches protection or
|
|
233
|
+
* `executeCleanup` — it is a weaker signal than a merged PR or git
|
|
234
|
+
* ancestry, so the engine only reports it for the operator to route to
|
|
235
|
+
* `/git-cleanup`.
|
|
236
|
+
*/
|
|
237
|
+
function partitionContentMerged(candidates) {
|
|
238
|
+
const contentMerged = [];
|
|
239
|
+
const reapCandidates = [];
|
|
240
|
+
for (const candidate of candidates) {
|
|
241
|
+
if (candidate.detectedBy === 'content-merged') {
|
|
242
|
+
contentMerged.push({
|
|
243
|
+
branch: candidate.branch,
|
|
244
|
+
worktreePath: candidate.worktreePath ?? null,
|
|
245
|
+
});
|
|
246
|
+
} else {
|
|
247
|
+
reapCandidates.push(candidate);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { contentMerged, reapCandidates };
|
|
251
|
+
}
|
|
252
|
+
|
|
142
253
|
/**
|
|
143
254
|
* Inner: the plan + protect + execute pipeline. Kept separate so the
|
|
144
|
-
* outer
|
|
145
|
-
* acquire/release wrapper.
|
|
255
|
+
* outer engine can stay focused on the lock and fast-forward wrappers.
|
|
146
256
|
*/
|
|
147
257
|
async function runSweepUnderLock({
|
|
148
258
|
cwd,
|
|
149
259
|
baseBranch,
|
|
150
|
-
|
|
260
|
+
include,
|
|
261
|
+
exclude,
|
|
151
262
|
log,
|
|
263
|
+
logTag,
|
|
152
264
|
planCleanupFn,
|
|
153
265
|
executeCleanupFn,
|
|
154
266
|
protectionFn,
|
|
155
267
|
protectionCtx,
|
|
156
268
|
}) {
|
|
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
|
-
});
|
|
269
|
+
const filter = buildGlobFilter({ include, exclude });
|
|
165
270
|
|
|
166
271
|
let plan;
|
|
167
272
|
try {
|
|
168
273
|
plan = planCleanupFn({ cwd, baseBranch, filter });
|
|
169
274
|
} catch (err) {
|
|
170
275
|
const msg = err?.message ?? String(err);
|
|
171
|
-
log.warn(
|
|
276
|
+
log.warn(`${logTag} plan failed: ${msg}`);
|
|
172
277
|
return zeroResult({ error: `plan: ${msg}` });
|
|
173
278
|
}
|
|
174
279
|
|
|
175
|
-
|
|
176
|
-
|
|
280
|
+
const { contentMerged, reapCandidates } = partitionContentMerged(
|
|
281
|
+
plan.candidates,
|
|
282
|
+
);
|
|
283
|
+
if (contentMerged.length > 0) {
|
|
284
|
+
log.info(
|
|
285
|
+
`${logTag} ${contentMerged.length} content-merged branch(es) detected (report-only, not reaped): ${contentMerged
|
|
286
|
+
.map((c) => c.branch)
|
|
287
|
+
.join(', ')}.`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (reapCandidates.length === 0) {
|
|
292
|
+
log.info(`${logTag} no merged branches to reap.`);
|
|
177
293
|
return {
|
|
178
294
|
ok: true,
|
|
179
295
|
skipped: false,
|
|
@@ -181,49 +297,76 @@ async function runSweepUnderLock({
|
|
|
181
297
|
localDeleted: 0,
|
|
182
298
|
remoteDeleted: 0,
|
|
183
299
|
protected: [],
|
|
300
|
+
contentMerged,
|
|
184
301
|
failures: [],
|
|
185
302
|
};
|
|
186
303
|
}
|
|
187
304
|
|
|
188
305
|
const { reapable, protectedList } = await partitionCandidates({
|
|
189
|
-
candidates:
|
|
306
|
+
candidates: reapCandidates,
|
|
190
307
|
protectionFn,
|
|
191
308
|
protectionCtx,
|
|
192
309
|
log,
|
|
310
|
+
logTag,
|
|
193
311
|
});
|
|
194
312
|
|
|
195
313
|
if (reapable.length === 0) {
|
|
196
314
|
log.info(
|
|
197
|
-
|
|
315
|
+
`${logTag} all ${reapCandidates.length} candidate(s) protected; no reap.`,
|
|
198
316
|
);
|
|
199
317
|
return {
|
|
200
318
|
ok: true,
|
|
201
319
|
skipped: false,
|
|
202
|
-
candidates:
|
|
320
|
+
candidates: reapCandidates.length,
|
|
203
321
|
localDeleted: 0,
|
|
204
322
|
remoteDeleted: 0,
|
|
205
323
|
protected: protectedList,
|
|
324
|
+
contentMerged,
|
|
206
325
|
failures: [],
|
|
207
326
|
};
|
|
208
327
|
}
|
|
209
328
|
|
|
329
|
+
return executeReap({
|
|
330
|
+
reapable,
|
|
331
|
+
protectedList,
|
|
332
|
+
contentMerged,
|
|
333
|
+
candidateCount: reapCandidates.length,
|
|
334
|
+
cwd,
|
|
335
|
+
executeCleanupFn,
|
|
336
|
+
log,
|
|
337
|
+
logTag,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Execute the reap plan for the reapable candidates and shape the result
|
|
343
|
+
* envelope. Split out of {@link runSweepUnderLock} so each function keeps
|
|
344
|
+
* a single responsibility.
|
|
345
|
+
*/
|
|
346
|
+
function executeReap({
|
|
347
|
+
reapable,
|
|
348
|
+
protectedList,
|
|
349
|
+
contentMerged,
|
|
350
|
+
candidateCount,
|
|
351
|
+
cwd,
|
|
352
|
+
executeCleanupFn,
|
|
353
|
+
log,
|
|
354
|
+
logTag,
|
|
355
|
+
}) {
|
|
210
356
|
let result;
|
|
211
357
|
try {
|
|
212
|
-
result = executeCleanupFn({
|
|
213
|
-
candidates: reapable,
|
|
214
|
-
cwd,
|
|
215
|
-
remote: true,
|
|
216
|
-
});
|
|
358
|
+
result = executeCleanupFn({ candidates: reapable, cwd, remote: true });
|
|
217
359
|
} catch (err) {
|
|
218
360
|
const msg = err?.message ?? String(err);
|
|
219
|
-
log.warn(
|
|
361
|
+
log.warn(`${logTag} execute failed: ${msg}`);
|
|
220
362
|
return {
|
|
221
363
|
ok: false,
|
|
222
364
|
skipped: false,
|
|
223
|
-
candidates:
|
|
365
|
+
candidates: candidateCount,
|
|
224
366
|
localDeleted: 0,
|
|
225
367
|
remoteDeleted: 0,
|
|
226
368
|
protected: protectedList,
|
|
369
|
+
contentMerged,
|
|
227
370
|
failures: [{ branch: null, scope: 'execute', stderr: msg }],
|
|
228
371
|
error: `execute: ${msg}`,
|
|
229
372
|
};
|
|
@@ -241,25 +384,64 @@ async function runSweepUnderLock({
|
|
|
241
384
|
const summary = `${localDeleted} local + ${remoteDeleted} remote${protectedSummary}`;
|
|
242
385
|
if (result.ok) {
|
|
243
386
|
log.info(
|
|
244
|
-
|
|
387
|
+
`${logTag} reaped ${summary}${reapedBranches ? ` [${reapedBranches}]` : ''}.`,
|
|
245
388
|
);
|
|
246
389
|
} else {
|
|
247
390
|
log.warn(
|
|
248
|
-
|
|
391
|
+
`${logTag} reaped ${summary} with ${result.failures.length} failure(s) — host continues.`,
|
|
249
392
|
);
|
|
250
393
|
}
|
|
251
394
|
|
|
252
395
|
return {
|
|
253
396
|
ok: result.ok,
|
|
254
397
|
skipped: false,
|
|
255
|
-
candidates:
|
|
398
|
+
candidates: candidateCount,
|
|
256
399
|
localDeleted,
|
|
257
400
|
remoteDeleted,
|
|
258
401
|
protected: protectedList,
|
|
402
|
+
contentMerged,
|
|
259
403
|
failures: result.failures,
|
|
260
404
|
};
|
|
261
405
|
}
|
|
262
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Best-effort fast-forward of the base branch through the git-cleanup
|
|
409
|
+
* fast-forward phase. Never throws — a failed fast-forward is logged and
|
|
410
|
+
* returned as `{ ok: false, error }` but must never fail the sweep.
|
|
411
|
+
*/
|
|
412
|
+
function runFastForwardStep({
|
|
413
|
+
cwd,
|
|
414
|
+
baseBranch,
|
|
415
|
+
log,
|
|
416
|
+
logTag,
|
|
417
|
+
planFastForwardFn,
|
|
418
|
+
executeFastForwardFn,
|
|
419
|
+
}) {
|
|
420
|
+
try {
|
|
421
|
+
const plan = planFastForwardFn({ cwd, baseBranch });
|
|
422
|
+
const ff = executeFastForwardFn({
|
|
423
|
+
cwd,
|
|
424
|
+
baseBranch,
|
|
425
|
+
plan,
|
|
426
|
+
logger: {
|
|
427
|
+
info: (m) => log.info(m.replace(/^\[git-cleanup\]\s*/, `${logTag} `)),
|
|
428
|
+
warn: (m) => log.warn(m.replace(/^\[git-cleanup\]\s*/, `${logTag} `)),
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
return {
|
|
432
|
+
ok: ff.ok !== false,
|
|
433
|
+
applied: !!ff.applied,
|
|
434
|
+
skipped: !!ff.skipped,
|
|
435
|
+
behind: ff.behind ?? null,
|
|
436
|
+
reason: ff.reason ?? null,
|
|
437
|
+
};
|
|
438
|
+
} catch (err) {
|
|
439
|
+
const msg = err?.message ?? String(err);
|
|
440
|
+
log.warn(`${logTag} fast-forward failed: ${msg}`);
|
|
441
|
+
return { ok: false, applied: false, skipped: false, error: msg };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
263
445
|
/**
|
|
264
446
|
* Iterate plan candidates and split them into `reapable` (safe to pass
|
|
265
447
|
* to executeCleanup) and `protectedList` (skipped, with a reason).
|
|
@@ -270,14 +452,15 @@ async function runSweepUnderLock({
|
|
|
270
452
|
*
|
|
271
453
|
* When no `protectionCtx` is supplied (legacy callers, unit tests),
|
|
272
454
|
* the protection check is bypassed entirely and every candidate is
|
|
273
|
-
* reapable. The CLI
|
|
274
|
-
*
|
|
455
|
+
* reapable. The boot-path CLI surfaces always supply a ctx, so this
|
|
456
|
+
* fallback never fires in production.
|
|
275
457
|
*/
|
|
276
458
|
async function partitionCandidates({
|
|
277
459
|
candidates,
|
|
278
460
|
protectionFn,
|
|
279
461
|
protectionCtx,
|
|
280
462
|
log,
|
|
463
|
+
logTag,
|
|
281
464
|
}) {
|
|
282
465
|
const reapable = [];
|
|
283
466
|
const protectedList = [];
|
|
@@ -291,7 +474,7 @@ async function partitionCandidates({
|
|
|
291
474
|
verdict = await protectionFn({ candidate, ctx: protectionCtx });
|
|
292
475
|
} catch (err) {
|
|
293
476
|
const reason = `protection-eval-error: ${err?.message ?? err}`;
|
|
294
|
-
log.warn(
|
|
477
|
+
log.warn(`${logTag} protected ${candidate.branch}: ${reason}`);
|
|
295
478
|
protectedList.push({
|
|
296
479
|
branch: candidate.branch,
|
|
297
480
|
reason,
|
|
@@ -300,9 +483,7 @@ async function partitionCandidates({
|
|
|
300
483
|
continue;
|
|
301
484
|
}
|
|
302
485
|
if (verdict?.protected) {
|
|
303
|
-
log.info(
|
|
304
|
-
`[single-story-sweep] protected ${candidate.branch}: ${verdict.reason}`,
|
|
305
|
-
);
|
|
486
|
+
log.info(`${logTag} protected ${candidate.branch}: ${verdict.reason}`);
|
|
306
487
|
protectedList.push({
|
|
307
488
|
branch: candidate.branch,
|
|
308
489
|
reason: verdict.reason ?? 'unknown',
|
|
@@ -323,6 +504,7 @@ function zeroResult({ error }) {
|
|
|
323
504
|
localDeleted: 0,
|
|
324
505
|
remoteDeleted: 0,
|
|
325
506
|
protected: [],
|
|
507
|
+
contentMerged: [],
|
|
326
508
|
failures: [],
|
|
327
509
|
error,
|
|
328
510
|
};
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
* @module story-body
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
|
+
import {
|
|
46
|
+
AUTHORED_MARKER_LINE_RE,
|
|
47
|
+
authoredMarkerLine,
|
|
48
|
+
} from '../framework-version.js';
|
|
45
49
|
import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js';
|
|
46
50
|
|
|
47
51
|
// ---------------------------------------------------------------------------
|
|
@@ -75,6 +79,8 @@ import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js
|
|
|
75
79
|
* @property {string|null} reason_to_exist - One-sentence cohesion reason ("why this Story exists"), or null.
|
|
76
80
|
* @property {string[]} depends_on - Blocking story slugs / issue refs.
|
|
77
81
|
* @property {number|null} estimated_test_files - Test surface count or null.
|
|
82
|
+
* @property {string|null} mandrel_version - Framework version stamped at authoring, or null.
|
|
83
|
+
* @property {string|null} authored_at - Authoring date (YYYY-MM-DD) stamped at authoring, or null.
|
|
78
84
|
*/
|
|
79
85
|
|
|
80
86
|
/**
|
|
@@ -253,14 +259,21 @@ const META_BLOCK_RE = /<!--\s*meta:\s*(\{[\s\S]*?\})\s*-->/;
|
|
|
253
259
|
* otherwise-valid Story body. A parse failure degrades to the absent-meta
|
|
254
260
|
* defaults instead of throwing.
|
|
255
261
|
*
|
|
262
|
+
* The `mandrel_version` / `authored_at` provenance stamp (written once at
|
|
263
|
+
* authoring time by the ticket-creation path) is recovered here too so a later
|
|
264
|
+
* `parse → serialize` preserves the originally-authored version verbatim
|
|
265
|
+
* rather than dropping or re-deriving it.
|
|
266
|
+
*
|
|
256
267
|
* @param {string} markdown
|
|
257
|
-
* @returns {{ wide: { reason: string }|null, reason_to_exist: string|null, estimated_test_files: number|null }}
|
|
268
|
+
* @returns {{ wide: { reason: string }|null, reason_to_exist: string|null, estimated_test_files: number|null, mandrel_version: string|null, authored_at: string|null }}
|
|
258
269
|
*/
|
|
259
270
|
function extractMeta(markdown) {
|
|
260
271
|
const result = {
|
|
261
272
|
wide: null,
|
|
262
273
|
reason_to_exist: null,
|
|
263
274
|
estimated_test_files: null,
|
|
275
|
+
mandrel_version: null,
|
|
276
|
+
authored_at: null,
|
|
264
277
|
};
|
|
265
278
|
const match = markdown.match(META_BLOCK_RE);
|
|
266
279
|
if (!match) return result;
|
|
@@ -279,6 +292,15 @@ function extractMeta(markdown) {
|
|
|
279
292
|
if (typeof parsed.estimated_test_files === 'number') {
|
|
280
293
|
result.estimated_test_files = parsed.estimated_test_files;
|
|
281
294
|
}
|
|
295
|
+
if (
|
|
296
|
+
typeof parsed.mandrel_version === 'string' &&
|
|
297
|
+
parsed.mandrel_version.trim()
|
|
298
|
+
) {
|
|
299
|
+
result.mandrel_version = parsed.mandrel_version.trim();
|
|
300
|
+
}
|
|
301
|
+
if (typeof parsed.authored_at === 'string' && parsed.authored_at.trim()) {
|
|
302
|
+
result.authored_at = parsed.authored_at.trim();
|
|
303
|
+
}
|
|
282
304
|
return result;
|
|
283
305
|
}
|
|
284
306
|
|
|
@@ -409,6 +431,14 @@ function splitSections(markdown) {
|
|
|
409
431
|
continue;
|
|
410
432
|
}
|
|
411
433
|
|
|
434
|
+
// The visible `> 🏷️ Authored with Mandrel …` provenance marker is
|
|
435
|
+
// machine-managed metadata too (emitted alongside the meta block by the
|
|
436
|
+
// authoring path). Skip it so it never bleeds into the trailing structured
|
|
437
|
+
// section (e.g. `## Verify`); the value round-trips via the meta block.
|
|
438
|
+
if (AUTHORED_MARKER_LINE_RE.test(line)) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
|
|
412
442
|
if (inPreamble) {
|
|
413
443
|
preambleLines.push(line);
|
|
414
444
|
} else if (currentSection !== null) {
|
|
@@ -456,6 +486,8 @@ function parseLegacyStringBody(input, preamble, footer) {
|
|
|
456
486
|
reason_to_exist: null,
|
|
457
487
|
depends_on: extractBlockedBy(footer),
|
|
458
488
|
estimated_test_files: null,
|
|
489
|
+
mandrel_version: null,
|
|
490
|
+
authored_at: null,
|
|
459
491
|
};
|
|
460
492
|
return {
|
|
461
493
|
body,
|
|
@@ -602,6 +634,8 @@ export function parse(input) {
|
|
|
602
634
|
const estimated_test_files = meta.estimated_test_files;
|
|
603
635
|
const wide = meta.wide;
|
|
604
636
|
const reason_to_exist = meta.reason_to_exist;
|
|
637
|
+
const mandrel_version = meta.mandrel_version;
|
|
638
|
+
const authored_at = meta.authored_at;
|
|
605
639
|
if (estimated_test_files === null) {
|
|
606
640
|
warnings.push(
|
|
607
641
|
'test-surface-unestimated: estimated_test_files not present.',
|
|
@@ -619,6 +653,8 @@ export function parse(input) {
|
|
|
619
653
|
reason_to_exist,
|
|
620
654
|
depends_on: dependsOn,
|
|
621
655
|
estimated_test_files,
|
|
656
|
+
mandrel_version,
|
|
657
|
+
authored_at,
|
|
622
658
|
};
|
|
623
659
|
|
|
624
660
|
return {
|
|
@@ -699,6 +735,16 @@ function parseStructuredObject(obj) {
|
|
|
699
735
|
);
|
|
700
736
|
}
|
|
701
737
|
|
|
738
|
+
// Provenance stamp (preserved verbatim; never re-derived here).
|
|
739
|
+
const mandrel_version =
|
|
740
|
+
typeof obj.mandrel_version === 'string' && obj.mandrel_version.trim()
|
|
741
|
+
? obj.mandrel_version.trim()
|
|
742
|
+
: null;
|
|
743
|
+
const authored_at =
|
|
744
|
+
typeof obj.authored_at === 'string' && obj.authored_at.trim()
|
|
745
|
+
? obj.authored_at.trim()
|
|
746
|
+
: null;
|
|
747
|
+
|
|
702
748
|
const body = {
|
|
703
749
|
goal,
|
|
704
750
|
changes,
|
|
@@ -710,6 +756,8 @@ function parseStructuredObject(obj) {
|
|
|
710
756
|
reason_to_exist,
|
|
711
757
|
depends_on,
|
|
712
758
|
estimated_test_files,
|
|
759
|
+
mandrel_version,
|
|
760
|
+
authored_at,
|
|
713
761
|
};
|
|
714
762
|
|
|
715
763
|
return {
|
|
@@ -812,9 +860,11 @@ const SERIALIZE_SECTIONS = [
|
|
|
812
860
|
* `estimated_test_files`). Returns the empty string when no meta field is
|
|
813
861
|
* present so {@link serialize} appends nothing.
|
|
814
862
|
*
|
|
815
|
-
* Key insertion order (`wide` → `reason_to_exist` → `estimated_test_files`
|
|
816
|
-
* is load-bearing: it fixes the serialized
|
|
817
|
-
* meta round-trip and the unit suite assert
|
|
863
|
+
* Key insertion order (`wide` → `reason_to_exist` → `estimated_test_files` →
|
|
864
|
+
* `mandrel_version` → `authored_at`) is load-bearing: it fixes the serialized
|
|
865
|
+
* JSON byte sequence the parser's meta round-trip and the unit suite assert
|
|
866
|
+
* against. The provenance stamp keys are appended **last** so every
|
|
867
|
+
* pre-existing (stamp-less) body serialises byte-identically to before.
|
|
818
868
|
*
|
|
819
869
|
* @param {StoryBody} body
|
|
820
870
|
* @returns {string}
|
|
@@ -832,10 +882,36 @@ function serializeMetaBlock(body) {
|
|
|
832
882
|
if (typeof body.estimated_test_files === 'number') {
|
|
833
883
|
metaFields.estimated_test_files = body.estimated_test_files;
|
|
834
884
|
}
|
|
885
|
+
if (typeof body.mandrel_version === 'string' && body.mandrel_version.trim()) {
|
|
886
|
+
metaFields.mandrel_version = body.mandrel_version.trim();
|
|
887
|
+
}
|
|
888
|
+
if (typeof body.authored_at === 'string' && body.authored_at.trim()) {
|
|
889
|
+
metaFields.authored_at = body.authored_at.trim();
|
|
890
|
+
}
|
|
835
891
|
if (Object.keys(metaFields).length === 0) return '';
|
|
836
892
|
return `\n\n<!-- meta: ${JSON.stringify(metaFields)} -->`;
|
|
837
893
|
}
|
|
838
894
|
|
|
895
|
+
/**
|
|
896
|
+
* Build the visible `> 🏷️ Authored with Mandrel v<version> · <date>` marker
|
|
897
|
+
* line when the body carries a complete provenance stamp
|
|
898
|
+
* (`mandrel_version` + `authored_at`). Emitted just above the meta block so it
|
|
899
|
+
* round-trips with the hidden field. Returns the empty string when either
|
|
900
|
+
* field is absent, so every pre-existing (stamp-less) body serialises
|
|
901
|
+
* byte-identically to before.
|
|
902
|
+
*
|
|
903
|
+
* @param {StoryBody} body
|
|
904
|
+
* @returns {string}
|
|
905
|
+
*/
|
|
906
|
+
function serializeAuthoredMarker(body) {
|
|
907
|
+
const version =
|
|
908
|
+
typeof body.mandrel_version === 'string' ? body.mandrel_version.trim() : '';
|
|
909
|
+
const authoredAt =
|
|
910
|
+
typeof body.authored_at === 'string' ? body.authored_at.trim() : '';
|
|
911
|
+
if (!version || !authoredAt) return '';
|
|
912
|
+
return `\n\n${authoredMarkerLine({ version, authoredAt })}`;
|
|
913
|
+
}
|
|
914
|
+
|
|
839
915
|
/**
|
|
840
916
|
* Build the optional `---` footer block (`parent` / `Epic` / `blocked by`
|
|
841
917
|
* lines). Returns the empty string when `opts.includeFooter` is falsy.
|
|
@@ -888,6 +964,7 @@ export function serialize(body, opts = {}) {
|
|
|
888
964
|
|
|
889
965
|
return (
|
|
890
966
|
sections.join('\n\n') +
|
|
967
|
+
serializeAuthoredMarker(body) +
|
|
891
968
|
serializeMetaBlock(body) +
|
|
892
969
|
serializeFooter(body, opts)
|
|
893
970
|
);
|