mandrel 2.16.0 → 2.18.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 +10 -0
- package/.agents/docs/configuration.md +9 -0
- package/.agents/docs/quality-gates.md +137 -0
- package/.agents/schemas/agentrc.schema.json +48 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +4 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/acceptance-eval.js +52 -12
- package/.agents/scripts/audit-to-stories.js +92 -25
- package/.agents/scripts/boot-sweep.js +67 -8
- package/.agents/scripts/check-baseline-drift.js +138 -0
- package/.agents/scripts/coverage-capture.js +74 -25
- package/.agents/scripts/deliver-recover.js +45 -18
- package/.agents/scripts/drain-pending-cleanup.js +67 -23
- package/.agents/scripts/generate-lens-checklists.js +81 -30
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
- package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
- package/.agents/scripts/lib/baselines/envelope.js +7 -0
- package/.agents/scripts/lib/baselines/kernel.js +31 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
- package/.agents/scripts/lib/baselines/reader.js +12 -1
- package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
- package/.agents/scripts/lib/baselines/writer.js +10 -0
- package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
- package/.agents/scripts/lib/cli-utils.js +48 -13
- package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
- package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
- package/.agents/scripts/lib/close-validation/runner.js +68 -0
- package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
- package/.agents/scripts/lib/config/quality.js +40 -0
- package/.agents/scripts/lib/config/temp-paths.js +27 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +69 -0
- package/.agents/scripts/lib/coverage-utils.js +92 -9
- package/.agents/scripts/lib/crap-engine.js +113 -23
- package/.agents/scripts/lib/crap-utils.js +159 -93
- package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
- package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
- package/.agents/scripts/lib/observability/terse-result.js +7 -3
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +19 -41
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +9 -5
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +15 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +31 -1
- package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
- package/.agents/scripts/lib/single-story-sweep.js +11 -0
- package/.agents/scripts/lib/stdio-flush.js +71 -0
- package/.agents/scripts/lib/temp-retention.js +559 -0
- package/.agents/scripts/lib/transpile.js +133 -6
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
- package/.agents/scripts/lib/workers/crap-worker.js +49 -76
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
- package/.agents/scripts/nav-registry-diff.js +30 -8
- package/.agents/scripts/plan-run-epilogue.js +27 -11
- package/.agents/scripts/resolve-doc-tiers.js +18 -8
- package/.agents/scripts/single-story-close.js +9 -92
- package/.agents/scripts/single-story-init.js +1 -1
- package/.agents/scripts/sync-branch-from-base.js +6 -1
- package/.agents/scripts/update-crap-baseline.js +13 -0
- package/README.md +14 -6
- package/docs/CHANGELOG.md +36 -0
- package/lib/cli/version-helpers.js +7 -0
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
- package/package.json +5 -1
|
@@ -340,12 +340,13 @@ export function computeNavDiff({ routes = [], nav = [], refs = [] } = {}) {
|
|
|
340
340
|
*
|
|
341
341
|
* @param {string} label human-readable role for the error message
|
|
342
342
|
* @param {string} file
|
|
343
|
+
* @param {typeof fs} [fsImpl] filesystem seam; defaults to the real `node:fs`.
|
|
343
344
|
* @returns {unknown[]}
|
|
344
345
|
*/
|
|
345
|
-
function readJsonArray(label, file) {
|
|
346
|
+
function readJsonArray(label, file, fsImpl = fs) {
|
|
346
347
|
let raw;
|
|
347
348
|
try {
|
|
348
|
-
raw =
|
|
349
|
+
raw = fsImpl.readFileSync(file, 'utf8');
|
|
349
350
|
} catch (err) {
|
|
350
351
|
throw new Error(
|
|
351
352
|
`nav-registry-diff: cannot read ${label} file '${file}': ${err.message}`,
|
|
@@ -400,10 +401,23 @@ export function formatDiffText(diff) {
|
|
|
400
401
|
}
|
|
401
402
|
|
|
402
403
|
/**
|
|
403
|
-
*
|
|
404
|
+
* The reporter core, extracted from the CLI shell so the argv → read → diff →
|
|
405
|
+
* render → exit-code path is reachable without touching the real filesystem or
|
|
406
|
+
* the real stdout.
|
|
407
|
+
*
|
|
408
|
+
* Both seams on the optional final `deps` parameter default to the real
|
|
409
|
+
* implementation (`.agents/rules/test-seams.md` rules 1-2, 4 — `readJsonArray`
|
|
410
|
+
* forwards `fsImpl` rather than re-acquiring `fs`), so `main` and every
|
|
411
|
+
* production invocation are unchanged.
|
|
412
|
+
*
|
|
413
|
+
* @param {string[]} [argv]
|
|
414
|
+
* @param {{ fsImpl?: typeof fs, stdout?: { write: (s: string) => void } }} [deps]
|
|
404
415
|
* @returns {Promise<number>} process exit code
|
|
405
416
|
*/
|
|
406
|
-
async function
|
|
417
|
+
export async function runNavRegistryDiff(
|
|
418
|
+
argv = process.argv.slice(2),
|
|
419
|
+
{ fsImpl = fs, stdout = process.stdout } = {},
|
|
420
|
+
) {
|
|
407
421
|
const { values } = parseArgs({
|
|
408
422
|
args: argv,
|
|
409
423
|
options: {
|
|
@@ -423,9 +437,9 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
423
437
|
);
|
|
424
438
|
}
|
|
425
439
|
|
|
426
|
-
const routes = readJsonArray('routes', values.routes);
|
|
427
|
-
const nav = readJsonArray('nav', values.nav);
|
|
428
|
-
const refs = values.refs ? readJsonArray('refs', values.refs) : [];
|
|
440
|
+
const routes = readJsonArray('routes', values.routes, fsImpl);
|
|
441
|
+
const nav = readJsonArray('nav', values.nav, fsImpl);
|
|
442
|
+
const refs = values.refs ? readJsonArray('refs', values.refs, fsImpl) : [];
|
|
429
443
|
|
|
430
444
|
const diff = computeNavDiff({ routes, nav, refs });
|
|
431
445
|
|
|
@@ -434,13 +448,21 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
434
448
|
const rendered = values.json
|
|
435
449
|
? JSON.stringify(diff, null, 2)
|
|
436
450
|
: formatDiffText(diff);
|
|
437
|
-
|
|
451
|
+
stdout.write(`${rendered}\n`);
|
|
438
452
|
|
|
439
453
|
const hasFindings =
|
|
440
454
|
diff.orphanedRoutes.length > 0 || diff.deadHrefs.length > 0;
|
|
441
455
|
return values.strict && hasFindings ? 1 : 0;
|
|
442
456
|
}
|
|
443
457
|
|
|
458
|
+
/**
|
|
459
|
+
* @param {string[]} [argv]
|
|
460
|
+
* @returns {Promise<number>} process exit code
|
|
461
|
+
*/
|
|
462
|
+
async function main(argv = process.argv.slice(2)) {
|
|
463
|
+
return runNavRegistryDiff(argv);
|
|
464
|
+
}
|
|
465
|
+
|
|
444
466
|
export { main };
|
|
445
467
|
|
|
446
468
|
runAsCli(import.meta.url, main, {
|
|
@@ -27,9 +27,23 @@ const CLI_OPTIONS = {
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* @param {string[]} [argv]
|
|
30
|
+
* @param {{
|
|
31
|
+
* resolveConfigImpl?: typeof resolveConfig,
|
|
32
|
+
* createProviderImpl?: typeof createProvider,
|
|
33
|
+
* runPlanRunEpilogueImpl?: typeof runPlanRunEpilogue,
|
|
34
|
+
* logger?: { info: Function, warn: Function },
|
|
35
|
+
* }} [deps] Injectable seams; every entry defaults to the real
|
|
36
|
+
* implementation (`.agents/rules/test-seams.md` rules 1-2), so the CLI path
|
|
37
|
+
* and every production caller are unchanged.
|
|
30
38
|
* @returns {Promise<object>}
|
|
31
39
|
*/
|
|
32
|
-
export async function main(argv = process.argv.slice(2)) {
|
|
40
|
+
export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
41
|
+
const {
|
|
42
|
+
resolveConfigImpl = resolveConfig,
|
|
43
|
+
createProviderImpl = createProvider,
|
|
44
|
+
runPlanRunEpilogueImpl = runPlanRunEpilogue,
|
|
45
|
+
logger = Logger,
|
|
46
|
+
} = deps;
|
|
33
47
|
const { values } = parseArgs({
|
|
34
48
|
args: argv,
|
|
35
49
|
options: CLI_OPTIONS,
|
|
@@ -44,8 +58,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
44
58
|
typeof values.cwd === 'string' && values.cwd.trim()
|
|
45
59
|
? values.cwd.trim()
|
|
46
60
|
: process.cwd();
|
|
47
|
-
const config =
|
|
48
|
-
const provider =
|
|
61
|
+
const config = resolveConfigImpl({ cwd });
|
|
62
|
+
const provider = createProviderImpl(config);
|
|
49
63
|
|
|
50
64
|
// Story #4540 retired the `--run <planRunId>` label-resolution branch
|
|
51
65
|
// along with the label itself. The epilogue is keyed on the delivered id
|
|
@@ -58,16 +72,16 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
58
72
|
|
|
59
73
|
const planRunId = `adhoc-${[...stories].sort((a, b) => a - b).join('-')}`;
|
|
60
74
|
|
|
61
|
-
const result = await
|
|
75
|
+
const result = await runPlanRunEpilogueImpl({
|
|
62
76
|
planRunId,
|
|
63
77
|
stories,
|
|
64
78
|
provider,
|
|
65
79
|
config,
|
|
66
80
|
cwd,
|
|
67
81
|
});
|
|
68
|
-
warnOnUnresolvedBase(result);
|
|
69
|
-
warnOnEmptyRollup(result);
|
|
70
|
-
|
|
82
|
+
warnOnUnresolvedBase(result, logger);
|
|
83
|
+
warnOnEmptyRollup(result, logger);
|
|
84
|
+
logger.info(JSON.stringify(result));
|
|
71
85
|
if (result.errors?.length) {
|
|
72
86
|
process.exitCode = 1;
|
|
73
87
|
}
|
|
@@ -84,15 +98,16 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
84
98
|
* roster is still useful.
|
|
85
99
|
*
|
|
86
100
|
* @param {object} result - `runPlanRunEpilogue` envelope.
|
|
101
|
+
* @param {{ warn: Function }} [logger]
|
|
87
102
|
* @returns {void}
|
|
88
103
|
*/
|
|
89
|
-
function warnOnUnresolvedBase(result) {
|
|
104
|
+
function warnOnUnresolvedBase(result, logger = Logger) {
|
|
90
105
|
const roster = (result?.results ?? []).find(
|
|
91
106
|
(r) => r?.kind === 'audit-roster',
|
|
92
107
|
);
|
|
93
108
|
const base = roster?.baseResolution;
|
|
94
109
|
if (base?.resolved !== false) return;
|
|
95
|
-
|
|
110
|
+
logger.warn(
|
|
96
111
|
`⚠️ Combined landed diff unavailable — the pre-run base sha could not be ` +
|
|
97
112
|
`resolved against \`${base.baseRef}\`: ${base.reason}\n` +
|
|
98
113
|
` changedFiles is null (NOT an empty set). Determine the run diff by ` +
|
|
@@ -117,14 +132,15 @@ function warnOnUnresolvedBase(result) {
|
|
|
117
132
|
* rather than asserting either reading.
|
|
118
133
|
*
|
|
119
134
|
* @param {object} result - `runPlanRunEpilogue` envelope.
|
|
135
|
+
* @param {{ warn: Function }} [logger]
|
|
120
136
|
* @returns {void}
|
|
121
137
|
*/
|
|
122
|
-
function warnOnEmptyRollup(result) {
|
|
138
|
+
function warnOnEmptyRollup(result, logger = Logger) {
|
|
123
139
|
const rollup = (result?.results ?? []).find(
|
|
124
140
|
(r) => r?.kind === 'follow-up-rollup',
|
|
125
141
|
);
|
|
126
142
|
if (!rollup?.emptyRollupSuspect) return;
|
|
127
|
-
|
|
143
|
+
logger.warn(
|
|
128
144
|
`⚠️ 0 friction signals across ${rollup.storyCount} Stories — telemetry may not ` +
|
|
129
145
|
`have fired.\n` +
|
|
130
146
|
` An empty roll-up is NOT evidence of a clean run: it is the same output a ` +
|
|
@@ -50,24 +50,34 @@ export function parseArgv(argv = []) {
|
|
|
50
50
|
* Top-level CLI entry. Exported so tests can drive it against a fixture root
|
|
51
51
|
* with an injected sink and config.
|
|
52
52
|
*
|
|
53
|
+
* The optional final `deps` parameter is the module's injectable seam
|
|
54
|
+
* (`.agents/rules/test-seams.md` rules 1-2): every entry defaults to the real
|
|
55
|
+
* implementation, so the CLI path below — and any production caller — needs no
|
|
56
|
+
* configuration change.
|
|
57
|
+
*
|
|
53
58
|
* @param {{
|
|
54
59
|
* argv?: string[],
|
|
55
60
|
* config?: object,
|
|
56
61
|
* root?: string,
|
|
57
62
|
* stdout?: { write: (s: string) => void },
|
|
58
63
|
* }} [opts]
|
|
64
|
+
* @param {{
|
|
65
|
+
* resolveConfigImpl?: typeof resolveConfig,
|
|
66
|
+
* resolveDocTiersImpl?: typeof resolveDocTiers,
|
|
67
|
+
* }} [deps]
|
|
59
68
|
* @returns {Promise<number>} always 0
|
|
60
69
|
*/
|
|
61
|
-
export async function runCli(
|
|
62
|
-
argv = process.argv.slice(2),
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
} = {}
|
|
70
|
+
export async function runCli(
|
|
71
|
+
{ argv = process.argv.slice(2), config, root, stdout = process.stdout } = {},
|
|
72
|
+
{
|
|
73
|
+
resolveConfigImpl = resolveConfig,
|
|
74
|
+
resolveDocTiersImpl = resolveDocTiers,
|
|
75
|
+
} = {},
|
|
76
|
+
) {
|
|
67
77
|
const { rootPath } = parseArgv(argv);
|
|
68
|
-
const resolvedConfig = config ??
|
|
78
|
+
const resolvedConfig = config ?? resolveConfigImpl();
|
|
69
79
|
const resolvedRoot = root ?? rootPath ?? PROJECT_ROOT;
|
|
70
|
-
const result =
|
|
80
|
+
const result = resolveDocTiersImpl(resolvedConfig, { root: resolvedRoot });
|
|
71
81
|
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
72
82
|
return 0;
|
|
73
83
|
}
|
|
@@ -90,6 +90,10 @@ import { runAsCli } from './lib/cli-utils.js';
|
|
|
90
90
|
import { formatCliError } from './lib/error-redactor.js';
|
|
91
91
|
import { Logger } from './lib/Logger.js';
|
|
92
92
|
import { emitTerminalFriction } from './lib/observability/runtime-friction.js';
|
|
93
|
+
import {
|
|
94
|
+
failedTerminalFor,
|
|
95
|
+
gatesForFailedPhase,
|
|
96
|
+
} from './lib/orchestration/single-story-close/failed-terminal.js';
|
|
93
97
|
import { enableAutoMergeWith } from './lib/orchestration/single-story-close/phases/auto-merge.js';
|
|
94
98
|
import {
|
|
95
99
|
buildSyncFailureCommentBody,
|
|
@@ -102,10 +106,8 @@ import {
|
|
|
102
106
|
} from './lib/orchestration/single-story-close/phases/code-review.js';
|
|
103
107
|
import { ensurePullRequestWith } from './lib/orchestration/single-story-close/phases/pull-request.js';
|
|
104
108
|
import {
|
|
105
|
-
buildTerminalEnvelope,
|
|
106
109
|
emitTerminalEnvelope,
|
|
107
110
|
exitCodeForTerminal,
|
|
108
|
-
NEXT_COMMANDS,
|
|
109
111
|
} from './lib/orchestration/story-deliver-terminal.js';
|
|
110
112
|
|
|
111
113
|
// Story #2990 moved the `gh`-spawn boundary into the `lib/gh-exec.js`
|
|
@@ -118,9 +120,13 @@ export const enableAutoMerge = enableAutoMergeWith;
|
|
|
118
120
|
|
|
119
121
|
// Re-export pure helpers verbatim — they don't touch `execFileSync`
|
|
120
122
|
// or any URL-mocked module, so the phase exports work unmodified.
|
|
123
|
+
// `gatesForFailedPhase` now lives beside the envelope it feeds
|
|
124
|
+
// (`single-story-close/failed-terminal.js`); it is re-exported here so the
|
|
125
|
+
// CLI's public surface is unchanged by that move.
|
|
121
126
|
export {
|
|
122
127
|
buildStoryReviewCrossRefBody,
|
|
123
128
|
buildSyncFailureCommentBody,
|
|
129
|
+
gatesForFailedPhase,
|
|
124
130
|
handleSyncFailure,
|
|
125
131
|
parsePrNumber,
|
|
126
132
|
runStoryScopeReview,
|
|
@@ -134,95 +140,6 @@ export async function runSingleStoryClose(opts) {
|
|
|
134
140
|
return mod.runSingleStoryClose(opts);
|
|
135
141
|
}
|
|
136
142
|
|
|
137
|
-
/**
|
|
138
|
-
* The close pipeline's phase order, as `setPhase` walks it. Only used to
|
|
139
|
-
* decide whether a gate had already run when a later phase died.
|
|
140
|
-
*/
|
|
141
|
-
const PHASE_ORDER = Object.freeze([
|
|
142
|
-
'init',
|
|
143
|
-
'wrong-tree-guard',
|
|
144
|
-
'close-validation',
|
|
145
|
-
'base-sync',
|
|
146
|
-
'push',
|
|
147
|
-
'pull-request',
|
|
148
|
-
'code-review',
|
|
149
|
-
'auto-merge',
|
|
150
|
-
'confirm-merge',
|
|
151
|
-
'post-land',
|
|
152
|
-
'done',
|
|
153
|
-
]);
|
|
154
|
-
|
|
155
|
-
/** Each reported gate and the pipeline phase that decides it. */
|
|
156
|
-
const GATE_PHASES = Object.freeze([
|
|
157
|
-
['validation', 'close-validation'],
|
|
158
|
-
['baseSync', 'base-sync'],
|
|
159
|
-
['codeReview', 'code-review'],
|
|
160
|
-
]);
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Report every gate's outcome for a run that died at `phase`.
|
|
164
|
-
*
|
|
165
|
-
* The schema's contract: "A gate the run skipped … reports `skipped` rather
|
|
166
|
-
* than being omitted, so a missing gate is never mistaken for a passing one."
|
|
167
|
-
* The previous shape named only the gate that died and omitted the rest
|
|
168
|
-
* entirely — exactly the ambiguity the contract forbids.
|
|
169
|
-
*
|
|
170
|
-
* Reconstructed from the phase order, which is sound because the pipeline is
|
|
171
|
-
* strictly sequential: reaching phase N means every gate before it completed.
|
|
172
|
-
* A gate whose phase the run never reached is `skipped`; one the operator
|
|
173
|
-
* turned off via `--skip-validation` / `--skip-sync` is `skipped` too (it did
|
|
174
|
-
* not pass — it never ran).
|
|
175
|
-
*
|
|
176
|
-
* @param {string} phase The phase the run died in.
|
|
177
|
-
* @param {{ skipValidation?: boolean, skipSync?: boolean }} args Parsed CLI args.
|
|
178
|
-
* @returns {Record<string, 'passed'|'failed'|'skipped'>}
|
|
179
|
-
*/
|
|
180
|
-
export function gatesForFailedPhase(phase, args = {}) {
|
|
181
|
-
const skipped = { validation: args.skipValidation, baseSync: args.skipSync };
|
|
182
|
-
const failedAt = PHASE_ORDER.indexOf(phase);
|
|
183
|
-
const gates = {};
|
|
184
|
-
for (const [gate, gatePhase] of GATE_PHASES) {
|
|
185
|
-
const at = PHASE_ORDER.indexOf(gatePhase);
|
|
186
|
-
if (gatePhase === phase) gates[gate] = 'failed';
|
|
187
|
-
else if (failedAt < 0 || at > failedAt) gates[gate] = 'skipped';
|
|
188
|
-
else gates[gate] = skipped[gate] ? 'skipped' : 'passed';
|
|
189
|
-
}
|
|
190
|
-
return gates;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Build the `failed` terminal for a phase that crashed.
|
|
195
|
-
*
|
|
196
|
-
* The runner deliberately throws rather than returning a failure (a red gate
|
|
197
|
-
* must not look like a return value), so without this the most common
|
|
198
|
-
* non-happy ending — a failing close-validation gate — would emit **no
|
|
199
|
-
* envelope at all**, exiting 1 with only a stderr line while the workflow
|
|
200
|
-
* docs promise the agent a `failed` envelope naming the phase. Every close
|
|
201
|
-
* invocation emits exactly one envelope; this is the path that keeps that
|
|
202
|
-
* true when a phase dies.
|
|
203
|
-
*
|
|
204
|
-
* `err.closePhase` is tagged by the runner's phase tracker.
|
|
205
|
-
*
|
|
206
|
-
* @param {unknown} err
|
|
207
|
-
* @returns {object|null} A validated envelope, or null when even the story id
|
|
208
|
-
* is unknown (a usage error — there is nothing to report an envelope about).
|
|
209
|
-
*/
|
|
210
|
-
function failedTerminalFor(err) {
|
|
211
|
-
const phase = err?.closePhase ?? 'init';
|
|
212
|
-
const args = parseSprintArgs();
|
|
213
|
-
const storyId = Number(args.storyId);
|
|
214
|
-
if (!Number.isInteger(storyId) || storyId <= 0) return null;
|
|
215
|
-
return buildTerminalEnvelope({
|
|
216
|
-
storyId,
|
|
217
|
-
status: 'failed',
|
|
218
|
-
phase,
|
|
219
|
-
gates: gatesForFailedPhase(phase, args),
|
|
220
|
-
failure: { reason: String(err?.message ?? err) },
|
|
221
|
-
nextCommand: NEXT_COMMANDS.recover(storyId),
|
|
222
|
-
elapsedSeconds: 0,
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
|
|
226
143
|
/**
|
|
227
144
|
* CLI entry — resolves the process exit code from the terminal envelope's
|
|
228
145
|
* status rather than from a thrown/not-thrown distinction, so `pending`
|
|
@@ -234,7 +151,7 @@ async function main() {
|
|
|
234
151
|
const outcome = await runSingleStoryClose();
|
|
235
152
|
return exitCodeForTerminal(outcome?.terminal ?? { status: 'failed' });
|
|
236
153
|
} catch (err) {
|
|
237
|
-
const terminal = failedTerminalFor(err);
|
|
154
|
+
const terminal = failedTerminalFor(err, parseSprintArgs());
|
|
238
155
|
if (!terminal) throw err;
|
|
239
156
|
// Mirror runAsCli's default error line (which this catch pre-empts) so the
|
|
240
157
|
// human-facing failure text is unchanged, then emit the envelope.
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import path from 'node:path';
|
|
32
32
|
import { parseArgs } from 'node:util';
|
|
33
33
|
import { runAsCli } from './lib/cli-utils.js';
|
|
34
|
+
import { resolveConfig } from './lib/config-resolver.js';
|
|
34
35
|
import { syncBranchFromBase } from './lib/git/sync-from-base.js';
|
|
35
36
|
import { gitSpawn, gitSync } from './lib/git-utils.js';
|
|
36
37
|
import { Logger } from './lib/Logger.js';
|
|
@@ -95,11 +96,15 @@ export async function runSyncBranchFromBase(opts = {}) {
|
|
|
95
96
|
});
|
|
96
97
|
|
|
97
98
|
// Story #4685 — full detail to a temp log; emit a single summary line.
|
|
99
|
+
// Story #4794 — resolve the config so the log honours `project.paths.tempRoot`
|
|
100
|
+
// instead of the hardcoded `<cwd>/temp` this used to join. `resolveConfig`
|
|
101
|
+
// degrades to the framework defaults when no `.agentrc.json` is present, so
|
|
102
|
+
// a zero-config invocation needs no guard here.
|
|
98
103
|
emitTerseResult({
|
|
99
104
|
label: 'SYNC RESULT',
|
|
100
105
|
result,
|
|
101
106
|
scope: branch,
|
|
102
|
-
|
|
107
|
+
config: resolveConfig({ cwd }),
|
|
103
108
|
summary: { branch, base, synced: result.synced, kind: result.kind },
|
|
104
109
|
});
|
|
105
110
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from './lib/config-resolver.js';
|
|
14
14
|
import { loadCoverage } from './lib/coverage-utils.js';
|
|
15
15
|
import {
|
|
16
|
+
checkResolutionFloor,
|
|
16
17
|
resolveEscomplexVersion,
|
|
17
18
|
resolveTsTranspilerVersion,
|
|
18
19
|
scanAndScore,
|
|
@@ -71,6 +72,7 @@ async function main() {
|
|
|
71
72
|
const crap = getQuality(config).crap;
|
|
72
73
|
const targetDirs = Array.isArray(crap.targetDirs) ? crap.targetDirs : [];
|
|
73
74
|
const requireCoverage = crap.requireCoverage !== false;
|
|
75
|
+
const minMethodResolutionRate = crap.minMethodResolutionRate ?? 0.75;
|
|
74
76
|
const coveragePath =
|
|
75
77
|
args.coveragePath ?? crap.coveragePath ?? 'coverage/coverage-final.json';
|
|
76
78
|
const baselinePath = args.baselinePath ?? getBaselines(config).crap.path;
|
|
@@ -117,6 +119,7 @@ async function main() {
|
|
|
117
119
|
scannedFiles,
|
|
118
120
|
skippedFilesNoCoverage,
|
|
119
121
|
skippedMethodsNoCoverage,
|
|
122
|
+
resolution,
|
|
120
123
|
} = await scanAndScore({
|
|
121
124
|
targetDirs,
|
|
122
125
|
coverage,
|
|
@@ -137,6 +140,16 @@ async function main() {
|
|
|
137
140
|
`[CRAP] Skipped ${skippedMethodsNoCoverage} method(s) whose per-method coverage was unresolved.`,
|
|
138
141
|
);
|
|
139
142
|
}
|
|
143
|
+
if (resolution) {
|
|
144
|
+
Logger.info(
|
|
145
|
+
`[CRAP] Method resolution: ${resolution.resolvedMethods}/${resolution.joinableMethods} ` +
|
|
146
|
+
`(${(resolution.rate * 100).toFixed(1)}%) in files with coverage.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
// Fail closed BEFORE the service persists anything — a thin baseline is
|
|
150
|
+
// never written and then apologised for.
|
|
151
|
+
const refusal = checkResolutionFloor(resolution, minMethodResolutionRate);
|
|
152
|
+
if (refusal) throw new Error(refusal);
|
|
140
153
|
|
|
141
154
|
return (rows ?? []).filter(
|
|
142
155
|
(r) => typeof r?.crap === 'number' && Number.isFinite(r.crap),
|
package/README.md
CHANGED
|
@@ -181,10 +181,14 @@ dimensions, run model, and how to benchmark a new version.
|
|
|
181
181
|
|
|
182
182
|
## Contributors
|
|
183
183
|
|
|
184
|
-
|
|
185
|
-
`
|
|
186
|
-
|
|
187
|
-
|
|
184
|
+
The published `mandrel` package ships three directories — `.agents/`, `bin/`,
|
|
185
|
+
and `lib/` (see the `files` array in [`package.json`](package.json)).
|
|
186
|
+
`.agents/` is the payload `mandrel sync` materializes into a consumer's
|
|
187
|
+
`./.agents/` directory; `bin/mandrel.js` and its `lib/` implementation stay
|
|
188
|
+
inside `node_modules/mandrel/` and back the `npx mandrel …` CLI used
|
|
189
|
+
throughout this README. Everything else in this repository — `docs/`,
|
|
190
|
+
`tests/`, `.github/`, the root tooling configs — is internal development
|
|
191
|
+
tooling and is not published.
|
|
188
192
|
|
|
189
193
|
Common commands while developing the framework itself:
|
|
190
194
|
|
|
@@ -204,8 +208,12 @@ Deeper reference material lives in `docs/` rather than inline here:
|
|
|
204
208
|
- [`.agents/docs/workflows.md`](.agents/docs/workflows.md) — slash-command
|
|
205
209
|
index (auto-generated from the workflow set).
|
|
206
210
|
- [`docs/CHANGELOG.md`](docs/CHANGELOG.md) — release history.
|
|
207
|
-
- [`AGENTS.md`](AGENTS.md) — repository
|
|
208
|
-
|
|
211
|
+
- [`AGENTS.md`](AGENTS.md) — the repository-level orientation pointer; it
|
|
212
|
+
links on to [`docs/onboarding.md`](docs/onboarding.md) for the layout,
|
|
213
|
+
commands, and development standards.
|
|
214
|
+
- [`docs/release-operations.md`](docs/release-operations.md) — the Release
|
|
215
|
+
Checklist, the Install Matrix release gate, the single-package release
|
|
216
|
+
topology, PAT / npm-token setup, and the major-version policy. Releases are
|
|
209
217
|
automated by `release-please`: land Conventional Commits on `main` and it
|
|
210
218
|
opens a combined `chore: release main` PR that squash-merges itself once
|
|
211
219
|
CI is green, tags `main`, and publishes `mandrel` to npm.
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [2.18.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.17.0...mandrel-v2.18.0) (2026-07-26)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
* **temp:** auto-purge merged Stories' spent temp artifacts behind an allowlist, keeping signals ([#4794](https://github.com/dsj1984/mandrel/issues/4794)) ([#4795](https://github.com/dsj1984/mandrel/issues/4795)) ([d69d985](https://github.com/dsj1984/mandrel/commit/d69d9858511658e0ec0336db0cf8aa65d725637b))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Chores
|
|
14
|
+
|
|
15
|
+
* **release:** cut the 2.18.0 release missed by an unparseable squash subject ([#4796](https://github.com/dsj1984/mandrel/issues/4796)) ([8ccff57](https://github.com/dsj1984/mandrel/commit/8ccff574d44519595e04fc0f3597cb4dcd005436))
|
|
16
|
+
|
|
17
|
+
## [2.17.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.16.0...mandrel-v2.17.0) (2026-07-26)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
* **crap:** wire the baseline-refresh projection layer and add CRAP freshness + full-scope drift detection ([#4776](https://github.com/dsj1984/mandrel/issues/4776)) ([#4778](https://github.com/dsj1984/mandrel/issues/4778)) ([8c49a12](https://github.com/dsj1984/mandrel/commit/8c49a12be6ca5ecaca60d8f0bc868f47c9a02534))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
* **audit-to-stories:** anchor findings to their real primary file (refs [#4781](https://github.com/dsj1984/mandrel/issues/4781)) ([#4782](https://github.com/dsj1984/mandrel/issues/4782)) ([a5794c0](https://github.com/dsj1984/mandrel/commit/a5794c02c3443c39ce116699c311b4cdd8001788))
|
|
28
|
+
* **cli:** drain stdio before exit and settle the audit fan-out per dimension (refs [#4783](https://github.com/dsj1984/mandrel/issues/4783)) ([#4787](https://github.com/dsj1984/mandrel/issues/4787)) ([3422fd5](https://github.com/dsj1984/mandrel/commit/3422fd564773d0e79ac4d16af5bbc5414d06c65b))
|
|
29
|
+
* **crap:** join per-method coverage in original-source coordinates — TS scoring resolves ~5% of methods ([#4775](https://github.com/dsj1984/mandrel/issues/4775)) ([#4777](https://github.com/dsj1984/mandrel/issues/4777)) ([1464c75](https://github.com/dsj1984/mandrel/commit/1464c75c061960aaa9ee1af88175d9706b044c26))
|
|
30
|
+
* **deliver:** keep the terminal envelope alive after the worktree reap (refs [#4784](https://github.com/dsj1984/mandrel/issues/4784)) ([#4791](https://github.com/dsj1984/mandrel/issues/4791)) ([907b7bd](https://github.com/dsj1984/mandrel/commit/907b7bdb951a70a7155e5a9a50afdbea7a81d6a6))
|
|
31
|
+
* **deps:** decouple the js-yaml override from markdownlint-cli2, close Node-engine drift, and re-enable knip dependency rules ([#4784](https://github.com/dsj1984/mandrel/issues/4784)) ([#4788](https://github.com/dsj1984/mandrel/issues/4788)) ([9ed687b](https://github.com/dsj1984/mandrel/commit/9ed687bead90d3e0edf76e7d0e700b0f51e8cb5d))
|
|
32
|
+
* **docs:** re-sync the reference docs to the contracts the code actually implements ([#4785](https://github.com/dsj1984/mandrel/issues/4785)) ([#4790](https://github.com/dsj1984/mandrel/issues/4790)) ([00d9270](https://github.com/dsj1984/mandrel/commit/00d92708b3d3ac0978c073c5a31fd6a80a25767e))
|
|
33
|
+
* **docs:** supersede the stale ADR chain in place and archive the retired pattern history ([#4786](https://github.com/dsj1984/mandrel/issues/4786)) ([#4789](https://github.com/dsj1984/mandrel/issues/4789)) ([5ba2e34](https://github.com/dsj1984/mandrel/commit/5ba2e3486973b87603fd3f2f23f750f18e75e240))
|
|
34
|
+
* **git-cleanup:** report the refs the prune phase actually dropped (refs [#4772](https://github.com/dsj1984/mandrel/issues/4772)) ([#4773](https://github.com/dsj1984/mandrel/issues/4773)) ([16424d5](https://github.com/dsj1984/mandrel/commit/16424d53305549841b6294ceb1d7651baf1f98e7))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
* **quality:** floor CRAP on methodsAbove20 instead of a fitted max ceiling ([#4779](https://github.com/dsj1984/mandrel/issues/4779)) ([647130b](https://github.com/dsj1984/mandrel/commit/647130b313a85add64978205d4bb300e90e3c9a3)), closes [#4775](https://github.com/dsj1984/mandrel/issues/4775)
|
|
40
|
+
|
|
5
41
|
## [2.16.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.15.0...mandrel-v2.16.0) (2026-07-25)
|
|
6
42
|
|
|
7
43
|
|
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
* Builtins only — this module is imported from both the CLI surface
|
|
17
17
|
* (`lib/cli/`) and the doctor registry which runs before third-party
|
|
18
18
|
* packages are guaranteed to be present.
|
|
19
|
+
*
|
|
20
|
+
* The two filesystem-touching helpers below take the module's injectable
|
|
21
|
+
* seam as their optional final `fsImpl` parameter, defaulting to the real
|
|
22
|
+
* `node:fs` (`.agents/rules/test-seams.md` rules 1-2);
|
|
23
|
+
* `resolveConsumerPinVersion` forwards that seam to
|
|
24
|
+
* `resolveConsumerPinSpec` rather than re-acquiring `fs` itself (rule 4).
|
|
25
|
+
* Every other export here is pure, so it needs no seam of its own.
|
|
19
26
|
*/
|
|
20
27
|
|
|
21
28
|
import nodeFs from 'node:fs';
|
|
@@ -40,10 +40,11 @@ const TAG_LINE_RE = /^(\s*)(@\S+(?:\s+@\S+)*)\s*$/;
|
|
|
40
40
|
* Recursively collect `.feature` file paths under `root`.
|
|
41
41
|
*
|
|
42
42
|
* @param {string} root
|
|
43
|
-
* @param {typeof nodeFs} fsImpl
|
|
43
|
+
* @param {typeof nodeFs} [fsImpl] Filesystem seam; defaults to the real
|
|
44
|
+
* `node:fs` per `.agents/rules/test-seams.md` rule 1.
|
|
44
45
|
* @returns {string[]}
|
|
45
46
|
*/
|
|
46
|
-
function collectFeatureFiles(root, fsImpl) {
|
|
47
|
+
function collectFeatureFiles(root, fsImpl = nodeFs) {
|
|
47
48
|
/** @type {string[]} */
|
|
48
49
|
const found = [];
|
|
49
50
|
/** @type {string[]} */
|
|
@@ -71,11 +72,13 @@ function collectFeatureFiles(root, fsImpl) {
|
|
|
71
72
|
|
|
72
73
|
/**
|
|
73
74
|
* @param {unknown} ctx
|
|
74
|
-
* @param {typeof nodeFs} fsImpl
|
|
75
|
+
* @param {typeof nodeFs} [fsImpl] Filesystem seam; forwarded to
|
|
76
|
+
* {@link collectFeatureFiles} rather than re-acquired there
|
|
77
|
+
* (`.agents/rules/test-seams.md` rule 4).
|
|
75
78
|
* @returns {string[]} Absolute paths of every `.feature` file under the
|
|
76
79
|
* canonical feature roots that exist in the consumer tree.
|
|
77
80
|
*/
|
|
78
|
-
function resolveFeatureFiles(ctx, fsImpl) {
|
|
81
|
+
function resolveFeatureFiles(ctx, fsImpl = nodeFs) {
|
|
79
82
|
const projectRoot = ctx?.projectRoot ?? process.cwd();
|
|
80
83
|
return CANONICAL_FEATURE_ROOTS.flatMap((root) =>
|
|
81
84
|
collectFeatureFiles(path.join(projectRoot, root), fsImpl),
|
|
@@ -119,10 +122,12 @@ export const retireEpicAcTags = {
|
|
|
119
122
|
'(their reconciler consumer was deleted in the v2 Epic removal)',
|
|
120
123
|
/**
|
|
121
124
|
* @param {{ projectRoot?: string, fs?: typeof nodeFs }} [ctx]
|
|
125
|
+
* @param {typeof nodeFs} [fsImpl] Optional final filesystem seam; defaults
|
|
126
|
+
* to `ctx.fs` when the migration runner threads one, else the real
|
|
127
|
+
* `node:fs` (`.agents/rules/test-seams.md` rule 1).
|
|
122
128
|
* @returns {boolean}
|
|
123
129
|
*/
|
|
124
|
-
detect(ctx) {
|
|
125
|
-
const fsImpl = ctx?.fs ?? nodeFs;
|
|
130
|
+
detect(ctx, fsImpl = ctx?.fs ?? nodeFs) {
|
|
126
131
|
return resolveFeatureFiles(ctx, fsImpl).some((file) => {
|
|
127
132
|
try {
|
|
128
133
|
return EPIC_AC_TAG_RE.test(fsImpl.readFileSync(file, 'utf8'));
|
|
@@ -133,10 +138,12 @@ export const retireEpicAcTags = {
|
|
|
133
138
|
},
|
|
134
139
|
/**
|
|
135
140
|
* @param {{ projectRoot?: string, fs?: typeof nodeFs }} [ctx]
|
|
141
|
+
* @param {typeof nodeFs} [fsImpl] Optional final filesystem seam; defaults
|
|
142
|
+
* to `ctx.fs` when the migration runner threads one, else the real
|
|
143
|
+
* `node:fs` (`.agents/rules/test-seams.md` rule 1).
|
|
136
144
|
* @returns {void}
|
|
137
145
|
*/
|
|
138
|
-
apply(ctx) {
|
|
139
|
-
const fsImpl = ctx?.fs ?? nodeFs;
|
|
146
|
+
apply(ctx, fsImpl = ctx?.fs ?? nodeFs) {
|
|
140
147
|
for (const file of resolveFeatureFiles(ctx, fsImpl)) {
|
|
141
148
|
/** @type {string} */
|
|
142
149
|
let content;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mandrel",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.18.0",
|
|
4
4
|
"description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
|
|
5
5
|
"files": [
|
|
6
6
|
".agents/",
|
|
@@ -110,6 +110,10 @@
|
|
|
110
110
|
"optional": true
|
|
111
111
|
}
|
|
112
112
|
},
|
|
113
|
+
"//": {
|
|
114
|
+
"overrides.js-yaml": "COUPLED to devDependencies.markdownlint-cli2 — do not bump either alone, and do NOT drop this override. It is load-bearing: markdownlint-cli2 0.22.x pulls js-yaml 4.1.1, which carries GHSA-52cp-r559-cp3m (high) and GHSA-h67p-54hq-rp68 (moderate); removing the override was measured to reintroduce both (1 high + 1 moderate), while with it in place `npm audit` is clean. An npm override also wins over a transitive package's own pin, so this tree-wide ^4.2.0 is imposed on every js-yaml consumer regardless of what they declare — and markdownlint-cli2 0.23.x declares an exact js-yaml 5.2.1. A dry-run bump confirmed the trap: markdownlint-cli2 0.23.1 resolves against js-yaml 4.3.0, two majors off what it declares, silently. The only safe move is to raise this override and bump markdownlint-cli2 in ONE reviewed commit (first confirming cosmiconfig, under @commitlint/cli, tolerates the same major). renovate.json excludes markdownlint-cli2 from devDependency auto-merge so that pair cannot drift apart unattended.",
|
|
115
|
+
"dependencies.js-yaml": "States the SAME range as overrides.js-yaml above. The two are deliberate duplicates — npm has no way to reference the direct range from the overrides block — so they MUST move in lockstep; changing one without the other silently splits the direct and transitive resolutions."
|
|
116
|
+
},
|
|
113
117
|
"overrides": {
|
|
114
118
|
"js-yaml": "^4.2.0",
|
|
115
119
|
"markdown-it": "^14.2.0"
|