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
@@ -0,0 +1,421 @@
1
+ import { existsSync, linkSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { sha256 } from '@chati/core';
4
+ import { RailEngine } from '@chati/rail';
5
+ import { validateSealedHandoff } from '@chati/planning';
6
+ import { loadRuntimeInstallationV2, resolveRuntimeInvocationV2 } from './runtime-installation-v2.js';
7
+ import { createProjectBrowserSession, launchProjectBrowserSession } from './browser-runtime.js';
8
+ import { enqueueClickUpProjectionFromState } from './clickup-projection.js';
9
+
10
+ export const RAIL_V2_DIR = '.chati/v2/rail';
11
+
12
+ function fail(code, message, cause) {
13
+ const error = new Error(message, cause ? { cause } : undefined);
14
+ error.code = code;
15
+ throw error;
16
+ }
17
+
18
+ function handoffPath(projectDir, handoffId) {
19
+ if (typeof handoffId !== 'string' || handoffId.trim() === '') fail('INVALID_HANDOFF_ID', 'handoff_id is required');
20
+ if (!/^[A-Za-z0-9._-]+$/.test(handoffId)) fail('INVALID_HANDOFF_ID', 'handoff_id contains unsupported path characters');
21
+ return join(projectDir, RAIL_V2_DIR, 'handoffs', `${handoffId}.json`);
22
+ }
23
+
24
+ function completionReference(handoffId, attemptId, completionId) {
25
+ for (const [label, value] of Object.entries({ handoff_id: handoffId, attempt_id: attemptId, completion_id: completionId })) {
26
+ if (typeof value !== 'string' || !/^[A-Za-z0-9._-]+$/.test(value)) fail('INVALID_COMPLETION_REFERENCE', `${label} contains unsupported reference characters`);
27
+ }
28
+ return `rail-completion://${handoffId}/${attemptId}/${completionId}`;
29
+ }
30
+
31
+ export function getRailPaths(projectDir) {
32
+ return Object.freeze({
33
+ root: join(projectDir, RAIL_V2_DIR),
34
+ handoffs: join(projectDir, RAIL_V2_DIR, 'handoffs'),
35
+ journal: join(projectDir, RAIL_V2_DIR, 'journal.jsonl'),
36
+ });
37
+ }
38
+
39
+ /** Persist one sealed planning handoff. Conflicting material is never overwritten. */
40
+ export function persistRailHandoff({ projectDir, handoff }) {
41
+ const validated = validateSealedHandoff(handoff);
42
+ const path = handoffPath(projectDir, validated.handoff_id);
43
+ const content = `${JSON.stringify(validated, null, 2)}\n`;
44
+ if (existsSync(path)) {
45
+ const existing = readFileSync(path, 'utf8');
46
+ if (existing === content) return Object.freeze({ path, handoff: validated, written: false });
47
+ fail('HANDOFF_STORAGE_CONFLICT', `sealed handoff ${validated.handoff_id} already exists with different material`);
48
+ }
49
+ mkdirSync(getRailPaths(projectDir).handoffs, { recursive: true, mode: 0o700 });
50
+ const temporary = `${path}.${process.pid}.tmp`;
51
+ try {
52
+ writeFileSync(temporary, content, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
53
+ linkSync(temporary, path);
54
+ } catch (error) {
55
+ if (error?.code === 'EEXIST' && existsSync(path) && readFileSync(path, 'utf8') === content) {
56
+ return Object.freeze({ path, handoff: validated, written: false });
57
+ }
58
+ if (error?.code === 'EEXIST') fail('HANDOFF_STORAGE_CONFLICT', `sealed handoff ${validated.handoff_id} already exists`, error);
59
+ throw error;
60
+ } finally {
61
+ if (existsSync(temporary)) unlinkSync(temporary);
62
+ }
63
+ return Object.freeze({ path, handoff: validated, written: true });
64
+ }
65
+
66
+ export function loadRailHandoff({ projectDir, handoff_id }) {
67
+ const path = handoffPath(projectDir, handoff_id);
68
+ if (!existsSync(path)) fail('HANDOFF_NOT_FOUND', `no sealed handoff exists for ${handoff_id}`);
69
+ try {
70
+ return Object.freeze({ path, handoff: validateSealedHandoff(JSON.parse(readFileSync(path, 'utf8'))) });
71
+ } catch (error) {
72
+ if (error?.code) throw error;
73
+ return fail('INVALID_STORED_HANDOFF', `sealed handoff ${handoff_id} cannot be read`, error);
74
+ }
75
+ }
76
+
77
+ export function createProjectRailEngine({ projectDir, clock = () => new Date() }) {
78
+ return new RailEngine({ journal_path: getRailPaths(projectDir).journal, clock });
79
+ }
80
+
81
+ function loadProjectRail({ projectDir, handoff_id, clock }) {
82
+ const stored = loadRailHandoff({ projectDir, handoff_id });
83
+ return Object.freeze({ handoff: stored.handoff, engine: createProjectRailEngine({ projectDir, ...(clock === undefined ? {} : { clock }) }) });
84
+ }
85
+
86
+ function internalTrackingInstallation(projectDir, clock) {
87
+ const artifact = loadRuntimeInstallationV2(projectDir, { ...(clock === undefined ? {} : { clock }) });
88
+ return artifact?.installation?.profile === 'focus-ai-internal'
89
+ && artifact.installation.external_integrations?.clickup === 'required';
90
+ }
91
+
92
+ function assertCompletionMetricsEvidence(metrics, reviews, acceptanceEvidenceRefs, records, attemptId) {
93
+ if (!metrics || typeof metrics !== 'object' || Array.isArray(metrics)) fail('COMPLETION_METRICS_REQUIRED', 'internal merged completion requires human effort, AI processing and rework metrics');
94
+ const evidenceRefs = [metrics.actual_human_effort?.evidence_ref, metrics.actual_ai_processing?.evidence_ref];
95
+ if (!evidenceRefs.every((ref) => typeof ref === 'string' && acceptanceEvidenceRefs?.includes(ref))) {
96
+ fail('COMPLETION_METRICS_EVIDENCE_REQUIRED', 'effort metrics evidence must be included in acceptance_evidence_refs');
97
+ }
98
+ if (!Array.isArray(metrics.rework_review_refs) || !metrics.rework_review_refs.every((ref) => reviews?.includes(ref))) {
99
+ fail('COMPLETION_REWORK_EVIDENCE_REQUIRED', 'rework metric references must be included in completion reviews');
100
+ }
101
+ const journalReworkRefs = records
102
+ .filter((record) => record.type === 'review_decided'
103
+ && record.payload?.attempt_id === attemptId
104
+ && record.payload?.decision?.decision === 'normal-rework')
105
+ .map((record) => record.payload.review_ref);
106
+ if (metrics.rework_cycles !== journalReworkRefs.length
107
+ || new Set(metrics.rework_review_refs).size !== metrics.rework_review_refs.length
108
+ || !journalReworkRefs.every((ref) => metrics.rework_review_refs.includes(ref))) {
109
+ fail('COMPLETION_REWORK_JOURNAL_MISMATCH', 'rework metrics must exactly match normal-rework decisions in the canonical journal');
110
+ }
111
+ }
112
+
113
+ function trackingIntentDirectory(projectDir) {
114
+ return join(projectDir, '.chati/v2/tracking/pending-projections');
115
+ }
116
+
117
+ function trackingIntentPath(projectDir, intent) {
118
+ return join(trackingIntentDirectory(projectDir), `${sha256(intent).slice(0, 32)}.json`);
119
+ }
120
+
121
+ function prepareTrackingIntent(projectDir, intent) {
122
+ const normalized = Object.freeze({ schema_version: 1, ...intent });
123
+ const path = trackingIntentPath(projectDir, normalized);
124
+ const content = `${JSON.stringify(normalized, null, 2)}\n`;
125
+ mkdirSync(trackingIntentDirectory(projectDir), { recursive: true, mode: 0o700 });
126
+ if (existsSync(path)) {
127
+ if (readFileSync(path, 'utf8') !== content) fail('TRACKING_INTENT_CONFLICT', 'tracking intent path contains different material');
128
+ return path;
129
+ }
130
+ const temporary = `${path}.${process.pid}.tmp`;
131
+ writeFileSync(temporary, content, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
132
+ renameSync(temporary, path);
133
+ return path;
134
+ }
135
+
136
+ function discardTrackingIntentWhenMutationAbsent({ intentPath, engine, matchesMutation }) {
137
+ if (!intentPath || !existsSync(intentPath)) return;
138
+ // If append reached the journal but checkpointing failed, the durable intent
139
+ // must survive so recovery can reconcile the two local stores. Only discard
140
+ // it when the in-memory canonical record set proves no mutation was appended.
141
+ if (!engine.records.some(matchesMutation)) unlinkSync(intentPath);
142
+ }
143
+
144
+ /** Replays durable local projection intents from canonical RAIL journal state. */
145
+ export function replayPendingClickUpProjections({ projectDir, handoff_id, clock } = {}) {
146
+ const directory = trackingIntentDirectory(projectDir);
147
+ if (!existsSync(directory)) return Object.freeze([]);
148
+ const outcomes = [];
149
+ for (const filename of readdirSync(directory).filter((name) => name.endsWith('.json')).sort()) {
150
+ const path = join(directory, filename);
151
+ let intent;
152
+ try { intent = JSON.parse(readFileSync(path, 'utf8')); } catch (error) {
153
+ outcomes.push(Object.freeze({ path, state: 'blocked', error_code: 'TRACKING_INTENT_CORRUPT', message: error.message }));
154
+ continue;
155
+ }
156
+ if (handoff_id && intent.handoff_id !== handoff_id) continue;
157
+ try {
158
+ const { handoff, engine } = loadProjectRail({ projectDir, handoff_id: intent.handoff_id, clock });
159
+ const task = handoff.tasks.find((item) => item.task_id === intent.task_id);
160
+ if (!task?.clickup_ref) fail('MISSING_CLICKUP_REFERENCE', 'internal tracking intent requires task.clickup_ref');
161
+ const claimRecord = engine.records.find((record) => record.type === 'attempt_claimed'
162
+ && record.payload?.attempt?.attempt_id === intent.attempt_id
163
+ && record.payload.attempt.task_id === intent.task_id);
164
+ if (!claimRecord) {
165
+ outcomes.push(Object.freeze({ path, state: 'waiting-for-journal' }));
166
+ continue;
167
+ }
168
+ let operation = 'update';
169
+ let payload = { status: 'in_progress', started_at: claimRecord.payload.claim.lease.acquired_at };
170
+ let completion;
171
+ if (intent.completion_id) {
172
+ const completionRecord = engine.records.find((record) => record.type === 'attempt_completed'
173
+ && record.payload?.completion?.completion_id === intent.completion_id
174
+ && record.payload.completion.attempt_id === intent.attempt_id);
175
+ if (!completionRecord) {
176
+ outcomes.push(Object.freeze({ path, state: 'waiting-for-journal' }));
177
+ continue;
178
+ }
179
+ const stored = completionRecord.payload.completion;
180
+ completion = Object.freeze({
181
+ ...stored,
182
+ event_id: completionRecord.event_id,
183
+ immutable_ref: completionReference(handoff.handoff_id, stored.attempt_id, stored.completion_id),
184
+ });
185
+ operation = stored.state === 'merged' ? 'close' : 'update';
186
+ payload = stored.state === 'merged'
187
+ ? { status: 'done', end_date: stored.completed_at, ...stored.metrics }
188
+ : { status: 'ready_to_merge' };
189
+ }
190
+ const projection = enqueueClickUpProjectionFromState({
191
+ projectDir, handoff, records: engine.records, task_id: intent.task_id, attempt_id: intent.attempt_id,
192
+ operation, payload, ...(completion ? { completion } : {}), ...(clock === undefined ? {} : { clock }),
193
+ });
194
+ // Keep the intent as durable reconstruction material. The projection is
195
+ // idempotent, so an hourly reconciliation can rebuild a deleted outbox
196
+ // from the canonical journal without waiting for another task mutation.
197
+ outcomes.push(Object.freeze({ path, state: 'queued', projection_id: projection.projection_id }));
198
+ } catch (error) {
199
+ outcomes.push(Object.freeze({ path, state: 'pending-local-replay', error_code: error.code || 'TRACKING_PROJECTION_FAILED', message: error.message }));
200
+ }
201
+ }
202
+ return Object.freeze(outcomes);
203
+ }
204
+
205
+ /** Hourly-safe local reconciliation entry point for the ClickUp outbox. */
206
+ export function reconcileProjectClickUpTracking({ projectDir, handoff_id, clock } = {}) {
207
+ const outcomes = replayPendingClickUpProjections({ projectDir, handoff_id, ...(clock === undefined ? {} : { clock }) });
208
+ return Object.freeze({
209
+ state: outcomes.some((item) => item.state === 'blocked' || item.state === 'pending-local-replay') ? 'pending' : 'synchronized',
210
+ outcomes,
211
+ });
212
+ }
213
+
214
+ function attemptIdsForHandoff(records, handoffId) {
215
+ return new Set(records.filter((record) => record.type === 'attempt_claimed' && record.payload?.attempt?.handoff_id === handoffId)
216
+ .map((record) => record.payload.attempt.attempt_id));
217
+ }
218
+
219
+ function taskStates(engine, handoffId) {
220
+ const attemptIds = attemptIdsForHandoff(engine.records, handoffId);
221
+ const states = new Map();
222
+ for (const record of engine.records) {
223
+ if (record.type === 'attempt_claimed' && record.payload?.attempt?.handoff_id === handoffId) {
224
+ states.set(record.payload.attempt.task_id, { task_id: record.payload.attempt.task_id, attempt_id: record.payload.attempt.attempt_id, state: 'active' });
225
+ }
226
+ if (record.type === 'attempt_completed' && attemptIds.has(record.payload?.completion?.attempt_id)) {
227
+ const completion = record.payload.completion;
228
+ states.set(completion.task_id, { task_id: completion.task_id, attempt_id: completion.attempt_id, state: completion.state });
229
+ }
230
+ }
231
+ return states;
232
+ }
233
+
234
+ /**
235
+ * Returns the dependency-ready tasks from the project-local sealed handoff.
236
+ * A task becomes unblocked only after every predecessor is canonically merged,
237
+ * never merely claimed or marked ready_to_merge.
238
+ */
239
+ export function selectReadyRailTasks({ projectDir, handoff_id, clock } = {}) {
240
+ const { handoff, engine } = loadProjectRail({ projectDir, handoff_id, clock });
241
+ const states = taskStates(engine, handoff.handoff_id);
242
+ const merged = new Set([...states.values()].filter((item) => item.state === 'merged').map((item) => item.task_id));
243
+ const ready = handoff.tasks.filter((task) => !states.has(task.task_id)
244
+ && task.dependency_ids.every((dependency) => merged.has(dependency)));
245
+ return Object.freeze({
246
+ handoff_id: handoff.handoff_id,
247
+ completed_task_ids: Object.freeze([...merged].sort()),
248
+ ready_task_ids: Object.freeze(ready.map((task) => task.task_id)),
249
+ blocked_task_ids: Object.freeze(handoff.tasks.filter((task) => !states.has(task.task_id) && !ready.includes(task)).map((task) => task.task_id)),
250
+ });
251
+ }
252
+
253
+ /**
254
+ * Computes the only local condition that may hand a project from RAIL to the
255
+ * Release Lane. A `ready` result is evidence of merged task completions, not
256
+ * a release authorization and never invokes a deployment adapter.
257
+ */
258
+ export function selectRailReleaseReadiness({ projectDir, handoff_id, clock } = {}) {
259
+ const { handoff, engine } = loadProjectRail({ projectDir, handoff_id, clock });
260
+ const merged = new Map();
261
+ const attemptIds = attemptIdsForHandoff(engine.records, handoff.handoff_id);
262
+ for (const record of engine.records) {
263
+ if (record.type !== 'attempt_completed' || record.payload?.completion?.state !== 'merged' || !attemptIds.has(record.payload.completion.attempt_id)) continue;
264
+ const completion = record.payload.completion;
265
+ merged.set(completion.task_id, completionReference(handoff.handoff_id, completion.attempt_id, completion.completion_id));
266
+ }
267
+ const missing_task_ids = handoff.tasks.filter((task) => !merged.has(task.task_id)).map((task) => task.task_id);
268
+ return Object.freeze({
269
+ handoff_id: handoff.handoff_id,
270
+ state: missing_task_ids.length === 0 ? 'ready' : 'not-ready',
271
+ missing_task_ids: Object.freeze(missing_task_ids),
272
+ source_completion_refs: Object.freeze(handoff.tasks.map((task) => merged.get(task.task_id)).filter(Boolean)),
273
+ });
274
+ }
275
+
276
+ /**
277
+ * Claims one dependency-ready RAIL task and invokes the explicitly injected
278
+ * harness adapter with the installation-selected binding. There is no provider
279
+ * fallback and no direct CLI invocation in this boundary.
280
+ */
281
+ export function dispatchReadyRailTask({ projectDir, handoff_id, task_id, attempt_id, operator_id, harness_process_id, adapter, browser_request, git_dir = projectDir, lease_ttl_ms, clock } = {}) {
282
+ if (!adapter || typeof adapter.invoke !== 'function') fail('HARNESS_ADAPTER_UNAVAILABLE', 'RAIL dispatch requires an injected harness adapter');
283
+ const installation = loadRuntimeInstallationV2(projectDir, { ...(clock === undefined ? {} : { clock }) });
284
+ if (!installation) fail('V2_INSTALLATION_REQUIRED', 'RAIL dispatch requires a v2 installation');
285
+ const selection = selectReadyRailTasks({ projectDir, handoff_id, clock });
286
+ if (!selection.ready_task_ids.includes(task_id)) fail('TASK_NOT_DISPATCHABLE', 'task is not dependency-ready for dispatch', { task_id, ready_task_ids: selection.ready_task_ids });
287
+ const { handoff } = loadProjectRail({ projectDir, handoff_id, clock });
288
+ const task = handoff.tasks.find((item) => item.task_id === task_id);
289
+ if (!task.execution_binding || typeof task.execution_binding !== 'object'
290
+ || ['provider_id', 'harness_id', 'model_id', 'reasoning_configuration']
291
+ .some((field) => typeof task.execution_binding[field] !== 'string' || !task.execution_binding[field].trim())) {
292
+ fail('TASK_EXECUTION_BINDING_REQUIRED', 'v2 RAIL dispatch requires a complete explicit task execution binding');
293
+ }
294
+ if (task.requires_browser === true && (!browser_request || typeof browser_request !== 'object')) fail('BROWSER_PREFLIGHT_REQUIRED', 'browser task dispatch requires a complete browser preflight request');
295
+ const browser = task.requires_browser === true
296
+ ? launchProjectBrowserSession({ session: createProjectBrowserSession({ task, request: browser_request }), request: browser_request })
297
+ : null;
298
+ const selectedBinding = { provider_id: task.execution_binding.provider_id, harness_id: task.execution_binding.harness_id };
299
+ const invocationBase = resolveRuntimeInvocationV2({
300
+ artifact: installation,
301
+ agent: 'dev',
302
+ binding: selectedBinding,
303
+ model_id: task.execution_binding.model_id,
304
+ reasoning_configuration: task.execution_binding.reasoning_configuration,
305
+ ...(clock === undefined ? {} : { clock }),
306
+ });
307
+ if (adapter.harness_id && adapter.harness_id !== invocationBase.harness_id) fail('HARNESS_MISMATCH', 'selected installation binding does not match the injected adapter');
308
+ const claim = claimRailTask({ projectDir, handoff_id, task_id, attempt_id, operator_id, harness_process_id, completed_task_ids: selection.completed_task_ids, git_dir, ...(lease_ttl_ms === undefined ? {} : { lease_ttl_ms }), ...(clock === undefined ? {} : { clock }) });
309
+ const invocation = Object.freeze({
310
+ ...invocationBase,
311
+ invocation_id: `rail-invocation-${attempt_id}`,
312
+ attempt_id,
313
+ tool_policy_ref: task.routing_constraints_ref,
314
+ input_ref: task.execution_scope_ref,
315
+ expected_output_schema: 'rail-task-execution/v1',
316
+ });
317
+ const receipt = adapter.invoke({ installation: installation.installation, snapshot: installation.capability_snapshot, invocation });
318
+ return Object.freeze({ claim, invocation, receipt, task_id, handoff_id, browser_manifest: browser?.manifest ?? null });
319
+ }
320
+
321
+ /**
322
+ * Entry point used by the CHATI runtime after a sealed planning handoff is
323
+ * present. It creates an auditable RAIL claim before any task executor runs.
324
+ */
325
+ export function claimRailTask({ projectDir, handoff_id, task_id, attempt_id, operator_id, harness_process_id, git_dir = projectDir, lease_ttl_ms, clock } = {}) {
326
+ const { handoff, engine } = loadProjectRail({ projectDir, handoff_id, clock });
327
+ const states = taskStates(engine, handoff.handoff_id);
328
+ const completed_task_ids = [...states.values()].filter((item) => item.state === 'merged').map((item) => item.task_id);
329
+ const trackingRequired = internalTrackingInstallation(projectDir, clock);
330
+ const task = handoff.tasks.find((item) => item.task_id === task_id);
331
+ if (trackingRequired && !task?.clickup_ref) fail('MISSING_CLICKUP_REFERENCE', 'internal RAIL task requires clickup_ref before claim');
332
+ const intentPath = trackingRequired ? prepareTrackingIntent(projectDir, { handoff_id, task_id, attempt_id, event: 'claim' }) : null;
333
+ let claim;
334
+ try {
335
+ claim = engine.claimTask({
336
+ handoff, task_id, attempt_id, operator_id, harness_process_id,
337
+ completed_task_ids, git_dir, ...(lease_ttl_ms === undefined ? {} : { lease_ttl_ms }),
338
+ });
339
+ } catch (error) {
340
+ discardTrackingIntentWhenMutationAbsent({
341
+ intentPath,
342
+ engine,
343
+ matchesMutation: (record) => record.type === 'attempt_claimed'
344
+ && record.payload?.attempt?.attempt_id === attempt_id
345
+ && record.payload.attempt.task_id === task_id,
346
+ });
347
+ throw error;
348
+ }
349
+ const tracking_projection = trackingRequired
350
+ ? replayPendingClickUpProjections({ projectDir, handoff_id, ...(clock === undefined ? {} : { clock }) }).find((item) => item.path === intentPath)
351
+ : undefined;
352
+ return Object.freeze({ ...claim, ...(tracking_projection ? { tracking_projection } : {}) });
353
+ }
354
+
355
+ /** Renew a claim lease against the local project journal and sealed handoff. */
356
+ export function heartbeatRailTask({ projectDir, handoff_id, attempt_id, claim_id, operator_id, lease_ttl_ms, clock } = {}) {
357
+ const { engine } = loadProjectRail({ projectDir, handoff_id, clock });
358
+ return engine.heartbeat({
359
+ attempt_id, claim_id, operator_id,
360
+ ...(lease_ttl_ms === undefined ? {} : { lease_ttl_ms }),
361
+ });
362
+ }
363
+
364
+ /** Recover an expired attempt only after the caller supplies explicit reconciliation evidence. */
365
+ export function recoverRailTask({ projectDir, handoff_id, attempt_id, operator_id, harness_process_id, reconciliation, git_dir = projectDir, lease_ttl_ms, clock } = {}) {
366
+ const { engine } = loadProjectRail({ projectDir, handoff_id, clock });
367
+ return engine.recoverAttempt({
368
+ attempt_id, operator_id, harness_process_id, reconciliation, git_dir,
369
+ ...(lease_ttl_ms === undefined ? {} : { lease_ttl_ms }),
370
+ });
371
+ }
372
+
373
+ /**
374
+ * Write an immutable C-06 completion record. Git, CI, review and acceptance
375
+ * evidence are validated by RailEngine against the project-local journal.
376
+ */
377
+ export function completeRailTask({ projectDir, handoff_id, completion_id, attempt_id, claim_id, operator_id, state, git, ci, reviews, acceptance_evidence_refs, completed_at, metrics, clock } = {}) {
378
+ const { handoff, engine } = loadProjectRail({ projectDir, handoff_id, clock });
379
+ const trackingRequired = internalTrackingInstallation(projectDir, clock);
380
+ if (trackingRequired && state === 'merged') assertCompletionMetricsEvidence(metrics, reviews, acceptance_evidence_refs, engine.records, attempt_id);
381
+ const taskId = engine.records.find((record) => record.type === 'attempt_claimed' && record.payload?.attempt?.attempt_id === attempt_id)?.payload?.attempt?.task_id;
382
+ const task = handoff.tasks.find((item) => item.task_id === taskId);
383
+ if (trackingRequired && !task?.clickup_ref) fail('MISSING_CLICKUP_REFERENCE', 'internal RAIL task requires clickup_ref before completion');
384
+ const intentPath = trackingRequired ? prepareTrackingIntent(projectDir, { handoff_id, task_id: taskId, attempt_id, completion_id, state, event: 'completion' }) : null;
385
+ let completion;
386
+ try {
387
+ completion = engine.completeAttempt({
388
+ completion_id, attempt_id, claim_id, operator_id, state, git: { ...git, git_dir: projectDir }, ci, reviews, acceptance_evidence_refs,
389
+ ...(completed_at === undefined ? {} : { completed_at }), ...(metrics === undefined ? {} : { metrics }), handoff,
390
+ });
391
+ } catch (error) {
392
+ discardTrackingIntentWhenMutationAbsent({
393
+ intentPath,
394
+ engine,
395
+ matchesMutation: (record) => record.type === 'attempt_completed'
396
+ && record.payload?.completion?.completion_id === completion_id
397
+ && record.payload.completion.attempt_id === attempt_id,
398
+ });
399
+ throw error;
400
+ }
401
+ const result = Object.freeze({ ...completion, immutable_ref: completionReference(handoff.handoff_id, completion.attempt_id, completion.completion_id) });
402
+ const tracking_projection = trackingRequired
403
+ ? replayPendingClickUpProjections({ projectDir, handoff_id, ...(clock === undefined ? {} : { clock }) }).find((item) => item.path === intentPath)
404
+ : undefined;
405
+ return Object.freeze({ ...result, ...(tracking_projection ? { tracking_projection } : {}) });
406
+ }
407
+
408
+ /** Resolves only a completion that exists in the immutable project-local journal. */
409
+ export function resolveRailCompletionReference({ projectDir, handoff_id, immutable_ref, clock } = {}) {
410
+ const { handoff, engine } = loadProjectRail({ projectDir, handoff_id, clock });
411
+ const prefix = `rail-completion://${handoff.handoff_id}/`;
412
+ if (typeof immutable_ref !== 'string' || !immutable_ref.startsWith(prefix)) fail('INVALID_COMPLETION_REFERENCE', 'completion reference is not bound to the sealed handoff');
413
+ const pieces = immutable_ref.slice(prefix.length).split('/');
414
+ if (pieces.length !== 2) fail('INVALID_COMPLETION_REFERENCE', 'completion reference has an invalid shape');
415
+ const [attempt_id, completion_id] = pieces;
416
+ const record = engine.records.find((item) => item.type === 'attempt_completed'
417
+ && item.payload?.completion?.attempt_id === attempt_id
418
+ && item.payload.completion.completion_id === completion_id);
419
+ if (!record) fail('COMPLETION_NOT_FOUND', 'completion reference is absent from the project-local journal');
420
+ return Object.freeze({ ...record.payload.completion, immutable_ref });
421
+ }
@@ -0,0 +1,14 @@
1
+ import { ReleaseLane } from '@chati/release-lane';
2
+ import { resolveRailCompletionReference } from './rail-runtime.js';
3
+
4
+ /**
5
+ * G3 local release authority. The returned ReleaseLane accepts only its own
6
+ * FakeReleaseTransport capability and resolves completion evidence from the
7
+ * project-local RAIL journal. It has no external deployment adapter.
8
+ */
9
+ export function createProjectReleaseLane({ projectDir, handoff_id, clock = () => new Date() } = {}) {
10
+ return new ReleaseLane({
11
+ clock,
12
+ verify_completion: (immutable_ref) => resolveRailCompletionReference({ projectDir, handoff_id, immutable_ref, clock }),
13
+ });
14
+ }
@@ -0,0 +1,74 @@
1
+ import { existsSync, linkSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { acceptReview, decideReviewProgress } from '@chati/review-council';
4
+ import { loadRuntimeInstallationV2 } from './runtime-installation-v2.js';
5
+ import { createProjectRailEngine, loadRailHandoff } from './rail-runtime.js';
6
+
7
+ function fail(code, message) { const error = new Error(message); error.code = code; throw error; }
8
+ function safeId(value, label) {
9
+ if (typeof value !== 'string' || !/^[A-Za-z0-9._-]+$/.test(value)) fail('INVALID_REVIEW_STORAGE_ID', `${label} must contain only letters, digits, dot, underscore or hyphen`);
10
+ return value;
11
+ }
12
+ function reviewDir(projectDir, attemptId) { return join(projectDir, '.chati/v2/rail/reviews', safeId(attemptId, 'attempt_id')); }
13
+ function reviewPath(projectDir, attemptId, reviewId) { return join(reviewDir(projectDir, attemptId), `${safeId(reviewId, 'review_id')}.json`); }
14
+
15
+ function readReviewEvidence(projectDir, attemptId) {
16
+ const directory = reviewDir(projectDir, attemptId);
17
+ if (!existsSync(directory)) return [];
18
+ return readdirSync(directory).filter((name) => name.endsWith('.json')).sort().map((name) => {
19
+ try { return JSON.parse(readFileSync(join(directory, name), 'utf8')); }
20
+ catch { return fail('INVALID_STORED_REVIEW', `review evidence cannot be read: ${name}`); }
21
+ });
22
+ }
23
+
24
+ function resolveAttemptLineage(projectDir, attemptId, clock) {
25
+ const engine = createProjectRailEngine({ projectDir, clock });
26
+ const claimRecord = engine.records.find((record) => record.type === 'attempt_claimed' && record.payload?.attempt?.attempt_id === attemptId);
27
+ if (!claimRecord) fail('REVIEW_ATTEMPT_NOT_CLAIMED', `review attempt ${attemptId} has no project-local RAIL claim`);
28
+ const attempt = claimRecord.payload.attempt;
29
+ const { handoff } = loadRailHandoff({ projectDir, handoff_id: attempt.handoff_id });
30
+ if (!handoff.tasks.some((task) => task.task_id === attempt.task_id)) fail('REVIEW_ATTEMPT_TASK_NOT_IN_HANDOFF', `claimed task ${attempt.task_id} is absent from its sealed handoff`);
31
+ return Object.freeze({
32
+ handoff_id: handoff.handoff_id,
33
+ handoff_manifest_digest: handoff.seal.manifest_digest,
34
+ task_id: attempt.task_id,
35
+ });
36
+ }
37
+
38
+ /** Stores a selected-only review record and derives the next rework decision. */
39
+ export function recordRailReview({ projectDir, review, invocation, clock = () => new Date() } = {}) {
40
+ const artifact = loadRuntimeInstallationV2(projectDir, { clock });
41
+ if (!artifact) fail('V2_INSTALLATION_REQUIRED', 'RAIL review requires a v2 installation');
42
+ const validated = acceptReview({ review, invocation, installation: artifact.installation, snapshot: artifact.capability_snapshot, clock });
43
+ const lineage = resolveAttemptLineage(projectDir, validated.attempt_id, clock);
44
+ const path = reviewPath(projectDir, validated.attempt_id, validated.review_id);
45
+ const content = `${JSON.stringify({ review: validated, invocation, lineage }, null, 2)}\n`;
46
+ const stored = readReviewEvidence(projectDir, validated.attempt_id);
47
+ const matching = stored.find((entry) => entry.review?.review_id === validated.review_id);
48
+ if (matching && readFileSync(path, 'utf8') !== content) fail('REVIEW_STORAGE_CONFLICT', `review ${validated.review_id} already exists with different material`);
49
+ const prior = stored.filter((entry) => entry.review?.review_id !== validated.review_id);
50
+ const decision = decideReviewProgress({
51
+ prior_review_evidence: prior, current_review: validated, reviewer_invocation: invocation,
52
+ installation: artifact.installation, snapshot: artifact.capability_snapshot, clock,
53
+ });
54
+ const review_ref = `rail-review://${lineage.handoff_id}/${validated.attempt_id}/${validated.review_id}`;
55
+ let written = false;
56
+ if (!matching) {
57
+ mkdirSync(reviewDir(projectDir, validated.attempt_id), { recursive: true, mode: 0o700 });
58
+ const temporary = `${path}.${process.pid}.tmp`;
59
+ writeFileSync(temporary, content, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
60
+ try {
61
+ linkSync(temporary, path);
62
+ written = true;
63
+ } catch (error) {
64
+ if (error?.code !== 'EEXIST' || readFileSync(path, 'utf8') !== content) fail('REVIEW_STORAGE_CONFLICT', `review ${validated.review_id} raced with different material`);
65
+ } finally {
66
+ if (existsSync(temporary)) unlinkSync(temporary);
67
+ }
68
+ }
69
+ const journal = createProjectRailEngine({ projectDir, clock }).recordReviewDecision({
70
+ attempt_id: validated.attempt_id, review_ref, decision, review: validated, invocation,
71
+ prior_review_evidence: prior, installation: artifact.installation, snapshot: artifact.capability_snapshot,
72
+ });
73
+ return Object.freeze({ path, review_ref, lineage, review: validated, decision, journal, written });
74
+ }
@@ -0,0 +1,57 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { INSTALLATION_ARTIFACT_PATH, doctorV2 } from '../installer-v2/index.js';
4
+ import { assertEligibleInvocation } from '@chati/provider-registry';
5
+
6
+ const AGENT_ACTIONS = Object.freeze({
7
+ 'greenfield-wu': 'discovery', 'brownfield-wu': 'discovery', brief: 'discovery',
8
+ detail: 'planning', architect: 'planning', ux: 'planning', phases: 'planning', tasks: 'planning',
9
+ 'qa-planning': 'review', dev: 'build', 'qa-implementation': 'review', 'qa-visual': 'review',
10
+ devops: 'build',
11
+ });
12
+
13
+ export function loadRuntimeInstallationV2(projectDir, { clock = () => new Date() } = {}) {
14
+ const path = join(projectDir, INSTALLATION_ARTIFACT_PATH);
15
+ if (!existsSync(path)) return null;
16
+ let artifact;
17
+ try { artifact = JSON.parse(readFileSync(path, 'utf8')); } catch (error) {
18
+ throw Object.assign(new Error('Invalid v2 installation artifact'), { code: 'INVALID_INSTALLATION_ARTIFACT', cause: error });
19
+ }
20
+ const doctor = doctorV2(artifact, { clock });
21
+ if (!doctor.passed) throw Object.assign(new Error('V2 installation artifact failed doctor'), { code: doctor.checks[0]?.code || 'INVALID_INSTALLATION_ARTIFACT' });
22
+ return Object.freeze(artifact);
23
+ }
24
+
25
+ export function resolveRuntimeInvocationV2({ artifact, agent, binding, model_id, reasoning_configuration, clock = () => new Date() }) {
26
+ const action = AGENT_ACTIONS[agent];
27
+ if (!action) throw Object.assign(new Error(`No v2 action mapping for agent ${agent}`), { code: 'UNMAPPED_AGENT_ACTION' });
28
+ const candidates = artifact.installation.enabled_providers.flatMap((candidateBinding) => {
29
+ if (binding && (candidateBinding.provider_id !== binding.provider_id || candidateBinding.harness_id !== binding.harness_id)) return [];
30
+ return candidateBinding.allowed_models.flatMap((candidateModelId) => {
31
+ if (model_id && candidateModelId !== model_id) return [];
32
+ const catalogModel = artifact.capability_snapshot.models.find((candidate) =>
33
+ candidate.provider_id === candidateBinding.provider_id && candidate.model_id === candidateModelId
34
+ );
35
+ if (!catalogModel?.actions.includes(action)) return [];
36
+ return [{
37
+ binding: candidateBinding,
38
+ model: catalogModel,
39
+ priority: catalogModel.routing_priority?.[agent] ?? catalogModel.routing_priority?.[action] ?? 0,
40
+ }];
41
+ });
42
+ }).sort((left, right) =>
43
+ right.priority - left.priority
44
+ || (right.model.adjudication_priority ?? 0) - (left.model.adjudication_priority ?? 0)
45
+ || `${left.binding.harness_id}:${left.model.model_id}`.localeCompare(`${right.binding.harness_id}:${right.model.model_id}`)
46
+ );
47
+ const selectedCandidate = candidates[0];
48
+ if (!selectedCandidate) throw Object.assign(new Error(`No eligible installed model for ${agent}/${action}`), { code: 'NO_ELIGIBLE_MODEL' });
49
+ const selected = selectedCandidate.binding;
50
+ const model = selectedCandidate.model.model_id;
51
+ const invocation = {
52
+ provider_id: selected.provider_id, harness_id: selected.harness_id, action,
53
+ model_pin: { model_id: model, catalog_snapshot_ref: artifact.capability_snapshot.snapshot_id, reasoning_configuration },
54
+ };
55
+ assertEligibleInvocation({ installation: artifact.installation, snapshot: artifact.capability_snapshot, invocation, clock });
56
+ return Object.freeze(invocation);
57
+ }
@@ -5,7 +5,7 @@
5
5
  * agent completions, and session validation for the Chati.dev pipeline.
6
6
  */
7
7
 
8
- import yaml from 'js-yaml';
8
+ import * as yaml from 'js-yaml';
9
9
  import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
10
10
  import { join, dirname } from 'path';
11
11
 
@@ -9,7 +9,7 @@ import { readFileSync, writeFileSync, existsSync } from 'fs';
9
9
  import { join } from 'path';
10
10
  import { resolveFrameworkDir } from '../utils/framework-dir.js';
11
11
  import { randomUUID } from 'crypto';
12
- import yaml from 'js-yaml';
12
+ import * as yaml from 'js-yaml';
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // Config Management
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Grok Build CLI adapter.
3
+ *
4
+ * Grok's supported headless form is `grok -p <prompt> -m <model>`. The prompt
5
+ * deliberately remains an argument here because `-p` is the documented
6
+ * non-interactive interface. This adapter does not silently select a model.
7
+ */
8
+ export function buildCommand(config, provider) {
9
+ const args = [...provider.baseArgs];
10
+ if (config.model) {
11
+ const resolvedModel = provider.modelMap[config.model] || config.model;
12
+ args.push(provider.modelFlag, resolvedModel);
13
+ }
14
+ args.push('-p', config.prompt || '');
15
+ return { command: provider.command, args, stdinPrompt: null };
16
+ }
@@ -5,3 +5,4 @@
5
5
  export * as claude from './claude-adapter.js';
6
6
  export * as gemini from './gemini-adapter.js';
7
7
  export * as codex from './codex-adapter.js';
8
+ export * as grok from './grok-adapter.js';