chati-dev 4.5.15 → 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 (124) 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 +2 -2
  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 +278 -24
  43. package/framework/orchestrator/chati.md +250 -68
  44. package/framework/schemas/session.schema.json +21 -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 +157 -56
  82. package/src/installer/manifest.js +140 -9
  83. package/src/installer/package-artifact.js +249 -0
  84. package/src/installer/templates.js +91 -23
  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/intelligence/timeline.js +3 -1
  91. package/src/orchestrator/browser-runtime.js +44 -13
  92. package/src/orchestrator/cli.js +1616 -151
  93. package/src/orchestrator/clickup-projection.js +43 -1
  94. package/src/orchestrator/clickup-runtime.js +355 -39
  95. package/src/orchestrator/doctor.js +87 -4
  96. package/src/orchestrator/index.js +16 -0
  97. package/src/orchestrator/planning-runtime.js +20 -2
  98. package/src/orchestrator/rail-adjudication-evidence.js +234 -0
  99. package/src/orchestrator/rail-evidence-authority.js +147 -0
  100. package/src/orchestrator/rail-execution-evidence.js +45 -0
  101. package/src/orchestrator/rail-runtime.js +836 -56
  102. package/src/orchestrator/release-runtime.js +65 -6
  103. package/src/orchestrator/review-runtime.js +186 -41
  104. package/src/orchestrator/runtime-installation-v2.js +236 -20
  105. package/src/orchestrator/session-manager.js +1249 -52
  106. package/src/terminal/adapters/claude-adapter.js +3 -1
  107. package/src/terminal/adapters/codex-adapter.js +2 -0
  108. package/src/terminal/adapters/grok-adapter.js +7 -4
  109. package/src/terminal/handoff-parser.js +19 -1
  110. package/src/terminal/prompt-builder.js +10 -1
  111. package/src/terminal/provider-preflight.js +36 -3
  112. package/src/terminal/rail-execution-worktree.js +213 -0
  113. package/src/terminal/rail-prompts.js +169 -0
  114. package/src/terminal/rail-readonly-workspace.js +324 -0
  115. package/src/terminal/run-agent.js +434 -16
  116. package/src/terminal/run-parallel.js +5 -0
  117. package/src/terminal/run-rail-adjudication.js +323 -0
  118. package/src/terminal/run-rail-review.js +291 -0
  119. package/src/terminal/run-rail-rework.js +325 -0
  120. package/src/terminal/run-rail-task.js +380 -0
  121. package/src/terminal/run-team.js +5 -0
  122. package/src/terminal/spawner.js +1225 -77
  123. package/src/utils/schema-validator.js +5 -0
  124. 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,6 +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';
30
+ import {
31
+ recordPendingInteraction, clearPendingInteraction,
32
+ claimPendingInteraction, releasePendingInteraction,
33
+ loadSession, startAgentExecution, holdAgentExecutionForAdvance, releaseAgentExecution,
34
+ } from '../orchestrator/session-manager.js';
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
+ }
26
61
 
27
62
  // ---------------------------------------------------------------------------
28
63
  // CLI argument parsing (no external deps)
@@ -35,7 +70,12 @@ function parseArgs(argv) {
35
70
  if (arg.startsWith('--')) {
36
71
  const key = arg.slice(2);
37
72
  const next = argv[i + 1];
38
- 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('--')) {
39
79
  args[key] = next;
40
80
  i++;
41
81
  } else {
@@ -66,21 +106,106 @@ async function main() {
66
106
  const projectDir = args['project-dir'] || process.cwd();
67
107
  const timeout = parseInt(args.timeout, 10) || 600_000; // default 10 minutes
68
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
+
69
191
  // Initialize telemetry for this process
70
192
  initCollector(isTelemetryEnabled(projectDir));
71
193
 
72
194
  // Load session state if available
73
195
  let sessionState = {};
74
- try {
75
- const { existsSync, readFileSync } = await import('fs');
76
- const { join } = await import('path');
77
- const sessionPath = join(projectDir, '.chati', 'session.yaml');
78
- if (existsSync(sessionPath)) {
79
- const raw = readFileSync(sessionPath, 'utf-8');
80
- sessionState = parseSimpleYaml(raw);
81
- }
82
- } catch (err) {
83
- 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);
84
209
  }
85
210
 
86
211
  // Build the agent prompt
@@ -99,6 +224,11 @@ async function main() {
99
224
  strictProvider: args['strict-provider'] === 'true',
100
225
  });
101
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
+ }
102
232
  outputError(`Failed to build prompt: ${err.message}`);
103
233
  process.exit(1);
104
234
  }
@@ -106,11 +236,23 @@ async function main() {
106
236
  // Wait for rate limit slot before spawning
107
237
  const spawnProvider = promptResult.provider || args.provider;
108
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
+ }
109
244
  outputError('No routed provider was supplied for agent execution');
110
245
  process.exit(1);
111
246
  }
112
- const readiness = checkProviderReadiness(spawnProvider, { workingDir: projectDir });
247
+ const readiness = checkProviderReadiness(spawnProvider, {
248
+ workingDir: projectDir, executableBinding: runtimeRoute.executableBinding || undefined,
249
+ });
113
250
  if (!readiness.ready) {
251
+ const relayState = releasePendingForFailedRelay(projectDir, args);
252
+ if (!relayState.saved) {
253
+ outputResult({ status: 'error', code: 'SESSION_WRITE_FAILED', error: relayState.error, cause: readiness.code });
254
+ process.exit(1);
255
+ }
114
256
  outputResult({ status: 'error', error: readiness.detail, code: readiness.code, provider: spawnProvider });
115
257
  process.exit(1);
116
258
  }
@@ -126,6 +268,34 @@ async function main() {
126
268
  });
127
269
  }
128
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
+
129
299
  // Spawn the agent terminal
130
300
  const startTime = Date.now();
131
301
  let handle;
@@ -143,17 +313,34 @@ async function main() {
143
313
  providerId: args['provider-id'] || null,
144
314
  reasoningConfiguration: args['reasoning-configuration'] || null,
145
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
+ } : {}),
146
326
  };
147
327
  handle = await spawnTerminalWithRetry(spawnConfig, {
148
328
  maxRetries: 1,
149
329
  baseDelay: 500,
150
- 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,
151
333
  shouldRetry(exitCode, output) {
152
334
  const combined = Array.isArray(output) ? output.join('') : String(output || '');
153
335
  return isTransientFailure(exitCode, combined) || /not logged in|not authenticated/i.test(combined);
154
336
  },
155
337
  });
156
338
  } catch (err) {
339
+ const relayState = releasePendingForFailedRelay(projectDir, args);
340
+ if (!relayState.saved) {
341
+ outputResult({ status: 'error', code: 'SESSION_WRITE_FAILED', error: relayState.error, cause: err.message });
342
+ process.exit(1);
343
+ }
157
344
  outputError(`Failed to spawn terminal: ${err.message}`);
158
345
  process.exit(1);
159
346
  }
@@ -213,8 +400,14 @@ async function main() {
213
400
 
214
401
  // Parse the handoff from stdout
215
402
  const parsed = parseAgentOutput(stdout);
403
+ const recovered = recoverInteractiveHandoff(args.agent, stdout, handle.exitCode);
216
404
 
217
405
  if (handle.exitCode !== 0 && /not logged in|not authenticated/i.test(providerOutput)) {
406
+ const relayState = releasePendingForFailedRelay(projectDir, args);
407
+ if (!relayState.saved) {
408
+ outputResult({ status: 'error', code: 'SESSION_WRITE_FAILED', error: relayState.error, cause: 'PROVIDER_AUTH_STATE_MISMATCH' });
409
+ process.exit(1);
410
+ }
218
411
  outputResult({
219
412
  status: 'error',
220
413
  code: 'PROVIDER_AUTH_STATE_MISMATCH',
@@ -226,7 +419,46 @@ async function main() {
226
419
  process.exit(1);
227
420
  }
228
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
+
229
445
  if (parsed.found && parsed.valid) {
446
+ const persistence = persistInteractiveState(projectDir, args, parsed.handoff, promptResult.model, spawnProvider);
447
+ if (!persistence.saved) {
448
+ outputResult({ status: 'error', code: 'SESSION_WRITE_FAILED', error: persistence.error, agent: args.agent });
449
+ process.exit(1);
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);
230
462
  outputResult({
231
463
  status: parsed.handoff.status,
232
464
  agent: args.agent,
@@ -235,20 +467,30 @@ async function main() {
235
467
  reasoningConfiguration: args['reasoning-configuration'] || null,
236
468
  exitCode: handle.exitCode,
237
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
+ : {}),
238
473
  elapsed,
239
474
  costEstimate,
240
475
  });
241
- } else if (recoverInteractiveHandoff(args.agent, stdout, handle.exitCode)) {
476
+ } else if (recovered) {
242
477
  // Interactive discovery models sometimes answer with the user-facing
243
478
  // question but omit the machine block. Preserve the exact question while
244
479
  // restoring the deterministic relay contract for the parent orchestrator.
480
+ const persistence = persistInteractiveState(projectDir, args, recovered, promptResult.model, spawnProvider);
481
+ if (!persistence.saved) {
482
+ outputResult({ status: 'error', code: 'SESSION_WRITE_FAILED', error: persistence.error, agent: args.agent });
483
+ process.exit(1);
484
+ }
485
+ cleanupResponseFile(responseFile.filePath);
245
486
  outputResult({
246
487
  status: 'needs_input',
247
488
  agent: args.agent,
248
489
  model: promptResult.model,
249
490
  provider: spawnProvider,
250
491
  exitCode: handle.exitCode,
251
- handoff: recoverInteractiveHandoff(args.agent, stdout, handle.exitCode),
492
+ handoff: recovered,
493
+ needs_input_question: recovered.needs_input_question,
252
494
  contractRecovery: 'interactive_output_wrapped',
253
495
  elapsed,
254
496
  costEstimate,
@@ -256,6 +498,11 @@ async function main() {
256
498
  } else {
257
499
  // A non-interactive agent that omits the handoff violated the execution
258
500
  // contract and cannot be treated as successful or partially complete.
501
+ const relayState = releasePendingForFailedRelay(projectDir, args);
502
+ if (!relayState.saved) {
503
+ outputResult({ status: 'error', code: 'SESSION_WRITE_FAILED', error: relayState.error, cause: parsed.found ? 'INVALID_HANDOFF' : 'MISSING_HANDOFF' });
504
+ process.exit(1);
505
+ }
259
506
  outputResult({
260
507
  status: 'error',
261
508
  code: parsed.found ? 'INVALID_HANDOFF' : 'MISSING_HANDOFF',
@@ -275,6 +522,177 @@ async function main() {
275
522
  process.exit(handle.exitCode === 0 ? 0 : 1);
276
523
  }
277
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
+
544
+ function persistInteractiveState(projectDir, args, handoff, model, provider) {
545
+ if (handoff?.status === 'needs_input' && handoff.needs_input_question) {
546
+ const result = recordPendingInteraction(projectDir, {
547
+ agent: args.agent,
548
+ taskId: args['reasoning-escalation'] === 'true'
549
+ ? `${args['task-id']}-post-escalation`
550
+ : args['task-id'],
551
+ question: handoff.needs_input_question,
552
+ provider,
553
+ providerId: args['provider-id'] || null,
554
+ model,
555
+ reasoningConfiguration: args['reasoning-configuration'] || null,
556
+ catalogSnapshotRef: args['catalog-snapshot-ref'] || null,
557
+ advanceRelay: Boolean(args['additional-context']?.trim()),
558
+ });
559
+ recordEvent(projectDir, EventType.AGENT_WAITING_FOR_INPUT, args.agent, {
560
+ task_id: args['task-id'],
561
+ });
562
+ return result;
563
+ }
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
+ }
658
+ }
659
+
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
+ });
694
+ }
695
+
278
696
  // ---------------------------------------------------------------------------
279
697
  // Helpers
280
698
  // ---------------------------------------------------------------------------
@@ -400,4 +818,4 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
400
818
  });
401
819
  }
402
820
 
403
- 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