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.
- package/.agents/docs/SDLC.md +1 -1
- 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/check-doc-links.js +141 -9
- package/.agents/scripts/lib/baselines/env-overrides.js +40 -48
- 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/check-baselines/phases/evaluate.js +51 -77
- package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +20 -12
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +2 -1
- 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/.agents/workflows/audit-performance.md +2 -2
- package/.agents/workflows/helpers/diagnose.md +1 -1
- package/.agents/workflows/helpers/signals.md +2 -2
- package/.agents/workflows/mandrel-update.md +4 -4
- package/docs/CHANGELOG.md +20 -0
- package/package.json +1 -1
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* temp-retention.js — allowlisted auto-purge of spent temp artifacts (Story #4794).
|
|
3
|
+
*
|
|
4
|
+
* The workspace temp tree grew without bound: every landed Story left its
|
|
5
|
+
* close-gate transcript (~1.4MB each), its terse-result detail dumps, and its
|
|
6
|
+
* validation-evidence envelope behind forever, because no code path had ever
|
|
7
|
+
* removed them. This module is the single engine that reclaims them.
|
|
8
|
+
*
|
|
9
|
+
* ## Allowlist, never a blocklist
|
|
10
|
+
*
|
|
11
|
+
* The safety property that matters is not "delete the right things" but
|
|
12
|
+
* "never delete the wrong thing". So classification is positive: an artifact
|
|
13
|
+
* is a purge candidate only when a declared class claims it. Everything else —
|
|
14
|
+
* an operator's scratch directory, a hand-parked file, a family a future
|
|
15
|
+
* Story adds without teaching this module about it — is **unrecognized**, is
|
|
16
|
+
* never touched, and is reported with its byte size so a human decides. A
|
|
17
|
+
* blocklist would have the opposite failure mode: anything the framework
|
|
18
|
+
* forgot to exclude gets deleted.
|
|
19
|
+
*
|
|
20
|
+
* ## Two eligibility signals, deliberately different in strength
|
|
21
|
+
*
|
|
22
|
+
* - **Story-keyed.** An artifact whose name carries a Story id is purged when
|
|
23
|
+
* that Story's merge has been *confirmed* by the caller — the post-land tail
|
|
24
|
+
* or a boot sweep that read live state. This is the primary path and the one
|
|
25
|
+
* the operator asked for: spent the moment the work lands.
|
|
26
|
+
* - **Age-floored.** Artifacts no Story id can be recovered from (audit
|
|
27
|
+
* reports, abandoned `plan-<slug>/` dirs) fall back to a `staleDays` floor.
|
|
28
|
+
* Only the sweep opts into this; the per-Story purge never does, so a
|
|
29
|
+
* post-land tail can never reap a sibling's in-flight artifact.
|
|
30
|
+
*
|
|
31
|
+
* ## Keep-class
|
|
32
|
+
*
|
|
33
|
+
* `signals.ndjson` is the artifact whose value *starts* when the run ends —
|
|
34
|
+
* `signals-view`, `acceptance-eval`, and the loop-health check all read it
|
|
35
|
+
* long after the Story merged. It is excluded twice over: it is not in the
|
|
36
|
+
* evidence basename allowlist, and {@link KEEP_BASENAMES} is re-checked at
|
|
37
|
+
* the deletion site. Defence in depth is warranted for the one file whose
|
|
38
|
+
* loss is silent and unrecoverable.
|
|
39
|
+
*
|
|
40
|
+
* ## Best-effort, never load-bearing
|
|
41
|
+
*
|
|
42
|
+
* Every entry point resolves rather than throws. This is hygiene: a purge
|
|
43
|
+
* that fails must never fail a land, a boot, or a persist that already did
|
|
44
|
+
* its real work. Failures are collected into `errors[]` and reported.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import fsPromises from 'node:fs/promises';
|
|
48
|
+
import path from 'node:path';
|
|
49
|
+
|
|
50
|
+
import {
|
|
51
|
+
anchorTempRoot,
|
|
52
|
+
ORCHESTRATION_DIRNAME,
|
|
53
|
+
tempRootFrom,
|
|
54
|
+
} from './config/temp-paths.js';
|
|
55
|
+
import { Logger } from './Logger.js';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Shipped defaults for `delivery.tempRetention`. `enabled` defaults to `true`:
|
|
59
|
+
* the operator asked for auto-purge to be the behaviour, with the knob there
|
|
60
|
+
* to turn it off rather than to turn it on.
|
|
61
|
+
*/
|
|
62
|
+
export const TEMP_RETENTION_DEFAULTS = Object.freeze({
|
|
63
|
+
enabled: true,
|
|
64
|
+
staleDays: 7,
|
|
65
|
+
classes: Object.freeze({
|
|
66
|
+
orchestrationLogs: true,
|
|
67
|
+
validationEvidence: true,
|
|
68
|
+
auditResults: true,
|
|
69
|
+
planDirs: true,
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** Every declared purge class, in classification order. */
|
|
74
|
+
export const PURGE_CLASS_NAMES = Object.freeze(
|
|
75
|
+
Object.keys(TEMP_RETENTION_DEFAULTS.classes),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Basenames no path may ever delete, re-checked at the deletion site even
|
|
80
|
+
* though classification already excludes them. See the module header.
|
|
81
|
+
*/
|
|
82
|
+
export const KEEP_BASENAMES = Object.freeze(['signals.ndjson']);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The per-Story artifact basenames `validationEvidence` claims. An explicit
|
|
86
|
+
* allowlist rather than a "delete everything but signals" rule: a file this
|
|
87
|
+
* module has not been taught about is kept, not guessed at.
|
|
88
|
+
*/
|
|
89
|
+
const STORY_EVIDENCE_BASENAMES = Object.freeze([
|
|
90
|
+
'validation-evidence.json',
|
|
91
|
+
'lifecycle.ndjson',
|
|
92
|
+
'manifest.md',
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Top-level temp entries that belong to the framework but are never purge
|
|
97
|
+
* candidates: `qa/` holds resumable operator-owned session ledgers, `cache/`
|
|
98
|
+
* has its own invalidation, and `*.lock` files are live coordination state.
|
|
99
|
+
*/
|
|
100
|
+
const RESERVED_TOP_LEVEL = Object.freeze(['qa', 'cache']);
|
|
101
|
+
|
|
102
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
103
|
+
|
|
104
|
+
/** `story-4794` → `4794`. */
|
|
105
|
+
const STORY_DIR_PATTERN = /^story-(\d+)$/;
|
|
106
|
+
/** `close-gates-4794`, `sync-result-story-4794` → `4794`. */
|
|
107
|
+
const TRAILING_ID_PATTERN = /-(\d+)$/;
|
|
108
|
+
/** `audit-story-4794-audit-clean-code.md` → `4794`. */
|
|
109
|
+
const AUDIT_STORY_PATTERN = /^audit-story-(\d+)-/;
|
|
110
|
+
/** `run-1030` — a per-run temp tree holding `stories/story-<id>/` children. */
|
|
111
|
+
const RUN_DIR_PATTERN = /^run-\d+$/;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resolve the effective retention policy, filling every field from
|
|
115
|
+
* {@link TEMP_RETENTION_DEFAULTS}. An unset block yields the defaults, so a
|
|
116
|
+
* consumer that never heard of this feature gets the purge.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} [config] Resolved config bag.
|
|
119
|
+
* @returns {{ enabled: boolean, staleDays: number, classes: Record<string, boolean> }}
|
|
120
|
+
*/
|
|
121
|
+
export function resolveTempRetention(config) {
|
|
122
|
+
const raw = config?.delivery?.tempRetention ?? {};
|
|
123
|
+
const classes = {};
|
|
124
|
+
for (const name of PURGE_CLASS_NAMES) {
|
|
125
|
+
classes[name] =
|
|
126
|
+
raw.classes?.[name] ?? TEMP_RETENTION_DEFAULTS.classes[name];
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
enabled: raw.enabled ?? TEMP_RETENTION_DEFAULTS.enabled,
|
|
130
|
+
staleDays: raw.staleDays ?? TEMP_RETENTION_DEFAULTS.staleDays,
|
|
131
|
+
classes,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `readdir` that yields `[]` for a directory that does not exist or cannot be
|
|
137
|
+
* read. Every scan below walks optional trees, so an absent one is the normal
|
|
138
|
+
* case, not an error.
|
|
139
|
+
*
|
|
140
|
+
* @param {typeof fsPromises} fsp
|
|
141
|
+
* @param {string} dir
|
|
142
|
+
* @returns {Promise<import('node:fs').Dirent[]>}
|
|
143
|
+
*/
|
|
144
|
+
async function safeReaddir(fsp, dir) {
|
|
145
|
+
try {
|
|
146
|
+
return await fsp.readdir(dir, { withFileTypes: true });
|
|
147
|
+
} catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Total bytes under a path — the file's own size, or the recursive sum for a
|
|
154
|
+
* directory. Reporting-only: a vanished child is skipped rather than fatal.
|
|
155
|
+
*
|
|
156
|
+
* @param {typeof fsPromises} fsp
|
|
157
|
+
* @param {string} target
|
|
158
|
+
* @returns {Promise<number>}
|
|
159
|
+
*/
|
|
160
|
+
async function sizeOf(fsp, target) {
|
|
161
|
+
let total = 0;
|
|
162
|
+
const stack = [target];
|
|
163
|
+
while (stack.length > 0) {
|
|
164
|
+
const current = stack.pop();
|
|
165
|
+
let stats;
|
|
166
|
+
try {
|
|
167
|
+
stats = await fsp.stat(current);
|
|
168
|
+
} catch {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (!stats.isDirectory()) {
|
|
172
|
+
total += stats.size;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
for (const child of await safeReaddir(fsp, current)) {
|
|
176
|
+
stack.push(path.join(current, child.name));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return total;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Build one classified entry. `mtimeMs` is the entry's **own** mtime, not the
|
|
184
|
+
* newest mtime beneath it — that is the semantics the shipped stale-plan-dir
|
|
185
|
+
* reap has always used, and widening it here would silently change when an
|
|
186
|
+
* abandoned directory becomes eligible.
|
|
187
|
+
*
|
|
188
|
+
* @param {typeof fsPromises} fsp
|
|
189
|
+
* @param {string} target
|
|
190
|
+
* @param {string} className
|
|
191
|
+
* @param {number|null} storyId
|
|
192
|
+
* @param {boolean} keep
|
|
193
|
+
* @returns {Promise<object|null>}
|
|
194
|
+
*/
|
|
195
|
+
async function makeEntry(fsp, target, className, storyId, keep = false) {
|
|
196
|
+
let stats;
|
|
197
|
+
try {
|
|
198
|
+
stats = await fsp.stat(target);
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
path: target,
|
|
204
|
+
className,
|
|
205
|
+
storyId,
|
|
206
|
+
keep,
|
|
207
|
+
mtimeMs: stats.mtimeMs,
|
|
208
|
+
bytes: stats.isDirectory() ? await sizeOf(fsp, target) : stats.size,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Recover the Story id a run-log basename carries. Both writers that land in
|
|
214
|
+
* `orchestration/` end their name with the scope: `close-gates-4794.log` from
|
|
215
|
+
* the gate sink, `sync-result-story-4794.log` from the terse-result dump.
|
|
216
|
+
*
|
|
217
|
+
* @param {string} name
|
|
218
|
+
* @returns {number|null}
|
|
219
|
+
*/
|
|
220
|
+
function storyIdFromLogName(name) {
|
|
221
|
+
const match = TRAILING_ID_PATTERN.exec(name.replace(/\.log$/, ''));
|
|
222
|
+
return match ? Number(match[1]) : null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* `<tempRoot>/orchestration/*.log` — close gate transcripts and terse-result
|
|
227
|
+
* detail dumps. A log whose name carries no id (there are none today, but the
|
|
228
|
+
* class owns the directory) is age-floored rather than dropped from the class.
|
|
229
|
+
*/
|
|
230
|
+
async function scanOrchestrationLogs(tempRoot, fsp) {
|
|
231
|
+
const dir = path.join(tempRoot, ORCHESTRATION_DIRNAME);
|
|
232
|
+
const entries = [];
|
|
233
|
+
for (const dirent of await safeReaddir(fsp, dir)) {
|
|
234
|
+
if (!dirent.isFile() || !dirent.name.endsWith('.log')) continue;
|
|
235
|
+
const entry = await makeEntry(
|
|
236
|
+
fsp,
|
|
237
|
+
path.join(dir, dirent.name),
|
|
238
|
+
'orchestrationLogs',
|
|
239
|
+
storyIdFromLogName(dirent.name),
|
|
240
|
+
);
|
|
241
|
+
if (entry) entries.push(entry);
|
|
242
|
+
}
|
|
243
|
+
return entries;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Every directory that holds `story-<id>/` children: the standalone tree and
|
|
248
|
+
* each per-run tree.
|
|
249
|
+
*/
|
|
250
|
+
async function storyParentDirs(tempRoot, fsp) {
|
|
251
|
+
const parents = [path.join(tempRoot, 'standalone', 'stories')];
|
|
252
|
+
for (const dirent of await safeReaddir(fsp, tempRoot)) {
|
|
253
|
+
if (dirent.isDirectory() && RUN_DIR_PATTERN.test(dirent.name)) {
|
|
254
|
+
parents.push(path.join(tempRoot, dirent.name, 'stories'));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return parents;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* `<…>/stories/story-<id>/*` — the per-Story delivery artifacts.
|
|
262
|
+
*
|
|
263
|
+
* Every file in the directory is emitted, but only the declared evidence
|
|
264
|
+
* basenames are purge candidates; `signals.ndjson` and anything unrecognized
|
|
265
|
+
* are emitted with `keep: true` so the envelope can show what survived.
|
|
266
|
+
*/
|
|
267
|
+
async function scanValidationEvidence(tempRoot, fsp) {
|
|
268
|
+
const entries = [];
|
|
269
|
+
for (const parent of await storyParentDirs(tempRoot, fsp)) {
|
|
270
|
+
for (const dirent of await safeReaddir(fsp, parent)) {
|
|
271
|
+
const match = STORY_DIR_PATTERN.exec(dirent.name);
|
|
272
|
+
if (!dirent.isDirectory() || !match) continue;
|
|
273
|
+
const storyDir = path.join(parent, dirent.name);
|
|
274
|
+
for (const file of await safeReaddir(fsp, storyDir)) {
|
|
275
|
+
if (!file.isFile()) continue;
|
|
276
|
+
const entry = await makeEntry(
|
|
277
|
+
fsp,
|
|
278
|
+
path.join(storyDir, file.name),
|
|
279
|
+
'validationEvidence',
|
|
280
|
+
Number(match[1]),
|
|
281
|
+
!STORY_EVIDENCE_BASENAMES.includes(file.name),
|
|
282
|
+
);
|
|
283
|
+
if (entry) entries.push(entry);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return entries;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* `<tempRoot>/audits/*` — audit reports. `audit-story-<id>-<lens>.md` is
|
|
292
|
+
* Story-keyed; the roster-level reports and profiling output are age-floored.
|
|
293
|
+
*/
|
|
294
|
+
async function scanAuditResults(tempRoot, fsp) {
|
|
295
|
+
const dir = path.join(tempRoot, 'audits');
|
|
296
|
+
const entries = [];
|
|
297
|
+
for (const dirent of await safeReaddir(fsp, dir)) {
|
|
298
|
+
const match = AUDIT_STORY_PATTERN.exec(dirent.name);
|
|
299
|
+
const entry = await makeEntry(
|
|
300
|
+
fsp,
|
|
301
|
+
path.join(dir, dirent.name),
|
|
302
|
+
'auditResults',
|
|
303
|
+
match ? Number(match[1]) : null,
|
|
304
|
+
);
|
|
305
|
+
if (entry) entries.push(entry);
|
|
306
|
+
}
|
|
307
|
+
return entries;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* `<tempRoot>/plan-<slug>/` — plan authoring dirs. Never Story-keyed: the
|
|
312
|
+
* directory predates the Stories it creates, so age is the only safe signal.
|
|
313
|
+
*/
|
|
314
|
+
async function scanPlanDirs(tempRoot, fsp) {
|
|
315
|
+
const entries = [];
|
|
316
|
+
for (const dirent of await safeReaddir(fsp, tempRoot)) {
|
|
317
|
+
if (!dirent.isDirectory() || !dirent.name.startsWith('plan-')) continue;
|
|
318
|
+
const entry = await makeEntry(
|
|
319
|
+
fsp,
|
|
320
|
+
path.join(tempRoot, dirent.name),
|
|
321
|
+
'planDirs',
|
|
322
|
+
null,
|
|
323
|
+
);
|
|
324
|
+
if (entry) entries.push(entry);
|
|
325
|
+
}
|
|
326
|
+
return entries;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Class name → scanner. Iteration order matches {@link PURGE_CLASS_NAMES}. */
|
|
330
|
+
const SCANNERS = Object.freeze({
|
|
331
|
+
orchestrationLogs: scanOrchestrationLogs,
|
|
332
|
+
validationEvidence: scanValidationEvidence,
|
|
333
|
+
auditResults: scanAuditResults,
|
|
334
|
+
planDirs: scanPlanDirs,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Does a top-level temp entry belong to a declared class? Kept in lockstep
|
|
339
|
+
* with the scanners above: an entry no class walks must show up as
|
|
340
|
+
* unrecognized, never be silently ignored.
|
|
341
|
+
*
|
|
342
|
+
* @param {string} name
|
|
343
|
+
* @returns {boolean}
|
|
344
|
+
*/
|
|
345
|
+
function isClassOwnedTopLevel(name) {
|
|
346
|
+
return (
|
|
347
|
+
name === ORCHESTRATION_DIRNAME ||
|
|
348
|
+
name === 'standalone' ||
|
|
349
|
+
name === 'audits' ||
|
|
350
|
+
name.startsWith('plan-') ||
|
|
351
|
+
RUN_DIR_PATTERN.test(name)
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Top-level entries that no class claims and that are not framework-reserved.
|
|
357
|
+
* Reported with byte sizes, never deleted — this is what makes a 49MB scratch
|
|
358
|
+
* directory visible to the operator instead of invisible to the tooling.
|
|
359
|
+
*
|
|
360
|
+
* @param {string} tempRoot
|
|
361
|
+
* @param {typeof fsPromises} fsp
|
|
362
|
+
* @returns {Promise<Array<{ path: string, bytes: number }>>}
|
|
363
|
+
*/
|
|
364
|
+
async function collectUnrecognized(tempRoot, fsp) {
|
|
365
|
+
const found = [];
|
|
366
|
+
for (const dirent of await safeReaddir(fsp, tempRoot)) {
|
|
367
|
+
const { name } = dirent;
|
|
368
|
+
if (isClassOwnedTopLevel(name)) continue;
|
|
369
|
+
if (RESERVED_TOP_LEVEL.includes(name) || name.endsWith('.lock')) continue;
|
|
370
|
+
const target = path.join(tempRoot, name);
|
|
371
|
+
found.push({ path: target, bytes: await sizeOf(fsp, target) });
|
|
372
|
+
}
|
|
373
|
+
return found;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Classify a whole temp tree without deleting anything. Exported so a caller
|
|
378
|
+
* (or a test) can see exactly what the purge would consider.
|
|
379
|
+
*
|
|
380
|
+
* @param {{ config?: object, tempRoot?: string, fsp?: typeof fsPromises }} [args]
|
|
381
|
+
* @returns {Promise<{ tempRoot: string, entries: object[], unrecognized: Array<{ path: string, bytes: number }> }>}
|
|
382
|
+
*/
|
|
383
|
+
export async function collectTempEntries({
|
|
384
|
+
config,
|
|
385
|
+
tempRoot,
|
|
386
|
+
fsp = fsPromises,
|
|
387
|
+
} = {}) {
|
|
388
|
+
const root = tempRoot ?? anchorTempRoot(tempRootFrom(config));
|
|
389
|
+
const entries = [];
|
|
390
|
+
for (const className of PURGE_CLASS_NAMES) {
|
|
391
|
+
entries.push(...(await SCANNERS[className](root, fsp)));
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
tempRoot: root,
|
|
395
|
+
entries,
|
|
396
|
+
unrecognized: await collectUnrecognized(root, fsp),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Is this entry eligible for deletion under the current policy and signals?
|
|
402
|
+
*
|
|
403
|
+
* @param {object} entry
|
|
404
|
+
* @param {object} ctx
|
|
405
|
+
* @returns {boolean}
|
|
406
|
+
*/
|
|
407
|
+
function isPurgeable(entry, ctx) {
|
|
408
|
+
if (entry.keep) return false;
|
|
409
|
+
if (KEEP_BASENAMES.includes(path.basename(entry.path))) return false;
|
|
410
|
+
if (!ctx.classes[entry.className]) return false;
|
|
411
|
+
if (ctx.only && !ctx.only.includes(entry.className)) return false;
|
|
412
|
+
if (ctx.excluded.has(path.resolve(entry.path))) return false;
|
|
413
|
+
if (entry.storyId !== null && ctx.storyIds.has(entry.storyId)) return true;
|
|
414
|
+
return ctx.sweepStale && ctx.now - entry.mtimeMs >= ctx.staleMs;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The purge core. Deliberately **module-private**: the two exported entry
|
|
419
|
+
* points below are the whole public surface, and each encodes a policy
|
|
420
|
+
* decision (Story-keyed vs. age-floored) that a caller reaching this directly
|
|
421
|
+
* could get wrong. Exporting it would also be a dead export — nothing outside
|
|
422
|
+
* this file has a reason to call it.
|
|
423
|
+
*
|
|
424
|
+
* @param {object} [args]
|
|
425
|
+
* @param {object} [args.config] Resolved config bag.
|
|
426
|
+
* @param {number[]} [args.storyIds] Stories whose merge the caller CONFIRMED.
|
|
427
|
+
* @param {boolean} [args.sweepStale] Opt into the age floor for un-keyed entries.
|
|
428
|
+
* @param {string[]|null} [args.only] Restrict to these class names.
|
|
429
|
+
* @param {string[]} [args.excludePaths] Absolute paths to leave alone.
|
|
430
|
+
* @param {number} [args.now] Clock seam.
|
|
431
|
+
* @param {string} [args.tempRoot] Temp root override (tests).
|
|
432
|
+
* @param {typeof fsPromises} [args.fsp] Filesystem seam.
|
|
433
|
+
* @param {{ info: Function }} [args.logger] Logger seam.
|
|
434
|
+
* @param {string} [args.label] Prefix for the single summary line.
|
|
435
|
+
* @returns {Promise<object>} Result envelope; never throws.
|
|
436
|
+
*/
|
|
437
|
+
async function purgeTempArtifacts({
|
|
438
|
+
config,
|
|
439
|
+
storyIds = [],
|
|
440
|
+
sweepStale = false,
|
|
441
|
+
only = null,
|
|
442
|
+
excludePaths = [],
|
|
443
|
+
now = Date.now(),
|
|
444
|
+
tempRoot,
|
|
445
|
+
fsp = fsPromises,
|
|
446
|
+
logger = Logger,
|
|
447
|
+
label = 'temp-retention',
|
|
448
|
+
} = {}) {
|
|
449
|
+
const policy = resolveTempRetention(config);
|
|
450
|
+
const base = {
|
|
451
|
+
enabled: policy.enabled,
|
|
452
|
+
tempRoot: tempRoot ?? anchorTempRoot(tempRootFrom(config)),
|
|
453
|
+
purged: [],
|
|
454
|
+
kept: [],
|
|
455
|
+
unrecognized: [],
|
|
456
|
+
bytesReclaimed: 0,
|
|
457
|
+
errors: [],
|
|
458
|
+
};
|
|
459
|
+
if (!policy.enabled) return { ...base, skipped: 'disabled' };
|
|
460
|
+
|
|
461
|
+
let scan;
|
|
462
|
+
try {
|
|
463
|
+
scan = await collectTempEntries({ config, tempRoot: base.tempRoot, fsp });
|
|
464
|
+
} catch (err) {
|
|
465
|
+
return { ...base, skipped: null, errors: [String(err?.message ?? err)] };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const ctx = {
|
|
469
|
+
classes: policy.classes,
|
|
470
|
+
only,
|
|
471
|
+
storyIds: new Set(storyIds),
|
|
472
|
+
sweepStale,
|
|
473
|
+
staleMs: policy.staleDays * MS_PER_DAY,
|
|
474
|
+
now,
|
|
475
|
+
excluded: new Set(excludePaths.map((p) => path.resolve(p))),
|
|
476
|
+
};
|
|
477
|
+
const result = { ...base, skipped: null, unrecognized: scan.unrecognized };
|
|
478
|
+
|
|
479
|
+
for (const entry of scan.entries) {
|
|
480
|
+
if (!isPurgeable(entry, ctx)) {
|
|
481
|
+
if (entry.keep) result.kept.push(entry.path);
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
try {
|
|
485
|
+
await fsp.rm(entry.path, { recursive: true, force: true });
|
|
486
|
+
result.purged.push({ path: entry.path, bytes: entry.bytes });
|
|
487
|
+
result.bytesReclaimed += entry.bytes;
|
|
488
|
+
} catch (err) {
|
|
489
|
+
// A racing writer or a permission error: leave it for the next run.
|
|
490
|
+
result.errors.push(`${entry.path}: ${String(err?.message ?? err)}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (result.purged.length > 0) {
|
|
495
|
+
logger?.info?.(
|
|
496
|
+
`[${label}] purged ${result.purged.length} spent temp artifact(s), ` +
|
|
497
|
+
`reclaimed ${formatBytes(result.bytesReclaimed)} under ${result.tempRoot}.`,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
return result;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Human-readable byte count for the one summary line.
|
|
505
|
+
*
|
|
506
|
+
* @param {number} bytes
|
|
507
|
+
* @returns {string}
|
|
508
|
+
*/
|
|
509
|
+
export function formatBytes(bytes) {
|
|
510
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
511
|
+
const units = ['KB', 'MB', 'GB'];
|
|
512
|
+
let value = bytes / 1024;
|
|
513
|
+
let unit = 0;
|
|
514
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
515
|
+
value /= 1024;
|
|
516
|
+
unit += 1;
|
|
517
|
+
}
|
|
518
|
+
return `${value.toFixed(1)}${units[unit]}`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Purge one merged Story's spent artifacts. Called from the post-land tail,
|
|
523
|
+
* where "merged" is already confirmed — so this never applies the age floor
|
|
524
|
+
* and can never touch a sibling Story's in-flight artifacts.
|
|
525
|
+
*
|
|
526
|
+
* @param {{ storyId: number, config?: object, now?: number, tempRoot?: string,
|
|
527
|
+
* fsp?: typeof fsPromises, logger?: object }} args
|
|
528
|
+
* @returns {Promise<object>} Result envelope; never throws.
|
|
529
|
+
*/
|
|
530
|
+
export async function purgeStoryTempArtifacts({ storyId, config, ...rest }) {
|
|
531
|
+
return purgeTempArtifacts({
|
|
532
|
+
config,
|
|
533
|
+
storyIds: Number.isInteger(storyId) ? [storyId] : [],
|
|
534
|
+
sweepStale: false,
|
|
535
|
+
...rest,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Catch-up sweep: purge the artifacts of every Story the caller confirmed
|
|
541
|
+
* merged, plus every age-floored entry past `staleDays`. This is the path
|
|
542
|
+
* that reclaims a backlog — Stories merged in an earlier run, merged through
|
|
543
|
+
* the GitHub UI, or delivered before this feature existed.
|
|
544
|
+
*
|
|
545
|
+
* @param {{ config?: object, mergedStoryIds?: number[], now?: number,
|
|
546
|
+
* tempRoot?: string, fsp?: typeof fsPromises, logger?: object,
|
|
547
|
+
* only?: string[]|null, excludePaths?: string[], label?: string }} [args]
|
|
548
|
+
* @returns {Promise<object>} Result envelope; never throws.
|
|
549
|
+
*/
|
|
550
|
+
export async function sweepTempRetention({
|
|
551
|
+
mergedStoryIds = [],
|
|
552
|
+
...rest
|
|
553
|
+
} = {}) {
|
|
554
|
+
return purgeTempArtifacts({
|
|
555
|
+
storyIds: mergedStoryIds,
|
|
556
|
+
sweepStale: true,
|
|
557
|
+
...rest,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
@@ -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
|
|
|
@@ -39,8 +39,8 @@ Sequential inline execution is the fallback (see the core's Execution strategy).
|
|
|
39
39
|
> orchestrated path grants its measurement agents a `Bash` tool restricted to a
|
|
40
40
|
> **non-mutating command allowlist** (profilers, timers, bundle-stat and
|
|
41
41
|
> file-size probes — never a command that writes source, installs, or mutates
|
|
42
|
-
> git/labels). See the allowlist in
|
|
43
|
-
>
|
|
42
|
+
> git/labels). See the allowlist in the harness-generated
|
|
43
|
+
> `.claude/workflows/audit-performance.workflow.js`.
|
|
44
44
|
|
|
45
45
|
## Step 0: Measure before you judge (mandatory)
|
|
46
46
|
|
|
@@ -113,5 +113,5 @@ pipelines and CI-step parsing.
|
|
|
113
113
|
— the canonical contract every check module must satisfy.
|
|
114
114
|
- [`.agents/scripts/diagnose.js`](../../scripts/diagnose.js) — the CLI
|
|
115
115
|
implementation backing this helper.
|
|
116
|
-
- [`tests/diagnose-output.test.js`](
|
|
116
|
+
- [`tests/diagnose-output.test.js`](https://github.com/dsj1984/mandrel/blob/main/tests/diagnose-output.test.js)
|
|
117
117
|
— pinned output and exit-code contracts.
|
|
@@ -106,7 +106,7 @@ node .agents/scripts/signals-view.js 9999
|
|
|
106
106
|
CLI implementation backing this helper.
|
|
107
107
|
- [`.agents/scripts/lib/signals/`](../../scripts/lib/signals/) — the
|
|
108
108
|
shared reader + schema + span-tree barrel.
|
|
109
|
-
- [`tests/signals-view.test.js`](
|
|
109
|
+
- [`tests/signals-view.test.js`](https://github.com/dsj1984/mandrel/blob/main/tests/signals-view.test.js) —
|
|
110
110
|
pinned output and tempRoot-honour contracts.
|
|
111
|
-
- [`tests/lib/signals/span-tree.test.js`](
|
|
111
|
+
- [`tests/lib/signals/span-tree.test.js`](https://github.com/dsj1984/mandrel/blob/main/tests/lib/signals/span-tree.test.js) —
|
|
112
112
|
pure-function contract for the span-tree builder.
|
|
@@ -13,7 +13,7 @@ description: >-
|
|
|
13
13
|
# /mandrel-update
|
|
14
14
|
|
|
15
15
|
> **Upgrade owner.** The mechanical upgrade is owned end to end by the
|
|
16
|
-
> [`mandrel update`](
|
|
16
|
+
> [`mandrel update`](https://github.com/dsj1984/mandrel/blob/main/lib/cli/update.js) CLI under the npm distribution
|
|
17
17
|
> model. This workflow wraps that CLI: it runs
|
|
18
18
|
> `npx mandrel update`, then walks the operator through the
|
|
19
19
|
> **distribution-agnostic judgment steps** the CLI deliberately does **not**
|
|
@@ -66,7 +66,7 @@ envelope (`{ ok, blocked, findings[] }`) plus a human-readable report:
|
|
|
66
66
|
the version probe.
|
|
67
67
|
|
|
68
68
|
The preflight is a workflow-layer guard; it deliberately lives outside
|
|
69
|
-
[`lib/cli/update.js`](
|
|
69
|
+
[`lib/cli/update.js`](https://github.com/dsj1984/mandrel/blob/main/lib/cli/update.js), which stays git-free.
|
|
70
70
|
|
|
71
71
|
## Step 1 — Run the updater
|
|
72
72
|
|
|
@@ -102,9 +102,9 @@ recovered and a clean re-run reports success.**
|
|
|
102
102
|
|
|
103
103
|
Identify the failed phase (the CLI's stderr names it) and run the matching
|
|
104
104
|
remedy. These commands match the hint strings
|
|
105
|
-
[`lib/cli/update.js`](
|
|
105
|
+
[`lib/cli/update.js`](https://github.com/dsj1984/mandrel/blob/main/lib/cli/update.js) emits verbatim — it is the
|
|
106
106
|
single source of truth, kept in lockstep with this table by
|
|
107
|
-
[`tests/bootstrap/mandrel-update-recovery-drift.test.js`](
|
|
107
|
+
[`tests/bootstrap/mandrel-update-recovery-drift.test.js`](https://github.com/dsj1984/mandrel/blob/main/tests/bootstrap/mandrel-update-recovery-drift.test.js):
|
|
108
108
|
|
|
109
109
|
| Failed phase | Manual remedy |
|
|
110
110
|
| ----------------- | ------------------------------------------------------- |
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [2.19.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.18.0...mandrel-v2.19.0) (2026-07-27)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
* check-baselines: generalize the one-shot baseline-refresh acknowledgment across every ratcheted kind ([#4802](https://github.com/dsj1984/mandrel/issues/4802)) ([#4805](https://github.com/dsj1984/mandrel/issues/4805)) ([ec9b066](https://github.com/dsj1984/mandrel/commit/ec9b066a5d4f74eb53f88ab347138b3434ffa47b))
|
|
11
|
+
* **doc-links:** reject links escaping the materialized .agents payload (refs [#4801](https://github.com/dsj1984/mandrel/issues/4801)) ([#4803](https://github.com/dsj1984/mandrel/issues/4803)) ([97a383a](https://github.com/dsj1984/mandrel/commit/97a383a4765a9a617648f6c1b422c17d837b6ad8))
|
|
12
|
+
|
|
13
|
+
## [2.18.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.17.0...mandrel-v2.18.0) (2026-07-26)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
* **temp:** auto-purge merged Stories' spent temp artifacts behind an allowlist, keeping signals ([#4794](https://github.com/dsj1984/mandrel/issues/4794)) ([#4795](https://github.com/dsj1984/mandrel/issues/4795)) ([d69d985](https://github.com/dsj1984/mandrel/commit/d69d9858511658e0ec0336db0cf8aa65d725637b))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Chores
|
|
22
|
+
|
|
23
|
+
* **release:** cut the 2.18.0 release missed by an unparseable squash subject ([#4796](https://github.com/dsj1984/mandrel/issues/4796)) ([8ccff57](https://github.com/dsj1984/mandrel/commit/8ccff574d44519595e04fc0f3597cb4dcd005436))
|
|
24
|
+
|
|
5
25
|
## [2.17.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.16.0...mandrel-v2.17.0) (2026-07-26)
|
|
6
26
|
|
|
7
27
|
|
package/package.json
CHANGED