chati-dev 4.5.6 → 4.5.7

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.
@@ -1,8 +1,8 @@
1
1
  # chati.dev Configuration
2
- version: "4.5.6"
2
+ version: "4.5.7"
3
3
  installed_at: "2026-02-07T10:00:00Z"
4
4
  updated_at: "2026-08-27T00:00:00Z"
5
- installer_version: "4.5.6"
5
+ installer_version: "4.5.7"
6
6
  project_type: greenfield
7
7
  language: en
8
8
  ides: []
@@ -1,7 +1,7 @@
1
1
  # Chati.dev System Context
2
2
 
3
3
  ## Framework
4
- - **Version**: 4.5.6
4
+ - **Version**: 4.5.7
5
5
  - **Agents**: Specialized discovery and planning agents feeding provider-neutral RAIL execution and a sealed release lane
6
6
  - **Constitution**: 25 Articles + Preamble
7
7
  - **Quality**: 5 pipeline gates + 3-tier verdicts + Fault Vector Protocol (Article XXII)
@@ -3,7 +3,7 @@
3
3
  # and by the Health Check for system integrity validation.
4
4
 
5
5
  metadata:
6
- version: "4.5.6"
6
+ version: "4.5.7"
7
7
  last_updated: "2026-08-27T00:00:00Z"
8
8
  entity_count: 109
9
9
  checksum_algorithm: sha256
@@ -1,5 +1,5 @@
1
1
  {
2
- "createdAt": "2026-08-27T09:30:26.218Z",
2
+ "createdAt": "2026-08-27T10:57:24.520Z",
3
3
  "files": {
4
4
  "agents/build/dev.md": {
5
5
  "hash": "5c8da2eee3357bbe84a6c468c76fe60a0c77357575503c3797aa94128282c565",
@@ -97,7 +97,7 @@
97
97
  "type": "md"
98
98
  },
99
99
  "config.yaml": {
100
- "hash": "77e84e99a6e31a664e9da640df949ef4d816359979ac0be093eb7e2c726298bc",
100
+ "hash": "394515115a919ac4b838cd58b3cf8f7cb34b535909b9a35f2d472cb51cc107ad",
101
101
  "size": 2669,
102
102
  "type": "yaml"
103
103
  },
@@ -122,12 +122,12 @@
122
122
  "type": "md"
123
123
  },
124
124
  "context/root.md": {
125
- "hash": "57e0b456f520705cef15b47a4aa7bb9d159d26222a8e04c094064a0bce323c16",
125
+ "hash": "1058040823bbd394775a017f6b714b5f53ff6030f08113c191d1a477f8635e95",
126
126
  "size": 1217,
127
127
  "type": "md"
128
128
  },
129
129
  "data/entity-registry.yaml": {
130
- "hash": "8852405c96cccf20672111d9367d0ebf87225e79eb966e56353d957ac6d7f037",
130
+ "hash": "8049c68d09ed547d64d1120f10a3f064f1c4f8313eacf8c2d267808e83a07265",
131
131
  "size": 34605,
132
132
  "type": "yaml"
133
133
  },
@@ -852,8 +852,8 @@
852
852
  "type": "json"
853
853
  },
854
854
  "schemas/session.schema.json": {
855
- "hash": "f2ab86653c6e52a008268256198b9319de1602515aafd504bbbbe69df696c1de",
856
- "size": 18014,
855
+ "hash": "57b0aa1b097b2253664a548cad42652ed3ec759feab6d0c1098062e88048cf1e",
856
+ "size": 19069,
857
857
  "type": "json"
858
858
  },
859
859
  "schemas/task.schema.json": {
@@ -1347,5 +1347,5 @@
1347
1347
  "type": "yaml"
1348
1348
  }
1349
1349
  },
1350
- "version": "4.5.6"
1350
+ "version": "4.5.7"
1351
1351
  }
@@ -1 +1 @@
1
- vWBsUGE3V8dJWM3K5vpCFcUAeqdRor+u3DyDsIzUKtXE8le4HVw8hRvfMa9P4PDyoZvrY/smYteiHKZcdXeGDA==
1
+ s4/05J4PlrCMfsXB3OMeVX+lE+7A2MMEycfr10Z+2uwgFh5DlWnSGxkMmCS4dZ3hVAWgSWDk7waAR8kA1JWNAw==
@@ -196,16 +196,33 @@
196
196
  },
197
197
  "model_selections": {
198
198
  "type": "array",
199
- "description": "Model selection log for cost tracking (Article XVI)",
199
+ "description": "Append-only audit log of the exact execution binding used for each task (Article XVI)",
200
200
  "items": {
201
201
  "type": "object",
202
- "required": ["agent", "recommended", "timestamp"],
202
+ "anyOf": [
203
+ {
204
+ "required": ["agent", "task_id", "provider", "model", "timestamp"],
205
+ "properties": {
206
+ "provider": { "enum": ["claude", "codex", "grok", "gemini"] }
207
+ }
208
+ },
209
+ {
210
+ "required": ["agent", "recommended", "timestamp"],
211
+ "not": { "required": ["task_id", "provider", "model"] }
212
+ }
213
+ ],
203
214
  "properties": {
204
215
  "agent": { "type": "string", "description": "Agent name" },
205
- "recommended": { "enum": ["opus", "sonnet", "haiku"], "description": "Recommended model" },
206
- "actual": { "enum": ["opus", "sonnet", "haiku"], "description": "Model actually used" },
207
- "reason": { "type": "string", "description": "Why this model was selected (default, upgrade condition, no downgrade)" },
208
- "provider": { "type": "string", "description": "CLI provider used (claude, gemini, codex)" },
216
+ "task_id": { "type": "string", "description": "Task identity supplied to the runner" },
217
+ "provider": { "type": "string", "description": "CLI harness that executed the task" },
218
+ "provider_id": { "type": ["string", "null"], "description": "Vendor provider identifier from the capability catalog" },
219
+ "model": { "type": "string", "minLength": 1, "description": "Exact model identifier used" },
220
+ "reasoning_configuration": { "enum": ["low", "medium", "high", "xhigh", "max", null], "description": "Reasoning effort passed to the CLI when supported" },
221
+ "catalog_snapshot_ref": { "type": ["string", "null"], "description": "Capability catalog snapshot used for routing" },
222
+ "status": { "enum": ["dispatched", "completed", "failed"], "default": "dispatched" },
223
+ "recommended": { "type": "string", "description": "Legacy pre-4.5.7 recommended model field, migrated on session load" },
224
+ "actual": { "type": "string", "description": "Legacy pre-4.5.7 actual model field, migrated on session load" },
225
+ "reason": { "type": "string", "description": "Legacy pre-4.5.7 selection reason" },
209
226
  "timestamp": { "type": "string", "format": "date-time" }
210
227
  }
211
228
  }
@@ -367,13 +384,13 @@
367
384
  "active_model": {
368
385
  "type": ["string", "null"],
369
386
  "default": null,
370
- "description": "Normalized Claude model key (Article XII v2 / context-window-detection v1). Output of parseClaudeModelId — e.g. 'opus', 'opus[1m]', 'sonnet', 'haiku'. Written at /chati boot; drives context-limit resolution in hooks. Null = unknown (detection chain falls through to provider default)."
387
+ "description": "Model identifier detected for the current interactive harness. Written at /chati boot and used for context-window resolution. Per-task routed models are recorded separately in model_selections[]."
371
388
  },
372
389
  "active_provider": {
373
390
  "type": ["string", "null"],
374
- "enum": ["claude", "gemini", "codex", null],
391
+ "enum": ["claude", "gemini", "codex", "grok", null],
375
392
  "default": null,
376
- "description": "Active CLI provider. Redundant with providers_enabled[0] but flat for hook convenience."
393
+ "description": "Current interactive CLI harness. This is independent from the per-task provider recorded in model_selections[]."
377
394
  },
378
395
  "context_tokens_used": {
379
396
  "type": "integer",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chati-dev",
3
- "version": "4.5.6",
3
+ "version": "4.5.7",
4
4
  "description": "AI-Powered Multi-Agent Orchestration System - Structured vibe coding for Full Stack Development",
5
5
  "type": "module",
6
6
  "bin": {
@@ -77,6 +77,7 @@
77
77
  "chalk": "^5.3.0",
78
78
  "js-yaml": "^5.4.0",
79
79
  "ora": "^8.0.1",
80
+ "proper-lockfile": "4.1.2",
80
81
  "semver": "^7.6.0"
81
82
  },
82
83
  "bundleDependencies": [
@@ -578,6 +578,10 @@ export function updateGitignore(targetDir, selectedIDEs) {
578
578
  '.chati/memories/*/session/',
579
579
  '# NOTE: durable/ and daily/ memories ARE committed — they contain project knowledge',
580
580
  '',
581
+ '# Operating system metadata',
582
+ '.DS_Store',
583
+ '._*',
584
+ '',
581
585
  '# Claude Code integration (framework portions only)',
582
586
  '.claude/commands/chati.md',
583
587
  '.claude/rules/chati/',
@@ -548,7 +548,7 @@ function sessionToPipelineState(session, projectDir) {
548
548
  * Build the spawn command string for an autonomous agent.
549
549
  * Exported for testing (path resolution must hold in every install layout).
550
550
  */
551
- export function buildSpawnCommand(agent, projectDir, previousAgent, provider, timeout, model = null, strictProvider = false) {
551
+ export function buildSpawnCommand(agent, projectDir, previousAgent, provider, timeout, model = null, strictProvider = false, routing = {}) {
552
552
  const parts = [
553
553
  'node', RUNNER('run-agent.js'),
554
554
  '--agent', agent,
@@ -557,6 +557,9 @@ export function buildSpawnCommand(agent, projectDir, previousAgent, provider, ti
557
557
  '--previous-agent', previousAgent || 'none',
558
558
  '--provider', provider || 'claude',
559
559
  ...(model ? ['--model', model] : []),
560
+ ...(routing.provider_id ? ['--provider-id', routing.provider_id] : []),
561
+ ...(routing.reasoning_configuration ? ['--reasoning-configuration', routing.reasoning_configuration] : []),
562
+ ...(routing.catalog_snapshot_ref ? ['--catalog-snapshot-ref', routing.catalog_snapshot_ref] : []),
560
563
  ...(strictProvider ? ['--strict-provider'] : []),
561
564
  '--timeout', String(timeout || 600000),
562
565
  ];
@@ -591,7 +594,7 @@ export function buildRoutedParallelSpawnCommands(agents, projectDir, previousAge
591
594
  provider: modelInfo.provider,
592
595
  model: modelInfo.model,
593
596
  reasoning_configuration: modelInfo.reasoning_configuration,
594
- command: buildSpawnCommand(agent, projectDir, previousAgent, modelInfo.provider, timeout, modelInfo.model, modelInfo.source === 'installation-v2'),
597
+ command: buildSpawnCommand(agent, projectDir, previousAgent, modelInfo.provider, timeout, modelInfo.model, modelInfo.source === 'installation-v2', modelInfo),
595
598
  });
596
599
  });
597
600
  }
@@ -849,7 +852,7 @@ async function _handleNextInner(projectDir) {
849
852
  agent,
850
853
  agent_file: agentFile,
851
854
  phase: agentDef?.phase || session.mode,
852
- spawn_command: buildSpawnCommand(agent, projectDir, session.last_handoff || 'none', modelInfo.provider, 600000, modelInfo.model, modelInfo.source === 'installation-v2'),
855
+ spawn_command: buildSpawnCommand(agent, projectDir, session.last_handoff || 'none', modelInfo.provider, 600000, modelInfo.model, modelInfo.source === 'installation-v2', modelInfo),
853
856
  parallel_spawn_command: null,
854
857
  parallel_agents: [],
855
858
  handoff_status: { valid: true, missing: [], warnings: ['Resuming in-progress agent'] },
@@ -878,7 +881,7 @@ async function _handleNextInner(projectDir) {
878
881
  agent: firstAgent,
879
882
  agent_file: agentFile,
880
883
  phase: 'discover',
881
- spawn_command: buildSpawnCommand(firstAgent, projectDir, 'none', modelInfo.provider, 600000, modelInfo.model, modelInfo.source === 'installation-v2'),
884
+ spawn_command: buildSpawnCommand(firstAgent, projectDir, 'none', modelInfo.provider, 600000, modelInfo.model, modelInfo.source === 'installation-v2', modelInfo),
882
885
  parallel_spawn_command: null,
883
886
  parallel_agents: [],
884
887
  handoff_status: { valid: true, missing: [], warnings: [] },
@@ -979,10 +982,10 @@ async function _handleNextInner(projectDir) {
979
982
  teamData = { team_id: teamId, team_type: 'build', members: teamConfig.members };
980
983
  } else if (isInteractive) {
981
984
  action = 'spawn_routed_interactive';
982
- spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000, modelInfo.model, modelInfo.source === 'installation-v2');
985
+ spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000, modelInfo.model, modelInfo.source === 'installation-v2', modelInfo);
983
986
  } else {
984
987
  action = 'spawn_autonomous';
985
- spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000, modelInfo.model, modelInfo.source === 'installation-v2');
988
+ spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000, modelInfo.model, modelInfo.source === 'installation-v2', modelInfo);
986
989
  }
987
990
 
988
991
  // Surface criteria (Article XVII): even in autonomous mode, a high-stakes
@@ -7,7 +7,8 @@
7
7
 
8
8
  import * as yaml from 'js-yaml';
9
9
  import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
10
- import { join, dirname } from 'path';
10
+ import { join, dirname, resolve } from 'path';
11
+ import lockfile from 'proper-lockfile';
11
12
 
12
13
  const SESSION_FILE = '.chati/session.yaml';
13
14
 
@@ -24,6 +25,35 @@ function writeFileAtomic(path, content, encoding = 'utf-8') {
24
25
  renameSync(tmp, path);
25
26
  }
26
27
 
28
+ function waitForSessionWriteLock(sessionPath, maxWaitMs = 2000) {
29
+ const startedAt = Date.now();
30
+ while (true) {
31
+ try {
32
+ return lockfile.lockSync(sessionPath, {
33
+ realpath: false,
34
+ stale: 30_000,
35
+ update: 10_000,
36
+ });
37
+ } catch (err) {
38
+ if (err.code !== 'ELOCKED') throw err;
39
+ if (Date.now() - startedAt >= maxWaitMs) {
40
+ throw new Error(`Timed out waiting for session write lock after ${maxWaitMs}ms`, { cause: err });
41
+ }
42
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
43
+ }
44
+ }
45
+ }
46
+
47
+ function findSessionProjectDir(startDir) {
48
+ let current = resolve(startDir);
49
+ while (true) {
50
+ if (existsSync(join(current, SESSION_FILE))) return current;
51
+ const parent = dirname(current);
52
+ if (parent === current) return null;
53
+ current = parent;
54
+ }
55
+ }
56
+
27
57
  /**
28
58
  * Default session template.
29
59
  */
@@ -182,8 +212,31 @@ export function migrateSession(session) {
182
212
  return { migrated: false, fromVersion: null, toVersion: CURRENT_SCHEMA_VERSION };
183
213
  }
184
214
 
215
+ let normalizedLegacySelections = false;
216
+ if (Array.isArray(session.model_selections)) {
217
+ session.model_selections = session.model_selections.map((entry) => {
218
+ if (!entry || typeof entry !== 'object') return entry;
219
+ if (entry.task_id && entry.provider && entry.model) return entry;
220
+ const model = entry.actual || entry.recommended;
221
+ if (!entry.agent || !model || !entry.timestamp) return entry;
222
+ const provider = entry.provider || 'claude';
223
+ if (!['claude', 'codex', 'grok', 'gemini'].includes(provider)) return entry;
224
+ normalizedLegacySelections = true;
225
+ return {
226
+ ...entry,
227
+ task_id: entry.task_id || `${entry.agent}-legacy`,
228
+ provider,
229
+ provider_id: entry.provider_id || null,
230
+ model,
231
+ reasoning_configuration: entry.reasoning_configuration || null,
232
+ catalog_snapshot_ref: entry.catalog_snapshot_ref || null,
233
+ status: entry.status || 'dispatched',
234
+ };
235
+ });
236
+ }
237
+
185
238
  if (session.schema_version === CURRENT_SCHEMA_VERSION) {
186
- return { migrated: false, fromVersion: session.schema_version, toVersion: CURRENT_SCHEMA_VERSION };
239
+ return { migrated: normalizedLegacySelections, fromVersion: session.schema_version, toVersion: CURRENT_SCHEMA_VERSION };
187
240
  }
188
241
 
189
242
  // v0 (no schema_version) → v1.0 → v1.1 → v1.2
@@ -294,22 +347,22 @@ export function loadSession(projectDir) {
294
347
 
295
348
  try {
296
349
  const content = readFileSync(sessionPath, 'utf-8');
297
- const session = yaml.load(content);
350
+ let session = yaml.load(content);
298
351
 
299
352
  // Run migration if needed
300
353
  const migration = migrateSession(session);
301
354
  if (migration.migrated) {
302
- try {
303
- const yamlContent = yaml.dump(session, { lineWidth: -1, noRefs: true });
304
- writeFileAtomic(sessionPath, yamlContent);
305
- } catch (writeErr) {
355
+ const persisted = mutateSession(projectDir, current => current);
356
+ if (persisted.saved) {
357
+ session = persisted.session;
358
+ } else {
306
359
  // Migration write-back failed (read-only filesystem, EACCES). The
307
360
  // session continues with the migrated in-memory copy, but disk stays
308
361
  // on the old schema and re-migrates on every load. Surface it so the
309
362
  // user can fix the cause (make .chati/ writable) instead of it failing
310
363
  // silently. The production path (writable FS) never reaches here.
311
364
  process.stderr.write(
312
- `[chati] warning: session migrated in memory but could not be written back to ${sessionPath} (${writeErr.code || writeErr.message}); it will re-migrate on every load until the directory is writable\n`
365
+ `[chati] warning: session migrated in memory but could not be written back to ${sessionPath} (${persisted.error}); it will re-migrate on every load until the directory is writable\n`
313
366
  );
314
367
  }
315
368
  }
@@ -336,115 +389,108 @@ export function loadSession(projectDir) {
336
389
  * @returns {{ saved: boolean }}
337
390
  */
338
391
  export function updateSession(projectDir, updates) {
339
- const sessionPath = join(projectDir, SESSION_FILE);
340
-
341
- // Load existing session
342
- const loadResult = loadSession(projectDir);
343
-
344
- if (!loadResult.loaded) {
345
- return {
346
- saved: false,
347
- error: loadResult.error,
348
- };
349
- }
350
-
351
- try {
392
+ return mutateSession(projectDir, (session) => {
352
393
  // Merge updates
353
394
  const merged = {
354
- ...loadResult.session,
395
+ ...session,
355
396
  ...updates,
356
397
  };
357
398
 
358
399
  // Deep merge agents if provided
359
400
  if (updates.agents) {
360
401
  merged.agents = {
361
- ...loadResult.session.agents,
402
+ ...session.agents,
362
403
  ...updates.agents,
363
404
  };
364
405
 
365
406
  // Merge individual agent data
366
407
  for (const [agentName, agentData] of Object.entries(updates.agents)) {
367
408
  merged.agents[agentName] = {
368
- ...loadResult.session.agents[agentName],
409
+ ...session.agents[agentName],
369
410
  ...agentData,
370
411
  };
371
412
  }
372
413
  }
414
+ return merged;
415
+ });
416
+ }
373
417
 
374
- // Write back
375
- const yamlContent = yaml.dump(merged, {
376
- lineWidth: -1,
377
- noRefs: true,
378
- });
379
-
418
+ function mutateSession(projectDir, mutator) {
419
+ const sessionPath = join(projectDir, SESSION_FILE);
420
+ if (!existsSync(sessionPath)) return { saved: false, error: 'Session file not found' };
421
+ let releaseLock = null;
422
+ let outcome;
423
+ try {
424
+ releaseLock = waitForSessionWriteLock(sessionPath);
425
+ const session = yaml.load(readFileSync(sessionPath, 'utf-8'));
426
+ if (!session || typeof session !== 'object' || Array.isArray(session)) {
427
+ throw new Error('Invalid session document');
428
+ }
429
+ migrateSession(session);
430
+ const nextSession = mutator(session) || session;
431
+ const yamlContent = yaml.dump(nextSession, { lineWidth: -1, noRefs: true });
380
432
  writeFileAtomic(sessionPath, yamlContent);
381
-
382
- return {
383
- saved: true,
384
- session: merged,
385
- };
433
+ outcome = { saved: true, session: nextSession };
386
434
  } catch (err) {
387
- return {
388
- saved: false,
389
- error: `Failed to update session: ${err.message}`,
390
- };
435
+ outcome = { saved: false, error: `Failed to update session: ${err.message}` };
436
+ } finally {
437
+ if (releaseLock) {
438
+ try {
439
+ releaseLock();
440
+ } catch (err) {
441
+ outcome = { saved: false, error: `Session write lock was compromised: ${err.message}` };
442
+ }
443
+ }
391
444
  }
445
+ return outcome;
392
446
  }
393
447
 
394
448
  /**
395
- * Record a mode transition in session history.
396
- * @param {string} projectDir
397
- * @param {object} transition - { from, to, trigger, reason }
398
- * @returns {{ saved: boolean }}
449
+ * Append the exact provider/model binding used for an execution.
450
+ * A short cross-process lock prevents parallel agents from overwriting each
451
+ * other's append-only audit entries.
399
452
  */
400
- export function recordModeTransition(projectDir, transition) {
401
- const loadResult = loadSession(projectDir);
402
-
403
- if (!loadResult.loaded) {
404
- return {
405
- saved: false,
406
- error: loadResult.error,
407
- };
408
- }
409
-
410
- const session = loadResult.session;
411
-
412
- // Add transition to history
413
- session.mode_transitions = session.mode_transitions || [];
414
- session.mode_transitions.push({
415
- from: transition.from,
416
- to: transition.to,
417
- trigger: transition.trigger || 'manual',
418
- reason: transition.reason || '',
419
- timestamp: new Date().toISOString(),
453
+ export function recordModelSelection(projectDir, selection) {
454
+ const sessionProjectDir = findSessionProjectDir(projectDir);
455
+ if (!sessionProjectDir) return { saved: false, error: 'Session file not found in working directory or its parents' };
456
+
457
+ return mutateSession(sessionProjectDir, (session) => {
458
+ session.model_selections = Array.isArray(session.model_selections) ? session.model_selections : [];
459
+ session.model_selections.push({
460
+ agent: selection.agent,
461
+ task_id: selection.taskId,
462
+ provider: selection.provider,
463
+ provider_id: selection.providerId || null,
464
+ model: selection.model,
465
+ reasoning_configuration: selection.reasoningConfiguration || null,
466
+ catalog_snapshot_ref: selection.catalogSnapshotRef || null,
467
+ status: selection.status || 'dispatched',
468
+ timestamp: selection.timestamp || new Date().toISOString(),
469
+ });
470
+ return session;
420
471
  });
421
-
422
- // Update current mode (both flat and nested for compatibility)
423
- session.mode = transition.to;
424
- if (session.project) {
425
- session.project.state = transition.to;
426
- }
427
-
428
- return writeSessionToDisk(projectDir, session);
429
472
  }
430
473
 
431
474
  /**
432
- * Persist the full session object to disk. Avoids routing through updateSession's
433
- * spread+deep-merge logic (which can corrupt list fields like `completed_agents`
434
- * when the full session is passed as the updates arg).
475
+ * Record a mode transition in session history.
435
476
  * @param {string} projectDir
436
- * @param {object} session
437
- * @returns {{ saved: boolean, session?: object, error?: string }}
477
+ * @param {object} transition - { from, to, trigger, reason }
478
+ * @returns {{ saved: boolean }}
438
479
  */
439
- function writeSessionToDisk(projectDir, session) {
440
- const sessionPath = join(projectDir, SESSION_FILE);
441
- try {
442
- const yamlContent = yaml.dump(session, { lineWidth: -1, noRefs: true });
443
- writeFileAtomic(sessionPath, yamlContent);
444
- return { saved: true, session };
445
- } catch (err) {
446
- return { saved: false, error: `Failed to write session: ${err.message}` };
447
- }
480
+ export function recordModeTransition(projectDir, transition) {
481
+ return mutateSession(projectDir, (session) => {
482
+ session.mode_transitions = session.mode_transitions || [];
483
+ session.mode_transitions.push({
484
+ from: transition.from,
485
+ to: transition.to,
486
+ trigger: transition.trigger || 'manual',
487
+ reason: transition.reason || '',
488
+ timestamp: new Date().toISOString(),
489
+ });
490
+ session.mode = transition.to;
491
+ if (session.project) session.project.state = transition.to;
492
+ return session;
493
+ });
448
494
  }
449
495
 
450
496
  /**
@@ -457,17 +503,8 @@ function writeSessionToDisk(projectDir, session) {
457
503
  * @returns {{ saved: boolean }}
458
504
  */
459
505
  export function recordAgentCompletion(projectDir, completion) {
460
- const loadResult = loadSession(projectDir);
461
-
462
- if (!loadResult.loaded) {
463
- return {
464
- saved: false,
465
- error: loadResult.error,
466
- };
467
- }
468
-
469
- const session = loadResult.session;
470
506
  const { agent, status, score, outputs = [], handoffData = {} } = completion;
507
+ return mutateSession(projectDir, (session) => {
471
508
 
472
509
  // Update agent data
473
510
  if (!session.agents[agent]) {
@@ -547,7 +584,8 @@ export function recordAgentCompletion(projectDir, completion) {
547
584
  }
548
585
  }
549
586
 
550
- return writeSessionToDisk(projectDir, session);
587
+ return session;
588
+ });
551
589
  }
552
590
 
553
591
  /**
@@ -19,15 +19,22 @@
19
19
  */
20
20
  export function buildCommand(config, provider) {
21
21
  const args = [...provider.baseArgs];
22
+ let effectiveModel = null;
22
23
 
23
24
  if (config.model) {
24
- const resolvedModel = provider.modelMap[config.model] || config.model;
25
- args.push(provider.modelFlag, resolvedModel);
25
+ effectiveModel = provider.modelMap[config.model] || config.model;
26
+ args.push(provider.modelFlag, effectiveModel);
27
+ }
28
+
29
+ if (config.reasoningConfiguration) {
30
+ args.push('--effort', config.reasoningConfiguration);
26
31
  }
27
32
 
28
33
  return {
29
34
  command: provider.command,
30
35
  args,
31
36
  stdinPrompt: config.prompt || null,
37
+ effectiveModel,
38
+ effectiveReasoningConfiguration: config.reasoningConfiguration || null,
32
39
  };
33
40
  }
@@ -14,10 +14,15 @@
14
14
  */
15
15
  export function buildCommand(config, provider) {
16
16
  const args = [...provider.baseArgs];
17
+ let effectiveModel = null;
17
18
 
18
19
  if (config.model) {
19
- const resolvedModel = provider.modelMap[config.model] || config.model;
20
- args.push(provider.modelFlag, resolvedModel);
20
+ effectiveModel = provider.modelMap[config.model] || config.model;
21
+ args.push(provider.modelFlag, effectiveModel);
22
+ }
23
+
24
+ if (config.reasoningConfiguration) {
25
+ args.push('-c', `model_reasoning_effort=${JSON.stringify(config.reasoningConfiguration)}`);
21
26
  }
22
27
 
23
28
  // Codex exec reads prompt from stdin when `-` is passed
@@ -27,5 +32,7 @@ export function buildCommand(config, provider) {
27
32
  command: provider.command,
28
33
  args,
29
34
  stdinPrompt: config.prompt || null,
35
+ effectiveModel,
36
+ effectiveReasoningConfiguration: config.reasoningConfiguration || null,
30
37
  };
31
38
  }
@@ -14,15 +14,18 @@
14
14
  */
15
15
  export function buildCommand(config, provider) {
16
16
  const args = [...provider.baseArgs];
17
+ let effectiveModel = null;
17
18
 
18
19
  if (config.model) {
19
- const resolvedModel = provider.modelMap[config.model] || config.model;
20
- args.push(provider.modelFlag, resolvedModel);
20
+ effectiveModel = provider.modelMap[config.model] || config.model;
21
+ args.push(provider.modelFlag, effectiveModel);
21
22
  }
22
23
 
23
24
  return {
24
25
  command: provider.command,
25
26
  args,
26
27
  stdinPrompt: config.prompt || null,
28
+ effectiveModel,
29
+ effectiveReasoningConfiguration: null,
27
30
  };
28
31
  }
@@ -7,10 +7,20 @@
7
7
  */
8
8
  export function buildCommand(config, provider) {
9
9
  const args = [...provider.baseArgs];
10
+ let effectiveModel = null;
10
11
  if (config.model) {
11
- const resolvedModel = provider.modelMap[config.model] || config.model;
12
- args.push(provider.modelFlag, resolvedModel);
12
+ effectiveModel = provider.modelMap[config.model] || config.model;
13
+ args.push(provider.modelFlag, effectiveModel);
14
+ }
15
+ if (config.reasoningConfiguration) {
16
+ args.push('--reasoning-effort', config.reasoningConfiguration);
13
17
  }
14
18
  args.push('-p', config.prompt || '');
15
- return { command: provider.command, args, stdinPrompt: null };
19
+ return {
20
+ command: provider.command,
21
+ args,
22
+ stdinPrompt: null,
23
+ effectiveModel,
24
+ effectiveReasoningConfiguration: config.reasoningConfiguration || null,
25
+ };
16
26
  }
@@ -19,6 +19,7 @@ import { parseProviderConfig, parseAgentOverride } from '../utils/config-parser.
19
19
  * @property {string} command - CLI command name
20
20
  * @property {string[]} baseArgs - Default CLI arguments for non-interactive mode
21
21
  * @property {string} modelFlag - CLI flag for model selection
22
+ * @property {string} defaultModel - Explicit legacy default model alias
22
23
  * @property {boolean} stdinSupport - Whether prompts can be piped via stdin
23
24
  * @property {boolean} hooksSupport - Whether the CLI supports hooks (event middleware)
24
25
  * @property {boolean} mcpSupport - Whether the CLI supports MCP servers
@@ -34,6 +35,7 @@ const PROVIDERS = {
34
35
  command: 'claude',
35
36
  baseArgs: ['--print', '--dangerously-skip-permissions'],
36
37
  modelFlag: '--model',
38
+ defaultModel: 'sonnet',
37
39
  stdinSupport: true,
38
40
  hooksSupport: true,
39
41
  mcpSupport: true,
@@ -50,6 +52,7 @@ const PROVIDERS = {
50
52
  command: 'gemini',
51
53
  baseArgs: [],
52
54
  modelFlag: '--model',
55
+ defaultModel: 'pro',
53
56
  stdinSupport: true,
54
57
  hooksSupport: true,
55
58
  mcpSupport: true,
@@ -65,6 +68,7 @@ const PROVIDERS = {
65
68
  command: 'codex',
66
69
  baseArgs: ['exec'],
67
70
  modelFlag: '-m',
71
+ defaultModel: 'codex',
68
72
  stdinSupport: true,
69
73
  hooksSupport: false,
70
74
  mcpSupport: true,
@@ -80,6 +84,7 @@ const PROVIDERS = {
80
84
  command: 'grok',
81
85
  baseArgs: [],
82
86
  modelFlag: '-m',
87
+ defaultModel: 'grok',
83
88
  stdinSupport: false,
84
89
  hooksSupport: true,
85
90
  mcpSupport: true,
@@ -130,6 +130,9 @@ async function main() {
130
130
  workingDir: projectDir,
131
131
  timeout,
132
132
  strictProvider: args['strict-provider'] === 'true',
133
+ providerId: args['provider-id'] || null,
134
+ reasoningConfiguration: args['reasoning-configuration'] || null,
135
+ catalogSnapshotRef: args['catalog-snapshot-ref'] || null,
133
136
  });
134
137
  } catch (err) {
135
138
  outputError(`Failed to spawn terminal: ${err.message}`);
@@ -144,6 +147,7 @@ async function main() {
144
147
  errorType: 'agent_failure',
145
148
  agent: args.agent,
146
149
  provider: promptResult.provider || args.provider || 'claude',
150
+ reasoningConfiguration: args['reasoning-configuration'] || null,
147
151
  phase: sessionState?.phase || 'unknown',
148
152
  });
149
153
  await flushAndSend(projectDir);
@@ -212,6 +216,7 @@ async function main() {
212
216
  agent: args.agent,
213
217
  model: promptResult.model,
214
218
  provider: promptResult.provider || args.provider || 'claude',
219
+ reasoningConfiguration: args['reasoning-configuration'] || null,
215
220
  exitCode: handle.exitCode,
216
221
  handoff: parsed.handoff,
217
222
  elapsed,
@@ -55,6 +55,9 @@ function createSpawnConfig({ agent, taskId, promptResult, projectDir, timeout, s
55
55
  workingDir: projectDir,
56
56
  timeout,
57
57
  strictProvider,
58
+ providerId: promptResult.providerId || null,
59
+ reasoningConfiguration: promptResult.reasoningConfiguration || null,
60
+ catalogSnapshotRef: promptResult.catalogSnapshotRef || null,
58
61
  };
59
62
  }
60
63
 
@@ -120,6 +123,10 @@ async function main() {
120
123
  strictProvider: args['strict-provider'] === 'true',
121
124
  });
122
125
 
126
+ promptResult.providerId = args['provider-id'] || null;
127
+ promptResult.reasoningConfiguration = args['reasoning-configuration'] || null;
128
+ promptResult.catalogSnapshotRef = args['catalog-snapshot-ref'] || null;
129
+
123
130
  configs.push(createSpawnConfig({
124
131
  agent: agents[i], taskId: taskIds[i], promptResult, projectDir, timeout,
125
132
  strictProvider: args['strict-provider'] === 'true',
@@ -11,6 +11,7 @@ import { spawn } from 'child_process';
11
11
  import { validateWriteScopes, buildIsolationEnv } from './isolation.js';
12
12
  import { getProvider } from './cli-registry.js';
13
13
  import { getRateLimiter } from './rate-limiter.js';
14
+ import { recordModelSelection } from '../orchestrator/session-manager.js';
14
15
 
15
16
  // ---------------------------------------------------------------------------
16
17
  // Constants
@@ -108,6 +109,9 @@ export function cleanParentEnv(env) {
108
109
  * @property {string} taskId - Task identifier
109
110
  * @property {string} [model] - LLM model tier name (e.g. opus, pro, codex, claude-sonnet)
110
111
  * @property {string} [provider] - CLI provider name (claude, gemini, codex, grok)
112
+ * @property {string} [providerId] - Vendor provider identifier (anthropic, openai, xai)
113
+ * @property {string} [reasoningConfiguration] - Requested reasoning effort
114
+ * @property {string} [catalogSnapshotRef] - Capability catalog snapshot used for routing
111
115
  * @property {boolean} [strictProvider] - Reject a missing provider rather than using the legacy fallback
112
116
  * @property {string} [prompt] - Full prompt string (from prompt-builder, piped via stdin)
113
117
  * @property {object} [contextPayload] - Context to inject via env var
@@ -159,6 +163,7 @@ export function buildSpawnCommand(config) {
159
163
  CHATI_AGENT: config.agent,
160
164
  CHATI_TASK_ID: config.taskId,
161
165
  CHATI_SPAWNED: 'true',
166
+ ...(config.reasoningConfiguration ? { CHATI_REASONING_CONFIGURATION: config.reasoningConfiguration } : {}),
162
167
  };
163
168
 
164
169
  if (config.contextPayload) {
@@ -174,13 +179,20 @@ export function buildSpawnCommand(config) {
174
179
  const providerName = config.provider || 'claude';
175
180
  let command, args, prompt;
176
181
  let providerFallback = null;
182
+ let actualProvider = providerName;
183
+ let actualModel;
184
+ let actualReasoningConfiguration = null;
177
185
 
178
186
  try {
179
187
  const provider = getProvider(providerName);
180
- const adapterResult = provider.adapter.buildCommand(config, provider);
188
+ const requestedModel = config.model || provider.defaultModel;
189
+ actualModel = provider.modelMap[requestedModel] || requestedModel;
190
+ const adapterResult = provider.adapter.buildCommand({ ...config, model: actualModel }, provider);
181
191
  command = adapterResult.command;
182
192
  args = adapterResult.args;
183
193
  prompt = adapterResult.stdinPrompt;
194
+ actualModel = adapterResult.effectiveModel;
195
+ actualReasoningConfiguration = adapterResult.effectiveReasoningConfiguration;
184
196
  } catch (err) {
185
197
  if (config.strictProvider) {
186
198
  const failure = new Error(`Selected provider "${providerName}" is unavailable: ${err.message}`);
@@ -194,18 +206,26 @@ export function buildSpawnCommand(config) {
194
206
  reason: err.message,
195
207
  timestamp: new Date().toISOString(),
196
208
  };
209
+ actualProvider = 'claude';
197
210
  console.error(`[chati] Provider "${providerName}" resolution failed: ${err.message}. Falling back to claude.`);
198
211
  command = 'claude';
199
212
  args = ['--print', '--dangerously-skip-permissions'];
200
- if (config.model) {
201
- const claudeProvider = getProvider('claude');
202
- const resolvedModel = claudeProvider.modelMap[config.model] || config.model;
203
- args.push('--model', resolvedModel);
213
+ const claudeProvider = getProvider('claude');
214
+ const requestedClaudeModel = config.model || 'sonnet';
215
+ actualModel = claudeProvider.modelMap[requestedClaudeModel]
216
+ || (requestedClaudeModel.startsWith('claude-') ? requestedClaudeModel : claudeProvider.modelMap.sonnet);
217
+ args.push('--model', actualModel);
218
+ if (config.reasoningConfiguration) {
219
+ args.push('--effort', config.reasoningConfiguration);
220
+ actualReasoningConfiguration = config.reasoningConfiguration;
204
221
  }
205
222
  prompt = config.prompt || null;
206
223
  }
207
224
 
208
- return { command, args, env, terminalId, prompt, providerFallback };
225
+ return {
226
+ command, args, env, terminalId, prompt, providerFallback,
227
+ actualProvider, actualModel, actualReasoningConfiguration,
228
+ };
209
229
  }
210
230
 
211
231
  /**
@@ -215,11 +235,29 @@ export function buildSpawnCommand(config) {
215
235
  * @returns {TerminalHandle}
216
236
  */
217
237
  export function spawnTerminal(config) {
218
- const { command, args, env, terminalId, prompt, providerFallback } = buildSpawnCommand(config);
238
+ const {
239
+ command, args, env, terminalId, prompt, providerFallback,
240
+ actualProvider, actualModel, actualReasoningConfiguration,
241
+ } = buildSpawnCommand(config);
219
242
 
220
243
  const cwd = config.workingDir || process.cwd();
221
244
  const timeout = config.timeout || 300_000; // default 5 minutes
222
245
 
246
+ const selectionRecord = recordModelSelection(cwd, {
247
+ agent: config.agent,
248
+ taskId: config.taskId,
249
+ provider: actualProvider,
250
+ providerId: providerFallback ? null : (config.providerId || null),
251
+ model: actualModel,
252
+ reasoningConfiguration: actualReasoningConfiguration,
253
+ catalogSnapshotRef: providerFallback ? null : (config.catalogSnapshotRef || null),
254
+ });
255
+ if (!selectionRecord.saved) {
256
+ const auditError = new Error(selectionRecord.error || 'Failed to persist model selection audit');
257
+ auditError.code = 'MODEL_SELECTION_AUDIT_FAILED';
258
+ throw auditError;
259
+ }
260
+
223
261
  const child = spawn(command, args, {
224
262
  cwd,
225
263
  env: { ...cleanParentEnv(process.env), ...env },
@@ -238,8 +276,9 @@ export function spawnTerminal(config) {
238
276
  process: child,
239
277
  agent: config.agent,
240
278
  taskId: config.taskId,
241
- model: config.model || 'unknown',
242
- provider: config.provider || 'claude',
279
+ model: actualModel || config.model || 'provider-default',
280
+ provider: actualProvider,
281
+ reasoningConfiguration: config.reasoningConfiguration || null,
243
282
  providerFallback,
244
283
  startedAt: new Date().toISOString(),
245
284
  status: 'running',
@@ -250,7 +289,7 @@ export function spawnTerminal(config) {
250
289
  };
251
290
 
252
291
  // Record spawn in rate limiter for throttling
253
- const providerForRate = config.provider || 'claude';
292
+ const providerForRate = actualProvider;
254
293
  getRateLimiter(providerForRate).recordSpawn();
255
294
 
256
295
  // Capture output (capped at ~10MB to prevent unbounded memory growth)