mandrel 2.50.0 → 2.52.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.
@@ -12,6 +12,7 @@ Self-check your change against this lens's concerns before you ship:
12
12
 
13
13
  - [ ] `audit-accessibility` (this lens)
14
14
  - [ ] `audit-ux-ui`
15
+ - [ ] `audit-mobile`
15
16
  - [ ] Renderable surface
16
17
  - [ ] Static a11y tooling already in the repo
17
18
  - [ ] Design tokens
@@ -0,0 +1,35 @@
1
+ <!-- GENERATED FILE — do not edit by hand.
2
+ Source of truth: .agents/workflows/audit-mobile.md
3
+ Regenerate: node .agents/scripts/generate-lens-checklists.js
4
+ Drift is gated by: npm run docs:check
5
+ -->
6
+
7
+ # Mobile & Tablet UX Audit — authoring checklist
8
+
9
+ > Audit mobile and tablet UX — layout and viewport correctness, touch ergonomics, responsive assets, and whether anything actually verifies them at a small viewport (static-first, with an optional runtime viewport pass)
10
+
11
+ Self-check your change against this lens's concerns before you ship:
12
+
13
+ - [ ] Breakpoint scale
14
+ - [ ] Viewport contract
15
+ - [ ] Declared device matrix
16
+ - [ ] Runtime target (optional)
17
+ - [ ] Viewport meta
18
+ - [ ] Fixed dimensions
19
+ - [ ] Viewport-height units
20
+ - [ ] Horizontal overflow
21
+ - [ ] Safe-area insets
22
+ - [ ] Hover-only interaction
23
+ - [ ] Control size and spacing
24
+ - [ ] Input ergonomics
25
+ - [ ] Gesture conflicts
26
+ - [ ] Images
27
+ - [ ] Media and embeds
28
+ - [ ] Typography and spacing
29
+ - [ ] Coverage — is anything exercised at a non-desktop viewport?
30
+ - [ ] Effectiveness — does that exercise assert anything mobile-specific?
31
+ - [ ] Resolve the target from config — never a hardcoded URL.
32
+ - [ ] Sample routes from the navigability SSOT.
33
+ - [ ] Drive two form factors per route.
34
+ - [ ] Median-of-3 or provisional.
35
+ - [ ] Leave the viewport as you found it.
@@ -21,5 +21,4 @@ Self-check your change against this lens's concerns before you ship:
21
21
  - [ ] Information Hierarchy
22
22
  - [ ] Error States
23
23
  - [ ] Loading States
24
- - [ ] Responsiveness
25
24
  - [ ] Accessibility (UX-focused)
@@ -32,7 +32,7 @@ by `node .agents/scripts/generate-workflows-doc.js`; `npm run docs:check`
32
32
  fails when it drifts from the on-disk workflow set. To change a command’s
33
33
  description, edit the workflow file’s front-matter and regenerate.
34
34
 
35
- ## Commands (28)
35
+ ## Commands (29)
36
36
 
37
37
  | Command | Description |
38
38
  | --- | --- |
@@ -45,6 +45,7 @@ description, edit the workflow file’s front-matter and regenerate.
45
45
  | `/audit-dependencies` | Audit `package.json` for unused, outdated, and major-version-stale dependencies; surface Node-engine drift and propose upgrade batches. |
46
46
  | `/audit-devops` | Audit CI/CD workflows, container images, infrastructure-as-code, and deployment pipelines; surface failure modes and hardening gaps. |
47
47
  | `/audit-documentation` | Audit the repository's main documentation for staleness, semantic drift, and completeness; emit a structured High/Medium/Low findings report. |
48
+ | `/audit-mobile` | Audit mobile and tablet UX — layout and viewport correctness, touch ergonomics, responsive assets, and whether anything actually verifies them at a small viewport (static-first, with an optional runtime viewport pass) |
48
49
  | `/audit-navigability` | Audit the whole route tree against the consumer's nav-registry SSOT — every route has a persona nav door and no nav href is dead. A deliberately-global lens exempt from the cross-epic-leak guard and routed onto route-adding change sets. |
49
50
  | `/audit-performance` | Audit performance by measuring first — profile hot paths, I/O, memory, and payload against the repo's own numbers — and audit interleaving/partial-failure correctness (TOCTOU, unawaited promises, non-atomic writes) as a first-class dimension. |
50
51
  | `/audit-privacy` | Audit logs, telemetry, and persistence paths for PII leakage and retention violations; surface secrets exposure and consent gaps. |
@@ -299,6 +299,36 @@
299
299
  "scope": "local",
300
300
  "substitutionKeys": []
301
301
  },
302
+ "audit-mobile": {
303
+ "triggers": {
304
+ "gates": ["gate2", "gate3"],
305
+ "keywords": [
306
+ "mobile",
307
+ "tablet",
308
+ "responsive",
309
+ "breakpoint",
310
+ "viewport",
311
+ "touch"
312
+ ],
313
+ "filePatterns": [
314
+ "**/*.html",
315
+ "**/*.astro",
316
+ "**/*.css",
317
+ "**/*.{scss,sass,less}",
318
+ "**/styles/**",
319
+ "**/components/**/*.{js,jsx,ts,tsx,vue,svelte}",
320
+ "**/app/**/{page,layout,route,head,default,template,loading,error,not-found}.{js,jsx,ts,tsx}",
321
+ "**/pages/**/*.{js,jsx,ts,tsx,vue}",
322
+ "**/routes/**/*.{jsx,tsx,vue,svelte}",
323
+ "**/tailwind.config.{js,ts,cjs,mjs}",
324
+ "**/playwright.config.{js,ts,cjs,mjs}",
325
+ "**/cypress.config.{js,ts,cjs,mjs}"
326
+ ]
327
+ },
328
+ "target": "web",
329
+ "scope": "local",
330
+ "substitutionKeys": []
331
+ },
302
332
  "audit-navigability": {
303
333
  "triggers": {
304
334
  "gates": ["gate2", "gate3"],
@@ -33,6 +33,7 @@ export const AUDIT_LENSES = Object.freeze([
33
33
  'dependencies',
34
34
  'devops',
35
35
  'documentation',
36
+ 'mobile',
36
37
  'navigability',
37
38
  'performance',
38
39
  'privacy',
@@ -95,6 +95,40 @@ export const RUNTIME_FRICTION_CATEGORIES = Object.freeze({
95
95
  REVIEW_BLOCK_OVERRIDDEN: 'review-block-overridden',
96
96
  });
97
97
 
98
+ /**
99
+ * The friction category one light-path refusal files under.
100
+ *
101
+ * `LIGHT_SCOPE_REJECTED` alone used to be the category for every refusal, and
102
+ * the retro composer aggregates, de-duplicates and labels on the category
103
+ * string ALONE — so an empty-diff refusal and a `public-api` sensitive-path
104
+ * refusal landed in one bucket and filed one "recurred 2 times across 2
105
+ * Stories" follow-up whose two occurrences had nothing in common but the
106
+ * category (issue #5237; consumer Beestera/swarm-os#2408). The roll-up's shape
107
+ * fingerprint could not separate them either: it hashes detail KEYS, and every
108
+ * refusal carries an identical key set.
109
+ *
110
+ * Encoding the refusal class in the category is what splits them, and it
111
+ * splits them in every consumer at once — bucket aggregation, the graduator's
112
+ * idempotency marker and the `friction::<category>` label all read this one
113
+ * string. N refusals of the SAME class still coalesce, which is the recurrence
114
+ * evidence the light ceilings are recalibrated from. New categories need no
115
+ * seeding: the graduator mints missing `friction::*` labels before filing.
116
+ *
117
+ * A class-less refusal keeps the bare category unchanged — that is the
118
+ * suitability gate, which refuses a prompt before any diff exists and so has
119
+ * no diff-derived class to carry.
120
+ *
121
+ * @param {string|null} [refusalClass] A
122
+ * {@link module:lib/orchestration/light-suitability.LIGHT_REFUSAL_CLASSES}
123
+ * value, or nullish for the unclassified refusal.
124
+ * @returns {string}
125
+ */
126
+ export function lightScopeRejectedCategory(refusalClass) {
127
+ const suffix = typeof refusalClass === 'string' ? refusalClass.trim() : '';
128
+ const base = RUNTIME_FRICTION_CATEGORIES.LIGHT_SCOPE_REJECTED;
129
+ return suffix === '' ? base : `${base}-${suffix}`;
130
+ }
131
+
98
132
  /** Cap on free-form reason text copied into a signal's `details`. */
99
133
  const REASON_PREVIEW_LIMIT = 500;
100
134
 
@@ -18,16 +18,26 @@
18
18
  * other consumer are looking at the same change set.
19
19
  * - `--numstat` gives per-file line counts, the only surface carrying them.
20
20
  *
21
+ * Both read committed state, which is why a third read exists
22
+ * ({@link module:lib/orchestration/worktree-dirty.hasUncommittedWork}): an
23
+ * empty diff is ambiguous between "no work" and "work not committed yet", and
24
+ * only one of those is about scope.
25
+ *
21
26
  * @module lib/orchestration/light-backstop
22
27
  */
23
28
 
29
+ import { gitSpawn } from '../git-utils.js';
24
30
  import { computeChangeSet } from './change-set.js';
25
31
  import { readNumstatRows, summarizeDiffMagnitude } from './diff-magnitude.js';
26
32
  import {
27
33
  handleBlockedBackstop,
28
34
  preserveRefusedWork,
29
35
  } from './light-escalation.js';
30
- import { checkLightDiffBackstop } from './light-suitability.js';
36
+ import {
37
+ checkLightDiffBackstop,
38
+ LIGHT_REFUSAL_CLASSES,
39
+ } from './light-suitability.js';
40
+ import { hasUncommittedWork } from './worktree-dirty.js';
31
41
 
32
42
  /** Exit code when the diff backstop blocked the land. */
33
43
  const EXIT_BACKSTOP_BLOCKED = 3;
@@ -41,6 +51,8 @@ const EXIT_BACKSTOP_BLOCKED = 3;
41
51
  * cwd?: string,
42
52
  * computeFn?: typeof computeChangeSet,
43
53
  * readRowsFn?: typeof readNumstatRows,
54
+ * dirtyProbeFn?: typeof hasUncommittedWork,
55
+ * gitFn?: typeof gitSpawn,
44
56
  * injectedRules?: object,
45
57
  * }} args
46
58
  * @returns {ReturnType<typeof checkLightDiffBackstop>}
@@ -51,6 +63,8 @@ function runDiffBackstop({
51
63
  cwd = process.cwd(),
52
64
  computeFn = computeChangeSet,
53
65
  readRowsFn = readNumstatRows,
66
+ dirtyProbeFn = hasUncommittedWork,
67
+ gitFn = gitSpawn,
54
68
  injectedRules,
55
69
  } = {}) {
56
70
  const headRef = `story-${storyId}`;
@@ -61,9 +75,49 @@ function runDiffBackstop({
61
75
  changedFiles: files,
62
76
  magnitude,
63
77
  injectedRules,
78
+ storyBranch: headRef,
79
+ // Only an ENUMERATED-empty diff can be explained by uncommitted work, so
80
+ // the probe's two git calls are spent only where they can change what the
81
+ // refusal tells the agent to do.
82
+ uncommittedWork:
83
+ Array.isArray(files) && files.length === 0
84
+ ? dirtyProbeFn({ branch: headRef, cwd, gitFn })
85
+ : false,
64
86
  });
65
87
  }
66
88
 
89
+ /**
90
+ * Is this refusal the one that is NOT about scope?
91
+ *
92
+ * @param {{ refusalClass?: string|null }} result
93
+ * @returns {boolean}
94
+ */
95
+ function isUncommittedRefusal(result) {
96
+ return result.refusalClass === LIGHT_REFUSAL_CLASSES.UNCOMMITTED_WORK;
97
+ }
98
+
99
+ /**
100
+ * Close the refusal log line: what became of the work, and what to run next.
101
+ *
102
+ * A `null` preservation is the uncommitted-work refusal by construction — that
103
+ * is the one path that does not push, because there is nothing a push could
104
+ * preserve: it would publish a branch at its base and then report uncommitted
105
+ * work as safe on `origin`, which is the opposite of true.
106
+ *
107
+ * @param {{
108
+ * storyId: number,
109
+ * preservation: { detail: string }|null,
110
+ * nextCommand: string,
111
+ * }} args
112
+ * @returns {string}
113
+ */
114
+ function describeBlockedTail({ storyId, preservation, nextCommand }) {
115
+ return preservation === null
116
+ ? `nothing is committed yet, so there is no work to preserve; ` +
117
+ `commit on story-${storyId}, then re-run: "${nextCommand}"`
118
+ : `${preservation.detail}; recycle the receipt with "${nextCommand}"`;
119
+ }
120
+
67
121
  /**
68
122
  * Resolve the backstop pass into everything the CLI needs to print and exit
69
123
  * with: the verdict, the recycle command on a refusal (`null` when clean), the
@@ -82,8 +136,8 @@ function runDiffBackstop({
82
136
  * handleBlockedFn?: typeof handleBlockedBackstop,
83
137
  * preserveFn?: typeof preserveRefusedWork,
84
138
  * }} args Any further keys (`baseRef`, `cwd`, `computeFn`, `readRowsFn`,
85
- * `injectedRules`) forward to the backstop run, so the git-surface join is
86
- * drivable through this one entry point.
139
+ * `dirtyProbeFn`, `gitFn`, `injectedRules`) forward to the backstop run, so
140
+ * the git-surface join is drivable through this one entry point.
87
141
  * @returns {Promise<{
88
142
  * result: ReturnType<typeof checkLightDiffBackstop>,
89
143
  * nextCommand: string|null,
@@ -109,7 +163,9 @@ export async function resolveBackstopOutcome({
109
163
  message: `[deliver-light] diff backstop clean for Story #${storyId}.`,
110
164
  };
111
165
  }
112
- const preservation = preserveFn({ storyId, cwd: seams.cwd });
166
+ const preservation = isUncommittedRefusal(result)
167
+ ? null
168
+ : preserveFn({ storyId, cwd: seams.cwd });
113
169
  const nextCommand = await handleBlockedFn({ storyId, result, preservation });
114
170
  return {
115
171
  result,
@@ -118,7 +174,7 @@ export async function resolveBackstopOutcome({
118
174
  exitCode: EXIT_BACKSTOP_BLOCKED,
119
175
  message:
120
176
  `[deliver-light] diff backstop BLOCKED Story #${storyId}: ` +
121
- `${result.reasons.join('; ')} — ${preservation.detail}; ` +
122
- `recycle the receipt with "${nextCommand}"`,
177
+ `${result.reasons.join('; ')} — ` +
178
+ describeBlockedTail({ storyId, preservation, nextCommand }),
123
179
  };
124
180
  }
@@ -41,8 +41,10 @@
41
41
  import { getStoryBranch, gitSpawn } from '../git-utils.js';
42
42
  import {
43
43
  emitRuntimeFriction,
44
+ lightScopeRejectedCategory,
44
45
  RUNTIME_FRICTION_CATEGORIES,
45
46
  } from '../observability/runtime-friction.js';
47
+ import { LIGHT_REFUSAL_CLASSES } from './light-suitability.js';
46
48
 
47
49
  /**
48
50
  * The `/mandrel-plan` invocation that owns a Story the light path could not land.
@@ -54,6 +56,21 @@ function buildRecycleCommand(storyId) {
54
56
  return `/mandrel-plan ${storyId}`;
55
57
  }
56
58
 
59
+ /**
60
+ * The command that re-runs the backstop once the work is committed.
61
+ *
62
+ * The one refusal that is NOT about scope gets its own next step: an empty diff
63
+ * over a dirty worktree means the backstop ran before the commit, and handing
64
+ * that run to `/mandrel-plan` recycles a receipt whose implementation is fine
65
+ * — the wrong door, dressed as an escalation (issue #5237).
66
+ *
67
+ * @param {number} storyId
68
+ * @returns {string}
69
+ */
70
+ function buildRerunBackstopCommand(storyId) {
71
+ return `node .agents/scripts/deliver-light.js --backstop --story ${storyId}`;
72
+ }
73
+
57
74
  /**
58
75
  * Coerce an `--amends` argument (`#123` or `123`) into a positive integer issue
59
76
  * number, or `null` when absent/malformed.
@@ -127,10 +144,12 @@ export async function handleBlockedBackstop({
127
144
  emitFn,
128
145
  recordFrictionFn = recordScopeFriction,
129
146
  } = {}) {
147
+ const refusalClass = result?.refusalClass ?? null;
130
148
  await recordFrictionFn({
131
149
  emitFn,
132
150
  storyId,
133
151
  surface: 'diff-backstop',
152
+ category: lightScopeRejectedCategory(refusalClass),
134
153
  reasons: result?.reasons ?? [],
135
154
  details: {
136
155
  fileCount: result?.fileCount ?? null,
@@ -142,9 +161,12 @@ export async function handleBlockedBackstop({
142
161
  // than one whose branch reached origin — the roll-up must be able to
143
162
  // tell them apart.
144
163
  preserved: preservation?.preserved ?? null,
164
+ refusalClass,
145
165
  },
146
166
  });
147
- return buildRecycleCommand(storyId);
167
+ return refusalClass === LIGHT_REFUSAL_CLASSES.UNCOMMITTED_WORK
168
+ ? buildRerunBackstopCommand(storyId)
169
+ : buildRecycleCommand(storyId);
148
170
  }
149
171
 
150
172
  /**
@@ -221,15 +243,19 @@ export function preserveRefusedWork({
221
243
  * @param {{
222
244
  * storyId?: number|null,
223
245
  * surface: string,
246
+ * category?: string,
224
247
  * reasons?: string[],
225
248
  * details?: object,
226
249
  * emitFn?: typeof emitRuntimeFriction,
227
- * }} args
250
+ * }} args `category` defaults to the unclassified light-refusal bucket, which
251
+ * is what the suitability gate emits: it refuses a prompt before any diff
252
+ * exists, so it carries no refusal class to encode.
228
253
  * @returns {Promise<boolean>}
229
254
  */
230
255
  async function recordScopeFriction({
231
256
  storyId,
232
257
  surface,
258
+ category = RUNTIME_FRICTION_CATEGORIES.LIGHT_SCOPE_REJECTED,
233
259
  reasons = [],
234
260
  details = {},
235
261
  emitFn,
@@ -238,7 +264,7 @@ async function recordScopeFriction({
238
264
  try {
239
265
  return await emit({
240
266
  storyId,
241
- category: RUNTIME_FRICTION_CATEGORIES.LIGHT_SCOPE_REJECTED,
267
+ category,
242
268
  tool: 'deliver-light',
243
269
  details: { surface, reasons, ...details },
244
270
  });
@@ -524,6 +524,52 @@ export function resolveLightGateOutcome({
524
524
  };
525
525
  }
526
526
 
527
+ /**
528
+ * The refusal classes a blocked diff backstop can carry — the machine-readable
529
+ * half of a verdict whose `reasons[]` are prose (Story #5238).
530
+ *
531
+ * One value per blocked verdict, and the reason it exists is downstream: the
532
+ * refusal's friction category is derived from it
533
+ * ({@link module:lib/observability/runtime-friction.lightScopeRejectedCategory}),
534
+ * and the category is the ONLY key the retro composer separates buckets on.
535
+ * Under one bare category an empty-diff refusal and a `public-api` refusal
536
+ * aggregated into a single "recurred 2 times" follow-up with nothing in common
537
+ * (issue #5237) — the roll-up's shape fingerprint could not tell them apart
538
+ * either, because it hashes detail keys and every refusal carries the same set.
539
+ *
540
+ * Kept coarse on purpose: a class must be stable enough that N refusals of one
541
+ * cause still coalesce into the recurrence evidence the ceilings are
542
+ * recalibrated from.
543
+ *
544
+ * @typedef {{ reason: string, refusalClass: string }} Objection
545
+ */
546
+ export const LIGHT_REFUSAL_CLASSES = Object.freeze({
547
+ /** The diff could not be enumerated, or enumerated to nothing. */
548
+ CHANGE_SET_UNKNOWN: 'change-set-unknown',
549
+ /** Enumerated-empty, but the worktree carries uncommitted changes. */
550
+ UNCOMMITTED_WORK: 'uncommitted-work',
551
+ /** The change set intersects a registered sensitive-path class. */
552
+ SENSITIVE_PATH: 'sensitive-path',
553
+ /** Sensitivity could not be classified, so non-sensitivity is unproven. */
554
+ SENSITIVITY_UNKNOWN: 'sensitivity-unknown',
555
+ /** The implementation magnitude could not be measured. */
556
+ MAGNITUDE_UNKNOWN: 'magnitude-unknown',
557
+ /** Measured magnitude exceeded a light ceiling. */
558
+ OVER_CEILING: 'over-ceiling',
559
+ });
560
+
561
+ /**
562
+ * Name the branch a commit-first refusal tells the agent to commit on, with a
563
+ * generic stand-in when the caller supplied none. Pure.
564
+ *
565
+ * @param {unknown} storyBranch
566
+ * @returns {string}
567
+ */
568
+ function describeStoryBranch(storyBranch) {
569
+ const name = typeof storyBranch === 'string' ? storyBranch.trim() : '';
570
+ return name === '' ? 'the Story branch' : name;
571
+ }
572
+
527
573
  /**
528
574
  * Diff-derived backstop (Story #4740 AC-4, re-based on magnitude by Story
529
575
  * #4856): re-check the **actual** change set after implementation, because the
@@ -555,7 +601,14 @@ export function resolveLightGateOutcome({
555
601
  * ceilings?: { maxImplLines?: number, maxImplFiles?: number },
556
602
  * injectedRules?: object,
557
603
  * selectSensitivePathClassesFn?: Function,
558
- * }} [args]
604
+ * storyBranch?: string,
605
+ * uncommittedWork?: boolean,
606
+ * }} [args] `uncommittedWork` is the caller's dirty-worktree probe result: the
607
+ * backstop reads COMMITTED state, so an implemented-but-uncommitted run
608
+ * measures an empty diff, and the door for that is `git commit` — not an
609
+ * escalation. It only ever refines an enumerated-empty verdict's guidance;
610
+ * the verdict itself still blocks. `storyBranch` names the branch that
611
+ * guidance points at.
559
612
  * @returns {{
560
613
  * blocked: boolean,
561
614
  * level: 'low'|'high'|null,
@@ -563,8 +616,10 @@ export function resolveLightGateOutcome({
563
616
  * fileCount: number|null,
564
617
  * magnitude: { implFiles: number, implLines: number }|null,
565
618
  * ceilings: { maxImplLines: number, maxImplFiles: number },
619
+ * refusalClass: string|null,
566
620
  * reasons: string[],
567
- * }}
621
+ * }} `refusalClass` is `null` on a clean verdict and exactly one
622
+ * {@link LIGHT_REFUSAL_CLASSES} value on every blocked one.
568
623
  */
569
624
  export function checkLightDiffBackstop({
570
625
  changedFiles,
@@ -572,6 +627,8 @@ export function checkLightDiffBackstop({
572
627
  ceilings,
573
628
  injectedRules,
574
629
  selectSensitivePathClassesFn,
630
+ storyBranch,
631
+ uncommittedWork = false,
575
632
  } = {}) {
576
633
  const resolved = resolveDiffCeilings(ceilings);
577
634
  const files = Array.isArray(changedFiles)
@@ -579,6 +636,12 @@ export function checkLightDiffBackstop({
579
636
  : null;
580
637
 
581
638
  if (files === null || files.length === 0) {
639
+ // An ENUMERATED-empty diff over a dirty worktree is a different event from
640
+ // an unverifiable one, and blocking is right for both — but only one of
641
+ // them is about scope. The caller's probe distinguishes them; `files ===
642
+ // null` never can, because a `git diff` that failed outright is exactly
643
+ // the case where nothing about the change is known.
644
+ const uncommitted = files !== null && uncommittedWork === true;
582
645
  return {
583
646
  blocked: true,
584
647
  level: null,
@@ -586,8 +649,13 @@ export function checkLightDiffBackstop({
586
649
  fileCount: files === null ? null : 0,
587
650
  magnitude: null,
588
651
  ceilings: resolved,
652
+ refusalClass: uncommitted
653
+ ? LIGHT_REFUSAL_CLASSES.UNCOMMITTED_WORK
654
+ : LIGHT_REFUSAL_CLASSES.CHANGE_SET_UNKNOWN,
589
655
  reasons: [
590
- 'actual change set is unknown or empty — cannot verify the diff is light; escalate to /mandrel-plan',
656
+ uncommitted
657
+ ? `the change set is empty but the worktree has uncommitted changes — commit them on ${describeStoryBranch(storyBranch)}, then re-run the backstop; nothing here is over-scope, so do NOT escalate to /mandrel-plan`
658
+ : 'actual change set is unknown or empty — cannot verify the diff is light; escalate to /mandrel-plan',
591
659
  ],
592
660
  };
593
661
  }
@@ -599,12 +667,12 @@ export function checkLightDiffBackstop({
599
667
  });
600
668
  const measured = normalizeMagnitude(magnitude);
601
669
 
602
- const reasons = [
670
+ const objections = [
603
671
  ...describeSensitivity({ level, classes }),
604
672
  ...describeMagnitude(measured, resolved),
605
673
  ];
606
674
 
607
- const blocked = reasons.length > 0;
675
+ const blocked = objections.length > 0;
608
676
  return {
609
677
  blocked,
610
678
  level,
@@ -612,8 +680,13 @@ export function checkLightDiffBackstop({
612
680
  fileCount: files.length,
613
681
  magnitude: measured,
614
682
  ceilings: resolved,
683
+ // Objection ORDER is the class precedence: sensitivity is derived before
684
+ // magnitude, so a diff that is both sensitive and over-ceiling files as a
685
+ // sensitive-path refusal. That is the right way round — the ceiling is
686
+ // recalibratable, the sensitive path is not.
687
+ refusalClass: blocked ? objections[0].refusalClass : null,
615
688
  reasons: blocked
616
- ? reasons
689
+ ? objections.map((objection) => objection.reason)
617
690
  : [
618
691
  `diff is light: ${measured.implLines} implementation line(s) ≤ ${resolved.maxImplLines} ` +
619
692
  `across ${measured.implFiles} implementation file(s) ≤ ${resolved.maxImplFiles} ` +
@@ -641,17 +714,24 @@ function normalizeMagnitude(magnitude) {
641
714
  * Sensitivity objections, over the **full** change set. Pure.
642
715
  *
643
716
  * @param {{ level: 'low'|'high'|null, classes: string[] }} derived
644
- * @returns {string[]}
717
+ * @returns {Objection[]}
645
718
  */
646
719
  function describeSensitivity({ level, classes }) {
647
720
  if (classes.length > 0) {
648
721
  return [
649
- `diff intersects sensitive-path class(es) ${classes.join(', ')} — escalate to /mandrel-plan (do not land light)`,
722
+ {
723
+ refusalClass: LIGHT_REFUSAL_CLASSES.SENSITIVE_PATH,
724
+ reason: `diff intersects sensitive-path class(es) ${classes.join(', ')} — escalate to /mandrel-plan (do not land light)`,
725
+ },
650
726
  ];
651
727
  }
652
728
  if (level !== 'low') {
653
729
  return [
654
- 'sensitive-path classification unavailable — cannot verify the diff is non-sensitive; escalate to /mandrel-plan',
730
+ {
731
+ refusalClass: LIGHT_REFUSAL_CLASSES.SENSITIVITY_UNKNOWN,
732
+ reason:
733
+ 'sensitive-path classification unavailable — cannot verify the diff is non-sensitive; escalate to /mandrel-plan',
734
+ },
655
735
  ];
656
736
  }
657
737
  return [];
@@ -662,26 +742,32 @@ function describeSensitivity({ level, classes }) {
662
742
  *
663
743
  * @param {{ implFiles: number, implLines: number }|null} measured
664
744
  * @param {{ maxImplLines: number, maxImplFiles: number }} ceilings
665
- * @returns {string[]}
745
+ * @returns {Objection[]}
666
746
  */
667
747
  function describeMagnitude(measured, ceilings) {
668
748
  if (measured === null) {
669
749
  return [
670
- 'change magnitude could not be measured (unreadable or unparseable numstat) — cannot verify the diff is light; escalate to /mandrel-plan',
750
+ {
751
+ refusalClass: LIGHT_REFUSAL_CLASSES.MAGNITUDE_UNKNOWN,
752
+ reason:
753
+ 'change magnitude could not be measured (unreadable or unparseable numstat) — cannot verify the diff is light; escalate to /mandrel-plan',
754
+ },
671
755
  ];
672
756
  }
673
- const reasons = [];
757
+ const objections = [];
674
758
  if (measured.implLines > ceilings.maxImplLines) {
675
- reasons.push(
676
- `diff changes ${measured.implLines} implementation line(s) (> maxImplLines ${ceilings.maxImplLines}) — escalate to /mandrel-plan (do not land light)`,
677
- );
759
+ objections.push({
760
+ refusalClass: LIGHT_REFUSAL_CLASSES.OVER_CEILING,
761
+ reason: `diff changes ${measured.implLines} implementation line(s) (> maxImplLines ${ceilings.maxImplLines}) — escalate to /mandrel-plan (do not land light)`,
762
+ });
678
763
  }
679
764
  if (measured.implFiles > ceilings.maxImplFiles) {
680
- reasons.push(
681
- `diff spans ${measured.implFiles} implementation file(s) (> maxImplFiles ${ceilings.maxImplFiles}) — escalate to /mandrel-plan (do not land light)`,
682
- );
765
+ objections.push({
766
+ refusalClass: LIGHT_REFUSAL_CLASSES.OVER_CEILING,
767
+ reason: `diff spans ${measured.implFiles} implementation file(s) (> maxImplFiles ${ceilings.maxImplFiles}) — escalate to /mandrel-plan (do not land light)`,
768
+ });
683
769
  }
684
- return reasons;
770
+ return objections;
685
771
  }
686
772
 
687
773
  /** Cap on a receipt slug's length — keep the branch/id readable. */
@@ -305,6 +305,39 @@ function fingerprintBucket(category, tools, detailKeys) {
305
305
  .slice(0, 8);
306
306
  }
307
307
 
308
+ /**
309
+ * Every reason text one signal's `details` carries, read from BOTH shapes the
310
+ * emitters actually write.
311
+ *
312
+ * Two keys because two emitter conventions, and the divergence was silent: the
313
+ * degradation emitters write a singular `details.reason` string, while the
314
+ * light path's refusal emitter (`light-escalation.recordScopeFriction`) writes
315
+ * `details.reasons` — an **array**, because one backstop verdict can object on
316
+ * sensitivity and magnitude in the same pass. Reading only the singular key is
317
+ * why every `light-scope-rejected` follow-up rendered with no `Reason:` line
318
+ * at all (issue #5237), which defeated Story #4837's whole intent for that
319
+ * emitter: the filed issue named a count and a category, and the refusal text
320
+ * that would have told a reader which ceiling fired stayed in the ledger.
321
+ *
322
+ * Non-string members are skipped rather than coerced — `String(value)` would
323
+ * put `[object Object]` in a live issue body.
324
+ *
325
+ * @param {object} details
326
+ * @returns {string[]}
327
+ */
328
+ function collectReasons(details) {
329
+ const out = [];
330
+ const single = asString(details.reason);
331
+ if (single.length > 0) out.push(single);
332
+ if (Array.isArray(details.reasons)) {
333
+ for (const raw of details.reasons) {
334
+ const text = asString(raw);
335
+ if (text.length > 0) out.push(text);
336
+ }
337
+ }
338
+ return out;
339
+ }
340
+
308
341
  /**
309
342
  * Widen a bucket's first-to-last window to include one instant. A row with no
310
343
  * usable `ts` widens nothing — it is counted in `total` but cannot date the
@@ -391,8 +424,9 @@ function aggregateByCategory(signals) {
391
424
  // over keys, deliberately) is untouched by them.
392
425
  const surface = asString(sig.details.surface);
393
426
  if (surface.length > 0) entry.surfaces.add(surface);
394
- const reason = asString(sig.details.reason);
395
- if (reason.length > 0) entry.reasons.add(reason);
427
+ for (const reason of collectReasons(sig.details)) {
428
+ entry.reasons.add(reason);
429
+ }
396
430
  }
397
431
  // An id that cannot be resolved to a real issue is tracked separately
398
432
  // rather than dropped: it must never be counted or printed as recurrence
@@ -0,0 +1,80 @@
1
+ /**
2
+ * lib/orchestration/worktree-dirty.js — "does this branch's checkout carry
3
+ * uncommitted changes?" (Story #5238).
4
+ *
5
+ * Its own module because the light path's diff backstop is otherwise a pure
6
+ * join over two committed-state git reads, and this is the one question there
7
+ * that needs a third read of a *working tree*. Keeping it here leaves
8
+ * {@link module:lib/orchestration/light-backstop} the thin join it claims to
9
+ * be, and gives the probe its own place to be tested against every way a git
10
+ * read can fail.
11
+ *
12
+ * @module lib/orchestration/worktree-dirty
13
+ */
14
+
15
+ import { gitSpawn } from '../git-utils.js';
16
+ import { parseWorktreePorcelain } from '../worktree/inspector.js';
17
+
18
+ /**
19
+ * The stdout of a successful git read, or `null` when it cannot be trusted.
20
+ *
21
+ * @param {{ status?: number, stdout?: unknown }|null|undefined} result
22
+ * @returns {string|null}
23
+ */
24
+ function readableStdout(result) {
25
+ if (result?.status !== 0) return null;
26
+ return typeof result.stdout === 'string' ? result.stdout : null;
27
+ }
28
+
29
+ /**
30
+ * Locate the checkout that has `branch` checked out, or `null` when no
31
+ * checkout does.
32
+ *
33
+ * `git worktree list --porcelain` enumerates **every** checkout including the
34
+ * main one, so a repository working on the branch directly (no separate
35
+ * worktree) is found by this same lookup rather than by a second fallback path.
36
+ *
37
+ * @param {{ branch: string, cwd: string, gitFn: typeof gitSpawn }} args
38
+ * @returns {string|null}
39
+ */
40
+ function resolveBranchCheckout({ branch, cwd, gitFn }) {
41
+ const listed = readableStdout(gitFn(cwd, 'worktree', 'list', '--porcelain'));
42
+ if (listed === null) return null;
43
+ const match = parseWorktreePorcelain(listed).find(
44
+ (record) => record.branch === branch,
45
+ );
46
+ return match ? match.path : null;
47
+ }
48
+
49
+ /**
50
+ * Does the checkout holding `branch` have uncommitted changes?
51
+ *
52
+ * **Why the light path asks.** Its diff backstop measures `base...head`, which
53
+ * is committed state, so a run that implemented and did not commit measures an
54
+ * empty change set — and the refusal then told the agent its scope was
55
+ * unverifiable and to escalate, when the actual fix was `git commit`. Measured
56
+ * in the consumer: the refusal signal is stamped 12:14:06Z and the branch's
57
+ * only commit 12:15:19Z (issue #5237).
58
+ *
59
+ * Total, and deliberately asymmetric: every unreadable surface — a failed
60
+ * `worktree list`, a branch no checkout holds, a failed `status`, a throwing
61
+ * git — answers `false`. A probe that cannot see the tree must not be able to
62
+ * talk a refusal into friendlier guidance than the evidence supports.
63
+ *
64
+ * @param {{ branch: string, cwd?: string, gitFn?: typeof gitSpawn }} args
65
+ * @returns {boolean}
66
+ */
67
+ export function hasUncommittedWork({
68
+ branch,
69
+ cwd = process.cwd(),
70
+ gitFn = gitSpawn,
71
+ } = {}) {
72
+ try {
73
+ const checkout = resolveBranchCheckout({ branch, cwd, gitFn });
74
+ if (checkout === null) return false;
75
+ const status = readableStdout(gitFn(checkout, 'status', '--porcelain'));
76
+ return status !== null && status.trim() !== '';
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
@@ -31,9 +31,9 @@ on a project with no rendered frontend, since there are no components, templates
31
31
  or routes to hold to WCAG. See the `target` key's schema description for how
32
32
  applicability is probed from the consumer's checkout.
33
33
 
34
- ## Boundary with `audit-ux-ui`
34
+ ## Boundaries with the neighbouring web lenses
35
35
 
36
- These two web lenses share a border and must not double-report:
36
+ These lenses share a border with this one and must not double-report:
37
37
 
38
38
  - **`audit-accessibility` (this lens)** owns **WCAG conformance** — the
39
39
  standards question: does an assistive-technology user perceive, operate, and
@@ -41,11 +41,17 @@ These two web lenses share a border and must not double-report:
41
41
  controls, text alternatives, and contrast against the WCAG ratio thresholds.
42
42
  - **`audit-ux-ui`** owns **design-system adherence** — the consistency
43
43
  question: do components and tokens match the project's own design system?
44
-
45
- Contrast is the one axis both can touch: **accessibility owns the WCAG ratio
46
- verdict** (4.5:1 body / 3:1 large text / 3:1 non-text), while ux-ui owns whether
47
- the colour came from a sanctioned token. When a contrast defect is in scope for
48
- both, report the WCAG failure here and leave the token-adherence note to ux-ui.
44
+ - **`audit-mobile`** owns **small-screen and touch behaviour** — layout at a
45
+ phone or tablet viewport, touch ergonomics, responsive assets, and mobile
46
+ test coverage. It may measure a control's rendered size as ergonomic
47
+ evidence, but the `2.5.8 Target Size (Minimum)` verdict on an undersized
48
+ target is reported here.
49
+
50
+ Contrast is the one axis accessibility and ux-ui both touch: **accessibility
51
+ owns the WCAG ratio verdict** (4.5:1 body / 3:1 large text / 3:1 non-text),
52
+ while ux-ui owns whether the colour came from a sanctioned token. When a
53
+ contrast defect is in scope for both, report the WCAG failure here and leave
54
+ the token-adherence note to ux-ui.
49
55
 
50
56
  ## Scope
51
57
 
@@ -0,0 +1,242 @@
1
+ ---
2
+ description: Audit mobile and tablet UX — layout and viewport correctness, touch ergonomics, responsive assets, and whether anything actually verifies them at a small viewport (static-first, with an optional runtime viewport pass)
3
+ ---
4
+
5
+ # Mobile & Tablet UX Audit
6
+
7
+ You are a Senior Mobile Web Engineer holding the frontend to its **small-screen
8
+ and touch contract**: does every surface lay out, scroll, and answer a finger on
9
+ a phone and a tablet — and does anything in the suite actually verify that it
10
+ does? Default to **static** detection over source; escalate to a **runtime**
11
+ viewport pass only when a live target is configured. The shared lens machinery —
12
+ read-only constraint, scope interpretation, report envelope + finding-block
13
+ skeleton, severity scale, self-cross-check, and execution strategy — lives in
14
+ [`helpers/audit-lens-core.md`](helpers/audit-lens-core.md). Write the report to
15
+ `{{auditOutputDir}}/audit-mobile-results.md`. Dimension values:
16
+ `Layout & Viewport | Touch Ergonomics | Responsive Assets | Mobile Verification`.
17
+ Extra finding field: **Evidence:** (`measured | static` + the observable;
18
+ single-run runtime numbers are tagged `provisional`).
19
+ The report adds a **Runtime Viewport Pass** section.
20
+
21
+ > **An emulated viewport is not a device.** Viewport emulation resizes and
22
+ > re-flows the page; it does not reproduce a real device's browser engine,
23
+ > input latency, font rendering, or OS chrome. Report what the emulator
24
+ > observed, never "verified on iPhone".
25
+
26
+ ## Applicability
27
+
28
+ **Web targets only.** Registered with `target: "web"` in
29
+ [`audit-rules.json`](../schemas/audit-rules.json): the selector skips this lens
30
+ on a project with no rendered frontend, since there is no layout to re-flow and
31
+ no control to touch. See the `target` key's schema description for how
32
+ applicability is probed from the consumer's checkout.
33
+
34
+ ## Boundaries with the neighbouring lenses
35
+
36
+ Four lenses border this one. Report a finding **here** only when it is a
37
+ small-screen or touch defect; defer the rest so the suite never double-reports:
38
+
39
+ - [`/audit-accessibility`](audit-accessibility.md) owns every **WCAG
40
+ success-criterion verdict**, including `2.5.8 Target Size (Minimum)`. This
41
+ lens may **measure** a control's rendered size as ergonomic evidence, but the
42
+ conformance verdict on an undersized target belongs there.
43
+ - [`/audit-ux-ui`](audit-ux-ui.md) owns **design-system adherence** — whether a
44
+ value came from a sanctioned token and whether a raw element should have
45
+ deferred to a design-system component. This lens asks only whether the result
46
+ works at a small viewport, whatever its provenance.
47
+ - [`/audit-performance`](audit-performance.md) owns **Core Web Vitals, bundle
48
+ weight, and network cost**, mobile ones included. An oversized hero image is
49
+ reported here only as a missing `srcset`/`sizes` **contract**, never as a
50
+ payload-weight verdict.
51
+ - [`/audit-quality`](audit-quality.md) owns the **generic** test verdicts —
52
+ pyramid balance, flake, and coverage gaps. This lens owns exactly one test
53
+ question: is any of it exercised at a non-desktop viewport, and does that
54
+ exercise assert something mobile-specific (Step 2).
55
+
56
+ ## Scope
57
+
58
+ Interpret this lens's change-set fence per the core's Scope interpretation:
59
+
60
+ ```text
61
+ {{changedFiles}}
62
+ ```
63
+
64
+ ## Execution strategy
65
+
66
+ Run this lens as a single `subagent_type: auditor` dispatch returning the report
67
+ path + Executive Summary; sequential inline execution is the fallback (see the
68
+ core's Execution strategy).
69
+
70
+ ## Step 0: Discover the responsive baseline (run first)
71
+
72
+ **You cannot audit responsiveness against a generic ideal — a 640px fixed width
73
+ is a defect only relative to the breakpoints the project actually claims to
74
+ support.** Before any detection, locate this lens's own sources of truth (they
75
+ are *not* ux-ui's design system, though they often live beside it) and record
76
+ what they declare:
77
+
78
+ - **Breakpoint scale:** the `screens` map in `tailwind.config.{js,ts}`, CSS
79
+ custom media / container queries, a `breakpoints` token file, or a
80
+ CSS-in-JS theme's media helpers. Census the `@media` / `container` queries
81
+ actually used in the stylesheets and note the narrowest one — that is the
82
+ smallest width the project has any evidence of supporting.
83
+ - **Viewport contract:** the `<meta name="viewport">` tag (or the framework
84
+ `viewport` export) and whether it sets `width=device-width` and leaves user
85
+ scaling enabled.
86
+ - **Declared device matrix:** any non-desktop viewport already configured in the
87
+ consumer's test tooling — Playwright `projects[]` using `devices[...]` or an
88
+ explicit `viewport`, a Cypress `viewportWidth`/`viewportHeight`, a
89
+ visual-regression viewport list. This is the project's own statement of which
90
+ form factors it holds itself to.
91
+ - **Runtime target (optional):** the `qa.environments` map (see
92
+ [*Runtime viewport pass*](#step-3-runtime-viewport-pass-optional-corroboration))
93
+ and the navigability route SSOT.
94
+
95
+ Record the breakpoints, the viewport contract, and the device matrix. Every
96
+ finding downstream is measured against *this discovered baseline*. If the
97
+ project declares **no** breakpoint scale and no device matrix, say so and
98
+ downgrade findings to "no responsive baseline declared — recommend establishing
99
+ a breakpoint scale and a phone/tablet test viewport first" rather than scoring
100
+ the tree against an invented one.
101
+
102
+ ## Step 1: Static detection, then triage
103
+
104
+ Run the **mechanical detectors first** (cheap, deterministic greps over the
105
+ in-scope styles and components), then apply **LLM triage** to each candidate
106
+ against the Step 0 baseline — a mechanical hit is a *candidate*, not
107
+ automatically a finding.
108
+
109
+ ### Layout & Viewport
110
+
111
+ - **Viewport meta:** absent `<meta name="viewport">`, a missing
112
+ `width=device-width`, or `user-scalable=no` / `maximum-scale=1` pinning the
113
+ page against pinch-zoom.
114
+ - **Fixed dimensions:** `width`/`min-width`/`height` px literals wider than the
115
+ narrowest declared breakpoint, outside token and container-query files — the
116
+ classic source of a page that cannot shrink.
117
+ - **Viewport-height units:** `100vh` (or `vh` arithmetic) with no `dvh`/`svh`
118
+ fallback, which cuts content off under a mobile browser's collapsing toolbar.
119
+ - **Horizontal overflow:** unconstrained wide content — tables, `<pre>` blocks,
120
+ code fences, flex rows with no `min-width: 0`, absolutely-positioned elements
121
+ extending past the viewport — and any `overflow-x: visible` on a container
122
+ holding them. A page whose body scrolls sideways on a phone is a defect
123
+ regardless of its cause.
124
+ - **Safe-area insets:** `position: fixed`/`sticky` elements pinned to a screen
125
+ edge (bottom bars, floating actions, drawers, modals) with no
126
+ `env(safe-area-inset-*)` allowance, so a notch or home indicator overlaps
127
+ them.
128
+
129
+ ### Touch Ergonomics
130
+
131
+ - **Hover-only interaction:** a `:hover`/`hover:` state that reveals content or
132
+ is the only affordance for an action, with no touch-reachable equivalent
133
+ (a click/tap handler, a focus state, or an always-visible control). On a
134
+ touch device that interaction does not exist.
135
+ - **Control size and spacing:** interactive controls whose rendered box is
136
+ visibly under ~44×44 CSS px, or adjacent tap targets with no separating
137
+ spacing. Report the **measurement** as ergonomic evidence and leave the
138
+ WCAG `2.5.8` verdict to the accessibility lens.
139
+ - **Input ergonomics:** form inputs with a font-size under 16px (iOS Safari
140
+ zooms the whole page on focus), a missing or wrong `inputmode`/`type` for the
141
+ expected keyboard (numeric, email, tel), and `autocomplete` omitted on
142
+ identity or address fields where a small-screen user most needs it.
143
+ - **Gesture conflicts:** custom swipe/drag handlers that call
144
+ `preventDefault()` on `touchstart`/`touchmove` across a scrollable region, or
145
+ scroll containers nested inside a horizontal pager, which strand the user's
146
+ scroll.
147
+
148
+ ### Responsive Assets
149
+
150
+ - **Images:** `<img>` with no `srcset`/`sizes` (or a framework image component
151
+ bypassed for a raw tag) where the same file serves every width; a missing
152
+ intrinsic `width`/`height` or `aspect-ratio`, which shifts the layout as
153
+ images land.
154
+ - **Media and embeds:** `<video>`, `<iframe>`, and map/chart embeds with fixed
155
+ pixel dimensions or no responsive container.
156
+ - **Typography and spacing:** a type or spacing scale with no small-viewport
157
+ step, so a desktop-tuned heading dominates a phone screen.
158
+
159
+ > **Detector output is candidates.** Triage each against the Step 0 baseline
160
+ > before promoting it to a finding — a fixed width inside a design-system
161
+ > primitive that its container query already re-flows, a `100vh` on a
162
+ > deliberately desktop-only admin surface, or a raw `<img>` for a fixed-size
163
+ > icon, is expected, not a defect.
164
+
165
+ ## Step 2: Mobile verification coverage & effectiveness
166
+
167
+ A responsive surface with nothing holding it responsive regresses on the next
168
+ change. This lens owns that one test question, in two parts — report them as
169
+ separate findings, because the fixes differ:
170
+
171
+ 1. **Coverage — is anything exercised at a non-desktop viewport?** Reconcile the
172
+ Step 0 device matrix against the suites that exist: an e2e config with only a
173
+ desktop project, a visual-regression suite with a single wide snapshot width,
174
+ or Gherkin features with no phone/tablet variant all mean the responsive
175
+ behaviour is unverified. Name the specific surfaces in scope that no
176
+ non-desktop run touches.
177
+ 2. **Effectiveness — does that exercise assert anything mobile-specific?** A
178
+ suite that merely replays its desktop assertions at 390px wide proves the
179
+ page renders, not that it works. Look for assertions that could only pass on
180
+ a small viewport: the drawer or hamburger nav opening in place of the desktop
181
+ bar, the absence of horizontal document scroll, a bottom bar clearing the
182
+ safe area, an orientation change, a swipe or long-press gesture, a
183
+ viewport-conditional element being hidden or shown. A mobile project whose
184
+ assertions are viewport-agnostic is a **false-confidence** finding: it is
185
+ reported even though the suite is green, and it is usually more valuable than
186
+ a missing-coverage finding, because the project believes it is covered.
187
+
188
+ Keep the generic test verdicts out of this step — pyramid balance, flake, and
189
+ overall coverage gaps belong to [`/audit-quality`](audit-quality.md). Where a
190
+ fix is a new test, name the viewport and the assertion it should make, not just
191
+ "add mobile tests".
192
+
193
+ ## Step 3: Runtime viewport pass (optional corroboration)
194
+
195
+ Static detection is the default and always runs. The runtime pass is
196
+ **conditional** — it runs only when a live target is configured; its absence
197
+ never blocks the static report.
198
+
199
+ 1. **Resolve the target from config — never a hardcoded URL.** Resolve the
200
+ target through the consumer's `qa.environments.<env>.baseUrl` (via
201
+ [`resolveQaEnvironment`](../scripts/lib/qa/resolve-qa-contract.js), the same
202
+ resolver `/qa-run` uses): an `<env>` argument resolves by exact name or
203
+ origin match; with no argument, enumerate `name → baseUrl` and let the
204
+ operator pick. If **no** `qa.environments` target is configured, **skip this
205
+ step** and note in the report that runtime corroboration was unavailable —
206
+ do not invent a URL and do not start an arbitrary dev server.
207
+ 2. **Sample routes from the navigability SSOT.** Draw the routes to exercise
208
+ from the consumer's route/nav registry (`planning.navigation.navRegistry` /
209
+ `routeGlobs` — the same SSOT [`/audit-navigability`](audit-navigability.md)
210
+ reads), sampling a representative set (key personas' landing routes plus any
211
+ route in the change-set scope) rather than a single hardcoded page.
212
+ 3. **Drive two form factors per route.** Emulate a **phone** and a **tablet**
213
+ viewport — `mcp__chrome-devtools__emulate` for a device profile, or
214
+ `resize_page` for an explicit width/height — then, per route and viewport:
215
+ take a screenshot, and evaluate the two observables static analysis cannot
216
+ resolve — whether `document.scrollingElement.scrollWidth` exceeds the
217
+ viewport width (horizontal overflow), and the rendered box of the
218
+ interactive controls Step 1 flagged as candidates. Reload after switching
219
+ form factor so load-time device gates re-run.
220
+ 4. **Median-of-3 or provisional.** Any runtime measurement is subject to
221
+ run-to-run variance: capture a **median-of-3** (three runs per route, report
222
+ the median) before treating a number as authoritative. A single-run value is
223
+ reported **provisional** and never drives a Critical/High verdict on its own.
224
+ 5. **Leave the viewport as you found it.** Reset the emulation before finishing
225
+ so a following lens or QA run does not inherit a phone viewport.
226
+
227
+ Corroborate static findings against the runtime observations (a statically
228
+ flagged fixed width confirmed by a real horizontal overflow graduates from
229
+ provisional to confirmed), and surface runtime-only defects the static pass
230
+ could not see — an element clipped only once the toolbar collapses, a drawer
231
+ that opens off-screen.
232
+
233
+ ## Report additions
234
+
235
+ Beyond the shared skeleton, the Executive Summary states the runtime mode's
236
+ status (ran against `<env>` / skipped — no target configured) and names the
237
+ narrowest breakpoint the project declares, so a reader can tell what "mobile"
238
+ meant for this run. The report ends with a **Runtime Viewport Pass** section:
239
+ per-route, per-form-factor observations when the runtime mode ran, or
240
+ "*Runtime corroboration unavailable — no `qa.environments` target configured.*"
241
+ Drop every claimed finding that names no concrete element, style rule, or test
242
+ file.
@@ -89,12 +89,17 @@ baseline — a mechanical hit is a *candidate*, not automatically a finding.
89
89
  2. **Error States:** Are form errors clear and helpful, or generic and
90
90
  frustrating?
91
91
  3. **Loading States:** Are there skeletons or spinners for async operations?
92
- 4. **Responsiveness:** Check layouts at mobile, tablet, and desktop breakpoints.
93
- 5. **Accessibility (UX-focused):** Focus on tab order, touch-target sizes, and
94
- whether interaction colours come from a sanctioned token. **WCAG conformance
95
- is out of scope here** — semantic structure, ARIA correctness,
96
- keyboard/focus operability, form labelling, media alternatives, and the WCAG
97
- contrast-ratio verdict are owned by [`/audit-accessibility`](audit-accessibility.md).
92
+ 4. **Accessibility (UX-focused):** Focus on tab order and whether interaction
93
+ colours come from a sanctioned token. **WCAG conformance is out of scope
94
+ here** — semantic structure, ARIA correctness, keyboard/focus operability,
95
+ form labelling, media alternatives, and the WCAG contrast-ratio verdict are
96
+ owned by [`/audit-accessibility`](audit-accessibility.md).
98
97
  This lens keeps token/component design-system adherence; defer every WCAG
99
98
  success-criterion judgement to the accessibility lens so the two never
100
99
  double-report.
100
+
101
+ > **Small-screen behaviour is out of scope here too.** Layout at a phone or
102
+ > tablet viewport, touch-target ergonomics, responsive assets, and whether any
103
+ > suite exercises a non-desktop viewport belong to [`/audit-mobile`](audit-mobile.md).
104
+ > This lens keeps the token/component question at whatever viewport the surface
105
+ > renders.
@@ -153,13 +153,19 @@ answer).
153
153
  number that then rejects the change. They are **not** exempt from
154
154
  sensitive-path matching, which runs over the full change set.
155
155
 
156
- Exit `3` (`blocked: true`) means the diff exceeds a light ceiling or touches a
157
- sensitive-path class. STOP, flip `agent::blocked`, and **recycle the receipt**
158
- through the envelope's `nextCommand` (`/mandrel-plan <storyId>`) — tickets mode
159
- rewrites it into properly-planned Stories and closes it as superseded. Do not
160
- land, and do not leave the receipt open with no successor: it already carries
161
- the branch, the worktree, and the implementation, all of which are evidence
162
- the plan should read.
156
+ **Commit before you run it.** The backstop measures **committed** state, so
157
+ a run that implemented but has not committed measures an empty diff and is
158
+ refused for a scope it never had. That refusal names the real fix — commit
159
+ on `story-<id>`, then re-run the backstop — and its `nextCommand` is that
160
+ re-run, not an escalation.
161
+
162
+ Exit `3` (`blocked: true`) otherwise means the diff exceeds a light ceiling or
163
+ touches a sensitive-path class. STOP, flip `agent::blocked`, and **recycle the
164
+ receipt** through the envelope's `nextCommand` (`/mandrel-plan <storyId>`) —
165
+ tickets mode rewrites it into properly-planned Stories and closes it as
166
+ superseded. Do not land, and do not leave the receipt open with no successor:
167
+ it already carries the branch, the worktree, and the implementation, all of
168
+ which are evidence the plan should read.
163
169
 
164
170
  5. **Close and land (same engine).** Exactly [`/mandrel-deliver`](../mandrel-deliver.md)'s close:
165
171
 
package/docs/CHANGELOG.md CHANGED
@@ -15,6 +15,20 @@ All notable changes to this project will be documented in this file.
15
15
  -->
16
16
  <!-- markdownlint-disable-file MD004 MD012 MD037 -->
17
17
 
18
+ ## [2.52.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.51.0...mandrel-v2.52.0) (2026-09-08)
19
+
20
+
21
+ ### Fixed
22
+
23
+ * render deliver-light refusal reasons in retro follow-ups and file each backstop refusal class as its own friction category ([#5238](https://github.com/dsj1984/mandrel/issues/5238)) ([#5239](https://github.com/dsj1984/mandrel/issues/5239)) ([65ff0ff](https://github.com/dsj1984/mandrel/commit/65ff0ff4252ddc4fe34d4d8453159a80afae6223))
24
+
25
+ ## [2.51.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.50.0...mandrel-v2.51.0) (2026-09-08)
26
+
27
+
28
+ ### Added
29
+
30
+ * **audit:** add the /audit-mobile lens for mobile and tablet UX (refs [#5233](https://github.com/dsj1984/mandrel/issues/5233)) ([#5234](https://github.com/dsj1984/mandrel/issues/5234)) ([eccd505](https://github.com/dsj1984/mandrel/commit/eccd50527f04a25e02d50f57e1c6d760345bc9e8))
31
+
18
32
  ## [2.50.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.49.0...mandrel-v2.50.0) (2026-09-08)
19
33
 
20
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.50.0",
3
+ "version": "2.52.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",