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
@@ -11,14 +11,19 @@
11
11
  *
12
12
  * What this script does:
13
13
  * 1. Validate the Story (type::story, not closed).
14
- * 2. Fetch origin.
15
- * 3. Create the Story branch from `project.baseBranch` (default
14
+ * 2. Acquire the assignee lease, then refuse a Story already labelled
15
+ * `agent::executing` this run does not hold (unless `--steal`).
16
+ * 3. Flip the Story to `agent::executing` — BEFORE provisioning, so the
17
+ * claim is label-visible to concurrent operators' probes during the
18
+ * multi-minute install window (Story #4620). A provisioning failure after
19
+ * this reverts the label and releases the lease.
20
+ * 4. Fetch origin.
21
+ * 5. Create the Story branch from `project.baseBranch` (default
16
22
  * `main`) — local-only, no remote push at this stage.
17
- * 4. Materialise a worktree at `.worktrees/story-<id>/` when worktree
23
+ * 6. Materialise a worktree at `.worktrees/story-<id>/` when worktree
18
24
  * isolation is enabled; otherwise check out the branch in-place.
19
- * 5. Upsert a `story-init` structured comment carrying
25
+ * 7. Upsert a `story-init` structured comment carrying
20
26
  * `standalone: true`.
21
- * 6. Flip the Story to `agent::executing`.
22
27
  *
23
28
  * What this script does NOT do:
24
29
  * - Child-Task transitions — a Story is atomic (one branch, one
@@ -55,7 +60,10 @@ import {
55
60
  planFastForward,
56
61
  } from './lib/orchestration/git-cleanup/phases/fast-forward.js';
57
62
  import { verifyRemote } from './lib/orchestration/remote-verifier.js';
58
- import { acquireStoryLease } from './lib/orchestration/single-story-lease-guard.js';
63
+ import {
64
+ acquireStoryLease,
65
+ releaseStoryLease,
66
+ } from './lib/orchestration/single-story-lease-guard.js';
59
67
  import { handleRemoteVerificationFailure } from './lib/orchestration/story-init-remote.js';
60
68
  import {
61
69
  STATE_LABELS,
@@ -127,6 +135,127 @@ export function assertDeliverableStory(story, storyId) {
127
135
  }
128
136
  }
129
137
 
138
+ /**
139
+ * Defense-in-depth refusal for a Story already labelled `agent::executing`
140
+ * that this run does not already hold.
141
+ *
142
+ * The assignee lease is the primary cross-run guard, but the label and the
143
+ * assignee can drift apart: a prior run that crashed *after* the early
144
+ * `agent::executing` flip but *before* (or without) taking/holding the lease
145
+ * leaves the Story labelled executing with no live foreign lease to trip the
146
+ * lease preflight. Left unchecked, a fresh run would seed the branch and
147
+ * worktree straight over that drift. Refuse unless the caller already holds the
148
+ * lease (`reason === 'already-held'`, i.e. a legitimate idempotent re-init) or
149
+ * passed `--steal`.
150
+ *
151
+ * Runs *after* the lease acquire (so it can read the acquire's reason) but
152
+ * *before* any git mutation. On refusal it releases the lease this run just
153
+ * took so the ticket is left exactly as found — a clean state for the operator
154
+ * to inspect before re-running with `--steal`.
155
+ *
156
+ * @param {object} args
157
+ * @param {{ labels?: string[] }} args.story Fetched Story ticket.
158
+ * @param {{ reason: string, previousOwner: string|null }} args.lease Acquire result.
159
+ * @param {boolean} args.stealRequested
160
+ * @param {number} args.storyId
161
+ * @param {object} args.provider
162
+ * @param {object} args.config
163
+ */
164
+ export async function assertNotForeignExecuting({
165
+ story,
166
+ lease,
167
+ stealRequested,
168
+ storyId,
169
+ provider,
170
+ config,
171
+ }) {
172
+ const labelled =
173
+ Array.isArray(story?.labels) &&
174
+ story.labels.includes(STATE_LABELS.EXECUTING);
175
+ if (!labelled || stealRequested || lease.reason === 'already-held') return;
176
+
177
+ // Back out the lease we just took so the refusal leaves the ticket unchanged.
178
+ try {
179
+ await releaseStoryLease({ provider, storyId, config });
180
+ } catch (err) {
181
+ Logger.error(
182
+ `[single-story-init] ⚠️ Failed to release lease during executing-refusal: ${err?.message ?? err}`,
183
+ );
184
+ }
185
+ throw new Error(
186
+ `Story #${storyId} is already labelled agent::executing` +
187
+ (lease.previousOwner
188
+ ? ` (assignee @${lease.previousOwner})`
189
+ : ' with no assignee') +
190
+ '. Another /deliver run may already own it. Confirm that run is dead, ' +
191
+ 'then re-run with --steal to take it.',
192
+ );
193
+ }
194
+
195
+ /**
196
+ * Publish this run's claim as the `agent::executing` label **before** the
197
+ * multi-minute worktree install, so a concurrent operator's probe sees the
198
+ * claim during the install window instead of reading `agent::ready` and
199
+ * dispatching the Story a second time.
200
+ *
201
+ * Best-effort: the assignee lease is the real guard, so a failed flip logs and
202
+ * proceeds rather than aborting init. Routes through `transitionTicketState`
203
+ * so the Projects v2 Status column follows the label (Story #2548).
204
+ *
205
+ * @param {object} provider
206
+ * @param {number} storyId
207
+ * @param {object} story Prefetched snapshot (round-trip elimination).
208
+ * @returns {Promise<void>}
209
+ */
210
+ async function flipStoryToExecuting(provider, storyId, story) {
211
+ try {
212
+ await transitionTicketState(provider, storyId, STATE_LABELS.EXECUTING, {
213
+ ticketSnapshot: story,
214
+ cascade: false,
215
+ });
216
+ progress('LABELS', `🏷️ Story #${storyId} → agent::executing`);
217
+ } catch (err) {
218
+ Logger.error(
219
+ `[single-story-init] ⚠️ Failed to flip Story labels: ${err?.message ?? err}`,
220
+ );
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Undo this run's claim when provisioning fails after the early
226
+ * `agent::executing` flip: revert the label to `agent::ready` and release the
227
+ * lease, both best-effort. Without this a crashed init would strand the Story
228
+ * as phantom-executing — claimed and labelled in-flight but with no live run —
229
+ * which every other operator's probe would then withhold indefinitely.
230
+ *
231
+ * @param {object} provider
232
+ * @param {number} storyId
233
+ * @param {object} config
234
+ * @returns {Promise<void>}
235
+ */
236
+ async function rollbackClaimOnInitFailure(provider, storyId, config) {
237
+ try {
238
+ await transitionTicketState(provider, storyId, STATE_LABELS.READY, {
239
+ cascade: false,
240
+ });
241
+ progress(
242
+ 'ROLLBACK',
243
+ `↩️ Reverted Story #${storyId} → agent::ready after init failure`,
244
+ );
245
+ } catch (err) {
246
+ Logger.error(
247
+ `[single-story-init] ⚠️ Failed to revert label after init failure: ${err?.message ?? err}`,
248
+ );
249
+ }
250
+ try {
251
+ await releaseStoryLease({ provider, storyId, config });
252
+ } catch (err) {
253
+ Logger.error(
254
+ `[single-story-init] ⚠️ Failed to release lease after init failure: ${err?.message ?? err}`,
255
+ );
256
+ }
257
+ }
258
+
130
259
  /**
131
260
  * Decide how to seed the Story branch given local / remote presence. Pure and
132
261
  * exported for testing (Story #3483 AC3: an existing `story-<id>` branch must
@@ -449,6 +578,11 @@ export async function runSingleStoryInit({
449
578
  steal = false,
450
579
  leaseNow,
451
580
  injectedVerifyRemote,
581
+ // Story #4620: swap the git-touching provisioning steps so the
582
+ // early-flip-then-rollback ordering is unit-testable without a real worktree.
583
+ injectedMaterialize = materializeBaseBranch,
584
+ injectedSeedBranch = seedStoryBranch,
585
+ injectedProvisionWorktree = provisionWorktree,
452
586
  } = {}) {
453
587
  const parsed =
454
588
  storyIdParam !== undefined
@@ -523,6 +657,10 @@ export async function runSingleStoryInit({
523
657
  // assignee is treated as a live claim and aborts init (naming the current
524
658
  // owner) unless --steal forcibly transfers it. Unclaimed / self-held claims
525
659
  // proceed. Skipped under --dry-run (no assignee mutation).
660
+ let workCwd = cwd;
661
+ let worktreeCreated = false;
662
+ let installStatus = { status: 'skipped', reason: 'dry-run' };
663
+
526
664
  if (!dryRun) {
527
665
  const acquire = injectedAcquireLease ?? acquireStoryLease;
528
666
  const lease = await acquire({
@@ -536,31 +674,51 @@ export async function runSingleStoryInit({
536
674
  'LEASE',
537
675
  `🔒 Story #${storyId} lease ${lease.reason} (owner=@${lease.owner}).`,
538
676
  );
539
- }
540
-
541
- let workCwd = cwd;
542
- let worktreeCreated = false;
543
- let installStatus = { status: 'skipped', reason: 'dry-run' };
544
677
 
545
- if (!dryRun) {
546
- await materializeBaseBranch({
547
- cwd,
548
- baseBranch,
549
- storyBranch,
550
- config,
551
- provider,
552
- injectedSweep,
553
- progress,
554
- });
555
- seedStoryBranch({ cwd, storyBranch, baseBranch, progress });
556
- ({ workCwd, worktreeCreated, installStatus } = await provisionWorktree({
557
- runtime,
558
- cwd,
678
+ // Defense in depth: refuse a Story already labelled agent::executing that
679
+ // this run does not hold (label/assignee drift the lease alone misses).
680
+ // Runs before any git mutation; releases the just-taken lease on refusal.
681
+ await assertNotForeignExecuting({
682
+ story,
683
+ lease,
684
+ stealRequested,
559
685
  storyId,
560
- storyBranch,
686
+ provider,
561
687
  config,
562
- progress,
563
- }));
688
+ });
689
+
690
+ // Publish the claim as agent::executing BEFORE the multi-minute worktree
691
+ // install (not after), so a concurrent operator's probe sees it during the
692
+ // install window instead of reading agent::ready and double-dispatching.
693
+ await flipStoryToExecuting(provider, storyId, story);
694
+
695
+ // Any failure from here on leaves a claimed, executing-labelled Story with
696
+ // no live run behind it — revert the label and release the lease so the
697
+ // Story is not stranded as phantom-executing.
698
+ try {
699
+ await injectedMaterialize({
700
+ cwd,
701
+ baseBranch,
702
+ storyBranch,
703
+ config,
704
+ provider,
705
+ injectedSweep,
706
+ progress,
707
+ });
708
+ injectedSeedBranch({ cwd, storyBranch, baseBranch, progress });
709
+ ({ workCwd, worktreeCreated, installStatus } =
710
+ await injectedProvisionWorktree({
711
+ runtime,
712
+ cwd,
713
+ storyId,
714
+ storyBranch,
715
+ config,
716
+ progress,
717
+ }));
718
+ } catch (err) {
719
+ await rollbackClaimOnInitFailure(provider, storyId, config);
720
+ throw err;
721
+ }
564
722
  }
565
723
 
566
724
  const dependenciesInstalled =
@@ -589,8 +747,9 @@ export async function runSingleStoryInit({
589
747
  remoteProbe: { remoteUrl: remote.remoteUrl, detail: remote.detail },
590
748
  };
591
749
 
592
- // Upsert the `story-init` structured comment + flip Story to executing.
593
- // Both are no-ops under --dry-run.
750
+ // Upsert the `story-init` structured comment (no-op under --dry-run). The
751
+ // `agent::executing` flip already happened above, before provisioning, so the
752
+ // claim is label-visible during the install window (see `flipStoryToExecuting`).
594
753
  if (!dryRun) {
595
754
  try {
596
755
  await upsertStructuredComment(
@@ -608,27 +767,6 @@ export async function runSingleStoryInit({
608
767
  `[single-story-init] ⚠️ Failed to upsert story-init structured comment: ${err?.message ?? err}`,
609
768
  );
610
769
  }
611
-
612
- try {
613
- // Route through the canonical state mutator so the Projects v2
614
- // Status column mirrors the label flip (Story #2548 wires column-
615
- // sync inside `transitionTicketState`). A direct
616
- // `provider.updateTicket({ labels })` would skip the board update
617
- // and leave the Story on its prior status column for the entire
618
- // run. `cascade: false` is correct — a standalone Story has no
619
- // parent chain — and threading the prefetched `story` as
620
- // `ticketSnapshot` preserves the round-trip elimination from
621
- // Story #1795.
622
- await transitionTicketState(provider, storyId, STATE_LABELS.EXECUTING, {
623
- ticketSnapshot: story,
624
- cascade: false,
625
- });
626
- progress('LABELS', `🏷️ Story #${storyId} → agent::executing`);
627
- } catch (err) {
628
- Logger.error(
629
- `[single-story-init] ⚠️ Failed to flip Story labels: ${err?.message ?? err}`,
630
- );
631
- }
632
770
  }
633
771
 
634
772
  Logger.info('\n--- STORY INIT RESULT ---');
@@ -709,12 +709,13 @@ export async function runProbedStoriesWaveTick({
709
709
 
710
710
  let probed;
711
711
  try {
712
- const { provider, owner, repo } = context();
712
+ const { provider, owner, repo, self } = context();
713
713
  probed = await probe({
714
714
  ids,
715
715
  provider,
716
716
  owner,
717
717
  repo,
718
+ self,
718
719
  dispatched: [...dispatchedIds],
719
720
  warn: (m) => Logger.warn(m),
720
721
  });
@@ -728,7 +729,13 @@ export async function runProbedStoriesWaveTick({
728
729
  );
729
730
  }
730
731
 
731
- const { nodes, doneIds, inFlight, blockedIds = [] } = probed;
732
+ const {
733
+ nodes,
734
+ doneIds,
735
+ inFlight,
736
+ blockedIds = [],
737
+ foreignHeld = [],
738
+ } = probed;
732
739
  const { envelope, exitCode } = buildReadySetEnvelope(nodes, {
733
740
  concurrencyCap,
734
741
  doneIds,
@@ -745,6 +752,11 @@ export async function runProbedStoriesWaveTick({
745
752
  epilogueDue,
746
753
  blocked: blockedIds,
747
754
  blockedReason: blockedReasonFor(blockedIds),
755
+ // Stories another operator's lease holds — withheld from dispatch this
756
+ // beat (folded into in-flight) and surfaced so the run can report
757
+ // "#<id> held by @<holder>" instead of dispatching into an init refusal.
758
+ foreignHeld,
759
+ foreignHeldReason: foreignHeldReasonFor(foreignHeld),
748
760
  },
749
761
  // A blocked Story outranks the scheduler's own verdict — including a
750
762
  // wedge, whose named blockers are moot while a human owes a decision.
@@ -776,6 +788,31 @@ function blockedReasonFor(blockedIds) {
776
788
  );
777
789
  }
778
790
 
791
+ /**
792
+ * Render the operator-facing note for Stories held by another operator's
793
+ * lease, or `null` when none are held.
794
+ *
795
+ * These are not errors and not a wedge: the holder's run is progressing
796
+ * normally, this run simply must not join it on the same branch. The Story
797
+ * stays withheld and re-probes each beat, so it dispatches on its own the
798
+ * moment the holder's lease clears (their run lands or is stolen).
799
+ *
800
+ * @param {Array<{id: number, holder: string}>} foreignHeld
801
+ * @returns {string|null}
802
+ */
803
+ function foreignHeldReasonFor(foreignHeld) {
804
+ if (!Array.isArray(foreignHeld) || foreignHeld.length === 0) return null;
805
+ const list = foreignHeld
806
+ .map((h) => `#${h.id} held by @${h.holder}`)
807
+ .join(', ');
808
+ return (
809
+ `${foreignHeld.length} Story(ies) are held by another operator's lease — ` +
810
+ `${list}. They are withheld this beat, not failed: the holder's run owns ` +
811
+ `the branch and worktree. This run picks each up automatically once that ` +
812
+ `lease clears (their run lands, or you --steal it after confirming it is dead).`
813
+ );
814
+ }
815
+
779
816
  async function main(argv) {
780
817
  const { values } = parseArgs({
781
818
  args: argv,
@@ -0,0 +1,243 @@
1
+ ---
2
+ description: Audit WCAG accessibility conformance (static-first) with an optional runtime verification pass, and produce a structured findings report
3
+ ---
4
+
5
+ # Accessibility (WCAG) Audit
6
+
7
+ ## Applicability
8
+
9
+ **Web targets only.** Registered with `target: "web"` in
10
+ [`audit-rules.json`](../schemas/audit-rules.json): the selector skips this lens
11
+ on a project with no rendered frontend, since there are no components, templates,
12
+ or routes to hold to WCAG. See the `target` key's schema description for how
13
+ applicability is probed from the consumer's checkout.
14
+
15
+ ## Role
16
+
17
+ Senior Accessibility Engineer. You hold the frontend to **WCAG 2.x
18
+ conformance** — semantic structure, ARIA correctness, keyboard operability,
19
+ form labelling, media alternatives, and contrast — grounding every finding in a
20
+ concrete element and the success criterion it violates. You default to
21
+ **static** detection over the source, and escalate to a **runtime** pass only
22
+ when a live target is configured.
23
+
24
+ ## Context & Objective
25
+
26
+ This is a **read-only** audit. Detect WCAG violations statically from the
27
+ component/template/route source, optionally corroborate them against a running
28
+ build, and emit a structured Markdown report at
29
+ `{{auditOutputDir}}/audit-accessibility-results.md`. Do not modify application
30
+ code — surfacing the violations (each keyed to a WCAG success criterion) is the
31
+ deliverable; fixing them is a separate pass.
32
+
33
+ > **No conformance certification.** The lens reports findings against WCAG
34
+ > success criteria; it does **not** assert a conformance level (A / AA / AAA)
35
+ > for the product. "No findings in scope" is not "certified conformant".
36
+
37
+ ## Boundary with `audit-ux-ui`
38
+
39
+ These two web lenses share a border and must not double-report:
40
+
41
+ - **`audit-accessibility` (this lens)** owns **WCAG conformance** — the
42
+ standards question: does an assistive-technology user perceive, operate, and
43
+ understand the surface? Semantic HTML, ARIA, keyboard/focus, labelled
44
+ controls, text alternatives, and contrast against the WCAG ratio thresholds.
45
+ - **`audit-ux-ui`** owns **design-system adherence** — the consistency
46
+ question: do components and tokens match the project's own design system
47
+ (hardcoded values that bypass a token, raw elements that should defer to a
48
+ design-system component, interaction/loading/error states, premium feel)?
49
+
50
+ Contrast is the one axis both can touch: **accessibility owns the WCAG ratio
51
+ verdict** (4.5:1 body / 3:1 large text / 3:1 non-text), while ux-ui owns whether
52
+ the colour came from a sanctioned token. When a contrast defect is in scope for
53
+ both, report the WCAG failure here and leave the token-adherence note to ux-ui.
54
+
55
+ ## Scope (Story / plan-run mode)
56
+
57
+ When this lens is invoked from `/deliver` close lenses (or a plan-run audit), the
58
+ following block is populated with the Story (or plan-run) change-set file list.
59
+ Otherwise — for any manual `/audit-<dimension>` invocation — the block
60
+ renders the literal substitution token and you MUST treat it as **no
61
+ scope filter — run the lens codebase-wide** exactly as you would have
62
+ before this section existed.
63
+
64
+ ```text
65
+ {{changedFiles}}
66
+ ```
67
+
68
+ - If the block above contains a newline-delimited list of file paths,
69
+ restrict your analysis to those files (and their direct dependencies
70
+ when the lens explicitly calls for cross-file reasoning).
71
+ - If the block above renders as the literal string `{{changedFiles}}`
72
+ (i.e. no substitution was supplied), ignore this section entirely and
73
+ proceed with the full codebase-wide scan defined in the remaining
74
+ steps.
75
+
76
+ ## Step 0: Discover the frontend surface and config (run first)
77
+
78
+ **You cannot audit WCAG conformance until you have located what renders and how
79
+ the project is configured.** Before any detection:
80
+
81
+ - **Renderable surface:** the component directories (`components/**`,
82
+ `app/**`, `pages/**`, `src/**`), templates (`**/*.html`, `**/*.astro`,
83
+ framework SFCs), and any design-system component library raw elements are
84
+ expected to defer to.
85
+ - **Static a11y tooling already in the repo:** an `eslint-plugin-jsx-a11y`
86
+ config, an `axe-core` / `@axe-core/*` dependency, or a `pa11y` config. Prefer
87
+ reusing the consumer's configured ruleset over inventing one.
88
+ - **Design tokens:** the colour tokens (`tailwind.config.*`, CSS custom
89
+ properties, a theme object) whose literal values you need to compute contrast
90
+ ratios statically.
91
+ - **Runtime target (optional):** the `qa.environments` map (see
92
+ [_Runtime verification mode_](#step-2-runtime-verification-mode-optional-corroboration))
93
+ and the navigability route SSOT.
94
+
95
+ Record what exists. Every finding downstream is measured against _this
96
+ discovered surface and config_, not a generic ideal. If **no** frontend surface
97
+ exists in scope, say so and emit an empty report rather than inventing findings.
98
+
99
+ ## Step 1: Static WCAG detection, then triage
100
+
101
+ > Apply [`helpers/parallel-tooling.md`](helpers/parallel-tooling.md) when batching the scan below — independent reads belong in one turn, long shells run via `run_in_background` + `Monitor`.
102
+
103
+ Run the **mechanical detectors first** (cheap, deterministic greps and the
104
+ static a11y linters discovered in Step 0), then apply **LLM triage** to each
105
+ candidate — a mechanical hit is a _candidate_, not automatically a finding.
106
+ Cover every static WCAG dimension:
107
+
108
+ - **Semantic structure** (WCAG 1.3.1) — landmark regions (`<main>`, `<nav>`,
109
+ `<header>`), a single `<h1>` and a non-skipping heading order, lists for
110
+ list-shaped content, and `<button>`/`<a>` used for their real role rather
111
+ than a clickable `<div>`/`<span>`.
112
+ - **ARIA correctness** (WCAG 4.1.2) — census `role=` / `aria-*` usage: invalid
113
+ roles, `aria-*` attributes unsupported on their element, `aria-labelledby` /
114
+ `aria-describedby` pointing at absent ids, redundant roles on native
115
+ elements, and interactive `role=` on a non-focusable element.
116
+ - **Keyboard operability & focus management** (WCAG 2.1.1 / 2.4.3 / 2.4.7) —
117
+ click handlers on non-interactive elements with no keyboard handler or
118
+ `tabindex`, positive `tabindex` values, `:hover`/`hover:` states with no
119
+ matching `:focus-visible`/`focus-visible:`, focus traps, and `outline: none`
120
+ with no replacement focus indicator.
121
+ - **Forms & labels** (WCAG 1.3.1 / 3.3.2 / 4.1.2) — inputs with no associated
122
+ `<label for>` / wrapping label / `aria-label`, placeholder-as-label,
123
+ unlabelled control groups (`fieldset`/`legend`), and error text not tied to
124
+ its field.
125
+ - **Media alternatives** (WCAG 1.1.1 / 1.2.x) — `<img>` with no `alt`
126
+ (and decorative images missing `alt=""`), `<video>`/`<audio>` with no
127
+ captions/transcript track, `<svg>` conveying meaning with no accessible name,
128
+ and icon-only controls with no accessible name.
129
+ - **Contrast where statically derivable** (WCAG 1.4.3 / 1.4.11) — when both the
130
+ foreground and background resolve to concrete token/literal colour values,
131
+ compute the ratio and flag body text below 4.5:1, large text below 3:1, and
132
+ non-text/UI boundaries below 3:1. When either colour cannot be resolved
133
+ statically (runtime theme, image background), mark it **provisional** and
134
+ defer confirmation to Step 2 rather than guessing.
135
+ - **Raw-element census** — when the project configures a static ruleset,
136
+ reconcile the raw-element findings above against the design-system components
137
+ that already encode the accessible pattern, so the fix lands in one place.
138
+
139
+ > **Detector output is candidates.** Triage each against the Step 0 surface and
140
+ > the specific WCAG success criterion before promoting it to a finding — a role
141
+ > on a design-system primitive that already manages focus, or a token `px` value
142
+ > inside a token file, is expected, not a defect.
143
+
144
+ ## Step 2: Runtime verification mode (optional corroboration)
145
+
146
+ Static detection is the default and always runs. The runtime pass is
147
+ **conditional** — it runs only when a live target is configured; its absence
148
+ never blocks the static report.
149
+
150
+ 1. **Resolve the target from config — never a hardcoded URL.** Resolve the
151
+ target through the consumer's `qa.environments.<env>.baseUrl` (via
152
+ [`resolveQaEnvironment`](../scripts/lib/qa/resolve-qa-contract.js), the same
153
+ resolver `/qa-run` uses): an `<env>` argument resolves by exact name or
154
+ origin match; with no argument, enumerate `name → baseUrl` and let the
155
+ operator pick. If **no** `qa.environments` target is configured, **skip this
156
+ step** and note in the report that runtime corroboration was unavailable —
157
+ do not invent a URL and do not start an arbitrary dev server.
158
+ 2. **Sample routes from the navigability SSOT.** Draw the routes to exercise
159
+ from the consumer's route/nav registry (`planning.navigation.navRegistry` /
160
+ `routeGlobs` — the same SSOT [`/audit-navigability`](audit-navigability.md)
161
+ reads), sampling a representative set (key personas' landing routes plus any
162
+ route in the change-set scope) rather than a single hardcoded page.
163
+ 3. **Run an accessibility engine per sampled route.** Use the
164
+ `mcp__chrome-devtools__lighthouse_audit` tool's **Accessibility category**,
165
+ or run **axe** via the browser tooling, against each sampled `baseUrl`-rooted
166
+ route. Prefer a production-mode build.
167
+ 4. **Median-of-3 or provisional.** Any runtime score or metric is subject to
168
+ run-to-run variance: capture a **median-of-3** (three runs per route, report
169
+ the median) before treating a number as authoritative. A single-run value is
170
+ reported **provisional** and never drives a Critical/High verdict on its own.
171
+
172
+ Corroborate static findings against the runtime results (a statically-flagged
173
+ contrast defect confirmed by the engine graduates from provisional to
174
+ confirmed), and surface runtime-only violations the static pass could not see.
175
+
176
+ ## Step 3: Output Requirements
177
+
178
+ Generate and save a structured Markdown report to
179
+ `{{auditOutputDir}}/audit-accessibility-results.md`, using the exact template
180
+ below. The report MUST include all sections, even if empty (write
181
+ "_No findings._" rather than omitting a section).
182
+
183
+ > Grade every finding's severity on the shared
184
+ > [`Critical | High | Medium | Low` scale](helpers/audit-severity-scale.md), and
185
+ > key every finding to the WCAG success criterion it violates.
186
+
187
+ ```markdown
188
+ # Accessibility (WCAG) Audit report
189
+
190
+ ## Executive Summary
191
+
192
+ [Overview of WCAG conformance health across the scope, the runtime mode's
193
+ status (ran against `<env>` / skipped — no target configured), and the
194
+ self-cross-check line.]
195
+
196
+ ## Detailed Findings
197
+
198
+ [For every WCAG violation identified, use the following strict structure. Lead
199
+ each title with the primary file the finding lives in:]
200
+
201
+ ### `path/to/primary-file.ext` — [Short title of the issue]
202
+
203
+ - **Dimension:** [e.g., Semantic Structure | ARIA | Keyboard & Focus | Forms & Labels | Media Alternatives | Contrast]
204
+ - **Severity:** [Critical | High | Medium | Low]
205
+ - **WCAG:** [success criterion — e.g. `1.1.1 Non-text Content (A)`]
206
+ - **Location:** `path/to/primary-file.ext:line`
207
+ - **Evidence:** [measured | static] [the observable — a quoted element, the
208
+ computed contrast ratio, the failing axe/Lighthouse audit id + median score.
209
+ Runtime numbers from a single run are tagged `provisional`.]
210
+ - **Current State:** [what is implemented and why it fails the criterion]
211
+ - **Recommendation & Rationale:** [the specific change — attribute to add,
212
+ element to swap, token to adjust — and the assistive-technology behaviour it
213
+ restores]
214
+ - **Acceptance signal:** [the command or observable that proves this finding is
215
+ remediated — e.g. the axe rule now passing on the route, or a re-run of this
216
+ lens]
217
+ - **Agent Prompt:**
218
+ `[A copy-pasteable, highly specific prompt to execute this a11y fix independently]`
219
+
220
+ ## Runtime Verification
221
+
222
+ [Per-route median-of-3 accessibility scores when the runtime mode ran, or
223
+ "_Runtime corroboration unavailable — no `qa.environments` target configured._"]
224
+ ```
225
+
226
+ ## Constraint
227
+
228
+ This is a **read-only** audit. Provide the critique and implementation
229
+ suggestions, but do not modify components, styles, or configuration. The
230
+ runtime mode runs **non-mutating** measurements only and starts no arbitrary
231
+ dev server.
232
+
233
+ ## Self-cross-check (mandatory — filter false positives before you finalize)
234
+
235
+ Before you write the report artifact from the previous step, run the shared
236
+ adversarial self-cross-check over your Detailed Findings — see
237
+ [`helpers/audit-self-check.md`](helpers/audit-self-check.md). It defines the
238
+ per-finding evidence bar, the exclusion list, and the final re-open-and-drop
239
+ pass whose `kept <k> / dropped <d>` counts you record in the Executive
240
+ Summary, so the sequential single-pass path filters unverified findings just as
241
+ the orchestrated path's adversarial reviewer does. Drop every claimed
242
+ violation that names no concrete element and no specific WCAG success
243
+ criterion.