chati-dev 4.4.0 → 4.5.1

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 (85) hide show
  1. package/README.md +29 -27
  2. package/bin/chati.js +251 -1
  3. package/framework/agents/plan/tasks.md +46 -1
  4. package/framework/config.yaml +3 -3
  5. package/framework/constitution.md +16 -17
  6. package/framework/context/governance.md +5 -5
  7. package/framework/context/root.md +5 -5
  8. package/framework/data/entity-registry.yaml +1 -1
  9. package/framework/manifest.json +1351 -0
  10. package/framework/manifest.sig +1 -0
  11. package/framework/orchestrator/chati.md +48 -14
  12. package/node_modules/@chati/browser-capability/README.md +10 -0
  13. package/node_modules/@chati/browser-capability/package.json +17 -0
  14. package/node_modules/@chati/browser-capability/src/index.js +165 -0
  15. package/node_modules/@chati/core/package.json +13 -0
  16. package/node_modules/@chati/core/src/index.js +111 -0
  17. package/node_modules/@chati/knowledge-context/package.json +17 -0
  18. package/node_modules/@chati/knowledge-context/src/index.js +202 -0
  19. package/node_modules/@chati/planning/package.json +17 -0
  20. package/node_modules/@chati/planning/src/index.js +367 -0
  21. package/node_modules/@chati/provider-registry/package.json +16 -0
  22. package/node_modules/@chati/provider-registry/src/index.js +132 -0
  23. package/node_modules/@chati/rail/README.md +24 -0
  24. package/node_modules/@chati/rail/package.json +19 -0
  25. package/node_modules/@chati/rail/src/index.js +437 -0
  26. package/node_modules/@chati/release-lane/README.md +24 -0
  27. package/node_modules/@chati/release-lane/package.json +17 -0
  28. package/node_modules/@chati/release-lane/src/index.js +172 -0
  29. package/node_modules/@chati/review-council/package.json +17 -0
  30. package/node_modules/@chati/review-council/src/index.js +264 -0
  31. package/node_modules/@chati/tracking-clickup/README.md +55 -0
  32. package/node_modules/@chati/tracking-clickup/package.json +17 -0
  33. package/node_modules/@chati/tracking-clickup/src/index.js +293 -0
  34. package/package.json +25 -3
  35. package/src/config/claude-settings-generator.js +9 -6
  36. package/src/config/ide-configs.js +11 -0
  37. package/src/context/domain-loader.js +1 -1
  38. package/src/dashboard/data-reader.js +1 -1
  39. package/src/executors/runner.js +1 -1
  40. package/src/installer/core.js +33 -3
  41. package/src/installer/manifest.js +30 -4
  42. package/src/installer/scaffold-applier.js +1 -1
  43. package/src/installer/signing-public-key.pem +1 -1
  44. package/src/installer/templates.js +3 -3
  45. package/src/installer/validator.js +3 -2
  46. package/src/installer-v2/catalog-client.js +141 -0
  47. package/src/installer-v2/index.js +301 -0
  48. package/src/installer-v2/model-catalog-envelope.json +59 -0
  49. package/src/installer-v2/model-catalog.json +33 -0
  50. package/src/installer-v2/model-catalog.sig +1 -0
  51. package/src/installer-v2/wizard-installation.js +115 -0
  52. package/src/intelligence/registry-manager.js +3 -2
  53. package/src/license/client.js +1 -1
  54. package/src/memory/magic-docs.js +6 -1
  55. package/src/memory/session-digest.js +1 -1
  56. package/src/merger/yaml-merger.js +1 -1
  57. package/src/orchestrator/browser-runtime.js +25 -0
  58. package/src/orchestrator/cli.js +112 -15
  59. package/src/orchestrator/clickup-projection.js +84 -0
  60. package/src/orchestrator/clickup-runtime.js +13 -0
  61. package/src/orchestrator/deviation-handler.js +5 -3
  62. package/src/orchestrator/knowledge-runtime.js +64 -0
  63. package/src/orchestrator/planning-runtime.js +127 -0
  64. package/src/orchestrator/rail-runtime.js +421 -0
  65. package/src/orchestrator/release-runtime.js +14 -0
  66. package/src/orchestrator/review-runtime.js +74 -0
  67. package/src/orchestrator/runtime-installation-v2.js +38 -0
  68. package/src/orchestrator/session-manager.js +1 -1
  69. package/src/telemetry/config.js +1 -1
  70. package/src/terminal/adapters/grok-adapter.js +16 -0
  71. package/src/terminal/adapters/index.js +1 -0
  72. package/src/terminal/cli-registry.js +14 -0
  73. package/src/terminal/prompt-builder.js +2 -0
  74. package/src/terminal/run-agent.js +3 -0
  75. package/src/terminal/run-parallel.js +35 -24
  76. package/src/terminal/spawner.js +7 -1
  77. package/src/terminal/team-task-list.js +1 -1
  78. package/src/upgrade/backup.js +22 -44
  79. package/src/upgrade/checker.js +1 -1
  80. package/src/upgrade/migrator.js +52 -13
  81. package/src/utils/constitution-meta.js +12 -0
  82. package/src/utils/feature-flags.js +25 -1
  83. package/src/wizard/i18n.js +8 -2
  84. package/src/wizard/index.js +39 -4
  85. package/src/wizard/questions.js +59 -2
@@ -1,7 +1,8 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
- import yaml from 'js-yaml';
3
+ import * as yaml from 'js-yaml';
4
4
  import { resolveFrameworkDir } from '../utils/framework-dir.js';
5
+ import { EXPECTED_ARTICLE_COUNT } from '../utils/constitution-meta.js';
5
6
  import { flattenEntitiesMap } from '../utils/flatten-entities.js';
6
7
 
7
8
  /**
@@ -143,7 +144,7 @@ export function runHealthCheck(targetDir) {
143
144
  if (existsSync(constitutionPath)) {
144
145
  const content = readFileSync(constitutionPath, 'utf-8');
145
146
  const articleCount = (content.match(/^## Article/gm) || []).length;
146
- checks.constitution.pass = articleCount >= 22;
147
+ checks.constitution.pass = articleCount === EXPECTED_ARTICLE_COUNT;
147
148
  checks.constitution.details = `${articleCount} articles`;
148
149
  } else {
149
150
  checks.constitution.details = 'Not found';
@@ -1,7 +1,7 @@
1
1
  import { homedir } from 'os';
2
2
  import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
3
3
  import { join } from 'path';
4
- import yaml from 'js-yaml';
4
+ import * as yaml from 'js-yaml';
5
5
  import { getMachineId, getMachineName } from './machine-id.js';
6
6
 
7
7
  const API_BASE = 'https://chati.dev/api';
@@ -12,7 +12,12 @@ import { existsSync, readFileSync, writeFileSync } from 'fs';
12
12
  import { join } from 'path';
13
13
 
14
14
  const SECTION_HEADER = '## Current State';
15
- const SECTION_REGEX = /^## Current State\n([\s\S]*?)(?=\n## |\n---|\n$|$)/m;
15
+ // The section ends at the next `## ` heading, a `---` rule, or the TRUE end of
16
+ // the file. `$(?![\s\S])` is the absolute end-of-string: with the `m` flag a
17
+ // bare `$` matches at every line end, which made the lazy capture stop after
18
+ // the FIRST content line, orphaning the rest of the section below the
19
+ // replacement on every write (the corruption grew with each update).
20
+ const SECTION_REGEX = /^## Current State\n([\s\S]*?)(?=\n## |\n---|$(?![\s\S]))/m;
16
21
 
17
22
  /**
18
23
  * Update CLAUDE.md with current project state after a handoff.
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from 'fs';
2
2
  import { join } from 'path';
3
- import yaml from 'js-yaml';
3
+ import * as yaml from 'js-yaml';
4
4
 
5
5
  const DIGEST_DIR = '.chati/memories/shared/session';
6
6
 
@@ -1,4 +1,4 @@
1
- import yaml from 'js-yaml';
1
+ import * as yaml from 'js-yaml';
2
2
 
3
3
  const MANAGED_START = '# CHATI-MANAGED-START';
4
4
  const MANAGED_END = '# CHATI-MANAGED-END';
@@ -0,0 +1,25 @@
1
+ import { assertBrowserCapabilityForTask, BrowserCapability } from '@chati/browser-capability';
2
+
3
+ /**
4
+ * G3 browser boundary. This runtime has no reference to Playwright, Chrome,
5
+ * MCP, credentials or a shared profile. It can only validate a task policy and
6
+ * use the internal fake adapter supplied by BrowserCapability.
7
+ */
8
+ export function createProjectBrowserSession({ task, request } = {}) {
9
+ const policy = assertBrowserCapabilityForTask(task);
10
+ if (!policy) return Object.freeze({ state: 'not-required', manifest: null, capability: null });
11
+ const capability = new BrowserCapability({});
12
+ const manifest = capability.preflight({ ...request, policy });
13
+ return Object.freeze({ state: 'preflighted', policy, manifest, capability });
14
+ }
15
+
16
+ /** Only the same local session may advance its fake adapter past preflight. */
17
+ export function launchProjectBrowserSession({ session, request } = {}) {
18
+ if (!session || session.state !== 'preflighted' || !session.capability || !session.policy) {
19
+ const error = new Error('browser launch requires a preflighted project-local browser session');
20
+ error.code = 'BROWSER_PREFLIGHT_REQUIRED';
21
+ throw error;
22
+ }
23
+ const manifest = session.capability.launch({ ...request, policy: session.policy });
24
+ return Object.freeze({ state: 'launched', manifest, fake_launches: session.capability.fakeLaunches, capability: session.capability, policy: session.policy });
25
+ }
@@ -42,9 +42,10 @@ import { analyzeDeviationImpact, applyDeviation } from './index.js';
42
42
  import { detectQuickFlow, detectStandardFlow } from './index.js';
43
43
  import { getAgentFile } from '../terminal/prompt-builder.js';
44
44
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
45
- import { join, resolve } from 'path';
45
+ import { join, resolve, dirname } from 'path';
46
+ import { fileURLToPath } from 'url';
46
47
  import { execSync } from 'child_process';
47
- import yaml from 'js-yaml';
48
+ import * as yaml from 'js-yaml';
48
49
  import {
49
50
  initTaskList, readTaskList, getTeamProgress,
50
51
  } from '../terminal/team-task-list.js';
@@ -52,6 +53,18 @@ import {
52
53
  recordEvent, EventType, clearTimeline,
53
54
  } from '../intelligence/timeline.js';
54
55
  import { updateClaudeMd } from '../memory/magic-docs.js';
56
+ import { loadRuntimeInstallationV2, resolveRuntimeInvocationV2 } from './runtime-installation-v2.js';
57
+ import { compilePlanningHandoff, PLANNING_V2_TASKS_PATH } from './planning-runtime.js';
58
+ import { persistRailHandoff } from './rail-runtime.js';
59
+
60
+ // Spawn commands must reference the terminal runners with an absolute,
61
+ // layout-independent path: the old monorepo-relative
62
+ // 'packages/chati-dev/src/terminal/...' only resolves when the command runs
63
+ // from the monorepo root, never in an installed project (.chati.dev/_cli/ or
64
+ // node_modules). Anchoring to this file's own location works in every layout
65
+ // because the src/ tree shape is preserved verbatim by the CLI bundling.
66
+ const _THISDIR = dirname(fileURLToPath(import.meta.url));
67
+ const RUNNER = (name) => JSON.stringify(join(_THISDIR, '..', 'terminal', name));
55
68
 
56
69
  // ---------------------------------------------------------------------------
57
70
  // Constants
@@ -533,15 +546,18 @@ function sessionToPipelineState(session, projectDir) {
533
546
 
534
547
  /**
535
548
  * Build the spawn command string for an autonomous agent.
549
+ * Exported for testing (path resolution must hold in every install layout).
536
550
  */
537
- function buildSpawnCommand(agent, projectDir, previousAgent, provider, timeout) {
551
+ export function buildSpawnCommand(agent, projectDir, previousAgent, provider, timeout, model = null, strictProvider = false) {
538
552
  const parts = [
539
- 'node', 'packages/chati-dev/src/terminal/run-agent.js',
553
+ 'node', RUNNER('run-agent.js'),
540
554
  '--agent', agent,
541
555
  '--task-id', `${agent}-task`,
542
- '--project-dir', resolve(projectDir),
556
+ '--project-dir', JSON.stringify(resolve(projectDir)),
543
557
  '--previous-agent', previousAgent || 'none',
544
558
  '--provider', provider || 'claude',
559
+ ...(model ? ['--model', model] : []),
560
+ ...(strictProvider ? ['--strict-provider'] : []),
545
561
  '--timeout', String(timeout || 600000),
546
562
  ];
547
563
  return parts.join(' ');
@@ -549,16 +565,19 @@ function buildSpawnCommand(agent, projectDir, previousAgent, provider, timeout)
549
565
 
550
566
  /**
551
567
  * Build the parallel spawn command string.
568
+ * Exported for testing.
552
569
  */
553
- function buildParallelSpawnCommand(agents, projectDir, previousAgent, provider, timeout) {
570
+ export function buildParallelSpawnCommand(agents, projectDir, previousAgent, provider, timeout, model = null, strictProvider = false) {
554
571
  const taskIds = agents.map(a => `${a}-task`).join(',');
555
572
  const parts = [
556
- 'node', 'packages/chati-dev/src/terminal/run-parallel.js',
573
+ 'node', RUNNER('run-parallel.js'),
557
574
  '--agents', agents.join(','),
558
575
  '--task-ids', taskIds,
559
- '--project-dir', resolve(projectDir),
576
+ '--project-dir', JSON.stringify(resolve(projectDir)),
560
577
  '--previous-agent', previousAgent || 'none',
561
578
  '--provider', provider || 'claude',
579
+ ...(model ? ['--model', model] : []),
580
+ ...(strictProvider ? ['--strict-provider'] : []),
562
581
  '--timeout', String(timeout || 900000),
563
582
  ];
564
583
  return parts.join(' ');
@@ -588,7 +607,19 @@ const AGENT_MODEL_MAP = {
588
607
  * Resolve provider and model for an agent.
589
608
  * Checks config.yaml for agent_overrides, falls back to AGENT_MODEL_MAP defaults.
590
609
  */
591
- function resolveAgentModel(agent, projectDir) {
610
+ export function resolveAgentModel(agent, projectDir) {
611
+ const v2Artifact = loadRuntimeInstallationV2(projectDir);
612
+ if (v2Artifact) {
613
+ const invocation = resolveRuntimeInvocationV2({ artifact: v2Artifact, agent });
614
+ return {
615
+ provider: invocation.harness_id,
616
+ provider_id: invocation.provider_id,
617
+ model: invocation.model_pin.model_id,
618
+ reasoning_configuration: invocation.model_pin.reasoning_configuration,
619
+ catalog_snapshot_ref: invocation.model_pin.catalog_snapshot_ref,
620
+ source: 'installation-v2',
621
+ };
622
+ }
592
623
  const defaults = AGENT_MODEL_MAP[agent] || { provider: 'claude', model: 'sonnet', upgrade: '' };
593
624
 
594
625
  // Check config.yaml for agent_overrides
@@ -804,7 +835,10 @@ async function _handleNextInner(projectDir) {
804
835
  agent,
805
836
  agent_file: agentFile,
806
837
  phase: agentDef?.phase || session.mode,
807
- spawn_command: isInteractive ? null : buildSpawnCommand(agent, projectDir, session.last_handoff || 'none', resolveAgentModel(agent, projectDir).provider, 600000),
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
+ })(),
808
842
  parallel_spawn_command: null,
809
843
  parallel_agents: [],
810
844
  handoff_status: { valid: true, missing: [], warnings: ['Resuming in-progress agent'] },
@@ -914,7 +948,7 @@ async function _handleNextInner(projectDir) {
914
948
  } else {
915
949
  action = 'spawn_parallel';
916
950
  parallelAgents = nextInfo.group;
917
- parallelSpawnCommand = buildParallelSpawnCommand(nextInfo.group, projectDir, lastAgent, modelInfo.provider, 900000);
951
+ parallelSpawnCommand = buildParallelSpawnCommand(nextInfo.group, projectDir, lastAgent, modelInfo.provider, 900000, modelInfo.model, modelInfo.source === 'installation-v2');
918
952
  }
919
953
  } else if (nextAgent === 'dev' && teamsEnabled) {
920
954
  // Build Team auto-spawn: dev + qa-implementation are NOT parallel in the
@@ -931,7 +965,7 @@ async function _handleNextInner(projectDir) {
931
965
  action = 'activate_interactive';
932
966
  } else {
933
967
  action = 'spawn_autonomous';
934
- spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000);
968
+ spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000, modelInfo.model, modelInfo.source === 'installation-v2');
935
969
  }
936
970
 
937
971
  // Surface criteria (Article XVII): even in autonomous mode, a high-stakes
@@ -1334,6 +1368,35 @@ async function handleAdvance(projectDir, args) {
1334
1368
 
1335
1369
  // Deterministic gates: block advance if required artifacts are missing.
1336
1370
 
1371
+ // V2 replaces the legacy Build/Deploy entry with a sealed, project-local
1372
+ // RAIL handoff. QA Planning validates planning quality, but the Tasks agent
1373
+ // is the first stage that produces the structured task graph required by
1374
+ // the compiler. Sealing at qa-planning therefore races the normal pipeline
1375
+ // and can only produce a false missing-artifact block.
1376
+ //
1377
+ // The compiler is deliberately strict: prose planning cannot become
1378
+ // executable work without task-level scope, criteria, estimates,
1379
+ // dependencies and ClickUp references.
1380
+ if (agent === 'tasks' && status === 'completed' && score >= 95 && loadRuntimeInstallationV2(projectDir)) {
1381
+ try {
1382
+ const handoff = compilePlanningHandoff({ projectDir });
1383
+ handoffData.rail_handoff = persistRailHandoff({ projectDir, handoff });
1384
+ } catch (error) {
1385
+ return {
1386
+ advanced: false,
1387
+ blocked: true,
1388
+ reason: error.code || 'RAIL_HANDOFF_COMPILATION_FAILED',
1389
+ message: error.message,
1390
+ expected_artifact: PLANNING_V2_TASKS_PATH,
1391
+ action_required: {
1392
+ owner: 'Tasks and QA Planning agents',
1393
+ artifact: PLANNING_V2_TASKS_PATH,
1394
+ requirements: ['source artifacts', 'task IDs and dependencies', 'Given-When-Then criteria', 'execution scopes', 'AI and human estimates', 'ClickUp references'],
1395
+ },
1396
+ };
1397
+ }
1398
+ }
1399
+
1337
1400
  // BRIEF gate: reference frames captured → Visualizer MUST run for ALL sites before Brief advances.
1338
1401
  if (agent === 'brief') {
1339
1402
  const refsDir = join(projectDir, 'artifacts', '4-UX', 'references');
@@ -1761,6 +1824,35 @@ async function handleAdvance(projectDir, args) {
1761
1824
  await recordAgentCompletion(projectDir, { agent, status, score, handoffData });
1762
1825
  recordEvent(projectDir, EventType.AGENT_COMPLETED, agent, { score, status });
1763
1826
 
1827
+ // A v2 task graph has become an immutable RAIL handoff. Do not silently
1828
+ // continue into the legacy BUILD/DEPLOY agent chain: that would create two
1829
+ // competing execution authorities for the same plan. The caller receives
1830
+ // the handoff identifier and must enter the project-local RAIL lifecycle.
1831
+ if (handoffData.rail_handoff) {
1832
+ const railState = {
1833
+ current_agent: 'rail',
1834
+ last_handoff: agent,
1835
+ pipeline_position: (session.completed_agents || []).length + 1,
1836
+ rail_handoff_id: handoffData.rail_handoff.handoff.handoff_id,
1837
+ rail_handoff_path: handoffData.rail_handoff.path,
1838
+ rail_state: 'ready',
1839
+ };
1840
+ const saved = await updateSession(projectDir, railState);
1841
+ if (saved && saved.saved === false) return errorResult(`Session update failed: ${saved.error}`, 'SESSION_WRITE_FAILED');
1842
+ return {
1843
+ advanced: true,
1844
+ agent_completed: agent,
1845
+ score,
1846
+ rail_ready: true,
1847
+ rail_handoff: handoffData.rail_handoff,
1848
+ next: {
1849
+ action: 'rail_execute',
1850
+ handoff_id: handoffData.rail_handoff.handoff.handoff_id,
1851
+ reason: 'v2 planning is sealed; RAIL is now the sole execution authority',
1852
+ },
1853
+ };
1854
+ }
1855
+
1764
1856
  // Fase 10 — Scaffold auto-gate. After qa-planning completes, check whether
1765
1857
  // a scaffold preset has cleared the confidence threshold and is still
1766
1858
  // unapplied. If so, return an action_required response with the 3-option
@@ -2539,7 +2631,12 @@ function generateTeamId(slug) {
2539
2631
  * providers would fail. We always fall back silently to spawn_parallel
2540
2632
  * (Gemini) or spawn_autonomous (Codex) when the active provider is not claude.
2541
2633
  */
2542
- function isAgentTeamsEnabled(projectDir) {
2634
+ export function isAgentTeamsEnabled(projectDir) {
2635
+ // The v1 Agent Teams runner is Claude-specific. Provider-neutral v2 routes
2636
+ // parallel work through the regular strict-provider runner until its own
2637
+ // team coordinator exists. It must never silently use Claude's native team
2638
+ // semantics for a Codex- or Grok-selected installation.
2639
+ if (loadRuntimeInstallationV2(projectDir)) return false;
2543
2640
  const configPath = join(projectDir, resolveFrameworkDir(projectDir), 'config.yaml');
2544
2641
  if (!existsSync(configPath)) return false;
2545
2642
  const raw = readFileSync(configPath, 'utf-8');
@@ -2700,10 +2797,10 @@ async function handleSpawnTeam(projectDir, args) {
2700
2797
 
2701
2798
  // Build spawn command
2702
2799
  const spawnCmd = [
2703
- 'node', join('packages', 'chati-dev', 'src', 'terminal', 'run-team.js'),
2800
+ 'node', RUNNER('run-team.js'),
2704
2801
  '--team-id', teamId,
2705
2802
  '--team-type', teamType,
2706
- '--project-dir', resolve(projectDir),
2803
+ '--project-dir', JSON.stringify(resolve(projectDir)),
2707
2804
  '--previous-agent', previousAgent,
2708
2805
  '--provider', provider,
2709
2806
  '--timeout', args.timeout || '1800000',
@@ -0,0 +1,84 @@
1
+ import { join } from 'node:path';
2
+ import { sha256 } from '@chati/core';
3
+ import { TrackingClickUp } from '@chati/tracking-clickup';
4
+
5
+ function handoffAttemptIds(records, handoffId) {
6
+ return new Set(records.filter((record) => record.type === 'attempt_claimed' && record.payload?.attempt?.handoff_id === handoffId).map((record) => record.payload.attempt.attempt_id));
7
+ }
8
+
9
+ function taskState(records, handoffId, taskId) {
10
+ const attempts = handoffAttemptIds(records, handoffId);
11
+ const attempt = records.filter((record) => (record.payload?.attempt?.task_id === taskId && record.payload.attempt.handoff_id === handoffId)
12
+ || (record.payload?.completion?.task_id === taskId && attempts.has(record.payload.completion.attempt_id))).at(-1);
13
+ if (!attempt) return 'pending';
14
+ if (attempt.type === 'attempt_completed') return attempt.payload.completion.state;
15
+ return 'active';
16
+ }
17
+
18
+ function completionIsCanonical(records, completion) {
19
+ if (!completion || typeof completion !== 'object' || typeof completion.completion_id !== 'string') return false;
20
+ const material = { ...completion };
21
+ delete material.event_id;
22
+ delete material.immutable_ref;
23
+ return records.some((record) => record.type === 'attempt_completed'
24
+ && record.payload?.completion?.completion_id === completion.completion_id
25
+ && sha256(record.payload.completion) === sha256(material));
26
+ }
27
+
28
+ function journalBlockers(records, handoffId, taskId) {
29
+ const attempts = handoffAttemptIds(records, handoffId);
30
+ return records
31
+ .filter((record) => (record.type === 'attempt_blocked' || record.type === 'attempt_cancelled') && record.payload?.attempt_id)
32
+ .filter((record) => attempts.has(record.payload.attempt_id)
33
+ && records.some((claim) => claim.type === 'attempt_claimed' && claim.payload?.attempt?.attempt_id === record.payload.attempt_id && claim.payload.attempt.task_id === taskId))
34
+ .map((record) => `${record.type}:${record.payload.attempt_id}`);
35
+ }
36
+
37
+ function journalDecisions(records, handoffId, taskId) {
38
+ const attempts = handoffAttemptIds(records, handoffId);
39
+ return records
40
+ .filter((record) => record.type === 'review_decided' && record.payload?.task_id === taskId && attempts.has(record.payload.attempt_id))
41
+ .map((record) => ({
42
+ decision_ref: `rail-review-decision://${handoffId}/${record.event_id}`,
43
+ decision: record.payload.decision.decision,
44
+ review_ref: record.payload.review_ref,
45
+ }));
46
+ }
47
+
48
+ export function enqueueClickUpProjectionFromState({ projectDir, handoff, records, task_id, attempt_id, operation = 'update', payload, completion, decisions, clock = () => new Date() } = {}) {
49
+ const task = handoff.tasks.find((item) => item.task_id === task_id);
50
+ if (!task) throw Object.assign(new Error(`task ${task_id} is absent from handoff`), { code: 'TASK_NOT_FOUND' });
51
+ const handoff_ref = `rail-handoff://${handoff.handoff_id}/${handoff.seal.manifest_digest}`;
52
+ const journal_ref = `rail-journal://sha256/${sha256(records)}`;
53
+ const derivedDecisions = journalDecisions(records, handoff.handoff_id, task_id);
54
+ // Canonical journal decisions always win over caller-supplied annotations
55
+ // sharing the same identity.
56
+ const normalizedDecisions = [...new Map([...(decisions || []), ...derivedDecisions].map((item) => [item.decision_ref, item])).values()];
57
+ const refs = new Set([
58
+ handoff_ref,
59
+ journal_ref,
60
+ ...derivedDecisions.flatMap((item) => [item.decision_ref, item.review_ref]),
61
+ ...(completion?.reviews || []),
62
+ ...(completion?.acceptance_evidence_refs || []),
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) },
72
+ });
73
+ const projection = tracking.enqueue({
74
+ projection_id: `clickup-${sha256({ handoff_id: handoff.handoff_id, task_id, attempt_id, operation, payload }).slice(0, 24)}`,
75
+ idempotency_key: `clickup:${handoff.handoff_id}:${task_id}:${attempt_id}:${operation}:${completion?.state || payload?.status || 'active'}`,
76
+ canonical_event_id: `rail:${sha256(records).slice(0, 24)}`,
77
+ task_id, attempt_id, clickup_ref: task.clickup_ref, operation, payload, completion, decisions: normalizedDecisions,
78
+ project_id: handoff.project_id, handoff_ref, journal_ref,
79
+ });
80
+ if (operation === 'close' && completion?.metrics) {
81
+ return tracking.recordMetrics({ projection_id: projection.projection_id, completion, ...completion.metrics });
82
+ }
83
+ return projection;
84
+ }
@@ -0,0 +1,13 @@
1
+ import { createProjectRailEngine, loadRailHandoff } from './rail-runtime.js';
2
+ import { enqueueClickUpProjectionFromState } from './clickup-projection.js';
3
+
4
+ /** Enqueues a durable local ClickUp projection. It never sends to ClickUp. */
5
+ export function enqueueClickUpProjection({ projectDir, handoff_id, task_id, attempt_id, operation = 'update', payload, completion, decisions, clock = () => new Date() } = {}) {
6
+ const { handoff } = loadRailHandoff({ projectDir, handoff_id });
7
+ const records = createProjectRailEngine({ projectDir, clock }).records;
8
+ return enqueueClickUpProjectionFromState({
9
+ projectDir, handoff, records, task_id, attempt_id, operation, payload, completion, decisions, clock,
10
+ });
11
+ }
12
+
13
+ export { enqueueClickUpProjectionFromState } from './clickup-projection.js';
@@ -105,7 +105,9 @@ export function detectDeviation(userMessage, _pipelineState) {
105
105
  * @returns {{ impact: 'low'|'medium'|'high', affectedAgents: string[], recommendation: string, requiresConfirmation: boolean }}
106
106
  */
107
107
  export function analyzeDeviationImpact(deviationType, pipelineState, details = {}) {
108
- const currentAgent = pipelineState.current_agent;
108
+ // sessionToPipelineState produces camelCase (currentAgent); accept the legacy
109
+ // snake_case shape too so direct callers keep working.
110
+ const currentAgent = pipelineState.currentAgent ?? pipelineState.current_agent;
109
111
  const completedAgents = Object.entries(pipelineState.agents || {})
110
112
  .filter(([, data]) => data.status === 'completed')
111
113
  .map(([name]) => name);
@@ -268,7 +270,7 @@ export function applyDeviation(pipelineState, deviationType, details) {
268
270
  changes.push(`Reset agent: ${agentName}`);
269
271
  }
270
272
 
271
- newState.current_agent = targetAgent;
273
+ newState.currentAgent = targetAgent;
272
274
  changes.push(`Rolled back to: ${targetAgent}`);
273
275
 
274
276
  break;
@@ -302,7 +304,7 @@ export function applyDeviation(pipelineState, deviationType, details) {
302
304
  }
303
305
 
304
306
  // Reset to initial state
305
- newState.current_agent = '';
307
+ newState.currentAgent = '';
306
308
  newState.backlog = [];
307
309
  changes.push('Reset all agents to initial state');
308
310
 
@@ -0,0 +1,64 @@
1
+ import { emitLocalLearningCandidate, validateKnowledgeRetrievalRequest } from '@chati/knowledge-context';
2
+ import { loadRuntimeInstallationV2 } from './runtime-installation-v2.js';
3
+ import { createProjectRailEngine, loadRailHandoff } from './rail-runtime.js';
4
+
5
+ function fail(code, message) {
6
+ throw Object.assign(new Error(message), { code });
7
+ }
8
+
9
+ function railCandidateWithVerifiedLineage({ projectDir, candidate, handoff_id, task_id, attempt_id, clock }) {
10
+ if (candidate?.origin !== 'rail-evidence') return candidate;
11
+ for (const [name, value] of Object.entries({ handoff_id, task_id, attempt_id })) {
12
+ if (typeof value !== 'string' || value.trim() === '') fail('RAIL_CANDIDATE_LINEAGE_REQUIRED', `${name} is required for rail-evidence candidates`);
13
+ }
14
+ const { handoff } = loadRailHandoff({ projectDir, handoff_id });
15
+ if (!handoff.tasks.some((task) => task.task_id === task_id)) fail('RAIL_CANDIDATE_TASK_NOT_FOUND', 'candidate task is absent from the sealed handoff');
16
+ const engine = createProjectRailEngine({ projectDir, ...(clock === undefined ? {} : { clock }) });
17
+ const claim = engine.records.find((record) => record.type === 'attempt_claimed'
18
+ && record.payload?.attempt?.attempt_id === attempt_id
19
+ && record.payload.attempt.handoff_id === handoff_id
20
+ && record.payload.attempt.task_id === task_id);
21
+ if (!claim) fail('RAIL_CANDIDATE_ATTEMPT_NOT_FOUND', 'candidate attempt has no matching project-local RAIL claim');
22
+ const lineage_refs = [...new Set([
23
+ ...(candidate.lineage_refs || []),
24
+ `rail-handoff://${handoff_id}/${handoff.seal.manifest_digest}`,
25
+ `rail-task://${handoff_id}/${task_id}`,
26
+ `rail-attempt://${handoff_id}/${attempt_id}/${claim.event_id}`,
27
+ ])];
28
+ return { ...candidate, lineage_refs };
29
+ }
30
+
31
+ /**
32
+ * CHATI's knowledge boundary. Retrieval is delegated to an injected read-only
33
+ * adapter. Candidate emission is project-local and never promotes to a Brain.
34
+ */
35
+ export function retrieveProjectKnowledge({ projectDir, request, adapter, clock = () => new Date() } = {}) {
36
+ const installation = loadRuntimeInstallationV2(projectDir, { clock });
37
+ if (!installation) throw Object.assign(new Error('knowledge retrieval requires a v2 installation'), { code: 'V2_INSTALLATION_REQUIRED' });
38
+ const capability = installation.brain_read_capability;
39
+ if (capability?.status !== 'configured') return Object.freeze({ state: 'not-configured', returned_learning_refs: [] });
40
+ if (capability.mode !== 'read-only') fail('BRAIN_WRITE_FORBIDDEN', 'knowledge capability must remain read-only at retrieval time');
41
+ if (!adapter || adapter.mode !== 'read-only' || typeof adapter.retrieve !== 'function') fail('KNOWLEDGE_ADAPTER_UNAVAILABLE', 'an explicitly read-only knowledge adapter is required');
42
+ const callableCapabilities = new Set();
43
+ for (let cursor = adapter; cursor && cursor !== Object.prototype; cursor = Object.getPrototypeOf(cursor)) {
44
+ for (const key of Reflect.ownKeys(cursor)) {
45
+ if (key !== 'constructor' && typeof adapter[key] === 'function') callableCapabilities.add(key);
46
+ }
47
+ }
48
+ const mutatingCapabilities = [...callableCapabilities].filter((key) => key !== 'retrieve');
49
+ if (mutatingCapabilities.length > 0) fail('BRAIN_WRITE_FORBIDDEN', 'knowledge adapter may expose only the retrieve capability');
50
+ const validated = validateKnowledgeRetrievalRequest(request);
51
+ if (validated.policy_ref !== capability.policy_ref) fail('KNOWLEDGE_POLICY_MISMATCH', 'retrieval policy_ref must match the installed Brain read capability');
52
+ const receipt = adapter.retrieve(validated);
53
+ if (!receipt || typeof receipt !== 'object' || receipt.policy_ref !== capability.policy_ref || receipt.retrieval_id !== validated.retrieval_id) {
54
+ fail('INVALID_KNOWLEDGE_RECEIPT', 'knowledge receipt must remain bound to the installed policy and retrieval request');
55
+ }
56
+ return Object.freeze(receipt);
57
+ }
58
+
59
+ export function emitProjectKnowledgeCandidate({ projectDir, candidate, handoff_id, task_id, attempt_id, clock } = {}) {
60
+ const installation = loadRuntimeInstallationV2(projectDir, { ...(clock === undefined ? {} : { clock }) });
61
+ if (!installation) throw Object.assign(new Error('knowledge candidate emission requires a v2 installation'), { code: 'V2_INSTALLATION_REQUIRED' });
62
+ const verified = railCandidateWithVerifiedLineage({ projectDir, candidate, handoff_id, task_id, attempt_id, clock });
63
+ return emitLocalLearningCandidate({ project_dir: projectDir, candidate: verified });
64
+ }
@@ -0,0 +1,127 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { sha256 } from '@chati/core';
5
+ import { sealHandoff } from '@chati/planning';
6
+ import { assertEligibleInvocation } from '@chati/provider-registry';
7
+ import { loadRuntimeInstallationV2 } from './runtime-installation-v2.js';
8
+
9
+ export const PLANNING_V2_TASKS_PATH = '.chati/v2/planning/tasks.json';
10
+
11
+ function fail(code, message, cause) {
12
+ const error = new Error(message, cause ? { cause } : undefined);
13
+ error.code = code;
14
+ throw error;
15
+ }
16
+
17
+ function readJson(path, code) {
18
+ if (!existsSync(path)) fail(code, `missing required planning artifact: ${path}`);
19
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch (error) { return fail(code, `invalid JSON planning artifact: ${path}`, error); }
20
+ }
21
+
22
+ function immutableFileRef(projectDir, relativePath, code) {
23
+ if (typeof relativePath !== 'string' || relativePath.trim() === '') fail(code, 'planning source path is required');
24
+ const path = join(projectDir, relativePath);
25
+ if (!existsSync(path)) fail(code, `planning source file does not exist: ${relativePath}`);
26
+ return `local-artifact://sha256/${sha256(readFileSync(path, 'utf8'))}`;
27
+ }
28
+
29
+ function criterionRef(taskId, criterion) {
30
+ if (typeof criterion === 'string' && criterion.trim()) return `local-artifact://sha256/${sha256({ task_id: taskId, criterion })}`;
31
+ if (criterion && typeof criterion === 'object' && ['given', 'when', 'then'].every((field) => typeof criterion[field] === 'string' && criterion[field].trim())) {
32
+ return `local-artifact://sha256/${sha256({ task_id: taskId, criterion })}`;
33
+ }
34
+ return fail('INVALID_PLANNING_TASK', `task ${taskId} has an invalid acceptance criterion`);
35
+ }
36
+
37
+ function gitCommit(projectDir) {
38
+ try { return execFileSync('git', ['-C', projectDir, 'rev-parse', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); }
39
+ catch (error) { return fail('MISSING_GIT_COMMIT', 'Planning-to-RAIL handoff requires a local Git commit', error); }
40
+ }
41
+
42
+ function taskExecutionBinding(task, installation, clock) {
43
+ const binding = task.execution_binding;
44
+ if (!binding || typeof binding !== 'object' || Array.isArray(binding)) {
45
+ fail('TASK_EXECUTION_BINDING_REQUIRED', `task ${task.id} must select an execution binding`);
46
+ }
47
+ for (const field of ['provider_id', 'harness_id', 'model_id', 'reasoning_configuration']) {
48
+ if (typeof binding[field] !== 'string' || !binding[field].trim()) {
49
+ fail('TASK_EXECUTION_BINDING_REQUIRED', `task ${task.id} execution_binding.${field} is required`);
50
+ }
51
+ }
52
+ const invocation = {
53
+ provider_id: binding.provider_id,
54
+ harness_id: binding.harness_id,
55
+ action: 'build',
56
+ model_pin: {
57
+ model_id: binding.model_id,
58
+ catalog_snapshot_ref: installation.capability_snapshot.snapshot_id,
59
+ reasoning_configuration: binding.reasoning_configuration,
60
+ },
61
+ };
62
+ try {
63
+ assertEligibleInvocation({ installation: installation.installation, snapshot: installation.capability_snapshot, invocation, clock });
64
+ } catch (error) {
65
+ fail(error.code || 'TASK_EXECUTION_BINDING_INVALID', `task ${task.id} has an ineligible execution binding: ${error.message}`, error);
66
+ }
67
+ return binding;
68
+ }
69
+
70
+ /**
71
+ * Compiles the structured output of the Tasks agent into the only artifact the
72
+ * RAIL runtime accepts. It does not infer scope, criteria, estimates, ClickUp
73
+ * mapping, or dependencies from prose.
74
+ */
75
+ export function compilePlanningHandoff({ projectDir, clock = () => new Date() } = {}) {
76
+ const installation = loadRuntimeInstallationV2(projectDir, { clock });
77
+ if (!installation) fail('V2_INSTALLATION_REQUIRED', 'planning-to-RAIL compilation requires a v2 installation');
78
+ const inputPath = join(projectDir, PLANNING_V2_TASKS_PATH);
79
+ const planning = readJson(inputPath, 'PLANNING_TASK_GRAPH_REQUIRED');
80
+ if (!Array.isArray(planning.tasks) || planning.tasks.length === 0) fail('PLANNING_TASK_GRAPH_REQUIRED', 'planning tasks must be a non-empty array');
81
+ if (!planning.source_artifacts || typeof planning.source_artifacts !== 'object') fail('PLANNING_SOURCE_ARTIFACTS_REQUIRED', 'planning source_artifacts are required');
82
+ const sources = planning.source_artifacts;
83
+ const source_artifacts = {
84
+ requirements_ref: immutableFileRef(projectDir, sources.requirements_path, 'PLANNING_SOURCE_ARTIFACTS_REQUIRED'),
85
+ architecture_ref: immutableFileRef(projectDir, sources.architecture_path, 'PLANNING_SOURCE_ARTIFACTS_REQUIRED'),
86
+ qa_plan_ref: immutableFileRef(projectDir, sources.qa_plan_path, 'PLANNING_SOURCE_ARTIFACTS_REQUIRED'),
87
+ };
88
+ if (sources.ux_path) source_artifacts.ux_ref = immutableFileRef(projectDir, sources.ux_path, 'PLANNING_SOURCE_ARTIFACTS_REQUIRED');
89
+ const ids = new Set();
90
+ const clickupRequired = installation.installation.profile === 'focus-ai-internal'
91
+ && installation.installation.external_integrations?.clickup === 'required';
92
+ const tasks = planning.tasks.map((task) => {
93
+ 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
+ ids.add(task.id);
95
+ if (!Array.isArray(task.criteria) || task.criteria.length === 0) fail('INVALID_PLANNING_TASK', `task ${task.id} must have acceptance criteria`);
96
+ if (typeof task.execution_scope_ref !== 'string' || !task.execution_scope_ref.trim()) fail('INVALID_PLANNING_TASK', `task ${task.id} must have execution_scope_ref`);
97
+ if (clickupRequired && (typeof task.clickup_ref !== 'string' || !task.clickup_ref.trim())) fail('MISSING_CLICKUP_REFERENCE', `internal task ${task.id} must have clickup_ref`);
98
+ if (task.clickup_ref !== undefined && (typeof task.clickup_ref !== 'string' || !task.clickup_ref.trim())) fail('INVALID_CLICKUP_REFERENCE', `task ${task.id} clickup_ref must be a non-empty string when provided`);
99
+ if (!task.estimate || task.estimate.ai_processing == null || task.estimate.human_effort == null || task.estimate.uncertainty == null) fail('INVALID_PLANNING_TASK', `task ${task.id} must have ai_processing, human_effort and uncertainty estimates`);
100
+ return {
101
+ task_id: task.id,
102
+ dependency_ids: task.dependencies ?? [],
103
+ acceptance_criteria_refs: task.criteria.map((criterion) => criterionRef(task.id, criterion)),
104
+ execution_scope_ref: task.execution_scope_ref,
105
+ routing_constraints_ref: task.routing_constraints_ref || `policy://installation/${installation.installation.installation_id}/task/${task.id}`,
106
+ execution_binding: taskExecutionBinding(task, installation, clock),
107
+ estimate: task.estimate,
108
+ ...(task.clickup_ref === undefined ? {} : { clickup_ref: task.clickup_ref }),
109
+ ...(task.requires_browser === undefined ? {} : { requires_browser: task.requires_browser }),
110
+ ...(task.browser_policy === undefined ? {} : { browser_policy: task.browser_policy }),
111
+ };
112
+ });
113
+ const commit = gitCommit(projectDir);
114
+ const task_graph_ref = `local-artifact://sha256/${sha256(readFileSync(inputPath, 'utf8'))}`;
115
+ const handoff_id = planning.handoff_id || `handoff-${sha256({ project_id: installation.project_id, task_graph_ref, git_commit_ref: commit }).slice(0, 24)}`;
116
+ const now = clock();
117
+ const sealedAt = (now instanceof Date ? now : new Date(now)).toISOString();
118
+ return sealHandoff({
119
+ handoff_id,
120
+ version: planning.version || '1.0.0',
121
+ project_id: installation.project_id,
122
+ source_artifacts,
123
+ task_graph_ref,
124
+ tasks,
125
+ seal: { sealed_by: planning.sealed_by || 'actor://chati/qa-planning', sealed_at: sealedAt, git_commit_ref: commit },
126
+ });
127
+ }