chati-dev 4.2.1 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/README.md +80 -53
  2. package/bin/chati.js +150 -5
  3. package/framework/agents/build/dev.md +509 -76
  4. package/framework/agents/deploy/devops.md +40 -25
  5. package/framework/agents/discover/brief.md +156 -22
  6. package/framework/agents/discover/brownfield-wu.md +24 -14
  7. package/framework/agents/discover/greenfield-wu.md +100 -14
  8. package/framework/agents/plan/architect-data-engineer.md +6 -6
  9. package/framework/agents/plan/architect-system.md +46 -12
  10. package/framework/agents/plan/architect.md +40 -20
  11. package/framework/agents/plan/detail.md +36 -24
  12. package/framework/agents/plan/phases.md +36 -26
  13. package/framework/agents/plan/tasks.md +114 -33
  14. package/framework/agents/plan/ux-brand-architect.md +240 -8
  15. package/framework/agents/plan/ux-component-engineer.md +28 -13
  16. package/framework/agents/plan/ux-researcher.md +7 -6
  17. package/framework/agents/plan/ux.md +55 -22
  18. package/framework/agents/quality/qa-implementation.md +143 -74
  19. package/framework/agents/quality/qa-planning.md +115 -42
  20. package/framework/agents/quality/qa-visual.md +439 -0
  21. package/framework/agents/shared/visualizer.md +128 -0
  22. package/framework/config.yaml +8 -7
  23. package/framework/constitution.md +127 -43
  24. package/framework/context/governance.md +12 -7
  25. package/framework/context/quality.md +6 -5
  26. package/framework/context/root.md +6 -6
  27. package/framework/data/entity-registry.yaml +377 -4
  28. package/framework/data/model-limits.json +19 -0
  29. package/framework/domains/agents/qa-visual.yaml +74 -0
  30. package/framework/domains/constitution.yaml +46 -2
  31. package/framework/domains/workflows/greenfield-fullstack.yaml +2 -2
  32. package/framework/hooks/advance-trigger.js +131 -0
  33. package/framework/hooks/brief-validator.js +83 -0
  34. package/framework/hooks/constitution-guard.js +91 -36
  35. package/framework/hooks/license-guard.js +129 -190
  36. package/framework/hooks/mode-governance.js +68 -16
  37. package/framework/hooks/model-governance.js +19 -8
  38. package/framework/hooks/package.json +3 -0
  39. package/framework/hooks/post-dev.js +207 -0
  40. package/framework/hooks/prism-engine.js +288 -105
  41. package/framework/hooks/read-protection.js +37 -9
  42. package/framework/hooks/reasoning-escalator.js +371 -0
  43. package/framework/hooks/reference-trigger.js +117 -0
  44. package/framework/hooks/session-digest.js +94 -20
  45. package/framework/hooks/settings.json +32 -1
  46. package/framework/hooks/style-guard.js +52 -13
  47. package/framework/hooks/team-quality-gate.js +58 -25
  48. package/framework/hooks/undercover-guard.js +34 -13
  49. package/framework/i18n/en.yaml +3 -3
  50. package/framework/i18n/es.yaml +3 -3
  51. package/framework/i18n/fr.yaml +3 -3
  52. package/framework/i18n/pt.yaml +3 -3
  53. package/framework/intelligence/context-engine.md +4 -5
  54. package/framework/intelligence/decision-engine.md +1 -1
  55. package/framework/intelligence/hooks-performance.md +3 -3
  56. package/framework/migrations/v1.0-to-v1.1.yaml +1 -1
  57. package/framework/migrations/v1.4-to-v2.0.yaml +11 -11
  58. package/framework/migrations/v4.0-to-v4.1.yaml +2 -2
  59. package/framework/migrations/v4.2-to-v4.3.yaml +29 -0
  60. package/framework/orchestrator/chati-router.js +387 -0
  61. package/framework/orchestrator/chati-update.md +40 -40
  62. package/framework/orchestrator/chati.md +318 -115
  63. package/framework/scaffold/motion-premium/README.md +89 -0
  64. package/framework/scaffold/motion-premium/app/globals.css.template +400 -0
  65. package/framework/scaffold/motion-premium/app/layout.tsx.template +110 -0
  66. package/framework/scaffold/motion-premium/components/animation/BackgroundCrossfadeProvider.tsx.template +170 -0
  67. package/framework/scaffold/motion-premium/components/animation/LenisProvider.tsx.template +49 -0
  68. package/framework/scaffold/motion-premium/components/animation/PageTransitionWrapper.tsx.template +83 -0
  69. package/framework/scaffold/motion-premium/components/animation/Preloader.tsx.template +171 -0
  70. package/framework/scaffold/motion-premium/components/ui/Container.tsx.template +69 -0
  71. package/framework/scaffold/motion-premium/components/ui/PageSection.tsx.template +74 -0
  72. package/framework/scaffold/motion-premium/lib/animations/gsap.ts.template +112 -0
  73. package/framework/scaffold/motion-premium/lib/animations/refreshCoordinator.ts.template +75 -0
  74. package/framework/scaffold/motion-premium/lib/animations/tokens.ts.template +119 -0
  75. package/framework/scaffold/motion-premium/lib/animations/useGsapContext.ts.template +92 -0
  76. package/framework/scaffold/motion-premium/lib/animations/useScrollSnapStepper.ts.template +265 -0
  77. package/framework/scaffold/motion-premium/lib/animations/useSmoothScroll.ts.template +67 -0
  78. package/framework/scaffold/motion-premium/lib/brand.ts.template +43 -0
  79. package/framework/scaffold/motion-premium/scaffold.yaml +174 -0
  80. package/framework/scaffold/motion-premium-3d/README.md +80 -0
  81. package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/ScrollDemoCanvas.tsx.template +81 -0
  82. package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/ScrollDemoClient.tsx.template +75 -0
  83. package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/page.tsx.template +26 -0
  84. package/framework/scaffold/motion-premium-3d/components/3d/CameraRig.tsx.template +100 -0
  85. package/framework/scaffold/motion-premium-3d/components/3d/CanvasProvider.tsx.template +85 -0
  86. package/framework/scaffold/motion-premium-3d/components/3d/InvalidateOnScroll.tsx.template +51 -0
  87. package/framework/scaffold/motion-premium-3d/components/3d/MeshCrossfade.tsx.template +79 -0
  88. package/framework/scaffold/motion-premium-3d/components/3d/ScrollCrossfade.tsx.template +88 -0
  89. package/framework/scaffold/motion-premium-3d/components/3d/ScrollScene.tsx.template +121 -0
  90. package/framework/scaffold/motion-premium-3d/components/webgl/SceneFallback.tsx.template +65 -0
  91. package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +68 -0
  92. package/framework/scaffold/motion-premium-3d/lib/webgl/detect.ts.template +69 -0
  93. package/framework/scaffold/motion-premium-3d/scaffold.yaml +133 -0
  94. package/framework/schemas/session.schema.json +109 -21
  95. package/framework/scripts/reference-capture.js +430 -0
  96. package/framework/scripts/visual-qa.js +674 -0
  97. package/framework/tasks/orchestrator-handoff.md +1 -1
  98. package/framework/tasks/orchestrator-resume.md +1 -1
  99. package/framework/tasks/orchestrator-route.md +1 -1
  100. package/framework/tasks/orchestrator-status.md +3 -3
  101. package/framework/tasks/qa-planning-gate-define.md +1 -1
  102. package/framework/templates/brandbook-html-tmpl.md +1 -1
  103. package/framework/templates/brandbook-tmpl.yaml +1 -1
  104. package/framework/templates/component-spec-tmpl.yaml +1 -1
  105. package/framework/templates/design-token-tmpl.yaml +1 -1
  106. package/framework/templates/icon-system-tmpl.yaml +1 -1
  107. package/framework/templates/team-planning-tasks.yaml +6 -5
  108. package/framework/workflows/brownfield-discovery.yaml +2 -2
  109. package/framework/workflows/brownfield-fullstack.yaml +15 -11
  110. package/framework/workflows/brownfield-service.yaml +14 -10
  111. package/framework/workflows/brownfield-ui.yaml +15 -11
  112. package/framework/workflows/greenfield-fullstack.yaml +16 -13
  113. package/framework/workflows/quick-flow.yaml +3 -3
  114. package/framework/workflows/standard-flow.yaml +12 -9
  115. package/package.json +10 -5
  116. package/src/autonomy/autonomous-gate.js +1 -0
  117. package/src/autonomy/build-state.js +1 -2
  118. package/src/autonomy/progress-reporter.js +1 -1
  119. package/src/config/agent-customizer.js +11 -3
  120. package/src/config/claude-settings-generator.js +226 -0
  121. package/src/config/context-file-generator.js +41 -21
  122. package/src/config/framework-adapter.js +1 -0
  123. package/src/config/gemini-hooks-generator.js +76 -6
  124. package/src/config/mcp-configs.js +1 -0
  125. package/src/context/layers/l1-global.js +2 -1
  126. package/src/dashboard/data-reader.js +4 -3
  127. package/src/dashboard/layout.js +2 -1
  128. package/src/decision/analyzer.js +6 -30
  129. package/src/decision/engine.js +4 -28
  130. package/src/decision/registry-healer.js +3 -2
  131. package/src/decision/registry-updater.js +23 -14
  132. package/src/extensions/loader.js +2 -8
  133. package/src/gates/g1-planning-complete.js +2 -1
  134. package/src/gates/g2-qa-planning.js +2 -1
  135. package/src/gates/g3-implementation.js +2 -1
  136. package/src/gates/g4-qa-implementation.js +3 -2
  137. package/src/gates/g5-deploy-ready.js +2 -1
  138. package/src/health/engine.js +4 -3
  139. package/src/installer/core.js +557 -81
  140. package/src/installer/preflight.js +131 -0
  141. package/src/installer/provider-overlay.js +3 -3
  142. package/src/installer/scaffold-applier.js +358 -0
  143. package/src/installer/templates.js +52 -31
  144. package/src/installer/validator.js +17 -12
  145. package/src/intelligence/registry-manager.js +22 -29
  146. package/src/intelligence/timeline.js +11 -6
  147. package/src/license/commands.js +1 -1
  148. package/src/license/wait.js +102 -0
  149. package/src/memory/agent-memory.js +81 -0
  150. package/src/memory/dream.js +32 -1
  151. package/src/merger/replace-merger.js +28 -15
  152. package/src/orchestrator/agent-selector.js +2 -1
  153. package/src/orchestrator/cli.js +1908 -80
  154. package/src/orchestrator/doctor.js +270 -0
  155. package/src/orchestrator/handoff-engine.js +4 -3
  156. package/src/orchestrator/index.js +2 -0
  157. package/src/orchestrator/pipeline-manager.js +306 -15
  158. package/src/orchestrator/session-manager.js +331 -6
  159. package/src/tasks/handoff.js +3 -2
  160. package/src/telemetry/config.js +4 -3
  161. package/src/telemetry/schema.js +1 -0
  162. package/src/terminal/collector.js +3 -2
  163. package/src/terminal/index.js +1 -2
  164. package/src/terminal/isolation.js +52 -18
  165. package/src/terminal/prompt-builder.js +42 -25
  166. package/src/terminal/run-parallel.js +1 -1
  167. package/src/terminal/run-team.js +3 -3
  168. package/src/terminal/team-task-list.js +43 -4
  169. package/src/upgrade/backup.js +3 -2
  170. package/src/upgrade/checker.js +3 -2
  171. package/src/upgrade/migrator.js +65 -7
  172. package/src/upgrade/tracked-files-detector.js +86 -0
  173. package/src/upgrade/user-messages.js +94 -0
  174. package/src/utils/config-parser.js +2 -1
  175. package/src/utils/feature-flags.js +2 -1
  176. package/src/utils/flatten-entities.js +69 -0
  177. package/src/utils/framework-dir.js +16 -0
  178. package/src/utils/model-id.js +85 -0
  179. package/src/utils/provider-limits.js +84 -23
  180. package/src/utils/schema-validator.js +1 -1
  181. package/src/wizard/i18n.js +5 -4
  182. package/src/wizard/index.js +14 -0
  183. package/assets/logo - c/303/263pia.png +0 -0
  184. package/assets/logo.svg +0 -42
  185. package/assets/logo2.png +0 -0
  186. package/assets/social-preview.png +0 -0
  187. package/scripts/bundle-framework.js +0 -69
  188. package/scripts/changelog-generator.js +0 -222
  189. package/scripts/codebase-mapper.js +0 -728
  190. package/scripts/commit-message-generator.js +0 -167
  191. package/scripts/coverage-analyzer.js +0 -260
  192. package/scripts/dependency-analyzer.js +0 -280
  193. package/scripts/doctor/checks/agents.js +0 -77
  194. package/scripts/doctor/checks/constitution.js +0 -41
  195. package/scripts/doctor/checks/domain-alignment.js +0 -58
  196. package/scripts/doctor/checks/prism-layers.js +0 -84
  197. package/scripts/doctor/checks/registry.js +0 -55
  198. package/scripts/doctor/checks/schemas.js +0 -61
  199. package/scripts/doctor/fixes/reference-fix.js +0 -100
  200. package/scripts/doctor/fixes/registry-fix.js +0 -56
  201. package/scripts/doctor/index.js +0 -212
  202. package/scripts/framework-analyzer.js +0 -308
  203. package/scripts/generate-constitution-domain.js +0 -253
  204. package/scripts/generate-signing-key.js +0 -33
  205. package/scripts/health-check.js +0 -481
  206. package/scripts/ide-sync.js +0 -326
  207. package/scripts/performance-analyzer.js +0 -325
  208. package/scripts/plan-tracker.js +0 -278
  209. package/scripts/populate-entity-registry.js +0 -481
  210. package/scripts/pr-review.js +0 -317
  211. package/scripts/rollback-manager.js +0 -310
  212. package/scripts/semantic-lint.js +0 -328
  213. package/scripts/sign-manifest.js +0 -53
  214. package/scripts/stuck-detector.js +0 -343
  215. package/scripts/test-quality-assessment.js +0 -257
  216. package/scripts/validate-agents.js +0 -368
  217. package/scripts/validate-package.js +0 -367
  218. package/scripts/validate-tasks.js +0 -465
  219. package/src/autonomy/worktree-manager.js +0 -250
  220. package/src/intelligence/decision-engine.js +0 -256
  221. package/src/intelligence/document-sharder.js +0 -221
  222. package/src/intelligence/elicitation.js +0 -265
@@ -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,37 @@ 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
+ }
223
632
 
224
- const lockBlock = `${LOCK_START}
225
- ## Session Lock -- ACTIVE
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
+ const lockInner = `## Session Lock -- ACTIVE
226
638
 
227
639
  **Chati.dev session is ACTIVE.** Follow these rules for EVERY message:
228
640
 
229
- 1. Read \`chati.dev/orchestrator/chati.md\` and follow its routing logic
641
+ 1. Read \`${resolveFrameworkDir(projectDir)}/orchestrator/chati.md\` and follow its routing logic
230
642
  2. Route ALL user messages through the current agent: \`${currentAgent}\`
231
643
  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
- `;
644
+ 4. The ONLY way to exit is via \`/chati exit\`, \`/chati stop\`, or \`/chati quit\``;
645
+
646
+ const stateInner = `## Current State
647
+ - **Agent**: ${currentAgent || 'None'}
648
+ - **Phase**: ${stateInfo.phase || 'discover'}
649
+ - **Pipeline**: ${stateInfo.position ?? 0}/${stateInfo.total ?? '?'} (${stateInfo.progress ?? 0}%)
650
+ - **Mode**: ${stateInfo.mode || 'interactive'}`;
235
651
 
236
- content = content.trimEnd() + '\n\n' + lockBlock;
652
+ content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
653
+ content = replaceBlock(content, STATE_START, STATE_END, stateInner);
237
654
  writeFileSync(localMdPath, content, 'utf-8');
238
655
  }
239
656
 
@@ -246,11 +663,15 @@ function removeSessionLock(projectDir, resumeMsg) {
246
663
 
247
664
  let content = readFileSync(localMdPath, 'utf-8');
248
665
 
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
- }
666
+ const lockInner = `## Session Lock
667
+ **Status: INACTIVE** Type \`/chati\` to activate.`;
668
+ const stateInner = `## Current State
669
+ - **Agent**: None (ready to start)
670
+ - **Pipeline**: Pre-start
671
+ - **Mode**: interactive`;
672
+
673
+ content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
674
+ content = replaceBlock(content, STATE_START, STATE_END, stateInner);
254
675
 
255
676
  if (resumeMsg) {
256
677
  content = content.trimEnd() + `\n\n## Session Paused\n\n${resumeMsg}\n`;
@@ -264,6 +685,48 @@ function removeSessionLock(projectDir, resumeMsg) {
264
685
  // ---------------------------------------------------------------------------
265
686
 
266
687
  async function handleNext(projectDir) {
688
+ const result = await _handleNextInner(projectDir);
689
+
690
+ // Fase 8: attach state-at-a-glance card to every non-error response.
691
+ // The card renders as a JSON string field; the terminal / orchestrator
692
+ // prompt can console.log it verbatim. Silent on setup/error responses
693
+ // so a missing session does not produce a misleading card.
694
+ if (result && result.action !== 'setup' && result.action !== 'error') {
695
+ try {
696
+ const { session } = loadSession(projectDir);
697
+ if (session) {
698
+ result.state_card = buildStateCard({ session, projectDir });
699
+ }
700
+ } catch { /* non-fatal — card is UX sugar */ }
701
+ }
702
+
703
+ // Centralized session lock update: applies to ALL return paths.
704
+ // Activates lock when an agent is returned, deactivates on completion.
705
+ if (result && result.agent && result.action !== 'error' && result.action !== 'setup') {
706
+ const activating = ['activate_interactive', 'spawn_autonomous', 'spawn_team', 'spawn_parallel'];
707
+ if (activating.includes(result.action)) {
708
+ try {
709
+ writeSessionLock(projectDir, result.agent, {
710
+ phase: result.phase || 'discover',
711
+ mode: result.session?.execution_mode || 'interactive',
712
+ position: result.pipeline_progress?.completedAgents?.length ?? 0,
713
+ total: AGENT_PIPELINE.length,
714
+ progress: result.pipeline_progress?.progress ?? 0,
715
+ });
716
+ } catch { /* non-fatal */ }
717
+ }
718
+ } else if (result && result.action === 'complete') {
719
+ try {
720
+ const { session } = loadSession(projectDir);
721
+ const lang = session?.language || 'en';
722
+ removeSessionLock(projectDir, RESUME_MESSAGES[lang] || RESUME_MESSAGES.en);
723
+ } catch { /* non-fatal */ }
724
+ }
725
+
726
+ return result;
727
+ }
728
+
729
+ async function _handleNextInner(projectDir) {
267
730
  const { loaded, session } = loadSession(projectDir);
268
731
  if (!loaded || !session) {
269
732
  return { action: 'setup', status_summary: 'No session found. Project needs initialization.' };
@@ -291,11 +754,37 @@ async function handleNext(projectDir) {
291
754
  if (currentAgent && !completedAgents.includes(currentAgent)) {
292
755
  const agent = currentAgent;
293
756
  const agentStatus = session.agents?.[agent]?.status;
294
- if (agentStatus === 'in_progress' || (agentStatus !== 'completed' && agentStatus !== 'skipped')) {
295
- const agentFile = AGENT_FILE_MAP[agent] || null;
757
+ if (agentStatus === 'in_progress') {
758
+ const agentFile = getAgentFile(agent, projectDir) || null;
296
759
  const agentDef = getAgentDefinition(agent);
297
760
  const isInteractive = INTERACTIVE_AGENTS.includes(agent);
298
761
 
762
+ // Build Team override: even when resuming, if agent is 'dev' and teams
763
+ // enabled, return spawn_team so Dev + QA-Implementation run together.
764
+ if (agent === 'dev' && isAgentTeamsEnabled(projectDir)) {
765
+ const teamConfig = TEAM_CONFIGS.build;
766
+ const teamId = generateTeamId(teamConfig.slug);
767
+ return {
768
+ action: 'spawn_team',
769
+ agent,
770
+ agent_file: agentFile,
771
+ phase: agentDef?.phase || session.mode,
772
+ spawn_command: null,
773
+ parallel_spawn_command: null,
774
+ parallel_agents: teamConfig.members,
775
+ handoff_status: { valid: true, missing: [], warnings: ['Resuming dev as Build Team'] },
776
+ gate_status: { canAdvance: true, reason: 'Build Team formation (Article XXI)' },
777
+ model_info: resolveAgentModel(agent, projectDir),
778
+ context_bracket: estimateContextBracket(completedAgents.length, AGENT_PIPELINE.length),
779
+ pipeline_progress: getPipelineProgress(pipelineState),
780
+ session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
781
+ status_summary: `Forming Build Team: dev + qa-implementation.`,
782
+ team_id: teamId,
783
+ team_type: 'build',
784
+ members: teamConfig.members,
785
+ };
786
+ }
787
+
299
788
  return {
300
789
  action: isInteractive ? 'activate_interactive' : 'spawn_autonomous',
301
790
  agent,
@@ -322,7 +811,7 @@ async function handleNext(projectDir) {
322
811
  if (!lastAgent || completedAgents.length === 0) {
323
812
  const projectType = session.project_type || session.project?.type || 'greenfield';
324
813
  const firstAgent = projectType === 'brownfield' ? 'brownfield-wu' : 'greenfield-wu';
325
- const agentFile = AGENT_FILE_MAP[firstAgent] || null;
814
+ const agentFile = getAgentFile(firstAgent, projectDir) || null;
326
815
 
327
816
  return {
328
817
  action: 'activate_interactive',
@@ -342,8 +831,24 @@ async function handleNext(projectDir) {
342
831
  };
343
832
  }
344
833
 
345
- // Get next agent
346
- const nextInfo = getNextAgent(lastAgent, completedAgents);
834
+ // Get next agent — if lastAgent is not in AGENT_PIPELINE (e.g. qa-planning
835
+ // which lives inside Planning Team), fall back to the last completed agent
836
+ // that IS in the pipeline so we can find the correct successor.
837
+ let nextInfo = getNextAgent(lastAgent, completedAgents);
838
+
839
+ if ((!nextInfo || !nextInfo.next) && lastAgent) {
840
+ const agentInPipeline = getAgentDefinition(lastAgent);
841
+ if (!agentInPipeline) {
842
+ // lastAgent is not in AGENT_PIPELINE — find the last completed agent that IS
843
+ const pipelineNames = AGENT_PIPELINE.map((a) => a.name);
844
+ for (let i = completedAgents.length - 1; i >= 0; i--) {
845
+ if (pipelineNames.includes(completedAgents[i])) {
846
+ nextInfo = getNextAgent(completedAgents[i], completedAgents);
847
+ if (nextInfo && nextInfo.next) break;
848
+ }
849
+ }
850
+ }
851
+ }
347
852
 
348
853
  if (!nextInfo || !nextInfo.next) {
349
854
  return {
@@ -355,7 +860,7 @@ async function handleNext(projectDir) {
355
860
  }
356
861
 
357
862
  const nextAgent = nextInfo.next;
358
- const agentFile = AGENT_FILE_MAP[nextAgent] || null;
863
+ const agentFile = getAgentFile(nextAgent, projectDir) || null;
359
864
  const agentDef = getAgentDefinition(nextAgent);
360
865
  const isInteractive = INTERACTIVE_AGENTS.includes(nextAgent);
361
866
 
@@ -381,24 +886,33 @@ async function handleNext(projectDir) {
381
886
  // Check for parallel group — with Agent Teams override (Article XXI)
382
887
  let action, spawnCommand = null, parallelSpawnCommand = null, parallelAgents = [];
383
888
  let teamData = null;
889
+ const teamsEnabled = isAgentTeamsEnabled(projectDir);
890
+
384
891
  if (nextInfo.isParallel && nextInfo.group.length > 1) {
385
- // When agent_teams is enabled, substitute spawn_parallel with spawn_team
386
- if (isAgentTeamsEnabled(projectDir)) {
387
- const teamType = nextInfo.group.includes('dev') ? 'build' : 'planning';
388
- const teamConfig = TEAM_CONFIGS[teamType];
892
+ // Planning Team auto-spawn: detail + architect + ux are marked parallel: true
893
+ // in agent-selector. When agent_teams enabled, swap spawn_parallel for spawn_team.
894
+ if (teamsEnabled) {
895
+ const teamConfig = TEAM_CONFIGS.planning;
389
896
  const teamId = generateTeamId(teamConfig.slug);
390
897
  action = 'spawn_team';
391
898
  parallelAgents = teamConfig.members;
392
- teamData = {
393
- team_id: teamId,
394
- team_type: teamType,
395
- members: teamConfig.members,
396
- };
899
+ teamData = { team_id: teamId, team_type: 'planning', members: teamConfig.members };
397
900
  } else {
398
901
  action = 'spawn_parallel';
399
902
  parallelAgents = nextInfo.group;
400
903
  parallelSpawnCommand = buildParallelSpawnCommand(nextInfo.group, projectDir, lastAgent, modelInfo.provider, 900000);
401
904
  }
905
+ } else if (nextAgent === 'dev' && teamsEnabled) {
906
+ // Build Team auto-spawn: dev + qa-implementation are NOT parallel in the
907
+ // classic sense (dev writes, qa reviews per-task), so they're not in a
908
+ // parallel group. But under Agent Teams, they coordinate via mailbox + shared
909
+ // task list, which IS the team mechanism. Spawn Build Team here when dev
910
+ // would otherwise spawn solo.
911
+ const teamConfig = TEAM_CONFIGS.build;
912
+ const teamId = generateTeamId(teamConfig.slug);
913
+ action = 'spawn_team';
914
+ parallelAgents = teamConfig.members;
915
+ teamData = { team_id: teamId, team_type: 'build', members: teamConfig.members };
402
916
  } else if (isInteractive) {
403
917
  action = 'activate_interactive';
404
918
  } else {
@@ -406,6 +920,9 @@ async function handleNext(projectDir) {
406
920
  spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000);
407
921
  }
408
922
 
923
+ // Record agent activation event
924
+ recordEvent(projectDir, EventType.AGENT_ACTIVATED, nextAgent, { action, phase: agentDef?.phase });
925
+
409
926
  const progress = getPipelineProgress(pipelineState);
410
927
  const bracket = estimateContextBracket(completedAgents.length, AGENT_PIPELINE.length);
411
928
 
@@ -436,6 +953,262 @@ async function handleNext(projectDir) {
436
953
  return result;
437
954
  }
438
955
 
956
+ // ---------------------------------------------------------------------------
957
+ // Fase 10 — Scaffold auto-gate helpers
958
+ //
959
+ // Problem: after qa-planning approves the plan, the framework knows (from
960
+ // greenfield-wu's scaffold_candidates + ux-brand-architect's scaffold_signals)
961
+ // whether a scaffold preset like motion-premium would save the Build phase
962
+ // from reinventing animation wiring by hand. Without a deterministic gate,
963
+ // the orchestrator must rely on Claude remembering to offer the scaffold —
964
+ // and Claude forgets. This gate turns remembering into code.
965
+ //
966
+ // A preset "fires" when:
967
+ // 1. It is in session.scaffold_candidates (stack match, Signal 1).
968
+ // 2. It has a signal with confidence >= 0.7 (premium intent, Signal 2).
969
+ // 3. It is NOT yet in session.scaffold_applied (idempotency).
970
+ // ---------------------------------------------------------------------------
971
+
972
+ const SCAFFOLD_CONFIDENCE_THRESHOLD = 0.7;
973
+
974
+ // Per-preset threshold overrides. ADR-3D-07 sets motion-premium-3d to 0.8
975
+ // (higher than motion-premium's 0.7) — false positives cost ~600KB of
976
+ // bundle pressed onto a project that did not need 3D. New presets added
977
+ // here; any preset not in the map inherits SCAFFOLD_CONFIDENCE_THRESHOLD.
978
+ const PRESET_CONFIDENCE_THRESHOLDS = {
979
+ 'motion-premium': 0.7,
980
+ 'motion-premium-3d': 0.8,
981
+ };
982
+
983
+ function thresholdFor(preset) {
984
+ return PRESET_CONFIDENCE_THRESHOLDS[preset] ?? SCAFFOLD_CONFIDENCE_THRESHOLD;
985
+ }
986
+
987
+ /**
988
+ * Evaluate which (if any) scaffold preset's auto-gate should fire.
989
+ * Enumerates candidates in session order; first preset whose signal
990
+ * confidence meets its per-preset threshold AND is not already applied
991
+ * wins. When a user applies or skips one preset, the next advance call
992
+ * re-evaluates — letting a project with BOTH motion-premium and
993
+ * motion-premium-3d signals receive both prompts in sequence.
994
+ *
995
+ * @param {object} session - Post-completion session
996
+ * @returns {{ fires: boolean, preset: string|null, signalKey: string|null, confidence: number, threshold: number, evidence: string[], reason: string|null }}
997
+ */
998
+ function checkScaffoldGate(session) {
999
+ const candidates = Array.isArray(session?.scaffold_candidates) ? session.scaffold_candidates : [];
1000
+ const signals = (session?.scaffold_signals && typeof session.scaffold_signals === 'object') ? session.scaffold_signals : {};
1001
+ const applied = new Set(Array.isArray(session?.scaffold_applied) ? session.scaffold_applied : []);
1002
+
1003
+ for (const preset of candidates) {
1004
+ if (applied.has(preset)) continue;
1005
+ const key = preset.replace(/-/g, '_');
1006
+ const signal = signals[key];
1007
+ if (!signal) continue;
1008
+ const confidence = typeof signal.confidence === 'number' ? signal.confidence : 0;
1009
+ const threshold = thresholdFor(preset);
1010
+ if (confidence < threshold) continue;
1011
+ return {
1012
+ fires: true,
1013
+ preset,
1014
+ signalKey: key,
1015
+ confidence,
1016
+ threshold,
1017
+ evidence: Array.isArray(signal.evidence) ? signal.evidence : [],
1018
+ reason: 'scaffold_decision_required',
1019
+ };
1020
+ }
1021
+
1022
+ return { fires: false, preset: null, signalKey: null, confidence: 0, threshold: SCAFFOLD_CONFIDENCE_THRESHOLD, evidence: [], reason: null };
1023
+ }
1024
+
1025
+ /**
1026
+ * Build the action_required payload the orchestrator presents to the user
1027
+ * when a scaffold gate fires.
1028
+ */
1029
+ function buildScaffoldGateResponse(gate, { agent, score, phaseTransition }) {
1030
+ return {
1031
+ advanced: true,
1032
+ agent_completed: agent,
1033
+ score,
1034
+ phase_transition: phaseTransition || { triggered: false },
1035
+ scaffold_decision_required: true,
1036
+ scaffold_gate: {
1037
+ preset: gate.preset,
1038
+ confidence: gate.confidence,
1039
+ evidence: gate.evidence,
1040
+ },
1041
+ next: {
1042
+ action: 'scaffold_decision',
1043
+ agent: null,
1044
+ pending_preset: gate.preset,
1045
+ confidence: gate.confidence,
1046
+ 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.`,
1047
+ options: [
1048
+ {
1049
+ id: 'apply',
1050
+ label: `Apply ${gate.preset} scaffold (15 files, Premium-tier animation wiring)`,
1051
+ command: `advance --agent qa-planning --score ${score} --decision apply`,
1052
+ description: 'Writes the scaffold templates to the project. Recommended — the Animation Inventory maps 1:1 to these files.',
1053
+ },
1054
+ {
1055
+ id: 'dryrun',
1056
+ label: `Preview ${gate.preset} scaffold (list files, write nothing)`,
1057
+ command: `advance --agent qa-planning --score ${score} --decision dryrun`,
1058
+ description: 'Shows what would be written. Run this first if you want to see the diff before committing.',
1059
+ },
1060
+ {
1061
+ id: 'skip',
1062
+ label: `Skip ${gate.preset} (proceed to dev without scaffold)`,
1063
+ command: `advance --agent qa-planning --score ${score} --decision skip`,
1064
+ 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).',
1065
+ },
1066
+ ],
1067
+ },
1068
+ };
1069
+ }
1070
+
1071
+ /**
1072
+ * Execute a scaffold decision (apply|skip|dryrun), update session, and
1073
+ * advance the pipeline from qa-planning to dev. Reuses applyScaffold from
1074
+ * the installer module (Fase 4) so the programmatic and CLI-wrapped paths
1075
+ * share one implementation.
1076
+ *
1077
+ * @param {string} projectDir
1078
+ * @param {object} session - Pre-decision session (qa-planning already completed)
1079
+ * @param {'apply'|'skip'|'dryrun'} decision
1080
+ * @param {string} agent - Should be 'qa-planning' when gate fired there.
1081
+ * @param {number} score
1082
+ */
1083
+ async function handleScaffoldDecision(projectDir, session, decision, agent, score) {
1084
+ const gate = checkScaffoldGate(session);
1085
+ if (!gate.fires) {
1086
+ return errorResult(
1087
+ 'No scaffold decision pending (no eligible candidate with confidence >= 0.7 and not already applied).',
1088
+ 'NO_SCAFFOLD_PENDING',
1089
+ );
1090
+ }
1091
+
1092
+ const preset = gate.preset;
1093
+ let scaffoldResult = null;
1094
+
1095
+ if (decision === 'apply' || decision === 'dryrun') {
1096
+ let scaffoldSourceDir;
1097
+ try {
1098
+ scaffoldSourceDir = resolveScaffoldSource(projectDir);
1099
+ } catch (err) {
1100
+ return errorResult(
1101
+ `Cannot locate scaffold source directory. ${err.message}`,
1102
+ 'SCAFFOLD_SOURCE_MISSING',
1103
+ );
1104
+ }
1105
+ try {
1106
+ // Sanity-check the preset exists before invoking applyScaffold.
1107
+ loadScaffoldManifest(scaffoldSourceDir, preset);
1108
+ } catch (err) {
1109
+ return errorResult(
1110
+ `Scaffold preset "${preset}" not found at ${scaffoldSourceDir}: ${err.message}`,
1111
+ 'SCAFFOLD_PRESET_MISSING',
1112
+ );
1113
+ }
1114
+ scaffoldResult = applyScaffold({
1115
+ projectDir,
1116
+ scaffoldSourceDir,
1117
+ preset,
1118
+ dryRun: decision === 'dryrun',
1119
+ force: false,
1120
+ });
1121
+ }
1122
+
1123
+ // On 'apply' and 'skip', record the preset as resolved so the gate does
1124
+ // not re-fire on a future advance. On 'dryrun', leave it pending.
1125
+ if (decision === 'apply' || decision === 'skip') {
1126
+ const appliedList = Array.isArray(session.scaffold_applied) ? [...session.scaffold_applied] : [];
1127
+ if (!appliedList.includes(preset)) appliedList.push(preset);
1128
+ await updateSession(projectDir, { scaffold_applied: appliedList });
1129
+ }
1130
+
1131
+ // Dry-run: return info, leave pipeline at qa-planning — user must re-run
1132
+ // advance with --decision apply|skip to actually proceed.
1133
+ if (decision === 'dryrun') {
1134
+ return {
1135
+ advanced: false,
1136
+ agent_completed: agent,
1137
+ score,
1138
+ scaffold_decision: decision,
1139
+ scaffold: {
1140
+ preset: scaffoldResult.preset,
1141
+ version: scaffoldResult.version,
1142
+ would_apply: scaffoldResult.applied,
1143
+ would_skip: scaffoldResult.skipped,
1144
+ placeholders: scaffoldResult.placeholders,
1145
+ brandSource: scaffoldResult.brandSource,
1146
+ },
1147
+ next: {
1148
+ action: 'scaffold_decision_pending',
1149
+ agent: null,
1150
+ 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.`,
1151
+ },
1152
+ };
1153
+ }
1154
+
1155
+ // apply / skip: advance qa-planning → dev.
1156
+ const { session: postDecision } = loadSession(projectDir);
1157
+ const pipelineState = sessionToPipelineState(postDecision || session, projectDir);
1158
+ pipelineState.completedAgents = [...(postDecision?.completed_agents || session.completed_agents || [])];
1159
+ const advanceResult = advancePipeline(pipelineState, agent, { score, handoffData: {}, findings: [] });
1160
+
1161
+ const updates = {
1162
+ current_agent: advanceResult.nextAgent || '',
1163
+ last_handoff: agent,
1164
+ pipeline_position: pipelineState.completedAgents.length,
1165
+ };
1166
+ if (advanceResult.needsModeSwitch) {
1167
+ const newPhase = advanceResult.state?.phase || session.mode;
1168
+ updates.mode = newPhase;
1169
+ if (session.project) updates.project = { ...session.project, state: newPhase };
1170
+ await recordModeTransition(projectDir, {
1171
+ from: session.mode, to: newPhase,
1172
+ trigger: `scaffold_decision=${decision} on ${preset}`,
1173
+ });
1174
+ }
1175
+ await updateSession(projectDir, updates);
1176
+
1177
+ const nextAgent = advanceResult.nextAgent || '';
1178
+ const progress = getPipelineProgress(advanceResult.state || pipelineState);
1179
+ try { updateClaudeMd(projectDir, { currentAgent: nextAgent, progress }); } catch { /* non-fatal */ }
1180
+ try {
1181
+ if (nextAgent) {
1182
+ writeSessionLock(projectDir, nextAgent, {
1183
+ phase: updates.mode || session.mode,
1184
+ mode: session.execution_mode || 'interactive',
1185
+ position: updates.pipeline_position,
1186
+ total: progress.total,
1187
+ progress: progress.percent,
1188
+ });
1189
+ }
1190
+ } catch { /* non-fatal */ }
1191
+
1192
+ const next = await handleNext(projectDir);
1193
+ return {
1194
+ advanced: true,
1195
+ agent_completed: agent,
1196
+ score,
1197
+ scaffold_decision: decision,
1198
+ scaffold: scaffoldResult
1199
+ ? {
1200
+ preset: scaffoldResult.preset,
1201
+ version: scaffoldResult.version,
1202
+ applied: scaffoldResult.applied,
1203
+ skipped: scaffoldResult.skipped,
1204
+ manifestPath: scaffoldResult.manifestPath,
1205
+ }
1206
+ : { preset, skipped_by_user: true },
1207
+ phase_transition: advanceResult.needsModeSwitch ? { triggered: true, from: session.mode, to: updates.mode } : { triggered: false },
1208
+ next,
1209
+ };
1210
+ }
1211
+
439
1212
  async function handleAdvance(projectDir, args) {
440
1213
  const agent = args.agent;
441
1214
  const score = parseInt(args.score, 10);
@@ -448,6 +1221,33 @@ async function handleAdvance(projectDir, args) {
448
1221
  const { loaded, session } = loadSession(projectDir);
449
1222
  if (!loaded || !session) return errorResult('No session found', 'NO_SESSION');
450
1223
 
1224
+ // Fase 10 scaffold decision — route apply|skip|dryrun to the scaffold
1225
+ // dispatcher BEFORE the alreadyCompleted guard so the user can resolve a
1226
+ // pending scaffold decision after qa-planning is marked complete.
1227
+ const SCAFFOLD_DECISIONS = new Set(['apply', 'skip', 'dryrun']);
1228
+ if (decision && SCAFFOLD_DECISIONS.has(decision)) {
1229
+ return handleScaffoldDecision(projectDir, session, decision, agent, score);
1230
+ }
1231
+
1232
+ // Idempotency guard: if this agent is already marked completed, return no-op.
1233
+ // Prevents duplicate advance calls (hook + user-approval path) from double-
1234
+ // incrementing pipeline_position or corrupting completed_agents.
1235
+ const agentState = session.agents && session.agents[agent];
1236
+ const alreadyCompleted =
1237
+ agentState && agentState.status === 'completed' &&
1238
+ Array.isArray(session.completed_agents) && session.completed_agents.includes(agent);
1239
+ if (alreadyCompleted && !decision) {
1240
+ const next = await handleNext(projectDir);
1241
+ return {
1242
+ advanced: false,
1243
+ already_advanced: true,
1244
+ agent_completed: agent,
1245
+ score: agentState.score,
1246
+ next,
1247
+ status_summary: `${agent} already completed (score ${agentState.score}). Next: ${next.agent || 'none'}.`,
1248
+ };
1249
+ }
1250
+
451
1251
  // Handle user_preview decisions
452
1252
  if (decision) {
453
1253
  const pipelineState = sessionToPipelineState(session, projectDir);
@@ -470,22 +1270,508 @@ async function handleAdvance(projectDir, args) {
470
1270
  return { advanced: true, agent_completed: agent, score, decision, phase_transition: { triggered: previewResult.needsModeSwitch }, next };
471
1271
  }
472
1272
 
473
- // Record completion
474
- await recordAgentCompletion(projectDir, { agent, status, score });
1273
+ // Read agent handoff document to extract Decision Trail entries.
1274
+ // Agents (e.g. qa-implementation) write Decision Trail entries to their handoff
1275
+ // instead of directly editing session.yaml (blocked by mode-governance hook).
1276
+ let handoffData = {};
1277
+ // Handoff files live at <frameworkDir>/artifacts/handoffs/<agent>-handoff.md
1278
+ // EXCEPT when artifacts/ is user-facing (non-framework). Currently install
1279
+ // writes artifacts to project root <projectDir>/artifacts/. Check both locations
1280
+ // to cover legacy + current layouts.
1281
+ const fwDir = resolveFrameworkDir(projectDir);
1282
+ const handoffCandidates = [
1283
+ join(projectDir, 'artifacts', 'handoffs', `${agent}-handoff.md`),
1284
+ join(projectDir, fwDir, 'artifacts', 'handoffs', `${agent}-handoff.md`),
1285
+ ];
1286
+ const handoffPath = handoffCandidates.find(p => existsSync(p));
1287
+ if (handoffPath) {
1288
+ try {
1289
+ const handoffMarkdown = readFileSync(handoffPath, 'utf-8');
1290
+ handoffData = parseHandoffDecisionTrail(handoffMarkdown);
1291
+ // Fase 10 auto-gate — parse scaffold candidates/signals alongside Decision Trail.
1292
+ const scaffold = parseHandoffScaffoldSignals(handoffMarkdown);
1293
+ handoffData.scaffold_candidates = scaffold.scaffold_candidates;
1294
+ handoffData.scaffold_signals = scaffold.scaffold_signals;
1295
+ } catch {
1296
+ // Non-fatal: if handoff can't be read, skip Decision Trail / scaffold merge
1297
+ }
1298
+ }
475
1299
 
476
- // Advance pipeline
477
- const pipelineState = sessionToPipelineState(session, projectDir);
478
- pipelineState.completedAgents = [...(session.completed_agents || []), agent];
1300
+ // Deterministic gates: block advance if required artifacts are missing.
1301
+
1302
+ // BRIEF gate: reference frames captured → Visualizer MUST run for ALL sites before Brief advances.
1303
+ if (agent === 'brief') {
1304
+ const refsDir = join(projectDir, 'artifacts', '4-UX', 'references');
1305
+ const completePath = join(refsDir, 'capture-complete.json');
1306
+ const analysisPath = join(projectDir, 'artifacts', '4-UX', 'reference-analysis.md');
1307
+
1308
+ if (existsSync(completePath)) {
1309
+ let captureData = {};
1310
+ try { captureData = JSON.parse(readFileSync(completePath, 'utf-8')); } catch { /* ignore */ }
1311
+ const okSites = Object.entries(captureData.sites || {})
1312
+ .filter(([, s]) => s.status === 'ok')
1313
+ .map(([slug, s]) => ({ slug, url: s.url, desktopFrames: `artifacts/4-UX/references/${slug}/desktop/frames/`, mobileFrames: `artifacts/4-UX/references/${slug}/mobile/frames/` }));
1314
+
1315
+ if (okSites.length > 0) {
1316
+ // Check: consolidated file must mention ALL sites, OR every site has its own analysis.md
1317
+ let allAnalyzed = false;
1318
+ if (existsSync(analysisPath)) {
1319
+ try {
1320
+ const content = readFileSync(analysisPath, 'utf-8');
1321
+ allAnalyzed = okSites.every(s => content.includes(s.slug) || content.includes(s.url));
1322
+ } catch { /* ignore */ }
1323
+ }
1324
+ if (!allAnalyzed) {
1325
+ const missingSites = okSites.filter(s => !existsSync(join(refsDir, s.slug, 'analysis.md')));
1326
+ allAnalyzed = missingSites.length === 0;
1327
+ }
1328
+
1329
+ if (!allAnalyzed) {
1330
+ // Find which sites still need analysis
1331
+ const needsAnalysis = okSites.filter(s => {
1332
+ if (existsSync(join(refsDir, s.slug, 'analysis.md'))) return false;
1333
+ if (existsSync(analysisPath)) {
1334
+ try {
1335
+ const content = readFileSync(analysisPath, 'utf-8');
1336
+ return !(content.includes(s.slug) || content.includes(s.url));
1337
+ } catch { return true; }
1338
+ }
1339
+ return true;
1340
+ });
1341
+
1342
+ return {
1343
+ advanced: false,
1344
+ blocked: true,
1345
+ reason: 'visual_analysis_required',
1346
+ message: `${needsAnalysis.length} of ${okSites.length} site(s) still need visual analysis. Spawn ONE Visualizer per missing site (parallel).`,
1347
+ already_analyzed: okSites.length - needsAnalysis.length,
1348
+ action_required: {
1349
+ step1: 'Display to user (translate to session.language): "Analyzing the reference sites you provided — scroll experience, animations, colors, and typography in depth..."',
1350
+ step2: `Spawn ${needsAnalysis.length} Agent(s) IN PARALLEL (one per site, each with model: sonnet):`,
1351
+ agents: needsAnalysis.map(s => ({
1352
+ description: `Visualizer — ${s.slug} scroll analysis`,
1353
+ model: 'sonnet',
1354
+ 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`,
1355
+ })),
1356
+ step3: 'Wait for ALL to return. Merge ALL per-site analysis.md files into artifacts/4-UX/reference-analysis.md',
1357
+ step4: 'Append Visual Experience Analysis summary to brief-report.md',
1358
+ step5: 'Present the UPDATED brief to the user with Completion Options. Do NOT advance — wait for user approval.',
1359
+ },
1360
+ };
1361
+ }
1362
+ }
1363
+ // okSites.length === 0 means all captures failed → allow advance, UX falls back to text
1364
+ }
1365
+
1366
+ // If capture not yet complete, return immediately — don't block advance for 10 min.
1367
+ // Tell Claude to call wait-for-capture separately, then re-try advance.
1368
+ if (!existsSync(join(refsDir, 'capture-complete.json'))) {
1369
+ try {
1370
+ const dirs = readdirSync(refsDir).filter(d => {
1371
+ try { return existsSync(join(refsDir, d, 'desktop')); } catch { return false; }
1372
+ });
1373
+ if (dirs.length > 0) {
1374
+ return {
1375
+ advanced: false,
1376
+ blocked: true,
1377
+ reason: 'capture_in_progress',
1378
+ message: 'Reference capture still running in background. Wait for it, then spawn Visualizer, then present options to user.',
1379
+ action_required: {
1380
+ step1: 'Run: node .chati.dev/orchestrator/chati-router.js wait-for-capture',
1381
+ step2: 'Then spawn Visualizer to analyze frames (save to artifacts/4-UX/reference-analysis.md)',
1382
+ step3: 'Append Visual Experience Analysis summary to brief-report.md',
1383
+ step4: 'Present the UPDATED brief to the user with Completion Options. Do NOT advance — wait for user approval.',
1384
+ },
1385
+ };
1386
+ }
1387
+ } catch { /* refsDir may not exist — no references, skip */ }
1388
+ }
1389
+ }
1390
+
1391
+ // QA-Visual gate: visual-qa.js must have run AND Visualizer must have analyzed
1392
+ // EVERY page — mirrors the Brief gate (ONE Visualizer per site/page in parallel).
1393
+ if (agent === 'qa-visual') {
1394
+ const vqaOutput = join(projectDir, '.chati', 'visual-qa');
1395
+ const projectReport = join(vqaOutput, 'report.json');
1396
+ // Only look at project-scoped .chati/visual-qa/. The /tmp/visual-qa/ fallback
1397
+ // was removed because it cross-contaminated isolated tests: any prior run of
1398
+ // visual-qa.js against a real project left /tmp/visual-qa/report.json behind,
1399
+ // which then polluted tmpdir-based test fixtures that expected a clean state.
1400
+ const reportPath = existsSync(projectReport) ? projectReport : null;
1401
+ const reportDir = reportPath ? reportPath.replace('/report.json', '') : vqaOutput;
1402
+ const consolidatedPath = join(projectDir, 'artifacts', '8-QA-Visual', 'visual-qa-analysis.md');
1403
+ const finalReportPath = join(projectDir, 'artifacts', '9-QA-Implementation', 'qa-visual-report.md');
1404
+
1405
+ // Check report.json exists (visual-qa.js ran)
1406
+ if (!reportPath) {
1407
+ // Autonomous-completion v1 (ADR-AUTO-02) rewrites this path:
1408
+ // The post-dev hook (P4) normally fires visual-qa.js when dev
1409
+ // completes. If we land here, either the hook did not fire (opted
1410
+ // out via config.yaml or dev score < 90) or its spawn is still
1411
+ // finishing in the background. Give Claude the commands to
1412
+ // recover manually.
1413
+ return {
1414
+ advanced: false,
1415
+ blocked: true,
1416
+ reason: 'visual_qa_not_run',
1417
+ message: 'visual-qa.js has not produced report.json yet. Either the post-dev auto-QA hook is still running, or it was skipped.',
1418
+ action_required: {
1419
+ step1: 'Run: node .chati.dev/scripts/visual-qa.js --pages "/" <add routes> --output .chati/visual-qa/',
1420
+ step2: 'The script spawns its own Next dev server on a free port, so you do not need to start one yourself.',
1421
+ step3: 'When the capture finishes, re-run advance qa-visual.',
1422
+ 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.',
1423
+ },
1424
+ };
1425
+ }
1426
+
1427
+ // Read captured pages from report.json
1428
+ let capturedPages = [];
1429
+ try {
1430
+ const report = JSON.parse(readFileSync(reportPath, 'utf-8'));
1431
+ capturedPages = (report.pages || []).map(p => ({ slug: p.slug || 'home', path: p.path || '/' }));
1432
+ } catch { /* ignore */ }
1433
+
1434
+ if (capturedPages.length === 0) {
1435
+ return {
1436
+ advanced: false,
1437
+ blocked: true,
1438
+ reason: 'visual_qa_report_empty',
1439
+ message: 'visual-qa.js ran but report.json has no captured pages. Re-run the script.',
1440
+ };
1441
+ }
1442
+
1443
+ // Autonomous-completion v1 (ADR-AUTO-04): Reduced-motion enforcement gate.
1444
+ // When visual-qa.js runs with --reduced-motion on|both, it writes
1445
+ // summary.reduced_motion_mode + summary.reduced_motion_violations to
1446
+ // report.json. A violation = a scroll-triggered region where frames
1447
+ // diverged between motion-on and motion-off captures — i.e. animation
1448
+ // that ignores the user's prefers-reduced-motion: reduce preference.
1449
+ // v1 contract: BLOCK when violations[] is non-empty (fault_origin: CODE).
1450
+ // v1 WARN: if reduced_motion_mode === 'off', the a11y pass never ran.
1451
+ // The comparison pass itself ships in v1.2 — in v1, violations[] is
1452
+ // always empty, so the gate runs clean today but is ready for the
1453
+ // upgrade without handleAdvance edits.
1454
+ try {
1455
+ const report = JSON.parse(readFileSync(reportPath, 'utf-8'));
1456
+ const rmMode = report.summary?.reduced_motion_mode;
1457
+ const violations = Array.isArray(report.summary?.reduced_motion_violations)
1458
+ ? report.summary.reduced_motion_violations
1459
+ : [];
1460
+ if (violations.length > 0) {
1461
+ return {
1462
+ advanced: false,
1463
+ blocked: true,
1464
+ reason: 'reduced_motion_violation',
1465
+ message: `${violations.length} region(s) animate under prefers-reduced-motion: reduce. Motion MUST be disabled when the user preference is set.`,
1466
+ reduced_motion_violations: violations,
1467
+ fault_origin: 'CODE',
1468
+ action_required: {
1469
+ step1: 'Display the violating pages + regions to the user.',
1470
+ step2: 'Route back to dev for rework (Fault Vector Protocol: CODE → dev).',
1471
+ step3: 'Dev wraps the offending animation in usePrefersReducedMotion() (from lib/animations/gsap.ts.template) and returns early when motion is reduced.',
1472
+ step4: 'Re-run visual-qa.js with --reduced-motion both, then re-run advance qa-visual.',
1473
+ },
1474
+ };
1475
+ }
1476
+ // WARN path — a11y pass not executed.
1477
+ if (rmMode === 'off') {
1478
+ // Non-blocking — attached to the response later as advisory_warnings
1479
+ // when we reach the success path. We do not early-return; other
1480
+ // gates (layout errors, per-page analysis) still need to run.
1481
+ // Storing in a closure var so downstream return decorates with it.
1482
+ // (Implemented as a side-effect on report — but report is local here;
1483
+ // use a hoisted pseudo-state via the outer function.)
1484
+ }
1485
+ } catch {
1486
+ // report.json unparseable — defer to the other checks which will catch
1487
+ // the corrupted-file case explicitly.
1488
+ }
1489
+
1490
+ // Objective layout gate — catches bugs that pass qualitative Visualizer review.
1491
+ // Root cause of 2026-04-16 cascade-layer trap: `* { margin: 0 }` outside any
1492
+ // @layer silently overrode all Tailwind utilities; Visualizers approved at
1493
+ // 94% because 1280px viewport masked the x=0 container alignment. This gate
1494
+ // blocks advance on measured facts (getBoundingClientRect / getComputedStyle)
1495
+ // regardless of what LLM review said.
1496
+ try {
1497
+ const report = JSON.parse(readFileSync(reportPath, 'utf-8'));
1498
+ const layoutErrors = (report.summary?.layout_errors || []).filter(e => e.severity === 'error');
1499
+ if (layoutErrors.length > 0) {
1500
+ // Group by page + type for a clean summary
1501
+ const byPageType = {};
1502
+ for (const e of layoutErrors) {
1503
+ const key = `${e.page || '?'}::${e.type}`;
1504
+ byPageType[key] = byPageType[key] || { page: e.page, type: e.type, viewports: [], details: [] };
1505
+ byPageType[key].viewports.push(e.viewport);
1506
+ byPageType[key].details.push(e.detail);
1507
+ }
1508
+ return {
1509
+ advanced: false,
1510
+ blocked: true,
1511
+ reason: 'layout_errors_detected',
1512
+ message: `${layoutErrors.length} objective layout error(s) measured by Playwright. These are measurements, not judgments — must be fixed before advancing.`,
1513
+ layout_error_count: layoutErrors.length,
1514
+ layout_errors: Object.values(byPageType),
1515
+ fault_origin: 'CODE',
1516
+ action_required: {
1517
+ step1: 'Display the errors to the user with specific selectors + measurements',
1518
+ step2: 'Route back to dev for rework (Fault Vector Protocol: CODE → dev)',
1519
+ step3: 'Dev fixes → pnpm build → re-run visual-qa.js → re-run advance qa-visual',
1520
+ common_causes: [
1521
+ 'Tailwind v4: unlayered CSS in globals.css wins over @layer utilities (wrap globals in @layer base {})',
1522
+ 'Tailwind v4: arbitrary CSS var without type hint silently omits property (use text-[length:var(...)])',
1523
+ 'Missing mx-auto on a max-w-* container',
1524
+ 'Element wider than viewport causing horizontal overflow',
1525
+ ],
1526
+ },
1527
+ };
1528
+ }
1529
+ } catch {
1530
+ // report.json unparseable — fall through to other checks, they will catch it
1531
+ }
1532
+
1533
+ // NEW (Fase 6): Shared CSS / token change → full coverage required.
1534
+ // Regression risk: a change to globals.css or brand.ts can silently break
1535
+ // routes that were NOT touched by the current task. If the current report
1536
+ // covers fewer routes than the project's app/ directory exposes, block.
1537
+ try {
1538
+ const modifiedFiles = getModifiedFiles(projectDir);
1539
+ const coverage = checkSharedCSSCoverage({ capturedPages, projectDir, modifiedFiles });
1540
+ if (coverage.blocked) {
1541
+ return {
1542
+ advanced: false,
1543
+ blocked: true,
1544
+ reason: coverage.reason,
1545
+ 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.`,
1546
+ triggering_files: coverage.triggeringFiles,
1547
+ captured_routes: capturedPages.map(p => p.path),
1548
+ expected_routes: coverage.appRoutes,
1549
+ uncovered_routes: coverage.uncovered,
1550
+ fault_origin: 'SPEC',
1551
+ action_required: {
1552
+ step1: 'Display the uncovered routes and the triggering shared files to the user.',
1553
+ step2: `Re-run visual-qa.js with --pages covering all ${coverage.appRoutes.length} routes:`,
1554
+ step3: `node .chati.dev/scripts/visual-qa.js --pages ${coverage.appRoutes.map(r => `"${r}"`).join(' ')} --url http://localhost:3456 --output .chati/visual-qa/`,
1555
+ step4: 'Then re-run advance qa-visual.',
1556
+ rationale: 'Shared-CSS changes can regress any route; visual-QA must check all of them when this class of file is modified.',
1557
+ },
1558
+ };
1559
+ }
1560
+ } catch {
1561
+ // git unavailable or projectDir not a repo — skip scope expansion silently.
1562
+ // Other checks below still enforce correctness on the covered routes.
1563
+ }
1564
+
1565
+ // Check: every captured page has per-page analysis.md OR is mentioned in the consolidated file
1566
+ let consolidatedContent = '';
1567
+ if (existsSync(consolidatedPath)) {
1568
+ try { consolidatedContent = readFileSync(consolidatedPath, 'utf-8'); } catch { /* ignore */ }
1569
+ }
1570
+ const needsAnalysis = capturedPages.filter(p => {
1571
+ const perPage = join(reportDir, p.slug, 'analysis.md');
1572
+ if (existsSync(perPage)) return false;
1573
+ if (consolidatedContent && (consolidatedContent.includes(p.slug) || consolidatedContent.includes(`\`${p.path}\``))) return false;
1574
+ return true;
1575
+ });
1576
+
1577
+ if (needsAnalysis.length > 0) {
1578
+ return {
1579
+ advanced: false,
1580
+ blocked: true,
1581
+ reason: 'visual_analysis_required',
1582
+ message: `${needsAnalysis.length} of ${capturedPages.length} page(s) still need Visualizer analysis. Spawn ONE Visualizer per missing page (parallel).`,
1583
+ already_analyzed: capturedPages.length - needsAnalysis.length,
1584
+ action_required: {
1585
+ step1: 'Display to user (translate to session.language): "Analyzing the build visually and comparing against the original references..."',
1586
+ step2: `Spawn ${needsAnalysis.length} Agent(s) IN PARALLEL (one per page, each with model: sonnet):`,
1587
+ agents: needsAnalysis.map(p => ({
1588
+ description: `Visualizer — ${p.slug} build validation`,
1589
+ model: 'sonnet',
1590
+ 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`,
1591
+ })),
1592
+ step3: 'Wait for ALL to return.',
1593
+ step4: `Merge ALL per-page analysis.md files into ${consolidatedPath} (consolidated Visualizer report).`,
1594
+ step5: `Write final QA-Visual verdict to ${finalReportPath} with score, blockers, warnings.`,
1595
+ step6: 'Present findings and verdict to the user with Completion Options. Do NOT advance — wait for user approval.',
1596
+ },
1597
+ };
1598
+ }
1599
+
1600
+ // Check consolidated analysis file exists
1601
+ if (!existsSync(consolidatedPath)) {
1602
+ return {
1603
+ advanced: false,
1604
+ blocked: true,
1605
+ reason: 'consolidated_analysis_missing',
1606
+ message: `Per-page analyses exist but consolidated file is missing. Create ${consolidatedPath} by merging all per-page analyses.`,
1607
+ };
1608
+ }
1609
+
1610
+ // Check final QA-Visual report exists at canonical path (NOT at 7-QA-Implementation)
1611
+ if (!existsSync(finalReportPath)) {
1612
+ const wrongPath = join(projectDir, 'artifacts', '7-QA-Implementation', 'qa-visual-report.md');
1613
+ const hint = existsSync(wrongPath)
1614
+ ? ` Found qa-visual-report.md at WRONG path ${wrongPath} — move it to ${finalReportPath}.`
1615
+ : '';
1616
+ return {
1617
+ advanced: false,
1618
+ blocked: true,
1619
+ reason: 'qa_visual_final_report_missing',
1620
+ message: `Final QA-Visual report missing at canonical path: ${finalReportPath}.${hint}`,
1621
+ };
1622
+ }
1623
+ }
1624
+
1625
+ // QA-Implementation gate: report MUST be at artifacts/9-QA-Implementation/ (NOT 7-*)
1626
+ if (agent === 'qa-implementation') {
1627
+ const canonicalDir = join(projectDir, 'artifacts', '9-QA-Implementation');
1628
+ const wrongDir = join(projectDir, 'artifacts', '7-QA-Implementation');
1629
+ const canonicalReport = join(canonicalDir, 'qa-implementation-report.md');
1630
+ const canonicalFinal = join(canonicalDir, 'qa-implementation-final-report.md');
1631
+
1632
+ // Block if wrong path exists — force relocation
1633
+ if (existsSync(wrongDir)) {
1634
+ return {
1635
+ advanced: false,
1636
+ blocked: true,
1637
+ reason: 'qa_impl_wrong_path',
1638
+ message: `artifacts/7-QA-Implementation/ is NOT canonical. Move all files to ${canonicalDir} (7- is reserved for QA-Planning).`,
1639
+ action_required: {
1640
+ step1: `Move files: mv artifacts/7-QA-Implementation/* artifacts/9-QA-Implementation/`,
1641
+ step2: `Remove empty folder: rmdir artifacts/7-QA-Implementation`,
1642
+ step3: 'Then re-run advance',
1643
+ },
1644
+ };
1645
+ }
1646
+
1647
+ // Require at least one of the canonical report files
1648
+ if (!existsSync(canonicalReport) && !existsSync(canonicalFinal)) {
1649
+ return {
1650
+ advanced: false,
1651
+ blocked: true,
1652
+ reason: 'qa_impl_report_missing',
1653
+ message: `QA-Implementation report missing. Expected ${canonicalReport} or ${canonicalFinal}.`,
1654
+ };
1655
+ }
1656
+ }
1657
+
1658
+ // UX (brand-architect) gate
1659
+ if (agent === 'ux') {
1660
+ // Fase 7 — Task-to-UX fidelity. Before checking brandbook.html, verify that
1661
+ // if the brief or reference-analysis cites premium animation references,
1662
+ // the brand-architect has produced the animation-inventory artifact. The
1663
+ // inventory is mandatory: without it, tasks generate generic "add scroll
1664
+ // reveals" instead of the specific patterns the user's references
1665
+ // encode, and qa-visual has no per-pattern checklist for Mode 2.
1666
+ const inventoryPath = join(projectDir, 'artifacts', '4-UX', 'animation-inventory.md');
1667
+ if (!existsSync(inventoryPath)) {
1668
+ const corpus = readBriefAndRefCorpus(projectDir);
1669
+ const refs = detectPremiumRefs(corpus);
1670
+ if (refs.detected) {
1671
+ return {
1672
+ advanced: false,
1673
+ blocked: true,
1674
+ reason: 'animation_inventory_required',
1675
+ message: `Brief or reference-analysis cites premium animation references (${refs.matches.join(', ')}). Brand-architect must produce artifacts/4-UX/animation-inventory.md before advancing.`,
1676
+ triggering_matches: refs.matches,
1677
+ expected_artifact: 'artifacts/4-UX/animation-inventory.md',
1678
+ fault_origin: 'SPEC',
1679
+ action_required: {
1680
+ step1: 'Inspect artifacts/1-Brief/brief-report.md and artifacts/4-UX/reference-analysis.md to enumerate every named animation pattern.',
1681
+ step2: 'Write artifacts/4-UX/animation-inventory.md with one row per pattern. Schema:',
1682
+ schema: '| Pattern name | Observed at (site / section) | Parameters (stagger, scrub, easing, duration) | Reproducible via (scaffold file) | Target route |',
1683
+ 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).',
1684
+ step4: 'Append a section to brandbook.md (or brand-architect handoff) noting the inventory is locked and tasks must 1:1 map against it.',
1685
+ step5: 'Then re-run advance ux.',
1686
+ 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.',
1687
+ },
1688
+ };
1689
+ }
1690
+ }
1691
+
1692
+ const brandbookPath = join(projectDir, 'artifacts', '4-UX', 'brandbook.html');
1693
+ if (!existsSync(brandbookPath)) {
1694
+ return {
1695
+ advanced: false,
1696
+ blocked: true,
1697
+ reason: 'brandbook_html_missing',
1698
+ message: 'brandbook.html is a mandatory deliverable for the UX agent. Write artifacts/4-UX/brandbook.html before advancing.',
1699
+ };
1700
+ }
1701
+ }
1702
+
1703
+ // Record completion (merges Decision Trail entries from handoff into session.yaml)
1704
+ await recordAgentCompletion(projectDir, { agent, status, score, handoffData });
1705
+ recordEvent(projectDir, EventType.AGENT_COMPLETED, agent, { score, status });
1706
+
1707
+ // Fase 10 — Scaffold auto-gate. After qa-planning completes, check whether
1708
+ // a scaffold preset has cleared the confidence threshold and is still
1709
+ // unapplied. If so, return an action_required response with the 3-option
1710
+ // decision payload. qa-planning stays marked complete; the user's next
1711
+ // advance call must carry --decision apply|skip|dryrun.
1712
+ if (status === 'completed' && score >= 95 && agent === 'qa-planning') {
1713
+ const { session: postQa } = loadSession(projectDir);
1714
+ const gate = checkScaffoldGate(postQa || session);
1715
+ if (gate.fires) {
1716
+ return buildScaffoldGateResponse(gate, {
1717
+ agent,
1718
+ score,
1719
+ phaseTransition: { triggered: false },
1720
+ });
1721
+ }
1722
+ }
1723
+
1724
+ // Advance pipeline. Build the pipelineState from POST-completion session
1725
+ // (reload after recordAgentCompletion) so completedAgents reflects the dedup
1726
+ // guard in session-manager (no duplicate pushes).
1727
+ const { session: postCompletion } = loadSession(projectDir);
1728
+ const pipelineState = sessionToPipelineState(postCompletion || session, projectDir);
1729
+ pipelineState.completedAgents = [...(postCompletion?.completed_agents || session.completed_agents || [])];
479
1730
  pipelineState.agents[agent] = { status: 'completed', score, startedAt: null, completedAt: new Date().toISOString() };
480
1731
 
481
- const advanceResult = advancePipeline(pipelineState, agent, { score });
1732
+ const advanceResult = advancePipeline(pipelineState, agent, {
1733
+ score,
1734
+ handoffData,
1735
+ findings: handoffData?.decision_trail_entries || [],
1736
+ });
482
1737
 
483
1738
  // Update session with new state
484
1739
  const updates = {
485
1740
  current_agent: advanceResult.nextAgent || '',
486
1741
  last_handoff: agent,
1742
+ pipeline_position: pipelineState.completedAgents.length,
487
1743
  };
488
1744
 
1745
+ // Backward transition (Article XXII Fault Vector Protocol):
1746
+ // QA failed → reset target agent (and everything after it) back to pending.
1747
+ // Persist the new agent states + correction_cycles counter + pipeline_position.
1748
+ if (advanceResult.nextAction === 'correction_loop' || advanceResult.nextAction === 'escalate') {
1749
+ const advancedState = advanceResult.state || pipelineState;
1750
+ // Serialize agent map back to session shape (status/score/started/completed).
1751
+ const agentResults = { ...(session.agent_results || {}) };
1752
+ for (const [name, a] of Object.entries(advancedState.agents || {})) {
1753
+ agentResults[name] = {
1754
+ ...(agentResults[name] || {}),
1755
+ status: a.status,
1756
+ score: a.score,
1757
+ started_at: a.startedAt || null,
1758
+ completed_at: a.completedAt || null,
1759
+ };
1760
+ }
1761
+ updates.agents = advancedState.agents;
1762
+ updates.agent_results = agentResults;
1763
+ updates.completed_agents = [...advancedState.completedAgents];
1764
+ updates.pipeline_position = advancedState.completedAgents.length;
1765
+ updates.correction_cycles = { ...(advancedState.correctionCycles || {}) };
1766
+
1767
+ recordEvent(
1768
+ projectDir,
1769
+ advanceResult.nextAction === 'escalate' ? EventType.CORRECTION_ESCALATED : EventType.CORRECTION_TRIGGERED,
1770
+ advanceResult.correction?.target || agent,
1771
+ { correction: advanceResult.correction },
1772
+ );
1773
+ }
1774
+
489
1775
  // Handle phase transition
490
1776
  let phaseTransition = { triggered: false };
491
1777
  if (advanceResult.needsModeSwitch) {
@@ -501,9 +1787,113 @@ async function handleAdvance(projectDir, args) {
501
1787
  to: newPhase,
502
1788
  trigger: `${agent} completed with score ${score}`,
503
1789
  });
1790
+ recordEvent(projectDir, EventType.MODE_TRANSITION, agent, { from: session.mode, to: newPhase });
504
1791
  }
505
1792
 
506
- await updateSession(projectDir, updates);
1793
+ const saveResult = await updateSession(projectDir, updates);
1794
+ if (saveResult && saveResult.saved === false) {
1795
+ return errorResult(`Session update failed: ${saveResult.error}`, 'SESSION_WRITE_FAILED');
1796
+ }
1797
+
1798
+ // Refresh Magic Docs: CLAUDE.md (public) + CLAUDE.local.md (runtime lock+state).
1799
+ // Non-fatal: if updates fail, pipeline state already advanced on disk.
1800
+ const nextAgent = advanceResult.nextAgent || '';
1801
+ const progress = getPipelineProgress(advanceResult.state || pipelineState);
1802
+ try {
1803
+ updateClaudeMd(projectDir, { currentAgent: nextAgent, progress });
1804
+ } catch { /* non-fatal */ }
1805
+ try {
1806
+ if (nextAgent) {
1807
+ writeSessionLock(projectDir, nextAgent, {
1808
+ phase: updates.mode || session.mode,
1809
+ mode: session.execution_mode || 'interactive',
1810
+ position: updates.pipeline_position,
1811
+ total: progress.total,
1812
+ progress: progress.percent,
1813
+ });
1814
+ } else {
1815
+ // Pipeline complete: reset lock to INACTIVE
1816
+ const lang = session.language || 'en';
1817
+ removeSessionLock(projectDir, RESUME_MESSAGES[lang] || RESUME_MESSAGES.en);
1818
+ }
1819
+ } catch { /* non-fatal */ }
1820
+
1821
+ // Backward transition via Fault Vector Protocol — QA agent failed threshold.
1822
+ // We have already reset target agent state above; now return the correction
1823
+ // payload so Claude/Agent UI knows which agent to invoke next.
1824
+ if (advanceResult.nextAction === 'correction_loop') {
1825
+ const c = advanceResult.correction;
1826
+ // Fase 8: compute the "Reset:" list for the state card — agents that
1827
+ // were completed before but are now back to pending (the correction
1828
+ // target + everything past it in the pipeline).
1829
+ const beforeCompleted = new Set(session.completed_agents || []);
1830
+ const afterCompleted = new Set(advanceResult.state?.completedAgents || pipelineState.completedAgents || []);
1831
+ const resetAgents = [...beforeCompleted].filter(a => !afterCompleted.has(a));
1832
+ if (!resetAgents.includes(c.target)) resetAgents.unshift(c.target);
1833
+ const stateCard = (() => {
1834
+ try {
1835
+ const { session: post } = loadSession(projectDir);
1836
+ return buildStateCard({
1837
+ session: post || session,
1838
+ projectDir,
1839
+ correctionReset: { agents: resetAgents, files: [] },
1840
+ });
1841
+ } catch { return ''; }
1842
+ })();
1843
+ return {
1844
+ advanced: true,
1845
+ agent_completed: agent,
1846
+ score,
1847
+ phase_transition: phaseTransition,
1848
+ correction_loop: true,
1849
+ correction: c,
1850
+ reset: { agents: resetAgents },
1851
+ state_card: stateCard,
1852
+ next: {
1853
+ action: 'correction_loop',
1854
+ agent: c.target,
1855
+ reason: c.reason,
1856
+ fault_origin: c.faultOrigin,
1857
+ cycle: c.cycle,
1858
+ max_cycles: 2,
1859
+ findings_count: (c.findings || []).length,
1860
+ pipeline_progress: getPipelineProgress(advanceResult.state || pipelineState),
1861
+ 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.`,
1862
+ },
1863
+ };
1864
+ }
1865
+
1866
+ // Max correction cycles exceeded — escalate to human.
1867
+ if (advanceResult.nextAction === 'escalate') {
1868
+ const c = advanceResult.correction;
1869
+ const stateCard = (() => {
1870
+ try {
1871
+ const { session: post } = loadSession(projectDir);
1872
+ return buildStateCard({ session: post || session, projectDir });
1873
+ } catch { return ''; }
1874
+ })();
1875
+ return {
1876
+ advanced: false,
1877
+ blocked: true,
1878
+ agent_completed: agent,
1879
+ score,
1880
+ phase_transition: phaseTransition,
1881
+ correction_loop: true,
1882
+ correction: c,
1883
+ reason: 'max_correction_cycles_exceeded',
1884
+ message: c.reason,
1885
+ state_card: stateCard,
1886
+ next: {
1887
+ action: 'escalate',
1888
+ agent: null,
1889
+ reason: c.reason,
1890
+ fault_origin: c.faultOrigin,
1891
+ cycle: c.cycle,
1892
+ max_cycles: 2,
1893
+ status_summary: `${agent} failed after ${c.cycle} correction cycles. Human review required before pipeline can continue.`,
1894
+ },
1895
+ };
1896
+ }
507
1897
 
508
1898
  // Check if user_preview is needed
509
1899
  if (advanceResult.nextAction === 'user_preview') {
@@ -527,6 +1917,23 @@ async function handleAdvance(projectDir, args) {
527
1917
  }
528
1918
 
529
1919
  async function handleInit(projectDir, args) {
1920
+ // Auto-resolve: if a parent directory already has a Chati installation,
1921
+ // use that as the project root automatically. Prevents accidental nested
1922
+ // installs (stray `init/` dirs) when CLI is invoked from a subdirectory.
1923
+ const { dirname: pathDirname } = await import('path');
1924
+ let probe = pathDirname(projectDir);
1925
+ let depth = 0;
1926
+ while (probe && probe !== '/' && depth < 5) {
1927
+ const fwParent = existsSync(join(probe, '.chati.dev')) || existsSync(join(probe, 'chati.dev'));
1928
+ if (fwParent) {
1929
+ // Found install in parent — use that as projectDir for the rest of init.
1930
+ projectDir = probe;
1931
+ break;
1932
+ }
1933
+ probe = pathDirname(probe);
1934
+ depth++;
1935
+ }
1936
+
530
1937
  // License validation at init time (moved from per-prompt UserPromptSubmit hook)
531
1938
  try {
532
1939
  const licensePath = join(process.env.HOME || '', '.chati-dev', 'license.yaml');
@@ -550,9 +1957,23 @@ async function handleInit(projectDir, args) {
550
1957
 
551
1958
  const type = args.type || 'greenfield';
552
1959
  const language = args.language || 'en';
553
- const name = args.name || '';
554
1960
  const workflow = args.workflow || 'full';
555
1961
 
1962
+ // Preserve fields from session.yaml created by installer (project name, ides, mcps)
1963
+ let preservedName = args.name || '';
1964
+ let preservedIdes = [];
1965
+ let preservedMcps = [];
1966
+ try {
1967
+ const existingPath = join(projectDir, '.chati', 'session.yaml');
1968
+ if (existsSync(existingPath)) {
1969
+ const existing = yaml.load(readFileSync(existingPath, 'utf-8')) || {};
1970
+ preservedName = preservedName || existing.project?.name || '';
1971
+ preservedIdes = existing.ides || [];
1972
+ preservedMcps = existing.mcps || [];
1973
+ }
1974
+ } catch { /* expected: session may not exist */ }
1975
+ const name = preservedName;
1976
+
556
1977
  const isGreenfield = type === 'greenfield';
557
1978
 
558
1979
  // Initialize pipeline state based on workflow
@@ -568,27 +1989,53 @@ async function handleInit(projectDir, args) {
568
1989
  pipelineAgents = Object.keys(p.agents);
569
1990
  }
570
1991
 
571
- // Initialize session
572
- const result = initSession(projectDir, {
573
- mode: 'discover',
574
- projectName: name,
575
- isGreenfield,
576
- language,
577
- });
578
-
579
- if (!result.created) {
580
- return errorResult(`Failed to initialize session: ${result.error}`, 'INIT_FAILED');
581
- }
1992
+ // RESUME GUARD: if a session already exists with completed agents or a non-discover mode,
1993
+ // this is a RESUME — preserve all state and only update workflow/lock. Do NOT reset.
1994
+ const sessionPath = join(projectDir, '.chati', 'session.yaml');
1995
+ const isResume = existsSync(sessionPath) && (() => {
1996
+ try {
1997
+ const s = yaml.load(readFileSync(sessionPath, 'utf-8')) || {};
1998
+ const hasProgress = (s.completed_agents && s.completed_agents.length > 0)
1999
+ || (s.mode && s.mode !== 'discover')
2000
+ || (s.current_agent && s.current_agent !== '');
2001
+ return hasProgress;
2002
+ } catch { return false; }
2003
+ })();
2004
+
2005
+ if (isResume) {
2006
+ // Resume path — keep session.yaml intact, just refresh workflow if changed
2007
+ await updateSession(projectDir, { workflow });
2008
+ recordEvent(projectDir, EventType.SESSION_STARTED, 'orchestrator', { resume: true, workflow });
2009
+ } else {
2010
+ // Fresh init — create new session
2011
+ const result = initSession(projectDir, {
2012
+ mode: 'discover',
2013
+ projectName: name,
2014
+ isGreenfield,
2015
+ language,
2016
+ ides: preservedIdes,
2017
+ mcps: preservedMcps,
2018
+ });
582
2019
 
583
- // Set workflow in session
584
- await updateSession(projectDir, { workflow });
2020
+ if (!result.created) {
2021
+ return errorResult(`Failed to initialize session: ${result.error}`, 'INIT_FAILED');
2022
+ }
585
2023
 
586
- // Determine first agent
587
- const firstAgent = isGreenfield ? 'greenfield-wu' : 'brownfield-wu';
588
- const firstAgentFile = AGENT_FILE_MAP[firstAgent] || null;
2024
+ await updateSession(projectDir, { workflow });
2025
+ clearTimeline(projectDir);
2026
+ recordEvent(projectDir, EventType.SESSION_STARTED, 'orchestrator', { projectType: type, workflow, language });
2027
+ }
589
2028
 
590
- // Update session with first agent
591
- await updateSession(projectDir, { current_agent: firstAgent });
2029
+ // Determine first/active agent on resume, use current_agent from session
2030
+ let firstAgent;
2031
+ if (isResume) {
2032
+ const s = yaml.load(readFileSync(sessionPath, 'utf-8')) || {};
2033
+ firstAgent = s.current_agent || (isGreenfield ? 'greenfield-wu' : 'brownfield-wu');
2034
+ } else {
2035
+ firstAgent = isGreenfield ? 'greenfield-wu' : 'brownfield-wu';
2036
+ await updateSession(projectDir, { current_agent: firstAgent });
2037
+ }
2038
+ const firstAgentFile = getAgentFile(firstAgent, projectDir) || null;
592
2039
 
593
2040
  // Write session lock
594
2041
  try {
@@ -665,6 +2112,27 @@ async function handleStatus(projectDir) {
665
2112
  agentModels[agentDef.name] = resolveAgentModel(agentDef.name, projectDir);
666
2113
  }
667
2114
 
2115
+ // Fase 10 workstream 2 (context-window-detection v1) — model-aware
2116
+ // context block. Resolves the active window from session.active_model
2117
+ // (populated by prism-engine on first prompt) and reports the exact
2118
+ // bracket prism-engine last computed. If the session predates v1.2
2119
+ // (no context_* fields yet), the block falls back to estimateContextBracket
2120
+ // so the existing response shape stays stable for consumers.
2121
+ const activeModel = session.active_model || null;
2122
+ const activeProvider = session.active_provider || (session.providers_enabled || ['claude'])[0];
2123
+ const windowTokens = session.context_window_tokens != null
2124
+ ? session.context_window_tokens
2125
+ : resolveContextLimit(activeModel, activeProvider);
2126
+ const tokensUsed = session.context_tokens_used != null ? session.context_tokens_used : 0;
2127
+ const remainingPct = windowTokens > 0
2128
+ ? Math.max(0, Math.round((1 - tokensUsed / windowTokens) * 100))
2129
+ : 100;
2130
+ const lastBracket = session.context_last_bracket
2131
+ || (remainingPct < 25 ? 'CRITICAL'
2132
+ : remainingPct < 40 ? 'DEPLETED'
2133
+ : remainingPct < 60 ? 'MODERATE'
2134
+ : 'FRESH');
2135
+
668
2136
  return {
669
2137
  session: summary.summary || summary,
670
2138
  pipeline: progress,
@@ -674,6 +2142,15 @@ async function handleStatus(projectDir) {
674
2142
  agents: session.agents || {},
675
2143
  agent_models: agentModels,
676
2144
  context_bracket: estimateContextBracket((session.completed_agents || []).length, AGENT_PIPELINE.length),
2145
+ context: {
2146
+ model: activeModel,
2147
+ provider: activeProvider,
2148
+ window_tokens: windowTokens,
2149
+ tokens_used: tokensUsed,
2150
+ remaining_pct: remainingPct,
2151
+ bracket: lastBracket,
2152
+ handoff_required: remainingPct < 15,
2153
+ },
677
2154
  user_level: session.user_level || 'auto',
678
2155
  execution_mode: session.execution_mode || 'interactive',
679
2156
  };
@@ -734,6 +2211,16 @@ async function handleDeviation(projectDir, args) {
734
2211
  }
735
2212
 
736
2213
  await updateSession(projectDir, updates);
2214
+
2215
+ // Refresh session lock if agent changed
2216
+ if (updates.current_agent) {
2217
+ try {
2218
+ writeSessionLock(projectDir, updates.current_agent, {
2219
+ phase: session.mode,
2220
+ mode: session.execution_mode || 'interactive',
2221
+ });
2222
+ } catch { /* non-fatal */ }
2223
+ }
737
2224
  }
738
2225
 
739
2226
  return {
@@ -751,6 +2238,7 @@ async function handleExit(projectDir, args) {
751
2238
  }
752
2239
 
753
2240
  // Save current state
2241
+ recordEvent(projectDir, EventType.SESSION_ENDED, session.current_agent || 'orchestrator', { mode: session.mode });
754
2242
  await updateSession(projectDir, {});
755
2243
 
756
2244
  // Release session ownership
@@ -781,8 +2269,16 @@ async function handleProviders(projectDir) {
781
2269
  agentModels[agentDef.name] = resolveAgentModel(agentDef.name, projectDir);
782
2270
  }
783
2271
 
2272
+ let primaryProvider = 'claude';
2273
+ const configPath = join(projectDir, resolveFrameworkDir(projectDir), 'config.yaml');
2274
+ if (existsSync(configPath)) {
2275
+ const raw = readFileSync(configPath, 'utf-8');
2276
+ const providerMatch = raw.match(/primary_provider:\s*["']?(\w+)/);
2277
+ if (providerMatch) primaryProvider = providerMatch[1].toLowerCase();
2278
+ }
2279
+
784
2280
  return {
785
- primary_provider: 'claude',
2281
+ primary_provider: primaryProvider,
786
2282
  agent_models: agentModels,
787
2283
  };
788
2284
  }
@@ -962,7 +2458,7 @@ async function handleScan(projectDir, args) {
962
2458
 
963
2459
  const TEAM_CONFIGS = {
964
2460
  planning: {
965
- members: ['detail', 'architect', 'ux'],
2461
+ members: ['detail', 'architect', 'ux', 'qa-planning'],
966
2462
  slug: 'pln',
967
2463
  templateFile: 'team-planning-tasks.yaml',
968
2464
  },
@@ -978,12 +2474,41 @@ function generateTeamId(slug) {
978
2474
  return `TM-${date}-${slug}`;
979
2475
  }
980
2476
 
2477
+ /**
2478
+ * Agent Teams require BOTH the feature flag AND a Claude provider.
2479
+ *
2480
+ * Why provider gate: Agent Teams uses Claude Code's native Task tool / subagent
2481
+ * spawn API. Gemini and Codex have no equivalent — calling spawn_team for those
2482
+ * providers would fail. We always fall back silently to spawn_parallel
2483
+ * (Gemini) or spawn_autonomous (Codex) when the active provider is not claude.
2484
+ */
981
2485
  function isAgentTeamsEnabled(projectDir) {
982
- const configPath = join(projectDir, 'chati.dev', 'config.yaml');
2486
+ const configPath = join(projectDir, resolveFrameworkDir(projectDir), 'config.yaml');
983
2487
  if (!existsSync(configPath)) return false;
984
2488
  const raw = readFileSync(configPath, 'utf-8');
985
- const match = raw.match(/agent_teams:\s*(true|false)/);
986
- return match ? match[1] === 'true' : false;
2489
+
2490
+ // Feature flag check
2491
+ const flagMatch = raw.match(/agent_teams:\s*(true|false)/);
2492
+ // If field is explicitly set, respect it
2493
+ if (flagMatch) {
2494
+ if (flagMatch[1] !== 'true') return false;
2495
+ } else {
2496
+ // If field is absent (pre-v4.2.0 projects), default to true for Claude provider
2497
+ // Claude Code supports Agent tool natively; Gemini/Codex fall back to sequential
2498
+ const providerMatch = raw.match(/primary_provider:\s*["']?(\w+)/);
2499
+ const provider = providerMatch ? providerMatch[1].toLowerCase() : 'claude';
2500
+ if (provider !== 'claude') return false;
2501
+ }
2502
+
2503
+ // Provider gate — Agent Teams is Claude-only.
2504
+ // Read primary_provider from session.yaml (preferred) or active_provider
2505
+ // field. Default to 'claude' if neither exists (early bootstrap).
2506
+ const sessionPath = join(projectDir, '.chati', 'session.yaml');
2507
+ if (!existsSync(sessionPath)) return true; // No session yet — assume claude
2508
+ const sessionRaw = readFileSync(sessionPath, 'utf-8');
2509
+ const providerMatch = sessionRaw.match(/^\s*(?:active_provider|primary_provider):\s*["']?(\w+)/m);
2510
+ const provider = providerMatch ? providerMatch[1] : 'claude';
2511
+ return provider === 'claude';
987
2512
  }
988
2513
 
989
2514
  async function handleSpawnTeam(projectDir, args) {
@@ -1006,7 +2531,7 @@ async function handleSpawnTeam(projectDir, args) {
1006
2531
  const teamDir = join(projectDir, '.chati', 'teams', teamId);
1007
2532
  const mailboxDir = join(teamDir, 'mailbox');
1008
2533
  const taskListPath = join(teamDir, 'tasks.yaml');
1009
- const templatePath = join(projectDir, 'chati.dev', 'templates', config.templateFile);
2534
+ const templatePath = join(projectDir, resolveFrameworkDir(projectDir), 'templates', config.templateFile);
1010
2535
 
1011
2536
  // Create team directories
1012
2537
  mkdirSync(mailboxDir, { recursive: true });
@@ -1017,7 +2542,7 @@ async function handleSpawnTeam(projectDir, args) {
1017
2542
 
1018
2543
  // For build teams: dynamically populate tasks from tasks.md (Article XXI §9)
1019
2544
  if (teamType === 'build') {
1020
- const tasksArtifact = join(projectDir, 'chati.dev', 'artifacts', '6-Tasks', 'tasks.md');
2545
+ const tasksArtifact = join(projectDir, 'artifacts', '6-Tasks', 'tasks.md');
1021
2546
  if (existsSync(tasksArtifact)) {
1022
2547
  const tasksContent = readFileSync(tasksArtifact, 'utf-8');
1023
2548
  // Extract task IDs (pattern: T{phase}.{seq})
@@ -1112,6 +2637,7 @@ async function handleSpawnTeam(projectDir, args) {
1112
2637
  });
1113
2638
 
1114
2639
  await updateSession(projectDir, session);
2640
+ recordEvent(projectDir, EventType.AGENT_ACTIVATED, `team:${teamType}`, { teamId, members: config.members });
1115
2641
  }
1116
2642
  } catch { /* non-critical: session update may fail, team can still spawn */ }
1117
2643
 
@@ -1232,7 +2758,7 @@ async function handleTeamDissolve(projectDir, args) {
1232
2758
  let correctionCyclesOk = true;
1233
2759
  let maxCorrectionCycles = 2; // default per constitution
1234
2760
  try {
1235
- const configPath = join(projectDir, 'chati.dev', 'config.yaml');
2761
+ const configPath = join(projectDir, resolveFrameworkDir(projectDir), 'config.yaml');
1236
2762
  if (existsSync(configPath)) {
1237
2763
  const configRaw = readFileSync(configPath, 'utf-8');
1238
2764
  const maxMatch = configRaw.match(/team_correction_cycles_max:\s*(\d+)/);
@@ -1256,7 +2782,7 @@ async function handleTeamDissolve(projectDir, args) {
1256
2782
  if (!mailboxClean) gateFailures.push('unresolved_mailbox_messages');
1257
2783
  if (!noBlockers) gateFailures.push('open_blockers');
1258
2784
  if (!correctionCyclesOk) gateFailures.push('correction_cycles_exceeded');
1259
- if (teamScore < 90) gateFailures.push('team_score_below_threshold');
2785
+ if (teamScore < tierThreshold) gateFailures.push('team_score_below_threshold');
1260
2786
 
1261
2787
  // Update session
1262
2788
  try {
@@ -1311,12 +2837,296 @@ async function handleTeamDissolve(projectDir, args) {
1311
2837
  mailbox_clean: mailboxClean,
1312
2838
  no_open_blockers: noBlockers,
1313
2839
  correction_cycles_ok: correctionCyclesOk,
1314
- team_score_above_threshold: teamScore >= 90,
2840
+ team_score_above_threshold: teamScore >= tierThreshold,
1315
2841
  },
1316
2842
  },
1317
2843
  };
1318
2844
  }
1319
2845
 
2846
+ // ---------------------------------------------------------------------------
2847
+ // Deterministic subcommands — replace generative LLM decisions with code
2848
+ // ---------------------------------------------------------------------------
2849
+
2850
+ /**
2851
+ * wait-for-license: poll ~/.chati-dev/license.yaml until status=VALID or
2852
+ * a terminal-bad status (EXPIRED|INVALID|REVOKED) or timeout. Used by the
2853
+ * /chati boot path when doctor reports a missing/invalid license — the
2854
+ * orchestrator displays the activate command, then calls this to block
2855
+ * until the user has run it in another terminal.
2856
+ *
2857
+ * @param {string} _projectDir unused (license is home-dir scoped)
2858
+ * @param {{ timeout?: string, interval?: string }} args
2859
+ */
2860
+ async function handleWaitForLicense(_projectDir, args) {
2861
+ const timeoutMs = args.timeout ? parseInt(args.timeout, 10) : undefined;
2862
+ const intervalMs = args.interval ? parseInt(args.interval, 10) : undefined;
2863
+ const result = await waitForLicense({ timeoutMs, intervalMs });
2864
+ return {
2865
+ valid: result.valid,
2866
+ status: result.status,
2867
+ reason: result.reason,
2868
+ waited_ms: result.waitedMs,
2869
+ next: result.valid
2870
+ ? { action: 'proceed', status_summary: 'License VALID — continuing /chati boot.' }
2871
+ : {
2872
+ action: 'license_resolution_required',
2873
+ status_summary: `License status: ${result.status}. ${result.reason || ''}`.trim(),
2874
+ fix: 'Paste in another terminal: `npx chati-dev activate --key=YOUR-KEY`, then re-invoke `/chati`.',
2875
+ },
2876
+ };
2877
+ }
2878
+
2879
+ /**
2880
+ * doctor: run the silent self-diagnostic. Returns the structured verdict
2881
+ * from runDoctor for the orchestrator to surface. Callers are /chati boot
2882
+ * (implicit) and `/chati doctor` (explicit).
2883
+ *
2884
+ * @param {string} projectDir
2885
+ */
2886
+ function handleDoctor(projectDir) {
2887
+ const verdict = runDoctor({ projectDir });
2888
+ return {
2889
+ ok: verdict.ok,
2890
+ checks: verdict.checks,
2891
+ blockers: verdict.blockers,
2892
+ warnings: verdict.warnings,
2893
+ next: verdict.ok
2894
+ ? { action: 'proceed', status_summary: 'doctor: all checks OK (silent pass).' }
2895
+ : {
2896
+ action: 'remediation_required',
2897
+ status_summary: `doctor: ${verdict.blockers.length} blocker(s), ${verdict.warnings.length} warning(s).`,
2898
+ remediations: verdict.blockers.map(b => ({ id: b.id, fix: b.fix })).filter(r => r.fix),
2899
+ },
2900
+ };
2901
+ }
2902
+
2903
+ /**
2904
+ * wait-for-capture: block until capture-complete.json exists (max 10 min).
2905
+ * Replaces Brand Architect's generative polling loop.
2906
+ */
2907
+ async function handleWaitForCapture(projectDir, args) {
2908
+ const refsDir = join(projectDir, 'artifacts', '4-UX', 'references');
2909
+ const completePath = join(refsDir, 'capture-complete.json');
2910
+ const timeoutMs = parseInt(args.timeout || '600000', 10); // 10 min default
2911
+ const pollMs = 5000; // check every 5s
2912
+ const start = Date.now();
2913
+
2914
+ while (Date.now() - start < timeoutMs) {
2915
+ if (existsSync(completePath)) {
2916
+ try {
2917
+ const data = JSON.parse(readFileSync(completePath, 'utf-8'));
2918
+ return { ready: true, ...data };
2919
+ } catch {
2920
+ return { ready: true, error: 'capture-complete.json exists but invalid JSON' };
2921
+ }
2922
+ }
2923
+ await new Promise(r => setTimeout(r, pollMs));
2924
+ }
2925
+
2926
+ return { ready: false, timedOut: true, message: `capture-complete.json not found after ${timeoutMs / 1000}s. Proceed with WebFetch fallback.` };
2927
+ }
2928
+
2929
+ /**
2930
+ * qa-visual-score: calculate visual QA score deterministically from report.json.
2931
+ * Replaces QA-Visual's generative scoring formula.
2932
+ */
2933
+ function handleQaVisualScore(args) {
2934
+ const reportPath = args.report || '/tmp/visual-qa/report.json';
2935
+ if (!existsSync(reportPath)) {
2936
+ return errorResult(`report.json not found at ${reportPath}`, 'REPORT_NOT_FOUND');
2937
+ }
2938
+
2939
+ let report;
2940
+ try { report = JSON.parse(readFileSync(reportPath, 'utf-8')); } catch {
2941
+ return errorResult('report.json is invalid JSON', 'INVALID_REPORT');
2942
+ }
2943
+
2944
+ const s = report.summary || {};
2945
+ const hasRefs = args['has-refs'] === 'true';
2946
+
2947
+ // Weights (adjust when references exist)
2948
+ const w = hasRefs
2949
+ ? { animation: 0.30, scroll: 0.20, responsive: 0.15, hover: 0.10, brand: 0.10, fidelity: 0.15 }
2950
+ : { animation: 0.35, scroll: 0.25, responsive: 0.20, hover: 0.10, brand: 0.10, fidelity: 0 };
2951
+
2952
+ // Base scores per dimension (0-100)
2953
+ 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));
2954
+ const scrollScore = (s.total_scroll_triggers || 0) > 0 ? 80 + Math.min(20, s.total_scroll_triggers * 2) : 40;
2955
+ const responsiveScore = 80; // baseline — actual responsive check is visual (generative)
2956
+ const hoverScore = 80; // baseline
2957
+ const brandScore = (s.reduced_motion_support ? 10 : 0) + Math.min(90, (report.pages?.[0]?.js_checks?.custom_properties_count ?? 0));
2958
+ const fidelityScore = 70; // placeholder — Visualizer comparison is generative
2959
+
2960
+ let rawScore = Math.round(
2961
+ animScore * w.animation +
2962
+ scrollScore * w.scroll +
2963
+ responsiveScore * w.responsive +
2964
+ hoverScore * w.hover +
2965
+ brandScore * w.brand +
2966
+ fidelityScore * w.fidelity
2967
+ );
2968
+
2969
+ // Penalties
2970
+ const errors = [];
2971
+ const warnings = [];
2972
+
2973
+ if (!s.lenis_active_all) errors.push('Lenis not active on all pages');
2974
+ if (!s.gsap_loaded_all) errors.push('GSAP not loaded on all pages');
2975
+ if (s.em_dashes_detected) { errors.push('Em-dashes found in visible text'); rawScore -= 10; }
2976
+ if (s.console_errors_total > 0) { warnings.push(`${s.console_errors_total} console error(s)`); rawScore -= 5; }
2977
+
2978
+ // Hard blocks
2979
+ const hardBlocked = !s.lenis_active_all || !s.gsap_loaded_all || s.em_dashes_detected;
2980
+ const score = Math.max(0, Math.min(100, rawScore));
2981
+ const threshold = 90;
2982
+ const verdict = hardBlocked ? 'BLOCKED' : score >= threshold ? 'APPROVED' : 'NEEDS_CORRECTION';
2983
+
2984
+ return { score, threshold, verdict, hardBlocked, errors, warnings, weights: w, dimensions: { animScore, scrollScore, responsiveScore, hoverScore, brandScore, fidelityScore } };
2985
+ }
2986
+
2987
+ /**
2988
+ * wave-status: read Build Team tasks.yaml + mailbox, return wave status.
2989
+ * Replaces Claude's manual mailbox counting.
2990
+ */
2991
+ async function handleWaveStatus(projectDir, args) {
2992
+ const teamId = args['team-id'];
2993
+ if (!teamId) return errorResult('Missing --team-id flag', 'MISSING_TEAM_ID');
2994
+
2995
+ const teamDir = join(projectDir, '.chati', 'teams', teamId);
2996
+ const tasksPath = join(teamDir, 'tasks.yaml');
2997
+ if (!existsSync(tasksPath)) return errorResult(`Team tasks not found: ${tasksPath}`, 'TEAM_NOT_FOUND');
2998
+
2999
+ let tasks;
3000
+ try { tasks = yaml.load(readFileSync(tasksPath, 'utf-8')); } catch {
3001
+ return errorResult('tasks.yaml is invalid', 'INVALID_TASKS');
3002
+ }
3003
+
3004
+ const taskList = tasks.tasks || [];
3005
+ const completed = taskList.filter(t => t.status === 'done' || t.status === 'completed');
3006
+ const inProgress = taskList.filter(t => t.status === 'in_progress');
3007
+ const pending = taskList.filter(t => t.status === 'pending');
3008
+ const blocked = taskList.filter(t => t.blocker);
3009
+ const failed = taskList.filter(t => t.status === 'failed' || t.status === 'needs_correction');
3010
+
3011
+ // Determine wave type
3012
+ let waveType = 'regular';
3013
+ if (pending.length === 0 && failed.length > 0) waveType = 'cleanup';
3014
+ if (pending.length === 0 && failed.length === 0 && inProgress.length === 0) waveType = 'complete';
3015
+
3016
+ return {
3017
+ team_id: teamId,
3018
+ wave_type: waveType,
3019
+ total: taskList.length,
3020
+ completed: completed.length,
3021
+ in_progress: inProgress.length,
3022
+ pending: pending.length,
3023
+ failed: failed.length,
3024
+ blocked: blocked.length,
3025
+ all_done: pending.length === 0 && inProgress.length === 0 && failed.length === 0,
3026
+ recommendation: waveType === 'complete'
3027
+ ? 'All tasks complete. Ready for team dissolution.'
3028
+ : waveType === 'cleanup'
3029
+ ? `${failed.length} task(s) need correction. Start Cleanup Wave.`
3030
+ : `${pending.length} task(s) pending, ${inProgress.length} in progress. Continue Regular Wave.`,
3031
+ };
3032
+ }
3033
+
3034
+ /**
3035
+ * Brief category dictionaries — user-input i18n tokens keyed by ISO 639-1.
3036
+ *
3037
+ * These match free-form brief text against 7 coverage categories. The set is
3038
+ * routed through session.language at call time (same exemption pattern as
3039
+ * RESUME_MESSAGES). English is always unioned in; the locale layer adds
3040
+ * dialect tokens so projects running brief in pt/es/fr still get coverage.
3041
+ *
3042
+ * Article VII: code identifiers and framework strings stay English; these
3043
+ * locale entries are user-input tokens, analogous to i18n message catalogs.
3044
+ */
3045
+ export const BRIEF_CATEGORY_KEYWORDS = {
3046
+ en: {
3047
+ problem: ['problem', 'issue', 'challenge', 'pain', 'struggle', 'frustrate', 'difficult', 'gap', 'need', 'fix'],
3048
+ users: ['user', 'customer', 'client', 'audience', 'persona', 'visitor', 'target', 'demographic', 'segment'],
3049
+ outcome: ['goal', 'outcome', 'result', 'success', 'metric', 'kpi', 'achieve', 'objective', 'want', 'expect'],
3050
+ constraints: ['budget', 'timeline', 'deadline', 'team', 'resource', 'limit', 'constraint', 'technology', 'stack'],
3051
+ scope: ['scope', 'feature', 'page', 'section', 'include', 'exclude', 'not build', 'out of scope', 'mvp', 'v1'],
3052
+ references: ['reference', 'competitor', 'inspiration', 'like', 'similar', 'example', 'benchmark', 'design', 'look'],
3053
+ context: ['company', 'business', 'industry', 'market', 'brand', 'history', 'background', 'about', 'mission'],
3054
+ },
3055
+ pt: {
3056
+ problem: ['problema', 'desafio', 'dor', 'dificuldade', 'necessidade', 'resolver', 'corrigir'],
3057
+ users: ['usuario', 'usu\u00e1rio', 'cliente', 'p\u00fablico', 'visitante', 'alvo', 'pessoa'],
3058
+ outcome: ['objetivo', 'meta', 'resultado', 'sucesso', 'quero', 'espero', 'alcan\u00e7ar', 'atingir'],
3059
+ constraints: ['or\u00e7amento', 'prazo', 'equipe', 'recurso', 'limite', 'tecnologia', 'restri\u00e7\u00e3o'],
3060
+ scope: ['escopo', 'funcionalidade', 'p\u00e1gina', 'incluir', 'excluir', 'n\u00e3o fazer', 'fora do escopo'],
3061
+ references: ['refer\u00eancia', 'referencia', 'concorrente', 'inspira\u00e7\u00e3o', 'inspiracao', 'parecido', 'exemplo', 'visual'],
3062
+ context: ['empresa', 'neg\u00f3cio', 'negocio', 'mercado', 'marca', 'hist\u00f3ria', 'historia', 'sobre', 'miss\u00e3o'],
3063
+ },
3064
+ es: {
3065
+ problem: ['problema', 'desaf\u00edo', 'dolor', 'dificultad', 'necesidad', 'resolver', 'corregir'],
3066
+ users: ['usuario', 'cliente', 'audiencia', 'p\u00fablico', 'visitante', 'objetivo', 'persona'],
3067
+ outcome: ['meta', 'objetivo', 'resultado', '\u00e9xito', 'quiero', 'espero', 'lograr', 'alcanzar'],
3068
+ constraints: ['presupuesto', 'plazo', 'equipo', 'recurso', 'l\u00edmite', 'tecnolog\u00eda', 'restricci\u00f3n'],
3069
+ scope: ['alcance', 'funcionalidad', 'p\u00e1gina', 'incluir', 'excluir', 'no hacer', 'fuera de alcance'],
3070
+ references: ['referencia', 'competidor', 'inspiraci\u00f3n', 'parecido', 'ejemplo', 'visual'],
3071
+ context: ['empresa', 'negocio', 'mercado', 'marca', 'historia', 'acerca', 'misi\u00f3n'],
3072
+ },
3073
+ fr: {
3074
+ problem: ['probl\u00e8me', 'd\u00e9fi', 'douleur', 'difficult\u00e9', 'besoin', 'r\u00e9soudre', 'corriger'],
3075
+ users: ['utilisateur', 'client', 'audience', 'public', 'visiteur', 'cible', 'persona'],
3076
+ outcome: ['objectif', 'r\u00e9sultat', 'succ\u00e8s', 'veux', 'souhaite', 'atteindre'],
3077
+ constraints: ['budget', 'd\u00e9lai', '\u00e9quipe', 'ressource', 'limite', 'technologie', 'contrainte'],
3078
+ scope: ['p\u00e9rim\u00e8tre', 'fonctionnalit\u00e9', 'page', 'inclure', 'exclure', 'hors p\u00e9rim\u00e8tre'],
3079
+ references: ['r\u00e9f\u00e9rence', 'concurrent', 'inspiration', 'similaire', 'exemple', 'visuel'],
3080
+ context: ['entreprise', 'march\u00e9', 'marque', 'histoire', 'mission'],
3081
+ },
3082
+ };
3083
+
3084
+ const BRIEF_CATEGORIES = ['problem', 'users', 'outcome', 'constraints', 'scope', 'references', 'context'];
3085
+
3086
+ /**
3087
+ * Build the keyword set for a category at a given language. Always includes EN
3088
+ * tokens; the locale layer is appended when session.language selects it.
3089
+ *
3090
+ * @param {string} category One of BRIEF_CATEGORIES.
3091
+ * @param {string} [language='en'] ISO 639-1 session language.
3092
+ * @returns {string[]}
3093
+ */
3094
+ export function getBriefCategoryKeywords(category, language = 'en') {
3095
+ const en = BRIEF_CATEGORY_KEYWORDS.en[category] ?? [];
3096
+ if (language === 'en') return [...en];
3097
+ const locale = BRIEF_CATEGORY_KEYWORDS[language]?.[category] ?? [];
3098
+ return [...en, ...locale];
3099
+ }
3100
+
3101
+ /**
3102
+ * assess-coverage: classify user input against 7 Brief categories.
3103
+ * Replaces Brief's generative coverage assessment.
3104
+ */
3105
+ function handleAssessCoverage(args) {
3106
+ const input = (args.input || '').toLowerCase();
3107
+ if (!input) return errorResult('Missing --input flag', 'MISSING_INPUT');
3108
+ const language = (args.language || 'en').toLowerCase();
3109
+
3110
+ const categories = {};
3111
+ for (const cat of BRIEF_CATEGORIES) {
3112
+ const keywords = getBriefCategoryKeywords(cat, language);
3113
+ categories[cat] = { keywords, found: keywords.some(k => input.includes(k)) };
3114
+ }
3115
+
3116
+ const covered = Object.entries(categories).filter(([, d]) => d.found).map(([k]) => k);
3117
+ const missing = Object.entries(categories).filter(([, d]) => !d.found).map(([k]) => k);
3118
+ const coveragePct = Math.round((covered.length / BRIEF_CATEGORIES.length) * 100);
3119
+
3120
+ return {
3121
+ coverage_pct: coveragePct,
3122
+ covered,
3123
+ missing,
3124
+ recommendation: coveragePct >= 50
3125
+ ? `Good coverage (${coveragePct}%). Clarify: ${missing.join(', ')}.`
3126
+ : `Low coverage (${coveragePct}%). Deep exploration needed on: ${missing.join(', ')}.`,
3127
+ };
3128
+ }
3129
+
1320
3130
  // ---------------------------------------------------------------------------
1321
3131
  // Main entry point
1322
3132
  // ---------------------------------------------------------------------------
@@ -1374,6 +3184,24 @@ export async function runOrchestrate(subCommand, argv, projectDir) {
1374
3184
  case 'team-dissolve':
1375
3185
  output(await handleTeamDissolve(projectDir, args));
1376
3186
  break;
3187
+ case 'wait-for-capture':
3188
+ output(await handleWaitForCapture(projectDir, args));
3189
+ break;
3190
+ case 'wait-for-license':
3191
+ output(await handleWaitForLicense(projectDir, args));
3192
+ break;
3193
+ case 'doctor':
3194
+ output(handleDoctor(projectDir));
3195
+ break;
3196
+ case 'qa-visual-score':
3197
+ output(handleQaVisualScore(args));
3198
+ break;
3199
+ case 'wave-status':
3200
+ output(await handleWaveStatus(projectDir, args));
3201
+ break;
3202
+ case 'assess-coverage':
3203
+ output(handleAssessCoverage(args));
3204
+ break;
1377
3205
  default:
1378
3206
  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'));
1379
3207
  break;