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
@@ -528,7 +528,7 @@ new CI gate**, route the check through a `package.json` script (add it to
528
528
  transitivity. **When a workflow file genuinely must change** (a new job, a
529
529
  trigger change, a runner bump), the edit must be made by an operator with
530
530
  `Workflows: Read and write` PAT permissions — see
531
- [`docs/release-operations.md` § One-time PAT setup](../../docs/release-operations.md#one-time-pat-setup).
531
+ [`docs/release-operations.md` § One-time PAT setup](https://github.com/dsj1984/mandrel/blob/main/docs/release-operations.md#one-time-pat-setup).
532
532
 
533
533
  ### Worktree config shadow
534
534
 
@@ -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}`);
@@ -25,6 +25,10 @@
25
25
  // a retired token is always a non-zero exit even if a stale workflow
26
26
  // file happens to exist.
27
27
  //
28
+ // 4. Story #4801 — every relative link originating under `.agents/**`
29
+ // resolves to a target that still exists once the tree is materialized
30
+ // into a *consumer* project. See `escapesPayload` for the boundary rule.
31
+ //
28
32
  // Exit codes:
29
33
  // 0 every link and slash-command token resolves cleanly.
30
34
  // 1 at least one violation; details are written to stderr (file:line).
@@ -35,6 +39,8 @@
35
39
  import fs from 'node:fs';
36
40
  import path from 'node:path';
37
41
  import { fileURLToPath } from 'node:url';
42
+ import { minimatch } from 'minimatch';
43
+ import { parseStandardCliArgs } from './lib/cli/standard-args.js';
38
44
  import { runAsCli } from './lib/cli-utils.js';
39
45
  import { Logger } from './lib/Logger.js';
40
46
 
@@ -124,6 +130,63 @@ export const SLASH_ALLOWLIST = new Set([
124
130
  'main',
125
131
  ]);
126
132
 
133
+ // --- Payload boundary (Story #4801) ----------------------------------------
134
+
135
+ // `mandrel sync` materializes ONLY the package's `.agents/` payload into a
136
+ // consumer's project, at `<projectRoot>/.agents` (see `lib/cli/sync.js`:
137
+ // `destRoot = path.join(projectRoot, '.agents')`). `bin/` and `lib/` ship
138
+ // inside the npm tarball but stay under `node_modules/mandrel/`, and the
139
+ // framework's own `tests/`, `docs/` (bar the CHANGELOG) and `.claude/` trees
140
+ // ship nowhere at all. So a relative link that escapes `.agents/` resolves
141
+ // cleanly in THIS repo and dangles in every consumer — which is exactly why
142
+ // the checker cannot catch this class by `fs.existsSync` alone.
143
+ //
144
+ // This is why the boundary is `.agents/` and NOT `package.json#files`: the
145
+ // latter lists `lib/` and `bin/`, which are packaged but never materialized
146
+ // at a consumer's repo root.
147
+ export const MATERIALIZED_ROOT = '.agents';
148
+
149
+ // Repo-root-relative paths OUTSIDE `.agents/` that a Mandrel *consumer*
150
+ // legitimately owns, so a doc under `.agents/**` may still link to them.
151
+ // Deliberately explicit rather than pattern-derived: whether a given repo-root
152
+ // path is consumer-owned or framework-only is a judgment per path, not a rule.
153
+ // A new escaping link fails closed until it is justified and added here.
154
+ export const CONSUMER_OWNED_PATHS = new Set([
155
+ 'package.json',
156
+ '.agentrc.json',
157
+ '.c8rc.cjs',
158
+ 'docs/architecture.md',
159
+ 'docs/decisions.md',
160
+ ]);
161
+
162
+ // Directory prefixes (repo-root-relative, trailing slash) whose whole subtree
163
+ // is consumer-owned.
164
+ export const CONSUMER_OWNED_PREFIXES = Object.freeze(['baselines/']);
165
+
166
+ /**
167
+ * True when `relTarget` is unreachable from a materialized consumer tree.
168
+ *
169
+ * Only links whose SOURCE lives under `.agents/**` are subject to the rule —
170
+ * `docs/**` is framework-repo-only, ships nowhere, and keeps today's
171
+ * existence-only semantics.
172
+ *
173
+ * @param {string} relFile repo-relative POSIX path of the linking document
174
+ * @param {string} relTarget repo-relative POSIX path the link resolves to
175
+ */
176
+ export function escapesPayload(relFile, relTarget) {
177
+ if (!relFile.startsWith(`${MATERIALIZED_ROOT}/`)) return false;
178
+ if (
179
+ relTarget === MATERIALIZED_ROOT ||
180
+ relTarget.startsWith(`${MATERIALIZED_ROOT}/`)
181
+ ) {
182
+ return false;
183
+ }
184
+ if (CONSUMER_OWNED_PATHS.has(relTarget)) return false;
185
+ if (CONSUMER_OWNED_PREFIXES.some((p) => relTarget.startsWith(p)))
186
+ return false;
187
+ return true;
188
+ }
189
+
127
190
  // --- File discovery --------------------------------------------------------
128
191
 
129
192
  function isExcludedRelPath(relPath) {
@@ -150,14 +213,28 @@ function walkMarkdown(dirAbs, repoRoot, out) {
150
213
  }
151
214
  }
152
215
 
153
- export function discoverMarkdown(rootAbs, scanRoots) {
216
+ /**
217
+ * Collect every non-excluded `*.md` under each `scanRoots` entry.
218
+ *
219
+ * @param {string} rootAbs absolute repo root
220
+ * @param {string[]} scanRoots repo-relative subtrees to walk
221
+ * @param {string[]} [exclude] minimatch globs; a repo-relative POSIX path
222
+ * matching any of them is dropped from the scan
223
+ */
224
+ export function discoverMarkdown(rootAbs, scanRoots, exclude = []) {
154
225
  const out = [];
155
226
  for (const sub of scanRoots) {
156
227
  const subAbs = path.join(rootAbs, sub);
157
228
  if (fs.existsSync(subAbs)) walkMarkdown(subAbs, rootAbs, out);
158
229
  }
159
- out.sort();
160
- return out;
230
+ const filtered = exclude.length
231
+ ? out.filter((abs) => {
232
+ const rel = path.relative(rootAbs, abs).split(path.sep).join('/');
233
+ return !exclude.some((g) => minimatch(rel, g, { dot: true }));
234
+ })
235
+ : out;
236
+ filtered.sort();
237
+ return filtered;
161
238
  }
162
239
 
163
240
  // --- Region masking --------------------------------------------------------
@@ -326,6 +403,26 @@ export function checkFile(absPath, repoRoot) {
326
403
  } else {
327
404
  resolved = path.resolve(fileDir, pathOnly);
328
405
  }
406
+ // Payload boundary (Story #4801) takes precedence over existence: a link
407
+ // that escapes the materialized tree is a defect even when the target
408
+ // exists here, and reporting both kinds for one link would double-count.
409
+ const relTarget = path
410
+ .relative(repoRoot, resolved)
411
+ .split(path.sep)
412
+ .join('/');
413
+ if (escapesPayload(relFile, relTarget)) {
414
+ violations.push({
415
+ file: relFile,
416
+ line,
417
+ kind: 'payload-boundary',
418
+ message:
419
+ `link escapes the materialized payload: ${target} → ${relTarget}. ` +
420
+ `Only '${MATERIALIZED_ROOT}/' is materialized into a consumer project, ` +
421
+ 'so this resolves here but dangles for every consumer. Use an absolute ' +
422
+ 'GitHub URL or a non-link code span.',
423
+ });
424
+ continue;
425
+ }
329
426
  if (!fs.existsSync(resolved)) {
330
427
  violations.push({
331
428
  file: relFile,
@@ -377,6 +474,8 @@ export function checkFile(absPath, repoRoot) {
377
474
 
378
475
  // --- Public entry point ----------------------------------------------------
379
476
 
477
+ export const DEFAULT_SCAN_ROOTS = Object.freeze(['docs', '.agents']);
478
+
380
479
  /**
381
480
  * Run the checker programmatically. Returns `{ exitCode, violations }`.
382
481
  * `exitCode` is 0 when every doc is clean, 1 otherwise.
@@ -384,11 +483,13 @@ export function checkFile(absPath, repoRoot) {
384
483
  * @param {object} [options]
385
484
  * @param {string} [options.repoRoot] Defaults to the framework repo root.
386
485
  * @param {string[]} [options.scanRoots] Defaults to `['docs', '.agents']`.
486
+ * @param {string[]} [options.exclude] minimatch globs dropped from the scan.
387
487
  */
388
488
  export function runCheck(options = {}) {
389
489
  const repoRoot = options.repoRoot ?? REPO_ROOT;
390
- const scanRoots = options.scanRoots ?? ['docs', '.agents'];
391
- const files = discoverMarkdown(repoRoot, scanRoots);
490
+ const scanRoots = options.scanRoots ?? [...DEFAULT_SCAN_ROOTS];
491
+ const exclude = options.exclude ?? [];
492
+ const files = discoverMarkdown(repoRoot, scanRoots, exclude);
392
493
  const violations = [];
393
494
  for (const abs of files) {
394
495
  const fileViolations = checkFile(abs, repoRoot);
@@ -405,8 +506,28 @@ function formatViolation(v) {
405
506
  return `${v.file}:${v.line}: [${v.kind}] ${v.message}`;
406
507
  }
407
508
 
509
+ /**
510
+ * Translate argv into `runCheck` options. Repeatable `--scan-root` replaces
511
+ * the default scan set entirely; repeatable `--exclude` filters whatever was
512
+ * scanned. Absent flags reproduce the pre-#4801 defaults exactly.
513
+ */
514
+ export function parseArgs(argv) {
515
+ const { values } = parseStandardCliArgs({
516
+ argv,
517
+ extras: {
518
+ 'scan-root': { type: 'string-multi', alias: 'scanRoot' },
519
+ exclude: { type: 'string-multi', alias: 'exclude' },
520
+ },
521
+ });
522
+ const scanRoots = values.scanRoot?.length
523
+ ? values.scanRoot
524
+ : [...DEFAULT_SCAN_ROOTS];
525
+ return { scanRoots, exclude: values.exclude ?? [] };
526
+ }
527
+
408
528
  async function main() {
409
- const result = runCheck();
529
+ const { scanRoots, exclude } = parseArgs(process.argv.slice(2));
530
+ const result = runCheck({ scanRoots, exclude });
410
531
  if (result.violations.length === 0) {
411
532
  Logger.info(
412
533
  `[check-doc-links] OK — scanned ${result.scanned} active markdown file(s); no violations.`,
@@ -426,11 +547,22 @@ async function main() {
426
547
  runAsCli(import.meta.url, main, {
427
548
  source: 'check-doc-links',
428
549
  usage: {
429
- invocation: 'node .agents/scripts/check-doc-links.js',
550
+ invocation:
551
+ 'node .agents/scripts/check-doc-links.js [--scan-root <path>] [--exclude <glob>]',
430
552
  summary:
431
- 'Validate every relative Markdown link and /slash-command token across docs/ and .agents/, and reject mentions of retired commands.',
432
- flags: [],
553
+ 'Validate every relative Markdown link and /slash-command token across docs/ and .agents/, reject mentions of retired commands, and reject links that escape the materialized .agents/ payload.',
554
+ flags: [
555
+ [
556
+ '--scan-root <path>',
557
+ 'Repeatable. Repo-relative subtree to scan. Replaces the default set (docs, .agents).',
558
+ ],
559
+ [
560
+ '--exclude <glob>',
561
+ 'Repeatable. minimatch glob; matching files are dropped from the scan.',
562
+ ],
563
+ ],
433
564
  notes: [
565
+ 'Consumers materialize only .agents/, so a relative link from .agents/**\nto a framework-repo-only path (tests/, lib/, .claude/, framework docs)\nis reported as a payload-boundary violation even though it resolves here.',
434
566
  'Exit codes:\n 0 every link and command token resolves\n 1 at least one violation (file:line on stderr)',
435
567
  ],
436
568
  },
@@ -83,69 +83,61 @@ export function resolveCrapEnvOverrides(crapConfig, env) {
83
83
  }
84
84
 
85
85
  /**
86
- * Pure helper: resolve the one-shot bundle-size refresh/acknowledge flag
87
- * (Story #151). Unlike `coverage` / `crap` / `maintainability`, the
88
- * bundle-size gate has no scorer of its own the measured sizes come from
89
- * a build step the operator already runs, not a source-tree rescan — so
90
- * there is no `refreshBaseline({ kind: 'bundle-size', ... })` path to
91
- * regenerate a "corrected" baseline. Instead, `BUNDLE_SIZE_REFRESH=1`
92
- * (mirroring `CRAP_TOLERANCE`'s env-override precedent) tells
93
- * `check-baselines --gate bundle-size` to treat this run's head
94
- * measurements as the newly acknowledged baseline: head-vs-base
95
- * regressions are demoted to `unchanged` for this invocation only. Floors
96
- * still apply — an acknowledged PR can still fail on an absolute budget
97
- * breach, only the ratchet-vs-`origin/main` comparison is suspended.
86
+ * The env var that acknowledges a deliberate baseline refresh for `kind`.
87
+ * Upper-snakes the kind name, so `bundle-size` `BUNDLE_SIZE_REFRESH` and
88
+ * `coverage` `COVERAGE_REFRESH`. The two names that predate the generic
89
+ * mechanism (`BUNDLE_SIZE_REFRESH`, Story #151; `MAINTAINABILITY_REFRESH`,
90
+ * Story #4731) are exactly what this rule produces, so generalizing kept
91
+ * both working unchanged.
98
92
  *
99
- * The flag is **not persisted** anywhere (no config write, no committed
100
- * tag): the very next `check-baselines` invocation without the env var
101
- * i.e. the next PR — reverts to full strict enforcement automatically, so
102
- * there is no lingering loosened tolerance to remember to reset (AC-3).
93
+ * Module-local: `resolveKindRefreshOverrides` is the public surface, and the
94
+ * naming rule is pinned through it rather than exported for its own sake.
103
95
  *
104
- * Accepted truthy values: `1`, `true` (case-insensitive). Anything else
105
- * (including unset/empty) resolves to `acknowledged: false`.
106
- *
107
- * @param {NodeJS.ProcessEnv} env
108
- * @returns {{ acknowledged: boolean, overrides: string[] }}
96
+ * @param {string} kind
97
+ * @returns {string|null} null when `kind` is not a usable kind name
109
98
  */
110
- export function resolveBundleSizeEnvOverrides(env) {
111
- const raw = env?.BUNDLE_SIZE_REFRESH;
112
- const acknowledged =
113
- typeof raw === 'string' && /^(1|true)$/i.test(raw.trim());
114
- const overrides = acknowledged
115
- ? [`acknowledged=true (BUNDLE_SIZE_REFRESH=${raw})`]
116
- : [];
117
- return { acknowledged, overrides };
99
+ function kindRefreshEnvVar(kind) {
100
+ if (typeof kind !== 'string' || kind.length === 0) return null;
101
+ return `${kind.toUpperCase().replace(/-/g, '_')}_REFRESH`;
118
102
  }
119
103
 
120
104
  /**
121
- * Pure helper: resolve the one-shot maintainability refresh/acknowledge flag
122
- * (Story #4731). This is the env-parity sibling of
123
- * `resolveBundleSizeEnvOverrides`: `MAINTAINABILITY_REFRESH=1` (or `true`,
124
- * case-insensitive) tells `check-baselines --gate maintainability` to demote
125
- * this run's head-vs-base maintainability regressions to `unchanged` for this
126
- * invocation only. Floors still apply — an acknowledged run can still fail on
127
- * an absolute floor breach (e.g. a row below `min` 70); only the
128
- * ratchet-vs-base regression comparison is suspended.
105
+ * Pure helper: resolve the one-shot baseline refresh/acknowledge flag for any
106
+ * ratcheted kind (Story #4802, generalizing Story #151 / Story #4731).
107
+ *
108
+ * `<KIND>_REFRESH=1` tells `check-baselines --gate <kind>` to demote this
109
+ * run's head-vs-base regressions to `unchanged` for this invocation only.
110
+ * Floors still apply — an acknowledged run can still fail on an absolute
111
+ * floor breach; only the ratchet-vs-base comparison is suspended.
112
+ *
113
+ * Why every kind needs this: a diff-scope baseline is an accretion of many
114
+ * partial runs, not one measurement. Replacing it with a single full-scope
115
+ * measurement necessarily produces row deltas in both directions that are
116
+ * arithmetic, not behavioural — so without an acknowledgment path the gate
117
+ * blocks precisely the correction it should encourage.
129
118
  *
130
- * Unlike bundle-size, maintainability also has a **commit-tagged** trigger
131
- * (a `baseline-refresh:`-tagged commit in the compared range that touches the
132
- * maintainability baseline file) resolved in the evaluate phase this env
133
- * flag is the manual override the two share by shape. Neither is persisted:
134
- * the next run without the flag / tag re-enforces the ratchet at full
135
- * strength automatically.
119
+ * The flag is **not persisted** anywhere (no config write, no committed tag):
120
+ * the very next invocation without the env var reverts to full strict
121
+ * enforcement automatically, so there is no lingering loosened tolerance to
122
+ * remember to reset. The evaluate phase pairs this with a commit-tagged
123
+ * trigger that is likewise one-shot by construction.
136
124
  *
137
- * Accepted truthy values: `1`, `true` (case-insensitive). Anything else
138
- * (including unset/empty) resolves to `acknowledged: false`.
125
+ * Accepted truthy values: `1`, `true` (case-insensitive), with surrounding
126
+ * whitespace trimmed. Anything else — including unset, empty, `0`, `false`,
127
+ * and non-string values — resolves to `acknowledged: false`.
139
128
  *
129
+ * @param {string} kind
140
130
  * @param {NodeJS.ProcessEnv} env
141
131
  * @returns {{ acknowledged: boolean, overrides: string[] }}
142
132
  */
143
- export function resolveMaintainabilityRefreshOverrides(env) {
144
- const raw = env?.MAINTAINABILITY_REFRESH;
133
+ export function resolveKindRefreshOverrides(kind, env) {
134
+ const varName = kindRefreshEnvVar(kind);
135
+ if (!varName) return { acknowledged: false, overrides: [] };
136
+ const raw = env?.[varName];
145
137
  const acknowledged =
146
138
  typeof raw === 'string' && /^(1|true)$/i.test(raw.trim());
147
139
  const overrides = acknowledged
148
- ? [`acknowledged=true (MAINTAINABILITY_REFRESH=${raw})`]
140
+ ? [`acknowledged=true (${varName}=${raw})`]
149
141
  : [];
150
142
  return { acknowledged, overrides };
151
143
  }
@@ -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,