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,180 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * .agents/scripts/generate-lens-checklists.js — per-lens authoring checklists
4
+ *
5
+ * Distils each canonical audit lens's own `.agents/workflows/audit-<lens>.md`
6
+ * workflow body into one compact authoring checklist under
7
+ * `.agents/audit-checklists/<lens>.md`. The distillation logic is the pure
8
+ * `lib/audit-suite/lens-checklist.js` seam; this entry point owns only the
9
+ * file read/write/prune and the `--check` drift gate.
10
+ *
11
+ * Why (Epic #4405 — shift-left audit): the audit lenses used to surface their
12
+ * concerns only when `/audit-<lens>` ran. These checklists move the concerns to
13
+ * the innermost, write-time tier as committed build artifacts, gated for
14
+ * staleness by `npm run docs:check` exactly like every other generated doc so
15
+ * they can never silently drift from their source workflow.
16
+ *
17
+ * The lens taxonomy is the SSOT `AUDIT_LENSES` list. A lens whose
18
+ * `audit-<lens>.md` workflow is absent produces **no** checklist and is
19
+ * reported (never a silent skip). Stray `.md` files under the checklist
20
+ * directory that no longer map to a lens are pruned in write mode and flagged
21
+ * in `--check` mode, keeping the directory a pure function of its sources.
22
+ *
23
+ * Modes:
24
+ * (default) — writes one `<lens>.md` per lens with a workflow, prunes
25
+ * strays, reports lenses missing a workflow.
26
+ * --check — exits 0 when every on-disk file matches the freshly generated
27
+ * content and no strays exist, throws (→ exit 1) otherwise.
28
+ *
29
+ * Per `.agents/rules/orchestration-error-handling.md`, unrecoverable failures
30
+ * surface via `throw new Error(...)` so `runAsCli` maps the throw to
31
+ * `process.exit(1)` deterministically (no `Logger.fatal`).
32
+ */
33
+
34
+ import fs from 'node:fs';
35
+ import path from 'node:path';
36
+ import { fileURLToPath } from 'node:url';
37
+ import { parseArgs } from 'node:util';
38
+ import { renderLensChecklist } from './lib/audit-suite/lens-checklist.js';
39
+ import { AUDIT_LENSES } from './lib/audit-to-stories/audit-lenses.js';
40
+ import { runAsCli } from './lib/cli-utils.js';
41
+ import { Logger } from './lib/Logger.js';
42
+
43
+ const __filename = fileURLToPath(import.meta.url);
44
+ const __dirname = path.dirname(__filename);
45
+ const PROJECT_ROOT = path.resolve(__dirname, '..', '..');
46
+ const WORKFLOWS_DIR = path.join(PROJECT_ROOT, '.agents', 'workflows');
47
+ const CHECKLISTS_DIR = path.join(PROJECT_ROOT, '.agents', 'audit-checklists');
48
+
49
+ /**
50
+ * Pure: plan the expected checklist set from the lens taxonomy, given a
51
+ * workflow-existence predicate and a reader. A lens whose workflow is absent
52
+ * yields no checklist and is recorded in `missing` (never silently skipped).
53
+ * Kept side-effect-free so it is unit-testable with fabricated inputs.
54
+ *
55
+ * @param {ReadonlyArray<string>} lenses — canonical lens names.
56
+ * @param {(lens: string) => boolean} workflowExists
57
+ * @param {(lens: string) => string} readWorkflow
58
+ * @returns {{ expected: Map<string, string>, missing: string[] }}
59
+ */
60
+ export function planChecklists(lenses, workflowExists, readWorkflow) {
61
+ const expected = new Map();
62
+ const missing = [];
63
+ for (const lens of lenses) {
64
+ if (!workflowExists(lens)) {
65
+ missing.push(lens);
66
+ continue;
67
+ }
68
+ expected.set(`${lens}.md`, renderLensChecklist(lens, readWorkflow(lens)));
69
+ }
70
+ return { expected, missing };
71
+ }
72
+
73
+ /**
74
+ * Build the full expected checklist set from the lens taxonomy and the on-disk
75
+ * workflow bodies, plus the strays (on-disk checklist files mapping to no
76
+ * current lens).
77
+ *
78
+ * @returns {{
79
+ * expected: Map<string, string>,
80
+ * missing: string[],
81
+ * strays: string[],
82
+ * }} `expected` maps a checklist basename (`<lens>.md`) to its generated
83
+ * content; `missing` lists lenses with no `audit-<lens>.md`; `strays` lists
84
+ * on-disk checklist basenames that map to no current lens.
85
+ */
86
+ export function buildExpected() {
87
+ const workflowPath = (lens) => path.join(WORKFLOWS_DIR, `audit-${lens}.md`);
88
+ const { expected, missing } = planChecklists(
89
+ AUDIT_LENSES,
90
+ (lens) => fs.existsSync(workflowPath(lens)),
91
+ (lens) => fs.readFileSync(workflowPath(lens), 'utf8'),
92
+ );
93
+
94
+ const onDisk = fs.existsSync(CHECKLISTS_DIR)
95
+ ? fs.readdirSync(CHECKLISTS_DIR).filter((name) => name.endsWith('.md'))
96
+ : [];
97
+ const strays = onDisk.filter((name) => !expected.has(name));
98
+
99
+ return { expected, missing, strays };
100
+ }
101
+
102
+ /**
103
+ * @param {string} basename — e.g. `security.md`
104
+ * @returns {string} repo-relative POSIX path for messages.
105
+ */
106
+ function relChecklist(basename) {
107
+ return path
108
+ .relative(PROJECT_ROOT, path.join(CHECKLISTS_DIR, basename))
109
+ .split(path.sep)
110
+ .join('/');
111
+ }
112
+
113
+ /**
114
+ * @param {string[]} argv
115
+ */
116
+ async function main(argv = process.argv.slice(2)) {
117
+ const { values } = parseArgs({
118
+ args: argv,
119
+ options: { check: { type: 'boolean', default: false } },
120
+ allowPositionals: false,
121
+ });
122
+
123
+ const { expected, missing, strays } = buildExpected();
124
+
125
+ if (missing.length > 0) {
126
+ Logger.info(
127
+ `generate-lens-checklists: no audit-<lens>.md for: ${missing.join(', ')} — no checklist emitted.`,
128
+ );
129
+ }
130
+
131
+ if (values.check) {
132
+ const drifted = [];
133
+ for (const [basename, content] of expected) {
134
+ const target = path.join(CHECKLISTS_DIR, basename);
135
+ const original = fs.existsSync(target)
136
+ ? fs.readFileSync(target, 'utf8')
137
+ : null;
138
+ if (original !== content) drifted.push(relChecklist(basename));
139
+ }
140
+ if (drifted.length === 0 && strays.length === 0) {
141
+ Logger.info(
142
+ `generate-lens-checklists: ${expected.size} checklist(s) up to date.`,
143
+ );
144
+ return;
145
+ }
146
+ const problems = [
147
+ ...drifted.map((p) => `out of date: ${p}`),
148
+ ...strays.map((s) => `stray (no lens): ${relChecklist(s)}`),
149
+ ];
150
+ throw new Error(
151
+ `Lens checklists are out of sync:\n ${problems.join('\n ')}\n` +
152
+ 'Run `node .agents/scripts/generate-lens-checklists.js` to regenerate.',
153
+ );
154
+ }
155
+
156
+ fs.mkdirSync(CHECKLISTS_DIR, { recursive: true });
157
+ let wrote = 0;
158
+ for (const [basename, content] of expected) {
159
+ const target = path.join(CHECKLISTS_DIR, basename);
160
+ const original = fs.existsSync(target)
161
+ ? fs.readFileSync(target, 'utf8')
162
+ : null;
163
+ if (original === content) continue;
164
+ fs.writeFileSync(target, content, 'utf8');
165
+ wrote += 1;
166
+ }
167
+ for (const stray of strays) {
168
+ fs.rmSync(path.join(CHECKLISTS_DIR, stray));
169
+ Logger.info(
170
+ `generate-lens-checklists: pruned stray ${relChecklist(stray)}`,
171
+ );
172
+ }
173
+ Logger.info(
174
+ `generate-lens-checklists: wrote ${wrote} of ${expected.size} checklist(s) (${strays.length} pruned).`,
175
+ );
176
+ }
177
+
178
+ export { CHECKLISTS_DIR, WORKFLOWS_DIR };
179
+
180
+ runAsCli(import.meta.url, main, { source: 'generate-lens-checklists' });
@@ -0,0 +1,300 @@
1
+ /**
2
+ * lib/audit-suite/checklist-threading.js — write-time local-lens checklist
3
+ * threading (Epic #4405, Story #4410).
4
+ *
5
+ * Given a Story's predicted footprint (its `changes[]` / `references[]` path
6
+ * list), select the distilled authoring checklists for the **footprint-matched
7
+ * LOCAL lenses only** and assemble them into one payload the dispatch/hydrator
8
+ * seam threads into the Story maker prompt. This moves each local-lens concern
9
+ * to the innermost, write-time tier so makers author against it on fresh
10
+ * context instead of paying an Epic-close remediation loop on stale context.
11
+ *
12
+ * Matching is the **light** path, deliberately NOT `selectAudits`:
13
+ * - tier gate — a lens is in scope only when `resolveLensTier(lens) ===
14
+ * 'local'` (the pure `scope`-field read; cumulative/global
15
+ * lenses are owned by the Epic-close tier, never threaded
16
+ * here).
17
+ * - file gate — the pure `matchesAnyFilePattern` matcher runs the lens's
18
+ * own `triggers.filePatterns` (from `audit-rules.json`)
19
+ * against the predicted footprint.
20
+ *
21
+ * `selectAudits` is the wrong tool here: it is gate-aware and provider-backed
22
+ * (it reads the ticket through an `ITicketingProvider` and diffs the working
23
+ * tree via `git`). The write-time path has a *predicted* footprint, not a git
24
+ * diff, and no ticket to fetch — so this module reaches for the two pure
25
+ * primitives directly and never touches a provider or `git`. That purity is
26
+ * load-bearing (asserted by the test): the threading path must be a pure
27
+ * function of the footprint and the on-disk manifest/checklists.
28
+ *
29
+ * A hard token budget caps the assembled payload. When the matched lenses'
30
+ * checklists together exceed the budget, the payload is truncated
31
+ * deterministically (a stable prefix in {@link AUDIT_LENSES} order is kept;
32
+ * the overflowing tail is dropped) and every dropped lens is logged so the
33
+ * elision is never silent.
34
+ *
35
+ * Pure (modulo the default disk reads for `audit-rules.json` and the checklist
36
+ * artifacts, both injectable seams). No git, no provider, no network.
37
+ */
38
+
39
+ import fs from 'node:fs';
40
+ import path from 'node:path';
41
+ import { AUDIT_LENSES } from '../audit-to-stories/audit-lenses.js';
42
+ import { getPaths, PROJECT_ROOT, resolveConfig } from '../config-resolver.js';
43
+ import { Logger } from '../Logger.js';
44
+ import { estimateTokens } from '../orchestration/context-envelope.js';
45
+ import { matchesAnyFilePattern, resolveLensTier } from './selector.js';
46
+
47
+ /**
48
+ * Hard cap on the assembled checklist payload, in the ≈4-char/token estimate
49
+ * shared with the rest of the hydrator ({@link estimateTokens}). Generous
50
+ * relative to the real checklist sizes (each distilled lens checklist is
51
+ * ~130–190 tokens, and at most the seven local lenses can match), so a normal
52
+ * Story is never truncated — the cap is a safety ceiling against a pathological
53
+ * footprint that matches every local lens, not a routine squeeze. Callers may
54
+ * override per-invocation via `tokenBudget`.
55
+ */
56
+ export const DEFAULT_CHECKLIST_TOKEN_BUDGET = 4000;
57
+
58
+ /** Separator between concatenated per-lens checklist sections. */
59
+ const SECTION_SEPARATOR = '\n\n';
60
+
61
+ /**
62
+ * The `audit-rules.json` / `resolveLensTier` lens key for a canonical lens
63
+ * name. The taxonomy in {@link AUDIT_LENSES} carries bare names (`clean-code`);
64
+ * the manifest and the tier resolver key off the `audit-`-prefixed form
65
+ * (`audit-clean-code`).
66
+ *
67
+ * @param {string} lens
68
+ * @returns {string}
69
+ */
70
+ function lensKeyFor(lens) {
71
+ return `audit-${lens}`;
72
+ }
73
+
74
+ /**
75
+ * Absolute path to the committed distilled-checklist directory. Mirrors the
76
+ * SSOT the generator writes to (`generate-lens-checklists.js` →
77
+ * `<agentRoot>/audit-checklists/<lens>.md`), resolved through the same
78
+ * `agentRoot` the rest of the config surface uses so a consumer that relocates
79
+ * `.agents` reads its checklists from the relocated tree.
80
+ *
81
+ * @param {object} [config] resolved config wrapper (defaults to a fresh read).
82
+ * @returns {string}
83
+ */
84
+ function checklistsDir(config = resolveConfig()) {
85
+ return path.join(
86
+ PROJECT_ROOT,
87
+ getPaths(config).agentRoot,
88
+ 'audit-checklists',
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Read and parse `audit-rules.json` (the same manifest {@link selectAudits}
94
+ * and {@link resolveLensTier} consume), resolved through the configured
95
+ * `schemasRoot`. Synchronous — this is a pure-ish read with no git and no
96
+ * provider.
97
+ *
98
+ * @param {object} [config] resolved config wrapper (defaults to a fresh read).
99
+ * @returns {{ audits?: Record<string, { triggers?: { filePatterns?: string[] } }> }}
100
+ */
101
+ export function readAuditRules(config = resolveConfig()) {
102
+ const rulesPath = path.join(
103
+ PROJECT_ROOT,
104
+ getPaths(config).schemasRoot,
105
+ 'audit-rules.json',
106
+ );
107
+ return JSON.parse(fs.readFileSync(rulesPath, 'utf8'));
108
+ }
109
+
110
+ /**
111
+ * The `triggers.filePatterns` a lens declares in the manifest, or `[]` when
112
+ * the lens declares none. (`audit-clean-code` carries the universal
113
+ * match-everything glob, so it footprint-matches every non-empty change
114
+ * set — the selector.js twin comment documents the same post-#4405
115
+ * follow-up fix.)
116
+ *
117
+ * @param {object} rules parsed `audit-rules.json`.
118
+ * @param {string} lens canonical lens name.
119
+ * @returns {string[]}
120
+ */
121
+ function filePatternsFor(rules, lens) {
122
+ return rules?.audits?.[lensKeyFor(lens)]?.triggers?.filePatterns ?? [];
123
+ }
124
+
125
+ /**
126
+ * Normalize a predicted footprint into a clean path list. Accepts a plain
127
+ * `string[]` (the shape the caller derives from a Story's `changes[]` /
128
+ * `references[]` entries); drops empty and non-string entries.
129
+ *
130
+ * @param {unknown} footprint
131
+ * @returns {string[]}
132
+ */
133
+ function normalizeFootprint(footprint) {
134
+ if (!Array.isArray(footprint)) return [];
135
+ return footprint
136
+ .filter((p) => typeof p === 'string')
137
+ .map((p) => p.trim())
138
+ .filter(Boolean);
139
+ }
140
+
141
+ /**
142
+ * Select, in stable {@link AUDIT_LENSES} order, the LOCAL lenses whose
143
+ * `filePatterns` match the predicted footprint. This is the whole matching
144
+ * contract — `resolveLensTier(lens) === 'local'` for the tier gate plus the
145
+ * pure `matchesAnyFilePattern` matcher for the file gate — with no provider and
146
+ * no git diff.
147
+ *
148
+ * A lens whose tier cannot be resolved (not registered / malformed scope) is
149
+ * skipped rather than allowed to throw the whole selection; the generator and
150
+ * schema keep the manifest well-formed, so this only guards against drift.
151
+ *
152
+ * @param {object} params
153
+ * @param {string[]} params.footprint predicted footprint path list.
154
+ * @param {object} [params.rules] parsed `audit-rules.json` (injectable seam).
155
+ * @param {(lens: string) => string} [params.resolveTier] tier resolver
156
+ * (injectable seam; defaults to {@link resolveLensTier}).
157
+ * @returns {string[]} matched local lens names, in taxonomy order.
158
+ */
159
+ export function matchLocalLenses({
160
+ footprint,
161
+ rules = readAuditRules(),
162
+ resolveTier = resolveLensTier,
163
+ } = {}) {
164
+ const paths = normalizeFootprint(footprint);
165
+ if (paths.length === 0) return [];
166
+
167
+ const matched = [];
168
+ for (const lens of AUDIT_LENSES) {
169
+ let tier;
170
+ try {
171
+ tier = resolveTier(lensKeyFor(lens));
172
+ } catch {
173
+ continue;
174
+ }
175
+ if (tier !== 'local') continue;
176
+
177
+ const patterns = filePatternsFor(rules, lens);
178
+ if (patterns.length === 0) continue;
179
+ if (matchesAnyFilePattern(patterns, paths)) matched.push(lens);
180
+ }
181
+ return matched;
182
+ }
183
+
184
+ /**
185
+ * Default checklist reader: read `<agentRoot>/audit-checklists/<lens>.md`.
186
+ * Returns `null` when the artifact is absent (a matched lens with no committed
187
+ * checklist), so the caller skips it rather than crashing.
188
+ *
189
+ * @param {string} lens
190
+ * @param {object} [config]
191
+ * @returns {string|null}
192
+ */
193
+ function readChecklistFile(lens, config = resolveConfig()) {
194
+ const file = path.join(checklistsDir(config), `${lens}.md`);
195
+ try {
196
+ return fs.readFileSync(file, 'utf8');
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Build the write-time checklist payload for a Story's predicted footprint.
204
+ *
205
+ * Returns the assembled payload string plus the accounting the caller (and the
206
+ * test) asserts against: which lenses were included, which were dropped by the
207
+ * budget, the full matched set, and the payload's estimated token count.
208
+ *
209
+ * Truncation is deterministic prefix truncation: lenses are considered in
210
+ * stable {@link AUDIT_LENSES} order and appended while the running payload
211
+ * estimate stays within `tokenBudget`; the first lens that would overflow — and
212
+ * every remaining matched lens — is dropped, and the drop is logged. The
213
+ * returned `payload` therefore always satisfies
214
+ * `estimateTokens(payload) <= tokenBudget`.
215
+ *
216
+ * @param {object} params
217
+ * @param {string[]} params.footprint predicted footprint (`changes[]` /
218
+ * `references[]` path list).
219
+ * @param {number} [params.tokenBudget] hard cap (defaults to
220
+ * {@link DEFAULT_CHECKLIST_TOKEN_BUDGET}).
221
+ * @param {object} [params.rules] parsed `audit-rules.json` (injectable seam).
222
+ * @param {(lens: string) => string} [params.resolveTier] tier resolver
223
+ * (injectable seam).
224
+ * @param {(lens: string) => (string|null)} [params.readChecklist] checklist
225
+ * reader (injectable seam; defaults to the on-disk reader).
226
+ * @param {{ warn?: (msg: string) => void }} [params.logger] logger for the drop
227
+ * record (defaults to {@link Logger}).
228
+ * @returns {{
229
+ * payload: string,
230
+ * includedLenses: string[],
231
+ * droppedLenses: string[],
232
+ * matchedLenses: string[],
233
+ * estimatedTokens: number,
234
+ * tokenBudget: number,
235
+ * }}
236
+ */
237
+ export function buildChecklistPayload({
238
+ footprint,
239
+ tokenBudget = DEFAULT_CHECKLIST_TOKEN_BUDGET,
240
+ rules,
241
+ resolveTier,
242
+ readChecklist = readChecklistFile,
243
+ logger = Logger,
244
+ } = {}) {
245
+ const resolvedRules = rules ?? readAuditRules();
246
+ const matchedLenses = matchLocalLenses({
247
+ footprint,
248
+ rules: resolvedRules,
249
+ resolveTier,
250
+ });
251
+
252
+ const includedLenses = [];
253
+ const droppedLenses = [];
254
+ const sections = [];
255
+ let payload = '';
256
+
257
+ for (let i = 0; i < matchedLenses.length; i++) {
258
+ const lens = matchedLenses[i];
259
+ const content = readChecklist(lens);
260
+ if (content == null) {
261
+ // Matched a lens with no committed checklist artifact — skip it (never a
262
+ // budget drop). The generator keeps every lens's checklist in tree, so
263
+ // this only trips on drift, which we surface rather than swallow.
264
+ logger?.warn?.(
265
+ `[checklist-threading] no checklist artifact for matched local lens '${lens}' — skipping`,
266
+ );
267
+ continue;
268
+ }
269
+
270
+ const trimmed = content.trim();
271
+ const candidate = [...sections, trimmed].join(SECTION_SEPARATOR);
272
+ if (estimateTokens(candidate) > tokenBudget) {
273
+ // Deterministic prefix truncation: this lens and every remaining matched
274
+ // lens are dropped, keeping the payload within budget.
275
+ droppedLenses.push(...matchedLenses.slice(i));
276
+ break;
277
+ }
278
+
279
+ sections.push(trimmed);
280
+ includedLenses.push(lens);
281
+ payload = candidate;
282
+ }
283
+
284
+ if (droppedLenses.length > 0) {
285
+ logger?.warn?.(
286
+ `[checklist-threading] token budget ${tokenBudget} exceeded — dropped ` +
287
+ `${droppedLenses.length} footprint-matched local-lens checklist(s): ` +
288
+ `${droppedLenses.join(', ')}`,
289
+ );
290
+ }
291
+
292
+ return {
293
+ payload,
294
+ includedLenses,
295
+ droppedLenses,
296
+ matchedLenses,
297
+ estimatedTokens: estimateTokens(payload),
298
+ tokenBudget,
299
+ };
300
+ }
@@ -35,6 +35,33 @@ export function aggregateSummary(findings) {
35
35
  return summary;
36
36
  }
37
37
 
38
+ /**
39
+ * The single halting rule for the unified `verification-results` findings
40
+ * contract (Story #4411, Epic #4405): a **surviving** (unfixed) Critical
41
+ * finding halts the delivery gate. Every code consumer of the contract —
42
+ * the in-process code-review producer (`runCodeReview`) and the auto-merge
43
+ * integration gate (`evaluateAutoMergePredicate`) — routes its
44
+ * halt-on-critical decision through this one predicate so the rule has a
45
+ * single definition rather than a re-derived `critical > 0` expression at
46
+ * each site.
47
+ *
48
+ * Accepts either the severity **count object** produced by
49
+ * `countBySeverity` / {@link aggregateSummary} (`{ critical: n, ... }`) or a
50
+ * raw `Finding[]`. A non-numeric / absent `critical` count (e.g. the
51
+ * auto-merge gate's "unparseable body" sentinel `null`) is **not** a halt —
52
+ * the caller owns that fail-open path separately. Pure; never throws.
53
+ *
54
+ * @param {{ critical?: unknown }|Array<{ severity?: string }>|null|undefined} input
55
+ * @returns {boolean} `true` when at least one surviving Critical is present.
56
+ */
57
+ export function hasSurvivingCritical(input) {
58
+ if (Array.isArray(input)) {
59
+ return input.some((finding) => finding?.severity === 'critical');
60
+ }
61
+ const critical = input?.critical;
62
+ return typeof critical === 'number' && critical > 0;
63
+ }
64
+
38
65
  /**
39
66
  * Resolve the per-component rollup map for an envelope. When the envelope
40
67
  * already carries a `rollup` block (every writer-produced baseline does), we
@@ -14,14 +14,23 @@
14
14
  * import { runAuditSuite, selectAudits } from './lib/audit-suite/index.js';
15
15
  */
16
16
 
17
+ export {
18
+ buildChecklistPayload,
19
+ DEFAULT_CHECKLIST_TOKEN_BUDGET,
20
+ matchLocalLenses,
21
+ readAuditRules,
22
+ } from './checklist-threading.js';
17
23
  export { runAuditSuite } from './runner.js';
18
24
  export {
19
25
  GLOBAL_LENS_ALLOWLIST,
20
26
  isGlobalLens,
27
+ LENS_TIERS,
21
28
  matchesAnyFilePattern,
22
29
  matchesFilePattern,
23
30
  NAVIGABILITY_LENS,
31
+ resolveLensTier,
24
32
  resolveNavigabilityRouteGlobs,
25
33
  routesNavigabilityLens,
26
34
  selectAudits,
35
+ selectLocalLenses,
27
36
  } from './selector.js';