mandrel 2.19.0 → 2.20.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/docs/agentrc-reference.json +0 -21
- package/.agents/docs/configuration.md +11 -14
- package/.agents/docs/execution-reference.md +8 -5
- package/.agents/schemas/agentrc.schema.json +0 -31
- package/.agents/scripts/check-test-temp-hygiene.js +153 -14
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +3 -4
- package/.agents/scripts/lib/bdd-scenario-scanner.js +3 -2
- package/.agents/scripts/lib/config/explain.js +0 -8
- package/.agents/scripts/lib/config/temp-paths.js +12 -1
- package/.agents/scripts/lib/config-settings-schema.js +8 -24
- package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -2
- package/.agents/scripts/lib/orchestration/plan-context.js +13 -14
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +12 -66
- package/.agents/scripts/lib/test-env.js +15 -3
- package/.agents/scripts/lib/test-temp.js +311 -0
- package/.agents/workflows/helpers/plan-reference.md +19 -4
- package/.agents/workflows/plan.md +7 -6
- package/docs/CHANGELOG.md +8 -0
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +113 -0
- package/package.json +1 -1
- package/.agents/scripts/lib/codebase-snapshot.js +0 -513
- package/.agents/scripts/lib/orchestration/planning/spec-authoring-grounding.js +0 -147
- package/.agents/scripts/lib/orchestration/spec-freshness.js +0 -129
|
@@ -14,15 +14,12 @@ import {
|
|
|
14
14
|
verifyBddRunnerPendingTag,
|
|
15
15
|
} from '../../bdd-runner-detect.js';
|
|
16
16
|
import { scanBddScenarios } from '../../bdd-scenario-scanner.js';
|
|
17
|
-
import { buildCodebaseSnapshot } from '../../codebase-snapshot.js';
|
|
18
17
|
import { getPaths, PROJECT_ROOT } from '../../config-resolver.js';
|
|
19
18
|
import { scanMemoryFreshness } from '../../feedback-loop/memory-freshness.js';
|
|
20
19
|
import { fetchPriorFeedback } from '../../feedback-loop/prior-feedback-fetcher.js';
|
|
21
20
|
import { Logger } from '../../Logger.js';
|
|
22
21
|
import { hasTicketSection } from '../../ticket-body-sections.js';
|
|
23
22
|
import { ensureDocsDigest } from '../docs-digest.js';
|
|
24
|
-
import { collectReferences, hasNewFileCue } from '../spec-freshness.js';
|
|
25
|
-
import { buildAuthoringGrounding } from './spec-authoring-grounding.js';
|
|
26
23
|
|
|
27
24
|
/**
|
|
28
25
|
* Resolve the per-project memory directory used by the memory-freshness
|
|
@@ -179,68 +176,18 @@ export async function buildAuthoringContext(
|
|
|
179
176
|
repo: githubCfg?.repo,
|
|
180
177
|
});
|
|
181
178
|
|
|
182
|
-
// Story #
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
exclude: settings?.planning?.codebaseSnapshot?.exclude,
|
|
195
|
-
recentCommitWindow:
|
|
196
|
-
settings?.planning?.codebaseSnapshot?.recentCommitWindow,
|
|
197
|
-
});
|
|
198
|
-
// Story #4139 (F10) — ground the spec author in the files it will cite.
|
|
199
|
-
// Two signals are attached to the snapshot envelope so the author (which
|
|
200
|
-
// consumes the JSON, not stderr) cannot miss them:
|
|
201
|
-
// 1. `grounding.truncation` — the structured, in-envelope form of the
|
|
202
|
-
// Story #3959 dropped-file warning. The skinny-tier cap used to drop
|
|
203
|
-
// the majority of matched files with only a stderr `Logger.warn` and
|
|
204
|
-
// a bare `truncated: true` flag; the author never learned the
|
|
205
|
-
// snapshot was partial (a real run dropped "377 of 627 files").
|
|
206
|
-
// 2. `grounding.citedButAbsent` — path-shaped references in the Epic
|
|
207
|
-
// body (the prose the author grounds *from*) that are absent from
|
|
208
|
-
// the snapshot's file set and not phrased as net-new, so cited-but-
|
|
209
|
-
// absent surfaces are visible *during* authoring rather than only
|
|
210
|
-
// after the post-author freshness gate (Story #2635).
|
|
211
|
-
// The grounding consults only the snapshot's file set and the Epic body —
|
|
212
|
-
// no new filesystem or git probes — so the context stays bounded for cost.
|
|
213
|
-
if (codebaseSnapshot) {
|
|
214
|
-
const grounding = buildAuthoringGrounding({
|
|
215
|
-
snapshot: codebaseSnapshot,
|
|
216
|
-
prose: epic.body ?? '',
|
|
217
|
-
collectReferences,
|
|
218
|
-
hasNewFileCue,
|
|
219
|
-
});
|
|
220
|
-
codebaseSnapshot.grounding = grounding;
|
|
221
|
-
if (grounding.truncation) {
|
|
222
|
-
const { dropped, matched, tier } = grounding.truncation;
|
|
223
|
-
Logger.warn(
|
|
224
|
-
`[plan-context] codebase snapshot truncated: ${dropped} of ` +
|
|
225
|
-
`${matched} matched file(s) dropped from the ${tier}-tier view. ` +
|
|
226
|
-
`The /plan authoring context is partial. To restore full ` +
|
|
227
|
-
`grounding, ` +
|
|
228
|
-
`set planning.codebaseSnapshot.tier: "medium" and/or narrow ` +
|
|
229
|
-
`planning.codebaseSnapshot.include in .agentrc.json.`,
|
|
230
|
-
);
|
|
231
|
-
}
|
|
232
|
-
if (grounding.citedButAbsent.length > 0) {
|
|
233
|
-
Logger.warn(
|
|
234
|
-
`[plan-context] ${grounding.citedButAbsent.length} path(s) cited ` +
|
|
235
|
-
`in the authored Spec are absent from the codebase snapshot: ` +
|
|
236
|
-
`${grounding.citedButAbsent.join(', ')}. /plan will flag these ` +
|
|
237
|
-
`as drift unless they are net-new.`,
|
|
238
|
-
);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
} catch (err) {
|
|
242
|
-
Logger.warn(`[plan-context] codebase snapshot skipped: ${err.message}`);
|
|
243
|
-
}
|
|
179
|
+
// Story #4811 — the codebase snapshot (#2634), its authoring grounding
|
|
180
|
+
// (#4139 F10) and the spec-freshness helpers behind it are retired. The
|
|
181
|
+
// pre-computed structural view grounded nothing it promised: the default
|
|
182
|
+
// include globs missed the standard monorepo layout outright, its remedies
|
|
183
|
+
// pointed at knobs that re-filtered the same set, and its cited-but-absent
|
|
184
|
+
// signal inverted into noise whenever the snapshot was the thing that was
|
|
185
|
+
// wrong. Grounding now rests on the two mechanisms that read the real tree:
|
|
186
|
+
// the authoring model's own targeted retrieval (the digest-first precedent
|
|
187
|
+
// of Story #4433) and the Phase 8 `validateStoryFileAssumptions` gate, which
|
|
188
|
+
// probes every authored `{path, assumption}` against the working tree as a
|
|
189
|
+
// hard error. Nothing pre-computed replaces it — a stale inventory is the
|
|
190
|
+
// failure mode, not the fix.
|
|
244
191
|
|
|
245
192
|
// Story #4542 — planning authors no risk artifact at all. Review depth and
|
|
246
193
|
// the acceptance-critic mode are derived from the diff at close time
|
|
@@ -262,7 +209,6 @@ export async function buildAuthoringContext(
|
|
|
262
209
|
},
|
|
263
210
|
},
|
|
264
211
|
docsContext,
|
|
265
|
-
codebaseSnapshot,
|
|
266
212
|
bddRunner,
|
|
267
213
|
bddScenarios,
|
|
268
214
|
memoryFreshness,
|
|
@@ -3,6 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
|
|
5
5
|
import { TEST_TEMP_ROOT_ENV } from './config/temp-paths.js';
|
|
6
|
+
import { reapOnExit } from './test-temp.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Per-process memo for the created scratch dir, so repeated calls in one
|
|
@@ -32,16 +33,26 @@ export function _clearTestScratchTempRootCache() {
|
|
|
32
33
|
* `temp/` telemetry tree — the regression that let 99% of friction records
|
|
33
34
|
* be test-fixture pollution.
|
|
34
35
|
*
|
|
36
|
+
* Reaping (Story #4808): the minting process registers removal of the
|
|
37
|
+
* scratch dir at exit. This branch is creator-only by construction — a
|
|
38
|
+
* process that inherited an absolute root returns above without ever
|
|
39
|
+
* touching the memo, so a child can never delete its parent's scratch
|
|
40
|
+
* while the parent is still writing to it. Left unreaped, this seam was
|
|
41
|
+
* the single largest contributor to the OS-temp-root leak (870 surviving
|
|
42
|
+
* `mandrel-test-temp-` roots on one host), because it mints one per
|
|
43
|
+
* `run-tests.js` invocation.
|
|
44
|
+
*
|
|
35
45
|
* Directly unit-tested via the injectable `mkdtemp` seam in
|
|
36
46
|
* `tests/lib/test-env.test.js` (Story #4711).
|
|
37
47
|
*
|
|
38
48
|
* @param {NodeJS.ProcessEnv} [baseEnv=process.env]
|
|
39
|
-
* @param {{ mkdtemp?: typeof mkdtempSync }} [deps]
|
|
49
|
+
* @param {{ mkdtemp?: typeof mkdtempSync, onExit?: (fn: () => void) => void }} [deps]
|
|
50
|
+
* Injectable for tests.
|
|
40
51
|
* @returns {string} absolute scratch tempRoot
|
|
41
52
|
*/
|
|
42
53
|
export function ensureTestScratchTempRoot(
|
|
43
54
|
baseEnv = process.env,
|
|
44
|
-
{ mkdtemp = mkdtempSync } = {},
|
|
55
|
+
{ mkdtemp = mkdtempSync, onExit } = {},
|
|
45
56
|
) {
|
|
46
57
|
const existing = baseEnv?.[TEST_TEMP_ROOT_ENV];
|
|
47
58
|
if (
|
|
@@ -52,7 +63,8 @@ export function ensureTestScratchTempRoot(
|
|
|
52
63
|
return existing;
|
|
53
64
|
}
|
|
54
65
|
if (_createdScratchDir === null) {
|
|
55
|
-
_createdScratchDir = mkdtemp(path.join(os.tmpdir(), 'mandrel-test-temp-'));
|
|
66
|
+
_createdScratchDir = mkdtemp(path.join(os.tmpdir(), 'mandrel-test-temp-')); // test-temp-allow: children inherit this path, so it lives outside the suite root.
|
|
67
|
+
reapOnExit(_createdScratchDir, onExit ? { onExit } : {});
|
|
56
68
|
}
|
|
57
69
|
return _createdScratchDir;
|
|
58
70
|
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Managed test temp directories (Story #4808).
|
|
3
|
+
*
|
|
4
|
+
* The suite used to mint `os.tmpdir()` directories directly at ~71 call
|
|
5
|
+
* sites across 25 files that never reaped them, accumulating tens of
|
|
6
|
+
* thousands of entries per run into a temp root shared with self-hosted CI
|
|
7
|
+
* runners. The damaging axis is **entry count**, not bytes: a runner's
|
|
8
|
+
* job-started hook scanning an 841k-entry temp root burned 5m29s inside the
|
|
9
|
+
* job clock and timed jobs out.
|
|
10
|
+
*
|
|
11
|
+
* Per-call-site teardown had already failed 25 times, so this module makes
|
|
12
|
+
* teardown structural instead: a directory cannot be created without its
|
|
13
|
+
* reaping already registered.
|
|
14
|
+
*
|
|
15
|
+
* ## Why one suite root
|
|
16
|
+
*
|
|
17
|
+
* A guard over a *shared* `os.tmpdir()` cannot attribute an entry to this
|
|
18
|
+
* suite. A prefix allowlist rots the moment someone invents a new prefix,
|
|
19
|
+
* and a bare "no new entries" assertion false-positives on any unrelated
|
|
20
|
+
* process that happened to run concurrently. Nesting every managed
|
|
21
|
+
* directory inside a single per-process root
|
|
22
|
+
* (`mandrel-suite-<pid>-<random>`) makes attribution exact: the guard asks
|
|
23
|
+
* only whether a *suite root* survived, which is a question about this
|
|
24
|
+
* suite alone. The suite contributes exactly one shared-root entry per
|
|
25
|
+
* process, and reaps it.
|
|
26
|
+
*
|
|
27
|
+
* ## Why the root is never published to children
|
|
28
|
+
*
|
|
29
|
+
* Deliberately unlike `MANDREL_TEST_TEMP_ROOT` (the scratch seam in
|
|
30
|
+
* `test-env.js` / `config/temp-paths.js`, which children inherit): each
|
|
31
|
+
* process owns its own suite root, so "did I create it?" is always
|
|
32
|
+
* answerable locally and a child can never reap its parent's root
|
|
33
|
+
* mid-run.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import os from 'node:os';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import picomatch from 'picomatch';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Directory-name prefix identifying a per-process suite root. The guard
|
|
43
|
+
* matches on this, so it is the one string both sides must agree on.
|
|
44
|
+
*/
|
|
45
|
+
export const SUITE_ROOT_PREFIX = 'mandrel-suite-';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Reserved snapshot-manifest key under which the guard records the suite
|
|
49
|
+
* roots observed at `--snapshot` time. It cannot collide with a stream
|
|
50
|
+
* entry: those are always `*.ndjson` relative paths.
|
|
51
|
+
*/
|
|
52
|
+
export const SUITE_ROOTS_KEY = '#suiteRoots';
|
|
53
|
+
|
|
54
|
+
/** Per-process suite root, or `null` before the first `makeTempDir`. */
|
|
55
|
+
let _suiteRoot = null;
|
|
56
|
+
|
|
57
|
+
/** Guards against registering the exit reaper more than once. */
|
|
58
|
+
let _reaperRegistered = false;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Test-only: forget the per-process suite root without removing it, so a
|
|
62
|
+
* test can exercise the creation branch repeatedly in one process.
|
|
63
|
+
*/
|
|
64
|
+
export function _resetSuiteTempRootForTests() {
|
|
65
|
+
_suiteRoot = null;
|
|
66
|
+
_reaperRegistered = false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Test-only: report whether this process currently owns a suite root.
|
|
71
|
+
* @returns {string|null}
|
|
72
|
+
*/
|
|
73
|
+
export function _currentSuiteTempRoot() {
|
|
74
|
+
return _suiteRoot;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Remove this process's suite root and everything under it.
|
|
79
|
+
*
|
|
80
|
+
* A teardown failure is reported on stderr and swallowed: a suite that
|
|
81
|
+
* passed must not start failing because a directory could not be unlinked
|
|
82
|
+
* (a Windows file lock, a read-only mount). The leak is the lesser defect
|
|
83
|
+
* and the guard reports it separately.
|
|
84
|
+
*
|
|
85
|
+
* Only the process that minted the root can reach a non-null `_suiteRoot`,
|
|
86
|
+
* so this is creator-only by construction.
|
|
87
|
+
*
|
|
88
|
+
* @param {{ fsImpl?: typeof fs, warn?: (msg: string) => void }} [deps]
|
|
89
|
+
* @returns {string|null} the removed root, or `null` when there was none
|
|
90
|
+
*/
|
|
91
|
+
export function reapSuiteTempRoot({
|
|
92
|
+
fsImpl = fs,
|
|
93
|
+
warn = (msg) => process.stderr.write(`${msg}\n`),
|
|
94
|
+
} = {}) {
|
|
95
|
+
const root = _suiteRoot;
|
|
96
|
+
if (root === null) return null;
|
|
97
|
+
_suiteRoot = null;
|
|
98
|
+
try {
|
|
99
|
+
fsImpl.rmSync(root, { recursive: true, force: true });
|
|
100
|
+
} catch (err) {
|
|
101
|
+
warn(`[test-temp] failed to reap suite temp root ${root}: ${err.message}`);
|
|
102
|
+
}
|
|
103
|
+
return root;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve (creating on first use) this process's suite root.
|
|
108
|
+
*
|
|
109
|
+
* The reaper is registered on `exit` at creation time, so a directory
|
|
110
|
+
* cannot exist without its teardown already armed — including when the
|
|
111
|
+
* suite fails, since a failing `node --test` run still exits normally.
|
|
112
|
+
*
|
|
113
|
+
* @param {{ fsImpl?: typeof fs, tmpdir?: () => string, onExit?: (fn: () => void) => void }} [deps]
|
|
114
|
+
* @returns {string} absolute path to the suite root
|
|
115
|
+
*/
|
|
116
|
+
export function suiteTempRoot({
|
|
117
|
+
fsImpl = fs,
|
|
118
|
+
tmpdir = os.tmpdir,
|
|
119
|
+
onExit = (fn) => process.once('exit', fn),
|
|
120
|
+
} = {}) {
|
|
121
|
+
if (_suiteRoot !== null) return _suiteRoot;
|
|
122
|
+
_suiteRoot = fsImpl.mkdtempSync(
|
|
123
|
+
path.join(tmpdir(), `${SUITE_ROOT_PREFIX}${process.pid}-`),
|
|
124
|
+
);
|
|
125
|
+
if (!_reaperRegistered) {
|
|
126
|
+
_reaperRegistered = true;
|
|
127
|
+
onExit(() => reapSuiteTempRoot({ fsImpl }));
|
|
128
|
+
}
|
|
129
|
+
return _suiteRoot;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create a fresh temp directory for a test, nested inside this process's
|
|
134
|
+
* suite root and reaped with it.
|
|
135
|
+
*
|
|
136
|
+
* Drop-in for `mkdtempSync(path.join(os.tmpdir(), prefix))` — the returned
|
|
137
|
+
* path is absolute and unique, so call sites change only where the
|
|
138
|
+
* directory comes from, never how it is used.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} [prefix='t-'] label kept for readability in a stack trace
|
|
141
|
+
* @param {{ fsImpl?: typeof fs, tmpdir?: () => string, onExit?: (fn: () => void) => void }} [deps]
|
|
142
|
+
* @returns {string} absolute path to the new directory
|
|
143
|
+
*/
|
|
144
|
+
export function makeTempDir(prefix = 't-', deps = {}) {
|
|
145
|
+
const root = suiteTempRoot(deps);
|
|
146
|
+
const fsImpl = deps.fsImpl ?? fs;
|
|
147
|
+
return fsImpl.mkdtempSync(path.join(root, prefix));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Register removal of one specific scratch directory at process exit.
|
|
152
|
+
*
|
|
153
|
+
* For the two scratch seams (`test-env.js`, `config/temp-paths.js`) that
|
|
154
|
+
* mint a root *outside* the suite tree because children inherit its path
|
|
155
|
+
* through `MANDREL_TEST_TEMP_ROOT`. Call this only from the branch that
|
|
156
|
+
* actually minted the directory — a process that inherited the path must
|
|
157
|
+
* never reap it, or it deletes its parent's scratch mid-run.
|
|
158
|
+
*
|
|
159
|
+
* Teardown failures are swallowed for the same reason as
|
|
160
|
+
* {@link reapSuiteTempRoot}: a leak must not turn a passing suite red.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} dirPath absolute path this process minted
|
|
163
|
+
* @param {{ fsImpl?: typeof fs, onExit?: (fn: () => void) => void, warn?: (msg: string) => void }} [deps]
|
|
164
|
+
* @returns {void}
|
|
165
|
+
*/
|
|
166
|
+
export function reapOnExit(
|
|
167
|
+
dirPath,
|
|
168
|
+
{
|
|
169
|
+
fsImpl = fs,
|
|
170
|
+
onExit = (fn) => process.once('exit', fn),
|
|
171
|
+
warn = (msg) => process.stderr.write(`${msg}\n`),
|
|
172
|
+
} = {},
|
|
173
|
+
) {
|
|
174
|
+
onExit(() => {
|
|
175
|
+
try {
|
|
176
|
+
fsImpl.rmSync(dirPath, { recursive: true, force: true });
|
|
177
|
+
} catch (err) {
|
|
178
|
+
warn(`[test-temp] failed to reap scratch dir ${dirPath}: ${err.message}`);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* List the suite roots currently present in `tmpDir`, sorted.
|
|
185
|
+
*
|
|
186
|
+
* Names only (not absolute paths) so the guard can diff them against a
|
|
187
|
+
* recorded snapshot without embedding the temp root's absolute location.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} tmpDir
|
|
190
|
+
* @param {{ fsImpl?: typeof fs }} [deps]
|
|
191
|
+
* @returns {string[]}
|
|
192
|
+
*/
|
|
193
|
+
export function listSuiteTempRoots(tmpDir, { fsImpl = fs } = {}) {
|
|
194
|
+
if (!fsImpl.existsSync(tmpDir)) return [];
|
|
195
|
+
return fsImpl
|
|
196
|
+
.readdirSync(tmpDir, { withFileTypes: true })
|
|
197
|
+
.filter(
|
|
198
|
+
(ent) => ent.isDirectory() && ent.name.startsWith(SUITE_ROOT_PREFIX),
|
|
199
|
+
)
|
|
200
|
+
.map((ent) => ent.name)
|
|
201
|
+
.sort();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Suite roots that appeared since the snapshot and are still on disk —
|
|
206
|
+
* i.e. roots this suite run created and failed to reap.
|
|
207
|
+
*
|
|
208
|
+
* Diffing against the snapshot rather than asserting an empty set is what
|
|
209
|
+
* keeps a concurrently-running suite (another checkout, another worktree)
|
|
210
|
+
* from failing this one.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} tmpDir
|
|
213
|
+
* @param {string[]} snapshotRoots
|
|
214
|
+
* @param {{ fsImpl?: typeof fs }} [deps]
|
|
215
|
+
* @returns {string[]}
|
|
216
|
+
*/
|
|
217
|
+
export function survivingSuiteTempRoots(tmpDir, snapshotRoots, deps = {}) {
|
|
218
|
+
const known = new Set(snapshotRoots ?? []);
|
|
219
|
+
return listSuiteTempRoots(tmpDir, deps).filter((name) => !known.has(name));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Matches a `mkdtemp` / `mkdtempSync` call whose argument reaches
|
|
224
|
+
* `tmpdir()`. The lookahead spans the call's argument text rather than
|
|
225
|
+
* trying to balance parentheses, so it catches every shape in use:
|
|
226
|
+
* `mkdtempSync(path.join(os.tmpdir(), 'x-'))`, `mkdtempSync(join(tmpdir(),
|
|
227
|
+
* 'x-'))`, and `fs.mkdtempSync(...)`.
|
|
228
|
+
*/
|
|
229
|
+
const RAW_TMPDIR_MKDTEMP =
|
|
230
|
+
/mkdtemp(?:Sync)?\s*\([^;\n]{0,200}?tmpdir\s*\(\s*\)/;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Opt-out marker for a line that must call `mkdtemp` against the real OS
|
|
234
|
+
* temp root — the guard's own tests, and the scratch seams that
|
|
235
|
+
* deliberately mint a root outside the suite tree.
|
|
236
|
+
*/
|
|
237
|
+
const LINT_ESCAPE = 'test-temp-allow';
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Flag test files that mint OS temp directories directly instead of going
|
|
241
|
+
* through {@link makeTempDir}.
|
|
242
|
+
*
|
|
243
|
+
* This is the half of the backstop that catches the *next* leaking file at
|
|
244
|
+
* authoring time rather than after it has already leaked, so it is scoped
|
|
245
|
+
* to explicitly-passed globs: `check-test-temp-hygiene.js` ships in the
|
|
246
|
+
* materialized `.agents/` payload, and a consumer's tests are none of this
|
|
247
|
+
* rule's business.
|
|
248
|
+
*
|
|
249
|
+
* @param {string} repoRoot
|
|
250
|
+
* @param {string[]} globs repo-relative picomatch patterns
|
|
251
|
+
* @param {{ fsImpl?: typeof fs }} [deps]
|
|
252
|
+
* @returns {{ file: string, line: number, text: string }[]}
|
|
253
|
+
*/
|
|
254
|
+
export function findRawTmpdirMkdtemp(repoRoot, globs, { fsImpl = fs } = {}) {
|
|
255
|
+
const patterns = (globs ?? []).filter(Boolean);
|
|
256
|
+
// Negation is handled here rather than handed to picomatch: passing a
|
|
257
|
+
// mixed `['a/**', '!a/b']` array makes the `!` entry read as its own
|
|
258
|
+
// positive "everything but a/b" matcher, which silently *widens* the
|
|
259
|
+
// scan instead of narrowing it.
|
|
260
|
+
const include = patterns.filter((p) => !p.startsWith('!'));
|
|
261
|
+
const exclude = patterns
|
|
262
|
+
.filter((p) => p.startsWith('!'))
|
|
263
|
+
.map((p) => p.slice(1));
|
|
264
|
+
if (include.length === 0) return [];
|
|
265
|
+
const isIncluded = picomatch(include);
|
|
266
|
+
const isExcluded = exclude.length > 0 ? picomatch(exclude) : () => false;
|
|
267
|
+
const findings = [];
|
|
268
|
+
for (const rel of walkFiles(repoRoot, fsImpl)) {
|
|
269
|
+
if (!isIncluded(rel) || isExcluded(rel)) continue;
|
|
270
|
+
const lines = fsImpl
|
|
271
|
+
.readFileSync(path.join(repoRoot, rel), 'utf8')
|
|
272
|
+
.split('\n');
|
|
273
|
+
lines.forEach((text, i) => {
|
|
274
|
+
if (!RAW_TMPDIR_MKDTEMP.test(text)) return;
|
|
275
|
+
if (text.includes(LINT_ESCAPE)) return;
|
|
276
|
+
if (i > 0 && lines[i - 1].includes(LINT_ESCAPE)) return;
|
|
277
|
+
findings.push({ file: rel, line: i + 1, text: text.trim() });
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return findings;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Walk `root` for JavaScript sources, returning POSIX-normalised relative
|
|
285
|
+
* paths. Unlike the `test-isolate` walker this descends dot-prefixed
|
|
286
|
+
* directories (the `.agents/` payload carries `__tests__` trees) while
|
|
287
|
+
* still skipping the trees that are never source: `node_modules`,
|
|
288
|
+
* `.worktrees`, and `.git`.
|
|
289
|
+
*
|
|
290
|
+
* @param {string} root
|
|
291
|
+
* @param {typeof fs} fsImpl
|
|
292
|
+
* @returns {string[]}
|
|
293
|
+
*/
|
|
294
|
+
function walkFiles(root, fsImpl) {
|
|
295
|
+
const out = [];
|
|
296
|
+
const skip = new Set(['node_modules', '.worktrees', '.git', 'temp']);
|
|
297
|
+
const walk = (dir, prefix) => {
|
|
298
|
+
if (!fsImpl.existsSync(dir)) return;
|
|
299
|
+
for (const ent of fsImpl.readdirSync(dir, { withFileTypes: true })) {
|
|
300
|
+
if (skip.has(ent.name)) continue;
|
|
301
|
+
const rel = prefix ? `${prefix}/${ent.name}` : ent.name;
|
|
302
|
+
if (ent.isDirectory()) {
|
|
303
|
+
walk(path.join(dir, ent.name), rel);
|
|
304
|
+
} else if (/\.(?:js|mjs|cjs)$/.test(ent.name)) {
|
|
305
|
+
out.push(rel);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
walk(root, '');
|
|
310
|
+
return out.sort();
|
|
311
|
+
}
|
|
@@ -42,8 +42,8 @@ On a confirmed `deliverLightSuggestion`, `/plan` routes into
|
|
|
42
42
|
[`deliver-light.md`](deliver-light.md) **without ending the session**. Two
|
|
43
43
|
things make that safe, and both are worth understanding before changing it:
|
|
44
44
|
|
|
45
|
-
1. **The handoff carries the envelope, not the seed.** Gate #1 already holds
|
|
46
|
-
|
|
45
|
+
1. **The handoff carries the envelope, not the seed.** Gate #1 already holds
|
|
46
|
+
the interrogated `complexitySignals`; fill the light gate's `--creates`
|
|
47
47
|
/ `--refactors` / `--acceptance` / `--reason` from those. Re-deriving from
|
|
48
48
|
raw seed text throws away the better signal and can disagree with the
|
|
49
49
|
suggestion that routed you.
|
|
@@ -135,8 +135,8 @@ ceilings on `STORY_SHAPE_CEILINGS` in
|
|
|
135
135
|
## Correct-by-construction authoring template
|
|
136
136
|
|
|
137
137
|
`plan-context.js --out` writes `stories.template.json` as a
|
|
138
|
-
**correct-by-construction** skeleton, built from the same repo
|
|
139
|
-
`complexitySignals`
|
|
138
|
+
**correct-by-construction** skeleton, built from the same repo probe the
|
|
139
|
+
`complexitySignals` ran:
|
|
140
140
|
|
|
141
141
|
- **`verify[]` placeholders already end with a valid `(tier)` tag.** Keep
|
|
142
142
|
every filled entry's trailing tag one of `(unit)` / `(contract)` /
|
|
@@ -161,6 +161,21 @@ A faithfully-filled skeleton — placeholders replaced, pre-resolved entries
|
|
|
161
161
|
kept, tags valid — passes the persist ticket validators with no
|
|
162
162
|
round-trip.
|
|
163
163
|
|
|
164
|
+
### Authored entry shape
|
|
165
|
+
|
|
166
|
+
Each `stories.json` entry: `slug` (`^[a-z0-9][a-z0-9-]*$`), `type: "story"`,
|
|
167
|
+
`title`, `body` (`goal`, optional `spec`, `changes[{path, assumption}]` —
|
|
168
|
+
`creates|refactors-existing|deletes`, `non_goals`, `reason_to_exist`),
|
|
169
|
+
top-level `acceptance[]`, `verify[]` (`… (unit|contract|e2e|validate)`), and
|
|
170
|
+
`depends_on[]` (N>1 only).
|
|
171
|
+
|
|
172
|
+
Nothing in that shape inventories the repo for the author. `changes[]` arrives
|
|
173
|
+
pre-resolved against the working tree, and Phase 8's
|
|
174
|
+
`validateStoryFileAssumptions` re-probes every `{path, assumption}` at persist
|
|
175
|
+
as a hard error — so the grounding contract is the author's own targeted reads
|
|
176
|
+
plus that gate. There is no pre-computed codebase snapshot to fall back on,
|
|
177
|
+
and no manifest-derived replacement to build.
|
|
178
|
+
|
|
164
179
|
## Tickets mode — authoring `supersedes[]`
|
|
165
180
|
|
|
166
181
|
In `--tickets` mode each Story carries a top-level `supersedes` array claiming
|
|
@@ -64,7 +64,7 @@ node .agents/scripts/plan-context.js --seed "<seed>" \
|
|
|
64
64
|
and derives source ids from its `sourceTickets[]`; the CLI also writes
|
|
65
65
|
**`stories.template.json`**, the skeleton step 2 starts from.
|
|
66
66
|
|
|
67
|
-
The envelope carries docs context, the
|
|
67
|
+
The envelope carries docs context, the story-author
|
|
68
68
|
prompt, `sourceTickets[]`, `duplicates[]` (open **Stories**, never Epics), and
|
|
69
69
|
advisory `complexitySignals` (**no routing authority**). A trivial scope earns
|
|
70
70
|
`--route-downgrade-reason "<why>"` at persist — shape-validated, failing closed
|
|
@@ -93,11 +93,12 @@ valid. `body` is a markdown string **or** a structured object; persist parses
|
|
|
93
93
|
either, serializes the canonical markdown, and syncs top-level `acceptance[]` /
|
|
94
94
|
`verify[]` into it — never dual-author those lists.
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
(
|
|
96
|
+
**Grounding = your reads + Phase 8.** Nothing inventories the repo for you:
|
|
97
|
+
read each file you cite, then persist's file-assumption gate hard-errors on
|
|
98
|
+
every `{path, assumption}` that misses the real tree.
|
|
99
|
+
|
|
100
|
+
Entry fields (the `stories.template.json` shape):
|
|
101
|
+
[reference](helpers/plan-reference.md).
|
|
101
102
|
|
|
102
103
|
Artifacts under `temp/plan-<slug>/`: `stories.json` (**length 1 by default**;
|
|
103
104
|
over-budget Specs fail closed — split or tighten, never under `docs/`);
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [2.20.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.19.0...mandrel-v2.20.0) (2026-07-27)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
* retire the planner codebase snapshot; Phase 8 file-assumption validation is the grounding gate ([#4811](https://github.com/dsj1984/mandrel/issues/4811)) ([#4812](https://github.com/dsj1984/mandrel/issues/4812)) ([48621a8](https://github.com/dsj1984/mandrel/commit/48621a8fdcbfa5076a1c94ebe9098bb36736db09))
|
|
11
|
+
* route every suite temp dir under one reaped per-process root and guard the OS temp root against regression ([#4808](https://github.com/dsj1984/mandrel/issues/4808)) ([#4809](https://github.com/dsj1984/mandrel/issues/4809)) ([2e81c05](https://github.com/dsj1984/mandrel/commit/2e81c05f3113378ff2ed96b0f1802c406f4e61bf))
|
|
12
|
+
|
|
5
13
|
## [2.19.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.18.0...mandrel-v2.19.0) (2026-07-27)
|
|
6
14
|
|
|
7
15
|
|
package/lib/migrations/index.js
CHANGED
|
@@ -56,6 +56,7 @@ import { retireMiDropKnobs } from './steps/2.1.0-retire-mi-drop-knobs.js';
|
|
|
56
56
|
import { retireVerifyConcurrencyCap } from './steps/2.1.0-retire-verify-concurrency-cap.js';
|
|
57
57
|
import { retireEpicAcTags } from './steps/2.2.0-retire-epic-ac-tags.js';
|
|
58
58
|
import { retireMaxSeedWords } from './steps/2.11.0-retire-max-seed-words.js';
|
|
59
|
+
import { retireCodebaseSnapshot } from './steps/2.20.0-retire-codebase-snapshot.js';
|
|
59
60
|
|
|
60
61
|
/**
|
|
61
62
|
* Ordered registry of migration steps. MUST stay sorted ascending by
|
|
@@ -73,6 +74,7 @@ export const migrations = [
|
|
|
73
74
|
retireVerifyConcurrencyCap,
|
|
74
75
|
retireEpicAcTags,
|
|
75
76
|
retireMaxSeedWords,
|
|
77
|
+
retireCodebaseSnapshot,
|
|
76
78
|
];
|
|
77
79
|
|
|
78
80
|
/**
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// lib/migrations/steps/2.20.0-retire-codebase-snapshot.js
|
|
2
|
+
/**
|
|
3
|
+
* Story #4811 — strip the retired `planning.codebaseSnapshot` block from a
|
|
4
|
+
* consumer's `.agentrc.json` / `.agentrc.local.json`.
|
|
5
|
+
*
|
|
6
|
+
* #4811 hard-cutover-removed the `/plan` codebase snapshot: the pre-computed
|
|
7
|
+
* structural view grounded nothing it promised (its default include globs
|
|
8
|
+
* missed the standard monorepo layout, and its knobs only re-filtered the same
|
|
9
|
+
* matched set), and spec authoring is grounded instead by the author's own
|
|
10
|
+
* targeted repo retrieval plus the Phase 8 `validateStoryFileAssumptions`
|
|
11
|
+
* gate. The whole block was dropped from the runtime AJV schema and the
|
|
12
|
+
* published mirror. `planning` carries `additionalProperties: false`, so a
|
|
13
|
+
* consumer whose config still sets `codebaseSnapshot` hits a hard validation
|
|
14
|
+
* failure on upgrade, not a warning. This step strips the key before that
|
|
15
|
+
* check runs — the same contract-cutover pattern as
|
|
16
|
+
* `2.11.0-retire-max-seed-words.js`, widened to the local override file
|
|
17
|
+
* because a snapshot narrowed for one checkout is exactly the kind of knob an
|
|
18
|
+
* operator pins in `.agentrc.local.json`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import nodeFs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Both config files the resolver deep-merges. A key surviving in either one
|
|
26
|
+
* fails validation, so both are stripped.
|
|
27
|
+
*/
|
|
28
|
+
const AGENTRC_FILENAMES = ['.agentrc.json', '.agentrc.local.json'];
|
|
29
|
+
|
|
30
|
+
const RETIRED_KEY = 'codebaseSnapshot';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {unknown} ctx
|
|
34
|
+
* @param {string} filename
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
function resolveAgentrcPath(ctx, filename) {
|
|
38
|
+
const projectRoot = ctx?.projectRoot ?? process.cwd();
|
|
39
|
+
return path.join(projectRoot, filename);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {unknown} ctx
|
|
44
|
+
* @param {string} filename
|
|
45
|
+
* @param {typeof nodeFs} fsImpl
|
|
46
|
+
* @returns {object | null}
|
|
47
|
+
*/
|
|
48
|
+
function readAgentrcConfig(ctx, filename, fsImpl) {
|
|
49
|
+
try {
|
|
50
|
+
const raw = fsImpl.readFileSync(resolveAgentrcPath(ctx, filename), 'utf8');
|
|
51
|
+
return JSON.parse(raw);
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {object | null} config
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
function hasRetiredKey(config) {
|
|
62
|
+
const planning = config?.planning;
|
|
63
|
+
return Boolean(planning) && Object.hasOwn(planning, RETIRED_KEY);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Strip the key and prune the containers it emptied, so a config that carried
|
|
68
|
+
* nothing but the snapshot does not keep an orphan `planning: {}`.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} config
|
|
71
|
+
* @returns {void}
|
|
72
|
+
*/
|
|
73
|
+
function stripRetiredKey(config) {
|
|
74
|
+
delete config.planning[RETIRED_KEY];
|
|
75
|
+
if (Object.keys(config.planning).length === 0) {
|
|
76
|
+
delete config.planning;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const retireCodebaseSnapshot = {
|
|
81
|
+
version: '2.20.0',
|
|
82
|
+
description:
|
|
83
|
+
'strip retired planning.codebaseSnapshot from .agentrc.json / ' +
|
|
84
|
+
'.agentrc.local.json (spec authoring is grounded by targeted retrieval ' +
|
|
85
|
+
'plus the Phase 8 file-assumption gate — Story #4811)',
|
|
86
|
+
/**
|
|
87
|
+
* @param {{ projectRoot?: string, fs?: typeof nodeFs }} [ctx]
|
|
88
|
+
* @returns {boolean}
|
|
89
|
+
*/
|
|
90
|
+
detect(ctx) {
|
|
91
|
+
const fsImpl = ctx?.fs ?? nodeFs;
|
|
92
|
+
return AGENTRC_FILENAMES.some((filename) =>
|
|
93
|
+
hasRetiredKey(readAgentrcConfig(ctx, filename, fsImpl)),
|
|
94
|
+
);
|
|
95
|
+
},
|
|
96
|
+
/**
|
|
97
|
+
* @param {{ projectRoot?: string, fs?: typeof nodeFs }} [ctx]
|
|
98
|
+
* @returns {void}
|
|
99
|
+
*/
|
|
100
|
+
apply(ctx) {
|
|
101
|
+
const fsImpl = ctx?.fs ?? nodeFs;
|
|
102
|
+
for (const filename of AGENTRC_FILENAMES) {
|
|
103
|
+
const config = readAgentrcConfig(ctx, filename, fsImpl);
|
|
104
|
+
if (!hasRetiredKey(config)) continue;
|
|
105
|
+
|
|
106
|
+
stripRetiredKey(config);
|
|
107
|
+
fsImpl.writeFileSync(
|
|
108
|
+
resolveAgentrcPath(ctx, filename),
|
|
109
|
+
`${JSON.stringify(config, null, 2)}\n`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
};
|