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.
- package/.agents/docs/agentrc-reference.json +10 -0
- package/.agents/docs/configuration.md +8 -0
- package/.agents/schemas/agentrc.schema.json +42 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/boot-sweep.js +39 -2
- package/.agents/scripts/lib/config/temp-paths.js +27 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +69 -0
- package/.agents/scripts/lib/observability/terse-result.js +7 -3
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +19 -41
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +9 -5
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +1 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +31 -1
- package/.agents/scripts/lib/single-story-sweep.js +11 -0
- package/.agents/scripts/lib/temp-retention.js +559 -0
- package/.agents/scripts/single-story-init.js +1 -1
- package/.agents/scripts/sync-branch-from-base.js +6 -1
- package/docs/CHANGELOG.md +12 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
* `<
|
|
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 ??
|
|
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 {
|
|
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.
|
|
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
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
*
|
|
219
|
-
*
|
|
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 ??
|
|
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,
|
|
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
|
-
* @
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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