mandrel 2.54.0 → 2.56.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 (134) hide show
  1. package/.agents/agents/story-worker.md +24 -23
  2. package/.agents/audit-checklists/accessibility.md +0 -3
  3. package/.agents/audit-checklists/mobile.md +0 -4
  4. package/.agents/docs/agentrc-reference.json +8 -2
  5. package/.agents/docs/configuration.md +5 -0
  6. package/.agents/rules/ci-remediation.md +39 -21
  7. package/.agents/schemas/agentrc.schema.json +34 -1
  8. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  9. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  10. package/.agents/scripts/audit-to-stories.js +374 -76
  11. package/.agents/scripts/check-audit-attribution.js +119 -62
  12. package/.agents/scripts/check-test-portability.js +512 -0
  13. package/.agents/scripts/coverage-capture.js +17 -10
  14. package/.agents/scripts/evidence-gate.js +31 -4
  15. package/.agents/scripts/file-ci-gap.js +306 -0
  16. package/.agents/scripts/generate-workflows-doc.js +65 -14
  17. package/.agents/scripts/git-cleanup.js +4 -0
  18. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  19. package/.agents/scripts/lib/audit-advisories.js +195 -0
  20. package/.agents/scripts/lib/audit-attribution.js +22 -0
  21. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
  22. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +80 -29
  23. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
  24. package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
  25. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  26. package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
  27. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +61 -115
  28. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  29. package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
  30. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  31. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
  32. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  33. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  34. package/.agents/scripts/lib/cli-args.js +26 -0
  35. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  36. package/.agents/scripts/lib/close-validation/process.js +7 -3
  37. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  38. package/.agents/scripts/lib/config/ci.js +28 -9
  39. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  40. package/.agents/scripts/lib/config-settings-schema.js +52 -1
  41. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  42. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  43. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  44. package/.agents/scripts/lib/coverage-capture.js +77 -3
  45. package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
  46. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
  47. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
  48. package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
  49. package/.agents/scripts/lib/findings/route-finding.js +42 -2
  50. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  51. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  52. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  53. package/.agents/scripts/lib/github/framework-repo.js +148 -2
  54. package/.agents/scripts/lib/label-constants.js +6 -1
  55. package/.agents/scripts/lib/observability/source-classifier.js +2 -0
  56. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  57. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  58. package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
  59. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
  60. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  61. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  62. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  63. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  64. package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
  65. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  66. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  67. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  68. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  69. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  70. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  71. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  72. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  73. package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
  74. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  75. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  76. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +39 -3
  77. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  78. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  79. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
  80. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  81. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  82. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  83. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  84. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  85. package/.agents/scripts/lib/orchestration/run-epilogue.js +63 -42
  86. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  91. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  92. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  93. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  94. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  95. package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
  96. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  97. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
  98. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  99. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  100. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  101. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  102. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  103. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  104. package/.agents/scripts/lib/test-temp.js +167 -30
  105. package/.agents/scripts/lib/validation-evidence.js +37 -0
  106. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  107. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  108. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  109. package/.agents/scripts/merge-baseline.js +175 -21
  110. package/.agents/scripts/pr-watch-with-update.js +3 -2
  111. package/.agents/scripts/providers/github/errors.js +22 -1
  112. package/.agents/scripts/providers/github/issues.js +106 -1
  113. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  114. package/.agents/scripts/providers/github.js +6 -0
  115. package/.agents/scripts/resolve-stories.js +44 -34
  116. package/.agents/scripts/single-story-close.js +5 -0
  117. package/.agents/scripts/stories-wave-tick.js +37 -13
  118. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  119. package/.agents/workflows/audit-accessibility.md +16 -31
  120. package/.agents/workflows/audit-mobile.md +20 -37
  121. package/.agents/workflows/audit-to-stories.md +63 -27
  122. package/.agents/workflows/git-cleanup.md +17 -3
  123. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  124. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  125. package/.agents/workflows/helpers/deliver-reference.md +35 -14
  126. package/.agents/workflows/helpers/deliver-story-reference.md +26 -8
  127. package/.agents/workflows/helpers/deliver-story.md +15 -12
  128. package/.agents/workflows/helpers/plan-reference.md +30 -0
  129. package/.agents/workflows/mandrel-plan.md +10 -13
  130. package/.agents/workflows/memory-consolidate.md +14 -9
  131. package/docs/CHANGELOG.md +37 -0
  132. package/lib/cli/registry.js +64 -21
  133. package/lib/cli/sync.js +27 -2
  134. package/package.json +7 -4
@@ -25,6 +25,7 @@
25
25
  */
26
26
 
27
27
  import { META_LABELS } from '../label-constants.js';
28
+ import { CI_GAP_INTAKE_MARKER } from '../orchestration/ci-gap-intake.js';
28
29
  import { runChild } from './graduator-core.js';
29
30
 
30
31
  const DEFAULT_LIMIT = 50;
@@ -135,8 +136,15 @@ function formatGhError(label, { code, stderr, spawnError }) {
135
136
  * already-budgeted envelope, and trimming early avoids any ambient assumption
136
137
  * that downstream consumers can rely on extra fields.
137
138
  *
139
+ * `intake` is the one field derived rather than copied: an issue whose body
140
+ * carries the CI-gap intake marker is a filing awaiting graduation, not a
141
+ * finished report, and `/mandrel-plan` offers those a `/mandrel-plan <id>`
142
+ * rewrite. The body itself is NOT carried onto the envelope — the marker
143
+ * check is the whole reason it was fetched, and a planner payload does not
144
+ * need every intake issue's full text.
145
+ *
138
146
  * @param {object} raw
139
- * @returns {{ number: number, title: string, url: string, labels: string[] }|null}
147
+ * @returns {{ number: number, title: string, url: string, labels: string[], intake: boolean }|null}
140
148
  */
141
149
  function normalizeIssue(raw) {
142
150
  if (!raw || typeof raw !== 'object') return null;
@@ -144,12 +152,23 @@ function normalizeIssue(raw) {
144
152
  if (number === null) return null;
145
153
  const title = typeof raw.title === 'string' ? raw.title : '';
146
154
  const url = typeof raw.url === 'string' ? raw.url : '';
147
- const labels = Array.isArray(raw.labels)
148
- ? raw.labels
149
- .map((l) => (l && typeof l === 'object' ? l.name : l))
150
- .filter((name) => typeof name === 'string')
151
- : [];
152
- return { number, title, url, labels };
155
+ const intake =
156
+ typeof raw.body === 'string' && raw.body.includes(CI_GAP_INTAKE_MARKER);
157
+ return { number, title, url, labels: normalizeLabels(raw.labels), intake };
158
+ }
159
+
160
+ /**
161
+ * Flatten `gh issue list --json labels` into plain names. `gh` returns label
162
+ * objects; a hand-built fixture may return strings. Pure.
163
+ *
164
+ * @param {unknown} raw
165
+ * @returns {string[]}
166
+ */
167
+ function normalizeLabels(raw) {
168
+ if (!Array.isArray(raw)) return [];
169
+ return raw
170
+ .map((l) => (l && typeof l === 'object' ? l.name : l))
171
+ .filter((name) => typeof name === 'string');
153
172
  }
154
173
 
155
174
  /**
@@ -176,7 +195,7 @@ async function fetchByLabel({ owner, repo, label, ghPath, limit, spawnImpl }) {
176
195
  '--label',
177
196
  label,
178
197
  '--json',
179
- 'number,title,labels,url',
198
+ 'number,title,labels,url,body',
180
199
  '--limit',
181
200
  String(limit),
182
201
  ];
@@ -209,10 +228,32 @@ async function fetchByLabel({ owner, repo, label, ghPath, limit, spawnImpl }) {
209
228
  }
210
229
 
211
230
  /**
212
- * Fetch the union of open issues carrying either `meta::framework-gap` or
213
- * `meta::consumer-improvement` and split them into two arrays. Issues that
214
- * carry **both** labels appear in `frameworkGaps` only — dedupe-by-number
215
- * runs across both arrays so the planner sees each issue exactly once.
231
+ * Append every not-yet-seen issue to one bucket, marking it seen.
232
+ *
233
+ * An issue carrying more than one meta label must reach the planner exactly
234
+ * once, so the `seen` set spans all three buckets and the first bucket to
235
+ * claim a number keeps it. Mutates both arguments — one walk, three buckets.
236
+ *
237
+ * @param {object[]} bucket
238
+ * @param {object[]} issues
239
+ * @param {Set<number>} seen
240
+ * @returns {void}
241
+ */
242
+ function dedupeInto(bucket, issues, seen) {
243
+ for (const issue of issues) {
244
+ if (seen.has(issue.number)) continue;
245
+ seen.add(issue.number);
246
+ bucket.push(issue);
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Fetch the union of open issues carrying `meta::framework-gap`,
252
+ * `meta::consumer-improvement` or `meta::platform-gap` and split them into
253
+ * three arrays — one per ownership bucket in `github/framework-repo.js`, so
254
+ * a filing's bucket survives all the way to the planner. An issue carrying
255
+ * more than one label appears once, in that precedence order; dedupe-by-number
256
+ * runs across all three arrays so the planner sees each issue exactly once.
216
257
  *
217
258
  * The returned envelope is best-effort: every failure mode (gh missing, repo
218
259
  * not found, non-zero exit, malformed JSON) is captured as a string in
@@ -227,6 +268,7 @@ async function fetchByLabel({ owner, repo, label, ghPath, limit, spawnImpl }) {
227
268
  * @returns {Promise<{
228
269
  * frameworkGaps: object[],
229
270
  * consumerImprovements: object[],
271
+ * platformGaps: object[],
230
272
  * recurringDefectClasses: Array<{ class: string, count: number, issues: number[] }>,
231
273
  * fetchedAt: string,
232
274
  * errors: string[],
@@ -251,6 +293,7 @@ export async function fetchPriorFeedback({
251
293
  const envelope = {
252
294
  frameworkGaps: [],
253
295
  consumerImprovements: [],
296
+ platformGaps: [],
254
297
  recurringDefectClasses: [],
255
298
  fetchedAt: new Date().toISOString(),
256
299
  errors,
@@ -258,7 +301,7 @@ export async function fetchPriorFeedback({
258
301
 
259
302
  if (errors.length > 0) return envelope;
260
303
 
261
- const [gapsResult, improvementsResult] = await Promise.all([
304
+ const [gapsResult, improvementsResult, platformResult] = await Promise.all([
262
305
  fetchByLabel({
263
306
  owner,
264
307
  repo,
@@ -275,25 +318,27 @@ export async function fetchPriorFeedback({
275
318
  limit,
276
319
  spawnImpl,
277
320
  }),
321
+ fetchByLabel({
322
+ owner,
323
+ repo,
324
+ label: META_LABELS.PLATFORM_GAP,
325
+ ghPath,
326
+ limit,
327
+ spawnImpl,
328
+ }),
278
329
  ]);
279
330
 
280
- if (gapsResult.error) errors.push(gapsResult.error);
281
- if (improvementsResult.error) errors.push(improvementsResult.error);
331
+ for (const { error } of [gapsResult, improvementsResult, platformResult]) {
332
+ if (error) errors.push(error);
333
+ }
282
334
 
283
335
  // Dedupe by issue number across both arrays. Issues that carry both labels
284
336
  // land in frameworkGaps first (deterministic) and are filtered out of
285
337
  // consumerImprovements.
286
338
  const seen = new Set();
287
- for (const issue of gapsResult.issues) {
288
- if (seen.has(issue.number)) continue;
289
- seen.add(issue.number);
290
- envelope.frameworkGaps.push(issue);
291
- }
292
- for (const issue of improvementsResult.issues) {
293
- if (seen.has(issue.number)) continue;
294
- seen.add(issue.number);
295
- envelope.consumerImprovements.push(issue);
296
- }
339
+ dedupeInto(envelope.frameworkGaps, gapsResult.issues, seen);
340
+ dedupeInto(envelope.consumerImprovements, improvementsResult.issues, seen);
341
+ dedupeInto(envelope.platformGaps, platformResult.issues, seen);
297
342
 
298
343
  // Story #4135 (Epic #4131, F11) — close the retro→planner loop: derive the
299
344
  // recurring defect classes from the `friction::<class>` labels carried by
@@ -303,6 +348,7 @@ export async function fetchPriorFeedback({
303
348
  envelope.recurringDefectClasses = extractRecurringDefectClasses([
304
349
  ...envelope.frameworkGaps,
305
350
  ...envelope.consumerImprovements,
351
+ ...envelope.platformGaps,
306
352
  ]);
307
353
 
308
354
  return envelope;
@@ -22,7 +22,10 @@
22
22
  * Routing correctness: a routed item already knows its source
23
23
  * (`framework` / `consumer`), so we file each source bucket with its own
24
24
  * constant classifier and thread the graduator's per-run filing cap across
25
- * the two buckets. The `meta::<framework-gap|consumer-improvement>` +
25
+ * the two buckets. Which repository a source routes to is decided once, in
26
+ * `github/framework-repo.js` — the silent consumer-repo fallback that used
27
+ * to live here (and mis-filed framework work into the consumer's tracker)
28
+ * is gone from the whole path, not just from this module. The `meta::<framework-gap|consumer-improvement>` +
26
29
  * `friction::<category>` labels are lifted verbatim from the routed item.
27
30
  *
28
31
  * Behind the `delivery.feedbackLoop.retroProposals` toggle (default ON,
@@ -30,7 +33,10 @@
30
33
  * failure path is captured in `errors[]`.
31
34
  */
32
35
 
33
- import { DEFAULT_FRAMEWORK_REPO } from '../github/framework-repo.js';
36
+ import {
37
+ DEFAULT_FRAMEWORK_REPO,
38
+ parseRepoSlug,
39
+ } from '../github/framework-repo.js';
34
40
  import { META_LABELS } from '../label-constants.js';
35
41
  import {
36
42
  contentFingerprint,
@@ -218,6 +224,8 @@ function toFinding(item, source, index) {
218
224
  * @param {{owner: string, repo: string}} opts.currentRepo — the repo the
219
225
  * retro is running inside (the consumer's own repo); the cross-repo guard's
220
226
  * anchor.
227
+ * @param {{owner: string, repo: string}} [opts.platformRepo] — the shared
228
+ * platform/infra bucket, forwarded to the walk's routing SSOT.
221
229
  * @param {{owner: string, repo: string}} [opts.frameworkRepo] — where
222
230
  * framework-tagged proposals route.
223
231
  * @param {{ framework?: object[], consumer?: object[] }} [opts.routedProposals]
@@ -239,6 +247,7 @@ export async function graduateRetroProposals({
239
247
  config,
240
248
  currentRepo,
241
249
  frameworkRepo,
250
+ platformRepo,
242
251
  routedProposals,
243
252
  ghPath,
244
253
  spawnImpl,
@@ -294,6 +303,7 @@ export async function graduateRetroProposals({
294
303
  config,
295
304
  currentRepo,
296
305
  frameworkRepo,
306
+ platformRepo,
297
307
  // Each bucket's source is known — a constant classifier routes the
298
308
  // whole bucket to the correct repo and stamps the correct label.
299
309
  classifier: () => source,
@@ -380,22 +390,6 @@ export function enrichRoutedProposalsWithFilings(routedProposals, filed) {
380
390
  };
381
391
  }
382
392
 
383
- /**
384
- * Parse an `"<owner>/<repo>"` slug into `{ owner, repo }`, or `null` when
385
- * the slug is empty / malformed.
386
- *
387
- * @param {string|null|undefined} slug
388
- * @returns {{ owner: string, repo: string } | null}
389
- */
390
- function parseRepoSlug(slug) {
391
- if (typeof slug !== 'string') return null;
392
- const parts = slug.split('/');
393
- if (parts.length !== 2) return null;
394
- const [owner, repo] = parts;
395
- if (!owner || !repo) return null;
396
- return { owner, repo };
397
- }
398
-
399
393
  /**
400
394
  * Orchestrating seam invoked by the retro post-and-mirror phase: gate the
401
395
  * toggle, file the routed proposals, and return the routed proposals
@@ -452,13 +446,12 @@ export async function fileRetroProposals({
452
446
  );
453
447
  return passthrough('no-current-repo');
454
448
  }
455
- // Framework-repo fallback parity with `gatherRetroSignals`
456
- // (gather-signals.js): an unconfigured `github.frameworkRepo` falls
457
- // back to the Mandrel mirror constant, NEVER to the consumer's own
458
- // repo — the prior `?? currentRepo` fallback silently auto-filed
459
- // framework-tagged proposals into the consumer's repo while the retro
460
- // body rendered them under "framework repo" (masked in this repo only
461
- // because consumer === framework here).
449
+ // An unconfigured framework slug falls back to the Mandrel mirror
450
+ // constant, NEVER to the consumer's own repo: the retired consumer-repo
451
+ // fallback silently auto-filed framework-tagged proposals into the
452
+ // consumer's tracker while the retro body rendered them under "framework
453
+ // repo" (masked in this repo only because consumer === framework here).
454
+ // `github/framework-repo.js` is the SSOT for that rule now.
462
455
  const frameworkRepoObj =
463
456
  parseRepoSlug(frameworkRepo) ?? parseRepoSlug(DEFAULT_FRAMEWORK_REPO);
464
457
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * lib/audit-to-stories/ledger.js — Cross-run audit findings ledger.
2
+ * lib/findings/audit-ledger.js — Cross-run audit findings ledger.
3
3
  *
4
4
  * Without a committed memory of what a prior sweep already saw, every
5
5
  * `/audit-to-stories` run re-litigates the whole backlog from zero: it cannot
@@ -30,10 +30,7 @@
30
30
 
31
31
  import nodeFs from 'node:fs';
32
32
  import nodePath from 'node:path';
33
- import {
34
- fingerprintAuditFinding,
35
- semanticKeyForAuditFinding,
36
- } from './finding-adapter.js';
33
+ import { fingerprintFinding, semanticKeyFor } from './route-finding.js';
37
34
 
38
35
  export const DEFAULT_LEDGER_PATH = 'baselines/audit-ledger.json';
39
36
  const LEDGER_SCHEMA_URL =
@@ -54,13 +51,24 @@ function createEmptyLedger(now = new Date().toISOString()) {
54
51
  /**
55
52
  * Compute a finding's stable identity: its fingerprint (title-sensitive) and
56
53
  * its location-based semantic key (title-insensitive).
54
+ *
55
+ * The projection onto the canonical identity is the **caller's**, injected as
56
+ * `toCanonical`. That is what lets this module live beside `route-finding.js`
57
+ * in the shared findings layer: the audit pipeline's own adapter
58
+ * (`lib/audit-to-stories/finding-adapter.js`) imports *from* here, so importing
59
+ * it back would close a `findings → audit-to-stories → findings` cycle that
60
+ * `check-arch-cycles` rightly refuses. A caller that already holds canonical
61
+ * findings passes nothing.
62
+ *
57
63
  * @param {object} finding — a parsed/stamped audit finding.
64
+ * @param {(finding: object) => object} [toCanonical]
58
65
  * @returns {{ fingerprint: string, semanticKey: string }}
59
66
  */
60
- function findingIdentity(finding) {
67
+ function findingIdentity(finding, toCanonical) {
68
+ const canonical = toCanonical ? toCanonical(finding) : finding;
61
69
  return {
62
- fingerprint: fingerprintAuditFinding(finding).full,
63
- semanticKey: semanticKeyForAuditFinding(finding),
70
+ fingerprint: fingerprintFinding(canonical).full,
71
+ semanticKey: semanticKeyFor(canonical),
64
72
  };
65
73
  }
66
74
 
@@ -132,6 +140,19 @@ function resolveIssueState(id, existing, issueStates) {
132
140
  };
133
141
  }
134
142
 
143
+ /**
144
+ * The four verdicts the policy can reach, spelled once. Deduping them keeps
145
+ * `decideStatus` readable as the decision table it is, rather than eight
146
+ * near-identical object literals.
147
+ * @type {Record<string, { status: string, action: string }>}
148
+ */
149
+ const VERDICT = Object.freeze({
150
+ filed: Object.freeze({ status: 'filed', action: 'known' }),
151
+ propose: Object.freeze({ status: 'new', action: 'propose' }),
152
+ suppress: Object.freeze({ status: 'accepted-risk', action: 'suppress' }),
153
+ regressed: Object.freeze({ status: 'regressed', action: 'regressed' }),
154
+ });
155
+
135
156
  /**
136
157
  * Decide the finding's next status + action from its prior ledger state and
137
158
  * the live Issue state. This is the whole reconciliation policy in one place.
@@ -140,31 +161,41 @@ function resolveIssueState(id, existing, issueStates) {
140
161
  * @returns {{ status: string, action: 'propose'|'known'|'suppress'|'regressed' }}
141
162
  */
142
163
  function decideStatus(existing, issue) {
143
- // A closed Issue is the strongest signal — its close reason drives the verdict.
164
+ // A closed Issue is the strongest signal — its close reason drives the
165
+ // verdict, and it is read FIRST so a recorded `filed` can never outrank it.
144
166
  if (issue && issue.state === 'closed') {
145
- if (issue.stateReason === 'not_planned') {
146
- return { status: 'accepted-risk', action: 'suppress' };
147
- }
148
- // Closed as completed (or unspecified) but the finding is in this scan →
149
- // it came back. That is a regression, not a fresh proposal.
150
- return { status: 'regressed', action: 'regressed' };
167
+ return issue.stateReason === 'not_planned'
168
+ ? VERDICT.suppress
169
+ : // Closed as completed (or unspecified) but the finding is in this scan
170
+ // → it came back. That is a regression, not a fresh proposal.
171
+ VERDICT.regressed;
151
172
  }
152
173
 
153
- if (!existing) return { status: 'new', action: 'propose' };
174
+ // An OPEN tracking Issue means the finding has been filed, whatever the prior
175
+ // entry said — including when there is no prior entry at all. Until Story
176
+ // #5305 nothing in the package ever assigned `filed`, so this fell through to
177
+ // `new`/`propose` on every run and the `filed` arm below was unreachable in
178
+ // production: the ledger suppressed nothing, and only the GitHub-search dedup
179
+ // stopped a sweep re-filing what it had already filed. Reading it before the
180
+ // `!existing` guard is what makes a record pass correct on its FIRST run
181
+ // rather than its second.
182
+ const unseen = issue?.state === 'open' ? VERDICT.filed : VERDICT.propose;
183
+ if (!existing) return unseen;
154
184
 
155
185
  switch (existing.status) {
156
186
  case 'accepted-risk':
157
- return { status: 'accepted-risk', action: 'suppress' };
187
+ return VERDICT.suppress;
158
188
  case 'filed':
159
- return { status: 'filed', action: 'known' };
189
+ return VERDICT.filed;
190
+ // Recorded fixed, yet detected again with no closed-Issue evidence → treat
191
+ // as a regression the operator should look at. An open Issue does not
192
+ // soften that: the finding came back either way.
160
193
  case 'fixed':
161
- // Recorded fixed, yet detected again with no closed-Issue evidence →
162
- // treat as a regression the operator should look at.
163
- return { status: 'regressed', action: 'regressed' };
194
+ return VERDICT.regressed;
164
195
  case 'regressed':
165
- return { status: 'regressed', action: 'regressed' };
196
+ return VERDICT.regressed;
166
197
  default:
167
- return { status: 'new', action: 'propose' };
198
+ return unseen;
168
199
  }
169
200
  }
170
201
 
@@ -178,6 +209,8 @@ function decideStatus(existing, issue) {
178
209
  * Live Issue state keyed by fingerprint (or semanticKey). Optional — when a
179
210
  * prior entry already records the Issue, that is used.
180
211
  * @param {string} [params.now] — ISO timestamp for firstSeen/lastSeen stamping.
212
+ * @param {(finding: object) => object} [params.toCanonical] — projection onto
213
+ * the canonical identity; omit when `findings` are already canonical.
181
214
  * @returns {{
182
215
  * ledger: { $schema: string, generatedAt: string, entries: object[] },
183
216
  * classifications: Array<{ fingerprint: string, semanticKey: string, status: string, action: string, issue: object|null }>,
@@ -188,6 +221,7 @@ export function reconcileLedger({
188
221
  findings,
189
222
  issueStates = {},
190
223
  now = new Date().toISOString(),
224
+ toCanonical,
191
225
  } = {}) {
192
226
  if (!Array.isArray(findings)) {
193
227
  throw new Error('reconcileLedger: findings must be an array');
@@ -199,7 +233,7 @@ export function reconcileLedger({
199
233
  const classifications = [];
200
234
 
201
235
  for (const finding of findings) {
202
- const id = findingIdentity(finding);
236
+ const id = findingIdentity(finding, toCanonical);
203
237
  const existing =
204
238
  byFingerprint.get(id.fingerprint) ??
205
239
  (id.semanticKey ? bySemanticKey.get(id.semanticKey) : undefined) ??
@@ -254,3 +288,76 @@ export function reconcileLedger({
254
288
  classifications,
255
289
  };
256
290
  }
291
+
292
+ /**
293
+ * Record a set of already-known identities as filed against one Issue.
294
+ *
295
+ * The finding-shaped {@link reconcileLedger} cannot serve this caller:
296
+ * `plan-persist` never sees findings. It holds the provenance identities it
297
+ * stamped on a Story body — fingerprints and semantic keys, as strings — plus
298
+ * the issue number it just created. That is enough to record the filing, and
299
+ * demanding a finding it does not have would be the reason the recommended
300
+ * planning path never reached this ledger at all.
301
+ *
302
+ * An identity already carrying a **closed** Issue is left exactly as it is: a
303
+ * finding whose tracking Issue was closed `not_planned` is `accepted-risk` and
304
+ * must stay suppressed, and one closed as completed is a `regressed` the
305
+ * operator still needs to see. A fresh filing never overwrites either verdict.
306
+ *
307
+ * @param {object} params
308
+ * @param {{ entries?: object[] }} [params.ledger] — prior ledger (default empty).
309
+ * @param {Array<{ fingerprint: string, semanticKey?: string, title?: string, dimension?: string, primaryFile?: string }>} params.identities
310
+ * @param {{ number: number }} params.issue — the Issue these identities were filed as.
311
+ * @param {string} [params.now]
312
+ * @returns {{ ledger: object, recorded: number, skipped: number }}
313
+ */
314
+ export function recordFiledIdentities({
315
+ ledger = createEmptyLedger(),
316
+ identities,
317
+ issue,
318
+ now = new Date().toISOString(),
319
+ } = {}) {
320
+ if (!Array.isArray(identities)) {
321
+ throw new Error('recordFiledIdentities: identities must be an array');
322
+ }
323
+ if (!issue || typeof issue.number !== 'number') {
324
+ throw new Error('recordFiledIdentities: issue.number must be a number');
325
+ }
326
+
327
+ const { byFingerprint } = indexLedger(ledger);
328
+ const next = new Map(byFingerprint);
329
+ let recorded = 0;
330
+ let skipped = 0;
331
+
332
+ for (const identity of identities) {
333
+ const fingerprint = identity?.fingerprint;
334
+ if (typeof fingerprint !== 'string' || fingerprint.length === 0) continue;
335
+ const existing = byFingerprint.get(fingerprint) ?? null;
336
+ if (existing?.issue && existing.issue.state === 'closed') {
337
+ skipped += 1;
338
+ continue;
339
+ }
340
+ next.set(fingerprint, {
341
+ fingerprint,
342
+ semanticKey: identity.semanticKey ?? existing?.semanticKey ?? '',
343
+ title: identity.title ?? existing?.title ?? '',
344
+ dimension: identity.dimension ?? existing?.dimension ?? '',
345
+ primaryFile: identity.primaryFile ?? existing?.primaryFile ?? '',
346
+ status: 'filed',
347
+ issue: { number: issue.number, state: 'open', stateReason: null },
348
+ firstSeen: existing?.firstSeen ?? now,
349
+ lastSeen: now,
350
+ });
351
+ recorded += 1;
352
+ }
353
+
354
+ return {
355
+ ledger: {
356
+ $schema: ledger?.$schema ?? LEDGER_SCHEMA_URL,
357
+ generatedAt: now,
358
+ entries: [...next.values()],
359
+ },
360
+ recorded,
361
+ skipped,
362
+ };
363
+ }
@@ -36,6 +36,7 @@ import { fingerprintSeverity } from './severity.js';
36
36
  const SEP = '␟'; // unit separator — keeps fingerprint fields unambiguous
37
37
  const MARKER = 'audit-fingerprints:';
38
38
  const SEMANTIC_MARKER = 'audit-semantic-keys:';
39
+ const LABEL_MARKER = 'audit-labels:';
39
40
  export const SHA1_RE = /^[0-9a-f]{40}$/;
40
41
  // A semantic key round-trips through a comma-joined footer, so it must not
41
42
  // carry a comma or a `>` (which would truncate the HTML comment). Both are
@@ -160,12 +161,14 @@ export function semanticKeyFooter(keys) {
160
161
  /**
161
162
  * Extract semantic keys from an Issue body carrying the semantic-key footer.
162
163
  * The audit filers stamp the footer via {@link semanticKeyFooter}; the
163
- * confirmation path here and {@link carryProvenanceFooters} read it back.
164
+ * confirmation path here, {@link carryProvenanceFooters} and the audit dedup's
165
+ * local issue index read it back. Exported alongside its writer so an indexer
166
+ * cannot drift into a second parse of the same footer.
164
167
  *
165
168
  * @param {string} body
166
169
  * @returns {string[]}
167
170
  */
168
- function parseSemanticKeyFooter(body) {
171
+ export function parseSemanticKeyFooter(body) {
169
172
  return parseAllFooterValues(
170
173
  body,
171
174
  /<!--\s*audit-semantic-keys:\s*([^>]*?)\s*-->/g,
@@ -173,6 +176,43 @@ function parseSemanticKeyFooter(body) {
173
176
  );
174
177
  }
175
178
 
179
+ /**
180
+ * Render the machine-readable audit-label footer
181
+ * (`<!-- audit-labels: audit::x,audit::y -->`).
182
+ *
183
+ * The dedup corpus is listed by `audit::*` label, so a Story carrying none is
184
+ * absent from the pool an indexed run matches against — and with an index in
185
+ * play the exact lookup is answered locally and never reaches the provider, so
186
+ * a fingerprint footer alone cannot rescue it. Carrying the labels through the
187
+ * seed is what lets the planning path stamp them without the authoring agent
188
+ * being asked to notice them (Story #5307).
189
+ *
190
+ * @param {string | string[]} labels
191
+ * @returns {string}
192
+ */
193
+ export function auditLabelFooter(labels) {
194
+ const list = (Array.isArray(labels) ? labels : [labels])
195
+ .filter((l) => typeof l === 'string' && l.startsWith('audit::'))
196
+ .map((l) => l.replace(/[,>]/g, ' ').trim())
197
+ .filter((l) => l.length > 0);
198
+ if (list.length === 0) return '';
199
+ return `<!-- ${LABEL_MARKER} ${[...new Set(list)].sort().join(',')} -->`;
200
+ }
201
+
202
+ /**
203
+ * Read every `audit-labels` footer out of a body, de-duplicated.
204
+ *
205
+ * @param {string} body
206
+ * @returns {string[]}
207
+ */
208
+ export function parseAuditLabelFooter(body) {
209
+ return parseAllFooterValues(
210
+ body,
211
+ /<!--\s*audit-labels:\s*([^>]*?)\s*-->/g,
212
+ (s) => s.startsWith('audit::'),
213
+ );
214
+ }
215
+
176
216
  /**
177
217
  * Render the machine-readable fingerprint footer for one or more shas.
178
218
  *