mandrel 2.17.0 → 2.18.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.
@@ -128,6 +128,16 @@
128
128
  "docsFreshness": {
129
129
  "paths": ["README.md"]
130
130
  },
131
+ "tempRetention": {
132
+ "enabled": true,
133
+ "staleDays": 7,
134
+ "classes": {
135
+ "orchestrationLogs": true,
136
+ "validationEvidence": true,
137
+ "auditResults": true,
138
+ "planDirs": true
139
+ }
140
+ },
131
141
  "deliverRunner": {
132
142
  "concurrencyCap": 3
133
143
  },
@@ -125,6 +125,14 @@ top-level keys are validation errors.
125
125
  | `lease.ttlMs` | No | `integer` | — | — |
126
126
  | `docsFreshness` | No | `object` | — | Nested configuration block. |
127
127
  | `docsFreshness.paths` | No | `array` | — | — |
128
+ | `tempRetention` | No | `object` | — | Story #4794. Auto-purge of spent temp artifacts once their Story lands. Classification is an allowlist: only the declared classes below are ever deleted, so operator scratch files under tempRoot are reported with their size and left alone. signals.ndjson is never purged by any path. |
129
+ | `tempRetention.enabled` | No | `boolean` | — | Master switch. Default true — reclaiming a landed Story's gate transcripts and validation evidence is the behaviour, and this knob turns it off. When false every purge path is a reported no-op. |
130
+ | `tempRetention.staleDays` | No | `integer` | — | Age floor (days, default 7) for the families no Story id can be recovered from — roster-level audit reports and abandoned plan-<slug>/ dirs. Story-keyed artifacts do not wait for it: they are purged as soon as their merge is confirmed. |
131
+ | `tempRetention.classes` | No | `object` | — | Per-class opt-out. Each defaults to true; set one false to keep that family while the rest are purged. |
132
+ | `tempRetention.classes.orchestrationLogs` | No | `boolean` | — | <tempRoot>/orchestration/*.log — close gate transcripts and terse-result detail dumps. |
133
+ | `tempRetention.classes.validationEvidence` | No | `boolean` | — | Per-Story validation-evidence.json, lifecycle.ndjson, and manifest.md under the standalone and per-run story trees. |
134
+ | `tempRetention.classes.auditResults` | No | `boolean` | — | <tempRoot>/audits/ — audit lens reports. |
135
+ | `tempRetention.classes.planDirs` | No | `boolean` | — | <tempRoot>/plan-<slug>/ — abandoned plan authoring dirs. Age-floored only; the current run is always excluded. |
128
136
  | `deliverRunner` | No | `object` | — | Nested configuration block. |
129
137
  | `deliverRunner.concurrencyCap` | No | `integer` | — | Maximum ready Stories dispatched by /deliver at once. Default 3. Moderate by design — keeps host-quota consumption predictable while allowing a small ready-set fan-out. Set 1 for strictly sequential delivery; raise further on hosts with adequate parallel-agent quota. See deliver.md for the sequencing model and throughput tradeoff. |
130
138
  | `worktreeIsolation` | No | `object` | — | Nested configuration block. |
@@ -441,6 +441,45 @@
441
441
  },
442
442
  "additionalProperties": false
443
443
  },
444
+ "tempRetention": {
445
+ "type": "object",
446
+ "description": "Story #4794. Auto-purge of spent temp artifacts once their Story lands. Classification is an allowlist: only the declared classes below are ever deleted, so operator scratch files under tempRoot are reported with their size and left alone. signals.ndjson is never purged by any path.",
447
+ "properties": {
448
+ "enabled": {
449
+ "type": "boolean",
450
+ "description": "Master switch. Default true — reclaiming a landed Story's gate transcripts and validation evidence is the behaviour, and this knob turns it off. When false every purge path is a reported no-op."
451
+ },
452
+ "staleDays": {
453
+ "type": "integer",
454
+ "minimum": 1,
455
+ "description": "Age floor (days, default 7) for the families no Story id can be recovered from — roster-level audit reports and abandoned plan-<slug>/ dirs. Story-keyed artifacts do not wait for it: they are purged as soon as their merge is confirmed."
456
+ },
457
+ "classes": {
458
+ "type": "object",
459
+ "description": "Per-class opt-out. Each defaults to true; set one false to keep that family while the rest are purged.",
460
+ "properties": {
461
+ "orchestrationLogs": {
462
+ "type": "boolean",
463
+ "description": "<tempRoot>/orchestration/*.log — close gate transcripts and terse-result detail dumps."
464
+ },
465
+ "validationEvidence": {
466
+ "type": "boolean",
467
+ "description": "Per-Story validation-evidence.json, lifecycle.ndjson, and manifest.md under the standalone and per-run story trees."
468
+ },
469
+ "auditResults": {
470
+ "type": "boolean",
471
+ "description": "<tempRoot>/audits/ — audit lens reports."
472
+ },
473
+ "planDirs": {
474
+ "type": "boolean",
475
+ "description": "<tempRoot>/plan-<slug>/ — abandoned plan authoring dirs. Age-floored only; the current run is always excluded."
476
+ }
477
+ },
478
+ "additionalProperties": false
479
+ }
480
+ },
481
+ "additionalProperties": false
482
+ },
444
483
  "docsFreshness": {
445
484
  "type": "object",
446
485
  "properties": {
@@ -1324,6 +1363,9 @@
1324
1363
  "docsFreshness": {
1325
1364
  "$ref": "#/$defs/docsFreshness"
1326
1365
  },
1366
+ "tempRetention": {
1367
+ "$ref": "#/$defs/tempRetention"
1368
+ },
1327
1369
  "deliverRunner": {
1328
1370
  "$ref": "#/$defs/deliverRunner"
1329
1371
  },
@@ -86,13 +86,18 @@
86
86
  "followUps",
87
87
  "statusResync",
88
88
  "refCleanup",
89
- "baseFastForward"
89
+ "baseFastForward",
90
+ "tempPurge"
90
91
  ],
91
92
  "properties": {
92
93
  "followUps": { "type": "boolean" },
93
94
  "statusResync": { "type": "boolean" },
94
95
  "refCleanup": { "type": "boolean" },
95
96
  "baseFastForward": { "type": "boolean" },
97
+ "tempPurge": {
98
+ "type": "boolean",
99
+ "description": "Story #4794 — the merged Story's spent temp artifacts (gate transcripts, validation evidence) were purged under delivery.tempRetention. A disabled policy reports true: the operator turned the purge off, so doing nothing IS the correct outcome. Only a real failure — an unreadable temp root, an undeletable artifact — reports false, and like every tail step that degrades the report, never the land."
100
+ },
96
101
  "details": {
97
102
  "type": "object",
98
103
  "description": "Per-step diagnostic detail — the reason a false step reported false.",
@@ -46,6 +46,25 @@ import { Logger } from './lib/Logger.js';
46
46
  import { createProvider } from './lib/provider-factory.js';
47
47
  import { buildProtectionCtx } from './lib/single-story-sweep/protection-ctx.js';
48
48
  import { sweepMergedBranches } from './lib/single-story-sweep.js';
49
+ import { sweepTempRetention } from './lib/temp-retention.js';
50
+
51
+ /**
52
+ * Recover the Story ids from the branch names a sweep reaped. Only the
53
+ * canonical `story-<id>` shape yields an id — an operator's ad-hoc branch that
54
+ * happened to match the include glob contributes nothing, so a purge can never
55
+ * be triggered by a name this framework did not create.
56
+ *
57
+ * @param {string[]|undefined} branches
58
+ * @returns {number[]}
59
+ */
60
+ export function storyIdsFromBranches(branches) {
61
+ const ids = [];
62
+ for (const branch of Array.isArray(branches) ? branches : []) {
63
+ const match = /^story-(\d+)$/.exec(String(branch));
64
+ if (match) ids.push(Number(match[1]));
65
+ }
66
+ return ids;
67
+ }
49
68
 
50
69
  const HELP = `Usage: node .agents/scripts/boot-sweep.js [options]
51
70
 
@@ -85,9 +104,11 @@ Options:
85
104
  * injectedConfig?: object,
86
105
  * injectedProvider?: object,
87
106
  * injectedSweep?: Function,
107
+ * purgeFn?: Function,
88
108
  * logger?: { info?: Function, warn?: Function },
89
109
  * }} [args]
90
- * @returns {Promise<object>} the {@link sweepMergedBranches} envelope.
110
+ * @returns {Promise<object>} the {@link sweepMergedBranches} envelope, plus a
111
+ * `tempPurge` result from the Story #4794 temp-retention catch-up.
91
112
  */
92
113
  export async function runBootSweep({
93
114
  cwd,
@@ -99,6 +120,7 @@ export async function runBootSweep({
99
120
  injectedConfig,
100
121
  injectedProvider,
101
122
  injectedSweep,
123
+ purgeFn = sweepTempRetention,
102
124
  logger = Logger,
103
125
  } = {}) {
104
126
  const root = path.resolve(cwd ?? PROJECT_ROOT);
@@ -125,7 +147,7 @@ export async function runBootSweep({
125
147
  config.delivery?.worktreeIsolation?.sweepLockMs ?? 60_000;
126
148
 
127
149
  const sweepFn = injectedSweep ?? sweepMergedBranches;
128
- return await sweepFn({
150
+ const result = await sweepFn({
129
151
  cwd: root,
130
152
  baseBranch,
131
153
  include: includeGlobs,
@@ -140,6 +162,21 @@ export async function runBootSweep({
140
162
  lockPath,
141
163
  lockTimeoutMs,
142
164
  });
165
+
166
+ // Story #4794 — the temp-retention catch-up. Two eligibility signals, both
167
+ // already paid for: every branch this sweep reaped is a merge it CONFIRMED
168
+ // (merged PR + matching headRefOid), so those Stories' artifacts are spent;
169
+ // and the age floor collects everything else — the backlog from Stories
170
+ // merged before this existed, merged through the GitHub UI, or whose branch
171
+ // was already gone. Best-effort like the sweep itself: `runBootSweep`'s
172
+ // catch swallows any throw into the `ok: false` envelope, and exit stays 0.
173
+ const purge = await purgeFn({
174
+ config,
175
+ mergedStoryIds: storyIdsFromBranches(result?.reaped),
176
+ label: 'boot-sweep',
177
+ logger,
178
+ });
179
+ return { ...result, tempPurge: purge };
143
180
  } catch (err) {
144
181
  const msg = err?.message ?? String(err);
145
182
  logger.warn?.(`[boot-sweep] sweep threw (host continues): ${msg}`);
@@ -274,6 +274,33 @@ export function tempRootFrom(config) {
274
274
  : 'temp';
275
275
  }
276
276
 
277
+ /**
278
+ * Directory segment (under `tempRoot`) holding every orchestration run log —
279
+ * the close gate transcripts (`close-gates-<sid>.log`) and the terse-result
280
+ * detail dumps (`story-init-result-<sid>.log`, `sync-result-<branch>.log`, …).
281
+ *
282
+ * Story #4794: the four writers that land here each hand-rolled the temp path
283
+ * from a literal `temp` segment joined onto their own cwd, which ignores
284
+ * `project.paths.tempRoot` entirely. On a consumer that relocates its temp
285
+ * root, the writers wrote to `<cwd>/temp/` while every reader — including the
286
+ * retention purge — resolved the configured root, so the artifacts were
287
+ * invisible to the tooling meant to manage them. Routing all four through this
288
+ * helper also picks up main-checkout anchoring for free, so a close running
289
+ * from a Story worktree lands its logs in the same tree the host reads.
290
+ */
291
+ export const ORCHESTRATION_DIRNAME = 'orchestration';
292
+
293
+ /**
294
+ * `<tempRoot>/orchestration/` — resolved against the configured temp root and
295
+ * anchored to the main checkout, like every other helper in this module.
296
+ *
297
+ * @param {object} [config]
298
+ * @returns {string}
299
+ */
300
+ export function orchestrationLogDir(config) {
301
+ return path.join(anchorTempRoot(tempRootFrom(config)), ORCHESTRATION_DIRNAME);
302
+ }
303
+
277
304
  const runId = (id) => {
278
305
  if (!Number.isInteger(id) || id <= 0) {
279
306
  throw new Error(`[temp-paths] runId must be a positive integer; got ${id}`);
@@ -352,12 +352,81 @@ const AUDIT_TO_STORIES_SCHEMA = {
352
352
  additionalProperties: false,
353
353
  };
354
354
 
355
+ /**
356
+ * `delivery.tempRetention` — auto-purge of spent temp artifacts (Story #4794).
357
+ *
358
+ * `enabled` defaults to `true`: reclaiming a landed Story's gate transcripts
359
+ * and evidence is the behaviour, and the knob exists to turn it off. `classes`
360
+ * lets an operator keep one family while purging the rest; `staleDays` is the
361
+ * age floor for the families no Story id can be recovered from (audit reports,
362
+ * abandoned `plan-<slug>/` dirs).
363
+ */
364
+ const TEMP_RETENTION_SCHEMA = {
365
+ type: 'object',
366
+ description:
367
+ 'Story #4794. Auto-purge of spent temp artifacts once their Story lands. ' +
368
+ 'Classification is an allowlist: only the declared classes below are ever ' +
369
+ 'deleted, so operator scratch files under tempRoot are reported with their ' +
370
+ 'size and left alone. signals.ndjson is never purged by any path.',
371
+ properties: {
372
+ enabled: {
373
+ type: 'boolean',
374
+ description:
375
+ "Master switch. Default true — reclaiming a landed Story's gate " +
376
+ 'transcripts and validation evidence is the behaviour, and this knob ' +
377
+ 'turns it off. When false every purge path is a reported no-op.',
378
+ },
379
+ staleDays: {
380
+ type: 'integer',
381
+ minimum: 1,
382
+ description:
383
+ 'Age floor (days, default 7) for the families no Story id can be ' +
384
+ 'recovered from — roster-level audit reports and abandoned ' +
385
+ 'plan-<slug>/ dirs. Story-keyed artifacts do not wait for it: they are ' +
386
+ 'purged as soon as their merge is confirmed.',
387
+ },
388
+ classes: {
389
+ type: 'object',
390
+ description:
391
+ 'Per-class opt-out. Each defaults to true; set one false to keep that ' +
392
+ 'family while the rest are purged.',
393
+ properties: {
394
+ orchestrationLogs: {
395
+ type: 'boolean',
396
+ description:
397
+ '<tempRoot>/orchestration/*.log — close gate transcripts and ' +
398
+ 'terse-result detail dumps.',
399
+ },
400
+ validationEvidence: {
401
+ type: 'boolean',
402
+ description:
403
+ 'Per-Story validation-evidence.json, lifecycle.ndjson, and ' +
404
+ 'manifest.md under the standalone and per-run story trees.',
405
+ },
406
+ auditResults: {
407
+ type: 'boolean',
408
+ description: '<tempRoot>/audits/ — audit lens reports.',
409
+ },
410
+ planDirs: {
411
+ type: 'boolean',
412
+ description:
413
+ '<tempRoot>/plan-<slug>/ — abandoned plan authoring dirs. ' +
414
+ 'Age-floored only; the current run is always excluded.',
415
+ },
416
+ },
417
+ additionalProperties: false,
418
+ },
419
+ },
420
+ additionalProperties: false,
421
+ };
422
+
355
423
  export const DELIVERY_SCHEMA = {
356
424
  type: 'object',
357
425
  properties: {
358
426
  execution: EXECUTION_SCHEMA,
359
427
  lease: LEASE_SCHEMA,
360
428
  docsFreshness: DOCS_FRESHNESS_SCHEMA,
429
+ tempRetention: TEMP_RETENTION_SCHEMA,
361
430
  deliverRunner: DELIVER_RUNNER_SCHEMA,
362
431
  worktreeIsolation: WORKTREE_ISOLATION_SCHEMA,
363
432
  signals: SIGNALS_SCHEMA,
@@ -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 {
@@ -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: [],
@@ -0,0 +1,559 @@
1
+ /**
2
+ * temp-retention.js — allowlisted auto-purge of spent temp artifacts (Story #4794).
3
+ *
4
+ * The workspace temp tree grew without bound: every landed Story left its
5
+ * close-gate transcript (~1.4MB each), its terse-result detail dumps, and its
6
+ * validation-evidence envelope behind forever, because no code path had ever
7
+ * removed them. This module is the single engine that reclaims them.
8
+ *
9
+ * ## Allowlist, never a blocklist
10
+ *
11
+ * The safety property that matters is not "delete the right things" but
12
+ * "never delete the wrong thing". So classification is positive: an artifact
13
+ * is a purge candidate only when a declared class claims it. Everything else —
14
+ * an operator's scratch directory, a hand-parked file, a family a future
15
+ * Story adds without teaching this module about it — is **unrecognized**, is
16
+ * never touched, and is reported with its byte size so a human decides. A
17
+ * blocklist would have the opposite failure mode: anything the framework
18
+ * forgot to exclude gets deleted.
19
+ *
20
+ * ## Two eligibility signals, deliberately different in strength
21
+ *
22
+ * - **Story-keyed.** An artifact whose name carries a Story id is purged when
23
+ * that Story's merge has been *confirmed* by the caller — the post-land tail
24
+ * or a boot sweep that read live state. This is the primary path and the one
25
+ * the operator asked for: spent the moment the work lands.
26
+ * - **Age-floored.** Artifacts no Story id can be recovered from (audit
27
+ * reports, abandoned `plan-<slug>/` dirs) fall back to a `staleDays` floor.
28
+ * Only the sweep opts into this; the per-Story purge never does, so a
29
+ * post-land tail can never reap a sibling's in-flight artifact.
30
+ *
31
+ * ## Keep-class
32
+ *
33
+ * `signals.ndjson` is the artifact whose value *starts* when the run ends —
34
+ * `signals-view`, `acceptance-eval`, and the loop-health check all read it
35
+ * long after the Story merged. It is excluded twice over: it is not in the
36
+ * evidence basename allowlist, and {@link KEEP_BASENAMES} is re-checked at
37
+ * the deletion site. Defence in depth is warranted for the one file whose
38
+ * loss is silent and unrecoverable.
39
+ *
40
+ * ## Best-effort, never load-bearing
41
+ *
42
+ * Every entry point resolves rather than throws. This is hygiene: a purge
43
+ * that fails must never fail a land, a boot, or a persist that already did
44
+ * its real work. Failures are collected into `errors[]` and reported.
45
+ */
46
+
47
+ import fsPromises from 'node:fs/promises';
48
+ import path from 'node:path';
49
+
50
+ import {
51
+ anchorTempRoot,
52
+ ORCHESTRATION_DIRNAME,
53
+ tempRootFrom,
54
+ } from './config/temp-paths.js';
55
+ import { Logger } from './Logger.js';
56
+
57
+ /**
58
+ * Shipped defaults for `delivery.tempRetention`. `enabled` defaults to `true`:
59
+ * the operator asked for auto-purge to be the behaviour, with the knob there
60
+ * to turn it off rather than to turn it on.
61
+ */
62
+ export const TEMP_RETENTION_DEFAULTS = Object.freeze({
63
+ enabled: true,
64
+ staleDays: 7,
65
+ classes: Object.freeze({
66
+ orchestrationLogs: true,
67
+ validationEvidence: true,
68
+ auditResults: true,
69
+ planDirs: true,
70
+ }),
71
+ });
72
+
73
+ /** Every declared purge class, in classification order. */
74
+ export const PURGE_CLASS_NAMES = Object.freeze(
75
+ Object.keys(TEMP_RETENTION_DEFAULTS.classes),
76
+ );
77
+
78
+ /**
79
+ * Basenames no path may ever delete, re-checked at the deletion site even
80
+ * though classification already excludes them. See the module header.
81
+ */
82
+ export const KEEP_BASENAMES = Object.freeze(['signals.ndjson']);
83
+
84
+ /**
85
+ * The per-Story artifact basenames `validationEvidence` claims. An explicit
86
+ * allowlist rather than a "delete everything but signals" rule: a file this
87
+ * module has not been taught about is kept, not guessed at.
88
+ */
89
+ const STORY_EVIDENCE_BASENAMES = Object.freeze([
90
+ 'validation-evidence.json',
91
+ 'lifecycle.ndjson',
92
+ 'manifest.md',
93
+ ]);
94
+
95
+ /**
96
+ * Top-level temp entries that belong to the framework but are never purge
97
+ * candidates: `qa/` holds resumable operator-owned session ledgers, `cache/`
98
+ * has its own invalidation, and `*.lock` files are live coordination state.
99
+ */
100
+ const RESERVED_TOP_LEVEL = Object.freeze(['qa', 'cache']);
101
+
102
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
103
+
104
+ /** `story-4794` → `4794`. */
105
+ const STORY_DIR_PATTERN = /^story-(\d+)$/;
106
+ /** `close-gates-4794`, `sync-result-story-4794` → `4794`. */
107
+ const TRAILING_ID_PATTERN = /-(\d+)$/;
108
+ /** `audit-story-4794-audit-clean-code.md` → `4794`. */
109
+ const AUDIT_STORY_PATTERN = /^audit-story-(\d+)-/;
110
+ /** `run-1030` — a per-run temp tree holding `stories/story-<id>/` children. */
111
+ const RUN_DIR_PATTERN = /^run-\d+$/;
112
+
113
+ /**
114
+ * Resolve the effective retention policy, filling every field from
115
+ * {@link TEMP_RETENTION_DEFAULTS}. An unset block yields the defaults, so a
116
+ * consumer that never heard of this feature gets the purge.
117
+ *
118
+ * @param {object} [config] Resolved config bag.
119
+ * @returns {{ enabled: boolean, staleDays: number, classes: Record<string, boolean> }}
120
+ */
121
+ export function resolveTempRetention(config) {
122
+ const raw = config?.delivery?.tempRetention ?? {};
123
+ const classes = {};
124
+ for (const name of PURGE_CLASS_NAMES) {
125
+ classes[name] =
126
+ raw.classes?.[name] ?? TEMP_RETENTION_DEFAULTS.classes[name];
127
+ }
128
+ return {
129
+ enabled: raw.enabled ?? TEMP_RETENTION_DEFAULTS.enabled,
130
+ staleDays: raw.staleDays ?? TEMP_RETENTION_DEFAULTS.staleDays,
131
+ classes,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * `readdir` that yields `[]` for a directory that does not exist or cannot be
137
+ * read. Every scan below walks optional trees, so an absent one is the normal
138
+ * case, not an error.
139
+ *
140
+ * @param {typeof fsPromises} fsp
141
+ * @param {string} dir
142
+ * @returns {Promise<import('node:fs').Dirent[]>}
143
+ */
144
+ async function safeReaddir(fsp, dir) {
145
+ try {
146
+ return await fsp.readdir(dir, { withFileTypes: true });
147
+ } catch {
148
+ return [];
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Total bytes under a path — the file's own size, or the recursive sum for a
154
+ * directory. Reporting-only: a vanished child is skipped rather than fatal.
155
+ *
156
+ * @param {typeof fsPromises} fsp
157
+ * @param {string} target
158
+ * @returns {Promise<number>}
159
+ */
160
+ async function sizeOf(fsp, target) {
161
+ let total = 0;
162
+ const stack = [target];
163
+ while (stack.length > 0) {
164
+ const current = stack.pop();
165
+ let stats;
166
+ try {
167
+ stats = await fsp.stat(current);
168
+ } catch {
169
+ continue;
170
+ }
171
+ if (!stats.isDirectory()) {
172
+ total += stats.size;
173
+ continue;
174
+ }
175
+ for (const child of await safeReaddir(fsp, current)) {
176
+ stack.push(path.join(current, child.name));
177
+ }
178
+ }
179
+ return total;
180
+ }
181
+
182
+ /**
183
+ * Build one classified entry. `mtimeMs` is the entry's **own** mtime, not the
184
+ * newest mtime beneath it — that is the semantics the shipped stale-plan-dir
185
+ * reap has always used, and widening it here would silently change when an
186
+ * abandoned directory becomes eligible.
187
+ *
188
+ * @param {typeof fsPromises} fsp
189
+ * @param {string} target
190
+ * @param {string} className
191
+ * @param {number|null} storyId
192
+ * @param {boolean} keep
193
+ * @returns {Promise<object|null>}
194
+ */
195
+ async function makeEntry(fsp, target, className, storyId, keep = false) {
196
+ let stats;
197
+ try {
198
+ stats = await fsp.stat(target);
199
+ } catch {
200
+ return null;
201
+ }
202
+ return {
203
+ path: target,
204
+ className,
205
+ storyId,
206
+ keep,
207
+ mtimeMs: stats.mtimeMs,
208
+ bytes: stats.isDirectory() ? await sizeOf(fsp, target) : stats.size,
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Recover the Story id a run-log basename carries. Both writers that land in
214
+ * `orchestration/` end their name with the scope: `close-gates-4794.log` from
215
+ * the gate sink, `sync-result-story-4794.log` from the terse-result dump.
216
+ *
217
+ * @param {string} name
218
+ * @returns {number|null}
219
+ */
220
+ function storyIdFromLogName(name) {
221
+ const match = TRAILING_ID_PATTERN.exec(name.replace(/\.log$/, ''));
222
+ return match ? Number(match[1]) : null;
223
+ }
224
+
225
+ /**
226
+ * `<tempRoot>/orchestration/*.log` — close gate transcripts and terse-result
227
+ * detail dumps. A log whose name carries no id (there are none today, but the
228
+ * class owns the directory) is age-floored rather than dropped from the class.
229
+ */
230
+ async function scanOrchestrationLogs(tempRoot, fsp) {
231
+ const dir = path.join(tempRoot, ORCHESTRATION_DIRNAME);
232
+ const entries = [];
233
+ for (const dirent of await safeReaddir(fsp, dir)) {
234
+ if (!dirent.isFile() || !dirent.name.endsWith('.log')) continue;
235
+ const entry = await makeEntry(
236
+ fsp,
237
+ path.join(dir, dirent.name),
238
+ 'orchestrationLogs',
239
+ storyIdFromLogName(dirent.name),
240
+ );
241
+ if (entry) entries.push(entry);
242
+ }
243
+ return entries;
244
+ }
245
+
246
+ /**
247
+ * Every directory that holds `story-<id>/` children: the standalone tree and
248
+ * each per-run tree.
249
+ */
250
+ async function storyParentDirs(tempRoot, fsp) {
251
+ const parents = [path.join(tempRoot, 'standalone', 'stories')];
252
+ for (const dirent of await safeReaddir(fsp, tempRoot)) {
253
+ if (dirent.isDirectory() && RUN_DIR_PATTERN.test(dirent.name)) {
254
+ parents.push(path.join(tempRoot, dirent.name, 'stories'));
255
+ }
256
+ }
257
+ return parents;
258
+ }
259
+
260
+ /**
261
+ * `<…>/stories/story-<id>/*` — the per-Story delivery artifacts.
262
+ *
263
+ * Every file in the directory is emitted, but only the declared evidence
264
+ * basenames are purge candidates; `signals.ndjson` and anything unrecognized
265
+ * are emitted with `keep: true` so the envelope can show what survived.
266
+ */
267
+ async function scanValidationEvidence(tempRoot, fsp) {
268
+ const entries = [];
269
+ for (const parent of await storyParentDirs(tempRoot, fsp)) {
270
+ for (const dirent of await safeReaddir(fsp, parent)) {
271
+ const match = STORY_DIR_PATTERN.exec(dirent.name);
272
+ if (!dirent.isDirectory() || !match) continue;
273
+ const storyDir = path.join(parent, dirent.name);
274
+ for (const file of await safeReaddir(fsp, storyDir)) {
275
+ if (!file.isFile()) continue;
276
+ const entry = await makeEntry(
277
+ fsp,
278
+ path.join(storyDir, file.name),
279
+ 'validationEvidence',
280
+ Number(match[1]),
281
+ !STORY_EVIDENCE_BASENAMES.includes(file.name),
282
+ );
283
+ if (entry) entries.push(entry);
284
+ }
285
+ }
286
+ }
287
+ return entries;
288
+ }
289
+
290
+ /**
291
+ * `<tempRoot>/audits/*` — audit reports. `audit-story-<id>-<lens>.md` is
292
+ * Story-keyed; the roster-level reports and profiling output are age-floored.
293
+ */
294
+ async function scanAuditResults(tempRoot, fsp) {
295
+ const dir = path.join(tempRoot, 'audits');
296
+ const entries = [];
297
+ for (const dirent of await safeReaddir(fsp, dir)) {
298
+ const match = AUDIT_STORY_PATTERN.exec(dirent.name);
299
+ const entry = await makeEntry(
300
+ fsp,
301
+ path.join(dir, dirent.name),
302
+ 'auditResults',
303
+ match ? Number(match[1]) : null,
304
+ );
305
+ if (entry) entries.push(entry);
306
+ }
307
+ return entries;
308
+ }
309
+
310
+ /**
311
+ * `<tempRoot>/plan-<slug>/` — plan authoring dirs. Never Story-keyed: the
312
+ * directory predates the Stories it creates, so age is the only safe signal.
313
+ */
314
+ async function scanPlanDirs(tempRoot, fsp) {
315
+ const entries = [];
316
+ for (const dirent of await safeReaddir(fsp, tempRoot)) {
317
+ if (!dirent.isDirectory() || !dirent.name.startsWith('plan-')) continue;
318
+ const entry = await makeEntry(
319
+ fsp,
320
+ path.join(tempRoot, dirent.name),
321
+ 'planDirs',
322
+ null,
323
+ );
324
+ if (entry) entries.push(entry);
325
+ }
326
+ return entries;
327
+ }
328
+
329
+ /** Class name → scanner. Iteration order matches {@link PURGE_CLASS_NAMES}. */
330
+ const SCANNERS = Object.freeze({
331
+ orchestrationLogs: scanOrchestrationLogs,
332
+ validationEvidence: scanValidationEvidence,
333
+ auditResults: scanAuditResults,
334
+ planDirs: scanPlanDirs,
335
+ });
336
+
337
+ /**
338
+ * Does a top-level temp entry belong to a declared class? Kept in lockstep
339
+ * with the scanners above: an entry no class walks must show up as
340
+ * unrecognized, never be silently ignored.
341
+ *
342
+ * @param {string} name
343
+ * @returns {boolean}
344
+ */
345
+ function isClassOwnedTopLevel(name) {
346
+ return (
347
+ name === ORCHESTRATION_DIRNAME ||
348
+ name === 'standalone' ||
349
+ name === 'audits' ||
350
+ name.startsWith('plan-') ||
351
+ RUN_DIR_PATTERN.test(name)
352
+ );
353
+ }
354
+
355
+ /**
356
+ * Top-level entries that no class claims and that are not framework-reserved.
357
+ * Reported with byte sizes, never deleted — this is what makes a 49MB scratch
358
+ * directory visible to the operator instead of invisible to the tooling.
359
+ *
360
+ * @param {string} tempRoot
361
+ * @param {typeof fsPromises} fsp
362
+ * @returns {Promise<Array<{ path: string, bytes: number }>>}
363
+ */
364
+ async function collectUnrecognized(tempRoot, fsp) {
365
+ const found = [];
366
+ for (const dirent of await safeReaddir(fsp, tempRoot)) {
367
+ const { name } = dirent;
368
+ if (isClassOwnedTopLevel(name)) continue;
369
+ if (RESERVED_TOP_LEVEL.includes(name) || name.endsWith('.lock')) continue;
370
+ const target = path.join(tempRoot, name);
371
+ found.push({ path: target, bytes: await sizeOf(fsp, target) });
372
+ }
373
+ return found;
374
+ }
375
+
376
+ /**
377
+ * Classify a whole temp tree without deleting anything. Exported so a caller
378
+ * (or a test) can see exactly what the purge would consider.
379
+ *
380
+ * @param {{ config?: object, tempRoot?: string, fsp?: typeof fsPromises }} [args]
381
+ * @returns {Promise<{ tempRoot: string, entries: object[], unrecognized: Array<{ path: string, bytes: number }> }>}
382
+ */
383
+ export async function collectTempEntries({
384
+ config,
385
+ tempRoot,
386
+ fsp = fsPromises,
387
+ } = {}) {
388
+ const root = tempRoot ?? anchorTempRoot(tempRootFrom(config));
389
+ const entries = [];
390
+ for (const className of PURGE_CLASS_NAMES) {
391
+ entries.push(...(await SCANNERS[className](root, fsp)));
392
+ }
393
+ return {
394
+ tempRoot: root,
395
+ entries,
396
+ unrecognized: await collectUnrecognized(root, fsp),
397
+ };
398
+ }
399
+
400
+ /**
401
+ * Is this entry eligible for deletion under the current policy and signals?
402
+ *
403
+ * @param {object} entry
404
+ * @param {object} ctx
405
+ * @returns {boolean}
406
+ */
407
+ function isPurgeable(entry, ctx) {
408
+ if (entry.keep) return false;
409
+ if (KEEP_BASENAMES.includes(path.basename(entry.path))) return false;
410
+ if (!ctx.classes[entry.className]) return false;
411
+ if (ctx.only && !ctx.only.includes(entry.className)) return false;
412
+ if (ctx.excluded.has(path.resolve(entry.path))) return false;
413
+ if (entry.storyId !== null && ctx.storyIds.has(entry.storyId)) return true;
414
+ return ctx.sweepStale && ctx.now - entry.mtimeMs >= ctx.staleMs;
415
+ }
416
+
417
+ /**
418
+ * The purge core. Deliberately **module-private**: the two exported entry
419
+ * points below are the whole public surface, and each encodes a policy
420
+ * decision (Story-keyed vs. age-floored) that a caller reaching this directly
421
+ * could get wrong. Exporting it would also be a dead export — nothing outside
422
+ * this file has a reason to call it.
423
+ *
424
+ * @param {object} [args]
425
+ * @param {object} [args.config] Resolved config bag.
426
+ * @param {number[]} [args.storyIds] Stories whose merge the caller CONFIRMED.
427
+ * @param {boolean} [args.sweepStale] Opt into the age floor for un-keyed entries.
428
+ * @param {string[]|null} [args.only] Restrict to these class names.
429
+ * @param {string[]} [args.excludePaths] Absolute paths to leave alone.
430
+ * @param {number} [args.now] Clock seam.
431
+ * @param {string} [args.tempRoot] Temp root override (tests).
432
+ * @param {typeof fsPromises} [args.fsp] Filesystem seam.
433
+ * @param {{ info: Function }} [args.logger] Logger seam.
434
+ * @param {string} [args.label] Prefix for the single summary line.
435
+ * @returns {Promise<object>} Result envelope; never throws.
436
+ */
437
+ async function purgeTempArtifacts({
438
+ config,
439
+ storyIds = [],
440
+ sweepStale = false,
441
+ only = null,
442
+ excludePaths = [],
443
+ now = Date.now(),
444
+ tempRoot,
445
+ fsp = fsPromises,
446
+ logger = Logger,
447
+ label = 'temp-retention',
448
+ } = {}) {
449
+ const policy = resolveTempRetention(config);
450
+ const base = {
451
+ enabled: policy.enabled,
452
+ tempRoot: tempRoot ?? anchorTempRoot(tempRootFrom(config)),
453
+ purged: [],
454
+ kept: [],
455
+ unrecognized: [],
456
+ bytesReclaimed: 0,
457
+ errors: [],
458
+ };
459
+ if (!policy.enabled) return { ...base, skipped: 'disabled' };
460
+
461
+ let scan;
462
+ try {
463
+ scan = await collectTempEntries({ config, tempRoot: base.tempRoot, fsp });
464
+ } catch (err) {
465
+ return { ...base, skipped: null, errors: [String(err?.message ?? err)] };
466
+ }
467
+
468
+ const ctx = {
469
+ classes: policy.classes,
470
+ only,
471
+ storyIds: new Set(storyIds),
472
+ sweepStale,
473
+ staleMs: policy.staleDays * MS_PER_DAY,
474
+ now,
475
+ excluded: new Set(excludePaths.map((p) => path.resolve(p))),
476
+ };
477
+ const result = { ...base, skipped: null, unrecognized: scan.unrecognized };
478
+
479
+ for (const entry of scan.entries) {
480
+ if (!isPurgeable(entry, ctx)) {
481
+ if (entry.keep) result.kept.push(entry.path);
482
+ continue;
483
+ }
484
+ try {
485
+ await fsp.rm(entry.path, { recursive: true, force: true });
486
+ result.purged.push({ path: entry.path, bytes: entry.bytes });
487
+ result.bytesReclaimed += entry.bytes;
488
+ } catch (err) {
489
+ // A racing writer or a permission error: leave it for the next run.
490
+ result.errors.push(`${entry.path}: ${String(err?.message ?? err)}`);
491
+ }
492
+ }
493
+
494
+ if (result.purged.length > 0) {
495
+ logger?.info?.(
496
+ `[${label}] purged ${result.purged.length} spent temp artifact(s), ` +
497
+ `reclaimed ${formatBytes(result.bytesReclaimed)} under ${result.tempRoot}.`,
498
+ );
499
+ }
500
+ return result;
501
+ }
502
+
503
+ /**
504
+ * Human-readable byte count for the one summary line.
505
+ *
506
+ * @param {number} bytes
507
+ * @returns {string}
508
+ */
509
+ export function formatBytes(bytes) {
510
+ if (bytes < 1024) return `${bytes}B`;
511
+ const units = ['KB', 'MB', 'GB'];
512
+ let value = bytes / 1024;
513
+ let unit = 0;
514
+ while (value >= 1024 && unit < units.length - 1) {
515
+ value /= 1024;
516
+ unit += 1;
517
+ }
518
+ return `${value.toFixed(1)}${units[unit]}`;
519
+ }
520
+
521
+ /**
522
+ * Purge one merged Story's spent artifacts. Called from the post-land tail,
523
+ * where "merged" is already confirmed — so this never applies the age floor
524
+ * and can never touch a sibling Story's in-flight artifacts.
525
+ *
526
+ * @param {{ storyId: number, config?: object, now?: number, tempRoot?: string,
527
+ * fsp?: typeof fsPromises, logger?: object }} args
528
+ * @returns {Promise<object>} Result envelope; never throws.
529
+ */
530
+ export async function purgeStoryTempArtifacts({ storyId, config, ...rest }) {
531
+ return purgeTempArtifacts({
532
+ config,
533
+ storyIds: Number.isInteger(storyId) ? [storyId] : [],
534
+ sweepStale: false,
535
+ ...rest,
536
+ });
537
+ }
538
+
539
+ /**
540
+ * Catch-up sweep: purge the artifacts of every Story the caller confirmed
541
+ * merged, plus every age-floored entry past `staleDays`. This is the path
542
+ * that reclaims a backlog — Stories merged in an earlier run, merged through
543
+ * the GitHub UI, or delivered before this feature existed.
544
+ *
545
+ * @param {{ config?: object, mergedStoryIds?: number[], now?: number,
546
+ * tempRoot?: string, fsp?: typeof fsPromises, logger?: object,
547
+ * only?: string[]|null, excludePaths?: string[], label?: string }} [args]
548
+ * @returns {Promise<object>} Result envelope; never throws.
549
+ */
550
+ export async function sweepTempRetention({
551
+ mergedStoryIds = [],
552
+ ...rest
553
+ } = {}) {
554
+ return purgeTempArtifacts({
555
+ storyIds: mergedStoryIds,
556
+ sweepStale: true,
557
+ ...rest,
558
+ });
559
+ }
@@ -777,7 +777,7 @@ export async function runSingleStoryInit({
777
777
  label: 'STORY INIT RESULT',
778
778
  result,
779
779
  scope: storyId,
780
- logDir: path.join(cwd, 'temp', 'orchestration'),
780
+ config,
781
781
  summary: {
782
782
  storyId,
783
783
  storyBranch,
@@ -31,6 +31,7 @@
31
31
  import path from 'node:path';
32
32
  import { parseArgs } from 'node:util';
33
33
  import { runAsCli } from './lib/cli-utils.js';
34
+ import { resolveConfig } from './lib/config-resolver.js';
34
35
  import { syncBranchFromBase } from './lib/git/sync-from-base.js';
35
36
  import { gitSpawn, gitSync } from './lib/git-utils.js';
36
37
  import { Logger } from './lib/Logger.js';
@@ -95,11 +96,15 @@ export async function runSyncBranchFromBase(opts = {}) {
95
96
  });
96
97
 
97
98
  // Story #4685 — full detail to a temp log; emit a single summary line.
99
+ // Story #4794 — resolve the config so the log honours `project.paths.tempRoot`
100
+ // instead of the hardcoded `<cwd>/temp` this used to join. `resolveConfig`
101
+ // degrades to the framework defaults when no `.agentrc.json` is present, so
102
+ // a zero-config invocation needs no guard here.
98
103
  emitTerseResult({
99
104
  label: 'SYNC RESULT',
100
105
  result,
101
106
  scope: branch,
102
- logDir: path.join(cwd, 'temp', 'orchestration'),
107
+ config: resolveConfig({ cwd }),
103
108
  summary: { branch, base, synced: result.synced, kind: result.kind },
104
109
  });
105
110
 
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [2.18.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.17.0...mandrel-v2.18.0) (2026-07-26)
6
+
7
+
8
+ ### Added
9
+
10
+ * **temp:** auto-purge merged Stories' spent temp artifacts behind an allowlist, keeping signals ([#4794](https://github.com/dsj1984/mandrel/issues/4794)) ([#4795](https://github.com/dsj1984/mandrel/issues/4795)) ([d69d985](https://github.com/dsj1984/mandrel/commit/d69d9858511658e0ec0336db0cf8aa65d725637b))
11
+
12
+
13
+ ### Chores
14
+
15
+ * **release:** cut the 2.18.0 release missed by an unparseable squash subject ([#4796](https://github.com/dsj1984/mandrel/issues/4796)) ([8ccff57](https://github.com/dsj1984/mandrel/commit/8ccff574d44519595e04fc0f3597cb4dcd005436))
16
+
5
17
  ## [2.17.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.16.0...mandrel-v2.17.0) (2026-07-26)
6
18
 
7
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",