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.
- package/README.md +29 -27
- package/bin/chati.js +235 -1
- package/framework/agents/plan/tasks.md +46 -1
- package/framework/config.yaml +2 -2
- package/framework/constitution.md +13 -16
- package/framework/context/governance.md +5 -5
- package/framework/context/root.md +5 -5
- package/framework/i18n/en.yaml +8 -2
- package/framework/i18n/es.yaml +8 -2
- package/framework/i18n/fr.yaml +8 -2
- package/framework/i18n/pt.yaml +8 -2
- package/framework/manifest.json +21 -21
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati.md +43 -12
- package/node_modules/@chati/browser-capability/README.md +10 -0
- package/node_modules/@chati/browser-capability/package.json +17 -0
- package/node_modules/@chati/browser-capability/src/index.js +165 -0
- package/node_modules/@chati/core/package.json +13 -0
- package/node_modules/@chati/core/src/index.js +111 -0
- package/node_modules/@chati/knowledge-context/package.json +17 -0
- package/node_modules/@chati/knowledge-context/src/index.js +202 -0
- package/node_modules/@chati/planning/package.json +17 -0
- package/node_modules/@chati/planning/src/index.js +367 -0
- package/node_modules/@chati/provider-registry/package.json +16 -0
- package/node_modules/@chati/provider-registry/src/index.js +148 -0
- package/node_modules/@chati/rail/README.md +24 -0
- package/node_modules/@chati/rail/package.json +19 -0
- package/node_modules/@chati/rail/src/index.js +437 -0
- package/node_modules/@chati/release-lane/README.md +24 -0
- package/node_modules/@chati/release-lane/package.json +17 -0
- package/node_modules/@chati/release-lane/src/index.js +172 -0
- package/node_modules/@chati/review-council/package.json +17 -0
- package/node_modules/@chati/review-council/src/index.js +264 -0
- package/node_modules/@chati/tracking-clickup/README.md +55 -0
- package/node_modules/@chati/tracking-clickup/package.json +17 -0
- package/node_modules/@chati/tracking-clickup/src/index.js +293 -0
- package/package.json +25 -3
- package/src/config/ide-configs.js +11 -0
- package/src/context/domain-loader.js +1 -1
- package/src/dashboard/data-reader.js +1 -1
- package/src/executors/runner.js +1 -1
- package/src/installer/core.js +14 -13
- package/src/installer/provider-overlay.js +8 -15
- package/src/installer/scaffold-applier.js +1 -1
- package/src/installer/templates.js +12 -25
- package/src/installer/validator.js +5 -10
- package/src/installer-v2/catalog-client.js +165 -0
- package/src/installer-v2/index.js +304 -0
- package/src/installer-v2/model-catalog-envelope.json +278 -0
- package/src/installer-v2/model-catalog.json +130 -0
- package/src/installer-v2/model-catalog.sig +1 -0
- package/src/installer-v2/wizard-installation.js +116 -0
- package/src/intelligence/registry-manager.js +1 -1
- package/src/license/client.js +27 -1
- package/src/memory/session-digest.js +1 -1
- package/src/merger/yaml-merger.js +1 -1
- package/src/orchestrator/browser-runtime.js +25 -0
- package/src/orchestrator/cli.js +93 -8
- package/src/orchestrator/clickup-projection.js +84 -0
- package/src/orchestrator/clickup-runtime.js +13 -0
- package/src/orchestrator/knowledge-runtime.js +64 -0
- package/src/orchestrator/planning-runtime.js +127 -0
- package/src/orchestrator/rail-runtime.js +421 -0
- package/src/orchestrator/release-runtime.js +14 -0
- package/src/orchestrator/review-runtime.js +74 -0
- package/src/orchestrator/runtime-installation-v2.js +57 -0
- package/src/orchestrator/session-manager.js +1 -1
- package/src/telemetry/config.js +1 -1
- package/src/terminal/adapters/grok-adapter.js +16 -0
- package/src/terminal/adapters/index.js +1 -0
- package/src/terminal/cli-registry.js +20 -5
- package/src/terminal/prompt-builder.js +4 -2
- package/src/terminal/run-agent.js +3 -0
- package/src/terminal/run-parallel.js +21 -10
- package/src/terminal/spawner.js +7 -1
- package/src/terminal/team-task-list.js +1 -1
- package/src/upgrade/checker.js +1 -1
- package/src/upgrade/migrator.js +1 -1
- package/src/utils/config-parser.js +3 -8
- package/src/wizard/i18n.js +10 -4
- package/src/wizard/index.js +49 -17
- package/src/wizard/questions.js +50 -28
package/src/orchestrator/cli.js
CHANGED
|
@@ -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 :
|
|
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
|
+
}
|