chati-dev 4.5.11 → 4.5.13

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 (37) hide show
  1. package/framework/config.yaml +2 -2
  2. package/framework/constitution.md +21 -22
  3. package/framework/context/root.md +1 -1
  4. package/framework/data/entity-registry.yaml +1 -1
  5. package/framework/domains/constitution.yaml +1 -1
  6. package/framework/domains/workflows/brownfield-fullstack.yaml +5 -3
  7. package/framework/domains/workflows/brownfield-service.yaml +2 -3
  8. package/framework/domains/workflows/brownfield-ui.yaml +2 -3
  9. package/framework/domains/workflows/greenfield-fullstack.yaml +5 -3
  10. package/framework/domains/workflows/quick-flow.yaml +7 -6
  11. package/framework/domains/workflows/standard-flow.yaml +4 -5
  12. package/framework/frameworks/quality-dimensions.yaml +3 -3
  13. package/framework/manifest.json +37 -37
  14. package/framework/manifest.sig +1 -1
  15. package/framework/schemas/session.schema.json +1 -1
  16. package/framework/workflows/brownfield-fullstack.yaml +17 -123
  17. package/framework/workflows/brownfield-service.yaml +15 -109
  18. package/framework/workflows/brownfield-ui.yaml +15 -115
  19. package/framework/workflows/greenfield-fullstack.yaml +17 -130
  20. package/framework/workflows/quick-flow.yaml +20 -111
  21. package/framework/workflows/standard-flow.yaml +14 -158
  22. package/package.json +1 -1
  23. package/src/config/claude-settings-generator.js +4 -3
  24. package/src/config/context-file-generator.js +10 -1
  25. package/src/config/framework-adapter.js +31 -0
  26. package/src/installer/core.js +89 -8
  27. package/src/installer/templates.js +33 -3
  28. package/src/installer/validator.js +8 -2
  29. package/src/intelligence/registry-manager.js +11 -4
  30. package/src/orchestrator/cli.js +54 -28
  31. package/src/terminal/handoff-parser.js +23 -4
  32. package/src/terminal/prompt-builder.js +15 -1
  33. package/src/terminal/provider-preflight.js +78 -0
  34. package/src/terminal/run-agent.js +66 -23
  35. package/src/terminal/spawner.js +5 -1
  36. package/src/utils/schema-validator.js +5 -2
  37. package/src/wizard/index.js +1 -0
@@ -175,14 +175,14 @@ export function generateConfigYaml(config) {
175
175
  model_fallback: true,
176
176
  frustration_detection: true,
177
177
  bash_security_checks: true,
178
- // Agent Teams (Article XXI) default ON in v4.2.2.
178
+ // Agent Teams are planning-only in runtime v2.
179
179
  // Only effective when the active session runs in Claude (gated in cli.js
180
180
  // isAgentTeamsEnabled). Gemini and Codex always fall back to sequential
181
181
  // pipeline silently. See plan: was previously false for "safe rollout"
182
182
  // but the v4.2.0 launch never actually shipped, so this is a fresh start.
183
183
  agent_teams: true,
184
184
  team_planning_size: 4,
185
- team_build_size: 2,
185
+ ...(config.runtimeVersion === 2 ? {} : { team_build_size: 2 }),
186
186
  team_echo_threshold: 0.92,
187
187
  team_correction_cycles_max: 2,
188
188
  };
@@ -229,12 +229,42 @@ name: chati
229
229
  description: >
230
230
  Use this skill when the user wants to start or continue a Chati.dev session.
231
231
  This activates the orchestrator which routes to the correct agent in the pipeline.
232
- Invoke when the user mentions chati, /chati, wants to plan, build, or deploy a project.
232
+ Invoke when the user mentions chati, /chati, or wants to discover, plan, execute, or release a project.
233
233
  Do NOT use for general coding questions unrelated to the Chati.dev pipeline.
234
234
  ---
235
235
 
236
236
  # Chati.dev Orchestrator
237
237
 
238
+ ## STEP 0 — Run the deterministic router (MUST be the first action)
239
+
240
+ Use the shell in the current project directory and run:
241
+
242
+ \`\`\`bash
243
+ node .chati.dev/orchestrator/chati-router.js
244
+ \`\`\`
245
+
246
+ Do not infer provider authentication, session state, model availability, or the
247
+ next pipeline action from files or prior messages. The router output is the
248
+ source of truth. Do not tell the user to log in unless the router or the exact
249
+ routed spawn command returns a provider authentication error.
250
+
251
+ Parse the JSON output before responding:
252
+
253
+ - If \`ok\` is \`false\`, report the exact \`error\` and relevant detail, then stop.
254
+ - If \`ok\` is \`true\`, use \`session.language\` for every response.
255
+ - Follow the returned \`action\` in the adapted orchestrator.
256
+ - When the action includes \`pipeline.spawn_command\`, execute that exact command.
257
+ Never replace it with an inferred provider check or a manually constructed command.
258
+ - A router-provided spawn command starts an external provider CLI. Execute it
259
+ with native escalated execution: set \`sandbox_permissions\` to
260
+ \`require_escalated\`, preserve the exact command and working directory, and
261
+ offer the narrow reusable prefix
262
+ \`["node", "<absolute path from spawn_command to run-agent.js>"]\`.
263
+ - Do not run that provider command inside the workspace sandbox first. A
264
+ \`PROVIDER_NOT_READY\` or \`PROVIDER_NOT_AUTHENTICATED\` result from a sandboxed
265
+ attempt is not proof that the user is logged out. Rerun it once with native
266
+ escalation before reporting an authentication failure.
267
+
238
268
  ## CRITICAL: Language Override
239
269
 
240
270
  Read \`.chati/session.yaml\` field \`language\` BEFORE anything else.
@@ -17,6 +17,7 @@ function resolveFrameworkDir(targetDir) {
17
17
  * Checks all primary agents, constitution, session, schemas, etc.
18
18
  */
19
19
  export async function validateInstallation(targetDir) {
20
+ const isRuntimeV2 = existsSync(join(targetDir, '.chati', 'v2', 'installation.json'));
20
21
  const results = {
21
22
  agents: { pass: false, details: [] },
22
23
  constitution: { pass: false, details: [] },
@@ -35,7 +36,7 @@ export async function validateInstallation(targetDir) {
35
36
  };
36
37
 
37
38
  // Check all primary agents (orchestrator + 12 specialized, sub-agents validated by managers)
38
- const agentFiles = [
39
+ const planningAgentFiles = [
39
40
  'orchestrator/chati.md',
40
41
  'agents/discover/greenfield-wu.md',
41
42
  'agents/discover/brownfield-wu.md',
@@ -46,10 +47,15 @@ export async function validateInstallation(targetDir) {
46
47
  'agents/plan/phases.md',
47
48
  'agents/plan/tasks.md',
48
49
  'agents/quality/qa-planning.md',
50
+ ];
51
+ const legacyExecutionAgentFiles = [
49
52
  'agents/quality/qa-implementation.md',
50
53
  'agents/build/dev.md',
51
54
  'agents/deploy/devops.md',
52
55
  ];
56
+ const agentFiles = isRuntimeV2
57
+ ? planningAgentFiles
58
+ : [...planningAgentFiles, ...legacyExecutionAgentFiles];
53
59
 
54
60
  let agentCount = 0;
55
61
  for (const file of agentFiles) {
@@ -64,7 +70,7 @@ export async function validateInstallation(targetDir) {
64
70
  results.agents.details.push({ file, exists: false, hasProtocols: false });
65
71
  }
66
72
  }
67
- results.agents.pass = agentCount === 13;
73
+ results.agents.pass = agentCount === agentFiles.length;
68
74
  results.total += 1;
69
75
  if (results.agents.pass) results.passed += 1;
70
76
 
@@ -158,21 +158,28 @@ export function runHealthCheck(targetDir) {
158
158
  }
159
159
 
160
160
  // 4. Agent check
161
- const agentPaths = [
161
+ const planningAgentPaths = [
162
162
  'orchestrator/chati.md',
163
163
  'agents/discover/greenfield-wu.md', 'agents/discover/brownfield-wu.md',
164
164
  'agents/discover/brief.md', 'agents/plan/detail.md',
165
165
  'agents/plan/architect.md', 'agents/plan/ux.md',
166
166
  'agents/plan/phases.md', 'agents/plan/tasks.md',
167
- 'agents/quality/qa-planning.md', 'agents/quality/qa-implementation.md',
167
+ 'agents/quality/qa-planning.md',
168
+ ];
169
+ const legacyExecutionAgentPaths = [
170
+ 'agents/quality/qa-implementation.md',
168
171
  'agents/build/dev.md', 'agents/deploy/devops.md',
169
172
  ];
173
+ const isRuntimeV2 = existsSync(join(targetDir, '.chati', 'v2', 'installation.json'));
174
+ const agentPaths = isRuntimeV2
175
+ ? planningAgentPaths
176
+ : [...planningAgentPaths, ...legacyExecutionAgentPaths];
170
177
  let foundAgents = 0;
171
178
  for (const p of agentPaths) {
172
179
  if (existsSync(join(targetDir, fwDir, p))) foundAgents++;
173
180
  }
174
- checks.agents.pass = foundAgents === 13;
175
- checks.agents.details = `${foundAgents}/13 present`;
181
+ checks.agents.pass = foundAgents === agentPaths.length;
182
+ checks.agents.details = `${foundAgents}/${agentPaths.length} present`;
176
183
 
177
184
  // 5. Entity validation (registry vs filesystem)
178
185
  const entityResult = validateEntities(targetDir);
@@ -79,6 +79,8 @@ const LOCK_START = '<!-- chati-lock:start -->';
79
79
  const LOCK_END = '<!-- chati-lock:end -->';
80
80
  const STATE_START = '<!-- chati-state:start -->';
81
81
  const STATE_END = '<!-- chati-state:end -->';
82
+ const PAUSE_START = '<!-- chati-pause:start -->';
83
+ const PAUSE_END = '<!-- chati-pause:end -->';
82
84
 
83
85
  const RESUME_MESSAGES = {
84
86
  en: 'Session saved. Type /chati anytime to resume.',
@@ -700,9 +702,32 @@ function replaceBlock(content, startMarker, endMarker, newInner) {
700
702
  return content.trimEnd() + '\n\n' + block + '\n';
701
703
  }
702
704
 
705
+ function sessionLockTargets(projectDir) {
706
+ const frameworkDir = resolveFrameworkDir(projectDir);
707
+ return [
708
+ { path: join(projectDir, 'CLAUDE.local.md'), invocation: '/chati', provider: 'claude' },
709
+ { path: join(projectDir, 'AGENTS.override.md'), invocation: '$chati', provider: 'codex' },
710
+ { path: join(projectDir, '.grok', 'session-lock.md'), invocation: '/chati', provider: 'grok' },
711
+ { path: join(projectDir, '.gemini', 'session-lock.md'), invocation: '/chati', provider: 'gemini' },
712
+ ].map(target => ({
713
+ ...target,
714
+ orchestratorPath: existsSync(join(projectDir, frameworkDir, '.adapted', target.provider, 'orchestrator', 'chati.md'))
715
+ ? `${frameworkDir}/.adapted/${target.provider}/orchestrator/chati.md`
716
+ : `${frameworkDir}/orchestrator/chati.md`,
717
+ })).filter(({ path }) => path.endsWith('CLAUDE.local.md') || existsSync(path));
718
+ }
719
+
720
+ function removeLegacyPauseSection(content) {
721
+ let clean = content.replace(
722
+ /\n*<!-- chati-pause:start -->[\s\S]*?<!-- chati-pause:end -->\n*/g,
723
+ '\n'
724
+ );
725
+ // Migrate the unmarked block emitted by releases before 4.5.12.
726
+ clean = clean.replace(/\n+## Session Paused\n+[\s\S]*?(?=\n## |\n---|$)/g, '\n');
727
+ return clean;
728
+ }
729
+
703
730
  function writeSessionLock(projectDir, currentAgent, stateInfo = {}) {
704
- const localMdPath = join(projectDir, 'CLAUDE.local.md');
705
- let content = existsSync(localMdPath) ? readFileSync(localMdPath, 'utf-8') : '';
706
731
 
707
732
  // Phase display is authoritatively driven by session.mode on disk. Callers
708
733
  // previously passed stateInfo.phase derived from transient intermediate
@@ -715,50 +740,51 @@ function writeSessionLock(projectDir, currentAgent, stateInfo = {}) {
715
740
  if (loaded && session?.mode) authoritativePhase = session.mode;
716
741
  } catch { /* non-fatal — fall back to stateInfo */ }
717
742
 
718
- const lockInner = `## Session Lock -- ACTIVE
719
-
720
- **Chati.dev session is ACTIVE.** Follow these rules for EVERY message:
721
-
722
- 1. Read \`${resolveFrameworkDir(projectDir)}/orchestrator/chati.md\` and follow its routing logic
723
- 2. Route ALL user messages through the current agent: \`${currentAgent}\`
724
- 3. NEVER respond outside of the Chati.dev system
725
- 4. The ONLY way to exit is via \`/chati exit\`, \`/chati stop\`, or \`/chati quit\``;
726
-
727
743
  const stateInner = `## Current State
728
744
  - **Agent**: ${currentAgent || 'None'}
729
745
  - **Phase**: ${authoritativePhase || stateInfo.phase || 'discover'}
730
746
  - **Pipeline**: ${stateInfo.position ?? 0}/${stateInfo.total ?? '?'} (${stateInfo.progress ?? 0}%)
731
747
  - **Mode**: ${stateInfo.mode || 'interactive'}`;
732
748
 
733
- content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
734
- content = replaceBlock(content, STATE_START, STATE_END, stateInner);
735
- writeFileSync(localMdPath, content, 'utf-8');
749
+ for (const target of sessionLockTargets(projectDir)) {
750
+ let content = removeLegacyPauseSection(existsSync(target.path) ? readFileSync(target.path, 'utf-8') : '');
751
+ const lockInner = `## Session Lock: ACTIVE
752
+
753
+ **Chati.dev session is ACTIVE.** Follow these rules for EVERY message:
754
+
755
+ 1. Read \`${target.orchestratorPath}\` and follow its routing logic
756
+ 2. Route ALL user messages through the current agent: \`${currentAgent}\`
757
+ 3. NEVER respond outside of the Chati.dev system
758
+ 4. The ONLY way to exit is via \`${target.invocation} exit\`, \`${target.invocation} stop\`, or \`${target.invocation} quit\``;
759
+ content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
760
+ content = replaceBlock(content, STATE_START, STATE_END, stateInner);
761
+ writeFileSync(target.path, content.trimEnd() + '\n', 'utf-8');
762
+ }
736
763
  }
737
764
 
738
765
  /**
739
766
  * Remove session lock block from CLAUDE.local.md.
740
767
  */
741
768
  function removeSessionLock(projectDir, resumeMsg) {
742
- const localMdPath = join(projectDir, 'CLAUDE.local.md');
743
- if (!existsSync(localMdPath)) return;
744
-
745
- let content = readFileSync(localMdPath, 'utf-8');
746
-
747
- const lockInner = `## Session Lock
748
- **Status: INACTIVE** — Type \`/chati\` to activate.`;
749
769
  const stateInner = `## Current State
750
770
  - **Agent**: None (ready to start)
751
771
  - **Pipeline**: Pre-start
752
772
  - **Mode**: interactive`;
753
773
 
754
- content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
755
- content = replaceBlock(content, STATE_START, STATE_END, stateInner);
756
-
757
- if (resumeMsg) {
758
- content = content.trimEnd() + `\n\n## Session Paused\n\n${resumeMsg}\n`;
774
+ for (const target of sessionLockTargets(projectDir)) {
775
+ let content = removeLegacyPauseSection(existsSync(target.path) ? readFileSync(target.path, 'utf-8') : '');
776
+ const lockInner = `## Session Lock
777
+ **Status: INACTIVE**: Type \`${target.invocation}\` to activate.`;
778
+ content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
779
+ content = replaceBlock(content, STATE_START, STATE_END, stateInner);
780
+
781
+ if (resumeMsg) {
782
+ const providerMessage = resumeMsg.replaceAll('/chati', target.invocation);
783
+ const pauseBlock = `${PAUSE_START}\n## Session Paused\n\n${providerMessage}\n${PAUSE_END}`;
784
+ content = content.trimEnd() + `\n\n${pauseBlock}\n`;
785
+ }
786
+ writeFileSync(target.path, content.trimEnd() + '\n', 'utf-8');
759
787
  }
760
-
761
- writeFileSync(localMdPath, content.trimEnd() + '\n', 'utf-8');
762
788
  }
763
789
 
764
790
  // ---------------------------------------------------------------------------
@@ -14,7 +14,15 @@ import { validateSchema, HANDOFF_SCHEMA } from '../utils/schema-validator.js';
14
14
  * Valid status values for handoff blocks.
15
15
  * @type {string[]}
16
16
  */
17
- const VALID_STATUSES = ['APPROVED', 'NEEDS_REVISION', 'BLOCKED', 'unknown'];
17
+ const VALID_STATUSES = [
18
+ 'complete', 'partial', 'needs_input', 'error', 'unknown',
19
+ 'APPROVED', 'NEEDS_REVISION', 'BLOCKED',
20
+ ];
21
+ const LEGACY_STATUS_ALIASES = {
22
+ APPROVED: 'complete',
23
+ NEEDS_REVISION: 'partial',
24
+ BLOCKED: 'needs_input',
25
+ };
18
26
 
19
27
  /**
20
28
  * Parse the <chati-handoff> block from agent stdout.
@@ -36,6 +44,7 @@ export function parseAgentOutput(output) {
36
44
 
37
45
  const content = match[1].trim();
38
46
  const handoff = parseHandoffFields(content);
47
+ handoff.status = LEGACY_STATUS_ALIASES[handoff.status] || handoff.status;
39
48
 
40
49
  // Validate the parsed handoff
41
50
  const { valid, warnings } = validateHandoff(handoff);
@@ -114,10 +123,11 @@ function parseHandoffFields(content) {
114
123
  for (const line of lines) {
115
124
  const trimmed = line.trim();
116
125
  if (!trimmed) continue;
126
+ const indentation = line.length - line.trimStart().length;
117
127
 
118
128
  // Check if this is a list item ( - value)
119
129
  const listMatch = trimmed.match(/^-\s+(.+)/);
120
- if (listMatch && currentKey && currentType === 'list') {
130
+ if (indentation > 0 && listMatch && currentKey && currentType === 'list') {
121
131
  if (Array.isArray(result[currentKey])) {
122
132
  result[currentKey].push(listMatch[1].trim());
123
133
  }
@@ -126,7 +136,7 @@ function parseHandoffFields(content) {
126
136
 
127
137
  // Check if this is a map item ( key: value) under a map key
128
138
  const mapMatch = trimmed.match(/^(\w[\w_-]*):\s+(.+)/);
129
- if (mapMatch && currentKey && currentType === 'map') {
139
+ if (indentation > 0 && mapMatch && currentKey && currentType === 'map') {
130
140
  if (typeof result[currentKey] === 'object' && !Array.isArray(result[currentKey])) {
131
141
  result[currentKey][mapMatch[1]] = mapMatch[2].trim();
132
142
  }
@@ -145,7 +155,10 @@ function parseHandoffFields(content) {
145
155
  currentType = 'list';
146
156
  // If value is inline (not empty), treat as single item
147
157
  if (value && value !== '') {
148
- result[key] = [value];
158
+ if (value === '[]') result[key] = [];
159
+ else if (value.startsWith('[') && value.endsWith(']')) {
160
+ result[key] = value.slice(1, -1).split(',').map(item => item.trim()).filter(Boolean);
161
+ } else result[key] = [value];
149
162
  currentKey = null;
150
163
  currentType = null;
151
164
  }
@@ -154,6 +167,12 @@ function parseHandoffFields(content) {
154
167
 
155
168
  // Known map keys
156
169
  if (['decisions'].includes(key)) {
170
+ if (value === '{}') {
171
+ result[key] = {};
172
+ currentKey = null;
173
+ currentType = null;
174
+ continue;
175
+ }
157
176
  currentKey = key;
158
177
  currentType = 'map';
159
178
  continue;
@@ -212,7 +212,11 @@ export function buildAgentPrompt(config) {
212
212
  // 9. Anti-laziness reinforcement (proven remediation techniques)
213
213
  sections.push(buildAntiLazinessSection(config.agent));
214
214
 
215
- // 10. Output format instructions (handoff template includes provider/model for audit)
215
+ // 10. Spawn authority. This late prompt section wins over stale provider
216
+ // context that may have been loaded before the synchronized session lock.
217
+ sections.push(buildSpawnAuthoritySection(config));
218
+
219
+ // 11. Output format instructions (handoff template — includes provider/model for audit)
216
220
  sections.push(buildOutputInstructions({ provider: resolvedProvider, model }));
217
221
 
218
222
  const prompt = sections.join('\n\n---\n\n');
@@ -233,6 +237,16 @@ export function buildAgentPrompt(config) {
233
237
  };
234
238
  }
235
239
 
240
+ function buildSpawnAuthoritySection(config) {
241
+ return `<!-- SPAWN AUTHORITY -->
242
+ ## Active Orchestrator Assignment
243
+
244
+ This process was launched by the active Chati.dev orchestrator for agent \`${config.agent}\`.
245
+ Execute the assigned work now. Do not ask the user to activate Chati.dev, switch CLI, authenticate a provider, or restart the session.
246
+ If essential user information is missing, return status \`needs_input\` with exactly one concrete question in \`needs_input_question\`.
247
+ Never claim that this spawned session is paused. The parent orchestrator owns user interaction and will relay your structured handoff.`;
248
+ }
249
+
236
250
  // ---------------------------------------------------------------------------
237
251
  // Internal helpers
238
252
  // ---------------------------------------------------------------------------
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Deterministic provider readiness checks used immediately before a routed
3
+ * execution. These checks never mutate provider configuration.
4
+ */
5
+ import { spawnSync } from 'node:child_process';
6
+ import { getProvider } from './cli-registry.js';
7
+ import { cleanParentEnv } from './spawner.js';
8
+
9
+ const CHECKS = {
10
+ claude: { args: ['auth', 'status'], validate: validateClaude },
11
+ codex: { args: ['login', 'status'], validate: validateCodex },
12
+ grok: { args: ['inspect'], validate: validateGrok },
13
+ gemini: { args: ['--version'], validate: validateExit },
14
+ };
15
+
16
+ export function checkProviderReadiness(providerName, options = {}) {
17
+ const runner = options.runner || spawnSync;
18
+ let provider;
19
+ try {
20
+ provider = getProvider(providerName);
21
+ } catch (error) {
22
+ return { ready: false, code: 'PROVIDER_UNKNOWN', provider: providerName, detail: error.message };
23
+ }
24
+
25
+ const check = CHECKS[providerName] || { args: ['--version'], validate: validateExit };
26
+ let result;
27
+ try {
28
+ result = runner(provider.command, check.args, {
29
+ cwd: options.workingDir || process.cwd(),
30
+ env: cleanParentEnv(process.env),
31
+ encoding: 'utf8',
32
+ timeout: options.timeout || 30_000,
33
+ });
34
+ } catch (error) {
35
+ return { ready: false, code: 'PROVIDER_PREFLIGHT_FAILED', provider: providerName, detail: error.message };
36
+ }
37
+
38
+ if (result?.error?.code === 'ENOENT') {
39
+ return { ready: false, code: 'PROVIDER_CLI_MISSING', provider: providerName, detail: `${provider.command} is not installed` };
40
+ }
41
+ return check.validate(result, providerName);
42
+ }
43
+
44
+ function outputOf(result) {
45
+ return `${result?.stdout || ''}\n${result?.stderr || ''}`.trim();
46
+ }
47
+
48
+ function validateExit(result, provider) {
49
+ return result?.status === 0
50
+ ? { ready: true, code: 'PROVIDER_READY', provider }
51
+ : { ready: false, code: 'PROVIDER_NOT_READY', provider, detail: outputOf(result).slice(0, 500) };
52
+ }
53
+
54
+ function validateClaude(result, provider) {
55
+ if (result?.status !== 0) return validateExit(result, provider);
56
+ try {
57
+ const status = JSON.parse(result.stdout || '{}');
58
+ return status.loggedIn === true
59
+ ? { ready: true, code: 'PROVIDER_READY', provider }
60
+ : { ready: false, code: 'PROVIDER_NOT_AUTHENTICATED', provider, detail: 'Claude CLI reports loggedIn=false' };
61
+ } catch {
62
+ return { ready: false, code: 'PROVIDER_PREFLIGHT_INVALID', provider, detail: 'Claude CLI returned an invalid auth status' };
63
+ }
64
+ }
65
+
66
+ function validateCodex(result, provider) {
67
+ if (result?.status !== 0) return validateExit(result, provider);
68
+ return /logged in/i.test(outputOf(result))
69
+ ? { ready: true, code: 'PROVIDER_READY', provider }
70
+ : { ready: false, code: 'PROVIDER_NOT_AUTHENTICATED', provider, detail: 'Codex CLI did not confirm an authenticated session' };
71
+ }
72
+
73
+ function validateGrok(result, provider) {
74
+ // Grok currently exposes no stable auth-status command. `inspect` proves
75
+ // that the CLI can load the trusted project and its effective config.
76
+ return validateExit(result, provider);
77
+ }
78
+
@@ -15,8 +15,9 @@
15
15
 
16
16
  import { fileURLToPath } from 'url';
17
17
  import { buildAgentPrompt } from './prompt-builder.js';
18
- import { spawnTerminal } from './spawner.js';
18
+ import { isTransientFailure, spawnTerminalWithRetry } from './spawner.js';
19
19
  import { parseAgentOutput } from './handoff-parser.js';
20
+ import { checkProviderReadiness } from './provider-preflight.js';
20
21
  import { createCostTracker } from './cost-tracker.js';
21
22
  import { getRateLimiter } from './rate-limiter.js';
22
23
  import { initCollector, track as telemetryTrack, flush as telemetryFlush } from '../telemetry/collector.js';
@@ -104,6 +105,11 @@ async function main() {
104
105
 
105
106
  // Wait for rate limit slot before spawning
106
107
  const spawnProvider = promptResult.provider || args.provider || 'claude';
108
+ const readiness = checkProviderReadiness(spawnProvider, { workingDir: projectDir });
109
+ if (!readiness.ready) {
110
+ outputResult({ status: 'error', error: readiness.detail, code: readiness.code, provider: spawnProvider });
111
+ process.exit(1);
112
+ }
107
113
  const limiter = getRateLimiter(spawnProvider);
108
114
  if (!limiter.canSpawn()) {
109
115
  const rateLimitStart = Date.now();
@@ -121,7 +127,7 @@ async function main() {
121
127
  let handle;
122
128
 
123
129
  try {
124
- handle = spawnTerminal({
130
+ const spawnConfig = {
125
131
  agent: args.agent,
126
132
  taskId: args['task-id'],
127
133
  model: promptResult.model,
@@ -133,31 +139,25 @@ async function main() {
133
139
  providerId: args['provider-id'] || null,
134
140
  reasoningConfiguration: args['reasoning-configuration'] || null,
135
141
  catalogSnapshotRef: args['catalog-snapshot-ref'] || null,
142
+ };
143
+ handle = await spawnTerminalWithRetry(spawnConfig, {
144
+ maxRetries: 1,
145
+ baseDelay: 500,
146
+ enableModelFallback: args['strict-provider'] !== 'true',
147
+ shouldRetry(exitCode, output) {
148
+ const combined = Array.isArray(output) ? output.join('') : String(output || '');
149
+ return isTransientFailure(exitCode, combined) || /not logged in|not authenticated/i.test(combined);
150
+ },
136
151
  });
137
152
  } catch (err) {
138
153
  outputError(`Failed to spawn terminal: ${err.message}`);
139
154
  process.exit(1);
140
155
  }
141
156
 
142
- // Wait for the process to complete
143
- try {
144
- await waitForExit(handle, timeout);
145
- } catch (err) {
146
- telemetryTrack('error_occurred', {
147
- errorType: 'agent_failure',
148
- agent: args.agent,
149
- provider: promptResult.provider || args.provider || 'claude',
150
- reasoningConfiguration: args['reasoning-configuration'] || null,
151
- phase: sessionState?.phase || 'unknown',
152
- });
153
- await flushAndSend(projectDir);
154
- outputError(`Terminal execution failed: ${err.message}`);
155
- process.exit(2);
156
- }
157
-
158
157
  const elapsed = Date.now() - startTime;
159
158
  const stdout = handle.stdout.join('');
160
159
  const stderr = handle.stderr.join('');
160
+ const providerOutput = `${stdout}\n${stderr}`;
161
161
 
162
162
  // Track cost metrics
163
163
  const tracker = createCostTracker();
@@ -186,7 +186,7 @@ async function main() {
186
186
  model: costRecord.model,
187
187
  duration: elapsed,
188
188
  score: null, // Score is determined by the gate, not the agent
189
- retryCount: 0,
189
+ retryCount: handle.retryCount || 0,
190
190
  pipelineType: sessionState?.isQuickFlow ? 'quick-flow' : 'standard',
191
191
  });
192
192
 
@@ -210,7 +210,19 @@ async function main() {
210
210
  // Parse the handoff from stdout
211
211
  const parsed = parseAgentOutput(stdout);
212
212
 
213
- if (parsed.found) {
213
+ if (handle.exitCode !== 0 && /not logged in|not authenticated/i.test(providerOutput)) {
214
+ outputResult({
215
+ status: 'error',
216
+ code: 'PROVIDER_AUTH_STATE_MISMATCH',
217
+ error: `${spawnProvider} passed preflight but rejected the spawned execution`,
218
+ provider: spawnProvider,
219
+ exitCode: handle.exitCode,
220
+ retryCount: handle.retryCount || 0,
221
+ });
222
+ process.exit(1);
223
+ }
224
+
225
+ if (parsed.found && parsed.valid) {
214
226
  outputResult({
215
227
  status: parsed.handoff.status,
216
228
  agent: args.agent,
@@ -222,15 +234,33 @@ async function main() {
222
234
  elapsed,
223
235
  costEstimate,
224
236
  });
237
+ } else if (recoverInteractiveHandoff(args.agent, stdout, handle.exitCode)) {
238
+ // Interactive discovery models sometimes answer with the user-facing
239
+ // question but omit the machine block. Preserve the exact question while
240
+ // restoring the deterministic relay contract for the parent orchestrator.
241
+ outputResult({
242
+ status: 'needs_input',
243
+ agent: args.agent,
244
+ model: promptResult.model,
245
+ provider: promptResult.provider || args.provider || 'claude',
246
+ exitCode: handle.exitCode,
247
+ handoff: recoverInteractiveHandoff(args.agent, stdout, handle.exitCode),
248
+ contractRecovery: 'interactive_output_wrapped',
249
+ elapsed,
250
+ costEstimate,
251
+ });
225
252
  } else {
226
- // No handoff block found return raw output
253
+ // A non-interactive agent that omits the handoff violated the execution
254
+ // contract and cannot be treated as successful or partially complete.
227
255
  outputResult({
228
- status: handle.exitCode === 0 ? 'partial' : 'error',
256
+ status: 'error',
257
+ code: parsed.found ? 'INVALID_HANDOFF' : 'MISSING_HANDOFF',
229
258
  agent: args.agent,
230
259
  model: promptResult.model,
231
260
  provider: promptResult.provider || args.provider || 'claude',
232
261
  exitCode: handle.exitCode,
233
- handoff: null,
262
+ handoff: parsed.handoff,
263
+ handoffWarnings: parsed.warnings,
234
264
  rawOutput: stdout.slice(0, 5000), // Truncate to avoid huge JSON
235
265
  stderr: stderr.slice(0, 2000),
236
266
  elapsed,
@@ -306,6 +336,19 @@ function outputError(message) {
306
336
  process.stdout.write(JSON.stringify({ status: 'error', error: message }) + '\n');
307
337
  }
308
338
 
339
+ export function recoverInteractiveHandoff(agent, stdout, exitCode) {
340
+ if (exitCode !== 0 || !['greenfield-wu', 'brownfield-wu', 'brief'].includes(agent) || !stdout?.trim()) return null;
341
+ return {
342
+ status: 'needs_input',
343
+ score: null,
344
+ summary: 'Interactive agent requested user input.',
345
+ outputs: [],
346
+ decisions: {},
347
+ blockers: [],
348
+ needs_input_question: stdout.trim(),
349
+ };
350
+ }
351
+
309
352
  /**
310
353
  * Minimal YAML parser for session.yaml (handles flat and one-level nested keys).
311
354
  */
@@ -579,11 +579,13 @@ export async function spawnTerminalWithRetry(config, retryOptions = {}) {
579
579
 
580
580
  // Success — return immediately
581
581
  if (handle.exitCode === 0) {
582
+ handle.retryCount = attempt;
582
583
  return handle;
583
584
  }
584
585
 
585
586
  // Check if failure is transient and retries remain
586
- if (attempt < maxRetries && shouldRetry(handle.exitCode, handle.stderr)) {
587
+ const combinedOutput = [...(handle.stdout || []), ...(handle.stderr || [])];
588
+ if (attempt < maxRetries && shouldRetry(handle.exitCode, combinedOutput, handle)) {
587
589
  const delay = baseDelay * Math.pow(2, attempt);
588
590
  await new Promise(resolve => setTimeout(resolve, delay));
589
591
  continue;
@@ -617,9 +619,11 @@ export async function spawnTerminalWithRetry(config, retryOptions = {}) {
617
619
  timestamp: new Date().toISOString(),
618
620
  };
619
621
 
622
+ fallbackHandle.retryCount = maxRetries;
620
623
  return fallbackHandle;
621
624
  }
622
625
  }
623
626
 
627
+ if (lastHandle) lastHandle.retryCount = lastHandle.retryCount ?? maxRetries;
624
628
  return lastHandle;
625
629
  }
@@ -169,7 +169,7 @@ export const SESSION_SCHEMA = {
169
169
  properties: {
170
170
  project: { type: 'string', required: true, minLength: 1 },
171
171
  language: { type: 'string', required: true, default: 'en' },
172
- pipeline_phase: { type: 'string', enum: ['discover', 'plan', 'build', 'deploy', 'completed'] },
172
+ pipeline_phase: { type: 'string', enum: ['discover', 'plan', 'rail', 'release', 'build', 'validate', 'deploy', 'completed'] },
173
173
  current_agent: { type: 'string' },
174
174
  governance_mode: { type: 'string', enum: ['planning', 'build', 'deploy'] },
175
175
  execution_mode: { type: 'string', enum: ['interactive', 'autonomous'] },
@@ -201,7 +201,10 @@ export const CONFIG_SCHEMA = {
201
201
  export const HANDOFF_SCHEMA = {
202
202
  required: ['status'],
203
203
  properties: {
204
- status: { type: 'string', required: true, enum: ['APPROVED', 'NEEDS_REVISION', 'BLOCKED', 'unknown'] },
204
+ status: { type: 'string', required: true, enum: [
205
+ 'complete', 'partial', 'needs_input', 'error', 'unknown',
206
+ 'APPROVED', 'NEEDS_REVISION', 'BLOCKED',
207
+ ] },
205
208
  score: { type: 'number', min: 0, max: 100 },
206
209
  summary: { type: 'string', maxLength: 2000 },
207
210
  outputs: { type: 'array' },
@@ -185,6 +185,7 @@ export async function runWizard(targetDir, options = {}) {
185
185
  modelSelections,
186
186
  targetDir,
187
187
  version: VERSION,
188
+ runtimeVersion: 2,
188
189
  };
189
190
  v2InstallationInput = buildWizardV2InstallationInput({
190
191
  projectName,