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.
Files changed (41) hide show
  1. package/.agents/instructions.md +7 -0
  2. package/.agents/rules/git-conventions.md +45 -0
  3. package/.agents/scripts/boot-sweep.js +215 -0
  4. package/.agents/scripts/epic-deliver-prepare.js +55 -0
  5. package/.agents/scripts/git-cleanup.js +8 -0
  6. package/.agents/scripts/lib/checks/subagent-agent-tool-required.js +107 -30
  7. package/.agents/scripts/lib/epic-plan-ideation.js +24 -3
  8. package/.agents/scripts/lib/framework-version.js +210 -0
  9. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +7 -22
  10. package/.agents/scripts/lib/orchestration/epic-cleanup.js +330 -6
  11. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +34 -3
  12. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +102 -7
  13. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +83 -30
  14. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +85 -1
  15. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +34 -3
  16. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +71 -4
  17. package/.agents/scripts/lib/orchestration/lifecycle/listeners/branch-cleaner.js +8 -3
  18. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/gate-failure.js +54 -6
  19. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +35 -4
  20. package/.agents/scripts/lib/single-story-sweep/protection-ctx.js +75 -0
  21. package/.agents/scripts/lib/single-story-sweep.js +239 -57
  22. package/.agents/scripts/lib/story-body/story-body.js +81 -4
  23. package/.agents/scripts/providers/github/tickets.js +18 -1
  24. package/.agents/scripts/single-story-init.js +7 -51
  25. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +7 -2
  26. package/.agents/skills/core/epic-plan-premortem/SKILL.md +8 -2
  27. package/.agents/skills/skills.index.json +3 -3
  28. package/.agents/skills/stack/architecture/subagent-orchestration/SKILL.md +36 -8
  29. package/.agents/workflows/git-cleanup.md +72 -18
  30. package/.agents/workflows/git-deliver.md +36 -0
  31. package/.agents/workflows/helpers/acceptance-self-eval.md +23 -1
  32. package/.agents/workflows/helpers/deliver-epic-reference.md +19 -13
  33. package/.agents/workflows/helpers/deliver-epic.md +47 -3
  34. package/.agents/workflows/helpers/deliver-stories.md +16 -3
  35. package/.agents/workflows/helpers/epic-audit.md +60 -2
  36. package/.agents/workflows/helpers/parallel-tooling.md +9 -2
  37. package/.agents/workflows/helpers/plan-epic.md +32 -14
  38. package/.agents/workflows/loops/nightly-audit.md +9 -1
  39. package/.agents/workflows/plan.md +32 -4
  40. package/docs/CHANGELOG.md +27 -0
  41. package/package.json +1 -1
@@ -1,27 +1,51 @@
1
1
  /**
2
- * single-story-sweep.js — Sweep merged `story-*` branches at init.
2
+ * single-story-sweep.js — the scope-agnostic merged-branch sweep engine
3
+ * plus the `story-*` boot-sweep preset.
3
4
  *
4
- * Wraps `git-cleanup-branches.js` with a fixed policy tuned for the
5
- * `/single-story-deliver` boot path:
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: `story-*` only (never touches `epic/*`, `story/<id>/*`, etc.).
8
- * - Mode: --execute --remote (delete local + origin + prune trackers).
9
- * - Skip: the current run's `storyBranch` is always excluded, even if a
10
- * stale PR for the same id were already merged.
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 in the result envelope under
18
- * `protected` so the operator can see what was skipped.
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 (init continues — same contract as a
29
+ * sweep is skipped (the host continues — same contract as a
22
30
  * plan failure).
23
- * - Errors are caught and surfaced in the envelope. The caller MUST NOT
24
- * propagate sweep failures story init proceeds either way.
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
- * Sweep merged `story-*` branches in `cwd`.
67
+ * Scope-agnostic merged-branch sweep engine.
42
68
  *
43
69
  * @param {{
44
70
  * cwd: string,
45
71
  * baseBranch: string,
46
- * currentStoryBranch: string,
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 sweepMergedStoryBranches({
101
+ export async function sweepMergedBranches({
69
102
  cwd,
70
103
  baseBranch,
71
- currentStoryBranch,
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 init continue.
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
- `[single-story-sweep] lock not acquired (${lockResult.reason}${
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
- return await runSweepUnderLock({
159
+ const reap = await runSweepUnderLock({
124
160
  cwd,
125
161
  baseBranch,
126
- currentStoryBranch,
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 `sweepMergedStoryBranches` can stay focused on the lock
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
- currentStoryBranch,
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(`[single-story-sweep] plan failed: ${msg}`);
276
+ log.warn(`${logTag} plan failed: ${msg}`);
172
277
  return zeroResult({ error: `plan: ${msg}` });
173
278
  }
174
279
 
175
- if (plan.candidates.length === 0) {
176
- log.info('[single-story-sweep] no merged story branches to reap.');
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: plan.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
- `[single-story-sweep] all ${plan.candidates.length} candidate(s) protected; no reap.`,
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: plan.candidates.length,
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(`[single-story-sweep] execute failed: ${msg}`);
361
+ log.warn(`${logTag} execute failed: ${msg}`);
220
362
  return {
221
363
  ok: false,
222
364
  skipped: false,
223
- candidates: plan.candidates.length,
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
- `[single-story-sweep] reaped ${summary}${reapedBranches ? ` [${reapedBranches}]` : ''}.`,
387
+ `${logTag} reaped ${summary}${reapedBranches ? ` [${reapedBranches}]` : ''}.`,
245
388
  );
246
389
  } else {
247
390
  log.warn(
248
- `[single-story-sweep] reaped ${summary} with ${result.failures.length} failure(s) — init continues.`,
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: plan.candidates.length,
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 surface in `single-story-init.js` always supplies
274
- * a ctx, so this fallback never fires in production.
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(`[single-story-sweep] protected ${candidate.branch}: ${reason}`);
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 JSON byte sequence the parser's
817
- * meta round-trip and the unit suite assert against.
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
  );