chati-dev 4.5.5 → 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.
Files changed (52) hide show
  1. package/bin/chati.js +35 -1
  2. package/framework/agents/plan/tasks.md +1 -1
  3. package/framework/config.yaml +8 -17
  4. package/framework/constitution.md +30 -20
  5. package/framework/context/governance.md +3 -1
  6. package/framework/context/root.md +1 -1
  7. package/framework/data/entity-registry.yaml +2 -2
  8. package/framework/domains/agents/orchestrator.yaml +1 -1
  9. package/framework/domains/constitution.yaml +1 -1
  10. package/framework/domains/global.yaml +2 -2
  11. package/framework/hooks/model-governance.js +9 -0
  12. package/framework/manifest.json +38 -38
  13. package/framework/manifest.sig +1 -1
  14. package/framework/orchestrator/chati.md +27 -3
  15. package/framework/schemas/session.schema.json +27 -10
  16. package/framework/tasks/brownfield-wu-architecture-map.md +1 -1
  17. package/framework/tasks/brownfield-wu-deep-discovery.md +1 -1
  18. package/framework/tasks/brownfield-wu-dependency-scan.md +1 -1
  19. package/framework/tasks/brownfield-wu-migration-plan.md +1 -1
  20. package/framework/tasks/brownfield-wu-report.md +1 -1
  21. package/framework/tasks/brownfield-wu-risk-assess.md +1 -1
  22. package/framework/tasks/greenfield-wu-report.md +1 -1
  23. package/node_modules/@chati/provider-registry/src/index.js +3 -2
  24. package/node_modules/@chati/tracking-clickup/src/index.js +22 -0
  25. package/package.json +2 -1
  26. package/src/config/gemini-hooks-generator.js +10 -4
  27. package/src/dashboard/layout.js +6 -4
  28. package/src/installer/core.js +20 -0
  29. package/src/installer/templates.js +21 -1
  30. package/src/installer-v2/clickup-preflight.js +32 -0
  31. package/src/installer-v2/model-catalog-envelope.json +15 -13
  32. package/src/installer-v2/model-catalog.json +5 -5
  33. package/src/installer-v2/model-catalog.sig +1 -1
  34. package/src/installer-v2/wizard-installation.js +1 -1
  35. package/src/intelligence/registry-manager.js +9 -3
  36. package/src/orchestrator/cli.js +41 -23
  37. package/src/orchestrator/clickup-projection.js +25 -8
  38. package/src/orchestrator/clickup-runtime.js +89 -1
  39. package/src/orchestrator/planning-runtime.js +1 -2
  40. package/src/orchestrator/rail-runtime.js +1 -2
  41. package/src/orchestrator/runtime-installation-v2.js +10 -1
  42. package/src/orchestrator/session-manager.js +134 -96
  43. package/src/terminal/adapters/claude-adapter.js +9 -2
  44. package/src/terminal/adapters/codex-adapter.js +9 -2
  45. package/src/terminal/adapters/gemini-adapter.js +5 -2
  46. package/src/terminal/adapters/grok-adapter.js +13 -3
  47. package/src/terminal/cli-registry.js +5 -0
  48. package/src/terminal/run-agent.js +5 -0
  49. package/src/terminal/run-parallel.js +7 -0
  50. package/src/terminal/spawner.js +49 -10
  51. package/src/wizard/index.js +8 -0
  52. package/src/wizard/questions.js +7 -3
@@ -1,5 +1,8 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { sha256 } from '@chati/core';
1
4
  import { createProjectRailEngine, loadRailHandoff } from './rail-runtime.js';
2
- import { enqueueClickUpProjectionFromState } from './clickup-projection.js';
5
+ import { createClickUpTrackingFromState, enqueueClickUpProjectionFromState } from './clickup-projection.js';
3
6
 
4
7
  /** Enqueues a durable local ClickUp projection. It never sends to ClickUp. */
5
8
  export function enqueueClickUpProjection({ projectDir, handoff_id, task_id, attempt_id, operation = 'update', payload, completion, decisions, clock = () => new Date() } = {}) {
@@ -11,3 +14,88 @@ export function enqueueClickUpProjection({ projectDir, handoff_id, task_id, atte
11
14
  }
12
15
 
13
16
  export { enqueueClickUpProjectionFromState } from './clickup-projection.js';
17
+
18
+ export function listPendingClickUpProjections({ projectDir } = {}) {
19
+ const path = join(projectDir, '.chati/v2/tracking/clickup-outbox.json');
20
+ if (!existsSync(path)) return Object.freeze([]);
21
+ const outbox = JSON.parse(readFileSync(path, 'utf8'));
22
+ return Object.freeze((outbox.projections || [])
23
+ .filter((projection) => ['pending', 'retryable_failure', 'sent_unconfirmed'].includes(projection.delivery_state))
24
+ .map((projection) => Object.freeze({
25
+ projection_id: projection.projection_id,
26
+ clickup_ref: projection.clickup_ref,
27
+ operation: projection.operation,
28
+ payload: projection.payload,
29
+ idempotency_key: projection.idempotency_key,
30
+ })));
31
+ }
32
+
33
+ const SENSITIVE_RECEIPT_KEY = /(authorization|cookie|credential|password|secret|token)/i;
34
+
35
+ export function sanitizeClickUpReceipt(value, depth = 0) {
36
+ if (depth > 8) return '[truncated]';
37
+ if (Array.isArray(value)) return value.slice(0, 100).map((item) => sanitizeClickUpReceipt(item, depth + 1));
38
+ if (!value || typeof value !== 'object') return value;
39
+ return Object.fromEntries(Object.entries(value)
40
+ .filter(([key]) => !SENSITIVE_RECEIPT_KEY.test(key))
41
+ .map(([key, item]) => [key, sanitizeClickUpReceipt(item, depth + 1)]));
42
+ }
43
+
44
+ function hasRemoteReceiptIdentifier(value, depth = 0) {
45
+ if (depth > 8 || !value || typeof value !== 'object') return false;
46
+ if (Array.isArray(value)) return value.some((item) => hasRemoteReceiptIdentifier(item, depth + 1));
47
+ return Object.entries(value).some(([key, item]) =>
48
+ (/^(id|task_id|comment_id|task_url|url)$/i.test(key) && typeof item === 'string' && item.trim() !== '')
49
+ || hasRemoteReceiptIdentifier(item, depth + 1));
50
+ }
51
+
52
+ export function clickUpReceiptConfirmsSuccess(receipt) {
53
+ if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) return false;
54
+ if (receipt.isError === true || receipt.success === false) return false;
55
+ if (typeof receipt.status === 'string' && /^(failed|error|unauthorized)$/i.test(receipt.status)) return false;
56
+ return receipt.success === true
57
+ || (typeof receipt.status === 'string' && /^(success|ok|completed)$/i.test(receipt.status))
58
+ || hasRemoteReceiptIdentifier(receipt);
59
+ }
60
+
61
+ export function acknowledgeClickUpProjection({ projectDir, handoff_id, projection_id, receipt, clock = () => new Date() } = {}) {
62
+ if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) throw Object.assign(new Error('receipt must be an object'), { code: 'INVALID_CLICKUP_RECEIPT' });
63
+ const { handoff } = loadRailHandoff({ projectDir, handoff_id });
64
+ const outboxPath = join(projectDir, '.chati/v2/tracking/clickup-outbox.json');
65
+ const outbox = JSON.parse(readFileSync(outboxPath, 'utf8'));
66
+ const projection = outbox.projections?.find((item) => item.projection_id === projection_id);
67
+ if (!projection) throw Object.assign(new Error(`projection ${projection_id} does not exist`), { code: 'PROJECTION_NOT_FOUND' });
68
+ const records = createProjectRailEngine({ projectDir, clock }).records;
69
+ if (projection.delivery_state === 'confirmed' && projection.receipt_ref) {
70
+ const tracking = createClickUpTrackingFromState({
71
+ projectDir, handoff, records, task_id: projection.task_id, clock, extra_verified_refs: [projection.receipt_ref],
72
+ });
73
+ return tracking.confirmExternalDelivery({ projection_id, receipt_ref: projection.receipt_ref });
74
+ }
75
+ if (!clickUpReceiptConfirmsSuccess(receipt)) {
76
+ throw Object.assign(new Error('receipt does not prove a successful ClickUp operation'), { code: 'CLICKUP_RECEIPT_UNCONFIRMED' });
77
+ }
78
+ const observed = clock();
79
+ const receivedAt = (observed instanceof Date ? observed : new Date(observed)).toISOString();
80
+ const safeReceipt = sanitizeClickUpReceipt(receipt);
81
+ const receiptMaterial = { schema_version: 1, projection_id, received_at: receivedAt, response: safeReceipt };
82
+ if (JSON.stringify(receiptMaterial).length > 64 * 1024) {
83
+ throw Object.assign(new Error('receipt exceeds 64 KiB after sanitization'), { code: 'CLICKUP_RECEIPT_TOO_LARGE' });
84
+ }
85
+ const digest = sha256(receiptMaterial);
86
+ const receiptPath = join(projectDir, '.chati/v2/tracking/receipts', `${digest}.json`);
87
+ mkdirSync(dirname(receiptPath), { recursive: true, mode: 0o700 });
88
+ const serialized = `${JSON.stringify(receiptMaterial, null, 2)}\n`;
89
+ if (!existsSync(receiptPath)) {
90
+ const temporary = `${receiptPath}.${process.pid}.tmp`;
91
+ writeFileSync(temporary, serialized, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
92
+ renameSync(temporary, receiptPath);
93
+ } else if (readFileSync(receiptPath, 'utf8') !== serialized) {
94
+ throw Object.assign(new Error('receipt digest collision'), { code: 'CLICKUP_RECEIPT_CONFLICT' });
95
+ }
96
+ const receiptRef = `clickup-receipt://sha256/${digest}`;
97
+ const tracking = createClickUpTrackingFromState({
98
+ projectDir, handoff, records, task_id: projection.task_id, clock, extra_verified_refs: [receiptRef],
99
+ });
100
+ return tracking.confirmExternalDelivery({ projection_id, receipt_ref: receiptRef });
101
+ }
@@ -87,8 +87,7 @@ export function compilePlanningHandoff({ projectDir, clock = () => new Date() }
87
87
  };
88
88
  if (sources.ux_path) source_artifacts.ux_ref = immutableFileRef(projectDir, sources.ux_path, 'PLANNING_SOURCE_ARTIFACTS_REQUIRED');
89
89
  const ids = new Set();
90
- const clickupRequired = installation.installation.profile === 'focus-ai-internal'
91
- && installation.installation.external_integrations?.clickup === 'required';
90
+ const clickupRequired = installation.installation.external_integrations?.clickup === 'required';
92
91
  const tasks = planning.tasks.map((task) => {
93
92
  if (!task || typeof task !== 'object' || typeof task.id !== 'string' || !task.id.trim() || ids.has(task.id)) fail('INVALID_PLANNING_TASK', 'each planning task needs a unique id');
94
93
  ids.add(task.id);
@@ -85,8 +85,7 @@ function loadProjectRail({ projectDir, handoff_id, clock }) {
85
85
 
86
86
  function internalTrackingInstallation(projectDir, clock) {
87
87
  const artifact = loadRuntimeInstallationV2(projectDir, { ...(clock === undefined ? {} : { clock }) });
88
- return artifact?.installation?.profile === 'focus-ai-internal'
89
- && artifact.installation.external_integrations?.clickup === 'required';
88
+ return artifact?.installation?.external_integrations?.clickup === 'required';
90
89
  }
91
90
 
92
91
  function assertCompletionMetricsEvidence(metrics, reviews, acceptanceEvidenceRefs, records, attemptId) {
@@ -48,9 +48,18 @@ export function resolveRuntimeInvocationV2({ artifact, agent, binding, model_id,
48
48
  if (!selectedCandidate) throw Object.assign(new Error(`No eligible installed model for ${agent}/${action}`), { code: 'NO_ELIGIBLE_MODEL' });
49
49
  const selected = selectedCandidate.binding;
50
50
  const model = selectedCandidate.model.model_id;
51
+ const defaultReasoning = selectedCandidate.model.tier === 'worker'
52
+ ? 'low'
53
+ : selectedCandidate.model.tier === 'workhorse'
54
+ ? 'medium'
55
+ : 'high';
51
56
  const invocation = {
52
57
  provider_id: selected.provider_id, harness_id: selected.harness_id, action,
53
- model_pin: { model_id: model, catalog_snapshot_ref: artifact.capability_snapshot.snapshot_id, reasoning_configuration },
58
+ model_pin: {
59
+ model_id: model,
60
+ catalog_snapshot_ref: artifact.capability_snapshot.snapshot_id,
61
+ reasoning_configuration: reasoning_configuration ?? defaultReasoning,
62
+ },
54
63
  };
55
64
  assertEligibleInvocation({ installation: artifact.installation, snapshot: artifact.capability_snapshot, invocation, clock });
56
65
  return Object.freeze(invocation);
@@ -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',