mandrel 2.3.0 → 2.5.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 (76) hide show
  1. package/.agents/audit-checklists/accessibility.md +29 -0
  2. package/.agents/audit-checklists/architecture.md +4 -5
  3. package/.agents/audit-checklists/clean-code.md +10 -0
  4. package/.agents/audit-checklists/data-model.md +22 -0
  5. package/.agents/audit-checklists/dependencies.md +11 -2
  6. package/.agents/audit-checklists/devops.md +4 -0
  7. package/.agents/audit-checklists/navigability.md +3 -0
  8. package/.agents/audit-checklists/performance.md +8 -11
  9. package/.agents/audit-checklists/privacy.md +3 -4
  10. package/.agents/audit-checklists/quality.md +2 -0
  11. package/.agents/audit-checklists/security.md +4 -5
  12. package/.agents/audit-checklists/seo.md +7 -1
  13. package/.agents/audit-checklists/sre.md +14 -12
  14. package/.agents/audit-checklists/ux-ui.md +4 -0
  15. package/.agents/docs/configuration.md +3 -0
  16. package/.agents/docs/workflows.md +4 -3
  17. package/.agents/schemas/agentrc.schema.json +17 -0
  18. package/.agents/schemas/audit-rules.json +134 -19
  19. package/.agents/schemas/audit-rules.schema.json +6 -2
  20. package/.agents/scripts/audit-labels-bootstrap.js +4 -4
  21. package/.agents/scripts/audit-to-stories.js +244 -19
  22. package/.agents/scripts/lib/audit-suite/checklist-threading.js +26 -3
  23. package/.agents/scripts/lib/audit-suite/dispatch-checklist.js +132 -0
  24. package/.agents/scripts/lib/audit-suite/index.js +1 -0
  25. package/.agents/scripts/lib/audit-suite/selector.js +290 -14
  26. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +2 -1
  27. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +5 -1
  28. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +23 -3
  29. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +38 -0
  30. package/.agents/scripts/lib/audit-to-stories/ledger.js +256 -0
  31. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +41 -7
  32. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +20 -2
  33. package/.agents/scripts/lib/command-header.js +1 -1
  34. package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
  35. package/.agents/scripts/lib/dynamic-workflow/performance-report-contract.js +5 -3
  36. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +56 -0
  37. package/.agents/scripts/lib/findings/route-finding.js +108 -10
  38. package/.agents/scripts/lib/observability/runtime-friction.js +62 -0
  39. package/.agents/scripts/lib/orchestration/resolve-stories.js +9 -1
  40. package/.agents/scripts/lib/orchestration/retro-proposals.js +49 -1
  41. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +73 -15
  42. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +81 -1
  43. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +1 -0
  44. package/.agents/scripts/lib/orchestration/ticket-lease.js +78 -10
  45. package/.agents/scripts/lib/orchestration/ticketing/transition.js +68 -16
  46. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +73 -0
  47. package/.agents/scripts/lib/wave-runner/live-probe.js +93 -12
  48. package/.agents/scripts/nav-registry-diff.js +449 -0
  49. package/.agents/scripts/single-story-init.js +189 -51
  50. package/.agents/scripts/stories-wave-tick.js +39 -2
  51. package/.agents/workflows/audit-accessibility.md +243 -0
  52. package/.agents/workflows/audit-architecture.md +89 -71
  53. package/.agents/workflows/audit-clean-code.md +87 -53
  54. package/.agents/workflows/audit-data-model.md +198 -0
  55. package/.agents/workflows/audit-dependencies.md +143 -28
  56. package/.agents/workflows/audit-devops.md +109 -18
  57. package/.agents/workflows/audit-documentation.md +25 -53
  58. package/.agents/workflows/audit-navigability.md +78 -22
  59. package/.agents/workflows/audit-performance.md +207 -103
  60. package/.agents/workflows/audit-privacy.md +51 -13
  61. package/.agents/workflows/audit-quality.md +71 -61
  62. package/.agents/workflows/audit-security.md +94 -71
  63. package/.agents/workflows/audit-seo.md +80 -25
  64. package/.agents/workflows/audit-sre.md +99 -66
  65. package/.agents/workflows/audit-to-stories.md +44 -5
  66. package/.agents/workflows/audit-ux-ui.md +71 -17
  67. package/.agents/workflows/deliver.md +32 -8
  68. package/.agents/workflows/helpers/audit-dual-path.md +59 -0
  69. package/.agents/workflows/helpers/audit-self-check.md +70 -0
  70. package/.agents/workflows/helpers/audit-severity-scale.md +19 -0
  71. package/.agents/workflows/helpers/deliver-story.md +25 -0
  72. package/docs/CHANGELOG.md +29 -0
  73. package/package.json +1 -1
  74. package/.agents/audit-checklists/lighthouse.md +0 -15
  75. package/.agents/schemas/audit-results.schema.json +0 -69
  76. package/.agents/workflows/audit-lighthouse.md +0 -269
@@ -36,9 +36,16 @@ import { buildStoryBody } from './lib/audit-to-stories/build-story-body.js';
36
36
  import { classifyGroupsAgainstGitHub } from './lib/audit-to-stories/dedupe-against-github.js';
37
37
  import { withFingerprints } from './lib/audit-to-stories/finding-adapter.js';
38
38
  import { groupFindings } from './lib/audit-to-stories/group-findings.js';
39
+ import {
40
+ DEFAULT_LEDGER_PATH,
41
+ readLedger,
42
+ reconcileLedger,
43
+ writeLedger,
44
+ } from './lib/audit-to-stories/ledger.js';
39
45
  import { parseAuditReports } from './lib/audit-to-stories/parse-audit-md.js';
40
46
  import { buildPlanSeedMarkdown } from './lib/audit-to-stories/seed-from-findings.js';
41
47
  import { runAsCli } from './lib/cli-utils.js';
48
+ import { searchSemanticCandidates } from './lib/findings/semantic-issue-search.js';
42
49
  import { Logger } from './lib/Logger.js';
43
50
  import { parse as parseStoryBody } from './lib/story-body/story-body.js';
44
51
 
@@ -78,30 +85,54 @@ function tallyBySeverity(findings) {
78
85
  return t;
79
86
  }
80
87
 
81
- async function loadProvider() {
88
+ async function loadProvider({ createProviderImpl, resolveConfigImpl } = {}) {
82
89
  // The provider is optional — when missing, the dedupe step emits a
83
- // create-only classification and the workflow operator is informed.
90
+ // create-only classification and the workflow operator is informed. The
91
+ // `createProviderImpl` / `resolveConfigImpl` seams let a contract test drive
92
+ // this exact adapter (fingerprint + semantic-candidate ports) with an
93
+ // in-memory issue store instead of the live GitHub provider.
84
94
  try {
85
- const mod = await import('./lib/provider-factory.js');
86
- const { resolveConfig } = await import('./lib/config-resolver.js');
95
+ const resolveConfig =
96
+ resolveConfigImpl ??
97
+ (await import('./lib/config-resolver.js')).resolveConfig;
98
+ const createProvider =
99
+ createProviderImpl ??
100
+ (await import('./lib/provider-factory.js')).createProvider;
87
101
  const config = resolveConfig();
88
- const provider = mod.createProvider(config ?? {});
102
+ const provider = createProvider(config ?? {});
89
103
  // The existing provider exposes higher-level ticket I/O. The dedupe
90
- // module only needs `findIssuesByFingerprint(sha)`. Adapt here so we
91
- // don't bake provider-shape knowledge into the dedupe module.
104
+ // module needs `findIssuesByFingerprint(sha)` for the exact-fingerprint
105
+ // pass and since Story #4626 a `searchCandidates(finding)` port for
106
+ // the meaning-first Stage-1 pass. Adapt both here so we don't bake
107
+ // provider-shape knowledge into the dedupe module.
92
108
  if (typeof provider.searchIssues === 'function') {
109
+ const owner = config?.github?.owner;
110
+ const repo = config?.github?.repo;
111
+ const normalise = (h) => ({
112
+ number: h.number,
113
+ state: (h.state ?? h.state_reason ?? 'open')
114
+ .toString()
115
+ .toLowerCase()
116
+ .includes('closed')
117
+ ? 'closed'
118
+ : 'open',
119
+ title: h.title ?? '',
120
+ body: h.body ?? '',
121
+ });
93
122
  return {
94
123
  async findIssuesByFingerprint(sha) {
95
- const hits = await provider.searchIssues({
96
- query: sha,
97
- owner: config?.github?.owner,
98
- repo: config?.github?.repo,
99
- });
100
- return (hits ?? []).map((h) => ({
101
- number: h.number,
102
- state: h.state ?? h.state_reason ?? 'OPEN',
103
- body: h.body ?? '',
104
- }));
124
+ const hits = await provider.searchIssues({ query: sha, owner, repo });
125
+ return (hits ?? []).map(normalise);
126
+ },
127
+ async searchCandidates(finding) {
128
+ // Wire the shared semantic search onto the provider's full-text
129
+ // issue search (open + closed) so route-finding's Stage-1 pass runs.
130
+ const search = async (query) => {
131
+ if (!query || query.trim().length === 0) return [];
132
+ const hits = await provider.searchIssues({ query, owner, repo });
133
+ return (hits ?? []).map(normalise);
134
+ };
135
+ return searchSemanticCandidates(finding, { search });
105
136
  },
106
137
  };
107
138
  }
@@ -149,7 +180,7 @@ function dedupSkippedWarning(reason) {
149
180
  );
150
181
  }
151
182
 
152
- async function buildPlan({ glob: pattern, severity, useProvider }) {
183
+ async function buildPlan({ glob: pattern, severity, useProvider, ledger }) {
153
184
  const reportPaths = await collectReportPaths(pattern ?? DEFAULT_GLOB);
154
185
  if (reportPaths.length === 0) {
155
186
  return {
@@ -188,7 +219,11 @@ async function buildPlan({ glob: pattern, severity, useProvider }) {
188
219
  if (useProvider) {
189
220
  const provider = await loadProvider();
190
221
  if (provider) {
191
- const result = await classifyGroupsAgainstGitHub({ groups, provider });
222
+ const result = await classifyGroupsAgainstGitHub({
223
+ groups,
224
+ provider,
225
+ searchCandidates: provider.searchCandidates,
226
+ });
192
227
  classifications = result.classifications;
193
228
  summary = result.summary;
194
229
  dedupApplied = true;
@@ -205,6 +240,35 @@ async function buildPlan({ glob: pattern, severity, useProvider }) {
205
240
  Logger.warn(dedupSkippedWarning('disabled'));
206
241
  }
207
242
 
243
+ // Cross-run ledger (Story #4626): fold this scan onto the committed memory,
244
+ // suppress findings a prior run recorded as accepted-risk, and (unless the
245
+ // caller asked not to write) persist the updated ledger. Opt-in — the plain
246
+ // --scan path leaves it untouched so it never mutates a committed file.
247
+ let ledgerSummary;
248
+ if (ledger) {
249
+ const suppressed = reconcileScanLedger({
250
+ ledgerPath: ledger.path ?? DEFAULT_LEDGER_PATH,
251
+ findings: stamped,
252
+ classifications,
253
+ write: ledger.write !== false,
254
+ });
255
+ if (suppressed.size > 0) {
256
+ for (const c of classifications) {
257
+ const findings = c.group?.findings ?? [];
258
+ if (
259
+ findings.length > 0 &&
260
+ findings.every((f) => suppressed.has(f?.fingerprint?.full))
261
+ ) {
262
+ c.action = 'skip-accepted-risk';
263
+ }
264
+ }
265
+ }
266
+ ledgerSummary = {
267
+ path: ledger.path ?? DEFAULT_LEDGER_PATH,
268
+ suppressed: suppressed.size,
269
+ };
270
+ }
271
+
208
272
  return {
209
273
  generatedAt: new Date().toISOString(),
210
274
  sourceReports: reportPaths,
@@ -218,16 +282,157 @@ async function buildPlan({ glob: pattern, severity, useProvider }) {
218
282
  filtered: filtered.length,
219
283
  tally: tallyBySeverity(filtered),
220
284
  dedupApplied,
285
+ ...(ledgerSummary ? { ledger: ledgerSummary } : {}),
221
286
  ...summary,
222
287
  },
223
288
  };
224
289
  }
225
290
 
291
+ /**
292
+ * Fold the current scan onto the committed cross-run ledger and persist it.
293
+ * Returns the set of finding fingerprints the ledger says are accepted-risk
294
+ * (deliberately rejected) so the caller can suppress them.
295
+ *
296
+ * @param {object} params
297
+ * @param {string} params.ledgerPath
298
+ * @param {Array<object>} params.findings — stamped scan findings.
299
+ * @param {Array<{ group?: object, matchedIssues?: Array<{ number: number, state: string }>, matchedFingerprints?: string[] }>} params.classifications
300
+ * @param {boolean} [params.write=true]
301
+ * @returns {Set<string>}
302
+ */
303
+ function reconcileScanLedger({ ledgerPath, findings, classifications, write }) {
304
+ const prior = readLedger(ledgerPath);
305
+ const issueStates = issueStatesFromClassifications(classifications);
306
+ const { ledger: next } = reconcileLedger({
307
+ ledger: prior,
308
+ findings,
309
+ issueStates,
310
+ });
311
+ if (write !== false) writeLedger(ledgerPath, next);
312
+ return new Set(
313
+ next.entries
314
+ .filter((e) => e.status === 'accepted-risk')
315
+ .map((e) => e.fingerprint),
316
+ );
317
+ }
318
+
319
+ /**
320
+ * Derive a `{ fingerprint → issueState }` map from dedupe classifications so
321
+ * the ledger reconcile sees the live open/closed state of matched Issues.
322
+ * @param {Array<object>} classifications
323
+ * @returns {Record<string, { state: string, number: number|null }>}
324
+ */
325
+ function issueStatesFromClassifications(classifications) {
326
+ const states = {};
327
+ for (const c of classifications ?? []) {
328
+ const issue = (c.matchedIssues ?? [])[0];
329
+ if (!issue) continue;
330
+ const state = String(issue.state ?? '')
331
+ .toLowerCase()
332
+ .includes('closed')
333
+ ? 'closed'
334
+ : 'open';
335
+ for (const fp of c.matchedFingerprints ?? []) {
336
+ states[fp] = { state, number: issue.number ?? null };
337
+ }
338
+ }
339
+ return states;
340
+ }
341
+
226
342
  function loadPlan(planPath) {
227
343
  if (!planPath) throw new Error('--plan <path> is required');
228
344
  return JSON.parse(fs.readFileSync(planPath, 'utf8'));
229
345
  }
230
346
 
347
+ const DEFAULT_SEVERITY_FLOOR = 'high';
348
+
349
+ /**
350
+ * Resolve the unattended-sweep severity floor: an explicit `--severity` wins,
351
+ * else `delivery.auditToStories.severityFloor` from config, else the built-in
352
+ * default (`high`). Reads config defensively so a missing/failed resolve never
353
+ * breaks the run.
354
+ *
355
+ * @param {string|undefined} explicit
356
+ * @returns {Promise<string>}
357
+ */
358
+ async function resolveSeverityFloor(explicit) {
359
+ if (explicit) return explicit;
360
+ try {
361
+ const { resolveConfig } = await import('./lib/config-resolver.js');
362
+ const config = resolveConfig();
363
+ const floor = config?.delivery?.auditToStories?.severityFloor;
364
+ if (typeof floor === 'string' && floor.length > 0) return floor;
365
+ } catch (_) {
366
+ // fall through to default
367
+ }
368
+ return DEFAULT_SEVERITY_FLOOR;
369
+ }
370
+
371
+ /**
372
+ * Unattended `--auto` sweep. No interactive gates: it resolves the severity
373
+ * floor from config, builds the plan (with cross-run ledger reconciliation),
374
+ * and reports a run summary. Under `--dry-run` it performs zero GitHub writes
375
+ * and emits the summary only; otherwise it returns the create-eligible Story
376
+ * payloads for the caller to open. Always resolves — never prompts.
377
+ *
378
+ * @param {object} params
379
+ * @param {string} [params.glob]
380
+ * @param {string} [params.severity] — explicit floor override.
381
+ * @param {boolean} [params.dryRun]
382
+ * @param {boolean} [params.useProvider]
383
+ * @param {string} [params.ledgerPath]
384
+ * @returns {Promise<{ summary: object, stories: Array<object> }>}
385
+ */
386
+ async function runAuto({ glob, severity, dryRun, useProvider, ledgerPath }) {
387
+ const floor = await resolveSeverityFloor(severity);
388
+ const plan = await buildPlan({
389
+ glob,
390
+ severity: floor,
391
+ useProvider,
392
+ ledger: { path: ledgerPath ?? DEFAULT_LEDGER_PATH, write: !dryRun },
393
+ });
394
+
395
+ const byAction = {
396
+ create: [],
397
+ skipOpen: [],
398
+ skipReoccurring: [],
399
+ suppressed: [],
400
+ };
401
+ for (const c of plan.classifications ?? []) {
402
+ if (c.action === 'create') byAction.create.push(c);
403
+ else if (c.action === 'skip-open') byAction.skipOpen.push(c);
404
+ else if (c.action === 'skip-reoccurring') byAction.skipReoccurring.push(c);
405
+ else if (c.action === 'skip-accepted-risk') byAction.suppressed.push(c);
406
+ }
407
+
408
+ const eligible = byAction.create.map((c) => c.group);
409
+ const stories = dryRun ? [] : buildAndGateStories(eligible, plan.edges ?? []);
410
+
411
+ const summary = {
412
+ mode: 'auto',
413
+ dryRun: Boolean(dryRun),
414
+ severityFloor: floor,
415
+ sourceReports: plan.sourceReports ?? [],
416
+ totals: {
417
+ findings: plan.summary?.totalFindings ?? 0,
418
+ filtered: plan.summary?.filtered ?? 0,
419
+ groups: (plan.groups ?? []).length,
420
+ create: byAction.create.length,
421
+ skipOpen: byAction.skipOpen.length,
422
+ skipReoccurring: byAction.skipReoccurring.length,
423
+ suppressedByLedger: byAction.suppressed.length,
424
+ },
425
+ // Re-detected open Issues the operator may want a "re-detected" comment on.
426
+ reDetected: byAction.skipOpen
427
+ .flatMap((c) => c.matchedIssues ?? [])
428
+ .map((i) => i.number)
429
+ .filter((n) => typeof n === 'number'),
430
+ ledger: plan.summary?.ledger ?? null,
431
+ };
432
+
433
+ return { summary, stories };
434
+ }
435
+
231
436
  /**
232
437
  * Build every eligible group into a `{ title, body, labels }` Story object and
233
438
  * gate the batch against the inline-contract bar BEFORE any issue is opened.
@@ -284,6 +489,10 @@ export const __testing = {
284
489
  loadProvider,
285
490
  dedupSkippedWarning,
286
491
  buildAndGateStories,
492
+ runAuto,
493
+ resolveSeverityFloor,
494
+ reconcileScanLedger,
495
+ issueStatesFromClassifications,
287
496
  };
288
497
 
289
498
  async function main() {
@@ -291,10 +500,13 @@ async function main() {
291
500
  args: process.argv.slice(2),
292
501
  options: {
293
502
  scan: { type: 'boolean' },
503
+ auto: { type: 'boolean' },
504
+ 'dry-run': { type: 'boolean' },
294
505
  'emit-plan-seed': { type: 'boolean' },
295
506
  'emit-stories': { type: 'boolean' },
296
507
  glob: { type: 'string' },
297
508
  severity: { type: 'string' },
509
+ ledger: { type: 'string' },
298
510
  plan: { type: 'string' },
299
511
  out: { type: 'string' },
300
512
  'no-provider': { type: 'boolean' },
@@ -303,6 +515,19 @@ async function main() {
303
515
  strict: false,
304
516
  });
305
517
 
518
+ if (values.auto) {
519
+ const { summary } = await runAuto({
520
+ glob: values.glob,
521
+ severity: values.severity,
522
+ dryRun: values['dry-run'],
523
+ useProvider: !values['no-provider'],
524
+ ledgerPath: values.ledger,
525
+ });
526
+ persist(JSON.stringify(summary, null, 2), values.out);
527
+ if (!values.out) process.stdout.write('\n');
528
+ return;
529
+ }
530
+
306
531
  if (values.scan) {
307
532
  const plan = await buildPlan({
308
533
  glob: values.glob,
@@ -42,7 +42,11 @@ import { AUDIT_LENSES } from '../audit-to-stories/audit-lenses.js';
42
42
  import { getPaths, PROJECT_ROOT, resolveConfig } from '../config-resolver.js';
43
43
  import { Logger } from '../Logger.js';
44
44
  import { estimateTokens } from '../orchestration/context-envelope.js';
45
- import { matchesAnyFilePattern, resolveLensTier } from './selector.js';
45
+ import {
46
+ changeSetLacksSiblingTest,
47
+ matchesAnyFilePattern,
48
+ resolveLensTier,
49
+ } from './selector.js';
46
50
 
47
51
  /**
48
52
  * Hard cap on the assembled checklist payload, in the ≈4-char/token estimate
@@ -122,6 +126,18 @@ function filePatternsFor(rules, lens) {
122
126
  return rules?.audits?.[lensKeyFor(lens)]?.triggers?.filePatterns ?? [];
123
127
  }
124
128
 
129
+ /**
130
+ * The full `triggers` block a lens declares in the manifest (or `undefined`).
131
+ * Used to read non-glob trigger fields (e.g. `sourceWithoutSiblingTest`).
132
+ *
133
+ * @param {object} rules parsed `audit-rules.json`.
134
+ * @param {string} lens canonical lens name.
135
+ * @returns {object|undefined}
136
+ */
137
+ function triggerFor(rules, lens) {
138
+ return rules?.audits?.[lensKeyFor(lens)]?.triggers;
139
+ }
140
+
125
141
  /**
126
142
  * Normalize a predicted footprint into a clean path list. Accepts a plain
127
143
  * `string[]` (the shape the caller derives from a Story's `changes[]` /
@@ -175,8 +191,15 @@ export function matchLocalLenses({
175
191
  if (tier !== 'local') continue;
176
192
 
177
193
  const patterns = filePatternsFor(rules, lens);
178
- if (patterns.length === 0) continue;
179
- if (matchesAnyFilePattern(patterns, paths)) matched.push(lens);
194
+ const fileMatch =
195
+ patterns.length > 0 && matchesAnyFilePattern(patterns, paths);
196
+ // Coverage-gap routing (#4628): thread the quality checklist at write-time
197
+ // on the same sibling-test predicate the selector uses, so a source change
198
+ // that ships without a test is reminded of the quality lens up front.
199
+ const siblingMatch =
200
+ triggerFor(rules, lens)?.sourceWithoutSiblingTest === true &&
201
+ changeSetLacksSiblingTest(paths);
202
+ if (fileMatch || siblingMatch) matched.push(lens);
180
203
  }
181
204
  return matched;
182
205
  }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * lib/audit-suite/dispatch-checklist.js — the deliver-dispatch call site for
3
+ * write-time checklist threading (Story #4627, activating Epic #4405's
4
+ * Story #4410 machinery).
5
+ *
6
+ * `checklist-threading.js#buildChecklistPayload` was built and unit-tested but
7
+ * had **zero production call sites**: nothing derived a footprint, assembled
8
+ * the payload, or handed a `checklistPath` to the spawned Story worker. This
9
+ * module is that call site. Given a Story's predicted footprint (its
10
+ * `changes[]` / `references[]` path entries), it assembles the footprint-matched
11
+ * local-lens checklist payload, writes it to the run's temp dir, and returns
12
+ * the `checklistPath` the deliver-story spawn threads into the maker's prompt
13
+ * (the same way the docs digest reaches the worker as `docsDigestPath`).
14
+ *
15
+ * Pure modulo the single file write, which is an injectable seam
16
+ * (`writeFileFn`) so the builder is unit-testable without touching disk. No
17
+ * git, no provider, no network — the footprint is the planner's *prediction*,
18
+ * not a diff, so no repository read is involved.
19
+ */
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { buildChecklistPayload } from './checklist-threading.js';
24
+
25
+ /**
26
+ * Extract clean path strings from a Story's `changes[]` / `references[]`
27
+ * entries. Accepts both the parsed `{ path, assumption }` PathEntry shape and a
28
+ * bare `string`, dropping empty / non-string entries.
29
+ *
30
+ * @param {unknown} entries
31
+ * @returns {string[]}
32
+ */
33
+ function footprintFromEntries(entries) {
34
+ if (!Array.isArray(entries)) return [];
35
+ return entries
36
+ .map((entry) => (typeof entry === 'string' ? entry : entry?.path))
37
+ .filter((p) => typeof p === 'string' && p.trim().length > 0)
38
+ .map((p) => p.trim());
39
+ }
40
+
41
+ /**
42
+ * Derive the predicted footprint the checklist matcher reads: the union of a
43
+ * Story's `changes[]` and `references[]` path entries, in that order.
44
+ *
45
+ * Module-local: a detail of {@link buildDispatchChecklist}, exercised through
46
+ * that public entry point (assert the footprint the injected payload builder
47
+ * received) rather than imported directly, so it adds no test-only public
48
+ * export the dead-export ratchet would flag.
49
+ *
50
+ * @param {{ changes?: unknown, references?: unknown }} [args]
51
+ * @returns {string[]}
52
+ */
53
+ function deriveDispatchFootprint({ changes, references } = {}) {
54
+ return [
55
+ ...footprintFromEntries(changes),
56
+ ...footprintFromEntries(references),
57
+ ];
58
+ }
59
+
60
+ /**
61
+ * Default file writer: ensure the parent dir exists, then write the payload.
62
+ *
63
+ * @param {string} filePath
64
+ * @param {string} content
65
+ * @returns {void}
66
+ */
67
+ function defaultWriteFile(filePath, content) {
68
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
69
+ fs.writeFileSync(filePath, content, 'utf8');
70
+ }
71
+
72
+ /**
73
+ * Build the write-time checklist payload for a Story and write it to the run's
74
+ * temp dir, returning the `checklistPath` the deliver-story spawn threads.
75
+ *
76
+ * When the footprint matches no local lens (the common case for a
77
+ * docs-only or infra-only Story), nothing is written and `checklistPath` is
78
+ * `null` — the worker then runs with no write-time checklist, exactly as it
79
+ * does today, and lens-aware coverage still runs maker-blind at Story-scope
80
+ * close.
81
+ *
82
+ * @param {object} args
83
+ * @param {number|string} args.storyId — names the payload file.
84
+ * @param {unknown} [args.changes] — the Story's `changes[]` path entries.
85
+ * @param {unknown} [args.references] — the Story's `references[]` path entries.
86
+ * @param {string} args.runTempDir — the run temp dir the payload is written to.
87
+ * @param {number} [args.tokenBudget] — override the payload cap.
88
+ * @param {typeof buildChecklistPayload} [args.buildPayloadFn] — injectable seam.
89
+ * @param {(filePath: string, content: string) => void} [args.writeFileFn] —
90
+ * injectable write seam (defaults to the on-disk writer).
91
+ * @returns {{
92
+ * checklistPath: string|null,
93
+ * skipped: boolean,
94
+ * matchedLenses: string[],
95
+ * includedLenses: string[],
96
+ * droppedLenses: string[],
97
+ * }}
98
+ */
99
+ export function buildDispatchChecklist({
100
+ storyId,
101
+ changes,
102
+ references,
103
+ runTempDir,
104
+ tokenBudget,
105
+ buildPayloadFn = buildChecklistPayload,
106
+ writeFileFn = defaultWriteFile,
107
+ }) {
108
+ const footprint = deriveDispatchFootprint({ changes, references });
109
+ const result = buildPayloadFn({
110
+ footprint,
111
+ ...(tokenBudget != null ? { tokenBudget } : {}),
112
+ });
113
+ const accounting = {
114
+ matchedLenses: result.matchedLenses,
115
+ includedLenses: result.includedLenses,
116
+ droppedLenses: result.droppedLenses,
117
+ };
118
+
119
+ if (!result.payload || result.includedLenses.length === 0) {
120
+ return { checklistPath: null, skipped: true, ...accounting };
121
+ }
122
+
123
+ if (!runTempDir) {
124
+ throw new TypeError(
125
+ 'buildDispatchChecklist: runTempDir is required to write a non-empty checklist payload',
126
+ );
127
+ }
128
+
129
+ const checklistPath = path.join(runTempDir, `story-${storyId}-checklist.md`);
130
+ writeFileFn(checklistPath, result.payload);
131
+ return { checklistPath, skipped: false, ...accounting };
132
+ }
@@ -19,6 +19,7 @@ export {
19
19
  matchLocalLenses,
20
20
  readAuditRules,
21
21
  } from './checklist-threading.js';
22
+ export { buildDispatchChecklist } from './dispatch-checklist.js';
22
23
  export { runAuditSuite } from './runner.js';
23
24
  export {
24
25
  GLOBAL_LENS_ALLOWLIST,