chati-dev 4.5.16 → 4.5.27

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 (122) hide show
  1. package/README.md +8 -3
  2. package/bin/chati.js +124 -66
  3. package/framework/agents/build/dev.md +5 -5
  4. package/framework/agents/deploy/devops.md +7 -7
  5. package/framework/agents/discover/brief.md +4 -4
  6. package/framework/agents/discover/brownfield-wu.md +3 -3
  7. package/framework/agents/discover/greenfield-wu.md +3 -3
  8. package/framework/agents/plan/architect.md +2 -2
  9. package/framework/agents/plan/detail.md +4 -4
  10. package/framework/agents/plan/phases.md +2 -2
  11. package/framework/agents/plan/tasks.md +2 -2
  12. package/framework/agents/plan/ux.md +2 -2
  13. package/framework/agents/quality/qa-implementation.md +11 -10
  14. package/framework/agents/quality/qa-planning.md +3 -3
  15. package/framework/agents/quality/qa-visual.md +1 -1
  16. package/framework/config.yaml +3 -3
  17. package/framework/constitution.md +18 -18
  18. package/framework/context/protocols.md +1 -1
  19. package/framework/context/quality.md +1 -1
  20. package/framework/context/root.md +4 -4
  21. package/framework/data/entity-registry.yaml +1 -1
  22. package/framework/domains/agents/orchestrator.yaml +2 -2
  23. package/framework/domains/constitution.yaml +1 -1
  24. package/framework/hooks/advance-trigger.js +4 -6
  25. package/framework/hooks/git-push-authority.js +45 -37
  26. package/framework/hooks/mode-governance.js +149 -40
  27. package/framework/hooks/model-governance.js +13 -20
  28. package/framework/hooks/prism-engine.js +74 -92
  29. package/framework/hooks/reasoning-escalator.js +23 -40
  30. package/framework/hooks/session-digest.js +12 -13
  31. package/framework/hooks/session-reader.js +224 -0
  32. package/framework/hooks/session-writer.js +195 -0
  33. package/framework/hooks/team-quality-gate.js +34 -24
  34. package/framework/i18n/en.yaml +2 -2
  35. package/framework/i18n/es.yaml +2 -2
  36. package/framework/i18n/fr.yaml +2 -2
  37. package/framework/i18n/pt.yaml +2 -2
  38. package/framework/intelligence/context-engine.md +4 -4
  39. package/framework/intelligence/memory-layer.md +1 -1
  40. package/framework/manifest.json +129 -119
  41. package/framework/manifest.sig +1 -1
  42. package/framework/orchestrator/chati-router.js +252 -25
  43. package/framework/orchestrator/chati.md +238 -70
  44. package/framework/schemas/session.schema.json +1 -1
  45. package/framework/tasks/orchestrator-deviation.md +1 -1
  46. package/framework/tasks/orchestrator-escalate.md +1 -1
  47. package/framework/tasks/orchestrator-handoff.md +6 -6
  48. package/framework/tasks/orchestrator-health.md +5 -9
  49. package/framework/tasks/orchestrator-mode-switch.md +3 -7
  50. package/framework/tasks/orchestrator-resume.md +10 -14
  51. package/framework/tasks/orchestrator-route.md +3 -3
  52. package/framework/tasks/orchestrator-spawn-terminal.md +1 -1
  53. package/framework/tasks/orchestrator-status.md +9 -9
  54. package/framework/tasks/orchestrator-suggest-mode.md +1 -1
  55. package/framework/tasks/qa-impl-consolidate.md +2 -2
  56. package/framework/tasks/qa-impl-performance-test.md +4 -4
  57. package/framework/tasks/qa-impl-regression-check.md +4 -4
  58. package/framework/tasks/qa-impl-sast-scan.md +1 -1
  59. package/framework/tasks/qa-impl-test-execute.md +1 -1
  60. package/framework/tasks/qa-impl-verdict.md +2 -2
  61. package/framework/tasks/qa-planning-consolidate.md +3 -3
  62. package/framework/tasks/qa-planning-coverage-plan.md +2 -2
  63. package/framework/tasks/qa-planning-gate-define.md +4 -4
  64. package/framework/tasks/qa-planning-risk-matrix.md +4 -4
  65. package/framework/tasks/qa-planning-test-strategy.md +2 -2
  66. package/node_modules/@chati/browser-capability/src/index.js +12 -2
  67. package/node_modules/@chati/planning/src/index.js +24 -4
  68. package/node_modules/@chati/provider-registry/src/index.js +11 -0
  69. package/node_modules/@chati/rail/src/index.js +1967 -83
  70. package/node_modules/@chati/release-lane/README.md +12 -8
  71. package/node_modules/@chati/release-lane/package.json +1 -1
  72. package/node_modules/@chati/release-lane/src/index.js +1426 -58
  73. package/node_modules/@chati/review-council/src/index.js +63 -0
  74. package/node_modules/@chati/tracking-clickup/README.md +13 -0
  75. package/node_modules/@chati/tracking-clickup/src/index.js +690 -30
  76. package/package-artifact-manifest.json +1 -0
  77. package/package-artifact-manifest.sig +1 -0
  78. package/package.json +16 -7
  79. package/scripts/verify-real-harness-e2e.js +1581 -0
  80. package/src/config/framework-adapter.js +4 -4
  81. package/src/installer/core.js +148 -53
  82. package/src/installer/manifest.js +140 -9
  83. package/src/installer/package-artifact.js +249 -0
  84. package/src/installer/templates.js +65 -20
  85. package/src/installer-v2/catalog-client.js +531 -23
  86. package/src/installer-v2/index.js +91 -34
  87. package/src/installer-v2/installation-authority.js +327 -0
  88. package/src/installer-v2/provider-executable.js +235 -0
  89. package/src/installer-v2/wizard-installation.js +1 -1
  90. package/src/orchestrator/browser-runtime.js +44 -13
  91. package/src/orchestrator/cli.js +1545 -174
  92. package/src/orchestrator/clickup-projection.js +43 -1
  93. package/src/orchestrator/clickup-runtime.js +355 -39
  94. package/src/orchestrator/doctor.js +16 -3
  95. package/src/orchestrator/index.js +16 -0
  96. package/src/orchestrator/planning-runtime.js +20 -2
  97. package/src/orchestrator/rail-adjudication-evidence.js +234 -0
  98. package/src/orchestrator/rail-evidence-authority.js +147 -0
  99. package/src/orchestrator/rail-execution-evidence.js +45 -0
  100. package/src/orchestrator/rail-runtime.js +836 -56
  101. package/src/orchestrator/release-runtime.js +65 -6
  102. package/src/orchestrator/review-runtime.js +186 -41
  103. package/src/orchestrator/runtime-installation-v2.js +236 -20
  104. package/src/orchestrator/session-manager.js +1167 -53
  105. package/src/terminal/adapters/claude-adapter.js +3 -1
  106. package/src/terminal/adapters/codex-adapter.js +2 -0
  107. package/src/terminal/adapters/grok-adapter.js +7 -4
  108. package/src/terminal/handoff-parser.js +19 -1
  109. package/src/terminal/prompt-builder.js +9 -1
  110. package/src/terminal/provider-preflight.js +36 -3
  111. package/src/terminal/rail-execution-worktree.js +213 -0
  112. package/src/terminal/rail-prompts.js +169 -0
  113. package/src/terminal/rail-readonly-workspace.js +324 -0
  114. package/src/terminal/run-agent.js +384 -24
  115. package/src/terminal/run-parallel.js +5 -0
  116. package/src/terminal/run-rail-adjudication.js +323 -0
  117. package/src/terminal/run-rail-review.js +291 -0
  118. package/src/terminal/run-rail-rework.js +325 -0
  119. package/src/terminal/run-rail-task.js +380 -0
  120. package/src/terminal/run-team.js +5 -0
  121. package/src/terminal/spawner.js +1225 -77
  122. package/src/wizard/index.js +3 -2
@@ -14,8 +14,12 @@
14
14
  */
15
15
 
16
16
  import { fileURLToPath } from 'url';
17
+ import { randomUUID } from 'node:crypto';
18
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync, rmSync } from 'fs';
19
+ import { isAbsolute, relative, resolve } from 'path';
17
20
  import { buildAgentPrompt } from './prompt-builder.js';
18
21
  import { isTransientFailure, spawnTerminalWithRetry } from './spawner.js';
22
+ import { getWriteScope } from './isolation.js';
19
23
  import { parseAgentOutput } from './handoff-parser.js';
20
24
  import { checkProviderReadiness } from './provider-preflight.js';
21
25
  import { createCostTracker } from './cost-tracker.js';
@@ -23,8 +27,37 @@ import { getRateLimiter } from './rate-limiter.js';
23
27
  import { initCollector, track as telemetryTrack, flush as telemetryFlush } from '../telemetry/collector.js';
24
28
  import { sendEvents } from '../telemetry/sender.js';
25
29
  import { getTelemetryConfig, isEnabled as isTelemetryEnabled } from '../telemetry/config.js';
26
- import { recordPendingInteraction, clearPendingInteraction } from '../orchestrator/session-manager.js';
30
+ import {
31
+ recordPendingInteraction, clearPendingInteraction,
32
+ claimPendingInteraction, releasePendingInteraction,
33
+ loadSession, startAgentExecution, holdAgentExecutionForAdvance, releaseAgentExecution,
34
+ } from '../orchestrator/session-manager.js';
27
35
  import { recordEvent, EventType } from '../intelligence/timeline.js';
36
+ import {
37
+ assertRuntimeCommandV2, loadRuntimeInstallationV2, resolveRuntimeExecutableBindingV2,
38
+ } from '../orchestrator/runtime-installation-v2.js';
39
+
40
+ const RUN_AGENT_SECURITY_TEST_HOOKS = Symbol.for('chati.run-agent.security-test-hooks');
41
+ const ADDITIONAL_CONTEXT_MAX_BYTES = 256 * 1024;
42
+ const ADDITIONAL_CONTEXT_MAX_BYTES_BIGINT = BigInt(ADDITIONAL_CONTEXT_MAX_BYTES);
43
+
44
+ function runSecurityTestHook(name, detail) {
45
+ const hook = globalThis[RUN_AGENT_SECURITY_TEST_HOOKS]?.[name];
46
+ if (typeof hook === 'function') hook(detail);
47
+ }
48
+
49
+ function sameAdditionalContextSnapshot(left, right) {
50
+ return Boolean(left && right
51
+ && left.dev === right.dev
52
+ && left.ino === right.ino
53
+ && left.mode === right.mode
54
+ && left.uid === right.uid
55
+ && left.gid === right.gid
56
+ && left.nlink === right.nlink
57
+ && left.size === right.size
58
+ && left.mtimeNs === right.mtimeNs
59
+ && left.ctimeNs === right.ctimeNs);
60
+ }
28
61
 
29
62
  // ---------------------------------------------------------------------------
30
63
  // CLI argument parsing (no external deps)
@@ -37,7 +70,12 @@ function parseArgs(argv) {
37
70
  if (arg.startsWith('--')) {
38
71
  const key = arg.slice(2);
39
72
  const next = argv[i + 1];
40
- if (next && !next.startsWith('--')) {
73
+ if (key === 'additional-context' && next !== undefined && !next.startsWith('--')) {
74
+ args[key] = next;
75
+ i++;
76
+ } else if (key === 'additional-context') {
77
+ args[key] = null;
78
+ } else if (next && !next.startsWith('--')) {
41
79
  args[key] = next;
42
80
  i++;
43
81
  } else {
@@ -68,21 +106,106 @@ async function main() {
68
106
  const projectDir = args['project-dir'] || process.cwd();
69
107
  const timeout = parseInt(args.timeout, 10) || 600_000; // default 10 minutes
70
108
 
109
+ const runtimeRoute = validateRuntimeCommand(projectDir, args);
110
+ if (!runtimeRoute.ok) {
111
+ if (args['execution-claim-id']) {
112
+ releaseAgentExecution(projectDir, { agent: args.agent, claimId: args['execution-claim-id'] });
113
+ }
114
+ outputResult({
115
+ status: 'error',
116
+ code: runtimeRoute.code,
117
+ error: runtimeRoute.error,
118
+ agent: args.agent,
119
+ wait_for_user: false,
120
+ });
121
+ process.exit(1);
122
+ }
123
+
124
+ // A fresh dispatch uses the one-shot execution claim. A resumed interactive
125
+ // relay carries an interaction identity and is serialized separately by
126
+ // claimPendingInteraction after the response file is validated.
127
+ if (runtimeRoute.artifact && !args['interaction-id']) {
128
+ const execution = startAgentExecution(projectDir, {
129
+ agent: args.agent,
130
+ claimId: args['execution-claim-id'] || null,
131
+ });
132
+ if (!execution.saved || !execution.started) {
133
+ outputResult({
134
+ status: 'error',
135
+ code: execution.code || 'SESSION_WRITE_FAILED',
136
+ error: execution.error,
137
+ agent: args.agent,
138
+ wait_for_user: false,
139
+ });
140
+ process.exit(1);
141
+ }
142
+ process.once('exit', () => {
143
+ releaseAgentExecution(projectDir, {
144
+ agent: args.agent,
145
+ claimId: args['execution-claim-id'],
146
+ });
147
+ });
148
+ }
149
+
150
+ const responseFile = loadAdditionalContextFile(projectDir, args);
151
+ if (!responseFile.ok) {
152
+ outputResult({
153
+ status: 'error',
154
+ code: responseFile.code,
155
+ error: responseFile.error,
156
+ agent: args.agent,
157
+ wait_for_user: true,
158
+ });
159
+ process.exit(1);
160
+ }
161
+ if (responseFile.response !== null) args['additional-context'] = responseFile.response;
162
+
163
+ // A session awaiting user input is a local state gate, not a provider
164
+ // preflight concern. Check it before looking for a CLI so a missing or
165
+ // empty relay response cannot be masked by a host-specific CLI absence.
166
+ if (typeof args['additional-context'] !== 'string' || !args['additional-context'].trim()) {
167
+ const pendingGuard = claimPendingInteraction(projectDir, {
168
+ interactionId: args['interaction-id'] || null,
169
+ agent: args.agent,
170
+ taskId: args['task-id'],
171
+ provider: args.provider || null,
172
+ providerId: args['provider-id'] || null,
173
+ model: args.model || null,
174
+ reasoningConfiguration: args['reasoning-configuration'] || null,
175
+ catalogSnapshotRef: args['catalog-snapshot-ref'] || null,
176
+ response: args['additional-context'],
177
+ reasoningEscalation: args['reasoning-escalation'] === 'true',
178
+ });
179
+ if (!pendingGuard.saved || !pendingGuard.allowed) {
180
+ outputResult({
181
+ status: 'error',
182
+ code: pendingGuard.code || 'SESSION_WRITE_FAILED',
183
+ error: pendingGuard.error,
184
+ agent: args.agent,
185
+ wait_for_user: true,
186
+ });
187
+ process.exit(1);
188
+ }
189
+ }
190
+
71
191
  // Initialize telemetry for this process
72
192
  initCollector(isTelemetryEnabled(projectDir));
73
193
 
74
194
  // Load session state if available
75
195
  let sessionState = {};
76
- try {
77
- const { existsSync, readFileSync } = await import('fs');
78
- const { join } = await import('path');
79
- const sessionPath = join(projectDir, '.chati', 'session.yaml');
80
- if (existsSync(sessionPath)) {
81
- const raw = readFileSync(sessionPath, 'utf-8');
82
- sessionState = parseSimpleYaml(raw);
83
- }
84
- } catch (err) {
85
- process.stderr.write(`[chati] run-agent session-parse: ${err.message}\n`);
196
+ const loadedSession = loadSession(projectDir);
197
+ if (loadedSession.loaded) {
198
+ sessionState = loadedSession.session || {};
199
+ } else if (existsSync(resolve(projectDir, '.chati', 'session.yaml'))
200
+ || !['Session file not found', 'Session directory not found'].includes(loadedSession.error)) {
201
+ outputResult({
202
+ status: 'error',
203
+ code: 'INVALID_SESSION_PATH',
204
+ error: loadedSession.error,
205
+ agent: args.agent,
206
+ wait_for_user: false,
207
+ });
208
+ process.exit(1);
86
209
  }
87
210
 
88
211
  // Build the agent prompt
@@ -101,6 +224,11 @@ async function main() {
101
224
  strictProvider: args['strict-provider'] === 'true',
102
225
  });
103
226
  } catch (err) {
227
+ const relayState = releasePendingForFailedRelay(projectDir, args);
228
+ if (!relayState.saved) {
229
+ outputResult({ status: 'error', code: 'SESSION_WRITE_FAILED', error: relayState.error, cause: err.message });
230
+ process.exit(1);
231
+ }
104
232
  outputError(`Failed to build prompt: ${err.message}`);
105
233
  process.exit(1);
106
234
  }
@@ -108,12 +236,19 @@ async function main() {
108
236
  // Wait for rate limit slot before spawning
109
237
  const spawnProvider = promptResult.provider || args.provider;
110
238
  if (!spawnProvider) {
239
+ const relayState = releasePendingForFailedRelay(projectDir, args);
240
+ if (!relayState.saved) {
241
+ outputResult({ status: 'error', code: 'SESSION_WRITE_FAILED', error: relayState.error, cause: 'PROVIDER_MISSING' });
242
+ process.exit(1);
243
+ }
111
244
  outputError('No routed provider was supplied for agent execution');
112
245
  process.exit(1);
113
246
  }
114
- const readiness = checkProviderReadiness(spawnProvider, { workingDir: projectDir });
247
+ const readiness = checkProviderReadiness(spawnProvider, {
248
+ workingDir: projectDir, executableBinding: runtimeRoute.executableBinding || undefined,
249
+ });
115
250
  if (!readiness.ready) {
116
- const relayState = clearPendingForFailedRelay(projectDir, args);
251
+ const relayState = releasePendingForFailedRelay(projectDir, args);
117
252
  if (!relayState.saved) {
118
253
  outputResult({ status: 'error', code: 'SESSION_WRITE_FAILED', error: relayState.error, cause: readiness.code });
119
254
  process.exit(1);
@@ -133,6 +268,34 @@ async function main() {
133
268
  });
134
269
  }
135
270
 
271
+ // Reserve the relay only after local preparation and rate limiting. The
272
+ // lease therefore covers provider execution, not unbounded queue time.
273
+ const pendingClaim = claimPendingInteraction(projectDir, {
274
+ interactionId: args['interaction-id'] || null,
275
+ agent: args.agent,
276
+ taskId: args['task-id'],
277
+ provider: args.provider || null,
278
+ providerId: args['provider-id'] || null,
279
+ model: args.model || null,
280
+ reasoningConfiguration: args['reasoning-configuration'] || null,
281
+ catalogSnapshotRef: args['catalog-snapshot-ref'] || null,
282
+ response: args['additional-context'],
283
+ reasoningEscalation: args['reasoning-escalation'] === 'true',
284
+ leaseExpiresAt: new Date(Date.now() + (timeout * 2) + 5 * 60 * 1000).toISOString(),
285
+ });
286
+ if (!pendingClaim.saved || !pendingClaim.allowed) {
287
+ cleanupRejectedResponseFile(responseFile.filePath, pendingClaim);
288
+ outputResult({
289
+ status: 'error',
290
+ code: pendingClaim.code || 'SESSION_WRITE_FAILED',
291
+ error: pendingClaim.error,
292
+ agent: args.agent,
293
+ wait_for_user: true,
294
+ });
295
+ process.exit(1);
296
+ }
297
+ if (pendingClaim.claimed) args['relay-claim-id'] = pendingClaim.claimId;
298
+
136
299
  // Spawn the agent terminal
137
300
  const startTime = Date.now();
138
301
  let handle;
@@ -150,18 +313,30 @@ async function main() {
150
313
  providerId: args['provider-id'] || null,
151
314
  reasoningConfiguration: args['reasoning-configuration'] || null,
152
315
  catalogSnapshotRef: args['catalog-snapshot-ref'] || null,
316
+ providerExecutableBinding: runtimeRoute.executableBinding || undefined,
317
+ ...(runtimeRoute.artifact ? {
318
+ writeScope: getWriteScope(args.agent, projectDir),
319
+ authorityContext: {
320
+ authority_kind: 'planning_execution',
321
+ invocation_id: `planning:${args.agent}:${args['task-id']}:${randomUUID()}`,
322
+ launch_nonce: randomUUID(),
323
+ provider_id: runtimeRoute.invocation.provider_id,
324
+ },
325
+ } : {}),
153
326
  };
154
327
  handle = await spawnTerminalWithRetry(spawnConfig, {
155
328
  maxRetries: 1,
156
329
  baseDelay: 500,
157
- enableModelFallback: args['strict-provider'] !== 'true',
330
+ // The orchestrator must execute the routed model exactly. A silent
331
+ // downgrade would make the persisted binding and cost record false.
332
+ enableModelFallback: false,
158
333
  shouldRetry(exitCode, output) {
159
334
  const combined = Array.isArray(output) ? output.join('') : String(output || '');
160
335
  return isTransientFailure(exitCode, combined) || /not logged in|not authenticated/i.test(combined);
161
336
  },
162
337
  });
163
338
  } catch (err) {
164
- const relayState = clearPendingForFailedRelay(projectDir, args);
339
+ const relayState = releasePendingForFailedRelay(projectDir, args);
165
340
  if (!relayState.saved) {
166
341
  outputResult({ status: 'error', code: 'SESSION_WRITE_FAILED', error: relayState.error, cause: err.message });
167
342
  process.exit(1);
@@ -228,7 +403,7 @@ async function main() {
228
403
  const recovered = recoverInteractiveHandoff(args.agent, stdout, handle.exitCode);
229
404
 
230
405
  if (handle.exitCode !== 0 && /not logged in|not authenticated/i.test(providerOutput)) {
231
- const relayState = clearPendingForFailedRelay(projectDir, args);
406
+ const relayState = releasePendingForFailedRelay(projectDir, args);
232
407
  if (!relayState.saved) {
233
408
  outputResult({ status: 'error', code: 'SESSION_WRITE_FAILED', error: relayState.error, cause: 'PROVIDER_AUTH_STATE_MISMATCH' });
234
409
  process.exit(1);
@@ -244,12 +419,46 @@ async function main() {
244
419
  process.exit(1);
245
420
  }
246
421
 
422
+ // A syntactically valid handoff is not a successful execution when the
423
+ // provider process itself failed. In particular, a resumed relay must keep
424
+ // both its pending interaction and response file intact so the exact user
425
+ // answer can be retried. Persisting a trailing `complete` block from a failed
426
+ // process would clear that state and make the next CLI redispatch from zero.
427
+ if (handle.exitCode !== 0) {
428
+ const relayState = releasePendingForFailedRelay(projectDir, args);
429
+ if (!relayState.saved) {
430
+ outputResult({ status: 'error', code: 'SESSION_WRITE_FAILED', error: relayState.error, cause: 'PROVIDER_EXECUTION_FAILED' });
431
+ process.exit(1);
432
+ }
433
+ outputResult({
434
+ status: 'error',
435
+ code: 'PROVIDER_EXECUTION_FAILED',
436
+ error: `${spawnProvider} exited unsuccessfully; its handoff was not committed`,
437
+ provider: spawnProvider,
438
+ exitCode: handle.exitCode,
439
+ retryCount: handle.retryCount || 0,
440
+ agent: args.agent,
441
+ });
442
+ process.exit(1);
443
+ }
444
+
247
445
  if (parsed.found && parsed.valid) {
248
446
  const persistence = persistInteractiveState(projectDir, args, parsed.handoff, promptResult.model, spawnProvider);
249
447
  if (!persistence.saved) {
250
448
  outputResult({ status: 'error', code: 'SESSION_WRITE_FAILED', error: persistence.error, agent: args.agent });
251
449
  process.exit(1);
252
450
  }
451
+ if (parsed.handoff.status !== 'needs_input' && handle.exitCode === 0) {
452
+ const held = holdAgentExecutionForAdvance(projectDir, {
453
+ agent: args.agent,
454
+ claimId: args['interaction-id'] ? null : (args['execution-claim-id'] || null),
455
+ });
456
+ if (!held.saved || !held.held) {
457
+ outputResult({ status: 'error', code: held.code || 'SESSION_WRITE_FAILED', error: held.error, agent: args.agent });
458
+ process.exit(1);
459
+ }
460
+ }
461
+ cleanupResponseFile(responseFile.filePath);
253
462
  outputResult({
254
463
  status: parsed.handoff.status,
255
464
  agent: args.agent,
@@ -258,6 +467,9 @@ async function main() {
258
467
  reasoningConfiguration: args['reasoning-configuration'] || null,
259
468
  exitCode: handle.exitCode,
260
469
  handoff: parsed.handoff,
470
+ ...(parsed.handoff.status === 'needs_input' && parsed.handoff.needs_input_question
471
+ ? { needs_input_question: parsed.handoff.needs_input_question }
472
+ : {}),
261
473
  elapsed,
262
474
  costEstimate,
263
475
  });
@@ -270,6 +482,7 @@ async function main() {
270
482
  outputResult({ status: 'error', code: 'SESSION_WRITE_FAILED', error: persistence.error, agent: args.agent });
271
483
  process.exit(1);
272
484
  }
485
+ cleanupResponseFile(responseFile.filePath);
273
486
  outputResult({
274
487
  status: 'needs_input',
275
488
  agent: args.agent,
@@ -277,6 +490,7 @@ async function main() {
277
490
  provider: spawnProvider,
278
491
  exitCode: handle.exitCode,
279
492
  handoff: recovered,
493
+ needs_input_question: recovered.needs_input_question,
280
494
  contractRecovery: 'interactive_output_wrapped',
281
495
  elapsed,
282
496
  costEstimate,
@@ -284,7 +498,7 @@ async function main() {
284
498
  } else {
285
499
  // A non-interactive agent that omits the handoff violated the execution
286
500
  // contract and cannot be treated as successful or partially complete.
287
- const relayState = clearPendingForFailedRelay(projectDir, args);
501
+ const relayState = releasePendingForFailedRelay(projectDir, args);
288
502
  if (!relayState.saved) {
289
503
  outputResult({ status: 'error', code: 'SESSION_WRITE_FAILED', error: relayState.error, cause: parsed.found ? 'INVALID_HANDOFF' : 'MISSING_HANDOFF' });
290
504
  process.exit(1);
@@ -308,29 +522,175 @@ async function main() {
308
522
  process.exit(handle.exitCode === 0 ? 0 : 1);
309
523
  }
310
524
 
525
+ function cleanupResponseFile(filePath) {
526
+ if (!filePath) return { removed: false, error: null };
527
+ try {
528
+ rmSync(filePath);
529
+ return { removed: true, error: null };
530
+ } catch (err) {
531
+ if (err.code === 'ENOENT') return { removed: false, error: null };
532
+ process.stderr.write(`[chati] response-file cleanup failed: ${err.message}\n`);
533
+ return { removed: false, error: err.message };
534
+ }
535
+ }
536
+
537
+ export function cleanupRejectedResponseFile(filePath, claim = {}) {
538
+ if (claim.code !== 'PENDING_INTERACTION_NOT_FOUND') {
539
+ return { removed: false, preserved: Boolean(filePath), error: null };
540
+ }
541
+ return cleanupResponseFile(filePath);
542
+ }
543
+
311
544
  function persistInteractiveState(projectDir, args, handoff, model, provider) {
312
545
  if (handoff?.status === 'needs_input' && handoff.needs_input_question) {
313
546
  const result = recordPendingInteraction(projectDir, {
314
547
  agent: args.agent,
315
- taskId: args['task-id'],
548
+ taskId: args['reasoning-escalation'] === 'true'
549
+ ? `${args['task-id']}-post-escalation`
550
+ : args['task-id'],
316
551
  question: handoff.needs_input_question,
317
552
  provider,
318
553
  providerId: args['provider-id'] || null,
319
554
  model,
320
555
  reasoningConfiguration: args['reasoning-configuration'] || null,
321
556
  catalogSnapshotRef: args['catalog-snapshot-ref'] || null,
557
+ advanceRelay: Boolean(args['additional-context']?.trim()),
322
558
  });
323
559
  recordEvent(projectDir, EventType.AGENT_WAITING_FOR_INPUT, args.agent, {
324
560
  task_id: args['task-id'],
325
561
  });
326
562
  return result;
327
563
  }
328
- return clearPendingInteraction(projectDir, { agent: args.agent, taskId: args['task-id'] });
564
+ return clearPendingInteraction(projectDir, {
565
+ agent: args.agent,
566
+ taskId: args['task-id'],
567
+ interactionId: args['interaction-id'] || null,
568
+ claimId: args['relay-claim-id'] || null,
569
+ });
570
+ }
571
+
572
+ export function loadAdditionalContextFile(projectDir, args = {}) {
573
+ const fileArg = args['additional-context-file'];
574
+ if (!fileArg) return { ok: true, response: null, filePath: null };
575
+ if (typeof fileArg !== 'string') {
576
+ return { ok: false, code: 'INVALID_ADDITIONAL_CONTEXT_FILE', error: '--additional-context-file requires a path value.' };
577
+ }
578
+ if (args['additional-context'] !== undefined && args['additional-context'] !== null) {
579
+ return { ok: false, code: 'AMBIGUOUS_ADDITIONAL_CONTEXT', error: 'Use either --additional-context-file or --additional-context, not both.' };
580
+ }
581
+ const projectRoot = resolve(projectDir);
582
+ const chatiRoot = resolve(projectRoot, '.chati');
583
+ const runtimeRoot = resolve(chatiRoot, 'runtime');
584
+ const filePath = resolve(fileArg);
585
+ if (!existsSync(chatiRoot) || lstatSync(chatiRoot).isSymbolicLink() || !lstatSync(chatiRoot).isDirectory()) {
586
+ return { ok: false, code: 'INVALID_ADDITIONAL_CONTEXT_FILE', error: 'The .chati directory must be a real directory.' };
587
+ }
588
+ if (!existsSync(runtimeRoot) || lstatSync(runtimeRoot).isSymbolicLink() || !lstatSync(runtimeRoot).isDirectory()) {
589
+ return { ok: false, code: 'INVALID_ADDITIONAL_CONTEXT_FILE', error: 'The .chati/runtime directory must be a real directory.' };
590
+ }
591
+ const realProjectRoot = realpathSync(projectRoot);
592
+ const realChatiRoot = realpathSync(chatiRoot);
593
+ const chatiRel = relative(realProjectRoot, realChatiRoot);
594
+ if (!chatiRel || chatiRel.startsWith('..') || isAbsolute(chatiRel)) {
595
+ return { ok: false, code: 'INVALID_ADDITIONAL_CONTEXT_FILE', error: 'The .chati directory must remain inside the project.' };
596
+ }
597
+ const realRuntimeRoot = realpathSync(runtimeRoot);
598
+ const runtimeRel = relative(realChatiRoot, realRuntimeRoot);
599
+ if (!runtimeRel || runtimeRel.startsWith('..') || isAbsolute(runtimeRel)) {
600
+ return { ok: false, code: 'INVALID_ADDITIONAL_CONTEXT_FILE', error: 'The runtime directory must remain inside .chati.' };
601
+ }
602
+ const realFilePath = existsSync(filePath) ? realpathSync(filePath) : filePath;
603
+ const rel = relative(realRuntimeRoot, realFilePath);
604
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) {
605
+ return { ok: false, code: 'INVALID_ADDITIONAL_CONTEXT_FILE', error: 'Response file must be inside .chati/runtime.' };
606
+ }
607
+ if (!existsSync(filePath)) {
608
+ return { ok: false, code: 'ADDITIONAL_CONTEXT_FILE_MISSING', error: 'Pending response file does not exist.' };
609
+ }
610
+ const pathStat = lstatSync(filePath, { bigint: true });
611
+ if (!pathStat.isFile() || pathStat.isSymbolicLink()) {
612
+ return { ok: false, code: 'INVALID_ADDITIONAL_CONTEXT_FILE', error: 'Pending response path must be a regular file.' };
613
+ }
614
+ if (pathStat.size > ADDITIONAL_CONTEXT_MAX_BYTES_BIGINT) {
615
+ return { ok: false, code: 'ADDITIONAL_CONTEXT_FILE_TOO_LARGE', error: 'Pending response exceeds the 256 KiB limit.', filePath };
616
+ }
617
+ let fd;
618
+ try {
619
+ fd = openSync(
620
+ filePath,
621
+ constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0),
622
+ );
623
+ const openedStat = fstatSync(fd, { bigint: true });
624
+ if (!openedStat.isFile() || openedStat.nlink !== 1n
625
+ || !sameAdditionalContextSnapshot(pathStat, openedStat)) {
626
+ return { ok: false, code: 'INVALID_ADDITIONAL_CONTEXT_FILE', error: 'Pending response changed while it was being validated.', filePath };
627
+ }
628
+ if (openedStat.size > ADDITIONAL_CONTEXT_MAX_BYTES_BIGINT) {
629
+ return { ok: false, code: 'ADDITIONAL_CONTEXT_FILE_TOO_LARGE', error: 'Pending response exceeds the 256 KiB limit.', filePath };
630
+ }
631
+ runSecurityTestHook('afterAdditionalContextStat', { path: filePath });
632
+ const bytes = Buffer.allocUnsafe(ADDITIONAL_CONTEXT_MAX_BYTES + 1);
633
+ let total = 0;
634
+ while (total < bytes.length) {
635
+ const bytesRead = readSync(fd, bytes, total, bytes.length - total, null);
636
+ if (bytesRead === 0) break;
637
+ total += bytesRead;
638
+ }
639
+ if (total > ADDITIONAL_CONTEXT_MAX_BYTES) {
640
+ return { ok: false, code: 'ADDITIONAL_CONTEXT_FILE_TOO_LARGE', error: 'Pending response exceeds the 256 KiB limit.', filePath };
641
+ }
642
+ const finalStat = fstatSync(fd, { bigint: true });
643
+ const finalPathStat = lstatSync(filePath, { bigint: true });
644
+ if (!sameAdditionalContextSnapshot(openedStat, finalStat)
645
+ || !sameAdditionalContextSnapshot(finalStat, finalPathStat)) {
646
+ return { ok: false, code: 'INVALID_ADDITIONAL_CONTEXT_FILE', error: 'Pending response changed while it was being read.', filePath };
647
+ }
648
+ const payload = JSON.parse(bytes.subarray(0, total).toString('utf8'));
649
+ if (typeof payload?.response !== 'string' || !payload.response.trim()) {
650
+ return { ok: false, code: 'EMPTY_ADDITIONAL_CONTEXT', error: 'Pending response must contain a non-empty JSON string field named response.', filePath };
651
+ }
652
+ return { ok: true, response: payload.response, filePath };
653
+ } catch {
654
+ return { ok: false, code: 'INVALID_ADDITIONAL_CONTEXT_FILE', error: 'Could not parse the pending response JSON.', filePath };
655
+ } finally {
656
+ if (fd !== undefined) closeSync(fd);
657
+ }
329
658
  }
330
659
 
331
- export function clearPendingForFailedRelay(projectDir, args) {
332
- if (!args?.['additional-context']) return { saved: true, cleared: false };
333
- return clearPendingInteraction(projectDir, { agent: args.agent, taskId: args['task-id'] });
660
+ export function validateRuntimeCommand(projectDir, args = {}) {
661
+ try {
662
+ const artifact = loadRuntimeInstallationV2(projectDir);
663
+ if (!artifact) return { ok: true, artifact: null };
664
+ const invocation = assertRuntimeCommandV2({
665
+ artifact,
666
+ agent: args.agent,
667
+ reasoning_escalation: args['reasoning-escalation'] === 'true',
668
+ command: {
669
+ provider_id: args['provider-id'],
670
+ harness_id: args.provider,
671
+ model_id: args.model,
672
+ reasoning_configuration: args['reasoning-configuration'],
673
+ catalog_snapshot_ref: args['catalog-snapshot-ref'],
674
+ strict_provider: args['strict-provider'] === 'true',
675
+ },
676
+ });
677
+ const executableBinding = resolveRuntimeExecutableBindingV2({
678
+ artifact,
679
+ provider_id: invocation.provider_id,
680
+ harness_id: invocation.harness_id,
681
+ });
682
+ return { ok: true, artifact, invocation, executableBinding };
683
+ } catch (error) {
684
+ return { ok: false, code: error.code || 'RUNTIME_ROUTE_BINDING_INVALID', error: error.message };
685
+ }
686
+ }
687
+
688
+ export function releasePendingForFailedRelay(projectDir, args) {
689
+ if (!args?.['relay-claim-id']) return { saved: true, released: false };
690
+ return releasePendingInteraction(projectDir, {
691
+ claimId: args['relay-claim-id'],
692
+ interactionId: args['interaction-id'] || null,
693
+ });
334
694
  }
335
695
 
336
696
  // ---------------------------------------------------------------------------
@@ -458,4 +818,4 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
458
818
  });
459
819
  }
460
820
 
461
- export { parseArgs, waitForExit, parseSimpleYaml };
821
+ export { cleanupResponseFile, parseArgs, waitForExit, parseSimpleYaml };
@@ -22,6 +22,7 @@ import { collectResults, mergeHandoffs, buildConsolidatedHandoff } from './colle
22
22
  import { parseAgentOutput } from './handoff-parser.js';
23
23
  import { estimateTokens, COST_PER_1K } from './cost-tracker.js';
24
24
  import { getRateLimiter } from './rate-limiter.js';
25
+ import { loadRuntimeInstallationV2 } from '../orchestrator/runtime-installation-v2.js';
25
26
 
26
27
  // ---------------------------------------------------------------------------
27
28
  // CLI argument parsing
@@ -86,6 +87,10 @@ async function main() {
86
87
  }
87
88
 
88
89
  const projectDir = args['project-dir'] || process.cwd();
90
+ if (loadRuntimeInstallationV2(projectDir)) {
91
+ outputError('Runtime v2 requires individually signed planning commands. The legacy parallel runner is disabled.');
92
+ process.exit(1);
93
+ }
89
94
  const previousAgent = args['previous-agent'] || null;
90
95
  const timeout = parseInt(args.timeout, 10) || 900_000; // default 15 minutes
91
96