chati-dev 4.2.1 → 4.3.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 (222) hide show
  1. package/README.md +80 -53
  2. package/bin/chati.js +150 -5
  3. package/framework/agents/build/dev.md +509 -76
  4. package/framework/agents/deploy/devops.md +40 -25
  5. package/framework/agents/discover/brief.md +156 -22
  6. package/framework/agents/discover/brownfield-wu.md +24 -14
  7. package/framework/agents/discover/greenfield-wu.md +100 -14
  8. package/framework/agents/plan/architect-data-engineer.md +6 -6
  9. package/framework/agents/plan/architect-system.md +46 -12
  10. package/framework/agents/plan/architect.md +40 -20
  11. package/framework/agents/plan/detail.md +36 -24
  12. package/framework/agents/plan/phases.md +36 -26
  13. package/framework/agents/plan/tasks.md +114 -33
  14. package/framework/agents/plan/ux-brand-architect.md +240 -8
  15. package/framework/agents/plan/ux-component-engineer.md +28 -13
  16. package/framework/agents/plan/ux-researcher.md +7 -6
  17. package/framework/agents/plan/ux.md +55 -22
  18. package/framework/agents/quality/qa-implementation.md +143 -74
  19. package/framework/agents/quality/qa-planning.md +115 -42
  20. package/framework/agents/quality/qa-visual.md +439 -0
  21. package/framework/agents/shared/visualizer.md +128 -0
  22. package/framework/config.yaml +8 -7
  23. package/framework/constitution.md +127 -43
  24. package/framework/context/governance.md +12 -7
  25. package/framework/context/quality.md +6 -5
  26. package/framework/context/root.md +6 -6
  27. package/framework/data/entity-registry.yaml +377 -4
  28. package/framework/data/model-limits.json +19 -0
  29. package/framework/domains/agents/qa-visual.yaml +74 -0
  30. package/framework/domains/constitution.yaml +46 -2
  31. package/framework/domains/workflows/greenfield-fullstack.yaml +2 -2
  32. package/framework/hooks/advance-trigger.js +131 -0
  33. package/framework/hooks/brief-validator.js +83 -0
  34. package/framework/hooks/constitution-guard.js +91 -36
  35. package/framework/hooks/license-guard.js +129 -190
  36. package/framework/hooks/mode-governance.js +68 -16
  37. package/framework/hooks/model-governance.js +19 -8
  38. package/framework/hooks/package.json +3 -0
  39. package/framework/hooks/post-dev.js +207 -0
  40. package/framework/hooks/prism-engine.js +288 -105
  41. package/framework/hooks/read-protection.js +37 -9
  42. package/framework/hooks/reasoning-escalator.js +371 -0
  43. package/framework/hooks/reference-trigger.js +117 -0
  44. package/framework/hooks/session-digest.js +94 -20
  45. package/framework/hooks/settings.json +32 -1
  46. package/framework/hooks/style-guard.js +52 -13
  47. package/framework/hooks/team-quality-gate.js +58 -25
  48. package/framework/hooks/undercover-guard.js +34 -13
  49. package/framework/i18n/en.yaml +3 -3
  50. package/framework/i18n/es.yaml +3 -3
  51. package/framework/i18n/fr.yaml +3 -3
  52. package/framework/i18n/pt.yaml +3 -3
  53. package/framework/intelligence/context-engine.md +4 -5
  54. package/framework/intelligence/decision-engine.md +1 -1
  55. package/framework/intelligence/hooks-performance.md +3 -3
  56. package/framework/migrations/v1.0-to-v1.1.yaml +1 -1
  57. package/framework/migrations/v1.4-to-v2.0.yaml +11 -11
  58. package/framework/migrations/v4.0-to-v4.1.yaml +2 -2
  59. package/framework/migrations/v4.2-to-v4.3.yaml +29 -0
  60. package/framework/orchestrator/chati-router.js +387 -0
  61. package/framework/orchestrator/chati-update.md +40 -40
  62. package/framework/orchestrator/chati.md +318 -115
  63. package/framework/scaffold/motion-premium/README.md +89 -0
  64. package/framework/scaffold/motion-premium/app/globals.css.template +400 -0
  65. package/framework/scaffold/motion-premium/app/layout.tsx.template +110 -0
  66. package/framework/scaffold/motion-premium/components/animation/BackgroundCrossfadeProvider.tsx.template +170 -0
  67. package/framework/scaffold/motion-premium/components/animation/LenisProvider.tsx.template +49 -0
  68. package/framework/scaffold/motion-premium/components/animation/PageTransitionWrapper.tsx.template +83 -0
  69. package/framework/scaffold/motion-premium/components/animation/Preloader.tsx.template +171 -0
  70. package/framework/scaffold/motion-premium/components/ui/Container.tsx.template +69 -0
  71. package/framework/scaffold/motion-premium/components/ui/PageSection.tsx.template +74 -0
  72. package/framework/scaffold/motion-premium/lib/animations/gsap.ts.template +112 -0
  73. package/framework/scaffold/motion-premium/lib/animations/refreshCoordinator.ts.template +75 -0
  74. package/framework/scaffold/motion-premium/lib/animations/tokens.ts.template +119 -0
  75. package/framework/scaffold/motion-premium/lib/animations/useGsapContext.ts.template +92 -0
  76. package/framework/scaffold/motion-premium/lib/animations/useScrollSnapStepper.ts.template +265 -0
  77. package/framework/scaffold/motion-premium/lib/animations/useSmoothScroll.ts.template +67 -0
  78. package/framework/scaffold/motion-premium/lib/brand.ts.template +43 -0
  79. package/framework/scaffold/motion-premium/scaffold.yaml +174 -0
  80. package/framework/scaffold/motion-premium-3d/README.md +80 -0
  81. package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/ScrollDemoCanvas.tsx.template +81 -0
  82. package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/ScrollDemoClient.tsx.template +75 -0
  83. package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/page.tsx.template +26 -0
  84. package/framework/scaffold/motion-premium-3d/components/3d/CameraRig.tsx.template +100 -0
  85. package/framework/scaffold/motion-premium-3d/components/3d/CanvasProvider.tsx.template +85 -0
  86. package/framework/scaffold/motion-premium-3d/components/3d/InvalidateOnScroll.tsx.template +51 -0
  87. package/framework/scaffold/motion-premium-3d/components/3d/MeshCrossfade.tsx.template +79 -0
  88. package/framework/scaffold/motion-premium-3d/components/3d/ScrollCrossfade.tsx.template +88 -0
  89. package/framework/scaffold/motion-premium-3d/components/3d/ScrollScene.tsx.template +121 -0
  90. package/framework/scaffold/motion-premium-3d/components/webgl/SceneFallback.tsx.template +65 -0
  91. package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +68 -0
  92. package/framework/scaffold/motion-premium-3d/lib/webgl/detect.ts.template +69 -0
  93. package/framework/scaffold/motion-premium-3d/scaffold.yaml +133 -0
  94. package/framework/schemas/session.schema.json +109 -21
  95. package/framework/scripts/reference-capture.js +430 -0
  96. package/framework/scripts/visual-qa.js +674 -0
  97. package/framework/tasks/orchestrator-handoff.md +1 -1
  98. package/framework/tasks/orchestrator-resume.md +1 -1
  99. package/framework/tasks/orchestrator-route.md +1 -1
  100. package/framework/tasks/orchestrator-status.md +3 -3
  101. package/framework/tasks/qa-planning-gate-define.md +1 -1
  102. package/framework/templates/brandbook-html-tmpl.md +1 -1
  103. package/framework/templates/brandbook-tmpl.yaml +1 -1
  104. package/framework/templates/component-spec-tmpl.yaml +1 -1
  105. package/framework/templates/design-token-tmpl.yaml +1 -1
  106. package/framework/templates/icon-system-tmpl.yaml +1 -1
  107. package/framework/templates/team-planning-tasks.yaml +6 -5
  108. package/framework/workflows/brownfield-discovery.yaml +2 -2
  109. package/framework/workflows/brownfield-fullstack.yaml +15 -11
  110. package/framework/workflows/brownfield-service.yaml +14 -10
  111. package/framework/workflows/brownfield-ui.yaml +15 -11
  112. package/framework/workflows/greenfield-fullstack.yaml +16 -13
  113. package/framework/workflows/quick-flow.yaml +3 -3
  114. package/framework/workflows/standard-flow.yaml +12 -9
  115. package/package.json +10 -5
  116. package/src/autonomy/autonomous-gate.js +1 -0
  117. package/src/autonomy/build-state.js +1 -2
  118. package/src/autonomy/progress-reporter.js +1 -1
  119. package/src/config/agent-customizer.js +11 -3
  120. package/src/config/claude-settings-generator.js +226 -0
  121. package/src/config/context-file-generator.js +41 -21
  122. package/src/config/framework-adapter.js +1 -0
  123. package/src/config/gemini-hooks-generator.js +76 -6
  124. package/src/config/mcp-configs.js +1 -0
  125. package/src/context/layers/l1-global.js +2 -1
  126. package/src/dashboard/data-reader.js +4 -3
  127. package/src/dashboard/layout.js +2 -1
  128. package/src/decision/analyzer.js +6 -30
  129. package/src/decision/engine.js +4 -28
  130. package/src/decision/registry-healer.js +3 -2
  131. package/src/decision/registry-updater.js +23 -14
  132. package/src/extensions/loader.js +2 -8
  133. package/src/gates/g1-planning-complete.js +2 -1
  134. package/src/gates/g2-qa-planning.js +2 -1
  135. package/src/gates/g3-implementation.js +2 -1
  136. package/src/gates/g4-qa-implementation.js +3 -2
  137. package/src/gates/g5-deploy-ready.js +2 -1
  138. package/src/health/engine.js +4 -3
  139. package/src/installer/core.js +557 -81
  140. package/src/installer/preflight.js +131 -0
  141. package/src/installer/provider-overlay.js +3 -3
  142. package/src/installer/scaffold-applier.js +358 -0
  143. package/src/installer/templates.js +52 -31
  144. package/src/installer/validator.js +17 -12
  145. package/src/intelligence/registry-manager.js +22 -29
  146. package/src/intelligence/timeline.js +11 -6
  147. package/src/license/commands.js +1 -1
  148. package/src/license/wait.js +102 -0
  149. package/src/memory/agent-memory.js +81 -0
  150. package/src/memory/dream.js +32 -1
  151. package/src/merger/replace-merger.js +28 -15
  152. package/src/orchestrator/agent-selector.js +2 -1
  153. package/src/orchestrator/cli.js +1908 -80
  154. package/src/orchestrator/doctor.js +270 -0
  155. package/src/orchestrator/handoff-engine.js +4 -3
  156. package/src/orchestrator/index.js +2 -0
  157. package/src/orchestrator/pipeline-manager.js +306 -15
  158. package/src/orchestrator/session-manager.js +331 -6
  159. package/src/tasks/handoff.js +3 -2
  160. package/src/telemetry/config.js +4 -3
  161. package/src/telemetry/schema.js +1 -0
  162. package/src/terminal/collector.js +3 -2
  163. package/src/terminal/index.js +1 -2
  164. package/src/terminal/isolation.js +52 -18
  165. package/src/terminal/prompt-builder.js +42 -25
  166. package/src/terminal/run-parallel.js +1 -1
  167. package/src/terminal/run-team.js +3 -3
  168. package/src/terminal/team-task-list.js +43 -4
  169. package/src/upgrade/backup.js +3 -2
  170. package/src/upgrade/checker.js +3 -2
  171. package/src/upgrade/migrator.js +65 -7
  172. package/src/upgrade/tracked-files-detector.js +86 -0
  173. package/src/upgrade/user-messages.js +94 -0
  174. package/src/utils/config-parser.js +2 -1
  175. package/src/utils/feature-flags.js +2 -1
  176. package/src/utils/flatten-entities.js +69 -0
  177. package/src/utils/framework-dir.js +16 -0
  178. package/src/utils/model-id.js +85 -0
  179. package/src/utils/provider-limits.js +84 -23
  180. package/src/utils/schema-validator.js +1 -1
  181. package/src/wizard/i18n.js +5 -4
  182. package/src/wizard/index.js +14 -0
  183. package/assets/logo - c/303/263pia.png +0 -0
  184. package/assets/logo.svg +0 -42
  185. package/assets/logo2.png +0 -0
  186. package/assets/social-preview.png +0 -0
  187. package/scripts/bundle-framework.js +0 -69
  188. package/scripts/changelog-generator.js +0 -222
  189. package/scripts/codebase-mapper.js +0 -728
  190. package/scripts/commit-message-generator.js +0 -167
  191. package/scripts/coverage-analyzer.js +0 -260
  192. package/scripts/dependency-analyzer.js +0 -280
  193. package/scripts/doctor/checks/agents.js +0 -77
  194. package/scripts/doctor/checks/constitution.js +0 -41
  195. package/scripts/doctor/checks/domain-alignment.js +0 -58
  196. package/scripts/doctor/checks/prism-layers.js +0 -84
  197. package/scripts/doctor/checks/registry.js +0 -55
  198. package/scripts/doctor/checks/schemas.js +0 -61
  199. package/scripts/doctor/fixes/reference-fix.js +0 -100
  200. package/scripts/doctor/fixes/registry-fix.js +0 -56
  201. package/scripts/doctor/index.js +0 -212
  202. package/scripts/framework-analyzer.js +0 -308
  203. package/scripts/generate-constitution-domain.js +0 -253
  204. package/scripts/generate-signing-key.js +0 -33
  205. package/scripts/health-check.js +0 -481
  206. package/scripts/ide-sync.js +0 -326
  207. package/scripts/performance-analyzer.js +0 -325
  208. package/scripts/plan-tracker.js +0 -278
  209. package/scripts/populate-entity-registry.js +0 -481
  210. package/scripts/pr-review.js +0 -317
  211. package/scripts/rollback-manager.js +0 -310
  212. package/scripts/semantic-lint.js +0 -328
  213. package/scripts/sign-manifest.js +0 -53
  214. package/scripts/stuck-detector.js +0 -343
  215. package/scripts/test-quality-assessment.js +0 -257
  216. package/scripts/validate-agents.js +0 -368
  217. package/scripts/validate-package.js +0 -367
  218. package/scripts/validate-tasks.js +0 -465
  219. package/src/autonomy/worktree-manager.js +0 -250
  220. package/src/intelligence/decision-engine.js +0 -256
  221. package/src/intelligence/document-sharder.js +0 -221
  222. package/src/intelligence/elicitation.js +0 -265
@@ -0,0 +1,674 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * visual-qa.js — Visual Quality Assurance Script
4
+ *
5
+ * Uses Playwright to validate visual quality: scroll-through screenshots,
6
+ * hover states, animation library checks (GSAP, Lenis), responsive viewports.
7
+ *
8
+ * Usage: node visual-qa.js [--pages "/" "/about"] [--output /tmp/visual-qa/] [--port 3000]
9
+ */
10
+
11
+ import { mkdirSync, writeFileSync } from 'fs';
12
+ import { join, resolve } from 'path';
13
+ import { pathToFileURL } from 'url';
14
+ import { spawn } from 'child_process';
15
+ import { createServer, createConnection } from 'net';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Viewports
19
+ // ---------------------------------------------------------------------------
20
+ // Default set includes 1270 — the MacBook Air M4 13.8" dead-zone viewport where
21
+ // Tailwind v4's default `xl:` breakpoint (80rem = 1280px) did not apply, producing
22
+ // the signature "works on external monitor, breaks on the client's laptop" bug.
23
+ // See chati.dev/agents/build/dev.md ADR-TW4 (Pitfall 3) for the full history.
24
+ //
25
+ // Autonomous-completion v1 expanded the default from 4 to 6 viewports (ADR-AUTO-03):
26
+ // + 667 (phone landscape — iPhone SE/8 in landscape, routinely breaks nav + hero)
27
+ // + 1768 (foldable unfolded — Galaxy Fold/Pixel Fold open state)
28
+ // Rationale: premium sites that pass at the four portrait/landscape "desktop" sizes
29
+ // still fail these two. Catching them by default costs ~50% more QA runtime and
30
+ // zero author effort.
31
+ export const DEFAULT_VIEWPORT_SPEC = '1920,1270,768,667,375,1768';
32
+
33
+ const VIEWPORT_DEFAULTS = {
34
+ 1920: { height: 1080, label: 'desktop' },
35
+ 1768: { height: 1366, label: 'foldable_open' }, // Galaxy/Pixel Fold unfolded
36
+ 1280: { height: 800, label: 'desktop_narrow' },
37
+ 1270: { height: 800, label: 'laptop' }, // MacBook Air M4 scaled default
38
+ 1024: { height: 768, label: 'laptop_narrow' },
39
+ 768: { height: 1024, label: 'tablet' },
40
+ 667: { height: 375, label: 'phone_landscape' }, // iPhone SE/8 landscape
41
+ 414: { height: 896, label: 'mobile_large' },
42
+ 375: { height: 812, label: 'mobile' },
43
+ };
44
+
45
+ /**
46
+ * Parse a `--viewports` spec (e.g. "1920,1270,768,375") into structured entries.
47
+ * Unknown widths fall back to a 900px height and an anonymous label `w{width}`.
48
+ * Returns in the same order as the input spec.
49
+ */
50
+ export function parseViewports(spec) {
51
+ const parsed = String(spec || '')
52
+ .split(',')
53
+ .map(s => parseInt(s.trim(), 10))
54
+ .filter(n => Number.isFinite(n) && n > 0);
55
+ return parsed.map(width => {
56
+ const meta = VIEWPORT_DEFAULTS[width] || { height: 900, label: `w${width}` };
57
+ return {
58
+ width,
59
+ height: meta.height,
60
+ label: meta.label,
61
+ checkKey: `${meta.label}_${width}`, // layout_checks[...] key
62
+ errorField: `${meta.label}_${width}x${meta.height}`, // layout_errors[].viewport
63
+ };
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Compute reduced-motion violations from a set of page reports.
69
+ *
70
+ * Contract (ADR-3D-08 / P6 autonomous-completion): under --reduced-motion on,
71
+ * any page that still mounts a <Canvas> (canvas_hosts > 0) is a violation.
72
+ * The qa-visual gate reads summary.reduced_motion_violations[] and BLOCKs
73
+ * advance when non-empty. fault_origin: CODE, routed to dev for the
74
+ * usePrefersReducedMotion() wrap.
75
+ *
76
+ * @param {{pages: Array<{path:string, slug?:string, js_checks?:object}>}} report
77
+ * @param {'on'|'off'|'both'} reducedMotionMode
78
+ * @param {{checkKey?:string}} [primaryViewport]
79
+ * @returns {Array<{page:string, slug:string|null, viewport:string|null, canvas_hosts:number, scene_fallbacks:number}>}
80
+ */
81
+ export function computeReducedMotionViolations(report, reducedMotionMode, primaryViewport) {
82
+ if (reducedMotionMode !== 'on') return [];
83
+ const pages = Array.isArray(report?.pages) ? report.pages : [];
84
+ return pages
85
+ .filter(p => (p?.js_checks?.canvas_hosts ?? 0) > 0)
86
+ .map(p => ({
87
+ page: p.path,
88
+ slug: p.slug ?? null,
89
+ viewport: primaryViewport?.checkKey ?? null,
90
+ canvas_hosts: p.js_checks.canvas_hosts,
91
+ scene_fallbacks: p.js_checks.scene_fallbacks ?? 0,
92
+ }));
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Args
97
+ // ---------------------------------------------------------------------------
98
+ const args = process.argv.slice(2);
99
+ let pages = ['/'];
100
+ let output = '/tmp/visual-qa';
101
+ let preferredPort = 3000;
102
+ let startServer = true;
103
+ let externalUrl = null;
104
+ let viewportSpec = DEFAULT_VIEWPORT_SPEC;
105
+ // Autonomous-completion v1 — reduced-motion pass. Modes:
106
+ // 'on' — capture with prefers-reduced-motion: reduce (accessibility run)
107
+ // 'off' — capture normally (default behaviour)
108
+ // 'both' — capture BOTH; emit reduced_motion_violations when a scroll-triggered
109
+ // region differs between the two runs (authors shipped animation that
110
+ // ignores the user preference).
111
+ let reducedMotionMode = 'off';
112
+
113
+ for (let i = 0; i < args.length; i++) {
114
+ if (args[i] === '--pages') {
115
+ pages = [];
116
+ while (i + 1 < args.length && !args[i + 1].startsWith('--')) {
117
+ pages.push(args[++i]);
118
+ }
119
+ } else if (args[i] === '--output') output = args[++i];
120
+ else if (args[i] === '--port') preferredPort = parseInt(args[++i], 10);
121
+ else if (args[i] === '--url') { externalUrl = args[++i]; startServer = false; }
122
+ else if (args[i] === '--no-server') startServer = false;
123
+ else if (args[i] === '--viewports') viewportSpec = args[++i];
124
+ else if (args[i] === '--reduced-motion') reducedMotionMode = args[++i] || 'both';
125
+ else if (args[i] === '--help') {
126
+ console.log(`Usage: node visual-qa.js [options]
127
+ --pages "/" "/about" "/contact" Pages to test (default: "/")
128
+ --url "https://example.com" External URL (skips local dev server)
129
+ --output /path/to/dir Output directory (default: /tmp/visual-qa/)
130
+ --port 3000 Preferred port (default: 3000)
131
+ --viewports 1920,1270,768,667,375,1768 Viewport widths (default: 6 profiles;
132
+ 1270 MacBook Air M4 dead zone, 667 iPhone
133
+ landscape, 1768 Galaxy Fold unfolded).
134
+ --reduced-motion on|off|both Capture under prefers-reduced-motion (default: off).
135
+ 'both' compares runs; diverging scroll regions
136
+ are reported as reduced_motion_violations.
137
+ --no-server Don't start dev server (assume already running)
138
+ --help Show this help`);
139
+ process.exit(0);
140
+ }
141
+ }
142
+
143
+ const viewports = parseViewports(viewportSpec);
144
+ if (viewports.length === 0) {
145
+ console.error(`Invalid --viewports spec: "${viewportSpec}"`);
146
+ process.exit(1);
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Port detection
151
+ // ---------------------------------------------------------------------------
152
+ function findFreePort(preferred) {
153
+ return new Promise((resolve, reject) => {
154
+ const server = createServer();
155
+ server.listen(preferred, () => {
156
+ const port = server.address().port;
157
+ server.close(() => resolve(port));
158
+ });
159
+ server.on('error', () => {
160
+ // Port in use, try a random free port
161
+ const server2 = createServer();
162
+ server2.listen(0, () => {
163
+ const port = server2.address().port;
164
+ server2.close(() => resolve(port));
165
+ });
166
+ server2.on('error', reject);
167
+ });
168
+ });
169
+ }
170
+
171
+ async function waitForServerReady(port, timeoutMs = 30000) {
172
+ const start = Date.now();
173
+ while (Date.now() - start < timeoutMs) {
174
+ try {
175
+ const res = await fetch(`http://localhost:${port}`);
176
+ if (res.ok || res.status === 404) return true; // server is up
177
+ } catch { /* not ready yet */ }
178
+ await new Promise(r => setTimeout(r, 1000));
179
+ }
180
+ throw new Error(`Server not ready on port ${port} after ${timeoutMs}ms`);
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Layout measurement — objective, quantitative checks run inside the browser.
185
+ // Rationale: the Visualizer sub-agent is authoritative for qualitative concerns
186
+ // (mood, polish, animation feel). Positional/dimensional failures (Container
187
+ // not centered, H1 font-size collapsed, horizontal overflow) are MEASURED, not
188
+ // judged. This block catches the class of bugs that slips past the Visualizer
189
+ // when viewport size or low specificity of the rendered image masks the issue.
190
+ // ---------------------------------------------------------------------------
191
+ async function measureLayout(page) {
192
+ return page.evaluate(() => {
193
+ // Container centering: any layout wrapper with max-width < viewport should
194
+ // have equal left/right gutters (auto-centered via mx-auto or equivalent).
195
+ const containerSelector = 'main, main > *, section > div:first-child, [class*="max-w-"], [class*="container"]';
196
+ const seen = new Set();
197
+ const containers = [];
198
+ const viewport = { width: window.innerWidth, height: window.innerHeight };
199
+
200
+ for (const el of document.querySelectorAll(containerSelector)) {
201
+ // Dedupe by node
202
+ if (seen.has(el)) continue;
203
+ seen.add(el);
204
+
205
+ const rect = el.getBoundingClientRect();
206
+ const styles = getComputedStyle(el);
207
+ const maxWidthStr = styles.maxWidth || '';
208
+ const maxWidthPx = maxWidthStr.endsWith('px') ? parseFloat(maxWidthStr) : null;
209
+
210
+ // Only consider elements that are visibly narrower than the viewport and
211
+ // have a declared max-width (that's the pattern the bug affects).
212
+ if (!maxWidthPx) continue;
213
+ if (rect.width >= viewport.width - 1) continue; // full-width, not a centered container
214
+ if (rect.width < 200) continue; // ignore tiny elements
215
+
216
+ const leftGutter = Math.round(rect.left);
217
+ const rightGutter = Math.round(viewport.width - rect.right);
218
+ const delta = Math.abs(leftGutter - rightGutter);
219
+ const marginLeft = styles.marginLeft;
220
+ const marginRight = styles.marginRight;
221
+
222
+ // Critical distinction: an element with max-width but without mx-auto
223
+ // is NOT required to be centered. It's normal for <h1>, <p>, and other
224
+ // text elements inside a centered parent Container to be left-aligned
225
+ // against the parent's padding. We only flag centering failures when
226
+ // the INTENT to center is explicit — either `margin-*: auto` in computed
227
+ // styles (meaning mx-auto resolved correctly) OR the mx-auto class is
228
+ // present (meaning mx-auto was declared but utilities may be overridden).
229
+ const classList = [...el.classList];
230
+ const declaresMxAuto = classList.some(c => c === 'mx-auto' || c.startsWith('mx-['));
231
+ const computedCentered = marginLeft === 'auto' && marginRight === 'auto';
232
+ // Only containers that EXPECT to be centered trigger the check.
233
+ const expectsCentering = computedCentered || declaresMxAuto;
234
+
235
+ containers.push({
236
+ tag: el.tagName.toLowerCase(),
237
+ classes: classList.slice(0, 6).join(' '),
238
+ maxWidth: maxWidthPx,
239
+ rectLeft: leftGutter,
240
+ rectWidth: Math.round(rect.width),
241
+ rightGutter,
242
+ delta,
243
+ marginLeft,
244
+ marginRight,
245
+ declaresMxAuto,
246
+ computedCentered,
247
+ expectsCentering,
248
+ // Only flag as not-centered when the element was supposed to be centered.
249
+ // Allow 2px tolerance for subpixel rendering.
250
+ centered: !expectsCentering || delta <= 2,
251
+ });
252
+ }
253
+
254
+ // H1 measurement — font-size collapse catches Tailwind v4 JIT bug where
255
+ // `text-[var(--text-hero)]` silently fails to emit font-size.
256
+ const headings = [];
257
+ for (const h1 of document.querySelectorAll('h1')) {
258
+ const styles = getComputedStyle(h1);
259
+ const fontSize = parseFloat(styles.fontSize) || 0;
260
+ const fontWeight = parseInt(styles.fontWeight, 10) || 0;
261
+ headings.push({
262
+ text: (h1.textContent || '').trim().slice(0, 60),
263
+ fontSize,
264
+ fontWeight,
265
+ });
266
+ }
267
+
268
+ // Horizontal overflow — body should never scroll horizontally.
269
+ const overflow = {
270
+ documentScrollWidth: document.documentElement.scrollWidth,
271
+ viewportWidth: viewport.width,
272
+ hasOverflow: document.documentElement.scrollWidth > viewport.width + 1,
273
+ };
274
+
275
+ return { viewport, containers, headings, overflow };
276
+ });
277
+ }
278
+
279
+ /**
280
+ * Aggregate layout measurements into error records. Hard rules:
281
+ * - Container with max-width < viewport MUST be centered (delta <= 2px).
282
+ * - H1 on desktop MUST have fontSize >= 40px. (Mobile threshold 28px.)
283
+ * - No horizontal overflow.
284
+ *
285
+ * @param {object} layout - Output of measureLayout()
286
+ * @param {string} viewportName - Human label (e.g. 'desktop_1920x1080')
287
+ * @param {object} opts - { mobile: boolean }
288
+ * @returns {object[]} Array of error records with severity.
289
+ */
290
+ function aggregateLayoutErrors(layout, viewportName, opts = {}) {
291
+ const errors = [];
292
+ const h1MinFontSize = opts.mobile ? 28 : 40;
293
+
294
+ for (const c of layout.containers) {
295
+ if (!c.centered) {
296
+ errors.push({
297
+ severity: 'error',
298
+ type: 'container_not_centered',
299
+ viewport: viewportName,
300
+ detail: `<${c.tag}> with max-width ${c.maxWidth}px has leftGutter=${c.rectLeft}px rightGutter=${c.rightGutter}px (delta ${c.delta}px, tolerance 2px). marginLeft=${c.marginLeft} marginRight=${c.marginRight}. Likely cause: utilities overridden by unlayered global CSS, or mx-auto missing.`,
301
+ classes: c.classes,
302
+ });
303
+ }
304
+ }
305
+
306
+ for (const h of layout.headings) {
307
+ if (h.fontSize < h1MinFontSize) {
308
+ errors.push({
309
+ severity: 'error',
310
+ type: 'h1_font_size_collapsed',
311
+ viewport: viewportName,
312
+ detail: `<h1> "${h.text}" has computed fontSize=${h.fontSize}px (expected >= ${h1MinFontSize}px). Likely cause: Tailwind v4 arbitrary value without type hint (use text-[length:var(...)] instead of text-[var(...)]).`,
313
+ });
314
+ }
315
+ }
316
+
317
+ if (layout.overflow.hasOverflow) {
318
+ errors.push({
319
+ severity: 'error',
320
+ type: 'horizontal_overflow',
321
+ viewport: viewportName,
322
+ detail: `document.scrollWidth=${layout.overflow.documentScrollWidth}px > viewport=${layout.overflow.viewportWidth}px. Page scrolls horizontally.`,
323
+ });
324
+ }
325
+
326
+ return errors;
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Main
331
+ // ---------------------------------------------------------------------------
332
+ async function main() {
333
+ mkdirSync(output, { recursive: true });
334
+
335
+ // 1. Find free port (only if we need to start a server)
336
+ let port = preferredPort;
337
+ if (startServer) {
338
+ port = await findFreePort(preferredPort);
339
+ console.log(`Using port: ${port}`);
340
+ }
341
+
342
+ // 2. Start dev server
343
+ let serverProcess = null;
344
+ if (startServer) {
345
+ console.log('Starting dev server...');
346
+ serverProcess = spawn('npx', ['next', 'dev', '-p', String(port)], {
347
+ cwd: process.cwd(),
348
+ stdio: 'pipe',
349
+ detached: true,
350
+ });
351
+ serverProcess.unref();
352
+ await waitForServerReady(port);
353
+ console.log('Server ready.');
354
+ }
355
+
356
+ let exitCode = 0;
357
+
358
+ try {
359
+ // 3. Launch browser — try project node_modules first, then bundled _cli/
360
+ // Dynamic import() needs absolute file:// URL for paths, not relative.
361
+ const bundledPw = pathToFileURL(resolve(process.cwd(), '.chati.dev', '_cli', 'node_modules', 'playwright', 'index.mjs')).href;
362
+ let chromium;
363
+ for (const tryImport of [
364
+ () => import('playwright'),
365
+ () => import(bundledPw),
366
+ ]) {
367
+ try {
368
+ const pw = await tryImport();
369
+ chromium = pw.chromium || pw.default?.chromium;
370
+ if (chromium) break;
371
+ } catch { /* try next */ }
372
+ }
373
+ if (!chromium) throw new Error('Playwright not installed. Run: npx playwright install chromium');
374
+ const browser = await chromium.launch({ headless: true });
375
+ const report = { pages: [], summary: {} };
376
+
377
+ for (const pagePath of pages) {
378
+ const slug = pagePath === '/' ? 'home' : pagePath.replace(/\//g, '-').replace(/^-/, '');
379
+ console.log(`\nAnalyzing: ${pagePath} (${slug})`);
380
+
381
+ // Primary viewport = first entry in `viewports`. Default 1920x1080.
382
+ // Rationale: Tailwind cascade layer bugs and Container centering issues
383
+ // are invisible at 1280 because a max-w-[1200px] container fills 94% of
384
+ // 1280px — content "looks" close to centered even when mx-auto is broken.
385
+ // At 1920, the same container should have ~360px of margin on each side;
386
+ // broken centering is obvious. This is how the 2026-04-16 cascade layer
387
+ // trap escaped detection. Secondary viewports (1270, 768, 375 by default)
388
+ // add laptop dead-zone, tablet, and mobile coverage.
389
+ const primary = viewports[0];
390
+ const context = await browser.newContext({
391
+ viewport: { width: primary.width, height: primary.height },
392
+ // Autonomous-completion v1 (ADR-AUTO-04): accessibility pass under
393
+ // prefers-reduced-motion: reduce. Mode 'on' forces the CSS media
394
+ // query server-side so animated components take their reduced branch.
395
+ // Mode 'off' (default) captures normal behaviour.
396
+ ...(reducedMotionMode === 'on' ? { reducedMotion: 'reduce' } : {}),
397
+ });
398
+ const page = await context.newPage();
399
+
400
+ // Capture console errors
401
+ const consoleErrors = [];
402
+ page.on('console', msg => {
403
+ if (msg.type() === 'error') consoleErrors.push(msg.text());
404
+ });
405
+ page.on('pageerror', err => consoleErrors.push(err.message));
406
+
407
+ const baseUrl = externalUrl || `http://localhost:${port}`;
408
+ const fullUrl = externalUrl ? (pagePath === '/' ? externalUrl : `${externalUrl}${pagePath}`) : `${baseUrl}${pagePath}`;
409
+ await page.goto(fullUrl, {
410
+ waitUntil: 'networkidle',
411
+ timeout: 30000,
412
+ });
413
+ // Extra wait for animations to initialize
414
+ await page.waitForTimeout(1500);
415
+
416
+ const pageReport = {
417
+ path: pagePath,
418
+ slug,
419
+ screenshots: [],
420
+ hover_states: [],
421
+ js_checks: {},
422
+ layout_checks: {},
423
+ layout_errors: [],
424
+ console_errors: [],
425
+ };
426
+
427
+ // 4a. Full-page screenshot
428
+ const fullPath = join(output, `${slug}-full.png`);
429
+ await page.screenshot({ path: fullPath, fullPage: true });
430
+ pageReport.screenshots.push(`${slug}-full.png`);
431
+ console.log(` Full-page screenshot captured`);
432
+
433
+ // 4b. Smooth scroll through page to trigger all scroll-based animations
434
+ const scrollHeight = await page.evaluate(() => document.documentElement.scrollHeight);
435
+ const viewportHeight = primary.height;
436
+ const maxScroll = Math.max(0, scrollHeight - viewportHeight);
437
+
438
+ // Phase 1: scroll incrementally to trigger ScrollTrigger/GSAP/Lenis
439
+ const scrollStep = 100;
440
+ let y = 0;
441
+ while (y <= maxScroll) {
442
+ await page.evaluate(s => window.scrollBy(0, s), scrollStep);
443
+ await page.waitForTimeout(50);
444
+ y += scrollStep;
445
+ }
446
+ await page.waitForTimeout(1500); // let final animations settle
447
+
448
+ // Phase 2: capture at checkpoints (animations already triggered)
449
+ for (const pct of [0, 25, 50, 75, 100]) {
450
+ const y = Math.floor(maxScroll * pct / 100);
451
+ await page.evaluate(scrollY => window.scrollTo(0, scrollY), y);
452
+ await page.waitForTimeout(800);
453
+ const scrollPath = join(output, `${slug}-scroll-${pct}.png`);
454
+ await page.screenshot({ path: scrollPath });
455
+ pageReport.screenshots.push(`${slug}-scroll-${pct}.png`);
456
+ }
457
+ console.log(` Scroll-through: 5 positions captured (smooth scroll + checkpoints)`);
458
+
459
+ // 4c. Hover states on interactive elements
460
+ await page.evaluate(() => window.scrollTo(0, 0));
461
+ await page.waitForTimeout(500);
462
+ const interactiveElements = await page.locator('button:visible, a[href]:visible, [role="button"]:visible').all();
463
+ const hoverCount = Math.min(interactiveElements.length, 5);
464
+ for (let i = 0; i < hoverCount; i++) {
465
+ try {
466
+ await interactiveElements[i].hover({ timeout: 3000 });
467
+ await page.waitForTimeout(500);
468
+ const hoverPath = join(output, `${slug}-hover-${i}.png`);
469
+ await page.screenshot({ path: hoverPath });
470
+ pageReport.hover_states.push(`${slug}-hover-${i}.png`);
471
+ } catch { /* element may have moved */ }
472
+ }
473
+ console.log(` Hover states: ${hoverCount} elements captured`);
474
+
475
+ // 4d. JavaScript checks — animation libraries
476
+ pageReport.js_checks = await page.evaluate(() => {
477
+ const checks = {
478
+ lenis_active: false,
479
+ lenis_options: null,
480
+ gsap_loaded: false,
481
+ gsap_animations_count: 0,
482
+ scroll_triggers_count: 0,
483
+ scroll_height: document.documentElement.scrollHeight,
484
+ viewport_height: window.innerHeight,
485
+ em_dashes_found: false,
486
+ em_dash_texts: [],
487
+ reduced_motion_support: false,
488
+ custom_properties_count: 0,
489
+ };
490
+
491
+ // Lenis
492
+ if (window.lenis) {
493
+ checks.lenis_active = true;
494
+ checks.lenis_options = {
495
+ smooth: window.lenis.options?.smooth ?? null,
496
+ lerp: window.lenis.options?.lerp ?? null,
497
+ };
498
+ }
499
+
500
+ // GSAP
501
+ if (typeof gsap !== 'undefined') {
502
+ checks.gsap_loaded = true;
503
+ try {
504
+ checks.gsap_animations_count = gsap.globalTimeline.getChildren(true, true, true).length;
505
+ } catch { checks.gsap_animations_count = -1; }
506
+ }
507
+
508
+ // ScrollTrigger
509
+ if (typeof ScrollTrigger !== 'undefined') {
510
+ try {
511
+ checks.scroll_triggers_count = ScrollTrigger.getAll().length;
512
+ } catch { checks.scroll_triggers_count = -1; }
513
+ }
514
+
515
+ // Em-dashes in visible text
516
+ const bodyText = document.body.innerText;
517
+ checks.em_dashes_found = bodyText.includes('\u2014') || bodyText.includes('\u2013');
518
+ if (checks.em_dashes_found) {
519
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
520
+ let node;
521
+ while ((node = walker.nextNode()) && checks.em_dash_texts.length < 5) {
522
+ if (node.textContent.includes('\u2014') || node.textContent.includes('\u2013')) {
523
+ checks.em_dash_texts.push(node.textContent.trim().slice(0, 80));
524
+ }
525
+ }
526
+ }
527
+
528
+ // Reduced motion support
529
+ const styles = Array.from(document.querySelectorAll('style'));
530
+ checks.reduced_motion_support = styles.some(s =>
531
+ s.textContent.includes('prefers-reduced-motion')
532
+ );
533
+
534
+ // CSS custom properties (design tokens)
535
+ const rootStyles = getComputedStyle(document.documentElement);
536
+ let count = 0;
537
+ for (let i = 0; i < rootStyles.length; i++) {
538
+ if (rootStyles[i].startsWith('--')) count++;
539
+ }
540
+ checks.custom_properties_count = count;
541
+
542
+ // 3D scaffold signals (motion-premium-3d workstream P7). Detects
543
+ // whether the page rendered a Canvas or fell back to SceneFallback.
544
+ // The data-* attributes come from the CanvasProvider + SceneFallback
545
+ // scaffold templates; QA-Visual Mode 2 uses these to verify the
546
+ // WebGL gate behaved correctly for the page's motion-premium-3d
547
+ // inventory rows.
548
+ checks.canvas_hosts = document.querySelectorAll('[data-canvas-host]').length;
549
+ checks.scene_fallbacks = document.querySelectorAll('[data-scene-fallback]').length;
550
+ checks.has_3d_scene = checks.canvas_hosts > 0;
551
+ checks.has_3d_fallback = checks.scene_fallbacks > 0;
552
+
553
+ return checks;
554
+ });
555
+ console.log(` JS checks: Lenis=${pageReport.js_checks.lenis_active}, GSAP=${pageReport.js_checks.gsap_loaded}, Animations=${pageReport.js_checks.gsap_animations_count}`);
556
+
557
+ // 4e. Quantitative layout assertions — measured at EVERY viewport.
558
+ // These are objective checks: failures are NOT judgment calls, they are
559
+ // measured facts (via getBoundingClientRect / getComputedStyle). The
560
+ // Visualizer covers qualitative/aesthetic concerns; this block catches
561
+ // the class of bugs where utilities silently fail to apply (cascade
562
+ // layer traps, Tailwind v4 JIT type hint issues, xl breakpoint dead
563
+ // zone, overflow bugs). Laptop 1270 is the MacBook Air M4 viewport
564
+ // where `lg:` applies but `xl:` does not under the default Tailwind
565
+ // breakpoint — see dev.md ADR-TW4 (Pitfall 3).
566
+ await page.evaluate(() => window.scrollTo(0, 0));
567
+ await page.waitForTimeout(300);
568
+ const primaryLayout = await measureLayout(page);
569
+ pageReport.layout_checks[primary.checkKey] = primaryLayout;
570
+ const primaryIsMobile = primary.width < 640;
571
+ for (const err of aggregateLayoutErrors(primaryLayout, primary.errorField, { mobile: primaryIsMobile })) {
572
+ err.page = pagePath;
573
+ pageReport.layout_errors.push(err);
574
+ }
575
+ console.log(` Layout (${primary.width} ${primary.label}): ${primaryLayout.containers.length} container(s), ${pageReport.layout_errors.filter(e => e.viewport === primary.errorField).length} error(s)`);
576
+
577
+ // 4f. Secondary viewports: screenshot + layout measurement at each.
578
+ // Measurement at every viewport catches breakpoint-specific bugs
579
+ // (Tailwind xl dead zone at 1270, mobile overflow at 375) that do not
580
+ // appear at the primary viewport.
581
+ for (const vp of viewports.slice(1)) {
582
+ await page.setViewportSize({ width: vp.width, height: vp.height });
583
+ await page.evaluate(() => window.scrollTo(0, 0));
584
+ await page.waitForTimeout(500);
585
+ const respPath = join(output, `${slug}-${vp.label}.png`);
586
+ await page.screenshot({ path: respPath, fullPage: true });
587
+ pageReport.screenshots.push(`${slug}-${vp.label}.png`);
588
+
589
+ const vpLayout = await measureLayout(page);
590
+ pageReport.layout_checks[vp.checkKey] = vpLayout;
591
+ const isMobile = vp.width < 640;
592
+ for (const err of aggregateLayoutErrors(vpLayout, vp.errorField, { mobile: isMobile })) {
593
+ err.page = pagePath;
594
+ pageReport.layout_errors.push(err);
595
+ }
596
+ console.log(` Layout (${vp.width} ${vp.label}): ${vpLayout.containers.length} container(s), ${pageReport.layout_errors.filter(e => e.viewport === vp.errorField).length} error(s)`);
597
+ }
598
+
599
+ pageReport.console_errors = consoleErrors;
600
+ report.pages.push(pageReport);
601
+ await context.close();
602
+ }
603
+
604
+ // 5. Summary — includes aggregated layout_errors, the single source of
605
+ // truth for objective layout regressions. The qa-visual gate in
606
+ // handleAdvance reads summary.layout_errors and blocks advance if any
607
+ // ERROR-severity entry exists (routed to dev via Fault Vector Protocol).
608
+ const allLayoutErrors = report.pages.flatMap(p => p.layout_errors || []);
609
+
610
+ const reducedMotionViolations = computeReducedMotionViolations(
611
+ report, reducedMotionMode, viewports[0]
612
+ );
613
+
614
+ report.summary = {
615
+ total_pages: report.pages.length,
616
+ total_screenshots: report.pages.reduce((s, p) => s + p.screenshots.length + p.hover_states.length, 0),
617
+ lenis_active_all: report.pages.every(p => p.js_checks.lenis_active),
618
+ gsap_loaded_all: report.pages.every(p => p.js_checks.gsap_loaded),
619
+ total_gsap_animations: report.pages.reduce((s, p) => s + Math.max(0, p.js_checks.gsap_animations_count), 0),
620
+ total_scroll_triggers: report.pages.reduce((s, p) => s + Math.max(0, p.js_checks.scroll_triggers_count), 0),
621
+ em_dashes_detected: report.pages.some(p => p.js_checks.em_dashes_found),
622
+ console_errors_total: report.pages.reduce((s, p) => s + p.console_errors.length, 0),
623
+ reduced_motion_support: report.pages.every(p => p.js_checks.reduced_motion_support),
624
+ // Autonomous-completion v1 (ADR-AUTO-04): mode of the current capture.
625
+ // 'on' captures with prefers-reduced-motion: reduce applied; downstream
626
+ // qa-visual gate (P6) compares this run against a motion-on run to
627
+ // detect animation that ignores the user preference.
628
+ reduced_motion_mode: reducedMotionMode,
629
+ reduced_motion_violations: reducedMotionViolations,
630
+ viewport_profiles: viewports.map(v => v.checkKey),
631
+ layout_errors: allLayoutErrors,
632
+ layout_error_count: allLayoutErrors.filter(e => e.severity === 'error').length,
633
+ };
634
+
635
+ // 6. Write report
636
+ writeFileSync(join(output, 'report.json'), JSON.stringify(report, null, 2));
637
+ console.log(`\nReport written to ${join(output, 'report.json')}`);
638
+ console.log(`Total screenshots: ${report.summary.total_screenshots}`);
639
+ console.log(`Lenis: ${report.summary.lenis_active_all ? 'ACTIVE' : 'MISSING'}`);
640
+ console.log(`GSAP: ${report.summary.gsap_loaded_all ? 'LOADED' : 'MISSING'} (${report.summary.total_gsap_animations} animations, ${report.summary.total_scroll_triggers} scroll triggers)`);
641
+ console.log(`Em-dashes: ${report.summary.em_dashes_detected ? 'FOUND (ERROR)' : 'NONE (OK)'}`);
642
+ console.log(`Console errors: ${report.summary.console_errors_total}`);
643
+ console.log(`Layout errors: ${report.summary.layout_error_count} (${report.summary.layout_errors.length} total entries)`);
644
+ if (report.summary.layout_error_count > 0) {
645
+ console.log(`\nLayout errors by page:`);
646
+ for (const err of report.summary.layout_errors.filter(e => e.severity === 'error').slice(0, 10)) {
647
+ console.log(` [${err.viewport}] ${err.page || ''} ${err.type}: ${err.detail}`);
648
+ }
649
+ }
650
+
651
+ if (!report.summary.lenis_active_all || !report.summary.gsap_loaded_all || report.summary.em_dashes_detected || report.summary.layout_error_count > 0) {
652
+ exitCode = 1;
653
+ }
654
+
655
+ await browser.close();
656
+ } catch (err) {
657
+ console.error(`Visual QA failed: ${err.message}`);
658
+ exitCode = 1;
659
+ } finally {
660
+ // 7. Kill server
661
+ if (serverProcess) {
662
+ try { process.kill(-serverProcess.pid, 'SIGTERM'); } catch { /* already dead */ }
663
+ }
664
+ }
665
+
666
+ process.exit(exitCode);
667
+ }
668
+
669
+ // Only run main() when invoked as a script (not when imported by tests).
670
+ // `node visual-qa.js` → process.argv[1] is this file → run.
671
+ // `import { parseViewports } from '.../visual-qa.js'` → argv[1] is the test file → skip.
672
+ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
673
+ main();
674
+ }
@@ -77,7 +77,7 @@ Modify `.chati/session.yaml`:
77
77
 
78
78
  ### 7. Determine Next Agent
79
79
  Based on pipeline position and mode:
80
- - **DISCOVER → PLAN**: Follow sequence (wu → brief → detail architect ux → phases → tasks → qa-planning)
80
+ - **DISCOVER → PLAN**: Follow sequence (wu → brief → [Planning Team: detail + architect + ux + qa-planning] → phases → tasks)
81
81
  - **BUILD**: dev → qa-implementation
82
82
  - **DEPLOY**: devops
83
83
  - Handle special transitions (e.g., qa-planning → dev requires mode switch)