mandrel 2.56.0 → 2.58.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.
Files changed (114) hide show
  1. package/.agents/agents/plan-critic.md +13 -18
  2. package/.agents/agents/story-worker.md +25 -33
  3. package/.agents/docs/agentrc-reference.json +0 -30
  4. package/.agents/docs/configuration.md +8 -28
  5. package/.agents/docs/execution-reference.md +5 -5
  6. package/.agents/docs/quality-gates.md +8 -7
  7. package/.agents/instructions.md +9 -10
  8. package/.agents/schemas/agentrc.schema.json +9 -185
  9. package/.agents/schemas/story-deliver-terminal.schema.json +1 -1
  10. package/.agents/scripts/acceptance-eval.js +107 -17
  11. package/.agents/scripts/ceremony-derive.js +191 -0
  12. package/.agents/scripts/check-context-budget.js +28 -33
  13. package/.agents/scripts/check-cyclomatic.js +4 -3
  14. package/.agents/scripts/deliver-light.js +31 -94
  15. package/.agents/scripts/evidence-gate.js +17 -1
  16. package/.agents/scripts/lib/audit-suite/checklist-threading.js +15 -2
  17. package/.agents/scripts/lib/baselines/coverage-updater-cli.js +110 -0
  18. package/.agents/scripts/lib/baselines/crap-preview-scan.js +25 -0
  19. package/.agents/scripts/lib/baselines/crap-updater-cli.js +223 -0
  20. package/.agents/scripts/lib/bdd-scenario-budget.js +21 -3
  21. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -1
  22. package/.agents/scripts/lib/close-validation/gates.js +52 -1
  23. package/.agents/scripts/lib/config/acceptance-eval.js +25 -57
  24. package/.agents/scripts/lib/config/delivery-routing.js +7 -33
  25. package/.agents/scripts/lib/config/explain.js +0 -19
  26. package/.agents/scripts/lib/config/limits.js +18 -78
  27. package/.agents/scripts/lib/config/quality.js +6 -3
  28. package/.agents/scripts/lib/config/runners.js +3 -2
  29. package/.agents/scripts/lib/config-settings-schema-delivery.js +15 -68
  30. package/.agents/scripts/lib/config-settings-schema-quality.js +0 -14
  31. package/.agents/scripts/lib/config-settings-schema.js +16 -143
  32. package/.agents/scripts/lib/crap-engine.js +35 -4
  33. package/.agents/scripts/lib/crap-utils.js +17 -1
  34. package/.agents/scripts/lib/cyclomatic-ceiling.js +19 -7
  35. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  36. package/.agents/scripts/lib/observability/runtime-friction.js +1 -1
  37. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  38. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +5 -4
  39. package/.agents/scripts/lib/orchestration/ceremony-routing.js +19 -73
  40. package/.agents/scripts/lib/orchestration/code-review.js +7 -3
  41. package/.agents/scripts/lib/orchestration/complexity-gate.js +46 -212
  42. package/.agents/scripts/lib/orchestration/file-assumptions.js +32 -17
  43. package/.agents/scripts/lib/orchestration/light-escalation.js +3 -3
  44. package/.agents/scripts/lib/orchestration/light-suitability.js +66 -233
  45. package/.agents/scripts/lib/orchestration/pinned-identifier-lint.js +137 -0
  46. package/.agents/scripts/lib/orchestration/plan-context.js +189 -387
  47. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +42 -153
  48. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +14 -70
  49. package/.agents/scripts/lib/orchestration/plan-persist/acceptance-handle-repair.js +107 -0
  50. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +305 -0
  51. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +138 -170
  52. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +128 -297
  53. package/.agents/scripts/lib/orchestration/plan-persist/soft-findings.js +55 -0
  54. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +16 -65
  55. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +22 -35
  56. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +36 -135
  57. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +61 -223
  58. package/.agents/scripts/lib/orchestration/review-base-ref.js +138 -0
  59. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -0
  60. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +37 -5
  61. package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +46 -16
  62. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +6 -1
  63. package/.agents/scripts/lib/orchestration/story-close/context-budget-writeback.js +213 -0
  64. package/.agents/scripts/lib/orchestration/task-body-validator.js +10 -63
  65. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +33 -539
  66. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +21 -414
  67. package/.agents/scripts/lib/orchestration/ticket-validator.js +54 -118
  68. package/.agents/scripts/lib/orchestration/verify-credit.js +69 -24
  69. package/.agents/scripts/lib/story-body/body-format-lints.js +15 -85
  70. package/.agents/scripts/lib/story-body/story-body.js +54 -240
  71. package/.agents/scripts/lib/templates/decomposer-prompts.js +133 -121
  72. package/.agents/scripts/lib/test-isolate/cli-options.js +93 -0
  73. package/.agents/scripts/lib/test-isolate/progress-log.js +45 -0
  74. package/.agents/scripts/lib/test-isolate/render-report.js +97 -0
  75. package/.agents/scripts/lib/test-isolate/run-isolate.js +87 -0
  76. package/.agents/scripts/lib/test-run-credit.js +277 -0
  77. package/.agents/scripts/lib/wave-runner/footprint.js +48 -358
  78. package/.agents/scripts/lib/wave-runner/ready-set.js +6 -5
  79. package/.agents/scripts/lib/workers/crap-worker.js +32 -41
  80. package/.agents/scripts/plan-context.js +7 -9
  81. package/.agents/scripts/plan-critics.js +28 -54
  82. package/.agents/scripts/plan-persist.js +25 -68
  83. package/.agents/scripts/quality-preview.js +51 -0
  84. package/.agents/scripts/run-tests.js +12 -0
  85. package/.agents/scripts/stories-wave-tick.js +23 -45
  86. package/.agents/scripts/test-isolate.js +13 -180
  87. package/.agents/scripts/update-coverage-baseline.js +25 -70
  88. package/.agents/scripts/update-crap-baseline.js +19 -123
  89. package/.agents/skills/core/scope-triage/SKILL.md +3 -3
  90. package/.agents/workflows/audit-clean-code.md +4 -3
  91. package/.agents/workflows/helpers/acceptance-self-eval.md +41 -41
  92. package/.agents/workflows/helpers/code-quality-guardrails.md +4 -4
  93. package/.agents/workflows/helpers/code-review.md +2 -3
  94. package/.agents/workflows/helpers/deliver-digest.md +46 -55
  95. package/.agents/workflows/helpers/deliver-light.md +40 -105
  96. package/.agents/workflows/helpers/deliver-reference.md +1 -1
  97. package/.agents/workflows/helpers/deliver-story-reference.md +54 -55
  98. package/.agents/workflows/helpers/deliver-story.md +10 -13
  99. package/.agents/workflows/helpers/plan-reference.md +163 -221
  100. package/.agents/workflows/mandrel-plan.md +31 -40
  101. package/.agents/workflows/memory-consolidate.md +9 -13
  102. package/docs/CHANGELOG.md +36 -0
  103. package/lib/cli/registry.js +98 -2
  104. package/lib/migrations/index.js +4 -0
  105. package/lib/migrations/steps/2.57.0-retire-delivery-limit-knobs.js +45 -0
  106. package/lib/migrations/steps/2.57.0-retire-planning-limit-knobs.js +59 -0
  107. package/package.json +1 -1
  108. package/.agents/scripts/lib/framework-version.js +0 -39
  109. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +0 -223
  110. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +0 -97
  111. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +0 -26
  112. package/.agents/scripts/lib/orchestration/spec-budget.js +0 -89
  113. package/.agents/scripts/lib/orchestration/spec-spill.js +0 -74
  114. package/.agents/scripts/lib/orchestration/verify-tier-repair.js +0 -107
@@ -0,0 +1,223 @@
1
+ /**
2
+ * lib/baselines/crap-updater-cli.js — the `update-crap-baseline` CLI's own
3
+ * logic: flag parsing, option defaulting, and the bespoke scorer it hands
4
+ * `refreshBaseline`.
5
+ *
6
+ * Story #5316: all three lived inside `update-crap-baseline.js#main`, which no
7
+ * test imports. `parseCliArgs` scored CRAP 56, the inlined scorer 72, and
8
+ * `main` itself 90 — three of the ten methods Story #5311's honest re-anchor
9
+ * made visible, every one at 0% coverage. They sit here for the same reason
10
+ * `diff-scope-cli.js` does: a CLI shell is unreachable from a test, and the
11
+ * import from the CLI is what keeps these off the `dead-exports:production`
12
+ * ratchet.
13
+ *
14
+ * **Why this is NOT the `refresh-service.js` default scorer.** The service
15
+ * already resolves a `buildDefaultCrapScorer`, and Story #4293 made
16
+ * `update-maintainability-baseline.js` drop its bespoke scorer in favour of
17
+ * exactly that. This one cannot follow: it carries `checkResolutionFloor`, a
18
+ * fail-closed refusal that throws before anything is written when too few
19
+ * methods resolved a coverage entry — the guard that stops a broken join being
20
+ * persisted as a sparse baseline (Story #4775). Moving that into the shared
21
+ * default would change behaviour for `refresh-commit.js` and close-validation,
22
+ * which resolve the same default. So the scorer stays bespoke, and is tested
23
+ * here instead.
24
+ */
25
+
26
+ import path from 'node:path';
27
+ import { checkResolutionFloor, scanAndScore } from '../crap-utils.js';
28
+ import { Logger } from '../Logger.js';
29
+ import { parseDiffScopeFlag } from './diff-scope-cli.js';
30
+
31
+ /** Coverage artifact read when neither the flag nor config names one. */
32
+ const DEFAULT_COVERAGE_PATH = 'coverage/coverage-final.json';
33
+
34
+ /** Resolution-rate floor applied when config does not set one. */
35
+ const DEFAULT_MIN_RESOLUTION_RATE = 0.75;
36
+
37
+ /**
38
+ * Parse the updater's argv.
39
+ *
40
+ * `--full-scope` and `--diff-scope <ref>` are read here but deliberately NOT
41
+ * reconciled — {@link resolveCrapUpdaterOptions} owns the refusal, so a caller
42
+ * cannot get a half-validated shape by calling only this.
43
+ *
44
+ * @param {string[]} [argv]
45
+ * @returns {{baselinePath: string|undefined, coveragePath: string|undefined,
46
+ * fullScope: boolean, diffScopeRef: string|null}}
47
+ */
48
+ export function parseCrapUpdaterArgs(argv = []) {
49
+ const out = {
50
+ baselinePath: undefined,
51
+ coveragePath: undefined,
52
+ fullScope: false,
53
+ diffScopeRef: parseDiffScopeFlag(argv),
54
+ };
55
+ for (let i = 0; i < argv.length; i += 1) {
56
+ if (argv[i] === '--baseline' && argv[i + 1]) {
57
+ out.baselinePath = argv[i + 1];
58
+ i += 1;
59
+ } else if (argv[i] === '--coverage' && argv[i + 1]) {
60
+ out.coveragePath = argv[i + 1];
61
+ i += 1;
62
+ } else if (argv[i] === '--full-scope') {
63
+ out.fullScope = true;
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /**
70
+ * Fold parsed args over the project's quality config into the one shape the
71
+ * CLI and the scorer both read.
72
+ *
73
+ * Every flag wins over config, and config over the built-in default — the
74
+ * precedence that used to be a chain of `??` inside `main`, which is most of
75
+ * why `main` was cyclomatic 9.
76
+ *
77
+ * Throws on `--full-scope` together with `--diff-scope`: the two describe
78
+ * incompatible scopes and silently preferring one would write a baseline the
79
+ * operator did not ask for.
80
+ *
81
+ * @param {{baselinePath?: string, coveragePath?: string, fullScope?: boolean,
82
+ * diffScopeRef?: string|null}} args From {@link parseCrapUpdaterArgs}.
83
+ * @param {{crap?: object, baselines?: object}} sources Resolved config slices:
84
+ * `crap` is the quality block, `baselines` the baselines block.
85
+ * @param {string} [cwd] Root the baseline path is resolved against.
86
+ * @returns {{targetDirs: string[], ignoreGlobs: string[],
87
+ * requireCoverage: boolean, minMethodResolutionRate: number,
88
+ * coveragePath: string, baselinePath: string, absBaselinePath: string,
89
+ * fullScope: boolean, diffScopeRef: string|null}}
90
+ */
91
+ export function resolveCrapUpdaterOptions(
92
+ args = {},
93
+ { crap = {}, baselines = {} } = {},
94
+ cwd = process.cwd(),
95
+ ) {
96
+ if (args.fullScope && args.diffScopeRef != null) {
97
+ throw new Error(
98
+ '[CRAP] --full-scope is incompatible with --diff-scope; pick one',
99
+ );
100
+ }
101
+ const baselinePath = args.baselinePath ?? baselines?.crap?.path;
102
+ const coveragePath =
103
+ args.coveragePath ?? crap.coveragePath ?? DEFAULT_COVERAGE_PATH;
104
+ return {
105
+ targetDirs: Array.isArray(crap.targetDirs) ? crap.targetDirs : [],
106
+ ignoreGlobs: Array.isArray(crap.ignoreGlobs) ? crap.ignoreGlobs : [],
107
+ requireCoverage: crap.requireCoverage !== false,
108
+ minMethodResolutionRate:
109
+ crap.minMethodResolutionRate ?? DEFAULT_MIN_RESOLUTION_RATE,
110
+ coveragePath,
111
+ baselinePath,
112
+ absBaselinePath: path.isAbsolute(baselinePath)
113
+ ? baselinePath
114
+ : path.resolve(cwd, baselinePath),
115
+ fullScope: Boolean(args.fullScope),
116
+ diffScopeRef: args.diffScopeRef ?? null,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Report a scan's drop counters. Each earns a line only when it moved, so a
122
+ * clean scan stays quiet.
123
+ *
124
+ * `unscorableFiles` (Story #5311) is the one that must never be silent: a file
125
+ * the scan could not read, transpile or parse contributes no rows, so without
126
+ * a line of its own the run reads as a clean scan of a tree with fewer methods
127
+ * than it has.
128
+ *
129
+ * @param {{skippedFilesNoCoverage?: number, skippedMethodsNoCoverage?: number,
130
+ * unscorableFiles?: number, resolution?: object}} summary
131
+ * @param {{info: Function}} [logger]
132
+ */
133
+ function reportScanSummary(summary, logger = Logger) {
134
+ const counters = [
135
+ [
136
+ summary.skippedFilesNoCoverage,
137
+ 'file(s) skipped without coverage entries.',
138
+ ],
139
+ [
140
+ summary.skippedMethodsNoCoverage,
141
+ 'method(s) skipped — per-method coverage unresolved.',
142
+ ],
143
+ [
144
+ summary.unscorableFiles,
145
+ 'file(s) unscorable (read/transpile/parse failure) — no rows contributed.',
146
+ ],
147
+ ];
148
+ for (const [count, what] of counters) {
149
+ if (count > 0) logger.info(`[CRAP] ${count} ${what}`);
150
+ }
151
+ const r = summary.resolution;
152
+ if (r) {
153
+ logger.info(
154
+ `[CRAP] Method resolution: ${r.resolvedMethods}/${r.joinableMethods} ` +
155
+ `(${(r.rate * 100).toFixed(1)}%) in files with coverage.`,
156
+ );
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Build the scorer `refreshBaseline` invokes.
162
+ *
163
+ * Fails closed twice, and both refusals are the point of keeping this bespoke:
164
+ *
165
+ * - **No coverage artifact under `requireCoverage`** — every file would be
166
+ * skipped, so the scan is abandoned with an operator-facing warning rather
167
+ * than returning a confidently empty row set.
168
+ * - **Resolution rate below the floor** — `checkResolutionFloor` throws
169
+ * BEFORE the service writes anything. A baseline built from a broken join
170
+ * is not sparse, it is wrong.
171
+ *
172
+ * `loadCoverage` and the logger are named seams so a test drives the whole
173
+ * scorer without a coverage artifact on disk (`rules/test-seams.md`).
174
+ *
175
+ * @param {ReturnType<typeof resolveCrapUpdaterOptions>} options
176
+ * @param {{loadCoverage: Function, scan?: Function, logger?: object}} deps
177
+ * @returns {(files: string[], opts: object) => Promise<object[]>}
178
+ */
179
+ export function buildCrapUpdaterScorer(
180
+ options,
181
+ { loadCoverage, scan = scanAndScore, logger = Logger } = {},
182
+ ) {
183
+ return async (files, opts) => {
184
+ const effectiveCwd = opts?.cwd ?? process.cwd();
185
+ const coverageAbs = path.isAbsolute(options.coveragePath)
186
+ ? options.coveragePath
187
+ : path.resolve(effectiveCwd, options.coveragePath);
188
+ const coverage = loadCoverage(coverageAbs);
189
+ if (!coverage && options.requireCoverage) {
190
+ logger.warn(
191
+ `[CRAP] ⚠ No coverage artifact at ${options.coveragePath}. All files will be skipped under requireCoverage=true.`,
192
+ );
193
+ logger.warn(
194
+ "[CRAP] ⚠ Run 'npm run test:coverage' before 'npm run crap:update'.",
195
+ );
196
+ return [];
197
+ }
198
+
199
+ const summary = await scan({
200
+ targetDirs: options.targetDirs,
201
+ coverage,
202
+ requireCoverage: options.requireCoverage,
203
+ cwd: effectiveCwd,
204
+ ignoreGlobs: options.ignoreGlobs,
205
+ scopeFiles: opts?.fullScope ? null : (files ?? null),
206
+ });
207
+
208
+ logger.info(`[CRAP] Scanned ${summary.scannedFiles} file(s).`);
209
+ reportScanSummary(summary, logger);
210
+
211
+ // Fail closed BEFORE the service persists anything — a thin baseline is
212
+ // never written and then apologised for.
213
+ const refusal = checkResolutionFloor(
214
+ summary.resolution,
215
+ options.minMethodResolutionRate,
216
+ );
217
+ if (refusal) throw new Error(refusal);
218
+
219
+ return (summary.rows ?? []).filter(
220
+ (r) => typeof r?.crap === 'number' && Number.isFinite(r.crap),
221
+ );
222
+ };
223
+ }
@@ -36,7 +36,9 @@ const BDD_SCENARIOS_BYTE_BUDGET = 24_000;
36
36
  /**
37
37
  * Truncate a scenario index to a byte budget, deterministically (scan
38
38
  * order — file walk order, then in-file order — never re-sorted), and
39
- * report what was dropped rather than truncating silently.
39
+ * report what was dropped rather than truncating silently: `truncated` is
40
+ * `null` when everything fit, else a note naming how many scenarios were
41
+ * cut (Story #5312).
40
42
  *
41
43
  * @param {Array<object>} scenarios Full scan output (order preserved).
42
44
  * @param {{ byteBudget?: number }} [opts]
@@ -44,7 +46,7 @@ const BDD_SCENARIOS_BYTE_BUDGET = 24_000;
44
46
  * scenarios: Array<object>,
45
47
  * totalScenarios: number,
46
48
  * includedScenarios: number,
47
- * truncated: boolean,
49
+ * truncated: null | { droppedScenarios: number, note: string },
48
50
  * }}
49
51
  */
50
52
  export function capBddScenarios(scenarios, opts = {}) {
@@ -63,6 +65,22 @@ export function capBddScenarios(scenarios, opts = {}) {
63
65
  scenarios: list.slice(0, cut),
64
66
  totalScenarios: list.length,
65
67
  includedScenarios: cut,
66
- truncated: cut < list.length,
68
+ truncated: describeTruncation(list, cut, byteBudget),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * The `truncated` note: `null` when everything fit, else what was cut.
74
+ *
75
+ * @param {Array<object>} list
76
+ * @param {number} cut
77
+ * @param {number} byteBudget
78
+ * @returns {null | { droppedScenarios: number, note: string }}
79
+ */
80
+ function describeTruncation(list, cut, byteBudget) {
81
+ if (cut === list.length) return null;
82
+ return {
83
+ droppedScenarios: list.length - cut,
84
+ note: `bddScenarios cut to ${cut} of ${list.length} scenarios to fit the ${byteBudget}-byte envelope budget`,
67
85
  };
68
86
  }
@@ -86,7 +86,6 @@ export const PRE_COMMIT_MARKER =
86
86
  const QUALITY_CONFIG_DEFAULTS = Object.freeze({
87
87
  codingGuardrails: Object.freeze({
88
88
  cyclomaticFlag: 8,
89
- cyclomaticMustFix: 12,
90
89
  requireSiblingTest: false,
91
90
  }),
92
91
  autoRefresh: Object.freeze({
@@ -14,6 +14,7 @@ import { getQuality } from '../config/quality.js';
14
14
  import { filterFilesUnderTargets } from '../coverage-capture.js';
15
15
  import { hasNpmScript, readPackageScripts } from '../npm-scripts.js';
16
16
  import { KNOWN_KINDS } from '../orchestration/check-baselines/phases/parse-args.js';
17
+ import { predictsTestEvidenceCredit } from '../test-run-credit.js';
17
18
  import {
18
19
  buildFormatHint,
19
20
  FORMAT_CHECK_FALLBACK,
@@ -415,6 +416,35 @@ function predictsIncrementalCaptureSkip({
415
416
  }
416
417
  }
417
418
 
419
+ /**
420
+ * Is the `test` gate already credited for this build? Only consulted when
421
+ * coverage-capture is the active runner; a consumer without one gets the
422
+ * plain `test` gate regardless (Story #5313).
423
+ *
424
+ * @param {{ coverageCaptureActive: boolean } & Parameters<typeof predictsTestEvidenceCredit>[0]} opts
425
+ * @returns {boolean}
426
+ */
427
+ function resolveTestCredited({ coverageCaptureActive, ...probe }) {
428
+ return coverageCaptureActive && predictsTestEvidenceCredit(probe);
429
+ }
430
+
431
+ /**
432
+ * Will the `coverage-capture` gate be the one that runs the suite for this
433
+ * build — so the plain `test` gate is dropped? It is not when it is inactive,
434
+ * when its incremental skip is pre-decided (Story #5278), or when a green
435
+ * bare `npm test` already deposited the test credit (Story #5313).
436
+ *
437
+ * @param {{ coverageCaptureActive: boolean, captureSkipPredicted: boolean, testCredited: boolean }} opts
438
+ * @returns {boolean}
439
+ */
440
+ function coverageCaptureRunsSuite({
441
+ coverageCaptureActive,
442
+ captureSkipPredicted,
443
+ testCredited,
444
+ }) {
445
+ return coverageCaptureActive && !captureSkipPredicted && !testCredited;
446
+ }
447
+
418
448
  /**
419
449
  * The `coverage-capture` gate's argv.
420
450
  *
@@ -510,10 +540,25 @@ export function buildDefaultGates({
510
540
  presentBaselines,
511
541
  log,
512
542
  getChangedFilesImpl,
543
+ storyId,
544
+ evidenceCwd,
545
+ gitSpawnImpl,
546
+ shouldSkipImpl,
513
547
  } = {}) {
514
548
  const scripts = packageScripts ?? readPackageScripts(cwd);
515
549
  const coverageCaptureActive =
516
550
  isCrapGateEnabled(config) && hasNpmScript(scripts, 'test:coverage');
551
+ // Story #5313 — a credited bare `npm test` registers the plain `test` gate
552
+ // beside the capture so the credit is reported, never re-spent.
553
+ const testCredited = resolveTestCredited({
554
+ coverageCaptureActive,
555
+ storyId,
556
+ cwd,
557
+ evidenceCwd,
558
+ gitSpawnImpl,
559
+ shouldSkipImpl,
560
+ log,
561
+ });
517
562
  // Story #5278 — a registered coverage-capture gate that is going to take
518
563
  // its own incremental skip is not the test runner for this close, so the
519
564
  // plain `test` gate comes back beside it and the capture gate registers as
@@ -563,7 +608,13 @@ export function buildDefaultGates({
563
608
  // scoped pair does not shift the close-orchestrator log line, the
564
609
  // evidence keyspace, or the parallel-partition membership below.
565
610
  { name: 'lint', cmd: lint.cmd, args: lint.args },
566
- ...buildTestGateEntry(coverageCaptureActive && !captureSkipPredicted),
611
+ ...buildTestGateEntry(
612
+ coverageCaptureRunsSuite({
613
+ coverageCaptureActive,
614
+ captureSkipPredicted,
615
+ testCredited,
616
+ }),
617
+ ),
567
618
  {
568
619
  // Gate name kept generic ("format") so the close-orchestrator log line
569
620
  // doesn't shift when a repo swaps biome for Prettier / dprint via
@@ -1,34 +1,23 @@
1
1
  /**
2
- * Acceptance self-eval accessor (Story #3819).
2
+ * Acceptance self-eval accessor (Story #3819, Story #5313).
3
3
  *
4
4
  * Resolves `.agentrc.json → delivery.acceptanceEval` into the canonical
5
5
  * shape the per-Story acceptance self-eval loop consumes. The loop scores
6
6
  * the caller-injected change set against each inline `acceptance[]` item,
7
- * redrafts the unmet items, and re-evaluates — capped at `maxRounds` rounds,
8
- * then escalates to `agent::blocked` when criteria remain unmet.
9
- *
10
- * ## The undisableable cap
11
- *
12
- * `maxRounds` is operator-tunable, but the cap itself can never be turned
13
- * off. Two invariants enforce the open-loop token-burn guard:
14
- *
15
- * 1. A configured value is clamped into
16
- * `[1, ACCEPTANCE_EVAL_MAX_ROUNDS_CEILING]`. `maxRounds: 0` (which
17
- * would disable the loop) clamps up to 1; a pathological
18
- * `maxRounds: 9999` clamps down to the ceiling.
19
- * 2. Non-integer / non-finite / missing values fall back to the
20
- * documented default. The AJV schema already rejects `maxRounds < 1`
21
- * and non-integers before they reach this accessor, but the resolver
22
- * stays defensive so unit-test fixtures and degraded configs never
23
- * produce an unbounded or zero-round loop.
24
- *
25
- * There is intentionally no `enabled` flag — the loop is a hard cutover
26
- * (always on) per `rules/git-conventions.md` (no parallel old-shape path,
27
- * no toggle between "loop" and "no loop").
7
+ * redrafts the unmet items, and re-evaluates — capped at `maxRounds`
8
+ * redraft rounds, then escalates to `agent::blocked` when criteria remain
9
+ * unmet.
10
+ *
11
+ * Story #5313 dropped the hard ceiling (`ACCEPTANCE_EVAL_MAX_ROUNDS_CEILING`)
12
+ * and the floor-of-one clamp: `maxRounds` is any non-negative integer, and
13
+ * `0` means the verdict is scored **once** with no redraft round. The
14
+ * scoring pass itself is always on — there is intentionally no `enabled`
15
+ * flag (hard cutover per `rules/git-conventions.md`); only the redraft
16
+ * budget is tunable.
28
17
  */
29
18
 
30
19
  /**
31
- * Default redraft-round ceiling applied when `.agentrc.json` omits
20
+ * Default redraft-round budget applied when `.agentrc.json` omits
32
21
  * `delivery.acceptanceEval.maxRounds`. Frozen so downstream callers cannot
33
22
  * mutate the resolver's defaults across processes.
34
23
  *
@@ -41,55 +30,34 @@ export const ACCEPTANCE_EVAL_DEFAULTS = Object.freeze({
41
30
  });
42
31
 
43
32
  /**
44
- * Hard, undisableable ceiling on the number of redraft rounds. No
45
- * configuration can exceed this value — it is the open-loop token-burn
46
- * guard. A configured `maxRounds` larger than the ceiling is clamped down
47
- * to it.
48
- *
49
- * @type {number}
50
- */
51
- export const ACCEPTANCE_EVAL_MAX_ROUNDS_CEILING = 5;
52
-
53
- /**
54
- * Clamp a candidate round count into the inviolable `[1, ceiling]` range.
55
- * Non-integer / non-finite inputs fall back to the documented default.
33
+ * Normalize a candidate round count: a non-negative integer is taken as-is
34
+ * (including `0`); anything else — negative, non-integer, non-finite,
35
+ * missing — falls back to the documented default.
56
36
  *
57
37
  * @param {unknown} value
58
38
  * @param {number} fallback
59
39
  * @returns {number}
60
40
  */
61
- function clampRounds(value, fallback) {
62
- const candidate =
63
- typeof value === 'number' && Number.isInteger(value) ? value : fallback;
64
- if (candidate < 1) return 1;
65
- if (candidate > ACCEPTANCE_EVAL_MAX_ROUNDS_CEILING) {
66
- return ACCEPTANCE_EVAL_MAX_ROUNDS_CEILING;
67
- }
68
- return candidate;
41
+ function normalizeRounds(value, fallback) {
42
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0
43
+ ? value
44
+ : fallback;
69
45
  }
70
46
 
71
47
  /**
72
48
  * Read the merged acceptance-eval block. Returns the canonical shape:
73
49
  *
74
- * {
75
- * maxRounds: number, // clamped into [1, ceiling]
76
- * ceiling: number // the undisableable hard cap on rounds
77
- * }
78
- *
79
- * `maxRounds` is always a positive integer no greater than `ceiling`,
80
- * regardless of what the resolved config carried.
50
+ * { maxRounds: number } // non-negative integer; 0 = scored once
81
51
  *
82
52
  * @param {object | null | undefined} config
83
- * @returns {{ maxRounds: number, ceiling: number }}
53
+ * @returns {{ maxRounds: number }}
84
54
  */
85
55
  export function getAcceptanceEval(config) {
86
56
  const user = config?.delivery?.acceptanceEval ?? {};
87
- const maxRounds = clampRounds(
88
- user.maxRounds,
89
- ACCEPTANCE_EVAL_DEFAULTS.maxRounds,
90
- );
91
57
  return {
92
- maxRounds,
93
- ceiling: ACCEPTANCE_EVAL_MAX_ROUNDS_CEILING,
58
+ maxRounds: normalizeRounds(
59
+ user.maxRounds,
60
+ ACCEPTANCE_EVAL_DEFAULTS.maxRounds,
61
+ ),
94
62
  };
95
63
  }
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * `delivery.routing` accessor + framework defaults — Epic #4478 (M7-B), the
3
- * role-scoped-boot-context flip and the maker-checker sampling floor.
3
+ * role-scoped-boot-context flip and the ceremony profile.
4
4
  *
5
5
  * Stage 6 dropped `delivery.routing.singleDelivery` (the v1 epic
6
- * single-vs-fan-out kill-switch). v2 has one Story delivery path; routing
7
- * here is only about spawn boot context and critic sampling.
6
+ * single-vs-fan-out kill-switch). Story #5313 dropped
7
+ * `delivery.routing.freshCriticSampleRate` (the maker-checker sampling
8
+ * floor): the standard profile now routes purely off the derived change
9
+ * level — high or underivable → fresh critic, low → inline self-eval. v2 has
10
+ * one Story delivery path; routing here is only about spawn boot context and
11
+ * the ceremony profile.
8
12
  *
9
13
  * `delivery.routing.roleScopedAgents` is the **kill-switch for the role-scoped
10
14
  * boot contexts** (Epic #4478, M7-B). It defaults to `true`: a converted spawn
@@ -17,22 +21,11 @@
17
21
  * `.claude/agents/`. Flipping it off never drops a gate: the fallback is the
18
22
  * full-closure agent that ran before M7-B.
19
23
  *
20
- * `delivery.routing.freshCriticSampleRate` is the **maker-checker sampling
21
- * floor** (Epic #4478, M7-B, Part 2). Ceremony routing sends the acceptance
22
- * clusters of a change set that touches no sensitive path down the
23
- * contract-identical *inline* critic path, but a fraction of them are still
24
- * forced through a *fresh-context* critic so a low derived level never degrades
25
- * to zero independent checking. The rate is clamped into `[0, 1]`; `0` disables
26
- * the floor (pure level routing), `1` forces every cluster fresh. The default is
27
- * `0.2`. See `resolveCeremonyForRisk` in
28
- * `lib/orchestration/ceremony-routing.js`.
29
- *
30
24
  * Framework-defaults pattern mirrors `lib/config/ci.js#getCiDelivery`.
31
25
  */
32
26
 
33
27
  export const DELIVERY_ROUTING_DEFAULTS = Object.freeze({
34
28
  roleScopedAgents: true,
35
- freshCriticSampleRate: 0.2,
36
29
  /** @type {'minimal'|'standard'|'strict'} */
37
30
  ceremonyProfile: 'standard',
38
31
  /**
@@ -43,23 +36,6 @@ export const DELIVERY_ROUTING_DEFAULTS = Object.freeze({
43
36
  closeAndLand: true,
44
37
  });
45
38
 
46
- /**
47
- * Clamp a candidate sample rate into `[0, 1]`. Non-finite / non-number inputs
48
- * fall back to the framework default so a degraded config never yields a
49
- * NaN-driven or out-of-range floor.
50
- *
51
- * @param {unknown} value
52
- * @returns {number}
53
- */
54
- function clampSampleRate(value) {
55
- if (typeof value !== 'number' || !Number.isFinite(value)) {
56
- return DELIVERY_ROUTING_DEFAULTS.freshCriticSampleRate;
57
- }
58
- if (value < 0) return 0;
59
- if (value > 1) return 1;
60
- return value;
61
- }
62
-
63
39
  /**
64
40
  * Normalize ceremony profile; unknown values → `standard`.
65
41
  *
@@ -82,7 +58,6 @@ function normalizeCeremonyProfile(value) {
82
58
  * @param {object | null | undefined} config
83
59
  * @returns {{
84
60
  * roleScopedAgents: boolean,
85
- * freshCriticSampleRate: number,
86
61
  * ceremonyProfile: 'minimal'|'standard'|'strict',
87
62
  * closeAndLand: boolean,
88
63
  * }}
@@ -94,7 +69,6 @@ export function getDeliveryRouting(config) {
94
69
  typeof routing.roleScopedAgents === 'boolean'
95
70
  ? routing.roleScopedAgents
96
71
  : DELIVERY_ROUTING_DEFAULTS.roleScopedAgents,
97
- freshCriticSampleRate: clampSampleRate(routing.freshCriticSampleRate),
98
72
  ceremonyProfile: normalizeCeremonyProfile(routing.ceremonyProfile),
99
73
  closeAndLand:
100
74
  typeof routing.closeAndLand === 'boolean'
@@ -112,20 +112,6 @@ const KEY_MEANINGS = Object.freeze({
112
112
  'Allowlist of events that fire a webhook notification.',
113
113
 
114
114
  // planning.*
115
- 'planning.riskHeuristics':
116
- 'Phrases that flag a Story as high-risk for HITL escalation.',
117
- 'planning.failOnSharedEditors':
118
- 'Whether shared-editor conflict findings are promoted to hard errors.',
119
- 'planning.requireExplicitCrossStoryDeps':
120
- 'Whether implicit cross-Story dependencies are promoted to hard errors.',
121
- 'planning.failOnRegistryConflicts':
122
- 'Whether cross-cutting registry conflict findings are promoted to hard errors.',
123
- 'planning.failOnLargeFanOut':
124
- 'Whether large fan-out findings are promoted to hard errors.',
125
- 'planning.largeFanOutThreshold':
126
- 'Story count above which a plan is flagged as a large fan-out.',
127
- 'planning.crossCuttingRegistries':
128
- 'Glob patterns naming cross-cutting registry files the planner conflict-checks.',
129
115
  'planning.navigation.routeGlobs':
130
116
  'Glob patterns marking paths that add a user-facing route (plan-time reachability gate).',
131
117
  'planning.navigation.navRegistry':
@@ -168,14 +154,10 @@ const KEY_MEANINGS = Object.freeze({
168
154
  'Ordered provider chain the code-review phase consults.',
169
155
  'delivery.codeReview.maxFixAttempts':
170
156
  'Maximum auto-fix attempts the code-review phase makes.',
171
- 'delivery.codeReview.maxFixScopeFiles':
172
- 'Maximum files an auto-fix may touch in one attempt.',
173
157
  'delivery.codeReview.autoFixSeverity':
174
158
  'Severity threshold for on-branch code-review remediation (medium fixes 🔴/🟠/🟡, high fixes 🔴/🟠 only; default medium).',
175
159
  'delivery.routing.roleScopedAgents':
176
160
  'Whether delivery spawns boot on role-scoped .claude/agents/<role>.md contexts.',
177
- 'delivery.routing.freshCriticSampleRate':
178
- 'Fraction of low-risk acceptance clusters forced through a fresh-context critic (maker-checker floor).',
179
161
  'delivery.routing.ceremonyProfile':
180
162
  'Acceptance-ceremony depth: minimal (always inline), standard (derived-level routed), or strict (always fresh).',
181
163
  'delivery.routing.closeAndLand':
@@ -231,7 +213,6 @@ const PREFIX_MEANINGS = Object.freeze([
231
213
  'Tolerance epsilon applied when comparing a quality baseline.',
232
214
  ],
233
215
  ['delivery.quality', 'Delivery-time quality configuration.'],
234
- ['delivery.signals', 'Threshold for a delivery friction/telemetry signal.'],
235
216
  ['delivery.mergeWatch', 'Merge-wait poll cadence and wall-clock budget.'],
236
217
  [
237
218
  'delivery.feedbackLoop',