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.
- package/.agents/audit-checklists/accessibility.md +29 -0
- package/.agents/audit-checklists/architecture.md +4 -5
- package/.agents/audit-checklists/clean-code.md +10 -0
- package/.agents/audit-checklists/data-model.md +22 -0
- package/.agents/audit-checklists/dependencies.md +11 -2
- package/.agents/audit-checklists/devops.md +4 -0
- package/.agents/audit-checklists/navigability.md +3 -0
- package/.agents/audit-checklists/performance.md +8 -11
- package/.agents/audit-checklists/privacy.md +3 -4
- package/.agents/audit-checklists/quality.md +2 -0
- package/.agents/audit-checklists/security.md +4 -5
- package/.agents/audit-checklists/seo.md +7 -1
- package/.agents/audit-checklists/sre.md +14 -12
- package/.agents/audit-checklists/ux-ui.md +4 -0
- package/.agents/docs/configuration.md +3 -0
- package/.agents/docs/workflows.md +4 -3
- package/.agents/schemas/agentrc.schema.json +17 -0
- package/.agents/schemas/audit-rules.json +134 -19
- package/.agents/schemas/audit-rules.schema.json +6 -2
- package/.agents/scripts/audit-labels-bootstrap.js +4 -4
- package/.agents/scripts/audit-to-stories.js +244 -19
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +26 -3
- package/.agents/scripts/lib/audit-suite/dispatch-checklist.js +132 -0
- package/.agents/scripts/lib/audit-suite/index.js +1 -0
- package/.agents/scripts/lib/audit-suite/selector.js +290 -14
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +2 -1
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +5 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +23 -3
- package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +38 -0
- package/.agents/scripts/lib/audit-to-stories/ledger.js +256 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +41 -7
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +20 -2
- package/.agents/scripts/lib/command-header.js +1 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
- package/.agents/scripts/lib/dynamic-workflow/performance-report-contract.js +5 -3
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +56 -0
- package/.agents/scripts/lib/findings/route-finding.js +108 -10
- package/.agents/scripts/lib/observability/runtime-friction.js +62 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +9 -1
- package/.agents/scripts/lib/orchestration/retro-proposals.js +49 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +73 -15
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +81 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +1 -0
- package/.agents/scripts/lib/orchestration/ticket-lease.js +78 -10
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +68 -16
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +73 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +93 -12
- package/.agents/scripts/nav-registry-diff.js +449 -0
- package/.agents/scripts/single-story-init.js +189 -51
- package/.agents/scripts/stories-wave-tick.js +39 -2
- package/.agents/workflows/audit-accessibility.md +243 -0
- package/.agents/workflows/audit-architecture.md +89 -71
- package/.agents/workflows/audit-clean-code.md +87 -53
- package/.agents/workflows/audit-data-model.md +198 -0
- package/.agents/workflows/audit-dependencies.md +143 -28
- package/.agents/workflows/audit-devops.md +109 -18
- package/.agents/workflows/audit-documentation.md +25 -53
- package/.agents/workflows/audit-navigability.md +78 -22
- package/.agents/workflows/audit-performance.md +207 -103
- package/.agents/workflows/audit-privacy.md +51 -13
- package/.agents/workflows/audit-quality.md +71 -61
- package/.agents/workflows/audit-security.md +94 -71
- package/.agents/workflows/audit-seo.md +80 -25
- package/.agents/workflows/audit-sre.md +99 -66
- package/.agents/workflows/audit-to-stories.md +44 -5
- package/.agents/workflows/audit-ux-ui.md +71 -17
- package/.agents/workflows/deliver.md +32 -8
- package/.agents/workflows/helpers/audit-dual-path.md +59 -0
- package/.agents/workflows/helpers/audit-self-check.md +70 -0
- package/.agents/workflows/helpers/audit-severity-scale.md +19 -0
- package/.agents/workflows/helpers/deliver-story.md +25 -0
- package/docs/CHANGELOG.md +29 -0
- package/package.json +1 -1
- package/.agents/audit-checklists/lighthouse.md +0 -15
- package/.agents/schemas/audit-results.schema.json +0 -69
- 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.
|
|
15
|
-
*
|
|
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
|
-
*
|
|
23
|
+
* 6. Materialise a worktree at `.worktrees/story-<id>/` when worktree
|
|
18
24
|
* isolation is enabled; otherwise check out the branch in-place.
|
|
19
|
-
*
|
|
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 {
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
686
|
+
provider,
|
|
561
687
|
config,
|
|
562
|
-
|
|
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
|
|
593
|
-
//
|
|
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 {
|
|
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.
|