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
@@ -60,49 +60,32 @@ function collectTaskPathReferences(task) {
60
60
  */
61
61
  function collectTaskChangesPaths(task) {
62
62
  const paths = new Set();
63
- const body = task.body;
63
+ const source = resolveChangesSource(task);
64
+ if (source === null) return paths;
65
+ for (const arrName of ['changes', 'references']) {
66
+ const arr = source[arrName];
67
+ if (!Array.isArray(arr)) continue;
68
+ for (const item of arr) collectChangesItem(item, paths);
69
+ }
70
+ return paths;
71
+ }
64
72
 
73
+ function resolveChangesSource(task) {
74
+ const body = task.body;
65
75
  // A parse failure throws rather than yielding an empty whitelist, which
66
76
  // would misreport every declared path as missing.
67
77
  if (typeof body === 'string' && body.trim().length > 0) {
68
- const parsed = parseStoryBodyOrThrow(task);
69
- for (const arrName of ['changes', 'references']) {
70
- const arr = parsed[arrName];
71
- if (!Array.isArray(arr)) continue;
72
- for (const item of arr) {
73
- if (typeof item === 'string') {
74
- collectPathsFromText(item, paths);
75
- } else if (
76
- item !== null &&
77
- typeof item === 'object' &&
78
- typeof item.path === 'string' &&
79
- item.path.length > 0
80
- ) {
81
- paths.add(item.path);
82
- }
83
- }
84
- }
85
- return paths;
78
+ return parseStoryBodyOrThrow(task);
86
79
  }
80
+ return body !== null && typeof body === 'object' ? body : null;
81
+ }
87
82
 
88
- if (body === null || typeof body !== 'object') return paths;
89
- for (const arrName of ['changes', 'references']) {
90
- const arr = body[arrName];
91
- if (!Array.isArray(arr)) continue;
92
- for (const item of arr) {
93
- if (typeof item === 'string') {
94
- collectPathsFromText(item, paths);
95
- } else if (
96
- item !== null &&
97
- typeof item === 'object' &&
98
- typeof item.path === 'string' &&
99
- item.path.length > 0
100
- ) {
101
- paths.add(item.path);
102
- }
103
- }
83
+ function collectChangesItem(item, paths) {
84
+ if (typeof item === 'string') {
85
+ collectPathsFromText(item, paths);
86
+ } else if (typeof item?.path === 'string' && item.path.length > 0) {
87
+ paths.add(item.path);
104
88
  }
105
- return paths;
106
89
  }
107
90
 
108
91
  function defaultGitRunner({ baseBranchRef, path, cwd }) {
@@ -51,9 +51,12 @@ export function extractTool(rec) {
51
51
  */
52
52
  export function validateDetectorArgs(args, opts) {
53
53
  const { fnName } = opts;
54
- const requireTracesPath = opts.requireTracesPath ?? true;
55
- const requireStoryId = opts.requireStoryId ?? true;
56
- const requireThreshold = opts.requireThreshold ?? true;
54
+ const gates = {
55
+ always: true,
56
+ requireTracesPath: opts.requireTracesPath ?? true,
57
+ requireStoryId: opts.requireStoryId ?? true,
58
+ requireThreshold: opts.requireThreshold ?? true,
59
+ };
57
60
 
58
61
  if (args == null || typeof args !== 'object') {
59
62
  throw new TypeError(
@@ -61,57 +64,66 @@ export function validateDetectorArgs(args, opts) {
61
64
  );
62
65
  }
63
66
 
64
- const { tracesPath, epicId, storyId, threshold } = args;
65
- const taskId = args.taskId ?? null;
66
-
67
- if (args.nowFn != null && typeof args.nowFn !== 'function') {
68
- throw new TypeError(
69
- `${fnName}: nowFn, when provided, must be a function (got ${typeof args.nowFn})`,
70
- );
71
- }
72
- const nowFn = args.nowFn ?? (() => new Date().toISOString());
73
-
74
- if (requireTracesPath) {
75
- if (typeof tracesPath !== 'string' || tracesPath.length === 0) {
76
- throw new TypeError(
77
- `${fnName}: tracesPath must be a non-empty string (got ${tracesPath})`,
78
- );
79
- }
80
- }
81
-
82
- if (!isPositiveInt(epicId)) {
83
- throw new RangeError(
84
- `${fnName}: epicId must be a positive integer (got ${epicId})`,
85
- );
86
- }
87
-
88
- if (requireStoryId) {
89
- if (!isPositiveInt(storyId)) {
90
- throw new RangeError(
91
- `${fnName}: storyId must be a positive integer (got ${storyId})`,
92
- );
93
- }
94
- if (taskId !== null && !isPositiveInt(taskId)) {
95
- throw new RangeError(
96
- `${fnName}: taskId must be a positive integer or null (got ${taskId})`,
97
- );
98
- }
99
- }
100
-
101
- if (requireThreshold) {
102
- if (!Number.isInteger(threshold) || threshold < 0) {
103
- throw new RangeError(
104
- `${fnName}: threshold must be a non-negative integer (got ${threshold})`,
105
- );
67
+ const values = { ...args, taskId: args.taskId ?? null };
68
+ for (const rule of DETECTOR_ARG_RULES) {
69
+ const value = values[rule.field];
70
+ if (gates[rule.gate] && rule.invalid(value)) {
71
+ throw new rule.Error(`${fnName}: ${rule.message(value)}`);
106
72
  }
107
73
  }
108
74
 
109
75
  return {
110
- tracesPath: requireTracesPath ? tracesPath : undefined,
111
- epicId,
112
- storyId: requireStoryId ? storyId : undefined,
113
- taskId: requireStoryId ? taskId : undefined,
114
- threshold: requireThreshold ? threshold : undefined,
115
- nowFn,
76
+ tracesPath: gates.requireTracesPath ? values.tracesPath : undefined,
77
+ epicId: values.epicId,
78
+ storyId: gates.requireStoryId ? values.storyId : undefined,
79
+ taskId: gates.requireStoryId ? values.taskId : undefined,
80
+ threshold: gates.requireThreshold ? values.threshold : undefined,
81
+ nowFn: args.nowFn ?? (() => new Date().toISOString()),
116
82
  };
117
83
  }
84
+
85
+ const DETECTOR_ARG_RULES = [
86
+ {
87
+ field: 'nowFn',
88
+ gate: 'always',
89
+ invalid: (v) => v != null && typeof v !== 'function',
90
+ Error: TypeError,
91
+ message: (v) =>
92
+ `nowFn, when provided, must be a function (got ${typeof v})`,
93
+ },
94
+ {
95
+ field: 'tracesPath',
96
+ gate: 'requireTracesPath',
97
+ invalid: (v) => typeof v !== 'string' || v.length === 0,
98
+ Error: TypeError,
99
+ message: (v) => `tracesPath must be a non-empty string (got ${v})`,
100
+ },
101
+ {
102
+ field: 'epicId',
103
+ gate: 'always',
104
+ invalid: (v) => !isPositiveInt(v),
105
+ Error: RangeError,
106
+ message: (v) => `epicId must be a positive integer (got ${v})`,
107
+ },
108
+ {
109
+ field: 'storyId',
110
+ gate: 'requireStoryId',
111
+ invalid: (v) => !isPositiveInt(v),
112
+ Error: RangeError,
113
+ message: (v) => `storyId must be a positive integer (got ${v})`,
114
+ },
115
+ {
116
+ field: 'taskId',
117
+ gate: 'requireStoryId',
118
+ invalid: (v) => v !== null && !isPositiveInt(v),
119
+ Error: RangeError,
120
+ message: (v) => `taskId must be a positive integer or null (got ${v})`,
121
+ },
122
+ {
123
+ field: 'threshold',
124
+ gate: 'requireThreshold',
125
+ invalid: (v) => !Number.isInteger(v) || v < 0,
126
+ Error: RangeError,
127
+ message: (v) => `threshold must be a non-negative integer (got ${v})`,
128
+ },
129
+ ];
@@ -11,7 +11,7 @@ import {
11
11
  executeFastForward as defaultExecuteFastForward,
12
12
  planCleanup as defaultPlanCleanup,
13
13
  planFastForward as defaultPlanFastForward,
14
- } from '../git-cleanup.js';
14
+ } from '../clean-git.js';
15
15
  import { evaluateProtection as defaultEvaluateProtection } from './single-story-sweep/protection.js';
16
16
  import { acquireSweepLock as defaultAcquireSweepLock } from './single-story-sweep/sweep-lock.js';
17
17
 
@@ -175,7 +175,7 @@ export function sweepMergedStoryBranches(args = {}) {
175
175
 
176
176
  /**
177
177
  * Content-merged candidates are report-only: content equivalence is weaker
178
- * than a merged PR or ancestry, so only an operator-confirmed `/git-cleanup`
178
+ * than a merged PR or ancestry, so only an operator-confirmed `/clean-git`
179
179
  * may reap them.
180
180
  */
181
181
  function partitionContentMerged(candidates) {
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Filesystem primitives for the temp-retention engine: sizing a tree and
3
+ * deleting one while sparing the never-purged basenames at any depth.
4
+ */
5
+
6
+ import path from 'node:path';
7
+
8
+ /**
9
+ * Never deleted, re-checked at the deletion site: `signals.ndjson` is read
10
+ * long after merge and its loss is silent and unrecoverable.
11
+ */
12
+ export const KEEP_BASENAMES = Object.freeze(['signals.ndjson']);
13
+
14
+ /**
15
+ * `readdir` yielding `[]` for an absent or unreadable directory.
16
+ *
17
+ * @param {typeof import('node:fs/promises')} fsp
18
+ * @param {string} dir
19
+ * @returns {Promise<import('node:fs').Dirent[]>}
20
+ */
21
+ export async function safeReaddir(fsp, dir) {
22
+ try {
23
+ return await fsp.readdir(dir, { withFileTypes: true });
24
+ } catch {
25
+ return [];
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Recursive byte total; a vanished child is skipped.
31
+ *
32
+ * @param {typeof import('node:fs/promises')} fsp
33
+ * @param {string} target
34
+ * @returns {Promise<number>}
35
+ */
36
+ export async function sizeOf(fsp, target) {
37
+ let total = 0;
38
+ const stack = [target];
39
+ while (stack.length > 0) {
40
+ const current = stack.pop();
41
+ let stats;
42
+ try {
43
+ stats = await fsp.stat(current);
44
+ } catch {
45
+ continue;
46
+ }
47
+ if (!stats.isDirectory()) {
48
+ total += stats.size;
49
+ continue;
50
+ }
51
+ for (const child of await safeReaddir(fsp, current)) {
52
+ stack.push(path.join(current, child.name));
53
+ }
54
+ }
55
+ return total;
56
+ }
57
+
58
+ /**
59
+ * Paths of every never-purged basename at any depth under `target`.
60
+ *
61
+ * @param {typeof import('node:fs/promises')} fsp
62
+ * @param {string} target
63
+ * @returns {Promise<string[]>}
64
+ */
65
+ async function findKeptDescendants(fsp, target) {
66
+ const kept = [];
67
+ const stack = [target];
68
+ while (stack.length > 0) {
69
+ const current = stack.pop();
70
+ for (const child of await safeReaddir(fsp, current)) {
71
+ const childPath = path.join(current, child.name);
72
+ if (child.isDirectory()) stack.push(childPath);
73
+ else if (KEEP_BASENAMES.includes(child.name)) kept.push(childPath);
74
+ }
75
+ }
76
+ return kept;
77
+ }
78
+
79
+ /**
80
+ * Delete `target` while sparing every never-purged basename beneath it: a
81
+ * tree holding none goes in one `rm`; otherwise its children are removed
82
+ * one by one and the kept files (with their parent dirs) stay.
83
+ *
84
+ * @param {typeof import('node:fs/promises')} fsp
85
+ * @param {string} target
86
+ * @param {number} [knownBytes] Pre-computed size, spared a second walk.
87
+ * @returns {Promise<{ bytes: number, kept: string[] }>}
88
+ */
89
+ export async function removeSparingKept(fsp, target, knownBytes) {
90
+ const stats = await fsp.stat(target);
91
+ if (!stats.isDirectory()) {
92
+ if (KEEP_BASENAMES.includes(path.basename(target))) {
93
+ return { bytes: 0, kept: [target] };
94
+ }
95
+ await fsp.rm(target, { force: true });
96
+ return { bytes: stats.size, kept: [] };
97
+ }
98
+ const kept = await findKeptDescendants(fsp, target);
99
+ if (kept.length === 0) {
100
+ const bytes = knownBytes ?? (await sizeOf(fsp, target));
101
+ await fsp.rm(target, { recursive: true, force: true });
102
+ return { bytes, kept };
103
+ }
104
+ let bytes = 0;
105
+ for (const child of await safeReaddir(fsp, target)) {
106
+ bytes += (await removeSparingKept(fsp, path.join(target, child.name)))
107
+ .bytes;
108
+ }
109
+ return { bytes, kept };
110
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Allowlisted auto-purge of spent temp artifacts: only a declared class's
3
- * entries are candidates; the rest are reported, never touched. Never throws
3
+ * entries are candidates; the rest are reported, never touched by the
4
+ * auto-purge (only `/clean-temp`'s operator-confirmed path). Never throws
4
5
  * — a failed purge must not fail a land, boot, or persist.
5
6
  */
6
7
 
@@ -13,6 +14,14 @@ import {
13
14
  tempRootFrom,
14
15
  } from './config/temp-paths.js';
15
16
  import { Logger } from './Logger.js';
17
+ import {
18
+ KEEP_BASENAMES,
19
+ removeSparingKept,
20
+ safeReaddir,
21
+ sizeOf,
22
+ } from './temp-removal.js';
23
+
24
+ export { KEEP_BASENAMES };
16
25
 
17
26
  /**
18
27
  * Defaults for `delivery.tempRetention`; purge is on unless turned off.
@@ -26,6 +35,7 @@ export const TEMP_RETENTION_DEFAULTS = Object.freeze({
26
35
  validationEvidence: true,
27
36
  auditResults: true,
28
37
  planDirs: true,
38
+ scratch: true,
29
39
  }),
30
40
  });
31
41
 
@@ -33,12 +43,6 @@ export const PURGE_CLASS_NAMES = Object.freeze(
33
43
  Object.keys(TEMP_RETENTION_DEFAULTS.classes),
34
44
  );
35
45
 
36
- /**
37
- * Never deleted, re-checked at the deletion site: `signals.ndjson` is read
38
- * long after merge and its loss is silent and unrecoverable.
39
- */
40
- export const KEEP_BASENAMES = Object.freeze(['signals.ndjson']);
41
-
42
46
  /** Explicit allowlist: an untaught file in a Story dir is kept. */
43
47
  const STORY_EVIDENCE_BASENAMES = Object.freeze([
44
48
  'validation-evidence.json',
@@ -49,6 +53,9 @@ const STORY_EVIDENCE_BASENAMES = Object.freeze([
49
53
  /** Framework-owned, never purged (`*.lock` files are also skipped). */
50
54
  const RESERVED_TOP_LEVEL = Object.freeze(['qa', 'cache']);
51
55
 
56
+ /** Agent-authored scratch: `scratch/story-<id>/` or any other child. */
57
+ const SCRATCH_DIRNAME = 'scratch';
58
+
52
59
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
53
60
 
54
61
  const STORY_DIR_PATTERN = /^story-(\d+)$/;
@@ -77,50 +84,6 @@ export function resolveTempRetention(config) {
77
84
  };
78
85
  }
79
86
 
80
- /**
81
- * `readdir` yielding `[]` for an absent or unreadable directory.
82
- *
83
- * @param {typeof fsPromises} fsp
84
- * @param {string} dir
85
- * @returns {Promise<import('node:fs').Dirent[]>}
86
- */
87
- async function safeReaddir(fsp, dir) {
88
- try {
89
- return await fsp.readdir(dir, { withFileTypes: true });
90
- } catch {
91
- return [];
92
- }
93
- }
94
-
95
- /**
96
- * Recursive byte total; a vanished child is skipped.
97
- *
98
- * @param {typeof fsPromises} fsp
99
- * @param {string} target
100
- * @returns {Promise<number>}
101
- */
102
- async function sizeOf(fsp, target) {
103
- let total = 0;
104
- const stack = [target];
105
- while (stack.length > 0) {
106
- const current = stack.pop();
107
- let stats;
108
- try {
109
- stats = await fsp.stat(current);
110
- } catch {
111
- continue;
112
- }
113
- if (!stats.isDirectory()) {
114
- total += stats.size;
115
- continue;
116
- }
117
- for (const child of await safeReaddir(fsp, current)) {
118
- stack.push(path.join(current, child.name));
119
- }
120
- }
121
- return total;
122
- }
123
-
124
87
  /**
125
88
  * One classified entry. `mtimeMs` is the entry's own mtime, not the newest
126
89
  * beneath it; widening it would change when an abandoned dir becomes eligible.
@@ -257,13 +220,44 @@ async function scanPlanDirs(tempRoot, fsp) {
257
220
  return entries;
258
221
  }
259
222
 
223
+ /**
224
+ * `<tempRoot>/scratch/*`: `story-<id>/` is Story-keyed, anything else is
225
+ * age-floored — the one place an agent's ad-hoc files are reapable.
226
+ */
227
+ async function scanScratch(tempRoot, fsp) {
228
+ const dir = path.join(tempRoot, SCRATCH_DIRNAME);
229
+ const entries = [];
230
+ for (const dirent of await safeReaddir(fsp, dir)) {
231
+ const match = dirent.isDirectory()
232
+ ? STORY_DIR_PATTERN.exec(dirent.name)
233
+ : null;
234
+ const entry = await makeEntry(
235
+ fsp,
236
+ path.join(dir, dirent.name),
237
+ 'scratch',
238
+ match ? Number(match[1]) : null,
239
+ );
240
+ if (entry) entries.push(entry);
241
+ }
242
+ return entries;
243
+ }
244
+
260
245
  const SCANNERS = Object.freeze({
261
246
  orchestrationLogs: scanOrchestrationLogs,
262
247
  validationEvidence: scanValidationEvidence,
263
248
  auditResults: scanAuditResults,
264
249
  planDirs: scanPlanDirs,
250
+ scratch: scanScratch,
265
251
  });
266
252
 
253
+ /** Fixed top-level dirs a class scanner walks. */
254
+ const CLASS_OWNED_DIRNAMES = Object.freeze([
255
+ ORCHESTRATION_DIRNAME,
256
+ 'standalone',
257
+ 'audits',
258
+ SCRATCH_DIRNAME,
259
+ ]);
260
+
267
261
  /**
268
262
  * Keep in lockstep with the scanners: an entry no class walks must surface
269
263
  * as unrecognized.
@@ -273,29 +267,49 @@ const SCANNERS = Object.freeze({
273
267
  */
274
268
  function isClassOwnedTopLevel(name) {
275
269
  return (
276
- name === ORCHESTRATION_DIRNAME ||
277
- name === 'standalone' ||
278
- name === 'audits' ||
270
+ CLASS_OWNED_DIRNAMES.includes(name) ||
279
271
  name.startsWith('plan-') ||
280
272
  RUN_DIR_PATTERN.test(name)
281
273
  );
282
274
  }
283
275
 
284
276
  /**
285
- * Unclaimed, non-reserved top-level entries: reported with sizes, never deleted.
277
+ * Top-level names no path may ever delete: the reserved trees, lock files,
278
+ * and the never-purged basenames.
279
+ *
280
+ * @param {string} name
281
+ * @returns {boolean}
282
+ */
283
+ export function isReservedTopLevel(name) {
284
+ return (
285
+ RESERVED_TOP_LEVEL.includes(name) ||
286
+ name.endsWith('.lock') ||
287
+ KEEP_BASENAMES.includes(name)
288
+ );
289
+ }
290
+
291
+ /**
292
+ * Unclaimed, non-reserved top-level entries: reported with sizes and the
293
+ * entry's own mtime. The auto-purge never deletes one; only an operator-
294
+ * confirmed `purgeUnrecognizedEntries` call does.
286
295
  *
287
296
  * @param {string} tempRoot
288
297
  * @param {typeof fsPromises} fsp
289
- * @returns {Promise<Array<{ path: string, bytes: number }>>}
298
+ * @returns {Promise<Array<{ path: string, bytes: number, mtimeMs: number }>>}
290
299
  */
291
300
  async function collectUnrecognized(tempRoot, fsp) {
292
301
  const found = [];
293
302
  for (const dirent of await safeReaddir(fsp, tempRoot)) {
294
303
  const { name } = dirent;
295
- if (isClassOwnedTopLevel(name)) continue;
296
- if (RESERVED_TOP_LEVEL.includes(name) || name.endsWith('.lock')) continue;
297
- const target = path.join(tempRoot, name);
298
- found.push({ path: target, bytes: await sizeOf(fsp, target) });
304
+ if (isClassOwnedTopLevel(name) || isReservedTopLevel(name)) continue;
305
+ const entry = await makeEntry(fsp, path.join(tempRoot, name), null, null);
306
+ if (entry) {
307
+ found.push({
308
+ path: entry.path,
309
+ bytes: entry.bytes,
310
+ mtimeMs: entry.mtimeMs,
311
+ });
312
+ }
299
313
  }
300
314
  return found;
301
315
  }
@@ -304,7 +318,7 @@ async function collectUnrecognized(tempRoot, fsp) {
304
318
  * Classify a temp tree without deleting anything.
305
319
  *
306
320
  * @param {{ config?: object, tempRoot?: string, fsp?: typeof fsPromises }} [args]
307
- * @returns {Promise<{ tempRoot: string, entries: object[], unrecognized: Array<{ path: string, bytes: number }> }>}
321
+ * @returns {Promise<{ tempRoot: string, entries: object[], unrecognized: Array<{ path: string, bytes: number, mtimeMs: number }> }>}
308
322
  */
309
323
  export async function collectTempEntries({
310
324
  config,
@@ -353,6 +367,7 @@ function isPurgeable(entry, ctx) {
353
367
  * @param {typeof fsPromises} [args.fsp]
354
368
  * @param {{ info: Function }} [args.logger]
355
369
  * @param {string} [args.label]
370
+ * @param {boolean} [args.dryRun] Report what would go; delete nothing.
356
371
  * @returns {Promise<object>} Result envelope; never throws.
357
372
  */
358
373
  async function purgeTempArtifacts({
@@ -366,6 +381,7 @@ async function purgeTempArtifacts({
366
381
  fsp = fsPromises,
367
382
  logger = Logger,
368
383
  label = 'temp-retention',
384
+ dryRun = false,
369
385
  } = {}) {
370
386
  const policy = resolveTempRetention(config);
371
387
  const base = {
@@ -402,22 +418,55 @@ async function purgeTempArtifacts({
402
418
  if (entry.keep) result.kept.push(entry.path);
403
419
  continue;
404
420
  }
405
- try {
406
- await fsp.rm(entry.path, { recursive: true, force: true });
407
- result.purged.push({ path: entry.path, bytes: entry.bytes });
408
- result.bytesReclaimed += entry.bytes;
409
- } catch (err) {
410
- result.errors.push(`${entry.path}: ${String(err?.message ?? err)}`);
411
- }
421
+ await purgeOne(fsp, entry, result, dryRun);
412
422
  }
413
423
 
414
- if (result.purged.length > 0) {
415
- logger?.info?.(
416
- `[${label}] purged ${result.purged.length} spent temp artifact(s), ` +
417
- `reclaimed ${formatBytes(result.bytesReclaimed)} under ${result.tempRoot}.`,
424
+ if (!dryRun) reportPurge(logger, label, result);
425
+ return result;
426
+ }
427
+
428
+ /**
429
+ * One summary line for a purge that deleted something.
430
+ *
431
+ * @param {{ info?: Function }|undefined} logger
432
+ * @param {string} label
433
+ * @param {object} result
434
+ */
435
+ function reportPurge(logger, label, result) {
436
+ if (result.purged.length === 0) return;
437
+ logger?.info?.(
438
+ `[${label}] purged ${result.purged.length} spent temp artifact(s), ` +
439
+ `reclaimed ${formatBytes(result.bytesReclaimed)} under ${result.tempRoot}.`,
440
+ );
441
+ }
442
+
443
+ /**
444
+ * Remove (or, on a dry run, only record) one purgeable entry into `result`.
445
+ *
446
+ * @param {typeof fsPromises} fsp
447
+ * @param {{ path: string, bytes: number }} entry
448
+ * @param {object} result Mutated in place.
449
+ * @param {boolean} dryRun
450
+ * @returns {Promise<void>}
451
+ */
452
+ async function purgeOne(fsp, entry, result, dryRun) {
453
+ if (dryRun) {
454
+ result.purged.push({ path: entry.path, bytes: entry.bytes });
455
+ result.bytesReclaimed += entry.bytes;
456
+ return;
457
+ }
458
+ try {
459
+ const { bytes, kept } = await removeSparingKept(
460
+ fsp,
461
+ entry.path,
462
+ entry.bytes,
418
463
  );
464
+ result.kept.push(...kept);
465
+ result.purged.push({ path: entry.path, bytes });
466
+ result.bytesReclaimed += bytes;
467
+ } catch (err) {
468
+ result.errors.push(`${entry.path}: ${String(err?.message ?? err)}`);
419
469
  }
420
- return result;
421
470
  }
422
471
 
423
472
  /**
@@ -8,6 +8,9 @@ const require = createRequire(import.meta.url);
8
8
 
9
9
  const TS_EXTS = new Set(['.ts', '.tsx', '.mts', '.cts']);
10
10
 
11
+ /** TS 7 moved `transpileModule` under `unstable/*`; mirrors the peer range. */
12
+ const SUPPORTED_TS_RANGE = '>=5.0.0 <7';
13
+
11
14
  let _ts = null;
12
15
  let _tsLoadFailed = false;
13
16
 
@@ -23,6 +26,27 @@ function loadTypeScript() {
23
26
  }
24
27
  }
25
28
 
29
+ /** Compiler modules already diagnosed as API-less — one warning per module. */
30
+ const _unsupportedDiagnosed = new WeakSet();
31
+
32
+ /**
33
+ * Warn once per API-less module rather than once per file.
34
+ *
35
+ * @param {object} ts
36
+ * @returns {boolean}
37
+ */
38
+ function isUsableCompiler(ts) {
39
+ if (typeof ts.transpileModule === 'function') return true;
40
+ if (!_unsupportedDiagnosed.has(ts)) {
41
+ _unsupportedDiagnosed.add(ts);
42
+ Logger.warn(
43
+ `[Maintainability] ⚠ typescript ${ts.version ?? 'unknown'} exposes no transpileModule API; ` +
44
+ `TypeScript files are not scored. Supported range: ${SUPPORTED_TS_RANGE}.`,
45
+ );
46
+ }
47
+ return false;
48
+ }
49
+
26
50
  let _tsVersion = null;
27
51
 
28
52
  /**
@@ -100,7 +124,7 @@ function buildLineMapper(sourceMapText, code) {
100
124
  *
101
125
  * @param {string} filePath
102
126
  * @param {string} source
103
- * @param {{withLineMap?: boolean}} [opts]
127
+ * @param {{withLineMap?: boolean, typescript?: object}} [opts]
104
128
  * @returns {string|null|{code: string, mapLine: ((line: number) => number|null)|null}}
105
129
  */
106
130
  export function transpileIfNeeded(filePath, source, opts = {}) {
@@ -108,14 +132,15 @@ export function transpileIfNeeded(filePath, source, opts = {}) {
108
132
  if (!isTypeScriptPath(filePath)) {
109
133
  return withLineMap ? { code: source, mapLine: null } : source;
110
134
  }
111
- const ts = loadTypeScript();
135
+ const ts = opts?.typescript ?? loadTypeScript();
112
136
  if (!ts) {
113
137
  Logger.warn(
114
138
  `[Maintainability] ⚠ typescript package not resolvable; cannot score ${filePath}. ` +
115
- "Install with 'npm install --save-dev typescript' (peer dep, >=5.0.0).",
139
+ `Install with 'npm install --save-dev typescript' (peer dep, ${SUPPORTED_TS_RANGE}).`,
116
140
  );
117
141
  return null;
118
142
  }
143
+ if (!isUsableCompiler(ts)) return null;
119
144
  try {
120
145
  const result = ts.transpileModule(source, {
121
146
  compilerOptions: {