mandrel 1.86.0 → 1.88.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 (40) hide show
  1. package/.agents/docs/SDLC.md +15 -3
  2. package/.agents/docs/configuration.md +2 -0
  3. package/.agents/instructions.md +7 -0
  4. package/.agents/rules/git-conventions.md +13 -1
  5. package/.agents/schemas/agentrc.schema.json +12 -0
  6. package/.agents/scripts/boot-sweep.js +36 -4
  7. package/.agents/scripts/git-cleanup.js +8 -0
  8. package/.agents/scripts/lib/checks/subagent-agent-tool-required.js +107 -30
  9. package/.agents/scripts/lib/config/explain.js +4 -0
  10. package/.agents/scripts/lib/config/runners.js +13 -2
  11. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  12. package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
  13. package/.agents/scripts/lib/epic-plan-ideation.js +24 -3
  14. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +19 -5
  15. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +17 -0
  16. package/.agents/scripts/lib/framework-version.js +210 -0
  17. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +7 -22
  18. package/.agents/scripts/lib/orchestration/epic-cleanup.js +41 -5
  19. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +34 -3
  20. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +102 -7
  21. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +85 -1
  22. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +34 -3
  23. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +71 -4
  24. package/.agents/scripts/lib/single-story-sweep.js +60 -5
  25. package/.agents/scripts/lib/story-body/story-body.js +81 -4
  26. package/.agents/scripts/providers/github/tickets.js +18 -1
  27. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +7 -2
  28. package/.agents/skills/core/epic-plan-premortem/SKILL.md +8 -2
  29. package/.agents/skills/skills.index.json +3 -3
  30. package/.agents/skills/stack/architecture/subagent-orchestration/SKILL.md +36 -8
  31. package/.agents/workflows/git-cleanup.md +72 -18
  32. package/.agents/workflows/helpers/acceptance-self-eval.md +23 -1
  33. package/.agents/workflows/helpers/code-review.md +83 -7
  34. package/.agents/workflows/helpers/deliver-epic.md +46 -7
  35. package/.agents/workflows/helpers/epic-audit.md +153 -12
  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/docs/CHANGELOG.md +22 -0
  40. package/package.json +1 -1
@@ -344,7 +344,91 @@ export function branchTipSha({
344
344
  return res.status !== 0 ? null : validSha(firstLsRemoteSha(res.stdout));
345
345
  }
346
346
 
347
- export const __testing = { validSha, firstLsRemoteSha };
347
+ /* node:coverage ignore next */
348
+ // Story #4395: ancestry-anchor freshness check. `planCleanup` calls this
349
+ // before unioning `git branch --merged origin/<base>` into the ancestry
350
+ // signal so a stale local `<base>` (fast-forward phase skipped or
351
+ // `--branches` run alone) doesn't hide a branch that's already merged on
352
+ // the remote.
353
+ export function refExists(cwd, ref) {
354
+ const res = gitSpawn(cwd, 'rev-parse', '--verify', '--quiet', ref);
355
+ return res.status === 0;
356
+ }
357
+
358
+ /* node:coverage ignore next */
359
+ // Story #4395: last-commit timestamp for the dry-run `not-merged`
360
+ // skip-visibility line (branch name + last-commit age).
361
+ export function branchLastCommitAt(cwd, branch) {
362
+ const res = gitSpawn(
363
+ cwd,
364
+ 'log',
365
+ '-1',
366
+ '--format=%cI',
367
+ `refs/heads/${branch}`,
368
+ '--',
369
+ );
370
+ if (res.status !== 0) return null;
371
+ return res.stdout.trim() || null;
372
+ }
373
+
374
+ /**
375
+ * First non-empty trimmed stdout line — the resulting tree OID on a clean
376
+ * `git merge-tree --write-tree` run.
377
+ *
378
+ * @param {string} stdout
379
+ * @returns {string}
380
+ */
381
+ function firstStdoutLine(stdout) {
382
+ const first = (stdout ?? '')
383
+ .split('\n')
384
+ .map((l) => l.trim())
385
+ .find(Boolean);
386
+ return first ?? '';
387
+ }
388
+
389
+ /**
390
+ * Probe content-equivalence between `base` and `branch` via
391
+ * `git merge-tree --write-tree <base> <branch>` (git >= 2.38, Story #4395).
392
+ *
393
+ * A clean merge (exit 0) whose resulting tree OID equals `<base>`'s own
394
+ * tree OID means applying `branch`'s changes on top of `base` is a no-op —
395
+ * `branch`'s content already lives in `base` by another route (a
396
+ * squash-merged Epic PR, a cherry-pick, a manual `merge --squash`) that
397
+ * neither the PR probe nor the ancestry check can see.
398
+ *
399
+ * Both the "unsupported" case (git < 2.38 rejects `--write-tree`) and the
400
+ * "real conflict" case (branch and base diverge and cannot auto-merge)
401
+ * surface as a non-zero exit. This probe treats them identically — the
402
+ * signal is inconclusive, so the caller keeps the branch's current
403
+ * `not-merged` classification rather than guessing.
404
+ *
405
+ * @param {{ cwd: string, base: string, branch: string, spawn?: typeof gitSpawn }} args
406
+ * @returns {{ supported: false } | { supported: true, equivalent: boolean }}
407
+ */
408
+ export function probeContentEquivalent({
409
+ cwd,
410
+ base,
411
+ branch,
412
+ spawn = gitSpawn,
413
+ }) {
414
+ const merged = spawn(cwd, 'merge-tree', '--write-tree', base, branch);
415
+ if (merged.status !== 0) return { supported: false };
416
+ const mergedTree = validSha(firstStdoutLine(merged.stdout));
417
+ if (!mergedTree) return { supported: false };
418
+ const baseTreeRes = spawn(
419
+ cwd,
420
+ 'rev-parse',
421
+ '--verify',
422
+ '--quiet',
423
+ `${base}^{tree}`,
424
+ );
425
+ if (baseTreeRes.status !== 0) return { supported: false };
426
+ const baseTree = validSha(baseTreeRes.stdout);
427
+ if (!baseTree) return { supported: false };
428
+ return { supported: true, equivalent: mergedTree === baseTree };
429
+ }
430
+
431
+ export const __testing = { validSha, firstLsRemoteSha, firstStdoutLine };
348
432
 
349
433
  /**
350
434
  * Pure-ish: classify a latest-PR probe row into a planner verdict.
@@ -190,6 +190,22 @@ export async function runPrunePhase(opts, cwd) {
190
190
  // Branch phase
191
191
  // =====================================================================
192
192
 
193
+ /**
194
+ * Count of a plan's *actionable* candidates — the ones `executeCleanup`
195
+ * will actually delete given the current `--remote` setting. Story #4395
196
+ * always enumerates remote-only candidates in `plan.candidates` (so the
197
+ * operator sees them in the dry-run list), but their deletion still
198
+ * requires `--remote`; without it, `executeCleanup` no-ops on every
199
+ * `localExists: false` candidate. Counting only the actionable subset
200
+ * keeps the "no-candidates" short-circuit and the confirmation prompt's
201
+ * "Reap N" count honest about what will actually happen.
202
+ */
203
+ function countActionableCandidates(candidates, remote) {
204
+ return remote
205
+ ? candidates.length
206
+ : candidates.filter((c) => c.localExists !== false).length;
207
+ }
208
+
193
209
  /**
194
210
  * Pure: decide what the branch-reap phase should do given the plan.
195
211
  *
@@ -209,7 +225,11 @@ export function decideBranchPhase(state) {
209
225
  if (opts.dryRun) {
210
226
  return { kind: 'dry-run', plan, result: { plan, result: null } };
211
227
  }
212
- if (plan.candidates.length === 0) {
228
+ const actionableCount = countActionableCandidates(
229
+ plan.candidates,
230
+ opts.remote,
231
+ );
232
+ if (actionableCount === 0) {
213
233
  return { kind: 'no-candidates', plan, result: { plan, result: null } };
214
234
  }
215
235
  const executeArgs = {
@@ -218,10 +238,17 @@ export function decideBranchPhase(state) {
218
238
  remote: opts.remote,
219
239
  };
220
240
  if (!opts.yes) {
241
+ const contentMergedCount = plan.candidates.filter(
242
+ (c) => c.detectedBy === 'content-merged',
243
+ ).length;
244
+ const weakSignalNote =
245
+ contentMergedCount > 0
246
+ ? ` (${contentMergedCount} content-merged — weaker signal, verify before confirming)`
247
+ : '';
221
248
  return {
222
249
  kind: 'prompt-then-execute',
223
250
  plan,
224
- promptMessage: `${TAG} Reap ${plan.candidates.length} merged branch(es)${opts.remote ? ' (including origin)' : ''}?`,
251
+ promptMessage: `${TAG} Reap ${actionableCount} merged branch(es)${opts.remote ? ' (including origin)' : ''}${weakSignalNote}?`,
225
252
  declinedResult: { plan, result: null, declined: true },
226
253
  executeArgs,
227
254
  };
@@ -254,11 +281,15 @@ export async function runBranchPhase(opts, cwd, baseBranch) {
254
281
  include: opts.include,
255
282
  exclude: opts.exclude,
256
283
  });
284
+ // Story #4395: always enumerate remote-only merged branches so the
285
+ // dry-run / prompt shows them without requiring `--remote`. Deletion of
286
+ // a remote-only candidate still requires `--remote` — `executeCleanup`
287
+ // no-ops on `localExists: false` candidates otherwise (unchanged).
257
288
  const plan = planCleanup({
258
289
  cwd,
259
290
  baseBranch,
260
291
  filter,
261
- includeRemoteOnly: opts.remote === true,
292
+ includeRemoteOnly: true,
262
293
  });
263
294
  emitDryRunHuman(plan, baseBranch);
264
295
  const action = decideBranchPhase({ plan, opts, cwd });
@@ -9,10 +9,61 @@
9
9
  */
10
10
 
11
11
  const TAG = '[git-cleanup]';
12
+ const DAY_MS = 24 * 60 * 60 * 1000;
13
+
14
+ /**
15
+ * Pure: format a last-commit ISO timestamp as a short relative-age string
16
+ * for the `not-merged` skip-visibility line (Story #4395). Returns
17
+ * `'unknown'` when `iso` is missing or unparseable — a branch whose commit
18
+ * date could not be resolved (e.g. `gh`-degraded run, deleted ref) still
19
+ * gets a line, just without an age.
20
+ *
21
+ * @param {string|null|undefined} iso
22
+ * @param {number} now Epoch-ms reference clock (injectable for tests).
23
+ * @returns {string}
24
+ */
25
+ function formatCommitAge(iso, now) {
26
+ if (!iso) return 'unknown';
27
+ const then = Date.parse(iso);
28
+ if (!Number.isFinite(then)) return 'unknown';
29
+ const days = Math.max(0, Math.floor((now - then) / DAY_MS));
30
+ if (days === 0) return 'today';
31
+ if (days === 1) return '1 day ago';
32
+ return `${days} days ago`;
33
+ }
34
+
35
+ /**
36
+ * Pure: render a single `not-merged` skip-visibility line (Story #4395).
37
+ * `renderDryRun` previously kept `not-merged` survivors silent; this
38
+ * surfaces each one with its last-commit age so the operator can see why
39
+ * a leftover branch isn't reaped instead of hunting for it by hand.
40
+ *
41
+ * @param {{ branch: string, reason: string, lastCommitAt?: string|null }} skip
42
+ * @param {{ now?: number }} [opts]
43
+ * @returns {string | null}
44
+ */
45
+ export function renderNotMergedSkipLine(skip, opts = {}) {
46
+ if (!skip || skip.reason !== 'not-merged') return null;
47
+ const now = opts.now ?? Date.now();
48
+ const age = formatCommitAge(skip.lastCommitAt, now);
49
+ return `${TAG} ⏭️ ${skip.branch} skipped — not merged (last commit: ${age})`;
50
+ }
51
+
52
+ /**
53
+ * Pure: render a single content-merged candidate annotation line
54
+ * (Story #4395). `content-merged` is a weaker signal than a merged PR or
55
+ * git ancestry — this note lets the operator tell it apart in both the
56
+ * dry-run list and the confirmation prompt.
57
+ */
58
+ function contentMergedNote(candidate) {
59
+ return candidate.detectedBy === 'content-merged'
60
+ ? ' (weaker signal — verify before deleting)'
61
+ : '';
62
+ }
12
63
 
13
64
  /** Pure: render the dry-run plan as the operator-facing text block. */
14
65
  export function renderDryRun(plan, opts = {}) {
15
- const { baseBranch = null } = opts;
66
+ const { baseBranch = null, now } = opts;
16
67
  const lines = [
17
68
  `${TAG} DRY RUN (nothing deleted) — ${plan.candidates.length} candidate(s)`,
18
69
  ];
@@ -23,7 +74,9 @@ export function renderDryRun(plan, opts = {}) {
23
74
  const pr = c.prNumber ? `PR #${c.prNumber}` : c.detectedBy;
24
75
  const wt = c.hasWorktree ? ` (worktree: ${c.worktreePath})` : '';
25
76
  const remoteOnly = c.localExists === false ? ' (remote-only)' : '';
26
- lines.push(` • ${c.branch} — ${pr}${wt}${remoteOnly}`);
77
+ lines.push(
78
+ ` • ${c.branch} — ${pr}${wt}${remoteOnly}${contentMergedNote(c)}`,
79
+ );
27
80
  }
28
81
  }
29
82
  const skipped = plan.skipped ?? [];
@@ -40,6 +93,15 @@ export function renderDryRun(plan, opts = {}) {
40
93
  const line = renderLatestPrSkipLine(skip);
41
94
  if (line) lines.push(line);
42
95
  }
96
+ for (const skip of skipped) {
97
+ const line = renderNotMergedSkipLine(skip, { now });
98
+ if (line) lines.push(line);
99
+ }
100
+ if (plan.ghDegraded) {
101
+ lines.push(
102
+ `${TAG} ⚠️ gh probe degraded — candidates rely on git-only signals (ancestry + content-equivalence) for this run`,
103
+ );
104
+ }
43
105
  return lines;
44
106
  }
45
107
 
@@ -47,7 +109,8 @@ export function renderDryRun(plan, opts = {}) {
47
109
  * Pure: render a single latest-PR-state skip line. Returns null when the
48
110
  * skip reason is not one of the latest-PR family — `renderDryRun` filters
49
111
  * by truthy return value so unrelated skip reasons (`protected`,
50
- * `current-head`, `filtered`, `not-merged`) stay quiet.
112
+ * `current-head`, `filtered`) stay quiet here. `not-merged` gets its own
113
+ * renderer ({@link renderNotMergedSkipLine}).
51
114
  *
52
115
  * @param {{ branch: string, reason: string, prNumber?: number, tipSha?: string, mergedSha?: string }} skip
53
116
  * @returns {string | null}
@@ -64,7 +127,10 @@ export function renderLatestPrSkipLine(skip) {
64
127
  if (skip.reason === 'tip-diverged-from-merge') {
65
128
  const tip = skip.tipSha ? skip.tipSha.slice(0, 7) : '<unknown>';
66
129
  const merged = skip.mergedSha ? skip.mergedSha.slice(0, 7) : '<unknown>';
67
- return `${TAG} ⏭️ ${skip.branch} skipped — tip ${tip} diverges from ${prRef}'s merged ${merged} (post-merge force-push)`;
130
+ return (
131
+ `${TAG} ⏭️ ${skip.branch} skipped — tip ${tip} diverges from ${prRef}'s merged ${merged} (post-merge force-push); ` +
132
+ `resolve by deleting manually (\`git branch -D ${skip.branch}\`) or pushing the follow-up commit`
133
+ );
68
134
  }
69
135
  if (skip.reason === 'latest-pr-unknown-state') {
70
136
  return `${TAG} ⏭️ ${skip.branch} skipped — ${prRef} has an unrecognized state`;
@@ -160,6 +226,7 @@ export function buildJsonEnvelope({
160
226
  baseBranch,
161
227
  candidates: plan.candidates,
162
228
  skipped: plan.skipped,
229
+ ghDegraded: plan.ghDegraded ?? false,
163
230
  worktrees: r.worktrees,
164
231
  local: r.local,
165
232
  remote: r.remote,
@@ -28,6 +28,16 @@
28
28
  * lockfile around plan + execute. On lock contention the
29
29
  * sweep is skipped (the host continues — same contract as a
30
30
  * plan failure).
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.
31
41
  * - Never touches the stash stack.
32
42
  * - Errors are caught and surfaced in the envelope. Callers MUST NOT
33
43
  * propagate sweep failures — the host proceeds either way.
@@ -81,6 +91,7 @@ const STORY_BRANCH_INCLUDE = 'story-*';
81
91
  * localDeleted: number,
82
92
  * remoteDeleted: number,
83
93
  * protected: Array<{ branch: string, reason: string, worktreePath?: string|null }>,
94
+ * contentMerged: Array<{ branch: string, worktreePath: string|null }>,
84
95
  * failures: Array<{ branch: string|null, scope: string, stderr?: string }>,
85
96
  * fastForward?: object,
86
97
  * error?: string,
@@ -137,6 +148,7 @@ export async function sweepMergedBranches({
137
148
  localDeleted: 0,
138
149
  remoteDeleted: 0,
139
150
  protected: [],
151
+ contentMerged: [],
140
152
  failures: [],
141
153
  };
142
154
  }
@@ -213,6 +225,31 @@ export function sweepMergedStoryBranches(args = {}) {
213
225
  });
214
226
  }
215
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
+
216
253
  /**
217
254
  * Inner: the plan + protect + execute pipeline. Kept separate so the
218
255
  * outer engine can stay focused on the lock and fast-forward wrappers.
@@ -240,7 +277,18 @@ async function runSweepUnderLock({
240
277
  return zeroResult({ error: `plan: ${msg}` });
241
278
  }
242
279
 
243
- if (plan.candidates.length === 0) {
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) {
244
292
  log.info(`${logTag} no merged branches to reap.`);
245
293
  return {
246
294
  ok: true,
@@ -249,12 +297,13 @@ async function runSweepUnderLock({
249
297
  localDeleted: 0,
250
298
  remoteDeleted: 0,
251
299
  protected: [],
300
+ contentMerged,
252
301
  failures: [],
253
302
  };
254
303
  }
255
304
 
256
305
  const { reapable, protectedList } = await partitionCandidates({
257
- candidates: plan.candidates,
306
+ candidates: reapCandidates,
258
307
  protectionFn,
259
308
  protectionCtx,
260
309
  log,
@@ -263,15 +312,16 @@ async function runSweepUnderLock({
263
312
 
264
313
  if (reapable.length === 0) {
265
314
  log.info(
266
- `${logTag} all ${plan.candidates.length} candidate(s) protected; no reap.`,
315
+ `${logTag} all ${reapCandidates.length} candidate(s) protected; no reap.`,
267
316
  );
268
317
  return {
269
318
  ok: true,
270
319
  skipped: false,
271
- candidates: plan.candidates.length,
320
+ candidates: reapCandidates.length,
272
321
  localDeleted: 0,
273
322
  remoteDeleted: 0,
274
323
  protected: protectedList,
324
+ contentMerged,
275
325
  failures: [],
276
326
  };
277
327
  }
@@ -279,7 +329,8 @@ async function runSweepUnderLock({
279
329
  return executeReap({
280
330
  reapable,
281
331
  protectedList,
282
- candidateCount: plan.candidates.length,
332
+ contentMerged,
333
+ candidateCount: reapCandidates.length,
283
334
  cwd,
284
335
  executeCleanupFn,
285
336
  log,
@@ -295,6 +346,7 @@ async function runSweepUnderLock({
295
346
  function executeReap({
296
347
  reapable,
297
348
  protectedList,
349
+ contentMerged,
298
350
  candidateCount,
299
351
  cwd,
300
352
  executeCleanupFn,
@@ -314,6 +366,7 @@ function executeReap({
314
366
  localDeleted: 0,
315
367
  remoteDeleted: 0,
316
368
  protected: protectedList,
369
+ contentMerged,
317
370
  failures: [{ branch: null, scope: 'execute', stderr: msg }],
318
371
  error: `execute: ${msg}`,
319
372
  };
@@ -346,6 +399,7 @@ function executeReap({
346
399
  localDeleted,
347
400
  remoteDeleted,
348
401
  protected: protectedList,
402
+ contentMerged,
349
403
  failures: result.failures,
350
404
  };
351
405
  }
@@ -450,6 +504,7 @@ function zeroResult({ error }) {
450
504
  localDeleted: 0,
451
505
  remoteDeleted: 0,
452
506
  protected: [],
507
+ contentMerged: [],
453
508
  failures: [],
454
509
  error,
455
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
  );
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import { parseBlockedBy, parseBlocks } from '../../lib/dependency-parser.js';
25
+ import { stampFrameworkVersion } from '../../lib/framework-version.js';
25
26
  import { Logger } from '../../lib/Logger.js';
26
27
  import { TYPE_LABELS } from '../../lib/label-constants.js';
27
28
  import { addIssueToBoard } from './board-add.js';
@@ -56,11 +57,20 @@ const SEARCH_PAGE_CAP = 10;
56
57
  * arrays on the Story body authored by the decomposer; there is no
57
58
  * server-side rendering of a four-section payload at create time.
58
59
  *
60
+ * Story #4382 — this is also where a Story body is stamped, once, with the
61
+ * running Mandrel framework version and authoring date (hidden `mandrel_version`
62
+ * / `authored_at` meta field + a visible `> 🏷️ Authored with Mandrel …`
63
+ * marker) via {@link stampFrameworkVersion}. The stamp is immutable: a body
64
+ * that already carries a version (e.g. a reconciler re-create) is preserved
65
+ * verbatim. The `stamp` override exists for deterministic tests; production
66
+ * callers omit it so the running version and today's date are used.
67
+ *
59
68
  * @param {{
60
69
  * body: string,
61
70
  * parentId: number,
62
71
  * epicId?: number,
63
72
  * dependencies?: number[],
73
+ * stamp?: { version?: string, authoredAt?: string } | false,
64
74
  * }} opts
65
75
  * @returns {string}
66
76
  *
@@ -75,8 +85,15 @@ export function composeStoryBody({
75
85
  parentId,
76
86
  epicId,
77
87
  dependencies = [],
88
+ stamp,
78
89
  }) {
79
- const head = typeof body === 'string' ? body : '';
90
+ const rawHead = typeof body === 'string' ? body : '';
91
+ // `stamp === false` → footer-only recomposition: the caller (the reconciler
92
+ // UPDATE/diff path) owns stamp preservation itself and must NOT introduce a
93
+ // fresh authoring stamp, which would churn or bump the version on every
94
+ // reconcile. Every other call is a create — stamp once (immutably).
95
+ const head =
96
+ stamp === false ? rawHead : stampFrameworkVersion(rawHead, stamp ?? {});
80
97
  const lines = ['---', `parent: #${parentId}`];
81
98
  if (epicId !== undefined && epicId !== null) {
82
99
  lines.push(`Epic: #${epicId}`);
@@ -32,6 +32,11 @@ allowed_tools:
32
32
  Senior Project Manager + Orchestrator, acting as a **holistic critic** with
33
33
  fresh context — deliberately *separate* from `epic-plan-decompose-author` (the
34
34
  generator) so the pass is a fresh-context review, not a same-pass self-critique.
35
+ The `/plan` workflow delivers that fresh context by **dispatching this skill
36
+ inside a genuine sub-agent** (`Agent` tool, `subagent_type: general-purpose`) at
37
+ Phase 8.3, rather than activating it inline in the authoring turn — the
38
+ sub-agent does not inherit the conversation that authored the draft, so the
39
+ critic cannot grade its own homework.
35
40
 
36
41
  > **Read [`examples.md`](./examples.md) on demand** for the extended rationale:
37
42
  > why this critic runs with fresh context, why scope conservation is your
@@ -50,8 +55,8 @@ emit a plan the validator would reject.
50
55
 
51
56
  ## Inputs
52
57
 
53
- The dispatcher passes the Epic ID as the Skill argument. The Skill itself
54
- reads:
58
+ The `/plan` workflow dispatches this skill inside a fresh-context sub-agent,
59
+ passing the Epic ID as the Skill argument. The Skill itself reads:
55
60
 
56
61
  - `temp/epic-<Epic_ID>/tickets.json` — the **draft** Story array the
57
62
  `epic-plan-decompose-author` Skill wrote. This is the consolidation input.