chati-dev 4.2.2 → 4.3.1

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 (221) hide show
  1. package/README.md +80 -53
  2. package/bin/chati.js +170 -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 +158 -22
  6. package/framework/agents/discover/brownfield-wu.md +26 -14
  7. package/framework/agents/discover/greenfield-wu.md +102 -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 +38 -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 +448 -0
  21. package/framework/agents/shared/visualizer.md +128 -0
  22. package/framework/config.yaml +7 -6
  23. package/framework/constitution.md +127 -44
  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 +24 -5
  35. package/framework/hooks/license-guard.js +62 -27
  36. package/framework/hooks/mode-governance.js +13 -2
  37. package/framework/hooks/model-governance.js +1 -0
  38. package/framework/hooks/post-dev.js +207 -0
  39. package/framework/hooks/prism-engine.js +274 -105
  40. package/framework/hooks/reasoning-escalator.js +371 -0
  41. package/framework/hooks/reference-trigger.js +117 -0
  42. package/framework/hooks/session-digest.js +50 -1
  43. package/framework/hooks/settings.json +32 -1
  44. package/framework/hooks/style-guard.js +25 -6
  45. package/framework/hooks/team-quality-gate.js +19 -12
  46. package/framework/hooks/undercover-guard.js +4 -2
  47. package/framework/i18n/en.yaml +3 -3
  48. package/framework/i18n/es.yaml +3 -3
  49. package/framework/i18n/fr.yaml +3 -3
  50. package/framework/i18n/pt.yaml +3 -3
  51. package/framework/intelligence/context-engine.md +4 -5
  52. package/framework/intelligence/decision-engine.md +1 -1
  53. package/framework/intelligence/hooks-performance.md +3 -3
  54. package/framework/migrations/v1.0-to-v1.1.yaml +1 -1
  55. package/framework/migrations/v1.4-to-v2.0.yaml +11 -11
  56. package/framework/migrations/v4.0-to-v4.1.yaml +2 -2
  57. package/framework/migrations/v4.2-to-v4.3.yaml +29 -0
  58. package/framework/orchestrator/chati-router.js +441 -0
  59. package/framework/orchestrator/chati-update.md +40 -40
  60. package/framework/orchestrator/chati.md +295 -94
  61. package/framework/rules/human-writing-style.md +47 -0
  62. package/framework/scaffold/motion-premium/README.md +99 -0
  63. package/framework/scaffold/motion-premium/app/globals.css.template +400 -0
  64. package/framework/scaffold/motion-premium/app/layout.tsx.template +110 -0
  65. package/framework/scaffold/motion-premium/components/animation/BackgroundCrossfadeProvider.tsx.template +170 -0
  66. package/framework/scaffold/motion-premium/components/animation/LenisProvider.tsx.template +49 -0
  67. package/framework/scaffold/motion-premium/components/animation/PageTransitionWrapper.tsx.template +83 -0
  68. package/framework/scaffold/motion-premium/components/animation/Preloader.tsx.template +171 -0
  69. package/framework/scaffold/motion-premium/components/ui/Container.tsx.template +69 -0
  70. package/framework/scaffold/motion-premium/components/ui/PageSection.tsx.template +74 -0
  71. package/framework/scaffold/motion-premium/lib/animations/gsap.ts.template +112 -0
  72. package/framework/scaffold/motion-premium/lib/animations/refreshCoordinator.ts.template +75 -0
  73. package/framework/scaffold/motion-premium/lib/animations/tokens.ts.template +119 -0
  74. package/framework/scaffold/motion-premium/lib/animations/useGsapContext.ts.template +92 -0
  75. package/framework/scaffold/motion-premium/lib/animations/useScrollSnapStepper.ts.template +265 -0
  76. package/framework/scaffold/motion-premium/lib/animations/useSmoothScroll.ts.template +67 -0
  77. package/framework/scaffold/motion-premium/lib/brand.ts.template +43 -0
  78. package/framework/scaffold/motion-premium/scaffold.yaml +178 -0
  79. package/framework/scaffold/motion-premium-3d/README.md +90 -0
  80. package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/ScrollDemoCanvas.tsx.template +81 -0
  81. package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/ScrollDemoClient.tsx.template +75 -0
  82. package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/page.tsx.template +26 -0
  83. package/framework/scaffold/motion-premium-3d/components/3d/CameraRig.tsx.template +100 -0
  84. package/framework/scaffold/motion-premium-3d/components/3d/CanvasProvider.tsx.template +85 -0
  85. package/framework/scaffold/motion-premium-3d/components/3d/InvalidateOnScroll.tsx.template +51 -0
  86. package/framework/scaffold/motion-premium-3d/components/3d/MeshCrossfade.tsx.template +79 -0
  87. package/framework/scaffold/motion-premium-3d/components/3d/ScrollCrossfade.tsx.template +88 -0
  88. package/framework/scaffold/motion-premium-3d/components/3d/ScrollScene.tsx.template +121 -0
  89. package/framework/scaffold/motion-premium-3d/components/webgl/SceneFallback.tsx.template +65 -0
  90. package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +68 -0
  91. package/framework/scaffold/motion-premium-3d/lib/webgl/detect.ts.template +69 -0
  92. package/framework/scaffold/motion-premium-3d/scaffold.yaml +136 -0
  93. package/framework/schemas/session.schema.json +109 -21
  94. package/framework/scripts/reference-capture.js +430 -0
  95. package/framework/scripts/visual-qa.js +686 -0
  96. package/framework/tasks/orchestrator-handoff.md +1 -1
  97. package/framework/tasks/orchestrator-resume.md +1 -1
  98. package/framework/tasks/orchestrator-route.md +1 -1
  99. package/framework/tasks/orchestrator-status.md +3 -3
  100. package/framework/tasks/qa-planning-gate-define.md +1 -1
  101. package/framework/templates/brandbook-html-tmpl.md +2 -2
  102. package/framework/templates/brandbook-tmpl.yaml +1 -1
  103. package/framework/templates/component-spec-tmpl.yaml +1 -1
  104. package/framework/templates/design-token-tmpl.yaml +1 -1
  105. package/framework/templates/icon-system-tmpl.yaml +1 -1
  106. package/framework/templates/team-planning-tasks.yaml +6 -5
  107. package/framework/workflows/brownfield-discovery.yaml +2 -2
  108. package/framework/workflows/brownfield-fullstack.yaml +15 -11
  109. package/framework/workflows/brownfield-service.yaml +14 -10
  110. package/framework/workflows/brownfield-ui.yaml +15 -11
  111. package/framework/workflows/greenfield-fullstack.yaml +16 -13
  112. package/framework/workflows/quick-flow.yaml +3 -3
  113. package/framework/workflows/standard-flow.yaml +12 -9
  114. package/package.json +12 -6
  115. package/src/autonomy/autonomous-gate.js +1 -0
  116. package/src/autonomy/build-state.js +1 -2
  117. package/src/autonomy/progress-reporter.js +1 -1
  118. package/src/config/agent-customizer.js +11 -3
  119. package/src/config/claude-settings-generator.js +27 -7
  120. package/src/config/context-file-generator.js +41 -21
  121. package/src/config/framework-adapter.js +1 -0
  122. package/src/config/gemini-hooks-generator.js +19 -7
  123. package/src/config/mcp-configs.js +1 -0
  124. package/src/context/layers/l1-global.js +2 -1
  125. package/src/dashboard/data-reader.js +4 -3
  126. package/src/dashboard/layout.js +2 -1
  127. package/src/decision/analyzer.js +6 -30
  128. package/src/decision/engine.js +4 -28
  129. package/src/decision/registry-healer.js +3 -2
  130. package/src/decision/registry-updater.js +23 -14
  131. package/src/extensions/loader.js +2 -8
  132. package/src/gates/g1-planning-complete.js +2 -1
  133. package/src/gates/g2-qa-planning.js +2 -1
  134. package/src/gates/g3-implementation.js +2 -1
  135. package/src/gates/g4-qa-implementation.js +3 -2
  136. package/src/gates/g5-deploy-ready.js +2 -1
  137. package/src/health/engine.js +4 -3
  138. package/src/installer/core.js +431 -81
  139. package/src/installer/preflight.js +131 -0
  140. package/src/installer/provider-overlay.js +3 -3
  141. package/src/installer/scaffold-applier.js +358 -0
  142. package/src/installer/templates.js +46 -29
  143. package/src/installer/validator.js +17 -12
  144. package/src/intelligence/registry-manager.js +22 -29
  145. package/src/intelligence/timeline.js +11 -6
  146. package/src/license/commands.js +1 -1
  147. package/src/license/wait.js +102 -0
  148. package/src/memory/agent-memory.js +81 -0
  149. package/src/memory/dream.js +32 -1
  150. package/src/merger/replace-merger.js +28 -15
  151. package/src/orchestrator/agent-selector.js +2 -1
  152. package/src/orchestrator/cli.js +1880 -71
  153. package/src/orchestrator/doctor.js +270 -0
  154. package/src/orchestrator/handoff-engine.js +4 -3
  155. package/src/orchestrator/index.js +2 -0
  156. package/src/orchestrator/pipeline-manager.js +306 -15
  157. package/src/orchestrator/session-manager.js +339 -6
  158. package/src/tasks/handoff.js +3 -2
  159. package/src/telemetry/config.js +4 -3
  160. package/src/telemetry/schema.js +1 -0
  161. package/src/terminal/collector.js +3 -2
  162. package/src/terminal/index.js +1 -2
  163. package/src/terminal/isolation.js +52 -18
  164. package/src/terminal/prompt-builder.js +42 -25
  165. package/src/terminal/run-parallel.js +1 -1
  166. package/src/terminal/run-team.js +3 -3
  167. package/src/terminal/team-task-list.js +43 -4
  168. package/src/upgrade/backup.js +3 -2
  169. package/src/upgrade/checker.js +3 -2
  170. package/src/upgrade/migrator.js +65 -7
  171. package/src/upgrade/tracked-files-detector.js +86 -0
  172. package/src/upgrade/user-messages.js +94 -0
  173. package/src/utils/config-parser.js +2 -1
  174. package/src/utils/feature-flags.js +2 -1
  175. package/src/utils/flatten-entities.js +69 -0
  176. package/src/utils/framework-dir.js +16 -0
  177. package/src/utils/model-id.js +85 -0
  178. package/src/utils/provider-limits.js +84 -23
  179. package/src/utils/schema-validator.js +1 -1
  180. package/src/wizard/i18n.js +5 -4
  181. package/src/wizard/index.js +14 -0
  182. package/assets/logo - c/303/263pia.png +0 -0
  183. package/assets/logo.svg +0 -42
  184. package/assets/logo2.png +0 -0
  185. package/assets/social-preview.png +0 -0
  186. package/scripts/bundle-framework.js +0 -69
  187. package/scripts/changelog-generator.js +0 -222
  188. package/scripts/codebase-mapper.js +0 -728
  189. package/scripts/commit-message-generator.js +0 -167
  190. package/scripts/coverage-analyzer.js +0 -260
  191. package/scripts/dependency-analyzer.js +0 -280
  192. package/scripts/doctor/checks/agents.js +0 -77
  193. package/scripts/doctor/checks/constitution.js +0 -41
  194. package/scripts/doctor/checks/domain-alignment.js +0 -58
  195. package/scripts/doctor/checks/prism-layers.js +0 -84
  196. package/scripts/doctor/checks/registry.js +0 -55
  197. package/scripts/doctor/checks/schemas.js +0 -61
  198. package/scripts/doctor/fixes/reference-fix.js +0 -100
  199. package/scripts/doctor/fixes/registry-fix.js +0 -56
  200. package/scripts/doctor/index.js +0 -212
  201. package/scripts/framework-analyzer.js +0 -308
  202. package/scripts/generate-constitution-domain.js +0 -253
  203. package/scripts/generate-signing-key.js +0 -33
  204. package/scripts/health-check.js +0 -481
  205. package/scripts/ide-sync.js +0 -326
  206. package/scripts/performance-analyzer.js +0 -325
  207. package/scripts/plan-tracker.js +0 -278
  208. package/scripts/populate-entity-registry.js +0 -481
  209. package/scripts/pr-review.js +0 -317
  210. package/scripts/rollback-manager.js +0 -310
  211. package/scripts/semantic-lint.js +0 -328
  212. package/scripts/sign-manifest.js +0 -53
  213. package/scripts/stuck-detector.js +0 -343
  214. package/scripts/test-quality-assessment.js +0 -257
  215. package/scripts/validate-agents.js +0 -368
  216. package/scripts/validate-package.js +0 -505
  217. package/scripts/validate-tasks.js +0 -465
  218. package/src/autonomy/worktree-manager.js +0 -250
  219. package/src/intelligence/decision-engine.js +0 -256
  220. package/src/intelligence/document-sharder.js +0 -221
  221. package/src/intelligence/elicitation.js +0 -265
@@ -11,11 +11,19 @@
11
11
  * Sub-commands: next, advance, init, validate-handoff, status, deviation, exit
12
12
  */
13
13
 
14
+ import { resolveFrameworkDir } from '../utils/framework-dir.js';
15
+
14
16
  import {
15
17
  loadSession, initSession, updateSession, recordAgentCompletion,
16
18
  recordModeTransition, getSessionSummary, validateSession,
17
- releaseSession,
19
+ releaseSession, parseHandoffDecisionTrail, parseHandoffScaffoldSignals,
18
20
  } from './index.js';
21
+ import {
22
+ resolveScaffoldSource, loadScaffoldManifest, applyScaffold,
23
+ } from '../installer/scaffold-applier.js';
24
+ import { resolveContextLimit } from '../utils/provider-limits.js';
25
+ import { waitForLicense } from '../license/wait.js';
26
+ import { runDoctor } from './doctor.js';
19
27
  import {
20
28
  getNextAgent, getAgentDefinition, AGENT_PIPELINE,
21
29
  } from './index.js';
@@ -29,12 +37,18 @@ import {
29
37
  } from './index.js';
30
38
  import { analyzeDeviationImpact, applyDeviation } from './index.js';
31
39
  import { detectQuickFlow, detectStandardFlow } from './index.js';
32
- import { AGENT_FILE_MAP } from '../terminal/prompt-builder.js';
33
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
40
+ import { getAgentFile } from '../terminal/prompt-builder.js';
41
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
34
42
  import { join, resolve } from 'path';
43
+ import { execSync } from 'child_process';
44
+ import yaml from 'js-yaml';
35
45
  import {
36
46
  initTaskList, readTaskList, getTeamProgress,
37
47
  } from '../terminal/team-task-list.js';
48
+ import {
49
+ recordEvent, EventType, clearTimeline,
50
+ } from '../intelligence/timeline.js';
51
+ import { updateClaudeMd } from '../memory/magic-docs.js';
38
52
 
39
53
  // ---------------------------------------------------------------------------
40
54
  // Constants
@@ -44,14 +58,411 @@ const INTERACTIVE_AGENTS = ['greenfield-wu', 'brownfield-wu', 'brief'];
44
58
 
45
59
  const LOCK_START = '<!-- chati-lock:start -->';
46
60
  const LOCK_END = '<!-- chati-lock:end -->';
61
+ const STATE_START = '<!-- chati-state:start -->';
62
+ const STATE_END = '<!-- chati-state:end -->';
47
63
 
48
64
  const RESUME_MESSAGES = {
49
65
  en: 'Session saved. Type /chati anytime to resume.',
50
- pt: 'Sessao salva. Digite /chati para retomar.',
51
- es: 'Sesion guardada. Escribe /chati para reanudar.',
66
+ pt: 'Sessão salva. Digite /chati para retomar.',
67
+ es: 'Sesión guardada. Escribe /chati para reanudar.',
52
68
  fr: 'Session sauvee. Tapez /chati pour reprendre.',
53
69
  };
54
70
 
71
+ // ---------------------------------------------------------------------------
72
+ // QA-Visual scope helpers (Fase 6 — scope isolation)
73
+ //
74
+ // When shared CSS or token files change (globals.css, brand.ts, tailwind
75
+ // config), a regression in the affected utility can silently break routes
76
+ // that were NOT touched by the current task. The qa-visual gate uses
77
+ // these helpers to detect the situation and require full app/ coverage
78
+ // before allowing advance.
79
+ // ---------------------------------------------------------------------------
80
+
81
+ const SHARED_CSS_PATTERNS = [
82
+ /(^|\/)app\/globals\.css$/,
83
+ /(^|\/)src\/app\/globals\.css$/,
84
+ /(^|\/)lib\/brand\.ts$/,
85
+ /(^|\/)src\/lib\/brand\.ts$/,
86
+ /(^|\/)tailwind\.config\.(?:js|ts|mjs|cjs)$/,
87
+ /(^|\/)postcss\.config\.(?:js|ts|mjs|cjs)$/,
88
+ /(^|\/)lib\/animations\/tokens\.(?:ts|js)$/,
89
+ /(^|\/)src\/lib\/animations\/tokens\.(?:ts|js)$/,
90
+ ];
91
+
92
+ /**
93
+ * True if any entry in `filesModified` matches a shared CSS / token file.
94
+ * These files touch every route — a change here requires full coverage in QA-Visual.
95
+ * Exported for testing.
96
+ *
97
+ * @param {string[]} filesModified - Repo-relative file paths.
98
+ * @returns {boolean}
99
+ */
100
+ export function detectSharedCSSChanges(filesModified) {
101
+ if (!Array.isArray(filesModified) || filesModified.length === 0) return false;
102
+ return filesModified.some(f => typeof f === 'string' && SHARED_CSS_PATTERNS.some(p => p.test(f)));
103
+ }
104
+
105
+ /**
106
+ * Enumerate Next.js App Router routes by walking a project's `app/` directory
107
+ * and collecting `page.{tsx,jsx,ts,js,mdx}` files. Excludes:
108
+ * - dynamic segments (`[slug]/`, `[[...slug]]/`) — can't be captured statically
109
+ * - private folders (`_components/`)
110
+ * - route groups (`(marketing)/`) stripped from the URL path
111
+ *
112
+ * Returns null if no `app/` directory exists (non-Next.js project).
113
+ * Exported for testing.
114
+ *
115
+ * @param {string} projectDir
116
+ * @returns {string[] | null}
117
+ */
118
+ export function discoverAppRoutes(projectDir) {
119
+ if (!projectDir) return null;
120
+ // Common locations: app/ at root, or src/app/ (create-next-app option).
121
+ const candidates = [join(projectDir, 'app'), join(projectDir, 'src', 'app')];
122
+ const appDir = candidates.find(d => existsSync(d));
123
+ if (!appDir) return null;
124
+
125
+ const routes = [];
126
+ const PAGE_RE = /^page\.(tsx|jsx|ts|js|mdx)$/;
127
+
128
+ function walk(dir, segments) {
129
+ let entries;
130
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
131
+ for (const e of entries) {
132
+ if (e.isDirectory()) {
133
+ const name = e.name;
134
+ // Skip private (underscore) and dynamic segments
135
+ if (name.startsWith('_')) continue;
136
+ if (name.startsWith('[')) continue; // dynamic: [slug], [...catchAll], [[...slug]]
137
+ // Route groups (parens) wrap but don't appear in URL
138
+ const isGroup = name.startsWith('(') && name.endsWith(')');
139
+ const nextSegments = isGroup ? segments : [...segments, name];
140
+ walk(join(dir, e.name), nextSegments);
141
+ } else if (e.isFile() && PAGE_RE.test(e.name)) {
142
+ routes.push(segments.length === 0 ? '/' : '/' + segments.join('/'));
143
+ }
144
+ }
145
+ }
146
+ walk(appDir, []);
147
+ // Deduplicate (route groups can produce the same URL twice) and sort.
148
+ return [...new Set(routes)].sort();
149
+ }
150
+
151
+ /**
152
+ * Run `git diff --name-only` to list files modified in the current working
153
+ * copy since HEAD, plus files changed relative to main. Silently returns []
154
+ * if git is unavailable or projectDir is not a git repo.
155
+ *
156
+ * @param {string} projectDir
157
+ * @returns {string[]}
158
+ */
159
+ export function getModifiedFiles(projectDir) {
160
+ if (!projectDir) return [];
161
+ const cmds = [
162
+ 'git diff --name-only HEAD 2>/dev/null',
163
+ 'git diff --name-only --cached 2>/dev/null',
164
+ 'git diff --name-only main...HEAD 2>/dev/null',
165
+ ];
166
+ const out = new Set();
167
+ for (const cmd of cmds) {
168
+ try {
169
+ const raw = execSync(cmd, { cwd: projectDir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
170
+ for (const line of raw.split('\n')) {
171
+ const f = line.trim();
172
+ if (f) out.add(f);
173
+ }
174
+ } catch { /* silently skip failed command (not a repo, no main branch, etc.) */ }
175
+ }
176
+ return [...out];
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Reasoning Tier (Fase 9 — Article XXIII)
181
+ //
182
+ // Per-agent DEFAULT tiers. The escalator hook promotes from default upward
183
+ // based on friction signals; de-escalation only happens on an explicit
184
+ // /quick slash command.
185
+ // ---------------------------------------------------------------------------
186
+
187
+ export const REASONING_TIERS = ['standard', 'deep', 'deliberate'];
188
+
189
+ export const AGENT_DEFAULT_TIER = {
190
+ 'greenfield-wu': 'standard',
191
+ 'brownfield-wu': 'deep',
192
+ 'brief': 'standard',
193
+ 'detail': 'deep',
194
+ 'architect': 'deep',
195
+ 'ux': 'standard',
196
+ 'phases': 'standard',
197
+ 'tasks': 'standard',
198
+ 'qa-planning': 'deep',
199
+ 'dev': 'standard',
200
+ 'qa-implementation': 'deep',
201
+ 'qa-visual': 'standard',
202
+ 'devops': 'standard',
203
+ };
204
+
205
+ /**
206
+ * Read the current reasoning_tier from a session object. Falls back to the
207
+ * per-agent default when the session field is missing or invalid.
208
+ * Exported for testing.
209
+ */
210
+ export function getReasoningTier(session) {
211
+ if (!session) return 'standard';
212
+ const recorded = session.reasoning_tier;
213
+ if (REASONING_TIERS.includes(recorded)) return recorded;
214
+ const agent = session.current_agent;
215
+ if (agent && AGENT_DEFAULT_TIER[agent]) return AGENT_DEFAULT_TIER[agent];
216
+ return 'standard';
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Premium-reference detection (Fase 7 — task-to-UX fidelity)
221
+ //
222
+ // When the brief or reference-analysis cites a well-known premium-animation
223
+ // site (Oryzo, Norris, Relats, Wero, igloo inc, Awwwards entries) OR uses
224
+ // specific phrase constructions that signal premium motion intent
225
+ // (cinematic scroll, horizontal pin, scroll-driven, award-winning), the
226
+ // brand-architect agent MUST produce an animation-inventory.md artifact
227
+ // that names every observed pattern and maps it to a scaffold template.
228
+ // The inventory is consumed 1:1 by the tasks agent and used as the
229
+ // checklist by qa-visual Mode 2. Without it, the downstream pipeline
230
+ // produces generic "add scroll reveals" tasks that fail to reproduce the
231
+ // specific patterns the user asked for. The gate below enforces the
232
+ // requirement at advance-ux time.
233
+ // ---------------------------------------------------------------------------
234
+
235
+ const PREMIUM_REF_PATTERNS = [
236
+ // Named premium-animation reference sites.
237
+ { label: 'Oryzo', pattern: /\boryzo\b/i },
238
+ { label: 'Norris', pattern: /\bnorris\b/i },
239
+ { label: 'Relats', pattern: /\brelats\b/i },
240
+ { label: 'Wero', pattern: /\bwero\b/i },
241
+ { label: 'Igloo Inc', pattern: /\bigloo\s*inc\b/i },
242
+ { label: 'Awwwards', pattern: /\bawwwards\b/i },
243
+ // Phrase constructions that signal premium animation intent.
244
+ { label: 'premium-animation', pattern: /\bpremium\s+(?:animation|motion|feel|experience)/i },
245
+ { label: 'cinematic-scroll', pattern: /\bcinematic\s+(?:scroll|experience|transitions?)/i },
246
+ { label: 'horizontal-pin', pattern: /\bhorizontal[- ]?pin(?:ned)?\s+(?:scroll|sequence|section)/i },
247
+ { label: 'scroll-driven', pattern: /\bscroll[- ]?driven\s+(?:animation|design|experience|crossfade|background)/i },
248
+ { label: 'award-winning', pattern: /\baward[- ]?winning\s+(?:design|animation|website|experience)/i },
249
+ { label: 'pixel-perfect-motion', pattern: /\bpixel[- ]?perfect\s+(?:animation|motion)/i },
250
+ ];
251
+
252
+ /**
253
+ * Detect premium-animation references in text (typically brief-report.md
254
+ * and/or reference-analysis.md concatenated). Exported for testing.
255
+ *
256
+ * @param {string} text
257
+ * @returns {{ detected: boolean, matches: string[] }}
258
+ */
259
+ export function detectPremiumRefs(text) {
260
+ if (!text || typeof text !== 'string') return { detected: false, matches: [] };
261
+ const matches = [];
262
+ for (const { label, pattern } of PREMIUM_REF_PATTERNS) {
263
+ if (pattern.test(text)) matches.push(label);
264
+ }
265
+ return { detected: matches.length > 0, matches };
266
+ }
267
+
268
+ /**
269
+ * Read brief-report.md + reference-analysis.md from a project as a single
270
+ * corpus for premium-ref detection. Missing files are silently skipped.
271
+ * Exported for testing.
272
+ *
273
+ * @param {string} projectDir
274
+ * @returns {string}
275
+ */
276
+ export function readBriefAndRefCorpus(projectDir) {
277
+ if (!projectDir) return '';
278
+ const paths = [
279
+ join(projectDir, 'artifacts', '1-Brief', 'brief-report.md'),
280
+ join(projectDir, 'artifacts', '4-UX', 'reference-analysis.md'),
281
+ ];
282
+ let corpus = '';
283
+ for (const p of paths) {
284
+ if (!existsSync(p)) continue;
285
+ try { corpus += readFileSync(p, 'utf-8') + '\n'; } catch { /* ignore */ }
286
+ }
287
+ return corpus;
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // State-at-a-glance card (Fase 8)
292
+ //
293
+ // Every handleNext response and every correction-loop trigger carries a
294
+ // rendered multi-line "state card" that gives the human operator a one-
295
+ // glance summary without grepping session.yaml. Box-drawn, unicode,
296
+ // fixed width for predictability.
297
+ // ---------------------------------------------------------------------------
298
+
299
+ export const STATE_CARD_TOTAL_WIDTH = 43;
300
+ export const STATE_CARD_CONTENT_WIDTH = 39;
301
+ const STATE_CARD_MAX_NAME = 30;
302
+
303
+ function _stateTruncate(s, maxLen) {
304
+ if (s === null || s === undefined) return '';
305
+ const str = String(s);
306
+ if (str.length <= maxLen) return str;
307
+ return str.slice(0, Math.max(0, maxLen - 1)) + '…';
308
+ }
309
+
310
+ function _statePadRight(s, width) {
311
+ const str = _stateTruncate(s, width);
312
+ return str + ' '.repeat(Math.max(0, width - str.length));
313
+ }
314
+
315
+ /**
316
+ * Pure formatter for the state-at-a-glance card. All input fields are
317
+ * taken as-is (no I/O). `buildStateCard` is the I/O wrapper that
318
+ * collects the fields from session + git + disk. Exported for testing.
319
+ *
320
+ * Card shape (fixed widths, STATE_CARD_TOTAL_WIDTH = 43 chars):
321
+ * ┌─ {project} ─────────────┐
322
+ * │ {Cycle N •} {agent status} • {mode} │
323
+ * │ Last commit: {sha7} • QA: {score|—} │
324
+ * │ Open: {N} item(s) from audit │
325
+ * [│ ↺ Reset: {agent-list} │] (only if correctionReset)
326
+ * └───────────────────────────────────────┘
327
+ *
328
+ * @param {object} opts
329
+ * @param {string} opts.projectName
330
+ * @param {number|null} [opts.cycle] - if number, "Cycle N" prefix is added
331
+ * @param {string} opts.agent
332
+ * @param {string} opts.agentStatus - pending | in_progress | completed | …
333
+ * @param {string} opts.mode - execution_mode (interactive|autonomous) per Article XVII
334
+ * @param {string} opts.commitShort - 7-char SHA or 'unknown'
335
+ * @param {number|null} opts.qaScore - numeric 0..100 or null (shows —)
336
+ * @param {number} opts.backlogCount
337
+ * @param {{agents?: string[], files?: string[]}|null} [opts.correctionReset]
338
+ * @returns {string} Multi-line card (use console.log or JSON field).
339
+ */
340
+ export function formatStateCard(opts = {}) {
341
+ const projectName = _stateTruncate(opts.projectName || 'unnamed project', STATE_CARD_MAX_NAME);
342
+ const dashesAfterName = STATE_CARD_TOTAL_WIDTH - 4 - projectName.length - 1;
343
+ const topLine = `┌─ ${projectName} ${'─'.repeat(Math.max(0, dashesAfterName))}┐`;
344
+
345
+ const parts1 = [];
346
+ if (typeof opts.cycle === 'number' && Number.isFinite(opts.cycle)) {
347
+ parts1.push(`Cycle ${opts.cycle}`);
348
+ }
349
+ parts1.push(`${opts.agent || 'none'} ${opts.agentStatus || 'pending'}`);
350
+ parts1.push(opts.mode || 'interactive');
351
+ const line1 = _statePadRight(parts1.join(' • '), STATE_CARD_CONTENT_WIDTH);
352
+
353
+ const qaDisplay = opts.qaScore !== null && opts.qaScore !== undefined ? String(opts.qaScore) : '—';
354
+ const line2 = _statePadRight(
355
+ `Last commit: ${opts.commitShort || 'unknown'} • QA: ${qaDisplay}`,
356
+ STATE_CARD_CONTENT_WIDTH,
357
+ );
358
+
359
+ const n = Number.isFinite(opts.backlogCount) ? opts.backlogCount : 0;
360
+ const line3 = _statePadRight(`Open: ${n} item${n === 1 ? '' : 's'} from audit`, STATE_CARD_CONTENT_WIDTH);
361
+
362
+ const lines = [topLine, `│ ${line1} │`, `│ ${line2} │`, `│ ${line3} │`];
363
+
364
+ if (opts.correctionReset && (
365
+ (opts.correctionReset.agents && opts.correctionReset.agents.length > 0) ||
366
+ (opts.correctionReset.files && opts.correctionReset.files.length > 0)
367
+ )) {
368
+ const agents = (opts.correctionReset.agents || []).join(', ');
369
+ const filesCount = (opts.correctionReset.files || []).length;
370
+ const resetDetail = [agents, filesCount > 0 ? `(+${filesCount} file${filesCount === 1 ? '' : 's'})` : '']
371
+ .filter(Boolean).join(' ') || 'n/a';
372
+ const resetLine = _statePadRight(`↺ Reset: ${resetDetail}`, STATE_CARD_CONTENT_WIDTH);
373
+ lines.push(`│ ${resetLine} │`);
374
+ }
375
+
376
+ lines.push(`└${'─'.repeat(STATE_CARD_TOTAL_WIDTH - 2)}┘`);
377
+ return lines.join('\n');
378
+ }
379
+
380
+ /**
381
+ * Collect state from session + git + qa report, then render the card via
382
+ * formatStateCard. Silently degrades when optional sources are missing
383
+ * (no git, no qa-visual report, etc.) — the card always renders.
384
+ * Exported for testing.
385
+ *
386
+ * @param {object} args
387
+ * @param {object} args.session - loaded session.yaml
388
+ * @param {string} args.projectDir
389
+ * @param {{agents?: string[], files?: string[]}|null} [args.correctionReset]
390
+ * @returns {string}
391
+ */
392
+ export function buildStateCard({ session, projectDir, correctionReset = null } = {}) {
393
+ if (!session) return '';
394
+
395
+ const projectName = session.project?.name || 'unnamed project';
396
+ const agent = session.current_agent || 'none';
397
+ const agentStatus = session.agents?.[agent]?.status || 'pending';
398
+ const mode = session.execution_mode || 'interactive';
399
+ const cycle = typeof session.cycle === 'number' ? session.cycle : null;
400
+
401
+ // Last commit SHA (7 chars) — silent on non-git dirs
402
+ let commitShort = 'unknown';
403
+ if (projectDir) {
404
+ try {
405
+ const out = execSync('git log --pretty=%h -1 HEAD', {
406
+ cwd: projectDir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
407
+ }).trim();
408
+ if (/^[a-f0-9]{7,40}$/.test(out)) commitShort = out.slice(0, 7);
409
+ } catch { /* not a repo / no commits */ }
410
+ }
411
+
412
+ // QA-Visual score — session.agents['qa-visual'].score takes priority; fallback to report.md.
413
+ let qaScore = null;
414
+ const qaAgentScore = session.agents?.['qa-visual']?.score;
415
+ if (typeof qaAgentScore === 'number' && qaAgentScore > 0) {
416
+ qaScore = qaAgentScore;
417
+ } else if (projectDir) {
418
+ const candidates = [
419
+ join(projectDir, 'artifacts', '9-QA-Implementation', 'qa-visual-report.md'),
420
+ join(projectDir, 'artifacts', '8-QA-Visual', 'visual-qa-analysis.md'),
421
+ ];
422
+ for (const p of candidates) {
423
+ if (!existsSync(p)) continue;
424
+ try {
425
+ const txt = readFileSync(p, 'utf-8');
426
+ const m = txt.match(/(?:^|\n)\s*(?:##\s+)?Score:\s*(\d+(?:\.\d+)?)\s*%?/);
427
+ if (m) { qaScore = parseFloat(m[1]); break; }
428
+ } catch { /* ignore */ }
429
+ }
430
+ }
431
+
432
+ const backlogCount = Array.isArray(session.backlog) ? session.backlog.length : 0;
433
+
434
+ return formatStateCard({
435
+ projectName, cycle, agent, agentStatus, mode, commitShort, qaScore, backlogCount, correctionReset,
436
+ });
437
+ }
438
+
439
+ /**
440
+ * Evaluate whether a shared CSS change requires blocking the qa-visual advance
441
+ * due to incomplete route coverage. Pure function (no git, no file-reading for
442
+ * modifiedFiles) — caller supplies `modifiedFiles`. Exported for testing.
443
+ *
444
+ * @param {object} opts
445
+ * @param {Array<{ path: string }>} opts.capturedPages - Pages present in report.json
446
+ * @param {string} opts.projectDir - For discoverAppRoutes
447
+ * @param {string[]} opts.modifiedFiles
448
+ * @returns {{ blocked: false } | { blocked: true, reason: string, triggeringFiles: string[], appRoutes: string[], uncovered: string[] }}
449
+ */
450
+ export function checkSharedCSSCoverage({ capturedPages, projectDir, modifiedFiles }) {
451
+ if (!detectSharedCSSChanges(modifiedFiles)) return { blocked: false };
452
+ const appRoutes = discoverAppRoutes(projectDir);
453
+ if (!appRoutes || appRoutes.length === 0) return { blocked: false };
454
+ const capturedPaths = new Set((capturedPages || []).map(p => p.path));
455
+ const uncovered = appRoutes.filter(r => !capturedPaths.has(r));
456
+ if (uncovered.length === 0) return { blocked: false };
457
+ return {
458
+ blocked: true,
459
+ reason: 'shared_css_change_requires_full_coverage',
460
+ triggeringFiles: modifiedFiles.filter(f => SHARED_CSS_PATTERNS.some(p => p.test(f))),
461
+ appRoutes,
462
+ uncovered,
463
+ };
464
+ }
465
+
55
466
  // ---------------------------------------------------------------------------
56
467
  // Helpers
57
468
  // ---------------------------------------------------------------------------
@@ -113,6 +524,7 @@ function sessionToPipelineState(session, projectDir) {
113
524
  currentAgent: session.current_agent || null,
114
525
  modeTransitions: session.mode_transitions || [],
115
526
  history: [],
527
+ correctionCycles: { ...(session.correction_cycles || {}) },
116
528
  };
117
529
  }
118
530
 
@@ -165,6 +577,7 @@ const AGENT_MODEL_MAP = {
165
577
  'qa-planning': { provider: 'claude', model: 'opus', upgrade: 'no downgrade' },
166
578
  dev: { provider: 'claude', model: 'opus', upgrade: 'no downgrade' },
167
579
  'qa-implementation': { provider: 'claude', model: 'opus', upgrade: 'no downgrade' },
580
+ 'qa-visual': { provider: 'claude', model: 'opus', upgrade: 'no downgrade' },
168
581
  devops: { provider: 'claude', model: 'sonnet', upgrade: 'opus if multi-environment or IaC' },
169
582
  };
170
583
 
@@ -177,7 +590,7 @@ function resolveAgentModel(agent, projectDir) {
177
590
 
178
591
  // Check config.yaml for agent_overrides
179
592
  try {
180
- const configPath = join(projectDir, 'chati.dev', 'config.yaml');
593
+ const configPath = join(projectDir, resolveFrameworkDir(projectDir), 'config.yaml');
181
594
  if (existsSync(configPath)) {
182
595
  const configRaw = readFileSync(configPath, 'utf-8');
183
596
  // Simple YAML parsing for agent_overrides section
@@ -207,33 +620,48 @@ function estimateContextBracket(completedCount, totalCount) {
207
620
  /**
208
621
  * Write session lock block to CLAUDE.local.md.
209
622
  */
210
- function writeSessionLock(projectDir, currentAgent) {
211
- const localMdPath = join(projectDir, 'CLAUDE.local.md');
212
- let content = '';
213
- if (existsSync(localMdPath)) {
214
- content = readFileSync(localMdPath, 'utf-8');
215
- }
216
-
217
- // Remove existing lock if present
218
- const startIdx = content.indexOf(LOCK_START);
219
- const endIdx = content.indexOf(LOCK_END);
623
+ function replaceBlock(content, startMarker, endMarker, newInner) {
624
+ const block = `${startMarker}\n${newInner}\n${endMarker}`;
625
+ const startIdx = content.indexOf(startMarker);
626
+ const endIdx = content.indexOf(endMarker);
220
627
  if (startIdx !== -1 && endIdx !== -1) {
221
- content = content.slice(0, startIdx) + content.slice(endIdx + LOCK_END.length);
628
+ return content.slice(0, startIdx) + block + content.slice(endIdx + endMarker.length);
222
629
  }
630
+ return content.trimEnd() + '\n\n' + block + '\n';
631
+ }
632
+
633
+ function writeSessionLock(projectDir, currentAgent, stateInfo = {}) {
634
+ const localMdPath = join(projectDir, 'CLAUDE.local.md');
635
+ let content = existsSync(localMdPath) ? readFileSync(localMdPath, 'utf-8') : '';
636
+
637
+ // Phase display is authoritatively driven by session.mode on disk. Callers
638
+ // previously passed stateInfo.phase derived from transient intermediate
639
+ // values (the next agent's expected phase before user approval), causing
640
+ // the lock to drift ahead of session.yaml (e.g. show "build" while
641
+ // session.mode was still "plan" awaiting approval).
642
+ let authoritativePhase = null;
643
+ try {
644
+ const { loaded, session } = loadSession(projectDir);
645
+ if (loaded && session?.mode) authoritativePhase = session.mode;
646
+ } catch { /* non-fatal — fall back to stateInfo */ }
223
647
 
224
- const lockBlock = `${LOCK_START}
225
- ## Session Lock -- ACTIVE
648
+ const lockInner = `## Session Lock -- ACTIVE
226
649
 
227
650
  **Chati.dev session is ACTIVE.** Follow these rules for EVERY message:
228
651
 
229
- 1. Read \`chati.dev/orchestrator/chati.md\` and follow its routing logic
652
+ 1. Read \`${resolveFrameworkDir(projectDir)}/orchestrator/chati.md\` and follow its routing logic
230
653
  2. Route ALL user messages through the current agent: \`${currentAgent}\`
231
654
  3. NEVER respond outside of the Chati.dev system
232
- 4. The ONLY way to exit is via \`/chati exit\`, \`/chati stop\`, or \`/chati quit\`
233
- ${LOCK_END}
234
- `;
655
+ 4. The ONLY way to exit is via \`/chati exit\`, \`/chati stop\`, or \`/chati quit\``;
656
+
657
+ const stateInner = `## Current State
658
+ - **Agent**: ${currentAgent || 'None'}
659
+ - **Phase**: ${authoritativePhase || stateInfo.phase || 'discover'}
660
+ - **Pipeline**: ${stateInfo.position ?? 0}/${stateInfo.total ?? '?'} (${stateInfo.progress ?? 0}%)
661
+ - **Mode**: ${stateInfo.mode || 'interactive'}`;
235
662
 
236
- content = content.trimEnd() + '\n\n' + lockBlock;
663
+ content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
664
+ content = replaceBlock(content, STATE_START, STATE_END, stateInner);
237
665
  writeFileSync(localMdPath, content, 'utf-8');
238
666
  }
239
667
 
@@ -246,11 +674,15 @@ function removeSessionLock(projectDir, resumeMsg) {
246
674
 
247
675
  let content = readFileSync(localMdPath, 'utf-8');
248
676
 
249
- const startIdx = content.indexOf(LOCK_START);
250
- const endIdx = content.indexOf(LOCK_END);
251
- if (startIdx !== -1 && endIdx !== -1) {
252
- content = content.slice(0, startIdx) + content.slice(endIdx + LOCK_END.length);
253
- }
677
+ const lockInner = `## Session Lock
678
+ **Status: INACTIVE** Type \`/chati\` to activate.`;
679
+ const stateInner = `## Current State
680
+ - **Agent**: None (ready to start)
681
+ - **Pipeline**: Pre-start
682
+ - **Mode**: interactive`;
683
+
684
+ content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
685
+ content = replaceBlock(content, STATE_START, STATE_END, stateInner);
254
686
 
255
687
  if (resumeMsg) {
256
688
  content = content.trimEnd() + `\n\n## Session Paused\n\n${resumeMsg}\n`;
@@ -264,6 +696,48 @@ function removeSessionLock(projectDir, resumeMsg) {
264
696
  // ---------------------------------------------------------------------------
265
697
 
266
698
  async function handleNext(projectDir) {
699
+ const result = await _handleNextInner(projectDir);
700
+
701
+ // Fase 8: attach state-at-a-glance card to every non-error response.
702
+ // The card renders as a JSON string field; the terminal / orchestrator
703
+ // prompt can console.log it verbatim. Silent on setup/error responses
704
+ // so a missing session does not produce a misleading card.
705
+ if (result && result.action !== 'setup' && result.action !== 'error') {
706
+ try {
707
+ const { session } = loadSession(projectDir);
708
+ if (session) {
709
+ result.state_card = buildStateCard({ session, projectDir });
710
+ }
711
+ } catch { /* non-fatal — card is UX sugar */ }
712
+ }
713
+
714
+ // Centralized session lock update: applies to ALL return paths.
715
+ // Activates lock when an agent is returned, deactivates on completion.
716
+ if (result && result.agent && result.action !== 'error' && result.action !== 'setup') {
717
+ const activating = ['activate_interactive', 'spawn_autonomous', 'spawn_team', 'spawn_parallel'];
718
+ if (activating.includes(result.action)) {
719
+ try {
720
+ writeSessionLock(projectDir, result.agent, {
721
+ phase: result.phase || 'discover',
722
+ mode: result.session?.execution_mode || 'interactive',
723
+ position: result.pipeline_progress?.completedAgents?.length ?? 0,
724
+ total: AGENT_PIPELINE.length,
725
+ progress: result.pipeline_progress?.progress ?? 0,
726
+ });
727
+ } catch { /* non-fatal */ }
728
+ }
729
+ } else if (result && result.action === 'complete') {
730
+ try {
731
+ const { session } = loadSession(projectDir);
732
+ const lang = session?.language || 'en';
733
+ removeSessionLock(projectDir, RESUME_MESSAGES[lang] || RESUME_MESSAGES.en);
734
+ } catch { /* non-fatal */ }
735
+ }
736
+
737
+ return result;
738
+ }
739
+
740
+ async function _handleNextInner(projectDir) {
267
741
  const { loaded, session } = loadSession(projectDir);
268
742
  if (!loaded || !session) {
269
743
  return { action: 'setup', status_summary: 'No session found. Project needs initialization.' };
@@ -291,11 +765,37 @@ async function handleNext(projectDir) {
291
765
  if (currentAgent && !completedAgents.includes(currentAgent)) {
292
766
  const agent = currentAgent;
293
767
  const agentStatus = session.agents?.[agent]?.status;
294
- if (agentStatus === 'in_progress' || (agentStatus !== 'completed' && agentStatus !== 'skipped')) {
295
- const agentFile = AGENT_FILE_MAP[agent] || null;
768
+ if (agentStatus === 'in_progress') {
769
+ const agentFile = getAgentFile(agent, projectDir) || null;
296
770
  const agentDef = getAgentDefinition(agent);
297
771
  const isInteractive = INTERACTIVE_AGENTS.includes(agent);
298
772
 
773
+ // Build Team override: even when resuming, if agent is 'dev' and teams
774
+ // enabled, return spawn_team so Dev + QA-Implementation run together.
775
+ if (agent === 'dev' && isAgentTeamsEnabled(projectDir)) {
776
+ const teamConfig = TEAM_CONFIGS.build;
777
+ const teamId = generateTeamId(teamConfig.slug);
778
+ return {
779
+ action: 'spawn_team',
780
+ agent,
781
+ agent_file: agentFile,
782
+ phase: agentDef?.phase || session.mode,
783
+ spawn_command: null,
784
+ parallel_spawn_command: null,
785
+ parallel_agents: teamConfig.members,
786
+ handoff_status: { valid: true, missing: [], warnings: ['Resuming dev as Build Team'] },
787
+ gate_status: { canAdvance: true, reason: 'Build Team formation (Article XXI)' },
788
+ model_info: resolveAgentModel(agent, projectDir),
789
+ context_bracket: estimateContextBracket(completedAgents.length, AGENT_PIPELINE.length),
790
+ pipeline_progress: getPipelineProgress(pipelineState),
791
+ session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
792
+ status_summary: `Forming Build Team: dev + qa-implementation.`,
793
+ team_id: teamId,
794
+ team_type: 'build',
795
+ members: teamConfig.members,
796
+ };
797
+ }
798
+
299
799
  return {
300
800
  action: isInteractive ? 'activate_interactive' : 'spawn_autonomous',
301
801
  agent,
@@ -322,7 +822,7 @@ async function handleNext(projectDir) {
322
822
  if (!lastAgent || completedAgents.length === 0) {
323
823
  const projectType = session.project_type || session.project?.type || 'greenfield';
324
824
  const firstAgent = projectType === 'brownfield' ? 'brownfield-wu' : 'greenfield-wu';
325
- const agentFile = AGENT_FILE_MAP[firstAgent] || null;
825
+ const agentFile = getAgentFile(firstAgent, projectDir) || null;
326
826
 
327
827
  return {
328
828
  action: 'activate_interactive',
@@ -342,8 +842,24 @@ async function handleNext(projectDir) {
342
842
  };
343
843
  }
344
844
 
345
- // Get next agent
346
- const nextInfo = getNextAgent(lastAgent, completedAgents);
845
+ // Get next agent — if lastAgent is not in AGENT_PIPELINE (e.g. qa-planning
846
+ // which lives inside Planning Team), fall back to the last completed agent
847
+ // that IS in the pipeline so we can find the correct successor.
848
+ let nextInfo = getNextAgent(lastAgent, completedAgents);
849
+
850
+ if ((!nextInfo || !nextInfo.next) && lastAgent) {
851
+ const agentInPipeline = getAgentDefinition(lastAgent);
852
+ if (!agentInPipeline) {
853
+ // lastAgent is not in AGENT_PIPELINE — find the last completed agent that IS
854
+ const pipelineNames = AGENT_PIPELINE.map((a) => a.name);
855
+ for (let i = completedAgents.length - 1; i >= 0; i--) {
856
+ if (pipelineNames.includes(completedAgents[i])) {
857
+ nextInfo = getNextAgent(completedAgents[i], completedAgents);
858
+ if (nextInfo && nextInfo.next) break;
859
+ }
860
+ }
861
+ }
862
+ }
347
863
 
348
864
  if (!nextInfo || !nextInfo.next) {
349
865
  return {
@@ -355,7 +871,7 @@ async function handleNext(projectDir) {
355
871
  }
356
872
 
357
873
  const nextAgent = nextInfo.next;
358
- const agentFile = AGENT_FILE_MAP[nextAgent] || null;
874
+ const agentFile = getAgentFile(nextAgent, projectDir) || null;
359
875
  const agentDef = getAgentDefinition(nextAgent);
360
876
  const isInteractive = INTERACTIVE_AGENTS.includes(nextAgent);
361
877
 
@@ -415,6 +931,9 @@ async function handleNext(projectDir) {
415
931
  spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000);
416
932
  }
417
933
 
934
+ // Record agent activation event
935
+ recordEvent(projectDir, EventType.AGENT_ACTIVATED, nextAgent, { action, phase: agentDef?.phase });
936
+
418
937
  const progress = getPipelineProgress(pipelineState);
419
938
  const bracket = estimateContextBracket(completedAgents.length, AGENT_PIPELINE.length);
420
939
 
@@ -445,6 +964,262 @@ async function handleNext(projectDir) {
445
964
  return result;
446
965
  }
447
966
 
967
+ // ---------------------------------------------------------------------------
968
+ // Fase 10 — Scaffold auto-gate helpers
969
+ //
970
+ // Problem: after qa-planning approves the plan, the framework knows (from
971
+ // greenfield-wu's scaffold_candidates + ux-brand-architect's scaffold_signals)
972
+ // whether a scaffold preset like motion-premium would save the Build phase
973
+ // from reinventing animation wiring by hand. Without a deterministic gate,
974
+ // the orchestrator must rely on Claude remembering to offer the scaffold —
975
+ // and Claude forgets. This gate turns remembering into code.
976
+ //
977
+ // A preset "fires" when:
978
+ // 1. It is in session.scaffold_candidates (stack match, Signal 1).
979
+ // 2. It has a signal with confidence >= 0.7 (premium intent, Signal 2).
980
+ // 3. It is NOT yet in session.scaffold_applied (idempotency).
981
+ // ---------------------------------------------------------------------------
982
+
983
+ const SCAFFOLD_CONFIDENCE_THRESHOLD = 0.7;
984
+
985
+ // Per-preset threshold overrides. ADR-3D-07 sets motion-premium-3d to 0.8
986
+ // (higher than motion-premium's 0.7) — false positives cost ~600KB of
987
+ // bundle pressed onto a project that did not need 3D. New presets added
988
+ // here; any preset not in the map inherits SCAFFOLD_CONFIDENCE_THRESHOLD.
989
+ const PRESET_CONFIDENCE_THRESHOLDS = {
990
+ 'motion-premium': 0.7,
991
+ 'motion-premium-3d': 0.8,
992
+ };
993
+
994
+ function thresholdFor(preset) {
995
+ return PRESET_CONFIDENCE_THRESHOLDS[preset] ?? SCAFFOLD_CONFIDENCE_THRESHOLD;
996
+ }
997
+
998
+ /**
999
+ * Evaluate which (if any) scaffold preset's auto-gate should fire.
1000
+ * Enumerates candidates in session order; first preset whose signal
1001
+ * confidence meets its per-preset threshold AND is not already applied
1002
+ * wins. When a user applies or skips one preset, the next advance call
1003
+ * re-evaluates — letting a project with BOTH motion-premium and
1004
+ * motion-premium-3d signals receive both prompts in sequence.
1005
+ *
1006
+ * @param {object} session - Post-completion session
1007
+ * @returns {{ fires: boolean, preset: string|null, signalKey: string|null, confidence: number, threshold: number, evidence: string[], reason: string|null }}
1008
+ */
1009
+ function checkScaffoldGate(session) {
1010
+ const candidates = Array.isArray(session?.scaffold_candidates) ? session.scaffold_candidates : [];
1011
+ const signals = (session?.scaffold_signals && typeof session.scaffold_signals === 'object') ? session.scaffold_signals : {};
1012
+ const applied = new Set(Array.isArray(session?.scaffold_applied) ? session.scaffold_applied : []);
1013
+
1014
+ for (const preset of candidates) {
1015
+ if (applied.has(preset)) continue;
1016
+ const key = preset.replace(/-/g, '_');
1017
+ const signal = signals[key];
1018
+ if (!signal) continue;
1019
+ const confidence = typeof signal.confidence === 'number' ? signal.confidence : 0;
1020
+ const threshold = thresholdFor(preset);
1021
+ if (confidence < threshold) continue;
1022
+ return {
1023
+ fires: true,
1024
+ preset,
1025
+ signalKey: key,
1026
+ confidence,
1027
+ threshold,
1028
+ evidence: Array.isArray(signal.evidence) ? signal.evidence : [],
1029
+ reason: 'scaffold_decision_required',
1030
+ };
1031
+ }
1032
+
1033
+ return { fires: false, preset: null, signalKey: null, confidence: 0, threshold: SCAFFOLD_CONFIDENCE_THRESHOLD, evidence: [], reason: null };
1034
+ }
1035
+
1036
+ /**
1037
+ * Build the action_required payload the orchestrator presents to the user
1038
+ * when a scaffold gate fires.
1039
+ */
1040
+ function buildScaffoldGateResponse(gate, { agent, score, phaseTransition }) {
1041
+ return {
1042
+ advanced: true,
1043
+ agent_completed: agent,
1044
+ score,
1045
+ phase_transition: phaseTransition || { triggered: false },
1046
+ scaffold_decision_required: true,
1047
+ scaffold_gate: {
1048
+ preset: gate.preset,
1049
+ confidence: gate.confidence,
1050
+ evidence: gate.evidence,
1051
+ },
1052
+ next: {
1053
+ action: 'scaffold_decision',
1054
+ agent: null,
1055
+ pending_preset: gate.preset,
1056
+ confidence: gate.confidence,
1057
+ status_summary: `Planning approved (qa-planning score ${score}). Scaffold "${gate.preset}" is eligible (confidence ${gate.confidence.toFixed(2)}). Present three options to the user before advancing to dev.`,
1058
+ options: [
1059
+ {
1060
+ id: 'apply',
1061
+ label: `Apply ${gate.preset} scaffold (15 files, Premium-tier animation wiring)`,
1062
+ command: `advance --agent qa-planning --score ${score} --decision apply`,
1063
+ description: 'Writes the scaffold templates to the project. Recommended — the Animation Inventory maps 1:1 to these files.',
1064
+ },
1065
+ {
1066
+ id: 'dryrun',
1067
+ label: `Preview ${gate.preset} scaffold (list files, write nothing)`,
1068
+ command: `advance --agent qa-planning --score ${score} --decision dryrun`,
1069
+ description: 'Shows what would be written. Run this first if you want to see the diff before committing.',
1070
+ },
1071
+ {
1072
+ id: 'skip',
1073
+ label: `Skip ${gate.preset} (proceed to dev without scaffold)`,
1074
+ command: `advance --agent qa-planning --score ${score} --decision skip`,
1075
+ description: 'Dev will have to implement each Animation Inventory row from scratch. Choose this only if the project cannot use the scaffold (e.g. alternative animation stack).',
1076
+ },
1077
+ ],
1078
+ },
1079
+ };
1080
+ }
1081
+
1082
+ /**
1083
+ * Execute a scaffold decision (apply|skip|dryrun), update session, and
1084
+ * advance the pipeline from qa-planning to dev. Reuses applyScaffold from
1085
+ * the installer module (Fase 4) so the programmatic and CLI-wrapped paths
1086
+ * share one implementation.
1087
+ *
1088
+ * @param {string} projectDir
1089
+ * @param {object} session - Pre-decision session (qa-planning already completed)
1090
+ * @param {'apply'|'skip'|'dryrun'} decision
1091
+ * @param {string} agent - Should be 'qa-planning' when gate fired there.
1092
+ * @param {number} score
1093
+ */
1094
+ async function handleScaffoldDecision(projectDir, session, decision, agent, score) {
1095
+ const gate = checkScaffoldGate(session);
1096
+ if (!gate.fires) {
1097
+ return errorResult(
1098
+ 'No scaffold decision pending (no eligible candidate with confidence >= 0.7 and not already applied).',
1099
+ 'NO_SCAFFOLD_PENDING',
1100
+ );
1101
+ }
1102
+
1103
+ const preset = gate.preset;
1104
+ let scaffoldResult = null;
1105
+
1106
+ if (decision === 'apply' || decision === 'dryrun') {
1107
+ let scaffoldSourceDir;
1108
+ try {
1109
+ scaffoldSourceDir = resolveScaffoldSource(projectDir);
1110
+ } catch (err) {
1111
+ return errorResult(
1112
+ `Cannot locate scaffold source directory. ${err.message}`,
1113
+ 'SCAFFOLD_SOURCE_MISSING',
1114
+ );
1115
+ }
1116
+ try {
1117
+ // Sanity-check the preset exists before invoking applyScaffold.
1118
+ loadScaffoldManifest(scaffoldSourceDir, preset);
1119
+ } catch (err) {
1120
+ return errorResult(
1121
+ `Scaffold preset "${preset}" not found at ${scaffoldSourceDir}: ${err.message}`,
1122
+ 'SCAFFOLD_PRESET_MISSING',
1123
+ );
1124
+ }
1125
+ scaffoldResult = applyScaffold({
1126
+ projectDir,
1127
+ scaffoldSourceDir,
1128
+ preset,
1129
+ dryRun: decision === 'dryrun',
1130
+ force: false,
1131
+ });
1132
+ }
1133
+
1134
+ // On 'apply' and 'skip', record the preset as resolved so the gate does
1135
+ // not re-fire on a future advance. On 'dryrun', leave it pending.
1136
+ if (decision === 'apply' || decision === 'skip') {
1137
+ const appliedList = Array.isArray(session.scaffold_applied) ? [...session.scaffold_applied] : [];
1138
+ if (!appliedList.includes(preset)) appliedList.push(preset);
1139
+ await updateSession(projectDir, { scaffold_applied: appliedList });
1140
+ }
1141
+
1142
+ // Dry-run: return info, leave pipeline at qa-planning — user must re-run
1143
+ // advance with --decision apply|skip to actually proceed.
1144
+ if (decision === 'dryrun') {
1145
+ return {
1146
+ advanced: false,
1147
+ agent_completed: agent,
1148
+ score,
1149
+ scaffold_decision: decision,
1150
+ scaffold: {
1151
+ preset: scaffoldResult.preset,
1152
+ version: scaffoldResult.version,
1153
+ would_apply: scaffoldResult.applied,
1154
+ would_skip: scaffoldResult.skipped,
1155
+ placeholders: scaffoldResult.placeholders,
1156
+ brandSource: scaffoldResult.brandSource,
1157
+ },
1158
+ next: {
1159
+ action: 'scaffold_decision_pending',
1160
+ agent: null,
1161
+ status_summary: `Dry-run complete for "${preset}". Re-run advance with --decision apply to write the files, or --decision skip to proceed without the scaffold.`,
1162
+ },
1163
+ };
1164
+ }
1165
+
1166
+ // apply / skip: advance qa-planning → dev.
1167
+ const { session: postDecision } = loadSession(projectDir);
1168
+ const pipelineState = sessionToPipelineState(postDecision || session, projectDir);
1169
+ pipelineState.completedAgents = [...(postDecision?.completed_agents || session.completed_agents || [])];
1170
+ const advanceResult = advancePipeline(pipelineState, agent, { score, handoffData: {}, findings: [] });
1171
+
1172
+ const updates = {
1173
+ current_agent: advanceResult.nextAgent || '',
1174
+ last_handoff: agent,
1175
+ pipeline_position: pipelineState.completedAgents.length,
1176
+ };
1177
+ if (advanceResult.needsModeSwitch) {
1178
+ const newPhase = advanceResult.state?.phase || session.mode;
1179
+ updates.mode = newPhase;
1180
+ if (session.project) updates.project = { ...session.project, state: newPhase };
1181
+ await recordModeTransition(projectDir, {
1182
+ from: session.mode, to: newPhase,
1183
+ trigger: `scaffold_decision=${decision} on ${preset}`,
1184
+ });
1185
+ }
1186
+ await updateSession(projectDir, updates);
1187
+
1188
+ const nextAgent = advanceResult.nextAgent || '';
1189
+ const progress = getPipelineProgress(advanceResult.state || pipelineState);
1190
+ try { updateClaudeMd(projectDir, { currentAgent: nextAgent, progress }); } catch { /* non-fatal */ }
1191
+ try {
1192
+ if (nextAgent) {
1193
+ writeSessionLock(projectDir, nextAgent, {
1194
+ phase: updates.mode || session.mode,
1195
+ mode: session.execution_mode || 'interactive',
1196
+ position: updates.pipeline_position,
1197
+ total: progress.total,
1198
+ progress: progress.percent,
1199
+ });
1200
+ }
1201
+ } catch { /* non-fatal */ }
1202
+
1203
+ const next = await handleNext(projectDir);
1204
+ return {
1205
+ advanced: true,
1206
+ agent_completed: agent,
1207
+ score,
1208
+ scaffold_decision: decision,
1209
+ scaffold: scaffoldResult
1210
+ ? {
1211
+ preset: scaffoldResult.preset,
1212
+ version: scaffoldResult.version,
1213
+ applied: scaffoldResult.applied,
1214
+ skipped: scaffoldResult.skipped,
1215
+ manifestPath: scaffoldResult.manifestPath,
1216
+ }
1217
+ : { preset, skipped_by_user: true },
1218
+ phase_transition: advanceResult.needsModeSwitch ? { triggered: true, from: session.mode, to: updates.mode } : { triggered: false },
1219
+ next,
1220
+ };
1221
+ }
1222
+
448
1223
  async function handleAdvance(projectDir, args) {
449
1224
  const agent = args.agent;
450
1225
  const score = parseInt(args.score, 10);
@@ -457,6 +1232,33 @@ async function handleAdvance(projectDir, args) {
457
1232
  const { loaded, session } = loadSession(projectDir);
458
1233
  if (!loaded || !session) return errorResult('No session found', 'NO_SESSION');
459
1234
 
1235
+ // Fase 10 scaffold decision — route apply|skip|dryrun to the scaffold
1236
+ // dispatcher BEFORE the alreadyCompleted guard so the user can resolve a
1237
+ // pending scaffold decision after qa-planning is marked complete.
1238
+ const SCAFFOLD_DECISIONS = new Set(['apply', 'skip', 'dryrun']);
1239
+ if (decision && SCAFFOLD_DECISIONS.has(decision)) {
1240
+ return handleScaffoldDecision(projectDir, session, decision, agent, score);
1241
+ }
1242
+
1243
+ // Idempotency guard: if this agent is already marked completed, return no-op.
1244
+ // Prevents duplicate advance calls (hook + user-approval path) from double-
1245
+ // incrementing pipeline_position or corrupting completed_agents.
1246
+ const agentState = session.agents && session.agents[agent];
1247
+ const alreadyCompleted =
1248
+ agentState && agentState.status === 'completed' &&
1249
+ Array.isArray(session.completed_agents) && session.completed_agents.includes(agent);
1250
+ if (alreadyCompleted && !decision) {
1251
+ const next = await handleNext(projectDir);
1252
+ return {
1253
+ advanced: false,
1254
+ already_advanced: true,
1255
+ agent_completed: agent,
1256
+ score: agentState.score,
1257
+ next,
1258
+ status_summary: `${agent} already completed (score ${agentState.score}). Next: ${next.agent || 'none'}.`,
1259
+ };
1260
+ }
1261
+
460
1262
  // Handle user_preview decisions
461
1263
  if (decision) {
462
1264
  const pipelineState = sessionToPipelineState(session, projectDir);
@@ -479,22 +1281,508 @@ async function handleAdvance(projectDir, args) {
479
1281
  return { advanced: true, agent_completed: agent, score, decision, phase_transition: { triggered: previewResult.needsModeSwitch }, next };
480
1282
  }
481
1283
 
482
- // Record completion
483
- await recordAgentCompletion(projectDir, { agent, status, score });
1284
+ // Read agent handoff document to extract Decision Trail entries.
1285
+ // Agents (e.g. qa-implementation) write Decision Trail entries to their handoff
1286
+ // instead of directly editing session.yaml (blocked by mode-governance hook).
1287
+ let handoffData = {};
1288
+ // Handoff files live at <frameworkDir>/artifacts/handoffs/<agent>-handoff.md
1289
+ // EXCEPT when artifacts/ is user-facing (non-framework). Currently install
1290
+ // writes artifacts to project root <projectDir>/artifacts/. Check both locations
1291
+ // to cover legacy + current layouts.
1292
+ const fwDir = resolveFrameworkDir(projectDir);
1293
+ const handoffCandidates = [
1294
+ join(projectDir, 'artifacts', 'handoffs', `${agent}-handoff.md`),
1295
+ join(projectDir, fwDir, 'artifacts', 'handoffs', `${agent}-handoff.md`),
1296
+ ];
1297
+ const handoffPath = handoffCandidates.find(p => existsSync(p));
1298
+ if (handoffPath) {
1299
+ try {
1300
+ const handoffMarkdown = readFileSync(handoffPath, 'utf-8');
1301
+ handoffData = parseHandoffDecisionTrail(handoffMarkdown);
1302
+ // Fase 10 auto-gate — parse scaffold candidates/signals alongside Decision Trail.
1303
+ const scaffold = parseHandoffScaffoldSignals(handoffMarkdown);
1304
+ handoffData.scaffold_candidates = scaffold.scaffold_candidates;
1305
+ handoffData.scaffold_signals = scaffold.scaffold_signals;
1306
+ } catch {
1307
+ // Non-fatal: if handoff can't be read, skip Decision Trail / scaffold merge
1308
+ }
1309
+ }
484
1310
 
485
- // Advance pipeline
486
- const pipelineState = sessionToPipelineState(session, projectDir);
487
- pipelineState.completedAgents = [...(session.completed_agents || []), agent];
1311
+ // Deterministic gates: block advance if required artifacts are missing.
1312
+
1313
+ // BRIEF gate: reference frames captured → Visualizer MUST run for ALL sites before Brief advances.
1314
+ if (agent === 'brief') {
1315
+ const refsDir = join(projectDir, 'artifacts', '4-UX', 'references');
1316
+ const completePath = join(refsDir, 'capture-complete.json');
1317
+ const analysisPath = join(projectDir, 'artifacts', '4-UX', 'reference-analysis.md');
1318
+
1319
+ if (existsSync(completePath)) {
1320
+ let captureData = {};
1321
+ try { captureData = JSON.parse(readFileSync(completePath, 'utf-8')); } catch { /* ignore */ }
1322
+ const okSites = Object.entries(captureData.sites || {})
1323
+ .filter(([, s]) => s.status === 'ok')
1324
+ .map(([slug, s]) => ({ slug, url: s.url, desktopFrames: `artifacts/4-UX/references/${slug}/desktop/frames/`, mobileFrames: `artifacts/4-UX/references/${slug}/mobile/frames/` }));
1325
+
1326
+ if (okSites.length > 0) {
1327
+ // Check: consolidated file must mention ALL sites, OR every site has its own analysis.md
1328
+ let allAnalyzed = false;
1329
+ if (existsSync(analysisPath)) {
1330
+ try {
1331
+ const content = readFileSync(analysisPath, 'utf-8');
1332
+ allAnalyzed = okSites.every(s => content.includes(s.slug) || content.includes(s.url));
1333
+ } catch { /* ignore */ }
1334
+ }
1335
+ if (!allAnalyzed) {
1336
+ const missingSites = okSites.filter(s => !existsSync(join(refsDir, s.slug, 'analysis.md')));
1337
+ allAnalyzed = missingSites.length === 0;
1338
+ }
1339
+
1340
+ if (!allAnalyzed) {
1341
+ // Find which sites still need analysis
1342
+ const needsAnalysis = okSites.filter(s => {
1343
+ if (existsSync(join(refsDir, s.slug, 'analysis.md'))) return false;
1344
+ if (existsSync(analysisPath)) {
1345
+ try {
1346
+ const content = readFileSync(analysisPath, 'utf-8');
1347
+ return !(content.includes(s.slug) || content.includes(s.url));
1348
+ } catch { return true; }
1349
+ }
1350
+ return true;
1351
+ });
1352
+
1353
+ return {
1354
+ advanced: false,
1355
+ blocked: true,
1356
+ reason: 'visual_analysis_required',
1357
+ message: `${needsAnalysis.length} of ${okSites.length} site(s) still need visual analysis. Spawn ONE Visualizer per missing site (parallel).`,
1358
+ already_analyzed: okSites.length - needsAnalysis.length,
1359
+ action_required: {
1360
+ step1: 'Display to user (translate to session.language): "Analyzing the reference sites you provided — scroll experience, animations, colors, and typography in depth..."',
1361
+ step2: `Spawn ${needsAnalysis.length} Agent(s) IN PARALLEL (one per site, each with model: sonnet):`,
1362
+ agents: needsAnalysis.map(s => ({
1363
+ description: `Visualizer — ${s.slug} scroll analysis`,
1364
+ model: 'sonnet',
1365
+ prompt: `Read and follow .chati.dev/agents/shared/visualizer.md (Mode 1: Reference Analysis). Analyze ONE site only: ${s.slug} (${s.url}). Read ALL frames in ${s.desktopFrames} and ${s.mobileFrames}. Read artifacts/4-UX/references/${s.slug}/extracted-tokens.json. Save output to artifacts/4-UX/references/${s.slug}/analysis.md`,
1366
+ })),
1367
+ step3: 'Wait for ALL to return. Merge ALL per-site analysis.md files into artifacts/4-UX/reference-analysis.md',
1368
+ step4: 'Append Visual Experience Analysis summary to brief-report.md',
1369
+ step5: 'Present the UPDATED brief to the user with Completion Options. Do NOT advance — wait for user approval.',
1370
+ },
1371
+ };
1372
+ }
1373
+ }
1374
+ // okSites.length === 0 means all captures failed → allow advance, UX falls back to text
1375
+ }
1376
+
1377
+ // If capture not yet complete, return immediately — don't block advance for 10 min.
1378
+ // Tell Claude to call wait-for-capture separately, then re-try advance.
1379
+ if (!existsSync(join(refsDir, 'capture-complete.json'))) {
1380
+ try {
1381
+ const dirs = readdirSync(refsDir).filter(d => {
1382
+ try { return existsSync(join(refsDir, d, 'desktop')); } catch { return false; }
1383
+ });
1384
+ if (dirs.length > 0) {
1385
+ return {
1386
+ advanced: false,
1387
+ blocked: true,
1388
+ reason: 'capture_in_progress',
1389
+ message: 'Reference capture still running in background. Wait for it, then spawn Visualizer, then present options to user.',
1390
+ action_required: {
1391
+ step1: 'Run: node .chati.dev/orchestrator/chati-router.js wait-for-capture',
1392
+ step2: 'Then spawn Visualizer to analyze frames (save to artifacts/4-UX/reference-analysis.md)',
1393
+ step3: 'Append Visual Experience Analysis summary to brief-report.md',
1394
+ step4: 'Present the UPDATED brief to the user with Completion Options. Do NOT advance — wait for user approval.',
1395
+ },
1396
+ };
1397
+ }
1398
+ } catch { /* refsDir may not exist — no references, skip */ }
1399
+ }
1400
+ }
1401
+
1402
+ // QA-Visual gate: visual-qa.js must have run AND Visualizer must have analyzed
1403
+ // EVERY page — mirrors the Brief gate (ONE Visualizer per site/page in parallel).
1404
+ if (agent === 'qa-visual') {
1405
+ const vqaOutput = join(projectDir, '.chati', 'visual-qa');
1406
+ const projectReport = join(vqaOutput, 'report.json');
1407
+ // Only look at project-scoped .chati/visual-qa/. The /tmp/visual-qa/ fallback
1408
+ // was removed because it cross-contaminated isolated tests: any prior run of
1409
+ // visual-qa.js against a real project left /tmp/visual-qa/report.json behind,
1410
+ // which then polluted tmpdir-based test fixtures that expected a clean state.
1411
+ const reportPath = existsSync(projectReport) ? projectReport : null;
1412
+ const reportDir = reportPath ? reportPath.replace('/report.json', '') : vqaOutput;
1413
+ const consolidatedPath = join(projectDir, 'artifacts', '8-QA-Visual', 'visual-qa-analysis.md');
1414
+ const finalReportPath = join(projectDir, 'artifacts', '9-QA-Implementation', 'qa-visual-report.md');
1415
+
1416
+ // Check report.json exists (visual-qa.js ran)
1417
+ if (!reportPath) {
1418
+ // Autonomous-completion v1 (ADR-AUTO-02) rewrites this path:
1419
+ // The post-dev hook (P4) normally fires visual-qa.js when dev
1420
+ // completes. If we land here, either the hook did not fire (opted
1421
+ // out via config.yaml or dev score < 90) or its spawn is still
1422
+ // finishing in the background. Give Claude the commands to
1423
+ // recover manually.
1424
+ return {
1425
+ advanced: false,
1426
+ blocked: true,
1427
+ reason: 'visual_qa_not_run',
1428
+ message: 'visual-qa.js has not produced report.json yet. Either the post-dev auto-QA hook is still running, or it was skipped.',
1429
+ action_required: {
1430
+ step1: 'Run: node .chati.dev/scripts/visual-qa.js --pages "/" <add routes> --output .chati/visual-qa/',
1431
+ step2: 'The script spawns its own Next dev server on a free port, so you do not need to start one yourself.',
1432
+ step3: 'When the capture finishes, re-run advance qa-visual.',
1433
+ rationale: 'post-dev.js fires automatically on dev-handoff.md with score >= 90 and auto_visual_qa: true (default). If auto-QA is off, run the command above. See ADR-AUTO-02 / autonomous-completion-v1-pr.md.',
1434
+ },
1435
+ };
1436
+ }
1437
+
1438
+ // Read captured pages from report.json
1439
+ let capturedPages = [];
1440
+ try {
1441
+ const report = JSON.parse(readFileSync(reportPath, 'utf-8'));
1442
+ capturedPages = (report.pages || []).map(p => ({ slug: p.slug || 'home', path: p.path || '/' }));
1443
+ } catch { /* ignore */ }
1444
+
1445
+ if (capturedPages.length === 0) {
1446
+ return {
1447
+ advanced: false,
1448
+ blocked: true,
1449
+ reason: 'visual_qa_report_empty',
1450
+ message: 'visual-qa.js ran but report.json has no captured pages. Re-run the script.',
1451
+ };
1452
+ }
1453
+
1454
+ // Autonomous-completion v1 (ADR-AUTO-04): Reduced-motion enforcement gate.
1455
+ // When visual-qa.js runs with --reduced-motion on|both, it writes
1456
+ // summary.reduced_motion_mode + summary.reduced_motion_violations to
1457
+ // report.json. A violation = a scroll-triggered region where frames
1458
+ // diverged between motion-on and motion-off captures — i.e. animation
1459
+ // that ignores the user's prefers-reduced-motion: reduce preference.
1460
+ // v1 contract: BLOCK when violations[] is non-empty (fault_origin: CODE).
1461
+ // v1 WARN: if reduced_motion_mode === 'off', the a11y pass never ran.
1462
+ // The comparison pass itself ships in v1.2 — in v1, violations[] is
1463
+ // always empty, so the gate runs clean today but is ready for the
1464
+ // upgrade without handleAdvance edits.
1465
+ try {
1466
+ const report = JSON.parse(readFileSync(reportPath, 'utf-8'));
1467
+ const rmMode = report.summary?.reduced_motion_mode;
1468
+ const violations = Array.isArray(report.summary?.reduced_motion_violations)
1469
+ ? report.summary.reduced_motion_violations
1470
+ : [];
1471
+ if (violations.length > 0) {
1472
+ return {
1473
+ advanced: false,
1474
+ blocked: true,
1475
+ reason: 'reduced_motion_violation',
1476
+ message: `${violations.length} region(s) animate under prefers-reduced-motion: reduce. Motion MUST be disabled when the user preference is set.`,
1477
+ reduced_motion_violations: violations,
1478
+ fault_origin: 'CODE',
1479
+ action_required: {
1480
+ step1: 'Display the violating pages + regions to the user.',
1481
+ step2: 'Route back to dev for rework (Fault Vector Protocol: CODE → dev).',
1482
+ step3: 'Dev wraps the offending animation in usePrefersReducedMotion() (from lib/animations/gsap.ts.template) and returns early when motion is reduced.',
1483
+ step4: 'Re-run visual-qa.js with --reduced-motion both, then re-run advance qa-visual.',
1484
+ },
1485
+ };
1486
+ }
1487
+ // WARN path — a11y pass not executed.
1488
+ if (rmMode === 'off') {
1489
+ // Non-blocking — attached to the response later as advisory_warnings
1490
+ // when we reach the success path. We do not early-return; other
1491
+ // gates (layout errors, per-page analysis) still need to run.
1492
+ // Storing in a closure var so downstream return decorates with it.
1493
+ // (Implemented as a side-effect on report — but report is local here;
1494
+ // use a hoisted pseudo-state via the outer function.)
1495
+ }
1496
+ } catch {
1497
+ // report.json unparseable — defer to the other checks which will catch
1498
+ // the corrupted-file case explicitly.
1499
+ }
1500
+
1501
+ // Objective layout gate — catches bugs that pass qualitative Visualizer review.
1502
+ // Root cause of 2026-04-16 cascade-layer trap: `* { margin: 0 }` outside any
1503
+ // @layer silently overrode all Tailwind utilities; Visualizers approved at
1504
+ // 94% because 1280px viewport masked the x=0 container alignment. This gate
1505
+ // blocks advance on measured facts (getBoundingClientRect / getComputedStyle)
1506
+ // regardless of what LLM review said.
1507
+ try {
1508
+ const report = JSON.parse(readFileSync(reportPath, 'utf-8'));
1509
+ const layoutErrors = (report.summary?.layout_errors || []).filter(e => e.severity === 'error');
1510
+ if (layoutErrors.length > 0) {
1511
+ // Group by page + type for a clean summary
1512
+ const byPageType = {};
1513
+ for (const e of layoutErrors) {
1514
+ const key = `${e.page || '?'}::${e.type}`;
1515
+ byPageType[key] = byPageType[key] || { page: e.page, type: e.type, viewports: [], details: [] };
1516
+ byPageType[key].viewports.push(e.viewport);
1517
+ byPageType[key].details.push(e.detail);
1518
+ }
1519
+ return {
1520
+ advanced: false,
1521
+ blocked: true,
1522
+ reason: 'layout_errors_detected',
1523
+ message: `${layoutErrors.length} objective layout error(s) measured by Playwright. These are measurements, not judgments — must be fixed before advancing.`,
1524
+ layout_error_count: layoutErrors.length,
1525
+ layout_errors: Object.values(byPageType),
1526
+ fault_origin: 'CODE',
1527
+ action_required: {
1528
+ step1: 'Display the errors to the user with specific selectors + measurements',
1529
+ step2: 'Route back to dev for rework (Fault Vector Protocol: CODE → dev)',
1530
+ step3: 'Dev fixes → pnpm build → re-run visual-qa.js → re-run advance qa-visual',
1531
+ common_causes: [
1532
+ 'Tailwind v4: unlayered CSS in globals.css wins over @layer utilities (wrap globals in @layer base {})',
1533
+ 'Tailwind v4: arbitrary CSS var without type hint silently omits property (use text-[length:var(...)])',
1534
+ 'Missing mx-auto on a max-w-* container',
1535
+ 'Element wider than viewport causing horizontal overflow',
1536
+ ],
1537
+ },
1538
+ };
1539
+ }
1540
+ } catch {
1541
+ // report.json unparseable — fall through to other checks, they will catch it
1542
+ }
1543
+
1544
+ // NEW (Fase 6): Shared CSS / token change → full coverage required.
1545
+ // Regression risk: a change to globals.css or brand.ts can silently break
1546
+ // routes that were NOT touched by the current task. If the current report
1547
+ // covers fewer routes than the project's app/ directory exposes, block.
1548
+ try {
1549
+ const modifiedFiles = getModifiedFiles(projectDir);
1550
+ const coverage = checkSharedCSSCoverage({ capturedPages, projectDir, modifiedFiles });
1551
+ if (coverage.blocked) {
1552
+ return {
1553
+ advanced: false,
1554
+ blocked: true,
1555
+ reason: coverage.reason,
1556
+ message: `Shared CSS / token change detected (${coverage.triggeringFiles.join(', ')}). Visual-QA captured ${capturedPages.length}/${coverage.appRoutes.length} routes. ${coverage.uncovered.length} route(s) uncovered — re-run with full coverage.`,
1557
+ triggering_files: coverage.triggeringFiles,
1558
+ captured_routes: capturedPages.map(p => p.path),
1559
+ expected_routes: coverage.appRoutes,
1560
+ uncovered_routes: coverage.uncovered,
1561
+ fault_origin: 'SPEC',
1562
+ action_required: {
1563
+ step1: 'Display the uncovered routes and the triggering shared files to the user.',
1564
+ step2: `Re-run visual-qa.js with --pages covering all ${coverage.appRoutes.length} routes:`,
1565
+ step3: `node .chati.dev/scripts/visual-qa.js --pages ${coverage.appRoutes.map(r => `"${r}"`).join(' ')} --url http://localhost:3456 --output .chati/visual-qa/`,
1566
+ step4: 'Then re-run advance qa-visual.',
1567
+ rationale: 'Shared-CSS changes can regress any route; visual-QA must check all of them when this class of file is modified.',
1568
+ },
1569
+ };
1570
+ }
1571
+ } catch {
1572
+ // git unavailable or projectDir not a repo — skip scope expansion silently.
1573
+ // Other checks below still enforce correctness on the covered routes.
1574
+ }
1575
+
1576
+ // Check: every captured page has per-page analysis.md OR is mentioned in the consolidated file
1577
+ let consolidatedContent = '';
1578
+ if (existsSync(consolidatedPath)) {
1579
+ try { consolidatedContent = readFileSync(consolidatedPath, 'utf-8'); } catch { /* ignore */ }
1580
+ }
1581
+ const needsAnalysis = capturedPages.filter(p => {
1582
+ const perPage = join(reportDir, p.slug, 'analysis.md');
1583
+ if (existsSync(perPage)) return false;
1584
+ if (consolidatedContent && (consolidatedContent.includes(p.slug) || consolidatedContent.includes(`\`${p.path}\``))) return false;
1585
+ return true;
1586
+ });
1587
+
1588
+ if (needsAnalysis.length > 0) {
1589
+ return {
1590
+ advanced: false,
1591
+ blocked: true,
1592
+ reason: 'visual_analysis_required',
1593
+ message: `${needsAnalysis.length} of ${capturedPages.length} page(s) still need Visualizer analysis. Spawn ONE Visualizer per missing page (parallel).`,
1594
+ already_analyzed: capturedPages.length - needsAnalysis.length,
1595
+ action_required: {
1596
+ step1: 'Display to user (translate to session.language): "Analyzing the build visually and comparing against the original references..."',
1597
+ step2: `Spawn ${needsAnalysis.length} Agent(s) IN PARALLEL (one per page, each with model: sonnet):`,
1598
+ agents: needsAnalysis.map(p => ({
1599
+ description: `Visualizer — ${p.slug} build validation`,
1600
+ model: 'sonnet',
1601
+ prompt: `Read and follow .chati.dev/agents/shared/visualizer.md (Mode 2: Build Validation). Analyze ONE page only: ${p.path} (${p.slug}). Read ALL build frames matching ${reportDir}/${p.slug}-*.png. Compare to artifacts/4-UX/brandbook.md and artifacts/4-UX/references/*/analysis.md (text only). Save output to ${reportDir}/${p.slug}/analysis.md`,
1602
+ })),
1603
+ step3: 'Wait for ALL to return.',
1604
+ step4: `Merge ALL per-page analysis.md files into ${consolidatedPath} (consolidated Visualizer report).`,
1605
+ step5: `Write final QA-Visual verdict to ${finalReportPath} with score, blockers, warnings.`,
1606
+ step6: 'Present findings and verdict to the user with Completion Options. Do NOT advance — wait for user approval.',
1607
+ },
1608
+ };
1609
+ }
1610
+
1611
+ // Check consolidated analysis file exists
1612
+ if (!existsSync(consolidatedPath)) {
1613
+ return {
1614
+ advanced: false,
1615
+ blocked: true,
1616
+ reason: 'consolidated_analysis_missing',
1617
+ message: `Per-page analyses exist but consolidated file is missing. Create ${consolidatedPath} by merging all per-page analyses.`,
1618
+ };
1619
+ }
1620
+
1621
+ // Check final QA-Visual report exists at canonical path (NOT at 7-QA-Implementation)
1622
+ if (!existsSync(finalReportPath)) {
1623
+ const wrongPath = join(projectDir, 'artifacts', '7-QA-Implementation', 'qa-visual-report.md');
1624
+ const hint = existsSync(wrongPath)
1625
+ ? ` Found qa-visual-report.md at WRONG path ${wrongPath} — move it to ${finalReportPath}.`
1626
+ : '';
1627
+ return {
1628
+ advanced: false,
1629
+ blocked: true,
1630
+ reason: 'qa_visual_final_report_missing',
1631
+ message: `Final QA-Visual report missing at canonical path: ${finalReportPath}.${hint}`,
1632
+ };
1633
+ }
1634
+ }
1635
+
1636
+ // QA-Implementation gate: report MUST be at artifacts/9-QA-Implementation/ (NOT 7-*)
1637
+ if (agent === 'qa-implementation') {
1638
+ const canonicalDir = join(projectDir, 'artifacts', '9-QA-Implementation');
1639
+ const wrongDir = join(projectDir, 'artifacts', '7-QA-Implementation');
1640
+ const canonicalReport = join(canonicalDir, 'qa-implementation-report.md');
1641
+ const canonicalFinal = join(canonicalDir, 'qa-implementation-final-report.md');
1642
+
1643
+ // Block if wrong path exists — force relocation
1644
+ if (existsSync(wrongDir)) {
1645
+ return {
1646
+ advanced: false,
1647
+ blocked: true,
1648
+ reason: 'qa_impl_wrong_path',
1649
+ message: `artifacts/7-QA-Implementation/ is NOT canonical. Move all files to ${canonicalDir} (7- is reserved for QA-Planning).`,
1650
+ action_required: {
1651
+ step1: `Move files: mv artifacts/7-QA-Implementation/* artifacts/9-QA-Implementation/`,
1652
+ step2: `Remove empty folder: rmdir artifacts/7-QA-Implementation`,
1653
+ step3: 'Then re-run advance',
1654
+ },
1655
+ };
1656
+ }
1657
+
1658
+ // Require at least one of the canonical report files
1659
+ if (!existsSync(canonicalReport) && !existsSync(canonicalFinal)) {
1660
+ return {
1661
+ advanced: false,
1662
+ blocked: true,
1663
+ reason: 'qa_impl_report_missing',
1664
+ message: `QA-Implementation report missing. Expected ${canonicalReport} or ${canonicalFinal}.`,
1665
+ };
1666
+ }
1667
+ }
1668
+
1669
+ // UX (brand-architect) gate
1670
+ if (agent === 'ux') {
1671
+ // Fase 7 — Task-to-UX fidelity. Before checking brandbook.html, verify that
1672
+ // if the brief or reference-analysis cites premium animation references,
1673
+ // the brand-architect has produced the animation-inventory artifact. The
1674
+ // inventory is mandatory: without it, tasks generate generic "add scroll
1675
+ // reveals" instead of the specific patterns the user's references
1676
+ // encode, and qa-visual has no per-pattern checklist for Mode 2.
1677
+ const inventoryPath = join(projectDir, 'artifacts', '4-UX', 'animation-inventory.md');
1678
+ if (!existsSync(inventoryPath)) {
1679
+ const corpus = readBriefAndRefCorpus(projectDir);
1680
+ const refs = detectPremiumRefs(corpus);
1681
+ if (refs.detected) {
1682
+ return {
1683
+ advanced: false,
1684
+ blocked: true,
1685
+ reason: 'animation_inventory_required',
1686
+ message: `Brief or reference-analysis cites premium animation references (${refs.matches.join(', ')}). Brand-architect must produce artifacts/4-UX/animation-inventory.md before advancing.`,
1687
+ triggering_matches: refs.matches,
1688
+ expected_artifact: 'artifacts/4-UX/animation-inventory.md',
1689
+ fault_origin: 'SPEC',
1690
+ action_required: {
1691
+ step1: 'Inspect artifacts/1-Brief/brief-report.md and artifacts/4-UX/reference-analysis.md to enumerate every named animation pattern.',
1692
+ step2: 'Write artifacts/4-UX/animation-inventory.md with one row per pattern. Schema:',
1693
+ schema: '| Pattern name | Observed at (site / section) | Parameters (stagger, scrub, easing, duration) | Reproducible via (scaffold file) | Target route |',
1694
+ step3: 'Populate the "Reproducible via" column from scaffold/motion-premium/* templates — gsap.ts, useGsapContext.ts, useScrollSnapStepper.ts, BackgroundCrossfadeProvider.tsx, etc. Each row that cannot be mapped to an existing scaffold pattern is a yellow flag (ask architect before committing a custom implementation).',
1695
+ step4: 'Append a section to brandbook.md (or brand-architect handoff) noting the inventory is locked and tasks must 1:1 map against it.',
1696
+ step5: 'Then re-run advance ux.',
1697
+ rationale: 'The inventory locks the promise made to the user by the brief/references. Tasks agent consumes it 1:1 to avoid generic descriptions; QA-Visual Mode 2 uses it as a per-pattern implementation checklist.',
1698
+ },
1699
+ };
1700
+ }
1701
+ }
1702
+
1703
+ const brandbookPath = join(projectDir, 'artifacts', '4-UX', 'brandbook.html');
1704
+ if (!existsSync(brandbookPath)) {
1705
+ return {
1706
+ advanced: false,
1707
+ blocked: true,
1708
+ reason: 'brandbook_html_missing',
1709
+ message: 'brandbook.html is a mandatory deliverable for the UX agent. Write artifacts/4-UX/brandbook.html before advancing.',
1710
+ };
1711
+ }
1712
+ }
1713
+
1714
+ // Record completion (merges Decision Trail entries from handoff into session.yaml)
1715
+ await recordAgentCompletion(projectDir, { agent, status, score, handoffData });
1716
+ recordEvent(projectDir, EventType.AGENT_COMPLETED, agent, { score, status });
1717
+
1718
+ // Fase 10 — Scaffold auto-gate. After qa-planning completes, check whether
1719
+ // a scaffold preset has cleared the confidence threshold and is still
1720
+ // unapplied. If so, return an action_required response with the 3-option
1721
+ // decision payload. qa-planning stays marked complete; the user's next
1722
+ // advance call must carry --decision apply|skip|dryrun.
1723
+ if (status === 'completed' && score >= 95 && agent === 'qa-planning') {
1724
+ const { session: postQa } = loadSession(projectDir);
1725
+ const gate = checkScaffoldGate(postQa || session);
1726
+ if (gate.fires) {
1727
+ return buildScaffoldGateResponse(gate, {
1728
+ agent,
1729
+ score,
1730
+ phaseTransition: { triggered: false },
1731
+ });
1732
+ }
1733
+ }
1734
+
1735
+ // Advance pipeline. Build the pipelineState from POST-completion session
1736
+ // (reload after recordAgentCompletion) so completedAgents reflects the dedup
1737
+ // guard in session-manager (no duplicate pushes).
1738
+ const { session: postCompletion } = loadSession(projectDir);
1739
+ const pipelineState = sessionToPipelineState(postCompletion || session, projectDir);
1740
+ pipelineState.completedAgents = [...(postCompletion?.completed_agents || session.completed_agents || [])];
488
1741
  pipelineState.agents[agent] = { status: 'completed', score, startedAt: null, completedAt: new Date().toISOString() };
489
1742
 
490
- const advanceResult = advancePipeline(pipelineState, agent, { score });
1743
+ const advanceResult = advancePipeline(pipelineState, agent, {
1744
+ score,
1745
+ handoffData,
1746
+ findings: handoffData?.decision_trail_entries || [],
1747
+ });
491
1748
 
492
1749
  // Update session with new state
493
1750
  const updates = {
494
1751
  current_agent: advanceResult.nextAgent || '',
495
1752
  last_handoff: agent,
1753
+ pipeline_position: pipelineState.completedAgents.length,
496
1754
  };
497
1755
 
1756
+ // Backward transition (Article XXII Fault Vector Protocol):
1757
+ // QA failed → reset target agent (and everything after it) back to pending.
1758
+ // Persist the new agent states + correction_cycles counter + pipeline_position.
1759
+ if (advanceResult.nextAction === 'correction_loop' || advanceResult.nextAction === 'escalate') {
1760
+ const advancedState = advanceResult.state || pipelineState;
1761
+ // Serialize agent map back to session shape (status/score/started/completed).
1762
+ const agentResults = { ...(session.agent_results || {}) };
1763
+ for (const [name, a] of Object.entries(advancedState.agents || {})) {
1764
+ agentResults[name] = {
1765
+ ...(agentResults[name] || {}),
1766
+ status: a.status,
1767
+ score: a.score,
1768
+ started_at: a.startedAt || null,
1769
+ completed_at: a.completedAt || null,
1770
+ };
1771
+ }
1772
+ updates.agents = advancedState.agents;
1773
+ updates.agent_results = agentResults;
1774
+ updates.completed_agents = [...advancedState.completedAgents];
1775
+ updates.pipeline_position = advancedState.completedAgents.length;
1776
+ updates.correction_cycles = { ...(advancedState.correctionCycles || {}) };
1777
+
1778
+ recordEvent(
1779
+ projectDir,
1780
+ advanceResult.nextAction === 'escalate' ? EventType.CORRECTION_ESCALATED : EventType.CORRECTION_TRIGGERED,
1781
+ advanceResult.correction?.target || agent,
1782
+ { correction: advanceResult.correction },
1783
+ );
1784
+ }
1785
+
498
1786
  // Handle phase transition
499
1787
  let phaseTransition = { triggered: false };
500
1788
  if (advanceResult.needsModeSwitch) {
@@ -510,9 +1798,113 @@ async function handleAdvance(projectDir, args) {
510
1798
  to: newPhase,
511
1799
  trigger: `${agent} completed with score ${score}`,
512
1800
  });
1801
+ recordEvent(projectDir, EventType.MODE_TRANSITION, agent, { from: session.mode, to: newPhase });
513
1802
  }
514
1803
 
515
- await updateSession(projectDir, updates);
1804
+ const saveResult = await updateSession(projectDir, updates);
1805
+ if (saveResult && saveResult.saved === false) {
1806
+ return errorResult(`Session update failed: ${saveResult.error}`, 'SESSION_WRITE_FAILED');
1807
+ }
1808
+
1809
+ // Refresh Magic Docs: CLAUDE.md (public) + CLAUDE.local.md (runtime lock+state).
1810
+ // Non-fatal: if updates fail, pipeline state already advanced on disk.
1811
+ const nextAgent = advanceResult.nextAgent || '';
1812
+ const progress = getPipelineProgress(advanceResult.state || pipelineState);
1813
+ try {
1814
+ updateClaudeMd(projectDir, { currentAgent: nextAgent, progress });
1815
+ } catch { /* non-fatal */ }
1816
+ try {
1817
+ if (nextAgent) {
1818
+ writeSessionLock(projectDir, nextAgent, {
1819
+ phase: updates.mode || session.mode,
1820
+ mode: session.execution_mode || 'interactive',
1821
+ position: updates.pipeline_position,
1822
+ total: progress.total,
1823
+ progress: progress.percent,
1824
+ });
1825
+ } else {
1826
+ // Pipeline complete: reset lock to INACTIVE
1827
+ const lang = session.language || 'en';
1828
+ removeSessionLock(projectDir, RESUME_MESSAGES[lang] || RESUME_MESSAGES.en);
1829
+ }
1830
+ } catch { /* non-fatal */ }
1831
+
1832
+ // Backward transition via Fault Vector Protocol — QA agent failed threshold.
1833
+ // We have already reset target agent state above; now return the correction
1834
+ // payload so Claude/Agent UI knows which agent to invoke next.
1835
+ if (advanceResult.nextAction === 'correction_loop') {
1836
+ const c = advanceResult.correction;
1837
+ // Fase 8: compute the "Reset:" list for the state card — agents that
1838
+ // were completed before but are now back to pending (the correction
1839
+ // target + everything past it in the pipeline).
1840
+ const beforeCompleted = new Set(session.completed_agents || []);
1841
+ const afterCompleted = new Set(advanceResult.state?.completedAgents || pipelineState.completedAgents || []);
1842
+ const resetAgents = [...beforeCompleted].filter(a => !afterCompleted.has(a));
1843
+ if (!resetAgents.includes(c.target)) resetAgents.unshift(c.target);
1844
+ const stateCard = (() => {
1845
+ try {
1846
+ const { session: post } = loadSession(projectDir);
1847
+ return buildStateCard({
1848
+ session: post || session,
1849
+ projectDir,
1850
+ correctionReset: { agents: resetAgents, files: [] },
1851
+ });
1852
+ } catch { return ''; }
1853
+ })();
1854
+ return {
1855
+ advanced: true,
1856
+ agent_completed: agent,
1857
+ score,
1858
+ phase_transition: phaseTransition,
1859
+ correction_loop: true,
1860
+ correction: c,
1861
+ reset: { agents: resetAgents },
1862
+ state_card: stateCard,
1863
+ next: {
1864
+ action: 'correction_loop',
1865
+ agent: c.target,
1866
+ reason: c.reason,
1867
+ fault_origin: c.faultOrigin,
1868
+ cycle: c.cycle,
1869
+ max_cycles: 2,
1870
+ findings_count: (c.findings || []).length,
1871
+ pipeline_progress: getPipelineProgress(advanceResult.state || pipelineState),
1872
+ status_summary: `${agent} scored ${c.score ?? 'n/a'} (below threshold). Fault origin: ${c.faultOrigin}. Re-opening ${c.target} (correction cycle ${c.cycle} of 2). Claude must invoke ${c.target} with the findings.`,
1873
+ },
1874
+ };
1875
+ }
1876
+
1877
+ // Max correction cycles exceeded — escalate to human.
1878
+ if (advanceResult.nextAction === 'escalate') {
1879
+ const c = advanceResult.correction;
1880
+ const stateCard = (() => {
1881
+ try {
1882
+ const { session: post } = loadSession(projectDir);
1883
+ return buildStateCard({ session: post || session, projectDir });
1884
+ } catch { return ''; }
1885
+ })();
1886
+ return {
1887
+ advanced: false,
1888
+ blocked: true,
1889
+ agent_completed: agent,
1890
+ score,
1891
+ phase_transition: phaseTransition,
1892
+ correction_loop: true,
1893
+ correction: c,
1894
+ reason: 'max_correction_cycles_exceeded',
1895
+ message: c.reason,
1896
+ state_card: stateCard,
1897
+ next: {
1898
+ action: 'escalate',
1899
+ agent: null,
1900
+ reason: c.reason,
1901
+ fault_origin: c.faultOrigin,
1902
+ cycle: c.cycle,
1903
+ max_cycles: 2,
1904
+ status_summary: `${agent} failed after ${c.cycle} correction cycles. Human review required before pipeline can continue.`,
1905
+ },
1906
+ };
1907
+ }
516
1908
 
517
1909
  // Check if user_preview is needed
518
1910
  if (advanceResult.nextAction === 'user_preview') {
@@ -536,6 +1928,23 @@ async function handleAdvance(projectDir, args) {
536
1928
  }
537
1929
 
538
1930
  async function handleInit(projectDir, args) {
1931
+ // Auto-resolve: if a parent directory already has a Chati installation,
1932
+ // use that as the project root automatically. Prevents accidental nested
1933
+ // installs (stray `init/` dirs) when CLI is invoked from a subdirectory.
1934
+ const { dirname: pathDirname } = await import('path');
1935
+ let probe = pathDirname(projectDir);
1936
+ let depth = 0;
1937
+ while (probe && probe !== '/' && depth < 5) {
1938
+ const fwParent = existsSync(join(probe, '.chati.dev')) || existsSync(join(probe, 'chati.dev'));
1939
+ if (fwParent) {
1940
+ // Found install in parent — use that as projectDir for the rest of init.
1941
+ projectDir = probe;
1942
+ break;
1943
+ }
1944
+ probe = pathDirname(probe);
1945
+ depth++;
1946
+ }
1947
+
539
1948
  // License validation at init time (moved from per-prompt UserPromptSubmit hook)
540
1949
  try {
541
1950
  const licensePath = join(process.env.HOME || '', '.chati-dev', 'license.yaml');
@@ -559,9 +1968,23 @@ async function handleInit(projectDir, args) {
559
1968
 
560
1969
  const type = args.type || 'greenfield';
561
1970
  const language = args.language || 'en';
562
- const name = args.name || '';
563
1971
  const workflow = args.workflow || 'full';
564
1972
 
1973
+ // Preserve fields from session.yaml created by installer (project name, ides, mcps)
1974
+ let preservedName = args.name || '';
1975
+ let preservedIdes = [];
1976
+ let preservedMcps = [];
1977
+ try {
1978
+ const existingPath = join(projectDir, '.chati', 'session.yaml');
1979
+ if (existsSync(existingPath)) {
1980
+ const existing = yaml.load(readFileSync(existingPath, 'utf-8')) || {};
1981
+ preservedName = preservedName || existing.project?.name || '';
1982
+ preservedIdes = existing.ides || [];
1983
+ preservedMcps = existing.mcps || [];
1984
+ }
1985
+ } catch { /* expected: session may not exist */ }
1986
+ const name = preservedName;
1987
+
565
1988
  const isGreenfield = type === 'greenfield';
566
1989
 
567
1990
  // Initialize pipeline state based on workflow
@@ -577,27 +2000,53 @@ async function handleInit(projectDir, args) {
577
2000
  pipelineAgents = Object.keys(p.agents);
578
2001
  }
579
2002
 
580
- // Initialize session
581
- const result = initSession(projectDir, {
582
- mode: 'discover',
583
- projectName: name,
584
- isGreenfield,
585
- language,
586
- });
587
-
588
- if (!result.created) {
589
- return errorResult(`Failed to initialize session: ${result.error}`, 'INIT_FAILED');
590
- }
2003
+ // RESUME GUARD: if a session already exists with completed agents or a non-discover mode,
2004
+ // this is a RESUME — preserve all state and only update workflow/lock. Do NOT reset.
2005
+ const sessionPath = join(projectDir, '.chati', 'session.yaml');
2006
+ const isResume = existsSync(sessionPath) && (() => {
2007
+ try {
2008
+ const s = yaml.load(readFileSync(sessionPath, 'utf-8')) || {};
2009
+ const hasProgress = (s.completed_agents && s.completed_agents.length > 0)
2010
+ || (s.mode && s.mode !== 'discover')
2011
+ || (s.current_agent && s.current_agent !== '');
2012
+ return hasProgress;
2013
+ } catch { return false; }
2014
+ })();
2015
+
2016
+ if (isResume) {
2017
+ // Resume path — keep session.yaml intact, just refresh workflow if changed
2018
+ await updateSession(projectDir, { workflow });
2019
+ recordEvent(projectDir, EventType.SESSION_STARTED, 'orchestrator', { resume: true, workflow });
2020
+ } else {
2021
+ // Fresh init — create new session
2022
+ const result = initSession(projectDir, {
2023
+ mode: 'discover',
2024
+ projectName: name,
2025
+ isGreenfield,
2026
+ language,
2027
+ ides: preservedIdes,
2028
+ mcps: preservedMcps,
2029
+ });
591
2030
 
592
- // Set workflow in session
593
- await updateSession(projectDir, { workflow });
2031
+ if (!result.created) {
2032
+ return errorResult(`Failed to initialize session: ${result.error}`, 'INIT_FAILED');
2033
+ }
594
2034
 
595
- // Determine first agent
596
- const firstAgent = isGreenfield ? 'greenfield-wu' : 'brownfield-wu';
597
- const firstAgentFile = AGENT_FILE_MAP[firstAgent] || null;
2035
+ await updateSession(projectDir, { workflow });
2036
+ clearTimeline(projectDir);
2037
+ recordEvent(projectDir, EventType.SESSION_STARTED, 'orchestrator', { projectType: type, workflow, language });
2038
+ }
598
2039
 
599
- // Update session with first agent
600
- await updateSession(projectDir, { current_agent: firstAgent });
2040
+ // Determine first/active agent on resume, use current_agent from session
2041
+ let firstAgent;
2042
+ if (isResume) {
2043
+ const s = yaml.load(readFileSync(sessionPath, 'utf-8')) || {};
2044
+ firstAgent = s.current_agent || (isGreenfield ? 'greenfield-wu' : 'brownfield-wu');
2045
+ } else {
2046
+ firstAgent = isGreenfield ? 'greenfield-wu' : 'brownfield-wu';
2047
+ await updateSession(projectDir, { current_agent: firstAgent });
2048
+ }
2049
+ const firstAgentFile = getAgentFile(firstAgent, projectDir) || null;
601
2050
 
602
2051
  // Write session lock
603
2052
  try {
@@ -674,6 +2123,27 @@ async function handleStatus(projectDir) {
674
2123
  agentModels[agentDef.name] = resolveAgentModel(agentDef.name, projectDir);
675
2124
  }
676
2125
 
2126
+ // Fase 10 workstream 2 (context-window-detection v1) — model-aware
2127
+ // context block. Resolves the active window from session.active_model
2128
+ // (populated by prism-engine on first prompt) and reports the exact
2129
+ // bracket prism-engine last computed. If the session predates v1.2
2130
+ // (no context_* fields yet), the block falls back to estimateContextBracket
2131
+ // so the existing response shape stays stable for consumers.
2132
+ const activeModel = session.active_model || null;
2133
+ const activeProvider = session.active_provider || (session.providers_enabled || ['claude'])[0];
2134
+ const windowTokens = session.context_window_tokens != null
2135
+ ? session.context_window_tokens
2136
+ : resolveContextLimit(activeModel, activeProvider);
2137
+ const tokensUsed = session.context_tokens_used != null ? session.context_tokens_used : 0;
2138
+ const remainingPct = windowTokens > 0
2139
+ ? Math.max(0, Math.round((1 - tokensUsed / windowTokens) * 100))
2140
+ : 100;
2141
+ const lastBracket = session.context_last_bracket
2142
+ || (remainingPct < 25 ? 'CRITICAL'
2143
+ : remainingPct < 40 ? 'DEPLETED'
2144
+ : remainingPct < 60 ? 'MODERATE'
2145
+ : 'FRESH');
2146
+
677
2147
  return {
678
2148
  session: summary.summary || summary,
679
2149
  pipeline: progress,
@@ -683,6 +2153,15 @@ async function handleStatus(projectDir) {
683
2153
  agents: session.agents || {},
684
2154
  agent_models: agentModels,
685
2155
  context_bracket: estimateContextBracket((session.completed_agents || []).length, AGENT_PIPELINE.length),
2156
+ context: {
2157
+ model: activeModel,
2158
+ provider: activeProvider,
2159
+ window_tokens: windowTokens,
2160
+ tokens_used: tokensUsed,
2161
+ remaining_pct: remainingPct,
2162
+ bracket: lastBracket,
2163
+ handoff_required: remainingPct < 15,
2164
+ },
686
2165
  user_level: session.user_level || 'auto',
687
2166
  execution_mode: session.execution_mode || 'interactive',
688
2167
  };
@@ -743,6 +2222,16 @@ async function handleDeviation(projectDir, args) {
743
2222
  }
744
2223
 
745
2224
  await updateSession(projectDir, updates);
2225
+
2226
+ // Refresh session lock if agent changed
2227
+ if (updates.current_agent) {
2228
+ try {
2229
+ writeSessionLock(projectDir, updates.current_agent, {
2230
+ phase: session.mode,
2231
+ mode: session.execution_mode || 'interactive',
2232
+ });
2233
+ } catch { /* non-fatal */ }
2234
+ }
746
2235
  }
747
2236
 
748
2237
  return {
@@ -760,6 +2249,7 @@ async function handleExit(projectDir, args) {
760
2249
  }
761
2250
 
762
2251
  // Save current state
2252
+ recordEvent(projectDir, EventType.SESSION_ENDED, session.current_agent || 'orchestrator', { mode: session.mode });
763
2253
  await updateSession(projectDir, {});
764
2254
 
765
2255
  // Release session ownership
@@ -790,8 +2280,16 @@ async function handleProviders(projectDir) {
790
2280
  agentModels[agentDef.name] = resolveAgentModel(agentDef.name, projectDir);
791
2281
  }
792
2282
 
2283
+ let primaryProvider = 'claude';
2284
+ const configPath = join(projectDir, resolveFrameworkDir(projectDir), 'config.yaml');
2285
+ if (existsSync(configPath)) {
2286
+ const raw = readFileSync(configPath, 'utf-8');
2287
+ const providerMatch = raw.match(/primary_provider:\s*["']?(\w+)/);
2288
+ if (providerMatch) primaryProvider = providerMatch[1].toLowerCase();
2289
+ }
2290
+
793
2291
  return {
794
- primary_provider: 'claude',
2292
+ primary_provider: primaryProvider,
795
2293
  agent_models: agentModels,
796
2294
  };
797
2295
  }
@@ -971,7 +2469,7 @@ async function handleScan(projectDir, args) {
971
2469
 
972
2470
  const TEAM_CONFIGS = {
973
2471
  planning: {
974
- members: ['detail', 'architect', 'ux'],
2472
+ members: ['detail', 'architect', 'ux', 'qa-planning'],
975
2473
  slug: 'pln',
976
2474
  templateFile: 'team-planning-tasks.yaml',
977
2475
  },
@@ -996,14 +2494,22 @@ function generateTeamId(slug) {
996
2494
  * (Gemini) or spawn_autonomous (Codex) when the active provider is not claude.
997
2495
  */
998
2496
  function isAgentTeamsEnabled(projectDir) {
999
- const configPath = join(projectDir, 'chati.dev', 'config.yaml');
2497
+ const configPath = join(projectDir, resolveFrameworkDir(projectDir), 'config.yaml');
1000
2498
  if (!existsSync(configPath)) return false;
1001
2499
  const raw = readFileSync(configPath, 'utf-8');
1002
2500
 
1003
2501
  // Feature flag check
1004
2502
  const flagMatch = raw.match(/agent_teams:\s*(true|false)/);
1005
- const flagOn = flagMatch ? flagMatch[1] === 'true' : false;
1006
- if (!flagOn) return false;
2503
+ // If field is explicitly set, respect it
2504
+ if (flagMatch) {
2505
+ if (flagMatch[1] !== 'true') return false;
2506
+ } else {
2507
+ // If field is absent (pre-v4.2.0 projects), default to true for Claude provider
2508
+ // Claude Code supports Agent tool natively; Gemini/Codex fall back to sequential
2509
+ const providerMatch = raw.match(/primary_provider:\s*["']?(\w+)/);
2510
+ const provider = providerMatch ? providerMatch[1].toLowerCase() : 'claude';
2511
+ if (provider !== 'claude') return false;
2512
+ }
1007
2513
 
1008
2514
  // Provider gate — Agent Teams is Claude-only.
1009
2515
  // Read primary_provider from session.yaml (preferred) or active_provider
@@ -1036,7 +2542,7 @@ async function handleSpawnTeam(projectDir, args) {
1036
2542
  const teamDir = join(projectDir, '.chati', 'teams', teamId);
1037
2543
  const mailboxDir = join(teamDir, 'mailbox');
1038
2544
  const taskListPath = join(teamDir, 'tasks.yaml');
1039
- const templatePath = join(projectDir, 'chati.dev', 'templates', config.templateFile);
2545
+ const templatePath = join(projectDir, resolveFrameworkDir(projectDir), 'templates', config.templateFile);
1040
2546
 
1041
2547
  // Create team directories
1042
2548
  mkdirSync(mailboxDir, { recursive: true });
@@ -1047,7 +2553,7 @@ async function handleSpawnTeam(projectDir, args) {
1047
2553
 
1048
2554
  // For build teams: dynamically populate tasks from tasks.md (Article XXI §9)
1049
2555
  if (teamType === 'build') {
1050
- const tasksArtifact = join(projectDir, 'chati.dev', 'artifacts', '6-Tasks', 'tasks.md');
2556
+ const tasksArtifact = join(projectDir, 'artifacts', '6-Tasks', 'tasks.md');
1051
2557
  if (existsSync(tasksArtifact)) {
1052
2558
  const tasksContent = readFileSync(tasksArtifact, 'utf-8');
1053
2559
  // Extract task IDs (pattern: T{phase}.{seq})
@@ -1142,6 +2648,7 @@ async function handleSpawnTeam(projectDir, args) {
1142
2648
  });
1143
2649
 
1144
2650
  await updateSession(projectDir, session);
2651
+ recordEvent(projectDir, EventType.AGENT_ACTIVATED, `team:${teamType}`, { teamId, members: config.members });
1145
2652
  }
1146
2653
  } catch { /* non-critical: session update may fail, team can still spawn */ }
1147
2654
 
@@ -1262,7 +2769,7 @@ async function handleTeamDissolve(projectDir, args) {
1262
2769
  let correctionCyclesOk = true;
1263
2770
  let maxCorrectionCycles = 2; // default per constitution
1264
2771
  try {
1265
- const configPath = join(projectDir, 'chati.dev', 'config.yaml');
2772
+ const configPath = join(projectDir, resolveFrameworkDir(projectDir), 'config.yaml');
1266
2773
  if (existsSync(configPath)) {
1267
2774
  const configRaw = readFileSync(configPath, 'utf-8');
1268
2775
  const maxMatch = configRaw.match(/team_correction_cycles_max:\s*(\d+)/);
@@ -1286,7 +2793,7 @@ async function handleTeamDissolve(projectDir, args) {
1286
2793
  if (!mailboxClean) gateFailures.push('unresolved_mailbox_messages');
1287
2794
  if (!noBlockers) gateFailures.push('open_blockers');
1288
2795
  if (!correctionCyclesOk) gateFailures.push('correction_cycles_exceeded');
1289
- if (teamScore < 90) gateFailures.push('team_score_below_threshold');
2796
+ if (teamScore < tierThreshold) gateFailures.push('team_score_below_threshold');
1290
2797
 
1291
2798
  // Update session
1292
2799
  try {
@@ -1341,12 +2848,296 @@ async function handleTeamDissolve(projectDir, args) {
1341
2848
  mailbox_clean: mailboxClean,
1342
2849
  no_open_blockers: noBlockers,
1343
2850
  correction_cycles_ok: correctionCyclesOk,
1344
- team_score_above_threshold: teamScore >= 90,
2851
+ team_score_above_threshold: teamScore >= tierThreshold,
1345
2852
  },
1346
2853
  },
1347
2854
  };
1348
2855
  }
1349
2856
 
2857
+ // ---------------------------------------------------------------------------
2858
+ // Deterministic subcommands — replace generative LLM decisions with code
2859
+ // ---------------------------------------------------------------------------
2860
+
2861
+ /**
2862
+ * wait-for-license: poll ~/.chati-dev/license.yaml until status=VALID or
2863
+ * a terminal-bad status (EXPIRED|INVALID|REVOKED) or timeout. Used by the
2864
+ * /chati boot path when doctor reports a missing/invalid license — the
2865
+ * orchestrator displays the activate command, then calls this to block
2866
+ * until the user has run it in another terminal.
2867
+ *
2868
+ * @param {string} _projectDir unused (license is home-dir scoped)
2869
+ * @param {{ timeout?: string, interval?: string }} args
2870
+ */
2871
+ async function handleWaitForLicense(_projectDir, args) {
2872
+ const timeoutMs = args.timeout ? parseInt(args.timeout, 10) : undefined;
2873
+ const intervalMs = args.interval ? parseInt(args.interval, 10) : undefined;
2874
+ const result = await waitForLicense({ timeoutMs, intervalMs });
2875
+ return {
2876
+ valid: result.valid,
2877
+ status: result.status,
2878
+ reason: result.reason,
2879
+ waited_ms: result.waitedMs,
2880
+ next: result.valid
2881
+ ? { action: 'proceed', status_summary: 'License VALID — continuing /chati boot.' }
2882
+ : {
2883
+ action: 'license_resolution_required',
2884
+ status_summary: `License status: ${result.status}. ${result.reason || ''}`.trim(),
2885
+ fix: 'Paste in another terminal: `npx chati-dev activate --key=YOUR-KEY`, then re-invoke `/chati`.',
2886
+ },
2887
+ };
2888
+ }
2889
+
2890
+ /**
2891
+ * doctor: run the silent self-diagnostic. Returns the structured verdict
2892
+ * from runDoctor for the orchestrator to surface. Callers are /chati boot
2893
+ * (implicit) and `/chati doctor` (explicit).
2894
+ *
2895
+ * @param {string} projectDir
2896
+ */
2897
+ function handleDoctor(projectDir) {
2898
+ const verdict = runDoctor({ projectDir });
2899
+ return {
2900
+ ok: verdict.ok,
2901
+ checks: verdict.checks,
2902
+ blockers: verdict.blockers,
2903
+ warnings: verdict.warnings,
2904
+ next: verdict.ok
2905
+ ? { action: 'proceed', status_summary: 'doctor: all checks OK (silent pass).' }
2906
+ : {
2907
+ action: 'remediation_required',
2908
+ status_summary: `doctor: ${verdict.blockers.length} blocker(s), ${verdict.warnings.length} warning(s).`,
2909
+ remediations: verdict.blockers.map(b => ({ id: b.id, fix: b.fix })).filter(r => r.fix),
2910
+ },
2911
+ };
2912
+ }
2913
+
2914
+ /**
2915
+ * wait-for-capture: block until capture-complete.json exists (max 10 min).
2916
+ * Replaces Brand Architect's generative polling loop.
2917
+ */
2918
+ async function handleWaitForCapture(projectDir, args) {
2919
+ const refsDir = join(projectDir, 'artifacts', '4-UX', 'references');
2920
+ const completePath = join(refsDir, 'capture-complete.json');
2921
+ const timeoutMs = parseInt(args.timeout || '600000', 10); // 10 min default
2922
+ const pollMs = 5000; // check every 5s
2923
+ const start = Date.now();
2924
+
2925
+ while (Date.now() - start < timeoutMs) {
2926
+ if (existsSync(completePath)) {
2927
+ try {
2928
+ const data = JSON.parse(readFileSync(completePath, 'utf-8'));
2929
+ return { ready: true, ...data };
2930
+ } catch {
2931
+ return { ready: true, error: 'capture-complete.json exists but invalid JSON' };
2932
+ }
2933
+ }
2934
+ await new Promise(r => setTimeout(r, pollMs));
2935
+ }
2936
+
2937
+ return { ready: false, timedOut: true, message: `capture-complete.json not found after ${timeoutMs / 1000}s. Proceed with WebFetch fallback.` };
2938
+ }
2939
+
2940
+ /**
2941
+ * qa-visual-score: calculate visual QA score deterministically from report.json.
2942
+ * Replaces QA-Visual's generative scoring formula.
2943
+ */
2944
+ function handleQaVisualScore(args) {
2945
+ const reportPath = args.report || '/tmp/visual-qa/report.json';
2946
+ if (!existsSync(reportPath)) {
2947
+ return errorResult(`report.json not found at ${reportPath}`, 'REPORT_NOT_FOUND');
2948
+ }
2949
+
2950
+ let report;
2951
+ try { report = JSON.parse(readFileSync(reportPath, 'utf-8')); } catch {
2952
+ return errorResult('report.json is invalid JSON', 'INVALID_REPORT');
2953
+ }
2954
+
2955
+ const s = report.summary || {};
2956
+ const hasRefs = args['has-refs'] === 'true';
2957
+
2958
+ // Weights (adjust when references exist)
2959
+ const w = hasRefs
2960
+ ? { animation: 0.30, scroll: 0.20, responsive: 0.15, hover: 0.10, brand: 0.10, fidelity: 0.15 }
2961
+ : { animation: 0.35, scroll: 0.25, responsive: 0.20, hover: 0.10, brand: 0.10, fidelity: 0 };
2962
+
2963
+ // Base scores per dimension (0-100)
2964
+ const animScore = Math.min(100, (s.lenis_active_all ? 50 : 0) + (s.gsap_loaded_all ? 30 : 0) + Math.min(20, (s.total_gsap_animations || 0) * 2));
2965
+ const scrollScore = (s.total_scroll_triggers || 0) > 0 ? 80 + Math.min(20, s.total_scroll_triggers * 2) : 40;
2966
+ const responsiveScore = 80; // baseline — actual responsive check is visual (generative)
2967
+ const hoverScore = 80; // baseline
2968
+ const brandScore = (s.reduced_motion_support ? 10 : 0) + Math.min(90, (report.pages?.[0]?.js_checks?.custom_properties_count ?? 0));
2969
+ const fidelityScore = 70; // placeholder — Visualizer comparison is generative
2970
+
2971
+ let rawScore = Math.round(
2972
+ animScore * w.animation +
2973
+ scrollScore * w.scroll +
2974
+ responsiveScore * w.responsive +
2975
+ hoverScore * w.hover +
2976
+ brandScore * w.brand +
2977
+ fidelityScore * w.fidelity
2978
+ );
2979
+
2980
+ // Penalties
2981
+ const errors = [];
2982
+ const warnings = [];
2983
+
2984
+ if (!s.lenis_active_all) errors.push('Lenis not active on all pages');
2985
+ if (!s.gsap_loaded_all) errors.push('GSAP not loaded on all pages');
2986
+ if (s.em_dashes_detected) { errors.push('Em-dashes found in visible text'); rawScore -= 10; }
2987
+ if (s.console_errors_total > 0) { warnings.push(`${s.console_errors_total} console error(s)`); rawScore -= 5; }
2988
+
2989
+ // Hard blocks
2990
+ const hardBlocked = !s.lenis_active_all || !s.gsap_loaded_all || s.em_dashes_detected;
2991
+ const score = Math.max(0, Math.min(100, rawScore));
2992
+ const threshold = 90;
2993
+ const verdict = hardBlocked ? 'BLOCKED' : score >= threshold ? 'APPROVED' : 'NEEDS_CORRECTION';
2994
+
2995
+ return { score, threshold, verdict, hardBlocked, errors, warnings, weights: w, dimensions: { animScore, scrollScore, responsiveScore, hoverScore, brandScore, fidelityScore } };
2996
+ }
2997
+
2998
+ /**
2999
+ * wave-status: read Build Team tasks.yaml + mailbox, return wave status.
3000
+ * Replaces Claude's manual mailbox counting.
3001
+ */
3002
+ async function handleWaveStatus(projectDir, args) {
3003
+ const teamId = args['team-id'];
3004
+ if (!teamId) return errorResult('Missing --team-id flag', 'MISSING_TEAM_ID');
3005
+
3006
+ const teamDir = join(projectDir, '.chati', 'teams', teamId);
3007
+ const tasksPath = join(teamDir, 'tasks.yaml');
3008
+ if (!existsSync(tasksPath)) return errorResult(`Team tasks not found: ${tasksPath}`, 'TEAM_NOT_FOUND');
3009
+
3010
+ let tasks;
3011
+ try { tasks = yaml.load(readFileSync(tasksPath, 'utf-8')); } catch {
3012
+ return errorResult('tasks.yaml is invalid', 'INVALID_TASKS');
3013
+ }
3014
+
3015
+ const taskList = tasks.tasks || [];
3016
+ const completed = taskList.filter(t => t.status === 'done' || t.status === 'completed');
3017
+ const inProgress = taskList.filter(t => t.status === 'in_progress');
3018
+ const pending = taskList.filter(t => t.status === 'pending');
3019
+ const blocked = taskList.filter(t => t.blocker);
3020
+ const failed = taskList.filter(t => t.status === 'failed' || t.status === 'needs_correction');
3021
+
3022
+ // Determine wave type
3023
+ let waveType = 'regular';
3024
+ if (pending.length === 0 && failed.length > 0) waveType = 'cleanup';
3025
+ if (pending.length === 0 && failed.length === 0 && inProgress.length === 0) waveType = 'complete';
3026
+
3027
+ return {
3028
+ team_id: teamId,
3029
+ wave_type: waveType,
3030
+ total: taskList.length,
3031
+ completed: completed.length,
3032
+ in_progress: inProgress.length,
3033
+ pending: pending.length,
3034
+ failed: failed.length,
3035
+ blocked: blocked.length,
3036
+ all_done: pending.length === 0 && inProgress.length === 0 && failed.length === 0,
3037
+ recommendation: waveType === 'complete'
3038
+ ? 'All tasks complete. Ready for team dissolution.'
3039
+ : waveType === 'cleanup'
3040
+ ? `${failed.length} task(s) need correction. Start Cleanup Wave.`
3041
+ : `${pending.length} task(s) pending, ${inProgress.length} in progress. Continue Regular Wave.`,
3042
+ };
3043
+ }
3044
+
3045
+ /**
3046
+ * Brief category dictionaries — user-input i18n tokens keyed by ISO 639-1.
3047
+ *
3048
+ * These match free-form brief text against 7 coverage categories. The set is
3049
+ * routed through session.language at call time (same exemption pattern as
3050
+ * RESUME_MESSAGES). English is always unioned in; the locale layer adds
3051
+ * dialect tokens so projects running brief in pt/es/fr still get coverage.
3052
+ *
3053
+ * Article VII: code identifiers and framework strings stay English; these
3054
+ * locale entries are user-input tokens, analogous to i18n message catalogs.
3055
+ */
3056
+ export const BRIEF_CATEGORY_KEYWORDS = {
3057
+ en: {
3058
+ problem: ['problem', 'issue', 'challenge', 'pain', 'struggle', 'frustrate', 'difficult', 'gap', 'need', 'fix'],
3059
+ users: ['user', 'customer', 'client', 'audience', 'persona', 'visitor', 'target', 'demographic', 'segment'],
3060
+ outcome: ['goal', 'outcome', 'result', 'success', 'metric', 'kpi', 'achieve', 'objective', 'want', 'expect'],
3061
+ constraints: ['budget', 'timeline', 'deadline', 'team', 'resource', 'limit', 'constraint', 'technology', 'stack'],
3062
+ scope: ['scope', 'feature', 'page', 'section', 'include', 'exclude', 'not build', 'out of scope', 'mvp', 'v1'],
3063
+ references: ['reference', 'competitor', 'inspiration', 'like', 'similar', 'example', 'benchmark', 'design', 'look'],
3064
+ context: ['company', 'business', 'industry', 'market', 'brand', 'history', 'background', 'about', 'mission'],
3065
+ },
3066
+ pt: {
3067
+ problem: ['problema', 'desafio', 'dor', 'dificuldade', 'necessidade', 'resolver', 'corrigir'],
3068
+ users: ['usuario', 'usu\u00e1rio', 'cliente', 'p\u00fablico', 'visitante', 'alvo', 'pessoa'],
3069
+ outcome: ['objetivo', 'meta', 'resultado', 'sucesso', 'quero', 'espero', 'alcan\u00e7ar', 'atingir'],
3070
+ constraints: ['or\u00e7amento', 'prazo', 'equipe', 'recurso', 'limite', 'tecnologia', 'restri\u00e7\u00e3o'],
3071
+ scope: ['escopo', 'funcionalidade', 'p\u00e1gina', 'incluir', 'excluir', 'n\u00e3o fazer', 'fora do escopo'],
3072
+ references: ['refer\u00eancia', 'referencia', 'concorrente', 'inspira\u00e7\u00e3o', 'inspiracao', 'parecido', 'exemplo', 'visual'],
3073
+ context: ['empresa', 'neg\u00f3cio', 'negocio', 'mercado', 'marca', 'hist\u00f3ria', 'historia', 'sobre', 'miss\u00e3o'],
3074
+ },
3075
+ es: {
3076
+ problem: ['problema', 'desaf\u00edo', 'dolor', 'dificultad', 'necesidad', 'resolver', 'corregir'],
3077
+ users: ['usuario', 'cliente', 'audiencia', 'p\u00fablico', 'visitante', 'objetivo', 'persona'],
3078
+ outcome: ['meta', 'objetivo', 'resultado', '\u00e9xito', 'quiero', 'espero', 'lograr', 'alcanzar'],
3079
+ constraints: ['presupuesto', 'plazo', 'equipo', 'recurso', 'l\u00edmite', 'tecnolog\u00eda', 'restricci\u00f3n'],
3080
+ scope: ['alcance', 'funcionalidad', 'p\u00e1gina', 'incluir', 'excluir', 'no hacer', 'fuera de alcance'],
3081
+ references: ['referencia', 'competidor', 'inspiraci\u00f3n', 'parecido', 'ejemplo', 'visual'],
3082
+ context: ['empresa', 'negocio', 'mercado', 'marca', 'historia', 'acerca', 'misi\u00f3n'],
3083
+ },
3084
+ fr: {
3085
+ problem: ['probl\u00e8me', 'd\u00e9fi', 'douleur', 'difficult\u00e9', 'besoin', 'r\u00e9soudre', 'corriger'],
3086
+ users: ['utilisateur', 'client', 'audience', 'public', 'visiteur', 'cible', 'persona'],
3087
+ outcome: ['objectif', 'r\u00e9sultat', 'succ\u00e8s', 'veux', 'souhaite', 'atteindre'],
3088
+ constraints: ['budget', 'd\u00e9lai', '\u00e9quipe', 'ressource', 'limite', 'technologie', 'contrainte'],
3089
+ scope: ['p\u00e9rim\u00e8tre', 'fonctionnalit\u00e9', 'page', 'inclure', 'exclure', 'hors p\u00e9rim\u00e8tre'],
3090
+ references: ['r\u00e9f\u00e9rence', 'concurrent', 'inspiration', 'similaire', 'exemple', 'visuel'],
3091
+ context: ['entreprise', 'march\u00e9', 'marque', 'histoire', 'mission'],
3092
+ },
3093
+ };
3094
+
3095
+ const BRIEF_CATEGORIES = ['problem', 'users', 'outcome', 'constraints', 'scope', 'references', 'context'];
3096
+
3097
+ /**
3098
+ * Build the keyword set for a category at a given language. Always includes EN
3099
+ * tokens; the locale layer is appended when session.language selects it.
3100
+ *
3101
+ * @param {string} category One of BRIEF_CATEGORIES.
3102
+ * @param {string} [language='en'] ISO 639-1 session language.
3103
+ * @returns {string[]}
3104
+ */
3105
+ export function getBriefCategoryKeywords(category, language = 'en') {
3106
+ const en = BRIEF_CATEGORY_KEYWORDS.en[category] ?? [];
3107
+ if (language === 'en') return [...en];
3108
+ const locale = BRIEF_CATEGORY_KEYWORDS[language]?.[category] ?? [];
3109
+ return [...en, ...locale];
3110
+ }
3111
+
3112
+ /**
3113
+ * assess-coverage: classify user input against 7 Brief categories.
3114
+ * Replaces Brief's generative coverage assessment.
3115
+ */
3116
+ function handleAssessCoverage(args) {
3117
+ const input = (args.input || '').toLowerCase();
3118
+ if (!input) return errorResult('Missing --input flag', 'MISSING_INPUT');
3119
+ const language = (args.language || 'en').toLowerCase();
3120
+
3121
+ const categories = {};
3122
+ for (const cat of BRIEF_CATEGORIES) {
3123
+ const keywords = getBriefCategoryKeywords(cat, language);
3124
+ categories[cat] = { keywords, found: keywords.some(k => input.includes(k)) };
3125
+ }
3126
+
3127
+ const covered = Object.entries(categories).filter(([, d]) => d.found).map(([k]) => k);
3128
+ const missing = Object.entries(categories).filter(([, d]) => !d.found).map(([k]) => k);
3129
+ const coveragePct = Math.round((covered.length / BRIEF_CATEGORIES.length) * 100);
3130
+
3131
+ return {
3132
+ coverage_pct: coveragePct,
3133
+ covered,
3134
+ missing,
3135
+ recommendation: coveragePct >= 50
3136
+ ? `Good coverage (${coveragePct}%). Clarify: ${missing.join(', ')}.`
3137
+ : `Low coverage (${coveragePct}%). Deep exploration needed on: ${missing.join(', ')}.`,
3138
+ };
3139
+ }
3140
+
1350
3141
  // ---------------------------------------------------------------------------
1351
3142
  // Main entry point
1352
3143
  // ---------------------------------------------------------------------------
@@ -1404,6 +3195,24 @@ export async function runOrchestrate(subCommand, argv, projectDir) {
1404
3195
  case 'team-dissolve':
1405
3196
  output(await handleTeamDissolve(projectDir, args));
1406
3197
  break;
3198
+ case 'wait-for-capture':
3199
+ output(await handleWaitForCapture(projectDir, args));
3200
+ break;
3201
+ case 'wait-for-license':
3202
+ output(await handleWaitForLicense(projectDir, args));
3203
+ break;
3204
+ case 'doctor':
3205
+ output(handleDoctor(projectDir));
3206
+ break;
3207
+ case 'qa-visual-score':
3208
+ output(handleQaVisualScore(args));
3209
+ break;
3210
+ case 'wave-status':
3211
+ output(await handleWaveStatus(projectDir, args));
3212
+ break;
3213
+ case 'assess-coverage':
3214
+ output(handleAssessCoverage(args));
3215
+ break;
1407
3216
  default:
1408
3217
  output(errorResult(`Unknown sub-command: ${subCommand}. Valid: next, advance, init, validate-handoff, status, deviation, exit, providers, detect-flow, backlog, qa-plan-score, qa-impl-score, scan, spawn-team, team-status, team-dissolve`, 'UNKNOWN_COMMAND'));
1409
3218
  break;