mandrel 2.19.0 → 2.21.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/story-worker.md +10 -0
- 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/deliver-light.js +72 -8
- 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 +74 -1
- package/.agents/scripts/lib/config-settings-schema.js +8 -24
- package/.agents/scripts/lib/orchestration/complexity-gate.js +68 -6
- package/.agents/scripts/lib/orchestration/deliver-recover.js +253 -6
- package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -2
- package/.agents/scripts/lib/orchestration/light-suitability.js +194 -11
- 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/orchestration/single-story-close/gate-log.js +11 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +1 -1
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +117 -4
- package/.agents/scripts/lib/temp-retention.js +23 -8
- package/.agents/scripts/lib/test-env.js +15 -3
- package/.agents/scripts/lib/test-temp.js +311 -0
- package/.agents/scripts/single-story-confirm-merge.js +1 -1
- package/.agents/workflows/helpers/deliver-light.md +45 -5
- package/.agents/workflows/helpers/deliver-story-reference.md +33 -0
- package/.agents/workflows/helpers/deliver-story.md +6 -3
- package/.agents/workflows/helpers/plan-reference.md +19 -4
- package/.agents/workflows/plan.md +7 -6
- package/docs/CHANGELOG.md +16 -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
|
@@ -134,6 +134,16 @@ outside the worktree / branch / PR path — or committing it to local `main`
|
|
|
134
134
|
— is expressly **forbidden**; the close pipeline's push
|
|
135
135
|
(`single-story-close.js`) is the only sanctioned landing.
|
|
136
136
|
|
|
137
|
+
## Hold the turn until the envelope arrives (MUST)
|
|
138
|
+
|
|
139
|
+
Run close in the **foreground** and wait for it. Never background it,
|
|
140
|
+
never delegate it to a child, and never end your turn while it is still
|
|
141
|
+
running — "close is running" is not a return value. Ending early strands
|
|
142
|
+
the envelope in a turn nobody reads and costs your caller a recovery
|
|
143
|
+
cycle plus a full resume of you. Close does persist a copy to
|
|
144
|
+
`temp/orchestration/story-deliver-terminal-<id>.json`; that is your
|
|
145
|
+
caller's fallback, not your licence to return before the verdict.
|
|
146
|
+
|
|
137
147
|
## Return schema
|
|
138
148
|
|
|
139
149
|
The return contract is
|
|
@@ -67,27 +67,6 @@
|
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
69
|
"planning": {
|
|
70
|
-
"codebaseSnapshot": {
|
|
71
|
-
"tier": "skinny",
|
|
72
|
-
"include": [
|
|
73
|
-
".agents/scripts/**",
|
|
74
|
-
"src/**",
|
|
75
|
-
"lib/**",
|
|
76
|
-
"app/**",
|
|
77
|
-
"packages/**"
|
|
78
|
-
],
|
|
79
|
-
"exclude": [
|
|
80
|
-
"**/node_modules/**",
|
|
81
|
-
"**/dist/**",
|
|
82
|
-
"**/build/**",
|
|
83
|
-
"**/.next/**",
|
|
84
|
-
"**/.turbo/**",
|
|
85
|
-
"**/coverage/**",
|
|
86
|
-
"**/*.test.*",
|
|
87
|
-
"**/*.spec.*"
|
|
88
|
-
],
|
|
89
|
-
"recentCommitWindow": 30
|
|
90
|
-
},
|
|
91
70
|
"riskHeuristics": [
|
|
92
71
|
"Destructive or irreversible data mutations (dropping tables, deleting rows without soft-delete or backup, truncating production state).",
|
|
93
72
|
"Modifications to shared security or auth infrastructure (IAM policies, auth middleware, session or token handling, secret rotation).",
|
|
@@ -26,7 +26,7 @@ with the runtime validators.
|
|
|
26
26
|
"$schema": "./.agents/schemas/agentrc.schema.json",
|
|
27
27
|
"project": { /* paths, commands, baseBranch, docsContextFiles */ },
|
|
28
28
|
"github": { /* owner, repo, branchProtection, mergeMethods, notifications */ },
|
|
29
|
-
"planning": { /* riskHeuristics, conflict gates,
|
|
29
|
+
"planning": { /* riskHeuristics, conflict gates, complexityGate, navigation */ },
|
|
30
30
|
"delivery": { /* execution, quality, worktreeIsolation, deliverRunner, ... */ }
|
|
31
31
|
}
|
|
32
32
|
```
|
|
@@ -38,7 +38,7 @@ top-level keys are validation errors.
|
|
|
38
38
|
| ------------- | -------- | ---------------------------------------------------------------------------------- |
|
|
39
39
|
| `project` | **Yes** | Project-local paths, base branch, validation commands, and context-hydration files. |
|
|
40
40
|
| `github` | No | Ticketing provider config: owner/repo, branch protection, merge methods, notifications. |
|
|
41
|
-
| `planning` | No | `/plan` tuning: conflict advisories,
|
|
41
|
+
| `planning` | No | `/plan` tuning: conflict advisories, complexity routing, navigability gate. (Story sizing ceilings and the planner-context cap are code-absolute — not agentrc.) |
|
|
42
42
|
| `delivery` | No | `/deliver` tuning: quality gates, worktree isolation, runners, CI watch, code-review providers. |
|
|
43
43
|
| `$schema` | No | JSON Schema pointer for editor tooling. |
|
|
44
44
|
|
|
@@ -97,11 +97,6 @@ top-level keys are validation errors.
|
|
|
97
97
|
| Key | Required | Type | Default | Description |
|
|
98
98
|
| --- | --- | --- | --- | --- |
|
|
99
99
|
| `riskHeuristics` | No | `string[]` or `{ append?, prepend? }` | — | — |
|
|
100
|
-
| `codebaseSnapshot` | No | `object` | — | Nested configuration block. |
|
|
101
|
-
| `codebaseSnapshot.tier` | No | `"skinny"` \| `"medium"` | — | — |
|
|
102
|
-
| `codebaseSnapshot.include` | No | `array<string>` | — | — |
|
|
103
|
-
| `codebaseSnapshot.exclude` | No | `array<string>` | — | — |
|
|
104
|
-
| `codebaseSnapshot.recentCommitWindow` | No | `integer` | — | — |
|
|
105
100
|
| `complexityGate` | No | `object` | — | Shape-derived ceremony-lite complexity routing. A lite claim is validated against the authored Story shape at persist and re-derived from the Story body at dispatch; conservative (full on any doubt). Never relaxes the Story-ticket / PR-to-main / repo-gates / security-baseline non-negotiables. |
|
|
106
101
|
| `complexityGate.enabled` | No | `boolean` | — | Master switch. When false, lite routing is disabled everywhere: persist refuses lite claims and dispatch always takes the sub-agent path. Default true. |
|
|
107
102
|
| `complexityGate.maxArtifacts` | No | `integer` | — | Enumerated-artifact threshold reported by the plan-context complexity signals. An input signal for the planner verdict — carries no routing authority. Default 1. |
|
|
@@ -352,13 +347,15 @@ Defaults are **advisory, not Story-width ceilings**. Session-mass ceilings live
|
|
|
352
347
|
as absolute authored-token constants on `DEFAULT_MODEL_CAPACITY` in
|
|
353
348
|
`ticket-validator-sizing.js` (soft 30k / hard 75k); there is no `maxTokenBudget`
|
|
354
349
|
envelope, and cohesion / split policy / conflict advisories are the primary
|
|
355
|
-
sizing signal.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
`
|
|
361
|
-
|
|
350
|
+
sizing signal.
|
|
351
|
+
|
|
352
|
+
There is no snapshot knob to tune: Story #4811 retired the structural-snapshot
|
|
353
|
+
block outright. Spec authoring is grounded by the author's own targeted repo
|
|
354
|
+
retrieval plus the Phase 8 `validateStoryFileAssumptions` gate, which probes
|
|
355
|
+
every authored `{path, assumption}` against the real tree as a hard error — a
|
|
356
|
+
pre-computed inventory added a second, staler answer to the same question.
|
|
357
|
+
A config still carrying the retired key is a hard validation failure; the
|
|
358
|
+
2.20.0 retirement migration strips it on upgrade.
|
|
362
359
|
|
|
363
360
|
- **`complexityGate`.** Shape-derived ceremony-lite routing (Story #4722,
|
|
364
361
|
superseding the word-count gate of Stories #4683/#4707). The full ceremony
|
|
@@ -96,12 +96,15 @@ over-ceiling envelope or an over-budget Story count.
|
|
|
96
96
|
- **`PLAN_CONTEXT_ENVELOPE_BYTE_CEILING`** (`lib/orchestration/plan-context.js`):
|
|
97
97
|
256 KB (≈64K tokens at the ≈4-chars/token estimate) on the serialized
|
|
98
98
|
envelope `buildPlanContext` assembles, checked at the single choke point
|
|
99
|
-
every mode returns through.
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
every mode returns through. A measured seed-mode envelope on this repo is
|
|
100
|
+
~120 KB — `docsContext` (~63 KB) and `systemPrompts` (~54 KB) are the whole
|
|
101
|
+
of it, every other field under 1 KB — so the ceiling carries roughly 2×
|
|
102
|
+
headroom. Story #4811 retired the codebase snapshot that used to sit
|
|
103
|
+
alongside them, leaving the operator-supplied seed the only unbounded
|
|
104
|
+
contributor.
|
|
102
105
|
- **On refusal**, the error names the envelope's largest fields. Trim the seed,
|
|
103
|
-
plan fewer `--tickets` source issues in one run
|
|
104
|
-
|
|
106
|
+
or plan fewer `--tickets` source issues in one run. The seed is carried
|
|
107
|
+
**verbatim** by design — it
|
|
105
108
|
is the operator's request, and summarizing it silently would degrade planning
|
|
106
109
|
quality precisely when the input is richest — so there is no elision path to
|
|
107
110
|
fall back on. Raising the ceiling needs a measured justification.
|
|
@@ -324,9 +324,6 @@
|
|
|
324
324
|
"riskHeuristics": {
|
|
325
325
|
"$ref": "#/$defs/listOrExtenderOfStrings"
|
|
326
326
|
},
|
|
327
|
-
"codebaseSnapshot": {
|
|
328
|
-
"$ref": "#/$defs/codebaseSnapshot"
|
|
329
|
-
},
|
|
330
327
|
"complexityGate": {
|
|
331
328
|
"type": "object",
|
|
332
329
|
"description": "Shape-derived ceremony-lite complexity routing. A lite claim is validated against the authored Story shape at persist and re-derived from the Story body at dispatch; conservative (full on any doubt). Never relaxes the Story-ticket / PR-to-main / repo-gates / security-baseline non-negotiables.",
|
|
@@ -392,34 +389,6 @@
|
|
|
392
389
|
},
|
|
393
390
|
"additionalProperties": false
|
|
394
391
|
},
|
|
395
|
-
"codebaseSnapshot": {
|
|
396
|
-
"type": "object",
|
|
397
|
-
"properties": {
|
|
398
|
-
"tier": {
|
|
399
|
-
"type": "string",
|
|
400
|
-
"enum": ["skinny", "medium"]
|
|
401
|
-
},
|
|
402
|
-
"include": {
|
|
403
|
-
"type": "array",
|
|
404
|
-
"items": {
|
|
405
|
-
"type": "string",
|
|
406
|
-
"minLength": 1
|
|
407
|
-
}
|
|
408
|
-
},
|
|
409
|
-
"exclude": {
|
|
410
|
-
"type": "array",
|
|
411
|
-
"items": {
|
|
412
|
-
"type": "string",
|
|
413
|
-
"minLength": 1
|
|
414
|
-
}
|
|
415
|
-
},
|
|
416
|
-
"recentCommitWindow": {
|
|
417
|
-
"type": "integer",
|
|
418
|
-
"minimum": 1
|
|
419
|
-
}
|
|
420
|
-
},
|
|
421
|
-
"additionalProperties": false
|
|
422
|
-
},
|
|
423
392
|
"execution": {
|
|
424
393
|
"type": "object",
|
|
425
394
|
"properties": {
|
|
@@ -15,16 +15,41 @@
|
|
|
15
15
|
* per-process scratch dir. This script is the regression guard that keeps
|
|
16
16
|
* the fix honest, plus a local cleanup mode for the accumulated noise:
|
|
17
17
|
*
|
|
18
|
+
* The guard covers two distinct temp roots, and conflating them is how the
|
|
19
|
+
* second one went unmeasured for so long:
|
|
20
|
+
*
|
|
21
|
+
* 1. The repo's own `temp/` telemetry tree — the original dimension above.
|
|
22
|
+
* 2. The **OS temp root** (Story #4808). The redirect in (1) sends stray
|
|
23
|
+
* writes into `os.tmpdir()` scratch dirs, and nothing ever reaped them:
|
|
24
|
+
* the remedy for (1) became the largest single leaker into (2). Since
|
|
25
|
+
* the damaging axis there is entry *count*, the suite now nests every
|
|
26
|
+
* managed dir inside one per-process `mandrel-suite-*` root
|
|
27
|
+
* (`lib/test-temp.js`) and reaps it, and this guard asserts that no
|
|
28
|
+
* such root survives a run.
|
|
29
|
+
*
|
|
18
30
|
* --snapshot Record a fingerprint (size + sha256) of every stream
|
|
19
|
-
* file under `temp
|
|
20
|
-
*
|
|
31
|
+
* file under `temp/`, plus the `mandrel-suite-*` roots
|
|
32
|
+
* already present in the OS temp root, to the snapshot
|
|
33
|
+
* baseline. Run this before the suite.
|
|
21
34
|
* --assert Re-scan and fail if any stream file was added or grew
|
|
22
|
-
* relative to the snapshot
|
|
23
|
-
*
|
|
35
|
+
* relative to the snapshot, or if a suite root appeared
|
|
36
|
+
* and survived. Run this after the suite. A missing
|
|
37
|
+
* snapshot is a hard failure ("snapshot missing
|
|
24
38
|
* — guard cannot attest"), never a silent re-baseline:
|
|
25
39
|
* the baseline lives *outside* the protected `temp/`
|
|
26
40
|
* tree (Story #4711), so a test wiping `temp/` can no
|
|
27
41
|
* longer destroy the baseline and fail the guard open.
|
|
42
|
+
* Recording pre-existing suite roots (rather than
|
|
43
|
+
* asserting an empty set) is what keeps a concurrent
|
|
44
|
+
* suite in another checkout from failing this one.
|
|
45
|
+
* --lint-globs <g> Comma-separated repo-relative globs to scan for test
|
|
46
|
+
* files that call `mkdtemp` against `os.tmpdir()`
|
|
47
|
+
* directly instead of going through `makeTempDir`.
|
|
48
|
+
* **Off unless passed**: this script ships in the
|
|
49
|
+
* materialized `.agents/` payload and a consumer's
|
|
50
|
+
* tests are none of this rule's business. A line (or
|
|
51
|
+
* the line above it) carrying `test-temp-allow` opts
|
|
52
|
+
* out.
|
|
28
53
|
* --baseline <path> Explicit snapshot-baseline path (CI sets this to a
|
|
29
54
|
* runner-temp path). Defaults to an OS scratch location
|
|
30
55
|
* keyed by the resolved repo root. Refused when it
|
|
@@ -53,6 +78,12 @@ import os from 'node:os';
|
|
|
53
78
|
import path from 'node:path';
|
|
54
79
|
import { fileURLToPath } from 'node:url';
|
|
55
80
|
import { runAsCli } from './lib/cli-utils.js';
|
|
81
|
+
import {
|
|
82
|
+
findRawTmpdirMkdtemp,
|
|
83
|
+
listSuiteTempRoots,
|
|
84
|
+
SUITE_ROOTS_KEY,
|
|
85
|
+
survivingSuiteTempRoots,
|
|
86
|
+
} from './lib/test-temp.js';
|
|
56
87
|
|
|
57
88
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
58
89
|
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
@@ -193,17 +224,27 @@ export function buildManifest(tempDir) {
|
|
|
193
224
|
* `defaultBaselinePath` — never inside `temp/`).
|
|
194
225
|
* @param {string} repoRoot
|
|
195
226
|
* @param {string} [baselinePath]
|
|
196
|
-
* @
|
|
227
|
+
* @param {{ tmpDir?: string }} [deps] Injectable OS temp root for tests.
|
|
228
|
+
* @returns {{ snapshotPath: string, count: number, suiteRoots: number }}
|
|
197
229
|
*/
|
|
198
|
-
export function writeSnapshot(repoRoot, baselinePath) {
|
|
230
|
+
export function writeSnapshot(repoRoot, baselinePath, { tmpDir } = {}) {
|
|
199
231
|
const snapshotPath = checkedBaselinePath(
|
|
200
232
|
repoRoot,
|
|
201
233
|
baselinePath ?? defaultBaselinePath(repoRoot),
|
|
202
234
|
);
|
|
203
235
|
const manifest = buildManifest(tempDirFor(repoRoot));
|
|
236
|
+
const count = Object.keys(manifest).length;
|
|
237
|
+
// Reserved key: stream entries are always `*.ndjson` relative paths, so
|
|
238
|
+
// this cannot shadow one, and `diffAgainstSnapshot` only ever looks up
|
|
239
|
+
// keys derived from the tree it just walked.
|
|
240
|
+
manifest[SUITE_ROOTS_KEY] = listSuiteTempRoots(tmpDir ?? os.tmpdir());
|
|
204
241
|
mkdirSync(path.dirname(snapshotPath), { recursive: true });
|
|
205
242
|
writeFileSync(snapshotPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
206
|
-
return {
|
|
243
|
+
return {
|
|
244
|
+
snapshotPath,
|
|
245
|
+
count,
|
|
246
|
+
suiteRoots: manifest[SUITE_ROOTS_KEY].length,
|
|
247
|
+
};
|
|
207
248
|
}
|
|
208
249
|
|
|
209
250
|
/**
|
|
@@ -331,7 +372,7 @@ export function cleanFixtureDirs({
|
|
|
331
372
|
/**
|
|
332
373
|
* Parse the CLI argv into a normalised options object.
|
|
333
374
|
* @param {string[]} argv
|
|
334
|
-
* @returns {{ mode: 'snapshot'|'assert'|'clean', apply: boolean, ids: number[]|null, repoRoot: string, baseline: string|null }}
|
|
375
|
+
* @returns {{ mode: 'snapshot'|'assert'|'clean', apply: boolean, ids: number[]|null, repoRoot: string, baseline: string|null, lintGlobs: string[] }}
|
|
335
376
|
*/
|
|
336
377
|
export function parseArgv(argv) {
|
|
337
378
|
let mode = 'assert';
|
|
@@ -339,13 +380,20 @@ export function parseArgv(argv) {
|
|
|
339
380
|
let ids = null;
|
|
340
381
|
let repoRoot = REPO_ROOT;
|
|
341
382
|
let baseline = null;
|
|
383
|
+
let lintGlobs = [];
|
|
342
384
|
for (let i = 0; i < argv.length; i += 1) {
|
|
343
385
|
const arg = argv[i];
|
|
344
386
|
if (arg === '--snapshot') mode = 'snapshot';
|
|
345
387
|
else if (arg === '--assert') mode = 'assert';
|
|
346
388
|
else if (arg === '--clean') mode = 'clean';
|
|
347
389
|
else if (arg === '--yes') apply = true;
|
|
348
|
-
else if (arg === '--
|
|
390
|
+
else if (arg === '--lint-globs') {
|
|
391
|
+
i += 1;
|
|
392
|
+
lintGlobs = String(argv[i] ?? '')
|
|
393
|
+
.split(',')
|
|
394
|
+
.map((s) => s.trim())
|
|
395
|
+
.filter(Boolean);
|
|
396
|
+
} else if (arg === '--ids') {
|
|
349
397
|
i += 1;
|
|
350
398
|
ids = String(argv[i] ?? '')
|
|
351
399
|
.split(',')
|
|
@@ -359,7 +407,7 @@ export function parseArgv(argv) {
|
|
|
359
407
|
baseline = path.resolve(String(argv[i] ?? '.'));
|
|
360
408
|
}
|
|
361
409
|
}
|
|
362
|
-
return { mode, apply, ids, repoRoot, baseline };
|
|
410
|
+
return { mode, apply, ids, repoRoot, baseline, lintGlobs };
|
|
363
411
|
}
|
|
364
412
|
|
|
365
413
|
/**
|
|
@@ -369,14 +417,23 @@ export function parseArgv(argv) {
|
|
|
369
417
|
*
|
|
370
418
|
* @param {ReturnType<typeof parseArgv>} opts
|
|
371
419
|
* @param {(line: string) => void} [log]
|
|
420
|
+
* @param {{ tmpDir?: string }} [deps] Injectable OS temp root for tests.
|
|
372
421
|
* @returns {number}
|
|
373
422
|
*/
|
|
374
|
-
export function runHygiene(
|
|
375
|
-
|
|
423
|
+
export function runHygiene(
|
|
424
|
+
opts,
|
|
425
|
+
log = (l) => process.stdout.write(`${l}\n`),
|
|
426
|
+
{ tmpDir = os.tmpdir() } = {},
|
|
427
|
+
) {
|
|
428
|
+
const { mode, apply, ids, repoRoot, baseline = null, lintGlobs = [] } = opts;
|
|
376
429
|
if (mode === 'snapshot') {
|
|
377
|
-
const { snapshotPath, count } = writeSnapshot(
|
|
430
|
+
const { snapshotPath, count, suiteRoots } = writeSnapshot(
|
|
431
|
+
repoRoot,
|
|
432
|
+
baseline,
|
|
433
|
+
{ tmpDir },
|
|
434
|
+
);
|
|
378
435
|
log(
|
|
379
|
-
`[test-temp-hygiene] snapshot recorded (${count} stream file(s)) → ${snapshotPath}`,
|
|
436
|
+
`[test-temp-hygiene] snapshot recorded (${count} stream file(s), ${suiteRoots} pre-existing suite root(s)) → ${snapshotPath}`,
|
|
380
437
|
);
|
|
381
438
|
return 0;
|
|
382
439
|
}
|
|
@@ -409,6 +466,26 @@ export function runHygiene(opts, log = (l) => process.stdout.write(`${l}\n`)) {
|
|
|
409
466
|
);
|
|
410
467
|
return 1;
|
|
411
468
|
}
|
|
469
|
+
// Every dimension runs and reports; a failure in one must not hide a
|
|
470
|
+
// failure in another, so the exit code is the max rather than an
|
|
471
|
+
// early return.
|
|
472
|
+
const codes = [
|
|
473
|
+
assertStreamTree(repoRoot, snapshot, log),
|
|
474
|
+
assertNoSurvivingSuiteRoots(snapshot, log, tmpDir),
|
|
475
|
+
assertNoRawTmpdirMkdtemp(repoRoot, lintGlobs, log),
|
|
476
|
+
];
|
|
477
|
+
return Math.max(...codes);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Dimension 1 — the repo's own `temp/` telemetry tree (Story #4696).
|
|
482
|
+
*
|
|
483
|
+
* @param {string} repoRoot
|
|
484
|
+
* @param {Record<string, unknown>} snapshot
|
|
485
|
+
* @param {(line: string) => void} log
|
|
486
|
+
* @returns {number} exit code
|
|
487
|
+
*/
|
|
488
|
+
function assertStreamTree(repoRoot, snapshot, log) {
|
|
412
489
|
const { added, changed } = diffAgainstSnapshot(
|
|
413
490
|
tempDirFor(repoRoot),
|
|
414
491
|
snapshot,
|
|
@@ -428,6 +505,68 @@ export function runHygiene(opts, log = (l) => process.stdout.write(`${l}\n`)) {
|
|
|
428
505
|
return 1;
|
|
429
506
|
}
|
|
430
507
|
|
|
508
|
+
/**
|
|
509
|
+
* Dimension 2 — the OS temp root (Story #4808). Fails when a suite root
|
|
510
|
+
* appeared since the snapshot and is still on disk, which means the run
|
|
511
|
+
* created it and never reaped it.
|
|
512
|
+
*
|
|
513
|
+
* @param {Record<string, unknown>} snapshot
|
|
514
|
+
* @param {(line: string) => void} log
|
|
515
|
+
* @param {string} tmpDir
|
|
516
|
+
* @returns {number} exit code
|
|
517
|
+
*/
|
|
518
|
+
function assertNoSurvivingSuiteRoots(snapshot, log, tmpDir) {
|
|
519
|
+
const before = Array.isArray(snapshot[SUITE_ROOTS_KEY])
|
|
520
|
+
? snapshot[SUITE_ROOTS_KEY]
|
|
521
|
+
: [];
|
|
522
|
+
const surviving = survivingSuiteTempRoots(tmpDir, before);
|
|
523
|
+
if (surviving.length === 0) {
|
|
524
|
+
log('[test-temp-hygiene] OK — no suite temp roots survived the run.');
|
|
525
|
+
return 0;
|
|
526
|
+
}
|
|
527
|
+
log(
|
|
528
|
+
`[test-temp-hygiene] FAIL — ${surviving.length} suite temp root(s) survived in ${tmpDir}:`,
|
|
529
|
+
);
|
|
530
|
+
for (const name of surviving) log(` + leaked ${name}`);
|
|
531
|
+
log(
|
|
532
|
+
'[test-temp-hygiene] a process minted a suite root and exited without reaping it. Do not delete these by hand — find the writer that bypassed makeTempDir().',
|
|
533
|
+
);
|
|
534
|
+
return 1;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Dimension 3 — the static backstop (Story #4808). Skipped, and reported
|
|
539
|
+
* as skipped, unless the caller passed `--lint-globs`.
|
|
540
|
+
*
|
|
541
|
+
* @param {string} repoRoot
|
|
542
|
+
* @param {string[]} globs
|
|
543
|
+
* @param {(line: string) => void} log
|
|
544
|
+
* @returns {number} exit code
|
|
545
|
+
*/
|
|
546
|
+
function assertNoRawTmpdirMkdtemp(repoRoot, globs, log) {
|
|
547
|
+
if (!globs || globs.length === 0) {
|
|
548
|
+
log(
|
|
549
|
+
'[test-temp-hygiene] SKIP — raw-tmpdir lint not requested (pass --lint-globs to enable).',
|
|
550
|
+
);
|
|
551
|
+
return 0;
|
|
552
|
+
}
|
|
553
|
+
const findings = findRawTmpdirMkdtemp(repoRoot, globs);
|
|
554
|
+
if (findings.length === 0) {
|
|
555
|
+
log(
|
|
556
|
+
'[test-temp-hygiene] OK — no test file mints OS temp dirs outside makeTempDir().',
|
|
557
|
+
);
|
|
558
|
+
return 0;
|
|
559
|
+
}
|
|
560
|
+
log(
|
|
561
|
+
`[test-temp-hygiene] FAIL — ${findings.length} raw os.tmpdir() mkdtemp call(s) in test files:`,
|
|
562
|
+
);
|
|
563
|
+
for (const f of findings) log(` ${f.file}:${f.line} ${f.text}`);
|
|
564
|
+
log(
|
|
565
|
+
"[test-temp-hygiene] use makeTempDir() from .agents/scripts/lib/test-temp.js so teardown is registered, or mark the line 'test-temp-allow: <reason>' when the real root is genuinely required.",
|
|
566
|
+
);
|
|
567
|
+
return 1;
|
|
568
|
+
}
|
|
569
|
+
|
|
431
570
|
runAsCli(
|
|
432
571
|
import.meta.url,
|
|
433
572
|
async () => {
|
|
@@ -24,7 +24,10 @@
|
|
|
24
24
|
* `proceed-light` it authors the receipt Story (via the plan-persist
|
|
25
25
|
* `createStoryIssues` surface) and prints the init/close hand-off. On
|
|
26
26
|
* over-scope it prints `ask-operator` (attended) or emits an `escalated`
|
|
27
|
-
* terminal envelope (`--yes`), never landing silently.
|
|
27
|
+
* terminal envelope (`--yes`), never landing silently. An attended
|
|
28
|
+
* `ask-operator` is answerable **either** way: `--operator-proceed-light`
|
|
29
|
+
* records the operator's proceed answer (Story #4815), which the gate
|
|
30
|
+
* applies only to a coarse size prediction and never to a risk rule.
|
|
28
31
|
* - **backstop** (`--backstop --story <id>`) — re-check the ACTUAL diff of
|
|
29
32
|
* the Story branch after implementation; exit non-zero when it exceeds the
|
|
30
33
|
* light ceilings, so an over-scope diff is blocked rather than landed.
|
|
@@ -81,7 +84,7 @@ Usage:
|
|
|
81
84
|
deliver-light.js --prompt <text> [--creates csv] [--refactors csv]
|
|
82
85
|
[--acceptance n] [--kinds csv] [--magnitude m]
|
|
83
86
|
[--uncertainty u] [--route lite|full] [--reason <text>]
|
|
84
|
-
[--amends '#id'] [--yes]
|
|
87
|
+
[--amends '#id'] [--operator-proceed-light <text>] [--yes]
|
|
85
88
|
deliver-light.js --backstop --story <id>
|
|
86
89
|
|
|
87
90
|
The thin /deliver-light entry point: suitability gate → inline receipt Story →
|
|
@@ -105,6 +108,15 @@ Gate options:
|
|
|
105
108
|
--route <r> Ledgered model verdict route: lite | full.
|
|
106
109
|
--reason <text> Recorded reason for a lite verdict (required for lite).
|
|
107
110
|
--amends <#id> Mark this as an amendment of an existing issue.
|
|
111
|
+
--operator-proceed-light <text>
|
|
112
|
+
Record the operator's "proceed light" answer to an
|
|
113
|
+
ask-operator gate, with their reason. Attended-only:
|
|
114
|
+
refused with --yes. Waives a coarse SIZE prediction
|
|
115
|
+
(change kinds, magnitude, uncertainty, deployable span)
|
|
116
|
+
only — sensitivity, migration span, and an unknown
|
|
117
|
+
footprint stay non-negotiable, the ledgered --route lite
|
|
118
|
+
verdict is still required, and the --backstop pass still
|
|
119
|
+
bounds the actual diff. Recorded in the receipt Story.
|
|
108
120
|
--yes Unattended: over-scope emits an escalated terminal
|
|
109
121
|
envelope and ENDS the session (no prompt, no fallback).
|
|
110
122
|
|
|
@@ -182,11 +194,14 @@ export function synthesizeAcceptance(count) {
|
|
|
182
194
|
* uncertainty?: string,
|
|
183
195
|
* route?: string,
|
|
184
196
|
* reason?: string,
|
|
197
|
+
* operatorProceedLight?: string,
|
|
185
198
|
* yes?: boolean,
|
|
186
199
|
* injectedRules?: object,
|
|
187
200
|
* }} args `kinds` / `magnitude` / `uncertainty` are the declared effort-and-risk
|
|
188
201
|
* axes the gate judges (Story #4764); omitting them declares no signal, not a
|
|
189
|
-
* small one — an unrecognized bucket fails closed.
|
|
202
|
+
* small one — an unrecognized bucket fails closed. `operatorProceedLight`
|
|
203
|
+
* carries the operator's recorded answer to an `ask-operator` outcome
|
|
204
|
+
* (Story #4815) and is adjudicated inside the gate, never applied here.
|
|
190
205
|
* @returns {{ action: string, suitability: object, outcome: object }}
|
|
191
206
|
*/
|
|
192
207
|
export function runLightGate({
|
|
@@ -198,6 +213,7 @@ export function runLightGate({
|
|
|
198
213
|
uncertainty,
|
|
199
214
|
route,
|
|
200
215
|
reason,
|
|
216
|
+
operatorProceedLight,
|
|
201
217
|
yes = false,
|
|
202
218
|
injectedRules,
|
|
203
219
|
} = {}) {
|
|
@@ -211,7 +227,11 @@ export function runLightGate({
|
|
|
211
227
|
verdict: { route, reason },
|
|
212
228
|
injectedRules,
|
|
213
229
|
});
|
|
214
|
-
const outcome = resolveLightGateOutcome({
|
|
230
|
+
const outcome = resolveLightGateOutcome({
|
|
231
|
+
suitability,
|
|
232
|
+
yes,
|
|
233
|
+
operatorOverride: operatorProceedLight,
|
|
234
|
+
});
|
|
215
235
|
return { action: outcome.action, suitability, outcome };
|
|
216
236
|
}
|
|
217
237
|
|
|
@@ -224,9 +244,11 @@ export function runLightGate({
|
|
|
224
244
|
* prompt: string,
|
|
225
245
|
* changedFiles?: string[],
|
|
226
246
|
* amends?: string|number|null,
|
|
247
|
+
* override?: object|null,
|
|
227
248
|
* assembleFn?: typeof assemblePlanStories,
|
|
228
249
|
* createFn?: typeof createStoryIssues,
|
|
229
|
-
* }} args
|
|
250
|
+
* }} args `override` is the applied operator scope override (Story #4815),
|
|
251
|
+
* recorded in the receipt body so the decision is auditable from the ticket.
|
|
230
252
|
* @returns {Promise<{ storyId: number, url: string|undefined, title: string }>}
|
|
231
253
|
*/
|
|
232
254
|
export async function createLightReceipt({
|
|
@@ -234,10 +256,16 @@ export async function createLightReceipt({
|
|
|
234
256
|
prompt,
|
|
235
257
|
changedFiles = [],
|
|
236
258
|
amends = null,
|
|
259
|
+
override = null,
|
|
237
260
|
assembleFn = assemblePlanStories,
|
|
238
261
|
createFn = createStoryIssues,
|
|
239
262
|
} = {}) {
|
|
240
|
-
const ticket = buildReceiptStoryTicket({
|
|
263
|
+
const ticket = buildReceiptStoryTicket({
|
|
264
|
+
prompt,
|
|
265
|
+
changedFiles,
|
|
266
|
+
amends,
|
|
267
|
+
override,
|
|
268
|
+
});
|
|
241
269
|
const { stories } = assembleFn([ticket]);
|
|
242
270
|
const { created } = await createFn({ provider, stories });
|
|
243
271
|
const receipt = created[0];
|
|
@@ -291,6 +319,19 @@ export function runDiffBackstop({
|
|
|
291
319
|
return checkLightDiffBackstop({ changedFiles: files, injectedRules });
|
|
292
320
|
}
|
|
293
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Was a non-blank `--operator-proceed-light` supplied? The gate core decides
|
|
324
|
+
* whether it *applies*; this only asks whether the operator typed one, so the
|
|
325
|
+
* attended-only refusal can fire before any adjudication.
|
|
326
|
+
*
|
|
327
|
+
* @param {{ 'operator-proceed-light'?: unknown }} values Parsed CLI values.
|
|
328
|
+
* @returns {boolean}
|
|
329
|
+
*/
|
|
330
|
+
export function hasOperatorOverride(values = {}) {
|
|
331
|
+
const raw = values['operator-proceed-light'];
|
|
332
|
+
return typeof raw === 'string' && raw.trim() !== '';
|
|
333
|
+
}
|
|
334
|
+
|
|
294
335
|
/**
|
|
295
336
|
* Emit a JSON envelope on stdout (the machine surface) so a headless caller can
|
|
296
337
|
* branch on it. Human-readable log lines stay on stderr.
|
|
@@ -341,8 +382,10 @@ async function runBackstopMode(values) {
|
|
|
341
382
|
* control flow rather than a claim the envelope makes about itself.
|
|
342
383
|
* - **`ask-operator`** is unchanged: the plain gate envelope and exit 2. It
|
|
343
384
|
* is not terminal — the operator has a choice to make, and manufacturing a
|
|
344
|
-
* terminal for it would end a session that is supposed to be waiting.
|
|
345
|
-
*
|
|
385
|
+
* terminal for it would end a session that is supposed to be waiting. The
|
|
386
|
+
* operator's proceed answer comes back as `--operator-proceed-light`.
|
|
387
|
+
* - **`proceed-light`** authors the receipt Story and prints the hand-off,
|
|
388
|
+
* carrying any applied `override` into both the receipt and the envelope.
|
|
346
389
|
*
|
|
347
390
|
* The injectable seams exist so the no-side-effect guarantee is testable
|
|
348
391
|
* without a network: a test asserts the escalate path never reaches them.
|
|
@@ -371,6 +414,17 @@ export async function runGateMode(values, deps = {}) {
|
|
|
371
414
|
throw new Error('[deliver-light] --prompt <text> is required for the gate');
|
|
372
415
|
}
|
|
373
416
|
|
|
417
|
+
// Attended-only, enforced loudly (Story #4815). Silently ignoring the flag
|
|
418
|
+
// under --yes would let an automated caller pass it as a hopeful no-op and
|
|
419
|
+
// read the resulting escalation as a bug; a usage error says which of the
|
|
420
|
+
// two the caller has to give up.
|
|
421
|
+
if (values.yes === true && hasOperatorOverride(values)) {
|
|
422
|
+
process.stderr.write(HELP);
|
|
423
|
+
throw new Error(
|
|
424
|
+
'[deliver-light] --operator-proceed-light is attended-only and cannot be combined with --yes: an unattended run has no operator whose answer this is, and over-scope must fail closed to /plan',
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
374
428
|
const gate = runLightGate({
|
|
375
429
|
creates: parseCsvPaths(values.creates),
|
|
376
430
|
refactors: parseCsvPaths(values.refactors),
|
|
@@ -382,6 +436,7 @@ export async function runGateMode(values, deps = {}) {
|
|
|
382
436
|
uncertainty: values.uncertainty,
|
|
383
437
|
route: values.route,
|
|
384
438
|
reason: values.reason,
|
|
439
|
+
operatorProceedLight: values['operator-proceed-light'],
|
|
385
440
|
yes: values.yes === true,
|
|
386
441
|
});
|
|
387
442
|
|
|
@@ -408,6 +463,7 @@ export async function runGateMode(values, deps = {}) {
|
|
|
408
463
|
return EXIT_NOT_PROCEED;
|
|
409
464
|
}
|
|
410
465
|
|
|
466
|
+
const override = gate.outcome.override ?? null;
|
|
411
467
|
const provider = createProviderFn(resolveConfigFn());
|
|
412
468
|
const receipt = await createReceiptFn({
|
|
413
469
|
provider,
|
|
@@ -417,6 +473,7 @@ export async function runGateMode(values, deps = {}) {
|
|
|
417
473
|
...parseCsvPaths(values.refactors),
|
|
418
474
|
],
|
|
419
475
|
amends: values.amends ?? null,
|
|
476
|
+
override,
|
|
420
477
|
});
|
|
421
478
|
emitFn(
|
|
422
479
|
{
|
|
@@ -424,11 +481,17 @@ export async function runGateMode(values, deps = {}) {
|
|
|
424
481
|
action: 'proceed-light',
|
|
425
482
|
storyId: receipt.storyId,
|
|
426
483
|
url: receipt.url,
|
|
484
|
+
...(override === null ? {} : { override }),
|
|
427
485
|
nextCommands: buildNextCommands(receipt.storyId),
|
|
428
486
|
outcome: gate.outcome,
|
|
429
487
|
},
|
|
430
488
|
values.pretty,
|
|
431
489
|
);
|
|
490
|
+
if (override !== null) {
|
|
491
|
+
Logger.warn(
|
|
492
|
+
`[deliver-light] operator scope override recorded on Story #${receipt.storyId}: waived "${override.overriddenCode}" — ${override.recordedReason}`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
432
495
|
Logger.info(
|
|
433
496
|
`[deliver-light] receipt Story #${receipt.storyId} created — hand off to single-story-init.js.`,
|
|
434
497
|
);
|
|
@@ -448,6 +511,7 @@ async function main() {
|
|
|
448
511
|
route: { type: 'string' },
|
|
449
512
|
reason: { type: 'string' },
|
|
450
513
|
amends: { type: 'string' },
|
|
514
|
+
'operator-proceed-light': { type: 'string' },
|
|
451
515
|
yes: { type: 'boolean', default: false },
|
|
452
516
|
backstop: { type: 'boolean', default: false },
|
|
453
517
|
story: { type: 'string' },
|
|
@@ -60,11 +60,10 @@ export const MAINTAINABILITY_EXCLUSIONS = Object.freeze(
|
|
|
60
60
|
// the audit-to-stories parser reuses the same regex-property scan
|
|
61
61
|
// patterns as acceptance-spec-reconciler.
|
|
62
62
|
'.agents/scripts/lib/audit-to-stories/parse-audit-md.js',
|
|
63
|
-
// escomplex: same "pattern" parse failure family — BDD scanner
|
|
64
|
-
//
|
|
65
|
-
//
|
|
63
|
+
// escomplex: same "pattern" parse failure family — the BDD scanner walks
|
|
64
|
+
// source trees with regex visitors that hit the upstream destructuring
|
|
65
|
+
// bug.
|
|
66
66
|
'.agents/scripts/lib/bdd-scenario-scanner.js',
|
|
67
|
-
'.agents/scripts/lib/codebase-snapshot.js',
|
|
68
67
|
// escomplex: same "pattern" parse failure — the wave-runner tick uses
|
|
69
68
|
// the regex-property destructuring escomplex chokes on.
|
|
70
69
|
'.agents/scripts/lib/wave-runner/tick.js',
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* bdd-scenario-scanner.js — Gherkin scenario index for /plan Phase 7.
|
|
3
3
|
*
|
|
4
|
-
* Story #2637 (sibling to #
|
|
5
|
-
*
|
|
4
|
+
* Story #2637 (sibling to #2636's file-assumption gate; the #2634 and #2635
|
|
5
|
+
* planner-grounding siblings were retired in Story #4811). The Acceptance
|
|
6
|
+
* Engineer step of
|
|
6
7
|
* `epic-plan-spec-author` currently writes ACs from Epic/Tech Spec narrative
|
|
7
8
|
* alone — it never inspects the consumer project's existing `.feature`
|
|
8
9
|
* files. Planned ACs frequently duplicate scenarios that already exist or
|
|
@@ -114,14 +114,6 @@ const KEY_MEANINGS = Object.freeze({
|
|
|
114
114
|
'Allowlist of events that fire a webhook notification.',
|
|
115
115
|
|
|
116
116
|
// planning.*
|
|
117
|
-
'planning.codebaseSnapshot.tier':
|
|
118
|
-
'Depth of the structural codebase view threaded into spec authoring.',
|
|
119
|
-
'planning.codebaseSnapshot.include':
|
|
120
|
-
'Glob patterns included in the codebase snapshot.',
|
|
121
|
-
'planning.codebaseSnapshot.exclude':
|
|
122
|
-
'Glob patterns excluded from the codebase snapshot.',
|
|
123
|
-
'planning.codebaseSnapshot.recentCommitWindow':
|
|
124
|
-
'How many recent commits the snapshot summarizes.',
|
|
125
117
|
'planning.riskHeuristics':
|
|
126
118
|
'Phrases that flag a Story as high-risk for HITL escalation.',
|
|
127
119
|
'planning.failOnSharedEditors':
|