chati-dev 4.2.2 → 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 (220) 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 +7 -6
  23. package/framework/constitution.md +127 -44
  24. package/framework/context/governance.md +12 -7
  25. package/framework/context/quality.md +6 -5
  26. package/framework/context/root.md +6 -6
  27. package/framework/data/entity-registry.yaml +377 -4
  28. package/framework/data/model-limits.json +19 -0
  29. package/framework/domains/agents/qa-visual.yaml +74 -0
  30. package/framework/domains/constitution.yaml +46 -2
  31. package/framework/domains/workflows/greenfield-fullstack.yaml +2 -2
  32. package/framework/hooks/advance-trigger.js +131 -0
  33. package/framework/hooks/brief-validator.js +83 -0
  34. package/framework/hooks/constitution-guard.js +24 -5
  35. package/framework/hooks/license-guard.js +62 -27
  36. package/framework/hooks/mode-governance.js +13 -2
  37. package/framework/hooks/model-governance.js +1 -0
  38. package/framework/hooks/post-dev.js +207 -0
  39. package/framework/hooks/prism-engine.js +274 -105
  40. package/framework/hooks/reasoning-escalator.js +371 -0
  41. package/framework/hooks/reference-trigger.js +117 -0
  42. package/framework/hooks/session-digest.js +50 -1
  43. package/framework/hooks/settings.json +32 -1
  44. package/framework/hooks/style-guard.js +25 -6
  45. package/framework/hooks/team-quality-gate.js +19 -12
  46. package/framework/hooks/undercover-guard.js +4 -2
  47. package/framework/i18n/en.yaml +3 -3
  48. package/framework/i18n/es.yaml +3 -3
  49. package/framework/i18n/fr.yaml +3 -3
  50. package/framework/i18n/pt.yaml +3 -3
  51. package/framework/intelligence/context-engine.md +4 -5
  52. package/framework/intelligence/decision-engine.md +1 -1
  53. package/framework/intelligence/hooks-performance.md +3 -3
  54. package/framework/migrations/v1.0-to-v1.1.yaml +1 -1
  55. package/framework/migrations/v1.4-to-v2.0.yaml +11 -11
  56. package/framework/migrations/v4.0-to-v4.1.yaml +2 -2
  57. package/framework/migrations/v4.2-to-v4.3.yaml +29 -0
  58. package/framework/orchestrator/chati-router.js +387 -0
  59. package/framework/orchestrator/chati-update.md +40 -40
  60. package/framework/orchestrator/chati.md +294 -94
  61. package/framework/scaffold/motion-premium/README.md +89 -0
  62. package/framework/scaffold/motion-premium/app/globals.css.template +400 -0
  63. package/framework/scaffold/motion-premium/app/layout.tsx.template +110 -0
  64. package/framework/scaffold/motion-premium/components/animation/BackgroundCrossfadeProvider.tsx.template +170 -0
  65. package/framework/scaffold/motion-premium/components/animation/LenisProvider.tsx.template +49 -0
  66. package/framework/scaffold/motion-premium/components/animation/PageTransitionWrapper.tsx.template +83 -0
  67. package/framework/scaffold/motion-premium/components/animation/Preloader.tsx.template +171 -0
  68. package/framework/scaffold/motion-premium/components/ui/Container.tsx.template +69 -0
  69. package/framework/scaffold/motion-premium/components/ui/PageSection.tsx.template +74 -0
  70. package/framework/scaffold/motion-premium/lib/animations/gsap.ts.template +112 -0
  71. package/framework/scaffold/motion-premium/lib/animations/refreshCoordinator.ts.template +75 -0
  72. package/framework/scaffold/motion-premium/lib/animations/tokens.ts.template +119 -0
  73. package/framework/scaffold/motion-premium/lib/animations/useGsapContext.ts.template +92 -0
  74. package/framework/scaffold/motion-premium/lib/animations/useScrollSnapStepper.ts.template +265 -0
  75. package/framework/scaffold/motion-premium/lib/animations/useSmoothScroll.ts.template +67 -0
  76. package/framework/scaffold/motion-premium/lib/brand.ts.template +43 -0
  77. package/framework/scaffold/motion-premium/scaffold.yaml +174 -0
  78. package/framework/scaffold/motion-premium-3d/README.md +80 -0
  79. package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/ScrollDemoCanvas.tsx.template +81 -0
  80. package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/ScrollDemoClient.tsx.template +75 -0
  81. package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/page.tsx.template +26 -0
  82. package/framework/scaffold/motion-premium-3d/components/3d/CameraRig.tsx.template +100 -0
  83. package/framework/scaffold/motion-premium-3d/components/3d/CanvasProvider.tsx.template +85 -0
  84. package/framework/scaffold/motion-premium-3d/components/3d/InvalidateOnScroll.tsx.template +51 -0
  85. package/framework/scaffold/motion-premium-3d/components/3d/MeshCrossfade.tsx.template +79 -0
  86. package/framework/scaffold/motion-premium-3d/components/3d/ScrollCrossfade.tsx.template +88 -0
  87. package/framework/scaffold/motion-premium-3d/components/3d/ScrollScene.tsx.template +121 -0
  88. package/framework/scaffold/motion-premium-3d/components/webgl/SceneFallback.tsx.template +65 -0
  89. package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +68 -0
  90. package/framework/scaffold/motion-premium-3d/lib/webgl/detect.ts.template +69 -0
  91. package/framework/scaffold/motion-premium-3d/scaffold.yaml +133 -0
  92. package/framework/schemas/session.schema.json +109 -21
  93. package/framework/scripts/reference-capture.js +430 -0
  94. package/framework/scripts/visual-qa.js +674 -0
  95. package/framework/tasks/orchestrator-handoff.md +1 -1
  96. package/framework/tasks/orchestrator-resume.md +1 -1
  97. package/framework/tasks/orchestrator-route.md +1 -1
  98. package/framework/tasks/orchestrator-status.md +3 -3
  99. package/framework/tasks/qa-planning-gate-define.md +1 -1
  100. package/framework/templates/brandbook-html-tmpl.md +1 -1
  101. package/framework/templates/brandbook-tmpl.yaml +1 -1
  102. package/framework/templates/component-spec-tmpl.yaml +1 -1
  103. package/framework/templates/design-token-tmpl.yaml +1 -1
  104. package/framework/templates/icon-system-tmpl.yaml +1 -1
  105. package/framework/templates/team-planning-tasks.yaml +6 -5
  106. package/framework/workflows/brownfield-discovery.yaml +2 -2
  107. package/framework/workflows/brownfield-fullstack.yaml +15 -11
  108. package/framework/workflows/brownfield-service.yaml +14 -10
  109. package/framework/workflows/brownfield-ui.yaml +15 -11
  110. package/framework/workflows/greenfield-fullstack.yaml +16 -13
  111. package/framework/workflows/quick-flow.yaml +3 -3
  112. package/framework/workflows/standard-flow.yaml +12 -9
  113. package/package.json +10 -5
  114. package/src/autonomy/autonomous-gate.js +1 -0
  115. package/src/autonomy/build-state.js +1 -2
  116. package/src/autonomy/progress-reporter.js +1 -1
  117. package/src/config/agent-customizer.js +11 -3
  118. package/src/config/claude-settings-generator.js +27 -7
  119. package/src/config/context-file-generator.js +41 -21
  120. package/src/config/framework-adapter.js +1 -0
  121. package/src/config/gemini-hooks-generator.js +19 -7
  122. package/src/config/mcp-configs.js +1 -0
  123. package/src/context/layers/l1-global.js +2 -1
  124. package/src/dashboard/data-reader.js +4 -3
  125. package/src/dashboard/layout.js +2 -1
  126. package/src/decision/analyzer.js +6 -30
  127. package/src/decision/engine.js +4 -28
  128. package/src/decision/registry-healer.js +3 -2
  129. package/src/decision/registry-updater.js +23 -14
  130. package/src/extensions/loader.js +2 -8
  131. package/src/gates/g1-planning-complete.js +2 -1
  132. package/src/gates/g2-qa-planning.js +2 -1
  133. package/src/gates/g3-implementation.js +2 -1
  134. package/src/gates/g4-qa-implementation.js +3 -2
  135. package/src/gates/g5-deploy-ready.js +2 -1
  136. package/src/health/engine.js +4 -3
  137. package/src/installer/core.js +422 -81
  138. package/src/installer/preflight.js +131 -0
  139. package/src/installer/provider-overlay.js +3 -3
  140. package/src/installer/scaffold-applier.js +358 -0
  141. package/src/installer/templates.js +46 -29
  142. package/src/installer/validator.js +17 -12
  143. package/src/intelligence/registry-manager.js +22 -29
  144. package/src/intelligence/timeline.js +11 -6
  145. package/src/license/commands.js +1 -1
  146. package/src/license/wait.js +102 -0
  147. package/src/memory/agent-memory.js +81 -0
  148. package/src/memory/dream.js +32 -1
  149. package/src/merger/replace-merger.js +28 -15
  150. package/src/orchestrator/agent-selector.js +2 -1
  151. package/src/orchestrator/cli.js +1869 -71
  152. package/src/orchestrator/doctor.js +270 -0
  153. package/src/orchestrator/handoff-engine.js +4 -3
  154. package/src/orchestrator/index.js +2 -0
  155. package/src/orchestrator/pipeline-manager.js +306 -15
  156. package/src/orchestrator/session-manager.js +331 -6
  157. package/src/tasks/handoff.js +3 -2
  158. package/src/telemetry/config.js +4 -3
  159. package/src/telemetry/schema.js +1 -0
  160. package/src/terminal/collector.js +3 -2
  161. package/src/terminal/index.js +1 -2
  162. package/src/terminal/isolation.js +52 -18
  163. package/src/terminal/prompt-builder.js +42 -25
  164. package/src/terminal/run-parallel.js +1 -1
  165. package/src/terminal/run-team.js +3 -3
  166. package/src/terminal/team-task-list.js +43 -4
  167. package/src/upgrade/backup.js +3 -2
  168. package/src/upgrade/checker.js +3 -2
  169. package/src/upgrade/migrator.js +65 -7
  170. package/src/upgrade/tracked-files-detector.js +86 -0
  171. package/src/upgrade/user-messages.js +94 -0
  172. package/src/utils/config-parser.js +2 -1
  173. package/src/utils/feature-flags.js +2 -1
  174. package/src/utils/flatten-entities.js +69 -0
  175. package/src/utils/framework-dir.js +16 -0
  176. package/src/utils/model-id.js +85 -0
  177. package/src/utils/provider-limits.js +84 -23
  178. package/src/utils/schema-validator.js +1 -1
  179. package/src/wizard/i18n.js +5 -4
  180. package/src/wizard/index.js +14 -0
  181. package/assets/logo - c/303/263pia.png +0 -0
  182. package/assets/logo.svg +0 -42
  183. package/assets/logo2.png +0 -0
  184. package/assets/social-preview.png +0 -0
  185. package/scripts/bundle-framework.js +0 -69
  186. package/scripts/changelog-generator.js +0 -222
  187. package/scripts/codebase-mapper.js +0 -728
  188. package/scripts/commit-message-generator.js +0 -167
  189. package/scripts/coverage-analyzer.js +0 -260
  190. package/scripts/dependency-analyzer.js +0 -280
  191. package/scripts/doctor/checks/agents.js +0 -77
  192. package/scripts/doctor/checks/constitution.js +0 -41
  193. package/scripts/doctor/checks/domain-alignment.js +0 -58
  194. package/scripts/doctor/checks/prism-layers.js +0 -84
  195. package/scripts/doctor/checks/registry.js +0 -55
  196. package/scripts/doctor/checks/schemas.js +0 -61
  197. package/scripts/doctor/fixes/reference-fix.js +0 -100
  198. package/scripts/doctor/fixes/registry-fix.js +0 -56
  199. package/scripts/doctor/index.js +0 -212
  200. package/scripts/framework-analyzer.js +0 -308
  201. package/scripts/generate-constitution-domain.js +0 -253
  202. package/scripts/generate-signing-key.js +0 -33
  203. package/scripts/health-check.js +0 -481
  204. package/scripts/ide-sync.js +0 -326
  205. package/scripts/performance-analyzer.js +0 -325
  206. package/scripts/plan-tracker.js +0 -278
  207. package/scripts/populate-entity-registry.js +0 -481
  208. package/scripts/pr-review.js +0 -317
  209. package/scripts/rollback-manager.js +0 -310
  210. package/scripts/semantic-lint.js +0 -328
  211. package/scripts/sign-manifest.js +0 -53
  212. package/scripts/stuck-detector.js +0 -343
  213. package/scripts/test-quality-assessment.js +0 -257
  214. package/scripts/validate-agents.js +0 -368
  215. package/scripts/validate-package.js +0 -505
  216. package/scripts/validate-tasks.js +0 -465
  217. package/src/autonomy/worktree-manager.js +0 -250
  218. package/src/intelligence/decision-engine.js +0 -256
  219. package/src/intelligence/document-sharder.js +0 -221
  220. 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
+ }
632
+
633
+ function writeSessionLock(projectDir, currentAgent, stateInfo = {}) {
634
+ const localMdPath = join(projectDir, 'CLAUDE.local.md');
635
+ let content = existsSync(localMdPath) ? readFileSync(localMdPath, 'utf-8') : '';
223
636
 
224
- const lockBlock = `${LOCK_START}
225
- ## Session Lock -- ACTIVE
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
 
@@ -415,6 +920,9 @@ async function handleNext(projectDir) {
415
920
  spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000);
416
921
  }
417
922
 
923
+ // Record agent activation event
924
+ recordEvent(projectDir, EventType.AGENT_ACTIVATED, nextAgent, { action, phase: agentDef?.phase });
925
+
418
926
  const progress = getPipelineProgress(pipelineState);
419
927
  const bracket = estimateContextBracket(completedAgents.length, AGENT_PIPELINE.length);
420
928
 
@@ -445,6 +953,262 @@ async function handleNext(projectDir) {
445
953
  return result;
446
954
  }
447
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
+
448
1212
  async function handleAdvance(projectDir, args) {
449
1213
  const agent = args.agent;
450
1214
  const score = parseInt(args.score, 10);
@@ -457,6 +1221,33 @@ async function handleAdvance(projectDir, args) {
457
1221
  const { loaded, session } = loadSession(projectDir);
458
1222
  if (!loaded || !session) return errorResult('No session found', 'NO_SESSION');
459
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
+
460
1251
  // Handle user_preview decisions
461
1252
  if (decision) {
462
1253
  const pipelineState = sessionToPipelineState(session, projectDir);
@@ -479,22 +1270,508 @@ async function handleAdvance(projectDir, args) {
479
1270
  return { advanced: true, agent_completed: agent, score, decision, phase_transition: { triggered: previewResult.needsModeSwitch }, next };
480
1271
  }
481
1272
 
482
- // Record completion
483
- 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
+ }
484
1299
 
485
- // Advance pipeline
486
- const pipelineState = sessionToPipelineState(session, projectDir);
487
- 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 || [])];
488
1730
  pipelineState.agents[agent] = { status: 'completed', score, startedAt: null, completedAt: new Date().toISOString() };
489
1731
 
490
- const advanceResult = advancePipeline(pipelineState, agent, { score });
1732
+ const advanceResult = advancePipeline(pipelineState, agent, {
1733
+ score,
1734
+ handoffData,
1735
+ findings: handoffData?.decision_trail_entries || [],
1736
+ });
491
1737
 
492
1738
  // Update session with new state
493
1739
  const updates = {
494
1740
  current_agent: advanceResult.nextAgent || '',
495
1741
  last_handoff: agent,
1742
+ pipeline_position: pipelineState.completedAgents.length,
496
1743
  };
497
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
+
498
1775
  // Handle phase transition
499
1776
  let phaseTransition = { triggered: false };
500
1777
  if (advanceResult.needsModeSwitch) {
@@ -510,9 +1787,113 @@ async function handleAdvance(projectDir, args) {
510
1787
  to: newPhase,
511
1788
  trigger: `${agent} completed with score ${score}`,
512
1789
  });
1790
+ recordEvent(projectDir, EventType.MODE_TRANSITION, agent, { from: session.mode, to: newPhase });
513
1791
  }
514
1792
 
515
- 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
+ }
516
1897
 
517
1898
  // Check if user_preview is needed
518
1899
  if (advanceResult.nextAction === 'user_preview') {
@@ -536,6 +1917,23 @@ async function handleAdvance(projectDir, args) {
536
1917
  }
537
1918
 
538
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
+
539
1937
  // License validation at init time (moved from per-prompt UserPromptSubmit hook)
540
1938
  try {
541
1939
  const licensePath = join(process.env.HOME || '', '.chati-dev', 'license.yaml');
@@ -559,9 +1957,23 @@ async function handleInit(projectDir, args) {
559
1957
 
560
1958
  const type = args.type || 'greenfield';
561
1959
  const language = args.language || 'en';
562
- const name = args.name || '';
563
1960
  const workflow = args.workflow || 'full';
564
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
+
565
1977
  const isGreenfield = type === 'greenfield';
566
1978
 
567
1979
  // Initialize pipeline state based on workflow
@@ -577,27 +1989,53 @@ async function handleInit(projectDir, args) {
577
1989
  pipelineAgents = Object.keys(p.agents);
578
1990
  }
579
1991
 
580
- // Initialize session
581
- const result = initSession(projectDir, {
582
- mode: 'discover',
583
- projectName: name,
584
- isGreenfield,
585
- language,
586
- });
587
-
588
- if (!result.created) {
589
- return errorResult(`Failed to initialize session: ${result.error}`, 'INIT_FAILED');
590
- }
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
+ });
591
2019
 
592
- // Set workflow in session
593
- await updateSession(projectDir, { workflow });
2020
+ if (!result.created) {
2021
+ return errorResult(`Failed to initialize session: ${result.error}`, 'INIT_FAILED');
2022
+ }
594
2023
 
595
- // Determine first agent
596
- const firstAgent = isGreenfield ? 'greenfield-wu' : 'brownfield-wu';
597
- 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
+ }
598
2028
 
599
- // Update session with first agent
600
- 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;
601
2039
 
602
2040
  // Write session lock
603
2041
  try {
@@ -674,6 +2112,27 @@ async function handleStatus(projectDir) {
674
2112
  agentModels[agentDef.name] = resolveAgentModel(agentDef.name, projectDir);
675
2113
  }
676
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
+
677
2136
  return {
678
2137
  session: summary.summary || summary,
679
2138
  pipeline: progress,
@@ -683,6 +2142,15 @@ async function handleStatus(projectDir) {
683
2142
  agents: session.agents || {},
684
2143
  agent_models: agentModels,
685
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
+ },
686
2154
  user_level: session.user_level || 'auto',
687
2155
  execution_mode: session.execution_mode || 'interactive',
688
2156
  };
@@ -743,6 +2211,16 @@ async function handleDeviation(projectDir, args) {
743
2211
  }
744
2212
 
745
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
+ }
746
2224
  }
747
2225
 
748
2226
  return {
@@ -760,6 +2238,7 @@ async function handleExit(projectDir, args) {
760
2238
  }
761
2239
 
762
2240
  // Save current state
2241
+ recordEvent(projectDir, EventType.SESSION_ENDED, session.current_agent || 'orchestrator', { mode: session.mode });
763
2242
  await updateSession(projectDir, {});
764
2243
 
765
2244
  // Release session ownership
@@ -790,8 +2269,16 @@ async function handleProviders(projectDir) {
790
2269
  agentModels[agentDef.name] = resolveAgentModel(agentDef.name, projectDir);
791
2270
  }
792
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
+
793
2280
  return {
794
- primary_provider: 'claude',
2281
+ primary_provider: primaryProvider,
795
2282
  agent_models: agentModels,
796
2283
  };
797
2284
  }
@@ -971,7 +2458,7 @@ async function handleScan(projectDir, args) {
971
2458
 
972
2459
  const TEAM_CONFIGS = {
973
2460
  planning: {
974
- members: ['detail', 'architect', 'ux'],
2461
+ members: ['detail', 'architect', 'ux', 'qa-planning'],
975
2462
  slug: 'pln',
976
2463
  templateFile: 'team-planning-tasks.yaml',
977
2464
  },
@@ -996,14 +2483,22 @@ function generateTeamId(slug) {
996
2483
  * (Gemini) or spawn_autonomous (Codex) when the active provider is not claude.
997
2484
  */
998
2485
  function isAgentTeamsEnabled(projectDir) {
999
- const configPath = join(projectDir, 'chati.dev', 'config.yaml');
2486
+ const configPath = join(projectDir, resolveFrameworkDir(projectDir), 'config.yaml');
1000
2487
  if (!existsSync(configPath)) return false;
1001
2488
  const raw = readFileSync(configPath, 'utf-8');
1002
2489
 
1003
2490
  // Feature flag check
1004
2491
  const flagMatch = raw.match(/agent_teams:\s*(true|false)/);
1005
- const flagOn = flagMatch ? flagMatch[1] === 'true' : false;
1006
- if (!flagOn) return 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
+ }
1007
2502
 
1008
2503
  // Provider gate — Agent Teams is Claude-only.
1009
2504
  // Read primary_provider from session.yaml (preferred) or active_provider
@@ -1036,7 +2531,7 @@ async function handleSpawnTeam(projectDir, args) {
1036
2531
  const teamDir = join(projectDir, '.chati', 'teams', teamId);
1037
2532
  const mailboxDir = join(teamDir, 'mailbox');
1038
2533
  const taskListPath = join(teamDir, 'tasks.yaml');
1039
- const templatePath = join(projectDir, 'chati.dev', 'templates', config.templateFile);
2534
+ const templatePath = join(projectDir, resolveFrameworkDir(projectDir), 'templates', config.templateFile);
1040
2535
 
1041
2536
  // Create team directories
1042
2537
  mkdirSync(mailboxDir, { recursive: true });
@@ -1047,7 +2542,7 @@ async function handleSpawnTeam(projectDir, args) {
1047
2542
 
1048
2543
  // For build teams: dynamically populate tasks from tasks.md (Article XXI §9)
1049
2544
  if (teamType === 'build') {
1050
- const tasksArtifact = join(projectDir, 'chati.dev', 'artifacts', '6-Tasks', 'tasks.md');
2545
+ const tasksArtifact = join(projectDir, 'artifacts', '6-Tasks', 'tasks.md');
1051
2546
  if (existsSync(tasksArtifact)) {
1052
2547
  const tasksContent = readFileSync(tasksArtifact, 'utf-8');
1053
2548
  // Extract task IDs (pattern: T{phase}.{seq})
@@ -1142,6 +2637,7 @@ async function handleSpawnTeam(projectDir, args) {
1142
2637
  });
1143
2638
 
1144
2639
  await updateSession(projectDir, session);
2640
+ recordEvent(projectDir, EventType.AGENT_ACTIVATED, `team:${teamType}`, { teamId, members: config.members });
1145
2641
  }
1146
2642
  } catch { /* non-critical: session update may fail, team can still spawn */ }
1147
2643
 
@@ -1262,7 +2758,7 @@ async function handleTeamDissolve(projectDir, args) {
1262
2758
  let correctionCyclesOk = true;
1263
2759
  let maxCorrectionCycles = 2; // default per constitution
1264
2760
  try {
1265
- const configPath = join(projectDir, 'chati.dev', 'config.yaml');
2761
+ const configPath = join(projectDir, resolveFrameworkDir(projectDir), 'config.yaml');
1266
2762
  if (existsSync(configPath)) {
1267
2763
  const configRaw = readFileSync(configPath, 'utf-8');
1268
2764
  const maxMatch = configRaw.match(/team_correction_cycles_max:\s*(\d+)/);
@@ -1286,7 +2782,7 @@ async function handleTeamDissolve(projectDir, args) {
1286
2782
  if (!mailboxClean) gateFailures.push('unresolved_mailbox_messages');
1287
2783
  if (!noBlockers) gateFailures.push('open_blockers');
1288
2784
  if (!correctionCyclesOk) gateFailures.push('correction_cycles_exceeded');
1289
- if (teamScore < 90) gateFailures.push('team_score_below_threshold');
2785
+ if (teamScore < tierThreshold) gateFailures.push('team_score_below_threshold');
1290
2786
 
1291
2787
  // Update session
1292
2788
  try {
@@ -1341,12 +2837,296 @@ async function handleTeamDissolve(projectDir, args) {
1341
2837
  mailbox_clean: mailboxClean,
1342
2838
  no_open_blockers: noBlockers,
1343
2839
  correction_cycles_ok: correctionCyclesOk,
1344
- team_score_above_threshold: teamScore >= 90,
2840
+ team_score_above_threshold: teamScore >= tierThreshold,
1345
2841
  },
1346
2842
  },
1347
2843
  };
1348
2844
  }
1349
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
+
1350
3130
  // ---------------------------------------------------------------------------
1351
3131
  // Main entry point
1352
3132
  // ---------------------------------------------------------------------------
@@ -1404,6 +3184,24 @@ export async function runOrchestrate(subCommand, argv, projectDir) {
1404
3184
  case 'team-dissolve':
1405
3185
  output(await handleTeamDissolve(projectDir, args));
1406
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;
1407
3205
  default:
1408
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'));
1409
3207
  break;