mandrel 1.87.0 → 1.89.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 (140) hide show
  1. package/.agents/README.md +18 -13
  2. package/.agents/audit-checklists/architecture.md +24 -0
  3. package/.agents/audit-checklists/clean-code.md +24 -0
  4. package/.agents/audit-checklists/dependencies.md +14 -0
  5. package/.agents/audit-checklists/devops.md +17 -0
  6. package/.agents/audit-checklists/documentation.md +22 -0
  7. package/.agents/audit-checklists/lighthouse.md +15 -0
  8. package/.agents/audit-checklists/navigability.md +14 -0
  9. package/.agents/audit-checklists/performance.md +22 -0
  10. package/.agents/audit-checklists/privacy.md +21 -0
  11. package/.agents/audit-checklists/quality.md +18 -0
  12. package/.agents/audit-checklists/security.md +22 -0
  13. package/.agents/audit-checklists/seo.md +16 -0
  14. package/.agents/audit-checklists/sre.md +24 -0
  15. package/.agents/audit-checklists/ux-ui.md +21 -0
  16. package/.agents/docs/SDLC.md +63 -16
  17. package/.agents/docs/configuration.md +5 -3
  18. package/.agents/instructions.md +51 -21
  19. package/.agents/personas/architect.md +10 -7
  20. package/.agents/personas/engineer.md +4 -3
  21. package/.agents/personas/project-manager.md +5 -2
  22. package/.agents/personas/refactorer.md +5 -3
  23. package/.agents/rules/git-conventions.md +77 -0
  24. package/.agents/schemas/agentrc.schema.json +16 -4
  25. package/.agents/schemas/audit-rules.json +16 -2
  26. package/.agents/schemas/audit-rules.schema.json +7 -6
  27. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -0
  28. package/.agents/schemas/signal-event.schema.json +28 -13
  29. package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
  30. package/.agents/scripts/check-context-budget.js +320 -0
  31. package/.agents/scripts/diagnose-friction.js +4 -4
  32. package/.agents/scripts/epic-audit-prepare.js +30 -2
  33. package/.agents/scripts/epic-audit-recheck.js +46 -13
  34. package/.agents/scripts/epic-deliver-prepare.js +80 -8
  35. package/.agents/scripts/epic-plan-spec.js +4 -8
  36. package/.agents/scripts/generate-lens-checklists.js +180 -0
  37. package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
  38. package/.agents/scripts/lib/audit-suite/findings.js +27 -0
  39. package/.agents/scripts/lib/audit-suite/index.js +9 -0
  40. package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
  41. package/.agents/scripts/lib/audit-suite/selector.js +136 -5
  42. package/.agents/scripts/lib/checks/loop-health.js +340 -0
  43. package/.agents/scripts/lib/cli-args.js +8 -0
  44. package/.agents/scripts/lib/config/explain.js +4 -0
  45. package/.agents/scripts/lib/config/runners.js +21 -2
  46. package/.agents/scripts/lib/config/temp-paths.js +24 -0
  47. package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
  48. package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
  49. package/.agents/scripts/lib/doc-tiers.js +291 -0
  50. package/.agents/scripts/lib/epic-body-sections.js +5 -2
  51. package/.agents/scripts/lib/epic-merge-lock.js +83 -0
  52. package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
  53. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +66 -20
  54. package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
  55. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
  56. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
  57. package/.agents/scripts/lib/gates/friction.js +15 -5
  58. package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
  59. package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
  60. package/.agents/scripts/lib/observability/signal-validator.js +204 -0
  61. package/.agents/scripts/lib/observability/signals-writer.js +157 -54
  62. package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
  63. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  64. package/.agents/scripts/lib/orchestration/code-review.js +74 -4
  65. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
  66. package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
  67. package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
  68. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
  69. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
  70. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +188 -0
  71. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
  72. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
  73. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
  74. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
  75. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
  76. package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
  77. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
  78. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
  79. package/.agents/scripts/lib/orchestration/merge-block-class.js +218 -0
  80. package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
  81. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
  82. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
  83. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
  84. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
  85. package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
  86. package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
  91. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
  92. package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
  93. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
  94. package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
  95. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
  96. package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
  97. package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
  98. package/.agents/scripts/lib/planning-corpus.js +306 -0
  99. package/.agents/scripts/lib/signals/detectors/common.js +10 -10
  100. package/.agents/scripts/lib/signals/detectors/index.js +4 -4
  101. package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
  102. package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
  103. package/.agents/scripts/lib/signals/schema.js +56 -81
  104. package/.agents/scripts/lib/signals/span-tree.js +6 -5
  105. package/.agents/scripts/lib/story-plan.js +3 -0
  106. package/.agents/scripts/lib/wave-runner/tick.js +10 -2
  107. package/.agents/scripts/lifecycle-emit.js +39 -8
  108. package/.agents/scripts/providers/github/issues.js +12 -1
  109. package/.agents/scripts/resolve-doc-tiers.js +83 -0
  110. package/.agents/scripts/retro-run.js +51 -0
  111. package/.agents/scripts/signals-view.js +1 -1
  112. package/.agents/scripts/single-story-close.js +20 -1
  113. package/.agents/scripts/standalone-feedback-rollup.js +188 -0
  114. package/.agents/scripts/story-close.js +48 -0
  115. package/.agents/scripts/story-plan.js +51 -12
  116. package/.agents/scripts/validate-docs-freshness.js +69 -15
  117. package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
  118. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  119. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
  120. package/.agents/skills/core/scope-triage/SKILL.md +61 -0
  121. package/.agents/skills/skills.index.json +3 -3
  122. package/.agents/workflows/audit-documentation.md +82 -2
  123. package/.agents/workflows/helpers/code-review.md +193 -44
  124. package/.agents/workflows/helpers/deliver-epic.md +128 -39
  125. package/.agents/workflows/helpers/deliver-stories.md +26 -0
  126. package/.agents/workflows/helpers/epic-audit.md +116 -283
  127. package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
  128. package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
  129. package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
  130. package/.agents/workflows/helpers/plan-epic.md +141 -105
  131. package/.agents/workflows/helpers/plan-story.md +32 -0
  132. package/.agents/workflows/helpers/single-story-deliver.md +43 -0
  133. package/.agents/workflows/loops/nightly-audit.md +9 -7
  134. package/docs/CHANGELOG.md +29 -0
  135. package/lib/cli/doctor.js +44 -0
  136. package/package.json +4 -3
  137. package/.agents/scripts/epic-plan-spec-validate.js +0 -111
  138. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -207
  139. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
  140. package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
@@ -0,0 +1,212 @@
1
+ /**
2
+ * lib/audit-suite/lens-checklist.js — distill an `audit-<lens>.md` workflow
3
+ * body into a compact, drift-gated authoring checklist.
4
+ *
5
+ * Epic #4405 (shift-left audit) moves each lens's concerns to the innermost,
6
+ * write-time tier: instead of an engineer only learning what a lens checks
7
+ * when `/audit-<lens>` runs, a compact per-lens checklist ships as a committed
8
+ * build artifact under `.agents/audit-checklists/`. This module is the pure
9
+ * distillation seam — no IO — so it is unit-testable and the generator
10
+ * (`generate-lens-checklists.js`) owns only the file read/write/prune and the
11
+ * `--check` drift gate.
12
+ *
13
+ * The distillation is a deterministic transform of the workflow markdown: it
14
+ * lifts the lens's *concern* labels — the bold lead-ins of the analysis /
15
+ * evaluation list items — from the audit region of the workflow (everything
16
+ * before the `Output Requirements` / report-template boundary), skipping the
17
+ * recurring boilerplate sections (Role, Context, Scope, Execution strategy,
18
+ * Configuration). When a lens exposes no bold concern items it falls back to
19
+ * its audit-step headings so every checklist is non-empty. The output is
20
+ * hard-capped at {@link MAX_CHECKLIST_LINES} lines.
21
+ *
22
+ * Pure: no IO, no provider calls, safe to unit-test in isolation.
23
+ *
24
+ * @see Story #4408 — per-lens authoring checklists as drift-gated artifacts.
25
+ */
26
+
27
+ import { clampSummary, extractFrontmatter } from './frontmatter.js';
28
+
29
+ // All RegExp instances are built via the constructor (rather than literal
30
+ // `/.../`) so the maintainability engine's AST walker (typhonjs-escomplex) can
31
+ // score this file — see the note in lib/audit-suite/frontmatter.js.
32
+ // biome-ignore-start lint/complexity/useRegexLiterals: typhonjs-escomplex MI workaround
33
+ const LINE_SPLIT_RE = new RegExp(String.raw`\r?\n`);
34
+ const FRONTMATTER_BLOCK_RE = new RegExp(
35
+ String.raw`^---\r?\n([\s\S]*?)\r?\n---`,
36
+ );
37
+ const BLOCK_SCALAR_INDICATOR_RE = new RegExp('^[|>][+-]?$');
38
+ const DESCRIPTION_KEY_RE = new RegExp(String.raw`^description\s*:`);
39
+ const INDENTED_LINE_RE = new RegExp(String.raw`^\s+\S`);
40
+ const COLLAPSE_WS_RE = new RegExp(String.raw`\s+`, 'g');
41
+ const HEADING_RE = new RegExp(String.raw`^(#{1,6})\s+(.*\S)\s*$`);
42
+ const BOLD_LEAD_ITEM_RE = new RegExp(
43
+ String.raw`^\s*(?:[-*+]|\d+\.)\s+\*\*(.+?)\*\*`,
44
+ );
45
+ const STEP_PREFIX_RE = new RegExp(String.raw`^Step\s+\d+\s*[:.]?\s*`, 'i');
46
+ const TRAILING_COLON_RE = new RegExp(String.raw`\s*:\s*$`);
47
+ // A `##` section whose heading opens with one of these is workflow boilerplate,
48
+ // not a lens concern — its list items are skipped during concern extraction.
49
+ const BOILERPLATE_SECTION_RE = new RegExp(
50
+ String.raw`^(Role|Context & Objective|Scope|Execution strategy|Configuration|Run Context|Target set)\b`,
51
+ 'i',
52
+ );
53
+ // The report-template / output-contract boundary. Everything at or after the
54
+ // first heading matching this is template scaffolding, never a concern.
55
+ const OUTPUT_BOUNDARY_RE = new RegExp(
56
+ '(Output Requirements|Generate the Report)',
57
+ 'i',
58
+ );
59
+ // biome-ignore-end lint/complexity/useRegexLiterals: typhonjs-escomplex MI workaround
60
+
61
+ /** Hard cap on generated checklist lines (asserted by the drift test). */
62
+ export const MAX_CHECKLIST_LINES = 40;
63
+
64
+ /** Fixed header/intro line budget consumed before the checklist items. */
65
+ const HEADER_LINE_BUDGET = 12;
66
+
67
+ /**
68
+ * Pure: walk the workflow body once, collecting the lens's concern labels (bold
69
+ * lead-ins of analysis/evaluation list items) and, as a fallback, its audit
70
+ * step headings. Extraction stops at the report-template boundary and ignores
71
+ * boilerplate sections.
72
+ *
73
+ * @param {string} content — raw `audit-<lens>.md` markdown.
74
+ * @returns {{ title: string|null, concerns: string[], stepHeadings: string[] }}
75
+ */
76
+ export function extractLensConcerns(content) {
77
+ const lines = String(content ?? '').split(LINE_SPLIT_RE);
78
+ const concerns = [];
79
+ const stepHeadings = [];
80
+ let title = null;
81
+ let inBoilerplate = false;
82
+
83
+ for (const line of lines) {
84
+ const heading = HEADING_RE.exec(line);
85
+ if (heading) {
86
+ const level = heading[1].length;
87
+ const text = heading[2].trim();
88
+ if (level === 1) {
89
+ // First H1 is the lens title; a later H1 opens the report template.
90
+ if (title === null) {
91
+ title = text;
92
+ continue;
93
+ }
94
+ break;
95
+ }
96
+ if (OUTPUT_BOUNDARY_RE.test(text)) break;
97
+ if (level === 2) {
98
+ inBoilerplate = BOILERPLATE_SECTION_RE.test(text);
99
+ if (!inBoilerplate) {
100
+ stepHeadings.push(text.replace(STEP_PREFIX_RE, '').trim());
101
+ }
102
+ }
103
+ continue;
104
+ }
105
+ if (title === null || inBoilerplate) continue;
106
+ const item = BOLD_LEAD_ITEM_RE.exec(line);
107
+ if (item) {
108
+ const label = item[1].replace(TRAILING_COLON_RE, '').trim();
109
+ if (label) concerns.push(label);
110
+ }
111
+ }
112
+
113
+ return { title, concerns, stepHeadings };
114
+ }
115
+
116
+ /**
117
+ * Pure: resolve a workflow's `description` to a single clamped line, folding a
118
+ * YAML block scalar (`description: >-` / `|` with indented continuation lines)
119
+ * back into one line. `extractFrontmatter` only sees the indicator token
120
+ * (`>-`) for a block scalar, so this reads the continuation lines directly.
121
+ *
122
+ * @param {string} content — raw workflow markdown.
123
+ * @param {Record<string, string>} fm — parsed frontmatter map.
124
+ * @returns {string} a single-line description (possibly empty).
125
+ */
126
+ export function resolveDescription(content, fm) {
127
+ const raw = (fm.description ?? '').trim();
128
+ if (raw && !BLOCK_SCALAR_INDICATOR_RE.test(raw)) {
129
+ return clampSummary(raw).replace(COLLAPSE_WS_RE, ' ');
130
+ }
131
+
132
+ const block = FRONTMATTER_BLOCK_RE.exec(String(content ?? ''));
133
+ if (!block) return '';
134
+ const lines = block[1].split(LINE_SPLIT_RE);
135
+ const start = lines.findIndex((line) => DESCRIPTION_KEY_RE.test(line));
136
+ if (start === -1) return '';
137
+ const collected = [];
138
+ for (let i = start + 1; i < lines.length; i += 1) {
139
+ if (INDENTED_LINE_RE.test(lines[i])) {
140
+ collected.push(lines[i].trim());
141
+ continue;
142
+ }
143
+ break; // a blank line or a dedented next key ends the scalar.
144
+ }
145
+ return clampSummary(collected.join(' ')).replace(COLLAPSE_WS_RE, ' ');
146
+ }
147
+
148
+ /**
149
+ * Pure: dedupe a list case-insensitively, preserving first-seen order.
150
+ *
151
+ * @param {string[]} items
152
+ * @returns {string[]}
153
+ */
154
+ function dedupePreserveOrder(items) {
155
+ const seen = new Set();
156
+ const out = [];
157
+ for (const item of items) {
158
+ const key = item.toLowerCase();
159
+ if (seen.has(key)) continue;
160
+ seen.add(key);
161
+ out.push(item);
162
+ }
163
+ return out;
164
+ }
165
+
166
+ /**
167
+ * Pure: render the authoring checklist for a single lens from its workflow
168
+ * markdown. The output is deterministic (a pure function of `content`) and
169
+ * hard-capped at {@link MAX_CHECKLIST_LINES} lines including the trailing
170
+ * newline, so the same input always regenerates byte-identically.
171
+ *
172
+ * @param {string} lens — canonical lens name (e.g. `security`).
173
+ * @param {string} content — raw `audit-<lens>.md` markdown.
174
+ * @returns {string} the checklist markdown (ends with a single newline).
175
+ */
176
+ export function renderLensChecklist(lens, content) {
177
+ const fm = extractFrontmatter(content);
178
+ const description = resolveDescription(content, fm);
179
+ const { title, concerns, stepHeadings } = extractLensConcerns(content);
180
+ const displayTitle = title || `audit-${lens}`;
181
+
182
+ let items = dedupePreserveOrder(concerns);
183
+ if (items.length === 0) items = dedupePreserveOrder(stepHeadings);
184
+ if (items.length === 0) items = ['Review the full lens workflow'];
185
+
186
+ const header = [
187
+ '<!-- GENERATED FILE — do not edit by hand.',
188
+ ` Source of truth: .agents/workflows/audit-${lens}.md`,
189
+ ' Regenerate: node .agents/scripts/generate-lens-checklists.js',
190
+ ' Drift is gated by: npm run docs:check',
191
+ '-->',
192
+ '',
193
+ `# ${displayTitle} — authoring checklist`,
194
+ '',
195
+ `> ${description || `Self-check your change against the ${lens} lens.`}`,
196
+ '',
197
+ "Self-check your change against this lens's concerns before you ship:",
198
+ '',
199
+ ];
200
+
201
+ // Reserve room for a truncation marker so the cap is never exceeded.
202
+ const maxItems = MAX_CHECKLIST_LINES - HEADER_LINE_BUDGET;
203
+ let itemLines;
204
+ if (items.length > maxItems) {
205
+ itemLines = items.slice(0, maxItems - 1).map((c) => `- [ ] ${c}`);
206
+ itemLines.push('- [ ] …see the full lens for the remaining concerns');
207
+ } else {
208
+ itemLines = items.map((c) => `- [ ] ${c}`);
209
+ }
210
+
211
+ return `${[...header, ...itemLines].join('\n')}\n`;
212
+ }
@@ -16,6 +16,7 @@
16
16
  * mapping. All rule-matching lives here.
17
17
  */
18
18
 
19
+ import { readFileSync } from 'node:fs';
19
20
  import fs from 'node:fs/promises';
20
21
  import path from 'node:path';
21
22
  import picomatch from 'picomatch';
@@ -62,6 +63,141 @@ export function isGlobalLens(lens) {
62
63
  return GLOBAL_LENS_ALLOWLIST.includes(lens);
63
64
  }
64
65
 
66
+ /**
67
+ * The canonical concern-ownership tiers a lens can declare via its
68
+ * `scope` field in [`audit-rules.json`](../../../schemas/audit-rules.json).
69
+ * This frozen tuple is the single source of truth for the tier vocabulary the
70
+ * schema's `scope` enum enforces and {@link resolveLensTier} returns:
71
+ *
72
+ * - `local` — decidable from a single Story's diff; verified at
73
+ * write-time and Story-scope review, not re-run at Epic close.
74
+ * - `cumulative` — only decidable across the Epic's combined diff; verified
75
+ * at Epic close.
76
+ * - `global` — evaluates a whole-product property regardless of the diff;
77
+ * verified at Epic close, exempt from change-set narrowing.
78
+ *
79
+ * @type {readonly ['local', 'cumulative', 'global']}
80
+ */
81
+ export const LENS_TIERS = Object.freeze(['local', 'cumulative', 'global']);
82
+
83
+ /**
84
+ * Resolve the concern-ownership tier a lens declares in `audit-rules.json`.
85
+ * This is the pure read-side of the `scope` field (Epic #4405, Story #4407)
86
+ * that replaced the former `alwaysRun` special case: every downstream tier —
87
+ * write-time checklist threading, Story-scope review, the Epic-close roster
88
+ * split — routes off this one field instead of a maintained prose constraint.
89
+ *
90
+ * Deterministic given the on-disk manifest: it reads the same
91
+ * `audit-rules.json` that {@link selectAudits} consumes (resolved through the
92
+ * project's configured `schemasRoot`), looks up the lens, and returns its
93
+ * `scope`. It takes no ticket, runs no git, and has no side effects.
94
+ *
95
+ * @param {string} lens Lens key registered in `audit-rules.json`
96
+ * (e.g. `audit-clean-code`).
97
+ * @returns {'local' | 'cumulative' | 'global'} The lens's declared tier.
98
+ * @throws {Error} When `lens` is not registered in the manifest, or the
99
+ * manifest cannot be read, or the registered entry carries a scope outside
100
+ * {@link LENS_TIERS}.
101
+ */
102
+ /**
103
+ * Read and parse the `audit-rules.json` manifest synchronously from the
104
+ * project's configured `schemasRoot`. Shared by the synchronous, ticket-free
105
+ * readers ({@link resolveLensTier}, {@link selectLocalLenses}) so the path
106
+ * resolution and read-failure handling live in one place rather than being
107
+ * duplicated per reader.
108
+ *
109
+ * @returns {{ audits?: Record<string, object> }} Parsed manifest.
110
+ * @throws {Error} When the manifest cannot be read or parsed.
111
+ */
112
+ function readAuditRulesSync() {
113
+ const { agentSettings } = resolveConfig();
114
+ const rulesPath = path.join(
115
+ PROJECT_ROOT,
116
+ getPaths({ agentSettings }).schemasRoot,
117
+ 'audit-rules.json',
118
+ );
119
+ try {
120
+ return JSON.parse(readFileSync(rulesPath, 'utf8'));
121
+ } catch (err) {
122
+ throw new Error(
123
+ `audit-suite: failed to read audit-rules from ${rulesPath}: ${err.message}`,
124
+ );
125
+ }
126
+ }
127
+
128
+ export function resolveLensTier(lens) {
129
+ const rulesData = readAuditRulesSync();
130
+
131
+ const entry = rulesData.audits?.[lens];
132
+ if (!entry) {
133
+ throw new Error(
134
+ `resolveLensTier: unknown lens '${lens}' — not registered in audit-rules.json`,
135
+ );
136
+ }
137
+
138
+ const { scope } = entry;
139
+ if (!LENS_TIERS.includes(scope)) {
140
+ throw new Error(
141
+ `resolveLensTier: lens '${lens}' declares invalid scope '${scope}'; expected one of ${LENS_TIERS.join(', ')}`,
142
+ );
143
+ }
144
+
145
+ return scope;
146
+ }
147
+
148
+ /**
149
+ * Select the LOCAL-tier lenses whose `filePatterns` triggers match a change
150
+ * set. This is the Story-scope roster used by the maker-blind story-close
151
+ * review (Epic #4405, Story #4409): a lens is selected iff
152
+ * `resolveLensTier(lens) === 'local'` **and** the pure
153
+ * {@link matchesAnyFilePattern} matcher hits at least one of `changedFiles`
154
+ * against the lens's registered `triggers.filePatterns`.
155
+ *
156
+ * This deliberately does **not** call {@link selectAudits}: `selectAudits`
157
+ * unions in keyword-matched and gate-scoped lenses and has no per-tier gate,
158
+ * so it would widen the roster beyond the local, footprint-matched set the
159
+ * shift-left Story-scope tier owns. A local lens with a universal
160
+ * `filePatterns` glob (e.g. `audit-clean-code`, whose sole pattern matches
161
+ * every path) matches every change set here, so its concern is verified at
162
+ * BOTH innermost tiers — the
163
+ * write-time checklist threading and this Story-scope lens pass — and excluded
164
+ * from Epic close (a local lens is dropped by {@link selectEpicCloseLenses}).
165
+ * A local lens with an empty `filePatterns` list matches nothing here, so a
166
+ * diff matching no local lens's patterns yields an empty roster and adds no
167
+ * lens work.
168
+ *
169
+ * Pure over its injected seams: `injectedRules` skips the disk read of the
170
+ * manifest and `resolveLensTierFn` overrides the tier resolver, so callers can
171
+ * exercise the selection without touching the filesystem. Selection order
172
+ * follows the manifest's declaration order, which is deterministic.
173
+ *
174
+ * @param {{
175
+ * changedFiles?: string[],
176
+ * injectedRules?: { audits?: Record<string, object> },
177
+ * resolveLensTierFn?: typeof resolveLensTier,
178
+ * }} [params]
179
+ * @returns {string[]} The matched local-lens identifiers, in manifest order.
180
+ */
181
+ export function selectLocalLenses({
182
+ changedFiles,
183
+ injectedRules,
184
+ resolveLensTierFn = resolveLensTier,
185
+ } = {}) {
186
+ const files = Array.isArray(changedFiles) ? changedFiles : [];
187
+ if (files.length === 0) return [];
188
+
189
+ const rules = injectedRules ?? readAuditRulesSync();
190
+ const selected = [];
191
+ for (const [lens, entry] of Object.entries(rules.audits ?? {})) {
192
+ if (resolveLensTierFn(lens) !== 'local') continue;
193
+ const patterns = entry?.triggers?.filePatterns ?? [];
194
+ if (matchesAnyFilePattern(patterns, files)) {
195
+ selected.push(lens);
196
+ }
197
+ }
198
+ return selected;
199
+ }
200
+
65
201
  /**
66
202
  * Resolve the consumer's navigability route globs from the resolved config.
67
203
  * Reads `delivery.quality.navigability.routeGlobs` — the route-tree SSOT the
@@ -264,11 +400,6 @@ export async function selectAudits({
264
400
  const gateMatch = triggers.gates?.includes(gate);
265
401
  if (!gateMatch) continue;
266
402
 
267
- if (triggers.alwaysRun) {
268
- selectedAudits.push(auditName);
269
- continue;
270
- }
271
-
272
403
  const keywords = triggers.keywords || [];
273
404
  let keywordMatch = false;
274
405
  for (const kw of keywords) {