chati-dev 4.5.13 → 4.5.14

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 (55) hide show
  1. package/framework/agents/build/dev.md +4 -5
  2. package/framework/agents/deploy/devops.md +9 -10
  3. package/framework/agents/discover/brief.md +7 -13
  4. package/framework/agents/discover/brownfield-wu.md +5 -11
  5. package/framework/agents/discover/greenfield-wu.md +7 -12
  6. package/framework/agents/plan/architect.md +1 -1
  7. package/framework/agents/plan/detail.md +5 -5
  8. package/framework/agents/plan/phases.md +4 -5
  9. package/framework/agents/plan/tasks.md +4 -5
  10. package/framework/agents/plan/ux-brand-architect.md +1 -1
  11. package/framework/agents/plan/ux.md +1 -1
  12. package/framework/agents/quality/qa-implementation.md +4 -5
  13. package/framework/agents/quality/qa-planning.md +4 -5
  14. package/framework/agents/quality/qa-visual.md +4 -4
  15. package/framework/agents/shared/visualizer.md +1 -1
  16. package/framework/config.yaml +5 -5
  17. package/framework/constitution.md +24 -26
  18. package/framework/context/governance.md +6 -6
  19. package/framework/context/root.md +4 -3
  20. package/framework/data/entity-registry.yaml +3 -3
  21. package/framework/data/model-limits.json +5 -2
  22. package/framework/hooks/model-governance.js +3 -2
  23. package/framework/hooks/prism-engine.js +1 -1
  24. package/framework/i18n/en.yaml +2 -2
  25. package/framework/i18n/es.yaml +2 -2
  26. package/framework/i18n/fr.yaml +2 -2
  27. package/framework/i18n/pt.yaml +2 -2
  28. package/framework/intelligence/context-engine.md +12 -16
  29. package/framework/manifest.json +63 -63
  30. package/framework/manifest.sig +1 -1
  31. package/framework/orchestrator/chati-router.js +40 -6
  32. package/framework/orchestrator/chati.md +12 -13
  33. package/framework/schemas/session.schema.json +1 -1
  34. package/package.json +1 -1
  35. package/src/config/context-file-generator.js +54 -246
  36. package/src/config/framework-adapter.js +45 -208
  37. package/src/context/bracket-tracker.js +3 -3
  38. package/src/context/engine.js +1 -1
  39. package/src/installer/core.js +190 -66
  40. package/src/installer/provider-overlay.js +1 -1
  41. package/src/installer/templates.js +55 -65
  42. package/src/installer-v2/index.js +2 -0
  43. package/src/memory/magic-docs.js +45 -26
  44. package/src/orchestrator/cli.js +50 -45
  45. package/src/orchestrator/handoff-engine.js +7 -5
  46. package/src/orchestrator/session-manager.js +24 -3
  47. package/src/terminal/cli-registry.js +5 -5
  48. package/src/terminal/prompt-builder.js +14 -10
  49. package/src/terminal/run-agent.js +10 -6
  50. package/src/terminal/run-parallel.js +6 -2
  51. package/src/terminal/run-team.js +7 -2
  52. package/src/terminal/spawner.js +34 -42
  53. package/src/utils/config-parser.js +2 -2
  54. package/src/utils/provider-limits.js +3 -1
  55. package/src/wizard/i18n.js +2 -2
@@ -1,14 +1,14 @@
1
1
  /**
2
- * Magic Docs Auto-updating CLAUDE.md after agent handoffs.
2
+ * Magic Docs: update Chati's provider-neutral project context after handoffs.
3
3
  *
4
4
  * Inspired by Claude Code's Magic Docs system that uses `# MAGIC DOC:` headers
5
5
  * to trigger background self-updating documentation.
6
6
  *
7
- * This module updates only the `## Current State` section of CLAUDE.md,
8
- * preserving all other user-written sections untouched.
7
+ * This module updates only `.chati/project-context.md`. Provider-native root
8
+ * files consume this canonical artifact and are never used as its source.
9
9
  */
10
10
 
11
- import { existsSync, readFileSync, writeFileSync } from 'fs';
11
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
12
12
  import { join } from 'path';
13
13
 
14
14
  const SECTION_HEADER = '## Current State';
@@ -20,7 +20,7 @@ const SECTION_HEADER = '## Current State';
20
20
  const SECTION_REGEX = /^## Current State\n([\s\S]*?)(?=\n## |\n---|$(?![\s\S]))/m;
21
21
 
22
22
  /**
23
- * Update CLAUDE.md with current project state after a handoff.
23
+ * Update the neutral project context with current state after a handoff.
24
24
  *
25
25
  * @param {string} projectDir - Project root directory
26
26
  * @param {object} context - Current state to write
@@ -28,12 +28,13 @@ const SECTION_REGEX = /^## Current State\n([\s\S]*?)(?=\n## |\n---|$(?![\s\S]))/
28
28
  * @param {string} [context.pipelinePosition] - Pipeline phase/step
29
29
  * @param {string} [context.activeTask] - Current task ID + title
30
30
  * @param {number} [context.progress] - Pipeline completion percentage
31
+ * @param {number} [context.validationScore] - Latest independent validation score
31
32
  * @param {Array<{what: string, why: string}>} [context.decisions] - Recent decisions
32
33
  * @param {string[]} [context.nextSteps] - Planned next steps
33
34
  * @returns {{ updated: boolean, path: string }}
34
35
  */
35
- export function updateClaudeMd(projectDir, context = {}) {
36
- const claudePath = join(projectDir, 'CLAUDE.md');
36
+ export function updateProjectContext(projectDir, context = {}) {
37
+ const contextPath = join(projectDir, '.chati', 'project-context.md');
37
38
  const timestamp = new Date().toISOString().split('T')[0];
38
39
 
39
40
  // Build the new section content
@@ -42,7 +43,13 @@ export function updateClaudeMd(projectDir, context = {}) {
42
43
  if (context.currentAgent) lines.push(`- **Agent**: ${context.currentAgent}`);
43
44
  if (context.pipelinePosition) lines.push(`- **Pipeline**: ${context.pipelinePosition}`);
44
45
  if (context.activeTask) lines.push(`- **Task**: ${context.activeTask}`);
45
- if (context.progress !== undefined) lines.push(`- **Progress**: ${context.progress}%`);
46
+ const progress = typeof context.progress === 'number'
47
+ ? context.progress
48
+ : (context.progress?.percent ?? context.progress?.progress);
49
+ if (progress !== undefined) lines.push(`- **Progress**: ${progress}%`);
50
+ if (typeof context.validationScore === 'number') {
51
+ lines.push(`- **Validation Score**: ${context.validationScore}`);
52
+ }
46
53
  lines.push(`- **Last Updated**: ${timestamp}`);
47
54
 
48
55
  if (context.decisions && context.decisions.length > 0) {
@@ -65,39 +72,51 @@ export function updateClaudeMd(projectDir, context = {}) {
65
72
  const newSection = lines.join('\n');
66
73
 
67
74
  try {
68
- if (!existsSync(claudePath)) {
69
- // Create CLAUDE.md with just the Current State section
70
- writeFileSync(claudePath, newSection + '\n', 'utf-8');
71
- return { updated: true, path: claudePath };
75
+ mkdirSync(join(projectDir, '.chati'), { recursive: true });
76
+ if (!existsSync(contextPath)) {
77
+ writeFileSync(contextPath, newSection + '\n', 'utf-8');
78
+ return { updated: true, path: contextPath };
72
79
  }
73
80
 
74
- const content = readFileSync(claudePath, 'utf-8');
81
+ const content = readFileSync(contextPath, 'utf-8');
75
82
 
76
83
  // Handle empty file as creation
77
84
  if (!content || content.trim().length === 0) {
78
- writeFileSync(claudePath, newSection + '\n', 'utf-8');
79
- return { updated: true, path: claudePath };
85
+ writeFileSync(contextPath, newSection + '\n', 'utf-8');
86
+ return { updated: true, path: contextPath };
80
87
  }
81
88
 
82
89
  let updatedContent;
83
90
  if (SECTION_REGEX.test(content)) {
84
91
  // Replace existing section
85
- updatedContent = content.replace(SECTION_REGEX, newSection);
92
+ updatedContent = content.replace(SECTION_REGEX, `${newSection}\n`);
86
93
  } else {
87
- // Find insertion point after first heading or at the top
88
- const firstHeadingEnd = content.indexOf('\n## ');
89
- if (firstHeadingEnd !== -1) {
90
- // Insert before the first ## section
91
- updatedContent = content.slice(0, firstHeadingEnd) + '\n\n' + newSection + '\n' + content.slice(firstHeadingEnd);
94
+ // Managed installer metadata must remain replaceable without swallowing
95
+ // runtime state. Insert state immediately after that managed block.
96
+ const managedEnd = '<!-- chati-context:end -->';
97
+ const managedEndIndex = content.indexOf(managedEnd);
98
+ if (managedEndIndex !== -1) {
99
+ const insertionPoint = managedEndIndex + managedEnd.length;
100
+ updatedContent = content.slice(0, insertionPoint) + '\n\n' + newSection + content.slice(insertionPoint);
92
101
  } else {
93
- // Append at end
94
- updatedContent = content.trimEnd() + '\n\n' + newSection + '\n';
102
+ // Legacy context: insert before the first secondary section.
103
+ const firstHeadingEnd = content.indexOf('\n## ');
104
+ if (firstHeadingEnd !== -1) {
105
+ // Insert before the first ## section
106
+ updatedContent = content.slice(0, firstHeadingEnd) + '\n\n' + newSection + '\n' + content.slice(firstHeadingEnd);
107
+ } else {
108
+ // Append at end
109
+ updatedContent = content.trimEnd() + '\n\n' + newSection + '\n';
110
+ }
95
111
  }
96
112
  }
97
113
 
98
- writeFileSync(claudePath, updatedContent, 'utf-8');
99
- return { updated: true, path: claudePath };
114
+ writeFileSync(contextPath, updatedContent, 'utf-8');
115
+ return { updated: true, path: contextPath };
100
116
  } catch { /* expected: operation may fail gracefully */
101
- return { updated: false, path: claudePath };
117
+ return { updated: false, path: contextPath };
102
118
  }
103
119
  }
120
+
121
+ // Backward-compatible export for integrations compiled against the old name.
122
+ export const updateClaudeMd = updateProjectContext;
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { resolveFrameworkDir } from '../utils/framework-dir.js';
15
+ import { parseProviderConfig } from '../utils/config-parser.js';
15
16
  import { isFeatureEnabled } from '../utils/feature-flags.js';
16
17
  import { runScriptTask, formatScriptHandoff } from '../executors/runner.js';
17
18
 
@@ -52,7 +53,7 @@ import {
52
53
  import {
53
54
  recordEvent, EventType, clearTimeline,
54
55
  } from '../intelligence/timeline.js';
55
- import { updateClaudeMd } from '../memory/magic-docs.js';
56
+ import { updateProjectContext } from '../memory/magic-docs.js';
56
57
  import {
57
58
  loadRuntimeInstallationV2, resolveRuntimeInvocationV2,
58
59
  describeRuntimeInstallationV2, V2_PLANNING_AGENTS,
@@ -571,13 +572,14 @@ function sessionToPipelineState(session, projectDir) {
571
572
  * Exported for testing (path resolution must hold in every install layout).
572
573
  */
573
574
  export function buildSpawnCommand(agent, projectDir, previousAgent, provider, timeout, model = null, strictProvider = false, routing = {}) {
575
+ if (!provider) throw new Error('A routed provider is required to build an agent spawn command');
574
576
  const parts = [
575
577
  'node', RUNNER('run-agent.js'),
576
578
  '--agent', agent,
577
579
  '--task-id', `${agent}-task`,
578
580
  '--project-dir', JSON.stringify(resolve(projectDir)),
579
581
  '--previous-agent', previousAgent || 'none',
580
- '--provider', provider || 'claude',
582
+ '--provider', provider,
581
583
  ...(model ? ['--model', model] : []),
582
584
  ...(routing.provider_id ? ['--provider-id', routing.provider_id] : []),
583
585
  ...(routing.reasoning_configuration ? ['--reasoning-configuration', routing.reasoning_configuration] : []),
@@ -593,6 +595,7 @@ export function buildSpawnCommand(agent, projectDir, previousAgent, provider, ti
593
595
  * Exported for testing.
594
596
  */
595
597
  export function buildParallelSpawnCommand(agents, projectDir, previousAgent, provider, timeout, model = null, strictProvider = false) {
598
+ if (!provider) throw new Error('A routed provider is required to build a parallel spawn command');
596
599
  const taskIds = agents.map(a => `${a}-task`).join(',');
597
600
  const parts = [
598
601
  'node', RUNNER('run-parallel.js'),
@@ -600,7 +603,7 @@ export function buildParallelSpawnCommand(agents, projectDir, previousAgent, pro
600
603
  '--task-ids', taskIds,
601
604
  '--project-dir', JSON.stringify(resolve(projectDir)),
602
605
  '--previous-agent', previousAgent || 'none',
603
- '--provider', provider || 'claude',
606
+ '--provider', provider,
604
607
  ...(model ? ['--model', model] : []),
605
608
  ...(strictProvider ? ['--strict-provider'] : []),
606
609
  '--timeout', String(timeout || 900000),
@@ -622,10 +625,10 @@ export function buildRoutedParallelSpawnCommands(agents, projectDir, previousAge
622
625
  }
623
626
 
624
627
  /**
625
- * Model map canonical model tier per agent.
626
- * Matches framework/hooks/model-governance.js AGENT_MODELS.
628
+ * Legacy v1 model map retained only for pre-v2 installations. New installs
629
+ * resolve exact task bindings from the signed capability snapshot.
627
630
  */
628
- const AGENT_MODEL_MAP = {
631
+ const LEGACY_V1_AGENT_MODEL_MAP = {
629
632
  'greenfield-wu': { provider: 'claude', model: 'haiku', upgrade: 'sonnet if multi-stack or enterprise' },
630
633
  'brownfield-wu': { provider: 'claude', model: 'opus', upgrade: 'no downgrade' },
631
634
  brief: { provider: 'claude', model: 'sonnet', upgrade: 'opus if enterprise or 10+ integrations' },
@@ -643,7 +646,8 @@ const AGENT_MODEL_MAP = {
643
646
 
644
647
  /**
645
648
  * Resolve provider and model for an agent.
646
- * Checks config.yaml for agent_overrides, falls back to AGENT_MODEL_MAP defaults.
649
+ * V2 uses the signed task catalog. Pre-v2 installations retain their explicit
650
+ * compatibility map and optional config overrides.
647
651
  */
648
652
  export function resolveAgentModel(agent, projectDir) {
649
653
  const v2Artifact = loadRuntimeInstallationV2(projectDir);
@@ -658,7 +662,7 @@ export function resolveAgentModel(agent, projectDir) {
658
662
  source: 'installation-v2',
659
663
  };
660
664
  }
661
- const defaults = AGENT_MODEL_MAP[agent] || { provider: 'claude', model: 'sonnet', upgrade: '' };
665
+ const defaults = LEGACY_V1_AGENT_MODEL_MAP[agent] || { provider: 'claude', model: 'sonnet', upgrade: '' };
662
666
 
663
667
  // Check config.yaml for agent_overrides
664
668
  try {
@@ -690,7 +694,7 @@ function estimateContextBracket(completedCount, totalCount) {
690
694
  }
691
695
 
692
696
  /**
693
- * Write session lock block to CLAUDE.local.md.
697
+ * Write session lock blocks to every enabled harness's native local file.
694
698
  */
695
699
  function replaceBlock(content, startMarker, endMarker, newInner) {
696
700
  const block = `${startMarker}\n${newInner}\n${endMarker}`;
@@ -704,6 +708,7 @@ function replaceBlock(content, startMarker, endMarker, newInner) {
704
708
 
705
709
  function sessionLockTargets(projectDir) {
706
710
  const frameworkDir = resolveFrameworkDir(projectDir);
711
+ const enabled = new Set(parseProviderConfig(projectDir).enabled);
707
712
  return [
708
713
  { path: join(projectDir, 'CLAUDE.local.md'), invocation: '/chati', provider: 'claude' },
709
714
  { path: join(projectDir, 'AGENTS.override.md'), invocation: '$chati', provider: 'codex' },
@@ -714,7 +719,7 @@ function sessionLockTargets(projectDir) {
714
719
  orchestratorPath: existsSync(join(projectDir, frameworkDir, '.adapted', target.provider, 'orchestrator', 'chati.md'))
715
720
  ? `${frameworkDir}/.adapted/${target.provider}/orchestrator/chati.md`
716
721
  : `${frameworkDir}/orchestrator/chati.md`,
717
- })).filter(({ path }) => path.endsWith('CLAUDE.local.md') || existsSync(path));
722
+ })).filter(({ provider, path }) => enabled.has(provider) && existsSync(path));
718
723
  }
719
724
 
720
725
  function removeLegacyPauseSection(content) {
@@ -763,7 +768,7 @@ function writeSessionLock(projectDir, currentAgent, stateInfo = {}) {
763
768
  }
764
769
 
765
770
  /**
766
- * Remove session lock block from CLAUDE.local.md.
771
+ * Remove session lock blocks from every enabled harness's native local file.
767
772
  */
768
773
  function removeSessionLock(projectDir, resumeMsg) {
769
774
  const stateInner = `## Current State
@@ -1352,15 +1357,15 @@ async function handleScaffoldDecision(projectDir, session, decision, agent, scor
1352
1357
 
1353
1358
  const nextAgent = advanceResult.nextAgent || '';
1354
1359
  const progress = getPipelineProgress(advanceResult.state || pipelineState);
1355
- try { updateClaudeMd(projectDir, { currentAgent: nextAgent, progress }); } catch { /* non-fatal */ }
1360
+ try { updateProjectContext(projectDir, { currentAgent: nextAgent, progress: progress.progress }); } catch { /* non-fatal */ }
1356
1361
  try {
1357
1362
  if (nextAgent) {
1358
1363
  writeSessionLock(projectDir, nextAgent, {
1359
1364
  phase: updates.mode || session.mode,
1360
1365
  mode: session.execution_mode || 'interactive',
1361
1366
  position: updates.pipeline_position,
1362
- total: progress.total,
1363
- progress: progress.percent,
1367
+ total: progress.totalAgents,
1368
+ progress: progress.progress,
1364
1369
  });
1365
1370
  }
1366
1371
  } catch { /* non-fatal */ }
@@ -1954,9 +1959,9 @@ async function handleAdvance(projectDir, args) {
1954
1959
  phase: 'rail', mode: session.execution_mode || 'interactive',
1955
1960
  position: runtimePipeline.length, total: runtimePipeline.length, progress: 100,
1956
1961
  });
1957
- updateClaudeMd(projectDir, {
1962
+ updateProjectContext(projectDir, {
1958
1963
  currentAgent: 'rail',
1959
- progress: { progress: 100, completedCount: runtimePipeline.length, totalAgents: runtimePipeline.length },
1964
+ progress: 100,
1960
1965
  });
1961
1966
  } catch { /* non-fatal: the canonical session state has already been persisted */ }
1962
1967
  return {
@@ -2069,12 +2074,12 @@ async function handleAdvance(projectDir, args) {
2069
2074
  return errorResult(`Session update failed: ${saveResult.error}`, 'SESSION_WRITE_FAILED');
2070
2075
  }
2071
2076
 
2072
- // Refresh Magic Docs: CLAUDE.md (public) + CLAUDE.local.md (runtime lock+state).
2077
+ // Refresh the provider-neutral project context and native runtime locks.
2073
2078
  // Non-fatal: if updates fail, pipeline state already advanced on disk.
2074
2079
  const nextAgent = advanceResult.nextAgent || '';
2075
2080
  const progress = getPipelineProgress(advanceResult.state || pipelineState);
2076
2081
  try {
2077
- updateClaudeMd(projectDir, { currentAgent: nextAgent, progress });
2082
+ updateProjectContext(projectDir, { currentAgent: nextAgent, progress: progress.progress });
2078
2083
  } catch { /* non-fatal */ }
2079
2084
  try {
2080
2085
  if (nextAgent) {
@@ -2082,8 +2087,8 @@ async function handleAdvance(projectDir, args) {
2082
2087
  phase: updates.mode || session.mode,
2083
2088
  mode: session.execution_mode || 'interactive',
2084
2089
  position: updates.pipeline_position,
2085
- total: progress.total,
2086
- progress: progress.percent,
2090
+ total: progress.totalAgents,
2091
+ progress: progress.progress,
2087
2092
  });
2088
2093
  } else {
2089
2094
  // Pipeline complete: reset lock to INACTIVE
@@ -2237,6 +2242,7 @@ async function handleInit(projectDir, args) {
2237
2242
  let preservedName = args.name || '';
2238
2243
  let preservedIdes = [];
2239
2244
  let preservedMcps = [];
2245
+ let preservedProviders = [];
2240
2246
  try {
2241
2247
  const existingPath = join(projectDir, '.chati', 'session.yaml');
2242
2248
  if (existsSync(existingPath)) {
@@ -2244,8 +2250,12 @@ async function handleInit(projectDir, args) {
2244
2250
  preservedName = preservedName || existing.project?.name || '';
2245
2251
  preservedIdes = existing.ides || [];
2246
2252
  preservedMcps = existing.mcps || [];
2253
+ preservedProviders = existing.providers_enabled || [];
2247
2254
  }
2248
2255
  } catch { /* expected: session may not exist */ }
2256
+ if (preservedProviders.length === 0) {
2257
+ preservedProviders = parseProviderConfig(projectDir).enabled;
2258
+ }
2249
2259
  const name = preservedName;
2250
2260
 
2251
2261
  const isGreenfield = type === 'greenfield';
@@ -2289,6 +2299,7 @@ async function handleInit(projectDir, args) {
2289
2299
  language,
2290
2300
  ides: preservedIdes,
2291
2301
  mcps: preservedMcps,
2302
+ providersEnabled: preservedProviders,
2292
2303
  });
2293
2304
 
2294
2305
  if (!result.created) {
@@ -2395,7 +2406,7 @@ async function handleStatus(projectDir) {
2395
2406
  // (no context_* fields yet), the block falls back to estimateContextBracket
2396
2407
  // so the existing response shape stays stable for consumers.
2397
2408
  const activeModel = session.active_model || null;
2398
- const activeProvider = session.active_provider || (session.providers_enabled || ['claude'])[0];
2409
+ const activeProvider = session.active_provider || (session.providers_enabled || [])[0] || null;
2399
2410
  const windowTokens = session.context_window_tokens != null
2400
2411
  ? session.context_window_tokens
2401
2412
  : resolveContextLimit(activeModel, activeProvider);
@@ -2758,12 +2769,9 @@ function generateTeamId(slug) {
2758
2769
  }
2759
2770
 
2760
2771
  /**
2761
- * Agent Teams require BOTH the feature flag AND a Claude provider.
2762
- *
2763
- * Why provider gate: Agent Teams uses Claude Code's native Task tool / subagent
2764
- * spawn API. Gemini and Codex have no equivalent — calling spawn_team for those
2765
- * providers would fail. We always fall back silently to spawn_parallel
2766
- * (Gemini) or spawn_autonomous (Codex) when the active provider is not claude.
2772
+ * The legacy native team runner requires an explicitly active Claude harness.
2773
+ * Runtime v2 always uses the strict provider-neutral parallel runner, whose
2774
+ * members keep their own signed provider and model bindings.
2767
2775
  */
2768
2776
  export function isAgentTeamsEnabled(projectDir) {
2769
2777
  // The v1 Agent Teams runner is Claude-specific. Provider-neutral v2 routes
@@ -2777,26 +2785,15 @@ export function isAgentTeamsEnabled(projectDir) {
2777
2785
 
2778
2786
  // Feature flag check
2779
2787
  const flagMatch = raw.match(/agent_teams:\s*(true|false)/);
2780
- // If field is explicitly set, respect it
2781
- if (flagMatch) {
2782
- if (flagMatch[1] !== 'true') return false;
2783
- } else {
2784
- // If field is absent (pre-v4.2.0 projects), default to true for Claude provider
2785
- // Claude Code supports Agent tool natively; Gemini/Codex fall back to sequential
2786
- const providerMatch = raw.match(/primary_provider:\s*["']?(\w+)/);
2787
- const provider = providerMatch ? providerMatch[1].toLowerCase() : 'claude';
2788
- if (provider !== 'claude') return false;
2789
- }
2788
+ if (!flagMatch || flagMatch[1] !== 'true') return false;
2790
2789
 
2791
- // Provider gate Agent Teams is Claude-only.
2792
- // Read primary_provider from session.yaml (preferred) or active_provider
2793
- // field. Default to 'claude' if neither exists (early bootstrap).
2790
+ // Never infer a primary harness. The legacy native team path is available
2791
+ // only while the session explicitly records Claude as the active provider.
2794
2792
  const sessionPath = join(projectDir, '.chati', 'session.yaml');
2795
- if (!existsSync(sessionPath)) return true; // No session yet — assume claude
2793
+ if (!existsSync(sessionPath)) return false;
2796
2794
  const sessionRaw = readFileSync(sessionPath, 'utf-8');
2797
- const providerMatch = sessionRaw.match(/^\s*(?:active_provider|primary_provider):\s*["']?(\w+)/m);
2798
- const provider = providerMatch ? providerMatch[1] : 'claude';
2799
- return provider === 'claude';
2795
+ const providerMatch = sessionRaw.match(/^\s*active_provider:\s*["']?(\w+)/m);
2796
+ return providerMatch?.[1] === 'claude';
2800
2797
  }
2801
2798
 
2802
2799
  async function handleSpawnTeam(projectDir, args) {
@@ -2890,7 +2887,15 @@ async function handleSpawnTeam(projectDir, args) {
2890
2887
 
2891
2888
  // Update session with team entry
2892
2889
  const previousAgent = args['previous-agent'] || 'none';
2893
- const provider = args.provider || 'claude';
2890
+ const { session: activeSession } = loadSession(projectDir);
2891
+ const provider = activeSession?.active_provider || null;
2892
+ if (provider !== 'claude') {
2893
+ return {
2894
+ action: 'spawn_parallel',
2895
+ fallback_required: true,
2896
+ fallback_reason: 'native_team_requires_explicit_claude_session',
2897
+ };
2898
+ }
2894
2899
 
2895
2900
  try {
2896
2901
  const { loaded, session } = loadSession(projectDir);
@@ -8,7 +8,7 @@
8
8
  import { buildHandoff, saveHandoff, loadHandoff } from '../tasks/handoff.js';
9
9
  import { readAgentMemory } from '../memory/agent-memory.js';
10
10
  import { getRelevantGotchas } from '../memory/gotchas.js';
11
- import { updateClaudeMd } from '../memory/magic-docs.js';
11
+ import { updateProjectContext } from '../memory/magic-docs.js';
12
12
  import { generateContextFiles } from '../config/context-file-generator.js';
13
13
  import { existsSync, readdirSync } from 'fs';
14
14
  import { join } from 'path';
@@ -68,14 +68,16 @@ export function executeHandoff(projectDir, params) {
68
68
  };
69
69
  }
70
70
 
71
- // Auto-update CLAUDE.md with current state (Magic Docs)
71
+ // Update the provider-neutral Chati project context (Magic Docs).
72
72
  try {
73
73
  const agentName = params.fromTask?.agent || params.fromTask?.name || params.fromTask?.id?.split('-')[0] || 'agent';
74
- updateClaudeMd(projectDir, {
74
+ updateProjectContext(projectDir, {
75
75
  currentAgent: agentName,
76
76
  pipelinePosition: handoff.status || 'in_progress',
77
77
  activeTask: params.fromTask?.title || params.fromTask?.id || null,
78
- progress: params.validation?.score || null,
78
+ validationScore: typeof params.validation?.score === 'number'
79
+ ? params.validation.score
80
+ : null,
79
81
  decisions: params.decisions
80
82
  ? Object.entries(params.decisions).map(([what, why]) => ({ what, why: String(why) }))
81
83
  : [],
@@ -85,7 +87,7 @@ export function executeHandoff(projectDir, params) {
85
87
  });
86
88
  } catch { /* Magic Docs update is non-critical */ }
87
89
 
88
- // Sync GEMINI.md and AGENTS.md with updated CLAUDE.md (multi-CLI parity)
90
+ // Regenerate native harness contexts from the neutral canonical artifact.
89
91
  try {
90
92
  generateContextFiles(projectDir);
91
93
  } catch { /* Context file sync is non-critical */ }
@@ -135,7 +135,7 @@ const DEFAULT_SESSION = {
135
135
  // Execution Mode (Article XVII) — canonical. Article XVIII was deprecated
136
136
  // 2026-04-18: execution_profile + profile_transitions were folded into
137
137
  // execution_mode (set in DEFAULT_SESSION above as 'interactive'/'autonomous').
138
- providers_enabled: ['claude'],
138
+ providers_enabled: [],
139
139
  // BUILD → DEPLOY preview state (user approves before devops ships).
140
140
  preview: {
141
141
  status: 'inactive',
@@ -176,6 +176,7 @@ export function initSession(projectDir, options = {}) {
176
176
  started_at: new Date().toISOString(),
177
177
  ides: options.ides || [],
178
178
  mcps: options.mcps || [],
179
+ providers_enabled: options.providersEnabled || [],
179
180
  };
180
181
 
181
182
  try {
@@ -219,7 +220,8 @@ export function migrateSession(session) {
219
220
  if (entry.task_id && entry.provider && entry.model) return entry;
220
221
  const model = entry.actual || entry.recommended;
221
222
  if (!entry.agent || !model || !entry.timestamp) return entry;
222
- const provider = entry.provider || 'claude';
223
+ const provider = entry.provider || inferLegacyProvider(model);
224
+ if (!provider) return entry;
223
225
  if (!['claude', 'codex', 'grok', 'gemini'].includes(provider)) return entry;
224
226
  normalizedLegacySelections = true;
225
227
  return {
@@ -279,7 +281,17 @@ export function migrateSession(session) {
279
281
  if (!Array.isArray(session.decision_trail)) session.decision_trail = [];
280
282
  if (!Array.isArray(session.teams)) session.teams = [];
281
283
  if (!Array.isArray(session.team_events)) session.team_events = [];
282
- if (!Array.isArray(session.providers_enabled)) session.providers_enabled = ['claude'];
284
+ if (!Array.isArray(session.providers_enabled)) {
285
+ const providersByIde = {
286
+ 'claude-code': 'claude',
287
+ 'gemini-cli': 'gemini',
288
+ 'codex-cli': 'codex',
289
+ 'grok-cli': 'grok',
290
+ };
291
+ const inferred = (session.ides || []).map(ide => providersByIde[ide]).filter(Boolean);
292
+ if (session.active_provider) inferred.unshift(session.active_provider);
293
+ session.providers_enabled = [...new Set(inferred)];
294
+ }
283
295
  if (!Array.isArray(session.model_selections)) session.model_selections = [];
284
296
  if (!session.preview || typeof session.preview !== 'object') {
285
297
  session.preview = {
@@ -329,6 +341,15 @@ export function migrateSession(session) {
329
341
  return { migrated: true, fromVersion, toVersion: CURRENT_SCHEMA_VERSION };
330
342
  }
331
343
 
344
+ function inferLegacyProvider(model) {
345
+ const normalized = String(model || '').toLowerCase();
346
+ if (/claude|opus|sonnet|haiku|fable/.test(normalized)) return 'claude';
347
+ if (/grok/.test(normalized)) return 'grok';
348
+ if (/gemini/.test(normalized)) return 'gemini';
349
+ if (/gpt|codex|terra|luna/.test(normalized)) return 'codex';
350
+ return null;
351
+ }
352
+
332
353
  /**
333
354
  * Load current session state.
334
355
  * @param {string} projectDir
@@ -128,7 +128,7 @@ export function getAllProviders() {
128
128
  * Load enabled providers from project config.yaml.
129
129
  *
130
130
  * @param {string} projectDir - Project root directory
131
- * @returns {{ primary: string, enabled: string[] }}
131
+ * @returns {{ primary: string|null, enabled: string[] }}
132
132
  */
133
133
  export function loadEnabledProviders(projectDir) {
134
134
  const { primary, enabled } = parseProviderConfig(projectDir);
@@ -136,7 +136,7 @@ export function loadEnabledProviders(projectDir) {
136
136
 
137
137
  // Filter out invalid provider names (typos in config.yaml)
138
138
  const validEnabled = enabled.filter(name => validNames.includes(name));
139
- const validPrimary = validNames.includes(primary) ? primary : validEnabled[0] || null;
139
+ const validPrimary = validNames.includes(primary) ? primary : null;
140
140
 
141
141
  return { primary: validPrimary, enabled: validEnabled };
142
142
  }
@@ -148,7 +148,7 @@ export function loadEnabledProviders(projectDir) {
148
148
  * @param {string} agent - Agent name
149
149
  * @param {string} projectDir - Project root directory
150
150
  * @param {Record<string, {provider: string, model: string, tier: string}>} agentModels - Agent model assignments
151
- * @returns {{ provider: string, model: string }}
151
+ * @returns {{ provider: string|null, model: string|null }}
152
152
  */
153
153
  export function resolveProviderForAgent(agent, projectDir, agentModels) {
154
154
  const { primary, enabled } = loadEnabledProviders(projectDir);
@@ -174,8 +174,8 @@ export function resolveProviderForAgent(agent, projectDir, agentModels) {
174
174
  // Legacy fallback only. Installation v2 routes from the signed task catalog.
175
175
  const fallbackProvider = primary || enabled[0];
176
176
  const provider = PROVIDERS[fallbackProvider];
177
- const defaultModel = provider ? Object.keys(provider.modelMap)[0] : 'sonnet';
178
- return { provider: fallbackProvider, model: defaultModel };
177
+ const defaultModel = provider ? Object.keys(provider.modelMap)[0] : null;
178
+ return { provider: fallbackProvider || null, model: defaultModel };
179
179
  }
180
180
 
181
181
  /**
@@ -2,7 +2,7 @@
2
2
  * @fileoverview Prompt builder for multi-terminal agent execution.
3
3
  *
4
4
  * Builds a complete, self-contained prompt for spawned agent terminals.
5
- * When an agent runs in a separate `claude -p` process, it has zero
5
+ * When an agent runs in a separate provider process, it has zero
6
6
  * conversation history — the prompt must contain everything: PRISM
7
7
  * context, agent definition, previous handoff, write scope, and
8
8
  * output format instructions.
@@ -18,7 +18,7 @@ import { resolveOverlayPath } from '../installer/provider-overlay.js';
18
18
  import { resolveProviderForAgent } from './cli-registry.js';
19
19
  import { buildCompactGotchasSummary } from '../memory/gotchas-injector.js';
20
20
  import { estimateTokens } from './cost-tracker.js';
21
- import { PROVIDER_LIMITS as PROVIDER_TOKEN_LIMITS } from '../utils/provider-limits.js';
21
+ import { resolveContextLimit } from '../utils/provider-limits.js';
22
22
 
23
23
  // Import AGENT_MODELS from model-governance (safe — named export,
24
24
  // does not trigger main() which is guarded by fileURLToPath check).
@@ -152,6 +152,10 @@ export function buildAgentPrompt(config) {
152
152
  if (!config.projectDir) {
153
153
  throw new Error('buildAgentPrompt requires config.projectDir');
154
154
  }
155
+ const runtimeV2 = existsSync(join(config.projectDir, '.chati', 'v2', 'installation.json'));
156
+ if (runtimeV2 && (!config.provider || !config.model)) {
157
+ throw new Error('Runtime v2 requires an exact routed provider and model binding');
158
+ }
155
159
 
156
160
  const sections = [];
157
161
 
@@ -203,8 +207,8 @@ export function buildAgentPrompt(config) {
203
207
  const resolved = (config.projectDir && AGENT_MODELS[config.agent])
204
208
  ? resolveProviderForAgent(config.agent, config.projectDir, AGENT_MODELS)
205
209
  : { provider: staticAssignment.provider, model: staticAssignment.model };
206
- const resolvedProvider = config.provider || resolved.provider || 'claude';
207
- const model = config.model || resolved.model || staticAssignment.model || 'sonnet';
210
+ const resolvedProvider = config.provider || resolved.provider || staticAssignment.provider;
211
+ const model = config.model || resolved.model || staticAssignment.model;
208
212
 
209
213
  // 8. Session context (uses resolved model/provider)
210
214
  sections.push(buildSessionSection(config, { model, provider: resolvedProvider }));
@@ -391,8 +395,8 @@ function buildSessionSection(config, resolvedModelInfo = {}) {
391
395
  `- **User Level**: ${state.user_level || 'auto'}`,
392
396
  `- **Execution Mode**: ${state.execution_mode || 'autonomous'}`,
393
397
  `- **Your Agent**: ${config.agent}`,
394
- `- **Your Model**: ${resolvedModelInfo.model || 'sonnet'}`,
395
- `- **Your Provider**: ${resolvedModelInfo.provider || 'claude'}`,
398
+ `- **Your Model**: ${resolvedModelInfo.model || 'unbound'}`,
399
+ `- **Your Provider**: ${resolvedModelInfo.provider || 'unbound'}`,
396
400
  ];
397
401
 
398
402
  return lines.join('\n');
@@ -479,11 +483,11 @@ export function buildAntiLazinessSection(agent) {
479
483
  * Validate prompt size against provider-specific limits.
480
484
  *
481
485
  * @param {string} prompt - The assembled prompt string
482
- * @param {string} [provider='claude'] - Provider name for limit lookup
486
+ * @param {string|null} [provider=null] - Provider name for limit lookup
483
487
  * @returns {{ valid: boolean, level: string, ratio: number, estimatedTokens: number, limit: number, message: string|null }}
484
488
  */
485
- export function validatePromptSize(prompt, provider = 'claude') {
486
- const limit = PROVIDER_TOKEN_LIMITS[provider] || PROVIDER_TOKEN_LIMITS.claude;
489
+ export function validatePromptSize(prompt, provider = null) {
490
+ const limit = resolveContextLimit(undefined, provider);
487
491
  const tokens = estimateTokens(prompt);
488
492
  const ratio = tokens / limit;
489
493
 
@@ -737,7 +741,7 @@ export function buildPresetSection(preset) {
737
741
  * Build output format instructions so the agent produces a parseable handoff.
738
742
  */
739
743
  function buildOutputInstructions(config) {
740
- const providerValue = config?.provider || 'claude';
744
+ const providerValue = config?.provider || 'unbound';
741
745
  const modelValue = config?.model || 'unknown';
742
746
 
743
747
  return `<!-- OUTPUT INSTRUCTIONS -->