chati-dev 4.0.10 → 4.1.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 (107) hide show
  1. package/README.md +5 -0
  2. package/bin/chati.js +23 -2
  3. package/framework/agents/build/dev.md +43 -10
  4. package/framework/agents/discover/brief.md +38 -8
  5. package/framework/agents/discover/brownfield-wu.md +44 -3
  6. package/framework/agents/discover/greenfield-wu.md +14 -0
  7. package/framework/agents/plan/architect.md +6 -0
  8. package/framework/agents/plan/detail.md +25 -0
  9. package/framework/agents/plan/tasks.md +29 -1
  10. package/framework/agents/plan/ux-brand-architect.md +215 -0
  11. package/framework/agents/plan/ux-component-engineer.md +289 -0
  12. package/framework/agents/plan/ux-researcher.md +166 -0
  13. package/framework/agents/plan/ux.md +126 -1008
  14. package/framework/agents/quality/qa-implementation.md +121 -22
  15. package/framework/agents/quality/qa-planning.md +18 -0
  16. package/framework/config.yaml +15 -4
  17. package/framework/constitution.md +8 -2
  18. package/framework/context/root.md +1 -1
  19. package/framework/data/entity-registry.yaml +59 -3
  20. package/framework/hooks/constitution-guard.js +67 -1
  21. package/framework/hooks/license-guard.js +4 -4
  22. package/framework/hooks/model-governance.js +2 -1
  23. package/framework/hooks/prism-engine.js +74 -6
  24. package/framework/hooks/read-protection.js +1 -1
  25. package/framework/hooks/session-digest.js +159 -7
  26. package/framework/hooks/settings.json +16 -4
  27. package/framework/hooks/style-guard.js +134 -0
  28. package/framework/hooks/undercover-guard.js +220 -0
  29. package/framework/intelligence/context-engine.md +21 -0
  30. package/framework/intelligence/hooks-performance.md +54 -0
  31. package/framework/intelligence/memory-layer.md +47 -0
  32. package/framework/migrations/v4.0-to-v4.1.yaml +165 -0
  33. package/framework/orchestrator/chati.md +327 -1067
  34. package/framework/templates/brandbook-html-tmpl.md +107 -0
  35. package/framework/templates/session-memory-tmpl.yaml +68 -0
  36. package/package.json +1 -1
  37. package/src/autonomy/build-state.js +1 -1
  38. package/src/autonomy/worktree-manager.js +13 -13
  39. package/src/config/agent-customizer.js +1 -1
  40. package/src/config/gemini-hooks-generator.js +6 -6
  41. package/src/config/ide-configs.js +1 -1
  42. package/src/context/bracket-tracker.js +25 -4
  43. package/src/context/engine.js +37 -7
  44. package/src/context/formatter.js +45 -1
  45. package/src/context/index.js +3 -3
  46. package/src/dashboard/data-reader.js +7 -7
  47. package/src/decision/engine.js +2 -2
  48. package/src/decision/registry-healer.js +1 -1
  49. package/src/decision/registry-updater.js +2 -2
  50. package/src/extensions/loader.js +1 -1
  51. package/src/gates/g1-planning-complete.js +1 -1
  52. package/src/gates/g2-qa-planning.js +1 -1
  53. package/src/gates/g3-implementation.js +4 -4
  54. package/src/gates/g4-qa-implementation.js +2 -2
  55. package/src/health/auto-fix.js +3 -3
  56. package/src/health/engine.js +2 -2
  57. package/src/installer/brownfield-upgrader.js +3 -3
  58. package/src/installer/manifest.js +1 -1
  59. package/src/installer/transaction.js +1 -1
  60. package/src/installer/validator.js +2 -2
  61. package/src/intelligence/decision-engine.js +1 -1
  62. package/src/intelligence/memory-manager.js +1 -1
  63. package/src/intelligence/registry-manager.js +2 -2
  64. package/src/intelligence/timeline.js +1 -1
  65. package/src/license/client.js +20 -2
  66. package/src/license/commands.js +7 -7
  67. package/src/memory/agent-memory.js +3 -3
  68. package/src/memory/daily-digest.js +170 -0
  69. package/src/memory/dream.js +254 -0
  70. package/src/memory/gotchas.js +2 -2
  71. package/src/memory/index.js +18 -0
  72. package/src/memory/magic-docs.js +98 -0
  73. package/src/memory/memory-extractor.js +163 -0
  74. package/src/memory/session-digest.js +144 -6
  75. package/src/merger/yaml-merger.js +1 -1
  76. package/src/orchestrator/cli.js +980 -0
  77. package/src/orchestrator/handoff-engine.js +25 -4
  78. package/src/orchestrator/index.js +9 -0
  79. package/src/orchestrator/session-manager.js +1 -1
  80. package/src/preview/detector.js +3 -3
  81. package/src/preview/launcher.js +2 -2
  82. package/src/quality/metrics-collector.js +1 -1
  83. package/src/quality/test-runner.js +2 -4
  84. package/src/scanning/density-scanner.js +51 -0
  85. package/src/scanning/env-scanner.js +97 -0
  86. package/src/scanning/index.js +7 -0
  87. package/src/scanning/leakage-scanner.js +54 -0
  88. package/src/scanning/placeholder-scanner.js +40 -0
  89. package/src/scanning/security-scanner.js +94 -0
  90. package/src/security/bash-security.js +335 -0
  91. package/src/security/index.js +9 -0
  92. package/src/telemetry/config.js +3 -5
  93. package/src/telemetry/sender.js +19 -17
  94. package/src/terminal/cli-registry.js +1 -1
  95. package/src/terminal/cost-tracker.js +1 -2
  96. package/src/terminal/prompt-builder.js +11 -27
  97. package/src/terminal/rate-limiter.js +1 -2
  98. package/src/terminal/run-agent.js +3 -3
  99. package/src/terminal/spawner.js +41 -4
  100. package/src/upgrade/checker.js +2 -2
  101. package/src/upgrade/migrator.js +1 -1
  102. package/src/utils/config-parser.js +1 -1
  103. package/src/utils/file-lock.js +3 -3
  104. package/src/utils/provider-limits.js +35 -4
  105. package/src/wizard/i18n.js +2 -2
  106. package/src/wizard/index.js +2 -1
  107. package/src/wizard/questions.js +4 -2
@@ -0,0 +1,980 @@
1
+ /**
2
+ * @fileoverview CLI handler for the `orchestrate` command.
3
+ *
4
+ * Exposes deterministic orchestration logic as JSON-outputting sub-commands.
5
+ * The thin orchestrator prompt calls these instead of reimplementing pipeline
6
+ * logic in natural language.
7
+ *
8
+ * Usage:
9
+ * node bin/chati.js orchestrate <subcommand> [--flag value ...]
10
+ *
11
+ * Sub-commands: next, advance, init, validate-handoff, status, deviation, exit
12
+ */
13
+
14
+ import {
15
+ loadSession, initSession, updateSession, recordAgentCompletion,
16
+ recordModeTransition, getSessionSummary, validateSession,
17
+ releaseSession,
18
+ } from './index.js';
19
+ import {
20
+ getNextAgent, getAgentDefinition, AGENT_PIPELINE,
21
+ } from './index.js';
22
+ import {
23
+ initPipeline, initQuickFlowPipeline, initStandardFlowPipeline,
24
+ advancePipeline, checkPhaseTransition, getPipelineProgress,
25
+ isPipelineComplete, confirmPreview,
26
+ } from './index.js';
27
+ import {
28
+ validateHandoffIntegrity, loadHandoffContext, getHandoffHistory,
29
+ } from './index.js';
30
+ import { analyzeDeviationImpact, applyDeviation } from './index.js';
31
+ import { detectQuickFlow, detectStandardFlow } from './index.js';
32
+ import { AGENT_FILE_MAP } from '../terminal/prompt-builder.js';
33
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
34
+ import { join, resolve } from 'path';
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Constants
38
+ // ---------------------------------------------------------------------------
39
+
40
+ const INTERACTIVE_AGENTS = ['greenfield-wu', 'brownfield-wu', 'brief'];
41
+
42
+ const LOCK_START = '<!-- chati-lock:start -->';
43
+ const LOCK_END = '<!-- chati-lock:end -->';
44
+
45
+ const RESUME_MESSAGES = {
46
+ en: 'Session saved. Type /chati anytime to resume.',
47
+ pt: 'Sessao salva. Digite /chati para retomar.',
48
+ es: 'Sesion guardada. Escribe /chati para reanudar.',
49
+ fr: 'Session sauvee. Tapez /chati pour reprendre.',
50
+ };
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Helpers
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function parseArgs(argv) {
57
+ const args = {};
58
+ for (let i = 0; i < argv.length; i++) {
59
+ const arg = argv[i];
60
+ if (arg.startsWith('--')) {
61
+ const key = arg.slice(2);
62
+ const next = argv[i + 1];
63
+ if (next && !next.startsWith('--')) {
64
+ args[key] = next;
65
+ i++;
66
+ } else {
67
+ args[key] = 'true';
68
+ }
69
+ }
70
+ }
71
+ return args;
72
+ }
73
+
74
+ function output(data) {
75
+ process.stdout.write(JSON.stringify(data, null, 2) + '\n');
76
+ }
77
+
78
+ function errorResult(message, code) {
79
+ return { action: 'error', error: message, code: code || 'UNKNOWN' };
80
+ }
81
+
82
+ /**
83
+ * Build a pipeline state object from session data.
84
+ * The session-manager and pipeline-manager use slightly different shapes;
85
+ * this bridges the two.
86
+ */
87
+ function sessionToPipelineState(session, projectDir) {
88
+ const agents = {};
89
+ if (session.agents) {
90
+ for (const [name, data] of Object.entries(session.agents)) {
91
+ agents[name] = {
92
+ status: data.status || 'pending',
93
+ score: data.score ?? null,
94
+ startedAt: data.started_at || null,
95
+ completedAt: data.completed_at || null,
96
+ };
97
+ }
98
+ }
99
+
100
+ return {
101
+ phase: session.mode || session.project?.state || 'discover',
102
+ isGreenfield: (session.project_type || session.project?.type) === 'greenfield',
103
+ sessionId: session.session_id || `${Date.now()}`,
104
+ targetDir: projectDir,
105
+ chatiVersion: session.version || 'unknown',
106
+ startedAt: session.started_at || new Date().toISOString(),
107
+ completedAt: null,
108
+ agents,
109
+ completedAgents: session.completed_agents || [],
110
+ currentAgent: session.current_agent || null,
111
+ modeTransitions: session.mode_transitions || [],
112
+ history: [],
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Build the spawn command string for an autonomous agent.
118
+ */
119
+ function buildSpawnCommand(agent, projectDir, previousAgent, provider, timeout) {
120
+ const parts = [
121
+ 'node', 'packages/chati-dev/src/terminal/run-agent.js',
122
+ '--agent', agent,
123
+ '--task-id', `${agent}-task`,
124
+ '--project-dir', resolve(projectDir),
125
+ '--previous-agent', previousAgent || 'none',
126
+ '--provider', provider || 'claude',
127
+ '--timeout', String(timeout || 600000),
128
+ ];
129
+ return parts.join(' ');
130
+ }
131
+
132
+ /**
133
+ * Build the parallel spawn command string.
134
+ */
135
+ function buildParallelSpawnCommand(agents, projectDir, previousAgent, provider, timeout) {
136
+ const taskIds = agents.map(a => `${a}-task`).join(',');
137
+ const parts = [
138
+ 'node', 'packages/chati-dev/src/terminal/run-parallel.js',
139
+ '--agents', agents.join(','),
140
+ '--task-ids', taskIds,
141
+ '--project-dir', resolve(projectDir),
142
+ '--previous-agent', previousAgent || 'none',
143
+ '--provider', provider || 'claude',
144
+ '--timeout', String(timeout || 900000),
145
+ ];
146
+ return parts.join(' ');
147
+ }
148
+
149
+ /**
150
+ * Model map — canonical model tier per agent.
151
+ * Matches framework/hooks/model-governance.js AGENT_MODELS.
152
+ */
153
+ const AGENT_MODEL_MAP = {
154
+ 'greenfield-wu': { provider: 'claude', model: 'haiku', upgrade: 'sonnet if multi-stack or enterprise' },
155
+ 'brownfield-wu': { provider: 'claude', model: 'opus', upgrade: 'no downgrade' },
156
+ brief: { provider: 'claude', model: 'sonnet', upgrade: 'opus if enterprise or 10+ integrations' },
157
+ detail: { provider: 'claude', model: 'opus', upgrade: 'no downgrade' },
158
+ architect: { provider: 'claude', model: 'opus', upgrade: 'no downgrade' },
159
+ ux: { provider: 'claude', model: 'sonnet', upgrade: 'opus if design system from scratch' },
160
+ phases: { provider: 'claude', model: 'sonnet', upgrade: 'opus if 20+ requirements' },
161
+ tasks: { provider: 'claude', model: 'sonnet', upgrade: 'opus if 50+ tasks' },
162
+ 'qa-planning': { provider: 'claude', model: 'opus', upgrade: 'no downgrade' },
163
+ dev: { provider: 'claude', model: 'opus', upgrade: 'no downgrade' },
164
+ 'qa-implementation': { provider: 'claude', model: 'opus', upgrade: 'no downgrade' },
165
+ devops: { provider: 'claude', model: 'sonnet', upgrade: 'opus if multi-environment or IaC' },
166
+ };
167
+
168
+ /**
169
+ * Resolve provider and model for an agent.
170
+ * Checks config.yaml for agent_overrides, falls back to AGENT_MODEL_MAP defaults.
171
+ */
172
+ function resolveAgentModel(agent, projectDir) {
173
+ const defaults = AGENT_MODEL_MAP[agent] || { provider: 'claude', model: 'sonnet', upgrade: '' };
174
+
175
+ // Check config.yaml for agent_overrides
176
+ try {
177
+ const configPath = join(projectDir, 'chati.dev', 'config.yaml');
178
+ if (existsSync(configPath)) {
179
+ const configRaw = readFileSync(configPath, 'utf-8');
180
+ // Simple YAML parsing for agent_overrides section
181
+ const overrideMatch = configRaw.match(new RegExp(`${agent}:\\s*\\n\\s+provider:\\s*(\\w+)`, 'm'));
182
+ if (overrideMatch) {
183
+ return { ...defaults, provider: overrideMatch[1] };
184
+ }
185
+ }
186
+ } catch { /* expected: config may not exist or be malformed */ }
187
+
188
+ return defaults;
189
+ }
190
+
191
+ /**
192
+ * Estimate context bracket from pipeline progress.
193
+ */
194
+ function estimateContextBracket(completedCount, totalCount) {
195
+ const progress = totalCount > 0 ? completedCount / totalCount : 0;
196
+ // Simple heuristic: context degrades with pipeline progress + conversation length
197
+ if (progress < 0.3) return { bracket: 'FRESH', remaining: 85 };
198
+ if (progress < 0.6) return { bracket: 'MODERATE', remaining: 60 };
199
+ if (progress < 0.85) return { bracket: 'DEPLETED', remaining: 30 };
200
+ return { bracket: 'CRITICAL', remaining: 10 };
201
+ }
202
+
203
+ /**
204
+ * Write session lock block to CLAUDE.local.md.
205
+ */
206
+ function writeSessionLock(projectDir, currentAgent) {
207
+ const localMdPath = join(projectDir, 'CLAUDE.local.md');
208
+ let content = '';
209
+ if (existsSync(localMdPath)) {
210
+ content = readFileSync(localMdPath, 'utf-8');
211
+ }
212
+
213
+ // Remove existing lock if present
214
+ const startIdx = content.indexOf(LOCK_START);
215
+ const endIdx = content.indexOf(LOCK_END);
216
+ if (startIdx !== -1 && endIdx !== -1) {
217
+ content = content.slice(0, startIdx) + content.slice(endIdx + LOCK_END.length);
218
+ }
219
+
220
+ const lockBlock = `${LOCK_START}
221
+ ## Session Lock -- ACTIVE
222
+
223
+ **Chati.dev session is ACTIVE.** Follow these rules for EVERY message:
224
+
225
+ 1. Read \`chati.dev/orchestrator/chati.md\` and follow its routing logic
226
+ 2. Route ALL user messages through the current agent: \`${currentAgent}\`
227
+ 3. NEVER respond outside of the Chati.dev system
228
+ 4. The ONLY way to exit is via \`/chati exit\`, \`/chati stop\`, or \`/chati quit\`
229
+ ${LOCK_END}
230
+ `;
231
+
232
+ content = content.trimEnd() + '\n\n' + lockBlock;
233
+ writeFileSync(localMdPath, content, 'utf-8');
234
+ }
235
+
236
+ /**
237
+ * Remove session lock block from CLAUDE.local.md.
238
+ */
239
+ function removeSessionLock(projectDir, resumeMsg) {
240
+ const localMdPath = join(projectDir, 'CLAUDE.local.md');
241
+ if (!existsSync(localMdPath)) return;
242
+
243
+ let content = readFileSync(localMdPath, 'utf-8');
244
+
245
+ const startIdx = content.indexOf(LOCK_START);
246
+ const endIdx = content.indexOf(LOCK_END);
247
+ if (startIdx !== -1 && endIdx !== -1) {
248
+ content = content.slice(0, startIdx) + content.slice(endIdx + LOCK_END.length);
249
+ }
250
+
251
+ if (resumeMsg) {
252
+ content = content.trimEnd() + `\n\n## Session Paused\n\n${resumeMsg}\n`;
253
+ }
254
+
255
+ writeFileSync(localMdPath, content.trimEnd() + '\n', 'utf-8');
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Sub-command handlers
260
+ // ---------------------------------------------------------------------------
261
+
262
+ async function handleNext(projectDir) {
263
+ const { loaded, session } = loadSession(projectDir);
264
+ if (!loaded || !session) {
265
+ return { action: 'setup', status_summary: 'No session found. Project needs initialization.' };
266
+ }
267
+
268
+ const validation = validateSession(projectDir);
269
+ if (!validation.valid) {
270
+ return errorResult(`Session invalid: ${validation.reason}`, 'SESSION_INVALID');
271
+ }
272
+
273
+ const pipelineState = sessionToPipelineState(session, projectDir);
274
+
275
+ if (isPipelineComplete(pipelineState)) {
276
+ return {
277
+ action: 'complete',
278
+ pipeline_progress: getPipelineProgress(pipelineState),
279
+ session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode },
280
+ status_summary: 'Pipeline complete. All agents finished.',
281
+ };
282
+ }
283
+
284
+ // Check for in-progress agent (resume scenario)
285
+ const currentAgent = session.current_agent || null;
286
+ const completedAgents = session.completed_agents || [];
287
+ if (currentAgent && !completedAgents.includes(currentAgent)) {
288
+ const agent = currentAgent;
289
+ const agentStatus = session.agents?.[agent]?.status;
290
+ if (agentStatus === 'in_progress' || (agentStatus !== 'completed' && agentStatus !== 'skipped')) {
291
+ const agentFile = AGENT_FILE_MAP[agent] || null;
292
+ const agentDef = getAgentDefinition(agent);
293
+ const isInteractive = INTERACTIVE_AGENTS.includes(agent);
294
+
295
+ return {
296
+ action: isInteractive ? 'activate_interactive' : 'spawn_autonomous',
297
+ agent,
298
+ agent_file: agentFile,
299
+ phase: agentDef?.phase || session.mode,
300
+ spawn_command: isInteractive ? null : buildSpawnCommand(agent, projectDir, session.last_handoff || 'none', resolveAgentModel(agent, projectDir).provider, 600000),
301
+ parallel_spawn_command: null,
302
+ parallel_agents: [],
303
+ handoff_status: { valid: true, missing: [], warnings: ['Resuming in-progress agent'] },
304
+ gate_status: { canAdvance: true, reason: 'Agent already in progress' },
305
+ model_info: resolveAgentModel(agent, projectDir),
306
+ context_bracket: estimateContextBracket(completedAgents.length, AGENT_PIPELINE.length),
307
+ pipeline_progress: getPipelineProgress(pipelineState),
308
+ session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
309
+ status_summary: `Resuming ${agent} (in progress).`,
310
+ };
311
+ }
312
+ }
313
+
314
+ // Determine last completed agent
315
+ const lastAgent = session.last_handoff || session.current_agent || null;
316
+
317
+ // If no agent has started yet, route to first agent
318
+ if (!lastAgent || completedAgents.length === 0) {
319
+ const projectType = session.project_type || session.project?.type || 'greenfield';
320
+ const firstAgent = projectType === 'brownfield' ? 'brownfield-wu' : 'greenfield-wu';
321
+ const agentFile = AGENT_FILE_MAP[firstAgent] || null;
322
+
323
+ return {
324
+ action: 'activate_interactive',
325
+ agent: firstAgent,
326
+ agent_file: agentFile,
327
+ phase: 'discover',
328
+ spawn_command: null,
329
+ parallel_spawn_command: null,
330
+ parallel_agents: [],
331
+ handoff_status: { valid: true, missing: [], warnings: [] },
332
+ gate_status: { canAdvance: true, reason: 'First agent in pipeline' },
333
+ model_info: resolveAgentModel(firstAgent, projectDir),
334
+ context_bracket: estimateContextBracket(0, AGENT_PIPELINE.length),
335
+ pipeline_progress: getPipelineProgress(pipelineState),
336
+ session: { language: session.language, project_type: projectType, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
337
+ status_summary: `Starting pipeline with ${firstAgent}.`,
338
+ };
339
+ }
340
+
341
+ // Get next agent
342
+ const nextInfo = getNextAgent(lastAgent, completedAgents);
343
+
344
+ if (!nextInfo || !nextInfo.next) {
345
+ return {
346
+ action: 'complete',
347
+ pipeline_progress: getPipelineProgress(pipelineState),
348
+ session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode },
349
+ status_summary: 'Pipeline complete. All agents finished.',
350
+ };
351
+ }
352
+
353
+ const nextAgent = nextInfo.next;
354
+ const agentFile = AGENT_FILE_MAP[nextAgent] || null;
355
+ const agentDef = getAgentDefinition(nextAgent);
356
+ const isInteractive = INTERACTIVE_AGENTS.includes(nextAgent);
357
+
358
+ // Validate handoff from previous agent
359
+ let handoffStatus;
360
+ try {
361
+ const handoffCtx = await loadHandoffContext(projectDir, lastAgent);
362
+ if (handoffCtx.context.handoff) {
363
+ handoffStatus = validateHandoffIntegrity(handoffCtx.context.handoff, nextAgent);
364
+ } else {
365
+ handoffStatus = { valid: false, missing: ['handoff document'], warnings: [`No handoff found from ${lastAgent}`] };
366
+ }
367
+ } catch { /* expected: handoff may not exist for first agent */
368
+ handoffStatus = { valid: true, missing: [], warnings: ['Could not load handoff (may be first agent)'] };
369
+ }
370
+
371
+ // Check quality gate
372
+ const gateStatus = checkPhaseTransition(pipelineState);
373
+
374
+ // Resolve model for next agent
375
+ const modelInfo = resolveAgentModel(nextAgent, projectDir);
376
+
377
+ // Check for parallel group
378
+ let action, spawnCommand = null, parallelSpawnCommand = null, parallelAgents = [];
379
+ if (nextInfo.isParallel && nextInfo.group.length > 1) {
380
+ action = 'spawn_parallel';
381
+ parallelAgents = nextInfo.group;
382
+ parallelSpawnCommand = buildParallelSpawnCommand(nextInfo.group, projectDir, lastAgent, modelInfo.provider, 900000);
383
+ } else if (isInteractive) {
384
+ action = 'activate_interactive';
385
+ } else {
386
+ action = 'spawn_autonomous';
387
+ spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000);
388
+ }
389
+
390
+ const progress = getPipelineProgress(pipelineState);
391
+ const bracket = estimateContextBracket(completedAgents.length, AGENT_PIPELINE.length);
392
+
393
+ return {
394
+ action,
395
+ agent: nextAgent,
396
+ agent_file: agentFile,
397
+ phase: agentDef?.phase || session.mode,
398
+ spawn_command: spawnCommand,
399
+ parallel_spawn_command: parallelSpawnCommand,
400
+ parallel_agents: parallelAgents,
401
+ handoff_status: handoffStatus,
402
+ gate_status: gateStatus,
403
+ model_info: modelInfo,
404
+ context_bracket: bracket,
405
+ pipeline_progress: progress,
406
+ session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
407
+ status_summary: `${lastAgent} completed. Next: ${nextAgent} (${action.replace('_', ' ')}, ${agentDef?.phase || 'unknown'} phase).`,
408
+ };
409
+ }
410
+
411
+ async function handleAdvance(projectDir, args) {
412
+ const agent = args.agent;
413
+ const score = parseInt(args.score, 10);
414
+ const status = args.status || 'completed';
415
+ const decision = args.decision || null;
416
+
417
+ if (!agent) return errorResult('Missing --agent flag', 'MISSING_AGENT');
418
+ if (isNaN(score)) return errorResult('Missing or invalid --score flag', 'MISSING_SCORE');
419
+
420
+ const { loaded, session } = loadSession(projectDir);
421
+ if (!loaded || !session) return errorResult('No session found', 'NO_SESSION');
422
+
423
+ // Handle user_preview decisions
424
+ if (decision) {
425
+ const pipelineState = sessionToPipelineState(session, projectDir);
426
+ const previewResult = confirmPreview(pipelineState, decision);
427
+
428
+ await updateSession(projectDir, {
429
+ current_agent: previewResult.nextAgent,
430
+ mode: previewResult.needsModeSwitch ? 'deploy' : session.mode,
431
+ });
432
+
433
+ if (previewResult.needsModeSwitch) {
434
+ await recordModeTransition(projectDir, {
435
+ from: session.mode,
436
+ to: 'deploy',
437
+ trigger: `user_preview: ${decision}`,
438
+ });
439
+ }
440
+
441
+ const next = await handleNext(projectDir);
442
+ return { advanced: true, agent_completed: agent, score, decision, phase_transition: { triggered: previewResult.needsModeSwitch }, next };
443
+ }
444
+
445
+ // Record completion
446
+ await recordAgentCompletion(projectDir, { agent, status, score });
447
+
448
+ // Advance pipeline
449
+ const pipelineState = sessionToPipelineState(session, projectDir);
450
+ pipelineState.completedAgents = [...(session.completed_agents || []), agent];
451
+ pipelineState.agents[agent] = { status: 'completed', score, startedAt: null, completedAt: new Date().toISOString() };
452
+
453
+ const advanceResult = advancePipeline(pipelineState, agent, { score });
454
+
455
+ // Update session with new state
456
+ const updates = {
457
+ current_agent: advanceResult.nextAgent || '',
458
+ last_handoff: agent,
459
+ };
460
+
461
+ // Handle phase transition
462
+ let phaseTransition = { triggered: false };
463
+ if (advanceResult.needsModeSwitch) {
464
+ const newPhase = advanceResult.state?.phase || session.mode;
465
+ updates.mode = newPhase;
466
+ if (session.project) {
467
+ updates.project = { ...session.project, state: newPhase };
468
+ }
469
+ phaseTransition = { triggered: true, from: session.mode, to: newPhase };
470
+
471
+ await recordModeTransition(projectDir, {
472
+ from: session.mode,
473
+ to: newPhase,
474
+ trigger: `${agent} completed with score ${score}`,
475
+ });
476
+ }
477
+
478
+ await updateSession(projectDir, updates);
479
+
480
+ // Check if user_preview is needed
481
+ if (advanceResult.nextAction === 'user_preview') {
482
+ return {
483
+ advanced: true,
484
+ agent_completed: agent,
485
+ score,
486
+ phase_transition: phaseTransition,
487
+ next: {
488
+ action: 'user_preview',
489
+ agent: agent,
490
+ pipeline_progress: getPipelineProgress(advanceResult.state || pipelineState),
491
+ session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode },
492
+ status_summary: `${agent} completed with score ${score}%. User preview required before deploy.`,
493
+ },
494
+ };
495
+ }
496
+
497
+ const next = await handleNext(projectDir);
498
+ return { advanced: true, agent_completed: agent, score, phase_transition: phaseTransition, next };
499
+ }
500
+
501
+ async function handleInit(projectDir, args) {
502
+ // License validation at init time (moved from per-prompt UserPromptSubmit hook)
503
+ try {
504
+ const licensePath = join(process.env.HOME || '', '.chati-dev', 'license.yaml');
505
+ if (existsSync(licensePath)) {
506
+ const licenseRaw = readFileSync(licensePath, 'utf-8');
507
+ const statusMatch = licenseRaw.match(/status:\s*["']?(\w+)/);
508
+ const checkedMatch = licenseRaw.match(/checked_at:\s*["']?([^"'\n]+)/);
509
+ if (statusMatch && statusMatch[1] !== 'VALID') {
510
+ return errorResult(`License status: ${statusMatch[1]}. Run: npx chati-dev activate --key=YOUR-KEY`, 'LICENSE_INVALID');
511
+ }
512
+ // Check cache freshness (24h TTL)
513
+ if (checkedMatch) {
514
+ const age = Date.now() - new Date(checkedMatch[1]).getTime();
515
+ if (age > 24 * 60 * 60 * 1000) {
516
+ process.stderr.write('[chati] license cache expired (24h) — will refresh on next API call\n');
517
+ }
518
+ }
519
+ }
520
+ // No license file = allow (fail-open, same as license-guard behavior)
521
+ } catch { /* expected: license file may not exist */ }
522
+
523
+ const type = args.type || 'greenfield';
524
+ const language = args.language || 'en';
525
+ const name = args.name || '';
526
+ const workflow = args.workflow || 'full';
527
+
528
+ const isGreenfield = type === 'greenfield';
529
+
530
+ // Initialize pipeline state based on workflow
531
+ let pipelineAgents;
532
+ if (workflow === 'quick') {
533
+ const p = initQuickFlowPipeline({ isGreenfield, targetDir: projectDir });
534
+ pipelineAgents = Object.keys(p.agents);
535
+ } else if (workflow === 'standard') {
536
+ const p = initStandardFlowPipeline({ isGreenfield, targetDir: projectDir });
537
+ pipelineAgents = Object.keys(p.agents);
538
+ } else {
539
+ const p = initPipeline({ isGreenfield, targetDir: projectDir });
540
+ pipelineAgents = Object.keys(p.agents);
541
+ }
542
+
543
+ // Initialize session
544
+ const result = initSession(projectDir, {
545
+ mode: 'discover',
546
+ projectName: name,
547
+ isGreenfield,
548
+ language,
549
+ });
550
+
551
+ if (!result.created) {
552
+ return errorResult(`Failed to initialize session: ${result.error}`, 'INIT_FAILED');
553
+ }
554
+
555
+ // Set workflow in session
556
+ await updateSession(projectDir, { workflow });
557
+
558
+ // Determine first agent
559
+ const firstAgent = isGreenfield ? 'greenfield-wu' : 'brownfield-wu';
560
+ const firstAgentFile = AGENT_FILE_MAP[firstAgent] || null;
561
+
562
+ // Update session with first agent
563
+ await updateSession(projectDir, { current_agent: firstAgent });
564
+
565
+ // Write session lock
566
+ try {
567
+ writeSessionLock(projectDir, firstAgent);
568
+ } catch (err) {
569
+ // Non-blocking — lock is nice-to-have, not critical
570
+ process.stderr.write(`[chati] cli lock-write: ${err.message}\n`);
571
+ }
572
+
573
+ return {
574
+ initialized: true,
575
+ session_path: '.chati/session.yaml',
576
+ workflow,
577
+ pipeline_agents: pipelineAgents,
578
+ first_agent: firstAgent,
579
+ first_agent_file: firstAgentFile,
580
+ lock_written: true,
581
+ language,
582
+ project_type: type,
583
+ };
584
+ }
585
+
586
+ async function handleValidateHandoff(projectDir, args) {
587
+ const from = args.from;
588
+ const to = args.to;
589
+
590
+ if (!from) return errorResult('Missing --from flag', 'MISSING_FROM');
591
+
592
+ let handoffCtx;
593
+ try {
594
+ handoffCtx = await loadHandoffContext(projectDir, from);
595
+ } catch (err) {
596
+ return { valid: false, from, to: to || 'unknown', missing: ['handoff document'], warnings: [err.message], handoff_loaded: false, sources: [] };
597
+ }
598
+
599
+ const handoff = handoffCtx.context?.handoff || null;
600
+ if (!handoff) {
601
+ return { valid: false, from, to: to || 'unknown', missing: ['handoff document'], warnings: [`No handoff found from ${from}`], handoff_loaded: false, sources: handoffCtx.sources || [] };
602
+ }
603
+
604
+ const receivingAgent = to || getNextAgent(from, [from])?.next || 'unknown';
605
+ const integrity = validateHandoffIntegrity(handoff, receivingAgent);
606
+
607
+ return {
608
+ valid: integrity.valid,
609
+ from,
610
+ to: receivingAgent,
611
+ missing: integrity.missing,
612
+ warnings: integrity.warnings,
613
+ handoff_loaded: true,
614
+ sources: handoffCtx.sources || [],
615
+ };
616
+ }
617
+
618
+ async function handleStatus(projectDir) {
619
+ const { loaded, session } = loadSession(projectDir);
620
+ if (!loaded || !session) {
621
+ return errorResult('No session found. Run `orchestrate init` first.', 'NO_SESSION');
622
+ }
623
+
624
+ const summary = getSessionSummary(projectDir);
625
+ const pipelineState = sessionToPipelineState(session, projectDir);
626
+ const progress = getPipelineProgress(pipelineState);
627
+
628
+ let handoffHistory = [];
629
+ try {
630
+ handoffHistory = getHandoffHistory(projectDir);
631
+ } catch { /* expected: handoff files may not exist yet */
632
+ }
633
+
634
+ // Build model map for all agents
635
+ const agentModels = {};
636
+ for (const agentDef of AGENT_PIPELINE) {
637
+ agentModels[agentDef.name] = resolveAgentModel(agentDef.name, projectDir);
638
+ }
639
+
640
+ return {
641
+ session: summary.summary || summary,
642
+ pipeline: progress,
643
+ handoff_history: handoffHistory,
644
+ deviation_count: (session.deviations || []).length,
645
+ backlog: session.backlog || [],
646
+ agents: session.agents || {},
647
+ agent_models: agentModels,
648
+ context_bracket: estimateContextBracket((session.completed_agents || []).length, AGENT_PIPELINE.length),
649
+ user_level: session.user_level || 'auto',
650
+ execution_mode: session.execution_mode || 'interactive',
651
+ };
652
+ }
653
+
654
+ async function handleDeviation(projectDir, args) {
655
+ const type = args.type;
656
+ const target = args.target || null;
657
+ const confirm = args.confirm === 'true';
658
+
659
+ if (!type) return errorResult('Missing --type flag', 'MISSING_TYPE');
660
+
661
+ const { loaded, session } = loadSession(projectDir);
662
+ if (!loaded || !session) return errorResult('No session found', 'NO_SESSION');
663
+
664
+ const pipelineState = sessionToPipelineState(session, projectDir);
665
+
666
+ const details = { targetAgent: target };
667
+ if (args['features-add']) {
668
+ details.addedFeatures = args['features-add'].split(',').map(s => s.trim());
669
+ }
670
+ if (args['features-remove']) {
671
+ details.removedFeatures = args['features-remove'].split(',').map(s => s.trim());
672
+ }
673
+
674
+ // Phase 1: Impact analysis
675
+ const impact = analyzeDeviationImpact(type, pipelineState, details);
676
+
677
+ if (!confirm) {
678
+ return { impact, applied: false, changes: [], new_state: null };
679
+ }
680
+
681
+ // Phase 2: Apply deviation
682
+ const result = applyDeviation(pipelineState, type, details);
683
+
684
+ if (result.applied) {
685
+ // Persist changes to session
686
+ const updates = {};
687
+ if (result.state.currentAgent !== session.current_agent) {
688
+ updates.current_agent = result.state.currentAgent || '';
689
+ }
690
+ // Update agent statuses
691
+ if (result.state.agents) {
692
+ const agentUpdates = {};
693
+ for (const [name, data] of Object.entries(result.state.agents)) {
694
+ agentUpdates[name] = {
695
+ status: data.status,
696
+ score: data.score ?? 0,
697
+ criteria_count: 0,
698
+ completed_at: data.completedAt || null,
699
+ };
700
+ }
701
+ updates.agents = agentUpdates;
702
+ }
703
+ // Update completed_agents
704
+ if (result.state.completedAgents) {
705
+ updates.completed_agents = result.state.completedAgents;
706
+ }
707
+
708
+ await updateSession(projectDir, updates);
709
+ }
710
+
711
+ return {
712
+ impact,
713
+ applied: result.applied,
714
+ changes: result.changes,
715
+ new_state: result.applied ? { current_agent: result.state.currentAgent } : null,
716
+ };
717
+ }
718
+
719
+ async function handleExit(projectDir, args) {
720
+ const { loaded, session } = loadSession(projectDir);
721
+ if (!loaded || !session) {
722
+ return { exited: true, session_saved: false, lock_removed: false, resume_message: null };
723
+ }
724
+
725
+ // Save current state
726
+ await updateSession(projectDir, {});
727
+
728
+ // Release session ownership
729
+ const userId = args['user-id'] || process.env.USER || 'default';
730
+ try {
731
+ releaseSession(projectDir, userId);
732
+ } catch { /* expected: session may already be released */ }
733
+
734
+ // Remove lock from CLAUDE.local.md
735
+ const lang = session.language || 'en';
736
+ const resumeMsg = RESUME_MESSAGES[lang] || RESUME_MESSAGES.en;
737
+ try {
738
+ removeSessionLock(projectDir, resumeMsg);
739
+ } catch { /* expected: lock file may not exist */ }
740
+
741
+ return {
742
+ exited: true,
743
+ session_saved: true,
744
+ lock_removed: true,
745
+ resume_message: resumeMsg,
746
+ language: lang,
747
+ };
748
+ }
749
+
750
+ async function handleProviders(projectDir) {
751
+ const agentModels = {};
752
+ for (const agentDef of AGENT_PIPELINE) {
753
+ agentModels[agentDef.name] = resolveAgentModel(agentDef.name, projectDir);
754
+ }
755
+
756
+ return {
757
+ primary_provider: 'claude',
758
+ agent_models: agentModels,
759
+ };
760
+ }
761
+
762
+ async function handleDetectFlow(projectDir, args) {
763
+ const message = args.message || '';
764
+ if (!message) return errorResult('Missing --message flag', 'MISSING_MESSAGE');
765
+
766
+ const { loaded, session } = loadSession(projectDir);
767
+ const sessionContext = loaded && session ? { mode: session.mode, project_type: session.project_type } : {};
768
+
769
+ const quickResult = detectQuickFlow(message, sessionContext);
770
+ const standardResult = detectStandardFlow(message, sessionContext);
771
+
772
+ let recommended = 'full';
773
+ if (quickResult.isQuickFlow && quickResult.confidence >= 0.8) {
774
+ recommended = 'quick';
775
+ } else if (standardResult.isStandardFlow && standardResult.confidence >= 0.8) {
776
+ recommended = 'standard';
777
+ }
778
+
779
+ return {
780
+ recommended,
781
+ quick_flow: quickResult,
782
+ standard_flow: standardResult,
783
+ };
784
+ }
785
+
786
+ async function handleBacklog(projectDir, args) {
787
+ const action = args.action || 'list';
788
+ const { loaded, session } = loadSession(projectDir);
789
+ if (!loaded || !session) return errorResult('No session found', 'NO_SESSION');
790
+
791
+ const backlog = session.backlog || [];
792
+
793
+ if (action === 'list') {
794
+ return { backlog, count: backlog.length };
795
+ }
796
+
797
+ if (action === 'add') {
798
+ const title = args.title;
799
+ const priority = args.priority || 'medium';
800
+ const source = args.source || 'user';
801
+ if (!title) return errorResult('Missing --title flag', 'MISSING_TITLE');
802
+
803
+ const item = {
804
+ id: `BL-${String(backlog.length + 1).padStart(3, '0')}`,
805
+ title,
806
+ priority,
807
+ status: 'pending',
808
+ source_agent: source,
809
+ created_at: new Date().toISOString(),
810
+ };
811
+ backlog.push(item);
812
+ await updateSession(projectDir, { backlog });
813
+ return { added: true, item, count: backlog.length };
814
+ }
815
+
816
+ return { backlog, count: backlog.length };
817
+ }
818
+
819
+ // ---------------------------------------------------------------------------
820
+ // QA Scoring Handlers (deterministic — no LLM)
821
+ // ---------------------------------------------------------------------------
822
+
823
+ function handleQaPlanScore(args) {
824
+ let score = 100;
825
+ const penalties = {};
826
+
827
+ const penaltyMap = [
828
+ ['orphans', 10, 'Orphaned requirement'],
829
+ ['placeholders', 5, 'Placeholder in artifact'],
830
+ ['weak-criteria', 10, 'Weak/vague criterion'],
831
+ ['scope-creep', 15, 'Scope creep item'],
832
+ ['gaps', 20, 'Critical gap'],
833
+ ['fail-dimensions', 10, 'Failed validation dimension'],
834
+ ];
835
+
836
+ for (const [key, weight, label] of penaltyMap) {
837
+ const count = parseInt(args[key]) || 0;
838
+ if (count > 0) {
839
+ const penalty = count * weight;
840
+ score -= penalty;
841
+ penalties[key] = { count, weight, penalty, label };
842
+ }
843
+ }
844
+
845
+ if (args['adversarial-incomplete'] === 'true') {
846
+ score -= 15;
847
+ penalties['adversarial-incomplete'] = { count: 1, weight: 15, penalty: 15, label: 'Adversarial review incomplete' };
848
+ }
849
+
850
+ score = Math.max(0, score);
851
+ const threshold = 95;
852
+ const verdict = score >= threshold ? 'APPROVED' : score >= threshold - 5 ? 'NEEDS_REVISION' : 'BLOCKED';
853
+
854
+ return { score, threshold, verdict, penalties };
855
+ }
856
+
857
+ function handleQaImplScore(args) {
858
+ const weights = { tests: 0.25, coverage: 0.10, security: 0.25, quality: 0.15, acceptance: 0.10, adversarial: 0.15 };
859
+
860
+ const scores = {
861
+ tests: parseInt(args['tests-pass']) || 0,
862
+ coverage: parseInt(args.coverage) || 0,
863
+ security: (parseInt(args.critical) === 0 && parseInt(args.high) === 0) ? 100 : (parseInt(args.critical) > 0 ? 0 : 50),
864
+ quality: parseInt(args['code-quality']) || 0,
865
+ acceptance: parseInt(args.acceptance) || 0,
866
+ adversarial: parseInt(args.adversarial) || 0,
867
+ };
868
+
869
+ const weighted = Object.entries(weights).reduce((sum, [k, w]) => sum + (scores[k] || 0) * w, 0);
870
+ const score = Math.round(weighted);
871
+ const threshold = 95;
872
+ const verdict = score >= threshold ? 'APPROVED' : score >= threshold - 5 ? 'NEEDS_REVISION' : 'BLOCKED';
873
+
874
+ return { score, threshold, verdict, breakdown: scores, weights };
875
+ }
876
+
877
+ // ---------------------------------------------------------------------------
878
+ // Scan Handler (deterministic — no LLM)
879
+ // ---------------------------------------------------------------------------
880
+
881
+ async function handleScan(projectDir, args) {
882
+ const type = args.type;
883
+ if (!type) return errorResult('Missing --type flag. Valid: placeholders, leakage, density, env, security', 'MISSING_TYPE');
884
+
885
+ try {
886
+ switch (type) {
887
+ case 'placeholders': {
888
+ const file = args.file;
889
+ if (!file) return errorResult('Missing --file flag', 'MISSING_FILE');
890
+ const { scanPlaceholders } = await import('../scanning/placeholder-scanner.js');
891
+ const { join: joinPath } = await import('path');
892
+ return scanPlaceholders(joinPath(projectDir, file));
893
+ }
894
+ case 'leakage': {
895
+ const file = args.file;
896
+ if (!file) return errorResult('Missing --file flag', 'MISSING_FILE');
897
+ const { scanImplementationLeakage } = await import('../scanning/leakage-scanner.js');
898
+ const { join: joinPath } = await import('path');
899
+ return scanImplementationLeakage(joinPath(projectDir, file));
900
+ }
901
+ case 'density': {
902
+ const file = args.file;
903
+ if (!file) return errorResult('Missing --file flag', 'MISSING_FILE');
904
+ const { scanInformationDensity } = await import('../scanning/density-scanner.js');
905
+ const { join: joinPath } = await import('path');
906
+ return scanInformationDensity(joinPath(projectDir, file));
907
+ }
908
+ case 'env': {
909
+ const { scanEnvSync } = await import('../scanning/env-scanner.js');
910
+ return scanEnvSync(projectDir);
911
+ }
912
+ case 'security': {
913
+ const { scanProjectSecurity } = await import('../scanning/security-scanner.js');
914
+ return scanProjectSecurity(projectDir, { language: args.language, dir: args.dir });
915
+ }
916
+ default:
917
+ return errorResult(`Unknown scan type: ${type}. Valid: placeholders, leakage, density, env, security`, 'UNKNOWN_SCAN_TYPE');
918
+ }
919
+ } catch (err) {
920
+ return errorResult(`Scan failed: ${err.message}`, 'SCAN_ERROR');
921
+ }
922
+ }
923
+
924
+ // ---------------------------------------------------------------------------
925
+ // Main entry point
926
+ // ---------------------------------------------------------------------------
927
+
928
+ export async function runOrchestrate(subCommand, argv, projectDir) {
929
+ const args = parseArgs(argv);
930
+
931
+ try {
932
+ switch (subCommand) {
933
+ case 'next':
934
+ output(await handleNext(projectDir));
935
+ break;
936
+ case 'advance':
937
+ output(await handleAdvance(projectDir, args));
938
+ break;
939
+ case 'init':
940
+ output(await handleInit(projectDir, args));
941
+ break;
942
+ case 'validate-handoff':
943
+ output(await handleValidateHandoff(projectDir, args));
944
+ break;
945
+ case 'status':
946
+ output(await handleStatus(projectDir));
947
+ break;
948
+ case 'deviation':
949
+ output(await handleDeviation(projectDir, args));
950
+ break;
951
+ case 'exit':
952
+ output(await handleExit(projectDir, args));
953
+ break;
954
+ case 'providers':
955
+ output(await handleProviders(projectDir));
956
+ break;
957
+ case 'detect-flow':
958
+ output(await handleDetectFlow(projectDir, args));
959
+ break;
960
+ case 'backlog':
961
+ output(await handleBacklog(projectDir, args));
962
+ break;
963
+ case 'qa-plan-score':
964
+ output(handleQaPlanScore(args));
965
+ break;
966
+ case 'qa-impl-score':
967
+ output(handleQaImplScore(args));
968
+ break;
969
+ case 'scan':
970
+ output(await handleScan(projectDir, args));
971
+ break;
972
+ default:
973
+ 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`, 'UNKNOWN_COMMAND'));
974
+ break;
975
+ }
976
+ } catch (err) {
977
+ output(errorResult(`Orchestrate error: ${err.message}`, 'INTERNAL_ERROR'));
978
+ process.exitCode = 1;
979
+ }
980
+ }