mandrel 2.65.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.
Files changed (82) hide show
  1. package/.agents/agents/acceptance-critic.md +7 -7
  2. package/.agents/agents/auditor.md +17 -18
  3. package/.agents/agents/plan-critic.md +5 -5
  4. package/.agents/agents/story-worker.md +5 -5
  5. package/.agents/docs/agentrc-reference.json +2 -1
  6. package/.agents/docs/configuration.md +2 -1
  7. package/.agents/docs/execution-reference.md +27 -5
  8. package/.agents/docs/workflows.md +4 -2
  9. package/.agents/instructions.md +12 -13
  10. package/.agents/rules/ci-remediation.md +3 -3
  11. package/.agents/rules/gherkin-standards.md +3 -2
  12. package/.agents/rules/git-conventions-reference.md +17 -8
  13. package/.agents/rules/git-conventions.md +10 -8
  14. package/.agents/rules/testing-standards.md +8 -7
  15. package/.agents/runtime-deps.json +1 -1
  16. package/.agents/schemas/agentrc.schema.json +6 -1
  17. package/.agents/scripts/boot-sweep.js +97 -9
  18. package/.agents/scripts/bootstrap.js +94 -89
  19. package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
  20. package/.agents/scripts/clean-temp.js +54 -0
  21. package/.agents/scripts/clean-worktrees.js +593 -0
  22. package/.agents/scripts/drain-pending-cleanup.js +5 -4
  23. package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
  24. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +78 -78
  25. package/.agents/scripts/lib/clean-temp.js +440 -0
  26. package/.agents/scripts/lib/cli/standard-args.js +60 -76
  27. package/.agents/scripts/lib/cli-args.js +26 -0
  28. package/.agents/scripts/lib/config/gates/shared.js +3 -3
  29. package/.agents/scripts/lib/config-settings-schema-delivery.js +11 -2
  30. package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
  31. package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
  32. package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
  33. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  34. package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
  35. package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
  36. package/.agents/scripts/lib/observability/signal-validator.js +17 -5
  37. package/.agents/scripts/lib/observability/source-classifier.js +3 -1
  38. package/.agents/scripts/lib/orchestration/code-review.js +22 -0
  39. package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
  40. package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
  41. package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
  42. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +23 -0
  43. package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
  44. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -0
  45. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +349 -263
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
  47. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
  48. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +327 -314
  49. package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
  50. package/.agents/scripts/lib/signals/detectors/common.js +63 -51
  51. package/.agents/scripts/lib/single-story-sweep.js +2 -2
  52. package/.agents/scripts/lib/temp-removal.js +110 -0
  53. package/.agents/scripts/lib/temp-retention.js +122 -73
  54. package/.agents/scripts/lib/transpile.js +28 -3
  55. package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
  56. package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
  57. package/.agents/scripts/single-story-close.js +10 -2
  58. package/.agents/scripts/single-story-confirm-merge.js +267 -238
  59. package/.agents/scripts/single-story-init.js +120 -17
  60. package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
  61. package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
  62. package/.agents/workflows/audit-architecture.md +5 -4
  63. package/.agents/workflows/audit-documentation.md +5 -5
  64. package/.agents/workflows/audit-performance.md +10 -10
  65. package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
  66. package/.agents/workflows/clean-temp.md +67 -0
  67. package/.agents/workflows/clean-worktrees.md +63 -0
  68. package/.agents/workflows/git-deliver.md +1 -1
  69. package/.agents/workflows/helpers/acceptance-self-eval.md +11 -10
  70. package/.agents/workflows/helpers/audit-lens-core.md +30 -57
  71. package/.agents/workflows/helpers/deliver-digest.md +2 -2
  72. package/.agents/workflows/helpers/deliver-reference.md +3 -1
  73. package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
  74. package/.agents/workflows/helpers/deliver-story.md +6 -1
  75. package/.agents/workflows/helpers/parallel-tooling.md +16 -18
  76. package/.agents/workflows/mandrel-deliver.md +1 -1
  77. package/.agents/workflows/mandrel-plan.md +6 -5
  78. package/docs/CHANGELOG.md +39 -0
  79. package/lib/cli/guarded-sync.js +87 -0
  80. package/lib/cli/sync-agents.js +9 -92
  81. package/lib/cli/sync-commands.js +9 -101
  82. package/package.json +2 -2
@@ -80,6 +80,10 @@ function writeJson(p, obj, fsImpl = fs) {
80
80
  fsImpl.writeFileSync(p, `${JSON.stringify(obj, null, 2)}\n`, 'utf8');
81
81
  }
82
82
 
83
+ function agentRootOf(ctx) {
84
+ return ctx.agentRoot ?? path.join(ctx.projectRoot, '.agents');
85
+ }
86
+
83
87
  /**
84
88
  * Node floor SSOT (`node:sqlite` stabilised at 22.22.1); import it, never
85
89
  * duplicate. Matches `package.json` `engines.node` (`>=22.22.1 <25`).
@@ -126,9 +130,36 @@ export function detectPackageManager(projectRoot, fsImpl = fs) {
126
130
  return detectPm(projectRoot, (p) => fsImpl.existsSync(p)) ?? 'npm';
127
131
  }
128
132
 
133
+ /** @returns {'added'|'already-present'} */
134
+ function ensureScript(scripts, key, command) {
135
+ if (scripts[key]) return 'already-present';
136
+ scripts[key] = command;
137
+ return 'added';
138
+ }
139
+
140
+ /** Append each projection independently so a partial prepare gains the other. */
141
+ function ensurePrepareScript(scripts) {
142
+ const prepare = scripts.prepare;
143
+ if (!prepare) {
144
+ scripts.prepare = `${SYNC_COMMAND} && ${SYNC_AGENTS_COMMAND}`;
145
+ return 'added';
146
+ }
147
+ let next = prepare;
148
+ if (!next.includes('sync-claude-commands.js')) {
149
+ next = `${next} && ${SYNC_COMMAND}`;
150
+ }
151
+ if (!next.includes('sync-claude-agents.js')) {
152
+ next = `${next} && ${SYNC_AGENTS_COMMAND}`;
153
+ }
154
+ if (next === prepare) return 'already-present';
155
+ scripts.prepare = next;
156
+ return 'appended';
157
+ }
158
+
129
159
  /**
130
160
  * Ensure `package.json` carries the sync/prepare/bootstrap scripts. Never
131
161
  * touches `dependencies` — framework deps arrive transitively via `mandrel`.
162
+ * An operator-defined `bootstrap` script always wins.
132
163
  *
133
164
  * @param {object} ctx
134
165
  * @param {typeof fs} [ctx.fsImpl]
@@ -136,62 +167,32 @@ export function detectPackageManager(projectRoot, fsImpl = fs) {
136
167
  export function ensurePackageJson(ctx) {
137
168
  const { fsImpl = fs } = ctx;
138
169
  const pkgPath = path.join(ctx.projectRoot, 'package.json');
139
- const projectName = path.basename(path.resolve(ctx.projectRoot));
140
- const outcomes = {
141
- created: false,
142
- scriptsSyncCommands: 'already-present',
143
- scriptsSyncAgents: 'already-present',
144
- scriptsPrepare: 'already-present',
145
- scriptsBootstrap: 'already-present',
170
+ const existing = readJsonIfExists(pkgPath, fsImpl);
171
+ const pkg = existing || {
172
+ name: path.basename(path.resolve(ctx.projectRoot)),
173
+ version: '0.0.0',
174
+ private: true,
175
+ type: 'module',
146
176
  };
147
- let pkg = readJsonIfExists(pkgPath, fsImpl);
148
- if (!pkg) {
149
- pkg = {
150
- name: projectName,
151
- version: '0.0.0',
152
- private: true,
153
- type: 'module',
154
- };
155
- outcomes.created = true;
156
- }
157
177
  pkg.scripts = pkg.scripts ?? {};
158
- if (!pkg.scripts['sync:commands']) {
159
- pkg.scripts['sync:commands'] = SYNC_COMMAND;
160
- outcomes.scriptsSyncCommands = 'added';
161
- }
162
- if (!pkg.scripts['sync:agents']) {
163
- pkg.scripts['sync:agents'] = SYNC_AGENTS_COMMAND;
164
- outcomes.scriptsSyncAgents = 'added';
165
- }
166
- const prepare = pkg.scripts.prepare;
167
- if (!prepare) {
168
- pkg.scripts.prepare = `${SYNC_COMMAND} && ${SYNC_AGENTS_COMMAND}`;
169
- outcomes.scriptsPrepare = 'added';
170
- } else {
171
- // Append each projection independently so a partial prepare gains the other.
172
- let next = prepare;
173
- if (!next.includes('sync-claude-commands.js')) {
174
- next = `${next} && ${SYNC_COMMAND}`;
175
- }
176
- if (!next.includes('sync-claude-agents.js')) {
177
- next = `${next} && ${SYNC_AGENTS_COMMAND}`;
178
- }
179
- if (next !== prepare) {
180
- pkg.scripts.prepare = next;
181
- outcomes.scriptsPrepare = 'appended';
182
- }
183
- }
184
- // An operator-defined `bootstrap` script always wins.
185
- if (!pkg.scripts.bootstrap) {
186
- pkg.scripts.bootstrap = BOOTSTRAP_COMMAND;
187
- outcomes.scriptsBootstrap = 'added';
188
- }
189
- const mutated =
190
- outcomes.created ||
191
- outcomes.scriptsSyncCommands === 'added' ||
192
- outcomes.scriptsSyncAgents === 'added' ||
193
- outcomes.scriptsPrepare !== 'already-present' ||
194
- outcomes.scriptsBootstrap === 'added';
178
+ const outcomes = {
179
+ created: !existing,
180
+ scriptsSyncCommands: ensureScript(
181
+ pkg.scripts,
182
+ 'sync:commands',
183
+ SYNC_COMMAND,
184
+ ),
185
+ scriptsSyncAgents: ensureScript(
186
+ pkg.scripts,
187
+ 'sync:agents',
188
+ SYNC_AGENTS_COMMAND,
189
+ ),
190
+ scriptsPrepare: ensurePrepareScript(pkg.scripts),
191
+ scriptsBootstrap: ensureScript(pkg.scripts, 'bootstrap', BOOTSTRAP_COMMAND),
192
+ };
193
+ const mutated = Object.values(outcomes).some(
194
+ (v) => v === true || v === 'added' || v === 'appended',
195
+ );
195
196
  if (mutated) writeJson(pkgPath, pkg, fsImpl);
196
197
  return { ...outcomes, path: pkgPath, mutated };
197
198
  }
@@ -246,10 +247,7 @@ export function ensureAgentrc(ctx) {
246
247
  if (fsImpl.existsSync(target)) {
247
248
  return { action: 'already-present', path: target };
248
249
  }
249
- const starter = path.join(
250
- ctx.agentRoot ?? path.join(ctx.projectRoot, '.agents'),
251
- 'starter-agentrc.json',
252
- );
250
+ const starter = path.join(agentRootOf(ctx), 'starter-agentrc.json');
253
251
  if (!fsImpl.existsSync(starter)) {
254
252
  return { action: 'missing-starter', path: target };
255
253
  }
@@ -269,16 +267,28 @@ export function ensureAgentrc(ctx) {
269
267
  return { action: 'seeded', path: target, source: 'starter' };
270
268
  }
271
269
 
270
+ async function loadAgentrcValidator(schemaModule) {
271
+ // pathToFileURL handles Windows drive letters and percent-encoding.
272
+ const mod = await import(pathToFileURL(schemaModule).href);
273
+ return mod.getAgentrcValidator();
274
+ }
275
+
276
+ function agentrcVerdict(validate, data) {
277
+ if (!data) return { ok: false, errors: ['.agentrc.json missing'] };
278
+ const ok = validate(data);
279
+ return { ok: !!ok, errors: ok ? [] : (validate.errors ?? []) };
280
+ }
281
+
272
282
  /**
273
283
  * Validate `.agentrc.json` against the AJV schema; the caller decides whether to abort.
274
284
  *
275
285
  * @param {object} ctx
276
286
  * @param {typeof fs} [ctx.fsImpl]
277
287
  */
278
- export async function validateAgentrc(ctx) {
288
+ async function validateAgentrc(ctx) {
279
289
  const { fsImpl = fs } = ctx;
280
290
  const schemaModule = path.join(
281
- ctx.agentRoot ?? path.join(ctx.projectRoot, '.agents'),
291
+ agentRootOf(ctx),
282
292
  'scripts',
283
293
  'lib',
284
294
  'config-settings-schema.js',
@@ -286,16 +296,12 @@ export async function validateAgentrc(ctx) {
286
296
  if (!fsImpl.existsSync(schemaModule)) {
287
297
  return { ok: false, errors: ['config-settings-schema.js not found'] };
288
298
  }
289
- // pathToFileURL handles Windows drive letters and percent-encoding.
290
- const mod = await import(pathToFileURL(schemaModule).href);
291
- const validate = mod.getAgentrcValidator();
299
+ const validate = await loadAgentrcValidator(schemaModule);
292
300
  const data = readJsonIfExists(
293
301
  path.join(ctx.projectRoot, '.agentrc.json'),
294
302
  fsImpl,
295
303
  );
296
- if (!data) return { ok: false, errors: ['.agentrc.json missing'] };
297
- const ok = validate(data);
298
- return { ok: !!ok, errors: ok ? [] : (validate.errors ?? []) };
304
+ return agentrcVerdict(validate, data);
299
305
  }
300
306
 
301
307
  /**
@@ -347,12 +353,9 @@ function ensureIssueFormsPhase(ctx) {
347
353
  * @param {object} ctx
348
354
  * @param {typeof defaultSpawnSync} [ctx.spawnImpl]
349
355
  */
350
- export function runSyncCommands(ctx) {
356
+ function runSyncCommands(ctx) {
351
357
  const { spawnImpl = defaultSpawnSync } = ctx;
352
- const scriptsDir = path.join(
353
- ctx.agentRoot ?? path.join(ctx.projectRoot, '.agents'),
354
- 'scripts',
355
- );
358
+ const scriptsDir = path.join(agentRootOf(ctx), 'scripts');
356
359
  const projections = [
357
360
  { label: 'sync-claude-commands.js', script: 'sync-claude-commands.js' },
358
361
  { label: 'sync-claude-agents.js', script: 'sync-claude-agents.js' },
@@ -390,10 +393,7 @@ export function runSyncCommands(ctx) {
390
393
  */
391
394
  export function checkParity(ctx) {
392
395
  const { fsImpl = fs } = ctx;
393
- const workflowsDir = path.join(
394
- ctx.agentRoot ?? path.join(ctx.projectRoot, '.agents'),
395
- 'workflows',
396
- );
396
+ const workflowsDir = path.join(agentRootOf(ctx), 'workflows');
397
397
  const commandsDir = path.join(ctx.projectRoot, '.claude', 'commands');
398
398
  const list = (dir) =>
399
399
  fsImpl.existsSync(dir)
@@ -437,13 +437,13 @@ export function ensureSystemPromptWiring(ctx) {
437
437
  * @param {typeof fs} [ctx.fsImpl]
438
438
  * @param {typeof defaultSpawnSync} [ctx.spawnImpl]
439
439
  */
440
- export function checkWindowsGitPerf(ctx) {
440
+ function checkWindowsGitPerf(ctx) {
441
441
  const { fsImpl = fs, spawnImpl = defaultSpawnSync } = ctx;
442
- if (os.platform() !== 'win32') {
442
+ if ((ctx.platform ?? os.platform()) !== 'win32') {
443
443
  return { platform: process.platform, skipped: true };
444
444
  }
445
445
  const script = path.join(
446
- ctx.agentRoot ?? path.join(ctx.projectRoot, '.agents'),
446
+ agentRootOf(ctx),
447
447
  'scripts',
448
448
  'check-windows-git-perf.js',
449
449
  );
@@ -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
+ }