chati-dev 4.5.5 → 4.5.6

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 (43) 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 +1 -1
  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 +1 -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 +16 -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 +36 -21
  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/wizard/index.js +8 -0
  43. package/src/wizard/questions.js +7 -3
@@ -200,6 +200,28 @@ export class TrackingClickUp {
200
200
  return Object.freeze(canonicalize(projection));
201
201
  }
202
202
 
203
+ /** Records a receipt produced by an independently authorized MCP boundary. */
204
+ confirmExternalDelivery({ projection_id, receipt_ref } = {}) {
205
+ const projection = this.#find(projection_id);
206
+ string(receipt_ref, 'INVALID_TRANSPORT_RECEIPT', 'receipt_ref');
207
+ if (this.#verifyReference(receipt_ref, 'receipt') !== true) {
208
+ fail('UNVERIFIED_RECEIPT', 'external delivery receipt must be an immutable verified reference');
209
+ }
210
+ if (projection.delivery_state === 'permanent_failure') fail('PERMANENT_FAILURE', 'permanently failed projection cannot be confirmed');
211
+ if (projection.delivery_state === 'confirmed') {
212
+ if (projection.receipt_ref !== receipt_ref) fail('RECEIPT_CONFLICT', 'projection is already confirmed by a different receipt');
213
+ return Object.freeze(canonicalize(projection));
214
+ }
215
+ projection.delivery_state = 'confirmed';
216
+ projection.receipt_ref = receipt_ref;
217
+ projection.reconciliation_state = 'pending';
218
+ projection.last_attempt_at = timestamp(this.#clock);
219
+ projection.attempts += 1;
220
+ this.#setEvolution(projection);
221
+ this.#persist();
222
+ return Object.freeze(canonicalize(projection));
223
+ }
224
+
203
225
  reconcile({ projection_id } = {}) {
204
226
  const projection = this.#find(projection_id);
205
227
  if (!this.#transport || typeof this.#transport.reconcile !== 'function') fail('RECONCILIATION_NOT_CONFIGURED', 'no authorized reconciliation transport is configured');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chati-dev",
3
- "version": "4.5.5",
3
+ "version": "4.5.6",
4
4
  "description": "AI-Powered Multi-Agent Orchestration System - Structured vibe coding for Full Stack Development",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  * Constitution Article XIX — hooks are generated at install time, zero runtime overhead.
10
10
  *
11
11
  * Gemini CLI Hook Events (used by chati.dev):
12
- * - BeforeModel: runs before model inference (PRISM injection, model advisory)
12
+ * - BeforeModel: runs before model inference (PRISM injection, legacy model advisory)
13
13
  * - BeforeTool: runs before tool execution (mode governance, constitution guard, read protection)
14
14
  * - PreCompress: runs before context compression (session digest)
15
15
  */
@@ -27,7 +27,7 @@ export const HOOK_MAP = {
27
27
  // indefinitely from a long-running session.
28
28
  'license-guard': { event: 'BeforeModel', description: 'Validate license on every turn (chati.dev governance)' },
29
29
  'prism-engine': { event: 'BeforeModel', description: 'Inject PRISM context into model prompt' },
30
- 'model-governance': { event: 'BeforeModel', description: 'Advisory: recommended model per agent' },
30
+ 'model-governance': { event: 'BeforeModel', description: 'Legacy advisory for pre-v2 installations' },
31
31
  'mode-governance': { event: 'BeforeTool', description: 'Block writes outside current mode scope' },
32
32
  'constitution-guard': { event: 'BeforeTool', description: 'Block destructive commands and secret writes' },
33
33
  'read-protection': { event: 'BeforeTool', description: 'Block reads of sensitive files' },
@@ -121,13 +121,14 @@ main();
121
121
 
122
122
  /**
123
123
  * Generate the model governance hook for Gemini CLI.
124
- * BeforeModel event — advisory about recommended model per agent.
124
+ * BeforeModel event — legacy advisory for pre-v2 installations.
125
125
  */
126
126
  function generateModelGovernance() {
127
127
  return `${HOOK_HEADER}
128
128
  /**
129
129
  * Model Governance — BeforeModel
130
- * Advisory: logs recommended model for current agent.
130
+ * V2 routing is automatic and invisible. This wrapper only supports legacy
131
+ * installations that do not have a v2 installation contract.
131
132
  */
132
133
  async function main() {
133
134
  let input = '';
@@ -139,6 +140,11 @@ async function main() {
139
140
  const event = JSON.parse(input);
140
141
  const cwd = event.cwd || process.cwd();
141
142
 
143
+ if (existsSync(join(cwd, '.chati', 'v2', 'installation.json'))) {
144
+ console.log(JSON.stringify({}));
145
+ return;
146
+ }
147
+
142
148
  const sessionPath = join(cwd, '.chati', 'session.yaml');
143
149
  if (!existsSync(sessionPath)) {
144
150
  console.log(JSON.stringify({}));
@@ -65,14 +65,16 @@ export function buildProjectInfo(data) {
65
65
  const rawMode = session.execution_mode;
66
66
  const mode = formatMode(rawMode && typeof rawMode === 'object' ? rawMode.mode : rawMode);
67
67
  const lang = session.language || 'en';
68
- // Show the provider-specific IDE (last in the list) rather than always claude-code
69
- const ides = session.ides || [];
70
- const ide = ides[ides.length - 1] || 'unknown';
68
+ const providers = Array.isArray(session.providers_enabled) && session.providers_enabled.length > 0
69
+ ? session.providers_enabled.join('/')
70
+ : 'unknown';
71
+ const languageVisibleWidth = 12 + lang.length;
72
+ const providersVisibleWidth = 11 + providers.length;
71
73
 
72
74
  return [
73
75
  brand('│') + ` ${dim('Project:')} ${name}` + ' '.repeat(Math.max(1, 30 - name.length)) + `${dim('Type:')} ${type}` + ' '.repeat(Math.max(1, 18 - type.length)) + brand('│'),
74
76
  brand('│') + ` ${dim('Phase:')} ${state}` + ' '.repeat(Math.max(1, 28)) + `${dim('Mode:')} ${mode}` + ' '.repeat(Math.max(1, 10)) + brand('│'),
75
- brand('│') + ` ${dim('Language:')} ${lang}` + ' '.repeat(Math.max(1, 30 - lang.length)) + `${dim('IDE:')} ${ide}` + ' '.repeat(Math.max(1, 18 - ide.length)) + brand('│'),
77
+ brand('│') + ` ${dim('Language:')} ${lang}` + ' '.repeat(Math.max(1, 28 - languageVisibleWidth)) + `${dim('Providers:')} ${providers}` + ' '.repeat(Math.max(1, 31 - providersVisibleWidth)) + brand('│'),
76
78
  ];
77
79
  }
78
80
 
@@ -1,4 +1,5 @@
1
1
  import { mkdirSync, writeFileSync, copyFileSync, existsSync, readFileSync, readdirSync, statSync } from 'fs';
2
+ import { execFileSync } from 'child_process';
2
3
  import { join, dirname, basename } from 'path';
3
4
  import { fileURLToPath } from 'url';
4
5
  import { createRequire } from 'module';
@@ -215,6 +216,21 @@ export async function installFramework(config) {
215
216
 
216
217
  // 7. Update .gitignore with runtime session lock files
217
218
  updateGitignore(targetDir, selectedIDEs);
219
+
220
+ // RAIL evidence and release lanes require a Git worktree. For a genuinely
221
+ // new project, initialize it automatically. Nested projects already covered
222
+ // by a parent worktree are left untouched.
223
+ ensureGitRepository(targetDir);
224
+ }
225
+
226
+ export function ensureGitRepository(targetDir) {
227
+ try {
228
+ execFileSync('git', ['-C', targetDir, 'rev-parse', '--show-toplevel'], { stdio: 'ignore' });
229
+ return { initialized: false };
230
+ } catch {
231
+ execFileSync('git', ['-C', targetDir, 'init', '-q'], { stdio: 'ignore' });
232
+ return { initialized: true };
233
+ }
218
234
  }
219
235
 
220
236
  /**
@@ -9,7 +9,7 @@ export function generateSessionYaml(config) {
9
9
  // Schema MUST match session-manager.js DEFAULT_SESSION fields,
10
10
  // otherwise validateSession() rejects installer-generated sessions.
11
11
  const session = {
12
- schema_version: '1.0',
12
+ schema_version: '1.2',
13
13
  version: '1.0',
14
14
  mode: 'discover',
15
15
  project: {
@@ -35,6 +35,26 @@ export function generateSessionYaml(config) {
35
35
  agents: {},
36
36
  backlog: [],
37
37
  last_handoff: '',
38
+ scaffold_candidates: [],
39
+ scaffold_signals: {},
40
+ scaffold_applied: [],
41
+ active_model: null,
42
+ active_provider: null,
43
+ context_tokens_used: 0,
44
+ context_window_tokens: null,
45
+ context_last_bracket: null,
46
+ decision_trail: [],
47
+ teams: [],
48
+ team_events: [],
49
+ preview: {
50
+ status: 'inactive',
51
+ url: null,
52
+ port: null,
53
+ framework: null,
54
+ logs_captured: 0,
55
+ adjustment_count: 0,
56
+ },
57
+ model_selections: [],
38
58
  // Article XXIII — Reasoning Tier Governance.
39
59
  // Tier starts at 'standard' and is promoted by reasoning-escalator.js
40
60
  // in response to friction signals. See chati.dev/hooks/reasoning-escalator.js.
@@ -0,0 +1,32 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ const COMMANDS = Object.freeze({ claude: 'claude', codex: 'codex', grok: 'grok' });
4
+
5
+ export function outputHasConnectedClickUp(output) {
6
+ if (typeof output !== 'string') return false;
7
+ return output.split(/\r?\n/).some((line) => {
8
+ if (!/\bclickup\b/i.test(line)) return false;
9
+ return !/\b(disabled|failed|error|not configured|unhealthy|unauthorized|disconnected)\b|authentication required/i.test(line);
10
+ });
11
+ }
12
+
13
+ /** Read-only preflight. It never writes provider config or reads credentials. */
14
+ export function checkClickUpMcp(selectedProviders, { exec = execFileSync } = {}) {
15
+ const providers = [...new Set(selectedProviders || [])].filter((provider) => COMMANDS[provider]);
16
+ const checks = providers.map((provider) => {
17
+ try {
18
+ const output = exec(COMMANDS[provider], ['mcp', 'list'], {
19
+ encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'],
20
+ });
21
+ return { provider, connected: outputHasConnectedClickUp(output) };
22
+ } catch {
23
+ return { provider, connected: false };
24
+ }
25
+ });
26
+ const connectedProviders = checks.filter((check) => check.connected).map((check) => check.provider);
27
+ return Object.freeze({
28
+ passed: connectedProviders.length > 0,
29
+ connected_providers: connectedProviders,
30
+ checks,
31
+ });
32
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "catalog": {
3
- "snapshot_id": "chati-model-catalog-2026-08-27-v2",
4
- "published_at": "2026-08-27T00:00:00.000Z",
3
+ "snapshot_id": "chati-model-catalog-2026-08-27-v3",
4
+ "published_at": "2026-08-27T12:00:00.000Z",
5
5
  "expires_at": "2026-11-25T00:00:00.000Z",
6
6
  "models": [
7
7
  {
@@ -17,13 +17,12 @@
17
17
  "tier": "frontier",
18
18
  "natural_role": "architecture and design",
19
19
  "routing_priority": {
20
- "discovery": 95,
21
- "planning": 100,
20
+ "discovery": 78,
21
+ "planning": 94,
22
22
  "build": 45,
23
23
  "review": 90,
24
24
  "adjudication": 95,
25
25
  "architect": 100,
26
- "detail": 100,
27
26
  "ux": 100
28
27
  },
29
28
  "adjudication_priority": 95,
@@ -42,11 +41,13 @@
42
41
  "tier": "frontier",
43
42
  "natural_role": "quality rules and judgment-heavy work",
44
43
  "routing_priority": {
45
- "discovery": 90,
46
- "planning": 95,
44
+ "discovery": 92,
45
+ "planning": 96,
47
46
  "build": 75,
48
47
  "review": 95,
49
48
  "adjudication": 100,
49
+ "brownfield-wu": 100,
50
+ "detail": 100,
50
51
  "qa-planning": 97,
51
52
  "qa-implementation": 97
52
53
  },
@@ -65,13 +66,14 @@
65
66
  "tier": "workhorse",
66
67
  "natural_role": "daily implementation and planning craft",
67
68
  "routing_priority": {
68
- "discovery": 85,
69
- "planning": 88,
69
+ "discovery": 90,
70
+ "planning": 92,
70
71
  "build": 98,
71
72
  "review": 80,
72
- "brief": 95,
73
- "phases": 95,
74
- "tasks": 95,
73
+ "greenfield-wu": 100,
74
+ "brief": 100,
75
+ "phases": 100,
76
+ "tasks": 100,
75
77
  "dev": 98
76
78
  },
77
79
  "adjudication_priority": 60
@@ -211,5 +213,5 @@
211
213
  }
212
214
  ]
213
215
  },
214
- "signature": "trEytAQJkukerJTwpNvEQ4UO49b+Yth5jtuYNy9WXVSBvKpJCmfuv3tBWyhNgwYG9w3/oKpKDJqJQwIb/oiMAQ=="
216
+ "signature": "tL1JS1RkPfOBDE6rcctrEPN98oQ6FjLurwZnzYglMvrsvvceRbS19INcxmPYQjT+NJv507Vuo/HIkm+J0nQ1Cw=="
215
217
  }
@@ -1,6 +1,6 @@
1
1
  {
2
- "snapshot_id": "chati-model-catalog-2026-08-27-v2",
3
- "published_at": "2026-08-27T00:00:00.000Z",
2
+ "snapshot_id": "chati-model-catalog-2026-08-27-v3",
3
+ "published_at": "2026-08-27T12:00:00.000Z",
4
4
  "expires_at": "2026-11-25T00:00:00.000Z",
5
5
  "models": [
6
6
  {
@@ -9,7 +9,7 @@
9
9
  "actions": ["discovery", "planning", "build", "review", "adjudication"],
10
10
  "tier": "frontier",
11
11
  "natural_role": "architecture and design",
12
- "routing_priority": { "discovery": 95, "planning": 100, "build": 45, "review": 90, "adjudication": 95, "architect": 100, "detail": 100, "ux": 100 },
12
+ "routing_priority": { "discovery": 78, "planning": 94, "build": 45, "review": 90, "adjudication": 95, "architect": 100, "ux": 100 },
13
13
  "adjudication_priority": 95,
14
14
  "highest_reasoning_configuration": "xhigh"
15
15
  },
@@ -19,7 +19,7 @@
19
19
  "actions": ["discovery", "planning", "build", "review", "adjudication"],
20
20
  "tier": "frontier",
21
21
  "natural_role": "quality rules and judgment-heavy work",
22
- "routing_priority": { "discovery": 90, "planning": 95, "build": 75, "review": 95, "adjudication": 100, "qa-planning": 97, "qa-implementation": 97 },
22
+ "routing_priority": { "discovery": 92, "planning": 96, "build": 75, "review": 95, "adjudication": 100, "brownfield-wu": 100, "detail": 100, "qa-planning": 97, "qa-implementation": 97 },
23
23
  "adjudication_priority": 100,
24
24
  "highest_reasoning_configuration": "xhigh"
25
25
  },
@@ -29,7 +29,7 @@
29
29
  "actions": ["discovery", "planning", "build", "review"],
30
30
  "tier": "workhorse",
31
31
  "natural_role": "daily implementation and planning craft",
32
- "routing_priority": { "discovery": 85, "planning": 88, "build": 98, "review": 80, "brief": 95, "phases": 95, "tasks": 95, "dev": 98 },
32
+ "routing_priority": { "discovery": 90, "planning": 92, "build": 98, "review": 80, "greenfield-wu": 100, "brief": 100, "phases": 100, "tasks": 100, "dev": 98 },
33
33
  "adjudication_priority": 60
34
34
  },
35
35
  {
@@ -1 +1 @@
1
- trEytAQJkukerJTwpNvEQ4UO49b+Yth5jtuYNy9WXVSBvKpJCmfuv3tBWyhNgwYG9w3/oKpKDJqJQwIb/oiMAQ==
1
+ tL1JS1RkPfOBDE6rcctrEPN98oQ6FjLurwZnzYglMvrsvvceRbS19INcxmPYQjT+NJv507Vuo/HIkm+J0nQ1Cw==
@@ -100,7 +100,7 @@ export function buildWizardV2InstallationInput({
100
100
  const projectSlug = projectName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'project';
101
101
  const installation = {
102
102
  installation_id: `wizard-${projectSlug}`,
103
- profile: installationMode === 'internal' ? 'focus-ai-internal' : 'other-supported-profile',
103
+ profile: installationMode === 'internal' ? 'authorized-internal' : 'open',
104
104
  enabled_providers,
105
105
  capability_snapshot_ref: snapshotId,
106
106
  policy_ref: `policy://installation/${snapshotId}`,
@@ -36,8 +36,15 @@ export function checkRegistry(targetDir) {
36
36
  // Check each registered entity exists on disk
37
37
  const entities = flattenEntitiesMap(registry.entities);
38
38
  for (const entity of entities) {
39
- const filePath = join(targetDir, entity.path);
40
- if (existsSync(filePath)) {
39
+ // Registry entries describe the monorepo source layout. Installed projects
40
+ // bundle the CLI source under .chati.dev/_cli, so health must resolve both
41
+ // layouts instead of reporting bundled runtime files as missing.
42
+ const candidates = [join(targetDir, entity.path)];
43
+ const bundledSourcePrefix = 'packages/chati-dev/src/';
44
+ if (entity.path.startsWith(bundledSourcePrefix)) {
45
+ candidates.push(join(targetDir, resolveFrameworkDir(targetDir), '_cli', entity.path.slice(bundledSourcePrefix.length)));
46
+ }
47
+ if (candidates.some((filePath) => existsSync(filePath))) {
41
48
  found.push(entity);
42
49
  } else {
43
50
  missing.push(entity);
@@ -193,4 +200,3 @@ function loadRegistry(registryPath) {
193
200
  return null;
194
201
  }
195
202
  }
196
-
@@ -583,6 +583,19 @@ export function buildParallelSpawnCommand(agents, projectDir, previousAgent, pro
583
583
  return parts.join(' ');
584
584
  }
585
585
 
586
+ export function buildRoutedParallelSpawnCommands(agents, projectDir, previousAgent, timeout = 900000) {
587
+ return agents.map((agent) => {
588
+ const modelInfo = resolveAgentModel(agent, projectDir);
589
+ return Object.freeze({
590
+ agent,
591
+ provider: modelInfo.provider,
592
+ model: modelInfo.model,
593
+ reasoning_configuration: modelInfo.reasoning_configuration,
594
+ command: buildSpawnCommand(agent, projectDir, previousAgent, modelInfo.provider, timeout, modelInfo.model, modelInfo.source === 'installation-v2'),
595
+ });
596
+ });
597
+ }
598
+
586
599
  /**
587
600
  * Model map — canonical model tier per agent.
588
601
  * Matches framework/hooks/model-governance.js AGENT_MODELS.
@@ -748,7 +761,7 @@ async function handleNext(projectDir) {
748
761
  // Centralized session lock update: applies to ALL return paths.
749
762
  // Activates lock when an agent is returned, deactivates on completion.
750
763
  if (result && result.agent && result.action !== 'error' && result.action !== 'setup') {
751
- const activating = ['activate_interactive', 'spawn_autonomous', 'spawn_team', 'spawn_parallel'];
764
+ const activating = ['activate_interactive', 'spawn_routed_interactive', 'spawn_autonomous', 'spawn_team', 'spawn_parallel'];
752
765
  if (activating.includes(result.action)) {
753
766
  try {
754
767
  writeSessionLock(projectDir, result.agent, {
@@ -830,20 +843,18 @@ async function _handleNextInner(projectDir) {
830
843
  };
831
844
  }
832
845
 
846
+ const modelInfo = resolveAgentModel(agent, projectDir);
833
847
  return {
834
- action: isInteractive ? 'activate_interactive' : 'spawn_autonomous',
848
+ action: isInteractive ? 'spawn_routed_interactive' : 'spawn_autonomous',
835
849
  agent,
836
850
  agent_file: agentFile,
837
851
  phase: agentDef?.phase || session.mode,
838
- spawn_command: isInteractive ? null : (() => {
839
- const modelInfo = resolveAgentModel(agent, projectDir);
840
- return buildSpawnCommand(agent, projectDir, session.last_handoff || 'none', modelInfo.provider, 600000, modelInfo.model, modelInfo.source === 'installation-v2');
841
- })(),
852
+ spawn_command: buildSpawnCommand(agent, projectDir, session.last_handoff || 'none', modelInfo.provider, 600000, modelInfo.model, modelInfo.source === 'installation-v2'),
842
853
  parallel_spawn_command: null,
843
854
  parallel_agents: [],
844
855
  handoff_status: { valid: true, missing: [], warnings: ['Resuming in-progress agent'] },
845
856
  gate_status: { canAdvance: true, reason: 'Agent already in progress' },
846
- model_info: resolveAgentModel(agent, projectDir),
857
+ model_info: modelInfo,
847
858
  context_bracket: estimateContextBracket(completedAgents.length, AGENT_PIPELINE.length),
848
859
  pipeline_progress: getPipelineProgress(pipelineState),
849
860
  session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
@@ -861,17 +872,18 @@ async function _handleNextInner(projectDir) {
861
872
  const firstAgent = projectType === 'brownfield' ? 'brownfield-wu' : 'greenfield-wu';
862
873
  const agentFile = getAgentFile(firstAgent, projectDir) || null;
863
874
 
875
+ const modelInfo = resolveAgentModel(firstAgent, projectDir);
864
876
  return {
865
- action: 'activate_interactive',
877
+ action: 'spawn_routed_interactive',
866
878
  agent: firstAgent,
867
879
  agent_file: agentFile,
868
880
  phase: 'discover',
869
- spawn_command: null,
881
+ spawn_command: buildSpawnCommand(firstAgent, projectDir, 'none', modelInfo.provider, 600000, modelInfo.model, modelInfo.source === 'installation-v2'),
870
882
  parallel_spawn_command: null,
871
883
  parallel_agents: [],
872
884
  handoff_status: { valid: true, missing: [], warnings: [] },
873
885
  gate_status: { canAdvance: true, reason: 'First agent in pipeline' },
874
- model_info: resolveAgentModel(firstAgent, projectDir),
886
+ model_info: modelInfo,
875
887
  context_bracket: estimateContextBracket(0, AGENT_PIPELINE.length),
876
888
  pipeline_progress: getPipelineProgress(pipelineState),
877
889
  session: { language: session.language, project_type: projectType, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
@@ -932,7 +944,7 @@ async function _handleNextInner(projectDir) {
932
944
  const modelInfo = resolveAgentModel(nextAgent, projectDir);
933
945
 
934
946
  // Check for parallel group — with Agent Teams override (Article XXI)
935
- let action, spawnCommand = null, parallelSpawnCommand = null, parallelAgents = [];
947
+ let action, spawnCommand = null, parallelSpawnCommand = null, parallelSpawnCommands = [], parallelAgents = [];
936
948
  let teamData = null;
937
949
  const teamsEnabled = isAgentTeamsEnabled(projectDir);
938
950
 
@@ -948,7 +960,11 @@ async function _handleNextInner(projectDir) {
948
960
  } else {
949
961
  action = 'spawn_parallel';
950
962
  parallelAgents = nextInfo.group;
951
- parallelSpawnCommand = buildParallelSpawnCommand(nextInfo.group, projectDir, lastAgent, modelInfo.provider, 900000, modelInfo.model, modelInfo.source === 'installation-v2');
963
+ if (modelInfo.source === 'installation-v2') {
964
+ parallelSpawnCommands = buildRoutedParallelSpawnCommands(nextInfo.group, projectDir, lastAgent);
965
+ } else {
966
+ parallelSpawnCommand = buildParallelSpawnCommand(nextInfo.group, projectDir, lastAgent, modelInfo.provider, 900000, modelInfo.model, false);
967
+ }
952
968
  }
953
969
  } else if (nextAgent === 'dev' && teamsEnabled) {
954
970
  // Build Team auto-spawn: dev + qa-implementation are NOT parallel in the
@@ -962,7 +978,8 @@ async function _handleNextInner(projectDir) {
962
978
  parallelAgents = teamConfig.members;
963
979
  teamData = { team_id: teamId, team_type: 'build', members: teamConfig.members };
964
980
  } else if (isInteractive) {
965
- action = 'activate_interactive';
981
+ action = 'spawn_routed_interactive';
982
+ spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000, modelInfo.model, modelInfo.source === 'installation-v2');
966
983
  } else {
967
984
  action = 'spawn_autonomous';
968
985
  spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000, modelInfo.model, modelInfo.source === 'installation-v2');
@@ -1000,6 +1017,7 @@ async function _handleNextInner(projectDir) {
1000
1017
  phase: agentDef?.phase || session.mode,
1001
1018
  spawn_command: spawnCommand,
1002
1019
  parallel_spawn_command: parallelSpawnCommand,
1020
+ parallel_spawn_commands: parallelSpawnCommands,
1003
1021
  parallel_agents: parallelAgents,
1004
1022
  handoff_status: handoffStatus,
1005
1023
  gate_status: gateStatus,
@@ -2418,16 +2436,13 @@ async function handleProviders(projectDir) {
2418
2436
  agentModels[agentDef.name] = resolveAgentModel(agentDef.name, projectDir);
2419
2437
  }
2420
2438
 
2421
- let primaryProvider = 'claude';
2422
- const configPath = join(projectDir, resolveFrameworkDir(projectDir), 'config.yaml');
2423
- if (existsSync(configPath)) {
2424
- const raw = readFileSync(configPath, 'utf-8');
2425
- const providerMatch = raw.match(/primary_provider:\s*["']?(\w+)/);
2426
- if (providerMatch) primaryProvider = providerMatch[1].toLowerCase();
2427
- }
2439
+ const artifact = loadRuntimeInstallationV2(projectDir);
2440
+ const enabledProviders = artifact
2441
+ ? artifact.installation.enabled_providers.map(({ provider_id, harness_id }) => ({ provider_id, harness_id }))
2442
+ : [...new Set(Object.values(agentModels).map(({ provider }) => provider).filter(Boolean))].map((harness_id) => ({ harness_id }));
2428
2443
 
2429
2444
  return {
2430
- primary_provider: primaryProvider,
2445
+ enabled_providers: enabledProviders,
2431
2446
  agent_models: agentModels,
2432
2447
  };
2433
2448
  }
@@ -61,14 +61,8 @@ export function enqueueClickUpProjectionFromState({ projectDir, handoff, records
61
61
  ...(completion?.reviews || []),
62
62
  ...(completion?.acceptance_evidence_refs || []),
63
63
  ]);
64
- const tracking = new TrackingClickUp({
65
- outbox_path: join(projectDir, '.chati/v2/tracking/clickup-outbox.json'),
66
- project_evolution_dir: join(projectDir, '.chati/v2/project-evolution'),
67
- clock, verify_completion: (candidate) => completionIsCanonical(records, candidate),
68
- verify_reference: (ref) => refs.has(ref),
69
- resolve_reference: (ref, type) => type === 'handoff'
70
- ? { phase: 'rail-execution', handoff_id: handoff.handoff_id }
71
- : { task_state: taskState(records, handoff.handoff_id, task_id), blockers: journalBlockers(records, handoff.handoff_id, task_id) },
64
+ const tracking = createClickUpTrackingFromState({
65
+ projectDir, handoff, records, task_id, clock, extra_verified_refs: [...refs],
72
66
  });
73
67
  const projection = tracking.enqueue({
74
68
  projection_id: `clickup-${sha256({ handoff_id: handoff.handoff_id, task_id, attempt_id, operation, payload }).slice(0, 24)}`,
@@ -82,3 +76,26 @@ export function enqueueClickUpProjectionFromState({ projectDir, handoff, records
82
76
  }
83
77
  return projection;
84
78
  }
79
+
80
+ export function createClickUpTrackingFromState({ projectDir, handoff, records, task_id, clock = () => new Date(), extra_verified_refs = [] } = {}) {
81
+ const task = handoff.tasks.find((item) => item.task_id === task_id);
82
+ if (!task) throw Object.assign(new Error(`task ${task_id} is absent from handoff`), { code: 'TASK_NOT_FOUND' });
83
+ const handoff_ref = `rail-handoff://${handoff.handoff_id}/${handoff.seal.manifest_digest}`;
84
+ const journal_ref = `rail-journal://sha256/${sha256(records)}`;
85
+ const derivedDecisions = journalDecisions(records, handoff.handoff_id, task_id);
86
+ const refs = new Set([
87
+ handoff_ref,
88
+ journal_ref,
89
+ ...derivedDecisions.flatMap((item) => [item.decision_ref, item.review_ref]),
90
+ ...extra_verified_refs,
91
+ ]);
92
+ return new TrackingClickUp({
93
+ outbox_path: join(projectDir, '.chati/v2/tracking/clickup-outbox.json'),
94
+ project_evolution_dir: join(projectDir, '.chati/v2/project-evolution'),
95
+ clock, verify_completion: (candidate) => completionIsCanonical(records, candidate),
96
+ verify_reference: (ref) => refs.has(ref),
97
+ resolve_reference: (ref, type) => type === 'handoff'
98
+ ? { phase: 'rail-execution', handoff_id: handoff.handoff_id }
99
+ : { task_state: taskState(records, handoff.handoff_id, task_id), blockers: journalBlockers(records, handoff.handoff_id, task_id) },
100
+ });
101
+ }
@@ -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);