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
@@ -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
 
@@ -39,8 +39,8 @@ Sequential inline execution is the fallback (see the core's Execution strategy).
39
39
  > orchestrated path grants its measurement agents a `Bash` tool restricted to a
40
40
  > **non-mutating command allowlist** (profilers, timers, bundle-stat and
41
41
  > file-size probes — never a command that writes source, installs, or mutates
42
- > git/labels). See the allowlist in
43
- > [`../../.claude/workflows/audit-performance.workflow.js`](../../.claude/workflows/audit-performance.workflow.js).
42
+ > git/labels). See the allowlist in the harness-generated
43
+ > `.claude/workflows/audit-performance.workflow.js`.
44
44
 
45
45
  ## Step 0: Measure before you judge (mandatory)
46
46
 
@@ -113,5 +113,5 @@ pipelines and CI-step parsing.
113
113
  — the canonical contract every check module must satisfy.
114
114
  - [`.agents/scripts/diagnose.js`](../../scripts/diagnose.js) — the CLI
115
115
  implementation backing this helper.
116
- - [`tests/diagnose-output.test.js`](../../../tests/diagnose-output.test.js)
116
+ - [`tests/diagnose-output.test.js`](https://github.com/dsj1984/mandrel/blob/main/tests/diagnose-output.test.js)
117
117
  — pinned output and exit-code contracts.
@@ -106,7 +106,7 @@ node .agents/scripts/signals-view.js 9999
106
106
  CLI implementation backing this helper.
107
107
  - [`.agents/scripts/lib/signals/`](../../scripts/lib/signals/) — the
108
108
  shared reader + schema + span-tree barrel.
109
- - [`tests/signals-view.test.js`](../../../tests/signals-view.test.js) —
109
+ - [`tests/signals-view.test.js`](https://github.com/dsj1984/mandrel/blob/main/tests/signals-view.test.js) —
110
110
  pinned output and tempRoot-honour contracts.
111
- - [`tests/lib/signals/span-tree.test.js`](../../../tests/lib/signals/span-tree.test.js) —
111
+ - [`tests/lib/signals/span-tree.test.js`](https://github.com/dsj1984/mandrel/blob/main/tests/lib/signals/span-tree.test.js) —
112
112
  pure-function contract for the span-tree builder.
@@ -13,7 +13,7 @@ description: >-
13
13
  # /mandrel-update
14
14
 
15
15
  > **Upgrade owner.** The mechanical upgrade is owned end to end by the
16
- > [`mandrel update`](../../lib/cli/update.js) CLI under the npm distribution
16
+ > [`mandrel update`](https://github.com/dsj1984/mandrel/blob/main/lib/cli/update.js) CLI under the npm distribution
17
17
  > model. This workflow wraps that CLI: it runs
18
18
  > `npx mandrel update`, then walks the operator through the
19
19
  > **distribution-agnostic judgment steps** the CLI deliberately does **not**
@@ -66,7 +66,7 @@ envelope (`{ ok, blocked, findings[] }`) plus a human-readable report:
66
66
  the version probe.
67
67
 
68
68
  The preflight is a workflow-layer guard; it deliberately lives outside
69
- [`lib/cli/update.js`](../../lib/cli/update.js), which stays git-free.
69
+ [`lib/cli/update.js`](https://github.com/dsj1984/mandrel/blob/main/lib/cli/update.js), which stays git-free.
70
70
 
71
71
  ## Step 1 — Run the updater
72
72
 
@@ -102,9 +102,9 @@ recovered and a clean re-run reports success.**
102
102
 
103
103
  Identify the failed phase (the CLI's stderr names it) and run the matching
104
104
  remedy. These commands match the hint strings
105
- [`lib/cli/update.js`](../../lib/cli/update.js) emits verbatim — it is the
105
+ [`lib/cli/update.js`](https://github.com/dsj1984/mandrel/blob/main/lib/cli/update.js) emits verbatim — it is the
106
106
  single source of truth, kept in lockstep with this table by
107
- [`tests/bootstrap/mandrel-update-recovery-drift.test.js`](../../tests/bootstrap/mandrel-update-recovery-drift.test.js):
107
+ [`tests/bootstrap/mandrel-update-recovery-drift.test.js`](https://github.com/dsj1984/mandrel/blob/main/tests/bootstrap/mandrel-update-recovery-drift.test.js):
108
108
 
109
109
  | Failed phase | Manual remedy |
110
110
  | ----------------- | ------------------------------------------------------- |
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [2.19.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.18.0...mandrel-v2.19.0) (2026-07-27)
6
+
7
+
8
+ ### Added
9
+
10
+ * check-baselines: generalize the one-shot baseline-refresh acknowledgment across every ratcheted kind ([#4802](https://github.com/dsj1984/mandrel/issues/4802)) ([#4805](https://github.com/dsj1984/mandrel/issues/4805)) ([ec9b066](https://github.com/dsj1984/mandrel/commit/ec9b066a5d4f74eb53f88ab347138b3434ffa47b))
11
+ * **doc-links:** reject links escaping the materialized .agents payload (refs [#4801](https://github.com/dsj1984/mandrel/issues/4801)) ([#4803](https://github.com/dsj1984/mandrel/issues/4803)) ([97a383a](https://github.com/dsj1984/mandrel/commit/97a383a4765a9a617648f6c1b422c17d837b6ad8))
12
+
13
+ ## [2.18.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.17.0...mandrel-v2.18.0) (2026-07-26)
14
+
15
+
16
+ ### Added
17
+
18
+ * **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))
19
+
20
+
21
+ ### Chores
22
+
23
+ * **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))
24
+
5
25
  ## [2.17.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.16.0...mandrel-v2.17.0) (2026-07-26)
6
26
 
7
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.17.0",
3
+ "version": "2.19.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/",