mandrel 2.24.0 → 2.26.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 (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -41,16 +41,31 @@
41
41
  * tier along with the per-AC-cluster `--epic <id> --cluster <id>` mode that
42
42
  * scored an Epic `## Acceptance Table` against a `main..epic/<id>` diff.)
43
43
  *
44
+ * One gate call per round (Story #4951). A round may fan out into N parallel
45
+ * maker-blind cluster critics, but their per-cluster verdicts are merged by
46
+ * the caller into ONE verdict — `criteria[]` in acceptance-array order — and
47
+ * scored here exactly once. Invoking the gate per cluster instead would burn
48
+ * one Story-level round per cluster (distinct fingerprints defeat the replay
49
+ * guard) and race the `signals.ndjson` round ledger. `--expected-criteria`
50
+ * makes that contract enforceable: a partial (single-cluster) verdict is
51
+ * rejected before scoring, so the mistake costs no round.
52
+ *
44
53
  * CLI:
45
- * --story <id> Story ID (required).
46
- * --verdict <path> Path to the round's verdict JSON (required).
47
- * --no-signal Suppress the signal emit (tests).
54
+ * --story <id> Story ID (required).
55
+ * --verdict <path> Path to the round's verdict JSON (required).
56
+ * --expected-criteria <n> Reject a verdict not covering exactly n criteria.
57
+ * --no-signal Suppress the signal emit (tests).
48
58
  *
49
59
  * Stdout: a single JSON envelope
50
60
  * { storyId, epicId, decision, round, cap, capReached, totalCriteria,
51
- * metCount, unmetCriteria[], signalEmitted }
61
+ * metCount, unmetCriteria[], signalEmitted, replay, verdictFingerprint }
52
62
  * (`epicId` is retained as a always-null field for envelope stability.)
53
63
  *
64
+ * Reading is free (Story #4874): re-invoking the gate over a verdict the
65
+ * ledger has already scored replays that round (`replay: true`,
66
+ * `signalEmitted: false`) instead of consuming one, so an unchanged verdict
67
+ * can never escalate from `redraft` to `block` by being looked at twice.
68
+ *
54
69
  * @see .agents/scripts/lib/orchestration/acceptance-eval-decision.js
55
70
  * @see .agents/schemas/acceptance-eval-verdict.schema.json
56
71
  */
@@ -69,8 +84,9 @@ import { Logger } from './lib/Logger.js';
69
84
  import { appendSignal } from './lib/observability/signals-writer.js';
70
85
  import {
71
86
  buildAcceptanceEvalSignal,
87
+ computeVerdictFingerprint,
72
88
  decideAcceptanceEval,
73
- deriveAcceptanceEvalRound,
89
+ resolveAcceptanceEvalRound,
74
90
  } from './lib/orchestration/acceptance-eval-decision.js';
75
91
 
76
92
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -137,6 +153,7 @@ function parseCliArgs(argv) {
137
153
  options: {
138
154
  story: { type: 'string' },
139
155
  verdict: { type: 'string' },
156
+ 'expected-criteria': { type: 'string' },
140
157
  'no-signal': { type: 'boolean', default: false },
141
158
  },
142
159
  strict: false,
@@ -145,10 +162,69 @@ function parseCliArgs(argv) {
145
162
  return {
146
163
  storyId: Number.isInteger(storyId) && storyId > 0 ? storyId : null,
147
164
  verdictPath: values.verdict ?? null,
165
+ expectedCriteria: values['expected-criteria'] ?? null,
148
166
  emitSignal: values['no-signal'] !== true,
149
167
  };
150
168
  }
151
169
 
170
+ /**
171
+ * The merge contract, stated once so both the flag error and the coverage
172
+ * error name the same shape the caller has to produce.
173
+ */
174
+ const MERGE_CONTRACT =
175
+ 'One round = N parallel cluster critics -> ONE merged verdict -> ONE gate call: ' +
176
+ "merge every cluster's records into a single criteria[] in acceptance[] order, " +
177
+ 'one per acceptance item, before scoring.';
178
+
179
+ /**
180
+ * Resolve the optional `--expected-criteria` flag to a positive integer, or
181
+ * `null` when the flag is absent (which preserves the pre-#4951 behaviour
182
+ * exactly — no coverage assertion is made).
183
+ *
184
+ * Exported for tests.
185
+ *
186
+ * @param {string|null|undefined} raw
187
+ * @returns {number|null}
188
+ */
189
+ export function resolveExpectedCriteria(raw) {
190
+ if (raw === null || raw === undefined) return null;
191
+ // Digits only. `Number.parseInt` stops at the first non-digit, so `4abc`
192
+ // resolved to 4 — a guard whose entire job is to reject a wrong-sized
193
+ // verdict was itself accepting a malformed count, and a typo'd `--expected-
194
+ // criteria` would then wave through a verdict of the wrong length.
195
+ const text = String(raw).trim();
196
+ const expected = /^\d+$/.test(text) ? Number(text) : Number.NaN;
197
+ if (!Number.isInteger(expected) || expected < 1) {
198
+ throw new Error(
199
+ `acceptance-eval: --expected-criteria must be a positive integer (the Story's acceptance[] count). ${MERGE_CONTRACT}`,
200
+ );
201
+ }
202
+ return expected;
203
+ }
204
+
205
+ /**
206
+ * Reject a verdict that does not cover exactly `expectedCriteria` criteria.
207
+ *
208
+ * Called **before** `runAcceptanceEval`, which is where the round ledger is
209
+ * read and appended — so a partial cluster verdict handed to the gate by
210
+ * mistake costs no round and can never escalate a `redraft` into a `block`.
211
+ *
212
+ * Exported for tests.
213
+ *
214
+ * @param {object} verdict — schema-validated verdict.
215
+ * @param {number|null} expectedCriteria — `null` disables the assertion.
216
+ * @returns {void}
217
+ */
218
+ export function assertCriteriaCoverage(verdict, expectedCriteria) {
219
+ if (expectedCriteria === null) return;
220
+ const actual = Array.isArray(verdict?.criteria) ? verdict.criteria.length : 0;
221
+ if (actual === expectedCriteria) return;
222
+ throw new Error(
223
+ `acceptance-eval: verdict covers ${actual} criteria but --expected-criteria is ${expectedCriteria}. ` +
224
+ `${MERGE_CONTRACT} No round was consumed.`,
225
+ );
226
+ }
227
+
152
228
  /**
153
229
  * Compose the operator-facing envelope and emit the per-criterion signal.
154
230
  *
@@ -166,7 +242,8 @@ function parseCliArgs(argv) {
166
242
  * self-reported `round` is never load-bearing for the cap.
167
243
  * @param {object} [deps]
168
244
  * @param {Function} [deps.appendSignalFn]
169
- * @param {Function} [deps.deriveRoundFn]
245
+ * @param {Function} [deps.resolveRoundFn]
246
+ * @param {Function} [deps.fingerprintFn]
170
247
  * @returns {Promise<{ envelope: object, exitCode: number }>}
171
248
  */
172
249
  export async function runAcceptanceEval(
@@ -175,13 +252,23 @@ export async function runAcceptanceEval(
175
252
  ) {
176
253
  const {
177
254
  appendSignalFn = appendSignal,
178
- deriveRoundFn = deriveAcceptanceEvalRound,
255
+ resolveRoundFn = resolveAcceptanceEvalRound,
256
+ fingerprintFn = computeVerdictFingerprint,
179
257
  } = deps;
180
258
  const { maxRounds } = getAcceptanceEval(config);
259
+ const verdictFingerprint = fingerprintFn(verdict);
260
+ // Story #4874: re-reading an already-scored verdict is a replay — it
261
+ // reports the round that verdict was scored under and appends nothing, so
262
+ // observation alone can never advance the counter or escalate a redraft.
263
+ const resolved = resolveRoundFn({
264
+ epicId: null,
265
+ storyId,
266
+ config,
267
+ verdictFingerprint,
268
+ });
269
+ const replay = resolved.replay === true;
181
270
  const resolvedRound =
182
- Number.isInteger(round) && round >= 1
183
- ? round
184
- : deriveRoundFn({ epicId: null, storyId, config });
271
+ Number.isInteger(round) && round >= 1 ? round : resolved.round;
185
272
  const outcome = decideAcceptanceEval({
186
273
  verdict,
187
274
  maxRounds,
@@ -189,9 +276,14 @@ export async function runAcceptanceEval(
189
276
  });
190
277
 
191
278
  let signalEmitted = false;
192
- if (emitSignal) {
279
+ if (emitSignal && !replay) {
193
280
  const signal = {
194
- ...buildAcceptanceEvalSignal({ storyId, epicId: null, outcome }),
281
+ ...buildAcceptanceEvalSignal({
282
+ storyId,
283
+ epicId: null,
284
+ outcome,
285
+ verdictFingerprint,
286
+ }),
195
287
  ts: new Date().toISOString(),
196
288
  };
197
289
  try {
@@ -228,6 +320,10 @@ export async function runAcceptanceEval(
228
320
  evidence: c.evidence,
229
321
  })),
230
322
  signalEmitted,
323
+ // True when this invocation re-read a verdict the ledger had already
324
+ // scored: the round was replayed, not advanced, and nothing was appended.
325
+ replay,
326
+ verdictFingerprint,
231
327
  };
232
328
 
233
329
  // `block` is the only non-zero exit: the loop has exhausted its bounded
@@ -268,11 +364,13 @@ export async function runAcceptanceEvalCli(
268
364
  runAcceptanceEvalImpl = runAcceptanceEval,
269
365
  logger = Logger,
270
366
  } = deps;
271
- const { storyId, verdictPath, emitSignal } = parseCliArgs(argv);
367
+ const { storyId, verdictPath, expectedCriteria, emitSignal } =
368
+ parseCliArgs(argv);
369
+ const expected = resolveExpectedCriteria(expectedCriteria);
272
370
 
273
371
  if (!storyId) {
274
372
  throw new Error(
275
- 'Usage: node acceptance-eval.js --story <id> --verdict <path> [--no-signal]',
373
+ 'Usage: node acceptance-eval.js --story <id> --verdict <path> [--expected-criteria <n>] [--no-signal]',
276
374
  );
277
375
  }
278
376
  if (!verdictPath) {
@@ -303,6 +401,11 @@ export async function runAcceptanceEvalCli(
303
401
 
304
402
  const verdict = validateVerdictImpl(parsed);
305
403
 
404
+ // Story #4951: a merged verdict must cover every acceptance[] item. This
405
+ // runs before the round ledger is touched, so a partial cluster verdict is
406
+ // a free mistake.
407
+ assertCriteriaCoverage(verdict, expected);
408
+
306
409
  // A verdict whose embedded storyId disagrees with the CLI flag is a
307
410
  // wiring error worth failing on, not a silent mismatch.
308
411
  if (Number.isInteger(verdict.storyId) && verdict.storyId !== storyId) {
@@ -346,12 +449,18 @@ runAsCli(import.meta.url, main, {
346
449
  source: 'acceptance-eval',
347
450
  usage: {
348
451
  invocation:
349
- 'node .agents/scripts/acceptance-eval.js --story <id> --verdict <path> [--no-signal]',
452
+ 'node .agents/scripts/acceptance-eval.js --story <id> --verdict <path> [--expected-criteria <n>] [--no-signal]',
350
453
  summary:
351
454
  "Score an authored acceptance verdict against the Story's acceptance[] criteria and emit the bounded loop's proceed / redraft / block decision.",
352
455
  flags: [
353
456
  ['--story <id>', 'GitHub issue number of the Story (required).'],
354
457
  ['--verdict <path>', 'Path to the authored verdict JSON (required).'],
458
+ [
459
+ '--expected-criteria <n>',
460
+ 'Reject — before scoring, consuming no round — a verdict whose criteria[] ' +
461
+ "length is not n. Pass the Story's acceptance[] count so a partial " +
462
+ 'cluster verdict cannot be scored as the round.',
463
+ ],
355
464
  [
356
465
  '--no-signal',
357
466
  "Skip appending the per-criterion signal to the Story's signals ledger.",
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * CLI: baseline hotspot engine for the `/audit-baselines` lens (Story #4902).
5
+ *
6
+ * Turns the committed `baselines/` folder into ranked hotspot clusters,
7
+ * gate-surface health signals, trend deltas, and floor-tightening headroom —
8
+ * the deterministic evidence half of a baseline review, so the lens spends
9
+ * its judgment on findings instead of re-deriving the numbers by hand.
10
+ *
11
+ * Read-only by contract: nothing under `baselines/` is written, and no test,
12
+ * coverage, or mutation suite is run. The only file this process creates is
13
+ * the envelope at `--out`.
14
+ *
15
+ * Exit 0 whenever evidence was assembled — findings are evidence, not a gate.
16
+ * A missing git history, absent friction ledger, or unresolvable import graph
17
+ * are reported as degradations and still exit 0. Only an unwritable `--out`
18
+ * (or a missing one) is a failure.
19
+ *
20
+ * Usage:
21
+ * node .agents/scripts/audit-baselines.js --out temp/audit-baselines/envelope.json
22
+ */
23
+
24
+ // Fail-fast if the framework's runtime deps are not installed — must be the
25
+ // first import so the check runs before any third-party-importing sibling
26
+ // module is evaluated (Story #3432).
27
+ import './lib/runtime-deps/ensure-installed.js';
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+ import process from 'node:process';
31
+ import {
32
+ DEFAULT_HOTSPOT_LIMIT,
33
+ runEngine,
34
+ summarize,
35
+ } from './lib/audit-baselines/engine.js';
36
+ import { DEFAULT_TOP_N } from './lib/audit-baselines/outliers.js';
37
+ import { buildBaselineSchemaAjv } from './lib/baseline-schema-registry.js';
38
+ import { defineFlags } from './lib/cli-args.js';
39
+ import { runAsCli } from './lib/cli-utils.js';
40
+
41
+ const ENVELOPE_SCHEMA_FILE = 'audit-baselines-envelope.schema.json';
42
+
43
+ const FLAG_SPEC = {
44
+ out: { type: 'string' },
45
+ cwd: { type: 'string' },
46
+ 'top-n': { type: 'integer' },
47
+ 'hotspot-limit': { type: 'integer' },
48
+ 'trend-depth': { type: 'integer' },
49
+ };
50
+
51
+ /**
52
+ * Validate the envelope against its shipped schema through the shared
53
+ * baseline schema registry. Throws with the AJV error list on mismatch —
54
+ * a malformed envelope is an engine bug, not evidence.
55
+ *
56
+ * @param {object} envelope
57
+ * @returns {void}
58
+ */
59
+ export function assertEnvelope(envelope) {
60
+ const validate = buildBaselineSchemaAjv().getSchema(ENVELOPE_SCHEMA_FILE);
61
+ if (!validate) {
62
+ throw new Error(
63
+ `[audit-baselines] ${ENVELOPE_SCHEMA_FILE} is not registered in the baseline schema registry`,
64
+ );
65
+ }
66
+ if (validate(envelope)) return;
67
+ const detail = (validate.errors ?? [])
68
+ .map((e) => `${e.instancePath || '/'} ${e.message}`)
69
+ .join('; ');
70
+ throw new Error(
71
+ `[audit-baselines] envelope failed schema validation: ${detail}`,
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Assemble the envelope, validate it, write it, and return the stdout
77
+ * summary. Exported so tests drive the whole pipeline without spawning.
78
+ *
79
+ * @param {{ argv?: string[], cwd?: string, stdout?: { write: (s: string) => void } }} [opts]
80
+ * @returns {Promise<number>} exit code
81
+ */
82
+ export async function runCli({
83
+ argv = process.argv.slice(2),
84
+ cwd = process.cwd(),
85
+ stdout = process.stdout,
86
+ } = {}) {
87
+ const { values } = defineFlags(FLAG_SPEC, argv);
88
+ if (!values.out) {
89
+ throw new Error('[audit-baselines] --out <path> is required');
90
+ }
91
+ const repoRoot = path.resolve(values.cwd ?? cwd);
92
+ const outPath = path.resolve(repoRoot, values.out);
93
+
94
+ const envelope = runEngine({
95
+ cwd: repoRoot,
96
+ topN: values.topN ?? DEFAULT_TOP_N,
97
+ hotspotLimit: values.hotspotLimit ?? DEFAULT_HOTSPOT_LIMIT,
98
+ trendDepth: values.trendDepth ?? 5,
99
+ });
100
+ assertEnvelope(envelope);
101
+
102
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
103
+ fs.writeFileSync(outPath, `${JSON.stringify(envelope, null, 2)}\n`, 'utf8');
104
+
105
+ stdout.write(`${JSON.stringify(summarize(envelope, outPath), null, 2)}\n`);
106
+ return 0;
107
+ }
108
+
109
+ runAsCli(import.meta.url, async () => runCli(), {
110
+ source: 'audit-baselines',
111
+ propagateExitCode: true,
112
+ errorPrefix: '[audit-baselines] ❌ Fatal error',
113
+ usage: {
114
+ invocation:
115
+ 'node .agents/scripts/audit-baselines.js --out <path> [--cwd <dir>] [--top-n <n>] [--hotspot-limit <n>] [--trend-depth <n>]',
116
+ summary:
117
+ 'Read-only baseline hotspot engine: extract bounded per-gate outliers, cluster them per file, rank by severity x churn x import in-degree x friction, and report gate-surface health, trend deltas, and floor headroom.',
118
+ flags: [
119
+ ['--out <path>', 'Write the JSON envelope here (required).'],
120
+ ['--cwd <dir>', 'Repository root to analyse (default: cwd).'],
121
+ [
122
+ '--top-n <n>',
123
+ `Outlier rows extracted per gate (default: ${DEFAULT_TOP_N}).`,
124
+ ],
125
+ [
126
+ '--hotspot-limit <n>',
127
+ `Hotspot clusters emitted (default: ${DEFAULT_HOTSPOT_LIMIT}).`,
128
+ ],
129
+ ['--trend-depth <n>', 'Baseline commits sampled per kind (default: 5).'],
130
+ ],
131
+ notes: [
132
+ 'Never writes under baselines/ and never runs a test, coverage, or mutation suite.',
133
+ 'Exit codes:\n 0 evidence assembled (including every degraded input)\n 1 the envelope could not be built or written',
134
+ ],
135
+ },
136
+ });
@@ -1,21 +1,20 @@
1
1
  /**
2
- * audit-labels-bootstrap.js — Idempotently create the `audit::<lens>`
3
- * label taxonomy in the configured GitHub repo.
2
+ * audit-labels-bootstrap.js — Idempotently create the audit label taxonomy in
3
+ * the configured GitHub repo.
4
4
  *
5
5
  * Run this once per repo before `/audit-to-stories` opens its first
6
6
  * Story. Re-runs are safe — existing labels are skipped, only missing
7
7
  * ones are created. Story #2583 acceptance criterion #6.
8
8
  *
9
- * The lens list is the shared SSOT `AUDIT_LENSES`
10
- * (`lib/audit-to-stories/audit-lenses.js`), one per `/audit-<lens>` workflow
11
- * under `.agents/workflows/`. Sourcing the list from the same module that
12
- * `build-story-body.js` derives `audit::<lens>` labels from guarantees the
13
- * label producer (this bootstrap) and the label deriver (story-body) cannot
14
- * drift a finding from `audit-documentation-results.md` derives
15
- * `audit::documentation`, and this bootstrap creates exactly that label
16
- * (Story #4195). The per-lens colour/description metadata lives in
17
- * `LENS_META` below; adding a new `audit-*` workflow means adding its lens to
18
- * `AUDIT_LENSES` and (optionally) a `LENS_META` entry.
9
+ * This CLI is a thin creator over
10
+ * [`lib/audit-to-stories/audit-label-taxonomy.js`](lib/audit-to-stories/audit-label-taxonomy.js),
11
+ * which is the SSOT for **every** label an audit sweep creates or generates:
12
+ * the `audit::<lens>` set (derived from the shared `AUDIT_LENSES` list, one per
13
+ * `/audit-<lens>` workflow) plus the story-axis labels the filer applies. The
14
+ * creator and the generator (`build-story-body.js`) read that one list, so the
15
+ * bootstrap cannot fall behind the filer — the drift that left `risk::high`
16
+ * generated but defined nowhere (Story #4877), and that made `audit::<dimension>`
17
+ * labels mint from free-form prose before Story #4195.
19
18
  *
20
19
  * Delegates to `gh label create` so the script works without any
21
20
  * provider plumbing — `gh auth status` is the only prerequisite. Per
@@ -26,88 +25,11 @@
26
25
  import process from 'node:process';
27
26
  import { parseArgs } from 'node:util';
28
27
 
29
- import { AUDIT_LENSES } from './lib/audit-to-stories/audit-lenses.js';
28
+ import { AUDIT_LABEL_TAXONOMY } from './lib/audit-to-stories/audit-label-taxonomy.js';
30
29
  import { runAsCli } from './lib/cli-utils.js';
31
30
  import { resolveConfig } from './lib/config-resolver.js';
32
31
  import { gh as defaultGh, GhExecError } from './lib/gh-exec.js';
33
32
 
34
- /**
35
- * Per-lens label presentation. Keyed by canonical lens name. A lens absent
36
- * from this map falls back to {@link DEFAULT_LENS_META} so a newly-added
37
- * `AUDIT_LENSES` entry still gets a label without a hard requirement to
38
- * register colour/description here first.
39
- */
40
- const LENS_META = Object.freeze({
41
- accessibility: {
42
- color: 'c5def5',
43
- description: 'Audit-sourced finding: WCAG accessibility conformance',
44
- },
45
- architecture: {
46
- color: '6f42c1',
47
- description: 'Audit-sourced finding: architectural concerns',
48
- },
49
- 'clean-code': {
50
- color: '0e8a16',
51
- description: 'Audit-sourced finding: clean-code / maintainability',
52
- },
53
- dependencies: {
54
- color: 'd4c5f9',
55
- description: 'Audit-sourced finding: dependencies / supply chain',
56
- },
57
- devops: {
58
- color: 'fbca04',
59
- description: 'Audit-sourced finding: DevOps / CI / CD',
60
- },
61
- documentation: {
62
- color: '1d76db',
63
- description: 'Audit-sourced finding: documentation staleness / gaps',
64
- },
65
- navigability: {
66
- color: 'bfdadc',
67
- description: 'Audit-sourced finding: route / nav reachability',
68
- },
69
- performance: {
70
- color: 'b60205',
71
- description: 'Audit-sourced finding: performance / latency',
72
- },
73
- privacy: {
74
- color: 'fef2c0',
75
- description: 'Audit-sourced finding: privacy / data handling',
76
- },
77
- quality: {
78
- color: '0052cc',
79
- description: 'Audit-sourced finding: test quality / coverage gaps',
80
- },
81
- security: {
82
- color: 'b60205',
83
- description: 'Audit-sourced finding: security / OWASP',
84
- },
85
- seo: {
86
- color: 'fbca04',
87
- description: 'Audit-sourced finding: SEO / discoverability',
88
- },
89
- sre: {
90
- color: '0052cc',
91
- description: 'Audit-sourced finding: SRE / observability / reliability',
92
- },
93
- 'ux-ui': {
94
- color: 'd4c5f9',
95
- description: 'Audit-sourced finding: UX / UI concerns',
96
- },
97
- });
98
-
99
- const DEFAULT_LENS_META = Object.freeze({
100
- color: 'ededed',
101
- description: 'Audit-sourced finding',
102
- });
103
-
104
- const DIMENSIONS = Object.freeze(
105
- AUDIT_LENSES.map((name) => ({
106
- name,
107
- ...(LENS_META[name] ?? DEFAULT_LENS_META),
108
- })),
109
- );
110
-
111
33
  async function labelExists(gh, owner, repo, name) {
112
34
  try {
113
35
  const list = await gh.label.list(
@@ -166,9 +88,8 @@ export async function bootstrapAuditLabels({
166
88
  const skipped = [];
167
89
  const failed = [];
168
90
 
169
- for (const dim of DIMENSIONS) {
170
- const labelName = `audit::${dim.name}`;
171
- const candidate = { ...dim, name: labelName };
91
+ for (const candidate of AUDIT_LABEL_TAXONOMY) {
92
+ const labelName = candidate.name;
172
93
 
173
94
  if (dryRun) {
174
95
  created.push(labelName);
@@ -190,7 +111,7 @@ export async function bootstrapAuditLabels({
190
111
  }
191
112
  }
192
113
 
193
- return { created, skipped, failed, total: DIMENSIONS.length };
114
+ return { created, skipped, failed, total: AUDIT_LABEL_TAXONOMY.length };
194
115
  }
195
116
 
196
117
  /**
@@ -241,7 +162,7 @@ export function formatBootstrapReport(result) {
241
162
  };
242
163
  }
243
164
 
244
- export const __testing = { DIMENSIONS };
165
+ export const __testing = { AUDIT_LABEL_TAXONOMY };
245
166
 
246
167
  async function main() {
247
168
  const { values } = parseArgs({
@@ -47,17 +47,34 @@ import { parseAuditReports } from './lib/audit-to-stories/parse-audit-md.js';
47
47
  import { buildPlanSeedMarkdown } from './lib/audit-to-stories/seed-from-findings.js';
48
48
  import { runAsCli } from './lib/cli-utils.js';
49
49
  import { searchSemanticCandidates } from './lib/findings/semantic-issue-search.js';
50
+ import { SEVERITIES, SEVERITY_RANK } from './lib/findings/severity.js';
50
51
  import { Logger } from './lib/Logger.js';
51
52
  import { parse as parseStoryBody } from './lib/story-body/story-body.js';
52
53
 
53
- const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
54
54
  const DEFAULT_GLOB = 'temp/audits/audit-*-results.md';
55
55
  const FAN_OUT_REPORT = 'audit-fan-out-results.md';
56
56
 
57
+ /**
58
+ * Does `finding` clear the `threshold` severity floor?
59
+ *
60
+ * `SEVERITY_RANK` is imported from the canonical scale rather than declared
61
+ * here (Story #4877). The local copy this replaces ranked only four levels
62
+ * (`critical|high|medium|low`), so `info` — the canonical floor — ranked `0`,
63
+ * below even `--severity low`, and every informational finding was silently
64
+ * dropped from every filtered run. Sourcing the ranking from the SSOT means a
65
+ * level cannot exist in the vocabulary and be invisible to the filter.
66
+ *
67
+ * An unrecognised or absent severity still ranks below every real floor: it
68
+ * failed to parse, so it is not evidence that a threshold was met.
69
+ *
70
+ * @param {{ severity?: string }} finding
71
+ * @param {string} [threshold] — a canonical level, `'all'`, or falsy for no floor.
72
+ * @returns {boolean}
73
+ */
57
74
  function meetsSeverity(finding, threshold) {
58
75
  if (!threshold || threshold === 'all') return true;
59
76
  const minRank = SEVERITY_RANK[threshold] ?? 0;
60
- const fRank = SEVERITY_RANK[finding.severity] ?? 0;
77
+ const fRank = SEVERITY_RANK[finding.severity] ?? -1;
61
78
  return fRank >= minRank;
62
79
  }
63
80
 
@@ -77,8 +94,21 @@ function readReports(paths) {
77
94
  }));
78
95
  }
79
96
 
97
+ /**
98
+ * Count findings per severity bucket. The buckets are the canonical levels plus
99
+ * `unknown` for a finding whose severity did not parse — kept as a visible
100
+ * bucket so an unparseable severity is reported rather than absorbed into a
101
+ * real level. Derived from `SEVERITIES` so a new level appears in the tally
102
+ * automatically instead of falling into `unknown` (Story #4877).
103
+ *
104
+ * @param {Array<{ severity?: string }>} findings
105
+ * @returns {Record<string, number>}
106
+ */
80
107
  function tallyBySeverity(findings) {
81
- const t = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
108
+ const t = {
109
+ ...Object.fromEntries(SEVERITIES.map((s) => [s, 0])),
110
+ unknown: 0,
111
+ };
82
112
  for (const f of findings) {
83
113
  if (Object.hasOwn(t, f.severity)) t[f.severity] += 1;
84
114
  else t.unknown += 1;