mandrel 2.4.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 (65) 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/orchestration/story-close/phases/local-lens-review.js +81 -1
  39. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +1 -0
  40. package/.agents/scripts/nav-registry-diff.js +449 -0
  41. package/.agents/workflows/audit-accessibility.md +243 -0
  42. package/.agents/workflows/audit-architecture.md +89 -71
  43. package/.agents/workflows/audit-clean-code.md +87 -53
  44. package/.agents/workflows/audit-data-model.md +198 -0
  45. package/.agents/workflows/audit-dependencies.md +143 -28
  46. package/.agents/workflows/audit-devops.md +109 -18
  47. package/.agents/workflows/audit-documentation.md +25 -53
  48. package/.agents/workflows/audit-navigability.md +78 -22
  49. package/.agents/workflows/audit-performance.md +207 -103
  50. package/.agents/workflows/audit-privacy.md +51 -13
  51. package/.agents/workflows/audit-quality.md +71 -61
  52. package/.agents/workflows/audit-security.md +94 -71
  53. package/.agents/workflows/audit-seo.md +80 -25
  54. package/.agents/workflows/audit-sre.md +99 -66
  55. package/.agents/workflows/audit-to-stories.md +44 -5
  56. package/.agents/workflows/audit-ux-ui.md +71 -17
  57. package/.agents/workflows/helpers/audit-dual-path.md +59 -0
  58. package/.agents/workflows/helpers/audit-self-check.md +70 -0
  59. package/.agents/workflows/helpers/audit-severity-scale.md +19 -0
  60. package/.agents/workflows/helpers/deliver-story.md +25 -0
  61. package/docs/CHANGELOG.md +16 -0
  62. package/package.json +1 -1
  63. package/.agents/audit-checklists/lighthouse.md +0 -15
  64. package/.agents/schemas/audit-results.schema.json +0 -69
  65. package/.agents/workflows/audit-lighthouse.md +0 -269
@@ -0,0 +1,256 @@
1
+ /**
2
+ * lib/audit-to-stories/ledger.js — Cross-run audit findings ledger.
3
+ *
4
+ * Without a committed memory of what a prior sweep already saw, every
5
+ * `/audit-to-stories` run re-litigates the whole backlog from zero: it cannot
6
+ * tell a brand-new finding from one already filed, an intentionally-rejected
7
+ * finding from an unseen one, or a genuine regression from routine churn. The
8
+ * ledger is that memory. It is a small committed JSON file
9
+ * (`baselines/audit-ledger.json`, the same envelope shape as the arch-cycles
10
+ * baseline — `{ $schema, generatedAt, entries: [] }`) keyed by each finding's
11
+ * shared-helper fingerprint plus a location-based `semanticKey` that survives a
12
+ * reworded title.
13
+ *
14
+ * Each entry carries a lifecycle `status`:
15
+ * - `new` — seen, not yet filed as an Issue.
16
+ * - `filed` — an Issue was opened; re-detections are known, not new.
17
+ * - `fixed` — the tracking Issue closed as completed.
18
+ * - `accepted-risk`— the tracking Issue closed as `not_planned`; the finding
19
+ * is deliberately rejected and is SUPPRESSED on re-detect.
20
+ * - `regressed` — a `fixed` finding re-appeared (closed-completed Issue,
21
+ * finding detected again).
22
+ *
23
+ * `reconcileLedger` folds a fresh scan and the live Issue states onto the prior
24
+ * ledger, returning the next ledger plus a per-finding classification whose
25
+ * `action` (`propose` | `known` | `suppress` | `regressed`) tells the caller
26
+ * whether to open a Story. Pure: filesystem access is confined to the tiny
27
+ * {@link readLedger} / {@link writeLedger} helpers, which take an injectable
28
+ * `fs` so tests never touch disk.
29
+ */
30
+
31
+ import nodeFs from 'node:fs';
32
+ import nodePath from 'node:path';
33
+ import {
34
+ fingerprintAuditFinding,
35
+ semanticKeyForAuditFinding,
36
+ } from './finding-adapter.js';
37
+
38
+ export const DEFAULT_LEDGER_PATH = 'baselines/audit-ledger.json';
39
+ const LEDGER_SCHEMA_URL =
40
+ 'https://mandrel.dev/baselines/audit-ledger.schema.json';
41
+
42
+ // Entry lifecycle states — `new | filed | fixed | accepted-risk | regressed`.
43
+ // The reconcile policy in `decideStatus` is the single source of truth.
44
+
45
+ /**
46
+ * Build an empty ledger envelope (arch-cycles-baseline shape).
47
+ * @param {string} [now] — ISO timestamp to stamp.
48
+ * @returns {{ $schema: string, generatedAt: string, entries: [] }}
49
+ */
50
+ function createEmptyLedger(now = new Date().toISOString()) {
51
+ return { $schema: LEDGER_SCHEMA_URL, generatedAt: now, entries: [] };
52
+ }
53
+
54
+ /**
55
+ * Compute a finding's stable identity: its fingerprint (title-sensitive) and
56
+ * its location-based semantic key (title-insensitive).
57
+ * @param {object} finding — a parsed/stamped audit finding.
58
+ * @returns {{ fingerprint: string, semanticKey: string }}
59
+ */
60
+ function findingIdentity(finding) {
61
+ return {
62
+ fingerprint: fingerprintAuditFinding(finding).full,
63
+ semanticKey: semanticKeyForAuditFinding(finding),
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Read the ledger from disk. Returns an empty ledger when the file is absent
69
+ * or unparseable — a missing memory is an empty memory, never a hard error.
70
+ * @param {string} filePath
71
+ * @param {{ fs?: typeof import('node:fs') }} [deps]
72
+ * @returns {{ $schema?: string, generatedAt?: string, entries: object[] }}
73
+ */
74
+ export function readLedger(filePath, { fs } = {}) {
75
+ const fsLike = fs ?? nodeFs;
76
+ if (!fsLike || !fsLike.existsSync(filePath)) return createEmptyLedger();
77
+ try {
78
+ const parsed = JSON.parse(fsLike.readFileSync(filePath, 'utf8'));
79
+ if (!parsed || !Array.isArray(parsed.entries)) return createEmptyLedger();
80
+ return parsed;
81
+ } catch (_) {
82
+ return createEmptyLedger();
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Persist the ledger to disk with a stable 2-space indent and a trailing
88
+ * newline (so the committed file diffs cleanly).
89
+ * @param {string} filePath
90
+ * @param {object} ledger
91
+ * @param {{ fs?: typeof import('node:fs'), path?: typeof import('node:path') }} [deps]
92
+ */
93
+ export function writeLedger(filePath, ledger, { fs, path } = {}) {
94
+ const fsLike = fs ?? nodeFs;
95
+ const pathLike = path ?? nodePath;
96
+ if (!fsLike) return;
97
+ fsLike.mkdirSync(pathLike.dirname(filePath), { recursive: true });
98
+ fsLike.writeFileSync(filePath, `${JSON.stringify(ledger, null, 2)}\n`);
99
+ }
100
+
101
+ /**
102
+ * Index a ledger's entries by fingerprint and by semanticKey for O(1) lookup.
103
+ * @param {{ entries?: object[] }} ledger
104
+ */
105
+ function indexLedger(ledger) {
106
+ const byFingerprint = new Map();
107
+ const bySemanticKey = new Map();
108
+ for (const entry of ledger?.entries ?? []) {
109
+ if (entry?.fingerprint) byFingerprint.set(entry.fingerprint, entry);
110
+ if (entry?.semanticKey) bySemanticKey.set(entry.semanticKey, entry);
111
+ }
112
+ return { byFingerprint, bySemanticKey };
113
+ }
114
+
115
+ /**
116
+ * Resolve the effective Issue state for a finding: an explicit override in
117
+ * `issueStates` (keyed by fingerprint then semanticKey) wins over whatever the
118
+ * prior ledger entry recorded.
119
+ * @param {{ fingerprint: string, semanticKey: string }} id
120
+ * @param {object|null} existing
121
+ * @param {Record<string, { state?: string, stateReason?: string|null, number?: number }>} issueStates
122
+ * @returns {{ state: string, stateReason: string|null, number: number|null }|null}
123
+ */
124
+ function resolveIssueState(id, existing, issueStates) {
125
+ const override = issueStates[id.fingerprint] ?? issueStates[id.semanticKey];
126
+ const raw = override ?? existing?.issue ?? null;
127
+ if (!raw) return null;
128
+ return {
129
+ state: (raw.state ?? '').toLowerCase(),
130
+ stateReason: raw.stateReason ? String(raw.stateReason).toLowerCase() : null,
131
+ number: typeof raw.number === 'number' ? raw.number : null,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Decide the finding's next status + action from its prior ledger state and
137
+ * the live Issue state. This is the whole reconciliation policy in one place.
138
+ * @param {object|null} existing — prior ledger entry (or null when unseen).
139
+ * @param {{ state: string, stateReason: string|null }|null} issue
140
+ * @returns {{ status: string, action: 'propose'|'known'|'suppress'|'regressed' }}
141
+ */
142
+ function decideStatus(existing, issue) {
143
+ // A closed Issue is the strongest signal — its close reason drives the verdict.
144
+ 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' };
151
+ }
152
+
153
+ if (!existing) return { status: 'new', action: 'propose' };
154
+
155
+ switch (existing.status) {
156
+ case 'accepted-risk':
157
+ return { status: 'accepted-risk', action: 'suppress' };
158
+ case 'filed':
159
+ return { status: 'filed', action: 'known' };
160
+ 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' };
164
+ case 'regressed':
165
+ return { status: 'regressed', action: 'regressed' };
166
+ default:
167
+ return { status: 'new', action: 'propose' };
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Fold a fresh scan and the live Issue states onto the prior ledger.
173
+ *
174
+ * @param {object} params
175
+ * @param {{ entries?: object[] }} [params.ledger] — prior ledger (default empty).
176
+ * @param {Array<object>} params.findings — parsed/stamped audit findings from this scan.
177
+ * @param {Record<string, { state?: string, stateReason?: string|null, number?: number }>} [params.issueStates]
178
+ * Live Issue state keyed by fingerprint (or semanticKey). Optional — when a
179
+ * prior entry already records the Issue, that is used.
180
+ * @param {string} [params.now] — ISO timestamp for firstSeen/lastSeen stamping.
181
+ * @returns {{
182
+ * ledger: { $schema: string, generatedAt: string, entries: object[] },
183
+ * classifications: Array<{ fingerprint: string, semanticKey: string, status: string, action: string, issue: object|null }>,
184
+ * }}
185
+ */
186
+ export function reconcileLedger({
187
+ ledger = createEmptyLedger(),
188
+ findings,
189
+ issueStates = {},
190
+ now = new Date().toISOString(),
191
+ } = {}) {
192
+ if (!Array.isArray(findings)) {
193
+ throw new Error('reconcileLedger: findings must be an array');
194
+ }
195
+
196
+ const { byFingerprint, bySemanticKey } = indexLedger(ledger);
197
+ // Preserve any prior entries NOT touched by this scan (their memory survives).
198
+ const nextByFingerprint = new Map(byFingerprint);
199
+ const classifications = [];
200
+
201
+ for (const finding of findings) {
202
+ const id = findingIdentity(finding);
203
+ const existing =
204
+ byFingerprint.get(id.fingerprint) ??
205
+ (id.semanticKey ? bySemanticKey.get(id.semanticKey) : undefined) ??
206
+ null;
207
+
208
+ const issue = resolveIssueState(id, existing, issueStates);
209
+ const { status, action } = decideStatus(existing, issue);
210
+
211
+ const entry = {
212
+ fingerprint: id.fingerprint,
213
+ semanticKey: id.semanticKey,
214
+ title: finding?.title ?? existing?.title ?? '',
215
+ dimension: finding?.dimension ?? existing?.dimension ?? '',
216
+ primaryFile:
217
+ (Array.isArray(finding?.files) && finding.files[0]) ??
218
+ existing?.primaryFile ??
219
+ '',
220
+ status,
221
+ issue: issue
222
+ ? {
223
+ number: issue.number,
224
+ state: issue.state,
225
+ stateReason: issue.stateReason,
226
+ }
227
+ : (existing?.issue ?? null),
228
+ firstSeen: existing?.firstSeen ?? now,
229
+ lastSeen: now,
230
+ };
231
+
232
+ // Re-key under the fresh fingerprint; drop the old entry if it was matched
233
+ // by semanticKey under a now-drifted fingerprint (reworded finding).
234
+ if (existing?.fingerprint && existing.fingerprint !== id.fingerprint) {
235
+ nextByFingerprint.delete(existing.fingerprint);
236
+ }
237
+ nextByFingerprint.set(id.fingerprint, entry);
238
+
239
+ classifications.push({
240
+ fingerprint: id.fingerprint,
241
+ semanticKey: id.semanticKey,
242
+ status,
243
+ action,
244
+ issue: entry.issue,
245
+ });
246
+ }
247
+
248
+ return {
249
+ ledger: {
250
+ $schema: ledger?.$schema ?? LEDGER_SCHEMA_URL,
251
+ generatedAt: now,
252
+ entries: [...nextByFingerprint.values()],
253
+ },
254
+ classifications,
255
+ };
256
+ }
@@ -2,11 +2,14 @@
2
2
  * lib/audit-to-stories/parse-audit-md.js — Parse `audit-*-results.md` reports.
3
3
  *
4
4
  * Extracts the `## Detailed Findings` section of an audit report and turns
5
- * every `### <title>` block into a normalised finding record. The 12 audit
6
- * workflows use slightly different field names `Severity` vs `Impact`,
7
- * `Dimension` vs `Category` so the parser captures every key/value pair
8
- * the block carries and normalises the two axes the downstream pipeline
9
- * relies on (severity + dimension).
5
+ * every `### <title>` block into a normalised finding record. Every audit lens
6
+ * report now shares one findings contract (Story #4625): a `Critical | High |
7
+ * Medium | Low` severity scale, a mandated `Location:` path anchor, and a
8
+ * dimension axis. Lenses still label the axes slightly differently — `Severity`
9
+ * vs `Impact`, `Dimension` vs `Category` vs `Type` — so the parser captures
10
+ * every key/value pair the block carries and normalises the two axes the
11
+ * downstream pipeline relies on (severity + dimension), harvesting the
12
+ * `Location:` field into `files[]` ahead of prose scraping.
10
13
  *
11
14
  * Pure: no filesystem I/O. The caller supplies the report text and source
12
15
  * path; the parser returns plain objects.
@@ -54,7 +57,7 @@ function normaliseSeverity(token) {
54
57
  }
55
58
 
56
59
  function deriveDimension(fields, fallbackDimension) {
57
- for (const key of ['dimension', 'category', 'area']) {
60
+ for (const key of ['dimension', 'category', 'area', 'type']) {
58
61
  const raw = fields[key];
59
62
  if (typeof raw === 'string' && raw.trim().length > 0) {
60
63
  return raw
@@ -66,6 +69,33 @@ function deriveDimension(fields, fallbackDimension) {
66
69
  return fallbackDimension;
67
70
  }
68
71
 
72
+ /**
73
+ * Harvest the mandated `Location:` field into concrete file paths. Every
74
+ * non-retired lens template now carries a `- **Location:** <path>:<line>`
75
+ * bullet (Story #4625); extracting it here — ahead of the heuristic prose
76
+ * scrape — gives a finding a deterministic identity anchor instead of relying
77
+ * on whatever path text happens to appear in the prose. The value may wrap the
78
+ * path in backticks and/or append a `:line` (or `:line:col`) suffix; both are
79
+ * stripped. Only tokens that look like paths (containing a separator) survive.
80
+ *
81
+ * @param {Record<string, string>} fields
82
+ * @returns {string[]}
83
+ */
84
+ function deriveLocationFiles(fields) {
85
+ const raw = fields.location;
86
+ if (typeof raw !== 'string' || raw.trim().length === 0) return [];
87
+ const cleaned = raw.replace(/[`[\]]/g, ' ');
88
+ const out = [];
89
+ for (const token of cleaned.split(/[\s,]+/)) {
90
+ if (!token) continue;
91
+ const withoutLine = token.replace(/:\d+(?::\d+)?$/, '');
92
+ if (withoutLine.includes('/') || withoutLine.includes('\\')) {
93
+ out.push(withoutLine);
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+
69
99
  function deriveSeverity(fields) {
70
100
  for (const key of ['severity', 'impact', 'risk']) {
71
101
  const sev = normaliseSeverity(fields[key]);
@@ -166,7 +196,8 @@ function parseBlockFields(bodyLines) {
166
196
  * @param {object} params
167
197
  * @param {string} params.markdown — full report text.
168
198
  * @param {string} params.sourceReport — path used for `sourceReport` field
169
- * and dimension inference when the report omits a `Dimension:` line.
199
+ * and dimension inference when the report omits a `Dimension:` / `Category:`
200
+ * / `Type:` line.
170
201
  * @returns {Array<{
171
202
  * dimension: string,
172
203
  * severity: 'critical'|'high'|'medium'|'low'|null,
@@ -200,6 +231,7 @@ export function parseAuditReport({ markdown, sourceReport }) {
200
231
  fields['recommendation & rationale'] ?? fields.recommendation ?? '';
201
232
  const agentPrompt = fields['agent prompt'] ?? '';
202
233
  const fileSet = new Set([
234
+ ...deriveLocationFiles(fields),
203
235
  ...extractFilePaths(currentState),
204
236
  ...extractFilePaths(recommendation),
205
237
  ...extractFilePaths(agentPrompt),
@@ -243,4 +275,6 @@ export const __testing = {
243
275
  normaliseSeverity,
244
276
  extractFilePaths,
245
277
  normaliseTitle,
278
+ deriveDimension,
279
+ deriveLocationFiles,
246
280
  };
@@ -17,6 +17,11 @@
17
17
  * Pure: returns a string. The caller decides where to persist it.
18
18
  */
19
19
 
20
+ import {
21
+ renderFingerprintFooter,
22
+ renderSemanticKeyFooter,
23
+ } from './finding-adapter.js';
24
+
20
25
  const DIMENSION_LABEL = {
21
26
  security: 'Security',
22
27
  privacy: 'Privacy',
@@ -24,7 +29,7 @@ const DIMENSION_LABEL = {
24
29
  'clean-code': 'Clean code',
25
30
  dependencies: 'Dependencies',
26
31
  devops: 'DevOps',
27
- lighthouse: 'Lighthouse',
32
+ accessibility: 'Accessibility',
28
33
  performance: 'Performance',
29
34
  seo: 'SEO',
30
35
  sre: 'SRE',
@@ -84,7 +89,20 @@ function formatMVPScope(groups) {
84
89
  .map((g, idx) => {
85
90
  const dims = g.dimensions.join(' / ');
86
91
  const file = g.files[0] ? ` (\`${g.files[0]}\`)` : '';
87
- return `${idx + 1}. **${g.title}** ${dims}${file}`;
92
+ // Carry each group's fingerprint (and location-based semantic-key)
93
+ // footer into the seed so a Story authored from it via `/plan` inherits
94
+ // the dedup identity — without this the recommended `/plan --seed-file`
95
+ // path is invisible to the next sweep's dedup (Story #4626). The footers
96
+ // are HTML comments, so they never render in the visible one-pager but
97
+ // stay machine-readable for the dedup probe.
98
+ const findings = Array.isArray(g.findings) ? g.findings : [];
99
+ const footers = [
100
+ renderFingerprintFooter(findings),
101
+ renderSemanticKeyFooter(findings),
102
+ ]
103
+ .map((f) => ` ${f}`)
104
+ .join('\n');
105
+ return `${idx + 1}. **${g.title}** — ${dims}${file}\n${footers}`;
88
106
  })
89
107
  .join('\n');
90
108
  }
@@ -35,7 +35,7 @@ export function applyHeader(content, header) {
35
35
  /**
36
36
  * True when a workflow opts out of slash-command projection via a
37
37
  * `command: false` key in its YAML frontmatter (#4482). Used for dual-use
38
- * lens files (e.g. `audit-security.md`, `audit-lighthouse.md`) that stay in
38
+ * lens files (e.g. `audit-security.md`) that stay in
39
39
  * the payload as `/deliver` audit-suite prompts but must NOT surface as
40
40
  * standalone slash commands because the host ships a native equivalent.
41
41
  *
@@ -303,6 +303,26 @@ const FEEDBACK_LOOP_SCHEMA = {
303
303
  additionalProperties: false,
304
304
  };
305
305
 
306
+ /**
307
+ * `delivery.auditToStories` — knobs for the `/audit-to-stories` unattended
308
+ * (`--auto`) sweep (Story #4626). `severityFloor` is the minimum severity a
309
+ * finding must meet to be proposed as a Story on an unattended run (default
310
+ * `high`); `autoComment`, when true (default), lets `--auto` post a
311
+ * "re-detected" comment on an already-open matched Issue instead of silently
312
+ * skipping it.
313
+ */
314
+ const AUDIT_TO_STORIES_SCHEMA = {
315
+ type: 'object',
316
+ properties: {
317
+ severityFloor: {
318
+ type: 'string',
319
+ enum: ['critical', 'high', 'medium', 'low', 'all'],
320
+ },
321
+ autoComment: { type: 'boolean' },
322
+ },
323
+ additionalProperties: false,
324
+ };
325
+
306
326
  export const DELIVERY_SCHEMA = {
307
327
  type: 'object',
308
328
  properties: {
@@ -318,6 +338,7 @@ export const DELIVERY_SCHEMA = {
318
338
  refactorStage: REFACTOR_STAGE_SCHEMA,
319
339
  acceptanceEval: ACCEPTANCE_EVAL_SCHEMA,
320
340
  feedbackLoop: FEEDBACK_LOOP_SCHEMA,
341
+ auditToStories: AUDIT_TO_STORIES_SCHEMA,
321
342
  ci: CI_DELIVERY_SCHEMA,
322
343
  routing: ROUTING_SCHEMA,
323
344
  },
@@ -12,9 +12,10 @@ import { assertSectionsContract } from './report-contract-core.js';
12
12
  * contract-tier test assert report conformance against one definition rather
13
13
  * than re-deriving headings from prose in two places.
14
14
  *
15
- * Changing the report contract is explicitly **out of scope** for this Story
16
- * this module documents the existing shape already declared by the lens
17
- * markdown's Step 3 template, it does not introduce a new one.
15
+ * The report shape is declared by the lens markdown's Step 4 template. Story
16
+ * #4631 added the mandatory per-finding **Evidence** field (a repro command
17
+ * plus a `measured`/`estimated` tag) so measurement-first evidence is part of
18
+ * the contract both paths must emit, not just prose in the lens.
18
19
  *
19
20
  * @module dynamic-workflow/performance-report-contract
20
21
  */
@@ -48,6 +49,7 @@ export const REPORT_TITLE = 'Performance Audit Report';
48
49
  export const FINDING_FIELDS = Object.freeze([
49
50
  'Dimension',
50
51
  'Impact',
52
+ 'Evidence',
51
53
  'Current State',
52
54
  'Recommendation & Rationale',
53
55
  'Agent Prompt',
@@ -45,6 +45,12 @@
45
45
  * - NEVER throw. Every failure path is captured in `errors[]`.
46
46
  */
47
47
 
48
+ import {
49
+ fingerprintFinding,
50
+ fingerprintFooter,
51
+ semanticKeyFooter,
52
+ semanticKeyFor,
53
+ } from '../findings/route-finding.js';
48
54
  import {
49
55
  contentFingerprint,
50
56
  graduate,
@@ -121,6 +127,50 @@ export function buildContentMarker(epicId, finding) {
121
127
  return `<!-- audit-results-followup: epic-${epicId}-${fp} -->`;
122
128
  }
123
129
 
130
+ /**
131
+ * Project a graduator audit finding onto the canonical identity the shared
132
+ * dedup helper (`lib/findings/route-finding.js`) fingerprints over, so a
133
+ * close-time graduator filing and a sweep-time `/audit-to-stories` filing
134
+ * share ONE identity namespace (Story #4626). The graduator's `lens`
135
+ * (`audit-<dimension>`) maps to the identity `area`; its cited `path` is the
136
+ * primary file; its `summary` is the title. Historically the graduator only
137
+ * stamped its own content-hash `audit-results-followup` marker, disjoint from
138
+ * route-finding's `audit-fingerprints` footer — so a `/audit-to-stories`
139
+ * sweep could not recognize a graduator-filed issue and would re-file it.
140
+ *
141
+ * @param {{ lens?: string, path?: string, summary?: string }} finding
142
+ * @returns {{ title: string, area: string, primaryFile: string, severity: string, labels: string[] }}
143
+ */
144
+ export function toCanonicalFinding(finding) {
145
+ const lens = typeof finding?.lens === 'string' ? finding.lens : '';
146
+ return {
147
+ title: typeof finding?.summary === 'string' ? finding.summary : '',
148
+ area: lens,
149
+ primaryFile: typeof finding?.path === 'string' ? finding.path : '',
150
+ severity: '',
151
+ labels: lens ? [lens] : [],
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Render the canonical `audit-fingerprints` (and location-based
157
+ * `audit-semantic-keys`) footer for a graduator finding, computed off the
158
+ * shared helper's canonical hash. Stamped into every filed follow-up body so
159
+ * the `/audit-to-stories` dedup probe recognizes a graduator-filed issue
160
+ * (Story #4626).
161
+ *
162
+ * @param {{ lens?: string, path?: string, summary?: string }} finding
163
+ * @returns {string}
164
+ */
165
+ export function canonicalFingerprintFooter(finding) {
166
+ const canonical = toCanonicalFinding(finding);
167
+ const { full } = fingerprintFinding(canonical);
168
+ const key = semanticKeyFor(canonical);
169
+ const lines = [fingerprintFooter(full)];
170
+ if (key) lines.push(semanticKeyFooter(key));
171
+ return lines.join('\n');
172
+ }
173
+
124
174
  /**
125
175
  * Parse the rendered audit-results markdown into a list of findings.
126
176
  * The format produced by `epic-audit.md` Step 4 groups findings under
@@ -260,6 +310,12 @@ export async function graduateAuditResults(opts = {}) {
260
310
  finding.summary,
261
311
  '',
262
312
  `_See Epic #${epicId} for the full audit-results report._`,
313
+ '',
314
+ // Canonical dedup identity so the `/audit-to-stories` sweep-time
315
+ // probe recognizes this close-time filing and never re-files it
316
+ // (Story #4626). The content-hash `audit-results-followup` marker
317
+ // above stays the graduator's own re-file guard.
318
+ canonicalFingerprintFooter(finding),
263
319
  ].join('\n');
264
320
  const labels = [
265
321
  'meta::audit-finding',