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.
- package/.agents/agents/acceptance-critic.md +7 -7
- package/.agents/agents/auditor.md +17 -18
- package/.agents/agents/plan-critic.md +5 -5
- package/.agents/agents/story-worker.md +5 -5
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/execution-reference.md +27 -5
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +12 -13
- package/.agents/rules/ci-remediation.md +3 -3
- package/.agents/rules/gherkin-standards.md +3 -2
- package/.agents/rules/git-conventions-reference.md +17 -8
- package/.agents/rules/git-conventions.md +10 -8
- package/.agents/rules/testing-standards.md +8 -7
- package/.agents/runtime-deps.json +1 -1
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/scripts/boot-sweep.js +97 -9
- package/.agents/scripts/bootstrap.js +94 -89
- package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
- package/.agents/scripts/clean-temp.js +54 -0
- package/.agents/scripts/clean-worktrees.js +593 -0
- package/.agents/scripts/drain-pending-cleanup.js +5 -4
- package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +78 -78
- package/.agents/scripts/lib/clean-temp.js +440 -0
- package/.agents/scripts/lib/cli/standard-args.js +60 -76
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/config/gates/shared.js +3 -3
- package/.agents/scripts/lib/config-settings-schema-delivery.js +11 -2
- package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
- package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
- package/.agents/scripts/lib/observability/signal-validator.js +17 -5
- package/.agents/scripts/lib/observability/source-classifier.js +3 -1
- package/.agents/scripts/lib/orchestration/code-review.js +22 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
- package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
- package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
- package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +23 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +349 -263
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +327 -314
- package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
- package/.agents/scripts/lib/signals/detectors/common.js +63 -51
- package/.agents/scripts/lib/single-story-sweep.js +2 -2
- package/.agents/scripts/lib/temp-removal.js +110 -0
- package/.agents/scripts/lib/temp-retention.js +122 -73
- package/.agents/scripts/lib/transpile.js +28 -3
- package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
- package/.agents/scripts/single-story-close.js +10 -2
- package/.agents/scripts/single-story-confirm-merge.js +267 -238
- package/.agents/scripts/single-story-init.js +120 -17
- package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
- package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
- package/.agents/workflows/audit-architecture.md +5 -4
- package/.agents/workflows/audit-documentation.md +5 -5
- package/.agents/workflows/audit-performance.md +10 -10
- 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 +11 -10
- package/.agents/workflows/helpers/audit-lens-core.md +30 -57
- package/.agents/workflows/helpers/deliver-digest.md +2 -2
- package/.agents/workflows/helpers/deliver-reference.md +3 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
- package/.agents/workflows/helpers/deliver-story.md +6 -1
- package/.agents/workflows/helpers/parallel-tooling.md +16 -18
- package/.agents/workflows/mandrel-deliver.md +1 -1
- package/.agents/workflows/mandrel-plan.md +6 -5
- package/docs/CHANGELOG.md +39 -0
- package/lib/cli/guarded-sync.js +87 -0
- package/lib/cli/sync-agents.js +9 -92
- package/lib/cli/sync-commands.js +9 -101
- 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
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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 {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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 (
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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: {
|