mandrel 2.17.0 → 2.19.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 (28) hide show
  1. package/.agents/docs/SDLC.md +1 -1
  2. package/.agents/docs/agentrc-reference.json +10 -0
  3. package/.agents/docs/configuration.md +8 -0
  4. package/.agents/schemas/agentrc.schema.json +42 -0
  5. package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
  6. package/.agents/scripts/boot-sweep.js +39 -2
  7. package/.agents/scripts/check-doc-links.js +141 -9
  8. package/.agents/scripts/lib/baselines/env-overrides.js +40 -48
  9. package/.agents/scripts/lib/config/temp-paths.js +27 -0
  10. package/.agents/scripts/lib/config-settings-schema-delivery.js +69 -0
  11. package/.agents/scripts/lib/observability/terse-result.js +7 -3
  12. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +51 -77
  13. package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +20 -12
  14. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +2 -1
  15. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +19 -41
  16. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +9 -5
  17. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +1 -1
  18. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +31 -1
  19. package/.agents/scripts/lib/single-story-sweep.js +11 -0
  20. package/.agents/scripts/lib/temp-retention.js +559 -0
  21. package/.agents/scripts/single-story-init.js +1 -1
  22. package/.agents/scripts/sync-branch-from-base.js +6 -1
  23. package/.agents/workflows/audit-performance.md +2 -2
  24. package/.agents/workflows/helpers/diagnose.md +1 -1
  25. package/.agents/workflows/helpers/signals.md +2 -2
  26. package/.agents/workflows/mandrel-update.md +4 -4
  27. package/docs/CHANGELOG.md +20 -0
  28. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import nodeFs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
+ import { orchestrationLogDir } from '../config/temp-paths.js';
4
5
  import { Logger } from '../Logger.js';
5
6
 
6
7
  /**
@@ -69,8 +70,10 @@ function detailBlock(label, result) {
69
70
  * on; serialized compactly onto the single summary line.
70
71
  * @param {string|number} [args.scope] Disambiguating suffix for the log name
71
72
  * (typically the Story id) so concurrent deliveries don't clobber one file.
72
- * @param {string} [args.logDir] Directory for the detail log. Defaults to
73
- * `<cwd>/temp/orchestration`.
73
+ * @param {string} [args.logDir] Directory for the detail log. Defaults to the
74
+ * configured `<tempRoot>/orchestration` (Story #4794 — was a hardcoded
75
+ * `<cwd>/temp/orchestration`, which ignored `project.paths.tempRoot`).
76
+ * @param {object} [args.config] Resolved config bag, for the default `logDir`.
74
77
  * @param {typeof nodeFs} [args.fs] Filesystem seam (tests).
75
78
  * @param {{ info: (m: string) => void }} [args.log] Logger seam (tests).
76
79
  * @param {NodeJS.ProcessEnv} [args.env] Environment seam (tests).
@@ -82,6 +85,7 @@ export function emitTerseResult({
82
85
  summary = {},
83
86
  scope,
84
87
  logDir,
88
+ config,
85
89
  fs = nodeFs,
86
90
  log = Logger,
87
91
  env = process.env,
@@ -94,7 +98,7 @@ export function emitTerseResult({
94
98
  return { logPath: null, inline: true };
95
99
  }
96
100
 
97
- const dir = logDir ?? path.join(process.cwd(), 'temp', 'orchestration');
101
+ const dir = logDir ?? orchestrationLogDir(config);
98
102
  const name = `${slugify(label)}${scope ? `-${scope}` : ''}.log`;
99
103
 
100
104
  try {
@@ -7,10 +7,7 @@
7
7
  * @module lib/orchestration/check-baselines/phases/evaluate
8
8
  */
9
9
 
10
- import {
11
- resolveBundleSizeEnvOverrides,
12
- resolveMaintainabilityRefreshOverrides,
13
- } from '../../../baselines/env-overrides.js';
10
+ import { resolveKindRefreshOverrides } from '../../../baselines/env-overrides.js';
14
11
  import { readRangeSubjectsTouchingFile } from '../../../baselines/git-base.js';
15
12
  import {
16
13
  checkBaselineSemantics,
@@ -91,57 +88,34 @@ function loadHeadBaseline(kind, cwd, configPath) {
91
88
  }
92
89
 
93
90
  /**
94
- * One-shot bundle-size refresh/acknowledge (Story #151). When
95
- * `BUNDLE_SIZE_REFRESH=1` is set, demote every `bundle-size` regression to
96
- * `unchanged` for this run only — floors still apply, so a genuine budget
97
- * breach is still caught. The flag is read fresh on every invocation and
98
- * never persisted, so the ratchet returns to full strength automatically on
99
- * the very next run (no lingering loosened tolerance to remember to reset).
100
- *
101
- * No-op for every other kind.
102
- */
103
- function applyBundleSizeAcknowledgment(kind, compareOutput, env) {
104
- if (kind !== 'bundle-size') return { compareOutput, acknowledged: false };
105
- const { acknowledged, overrides } = resolveBundleSizeEnvOverrides(env);
106
- if (!acknowledged || compareOutput.regressions.length === 0) {
107
- return { compareOutput, acknowledged: false };
108
- }
109
- Logger.warn(
110
- `[bundle-size] ⚠ ${overrides.join(', ')} — ` +
111
- `${compareOutput.regressions.length} regression(s) acknowledged for this run only; ` +
112
- 'floors still enforced. This does not persist: the next run without ' +
113
- 'BUNDLE_SIZE_REFRESH re-enforces the ratchet at full strength.',
114
- );
115
- return {
116
- acknowledged: true,
117
- compareOutput: {
118
- ...compareOutput,
119
- regressions: [],
120
- unchanged: [...compareOutput.unchanged, ...compareOutput.regressions],
121
- },
122
- };
123
- }
124
-
125
- /**
126
- * Resolve the maintainability refresh trigger (Story #4731). Two paths, either
127
- * of which acknowledges — mirroring the bundle-size acknowledge but adding the
128
- * commit-tagged trigger the breach message already documents:
91
+ * Resolve the one-shot refresh trigger for any ratcheted kind (Story #4802,
92
+ * generalizing Story #151's bundle-size env flag and Story #4731's
93
+ * maintainability env-or-commit-tag pair). Two paths, either of which
94
+ * acknowledges:
129
95
  *
130
- * 1. Env parity: `MAINTAINABILITY_REFRESH=1` (the manual override).
96
+ * 1. Env parity: `<KIND>_REFRESH=1` (the manual override) — upper-snaked,
97
+ * so the two pre-existing names (`BUNDLE_SIZE_REFRESH`,
98
+ * `MAINTAINABILITY_REFRESH`) keep working unchanged.
131
99
  * 2. Commit tag: a commit in the compared range `<baseRef>..HEAD` whose
132
- * subject contains the configured `refreshTag` AND whose diff touches the
133
- * maintainability baseline file. One-shot by construction — once merged,
134
- * the refreshed baseline becomes the base and the tag leaves the range.
100
+ * subject contains the configured `refreshTag` AND whose diff touches
101
+ * that kind's baseline file. One-shot by construction — once merged, the
102
+ * refreshed baseline becomes the base and the tag leaves the range.
135
103
  *
136
104
  * The tag is matched as a plain substring of a conventional commit subject, so
137
105
  * commitlint stays satisfied (e.g. `chore(baselines): baseline-refresh: …`).
138
106
  *
107
+ * Fails closed: a kind whose baseline path is neither configured nor present
108
+ * in `DEFAULT_BASELINE_PATHS` simply skips the commit-tag path rather than
109
+ * throwing, leaving the run un-acknowledged.
110
+ *
139
111
  * @returns {{ triggered: boolean, reasons: string[] }}
140
112
  */
141
- function resolveMaintainabilityRefreshTrigger({ gateBlock, cmp, cwd, env }) {
113
+ function resolveRefreshTrigger({ kind, gateBlock, cmp, cwd, env }) {
142
114
  const reasons = [];
143
- const { acknowledged: envAck, overrides } =
144
- resolveMaintainabilityRefreshOverrides(env);
115
+ const { acknowledged: envAck, overrides } = resolveKindRefreshOverrides(
116
+ kind,
117
+ env,
118
+ );
145
119
  if (envAck) reasons.push(...overrides);
146
120
 
147
121
  const baseRef = cmp?.baseRef ?? null;
@@ -154,15 +128,17 @@ function resolveMaintainabilityRefreshTrigger({ gateBlock, cmp, cwd, env }) {
154
128
  typeof gateBlock?.baselinePath === 'string' &&
155
129
  gateBlock.baselinePath.length
156
130
  ? gateBlock.baselinePath
157
- : DEFAULT_BASELINE_PATHS.maintainability;
158
- const subjects = readRangeSubjectsTouchingFile(baseRef, baselinePath, {
159
- cwd,
160
- });
161
- const match = subjects.find((s) => s.includes(refreshTag));
162
- if (match) {
163
- reasons.push(
164
- `refresh commit "${match}" (subject contains ${JSON.stringify(refreshTag)}, touches ${baselinePath})`,
165
- );
131
+ : DEFAULT_BASELINE_PATHS[kind];
132
+ if (typeof baselinePath === 'string' && baselinePath.length) {
133
+ const subjects = readRangeSubjectsTouchingFile(baseRef, baselinePath, {
134
+ cwd,
135
+ });
136
+ const match = subjects.find((s) => s.includes(refreshTag));
137
+ if (match) {
138
+ reasons.push(
139
+ `refresh commit "${match}" (subject contains ${JSON.stringify(refreshTag)}, touches ${baselinePath})`,
140
+ );
141
+ }
166
142
  }
167
143
  }
168
144
 
@@ -170,26 +146,24 @@ function resolveMaintainabilityRefreshTrigger({ gateBlock, cmp, cwd, env }) {
170
146
  }
171
147
 
172
148
  /**
173
- * One-shot maintainability refresh/acknowledge (Story #4731). When triggered
174
- * (env flag OR a `baseline-refresh:`-tagged range commit touching the baseline),
175
- * demote every maintainability head-vs-base regression to `unchanged` for this
176
- * run only — floors still apply, so a row below its `min` floor still breaches.
177
- * The trigger is read fresh every run and never persisted: post-merge the
178
- * refreshed baseline is the new base and the tag leaves the range, so the
179
- * ratchet returns to full strength automatically.
149
+ * One-shot baseline refresh/acknowledge for any ratcheted kind (Story #4802).
150
+ * When triggered (env flag OR a `baseline-refresh:`-tagged range commit
151
+ * touching that kind's baseline), demote every head-vs-base regression to
152
+ * `unchanged` for this run only — floors still apply, so a row below its floor
153
+ * still breaches. The trigger is read fresh every run and never persisted:
154
+ * post-merge the refreshed baseline is the new base and the tag leaves the
155
+ * range, so the ratchet returns to full strength automatically.
180
156
  *
181
- * No-op for every other kind.
157
+ * A no-op absent a trigger, so an unacknowledged run of any kind reports its
158
+ * regressions exactly as before.
182
159
  */
183
- function applyMaintainabilityAcknowledgment(kind, compareOutput, ctx) {
184
- if (kind !== 'maintainability') {
185
- return { compareOutput, acknowledged: false };
186
- }
187
- const { triggered, reasons } = resolveMaintainabilityRefreshTrigger(ctx);
160
+ function applyRefreshAcknowledgment(kind, compareOutput, ctx) {
161
+ const { triggered, reasons } = resolveRefreshTrigger({ ...ctx, kind });
188
162
  if (!triggered || compareOutput.regressions.length === 0) {
189
163
  return { compareOutput, acknowledged: false };
190
164
  }
191
165
  Logger.warn(
192
- `[maintainability] ⚠ ${reasons.join('; ')} — ` +
166
+ `[${kind}] ⚠ ${reasons.join('; ')} — ` +
193
167
  `${compareOutput.regressions.length} regression(s) acknowledged for this run only; ` +
194
168
  'floors still enforced. This does not persist: once the refresh is the ' +
195
169
  'new base the ratchet re-enforces at full strength.',
@@ -274,14 +248,14 @@ export async function evaluateKind({
274
248
  rawCompare,
275
249
  gateBlock.tolerance ?? null,
276
250
  );
277
- const bundleAck = applyBundleSizeAcknowledgment(kind, toleratedCompare, env);
278
- const miAck = applyMaintainabilityAcknowledgment(
279
- kind,
280
- bundleAck.compareOutput,
281
- { gateBlock, cmp, cwd, env },
282
- );
283
- const compareOutput = miAck.compareOutput;
284
- const acknowledged = bundleAck.acknowledged || miAck.acknowledged;
251
+ const ack = applyRefreshAcknowledgment(kind, toleratedCompare, {
252
+ gateBlock,
253
+ cmp,
254
+ cwd,
255
+ env,
256
+ });
257
+ const compareOutput = ack.compareOutput;
258
+ const acknowledged = ack.acknowledged;
285
259
  return buildGateReport({
286
260
  kind,
287
261
  gateBlock,
@@ -44,18 +44,26 @@ compare) over every configured gate, with centralised friction emission and
44
44
  aggregated exit codes.
45
45
 
46
46
  Env vars:
47
- BUNDLE_SIZE_REFRESH=1 One-shot acknowledge for an intentional bundle-size
48
- growth: demotes bundle-size regressions to
49
- "unchanged" for this run only (floors still
50
- enforced). Never persisted the next run without
51
- this flag re-enforces the ratchet.
52
- MAINTAINABILITY_REFRESH=1
53
- One-shot acknowledge for a deliberate maintainability
54
- baseline refresh: demotes maintainability head-vs-base
55
- regressions to "unchanged" for this run only (floors
56
- still enforced). Env-parity override for the
57
- 'baseline-refresh:'-tagged range commit that touches
58
- baselines/maintainability.json. Never persisted.
47
+ <KIND>_REFRESH=1 One-shot acknowledge for a deliberate baseline
48
+ refresh of that kind: demotes its head-vs-base
49
+ regressions to "unchanged" for this run only. Floors
50
+ are STILL enforced, so a genuine breach is still
51
+ caught. The kind name is upper-snaked, e.g.
52
+ COVERAGE_REFRESH, CRAP_REFRESH, DUPLICATION_REFRESH,
53
+ MAINTAINABILITY_REFRESH, BUNDLE_SIZE_REFRESH.
54
+ Never persisted the next run without the flag
55
+ re-enforces the ratchet at full strength.
56
+
57
+ Equivalent commit-tagged trigger: a commit in the
58
+ compared range whose subject contains the gate's
59
+ 'refreshTag' (default 'baseline-refresh:') AND whose
60
+ diff touches that kind's baseline file. One-shot by
61
+ construction — once merged the refreshed baseline is
62
+ the new base and the tag leaves the range.
63
+
64
+ Use these when replacing a diff-scope baseline with a
65
+ full-scope measurement: the resulting row deltas are
66
+ arithmetic, not behavioural.
59
67
 
60
68
  Exit codes:
61
69
  0 every enabled gate passes
@@ -2,7 +2,8 @@
2
2
 
3
3
  Each listener in this directory subscribes to one or more lifecycle bus
4
4
  events and performs a single side effect. The full close-tail roster and
5
- event taxonomy live in [`docs/LIFECYCLE.md`](../../../../../../docs/LIFECYCLE.md)
5
+ event taxonomy live in
6
+ [`docs/LIFECYCLE.md`](https://github.com/dsj1984/mandrel/blob/main/docs/LIFECYCLE.md)
6
7
  — that document is the SSOT. This README only indexes the **files that still
7
8
  live in this folder**.
8
9
 
@@ -37,13 +37,12 @@
37
37
  * @module lib/orchestration/plan-persist/run-plan-persist
38
38
  */
39
39
 
40
- import { readdir, rm, stat } from 'node:fs/promises';
40
+ import { rm } from 'node:fs/promises';
41
41
  import path from 'node:path';
42
-
43
- import { anchorTempRoot, tempRootFrom } from '../../config/temp-paths.js';
44
42
  import { getLimits, PROJECT_ROOT } from '../../config-resolver.js';
45
43
  import { gitSpawn } from '../../git-utils.js';
46
44
  import { Logger } from '../../Logger.js';
45
+ import { sweepTempRetention } from '../../temp-retention.js';
47
46
  import {
48
47
  deriveStoryShape,
49
48
  LITE_ROUTE_LABEL,
@@ -374,13 +373,6 @@ function resolveEffectiveRoute({
374
373
  };
375
374
  }
376
375
 
377
- /**
378
- * Age after which an abandoned `temp/plan-*` directory is reaped. A plan run
379
- * that is still being authored is minutes-to-hours old; a week is far past
380
- * any live run and comfortably past an operator returning to a paused one.
381
- */
382
- const STALE_PLAN_DIR_MS = 7 * 24 * 60 * 60 * 1000;
383
-
384
376
  /**
385
377
  * Reap abandoned `plan-*` directories under the temp root (Story #4541).
386
378
  *
@@ -389,9 +381,16 @@ const STALE_PLAN_DIR_MS = 7 * 24 * 60 * 60 * 1000;
389
381
  * `--dry-run` left its directory behind forever. This sweeps the stragglers
390
382
  * on each persist.
391
383
  *
384
+ * Story #4794 folded the age-floored reap into the shared temp-retention
385
+ * engine — `planDirs` is one of its declared classes, so the plan path and
386
+ * the delivery path now converge on one classifier and one staleness floor
387
+ * (`delivery.tempRetention.staleDays`, still 7 days by default) instead of
388
+ * this module owning a private constant. Behaviour is unchanged: only
389
+ * `plan-*` directories are considered, the age test is the directory's own
390
+ * mtime, and the current run's `planDir` is excluded.
391
+ *
392
392
  * Best-effort throughout: this is hygiene, never a reason to fail a run that
393
- * has already created Stories. The current run's own `planDir` is always
394
- * excluded — its cleanup is the caller's decision.
393
+ * has already created Stories.
395
394
  *
396
395
  * @param {{ config?: object, keepDir?: string|null, now?: number }} args
397
396
  * @returns {Promise<{ reaped: string[] }>}
@@ -401,35 +400,14 @@ export async function reapStalePlanDirs({
401
400
  keepDir = null,
402
401
  now = Date.now(),
403
402
  } = {}) {
404
- const reaped = [];
405
- const tempRoot = anchorTempRoot(tempRootFrom(config));
406
- let entries;
407
- try {
408
- entries = await readdir(tempRoot, { withFileTypes: true });
409
- } catch {
410
- return { reaped }; // No temp root yet — nothing to reap.
411
- }
412
- const keep = keepDir ? path.resolve(keepDir) : null;
413
- for (const entry of entries) {
414
- if (!entry.isDirectory() || !entry.name.startsWith('plan-')) continue;
415
- const dir = path.resolve(tempRoot, entry.name);
416
- if (keep !== null && dir === keep) continue;
417
- try {
418
- const { mtimeMs } = await stat(dir);
419
- if (now - mtimeMs < STALE_PLAN_DIR_MS) continue;
420
- await rm(dir, { recursive: true, force: true });
421
- reaped.push(dir);
422
- } catch {
423
- // A racing writer or a permission error: leave it for the next run.
424
- }
425
- }
426
- if (reaped.length > 0) {
427
- Logger.info(
428
- `[plan-persist] reaped ${reaped.length} abandoned plan director(ies) ` +
429
- `older than 7d under ${tempRoot}.`,
430
- );
431
- }
432
- return { reaped };
403
+ const result = await sweepTempRetention({
404
+ config,
405
+ only: ['planDirs'],
406
+ excludePaths: keepDir ? [keepDir] : [],
407
+ now,
408
+ label: 'plan-persist',
409
+ });
410
+ return { reaped: result.purged.map((entry) => entry.path) };
433
411
  }
434
412
 
435
413
  /**
@@ -59,6 +59,7 @@
59
59
  import nodeFs from 'node:fs';
60
60
  import path from 'node:path';
61
61
 
62
+ import { orchestrationLogDir } from '../../config/temp-paths.js';
62
63
  import { Logger, resolveLevel } from '../../Logger.js';
63
64
 
64
65
  /**
@@ -210,26 +211,29 @@ function createArtifactWriter(fs, logPath, handle) {
210
211
  *
211
212
  * @param {{
212
213
  * storyId: number|null,
213
- * cwd?: string,
214
214
  * logDir?: string,
215
215
  * fs?: typeof nodeFs,
216
216
  * logger?: { info: (m: string) => void },
217
217
  * level?: string,
218
- * }} [args] `logDir` defaults to `<cwd>/temp/orchestration`; `level` defaults
219
- * to the live Logger level so `AGENT_LOG_LEVEL=verbose` restores streaming.
218
+ * config?: object,
219
+ * }} [args] `logDir` defaults to the configured `<tempRoot>/orchestration`
220
+ * (Story #4794 — was a hardcoded `<cwd>/temp/orchestration`, which ignored
221
+ * `project.paths.tempRoot` and hid the artifact from the retention purge);
222
+ * `level` defaults to the live Logger level so `AGENT_LOG_LEVEL=verbose`
223
+ * restores streaming.
220
224
  * @returns {GateLogSink}
221
225
  */
222
226
  export function createGateLogSink({
223
227
  storyId = null,
224
- cwd = process.cwd(),
225
228
  logDir,
226
229
  fs = nodeFs,
227
230
  logger = Logger,
228
231
  level,
232
+ config,
229
233
  } = {}) {
230
234
  const emit = (line) => logger.info?.(line);
231
235
  const verbose = (level ?? resolveLevel()) === 'verbose';
232
- const dir = logDir ?? path.join(cwd, 'temp', 'orchestration');
236
+ const dir = logDir ?? orchestrationLogDir(config);
233
237
 
234
238
  let writer = null;
235
239
  let logPath = null;
@@ -143,7 +143,7 @@ export async function runCloseValidationPhase({
143
143
  );
144
144
  // Story #4736 — one sink for both `log` seams (gate construction and gate
145
145
  // execution), so nothing in the chain can route around the artifact.
146
- const gateLog = createGateLogSink({ storyId, cwd });
146
+ const gateLog = createGateLogSink({ storyId, config });
147
147
  let validation;
148
148
  try {
149
149
  validation = await runCloseValidation({
@@ -39,6 +39,7 @@ import {
39
39
  RUNTIME_FRICTION_CATEGORIES,
40
40
  } from '../../../observability/runtime-friction.js';
41
41
  import { acquireLockWithWait as defaultAcquireLockWithWait } from '../../../single-story-sweep/sweep-lock.js';
42
+ import { purgeStoryTempArtifacts as defaultPurgeStoryTempArtifacts } from '../../../temp-retention.js';
42
43
  import {
43
44
  executeFastForward as defaultExecuteFastForward,
44
45
  planFastForward as defaultPlanFastForward,
@@ -224,6 +225,22 @@ async function stepBaseFastForward({
224
225
  };
225
226
  }
226
227
 
228
+ /**
229
+ * Purge this Story's spent temp artifacts now that its merge is confirmed
230
+ * (Story #4794).
231
+ *
232
+ * The engine already emits its own one-line summary and returns a disabled
233
+ * policy as `skipped` with no errors, so this step needs no branching of its
234
+ * own: errors degrade it, everything else — including a deliberate
235
+ * config-disabled no-op — is a success. Reporting a disabled purge as a failed
236
+ * step would train readers to ignore the field.
237
+ */
238
+ async function stepTempPurge({ storyId, config, purgeStoryTempArtifactsFn }) {
239
+ const result = await purgeStoryTempArtifactsFn({ storyId, config });
240
+ const errors = result?.errors ?? [];
241
+ return { ok: errors.length === 0, detail: errors.join('; ') || null };
242
+ }
243
+
227
244
  /**
228
245
  * Run the whole post-land tail. Never throws.
229
246
  *
@@ -262,7 +279,8 @@ async function stepBaseFastForward({
262
279
  * @param {Function} [args.planFastForwardFn] Test seam.
263
280
  * @param {Function} [args.executeFastForwardFn] Test seam.
264
281
  * @param {Function} [args.acquireLockWithWaitFn] Test seam.
265
- * @returns {Promise<{ followUps: boolean, statusResync: boolean, refCleanup: boolean, baseFastForward: boolean, details: Record<string, string|null> }>}
282
+ * @param {Function} [args.purgeStoryTempArtifactsFn] Test seam.
283
+ * @returns {Promise<{ followUps: boolean, statusResync: boolean, refCleanup: boolean, baseFastForward: boolean, tempPurge: boolean, details: Record<string, string|null> }>}
266
284
  */
267
285
  export async function runPostLandTail({
268
286
  storyId,
@@ -280,6 +298,7 @@ export async function runPostLandTail({
280
298
  planFastForwardFn = defaultPlanFastForward,
281
299
  executeFastForwardFn = defaultExecuteFastForward,
282
300
  acquireLockWithWaitFn = defaultAcquireLockWithWait,
301
+ purgeStoryTempArtifactsFn = defaultPurgeStoryTempArtifacts,
283
302
  }) {
284
303
  progress?.('POST-LAND', `🧾 Running land tail for Story #${storyId}...`);
285
304
 
@@ -373,16 +392,27 @@ export async function runPostLandTail({
373
392
  if (lock.acquired) lock.release();
374
393
  }
375
394
 
395
+ // Story #4794 — the merge is confirmed, so this Story's gate transcripts and
396
+ // validation evidence are spent. Runs LAST so a purge can never race a step
397
+ // that still reads them, and outside the checkout lock because it touches
398
+ // only the temp tree. Its `signals.ndjson` survives by construction.
399
+ const tempPurge = await step(
400
+ () => stepTempPurge({ storyId, config, purgeStoryTempArtifactsFn }),
401
+ { name: 'temp purge', progress },
402
+ );
403
+
376
404
  const tail = {
377
405
  followUps: followUps.ok,
378
406
  statusResync: statusResync.ok,
379
407
  refCleanup: refCleanup.ok,
380
408
  baseFastForward: baseFastForward.ok,
409
+ tempPurge: tempPurge.ok,
381
410
  details: {
382
411
  followUps: followUps.detail,
383
412
  statusResync: statusResync.detail,
384
413
  refCleanup: refCleanup.detail,
385
414
  baseFastForward: baseFastForward.detail,
415
+ tempPurge: tempPurge.detail,
386
416
  },
387
417
  };
388
418
  const degraded = Object.entries(tail)
@@ -90,6 +90,7 @@ const STORY_BRANCH_INCLUDE = 'story-*';
90
90
  * candidates: number,
91
91
  * localDeleted: number,
92
92
  * remoteDeleted: number,
93
+ * reaped: string[],
93
94
  * protected: Array<{ branch: string, reason: string, worktreePath?: string|null }>,
94
95
  * contentMerged: Array<{ branch: string, worktreePath: string|null }>,
95
96
  * failures: Array<{ branch: string|null, scope: string, stderr?: string }>,
@@ -147,6 +148,7 @@ export async function sweepMergedBranches({
147
148
  candidates: 0,
148
149
  localDeleted: 0,
149
150
  remoteDeleted: 0,
151
+ reaped: [],
150
152
  protected: [],
151
153
  contentMerged: [],
152
154
  failures: [],
@@ -296,6 +298,7 @@ async function runSweepUnderLock({
296
298
  candidates: 0,
297
299
  localDeleted: 0,
298
300
  remoteDeleted: 0,
301
+ reaped: [],
299
302
  protected: [],
300
303
  contentMerged,
301
304
  failures: [],
@@ -320,6 +323,7 @@ async function runSweepUnderLock({
320
323
  candidates: reapCandidates.length,
321
324
  localDeleted: 0,
322
325
  remoteDeleted: 0,
326
+ reaped: [],
323
327
  protected: protectedList,
324
328
  contentMerged,
325
329
  failures: [],
@@ -365,6 +369,7 @@ function executeReap({
365
369
  candidates: candidateCount,
366
370
  localDeleted: 0,
367
371
  remoteDeleted: 0,
372
+ reaped: [],
368
373
  protected: protectedList,
369
374
  contentMerged,
370
375
  failures: [{ branch: null, scope: 'execute', stderr: msg }],
@@ -398,6 +403,11 @@ function executeReap({
398
403
  candidates: candidateCount,
399
404
  localDeleted,
400
405
  remoteDeleted,
406
+ // Story #4794 — the branch names, not just the count. Each one is a merge
407
+ // this sweep CONFIRMED (merged PR + matching headRefOid), which is exactly
408
+ // the evidence the temp-retention catch-up needs to purge that Story's
409
+ // spent artifacts. Previously these existed only inside a log string.
410
+ reaped: reapable.map((c) => c.branch),
401
411
  protected: protectedList,
402
412
  contentMerged,
403
413
  failures: result.failures,
@@ -503,6 +513,7 @@ function zeroResult({ error }) {
503
513
  candidates: 0,
504
514
  localDeleted: 0,
505
515
  remoteDeleted: 0,
516
+ reaped: [],
506
517
  protected: [],
507
518
  contentMerged: [],
508
519
  failures: [],