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
@@ -0,0 +1,193 @@
1
+ /**
2
+ * weights.js — the three ranking multipliers, and their degradations
3
+ * (Story #4902).
4
+ *
5
+ * A hotspot's severity says how bad the code measures. These three say how
6
+ * much that badness costs: how often the file changes (churn), how much of
7
+ * the repository depends on it (import in-degree), and how often agents have
8
+ * actually tripped over it (friction signals).
9
+ *
10
+ * All three are **optional inputs**. A shallow clone has no git history, a
11
+ * fresh checkout has no friction ledger, and a consumer whose sources live
12
+ * outside the scanned roots has no resolvable import graph. Each degrades to
13
+ * a neutral multiplier of exactly 1.0 — never 0 (which would erase the
14
+ * hotspot) and never a guess. The engine exits 0 in every degraded case.
15
+ *
16
+ * @module lib/audit-baselines/weights
17
+ */
18
+
19
+ import { execFileSync } from 'node:child_process';
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { computeInDegree, resolveRepoGraph } from '../import-graph.js';
23
+
24
+ /** Neutral multiplier every degraded weight collapses to. */
25
+ const NEUTRAL_WEIGHT = 1.0;
26
+
27
+ /**
28
+ * Saturating count → multiplier in `[1, 2)`. Zero observations gives exactly
29
+ * `NEUTRAL_WEIGHT`, so "no signal" and "signal says nothing notable" are the
30
+ * same number — the degradation is indistinguishable from an honest zero,
31
+ * which is the point: neither should move the ranking.
32
+ *
33
+ * @param {number} count
34
+ * @param {number} half count at which the multiplier reaches 1.5
35
+ * @returns {number}
36
+ */
37
+ function saturate(count, half) {
38
+ if (!Number.isFinite(count) || count <= 0) return NEUTRAL_WEIGHT;
39
+ return NEUTRAL_WEIGHT + count / (count + half);
40
+ }
41
+
42
+ /**
43
+ * Count commits touching each file in the recent history window.
44
+ *
45
+ * @param {{ cwd: string, windowDays?: number, run?: Function }} args
46
+ * @returns {{ counts: Map<string, number>, degraded: boolean }}
47
+ * `degraded` is true when git could not answer at all — not a git work
48
+ * tree, no commits yet, or the binary is unavailable.
49
+ */
50
+ export function readChurn({ cwd, windowDays = 180, run = execFileSync }) {
51
+ let stdout;
52
+ try {
53
+ stdout = run(
54
+ 'git',
55
+ [
56
+ 'log',
57
+ `--since=${windowDays}.days.ago`,
58
+ '--name-only',
59
+ '--pretty=format:',
60
+ '--no-renames',
61
+ ],
62
+ {
63
+ cwd,
64
+ encoding: 'utf8',
65
+ maxBuffer: 64 * 1024 * 1024,
66
+ // git narrates "not a git repository" on stderr; the degradation is
67
+ // reported in the envelope, not shouted at the operator.
68
+ stdio: ['ignore', 'pipe', 'ignore'],
69
+ },
70
+ );
71
+ } catch {
72
+ return { counts: new Map(), degraded: true };
73
+ }
74
+ const counts = new Map();
75
+ for (const line of String(stdout).split('\n')) {
76
+ const file = line.trim();
77
+ if (file.length === 0) continue;
78
+ counts.set(file, (counts.get(file) ?? 0) + 1);
79
+ }
80
+ return { counts, degraded: false };
81
+ }
82
+
83
+ /**
84
+ * Import in-degree per module, keyed by repo-relative posix path.
85
+ *
86
+ * @param {{ cwd: string, graph?: Map<string, string[]> | null }} args
87
+ * @returns {{ degrees: Map<string, number>, degraded: boolean }}
88
+ */
89
+ export function readCentrality({ cwd, graph }) {
90
+ const resolved = graph === undefined ? resolveRepoGraph(cwd) : graph;
91
+ if (!resolved) return { degrees: new Map(), degraded: true };
92
+ return { degrees: computeInDegree(resolved), degraded: false };
93
+ }
94
+
95
+ /** Path tokens that look like repository files, harvested from signal text. */
96
+ const PATH_TOKEN_RE = /[\w@][\w./@-]*\.(?:js|mjs|cjs|ts|tsx|json|md)\b/g;
97
+
98
+ /**
99
+ * Collect every `signals.ndjson` under `tempRoot`. Absent tree → empty list.
100
+ *
101
+ * @param {string} tempRootAbs
102
+ * @returns {string[]} absolute paths
103
+ */
104
+ function findSignalStreams(tempRootAbs) {
105
+ const out = [];
106
+ const walk = (dir, depth) => {
107
+ if (depth > 6) return;
108
+ let entries;
109
+ try {
110
+ entries = fs.readdirSync(dir, { withFileTypes: true });
111
+ } catch {
112
+ return;
113
+ }
114
+ for (const entry of entries) {
115
+ const child = path.join(dir, entry.name);
116
+ if (entry.isDirectory()) walk(child, depth + 1);
117
+ else if (entry.name === 'signals.ndjson') out.push(child);
118
+ }
119
+ };
120
+ walk(tempRootAbs, 0);
121
+ return out.sort();
122
+ }
123
+
124
+ /**
125
+ * Count friction signals blaming each file.
126
+ *
127
+ * Signal records carry no dedicated path field — the blamed file surfaces
128
+ * inside free-form `details` / `emitter.command` text — so paths are
129
+ * harvested by token scan over each record's serialized form. A malformed
130
+ * line is skipped, never fatal.
131
+ *
132
+ * @param {{ tempRootAbs: string, kinds?: Set<string> }} args
133
+ * @returns {{ counts: Map<string, number>, degraded: boolean, streams: number }}
134
+ */
135
+ export function readFriction({
136
+ tempRootAbs,
137
+ kinds = new Set(['friction', 'hotspot', 'rework', 'churn', 'retry']),
138
+ }) {
139
+ const streams = findSignalStreams(tempRootAbs);
140
+ if (streams.length === 0) {
141
+ return { counts: new Map(), degraded: true, streams: 0 };
142
+ }
143
+ const counts = new Map();
144
+ for (const stream of streams) {
145
+ let raw;
146
+ try {
147
+ raw = fs.readFileSync(stream, 'utf8');
148
+ } catch {
149
+ continue;
150
+ }
151
+ for (const line of raw.split('\n')) {
152
+ if (line.trim().length === 0) continue;
153
+ let record;
154
+ try {
155
+ record = JSON.parse(line);
156
+ } catch {
157
+ continue;
158
+ }
159
+ if (!kinds.has(record?.kind)) continue;
160
+ const text =
161
+ JSON.stringify(record.details ?? {}) + (record?.emitter?.command ?? '');
162
+ for (const token of text.match(PATH_TOKEN_RE) ?? []) {
163
+ counts.set(token, (counts.get(token) ?? 0) + 1);
164
+ }
165
+ }
166
+ }
167
+ return { counts, degraded: false, streams: streams.length };
168
+ }
169
+
170
+ /**
171
+ * Bundle the three weight lookups into one resolver the hotspot builder can
172
+ * call per cluster id, plus the degradation flags the envelope reports.
173
+ *
174
+ * @param {{
175
+ * churn: { counts: Map<string, number>, degraded: boolean },
176
+ * centrality: { degrees: Map<string, number>, degraded: boolean },
177
+ * friction: { counts: Map<string, number>, degraded: boolean },
178
+ * }} sources
179
+ * @returns {(id: string) => { churnWeight: number, centralityWeight: number, frictionWeight: number }}
180
+ */
181
+ export function makeWeightResolver({ churn, centrality, friction }) {
182
+ return (id) => ({
183
+ churnWeight: churn.degraded
184
+ ? NEUTRAL_WEIGHT
185
+ : saturate(churn.counts.get(id) ?? 0, 12),
186
+ centralityWeight: centrality.degraded
187
+ ? NEUTRAL_WEIGHT
188
+ : saturate(centrality.degrees.get(id) ?? 0, 8),
189
+ frictionWeight: friction.degraded
190
+ ? NEUTRAL_WEIGHT
191
+ : saturate(friction.counts.get(id) ?? 0, 4),
192
+ });
193
+ }
@@ -27,15 +27,10 @@ export {
27
27
  } from './lens-diff-floor.js';
28
28
  export { runAuditSuite } from './runner.js';
29
29
  export {
30
- GLOBAL_LENS_ALLOWLIST,
31
- isGlobalLens,
32
30
  LENS_TIERS,
33
31
  matchesAnyFilePattern,
34
32
  matchesFilePattern,
35
- NAVIGABILITY_LENS,
36
33
  resolveLensTier,
37
- resolveNavigabilityRouteGlobs,
38
- routesNavigabilityLens,
39
34
  selectAudits,
40
35
  selectLocalLenses,
41
36
  } from './selector.js';
@@ -30,42 +30,6 @@ import { readAuditRulesSync } from './audit-rules-reader.js';
30
30
 
31
31
  const DEFAULT_GIT_TIMEOUT_MS = 30000;
32
32
 
33
- /**
34
- * The audit-lens identifier for the navigability lens (Epic #4131, F2/F3).
35
- * Authored as `.agents/workflows/audit-navigability.md`; registered here so the
36
- * roster, the global-lens allowlist, and the route-added routing seam all
37
- * reference one symbol rather than a hard-coded string.
38
- */
39
- export const NAVIGABILITY_LENS = 'audit-navigability';
40
-
41
- /**
42
- * The **global-lens allowlist** — lenses that evaluate a property of the
43
- * **whole** product (not just the Epic's change set) and are therefore exempt
44
- * from the cross-epic-leak guard (`#3362`) that narrows every other lens's
45
- * evidence to the Epic's `changedFiles`. A lens in this set still runs through
46
- * the SAME `runAuditSuite` / `selectAuditStrategy` engine; only the
47
- * change-set narrowing is bypassed, and only for the listed lenses. The guard
48
- * is **not** weakened for any lens absent from this set.
49
- *
50
- * Navigability is the founding member: reachability is a global property — a
51
- * change can orphan a route it never touched — so the lens must read the whole
52
- * route tree + nav registry regardless of which file triggered it.
53
- */
54
- export const GLOBAL_LENS_ALLOWLIST = Object.freeze([NAVIGABILITY_LENS]);
55
-
56
- /**
57
- * True when `lens` is on the global-lens allowlist and is therefore exempt
58
- * from the cross-epic-leak guard's change-set narrowing. Pure; the single
59
- * read-side of {@link GLOBAL_LENS_ALLOWLIST} so callers never hard-code the
60
- * membership test.
61
- *
62
- * @param {string} lens
63
- * @returns {boolean}
64
- */
65
- export function isGlobalLens(lens) {
66
- return GLOBAL_LENS_ALLOWLIST.includes(lens);
67
- }
68
-
69
33
  /**
70
34
  * The canonical concern-ownership tiers a lens can declare via its
71
35
  * `scope` field in [`audit-rules.json`](../../../schemas/audit-rules.json).
@@ -222,40 +186,23 @@ export function selectSensitivePathClasses({
222
186
  /**
223
187
  * Resolve the consumer's navigability route globs from the resolved config.
224
188
  * Reads `delivery.quality.navigability.routeGlobs` — the route-tree SSOT the
225
- * navigability lens enumerates and the route-added routing predicate matches
226
- * against. Returns an empty array when the block (or any ancestor) is absent,
227
- * so an unconfigured consumer routes nothing and the lens degrades to a silent
228
- * no-op (Epic #4131 — "no-op when unconfigured").
189
+ * navigability lens enumerates. Returns an empty array when the block (or any
190
+ * ancestor) is absent, so an unconfigured consumer contributes no web-surface
191
+ * evidence (Epic #4131 "no-op when unconfigured").
192
+ *
193
+ * Module-local: the sole reader is {@link hasWebSurface}. It was previously
194
+ * exported alongside a route-added routing seam that Story #4926 removed —
195
+ * lens routing is decided by the `scope` field in `audit-rules.json`, never by
196
+ * a second predicate here.
229
197
  *
230
198
  * @param {object|null|undefined} config Resolved `.agentrc.json` wrapper.
231
199
  * @returns {string[]} Route globs, or `[]` when unconfigured.
232
200
  */
233
- export function resolveNavigabilityRouteGlobs(config) {
201
+ function resolveNavigabilityRouteGlobs(config) {
234
202
  const globs = config?.delivery?.quality?.navigability?.routeGlobs;
235
203
  return Array.isArray(globs) ? globs.filter((g) => typeof g === 'string') : [];
236
204
  }
237
205
 
238
- /**
239
- * Decide whether a change set routes the navigability lens. The lens is routed
240
- * when any `changedFiles` entry matches a consumer-configured route glob
241
- * (`delivery.quality.navigability.routeGlobs`) — i.e. the change set adds or
242
- * touches a route file. When no route globs are configured, this returns
243
- * `false` (the unconfigured no-op), so the existing change-set-scoped lens
244
- * selection is unchanged.
245
- *
246
- * A pure predicate over a change set — the same input {@link selectAudits} and
247
- * {@link selectLocalLenses} already match against. The caller unions its result
248
- * into the lens roster it is assembling; no new routing machinery is added.
249
- *
250
- * @param {{ changedFiles?: string[], config?: object|null }} params
251
- * @returns {boolean}
252
- */
253
- export function routesNavigabilityLens({ changedFiles, config } = {}) {
254
- const globs = resolveNavigabilityRouteGlobs(config);
255
- if (globs.length === 0) return false;
256
- return matchesAnyFilePattern(globs, changedFiles ?? []);
257
- }
258
-
259
206
  /**
260
207
  * Package names (or scope/name segments of them) that declare a **web
261
208
  * rendering surface**. Matched against the consumer's root `package.json`
@@ -0,0 +1,177 @@
1
+ /**
2
+ * lib/audit-to-stories/audit-label-taxonomy.js — the closed set of GitHub
3
+ * labels an audit sweep may create or generate (Story #4877).
4
+ *
5
+ * There are two sides to a label axis and they used to be written down in
6
+ * different places, which is how they drifted:
7
+ *
8
+ * - the **creator** — `audit-labels-bootstrap.js`, which ran `gh label create`
9
+ * over the `audit::<lens>` list and nothing else;
10
+ * - the **generator** — `audit-to-stories/build-story-body.js`, which emitted
11
+ * `type::story`, `agent::ready`, `audit::<lens>` and — as a bare string
12
+ * literal — `risk::high`.
13
+ *
14
+ * `risk::high` was therefore generated by the filer and defined by **no**
15
+ * taxonomy: not `LABEL_TAXONOMY` (the repo-wide bootstrap), not the audit
16
+ * bootstrap. A generated label the repository has never created is the exact
17
+ * failure shape that made every `gh issue create` in the feedback loop fail in
18
+ * Story #4828. This module is the one list both sides read, so the creator
19
+ * cannot fall behind the generator again.
20
+ *
21
+ * It lives under `lib/` rather than inside the bootstrap CLI so the generator
22
+ * can import it without a lib → top-level-script edge (and without dragging the
23
+ * CLI's config/`gh` dependencies into a pure body builder).
24
+ *
25
+ * Label *names* come from `lib/label-constants.js` — the repo-wide SSOT for
26
+ * label spelling — so a rename still lands in one place.
27
+ */
28
+
29
+ import {
30
+ AGENT_LABELS,
31
+ LABEL_COLORS,
32
+ RISK_LABELS,
33
+ TYPE_LABELS,
34
+ } from '../label-constants.js';
35
+ import { AUDIT_LENSES } from './audit-lenses.js';
36
+
37
+ /**
38
+ * Per-lens label presentation, keyed by canonical lens name. A lens absent from
39
+ * this map falls back to {@link DEFAULT_LENS_META}, so adding an `audit-*`
40
+ * workflow to `AUDIT_LENSES` still yields a label without registering
41
+ * colour/description here first.
42
+ */
43
+ const LENS_META = Object.freeze({
44
+ accessibility: {
45
+ color: 'c5def5',
46
+ description: 'Audit-sourced finding: WCAG accessibility conformance',
47
+ },
48
+ architecture: {
49
+ color: '6f42c1',
50
+ description: 'Audit-sourced finding: architectural concerns',
51
+ },
52
+ 'clean-code': {
53
+ color: '0e8a16',
54
+ description: 'Audit-sourced finding: clean-code / maintainability',
55
+ },
56
+ dependencies: {
57
+ color: 'd4c5f9',
58
+ description: 'Audit-sourced finding: dependencies / supply chain',
59
+ },
60
+ devops: {
61
+ color: 'fbca04',
62
+ description: 'Audit-sourced finding: DevOps / CI / CD',
63
+ },
64
+ documentation: {
65
+ color: '1d76db',
66
+ description: 'Audit-sourced finding: documentation staleness / gaps',
67
+ },
68
+ navigability: {
69
+ color: 'bfdadc',
70
+ description: 'Audit-sourced finding: route / nav reachability',
71
+ },
72
+ performance: {
73
+ color: 'b60205',
74
+ description: 'Audit-sourced finding: performance / latency',
75
+ },
76
+ privacy: {
77
+ color: 'fef2c0',
78
+ description: 'Audit-sourced finding: privacy / data handling',
79
+ },
80
+ quality: {
81
+ color: '0052cc',
82
+ description: 'Audit-sourced finding: test quality / coverage gaps',
83
+ },
84
+ security: {
85
+ color: 'b60205',
86
+ description: 'Audit-sourced finding: security / OWASP',
87
+ },
88
+ seo: {
89
+ color: 'fbca04',
90
+ description: 'Audit-sourced finding: SEO / discoverability',
91
+ },
92
+ sre: {
93
+ color: '0052cc',
94
+ description: 'Audit-sourced finding: SRE / observability / reliability',
95
+ },
96
+ 'ux-ui': {
97
+ color: 'd4c5f9',
98
+ description: 'Audit-sourced finding: UX / UI concerns',
99
+ },
100
+ });
101
+
102
+ const DEFAULT_LENS_META = Object.freeze({
103
+ color: 'ededed',
104
+ description: 'Audit-sourced finding',
105
+ });
106
+
107
+ /** `gh label create --color` wants a bare hex triplet, not a CSS `#rrggbb`. */
108
+ function hex(color) {
109
+ return String(color).replace('#', '');
110
+ }
111
+
112
+ /**
113
+ * The `audit::<lens>` half of the taxonomy, one per `/audit-<lens>` workflow.
114
+ * Derived from the same `AUDIT_LENSES` SSOT that `build-story-body.js` derives
115
+ * its labels from, so the creator and the deriver cannot drift (Story #4195).
116
+ */
117
+ const AUDIT_LENS_LABELS = Object.freeze(
118
+ AUDIT_LENSES.map((name) => ({
119
+ name: `audit::${name}`,
120
+ ...(LENS_META[name] ?? DEFAULT_LENS_META),
121
+ })),
122
+ );
123
+
124
+ /**
125
+ * The story-axis labels an audit-generated Story carries alongside its
126
+ * `audit::<lens>` labels.
127
+ *
128
+ * The `type::` / `agent::` entries are also in `LABEL_TAXONOMY` (the repo-wide
129
+ * bootstrap). Creating a label twice is a no-op, so the overlap costs nothing
130
+ * and means an audit sweep does not silently depend on the repo-wide bootstrap
131
+ * having run first.
132
+ */
133
+ const AUDIT_STORY_AXIS_LABELS = Object.freeze([
134
+ {
135
+ name: TYPE_LABELS.STORY,
136
+ color: hex(LABEL_COLORS.TYPE),
137
+ description: 'Story work item',
138
+ },
139
+ {
140
+ name: AGENT_LABELS.READY,
141
+ color: hex(LABEL_COLORS.AGENT),
142
+ description:
143
+ 'Parking state — frozen dispatch manifest exists; awaiting local /deliver',
144
+ },
145
+ {
146
+ name: RISK_LABELS.HIGH,
147
+ color: hex(LABEL_COLORS.RISK_HIGH),
148
+ description:
149
+ 'Planning/audit metadata: review this first (Critical finding present)',
150
+ },
151
+ ]);
152
+
153
+ /**
154
+ * Every label the audit sweep defines — the closed set the bootstrap creates
155
+ * and {@link definesAuditLabel} answers against.
156
+ */
157
+ export const AUDIT_LABEL_TAXONOMY = Object.freeze([
158
+ ...AUDIT_LENS_LABELS,
159
+ ...AUDIT_STORY_AXIS_LABELS,
160
+ ]);
161
+
162
+ const DEFINED_NAMES = new Set(AUDIT_LABEL_TAXONOMY.map((l) => l.name));
163
+
164
+ /**
165
+ * True when `name` is a label this taxonomy defines.
166
+ *
167
+ * The enforcement point is the label **generator** (`build-story-body.js`),
168
+ * which routes every label it derives through this predicate and refuses to
169
+ * emit one the taxonomy does not define — closing the drift in the direction it
170
+ * actually happened: a generator inventing names the bootstrap never created.
171
+ *
172
+ * @param {unknown} name
173
+ * @returns {boolean}
174
+ */
175
+ export function definesAuditLabel(name) {
176
+ return typeof name === 'string' && DEFINED_NAMES.has(name);
177
+ }
@@ -27,6 +27,7 @@
27
27
  export const AUDIT_LENSES = Object.freeze([
28
28
  'accessibility',
29
29
  'architecture',
30
+ 'baselines',
30
31
  'clean-code',
31
32
  'data-model',
32
33
  'dependencies',
@@ -22,14 +22,16 @@
22
22
  * structured contract.
23
23
  */
24
24
 
25
+ import { AGENT_LABELS, RISK_LABELS, TYPE_LABELS } from '../label-constants.js';
25
26
  import { serialize } from '../story-body/story-body.js';
27
+ import { definesAuditLabel } from './audit-label-taxonomy.js';
26
28
  import { auditLabelsForFindings } from './audit-lenses.js';
27
29
  import {
28
30
  renderFingerprintFooter,
29
31
  renderSemanticKeyFooter,
30
32
  } from './finding-adapter.js';
31
33
 
32
- const STATIC_LABELS = Object.freeze(['type::story', 'agent::ready']);
34
+ const STATIC_LABELS = Object.freeze([TYPE_LABELS.STORY, AGENT_LABELS.READY]);
33
35
 
34
36
  // The verify[] contract every generated audit Story carries. These commands
35
37
  // exist in this repo's harness (package.json scripts) so the Story satisfies
@@ -183,8 +185,38 @@ function labelsForGroup(group) {
183
185
  const hasCritical = (group.findings ?? []).some(
184
186
  (f) => f.severity === 'critical',
185
187
  );
186
- if (hasCritical) labels.push('risk::high');
187
- return uniq(labels);
188
+ if (hasCritical) labels.push(RISK_LABELS.HIGH);
189
+ return assertLabelsInTaxonomy(uniq(labels));
190
+ }
191
+
192
+ /**
193
+ * Refuse to generate a label the audit bootstrap taxonomy does not define
194
+ * (Story #4877).
195
+ *
196
+ * Story #4195 fixed half of this: `audit::<dimension>` labels minted from
197
+ * free-form dimension prose ("stale-description", "dry") named labels that did
198
+ * not exist, so derivation moved to the closed lens list. The other half stayed
199
+ * open — `risk::high` was a bare string literal here, defined by no taxonomy —
200
+ * and nothing checked the generated set against anything at all. Throwing is
201
+ * deliberate: a label the repo has never created is dropped or fails the create
202
+ * outright, and a filer that silently loses `risk::high` on a Critical merge is
203
+ * worse than a loud failure at the point of generation.
204
+ *
205
+ * @param {string[]} labels
206
+ * @returns {string[]} the same labels, when every one is defined.
207
+ * @throws {Error} naming the offending labels.
208
+ */
209
+ function assertLabelsInTaxonomy(labels) {
210
+ const undefinedLabels = labels.filter((l) => !definesAuditLabel(l));
211
+ if (undefinedLabels.length > 0) {
212
+ throw new Error(
213
+ `buildStoryBody: generated label(s) ${undefinedLabels.join(', ')} are not ` +
214
+ 'defined by the audit label taxonomy (audit-label-taxonomy.js). Add ' +
215
+ 'them there — or stop generating them — rather than emitting a label ' +
216
+ 'the repository does not have.',
217
+ );
218
+ }
219
+ return labels;
188
220
  }
189
221
 
190
222
  /**
@@ -21,7 +21,7 @@
21
21
  * Pure: no I/O.
22
22
  */
23
23
 
24
- const SEVERITY_RANK = { critical: 3, high: 2, medium: 1, low: 0, null: -1 };
24
+ import { highestSeverity as highestSeverityOf } from '../findings/severity.js';
25
25
 
26
26
  function dirOf(filePath) {
27
27
  if (typeof filePath !== 'string' || filePath.length === 0) return '';
@@ -37,17 +37,26 @@ function pickPrimaryFile(finding) {
37
37
  return null;
38
38
  }
39
39
 
40
+ /**
41
+ * The highest severity across a group's findings, ranked by the severity SSOT
42
+ * ({@link highestSeverityOf}) rather than a local copy of the scale.
43
+ *
44
+ * The rank map this replaces knew four levels and not `info`, so an Info
45
+ * finding tied with a finding carrying no severity at all — the same partial
46
+ * vocabulary this Story removes everywhere else.
47
+ *
48
+ * A group whose findings all lack a usable severity still reports `null`, not
49
+ * the SSOT's `info` floor: absent is not the same claim as "graded lowest",
50
+ * and the callers that tally and threshold on this value distinguish them.
51
+ *
52
+ * @param {Array<{ severity?: string }>} findings
53
+ * @returns {string|null} a canonical severity, or null when none is stated.
54
+ */
40
55
  function highestSeverity(findings) {
41
- let best = null;
42
- let bestRank = -2;
43
- for (const f of findings) {
44
- const r = SEVERITY_RANK[f.severity ?? 'null'] ?? -1;
45
- if (r > bestRank) {
46
- bestRank = r;
47
- best = f.severity ?? null;
48
- }
49
- }
50
- return best;
56
+ const stated = findings
57
+ .map((f) => f?.severity)
58
+ .filter((value) => typeof value === 'string' && value.length > 0);
59
+ return stated.length === 0 ? null : highestSeverityOf(stated);
51
60
  }
52
61
 
53
62
  /**
@@ -17,14 +17,7 @@
17
17
 
18
18
  import path from 'node:path';
19
19
 
20
- const SEVERITY_ALIASES = Object.freeze({
21
- critical: 'critical',
22
- high: 'high',
23
- medium: 'medium',
24
- mod: 'medium',
25
- moderate: 'medium',
26
- low: 'low',
27
- });
20
+ import { normalizeSeverity } from '../findings/severity.js';
28
21
 
29
22
  const KEY_LINE = /^\s*-\s*\*\*([^:*]+):\*\*\s*(.*)$/;
30
23
  const HEADING_FINDING = /^###\s+(.+?)\s*$/;
@@ -44,6 +37,27 @@ function unwrapInlineCode(value) {
44
37
  return trimmed;
45
38
  }
46
39
 
40
+ /**
41
+ * Resolve a raw severity/impact token to a canonical level, or `null` when the
42
+ * token carries no recognisable severity at all.
43
+ *
44
+ * The vocabulary itself is NOT written down here (Story #4877). This module used
45
+ * to carry its own alias table covering `critical|high|medium|mod|moderate|low`
46
+ * — four of the canonical five levels, missing `info`. A lens that graded a
47
+ * finding `Info` or `Informational` (which the shared severity scale now
48
+ * sanctions) therefore parsed to `null`, tallied as `unknown`, and was dropped
49
+ * by every severity-filtered run, `--severity low` included. Delegating to the
50
+ * canonical normaliser in `lib/findings/severity.js` means this parser cannot
51
+ * know a narrower vocabulary than the rest of the pipeline.
52
+ *
53
+ * `null` — rather than the normaliser's `info` fallback — remains the
54
+ * no-severity answer, because {@link deriveSeverity} walks several candidate
55
+ * keys and needs to distinguish "this key had no severity" from "this key said
56
+ * `info`".
57
+ *
58
+ * @param {unknown} token
59
+ * @returns {string|null}
60
+ */
47
61
  function normaliseSeverity(token) {
48
62
  if (typeof token !== 'string') return null;
49
63
  const cleaned = token
@@ -53,7 +67,7 @@ function normaliseSeverity(token) {
53
67
  .trim();
54
68
  if (!cleaned) return null;
55
69
  for (const word of cleaned.split(/[\s|/,]+/)) {
56
- const hit = SEVERITY_ALIASES[word];
70
+ const hit = normalizeSeverity(word, null);
57
71
  if (hit) return hit;
58
72
  }
59
73
  return null;
@@ -17,6 +17,7 @@
17
17
  * Pure: returns a string. The caller decides where to persist it.
18
18
  */
19
19
 
20
+ import { SEVERITIES } from '../findings/severity.js';
20
21
  import {
21
22
  renderFingerprintFooter,
22
23
  renderSemanticKeyFooter,
@@ -37,10 +38,16 @@ const DIMENSION_LABEL = {
37
38
  architecture: 'Architecture',
38
39
  };
39
40
 
40
- const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low'];
41
+ /**
42
+ * The severity profile in the seed's Problem Statement is ordered and bucketed
43
+ * by the canonical scale (Story #4877) rather than by a fourth local copy of
44
+ * it. The list this replaces omitted `info`, so an informational finding was
45
+ * absent from the profile the planner reads even when it survived the filter.
46
+ */
47
+ const SEVERITY_ORDER = SEVERITIES;
41
48
 
42
49
  function tallySeverities(findings) {
43
- const tally = { critical: 0, high: 0, medium: 0, low: 0 };
50
+ const tally = Object.fromEntries(SEVERITIES.map((s) => [s, 0]));
44
51
  for (const f of findings) {
45
52
  if (Object.hasOwn(tally, f.severity)) tally[f.severity] += 1;
46
53
  }