chati-dev 4.5.10 → 4.5.12

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 (47) hide show
  1. package/framework/agents/discover/brownfield-wu.md +1 -1
  2. package/framework/agents/plan/tasks.md +2 -2
  3. package/framework/config.yaml +2 -2
  4. package/framework/constitution.md +21 -22
  5. package/framework/context/governance.md +10 -10
  6. package/framework/context/quality.md +6 -6
  7. package/framework/context/root.md +1 -1
  8. package/framework/data/entity-registry.yaml +1 -1
  9. package/framework/domains/constitution.yaml +1 -1
  10. package/framework/domains/workflows/brownfield-fullstack.yaml +5 -3
  11. package/framework/domains/workflows/brownfield-service.yaml +2 -3
  12. package/framework/domains/workflows/brownfield-ui.yaml +2 -3
  13. package/framework/domains/workflows/greenfield-fullstack.yaml +5 -3
  14. package/framework/domains/workflows/quick-flow.yaml +7 -6
  15. package/framework/domains/workflows/standard-flow.yaml +4 -5
  16. package/framework/frameworks/quality-dimensions.yaml +3 -3
  17. package/framework/hooks/git-push-authority.js +8 -4
  18. package/framework/manifest.json +49 -49
  19. package/framework/manifest.sig +1 -1
  20. package/framework/orchestrator/chati.md +29 -20
  21. package/framework/schemas/session.schema.json +5 -5
  22. package/framework/workflows/brownfield-fullstack.yaml +17 -123
  23. package/framework/workflows/brownfield-service.yaml +15 -109
  24. package/framework/workflows/brownfield-ui.yaml +15 -115
  25. package/framework/workflows/greenfield-fullstack.yaml +17 -130
  26. package/framework/workflows/quick-flow.yaml +20 -111
  27. package/framework/workflows/standard-flow.yaml +14 -158
  28. package/package.json +1 -1
  29. package/src/config/claude-settings-generator.js +4 -3
  30. package/src/config/context-file-generator.js +10 -1
  31. package/src/config/framework-adapter.js +10 -2
  32. package/src/context/layers/l1-global.js +2 -0
  33. package/src/installer/core.js +94 -13
  34. package/src/installer/templates.js +5 -4
  35. package/src/installer/validator.js +8 -2
  36. package/src/intelligence/registry-manager.js +11 -4
  37. package/src/orchestrator/cli.js +158 -42
  38. package/src/orchestrator/pipeline-manager.js +2 -0
  39. package/src/orchestrator/runtime-installation-v2.js +38 -0
  40. package/src/orchestrator/session-manager.js +1 -1
  41. package/src/terminal/handoff-parser.js +23 -4
  42. package/src/terminal/prompt-builder.js +15 -1
  43. package/src/terminal/provider-preflight.js +78 -0
  44. package/src/terminal/run-agent.js +66 -23
  45. package/src/terminal/spawner.js +5 -1
  46. package/src/utils/schema-validator.js +5 -2
  47. package/src/wizard/index.js +1 -0
@@ -53,7 +53,10 @@ import {
53
53
  recordEvent, EventType, clearTimeline,
54
54
  } from '../intelligence/timeline.js';
55
55
  import { updateClaudeMd } from '../memory/magic-docs.js';
56
- import { loadRuntimeInstallationV2, resolveRuntimeInvocationV2 } from './runtime-installation-v2.js';
56
+ import {
57
+ loadRuntimeInstallationV2, resolveRuntimeInvocationV2,
58
+ describeRuntimeInstallationV2, V2_PLANNING_AGENTS,
59
+ } from './runtime-installation-v2.js';
57
60
  import { compilePlanningHandoff, PLANNING_V2_TASKS_PATH } from './planning-runtime.js';
58
61
  import { persistRailHandoff } from './rail-runtime.js';
59
62
 
@@ -76,6 +79,8 @@ const LOCK_START = '<!-- chati-lock:start -->';
76
79
  const LOCK_END = '<!-- chati-lock:end -->';
77
80
  const STATE_START = '<!-- chati-state:start -->';
78
81
  const STATE_END = '<!-- chati-state:end -->';
82
+ const PAUSE_START = '<!-- chati-pause:start -->';
83
+ const PAUSE_END = '<!-- chati-pause:end -->';
79
84
 
80
85
  const RESUME_MESSAGES = {
81
86
  en: 'Session saved. Type /chati anytime to resume.',
@@ -84,6 +89,16 @@ const RESUME_MESSAGES = {
84
89
  fr: 'Session sauvee. Tapez /chati pour reprendre.',
85
90
  };
86
91
 
92
+ function getRuntimeAgentPipeline(projectDir, session = null) {
93
+ if (!loadRuntimeInstallationV2(projectDir)) return AGENT_PIPELINE;
94
+ const projectType = session?.project_type || session?.project?.type || 'greenfield';
95
+ const selectedWu = projectType === 'brownfield' ? 'brownfield-wu' : 'greenfield-wu';
96
+ return AGENT_PIPELINE.filter(({ name }) =>
97
+ V2_PLANNING_AGENTS.includes(name)
98
+ && (!name.endsWith('-wu') || name === selectedWu)
99
+ );
100
+ }
101
+
87
102
  // ---------------------------------------------------------------------------
88
103
  // QA-Visual scope helpers (Fase 6 — scope isolation)
89
104
  //
@@ -516,8 +531,13 @@ function errorResult(message, code) {
516
531
  */
517
532
  function sessionToPipelineState(session, projectDir) {
518
533
  const agents = {};
534
+ const installationV2 = loadRuntimeInstallationV2(projectDir);
535
+ const allowedAgents = installationV2
536
+ ? new Set(getRuntimeAgentPipeline(projectDir, session).map(({ name }) => name))
537
+ : null;
519
538
  if (session.agents) {
520
539
  for (const [name, data] of Object.entries(session.agents)) {
540
+ if (allowedAgents && !allowedAgents.has(name)) continue;
521
541
  agents[name] = {
522
542
  status: data.status || 'pending',
523
543
  score: data.score ?? null,
@@ -536,7 +556,9 @@ function sessionToPipelineState(session, projectDir) {
536
556
  startedAt: session.started_at || new Date().toISOString(),
537
557
  completedAt: null,
538
558
  agents,
539
- completedAgents: session.completed_agents || [],
559
+ completedAgents: allowedAgents
560
+ ? (session.completed_agents || []).filter((name) => allowedAgents.has(name))
561
+ : (session.completed_agents || []),
540
562
  currentAgent: session.current_agent || null,
541
563
  modeTransitions: session.mode_transitions || [],
542
564
  history: [],
@@ -680,9 +702,32 @@ function replaceBlock(content, startMarker, endMarker, newInner) {
680
702
  return content.trimEnd() + '\n\n' + block + '\n';
681
703
  }
682
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
+
683
730
  function writeSessionLock(projectDir, currentAgent, stateInfo = {}) {
684
- const localMdPath = join(projectDir, 'CLAUDE.local.md');
685
- let content = existsSync(localMdPath) ? readFileSync(localMdPath, 'utf-8') : '';
686
731
 
687
732
  // Phase display is authoritatively driven by session.mode on disk. Callers
688
733
  // previously passed stateInfo.phase derived from transient intermediate
@@ -695,50 +740,51 @@ function writeSessionLock(projectDir, currentAgent, stateInfo = {}) {
695
740
  if (loaded && session?.mode) authoritativePhase = session.mode;
696
741
  } catch { /* non-fatal — fall back to stateInfo */ }
697
742
 
698
- const lockInner = `## Session Lock -- ACTIVE
699
-
700
- **Chati.dev session is ACTIVE.** Follow these rules for EVERY message:
701
-
702
- 1. Read \`${resolveFrameworkDir(projectDir)}/orchestrator/chati.md\` and follow its routing logic
703
- 2. Route ALL user messages through the current agent: \`${currentAgent}\`
704
- 3. NEVER respond outside of the Chati.dev system
705
- 4. The ONLY way to exit is via \`/chati exit\`, \`/chati stop\`, or \`/chati quit\``;
706
-
707
743
  const stateInner = `## Current State
708
744
  - **Agent**: ${currentAgent || 'None'}
709
745
  - **Phase**: ${authoritativePhase || stateInfo.phase || 'discover'}
710
746
  - **Pipeline**: ${stateInfo.position ?? 0}/${stateInfo.total ?? '?'} (${stateInfo.progress ?? 0}%)
711
747
  - **Mode**: ${stateInfo.mode || 'interactive'}`;
712
748
 
713
- content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
714
- content = replaceBlock(content, STATE_START, STATE_END, stateInner);
715
- 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
+ }
716
763
  }
717
764
 
718
765
  /**
719
766
  * Remove session lock block from CLAUDE.local.md.
720
767
  */
721
768
  function removeSessionLock(projectDir, resumeMsg) {
722
- const localMdPath = join(projectDir, 'CLAUDE.local.md');
723
- if (!existsSync(localMdPath)) return;
724
-
725
- let content = readFileSync(localMdPath, 'utf-8');
726
-
727
- const lockInner = `## Session Lock
728
- **Status: INACTIVE** — Type \`/chati\` to activate.`;
729
769
  const stateInner = `## Current State
730
770
  - **Agent**: None (ready to start)
731
771
  - **Pipeline**: Pre-start
732
772
  - **Mode**: interactive`;
733
773
 
734
- content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
735
- content = replaceBlock(content, STATE_START, STATE_END, stateInner);
736
-
737
- if (resumeMsg) {
738
- 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');
739
787
  }
740
-
741
- writeFileSync(localMdPath, content.trimEnd() + '\n', 'utf-8');
742
788
  }
743
789
 
744
790
  // ---------------------------------------------------------------------------
@@ -767,11 +813,12 @@ async function handleNext(projectDir) {
767
813
  const activating = ['activate_interactive', 'spawn_routed_interactive', 'spawn_autonomous', 'spawn_team', 'spawn_parallel'];
768
814
  if (activating.includes(result.action)) {
769
815
  try {
816
+ const runtimePipeline = getRuntimeAgentPipeline(projectDir, result.session);
770
817
  writeSessionLock(projectDir, result.agent, {
771
818
  phase: result.phase || 'discover',
772
819
  mode: result.session?.execution_mode || 'interactive',
773
820
  position: result.pipeline_progress?.completedAgents?.length ?? 0,
774
- total: AGENT_PIPELINE.length,
821
+ total: runtimePipeline.length,
775
822
  progress: result.pipeline_progress?.progress ?? 0,
776
823
  });
777
824
  } catch { /* non-fatal */ }
@@ -798,6 +845,38 @@ async function _handleNextInner(projectDir) {
798
845
  return errorResult(`Session invalid: ${validation.reason}`, 'SESSION_INVALID');
799
846
  }
800
847
 
848
+ const installationV2 = loadRuntimeInstallationV2(projectDir);
849
+ const runtimePipeline = getRuntimeAgentPipeline(projectDir, session);
850
+ if (installationV2 && session.rail_handoff_id) {
851
+ const completedPlanning = runtimePipeline.length;
852
+ return {
853
+ action: 'rail_execute',
854
+ agent: 'rail',
855
+ phase: 'rail',
856
+ handoff_id: session.rail_handoff_id,
857
+ rail_state: session.rail_state || 'ready',
858
+ pipeline_progress: {
859
+ phase: 'rail', progress: 100, completedCount: completedPlanning,
860
+ totalAgents: completedPlanning, completedAgents: session.completed_agents || [],
861
+ currentAgent: 'rail', nextAgent: null,
862
+ },
863
+ session: {
864
+ language: session.language,
865
+ project_type: session.project_type || session.project?.type,
866
+ execution_mode: session.execution_mode,
867
+ },
868
+ status_summary: 'Planning is sealed. Resume execution through RAIL.',
869
+ };
870
+ }
871
+
872
+ const legacyExecutionAgents = new Set(['dev', 'qa-implementation', 'qa-visual', 'devops']);
873
+ if (installationV2 && legacyExecutionAgents.has(session.current_agent)) {
874
+ return errorResult(
875
+ 'A v2 installation cannot resume a legacy Build/Deploy agent. Re-seal Tasks to create the RAIL handoff.',
876
+ 'V2_RAIL_HANDOFF_REQUIRED',
877
+ );
878
+ }
879
+
801
880
  const pipelineState = sessionToPipelineState(session, projectDir);
802
881
 
803
882
  if (isPipelineComplete(pipelineState)) {
@@ -836,7 +915,7 @@ async function _handleNextInner(projectDir) {
836
915
  handoff_status: { valid: true, missing: [], warnings: ['Resuming dev as Build Team'] },
837
916
  gate_status: { canAdvance: true, reason: 'Build Team formation (Article XXI)' },
838
917
  model_info: resolveAgentModel(agent, projectDir),
839
- context_bracket: estimateContextBracket(completedAgents.length, AGENT_PIPELINE.length),
918
+ context_bracket: estimateContextBracket(completedAgents.length, runtimePipeline.length),
840
919
  pipeline_progress: getPipelineProgress(pipelineState),
841
920
  session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
842
921
  status_summary: `Forming Build Team: dev + qa-implementation.`,
@@ -858,7 +937,7 @@ async function _handleNextInner(projectDir) {
858
937
  handoff_status: { valid: true, missing: [], warnings: ['Resuming in-progress agent'] },
859
938
  gate_status: { canAdvance: true, reason: 'Agent already in progress' },
860
939
  model_info: modelInfo,
861
- context_bracket: estimateContextBracket(completedAgents.length, AGENT_PIPELINE.length),
940
+ context_bracket: estimateContextBracket(completedAgents.length, runtimePipeline.length),
862
941
  pipeline_progress: getPipelineProgress(pipelineState),
863
942
  session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
864
943
  status_summary: `Resuming ${agent} (in progress).`,
@@ -887,7 +966,7 @@ async function _handleNextInner(projectDir) {
887
966
  handoff_status: { valid: true, missing: [], warnings: [] },
888
967
  gate_status: { canAdvance: true, reason: 'First agent in pipeline' },
889
968
  model_info: modelInfo,
890
- context_bracket: estimateContextBracket(0, AGENT_PIPELINE.length),
969
+ context_bracket: estimateContextBracket(0, runtimePipeline.length),
891
970
  pipeline_progress: getPipelineProgress(pipelineState),
892
971
  session: { language: session.language, project_type: projectType, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
893
972
  status_summary: `Starting pipeline with ${firstAgent}.`,
@@ -927,6 +1006,13 @@ async function _handleNextInner(projectDir) {
927
1006
  const agentDef = getAgentDefinition(nextAgent);
928
1007
  const isInteractive = INTERACTIVE_AGENTS.includes(nextAgent);
929
1008
 
1009
+ if (installationV2 && legacyExecutionAgents.has(nextAgent)) {
1010
+ return errorResult(
1011
+ 'Planning reached the v2 execution boundary without a sealed RAIL handoff. Complete or re-run Tasks instead of entering Build/Deploy.',
1012
+ 'V2_RAIL_HANDOFF_REQUIRED',
1013
+ );
1014
+ }
1015
+
930
1016
  // Validate handoff from previous agent
931
1017
  let handoffStatus;
932
1018
  try {
@@ -1011,7 +1097,7 @@ async function _handleNextInner(projectDir) {
1011
1097
  recordEvent(projectDir, EventType.AGENT_ACTIVATED, nextAgent, { action, phase: agentDef?.phase });
1012
1098
 
1013
1099
  const progress = getPipelineProgress(pipelineState);
1014
- const bracket = estimateContextBracket(completedAgents.length, AGENT_PIPELINE.length);
1100
+ const bracket = estimateContextBracket(completedAgents.length, runtimePipeline.length);
1015
1101
 
1016
1102
  const result = {
1017
1103
  action,
@@ -1851,6 +1937,8 @@ async function handleAdvance(projectDir, args) {
1851
1937
  // the handoff identifier and must enter the project-local RAIL lifecycle.
1852
1938
  if (handoffData.rail_handoff) {
1853
1939
  const railState = {
1940
+ mode: 'rail',
1941
+ project: { ...session.project, state: 'rail' },
1854
1942
  current_agent: 'rail',
1855
1943
  last_handoff: agent,
1856
1944
  pipeline_position: (session.completed_agents || []).length + 1,
@@ -1860,12 +1948,28 @@ async function handleAdvance(projectDir, args) {
1860
1948
  };
1861
1949
  const saved = await updateSession(projectDir, railState);
1862
1950
  if (saved && saved.saved === false) return errorResult(`Session update failed: ${saved.error}`, 'SESSION_WRITE_FAILED');
1951
+ const runtimePipeline = getRuntimeAgentPipeline(projectDir, session);
1952
+ try {
1953
+ writeSessionLock(projectDir, 'rail', {
1954
+ phase: 'rail', mode: session.execution_mode || 'interactive',
1955
+ position: runtimePipeline.length, total: runtimePipeline.length, progress: 100,
1956
+ });
1957
+ updateClaudeMd(projectDir, {
1958
+ currentAgent: 'rail',
1959
+ progress: { progress: 100, completedCount: runtimePipeline.length, totalAgents: runtimePipeline.length },
1960
+ });
1961
+ } catch { /* non-fatal: the canonical session state has already been persisted */ }
1863
1962
  return {
1864
1963
  advanced: true,
1865
1964
  agent_completed: agent,
1866
1965
  score,
1867
1966
  rail_ready: true,
1868
1967
  rail_handoff: handoffData.rail_handoff,
1968
+ pipeline_progress: {
1969
+ phase: 'rail', progress: 100, completedCount: runtimePipeline.length,
1970
+ totalAgents: runtimePipeline.length, completedAgents: [...(session.completed_agents || []), agent],
1971
+ currentAgent: 'rail', nextAgent: null,
1972
+ },
1869
1973
  next: {
1870
1974
  action: 'rail_execute',
1871
1975
  handoff_id: handoffData.rail_handoff.handoff.handoff_id,
@@ -2277,8 +2381,10 @@ async function handleStatus(projectDir) {
2277
2381
  }
2278
2382
 
2279
2383
  // Build model map for all agents
2384
+ const runtimePipeline = getRuntimeAgentPipeline(projectDir, session);
2385
+ const runtimeAgentNames = new Set(runtimePipeline.map(({ name }) => name));
2280
2386
  const agentModels = {};
2281
- for (const agentDef of AGENT_PIPELINE) {
2387
+ for (const agentDef of runtimePipeline) {
2282
2388
  agentModels[agentDef.name] = resolveAgentModel(agentDef.name, projectDir);
2283
2389
  }
2284
2390
 
@@ -2309,9 +2415,9 @@ async function handleStatus(projectDir) {
2309
2415
  handoff_history: handoffHistory,
2310
2416
  deviation_count: (session.deviations || []).length,
2311
2417
  backlog: session.backlog || [],
2312
- agents: session.agents || {},
2418
+ agents: Object.fromEntries(Object.entries(session.agents || {}).filter(([name]) => runtimeAgentNames.has(name))),
2313
2419
  agent_models: agentModels,
2314
- context_bracket: estimateContextBracket((session.completed_agents || []).length, AGENT_PIPELINE.length),
2420
+ context_bracket: estimateContextBracket((session.completed_agents || []).length, runtimePipeline.length),
2315
2421
  context: {
2316
2422
  model: activeModel,
2317
2423
  provider: activeProvider,
@@ -2434,15 +2540,25 @@ async function handleExit(projectDir, args) {
2434
2540
  }
2435
2541
 
2436
2542
  async function handleProviders(projectDir) {
2543
+ const artifact = loadRuntimeInstallationV2(projectDir);
2544
+ if (artifact) {
2545
+ const { session } = loadSession(projectDir);
2546
+ const planningAgentModels = {};
2547
+ for (const { name } of getRuntimeAgentPipeline(projectDir, session)) {
2548
+ planningAgentModels[name] = resolveAgentModel(name, projectDir);
2549
+ }
2550
+ return {
2551
+ ...describeRuntimeInstallationV2(artifact),
2552
+ planning_agent_models: planningAgentModels,
2553
+ };
2554
+ }
2555
+
2437
2556
  const agentModels = {};
2438
2557
  for (const agentDef of AGENT_PIPELINE) {
2439
2558
  agentModels[agentDef.name] = resolveAgentModel(agentDef.name, projectDir);
2440
2559
  }
2441
2560
 
2442
- const artifact = loadRuntimeInstallationV2(projectDir);
2443
- const enabledProviders = artifact
2444
- ? artifact.installation.enabled_providers.map(({ provider_id, harness_id }) => ({ provider_id, harness_id }))
2445
- : [...new Set(Object.values(agentModels).map(({ provider }) => provider).filter(Boolean))].map((harness_id) => ({ harness_id }));
2561
+ const enabledProviders = [...new Set(Object.values(agentModels).map(({ provider }) => provider).filter(Boolean))].map((harness_id) => ({ harness_id }));
2446
2562
 
2447
2563
  return {
2448
2564
  enabled_providers: enabledProviders,
@@ -911,6 +911,8 @@ export function getPipelineProgress(pipelineState) {
911
911
  return {
912
912
  phase,
913
913
  progress: Math.round(progress),
914
+ completedCount: completed,
915
+ totalAgents,
914
916
  completedAgents,
915
917
  currentAgent,
916
918
  nextAgent,
@@ -10,6 +10,44 @@ const AGENT_ACTIONS = Object.freeze({
10
10
  devops: 'build',
11
11
  });
12
12
 
13
+ export const V2_PLANNING_AGENTS = Object.freeze([
14
+ 'greenfield-wu', 'brownfield-wu', 'brief',
15
+ 'detail', 'architect', 'ux', 'qa-planning', 'phases', 'tasks',
16
+ ]);
17
+
18
+ const RAIL_ACTIONS = new Set(['build', 'review']);
19
+
20
+ export function describeRuntimeInstallationV2(artifact) {
21
+ const railExecutionPool = artifact.installation.enabled_providers.map((binding) => {
22
+ const capabilities = binding.allowed_models.flatMap((modelId) => {
23
+ const model = artifact.capability_snapshot.models.find((candidate) =>
24
+ candidate.provider_id === binding.provider_id && candidate.model_id === modelId
25
+ );
26
+ if (!model) return [];
27
+ const actions = model.actions.filter((action) => RAIL_ACTIONS.has(action));
28
+ if (actions.length === 0) return [];
29
+ return [{ model_id: modelId, actions, tier: model.tier || null }];
30
+ });
31
+ return {
32
+ provider_id: binding.provider_id,
33
+ harness_id: binding.harness_id,
34
+ models: capabilities.map(({ model_id }) => model_id),
35
+ capabilities,
36
+ };
37
+ }).filter(({ models }) => models.length > 0);
38
+
39
+ return Object.freeze({
40
+ architecture: 'chati-v2-rail',
41
+ enabled_providers: artifact.installation.enabled_providers.map(({ provider_id, harness_id }) => ({ provider_id, harness_id })),
42
+ rail_execution_pool: railExecutionPool,
43
+ release_lane: {
44
+ authority: 'explicit-human-authorization',
45
+ execution_authority: 'rail',
46
+ legacy_devops_agent: false,
47
+ },
48
+ });
49
+ }
50
+
13
51
  export function loadRuntimeInstallationV2(projectDir, { clock = () => new Date() } = {}) {
14
52
  const path = join(projectDir, INSTALLATION_ARTIFACT_PATH);
15
53
  if (!existsSync(path)) return null;
@@ -973,7 +973,7 @@ export function validateSession(projectDir) {
973
973
  }
974
974
 
975
975
  // Validate mode
976
- const validModes = ['discover', 'plan', 'build', 'validate', 'deploy', 'completed'];
976
+ const validModes = ['discover', 'plan', 'rail', 'release', 'build', 'validate', 'deploy', 'completed'];
977
977
  if (!validModes.includes(session.mode)) {
978
978
  return {
979
979
  exists: true,
@@ -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
+