chati-dev 4.4.1 → 4.5.2

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 (82) hide show
  1. package/README.md +29 -27
  2. package/bin/chati.js +235 -1
  3. package/framework/agents/plan/tasks.md +46 -1
  4. package/framework/config.yaml +2 -2
  5. package/framework/constitution.md +13 -16
  6. package/framework/context/governance.md +5 -5
  7. package/framework/context/root.md +5 -5
  8. package/framework/i18n/en.yaml +8 -2
  9. package/framework/i18n/es.yaml +8 -2
  10. package/framework/i18n/fr.yaml +8 -2
  11. package/framework/i18n/pt.yaml +8 -2
  12. package/framework/manifest.json +21 -21
  13. package/framework/manifest.sig +1 -1
  14. package/framework/orchestrator/chati.md +43 -12
  15. package/node_modules/@chati/browser-capability/README.md +10 -0
  16. package/node_modules/@chati/browser-capability/package.json +17 -0
  17. package/node_modules/@chati/browser-capability/src/index.js +165 -0
  18. package/node_modules/@chati/core/package.json +13 -0
  19. package/node_modules/@chati/core/src/index.js +111 -0
  20. package/node_modules/@chati/knowledge-context/package.json +17 -0
  21. package/node_modules/@chati/knowledge-context/src/index.js +202 -0
  22. package/node_modules/@chati/planning/package.json +17 -0
  23. package/node_modules/@chati/planning/src/index.js +367 -0
  24. package/node_modules/@chati/provider-registry/package.json +16 -0
  25. package/node_modules/@chati/provider-registry/src/index.js +148 -0
  26. package/node_modules/@chati/rail/README.md +24 -0
  27. package/node_modules/@chati/rail/package.json +19 -0
  28. package/node_modules/@chati/rail/src/index.js +437 -0
  29. package/node_modules/@chati/release-lane/README.md +24 -0
  30. package/node_modules/@chati/release-lane/package.json +17 -0
  31. package/node_modules/@chati/release-lane/src/index.js +172 -0
  32. package/node_modules/@chati/review-council/package.json +17 -0
  33. package/node_modules/@chati/review-council/src/index.js +264 -0
  34. package/node_modules/@chati/tracking-clickup/README.md +55 -0
  35. package/node_modules/@chati/tracking-clickup/package.json +17 -0
  36. package/node_modules/@chati/tracking-clickup/src/index.js +293 -0
  37. package/package.json +25 -3
  38. package/src/config/ide-configs.js +11 -0
  39. package/src/context/domain-loader.js +1 -1
  40. package/src/dashboard/data-reader.js +1 -1
  41. package/src/executors/runner.js +1 -1
  42. package/src/installer/core.js +14 -13
  43. package/src/installer/provider-overlay.js +8 -15
  44. package/src/installer/scaffold-applier.js +1 -1
  45. package/src/installer/templates.js +12 -25
  46. package/src/installer/validator.js +5 -10
  47. package/src/installer-v2/catalog-client.js +165 -0
  48. package/src/installer-v2/index.js +304 -0
  49. package/src/installer-v2/model-catalog-envelope.json +278 -0
  50. package/src/installer-v2/model-catalog.json +130 -0
  51. package/src/installer-v2/model-catalog.sig +1 -0
  52. package/src/installer-v2/wizard-installation.js +116 -0
  53. package/src/intelligence/registry-manager.js +1 -1
  54. package/src/license/client.js +27 -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 +93 -8
  59. package/src/orchestrator/clickup-projection.js +84 -0
  60. package/src/orchestrator/clickup-runtime.js +13 -0
  61. package/src/orchestrator/knowledge-runtime.js +64 -0
  62. package/src/orchestrator/planning-runtime.js +127 -0
  63. package/src/orchestrator/rail-runtime.js +421 -0
  64. package/src/orchestrator/release-runtime.js +14 -0
  65. package/src/orchestrator/review-runtime.js +74 -0
  66. package/src/orchestrator/runtime-installation-v2.js +57 -0
  67. package/src/orchestrator/session-manager.js +1 -1
  68. package/src/telemetry/config.js +1 -1
  69. package/src/terminal/adapters/grok-adapter.js +16 -0
  70. package/src/terminal/adapters/index.js +1 -0
  71. package/src/terminal/cli-registry.js +20 -5
  72. package/src/terminal/prompt-builder.js +4 -2
  73. package/src/terminal/run-agent.js +3 -0
  74. package/src/terminal/run-parallel.js +21 -10
  75. package/src/terminal/spawner.js +7 -1
  76. package/src/terminal/team-task-list.js +1 -1
  77. package/src/upgrade/checker.js +1 -1
  78. package/src/upgrade/migrator.js +1 -1
  79. package/src/utils/config-parser.js +3 -8
  80. package/src/wizard/i18n.js +10 -4
  81. package/src/wizard/index.js +49 -17
  82. package/src/wizard/questions.js +50 -28
@@ -45,7 +45,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from
45
45
  import { join, resolve, dirname } from 'path';
46
46
  import { fileURLToPath } from 'url';
47
47
  import { execSync } from 'child_process';
48
- import yaml from 'js-yaml';
48
+ import * as yaml from 'js-yaml';
49
49
  import {
50
50
  initTaskList, readTaskList, getTeamProgress,
51
51
  } from '../terminal/team-task-list.js';
@@ -53,6 +53,9 @@ import {
53
53
  recordEvent, EventType, clearTimeline,
54
54
  } from '../intelligence/timeline.js';
55
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';
56
59
 
57
60
  // Spawn commands must reference the terminal runners with an absolute,
58
61
  // layout-independent path: the old monorepo-relative
@@ -545,7 +548,7 @@ function sessionToPipelineState(session, projectDir) {
545
548
  * Build the spawn command string for an autonomous agent.
546
549
  * Exported for testing (path resolution must hold in every install layout).
547
550
  */
548
- export function buildSpawnCommand(agent, projectDir, previousAgent, provider, timeout) {
551
+ export function buildSpawnCommand(agent, projectDir, previousAgent, provider, timeout, model = null, strictProvider = false) {
549
552
  const parts = [
550
553
  'node', RUNNER('run-agent.js'),
551
554
  '--agent', agent,
@@ -553,6 +556,8 @@ export function buildSpawnCommand(agent, projectDir, previousAgent, provider, ti
553
556
  '--project-dir', JSON.stringify(resolve(projectDir)),
554
557
  '--previous-agent', previousAgent || 'none',
555
558
  '--provider', provider || 'claude',
559
+ ...(model ? ['--model', model] : []),
560
+ ...(strictProvider ? ['--strict-provider'] : []),
556
561
  '--timeout', String(timeout || 600000),
557
562
  ];
558
563
  return parts.join(' ');
@@ -562,7 +567,7 @@ export function buildSpawnCommand(agent, projectDir, previousAgent, provider, ti
562
567
  * Build the parallel spawn command string.
563
568
  * Exported for testing.
564
569
  */
565
- export function buildParallelSpawnCommand(agents, projectDir, previousAgent, provider, timeout) {
570
+ export function buildParallelSpawnCommand(agents, projectDir, previousAgent, provider, timeout, model = null, strictProvider = false) {
566
571
  const taskIds = agents.map(a => `${a}-task`).join(',');
567
572
  const parts = [
568
573
  'node', RUNNER('run-parallel.js'),
@@ -571,6 +576,8 @@ export function buildParallelSpawnCommand(agents, projectDir, previousAgent, pro
571
576
  '--project-dir', JSON.stringify(resolve(projectDir)),
572
577
  '--previous-agent', previousAgent || 'none',
573
578
  '--provider', provider || 'claude',
579
+ ...(model ? ['--model', model] : []),
580
+ ...(strictProvider ? ['--strict-provider'] : []),
574
581
  '--timeout', String(timeout || 900000),
575
582
  ];
576
583
  return parts.join(' ');
@@ -600,7 +607,19 @@ const AGENT_MODEL_MAP = {
600
607
  * Resolve provider and model for an agent.
601
608
  * Checks config.yaml for agent_overrides, falls back to AGENT_MODEL_MAP defaults.
602
609
  */
603
- 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
+ }
604
623
  const defaults = AGENT_MODEL_MAP[agent] || { provider: 'claude', model: 'sonnet', upgrade: '' };
605
624
 
606
625
  // Check config.yaml for agent_overrides
@@ -816,7 +835,10 @@ async function _handleNextInner(projectDir) {
816
835
  agent,
817
836
  agent_file: agentFile,
818
837
  phase: agentDef?.phase || session.mode,
819
- 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
+ })(),
820
842
  parallel_spawn_command: null,
821
843
  parallel_agents: [],
822
844
  handoff_status: { valid: true, missing: [], warnings: ['Resuming in-progress agent'] },
@@ -926,7 +948,7 @@ async function _handleNextInner(projectDir) {
926
948
  } else {
927
949
  action = 'spawn_parallel';
928
950
  parallelAgents = nextInfo.group;
929
- 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');
930
952
  }
931
953
  } else if (nextAgent === 'dev' && teamsEnabled) {
932
954
  // Build Team auto-spawn: dev + qa-implementation are NOT parallel in the
@@ -943,7 +965,7 @@ async function _handleNextInner(projectDir) {
943
965
  action = 'activate_interactive';
944
966
  } else {
945
967
  action = 'spawn_autonomous';
946
- spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000);
968
+ spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000, modelInfo.model, modelInfo.source === 'installation-v2');
947
969
  }
948
970
 
949
971
  // Surface criteria (Article XVII): even in autonomous mode, a high-stakes
@@ -1346,6 +1368,35 @@ async function handleAdvance(projectDir, args) {
1346
1368
 
1347
1369
  // Deterministic gates: block advance if required artifacts are missing.
1348
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
+
1349
1400
  // BRIEF gate: reference frames captured → Visualizer MUST run for ALL sites before Brief advances.
1350
1401
  if (agent === 'brief') {
1351
1402
  const refsDir = join(projectDir, 'artifacts', '4-UX', 'references');
@@ -1773,6 +1824,35 @@ async function handleAdvance(projectDir, args) {
1773
1824
  await recordAgentCompletion(projectDir, { agent, status, score, handoffData });
1774
1825
  recordEvent(projectDir, EventType.AGENT_COMPLETED, agent, { score, status });
1775
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
+
1776
1856
  // Fase 10 — Scaffold auto-gate. After qa-planning completes, check whether
1777
1857
  // a scaffold preset has cleared the confidence threshold and is still
1778
1858
  // unapplied. If so, return an action_required response with the 3-option
@@ -2551,7 +2631,12 @@ function generateTeamId(slug) {
2551
2631
  * providers would fail. We always fall back silently to spawn_parallel
2552
2632
  * (Gemini) or spawn_autonomous (Codex) when the active provider is not claude.
2553
2633
  */
2554
- 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;
2555
2640
  const configPath = join(projectDir, resolveFrameworkDir(projectDir), 'config.yaml');
2556
2641
  if (!existsSync(configPath)) return false;
2557
2642
  const raw = readFileSync(configPath, 'utf-8');
@@ -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';
@@ -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
+ }