mandrel 2.55.0 → 2.57.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 (131) hide show
  1. package/.agents/agents/plan-critic.md +13 -18
  2. package/.agents/agents/story-worker.md +25 -34
  3. package/.agents/docs/agentrc-reference.json +4 -30
  4. package/.agents/docs/configuration.md +11 -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/rules/ci-remediation.md +39 -21
  9. package/.agents/schemas/agentrc.schema.json +28 -185
  10. package/.agents/schemas/story-deliver-terminal.schema.json +1 -1
  11. package/.agents/scripts/acceptance-eval.js +107 -17
  12. package/.agents/scripts/audit-to-stories.js +222 -75
  13. package/.agents/scripts/ceremony-derive.js +191 -0
  14. package/.agents/scripts/check-context-budget.js +28 -33
  15. package/.agents/scripts/check-cyclomatic.js +4 -3
  16. package/.agents/scripts/deliver-light.js +31 -94
  17. package/.agents/scripts/file-ci-gap.js +306 -0
  18. package/.agents/scripts/lib/audit-suite/checklist-threading.js +15 -2
  19. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
  20. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +40 -52
  21. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
  22. package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
  23. package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
  24. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +1 -1
  25. package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
  26. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
  27. package/.agents/scripts/lib/baselines/coverage-updater-cli.js +110 -0
  28. package/.agents/scripts/lib/baselines/crap-preview-scan.js +25 -0
  29. package/.agents/scripts/lib/baselines/crap-updater-cli.js +223 -0
  30. package/.agents/scripts/lib/bdd-scenario-budget.js +21 -3
  31. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -1
  32. package/.agents/scripts/lib/close-validation/gates.js +52 -1
  33. package/.agents/scripts/lib/config/acceptance-eval.js +25 -57
  34. package/.agents/scripts/lib/config/delivery-routing.js +7 -33
  35. package/.agents/scripts/lib/config/explain.js +0 -19
  36. package/.agents/scripts/lib/config/limits.js +18 -78
  37. package/.agents/scripts/lib/config/quality.js +6 -3
  38. package/.agents/scripts/lib/config/runners.js +3 -2
  39. package/.agents/scripts/lib/config-settings-schema-delivery.js +15 -68
  40. package/.agents/scripts/lib/config-settings-schema-quality.js +0 -14
  41. package/.agents/scripts/lib/config-settings-schema.js +49 -143
  42. package/.agents/scripts/lib/crap-engine.js +35 -4
  43. package/.agents/scripts/lib/crap-utils.js +17 -1
  44. package/.agents/scripts/lib/cyclomatic-ceiling.js +19 -7
  45. package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
  46. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
  47. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
  48. package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
  49. package/.agents/scripts/lib/findings/route-finding.js +38 -0
  50. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  51. package/.agents/scripts/lib/github/framework-repo.js +148 -2
  52. package/.agents/scripts/lib/label-constants.js +6 -1
  53. package/.agents/scripts/lib/observability/runtime-friction.js +1 -1
  54. package/.agents/scripts/lib/observability/source-classifier.js +2 -0
  55. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +5 -4
  56. package/.agents/scripts/lib/orchestration/ceremony-routing.js +19 -73
  57. package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
  58. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
  59. package/.agents/scripts/lib/orchestration/complexity-gate.js +46 -212
  60. package/.agents/scripts/lib/orchestration/file-assumptions.js +32 -17
  61. package/.agents/scripts/lib/orchestration/light-escalation.js +3 -3
  62. package/.agents/scripts/lib/orchestration/light-suitability.js +66 -233
  63. package/.agents/scripts/lib/orchestration/plan-context.js +181 -387
  64. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +42 -153
  65. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +14 -70
  66. package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
  67. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +300 -0
  68. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +131 -168
  69. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +133 -299
  70. package/.agents/scripts/lib/orchestration/plan-persist/soft-findings.js +55 -0
  71. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +16 -65
  72. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +22 -35
  73. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +30 -139
  74. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +61 -223
  75. package/.agents/scripts/lib/orchestration/run-epilogue.js +4 -4
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -0
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +46 -16
  78. package/.agents/scripts/lib/orchestration/story-close/context-budget-writeback.js +213 -0
  79. package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
  80. package/.agents/scripts/lib/orchestration/task-body-validator.js +10 -63
  81. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +33 -539
  82. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +21 -414
  83. package/.agents/scripts/lib/orchestration/ticket-validator.js +54 -118
  84. package/.agents/scripts/lib/orchestration/verify-credit.js +69 -24
  85. package/.agents/scripts/lib/story-body/body-format-lints.js +15 -85
  86. package/.agents/scripts/lib/story-body/story-body.js +17 -237
  87. package/.agents/scripts/lib/templates/decomposer-prompts.js +84 -121
  88. package/.agents/scripts/lib/test-isolate/cli-options.js +93 -0
  89. package/.agents/scripts/lib/test-isolate/progress-log.js +45 -0
  90. package/.agents/scripts/lib/test-isolate/render-report.js +97 -0
  91. package/.agents/scripts/lib/test-isolate/run-isolate.js +87 -0
  92. package/.agents/scripts/lib/test-run-credit.js +266 -0
  93. package/.agents/scripts/lib/wave-runner/footprint.js +48 -358
  94. package/.agents/scripts/lib/wave-runner/ready-set.js +6 -5
  95. package/.agents/scripts/lib/workers/crap-worker.js +32 -41
  96. package/.agents/scripts/plan-context.js +7 -9
  97. package/.agents/scripts/plan-critics.js +28 -54
  98. package/.agents/scripts/plan-persist.js +25 -68
  99. package/.agents/scripts/pr-watch-with-update.js +3 -2
  100. package/.agents/scripts/quality-preview.js +51 -0
  101. package/.agents/scripts/run-tests.js +12 -0
  102. package/.agents/scripts/stories-wave-tick.js +23 -45
  103. package/.agents/scripts/test-isolate.js +13 -180
  104. package/.agents/scripts/update-coverage-baseline.js +25 -70
  105. package/.agents/scripts/update-crap-baseline.js +19 -123
  106. package/.agents/skills/core/scope-triage/SKILL.md +3 -3
  107. package/.agents/workflows/audit-clean-code.md +4 -3
  108. package/.agents/workflows/audit-to-stories.md +63 -27
  109. package/.agents/workflows/helpers/acceptance-self-eval.md +41 -41
  110. package/.agents/workflows/helpers/code-quality-guardrails.md +4 -4
  111. package/.agents/workflows/helpers/code-review.md +2 -3
  112. package/.agents/workflows/helpers/deliver-digest.md +41 -57
  113. package/.agents/workflows/helpers/deliver-light.md +40 -105
  114. package/.agents/workflows/helpers/deliver-reference.md +1 -1
  115. package/.agents/workflows/helpers/deliver-story-reference.md +56 -62
  116. package/.agents/workflows/helpers/deliver-story.md +9 -13
  117. package/.agents/workflows/helpers/plan-reference.md +132 -196
  118. package/.agents/workflows/mandrel-plan.md +28 -41
  119. package/.agents/workflows/memory-consolidate.md +9 -13
  120. package/docs/CHANGELOG.md +33 -0
  121. package/lib/migrations/index.js +4 -0
  122. package/lib/migrations/steps/2.57.0-retire-delivery-limit-knobs.js +45 -0
  123. package/lib/migrations/steps/2.57.0-retire-planning-limit-knobs.js +59 -0
  124. package/package.json +1 -1
  125. package/.agents/scripts/lib/framework-version.js +0 -39
  126. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +0 -223
  127. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +0 -97
  128. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +0 -26
  129. package/.agents/scripts/lib/orchestration/spec-budget.js +0 -89
  130. package/.agents/scripts/lib/orchestration/spec-spill.js +0 -74
  131. package/.agents/scripts/lib/orchestration/verify-tier-repair.js +0 -107
@@ -24,198 +24,31 @@
24
24
  *
25
25
  * Output: human-readable text by default; pass `--json` for the raw
26
26
  * report envelope.
27
+ *
28
+ * **Shell only (Story #5316).** Nothing imports this file, so nothing here is
29
+ * reachable from a test — which is why every function it used to hold scored
30
+ * the CRAP formula's untested maximum. Argv parsing, report rendering,
31
+ * progress logging and the run orchestration now live under
32
+ * `lib/test-isolate/`, beside the `list-files` / `parse-tap` / `runner`
33
+ * modules that were always there. Keep this file a shell: anything with a
34
+ * branch in it belongs next door, where a test can reach it.
27
35
  */
28
36
 
29
37
  import path from 'node:path';
30
38
  import { fileURLToPath } from 'node:url';
31
39
  import { runAsCli } from './lib/cli-utils.js';
32
- import { resolveTestFiles } from './lib/test-isolate/list-files.js';
33
- import { diagnoseIsolation } from './lib/test-isolate/runner.js';
40
+ import { runTestIsolate } from './lib/test-isolate/run-isolate.js';
34
41
 
35
42
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
43
  const ROOT = path.resolve(__dirname, '..', '..');
37
44
 
38
- /**
39
- * @param {string[]} argv
40
- */
41
- export function parseIsolateArgv(argv) {
42
- const options = {
43
- pattern: undefined,
44
- workers: undefined,
45
- maxBisectDepth: 8,
46
- maxBisectTargets: 5,
47
- suiteConcurrency: 8,
48
- json: false,
49
- quiet: false,
50
- };
51
- for (let i = 0; i < argv.length; i += 1) {
52
- const arg = argv[i];
53
- if (arg === '--workers' && argv[i + 1]) {
54
- options.workers = Number(argv[++i]);
55
- } else if (arg === '--max-bisect-depth' && argv[i + 1]) {
56
- options.maxBisectDepth = Number(argv[++i]);
57
- } else if (arg === '--max-bisect-targets' && argv[i + 1]) {
58
- options.maxBisectTargets = Number(argv[++i]);
59
- } else if (arg === '--suite-concurrency' && argv[i + 1]) {
60
- options.suiteConcurrency = Number(argv[++i]);
61
- } else if (arg === '--json') {
62
- options.json = true;
63
- } else if (arg === '--quiet') {
64
- options.quiet = true;
65
- } else if (!arg.startsWith('--') && !options.pattern) {
66
- options.pattern = arg;
67
- }
68
- }
69
- return options;
70
- }
71
-
72
- /**
73
- * @param {import('./lib/test-isolate/runner.js').IsolateReport} report
74
- */
75
- export function renderReport(report) {
76
- const lines = [];
77
- lines.push('');
78
- lines.push('=== test-isolate diagnostic report ===');
79
- lines.push(`Files scanned: ${report.files.length}`);
80
- lines.push(`Wall duration: ${(report.durationMs / 1000).toFixed(1)}s`);
81
- lines.push('');
82
-
83
- if (report.flippers.length === 0) {
84
- lines.push('✓ No flippers detected — every file that passed alone');
85
- lines.push(' also passed in the full suite run.');
86
- } else {
87
- lines.push(`✗ ${report.flippers.length} flipper(s) detected:`);
88
- for (const f of report.flippers) {
89
- lines.push(` - ${f}`);
90
- }
91
- lines.push('');
92
- if (report.bisections.length > 0) {
93
- lines.push('Likely polluters (bisection suspects):');
94
- for (const b of report.bisections) {
95
- const tag = b.inconclusive ? ' [inconclusive]' : '';
96
- lines.push(` ${b.file}${tag}`);
97
- for (const s of b.suspects) {
98
- lines.push(` ← ${s}`);
99
- }
100
- }
101
- }
102
- }
103
-
104
- lines.push('');
105
- if (report.envMutators.length === 0) {
106
- lines.push('✓ No env-var leaks detected across isolated runs.');
107
- } else {
108
- lines.push(
109
- `⚠ ${report.envMutators.length} file(s) left process.env mutated:`,
110
- );
111
- for (const m of report.envMutators) {
112
- const parts = [];
113
- if (m.envDiff.added.length > 0) {
114
- parts.push(`added=[${m.envDiff.added.join(', ')}]`);
115
- }
116
- if (m.envDiff.removed.length > 0) {
117
- parts.push(`removed=[${m.envDiff.removed.join(', ')}]`);
118
- }
119
- if (m.envDiff.changed.length > 0) {
120
- parts.push(`changed=[${m.envDiff.changed.join(', ')}]`);
121
- }
122
- lines.push(` ${m.file}`);
123
- lines.push(` ${parts.join(' ')}`);
124
- }
125
- }
126
-
127
- lines.push('');
128
- return lines.join('\n');
129
- }
130
-
131
- /**
132
- * Programmatic entry — wired up by the CLI and exported for tests.
133
- *
134
- * @param {object} [opts]
135
- * @param {string[]} [opts.argv]
136
- * @param {string} [opts.repoRoot]
137
- * @param {(line: string) => void} [opts.onLog]
138
- * @returns {Promise<{ exitCode: number, report: import('./lib/test-isolate/runner.js').IsolateReport }>}
139
- */
140
- export async function runTestIsolate({
141
- argv = process.argv.slice(2),
142
- repoRoot = ROOT,
143
- onLog = (s) => process.stdout.write(`${s}\n`),
144
- } = {}) {
145
- const options = parseIsolateArgv(argv);
146
- const files = resolveTestFiles({
147
- pattern: options.pattern,
148
- repoRoot,
149
- });
150
- if (files.length === 0) {
151
- onLog(
152
- `[test-isolate] no test files matched pattern: ${options.pattern ?? '<default>'}`,
153
- );
154
- return { exitCode: 0, report: emptyReport() };
155
- }
156
-
157
- if (!options.quiet) {
158
- onLog(`[test-isolate] scanning ${files.length} file(s)...`);
159
- }
160
- const report = await diagnoseIsolation({
161
- repoRoot,
162
- files,
163
- workers: options.workers,
164
- suiteConcurrency: options.suiteConcurrency,
165
- maxBisectDepth: options.maxBisectDepth,
166
- maxBisectTargets: options.maxBisectTargets,
167
- onProgress: options.quiet
168
- ? undefined
169
- : (stage, payload) => {
170
- if (stage === 'isolated:start') {
171
- onLog(`[test-isolate] isolated phase: ${payload.count} file(s)`);
172
- } else if (stage === 'isolated:done') {
173
- onLog('[test-isolate] isolated phase: done');
174
- } else if (stage === 'suite:start') {
175
- onLog(`[test-isolate] suite phase: ${payload.count} file(s)`);
176
- } else if (stage === 'suite:done') {
177
- onLog('[test-isolate] suite phase: done');
178
- } else if (stage === 'bisect:start') {
179
- onLog(`[test-isolate] bisecting flipper: ${payload.target}`);
180
- } else if (stage === 'bisect:done') {
181
- const list = payload.suspects.slice(0, 3).join(', ');
182
- const more =
183
- payload.suspects.length > 3
184
- ? ` (+${payload.suspects.length - 3} more)`
185
- : '';
186
- onLog(`[test-isolate] suspects: ${list}${more}`);
187
- }
188
- },
189
- });
190
-
191
- if (options.json) {
192
- onLog(JSON.stringify(report, null, 2));
193
- } else {
194
- onLog(renderReport(report));
195
- }
196
-
197
- const exitCode =
198
- report.flippers.length === 0 && report.envMutators.length === 0 ? 0 : 1;
199
- return { exitCode, report };
200
- }
201
-
202
- function emptyReport() {
203
- return {
204
- pattern: null,
205
- files: [],
206
- isolated: [],
207
- suite: [],
208
- flippers: [],
209
- bisections: [],
210
- envMutators: [],
211
- durationMs: 0,
212
- };
213
- }
214
-
215
45
  runAsCli(
216
46
  import.meta.url,
217
47
  async () => {
218
- const { exitCode } = await runTestIsolate();
48
+ const { exitCode } = await runTestIsolate({
49
+ argv: process.argv.slice(2),
50
+ repoRoot: ROOT,
51
+ });
219
52
  if (exitCode !== 0) process.exit(exitCode);
220
53
  },
221
54
  { source: 'test-isolate' },
@@ -19,7 +19,10 @@
19
19
 
20
20
  import { createRequire } from 'node:module';
21
21
  import path from 'node:path';
22
- import { parseDiffScopeFlag } from './lib/baselines/diff-scope-cli.js';
22
+ import {
23
+ buildCoverageUpdaterScorer,
24
+ resolveCoverageUpdaterScope,
25
+ } from './lib/baselines/coverage-updater-cli.js';
23
26
  import { refreshBaseline } from './lib/baselines/refresh-service.js';
24
27
  import { runAsCli } from './lib/cli-utils.js';
25
28
  import { getBaselineEpsilon } from './lib/config/quality.js';
@@ -57,90 +60,42 @@ const USAGE = {
57
60
  ],
58
61
  };
59
62
 
63
+ /** CommonJS `require`, for the `.c8rc.cjs` scope config below. */
60
64
  const require = createRequire(import.meta.url);
61
65
 
66
+ /**
67
+ * Load the c8 include/exclude scope. Kept in the CLI rather than the extracted
68
+ * scorer because it is the one genuinely environment-bound step — a CJS
69
+ * `require` of a config file resolved against the working tree — and the
70
+ * scorer takes it as a seam so a test never touches the real `.c8rc.cjs`.
71
+ */
62
72
  function loadC8Scope(cwd) {
63
73
  return require(path.resolve(cwd, '.c8rc.cjs'));
64
74
  }
65
75
 
66
- function parseFullScopeFlag(argv = []) {
67
- return argv.includes('--full-scope');
68
- }
69
-
70
76
  function main() {
71
- const argv = process.argv.slice(2);
72
- const diffScopeRef = parseDiffScopeFlag(argv);
73
- const fullScope = parseFullScopeFlag(argv);
74
77
  const cwd = process.cwd();
78
+ const { fullScope, diffScopeRef } = resolveCoverageUpdaterScope(
79
+ process.argv.slice(2),
80
+ );
75
81
  Logger.info('[Coverage] Updating baseline from coverage-final.json...');
76
82
 
77
- if (fullScope && diffScopeRef !== null) {
78
- throw new Error(
79
- '[Coverage] --full-scope is incompatible with --diff-scope; pick one',
80
- );
81
- }
82
-
83
- // Build the per-kind scorer the service will invoke. The scorer receives
84
- // `(files, { fullScope })` and returns rows in the `{path, lines,
85
- // branches, functions}` shape the writer expects.
86
- const scorer = (files, opts) => {
87
- const effectiveCwd = opts?.cwd ?? cwd;
88
- let raw;
89
- try {
90
- raw = readCoverageFinal(effectiveCwd);
91
- } catch (err) {
92
- Logger.error(`[Coverage] ❌ ${err.message}`);
93
- return [];
94
- }
95
-
96
- const c8Config = loadC8Scope(effectiveCwd);
97
- const c8Scope = buildScopePredicate({
98
- include: c8Config.include ?? [],
99
- exclude: c8Config.exclude ?? [],
100
- });
101
- const scores = scoreCoverageFinal({
102
- raw,
103
- cwd: effectiveCwd,
104
- scope: c8Scope,
105
- });
106
-
107
- // In diff mode, further narrow to the service-resolved in-scope file list.
108
- const inScope =
109
- !opts?.fullScope && Array.isArray(files) && files.length > 0
110
- ? new Set(files)
111
- : null;
112
-
113
- const rows = Object.entries(scores)
114
- .filter(([relPath]) => inScope === null || inScope.has(relPath))
115
- .map(([relPath, score]) => ({
116
- path: relPath,
117
- lines: score?.lines ?? 0,
118
- branches: score?.branches ?? 0,
119
- functions: score?.functions ?? 0,
120
- }));
121
-
122
- const fileCount = Object.keys(scores).length;
123
- Logger.info(
124
- `[Coverage] Scored ${fileCount} file(s)${inScope ? ` (${rows.length} in scope)` : ''}.`,
125
- );
126
- return rows;
127
- };
128
-
129
83
  const absBaselinePath = path.resolve(cwd, COVERAGE_BASELINE_PATH);
130
- const epsilon = getBaselineEpsilon('coverage', null);
131
84
  const refreshOpts = {
132
85
  kind: 'coverage',
133
86
  writePath: absBaselinePath,
134
- epsilon,
135
- scorer,
87
+ epsilon: getBaselineEpsilon('coverage', null),
88
+ scorer: buildCoverageUpdaterScorer(cwd, {
89
+ readCoverage: readCoverageFinal,
90
+ loadScope: loadC8Scope,
91
+ buildScope: buildScopePredicate,
92
+ score: scoreCoverageFinal,
93
+ }),
136
94
  };
137
- if (fullScope) {
138
- refreshOpts.fullScope = true;
139
- } else if (diffScopeRef) {
140
- refreshOpts.baseRef = diffScopeRef;
141
- }
142
- // No flag → scopeFiles=null + fullScope=false → service derives the diff
143
- // via `origin/main..HEAD` (its default baseRef/headRef).
95
+ // No flag -> scopeFiles=null + fullScope=false -> the service derives the
96
+ // diff via `origin/main..HEAD` (its default baseRef/headRef).
97
+ if (fullScope) refreshOpts.fullScope = true;
98
+ else if (diffScopeRef) refreshOpts.baseRef = diffScopeRef;
144
99
 
145
100
  return refreshBaseline(refreshOpts).then((result) => {
146
101
  Logger.info(
@@ -2,8 +2,11 @@
2
2
  // first import so the check runs before any third-party-importing sibling
3
3
  // module is evaluated (Story #3432).
4
4
  import './lib/runtime-deps/ensure-installed.js';
5
- import path from 'node:path';
6
- import { parseDiffScopeFlag } from './lib/baselines/diff-scope-cli.js';
5
+ import {
6
+ buildCrapUpdaterScorer,
7
+ parseCrapUpdaterArgs,
8
+ resolveCrapUpdaterOptions,
9
+ } from './lib/baselines/crap-updater-cli.js';
7
10
  import { refreshBaseline } from './lib/baselines/refresh-service.js';
8
11
  import { runAsCli } from './lib/cli-utils.js';
9
12
  import { getBaselineEpsilon } from './lib/config/quality.js';
@@ -14,10 +17,8 @@ import {
14
17
  } from './lib/config-resolver.js';
15
18
  import { loadCoverage } from './lib/coverage-utils.js';
16
19
  import {
17
- checkResolutionFloor,
18
20
  resolveEscomplexVersion,
19
21
  resolveTsTranspilerVersion,
20
- scanAndScore,
21
22
  } from './lib/crap-utils.js';
22
23
 
23
24
  import { Logger } from './lib/Logger.js';
@@ -79,139 +80,34 @@ const USAGE = {
79
80
  ],
80
81
  };
81
82
 
82
- function parseCliArgs(argv = process.argv.slice(2)) {
83
- const out = {
84
- baselinePath: undefined,
85
- coveragePath: undefined,
86
- fullScope: false,
87
- diffScopeRef: null,
88
- };
89
- for (let i = 0; i < argv.length; i += 1) {
90
- if (argv[i] === '--baseline' && argv[i + 1]) {
91
- out.baselinePath = argv[i + 1];
92
- i += 1;
93
- } else if (argv[i] === '--coverage' && argv[i + 1]) {
94
- out.coveragePath = argv[i + 1];
95
- i += 1;
96
- } else if (argv[i] === '--full-scope') {
97
- out.fullScope = true;
98
- }
99
- }
100
- out.diffScopeRef = parseDiffScopeFlag(argv);
101
- return out;
102
- }
103
-
104
83
  async function main() {
105
- const args = parseCliArgs();
106
84
  const config = resolveConfig();
107
- const crap = getQuality(config).crap;
108
- const targetDirs = Array.isArray(crap.targetDirs) ? crap.targetDirs : [];
109
- const requireCoverage = crap.requireCoverage !== false;
110
- const minMethodResolutionRate = crap.minMethodResolutionRate ?? 0.75;
111
- const coveragePath =
112
- args.coveragePath ?? crap.coveragePath ?? 'coverage/coverage-final.json';
113
- const baselinePath = args.baselinePath ?? getBaselines(config).crap.path;
114
- const ignoreGlobs = Array.isArray(crap.ignoreGlobs) ? crap.ignoreGlobs : [];
85
+ const options = resolveCrapUpdaterOptions(
86
+ parseCrapUpdaterArgs(process.argv.slice(2)),
87
+ { crap: getQuality(config).crap, baselines: getBaselines(config) },
88
+ );
115
89
 
116
90
  Logger.info('[CRAP] Updating baseline...');
117
- Logger.info(`[CRAP] Target dirs: ${targetDirs.join(', ')}`);
91
+ Logger.info(`[CRAP] Target dirs: ${options.targetDirs.join(', ')}`);
118
92
  Logger.info(
119
- `[CRAP] Coverage source: ${coveragePath}${requireCoverage ? ' (required)' : ' (optional)'}`,
93
+ `[CRAP] Coverage source: ${options.coveragePath}${options.requireCoverage ? ' (required)' : ' (optional)'}`,
120
94
  );
121
95
 
122
- const absBaselinePath = path.isAbsolute(baselinePath)
123
- ? baselinePath
124
- : path.resolve(process.cwd(), baselinePath);
125
-
126
- if (args.fullScope && args.diffScopeRef !== null) {
127
- throw new Error(
128
- '[CRAP] --full-scope is incompatible with --diff-scope; pick one',
129
- );
130
- }
131
-
132
- // Build the per-kind scorer the service will invoke. The scorer receives
133
- // `(files, { fullScope, cwd })`:
134
- // - fullScope === true: walk all target dirs; scopeFiles=null passed to scanAndScore.
135
- // - fullScope === false: score only the diff-derived in-scope files.
136
- const scorer = async (files, opts) => {
137
- const effectiveCwd = opts?.cwd ?? process.cwd();
138
- const coverageAbs = path.isAbsolute(coveragePath)
139
- ? coveragePath
140
- : path.resolve(effectiveCwd, coveragePath);
141
- const coverage = loadCoverage(coverageAbs);
142
- if (!coverage && requireCoverage) {
143
- Logger.warn(
144
- `[CRAP] ⚠ No coverage artifact at ${coveragePath}. All files will be skipped under requireCoverage=true.`,
145
- );
146
- Logger.warn(
147
- "[CRAP] ⚠ Run 'npm run test:coverage' before 'npm run crap:update'.",
148
- );
149
- return [];
150
- }
151
- const scopeFiles = opts?.fullScope ? null : (files ?? null);
152
- const {
153
- rows,
154
- scannedFiles,
155
- skippedFilesNoCoverage,
156
- skippedMethodsNoCoverage,
157
- resolution,
158
- } = await scanAndScore({
159
- targetDirs,
160
- coverage,
161
- requireCoverage,
162
- cwd: effectiveCwd,
163
- ignoreGlobs,
164
- scopeFiles,
165
- });
166
-
167
- Logger.info(`[CRAP] Scanned ${scannedFiles} file(s).`);
168
- if (skippedFilesNoCoverage > 0) {
169
- Logger.info(
170
- `[CRAP] Skipped ${skippedFilesNoCoverage} file(s) without coverage entries.`,
171
- );
172
- }
173
- if (skippedMethodsNoCoverage > 0) {
174
- Logger.info(
175
- `[CRAP] Skipped ${skippedMethodsNoCoverage} method(s) whose per-method coverage was unresolved.`,
176
- );
177
- }
178
- if (resolution) {
179
- Logger.info(
180
- `[CRAP] Method resolution: ${resolution.resolvedMethods}/${resolution.joinableMethods} ` +
181
- `(${(resolution.rate * 100).toFixed(1)}%) in files with coverage.`,
182
- );
183
- }
184
- // Fail closed BEFORE the service persists anything — a thin baseline is
185
- // never written and then apologised for.
186
- const refusal = checkResolutionFloor(resolution, minMethodResolutionRate);
187
- if (refusal) throw new Error(refusal);
188
-
189
- return (rows ?? []).filter(
190
- (r) => typeof r?.crap === 'number' && Number.isFinite(r.crap),
191
- );
192
- };
193
-
194
- const epsilon = getBaselineEpsilon('crap', config);
195
96
  const refreshOpts = {
196
97
  kind: 'crap',
197
- writePath: absBaselinePath,
198
- epsilon,
199
- scorer,
98
+ writePath: options.absBaselinePath,
99
+ epsilon: getBaselineEpsilon('crap', config),
100
+ scorer: buildCrapUpdaterScorer(options, { loadCoverage }),
200
101
  };
201
- if (args.fullScope) {
202
- refreshOpts.fullScope = true;
203
- } else if (args.diffScopeRef) {
204
- refreshOpts.baseRef = args.diffScopeRef;
205
- }
206
- // No flag → scopeFiles=null + fullScope=false → service derives the diff
207
- // via `origin/main..HEAD` (its default baseRef/headRef).
102
+ // No flag -> scopeFiles=null + fullScope=false -> the service derives the
103
+ // diff via `origin/main..HEAD` (its default baseRef/headRef).
104
+ if (options.fullScope) refreshOpts.fullScope = true;
105
+ else if (options.diffScopeRef) refreshOpts.baseRef = options.diffScopeRef;
208
106
 
209
107
  const result = await refreshBaseline(refreshOpts);
210
108
 
211
- const escomplexVersion = resolveEscomplexVersion();
212
- const tsTranspilerVersion = resolveTsTranspilerVersion();
213
109
  Logger.info(
214
- `[CRAP] ✅ Baseline updated (kernelVersion=${result.envelope.kernelVersion}, escomplexVersion=${escomplexVersion}, tsTranspilerVersion=${tsTranspilerVersion}). Wrote to ${absBaselinePath}.`,
110
+ `[CRAP] ✅ Baseline updated (kernelVersion=${result.envelope.kernelVersion}, escomplexVersion=${resolveEscomplexVersion()}, tsTranspilerVersion=${resolveTsTranspilerVersion()}). Wrote to ${options.absBaselinePath}.`,
215
111
  );
216
112
  Logger.info(
217
113
  `[CRAP] Wrote ${result.envelope.rows.length} row(s). scope=${result.scope.mode}, wrote=${result.wrote}.`,
@@ -15,10 +15,10 @@ description:
15
15
  is a single path that emits **one Story by default**.
16
16
  - This skill is an optional **split advisory**: should the author keep one
17
17
  Story, or does the seed clear the default-single split policy?
18
- - Anchor sizing judgment to `DEFAULT_MODEL_CAPACITY` and
19
- `DELIVERABLE_GRANULARITY_GUIDANCE` in
18
+ - Anchor sizing judgment to `DELIVERABLE_GRANULARITY_GUIDANCE` in
20
19
  [`ticket-validator-sizing.js`](../../../scripts/lib/orchestration/ticket-validator-sizing.js).
21
- Do not restate numeric thresholds.
20
+ There is no numeric threshold to restate — Story #5312 deleted the
21
+ plan-time capacity ceilings.
22
22
  - Lead with **cohesion**: one Story is one coherent change with one reason
23
23
  to exist. Coupled work stays one Story and uses `## Slicing` for
24
24
  intra-session checkpoints.
@@ -46,7 +46,8 @@ run the tools first.
46
46
  `check-baselines.js` gate enforces. Treat any per-file MI drop beyond
47
47
  `delivery.quality.gates.maintainability.tolerance` (default 0.5pt) as a
48
48
  grounded must-fix finding, and any cyclomatic reading over the
49
- `codingGuardrails` ceilings (flag > 8, must-fix > 12) as measured, not
49
+ `codingGuardrails.cyclomaticFlag` (> 8) or the fixed ratchet ceiling
50
+ (≥ 12, reported by `quality:preview` as an advisory) as measured, not
50
51
  guessed.
51
52
 
52
53
  2. **Committed baselines (codebase-wide mode).** Read the committed metric
@@ -98,8 +99,8 @@ Analyze the repository with a focus on:
98
99
  from
99
100
  [`helpers/code-quality-guardrails.md`](helpers/code-quality-guardrails.md):
100
101
  cyclomatic complexity > 8 (`delivery.quality.codingGuardrails.cyclomaticFlag`)
101
- is **flag in review** (annotate or split); > 12
102
- (`codingGuardrails.cyclomaticMustFix`) is **must-fix** before the work merges.
102
+ is **flag in review** (annotate or split); ≥ 12 is the fixed ratchet
103
+ ceiling `check-cyclomatic.js` enforces on new breaches.
103
104
  A per-file MI drop beyond the configured
104
105
  `delivery.quality.gates.maintainability.tolerance` (default 0.5pt) requires
105
106
  a refactor in the same Story rather than a baseline bump.
@@ -181,6 +181,14 @@ persists, via `carryProvenanceFooters`
181
181
  The carry is additive, union-preserving and idempotent, so a resumed persist
182
182
  cannot stack footers and a hand-authored fingerprint is never dropped.
183
183
 
184
+ **Persist records the ledger and the labels too.** It stamps the seed's
185
+ `audit::<dimension>` labels — the dedup corpus is listed by them, and an indexed
186
+ run answers lookups from that pool without reaching the provider, so a footer
187
+ alone leaves a plan-path Story invisible — and records each Story's
188
+ **attributed** identities. Union-only identities are not recorded: every sibling
189
+ carries every fingerprint, so an owner would be a coin flip; persist says so on
190
+ stderr. Author per-Story `provenance` to record them.
191
+
184
192
  This is deliberately not an authoring step. It used to be: the footers reached
185
193
  the seed and stopped there, leaving the authoring agent to notice HTML comments
186
194
  in a one-pager and copy them forward — a remembered step, which is to say no
@@ -243,6 +251,14 @@ every Story that has a resolvable blocker with a canonical
243
251
  GitHub `blocked_by` relations. An edge whose target was never opened (deduped,
244
252
  ledger-suppressed) drops rather than becoming a `blocked by #undefined`.
245
253
 
254
+ **This pass also records the ledger.** The `--ids` map is the only artifact
255
+ carrying the numbers just opened, so `--wire-edges` folds in the cross-run
256
+ record: each mapped group's findings are written `filed` against its Issue
257
+ (`--ledger <path>`, default `baselines/audit-ledger.json`; `--dry-run`
258
+ suppresses the write). The record runs **before** the provider loads, so a host
259
+ with no `gh` still remembers what it filed. Without it nothing ever writes
260
+ `filed` and the ledger suppresses nothing.
261
+
246
262
  **Do not skip this.** `/mandrel-deliver` has no other source for this cohort's order:
247
263
  its footprint guard ignores the shared provenance footers, so an unwired cohort
248
264
  is genuinely unordered and `/mandrel-deliver` will co-dispatch Stories the edges say
@@ -277,31 +293,45 @@ the `classifications` array:
277
293
  is skipped by default; flag in the Phase 7 summary so the operator
278
294
  can decide whether to reopen.
279
295
 
280
- `routeFinding` is handed a `searchIssues` port adapted from the
281
- project's existing GitHub provider — the actual search runs against the
282
- repo's open + closed issues for each sha in the group, and the helper's
283
- footer-confirmation step filters out false-positive search hits whose
284
- body mentions the sha in prose without the canonical marker. The
285
- workflow owns **no** parallel dedup or footer-parsing code: the
286
- fingerprint, footer round-trip, and routing all live in that one shared
287
- module.
288
-
289
- Dedup runs in **two stages** when a provider resolves: a
290
- meaning-first **semantic candidate** pass (`searchCandidates`, wired to
291
- [`lib/findings/semantic-issue-search.js`](../scripts/lib/findings/semantic-issue-search.js))
292
- runs FIRST and widens the net across open + closed issues; the exact
293
- **fingerprint / semantic-key** confirmation runs SECOND. A finding whose title
294
- was reworded but whose *location* is unchanged still confirms against the Issue
295
- that already tracks that location, because the audit filers stamp a
296
- location-based `audit-semantic-keys` footer alongside the `audit-fingerprints`
297
- footer. Filings from the
296
+ `routeFinding` reads open + closed issues through two ports, which **both run
297
+ and union their pools** — the exact `searchIssues` lookup, and the meaning-first
298
+ `searchCandidates` pass wired to
299
+ [`lib/findings/semantic-issue-search.js`](../scripts/lib/findings/semantic-issue-search.js).
300
+ Confirmation then filters that union by footer, dropping hits whose body
301
+ mentions a sha in prose without the canonical marker. A finding reworded but
302
+ unmoved still confirms, because the filers stamp a location-based
303
+ `audit-semantic-keys` footer
304
+ beside `audit-fingerprints`; so do
298
305
  [`retro-proposals-graduator`](../scripts/lib/feedback-loop/retro-proposals-graduator.js)
299
- carry the same canonical `audit-fingerprints` footer, so a sweep recognizes a
300
- graduator-filed issue and never re-files it.
306
+ filings, which a sweep therefore never re-files.
307
+
308
+ Where the corpus is pre-fetched — either off the list endpoint or from
309
+ `--issues-file` — the exact lookup is answered from that local index and the
310
+ search API is spent only on findings with no exact hit. The workflow owns **no**
311
+ parallel dedup or footer-parsing code: fingerprint, footer round-trip, and
312
+ routing all live in that one shared module.
313
+
314
+ ### When there is no `gh` CLI
315
+
316
+ **No GitHub access** (air-gapped): pass `--no-provider` to `--scan`. Every
317
+ group is classified `create` and the operator is told dedupe was skipped — a
318
+ re-run opens duplicates.
319
+
320
+ **Reachable, but not through `gh`** (a cloud sandbox: no `gh`, no direct API,
321
+ MCP fine): fetch the corpus yourself. List every issue labelled `audit::*` at
322
+ state `all` — `mcp__github__list_issues` or any other path — write the raw
323
+ result as a JSON array, and pass it:
324
+
325
+ ```bash
326
+ node .agents/scripts/audit-to-stories.js --scan --no-provider \
327
+ --issues-file temp/audits/issues.json --glob "temp/audits/audit-*-results.md"
328
+ ```
301
329
 
302
- When no provider is available (e.g. air-gapped dev environment), pass
303
- `--no-provider` to the `--scan` step — every group is classified
304
- `create` and the operator is informed that dedupe was skipped.
330
+ Real `skip-open` / `skip-reoccurring` classifications come back. The corpus is
331
+ normalised on load, so a raw list result works as-is; only `number` and `body`
332
+ are read. An unreadable file is a hard error, never a silent fall-back to an
333
+ unchecked run; an **empty** array — a valid first sweep — is reported with its
334
+ count so it cannot pass for a failed fetch.
305
335
 
306
336
  ### Cross-run ledger
307
337
 
@@ -314,8 +344,12 @@ shape). Each entry is keyed by the finding's fingerprint plus a location-based
314
344
  (`new | filed | fixed | accepted-risk | regressed`). A finding whose tracking
315
345
  Issue was closed as `not_planned` becomes `accepted-risk` and is **suppressed**
316
346
  on every later scan; a `fixed` finding that re-appears becomes `regressed`. The
317
- ledger is written by the unattended `--auto` sweep and by any `--scan --ledger`
318
- run; the plain `--scan` path leaves it untouched.
347
+ ledger is written by the unattended `--auto` sweep, by any `--scan --ledger`
348
+ run, by the Phase 5c `--wire-edges` pass, and by `plan-persist` on the Phase 5a
349
+ chained path — the two filing paths both record what they filed; the
350
+ plain `--scan` path leaves it untouched. A finding whose resolved Issue is open
351
+ is recorded `filed` and is known on re-detection; a closed Issue still outranks
352
+ that.
319
353
 
320
354
  ## Phase 7 — Summary & cleanup
321
355
 
@@ -391,8 +425,10 @@ The routine shape is **lenses full-scope → dry-run → live with a ledger PR**
391
425
  from `delivery.auditToStories.severityFloor` (default `high`, overridable with
392
426
  `--severity`), applies the two-stage dedup, reconciles the cross-run ledger,
393
427
  and prints a run-summary JSON (create / skip-open / skip-reoccurring /
394
- suppressed-by-ledger tallies, plus the re-detected open Issue numbers an
395
- operator may want a "re-detected" comment on). `--dry-run` performs zero GitHub
428
+ suppressed-by-ledger tallies, the `create`-classified group keys the `--ids`
429
+ map is built from, plus the re-detected open Issue numbers an operator may want
430
+ a "re-detected" comment on). It opens no Issues itself, so run Phase 5c after
431
+ filing or the sweep's memory stays empty. `--dry-run` performs zero GitHub
396
432
  writes and skips the ledger write, emitting only the summary.
397
433
 
398
434
  `--auto` **fails closed on any `summary.reportFailures[]` entry** (Phase 1): an