mandrel 2.66.0 → 2.68.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/agents/story-worker.md +15 -11
- package/.agents/docs/agentrc-reference.json +5 -2
- package/.agents/docs/configuration.md +38 -2
- 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 +20 -2
- package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
- package/.agents/schemas/validation-evidence.schema.json +3 -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/coverage-capture.js +65 -9
- package/.agents/scripts/drain-pending-cleanup.js +5 -4
- package/.agents/scripts/evidence-gate.js +106 -8
- package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
- package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
- package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
- package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
- package/.agents/scripts/lib/child-exec.js +39 -1
- package/.agents/scripts/lib/clean-temp.js +440 -0
- package/.agents/scripts/lib/close-validation/gates.js +59 -19
- package/.agents/scripts/lib/close-validation/process.js +23 -24
- package/.agents/scripts/lib/close-validation/runner.js +71 -40
- package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
- package/.agents/scripts/lib/config/quality.js +7 -1
- package/.agents/scripts/lib/config/temp-paths.js +15 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +12 -3
- package/.agents/scripts/lib/coverage-baseline.js +78 -5
- package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
- package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
- package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
- package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
- package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
- package/.agents/scripts/lib/coverage-capture.js +121 -81
- package/.agents/scripts/lib/full-suite-lock.js +49 -46
- package/.agents/scripts/lib/full-suite-queue.js +83 -8
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +4 -1
- package/.agents/scripts/lib/orchestration/code-review.js +15 -3
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
- package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
- package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
- package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
- package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
- package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
- package/.agents/scripts/lib/process-group.js +1 -1
- package/.agents/scripts/lib/single-story-sweep.js +2 -2
- package/.agents/scripts/lib/supervised-suite.js +247 -0
- package/.agents/scripts/lib/temp-removal.js +110 -0
- package/.agents/scripts/lib/temp-retention.js +122 -73
- package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
- package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
- package/.agents/scripts/quality-preview.js +112 -14
- package/.agents/scripts/single-story-init.js +120 -17
- package/.agents/scripts/stories-wave-tick.js +47 -0
- package/.agents/scripts/story-review-compute.js +207 -0
- package/.agents/scripts/update-coverage-baseline.js +15 -10
- package/.agents/scripts/update-crap-baseline.js +12 -2
- package/.agents/scripts/update-maintainability-baseline.js +12 -2
- 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/code-review.md +7 -5
- package/.agents/workflows/helpers/deliver-digest.md +39 -36
- package/.agents/workflows/helpers/deliver-reference.md +115 -5
- package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
- package/.agents/workflows/helpers/deliver-story.md +2 -1
- package/docs/CHANGELOG.md +39 -0
- package/lib/cli/registry.js +125 -18
- package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
- 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
|
+
}
|
|
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs';
|
|
|
4
4
|
|
|
5
5
|
import { _internals as baselineReaderInternals } from '../baselines/reader.js';
|
|
6
6
|
import { getChangedFiles } from '../changed-files.js';
|
|
7
|
-
import { getQuality } from '../config/quality.js';
|
|
7
|
+
import { COVERAGE_GATE_DEFAULTS, getQuality } from '../config/quality.js';
|
|
8
8
|
import { filterFilesUnderTargets } from '../coverage-capture.js';
|
|
9
9
|
import { hasNpmScript, readPackageScripts } from '../npm-scripts.js';
|
|
10
10
|
import { KNOWN_KINDS } from '../orchestration/check-baselines/phases/parse-args.js';
|
|
@@ -290,41 +290,75 @@ function coverageCaptureRunsSuite({
|
|
|
290
290
|
return coverageCaptureActive && !captureSkipPredicted && !testCredited;
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Names every suite the capture can run: under `captureScope: "affected"` a
|
|
295
|
+
* red may come from a delta refresh the base-sync triggered, not the Story.
|
|
296
|
+
*/
|
|
297
|
+
const COVERAGE_CAPTURE_HINT =
|
|
298
|
+
'Coverage capture failed — its suite run (`npm run test:coverage`, or `npm run test:coverage:affected` under `captureScope: "affected"`, including a delta refresh keyed on the stamped commit after a base-sync) exited non-zero. The coverage-capture log names which ran; fix the failing tests or coverage-threshold breaches, then re-run close.';
|
|
299
|
+
|
|
293
300
|
const COVERAGE_CAPTURE_ARGS = Object.freeze([
|
|
294
301
|
'.agents/scripts/coverage-capture.js',
|
|
295
302
|
]);
|
|
296
303
|
|
|
297
|
-
/**
|
|
298
|
-
|
|
304
|
+
/**
|
|
305
|
+
* The `pre-push` hook's quality preview, split at close into its two halves.
|
|
306
|
+
* The MI half needs no coverage artifact, so it fails in the parallel phase;
|
|
307
|
+
* the CRAP half stays serial behind coverage-capture. Every name MUST be in
|
|
308
|
+
* the `gateName` enum of `validation-evidence.schema.json`.
|
|
309
|
+
*/
|
|
310
|
+
const QUALITY_PREVIEW_GATE_NAMES = Object.freeze({
|
|
311
|
+
maintainability: 'quality-preview-mi',
|
|
312
|
+
crap: 'quality-preview-crap',
|
|
313
|
+
});
|
|
299
314
|
|
|
300
|
-
const
|
|
301
|
-
|
|
315
|
+
const QUALITY_PREVIEW_HINTS = Object.freeze({
|
|
316
|
+
maintainability:
|
|
317
|
+
'Maintainability preview failed — the per-file MI check the `pre-push` hook runs, scored against the base branch. Simplify the flagged file(s), then re-run close; this gate runs before coverage-capture, so no suite was spent.',
|
|
318
|
+
crap: "CRAP preview failed — the per-file CRAP check the `pre-push` hook runs, scored against the base branch and the fresh coverage artifact. Reduce the flagged methods' complexity or cover them, then re-run close; a close that skipped this gate would have died at push instead.",
|
|
319
|
+
});
|
|
302
320
|
|
|
303
321
|
/**
|
|
304
|
-
* Replays the `pre-push`
|
|
305
|
-
*
|
|
306
|
-
*
|
|
322
|
+
* Replays the `pre-push` quality preview as two single-half gates, so an MI
|
|
323
|
+
* breach fails close before the suite runs and a CRAP breach fails it, not
|
|
324
|
+
* the push. Registered exactly when coverage-capture is.
|
|
307
325
|
*
|
|
308
326
|
* @param {{ coverageCaptureActive: boolean, baseBranch?: string }} opts
|
|
309
|
-
* @returns {Gate[]}
|
|
327
|
+
* @returns {{ maintainability: Gate[], crap: Gate[] }}
|
|
310
328
|
*/
|
|
311
|
-
function
|
|
312
|
-
if (!coverageCaptureActive) return [];
|
|
329
|
+
function buildQualityPreviewGateEntries({ coverageCaptureActive, baseBranch }) {
|
|
330
|
+
if (!coverageCaptureActive) return { maintainability: [], crap: [] };
|
|
313
331
|
const ref = `origin/${baseBranch || 'main'}`;
|
|
314
|
-
|
|
332
|
+
const entry = (half, only) => [
|
|
315
333
|
{
|
|
316
|
-
name:
|
|
334
|
+
name: QUALITY_PREVIEW_GATE_NAMES[half],
|
|
317
335
|
cmd: 'node',
|
|
318
|
-
args: [
|
|
319
|
-
|
|
336
|
+
args: [
|
|
337
|
+
'.agents/scripts/quality-preview.js',
|
|
338
|
+
'--only',
|
|
339
|
+
only,
|
|
340
|
+
'--changed-since',
|
|
341
|
+
ref,
|
|
342
|
+
],
|
|
343
|
+
hint: QUALITY_PREVIEW_HINTS[half],
|
|
320
344
|
},
|
|
321
345
|
];
|
|
346
|
+
return {
|
|
347
|
+
maintainability: entry('maintainability', 'mi'),
|
|
348
|
+
crap: entry('crap', 'crap'),
|
|
349
|
+
};
|
|
322
350
|
}
|
|
323
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Shown instead of a gate's own hint when it exits `COVERAGE_TIMEOUT_EXIT_CODE`
|
|
354
|
+
* — a killed suite, not a failing one.
|
|
355
|
+
*/
|
|
356
|
+
export const GATE_TIMEOUT_HINT = `The gate outran the suite timeout (\`delivery.quality.gates.coverage.timeoutMs\`, default ${COVERAGE_GATE_DEFAULTS.timeoutMs} ms — raise it for a slow or shared host) and its process group was killed — no test verdict exists. This is usually host contention (sibling suites or a peer close sharing the machine), not a failing test: re-run close once the host is quieter.`;
|
|
357
|
+
|
|
324
358
|
/**
|
|
325
359
|
* Build the close-validation gate list, cheapest fast-fail first: typecheck →
|
|
326
|
-
* lint → [test] → format → [
|
|
327
|
-
* check-baselines. Coverage-capture registers only when CRAP is enabled AND a
|
|
360
|
+
* lint → [test] → format → [quality-preview-mi] → [coverage-capture →
|
|
361
|
+
* quality-preview-crap] → check-baselines. Coverage-capture registers only when CRAP is enabled AND a
|
|
328
362
|
* `test:coverage` script exists; it then carries test-failure signalling and
|
|
329
363
|
* the plain `test` gate is dropped, so there is always exactly one test gate.
|
|
330
364
|
*
|
|
@@ -392,6 +426,10 @@ export function buildDefaultGates({
|
|
|
392
426
|
enabledKinds: baselineKinds,
|
|
393
427
|
presentBaselines,
|
|
394
428
|
});
|
|
429
|
+
const qualityPreview = buildQualityPreviewGateEntries({
|
|
430
|
+
coverageCaptureActive,
|
|
431
|
+
baseBranch,
|
|
432
|
+
});
|
|
395
433
|
if (!baselinesDecision.register && baselinesDecision.reason) {
|
|
396
434
|
log?.(`[close-validation] ${baselinesDecision.reason}`);
|
|
397
435
|
}
|
|
@@ -421,20 +459,21 @@ export function buildDefaultGates({
|
|
|
421
459
|
? { changedFileScope: formatChangedFileScope }
|
|
422
460
|
: {}),
|
|
423
461
|
},
|
|
462
|
+
...qualityPreview.maintainability,
|
|
424
463
|
...(coverageCaptureActive
|
|
425
464
|
? [
|
|
426
465
|
{
|
|
427
466
|
name: 'coverage-capture',
|
|
428
467
|
cmd: 'node',
|
|
429
468
|
args: [...COVERAGE_CAPTURE_ARGS],
|
|
430
|
-
hint:
|
|
469
|
+
hint: COVERAGE_CAPTURE_HINT,
|
|
431
470
|
...(captureSkipPredicted
|
|
432
471
|
? { skip: { reason: 'incremental-no-crap-changes' } }
|
|
433
472
|
: {}),
|
|
434
473
|
},
|
|
435
474
|
]
|
|
436
475
|
: []),
|
|
437
|
-
...
|
|
476
|
+
...qualityPreview.crap,
|
|
438
477
|
...buildBaselinesGateEntries({
|
|
439
478
|
decision: baselinesDecision,
|
|
440
479
|
kinds: baselineKinds,
|
|
@@ -459,6 +498,7 @@ const INDEPENDENT_GATE_NAMES = new Set([
|
|
|
459
498
|
'format',
|
|
460
499
|
'typecheck',
|
|
461
500
|
BASELINES_GATE_NAMES.independent,
|
|
501
|
+
QUALITY_PREVIEW_GATE_NAMES.maintainability,
|
|
462
502
|
]);
|
|
463
503
|
|
|
464
504
|
/**
|
|
@@ -4,13 +4,11 @@ import { spawn } from 'node:child_process';
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
LOCK_WAIT_EXPIRED_EXIT_CODE,
|
|
7
|
+
resolveFullSuiteLockBudget,
|
|
7
8
|
withFullSuiteLockAsync,
|
|
8
9
|
} from '../full-suite-lock.js';
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
superviseGroup,
|
|
12
|
-
TIMEOUT_EXIT_CODE,
|
|
13
|
-
} from '../process-group.js';
|
|
10
|
+
import { groupSpawnOptions, TIMEOUT_EXIT_CODE } from '../process-group.js';
|
|
11
|
+
import { gateSupervision } from '../supervised-suite.js';
|
|
14
12
|
|
|
15
13
|
/**
|
|
16
14
|
* Emit each line with `prefix`; the unterminated tail flushes on `end`.
|
|
@@ -80,31 +78,35 @@ function isBiomeNoFilesProcessed(output) {
|
|
|
80
78
|
*
|
|
81
79
|
* `fullSuiteLock` serializes the spawn behind the host lock (async, so
|
|
82
80
|
* sibling gates on this event loop are not stalled). An expired wait spawns
|
|
83
|
-
*
|
|
84
|
-
* `
|
|
85
|
-
*
|
|
81
|
+
* nothing and returns `LOCK_WAIT_EXPIRED_EXIT_CODE`, so close ends
|
|
82
|
+
* `pending`. A full-suite gate's timeout is armed at spawn, re-armed on the
|
|
83
|
+
* suite-ready handshake, and its lock wait, host wait and test run are logged
|
|
84
|
+
* as three figures. Each child leads its own process group so a timeout,
|
|
85
|
+
* abort or parent signal kills its workers too.
|
|
86
86
|
*
|
|
87
87
|
* @param {string} cmd
|
|
88
88
|
* @param {string[]} args
|
|
89
|
-
* @param {{ cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void, env?: Record<string, string>, tolerateNoFilesProcessed?: boolean, fullSuiteLock?: boolean,
|
|
89
|
+
* @param {{ cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void, env?: Record<string, string>, tolerateNoFilesProcessed?: boolean, fullSuiteLock?: boolean, timeoutMs?: number, lockOptions?: object, skipIfSatisfied?: () => {status: number}|undefined }} opts
|
|
90
90
|
* @returns {Promise<{ status: number }>}
|
|
91
91
|
*/
|
|
92
92
|
export function defaultGateRunner(cmd, args, opts = {}) {
|
|
93
93
|
if (!opts.fullSuiteLock) return spawnGate(cmd, args, opts);
|
|
94
94
|
// `skipIfSatisfied` re-probes after the wait: evidence another suite
|
|
95
95
|
// deposited meanwhile is returned instead of spawning.
|
|
96
|
-
return withFullSuiteLockAsync(gateLockOptions(opts), () =>
|
|
97
|
-
spawnGate(cmd, args, opts),
|
|
96
|
+
return withFullSuiteLockAsync(gateLockOptions(opts), (lock) =>
|
|
97
|
+
spawnGate(cmd, args, opts, lock),
|
|
98
98
|
);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/** The wait is bounded by the same kill bound that bounds the holder. */
|
|
101
102
|
function gateLockOptions(opts) {
|
|
102
103
|
return {
|
|
104
|
+
...resolveFullSuiteLockBudget(opts.timeoutMs),
|
|
103
105
|
cwd: opts.cwd,
|
|
104
106
|
log: opts.log,
|
|
105
107
|
skipIfSatisfied: opts.skipIfSatisfied,
|
|
106
|
-
onWaitExpired:
|
|
107
|
-
...opts.lockOptions, // test
|
|
108
|
+
onWaitExpired: deferredGateStatus,
|
|
109
|
+
...opts.lockOptions, // `rerunCommand`, plus test seams
|
|
108
110
|
};
|
|
109
111
|
}
|
|
110
112
|
|
|
@@ -116,20 +118,16 @@ function deferredGateStatus() {
|
|
|
116
118
|
* @param {string} cmd
|
|
117
119
|
* @param {string[]} args
|
|
118
120
|
* @param {Parameters<typeof defaultGateRunner>[2]} opts
|
|
121
|
+
* @param {{ lockWaitMs?: number }} [lock] How long the full-suite lock queued it.
|
|
119
122
|
* @returns {Promise<{ status: number }>}
|
|
120
123
|
*/
|
|
121
|
-
function spawnGate(cmd, args, opts) {
|
|
122
|
-
const
|
|
124
|
+
function spawnGate(cmd, args, opts, lock) {
|
|
125
|
+
const supervision = gateSupervision(opts, lock);
|
|
126
|
+
const child = spawnGateChild(cmd, args, opts.cwd, supervision.env);
|
|
123
127
|
const output = gateOutput(opts);
|
|
124
128
|
pipePrefixed(child.stdout, output.prefix, output.tap);
|
|
125
129
|
pipePrefixed(child.stderr, output.prefix, output.tap);
|
|
126
|
-
|
|
127
|
-
// (a capture holding the lock) get SIGTERM to release and kill their suite.
|
|
128
|
-
const supervisor = superviseGroup(child, {
|
|
129
|
-
timeoutMs: opts.timeoutMs,
|
|
130
|
-
abortSignal: opts.signal,
|
|
131
|
-
signalOnParentSignal: opts.fullSuiteLock ? 'SIGKILL' : 'SIGTERM',
|
|
132
|
-
});
|
|
130
|
+
const supervisor = supervision.supervise(child, output);
|
|
133
131
|
return new Promise((resolve) => {
|
|
134
132
|
// 'close', not 'exit': only 'close' waits for both pipes to drain.
|
|
135
133
|
child.on('close', (code, sig) => {
|
|
@@ -148,9 +146,10 @@ function spawnGate(cmd, args, opts) {
|
|
|
148
146
|
/**
|
|
149
147
|
* @param {string} cmd
|
|
150
148
|
* @param {string[]} args
|
|
151
|
-
* @param {
|
|
149
|
+
* @param {string} cwd
|
|
150
|
+
* @param {Record<string, string>} [env]
|
|
152
151
|
*/
|
|
153
|
-
function spawnGateChild(cmd, args,
|
|
152
|
+
function spawnGateChild(cmd, args, cwd, env) {
|
|
154
153
|
return spawn(cmd, args, {
|
|
155
154
|
cwd,
|
|
156
155
|
shell: process.platform === 'win32',
|