mandrel 2.8.0 → 2.10.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/.markdownlint.json +4 -0
- package/.agents/agents/acceptance-critic.md +30 -5
- package/.agents/agents/auditor.md +36 -19
- package/.agents/agents/plan-critic.md +31 -5
- package/.agents/agents/story-worker.md +91 -100
- package/.agents/docs/configuration.md +39 -1
- package/.agents/docs/execution-reference.md +13 -0
- package/.agents/docs/workflows.md +1 -1
- package/.agents/instructions.md +131 -265
- package/.agents/rules/git-conventions.md +47 -83
- package/.agents/rules/orchestration-error-handling.md +28 -0
- package/.agents/schemas/agentrc.schema.json +44 -1
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/acceptance-eval.js +1 -1
- package/.agents/scripts/apply-quality-bootstrap.js +1 -1
- package/.agents/scripts/audit-to-stories.js +51 -0
- package/.agents/scripts/check-test-temp-hygiene.js +438 -0
- package/.agents/scripts/deliver-recover.js +23 -6
- package/.agents/scripts/lib/audit-suite/index.js +5 -0
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +179 -0
- package/.agents/scripts/lib/audit-suite/selector.js +1 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +120 -55
- package/.agents/scripts/lib/config/temp-paths.js +121 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +30 -0
- package/.agents/scripts/lib/config-settings-schema.js +32 -0
- package/.agents/scripts/lib/findings/semantic-issue-search.js +43 -5
- package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
- package/.agents/scripts/lib/observability/terse-result.js +114 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +318 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +137 -10
- package/.agents/scripts/lib/orchestration/merge-block-class.js +36 -15
- package/.agents/scripts/lib/orchestration/merge-poll.js +213 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +60 -0
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +182 -9
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +29 -2
- package/.agents/scripts/lib/orchestration/plan-metrics.js +31 -82
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +102 -2
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +215 -14
- package/.agents/scripts/lib/orchestration/resolve-stories.js +7 -0
- package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +221 -8
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +55 -14
- package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +9 -3
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +89 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +73 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -1
- package/.agents/scripts/lib/orchestration/task-body-validator.js +13 -40
- package/.agents/scripts/lib/story-body/body-format-lints.js +215 -0
- package/.agents/scripts/lib/story-body/story-body.js +18 -2
- package/.agents/scripts/lib/templates/decomposer-prompts.js +29 -6
- package/.agents/scripts/lib/test-env.js +65 -0
- package/.agents/scripts/plan-context.js +66 -9
- package/.agents/scripts/plan-critics.js +115 -3
- package/.agents/scripts/plan-persist.js +11 -1
- package/.agents/scripts/plan-run-epilogue.js +1 -1
- package/.agents/scripts/providers/github/issues.js +54 -7
- package/.agents/scripts/providers/github/search-budget.js +124 -0
- package/.agents/scripts/providers/github/search-query.js +71 -0
- package/.agents/scripts/single-story-confirm-merge.js +79 -10
- package/.agents/scripts/single-story-init.js +19 -3
- package/.agents/scripts/stories-wave-tick.js +1 -1
- package/.agents/scripts/sync-branch-from-base.js +9 -3
- package/.agents/workflows/deliver.md +86 -230
- package/.agents/workflows/helpers/deliver-reference.md +167 -0
- package/.agents/workflows/helpers/deliver-story-reference.md +203 -0
- package/.agents/workflows/helpers/deliver-story.md +114 -422
- package/.agents/workflows/helpers/plan-reference.md +211 -0
- package/.agents/workflows/plan.md +107 -279
- package/docs/CHANGELOG.md +47 -0
- package/package.json +1 -1
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Test-temp hygiene guard (Story #4696).
|
|
5
|
+
*
|
|
6
|
+
* The friction / lifecycle / trace NDJSON streams under `temp/` are the
|
|
7
|
+
* substrate every retro, rollup, and loop-health consumer reads. When the
|
|
8
|
+
* test suite appends fixture records to the *real* `temp/` tree (the #4555
|
|
9
|
+
* defect class, re-surfaced for the signal writers), those consumers read
|
|
10
|
+
* noise: at the time this guard shipped, 99% of friction records were
|
|
11
|
+
* test-fixture pollution.
|
|
12
|
+
*
|
|
13
|
+
* The writer-layer fix (`lib/config/temp-paths.js` scratch seam +
|
|
14
|
+
* `lib/test-env.js` bootstrap) redirects stray test writes into an absolute
|
|
15
|
+
* per-process scratch dir. This script is the regression guard that keeps
|
|
16
|
+
* the fix honest, plus a local cleanup mode for the accumulated noise:
|
|
17
|
+
*
|
|
18
|
+
* --snapshot Record a fingerprint (size + sha256) of every stream
|
|
19
|
+
* file under `temp/` to the snapshot baseline. Run this
|
|
20
|
+
* before the suite.
|
|
21
|
+
* --assert Re-scan and fail if any stream file was added or grew
|
|
22
|
+
* relative to the snapshot. Run this after the suite. A
|
|
23
|
+
* missing snapshot is a hard failure ("snapshot missing
|
|
24
|
+
* — guard cannot attest"), never a silent re-baseline:
|
|
25
|
+
* the baseline lives *outside* the protected `temp/`
|
|
26
|
+
* tree (Story #4711), so a test wiping `temp/` can no
|
|
27
|
+
* longer destroy the baseline and fail the guard open.
|
|
28
|
+
* --baseline <path> Explicit snapshot-baseline path (CI sets this to a
|
|
29
|
+
* runner-temp path). Defaults to an OS scratch location
|
|
30
|
+
* keyed by the resolved repo root. Refused when it
|
|
31
|
+
* resolves inside the protected `temp/` tree.
|
|
32
|
+
* --clean List stream directories whose Epic/Story id matches a
|
|
33
|
+
* known fixture id (report-only; nothing is deleted).
|
|
34
|
+
* --clean --yes Delete those directories.
|
|
35
|
+
* --ids 1,2,3 Override the fixture-id list for --clean.
|
|
36
|
+
* --root <dir> Operate against <dir> instead of the repo root
|
|
37
|
+
* (its `temp/` subtree). Used by tests.
|
|
38
|
+
*
|
|
39
|
+
* Exit codes: 1 on an --assert failure (a new / grown stream file, or a
|
|
40
|
+
* missing snapshot baseline); 0 otherwise.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { createHash } from 'node:crypto';
|
|
44
|
+
import {
|
|
45
|
+
existsSync,
|
|
46
|
+
mkdirSync,
|
|
47
|
+
readdirSync,
|
|
48
|
+
readFileSync,
|
|
49
|
+
rmSync,
|
|
50
|
+
writeFileSync,
|
|
51
|
+
} from 'node:fs';
|
|
52
|
+
import os from 'node:os';
|
|
53
|
+
import path from 'node:path';
|
|
54
|
+
import { fileURLToPath } from 'node:url';
|
|
55
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
56
|
+
|
|
57
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
58
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Known fixture Epic/Story ids observed polluting the real `temp/` tree
|
|
62
|
+
* (Story #4696). `--clean` targets only these by default so real scratch
|
|
63
|
+
* (a genuine `run-<id>` from a live delivery) is never swept.
|
|
64
|
+
*/
|
|
65
|
+
export const KNOWN_FIXTURE_STORY_IDS = Object.freeze([
|
|
66
|
+
4428, 10, 4242, 5, 42, 100, 555, 2839, 4257, 4258, 4259,
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve the `temp/` directory for a given repo root.
|
|
71
|
+
* @param {string} repoRoot
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
export function tempDirFor(repoRoot) {
|
|
75
|
+
return path.join(repoRoot, 'temp');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Default snapshot-baseline path for a repo root — deliberately *outside*
|
|
80
|
+
* the protected `temp/` tree (Story #4711). The pre-#4711 baseline lived at
|
|
81
|
+
* `temp/.test-temp-hygiene-snapshot.json`, inside the very tree the guard
|
|
82
|
+
* protects: a test (or cleanup) that wiped `temp/` destroyed the baseline
|
|
83
|
+
* and the post-test `--assert` silently re-baselined the pollution. The
|
|
84
|
+
* default now lives under the OS scratch dir, keyed by the resolved repo
|
|
85
|
+
* root so parallel checkouts / worktrees never collide.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} repoRoot
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
export function defaultBaselinePath(repoRoot) {
|
|
91
|
+
const key = createHash('sha256')
|
|
92
|
+
.update(path.resolve(repoRoot))
|
|
93
|
+
.digest('hex')
|
|
94
|
+
.slice(0, 16);
|
|
95
|
+
return path.join(
|
|
96
|
+
os.tmpdir(),
|
|
97
|
+
'mandrel-test-temp-hygiene',
|
|
98
|
+
`snapshot-${key}.json`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Refuse a baseline path that resolves inside the protected `temp/` tree —
|
|
104
|
+
* storing the attestation inside the tree it attests recreates the
|
|
105
|
+
* fail-open gap this Story closes.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} repoRoot
|
|
108
|
+
* @param {string} baselinePath
|
|
109
|
+
* @returns {string} the resolved baseline path
|
|
110
|
+
*/
|
|
111
|
+
function checkedBaselinePath(repoRoot, baselinePath) {
|
|
112
|
+
const resolved = path.resolve(baselinePath);
|
|
113
|
+
const tempDir = path.resolve(tempDirFor(repoRoot));
|
|
114
|
+
if (resolved === tempDir || resolved.startsWith(tempDir + path.sep)) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`[test-temp-hygiene] baseline path must live outside the protected temp/ tree; got ${resolved}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return resolved;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Is `rel` (a path relative to `temp/`, POSIX-normalised) a telemetry stream
|
|
124
|
+
* file we guard? Stream files are `*.ndjson` living under a `run-<id>/`
|
|
125
|
+
* subtree or the `standalone/stories/` subtree.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} rel
|
|
128
|
+
* @returns {boolean}
|
|
129
|
+
*/
|
|
130
|
+
export function isStreamFile(rel) {
|
|
131
|
+
if (!rel.endsWith('.ndjson')) return false;
|
|
132
|
+
const first = rel.split('/')[0];
|
|
133
|
+
if (/^run-\d+$/.test(first)) return true;
|
|
134
|
+
return rel.startsWith('standalone/stories/');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Recursively list every stream file under `tempDir`, returned as
|
|
139
|
+
* POSIX-normalised paths relative to `tempDir`, sorted for determinism.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} tempDir
|
|
142
|
+
* @returns {string[]}
|
|
143
|
+
*/
|
|
144
|
+
export function listStreamFiles(tempDir) {
|
|
145
|
+
if (!existsSync(tempDir)) return [];
|
|
146
|
+
/** @type {string[]} */
|
|
147
|
+
const out = [];
|
|
148
|
+
const walk = (absDir, relDir) => {
|
|
149
|
+
for (const ent of readdirSync(absDir, { withFileTypes: true })) {
|
|
150
|
+
const abs = path.join(absDir, ent.name);
|
|
151
|
+
const rel = relDir ? `${relDir}/${ent.name}` : ent.name;
|
|
152
|
+
if (ent.isDirectory()) {
|
|
153
|
+
walk(abs, rel);
|
|
154
|
+
} else if (ent.isFile() && isStreamFile(rel)) {
|
|
155
|
+
out.push(rel);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
walk(tempDir, '');
|
|
160
|
+
return out.sort();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Fingerprint a single file by byte length + sha256 of its contents.
|
|
165
|
+
* @param {string} absPath
|
|
166
|
+
* @returns {{ size: number, sha256: string }}
|
|
167
|
+
*/
|
|
168
|
+
export function fingerprintFile(absPath) {
|
|
169
|
+
const buf = readFileSync(absPath);
|
|
170
|
+
return {
|
|
171
|
+
size: buf.length,
|
|
172
|
+
sha256: createHash('sha256').update(buf).digest('hex'),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Build a `{ [relPath]: fingerprint }` manifest of every stream file under
|
|
178
|
+
* `tempDir`.
|
|
179
|
+
* @param {string} tempDir
|
|
180
|
+
* @returns {Record<string, { size: number, sha256: string }>}
|
|
181
|
+
*/
|
|
182
|
+
export function buildManifest(tempDir) {
|
|
183
|
+
/** @type {Record<string, { size: number, sha256: string }>} */
|
|
184
|
+
const manifest = {};
|
|
185
|
+
for (const rel of listStreamFiles(tempDir)) {
|
|
186
|
+
manifest[rel] = fingerprintFile(path.join(tempDir, rel));
|
|
187
|
+
}
|
|
188
|
+
return manifest;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Persist the current manifest to the baseline path (default: the external
|
|
193
|
+
* `defaultBaselinePath` — never inside `temp/`).
|
|
194
|
+
* @param {string} repoRoot
|
|
195
|
+
* @param {string} [baselinePath]
|
|
196
|
+
* @returns {{ snapshotPath: string, count: number }}
|
|
197
|
+
*/
|
|
198
|
+
export function writeSnapshot(repoRoot, baselinePath) {
|
|
199
|
+
const snapshotPath = checkedBaselinePath(
|
|
200
|
+
repoRoot,
|
|
201
|
+
baselinePath ?? defaultBaselinePath(repoRoot),
|
|
202
|
+
);
|
|
203
|
+
const manifest = buildManifest(tempDirFor(repoRoot));
|
|
204
|
+
mkdirSync(path.dirname(snapshotPath), { recursive: true });
|
|
205
|
+
writeFileSync(snapshotPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
206
|
+
return { snapshotPath, count: Object.keys(manifest).length };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Load the persisted manifest, or `null` when no snapshot exists.
|
|
211
|
+
* @param {string} repoRoot
|
|
212
|
+
* @param {string} [baselinePath]
|
|
213
|
+
* @returns {Record<string, { size: number, sha256: string }> | null}
|
|
214
|
+
*/
|
|
215
|
+
export function readSnapshot(repoRoot, baselinePath) {
|
|
216
|
+
const snapshotPath = checkedBaselinePath(
|
|
217
|
+
repoRoot,
|
|
218
|
+
baselinePath ?? defaultBaselinePath(repoRoot),
|
|
219
|
+
);
|
|
220
|
+
if (!existsSync(snapshotPath)) return null;
|
|
221
|
+
return JSON.parse(readFileSync(snapshotPath, 'utf8'));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Diff the current stream tree against a snapshot manifest.
|
|
226
|
+
*
|
|
227
|
+
* `added` — stream files present now but absent from the snapshot.
|
|
228
|
+
* `changed` — stream files whose size or sha256 differs from the snapshot
|
|
229
|
+
* (i.e. a test grew or rewrote an existing stream).
|
|
230
|
+
*
|
|
231
|
+
* A file that shrank or vanished is not a pollution signal, so it is ignored.
|
|
232
|
+
*
|
|
233
|
+
* @param {string} tempDir
|
|
234
|
+
* @param {Record<string, { size: number, sha256: string }>} snapshot
|
|
235
|
+
* @returns {{ added: string[], changed: string[] }}
|
|
236
|
+
*/
|
|
237
|
+
export function diffAgainstSnapshot(tempDir, snapshot) {
|
|
238
|
+
const current = buildManifest(tempDir);
|
|
239
|
+
const added = [];
|
|
240
|
+
const changed = [];
|
|
241
|
+
for (const [rel, fp] of Object.entries(current)) {
|
|
242
|
+
const prior = snapshot[rel];
|
|
243
|
+
if (!prior) {
|
|
244
|
+
added.push(rel);
|
|
245
|
+
} else if (prior.size !== fp.size || prior.sha256 !== fp.sha256) {
|
|
246
|
+
changed.push(rel);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return { added: added.sort(), changed: changed.sort() };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Extract the fixture-matching id from a top-level `temp/` entry name, or
|
|
254
|
+
* `null` when the entry is not an id-keyed stream directory.
|
|
255
|
+
*
|
|
256
|
+
* `run-<id>` maps to `<id>`; the `standalone/stories/story-<id>` shape is
|
|
257
|
+
* handled by the caller (it recurses one level deeper).
|
|
258
|
+
*
|
|
259
|
+
* @param {string} name
|
|
260
|
+
* @returns {number|null}
|
|
261
|
+
*/
|
|
262
|
+
function runDirId(name) {
|
|
263
|
+
const m = /^run-(\d+)$/.exec(name);
|
|
264
|
+
return m ? Number(m[1]) : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Locate stream directories whose Epic/Story id is in `ids`.
|
|
269
|
+
*
|
|
270
|
+
* Two shapes are swept: `temp/run-<id>/` (Epic-scoped) and
|
|
271
|
+
* `temp/standalone/stories/story-<id>/` (standalone Story-scoped).
|
|
272
|
+
*
|
|
273
|
+
* @param {string} tempDir
|
|
274
|
+
* @param {ReadonlySet<number>} ids
|
|
275
|
+
* @returns {{ id: number, kind: 'run' | 'standalone-story', rel: string }[]}
|
|
276
|
+
*/
|
|
277
|
+
export function findFixtureDirs(tempDir, ids) {
|
|
278
|
+
if (!existsSync(tempDir)) return [];
|
|
279
|
+
const found = [];
|
|
280
|
+
for (const ent of readdirSync(tempDir, { withFileTypes: true })) {
|
|
281
|
+
if (!ent.isDirectory()) continue;
|
|
282
|
+
const id = runDirId(ent.name);
|
|
283
|
+
if (id !== null && ids.has(id)) {
|
|
284
|
+
found.push({ id, kind: 'run', rel: ent.name });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const storiesDir = path.join(tempDir, 'standalone', 'stories');
|
|
288
|
+
if (existsSync(storiesDir)) {
|
|
289
|
+
for (const ent of readdirSync(storiesDir, { withFileTypes: true })) {
|
|
290
|
+
if (!ent.isDirectory()) continue;
|
|
291
|
+
const m = /^story-(\d+)$/.exec(ent.name);
|
|
292
|
+
const id = m ? Number(m[1]) : null;
|
|
293
|
+
if (id !== null && ids.has(id)) {
|
|
294
|
+
found.push({
|
|
295
|
+
id,
|
|
296
|
+
kind: 'standalone-story',
|
|
297
|
+
rel: `standalone/stories/${ent.name}`,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return found.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Report (and optionally delete) fixture-id stream directories.
|
|
307
|
+
*
|
|
308
|
+
* @param {object} opts
|
|
309
|
+
* @param {string} opts.repoRoot
|
|
310
|
+
* @param {Iterable<number>} [opts.ids]
|
|
311
|
+
* @param {boolean} [opts.apply=false] delete when true; report-only otherwise.
|
|
312
|
+
* @returns {{ candidates: { id: number, kind: string, rel: string }[], removed: string[] }}
|
|
313
|
+
*/
|
|
314
|
+
export function cleanFixtureDirs({
|
|
315
|
+
repoRoot,
|
|
316
|
+
ids = KNOWN_FIXTURE_STORY_IDS,
|
|
317
|
+
apply = false,
|
|
318
|
+
}) {
|
|
319
|
+
const tempDir = tempDirFor(repoRoot);
|
|
320
|
+
const candidates = findFixtureDirs(tempDir, new Set(ids));
|
|
321
|
+
const removed = [];
|
|
322
|
+
if (apply) {
|
|
323
|
+
for (const c of candidates) {
|
|
324
|
+
rmSync(path.join(tempDir, c.rel), { recursive: true, force: true });
|
|
325
|
+
removed.push(c.rel);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return { candidates, removed };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Parse the CLI argv into a normalised options object.
|
|
333
|
+
* @param {string[]} argv
|
|
334
|
+
* @returns {{ mode: 'snapshot'|'assert'|'clean', apply: boolean, ids: number[]|null, repoRoot: string, baseline: string|null }}
|
|
335
|
+
*/
|
|
336
|
+
export function parseArgv(argv) {
|
|
337
|
+
let mode = 'assert';
|
|
338
|
+
let apply = false;
|
|
339
|
+
let ids = null;
|
|
340
|
+
let repoRoot = REPO_ROOT;
|
|
341
|
+
let baseline = null;
|
|
342
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
343
|
+
const arg = argv[i];
|
|
344
|
+
if (arg === '--snapshot') mode = 'snapshot';
|
|
345
|
+
else if (arg === '--assert') mode = 'assert';
|
|
346
|
+
else if (arg === '--clean') mode = 'clean';
|
|
347
|
+
else if (arg === '--yes') apply = true;
|
|
348
|
+
else if (arg === '--ids') {
|
|
349
|
+
i += 1;
|
|
350
|
+
ids = String(argv[i] ?? '')
|
|
351
|
+
.split(',')
|
|
352
|
+
.map((s) => Number(s.trim()))
|
|
353
|
+
.filter((n) => Number.isInteger(n) && n > 0);
|
|
354
|
+
} else if (arg === '--root') {
|
|
355
|
+
i += 1;
|
|
356
|
+
repoRoot = path.resolve(String(argv[i] ?? '.'));
|
|
357
|
+
} else if (arg === '--baseline') {
|
|
358
|
+
i += 1;
|
|
359
|
+
baseline = path.resolve(String(argv[i] ?? '.'));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return { mode, apply, ids, repoRoot, baseline };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Execute the guard for a parsed options object. Returns the process exit
|
|
367
|
+
* code (0 = clean / snapshot recorded; 1 = pollution detected under
|
|
368
|
+
* --assert). Printing is done via the injectable `log`.
|
|
369
|
+
*
|
|
370
|
+
* @param {ReturnType<typeof parseArgv>} opts
|
|
371
|
+
* @param {(line: string) => void} [log]
|
|
372
|
+
* @returns {number}
|
|
373
|
+
*/
|
|
374
|
+
export function runHygiene(opts, log = (l) => process.stdout.write(`${l}\n`)) {
|
|
375
|
+
const { mode, apply, ids, repoRoot, baseline = null } = opts;
|
|
376
|
+
if (mode === 'snapshot') {
|
|
377
|
+
const { snapshotPath, count } = writeSnapshot(repoRoot, baseline);
|
|
378
|
+
log(
|
|
379
|
+
`[test-temp-hygiene] snapshot recorded (${count} stream file(s)) → ${snapshotPath}`,
|
|
380
|
+
);
|
|
381
|
+
return 0;
|
|
382
|
+
}
|
|
383
|
+
if (mode === 'clean') {
|
|
384
|
+
const { candidates } = cleanFixtureDirs({
|
|
385
|
+
repoRoot,
|
|
386
|
+
ids: ids ?? KNOWN_FIXTURE_STORY_IDS,
|
|
387
|
+
apply,
|
|
388
|
+
});
|
|
389
|
+
if (candidates.length === 0) {
|
|
390
|
+
log('[test-temp-hygiene] no fixture-id stream directories found.');
|
|
391
|
+
return 0;
|
|
392
|
+
}
|
|
393
|
+
log(
|
|
394
|
+
`[test-temp-hygiene] ${candidates.length} fixture-id stream director(y/ies)${
|
|
395
|
+
apply ? ' removed' : ' (report-only; pass --yes to delete)'
|
|
396
|
+
}:`,
|
|
397
|
+
);
|
|
398
|
+
for (const c of candidates) {
|
|
399
|
+
log(` - ${c.rel} (id ${c.id}, ${c.kind})`);
|
|
400
|
+
}
|
|
401
|
+
return 0;
|
|
402
|
+
}
|
|
403
|
+
// mode === 'assert'
|
|
404
|
+
const snapshotPath = baseline ?? defaultBaselinePath(repoRoot);
|
|
405
|
+
const snapshot = readSnapshot(repoRoot, snapshotPath);
|
|
406
|
+
if (snapshot === null) {
|
|
407
|
+
log(
|
|
408
|
+
`[test-temp-hygiene] FAIL — snapshot missing (${path.resolve(snapshotPath)}); guard cannot attest. Run --snapshot before the suite (never re-baseline at assert time).`,
|
|
409
|
+
);
|
|
410
|
+
return 1;
|
|
411
|
+
}
|
|
412
|
+
const { added, changed } = diffAgainstSnapshot(
|
|
413
|
+
tempDirFor(repoRoot),
|
|
414
|
+
snapshot,
|
|
415
|
+
);
|
|
416
|
+
if (added.length === 0 && changed.length === 0) {
|
|
417
|
+
log('[test-temp-hygiene] OK — no new or grown stream files under temp/.');
|
|
418
|
+
return 0;
|
|
419
|
+
}
|
|
420
|
+
log(
|
|
421
|
+
'[test-temp-hygiene] FAIL — the test suite polluted the real temp/ tree:',
|
|
422
|
+
);
|
|
423
|
+
for (const rel of added) log(` + new ${rel}`);
|
|
424
|
+
for (const rel of changed) log(` ~ grew ${rel}`);
|
|
425
|
+
log(
|
|
426
|
+
'[test-temp-hygiene] a writer bypassed the scratch seam. Inject an absolute per-test tempRoot; do not weaken this guard.',
|
|
427
|
+
);
|
|
428
|
+
return 1;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
runAsCli(
|
|
432
|
+
import.meta.url,
|
|
433
|
+
async () => {
|
|
434
|
+
const code = runHygiene(parseArgv(process.argv.slice(2)));
|
|
435
|
+
return code;
|
|
436
|
+
},
|
|
437
|
+
{ source: 'check-test-temp-hygiene', propagateExitCode: true },
|
|
438
|
+
);
|
|
@@ -40,17 +40,23 @@ import {
|
|
|
40
40
|
import { PROJECT_ROOT } from './lib/project-root.js';
|
|
41
41
|
import { createProvider } from './lib/provider-factory.js';
|
|
42
42
|
|
|
43
|
-
const HELP = `Usage: node .agents/scripts/deliver-recover.js --story <id> [--cwd <main-repo>] [--json]
|
|
43
|
+
const HELP = `Usage: node .agents/scripts/deliver-recover.js --story <id> [--cwd <main-repo>] [--json] [--no-reprobe]
|
|
44
44
|
|
|
45
45
|
Probes a Story's live delivery state — labels, lease, branch, worktree, PR
|
|
46
46
|
state and checks — and prints the single next command that resumes it, with
|
|
47
47
|
the evidence it was derived from. Read-only: mutates nothing.
|
|
48
48
|
|
|
49
|
+
Mid-flight shapes (executing-*/closing-*) get a stability re-probe after a
|
|
50
|
+
short settle window: matching shapes return the fresher verdict; diverging
|
|
51
|
+
shapes report \`in-transition\` (a live delivery process is mutating the
|
|
52
|
+
state) instead of a confidently wrong command.
|
|
53
|
+
|
|
49
54
|
Flags:
|
|
50
|
-
--story
|
|
51
|
-
--cwd
|
|
52
|
-
--json
|
|
53
|
-
--
|
|
55
|
+
--story GitHub issue number of the Story (required).
|
|
56
|
+
--cwd Main-repo checkout to probe (default: project root).
|
|
57
|
+
--json Emit the full recovery envelope as JSON instead of prose.
|
|
58
|
+
--no-reprobe Skip the stability re-probe (single-probe verdict).
|
|
59
|
+
--help Show this message.
|
|
54
60
|
`;
|
|
55
61
|
|
|
56
62
|
export function parseArgv(argv) {
|
|
@@ -60,6 +66,7 @@ export function parseArgv(argv) {
|
|
|
60
66
|
story: { type: 'string' },
|
|
61
67
|
cwd: { type: 'string' },
|
|
62
68
|
json: { type: 'boolean', default: false },
|
|
69
|
+
'no-reprobe': { type: 'boolean', default: false },
|
|
63
70
|
help: { type: 'boolean', default: false },
|
|
64
71
|
},
|
|
65
72
|
strict: false,
|
|
@@ -68,6 +75,7 @@ export function parseArgv(argv) {
|
|
|
68
75
|
storyId: Number.parseInt(String(values.story ?? ''), 10),
|
|
69
76
|
cwd: values.cwd ?? null,
|
|
70
77
|
json: Boolean(values.json),
|
|
78
|
+
reprobe: !values['no-reprobe'],
|
|
71
79
|
help: Boolean(values.help),
|
|
72
80
|
};
|
|
73
81
|
}
|
|
@@ -79,15 +87,22 @@ export async function runDeliverRecover({
|
|
|
79
87
|
storyId: storyIdParam,
|
|
80
88
|
cwd: cwdParam,
|
|
81
89
|
json: jsonParam,
|
|
90
|
+
reprobe: reprobeParam,
|
|
82
91
|
argv,
|
|
83
92
|
injectedProvider,
|
|
84
93
|
injectedConfig,
|
|
85
94
|
injectedGh,
|
|
86
95
|
injectedGitSpawn,
|
|
96
|
+
injectedSleepFn,
|
|
87
97
|
} = {}) {
|
|
88
98
|
const parsed =
|
|
89
99
|
storyIdParam !== undefined
|
|
90
|
-
? {
|
|
100
|
+
? {
|
|
101
|
+
storyId: storyIdParam,
|
|
102
|
+
cwd: cwdParam ?? null,
|
|
103
|
+
json: !!jsonParam,
|
|
104
|
+
reprobe: reprobeParam ?? true,
|
|
105
|
+
}
|
|
91
106
|
: parseArgv(argv ?? process.argv.slice(2));
|
|
92
107
|
|
|
93
108
|
if (parsed.help) {
|
|
@@ -111,6 +126,8 @@ export async function runDeliverRecover({
|
|
|
111
126
|
config,
|
|
112
127
|
gh: injectedGh,
|
|
113
128
|
gitSpawnFn: injectedGitSpawn,
|
|
129
|
+
reprobe: parsed.reprobe,
|
|
130
|
+
...(injectedSleepFn ? { sleepFn: injectedSleepFn } : {}),
|
|
114
131
|
});
|
|
115
132
|
|
|
116
133
|
Logger.info(
|
|
@@ -20,6 +20,11 @@ export {
|
|
|
20
20
|
readAuditRules,
|
|
21
21
|
} from './checklist-threading.js';
|
|
22
22
|
export { buildDispatchChecklist } from './dispatch-checklist.js';
|
|
23
|
+
export {
|
|
24
|
+
countChangedLines,
|
|
25
|
+
evaluateLensDiffFloor,
|
|
26
|
+
resolveLensDiffFloor,
|
|
27
|
+
} from './lens-diff-floor.js';
|
|
23
28
|
export { runAuditSuite } from './runner.js';
|
|
24
29
|
export {
|
|
25
30
|
GLOBAL_LENS_ALLOWLIST,
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/audit-suite/lens-diff-floor.js — the close-scope lens diff-floor
|
|
3
|
+
* (Story #4699).
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* The Story-scope local-lens pass materialized ~28 KB of lens prompts per
|
|
8
|
+
* close while sampled closes yielded almost zero findings — and none above
|
|
9
|
+
* Suggestion severity. The cheapest correct response is a deterministic
|
|
10
|
+
* floor: a *small* diff that touches *no* sensitive path earns no lens
|
|
11
|
+
* materialization at all. The floor is measured in **changed lines**
|
|
12
|
+
* (additions + deletions across the diff), configured via
|
|
13
|
+
* `delivery.review.lensDiffFloor` (default {@link DEFAULT_LENS_DIFF_FLOOR};
|
|
14
|
+
* `0` disables the skip entirely).
|
|
15
|
+
*
|
|
16
|
+
* ## Fail-open contract
|
|
17
|
+
*
|
|
18
|
+
* The skip only fires on positive evidence that the diff is small and
|
|
19
|
+
* non-sensitive. Every degraded input — an unknown line count, an
|
|
20
|
+
* unreadable manifest, a disabled floor — resolves to "do not skip", so a
|
|
21
|
+
* measurement failure can never buy a change less review. Sensitive-path
|
|
22
|
+
* hits are matched with the same `sensitivePaths` classes the review-depth
|
|
23
|
+
* derivation reads (`selectSensitivePathClasses`), so the floor and the
|
|
24
|
+
* depth tiering can never disagree about what "sensitive" means.
|
|
25
|
+
*
|
|
26
|
+
* All exports are total: no throws, no I/O beyond the injected git spawn in
|
|
27
|
+
* {@link countChangedLines}.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { gitSpawn } from '../git-utils.js';
|
|
31
|
+
import { selectSensitivePathClasses } from './selector.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Default changed-line floor below which a non-sensitive diff skips lens
|
|
35
|
+
* materialization. Chosen from the measured distribution (Story #4699): the
|
|
36
|
+
* sampled zero-yield closes clustered well under this size.
|
|
37
|
+
*/
|
|
38
|
+
export const DEFAULT_LENS_DIFF_FLOOR = 40;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the configured lens diff-floor from a resolved config wrapper.
|
|
42
|
+
* `delivery.review.lensDiffFloor` must be a non-negative integer; anything
|
|
43
|
+
* else (absent block, wrong type, negative, non-finite) falls back to
|
|
44
|
+
* {@link DEFAULT_LENS_DIFF_FLOOR}. `0` is a valid, deliberate "floor off".
|
|
45
|
+
*
|
|
46
|
+
* @param {object|null|undefined} config Resolved `.agentrc.json` wrapper.
|
|
47
|
+
* @returns {number}
|
|
48
|
+
*/
|
|
49
|
+
export function resolveLensDiffFloor(config) {
|
|
50
|
+
const value = config?.delivery?.review?.lensDiffFloor;
|
|
51
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
return DEFAULT_LENS_DIFF_FLOOR;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Count the changed lines (additions + deletions) in the
|
|
59
|
+
* `baseRef...headRef` diff via `git diff --numstat`.
|
|
60
|
+
*
|
|
61
|
+
* Total — never throws. Returns `null` (the neutral "count unknown" signal
|
|
62
|
+
* the floor fails open on) for any git failure or unparseable output, and
|
|
63
|
+
* `0` for a genuinely empty diff. Binary rows (`-\t-\tpath`) contribute 0
|
|
64
|
+
* text lines but do not poison the parse.
|
|
65
|
+
*
|
|
66
|
+
* @param {{
|
|
67
|
+
* baseRef: string,
|
|
68
|
+
* headRef: string,
|
|
69
|
+
* cwd?: string,
|
|
70
|
+
* gitSpawnFn?: typeof gitSpawn,
|
|
71
|
+
* }} args
|
|
72
|
+
* @returns {number|null}
|
|
73
|
+
*/
|
|
74
|
+
export function countChangedLines({
|
|
75
|
+
baseRef,
|
|
76
|
+
headRef,
|
|
77
|
+
cwd = process.cwd(),
|
|
78
|
+
gitSpawnFn = gitSpawn,
|
|
79
|
+
} = {}) {
|
|
80
|
+
if (typeof baseRef !== 'string' || baseRef.length === 0) return null;
|
|
81
|
+
if (typeof headRef !== 'string' || headRef.length === 0) return null;
|
|
82
|
+
try {
|
|
83
|
+
const result = gitSpawnFn(
|
|
84
|
+
cwd,
|
|
85
|
+
'diff',
|
|
86
|
+
'--numstat',
|
|
87
|
+
`${baseRef}...${headRef}`,
|
|
88
|
+
);
|
|
89
|
+
if (!result || result.status !== 0 || typeof result.stdout !== 'string') {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
let total = 0;
|
|
93
|
+
for (const line of result.stdout.split('\n')) {
|
|
94
|
+
const trimmedEnd = line.replace(/\s+$/, '');
|
|
95
|
+
if (trimmedEnd.length === 0) continue;
|
|
96
|
+
const match = /^(\d+|-)\t(\d+|-)\t/.exec(trimmedEnd);
|
|
97
|
+
if (!match) return null; // Unexpected format — the count is not trustworthy.
|
|
98
|
+
if (match[1] !== '-') total += Number(match[1]);
|
|
99
|
+
if (match[2] !== '-') total += Number(match[2]);
|
|
100
|
+
}
|
|
101
|
+
return total;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Decide whether the close-scope lens pass should skip materialization for
|
|
109
|
+
* this diff. Skips **only** when all of the following hold:
|
|
110
|
+
*
|
|
111
|
+
* 1. The floor is enabled (`floor > 0`).
|
|
112
|
+
* 2. The changed-line count is *known* and strictly below the floor.
|
|
113
|
+
* 3. The changed-file set intersects **zero** registered sensitive-path
|
|
114
|
+
* classes (`audit-rules.json#sensitivePaths`).
|
|
115
|
+
*
|
|
116
|
+
* Every other state — floor disabled, unknown count, at-or-above floor, a
|
|
117
|
+
* sensitive-path hit — resolves to `skip: false` with a named reason, so
|
|
118
|
+
* the verdict is auditable in the findings-yield ledger.
|
|
119
|
+
*
|
|
120
|
+
* Pure and total: never throws (a throwing sensitive-path matcher degrades
|
|
121
|
+
* to "not skippable").
|
|
122
|
+
*
|
|
123
|
+
* @param {{
|
|
124
|
+
* changedFiles?: string[]|null,
|
|
125
|
+
* changedLineCount?: number|null,
|
|
126
|
+
* floor?: number,
|
|
127
|
+
* injectedRules?: object,
|
|
128
|
+
* selectSensitivePathClassesFn?: typeof selectSensitivePathClasses,
|
|
129
|
+
* }} [input]
|
|
130
|
+
* @returns {{
|
|
131
|
+
* skip: boolean,
|
|
132
|
+
* reason: 'floor-disabled'|'line-count-unknown'|'at-or-above-floor'|'sensitive-classes-unknown'|'sensitive-path-hit'|'below-floor',
|
|
133
|
+
* floor: number,
|
|
134
|
+
* changedLineCount: number|null,
|
|
135
|
+
* sensitiveClasses: string[],
|
|
136
|
+
* }}
|
|
137
|
+
*/
|
|
138
|
+
export function evaluateLensDiffFloor(input = {}) {
|
|
139
|
+
const floorRaw = input.floor;
|
|
140
|
+
const floor =
|
|
141
|
+
typeof floorRaw === 'number' && Number.isInteger(floorRaw) && floorRaw >= 0
|
|
142
|
+
? floorRaw
|
|
143
|
+
: DEFAULT_LENS_DIFF_FLOOR;
|
|
144
|
+
const count =
|
|
145
|
+
typeof input.changedLineCount === 'number' &&
|
|
146
|
+
Number.isFinite(input.changedLineCount) &&
|
|
147
|
+
input.changedLineCount >= 0
|
|
148
|
+
? Math.floor(input.changedLineCount)
|
|
149
|
+
: null;
|
|
150
|
+
const verdict = (skip, reason, sensitiveClasses = []) => ({
|
|
151
|
+
skip,
|
|
152
|
+
reason,
|
|
153
|
+
floor,
|
|
154
|
+
changedLineCount: count,
|
|
155
|
+
sensitiveClasses,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (floor <= 0) return verdict(false, 'floor-disabled');
|
|
159
|
+
if (count === null) return verdict(false, 'line-count-unknown');
|
|
160
|
+
if (count >= floor) return verdict(false, 'at-or-above-floor');
|
|
161
|
+
|
|
162
|
+
const select =
|
|
163
|
+
input.selectSensitivePathClassesFn ?? selectSensitivePathClasses;
|
|
164
|
+
let classes;
|
|
165
|
+
try {
|
|
166
|
+
classes = select({
|
|
167
|
+
changedFiles: Array.isArray(input.changedFiles) ? input.changedFiles : [],
|
|
168
|
+
injectedRules: input.injectedRules,
|
|
169
|
+
});
|
|
170
|
+
} catch {
|
|
171
|
+
// An unreadable manifest is not evidence the change is safe to skip.
|
|
172
|
+
return verdict(false, 'sensitive-classes-unknown');
|
|
173
|
+
}
|
|
174
|
+
const matched = Array.isArray(classes) ? classes : [];
|
|
175
|
+
if (matched.length > 0) {
|
|
176
|
+
return verdict(false, 'sensitive-path-hit', matched);
|
|
177
|
+
}
|
|
178
|
+
return verdict(true, 'below-floor');
|
|
179
|
+
}
|