mandrel 2.66.0 → 2.67.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/agents/acceptance-critic.md +2 -2
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +2 -1
- package/.agents/rules/git-conventions-reference.md +5 -5
- package/.agents/rules/git-conventions.md +1 -1
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/scripts/boot-sweep.js +97 -9
- package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
- package/.agents/scripts/clean-temp.js +54 -0
- package/.agents/scripts/clean-worktrees.js +593 -0
- package/.agents/scripts/drain-pending-cleanup.js +5 -4
- package/.agents/scripts/lib/clean-temp.js +440 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +11 -2
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +3 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
- package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
- package/.agents/scripts/lib/single-story-sweep.js +2 -2
- package/.agents/scripts/lib/temp-removal.js +110 -0
- package/.agents/scripts/lib/temp-retention.js +122 -73
- package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
- package/.agents/scripts/single-story-init.js +120 -17
- package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
- package/.agents/workflows/clean-temp.md +67 -0
- package/.agents/workflows/clean-worktrees.md +63 -0
- package/.agents/workflows/git-deliver.md +1 -1
- package/.agents/workflows/helpers/acceptance-self-eval.md +3 -2
- package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
- package/docs/CHANGELOG.md +13 -0
- package/package.json +1 -1
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/clean-temp` engine: sorts every top-level entry under the project's
|
|
3
|
+
* tempRoot into one bucket and, on `--execute`, deletes the confirmed
|
|
4
|
+
* buckets through the temp-retention engine — there is no second walker.
|
|
5
|
+
*
|
|
6
|
+
* Buckets: `framework` (a class entry the auto-purge would take),
|
|
7
|
+
* `closed-issue` (an unrecognized entry naming exactly one closed issue),
|
|
8
|
+
* `aged` (an unrecognized id-less entry past `staleDays`), and `kept`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { realpathSync } from 'node:fs';
|
|
12
|
+
import fsPromises from 'node:fs/promises';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { parseArgs } from 'node:util';
|
|
15
|
+
|
|
16
|
+
import { mainCheckoutRoot, tempRootFrom } from './config/temp-paths.js';
|
|
17
|
+
import { removeSparingKept, safeReaddir } from './temp-removal.js';
|
|
18
|
+
import {
|
|
19
|
+
collectTempEntries,
|
|
20
|
+
formatBytes,
|
|
21
|
+
isReservedTopLevel,
|
|
22
|
+
resolveTempRetention,
|
|
23
|
+
sweepTempRetention,
|
|
24
|
+
} from './temp-retention.js';
|
|
25
|
+
|
|
26
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
/** Deletable buckets, in execution order. `aged` is never deleted under `--yes`. */
|
|
29
|
+
const DELETABLE_BUCKETS = Object.freeze(['framework', 'closed-issue', 'aged']);
|
|
30
|
+
const UNATTENDED_BUCKETS = Object.freeze(['framework', 'closed-issue']);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A standalone run of 1–7 digits, no leading zero, not glued to a letter or
|
|
34
|
+
* digit — so a hash fragment, a timestamp or a version suffix is not an id.
|
|
35
|
+
*/
|
|
36
|
+
const ID_TOKEN = /(?<![A-Za-z0-9])[1-9]\d{0,6}(?![A-Za-z0-9])/g;
|
|
37
|
+
|
|
38
|
+
const silent = Object.freeze({ info: () => {} });
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Distinct issue-id candidates in a basename (extension stripped). More than
|
|
42
|
+
* one means the entry cannot be attributed to any of them.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} name
|
|
45
|
+
* @returns {number[]}
|
|
46
|
+
*/
|
|
47
|
+
function idCandidates(name) {
|
|
48
|
+
const stem = name.replace(/\.[^.]*$/, '');
|
|
49
|
+
return [...new Set(stem.match(ID_TOKEN) ?? [])].map(Number);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string[]} argv
|
|
54
|
+
* @returns {{ execute: boolean, yes: boolean, json: boolean, cwd: string|undefined }}
|
|
55
|
+
*/
|
|
56
|
+
function parseCleanTempArgs(argv) {
|
|
57
|
+
const { values } = parseArgs({
|
|
58
|
+
args: argv,
|
|
59
|
+
options: {
|
|
60
|
+
execute: { type: 'boolean', default: false },
|
|
61
|
+
yes: { type: 'boolean', default: false },
|
|
62
|
+
json: { type: 'boolean', default: false },
|
|
63
|
+
cwd: { type: 'string' },
|
|
64
|
+
},
|
|
65
|
+
strict: true,
|
|
66
|
+
});
|
|
67
|
+
return values;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `realpath` when the path exists, else a plain resolve. */
|
|
71
|
+
function canonical(target) {
|
|
72
|
+
try {
|
|
73
|
+
return realpathSync(target);
|
|
74
|
+
} catch {
|
|
75
|
+
return path.resolve(target);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The tempRoot a relative `project.paths.tempRoot` names, anchored at the
|
|
81
|
+
* project root, and whether it lies strictly inside that root.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} projectRoot
|
|
84
|
+
* @param {object} config
|
|
85
|
+
* @returns {{ tempRoot: string, inside: boolean }}
|
|
86
|
+
*/
|
|
87
|
+
function resolveScopedTempRoot(projectRoot, config) {
|
|
88
|
+
const raw = tempRootFrom(config);
|
|
89
|
+
const tempRoot = canonical(
|
|
90
|
+
path.isAbsolute(raw) ? raw : path.join(projectRoot, raw),
|
|
91
|
+
);
|
|
92
|
+
const rel = path.relative(canonical(projectRoot), tempRoot);
|
|
93
|
+
const inside = rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
94
|
+
return { tempRoot, inside };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Issue states, read once per id. A failed read is `error`, which every
|
|
99
|
+
* caller treats as "not closed" — reads fail safe.
|
|
100
|
+
*
|
|
101
|
+
* @param {object|null} provider
|
|
102
|
+
* @param {Iterable<number>} ids
|
|
103
|
+
* @returns {Promise<Map<number, 'closed'|'open'|'error'>>}
|
|
104
|
+
*/
|
|
105
|
+
async function readIssueStates(provider, ids) {
|
|
106
|
+
const states = new Map();
|
|
107
|
+
for (const id of ids) {
|
|
108
|
+
try {
|
|
109
|
+
const ticket = await provider.getTicket(id);
|
|
110
|
+
const state = String(ticket?.state ?? '').toLowerCase();
|
|
111
|
+
states.set(id, state === 'closed' ? 'closed' : 'open');
|
|
112
|
+
} catch {
|
|
113
|
+
states.set(id, 'error');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return states;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Top-level name of `target` beneath `tempRoot`. */
|
|
120
|
+
function topLevelName(tempRoot, target) {
|
|
121
|
+
return path.relative(tempRoot, target).split(path.sep)[0];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Every id the scan could need a state for. */
|
|
125
|
+
function idsToRead(scan) {
|
|
126
|
+
const ids = new Set();
|
|
127
|
+
for (const entry of scan.entries) {
|
|
128
|
+
if (entry.storyId !== null) ids.add(entry.storyId);
|
|
129
|
+
}
|
|
130
|
+
for (const u of scan.unrecognized) {
|
|
131
|
+
const candidates = idCandidates(path.basename(u.path));
|
|
132
|
+
if (candidates.length === 1) ids.add(candidates[0]);
|
|
133
|
+
}
|
|
134
|
+
return ids;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Closed ids among `states`. */
|
|
138
|
+
function closedIds(states) {
|
|
139
|
+
return [...states].filter(([, s]) => s === 'closed').map(([id]) => id);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Bucket one unrecognized entry.
|
|
144
|
+
*
|
|
145
|
+
* @param {{ path: string, bytes: number, mtimeMs: number }} u
|
|
146
|
+
* @param {Map<number, string>} states
|
|
147
|
+
* @param {{ now: number, staleMs: number }} ctx
|
|
148
|
+
* @returns {{ bucket: string, reason: string }}
|
|
149
|
+
*/
|
|
150
|
+
function classifyUnrecognized(u, states, ctx) {
|
|
151
|
+
const ids = idCandidates(path.basename(u.path));
|
|
152
|
+
if (ids.length === 1) {
|
|
153
|
+
const state = states.get(ids[0]);
|
|
154
|
+
if (state === 'closed') {
|
|
155
|
+
return { bucket: 'closed-issue', reason: `issue #${ids[0]} closed` };
|
|
156
|
+
}
|
|
157
|
+
const why = state === 'open' ? 'open' : 'read failed';
|
|
158
|
+
return { bucket: 'kept', reason: `issue #${ids[0]} ${why}` };
|
|
159
|
+
}
|
|
160
|
+
const label = ids.length === 0 ? 'no id' : `${ids.length} ids`;
|
|
161
|
+
if (ctx.now - u.mtimeMs >= ctx.staleMs) {
|
|
162
|
+
return { bucket: 'aged', reason: `${label}, past staleDays` };
|
|
163
|
+
}
|
|
164
|
+
return { bucket: 'kept', reason: `${label}, too recent` };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* One row per top-level entry under tempRoot.
|
|
169
|
+
*
|
|
170
|
+
* @param {object} args
|
|
171
|
+
* @returns {Promise<object[]>}
|
|
172
|
+
*/
|
|
173
|
+
async function buildRows({ tempRoot, scan, preview, states, ctx, fsp }) {
|
|
174
|
+
const spent = new Map();
|
|
175
|
+
for (const p of preview.purged) {
|
|
176
|
+
const name = topLevelName(tempRoot, p.path);
|
|
177
|
+
const acc = spent.get(name) ?? { count: 0, bytes: 0 };
|
|
178
|
+
spent.set(name, { count: acc.count + 1, bytes: acc.bytes + p.bytes });
|
|
179
|
+
}
|
|
180
|
+
const unrecognized = new Map(
|
|
181
|
+
scan.unrecognized.map((u) => [path.basename(u.path), u]),
|
|
182
|
+
);
|
|
183
|
+
const rows = [];
|
|
184
|
+
for (const dirent of await safeReaddir(fsp, tempRoot)) {
|
|
185
|
+
const { name } = dirent;
|
|
186
|
+
const row = { entry: name, path: path.join(tempRoot, name), bytes: 0 };
|
|
187
|
+
const u = unrecognized.get(name);
|
|
188
|
+
if (isReservedTopLevel(name)) {
|
|
189
|
+
rows.push({ ...row, bucket: 'kept', reason: 'reserved' });
|
|
190
|
+
} else if (u) {
|
|
191
|
+
const ageDays = Math.floor((ctx.now - u.mtimeMs) / MS_PER_DAY);
|
|
192
|
+
rows.push({
|
|
193
|
+
...row,
|
|
194
|
+
bytes: u.bytes,
|
|
195
|
+
ageDays,
|
|
196
|
+
...classifyUnrecognized(u, states, ctx),
|
|
197
|
+
});
|
|
198
|
+
} else if (spent.has(name)) {
|
|
199
|
+
const { count, bytes } = spent.get(name);
|
|
200
|
+
rows.push({
|
|
201
|
+
...row,
|
|
202
|
+
bytes,
|
|
203
|
+
bucket: 'framework',
|
|
204
|
+
reason: `${count} spent artifact(s)`,
|
|
205
|
+
});
|
|
206
|
+
} else {
|
|
207
|
+
rows.push({ ...row, bucket: 'kept', reason: 'framework: nothing spent' });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return rows.sort((a, b) => a.entry.localeCompare(b.entry));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {object[]} rows
|
|
215
|
+
* @returns {Record<string, { count: number, bytes: number }>}
|
|
216
|
+
*/
|
|
217
|
+
function bucketTotals(rows) {
|
|
218
|
+
const totals = {};
|
|
219
|
+
for (const bucket of [...DELETABLE_BUCKETS, 'kept']) {
|
|
220
|
+
const inBucket = rows.filter((r) => r.bucket === bucket);
|
|
221
|
+
totals[bucket] = {
|
|
222
|
+
count: inBucket.length,
|
|
223
|
+
bytes: inBucket.reduce((sum, r) => sum + r.bytes, 0),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return totals;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @param {object[]} rows
|
|
231
|
+
* @returns {string}
|
|
232
|
+
*/
|
|
233
|
+
function renderTable(rows) {
|
|
234
|
+
const lines = [
|
|
235
|
+
'bucket entry size age reason',
|
|
236
|
+
];
|
|
237
|
+
for (const r of rows) {
|
|
238
|
+
const age = r.ageDays === undefined ? '-' : `${r.ageDays}d`;
|
|
239
|
+
lines.push(
|
|
240
|
+
`${r.bucket.padEnd(13)} ${r.entry.padEnd(40)} ${formatBytes(r.bytes).padStart(8)} ${age.padStart(5)} ${r.reason}`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
return `${lines.join('\n')}\n`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The operator-confirmed deletion of unrecognized entries: each path is
|
|
248
|
+
* re-verified as still unrecognized by a fresh scan before it goes.
|
|
249
|
+
*
|
|
250
|
+
* @param {{ tempRoot: string, paths: string[], fsp: typeof fsPromises }} args
|
|
251
|
+
* @returns {Promise<{ bytes: number, errors: string[] }>}
|
|
252
|
+
*/
|
|
253
|
+
async function purgeUnrecognizedEntries({ tempRoot, paths, fsp }) {
|
|
254
|
+
const { unrecognized } = await collectTempEntries({ tempRoot, fsp });
|
|
255
|
+
const allowed = new Set(unrecognized.map((u) => u.path));
|
|
256
|
+
const out = { bytes: 0, errors: [] };
|
|
257
|
+
for (const target of paths) {
|
|
258
|
+
if (!allowed.has(target)) {
|
|
259
|
+
out.errors.push(`${target}: no longer an unrecognized entry — kept`);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
out.bytes += (await removeSparingKept(fsp, target)).bytes;
|
|
264
|
+
} catch (err) {
|
|
265
|
+
out.errors.push(`${target}: ${String(err?.message ?? err)}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Delete one bucket through the engine.
|
|
273
|
+
*
|
|
274
|
+
* @returns {Promise<{ bytes: number, errors: string[] }>}
|
|
275
|
+
*/
|
|
276
|
+
async function executeBucket(bucket, rows, env) {
|
|
277
|
+
if (bucket === 'framework') {
|
|
278
|
+
const result = await sweepTempRetention({
|
|
279
|
+
config: env.config,
|
|
280
|
+
tempRoot: env.tempRoot,
|
|
281
|
+
mergedStoryIds: env.closed,
|
|
282
|
+
now: env.now,
|
|
283
|
+
fsp: env.fsp,
|
|
284
|
+
logger: silent,
|
|
285
|
+
label: 'clean-temp',
|
|
286
|
+
});
|
|
287
|
+
return { bytes: result.bytesReclaimed, errors: result.errors };
|
|
288
|
+
}
|
|
289
|
+
return purgeUnrecognizedEntries({
|
|
290
|
+
tempRoot: env.tempRoot,
|
|
291
|
+
paths: rows.filter((r) => r.bucket === bucket).map((r) => r.path),
|
|
292
|
+
fsp: env.fsp,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Decide whether a bucket may be deleted this run.
|
|
298
|
+
*
|
|
299
|
+
* @returns {Promise<{ run: boolean, note?: string }>}
|
|
300
|
+
*/
|
|
301
|
+
async function approveBucket(bucket, total, opts, confirm) {
|
|
302
|
+
if (total.count === 0) return { run: false };
|
|
303
|
+
if (opts.yes) {
|
|
304
|
+
return UNATTENDED_BUCKETS.includes(bucket)
|
|
305
|
+
? { run: true }
|
|
306
|
+
: { run: false, note: 'never deleted unattended — rerun without --yes' };
|
|
307
|
+
}
|
|
308
|
+
const ok = await confirm(
|
|
309
|
+
`[clean-temp] Delete ${total.count} ${bucket} entr${total.count === 1 ? 'y' : 'ies'} (${formatBytes(total.bytes)})?`,
|
|
310
|
+
);
|
|
311
|
+
return ok ? { run: true } : { run: false, note: 'declined' };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* @returns {Promise<{ executed: object, bytesReclaimed: number, errors: string[] }>}
|
|
316
|
+
*/
|
|
317
|
+
async function executeBuckets(rows, totals, opts, env) {
|
|
318
|
+
const executed = {};
|
|
319
|
+
let bytesReclaimed = 0;
|
|
320
|
+
const errors = [];
|
|
321
|
+
for (const bucket of DELETABLE_BUCKETS) {
|
|
322
|
+
const decision = await approveBucket(
|
|
323
|
+
bucket,
|
|
324
|
+
totals[bucket],
|
|
325
|
+
opts,
|
|
326
|
+
env.confirm,
|
|
327
|
+
);
|
|
328
|
+
if (!decision.run) {
|
|
329
|
+
executed[bucket] = { deleted: false, note: decision.note ?? 'empty' };
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const result = await executeBucket(bucket, rows, env);
|
|
333
|
+
executed[bucket] = { deleted: true, bytes: result.bytes };
|
|
334
|
+
bytesReclaimed += result.bytes;
|
|
335
|
+
errors.push(...result.errors);
|
|
336
|
+
}
|
|
337
|
+
return { executed, bytesReclaimed, errors };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Run `/clean-temp`. Never calls `process.exit`; the CLI shell applies the
|
|
342
|
+
* returned exit code.
|
|
343
|
+
*
|
|
344
|
+
* @param {object} args
|
|
345
|
+
* @param {string[]} args.argv
|
|
346
|
+
* @param {string} args.cwd Invocation dir; `--cwd` overrides it.
|
|
347
|
+
* @param {(projectRoot: string) => object} args.loadConfig Resolved config bag.
|
|
348
|
+
* @param {(cwd: string) => string} [args.resolveRoot] The project root the
|
|
349
|
+
* run is scoped to — the main checkout root, else `cwd` itself.
|
|
350
|
+
* @param {(config: object) => object} args.getProvider Lazy; called only when
|
|
351
|
+
* an id needs a read.
|
|
352
|
+
* @param {(question: string) => Promise<boolean>} args.confirm
|
|
353
|
+
* @param {(text: string) => void} args.write stdout sink.
|
|
354
|
+
* @param {(text: string) => void} args.writeErr stderr sink.
|
|
355
|
+
* @param {number} [args.now]
|
|
356
|
+
* @param {typeof fsPromises} [args.fsp]
|
|
357
|
+
* @returns {Promise<{ exitCode: number, envelope: object }>}
|
|
358
|
+
*/
|
|
359
|
+
export async function runCleanTemp({
|
|
360
|
+
argv,
|
|
361
|
+
cwd,
|
|
362
|
+
loadConfig,
|
|
363
|
+
resolveRoot = (dir) => mainCheckoutRoot(dir) ?? dir,
|
|
364
|
+
getProvider,
|
|
365
|
+
confirm,
|
|
366
|
+
write,
|
|
367
|
+
writeErr,
|
|
368
|
+
now = Date.now(),
|
|
369
|
+
fsp = fsPromises,
|
|
370
|
+
}) {
|
|
371
|
+
const opts = parseCleanTempArgs(argv);
|
|
372
|
+
const projectRoot = resolveRoot(path.resolve(opts.cwd ?? cwd));
|
|
373
|
+
const config = loadConfig(projectRoot);
|
|
374
|
+
const { tempRoot, inside } = resolveScopedTempRoot(projectRoot, config);
|
|
375
|
+
if (!inside) {
|
|
376
|
+
writeErr(
|
|
377
|
+
`[clean-temp] refusing: tempRoot ${tempRoot} is not inside the project root ${projectRoot}.\n`,
|
|
378
|
+
);
|
|
379
|
+
return {
|
|
380
|
+
exitCode: 1,
|
|
381
|
+
envelope: { kind: 'clean-temp', refused: true, tempRoot, projectRoot },
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const policy = resolveTempRetention(config);
|
|
386
|
+
const ctx = { now, staleMs: policy.staleDays * MS_PER_DAY };
|
|
387
|
+
const scan = await collectTempEntries({ config, tempRoot, fsp });
|
|
388
|
+
const ids = idsToRead(scan);
|
|
389
|
+
const states =
|
|
390
|
+
ids.size > 0 ? await readIssueStates(getProvider(config), ids) : new Map();
|
|
391
|
+
const closed = closedIds(states);
|
|
392
|
+
const preview = await sweepTempRetention({
|
|
393
|
+
config,
|
|
394
|
+
tempRoot,
|
|
395
|
+
mergedStoryIds: closed,
|
|
396
|
+
now,
|
|
397
|
+
fsp,
|
|
398
|
+
dryRun: true,
|
|
399
|
+
logger: silent,
|
|
400
|
+
});
|
|
401
|
+
const rows = await buildRows({ tempRoot, scan, preview, states, ctx, fsp });
|
|
402
|
+
const totals = bucketTotals(rows);
|
|
403
|
+
const envelope = {
|
|
404
|
+
kind: 'clean-temp',
|
|
405
|
+
tempRoot,
|
|
406
|
+
dryRun: !opts.execute,
|
|
407
|
+
staleDays: policy.staleDays,
|
|
408
|
+
buckets: totals,
|
|
409
|
+
entries: rows.map(({ path: _p, ...r }) => r),
|
|
410
|
+
executed: null,
|
|
411
|
+
bytesReclaimed: 0,
|
|
412
|
+
errors: [],
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
if (!opts.json) write(renderTable(rows));
|
|
416
|
+
if (opts.execute) {
|
|
417
|
+
const env = { config, tempRoot, closed, now, fsp, confirm };
|
|
418
|
+
Object.assign(envelope, await executeBuckets(rows, totals, opts, env));
|
|
419
|
+
}
|
|
420
|
+
if (opts.json) write(`${JSON.stringify(envelope)}\n`);
|
|
421
|
+
else write(renderSummary(envelope));
|
|
422
|
+
return { exitCode: envelope.errors.length > 0 ? 1 : 0, envelope };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* @param {object} envelope
|
|
427
|
+
* @returns {string}
|
|
428
|
+
*/
|
|
429
|
+
function renderSummary(envelope) {
|
|
430
|
+
const parts = Object.entries(envelope.buckets).map(
|
|
431
|
+
([bucket, t]) => `${bucket}=${t.count} (${formatBytes(t.bytes)})`,
|
|
432
|
+
);
|
|
433
|
+
const head = envelope.dryRun
|
|
434
|
+
? '[clean-temp] dry run — nothing deleted; pass --execute to delete.'
|
|
435
|
+
: `[clean-temp] reclaimed ${formatBytes(envelope.bytesReclaimed)}.`;
|
|
436
|
+
const errors = envelope.errors
|
|
437
|
+
.map((e) => `[clean-temp] error: ${e}\n`)
|
|
438
|
+
.join('');
|
|
439
|
+
return `${head} ${parts.join(' · ')}\n${errors}`;
|
|
440
|
+
}
|
|
@@ -283,8 +283,9 @@ const TEMP_RETENTION_SCHEMA = {
|
|
|
283
283
|
description:
|
|
284
284
|
'Story #4794. Auto-purge of spent temp artifacts once their Story lands. ' +
|
|
285
285
|
'Classification is an allowlist: only the declared classes below are ever ' +
|
|
286
|
-
'deleted, so
|
|
287
|
-
'size and left alone
|
|
286
|
+
'deleted, so unrecognized files under tempRoot are reported with their ' +
|
|
287
|
+
'size and left alone (`/clean-temp` is the operator path for them). ' +
|
|
288
|
+
'signals.ndjson is never purged by any path.',
|
|
288
289
|
properties: {
|
|
289
290
|
enabled: {
|
|
290
291
|
type: 'boolean',
|
|
@@ -326,6 +327,14 @@ const TEMP_RETENTION_SCHEMA = {
|
|
|
326
327
|
'Age-floored only; the current run is always excluded.',
|
|
327
328
|
default: true,
|
|
328
329
|
},
|
|
330
|
+
scratch: {
|
|
331
|
+
type: 'boolean',
|
|
332
|
+
description:
|
|
333
|
+
'<tempRoot>/scratch/ — agent-authored scratch. ' +
|
|
334
|
+
'`scratch/story-<id>/` is purged when that Story lands; any ' +
|
|
335
|
+
'other `scratch/` entry is age-floored.',
|
|
336
|
+
default: true,
|
|
337
|
+
},
|
|
329
338
|
},
|
|
330
339
|
additionalProperties: false,
|
|
331
340
|
},
|