chati-dev 4.5.16 → 4.5.28

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 +247 -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
@@ -17,9 +17,14 @@ import { isFeatureEnabled } from '../utils/feature-flags.js';
17
17
  import { runScriptTask, formatScriptHandoff } from '../executors/runner.js';
18
18
 
19
19
  import {
20
- loadSession, initSession, updateSession, recordAgentCompletion,
20
+ loadSession, readSessionSafe, initSession, updateSession, transitionSessionLifecycle, rollbackSessionLifecycle,
21
+ activateAgentExecutionState, rollbackAgentDispatch, recordAgentCompletion,
21
22
  recordModeTransition, getSessionSummary, validateSession,
22
23
  releaseSession, parseHandoffDecisionTrail, parseHandoffScaffoldSignals,
24
+ ensurePendingInteractionIdentity, releasePendingInteraction,
25
+ claimAgentExecutions,
26
+ releaseReservedAgentExecutions,
27
+ withSessionProjectionLock,
23
28
  } from './index.js';
24
29
  import {
25
30
  resolveScaffoldSource, loadScaffoldManifest, applyScaffold,
@@ -42,9 +47,14 @@ import {
42
47
  import { analyzeDeviationImpact, applyDeviation } from './index.js';
43
48
  import { detectQuickFlow, detectStandardFlow } from './index.js';
44
49
  import { getAgentFile } from '../terminal/prompt-builder.js';
45
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
46
- import { join, resolve, dirname } from 'path';
50
+ import {
51
+ readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync,
52
+ lstatSync, realpathSync, renameSync, rmSync, openSync, closeSync, constants,
53
+ fstatSync, fchmodSync, chmodSync, readSync, linkSync, unlinkSync, fsyncSync, writeSync,
54
+ } from 'fs';
55
+ import { basename, join, parse, resolve, dirname, relative, isAbsolute, sep } from 'path';
47
56
  import { fileURLToPath } from 'url';
57
+ import { createHash, randomUUID } from 'crypto';
48
58
  import { execSync } from 'child_process';
49
59
  import * as yaml from 'js-yaml';
50
60
  import {
@@ -56,10 +66,14 @@ import {
56
66
  import { updateProjectContext } from '../memory/magic-docs.js';
57
67
  import {
58
68
  loadRuntimeInstallationV2, resolveRuntimeInvocationV2,
59
- describeRuntimeInstallationV2, V2_PLANNING_AGENTS,
69
+ resolveRuntimeAdjudicationV2, describeRuntimeInstallationV2, V2_PLANNING_AGENTS,
70
+ assertV2PlanningPredecessors,
60
71
  } from './runtime-installation-v2.js';
61
72
  import { compilePlanningHandoff, PLANNING_V2_TASKS_PATH } from './planning-runtime.js';
62
73
  import { persistRailHandoff } from './rail-runtime.js';
74
+ import {
75
+ installationAuthorityProjectScope, signHostAuthorityPayload, verifyHostAuthorityPayload,
76
+ } from '../installer-v2/installation-authority.js';
63
77
 
64
78
  // Spawn commands must reference the terminal runners with an absolute,
65
79
  // layout-independent path: the old monorepo-relative
@@ -68,7 +82,676 @@ import { persistRailHandoff } from './rail-runtime.js';
68
82
  // node_modules). Anchoring to this file's own location works in every layout
69
83
  // because the src/ tree shape is preserved verbatim by the CLI bundling.
70
84
  const _THISDIR = dirname(fileURLToPath(import.meta.url));
71
- const RUNNER = (name) => JSON.stringify(join(_THISDIR, '..', 'terminal', name));
85
+ const NATIVE_LOCK_MAX_BYTES = 256 * 1024;
86
+ const NATIVE_LOCK_MAX_BYTES_BIGINT = BigInt(NATIVE_LOCK_MAX_BYTES);
87
+ const RUNNER = (name) => join(_THISDIR, '..', 'terminal', name);
88
+ const RUNTIME_JSON_MAX_BYTES = 256 * 1024;
89
+ const RUNTIME_SECURITY_HOOKS = Symbol.for('chati.cli.runtime-security-test-hooks');
90
+ const SCRIPT_EXECUTION_RECEIPT_DOMAIN = 'chati.script-execution-receipt.v1';
91
+
92
+ function runRuntimeSecurityTestHook(name, detail) {
93
+ const hook = globalThis[RUNTIME_SECURITY_HOOKS]?.[name];
94
+ if (typeof hook === 'function') hook(detail);
95
+ }
96
+
97
+ function sameRuntimeIdentity(left, right) {
98
+ return Boolean(left && right && left.dev === right.dev && left.ino === right.ino);
99
+ }
100
+
101
+ function sameRuntimeFileSnapshot(left, right) {
102
+ return sameRuntimeIdentity(left, right)
103
+ && left.size === right.size
104
+ && left.mode === right.mode
105
+ && left.mtimeNs === right.mtimeNs
106
+ && left.ctimeNs === right.ctimeNs;
107
+ }
108
+
109
+ // A rename is allowed to update ctime on the source inode. For the commit
110
+ // check, retain identity, permissions, size and mtime, which are sufficient to
111
+ // reject a replacement or content mutation without treating a valid rename as
112
+ // a race.
113
+ function samePublishedRuntimeFile(left, right) {
114
+ return sameRuntimeIdentity(left, right)
115
+ && left.size === right.size
116
+ && left.mode === right.mode
117
+ && left.mtimeNs === right.mtimeNs;
118
+ }
119
+
120
+ function openRuntimeDirectoryNoFollow(runtimePath) {
121
+ if (process.platform !== 'linux') {
122
+ throw Object.assign(new Error('Runtime request authority requires Linux descriptor-relative filesystem operations'), { code: 'UNSUPPORTED_RUNTIME_REQUEST_PLATFORM' });
123
+ }
124
+ const absolute = resolve(runtimePath);
125
+ const root = parse(absolute).root;
126
+ const anchor = openWorkingDirectoryAnchor(absolute);
127
+ const segments = anchor ? anchor.segments : absolute.slice(root.length).split(sep).filter(Boolean);
128
+ let fd;
129
+ try {
130
+ fd = anchor
131
+ ? openSync(`/proc/self/fd/${anchor.descriptor}`, constants.O_RDONLY | (constants.O_DIRECTORY || 0))
132
+ : openSync(root, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
133
+ } finally {
134
+ if (anchor) closeSync(anchor.descriptor);
135
+ }
136
+ try {
137
+ for (const segment of segments) {
138
+ const child = openSync(
139
+ join(`/proc/self/fd/${fd}`, segment),
140
+ constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0),
141
+ );
142
+ const stat = fstatSync(child);
143
+ if (!stat.isDirectory()) {
144
+ closeSync(child);
145
+ throw Object.assign(new Error('The .chati/runtime chain must contain only real directories'), { code: 'UNSAFE_RUNTIME_PATH' });
146
+ }
147
+ closeSync(fd);
148
+ fd = child;
149
+ }
150
+ return { fd, stat: fstatSync(fd), absolute, reference: `/proc/self/fd/${fd}` };
151
+ } catch (error) {
152
+ closeSync(fd);
153
+ if (['ELOOP', 'ENOTDIR'].includes(error?.code)) {
154
+ throw Object.assign(new Error('The .chati/runtime chain must contain only real directories'), { code: 'UNSAFE_RUNTIME_PATH', cause: error });
155
+ }
156
+ throw error;
157
+ }
158
+ }
159
+
160
+ function assertRuntimeDirectoryRecord(record) {
161
+ const pinned = fstatSync(record.fd);
162
+ if (!pinned.isDirectory() || !sameRuntimeIdentity(pinned, record.runtimeIdentity)) {
163
+ throw Object.assign(new Error('The pinned runtime directory changed during request processing'), { code: 'UNSAFE_RUNTIME_PATH' });
164
+ }
165
+ let opened;
166
+ try {
167
+ opened = openRuntimeDirectoryNoFollow(record.runtimePath);
168
+ if (!sameRuntimeIdentity(opened.stat, record.runtimeIdentity)) {
169
+ throw Object.assign(new Error('The runtime directory changed during request processing'), { code: 'UNSAFE_RUNTIME_PATH' });
170
+ }
171
+ } finally {
172
+ if (opened) closeSync(opened.fd);
173
+ }
174
+ }
175
+
176
+ function openScriptHandoffDirectoryNoFollow(directoryPath, { create = false } = {}) {
177
+ if (process.platform !== 'linux') {
178
+ throw Object.assign(new Error('Script handoff persistence requires Linux descriptor-relative filesystem operations'), { code: 'UNSUPPORTED_SCRIPT_HANDOFF_PLATFORM' });
179
+ }
180
+ const absolute = resolve(directoryPath);
181
+ const root = parse(absolute).root;
182
+ const anchor = openWorkingDirectoryAnchor(absolute);
183
+ const segments = anchor ? anchor.segments : absolute.slice(root.length).split(sep).filter(Boolean);
184
+ let fd;
185
+ try {
186
+ fd = anchor
187
+ ? openSync(`/proc/self/fd/${anchor.descriptor}`, constants.O_RDONLY | (constants.O_DIRECTORY || 0))
188
+ : openSync(root, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
189
+ } finally {
190
+ if (anchor) closeSync(anchor.descriptor);
191
+ }
192
+ try {
193
+ for (const segment of segments) {
194
+ const entry = join(`/proc/self/fd/${fd}`, segment);
195
+ let child;
196
+ try {
197
+ child = openSync(entry, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
198
+ } catch (error) {
199
+ if (error?.code !== 'ENOENT' || !create) throw error;
200
+ try { mkdirSync(entry, { mode: 0o700 }); }
201
+ catch (mkdirError) { if (mkdirError?.code !== 'EEXIST') throw mkdirError; }
202
+ child = openSync(entry, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
203
+ }
204
+ const stat = fstatSync(child);
205
+ if (!stat.isDirectory()) {
206
+ closeSync(child);
207
+ throw Object.assign(new Error('The script handoff directory chain must contain only real directories'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
208
+ }
209
+ closeSync(fd);
210
+ fd = child;
211
+ }
212
+ return { fd, stat: fstatSync(fd), absolute, reference: `/proc/self/fd/${fd}` };
213
+ } catch (error) {
214
+ closeSync(fd);
215
+ if (['ELOOP', 'ENOTDIR'].includes(error?.code)) {
216
+ throw Object.assign(new Error('The script handoff directory chain must contain only real directories'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH', cause: error });
217
+ }
218
+ throw error;
219
+ }
220
+ }
221
+
222
+ function assertScriptHandoffDirectoryRecord(record) {
223
+ const pinned = fstatSync(record.fd);
224
+ if (!pinned.isDirectory() || !sameRuntimeIdentity(pinned, record.stat)) {
225
+ throw Object.assign(new Error('The pinned script handoff directory changed during persistence'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
226
+ }
227
+ let canonical;
228
+ try {
229
+ canonical = openScriptHandoffDirectoryNoFollow(record.absolute);
230
+ if (!sameRuntimeIdentity(canonical.stat, record.stat)) {
231
+ throw Object.assign(new Error('The script handoff directory chain changed during persistence'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
232
+ }
233
+ } catch (error) {
234
+ if (error?.code === 'UNSUPPORTED_SCRIPT_HANDOFF_PLATFORM') throw error;
235
+ throw Object.assign(new Error('The script handoff directory chain changed during persistence'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH', cause: error });
236
+ } finally {
237
+ if (canonical) closeSync(canonical.fd);
238
+ }
239
+ }
240
+
241
+ export function shellQuote(value) {
242
+ return `'${String(value).replaceAll("'", `'"'"'`)}'`;
243
+ }
244
+
245
+ function ensureRuntimeResponsePath(projectDir, filename) {
246
+ if (!/^[A-Za-z0-9_-]+\.json$/.test(filename)) {
247
+ throw Object.assign(new Error('Runtime response filename is not path-safe'), { code: 'UNSAFE_RUNTIME_PATH' });
248
+ }
249
+ const projectPath = resolve(projectDir);
250
+ const chatiPath = join(projectPath, '.chati');
251
+ if (!existsSync(chatiPath) || lstatSync(chatiPath).isSymbolicLink() || !lstatSync(chatiPath).isDirectory()) {
252
+ throw Object.assign(new Error('The .chati directory must be a real directory'), { code: 'UNSAFE_RUNTIME_PATH' });
253
+ }
254
+ const realProject = realpathSync(projectPath);
255
+ const realChati = realpathSync(chatiPath);
256
+ const chatiRelative = relative(realProject, realChati);
257
+ if (!chatiRelative || chatiRelative.startsWith('..') || isAbsolute(chatiRelative)) {
258
+ throw Object.assign(new Error('The .chati directory must remain inside the project'), { code: 'UNSAFE_RUNTIME_PATH' });
259
+ }
260
+ const runtimePath = join(chatiPath, 'runtime');
261
+ if (!existsSync(runtimePath)) mkdirSync(runtimePath, { mode: 0o700 });
262
+ const runtimeStat = lstatSync(runtimePath);
263
+ if (runtimeStat.isSymbolicLink() || !runtimeStat.isDirectory()) {
264
+ throw Object.assign(new Error('The .chati/runtime directory must be a real directory'), { code: 'UNSAFE_RUNTIME_PATH' });
265
+ }
266
+ if (typeof process.getuid !== 'function' || runtimeStat.uid === process.getuid()) chmodSync(runtimePath, 0o700);
267
+ const realRuntime = realpathSync(runtimePath);
268
+ const runtimeRelative = relative(realChati, realRuntime);
269
+ if (!runtimeRelative || runtimeRelative.startsWith('..') || isAbsolute(runtimeRelative)) {
270
+ throw Object.assign(new Error('The runtime directory must remain inside .chati'), { code: 'UNSAFE_RUNTIME_PATH' });
271
+ }
272
+ const target = join(realRuntime, filename);
273
+ if (existsSync(target)) {
274
+ const targetStat = lstatSync(target);
275
+ if (targetStat.isSymbolicLink() || !targetStat.isFile()) {
276
+ throw Object.assign(new Error('The runtime response target must be a regular file'), { code: 'UNSAFE_RUNTIME_PATH' });
277
+ }
278
+ }
279
+ return target;
280
+ }
281
+
282
+ function captureRuntimePath(projectDir, filename) {
283
+ const target = ensureRuntimeResponsePath(projectDir, filename);
284
+ const runtimePath = dirname(target);
285
+ const opened = openRuntimeDirectoryNoFollow(runtimePath);
286
+ return {
287
+ projectDir: resolve(projectDir),
288
+ target,
289
+ targetEntry: join(opened.reference, basename(target)),
290
+ runtimePath,
291
+ runtimeIdentity: opened.stat,
292
+ fd: opened.fd,
293
+ reference: opened.reference,
294
+ };
295
+ }
296
+
297
+ function closeRuntimeRecord(record) {
298
+ if (record?.fd === undefined || record.fd === null) return;
299
+ closeSync(record.fd);
300
+ record.fd = null;
301
+ }
302
+
303
+ function runtimeProcessAlive(pid) {
304
+ if (!Number.isInteger(pid) || pid <= 0) return false;
305
+ try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
306
+ }
307
+
308
+ function finalizeRuntimeRequestRestore(runtimeRecord, claimEntry, canonicalEntry, expectedIdentity) {
309
+ assertRuntimeDirectoryRecord(runtimeRecord);
310
+ const claimStat = lstatSync(claimEntry, { bigint: true });
311
+ const canonicalStat = lstatSync(canonicalEntry, { bigint: true });
312
+ if (claimStat.isSymbolicLink() || !claimStat.isFile()
313
+ || canonicalStat.isSymbolicLink() || !canonicalStat.isFile()
314
+ || !sameRuntimeIdentity(claimStat, expectedIdentity)
315
+ || !sameRuntimeIdentity(canonicalStat, expectedIdentity)
316
+ || !sameRuntimeIdentity(claimStat, canonicalStat)
317
+ || claimStat.nlink !== 2n || canonicalStat.nlink !== 2n) {
318
+ throw Object.assign(new Error('Runtime request restoration links do not identify one safe file'), { code: 'RUNTIME_REQUEST_CLAIM_CONFLICT' });
319
+ }
320
+ unlinkSync(claimEntry);
321
+ fsyncSync(runtimeRecord.fd);
322
+ assertRuntimeDirectoryRecord(runtimeRecord);
323
+ const restored = lstatSync(canonicalEntry, { bigint: true });
324
+ if (restored.isSymbolicLink() || !restored.isFile()
325
+ || !sameRuntimeIdentity(restored, expectedIdentity) || restored.nlink !== 1n) {
326
+ throw Object.assign(new Error('Restored runtime request did not become one canonical link'), { code: 'RUNTIME_REQUEST_CLAIM_CONFLICT' });
327
+ }
328
+ return true;
329
+ }
330
+
331
+ function recoverInterruptedRuntimeClaim(runtimeRecord) {
332
+ const claimPattern = /^\.claimed-(\d+)-[A-Fa-f0-9-]{36}\.json$/;
333
+ const claims = readdirSync(runtimeRecord.reference)
334
+ .map((name) => ({ name, match: name.match(claimPattern) }))
335
+ .filter((entry) => entry.match);
336
+ if (claims.length === 0) return false;
337
+ if (claims.length > 1) {
338
+ throw Object.assign(new Error('Multiple interrupted runtime request claims require manual reconciliation'), { code: 'RUNTIME_REQUEST_CLAIM_CONFLICT' });
339
+ }
340
+ const [{ name, match }] = claims;
341
+ const claimEntry = join(runtimeRecord.reference, name);
342
+ const claimStat = lstatSync(claimEntry, { bigint: true });
343
+ if (claimStat.isSymbolicLink() || !claimStat.isFile()
344
+ || ![1n, 2n].includes(claimStat.nlink)
345
+ || claimStat.size > BigInt(RUNTIME_JSON_MAX_BYTES)) {
346
+ throw Object.assign(new Error('Interrupted runtime claim is not a safe request file'), { code: 'INVALID_RUNTIME_JSON' });
347
+ }
348
+ let canonical;
349
+ try { canonical = lstatSync(runtimeRecord.targetEntry, { bigint: true }); }
350
+ catch (error) { if (error?.code !== 'ENOENT') throw error; }
351
+ if (canonical) {
352
+ if (sameRuntimeIdentity(canonical, claimStat)) {
353
+ return finalizeRuntimeRequestRestore(runtimeRecord, claimEntry, runtimeRecord.targetEntry, claimStat);
354
+ }
355
+ }
356
+ if (runtimeProcessAlive(Number(match[1]))) {
357
+ throw Object.assign(new Error('The runtime request is already claimed by a live process'), { code: 'RUNTIME_REQUEST_IN_PROGRESS' });
358
+ }
359
+ if (canonical) {
360
+ throw Object.assign(new Error('A canonical request and interrupted claim coexist'), { code: 'RUNTIME_REQUEST_CLAIM_CONFLICT' });
361
+ }
362
+ if (claimStat.nlink !== 1n) {
363
+ throw Object.assign(new Error('Interrupted runtime claim is not a safe request file'), { code: 'INVALID_RUNTIME_JSON' });
364
+ }
365
+ linkSync(claimEntry, runtimeRecord.targetEntry);
366
+ runRuntimeSecurityTestHook('afterRuntimeClaimRestoreLink', { claimPath: claimEntry, canonicalPath: runtimeRecord.targetEntry });
367
+ return finalizeRuntimeRequestRestore(runtimeRecord, claimEntry, runtimeRecord.targetEntry, claimStat);
368
+ }
369
+
370
+ function writePrivateRuntimeJson(runtimeRecord, payload, { preserveExisting = false } = {}) {
371
+ if (runtimeRecord?.fd === undefined || runtimeRecord?.fd === null
372
+ || !runtimeRecord?.targetEntry || !runtimeRecord?.reference) {
373
+ throw Object.assign(new Error('Runtime response persistence requires a pinned runtime directory'), { code: 'UNSAFE_RUNTIME_PATH' });
374
+ }
375
+ const temporary = join(runtimeRecord.reference, `.pending-${process.pid}-${randomUUID()}.tmp`);
376
+ let descriptor;
377
+ let written;
378
+ let publicationAttempted = false;
379
+ let published = false;
380
+ try {
381
+ assertRuntimeDirectoryRecord(runtimeRecord);
382
+ runRuntimeSecurityTestHook('beforeRuntimeResponseWrite', {
383
+ targetPath: runtimeRecord.target,
384
+ targetEntry: runtimeRecord.targetEntry,
385
+ runtimePath: runtimeRecord.runtimePath,
386
+ });
387
+ assertRuntimeDirectoryRecord(runtimeRecord);
388
+
389
+ let existing;
390
+ try { existing = lstatSync(runtimeRecord.targetEntry, { bigint: true }); }
391
+ catch (error) { if (error?.code !== 'ENOENT') throw error; }
392
+ if (existing) {
393
+ if (existing.isSymbolicLink() || !existing.isFile() || existing.nlink !== 1n) {
394
+ throw Object.assign(new Error('The runtime response target must be a regular single-link file'), { code: 'UNSAFE_RUNTIME_PATH' });
395
+ }
396
+ if (preserveExisting) return;
397
+ }
398
+
399
+ descriptor = openSync(
400
+ temporary,
401
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0),
402
+ 0o600,
403
+ );
404
+ const bytes = Buffer.from(JSON.stringify(payload));
405
+ let offset = 0;
406
+ while (offset < bytes.length) offset += writeSync(descriptor, bytes, offset, bytes.length - offset);
407
+ fsyncSync(descriptor);
408
+ written = fstatSync(descriptor, { bigint: true });
409
+ if (!written.isFile() || written.nlink !== 1n
410
+ || (written.mode & 0o777n) !== 0o600n || written.size !== BigInt(bytes.length)) {
411
+ throw Object.assign(new Error('The temporary runtime response is not a private regular file'), { code: 'UNSAFE_RUNTIME_PATH' });
412
+ }
413
+ assertRuntimeDirectoryRecord(runtimeRecord);
414
+ runRuntimeSecurityTestHook('beforeRuntimeResponseCommit', {
415
+ targetPath: runtimeRecord.target,
416
+ targetEntry: runtimeRecord.targetEntry,
417
+ temporary,
418
+ runtimePath: runtimeRecord.runtimePath,
419
+ });
420
+ assertRuntimeDirectoryRecord(runtimeRecord);
421
+ if (!sameRuntimeFileSnapshot(fstatSync(descriptor, { bigint: true }), written)) {
422
+ throw Object.assign(new Error('The temporary runtime response changed before publication'), { code: 'UNSAFE_RUNTIME_PATH' });
423
+ }
424
+ publicationAttempted = true;
425
+ renameSync(temporary, runtimeRecord.targetEntry);
426
+ const committed = lstatSync(runtimeRecord.targetEntry, { bigint: true });
427
+ if (committed.isSymbolicLink() || !committed.isFile() || !samePublishedRuntimeFile(committed, written)
428
+ || !samePublishedRuntimeFile(fstatSync(descriptor, { bigint: true }), written)) {
429
+ throw Object.assign(new Error('The published runtime response differs from its pinned temporary file'), { code: 'UNSAFE_RUNTIME_PATH' });
430
+ }
431
+ fsyncSync(runtimeRecord.fd);
432
+ assertRuntimeDirectoryRecord(runtimeRecord);
433
+ published = true;
434
+ } finally {
435
+ if (descriptor !== undefined) closeSync(descriptor);
436
+ try { unlinkSync(temporary); } catch { /* preserve primary response result */ }
437
+ if (publicationAttempted && !published && written) {
438
+ try {
439
+ const current = lstatSync(runtimeRecord.targetEntry, { bigint: true });
440
+ if (current.isFile() && !current.isSymbolicLink() && !sameRuntimeIdentity(current, written)) unlinkSync(runtimeRecord.targetEntry);
441
+ } catch { /* preserve primary response result */ }
442
+ }
443
+ }
444
+ }
445
+
446
+ function readClaimedRuntimeJson(claim, maxBytes = RUNTIME_JSON_MAX_BYTES) {
447
+ runRuntimeSecurityTestHook('beforeRuntimeRequestRead', { claimPath: claim.claimPath });
448
+ const chunks = [];
449
+ let total = 0;
450
+ while (true) {
451
+ const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1 - total));
452
+ const bytesRead = readSync(claim.fd, buffer, 0, buffer.length, null);
453
+ if (bytesRead === 0) break;
454
+ total += bytesRead;
455
+ if (total > maxBytes) {
456
+ throw Object.assign(new Error('Runtime JSON exceeds the size limit'), { code: 'INVALID_RUNTIME_JSON' });
457
+ }
458
+ chunks.push(buffer.subarray(0, bytesRead));
459
+ }
460
+ const after = fstatSync(claim.fd, { bigint: true });
461
+ if (!sameRuntimeIdentity(after, claim.identity)
462
+ || after.size !== claim.identity.size
463
+ || after.mtimeNs !== claim.identity.mtimeNs
464
+ || after.ctimeNs !== claim.identity.ctimeNs) {
465
+ throw Object.assign(new Error('Runtime JSON changed while it was being read'), { code: 'INVALID_RUNTIME_JSON' });
466
+ }
467
+ return JSON.parse(Buffer.concat(chunks, total).toString('utf-8'));
468
+ }
469
+
470
+ function claimRuntimeRequest(runtimeRecord) {
471
+ const canonicalPath = runtimeRecord.target;
472
+ const claimName = `.claimed-${process.pid}-${randomUUID()}.json`;
473
+ const claim = {
474
+ canonicalPath,
475
+ canonicalEntry: runtimeRecord.targetEntry,
476
+ claimPath: join(runtimeRecord.reference, claimName),
477
+ claimName,
478
+ runtimeRecord,
479
+ fd: null,
480
+ identity: null,
481
+ };
482
+ try {
483
+ assertRuntimeDirectoryRecord(runtimeRecord);
484
+ recoverInterruptedRuntimeClaim(runtimeRecord);
485
+ runRuntimeSecurityTestHook('beforeRuntimeRequestOpen', { canonicalPath, directory: runtimeRecord.runtimePath });
486
+ claim.fd = openSync(
487
+ claim.canonicalEntry,
488
+ constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0),
489
+ );
490
+ claim.identity = fstatSync(claim.fd, { bigint: true });
491
+ if (!claim.identity.isFile() || claim.identity.nlink !== 1n
492
+ || claim.identity.size > BigInt(RUNTIME_JSON_MAX_BYTES)) {
493
+ throw Object.assign(new Error('Runtime JSON must be a regular file within the size limit'), { code: 'INVALID_RUNTIME_JSON' });
494
+ }
495
+ runRuntimeSecurityTestHook('beforeRuntimeClaimRename', { canonicalPath, claimPath: claim.claimPath });
496
+ assertRuntimeDirectoryRecord(runtimeRecord);
497
+ renameSync(claim.canonicalEntry, claim.claimPath);
498
+ fsyncSync(runtimeRecord.fd);
499
+ const claimedPathStat = lstatSync(claim.claimPath, { bigint: true });
500
+ if (claimedPathStat.isSymbolicLink() || !claimedPathStat.isFile()
501
+ || !sameRuntimeIdentity(claimedPathStat, claim.identity)) {
502
+ throw Object.assign(new Error('Runtime JSON changed while it was being claimed'), { code: 'INVALID_RUNTIME_JSON' });
503
+ }
504
+ assertRuntimeDirectoryRecord(runtimeRecord);
505
+ fchmodSync(claim.fd, 0o600);
506
+ claim.identity = fstatSync(claim.fd, { bigint: true });
507
+ if ((claim.identity.mode & 0o777n) !== 0o600n) {
508
+ throw Object.assign(new Error('Runtime JSON permissions could not be restricted'), { code: 'INVALID_RUNTIME_JSON' });
509
+ }
510
+ return claim;
511
+ } catch (err) {
512
+ if (claim.fd !== null) closeSync(claim.fd);
513
+ err.runtimeClaim = claim;
514
+ throw err;
515
+ }
516
+ }
517
+
518
+ function restoreRuntimeRequest(claim) {
519
+ if (!claim?.claimPath || !claim.identity) return false;
520
+ let current;
521
+ try { current = lstatSync(claim.claimPath, { bigint: true }); }
522
+ catch (error) { if (error?.code === 'ENOENT') return false; throw error; }
523
+ if (current.isSymbolicLink() || !current.isFile() || !sameRuntimeIdentity(current, claim.identity)) return false;
524
+ try {
525
+ linkSync(claim.claimPath, claim.canonicalEntry);
526
+ } catch (error) {
527
+ if (error?.code !== 'EEXIST') throw error;
528
+ const canonical = lstatSync(claim.canonicalEntry, { bigint: true });
529
+ if (!sameRuntimeIdentity(canonical, current)) return false;
530
+ return finalizeRuntimeRequestRestore(claim.runtimeRecord, claim.claimPath, claim.canonicalEntry, claim.identity);
531
+ }
532
+ runRuntimeSecurityTestHook('afterRuntimeRequestRestoreLink', { claimPath: claim.claimPath, canonicalPath: claim.canonicalEntry });
533
+ return finalizeRuntimeRequestRestore(claim.runtimeRecord, claim.claimPath, claim.canonicalEntry, claim.identity);
534
+ }
535
+
536
+ function consumeRuntimeRequest(claim) {
537
+ if (!claim?.claimPath || !claim.identity) return true;
538
+ const current = lstatSync(claim.claimPath, { bigint: true });
539
+ if (current.isSymbolicLink() || !current.isFile() || !sameRuntimeIdentity(current, claim.identity)) return false;
540
+ unlinkSync(claim.claimPath);
541
+ fsyncSync(claim.runtimeRecord.fd);
542
+ assertRuntimeDirectoryRecord(claim.runtimeRecord);
543
+ return true;
544
+ }
545
+
546
+ function scriptExecutionReceiptDigest(receipt) {
547
+ return createHash('sha256').update(JSON.stringify({
548
+ schema_version: receipt.schema_version,
549
+ state: receipt.state,
550
+ request: receipt.request,
551
+ result: receipt.result,
552
+ executed_at: receipt.executed_at,
553
+ execution_id: receipt.execution_id,
554
+ project_scope: receipt.project_scope,
555
+ })).digest('hex');
556
+ }
557
+
558
+ function persistExecutedRuntimeRequest(runtimeRecord, claim, request, result, executedAt) {
559
+ assertRuntimeDirectoryRecord(runtimeRecord);
560
+ const current = lstatSync(claim.claimPath, { bigint: true });
561
+ if (current.isSymbolicLink() || !current.isFile() || !sameRuntimeIdentity(current, claim.identity)) {
562
+ throw Object.assign(new Error('The claimed request changed before its execution receipt was persisted'), { code: 'INVALID_RUNTIME_JSON' });
563
+ }
564
+ const material = {
565
+ schema_version: 1,
566
+ state: 'executed_pending_handoff',
567
+ request: {
568
+ kind: 'script', ref: request.ref, id: request.id || request.task || '', title: request.title || '',
569
+ },
570
+ result,
571
+ executed_at: executedAt,
572
+ execution_id: randomUUID(),
573
+ project_scope: installationAuthorityProjectScope(runtimeRecord.projectDir),
574
+ };
575
+ const proof = signHostAuthorityPayload(material, { domain: SCRIPT_EXECUTION_RECEIPT_DOMAIN });
576
+ const receipt = {
577
+ ...material,
578
+ receipt_digest: scriptExecutionReceiptDigest(material),
579
+ authority_id: proof.authority_id,
580
+ signature: proof.signature,
581
+ };
582
+ const temporary = join(runtimeRecord.reference, `.executed-${process.pid}-${randomUUID()}.tmp`);
583
+ const claimBackup = join(runtimeRecord.reference, `.receipt-backup-${process.pid}-${randomUUID()}.tmp`);
584
+ const originalClaimIdentity = claim.identity;
585
+ let temporaryDescriptor;
586
+ let written;
587
+ let publicationAttempted = false;
588
+ let published = false;
589
+ let backupCreated = false;
590
+ try {
591
+ const bytes = Buffer.from(JSON.stringify(receipt));
592
+ temporaryDescriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
593
+ let offset = 0;
594
+ while (offset < bytes.length) offset += writeSync(temporaryDescriptor, bytes, offset, bytes.length - offset);
595
+ fsyncSync(temporaryDescriptor);
596
+ written = fstatSync(temporaryDescriptor, { bigint: true });
597
+ if (!written.isFile() || written.nlink !== 1n || (written.mode & 0o777n) !== 0o600n
598
+ || written.size !== BigInt(bytes.length)) {
599
+ throw Object.assign(new Error('The temporary execution receipt is not a private regular file'), { code: 'INVALID_RUNTIME_JSON' });
600
+ }
601
+ assertRuntimeDirectoryRecord(runtimeRecord);
602
+ const beforeRename = lstatSync(claim.claimPath, { bigint: true });
603
+ if (beforeRename.isSymbolicLink() || !beforeRename.isFile() || !sameRuntimeIdentity(beforeRename, claim.identity)) {
604
+ throw Object.assign(new Error('The claimed request changed before its execution receipt was committed'), { code: 'INVALID_RUNTIME_JSON' });
605
+ }
606
+ runRuntimeSecurityTestHook('beforeRuntimeReceiptCommit', { claimPath: claim.claimPath, temporary, runtimePath: runtimeRecord.runtimePath });
607
+ assertRuntimeDirectoryRecord(runtimeRecord);
608
+ if (!sameRuntimeFileSnapshot(fstatSync(temporaryDescriptor, { bigint: true }), written)) {
609
+ throw Object.assign(new Error('The temporary execution receipt changed before publication'), { code: 'INVALID_RUNTIME_JSON' });
610
+ }
611
+ linkSync(claim.claimPath, claimBackup);
612
+ backupCreated = true;
613
+ const backup = lstatSync(claimBackup, { bigint: true });
614
+ if (backup.isSymbolicLink() || !backup.isFile() || !sameRuntimeIdentity(backup, originalClaimIdentity)
615
+ || backup.nlink !== 2n) {
616
+ throw Object.assign(new Error('The original request could not be protected during receipt publication'), { code: 'INVALID_RUNTIME_JSON' });
617
+ }
618
+ publicationAttempted = true;
619
+ renameSync(temporary, claim.claimPath);
620
+ fsyncSync(runtimeRecord.fd);
621
+ claim.identity = lstatSync(claim.claimPath, { bigint: true });
622
+ if (!claim.identity.isFile() || claim.identity.isSymbolicLink?.() || !samePublishedRuntimeFile(claim.identity, written)
623
+ || !samePublishedRuntimeFile(fstatSync(temporaryDescriptor, { bigint: true }), written)) {
624
+ throw Object.assign(new Error('The execution receipt is not a private regular file'), { code: 'INVALID_RUNTIME_JSON' });
625
+ }
626
+ assertRuntimeDirectoryRecord(runtimeRecord);
627
+ unlinkSync(claimBackup);
628
+ backupCreated = false;
629
+ fsyncSync(runtimeRecord.fd);
630
+ published = true;
631
+ return receipt;
632
+ } finally {
633
+ if (temporaryDescriptor !== undefined) closeSync(temporaryDescriptor);
634
+ try { unlinkSync(temporary); } catch { /* preserve primary receipt result */ }
635
+ if (publicationAttempted && !published && backupCreated) {
636
+ try {
637
+ const backup = lstatSync(claimBackup, { bigint: true });
638
+ if (!backup.isSymbolicLink() && backup.isFile() && sameRuntimeIdentity(backup, originalClaimIdentity)) {
639
+ renameSync(claimBackup, claim.claimPath);
640
+ claim.identity = lstatSync(claim.claimPath, { bigint: true });
641
+ fsyncSync(runtimeRecord.fd);
642
+ backupCreated = false;
643
+ }
644
+ } catch { /* preserve primary receipt result */ }
645
+ }
646
+ if (backupCreated) {
647
+ try { unlinkSync(claimBackup); } catch { /* preserve primary receipt result */ }
648
+ }
649
+ }
650
+ }
651
+
652
+ function writeDurableScriptHandoff(path, content) {
653
+ const directoryPath = dirname(path);
654
+ const directory = openScriptHandoffDirectoryNoFollow(directoryPath, { create: true });
655
+ const name = basename(path);
656
+ if (resolve(path) !== join(directory.absolute, name)) {
657
+ closeSync(directory.fd);
658
+ throw Object.assign(new Error('The script handoff target is not canonical'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
659
+ }
660
+ const temporary = join(directory.reference, `.${name}.${process.pid}.${randomUUID()}.tmp`);
661
+ const destination = join(directory.reference, name);
662
+ let descriptor;
663
+ let written;
664
+ let publicationAttempted = false;
665
+ let published = false;
666
+ try {
667
+ assertScriptHandoffDirectoryRecord(directory);
668
+ runRuntimeSecurityTestHook('beforeScriptHandoffTempOpen', { handoffPath: path, temporary, directory: directory.absolute });
669
+ assertScriptHandoffDirectoryRecord(directory);
670
+ descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
671
+ const bytes = Buffer.from(content);
672
+ let offset = 0;
673
+ while (offset < bytes.length) offset += writeSync(descriptor, bytes, offset, bytes.length - offset);
674
+ fsyncSync(descriptor);
675
+ written = fstatSync(descriptor, { bigint: true });
676
+ if (!written.isFile() || written.nlink !== 1n || (written.mode & 0o777n) !== 0o600n || written.size !== BigInt(bytes.length)) {
677
+ throw Object.assign(new Error('The temporary script handoff is not a private regular file'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
678
+ }
679
+ assertScriptHandoffDirectoryRecord(directory);
680
+ runRuntimeSecurityTestHook('beforeScriptHandoffCommit', { handoffPath: path, temporary, directory: directory.absolute });
681
+ assertScriptHandoffDirectoryRecord(directory);
682
+ let existing;
683
+ try { existing = lstatSync(destination, { bigint: true }); }
684
+ catch (error) { if (error?.code !== 'ENOENT') throw error; }
685
+ if (existing && (existing.isSymbolicLink() || !existing.isFile() || existing.nlink !== 1n)) {
686
+ throw Object.assign(new Error('The script handoff destination must be a regular single-link file'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
687
+ }
688
+ if (!sameRuntimeFileSnapshot(fstatSync(descriptor, { bigint: true }), written)) {
689
+ throw Object.assign(new Error('The temporary script handoff changed before publication'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
690
+ }
691
+ publicationAttempted = true;
692
+ renameSync(temporary, destination);
693
+ const committed = lstatSync(destination, { bigint: true });
694
+ if (committed.isSymbolicLink() || !committed.isFile() || !samePublishedRuntimeFile(committed, written)
695
+ || !samePublishedRuntimeFile(fstatSync(descriptor, { bigint: true }), written)) {
696
+ throw Object.assign(new Error('The published script handoff differs from its pinned temporary file'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
697
+ }
698
+ fsyncSync(directory.fd);
699
+ assertScriptHandoffDirectoryRecord(directory);
700
+ published = true;
701
+ } finally {
702
+ if (descriptor !== undefined) closeSync(descriptor);
703
+ try { unlinkSync(temporary); } catch { /* preserve primary handoff result */ }
704
+ if (publicationAttempted && !published && written) {
705
+ try {
706
+ const current = lstatSync(destination, { bigint: true });
707
+ if (current.isFile() && !current.isSymbolicLink() && !sameRuntimeIdentity(current, written)) unlinkSync(destination);
708
+ } catch { /* preserve primary handoff result */ }
709
+ }
710
+ closeSync(directory.fd);
711
+ }
712
+ }
713
+
714
+ function isExecutedScriptReceipt(projectDir, value) {
715
+ if (!value || typeof value !== 'object' || Array.isArray(value)
716
+ || value.schema_version !== 1 || value.state !== 'executed_pending_handoff'
717
+ || typeof value.executed_at !== 'string' || typeof value.receipt_digest !== 'string'
718
+ || typeof value.execution_id !== 'string' || !/^[a-f0-9-]{36}$/i.test(value.execution_id)
719
+ || typeof value.authority_id !== 'string' || typeof value.signature !== 'string'
720
+ || !value.result || typeof value.result !== 'object' || Array.isArray(value.result)) return false;
721
+ if (!validateScriptRequestSchema(value.request)) return false;
722
+ try {
723
+ const expectedScope = installationAuthorityProjectScope(projectDir);
724
+ if (JSON.stringify(value.project_scope) !== JSON.stringify(expectedScope)
725
+ || value.receipt_digest !== scriptExecutionReceiptDigest(value)) return false;
726
+ return verifyHostAuthorityPayload({
727
+ schema_version: value.schema_version,
728
+ state: value.state,
729
+ request: value.request,
730
+ result: value.result,
731
+ executed_at: value.executed_at,
732
+ execution_id: value.execution_id,
733
+ project_scope: value.project_scope,
734
+ }, {
735
+ domain: SCRIPT_EXECUTION_RECEIPT_DOMAIN,
736
+ authorityId: value.authority_id,
737
+ signature: value.signature,
738
+ });
739
+ } catch {
740
+ return false;
741
+ }
742
+ }
743
+
744
+ function validateScriptRequestSchema(request) {
745
+ if (!request || typeof request !== 'object' || Array.isArray(request)) return false;
746
+ if (typeof request.kind !== 'string' || typeof request.ref !== 'string') return false;
747
+ if (request.kind === 'script'
748
+ && (typeof request.id !== 'string' || typeof request.title !== 'string')) return false;
749
+ return true;
750
+ }
751
+
752
+ function scriptRequestError(code, error) {
753
+ return { ran: false, status: 'error', code, error, reason: error };
754
+ }
72
755
 
73
756
  // ---------------------------------------------------------------------------
74
757
  // Constants
@@ -87,7 +770,7 @@ const RESUME_MESSAGES = {
87
770
  en: 'Session saved. Type /chati anytime to resume.',
88
771
  pt: 'Sessão salva. Digite /chati para retomar.',
89
772
  es: 'Sesión guardada. Escribe /chati para reanudar.',
90
- fr: 'Session sauvee. Tapez /chati pour reprendre.',
773
+ fr: 'Session sauvée. Tapez /chati pour reprendre.',
91
774
  };
92
775
 
93
776
  function getRuntimeAgentPipeline(projectDir, session = null) {
@@ -573,17 +1256,22 @@ function sessionToPipelineState(session, projectDir) {
573
1256
  */
574
1257
  export function buildSpawnCommand(agent, projectDir, previousAgent, provider, timeout, model = null, strictProvider = false, routing = {}) {
575
1258
  if (!provider) throw new Error('A routed provider is required to build an agent spawn command');
1259
+ const taskId = routing.task_id || `${agent}-task`;
576
1260
  const parts = [
577
- 'node', RUNNER('run-agent.js'),
578
- '--agent', agent,
579
- '--task-id', `${agent}-task`,
580
- '--project-dir', JSON.stringify(resolve(projectDir)),
581
- '--previous-agent', previousAgent || 'none',
582
- '--provider', provider,
583
- ...(model ? ['--model', model] : []),
584
- ...(routing.provider_id ? ['--provider-id', routing.provider_id] : []),
585
- ...(routing.reasoning_configuration ? ['--reasoning-configuration', routing.reasoning_configuration] : []),
586
- ...(routing.catalog_snapshot_ref ? ['--catalog-snapshot-ref', routing.catalog_snapshot_ref] : []),
1261
+ 'node', shellQuote(RUNNER('run-agent.js')),
1262
+ '--agent', shellQuote(agent),
1263
+ '--task-id', shellQuote(taskId),
1264
+ '--project-dir', shellQuote(resolve(projectDir)),
1265
+ '--previous-agent', shellQuote(previousAgent || 'none'),
1266
+ '--provider', shellQuote(provider),
1267
+ ...(model ? ['--model', shellQuote(model)] : []),
1268
+ ...(routing.provider_id ? ['--provider-id', shellQuote(routing.provider_id)] : []),
1269
+ ...(routing.reasoning_configuration ? ['--reasoning-configuration', shellQuote(routing.reasoning_configuration)] : []),
1270
+ ...(routing.catalog_snapshot_ref ? ['--catalog-snapshot-ref', shellQuote(routing.catalog_snapshot_ref)] : []),
1271
+ ...(routing.additional_context_file ? ['--additional-context-file', shellQuote(routing.additional_context_file)] : []),
1272
+ ...(routing.interaction_id ? ['--interaction-id', shellQuote(routing.interaction_id)] : []),
1273
+ ...(routing.reasoning_escalation ? ['--reasoning-escalation'] : []),
1274
+ ...(routing.execution_claim_id ? ['--execution-claim-id', shellQuote(routing.execution_claim_id)] : []),
587
1275
  ...(strictProvider ? ['--strict-provider'] : []),
588
1276
  '--timeout', String(timeout || 600000),
589
1277
  ];
@@ -598,13 +1286,13 @@ export function buildParallelSpawnCommand(agents, projectDir, previousAgent, pro
598
1286
  if (!provider) throw new Error('A routed provider is required to build a parallel spawn command');
599
1287
  const taskIds = agents.map(a => `${a}-task`).join(',');
600
1288
  const parts = [
601
- 'node', RUNNER('run-parallel.js'),
602
- '--agents', agents.join(','),
603
- '--task-ids', taskIds,
604
- '--project-dir', JSON.stringify(resolve(projectDir)),
605
- '--previous-agent', previousAgent || 'none',
606
- '--provider', provider,
607
- ...(model ? ['--model', model] : []),
1289
+ 'node', shellQuote(RUNNER('run-parallel.js')),
1290
+ '--agents', shellQuote(agents.join(',')),
1291
+ '--task-ids', shellQuote(taskIds),
1292
+ '--project-dir', shellQuote(resolve(projectDir)),
1293
+ '--previous-agent', shellQuote(previousAgent || 'none'),
1294
+ '--provider', shellQuote(provider),
1295
+ ...(model ? ['--model', shellQuote(model)] : []),
608
1296
  ...(strictProvider ? ['--strict-provider'] : []),
609
1297
  '--timeout', String(timeout || 900000),
610
1298
  ];
@@ -681,6 +1369,26 @@ export function resolveAgentModel(agent, projectDir) {
681
1369
  return defaults;
682
1370
  }
683
1371
 
1372
+ function resolveAdjudicationModel(projectDir, fallbackBinding = {}) {
1373
+ const artifact = loadRuntimeInstallationV2(projectDir);
1374
+ if (artifact) {
1375
+ const invocation = resolveRuntimeAdjudicationV2({ artifact });
1376
+ return {
1377
+ provider: invocation.harness_id,
1378
+ provider_id: invocation.provider_id,
1379
+ model: invocation.model_pin.model_id,
1380
+ reasoning_configuration: invocation.model_pin.reasoning_configuration,
1381
+ catalog_snapshot_ref: invocation.model_pin.catalog_snapshot_ref,
1382
+ source: 'installation-v2-adjudication',
1383
+ };
1384
+ }
1385
+ return {
1386
+ ...fallbackBinding,
1387
+ reasoning_configuration: fallbackBinding.reasoning_configuration || 'high',
1388
+ source: 'legacy-explicit-adjudication',
1389
+ };
1390
+ }
1391
+
684
1392
  /**
685
1393
  * Estimate context bracket from pipeline progress.
686
1394
  */
@@ -719,7 +1427,7 @@ function sessionLockTargets(projectDir) {
719
1427
  orchestratorPath: existsSync(join(projectDir, frameworkDir, '.adapted', target.provider, 'orchestrator', 'chati.md'))
720
1428
  ? `${frameworkDir}/.adapted/${target.provider}/orchestrator/chati.md`
721
1429
  : `${frameworkDir}/orchestrator/chati.md`,
722
- })).filter(({ provider, path }) => enabled.has(provider) && existsSync(path));
1430
+ })).filter(({ provider }) => enabled.has(provider));
723
1431
  }
724
1432
 
725
1433
  function removeLegacyPauseSection(content) {
@@ -732,64 +1440,291 @@ function removeLegacyPauseSection(content) {
732
1440
  return clean;
733
1441
  }
734
1442
 
735
- function writeSessionLock(projectDir, currentAgent, stateInfo = {}) {
1443
+ function nativeLockFailure(code, message) {
1444
+ throw Object.assign(new Error(message), { code });
1445
+ }
1446
+
1447
+ function nativeLockSnapshot(stat) {
1448
+ return Object.freeze({
1449
+ dev: stat.dev, ino: stat.ino, mode: stat.mode, uid: stat.uid, gid: stat.gid,
1450
+ });
1451
+ }
1452
+
1453
+ function sameNativeLockSnapshot(left, right) {
1454
+ return Boolean(left && right && Object.keys(left).every((key) => left[key] === right[key]));
1455
+ }
1456
+
1457
+ function closeNativeLockStorage(storage) {
1458
+ for (const descriptor of [...(storage?.descriptors ?? [])].reverse()) {
1459
+ try { closeSync(descriptor); } catch { /* preserve primary projection result */ }
1460
+ }
1461
+ }
736
1462
 
737
- // Phase display is authoritatively driven by session.mode on disk. Callers
738
- // previously passed stateInfo.phase derived from transient intermediate
739
- // values (the next agent's expected phase before user approval), causing
740
- // the lock to drift ahead of session.yaml (e.g. show "build" while
741
- // session.mode was still "plan" awaiting approval).
742
- let authoritativePhase = null;
1463
+ function openWorkingDirectoryAnchor(absolute) {
1464
+ if (process.platform !== 'linux') return null;
1465
+ let descriptor;
743
1466
  try {
744
- const { loaded, session } = loadSession(projectDir);
745
- if (loaded && session?.mode) authoritativePhase = session.mode;
746
- } catch { /* non-fatal — fall back to stateInfo */ }
1467
+ descriptor = openSync('.', constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
1468
+ const workingDirectory = realpathSync(`/proc/self/fd/${descriptor}`);
1469
+ const targetRelative = relative(workingDirectory, absolute);
1470
+ if (targetRelative === '..' || targetRelative.startsWith(`..${sep}`) || isAbsolute(targetRelative)) {
1471
+ closeSync(descriptor);
1472
+ return null;
1473
+ }
1474
+ return Object.freeze({ descriptor, segments: targetRelative.split(sep).filter(Boolean) });
1475
+ } catch {
1476
+ if (descriptor !== undefined) closeSync(descriptor);
1477
+ return null;
1478
+ }
1479
+ }
747
1480
 
748
- const stateInner = `## Current State
749
- - **Agent**: ${currentAgent || 'None'}
750
- - **Phase**: ${authoritativePhase || stateInfo.phase || 'discover'}
751
- - **Pipeline**: ${stateInfo.position ?? 0}/${stateInfo.total ?? '?'} (${stateInfo.progress ?? 0}%)
752
- - **Mode**: ${stateInfo.mode || 'interactive'}`;
1481
+ function openNativeLockStorage(projectDir, targetPath) {
1482
+ if (process.platform !== 'linux') {
1483
+ return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock projection requires Linux descriptor-relative storage');
1484
+ }
1485
+ const project = resolve(projectDir);
1486
+ const target = resolve(targetPath);
1487
+ const targetRelative = relative(project, target);
1488
+ if (!targetRelative || targetRelative === '..' || targetRelative.startsWith(`..${sep}`) || isAbsolute(targetRelative)) {
1489
+ return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock target must remain inside the project');
1490
+ }
1491
+ const parent = dirname(target);
1492
+ const root = parse(parent).root;
1493
+ const workingDirectoryAnchor = openWorkingDirectoryAnchor(project);
1494
+ const parentRelative = relative(project, parent);
1495
+ const anchor = workingDirectoryAnchor;
1496
+ const segments = anchor
1497
+ ? [...anchor.segments, ...parentRelative.split(sep).filter(Boolean)]
1498
+ : parent.slice(root.length).split(sep).filter(Boolean);
1499
+ const descriptors = [];
1500
+ const snapshots = [];
1501
+ try {
1502
+ let descriptor;
1503
+ try {
1504
+ descriptor = anchor
1505
+ ? openSync(`/proc/self/fd/${anchor.descriptor}`, constants.O_RDONLY | (constants.O_DIRECTORY || 0))
1506
+ : openSync(root, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
1507
+ } finally {
1508
+ if (anchor) closeSync(anchor.descriptor);
1509
+ }
1510
+ descriptors.push(descriptor);
1511
+ snapshots.push(nativeLockSnapshot(fstatSync(descriptor)));
1512
+ for (const segment of segments) {
1513
+ descriptor = openSync(
1514
+ join(`/proc/self/fd/${descriptor}`, segment),
1515
+ constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0),
1516
+ );
1517
+ const stat = fstatSync(descriptor);
1518
+ if (!stat.isDirectory()) return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock directory chain must contain only real directories');
1519
+ descriptors.push(descriptor);
1520
+ snapshots.push(nativeLockSnapshot(stat));
1521
+ }
1522
+ return Object.freeze({
1523
+ project, target, name: basename(target), parent,
1524
+ descriptor: descriptors.at(-1), descriptors: Object.freeze(descriptors), snapshots: Object.freeze(snapshots),
1525
+ });
1526
+ } catch (error) {
1527
+ closeNativeLockStorage({ descriptors });
1528
+ if (error?.code === 'UNSAFE_NATIVE_LOCK_PATH') throw error;
1529
+ return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', `native lock directory cannot be pinned: ${error.message}`);
1530
+ }
1531
+ }
753
1532
 
754
- for (const target of sessionLockTargets(projectDir)) {
755
- let content = removeLegacyPauseSection(existsSync(target.path) ? readFileSync(target.path, 'utf-8') : '');
756
- const lockInner = `## Session Lock: ACTIVE
1533
+ function assertNativeLockStorage(storage) {
1534
+ let current;
1535
+ try {
1536
+ current = openNativeLockStorage(storage.project, storage.target);
1537
+ if (current.snapshots.length !== storage.snapshots.length
1538
+ || current.snapshots.some((snapshot, index) => !sameNativeLockSnapshot(snapshot, storage.snapshots[index]))) {
1539
+ return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock directory chain changed during projection');
1540
+ }
1541
+ for (const [index, descriptor] of storage.descriptors.entries()) {
1542
+ if (!sameNativeLockSnapshot(nativeLockSnapshot(fstatSync(descriptor)), storage.snapshots[index])) {
1543
+ return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'pinned native lock directory changed during projection');
1544
+ }
1545
+ }
1546
+ } finally { closeNativeLockStorage(current); }
1547
+ return undefined;
1548
+ }
757
1549
 
758
- **Chati.dev session is ACTIVE.** Follow these rules for EVERY message:
1550
+ function readHarnessProjection(storage) {
1551
+ const reference = join(`/proc/self/fd/${storage.descriptor}`, storage.name);
1552
+ let descriptor;
1553
+ try {
1554
+ const pathStat = lstatSync(reference, { bigint: true });
1555
+ if (pathStat.isSymbolicLink()) return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock must not be a symlink');
1556
+ descriptor = openSync(reference, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
1557
+ const before = fstatSync(descriptor, { bigint: true });
1558
+ if (!before.isFile() || before.nlink !== 1n) return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock must be a single-link regular file');
1559
+ if (before.size > NATIVE_LOCK_MAX_BYTES_BIGINT) return nativeLockFailure('NATIVE_LOCK_SIZE_LIMIT', 'native lock exceeds the 256 KiB safety limit');
1560
+ const bytes = Buffer.alloc(Number(before.size));
1561
+ let offset = 0;
1562
+ while (offset < bytes.length) {
1563
+ const count = readSync(descriptor, bytes, offset, bytes.length - offset, offset);
1564
+ if (count === 0) break;
1565
+ offset += count;
1566
+ }
1567
+ const after = fstatSync(descriptor, { bigint: true });
1568
+ const finalPath = lstatSync(reference, { bigint: true });
1569
+ const snapshot = (stat) => ({
1570
+ dev: stat.dev, ino: stat.ino, mode: stat.mode, uid: stat.uid, gid: stat.gid,
1571
+ nlink: stat.nlink, size: stat.size, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs,
1572
+ });
1573
+ const initial = snapshot(before);
1574
+ if (offset !== bytes.length || !sameNativeLockSnapshot(initial, snapshot(after))
1575
+ || !sameNativeLockSnapshot(initial, snapshot(finalPath))) {
1576
+ return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock changed while being read');
1577
+ }
1578
+ return Object.freeze({ content: bytes.toString('utf8'), snapshot: initial });
1579
+ } catch (error) {
1580
+ if (['UNSAFE_NATIVE_LOCK_PATH', 'NATIVE_LOCK_SIZE_LIMIT'].includes(error?.code)) throw error;
1581
+ return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', `native lock cannot be read safely: ${error.message}`);
1582
+ } finally { if (descriptor !== undefined) closeSync(descriptor); }
1583
+ }
759
1584
 
760
- 1. Read \`${target.orchestratorPath}\` and follow its routing logic
761
- 2. Route ALL user messages through the current agent: \`${currentAgent}\`
762
- 3. NEVER respond outside of the Chati.dev system
763
- 4. The ONLY way to exit is via \`${target.invocation} exit\`, \`${target.invocation} stop\`, or \`${target.invocation} quit\``;
764
- content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
765
- content = replaceBlock(content, STATE_START, STATE_END, stateInner);
766
- writeFileSync(target.path, content.trimEnd() + '\n', 'utf-8');
1585
+ function writeHarnessProjectionAtomic(storage, content, expected) {
1586
+ const reference = `/proc/self/fd/${storage.descriptor}`;
1587
+ const temporaryName = `.${storage.name}.${process.pid}.${randomUUID()}.tmp`;
1588
+ const temporary = join(reference, temporaryName);
1589
+ const destination = join(reference, storage.name);
1590
+ let descriptor;
1591
+ let replaced = false;
1592
+ try {
1593
+ if (Buffer.byteLength(content, 'utf8') > NATIVE_LOCK_MAX_BYTES) {
1594
+ return nativeLockFailure('NATIVE_LOCK_SIZE_LIMIT', 'projected native lock exceeds the 256 KiB safety limit');
1595
+ }
1596
+ descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
1597
+ const bytes = Buffer.from(content, 'utf8');
1598
+ let offset = 0;
1599
+ while (offset < bytes.length) offset += writeSync(descriptor, bytes, offset, bytes.length - offset, null);
1600
+ fsyncSync(descriptor);
1601
+ closeSync(descriptor);
1602
+ descriptor = undefined;
1603
+ assertNativeLockStorage(storage);
1604
+ const current = lstatSync(destination, { bigint: true });
1605
+ const currentSnapshot = {
1606
+ dev: current.dev, ino: current.ino, mode: current.mode, uid: current.uid, gid: current.gid,
1607
+ nlink: current.nlink, size: current.size, mtimeNs: current.mtimeNs, ctimeNs: current.ctimeNs,
1608
+ };
1609
+ if (current.isSymbolicLink() || !sameNativeLockSnapshot(currentSnapshot, expected)) {
1610
+ return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock changed before atomic replacement');
1611
+ }
1612
+ renameSync(temporary, destination);
1613
+ replaced = true;
1614
+ fsyncSync(storage.descriptor);
1615
+ } catch (error) {
1616
+ if (replaced) error.nativeLockCommitted = true;
1617
+ throw error;
1618
+ } finally {
1619
+ if (descriptor !== undefined) closeSync(descriptor);
1620
+ rmSync(temporary, { force: true });
767
1621
  }
1622
+ return undefined;
768
1623
  }
769
1624
 
770
- /**
771
- * Remove session lock blocks from every enabled harness's native local file.
772
- */
773
- function removeSessionLock(projectDir, resumeMsg) {
774
- const stateInner = `## Current State
1625
+ function projectSessionLocks(projectDir, { resumeMsg = null } = {}) {
1626
+ return withSessionProjectionLock(projectDir, (session) => {
1627
+ const active = session.session_status !== 'paused' && Boolean(session.current_agent);
1628
+ const pipeline = getRuntimeAgentPipeline(projectDir, session);
1629
+ const completed = Array.isArray(session.completed_agents) ? session.completed_agents.length : 0;
1630
+ const progress = pipeline.length > 0 ? Math.round((completed / pipeline.length) * 100) : 0;
1631
+ const stateInner = active
1632
+ ? `## Current State
1633
+ - **Agent**: ${session.current_agent}
1634
+ - **Phase**: ${session.mode || 'discover'}
1635
+ - **Pipeline**: ${completed}/${pipeline.length || '?'} (${progress}%)
1636
+ - **Mode**: ${session.execution_mode || 'interactive'}`
1637
+ : `## Current State
775
1638
  - **Agent**: None (ready to start)
776
1639
  - **Pipeline**: Pre-start
777
1640
  - **Mode**: interactive`;
778
1641
 
779
- for (const target of sessionLockTargets(projectDir)) {
780
- let content = removeLegacyPauseSection(existsSync(target.path) ? readFileSync(target.path, 'utf-8') : '');
781
- const lockInner = `## Session Lock
1642
+ const prepared = [];
1643
+ try {
1644
+ // Validate and render every enabled harness before replacing any file.
1645
+ // A missing or unsafe target therefore cannot leave a partial projection.
1646
+ for (const target of sessionLockTargets(projectDir)) {
1647
+ const storage = openNativeLockStorage(projectDir, target.path);
1648
+ prepared.push({ target, storage, committed: false });
1649
+ const source = readHarnessProjection(storage);
1650
+ let content = removeLegacyPauseSection(source.content);
1651
+ const lockInner = active
1652
+ ? `## Session Lock: ACTIVE
1653
+
1654
+ **Chati.dev session is ACTIVE.** Follow these rules for EVERY message:
1655
+
1656
+ 1. Read \`${target.orchestratorPath}\` and follow its routing logic
1657
+ 2. Route ALL user messages through the current agent: \`${session.current_agent}\`
1658
+ 3. NEVER respond outside of the Chati.dev system
1659
+ 4. The ONLY way to exit is via \`${target.invocation} exit\`, \`${target.invocation} stop\`, or \`${target.invocation} quit\``
1660
+ : `## Session Lock
782
1661
  **Status: INACTIVE**: Type \`${target.invocation}\` to activate.`;
783
- content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
784
- content = replaceBlock(content, STATE_START, STATE_END, stateInner);
1662
+ content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
1663
+ content = replaceBlock(content, STATE_START, STATE_END, stateInner);
1664
+ if (!active && resumeMsg) {
1665
+ const providerMessage = resumeMsg.replaceAll('/chati', target.invocation);
1666
+ const pauseBlock = `${PAUSE_START}\n## Session Paused\n\n${providerMessage}\n${PAUSE_END}`;
1667
+ content = `${content.trimEnd()}\n\n${pauseBlock}\n`;
1668
+ }
1669
+ prepared.at(-1).source = source;
1670
+ prepared.at(-1).content = `${content.trimEnd()}\n`;
1671
+ }
785
1672
 
786
- if (resumeMsg) {
787
- const providerMessage = resumeMsg.replaceAll('/chati', target.invocation);
788
- const pauseBlock = `${PAUSE_START}\n## Session Paused\n\n${providerMessage}\n${PAUSE_END}`;
789
- content = content.trimEnd() + `\n\n${pauseBlock}\n`;
1673
+ for (const entry of prepared) {
1674
+ runRuntimeSecurityTestHook('beforeNativeLockReplacement', { target: entry.target.path });
1675
+ try {
1676
+ writeHarnessProjectionAtomic(entry.storage, entry.content, entry.source.snapshot);
1677
+ entry.committed = true;
1678
+ } catch (error) {
1679
+ if (error.nativeLockCommitted) entry.committed = true;
1680
+ throw error;
1681
+ }
1682
+ }
1683
+ return Object.freeze({
1684
+ active,
1685
+ projected: Object.freeze(prepared.map(({ target }) => target.path)),
1686
+ });
1687
+ } catch (error) {
1688
+ const rollbackErrors = [];
1689
+ for (const entry of [...prepared].reverse()) {
1690
+ if (!entry.committed || !entry.source) continue;
1691
+ try {
1692
+ const current = readHarnessProjection(entry.storage);
1693
+ if (current.content !== entry.content) {
1694
+ throw Object.assign(
1695
+ new Error('native lock changed after this projection committed'),
1696
+ { code: 'NATIVE_LOCK_ROLLBACK_CONFLICT' },
1697
+ );
1698
+ }
1699
+ writeHarnessProjectionAtomic(entry.storage, entry.source.content, current.snapshot);
1700
+ } catch (rollbackError) {
1701
+ rollbackErrors.push(`${entry.target.path}: ${rollbackError.message}`);
1702
+ }
1703
+ }
1704
+ if (rollbackErrors.length > 0) {
1705
+ throw Object.assign(
1706
+ new Error(`Native lock projection failed (${error.message}) and rollback was incomplete: ${rollbackErrors.join('; ')}`),
1707
+ { code: 'NATIVE_LOCK_ROLLBACK_FAILED', cause: error },
1708
+ );
1709
+ }
1710
+ throw error;
1711
+ } finally {
1712
+ for (const entry of prepared) closeNativeLockStorage(entry.storage);
790
1713
  }
791
- writeFileSync(target.path, content.trimEnd() + '\n', 'utf-8');
792
- }
1714
+ });
1715
+ }
1716
+
1717
+ function writeSessionLock(projectDir, currentAgent, stateInfo = {}) {
1718
+ void currentAgent;
1719
+ void stateInfo;
1720
+ return projectSessionLocks(projectDir);
1721
+ }
1722
+
1723
+ /**
1724
+ * Remove session lock blocks from every enabled harness's native local file.
1725
+ */
1726
+ function removeSessionLock(projectDir, resumeMsg) {
1727
+ return projectSessionLocks(projectDir, { resumeMsg });
793
1728
  }
794
1729
 
795
1730
  // ---------------------------------------------------------------------------
@@ -798,6 +1733,7 @@ function removeSessionLock(projectDir, resumeMsg) {
798
1733
 
799
1734
  async function handleNext(projectDir) {
800
1735
  const result = await _handleNextInner(projectDir);
1736
+ let dispatchMutation = null;
801
1737
 
802
1738
  // The canonical session owns the active task. Native harness lock files are
803
1739
  // projections only, so a CLI switch cannot create a second execution.
@@ -817,28 +1753,50 @@ async function handleNext(projectDir) {
817
1753
  reasoning_configuration: result.model_info.reasoning_configuration || null,
818
1754
  catalog_snapshot_ref: result.model_info.catalog_snapshot_ref || null,
819
1755
  } : null;
820
- const persisted = updateSession(projectDir, {
821
- current_agent: result.agent,
822
- ...(binding ? { current_execution_binding: binding } : {}),
823
- agents: { [result.agent]: { status: 'in_progress' } },
824
- });
825
- if (!persisted.saved) {
826
- return errorResult(`Could not persist canonical agent state: ${persisted.error}`, 'SESSION_WRITE_FAILED');
827
- }
828
- recordEvent(projectDir, EventType.AGENT_ACTIVATED, result.agent, { action: result.action, phase: result.phase });
829
- }
830
-
831
- // Fase 8: attach state-at-a-glance card to every non-error response.
832
- // The card renders as a JSON string field; the terminal / orchestrator
833
- // prompt can console.log it verbatim. Silent on setup/error responses
834
- // so a missing session does not produce a misleading card.
835
- if (result && result.action !== 'setup' && result.action !== 'error') {
836
- try {
837
- const { session } = loadSession(projectDir);
838
- if (session) {
839
- result.state_card = buildStateCard({ session, projectDir });
1756
+ if (result.model_info?.source === 'installation-v2') {
1757
+ const dispatchedAgents = Array.isArray(result.parallel_spawn_commands) && result.parallel_spawn_commands.length > 0
1758
+ ? result.parallel_spawn_commands.map((entry) => ({ agent: entry.agent, binding: entry.agent === result.agent ? binding : null }))
1759
+ : [{ agent: result.agent, binding }];
1760
+ const claimed = claimAgentExecutions(projectDir, dispatchedAgents);
1761
+ if (!claimed.saved) {
1762
+ return errorResult(`Could not persist canonical agent state: ${claimed.error}`, 'SESSION_WRITE_FAILED');
840
1763
  }
841
- } catch { /* non-fatal — card is UX sugar */ }
1764
+ if (!claimed.claimed) {
1765
+ return {
1766
+ action: 'agent_execution_in_flight',
1767
+ agent: claimed.agent || result.agent,
1768
+ code: claimed.code || 'AGENT_EXECUTION_IN_FLIGHT',
1769
+ status_summary: claimed.error || `Agent ${result.agent} is already executing in another CLI.`,
1770
+ };
1771
+ }
1772
+ dispatchMutation = {
1773
+ expectedAgent: result.agent,
1774
+ claims: claimed.claims,
1775
+ previous: claimed.previous,
1776
+ };
1777
+ if (result.spawn_command && claimed.claims[result.agent]) {
1778
+ result.spawn_command += ` --execution-claim-id ${shellQuote(claimed.claims[result.agent])}`;
1779
+ }
1780
+ if (Array.isArray(result.parallel_spawn_commands)) {
1781
+ result.parallel_spawn_commands = result.parallel_spawn_commands.map((entry) => ({
1782
+ ...entry,
1783
+ command: `${entry.command} --execution-claim-id ${shellQuote(claimed.claims[entry.agent])}`,
1784
+ }));
1785
+ }
1786
+ result.execution_claim_id = claimed.claims[result.agent] || null;
1787
+ } else {
1788
+ const persisted = activateAgentExecutionState(projectDir, {
1789
+ agent: result.agent,
1790
+ binding,
1791
+ });
1792
+ if (!persisted.saved) {
1793
+ return errorResult(`Could not persist canonical agent state: ${persisted.error}`, 'SESSION_WRITE_FAILED');
1794
+ }
1795
+ dispatchMutation = {
1796
+ expectedAgent: result.agent,
1797
+ previous: persisted.previous,
1798
+ };
1799
+ }
842
1800
  }
843
1801
 
844
1802
  // Centralized session lock update: applies to ALL return paths.
@@ -846,31 +1804,77 @@ async function handleNext(projectDir) {
846
1804
  if (result && result.agent && result.action !== 'error' && result.action !== 'setup') {
847
1805
  const activating = ['activate_interactive', 'spawn_routed_interactive', 'spawn_autonomous', 'spawn_team', 'spawn_parallel', 'resume_pending_interaction'];
848
1806
  if (activating.includes(result.action)) {
1807
+ let projection;
849
1808
  try {
850
1809
  const runtimePipeline = getRuntimeAgentPipeline(projectDir, result.session);
851
- writeSessionLock(projectDir, result.agent, {
1810
+ projection = writeSessionLock(projectDir, result.agent, {
852
1811
  phase: result.phase || 'discover',
853
1812
  mode: result.session?.execution_mode || 'interactive',
854
1813
  position: result.pipeline_progress?.completedAgents?.length ?? 0,
855
1814
  total: runtimePipeline.length,
856
1815
  progress: result.pipeline_progress?.progress ?? 0,
857
1816
  });
858
- } catch { /* non-fatal */ }
1817
+ } catch (error) {
1818
+ projection = { projected: false, error: error.message, code: error.code };
1819
+ }
1820
+ if (!projection.projected) {
1821
+ if (dispatchMutation) {
1822
+ const rollback = rollbackAgentDispatch(projectDir, dispatchMutation);
1823
+ if (!rollback.saved || !rollback.rolledBack) {
1824
+ return errorResult(
1825
+ `Native session lock projection failed (${projection.error}); canonical rollback also failed: ${rollback.error}`,
1826
+ rollback.code || 'SESSION_PROJECTION_ROLLBACK_FAILED',
1827
+ );
1828
+ }
1829
+ }
1830
+ return errorResult(
1831
+ `Native session lock projection failed: ${projection.error}`,
1832
+ projection.code || 'SESSION_PROJECTION_FAILED',
1833
+ );
1834
+ }
859
1835
  }
860
1836
  } else if (result && result.action === 'complete') {
1837
+ let projection;
861
1838
  try {
862
1839
  const { session } = loadSession(projectDir);
863
1840
  const lang = session?.language || 'en';
864
- removeSessionLock(projectDir, RESUME_MESSAGES[lang] || RESUME_MESSAGES.en);
865
- } catch { /* non-fatal */ }
1841
+ projection = removeSessionLock(projectDir, RESUME_MESSAGES[lang] || RESUME_MESSAGES.en);
1842
+ } catch (error) {
1843
+ projection = { projected: false, error: error.message, code: error.code };
1844
+ }
1845
+ if (!projection.projected) {
1846
+ return errorResult(
1847
+ `Native session lock projection failed: ${projection.error}`,
1848
+ projection.code || 'SESSION_PROJECTION_FAILED',
1849
+ );
1850
+ }
1851
+ }
1852
+
1853
+ if (dispatchMutation) {
1854
+ recordEvent(projectDir, EventType.AGENT_ACTIVATED, result.agent, { action: result.action, phase: result.phase });
1855
+ }
1856
+
1857
+ // Fase 8: attach state-at-a-glance card after canonical state and every
1858
+ // enabled native projection have committed successfully.
1859
+ if (result && result.action !== 'setup' && result.action !== 'error') {
1860
+ try {
1861
+ const { session } = loadSession(projectDir);
1862
+ if (session) result.state_card = buildStateCard({ session, projectDir });
1863
+ } catch { /* non-fatal — card is UX sugar */ }
866
1864
  }
867
1865
 
868
1866
  return result;
869
1867
  }
870
1868
 
871
1869
  async function _handleNextInner(projectDir) {
872
- const { loaded, session } = loadSession(projectDir);
1870
+ const { loaded, session, error } = loadSession(projectDir);
873
1871
  if (!loaded || !session) {
1872
+ if (!['Session file not found', 'Session directory not found'].includes(error)) {
1873
+ const code = error?.includes('Timed out waiting for session write lock')
1874
+ ? 'SESSION_WRITE_FAILED'
1875
+ : 'INVALID_SESSION_PATH';
1876
+ return errorResult(`Cannot load session safely: ${error}`, code);
1877
+ }
874
1878
  return { action: 'setup', status_summary: 'No session found. Project needs initialization.' };
875
1879
  }
876
1880
 
@@ -883,9 +1887,118 @@ async function _handleNextInner(projectDir) {
883
1887
  const runtimePipeline = getRuntimeAgentPipeline(projectDir, session);
884
1888
 
885
1889
  const pending = session.pending_interaction;
1890
+ if (pending?.status === 'relay_in_flight') {
1891
+ const relayStartedAt = Date.parse(pending.relay_started_at || '');
1892
+ const explicitLeaseExpiry = Date.parse(pending.relay_lease_expires_at || '');
1893
+ const relayLeaseExpired = Number.isFinite(explicitLeaseExpiry)
1894
+ ? Date.now() >= explicitLeaseExpiry
1895
+ : Number.isFinite(relayStartedAt) && Date.now() - relayStartedAt > 25 * 60 * 1000;
1896
+ if (relayLeaseExpired) {
1897
+ const recovered = releasePendingInteraction(projectDir, {
1898
+ interactionId: pending.interaction_id || null,
1899
+ claimId: pending.relay_claim_id || null,
1900
+ });
1901
+ if (!recovered.saved || (!recovered.released && !recovered.settled)) {
1902
+ return errorResult(`Could not recover stale pending relay: ${recovered.error || 'ownership mismatch'}`, 'SESSION_WRITE_FAILED');
1903
+ }
1904
+ return _handleNextInner(projectDir);
1905
+ }
1906
+ return {
1907
+ action: 'pending_interaction_in_flight',
1908
+ agent: pending.agent,
1909
+ interaction_id: pending.interaction_id || null,
1910
+ relay_cycles: pending.relay_cycles || 1,
1911
+ wait_for_user: false,
1912
+ spawn_command: null,
1913
+ deferred_spawn_command: null,
1914
+ session: { language: session.language, active_harness: session.active_harness },
1915
+ status_summary: `The response for ${pending.agent} is already being relayed by another process.`,
1916
+ };
1917
+ }
886
1918
  if (pending?.status === 'waiting_for_user' && pending.agent && pending.question) {
1919
+ if ((pending.relay_cycles || 1) > 3) {
1920
+ const originalBinding = pending.execution_binding || session.current_execution_binding || resolveAgentModel(pending.agent, projectDir);
1921
+ const identity = ensurePendingInteractionIdentity(projectDir, originalBinding);
1922
+ if (!identity.saved || !identity.interactionId) {
1923
+ return errorResult(`Could not identify pending interaction: ${identity.error || 'unknown error'}`, 'SESSION_WRITE_FAILED');
1924
+ }
1925
+ const adjudicationBinding = resolveAdjudicationModel(projectDir, originalBinding);
1926
+ let contextFile;
1927
+ let contextRuntime;
1928
+ try {
1929
+ contextRuntime = captureRuntimePath(projectDir, `pending-adjudication-${identity.interactionId}.json`);
1930
+ writePrivateRuntimeJson(contextRuntime, {
1931
+ response: `Reasoning escalation after three unresolved relays. Review the pending interaction and its project context. Resolve the issue technically when safe. Ask the user only if authority, product intent, or unavailable information is strictly required. Pending question: ${pending.question}`,
1932
+ });
1933
+ contextFile = contextRuntime.target;
1934
+ } catch (error) {
1935
+ return errorResult(error.message, error.code || 'UNSAFE_RUNTIME_PATH');
1936
+ } finally {
1937
+ closeRuntimeRecord(contextRuntime);
1938
+ }
1939
+ const spawnCommand = buildSpawnCommand(
1940
+ pending.agent,
1941
+ projectDir,
1942
+ session.last_handoff || 'none',
1943
+ adjudicationBinding.provider,
1944
+ 600000,
1945
+ adjudicationBinding.model,
1946
+ true,
1947
+ {
1948
+ ...adjudicationBinding,
1949
+ task_id: pending.task_id,
1950
+ interaction_id: identity.interactionId,
1951
+ additional_context_file: contextFile,
1952
+ reasoning_escalation: true,
1953
+ },
1954
+ );
1955
+ return {
1956
+ action: 'pending_interaction_escalation_required',
1957
+ agent: pending.agent,
1958
+ question: pending.question,
1959
+ interaction_id: identity.interactionId,
1960
+ relay_cycles: pending.relay_cycles || 3,
1961
+ wait_for_user: false,
1962
+ spawn_command: spawnCommand,
1963
+ deferred_spawn_command: null,
1964
+ model_info: adjudicationBinding,
1965
+ escalation_required: true,
1966
+ session: { language: session.language, active_harness: session.active_harness },
1967
+ status_summary: 'Interactive relay limit reached. Apply the configured reasoning escalation.',
1968
+ };
1969
+ }
887
1970
  const binding = pending.execution_binding || session.current_execution_binding || resolveAgentModel(pending.agent, projectDir);
1971
+ const identity = ensurePendingInteractionIdentity(projectDir, binding);
1972
+ if (!identity.saved || !identity.interactionId) {
1973
+ return errorResult(`Could not identify pending interaction: ${identity.error || 'unknown error'}`, 'SESSION_WRITE_FAILED');
1974
+ }
1975
+ let responseFile;
1976
+ let responseRuntime;
1977
+ try {
1978
+ responseRuntime = captureRuntimePath(projectDir, `pending-response-${identity.interactionId}.json`);
1979
+ writePrivateRuntimeJson(responseRuntime, { response: '' }, { preserveExisting: true });
1980
+ responseFile = responseRuntime.target;
1981
+ } catch (error) {
1982
+ return errorResult(error.message, error.code || 'UNSAFE_RUNTIME_PATH');
1983
+ } finally {
1984
+ closeRuntimeRecord(responseRuntime);
1985
+ }
888
1986
  const agentDef = getAgentDefinition(pending.agent);
1987
+ const deferredSpawnCommand = buildSpawnCommand(
1988
+ pending.agent,
1989
+ projectDir,
1990
+ session.last_handoff || 'none',
1991
+ binding.provider,
1992
+ 600000,
1993
+ binding.model,
1994
+ true,
1995
+ {
1996
+ ...binding,
1997
+ task_id: pending.task_id,
1998
+ interaction_id: identity.interactionId,
1999
+ additional_context_file: responseFile,
2000
+ },
2001
+ );
889
2002
  return {
890
2003
  action: 'resume_pending_interaction',
891
2004
  agent: pending.agent,
@@ -893,16 +2006,13 @@ async function _handleNextInner(projectDir) {
893
2006
  phase: agentDef?.phase || session.mode,
894
2007
  question: pending.question,
895
2008
  relay_cycles: pending.relay_cycles || 1,
896
- spawn_command: buildSpawnCommand(
897
- pending.agent,
898
- projectDir,
899
- session.last_handoff || 'none',
900
- binding.provider,
901
- 600000,
902
- binding.model,
903
- Boolean(installationV2),
904
- binding,
905
- ),
2009
+ wait_for_user: true,
2010
+ requires_user_response: true,
2011
+ interaction_id: identity.interactionId,
2012
+ response_file: responseFile,
2013
+ response_file_format: { response: 'exact user response as a JSON string' },
2014
+ spawn_command: null,
2015
+ deferred_spawn_command: deferredSpawnCommand,
906
2016
  model_info: binding,
907
2017
  redispatched: false,
908
2018
  pipeline_progress: getPipelineProgress(sessionToPipelineState(session, projectDir)),
@@ -962,6 +2072,16 @@ async function _handleNextInner(projectDir) {
962
2072
  if (currentAgent && !completedAgents.includes(currentAgent)) {
963
2073
  const agent = currentAgent;
964
2074
  const agentStatus = session.agents?.[agent]?.status;
2075
+ if (agentStatus === 'awaiting_advance') {
2076
+ return {
2077
+ action: 'awaiting_agent_advance',
2078
+ agent,
2079
+ code: 'AGENT_AWAITING_ADVANCE',
2080
+ pipeline_progress: getPipelineProgress(pipelineState),
2081
+ session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode },
2082
+ status_summary: `${agent} finished its routed process and is waiting for canonical advance.`,
2083
+ };
2084
+ }
965
2085
  if (agentStatus === 'in_progress') {
966
2086
  const agentFile = getAgentFile(agent, projectDir) || null;
967
2087
  const agentDef = getAgentDefinition(agent);
@@ -1069,7 +2189,56 @@ async function _handleNextInner(projectDir) {
1069
2189
  };
1070
2190
  }
1071
2191
 
1072
- const nextAgent = nextInfo.next;
2192
+ let nextAgent = nextInfo.next;
2193
+
2194
+ // Parallel groups can be resumed after only a subset has completed. Never
2195
+ // send completed members back through the claim layer, and do not let a live
2196
+ // claim for one member prevent an unclaimed sibling from being dispatched.
2197
+ // A successful member waiting for canonical advance still takes precedence,
2198
+ // because its result must be committed before the group can progress.
2199
+ if (installationV2 && nextInfo.isParallel && nextInfo.group.length > 1) {
2200
+ const incompleteGroup = nextInfo.group.filter((agent) => (
2201
+ !completedAgents.includes(agent) && session.agents?.[agent]?.status !== 'completed'
2202
+ ));
2203
+ const awaitingAdvance = incompleteGroup.find((agent) => (
2204
+ session.agents?.[agent]?.status === 'awaiting_advance'
2205
+ || session.agents?.[agent]?.execution_claim_state === 'awaiting_advance'
2206
+ ));
2207
+ if (awaitingAdvance) {
2208
+ return {
2209
+ action: 'awaiting_agent_advance',
2210
+ agent: awaitingAdvance,
2211
+ code: 'AGENT_AWAITING_ADVANCE',
2212
+ pipeline_progress: getPipelineProgress(pipelineState),
2213
+ session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode },
2214
+ status_summary: `${awaitingAdvance} finished its routed process and is waiting for canonical advance.`,
2215
+ };
2216
+ }
2217
+ const now = Date.now();
2218
+ const liveAgents = incompleteGroup.filter((agent) => {
2219
+ const state = session.agents?.[agent];
2220
+ const expiry = Date.parse(state?.execution_claim_expires_at || '');
2221
+ return state?.status === 'in_progress'
2222
+ && Boolean(state.execution_claim_id)
2223
+ && Number.isFinite(expiry)
2224
+ && expiry > now;
2225
+ });
2226
+ const dispatchableGroup = incompleteGroup.filter((agent) => !liveAgents.includes(agent));
2227
+ if (dispatchableGroup.length === 0 && liveAgents.length > 0) {
2228
+ return {
2229
+ action: 'agent_execution_in_flight',
2230
+ agent: liveAgents[0],
2231
+ code: 'AGENT_EXECUTION_IN_FLIGHT',
2232
+ pipeline_progress: getPipelineProgress(pipelineState),
2233
+ session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode },
2234
+ status_summary: `The remaining planning agents are already executing: ${liveAgents.join(', ')}.`,
2235
+ };
2236
+ }
2237
+ if (dispatchableGroup.length > 0) {
2238
+ nextAgent = dispatchableGroup[0];
2239
+ nextInfo = { ...nextInfo, next: nextAgent, group: dispatchableGroup };
2240
+ }
2241
+ }
1073
2242
  const agentFile = getAgentFile(nextAgent, projectDir) || null;
1074
2243
  const agentDef = getAgentDefinition(nextAgent);
1075
2244
  const isInteractive = INTERACTIVE_AGENTS.includes(nextAgent);
@@ -1465,6 +2634,50 @@ async function handleAdvance(projectDir, args) {
1465
2634
  const { loaded, session } = loadSession(projectDir);
1466
2635
  if (!loaded || !session) return errorResult('No session found', 'NO_SESSION');
1467
2636
 
2637
+ let runtimeV2;
2638
+ try { runtimeV2 = loadRuntimeInstallationV2(projectDir); } catch (error) {
2639
+ return errorResult(error.message, error.code || 'INVALID_INSTALLATION_ARTIFACT');
2640
+ }
2641
+ if (runtimeV2) {
2642
+ if (!V2_PLANNING_AGENTS.includes(agent)) {
2643
+ return errorResult(
2644
+ `Agent ${agent} is outside the v2 planning runtime. Implementation and release must advance through a sealed RAIL handoff.`,
2645
+ 'V2_RAIL_EXECUTION_REQUIRED',
2646
+ );
2647
+ }
2648
+ if (!session.agents || !Object.hasOwn(session.agents, agent)) {
2649
+ return errorResult(`Agent ${agent} is not part of the canonical v2 session.`, 'INVALID_AGENT_ADVANCE');
2650
+ }
2651
+ try {
2652
+ assertV2PlanningPredecessors(session, agent);
2653
+ } catch (error) {
2654
+ return errorResult(error.message, error.code || 'V2_PLANNING_PREDECESSORS_INCOMPLETE');
2655
+ }
2656
+ }
2657
+ const agentState = session.agents && session.agents[agent];
2658
+
2659
+ // Legacy preview and scaffold decisions mutate the v1 Build/Deploy state
2660
+ // machine. Runtime v2 ends planning at a sealed RAIL handoff, so accepting
2661
+ // any of those decisions here would create a second execution authority.
2662
+ if (runtimeV2 && decision) {
2663
+ return errorResult(
2664
+ `Decision ${decision} belongs to the legacy Build/Deploy pipeline. Runtime v2 must continue through RAIL.`,
2665
+ 'V2_RAIL_DECISION_REQUIRED',
2666
+ );
2667
+ }
2668
+
2669
+ if (runtimeV2 && agent === 'tasks' && status === 'completed' && score < 95) {
2670
+ return {
2671
+ advanced: false,
2672
+ blocked: true,
2673
+ reason: 'V2_TASKS_SCORE_BELOW_THRESHOLD',
2674
+ message: `Tasks scored ${score}. A score of at least 95 is required before the planning handoff can be sealed for RAIL.`,
2675
+ score,
2676
+ required_score: 95,
2677
+ agent_state: agentState?.status || null,
2678
+ };
2679
+ }
2680
+
1468
2681
  // Fase 10 scaffold decision — route apply|skip|dryrun to the scaffold
1469
2682
  // dispatcher BEFORE the alreadyCompleted guard so the user can resolve a
1470
2683
  // pending scaffold decision after qa-planning is marked complete.
@@ -1476,7 +2689,6 @@ async function handleAdvance(projectDir, args) {
1476
2689
  // Idempotency guard: if this agent is already marked completed, return no-op.
1477
2690
  // Prevents duplicate advance calls (hook + user-approval path) from double-
1478
2691
  // incrementing pipeline_position or corrupting completed_agents.
1479
- const agentState = session.agents && session.agents[agent];
1480
2692
  const alreadyCompleted =
1481
2693
  agentState && agentState.status === 'completed' &&
1482
2694
  Array.isArray(session.completed_agents) && session.completed_agents.includes(agent);
@@ -1492,6 +2704,13 @@ async function handleAdvance(projectDir, args) {
1492
2704
  };
1493
2705
  }
1494
2706
 
2707
+ if (runtimeV2 && status === 'completed' && agentState?.status !== 'awaiting_advance') {
2708
+ return errorResult(
2709
+ `Agent ${agent} has no successful routed result awaiting canonical advance.`,
2710
+ 'V2_AGENT_RESULT_NOT_HELD',
2711
+ );
2712
+ }
2713
+
1495
2714
  // Handle user_preview decisions
1496
2715
  if (decision) {
1497
2716
  const pipelineState = sessionToPipelineState(session, projectDir);
@@ -2298,24 +3517,28 @@ async function handleInit(projectDir, args) {
2298
3517
  } catch { /* expected: license file may not exist */ }
2299
3518
 
2300
3519
  const type = args.type || 'greenfield';
2301
- const language = args.language || 'en';
3520
+ let language = args.language || null;
2302
3521
  const workflow = args.workflow || 'full';
3522
+ const runtimeV2 = Boolean(loadRuntimeInstallationV2(projectDir));
2303
3523
 
2304
- // Preserve fields from session.yaml created by installer (project name, ides, mcps)
3524
+ // Preserve fields from the canonical session created by the installer.
2305
3525
  let preservedName = args.name || '';
2306
3526
  let preservedIdes = [];
2307
3527
  let preservedMcps = [];
2308
3528
  let preservedProviders = [];
2309
- try {
2310
- const existingPath = join(projectDir, '.chati', 'session.yaml');
2311
- if (existsSync(existingPath)) {
2312
- const existing = yaml.load(readFileSync(existingPath, 'utf-8')) || {};
2313
- preservedName = preservedName || existing.project?.name || '';
2314
- preservedIdes = existing.ides || [];
2315
- preservedMcps = existing.mcps || [];
2316
- preservedProviders = existing.providers_enabled || [];
2317
- }
2318
- } catch { /* expected: session may not exist */ }
3529
+ const existingSession = loadSession(projectDir);
3530
+ if (existingSession.loaded) {
3531
+ const existing = existingSession.session || {};
3532
+ language = language || existing.language || 'en';
3533
+ preservedName = preservedName || existing.project?.name || '';
3534
+ preservedIdes = existing.ides || [];
3535
+ preservedMcps = existing.mcps || [];
3536
+ preservedProviders = existing.providers_enabled || [];
3537
+ } else if (existsSync(join(projectDir, '.chati', 'session.yaml'))
3538
+ || !['Session file not found', 'Session directory not found'].includes(existingSession.error)) {
3539
+ return errorResult(`Cannot read the existing session safely: ${existingSession.error}`, 'INVALID_SESSION_PATH');
3540
+ }
3541
+ language = language || 'en';
2319
3542
  if (preservedProviders.length === 0) {
2320
3543
  preservedProviders = parseProviderConfig(projectDir).enabled;
2321
3544
  }
@@ -2335,23 +3558,21 @@ async function handleInit(projectDir, args) {
2335
3558
  const p = initPipeline({ isGreenfield, targetDir: projectDir });
2336
3559
  pipelineAgents = Object.keys(p.agents);
2337
3560
  }
3561
+ if (runtimeV2) pipelineAgents = pipelineAgents.filter(agent => V2_PLANNING_AGENTS.includes(agent));
2338
3562
 
2339
3563
  // RESUME GUARD: if a session already exists with completed agents or a non-discover mode,
2340
3564
  // this is a RESUME — preserve all state and only update workflow/lock. Do NOT reset.
2341
- const sessionPath = join(projectDir, '.chati', 'session.yaml');
2342
- const isResume = existsSync(sessionPath) && (() => {
2343
- try {
2344
- const s = yaml.load(readFileSync(sessionPath, 'utf-8')) || {};
2345
- const hasProgress = (s.completed_agents && s.completed_agents.length > 0)
2346
- || (s.mode && s.mode !== 'discover')
2347
- || (s.current_agent && s.current_agent !== '');
2348
- return hasProgress;
2349
- } catch { return false; }
3565
+ let isResume = existingSession.loaded && (() => {
3566
+ const s = existingSession.session || {};
3567
+ return (s.completed_agents && s.completed_agents.length > 0)
3568
+ || (s.mode && s.mode !== 'discover')
3569
+ || (s.current_agent && s.current_agent !== '');
2350
3570
  })();
2351
3571
 
2352
3572
  if (isResume) {
2353
3573
  // Resume path — keep session.yaml intact, just refresh workflow if changed
2354
- await updateSession(projectDir, { workflow });
3574
+ const updated = updateSession(projectDir, { workflow });
3575
+ if (!updated.saved) return errorResult(`Failed to resume session: ${updated.error}`, 'SESSION_WRITE_FAILED');
2355
3576
  recordEvent(projectDir, EventType.SESSION_STARTED, 'orchestrator', { resume: true, workflow });
2356
3577
  } else {
2357
3578
  // Fresh init — create new session
@@ -2363,34 +3584,73 @@ async function handleInit(projectDir, args) {
2363
3584
  ides: preservedIdes,
2364
3585
  mcps: preservedMcps,
2365
3586
  providersEnabled: preservedProviders,
3587
+ runtimeVersion: runtimeV2 ? 2 : 1,
2366
3588
  });
2367
3589
 
2368
3590
  if (!result.created) {
2369
3591
  return errorResult(`Failed to initialize session: ${result.error}`, 'INIT_FAILED');
2370
3592
  }
2371
-
2372
- await updateSession(projectDir, { workflow });
2373
- clearTimeline(projectDir);
2374
- recordEvent(projectDir, EventType.SESSION_STARTED, 'orchestrator', { projectType: type, workflow, language });
3593
+ if (result.preserved) {
3594
+ isResume = true;
3595
+ const updated = updateSession(projectDir, { workflow });
3596
+ if (!updated.saved) return errorResult(`Failed to resume preserved session: ${updated.error}`, 'SESSION_WRITE_FAILED');
3597
+ recordEvent(projectDir, EventType.SESSION_STARTED, 'orchestrator', { resume: true, workflow, concurrent_init_preserved: true });
3598
+ } else {
3599
+ const updated = updateSession(projectDir, { workflow });
3600
+ if (!updated.saved) return errorResult(`Failed to initialize workflow state: ${updated.error}`, 'SESSION_WRITE_FAILED');
3601
+ clearTimeline(projectDir);
3602
+ recordEvent(projectDir, EventType.SESSION_STARTED, 'orchestrator', { projectType: type, workflow, language });
3603
+ }
2375
3604
  }
2376
3605
 
2377
3606
  // Determine first/active agent — on resume, use current_agent from session
2378
3607
  let firstAgent;
3608
+ let firstAgentMutation = null;
2379
3609
  if (isResume) {
2380
- const s = yaml.load(readFileSync(sessionPath, 'utf-8')) || {};
3610
+ const resumed = loadSession(projectDir);
3611
+ if (!resumed.loaded) return errorResult(`Failed to load resumed session: ${resumed.error}`, 'INVALID_SESSION_PATH');
3612
+ const s = resumed.session || {};
2381
3613
  firstAgent = s.current_agent || (isGreenfield ? 'greenfield-wu' : 'brownfield-wu');
2382
3614
  } else {
2383
3615
  firstAgent = isGreenfield ? 'greenfield-wu' : 'brownfield-wu';
2384
- await updateSession(projectDir, { current_agent: firstAgent });
3616
+ const updated = activateAgentExecutionState(projectDir, {
3617
+ agent: firstAgent,
3618
+ markInProgress: false,
3619
+ });
3620
+ if (!updated.saved) return errorResult(`Failed to select the first agent: ${updated.error}`, 'SESSION_WRITE_FAILED');
3621
+ firstAgentMutation = {
3622
+ expectedAgent: firstAgent,
3623
+ previous: updated.previous,
3624
+ };
2385
3625
  }
2386
3626
  const firstAgentFile = getAgentFile(firstAgent, projectDir) || null;
2387
3627
 
2388
3628
  // Write session lock
2389
3629
  try {
2390
- writeSessionLock(projectDir, firstAgent);
3630
+ const projection = writeSessionLock(projectDir, firstAgent);
3631
+ if (!projection.projected) {
3632
+ if (firstAgentMutation) {
3633
+ const rollback = rollbackAgentDispatch(projectDir, firstAgentMutation);
3634
+ if (!rollback.saved || !rollback.rolledBack) {
3635
+ return errorResult(
3636
+ `Native session lock projection failed (${projection.error}); canonical rollback also failed: ${rollback.error}`,
3637
+ rollback.code || 'SESSION_PROJECTION_ROLLBACK_FAILED',
3638
+ );
3639
+ }
3640
+ }
3641
+ return errorResult(`Native session lock projection failed: ${projection.error}`, projection.code || 'SESSION_PROJECTION_FAILED');
3642
+ }
2391
3643
  } catch (err) {
2392
- // Non-blocking — lock is nice-to-have, not critical
2393
- process.stderr.write(`[chati] cli lock-write: ${err.message}\n`);
3644
+ if (firstAgentMutation) {
3645
+ const rollback = rollbackAgentDispatch(projectDir, firstAgentMutation);
3646
+ if (!rollback.saved || !rollback.rolledBack) {
3647
+ return errorResult(
3648
+ `Native session lock projection failed (${err.message}); canonical rollback also failed: ${rollback.error}`,
3649
+ rollback.code || 'SESSION_PROJECTION_ROLLBACK_FAILED',
3650
+ );
3651
+ }
3652
+ }
3653
+ return errorResult(`Native session lock projection failed: ${err.message}`, err.code || 'SESSION_PROJECTION_FAILED');
2394
3654
  }
2395
3655
 
2396
3656
  return {
@@ -2488,6 +3748,7 @@ async function handleStatus(projectDir) {
2488
3748
  pipeline: progress,
2489
3749
  handoff_history: handoffHistory,
2490
3750
  deviation_count: (session.deviations || []).length,
3751
+ decision_trail: session.decision_trail || [],
2491
3752
  backlog: session.backlog || [],
2492
3753
  agents: Object.fromEntries(Object.entries(session.agents || {}).filter(([name]) => runtimeAgentNames.has(name))),
2493
3754
  agent_models: agentModels,
@@ -2511,15 +3772,47 @@ async function handleStatus(projectDir) {
2511
3772
 
2512
3773
  async function handleActivateHarness(projectDir, args) {
2513
3774
  const harness = args.harness;
2514
- if (!['claude', 'codex', 'grok'].includes(harness)) {
2515
- return errorResult('Harness must be claude, codex, or grok.', 'INVALID_HARNESS');
3775
+ const installation = loadRuntimeInstallationV2(projectDir);
3776
+ const supported = installation
3777
+ ? [...new Set(installation.installation.enabled_providers.map(({ harness_id }) => harness_id))]
3778
+ : parseProviderConfig(projectDir).enabled;
3779
+ if (!supported.includes(harness)) {
3780
+ const installed = supported.length > 0 ? supported.join(', ') : 'none';
3781
+ return errorResult(`Harness is not enabled by this installation. Enabled harnesses: ${installed}.`, 'INVALID_HARNESS');
3782
+ }
3783
+ const saved = transitionSessionLifecycle(projectDir, { activeHarness: harness, sessionStatus: 'active' });
3784
+ if (!saved.saved) {
3785
+ const code = ['Session file not found', 'Session directory not found'].includes(saved.error)
3786
+ ? 'NO_SESSION'
3787
+ : 'SESSION_WRITE_FAILED';
3788
+ return errorResult(code === 'NO_SESSION' ? 'No session found' : saved.error, code);
3789
+ }
3790
+ const session = saved.session;
3791
+ const previous = saved.previous.active_harness;
3792
+ const wasActive = saved.previous.session_status === 'active';
3793
+ let projection;
3794
+ try {
3795
+ projection = writeSessionLock(projectDir, session.current_agent || '', {
3796
+ phase: session.mode,
3797
+ mode: session.execution_mode || 'interactive',
3798
+ });
3799
+ } catch (error) {
3800
+ projection = { projected: false, error: error.message, code: error.code };
3801
+ }
3802
+ if (!projection.projected) {
3803
+ const rollback = rollbackSessionLifecycle(projectDir, {
3804
+ expectedActiveHarness: harness,
3805
+ expectedSessionStatus: 'active',
3806
+ previous: saved.previous,
3807
+ });
3808
+ if (!rollback.saved || !rollback.rolledBack) {
3809
+ return errorResult(
3810
+ `Native session lock projection failed (${projection.error}); canonical rollback also failed: ${rollback.error}`,
3811
+ rollback.code || 'SESSION_PROJECTION_ROLLBACK_FAILED',
3812
+ );
3813
+ }
3814
+ return errorResult(projection.error, projection.code || 'SESSION_PROJECTION_FAILED');
2516
3815
  }
2517
- const { loaded, session } = loadSession(projectDir);
2518
- if (!loaded || !session) return errorResult('No session found', 'NO_SESSION');
2519
- const previous = session.active_harness || null;
2520
- const wasActive = session.session_status === 'active';
2521
- const saved = updateSession(projectDir, { active_harness: harness, session_status: 'active' });
2522
- if (!saved.saved) return errorResult(saved.error, 'SESSION_WRITE_FAILED');
2523
3816
  if (previous !== harness) {
2524
3817
  recordEvent(projectDir, EventType.HARNESS_ACTIVATED, 'orchestrator', { harness, previous_harness: previous });
2525
3818
  }
@@ -2605,14 +3898,18 @@ async function handleDeviation(projectDir, args) {
2605
3898
  }
2606
3899
 
2607
3900
  async function handleExit(projectDir, args) {
2608
- const { loaded, session } = loadSession(projectDir);
2609
- if (!loaded || !session) {
3901
+ const persisted = transitionSessionLifecycle(projectDir, { sessionStatus: 'paused' });
3902
+ if (!persisted.saved && ['Session file not found', 'Session directory not found'].includes(persisted.error)) {
2610
3903
  return { exited: true, session_saved: false, lock_removed: false, resume_message: null };
2611
3904
  }
3905
+ if (!persisted.saved) {
3906
+ return { exited: false, session_saved: false, lock_removed: false, resume_message: null, error: persisted.error };
3907
+ }
3908
+ const session = persisted.previous;
2612
3909
 
2613
3910
  // Save current state
2614
3911
  recordEvent(projectDir, EventType.SESSION_ENDED, session.current_agent || 'orchestrator', { mode: session.mode, harness: session.active_harness || null });
2615
- await updateSession(projectDir, { session_status: 'paused' });
3912
+ releaseReservedAgentExecutions(projectDir);
2616
3913
 
2617
3914
  // Release session ownership
2618
3915
  const userId = args['user-id'] || process.env.USER || 'default';
@@ -2626,16 +3923,18 @@ async function handleExit(projectDir, args) {
2626
3923
  const resumeMsg = session.active_harness === 'codex'
2627
3924
  ? genericResumeMsg.replaceAll('/chati', '$chati')
2628
3925
  : genericResumeMsg;
3926
+ let projection;
2629
3927
  try {
2630
3928
  // Each lock target translates the generic command to its native syntax.
2631
3929
  // The returned message below remains native to the harness that exited.
2632
- removeSessionLock(projectDir, genericResumeMsg);
2633
- } catch { /* expected: lock file may not exist */ }
3930
+ projection = removeSessionLock(projectDir, genericResumeMsg);
3931
+ } catch (error) { projection = { projected: false, error: error.message }; }
2634
3932
 
2635
3933
  return {
2636
3934
  exited: true,
2637
3935
  session_saved: true,
2638
- lock_removed: true,
3936
+ lock_removed: projection.projected,
3937
+ ...(projection.projected ? {} : { lock_projection_error: projection.error }),
2639
3938
  resume_message: resumeMsg,
2640
3939
  language: lang,
2641
3940
  };
@@ -2880,11 +4179,8 @@ export function isAgentTeamsEnabled(projectDir) {
2880
4179
 
2881
4180
  // Never infer a primary harness. The legacy native team path is available
2882
4181
  // only while the session explicitly records Claude as the active provider.
2883
- const sessionPath = join(projectDir, '.chati', 'session.yaml');
2884
- if (!existsSync(sessionPath)) return false;
2885
- const sessionRaw = readFileSync(sessionPath, 'utf-8');
2886
- const providerMatch = sessionRaw.match(/^\s*active_provider:\s*["']?(\w+)/m);
2887
- return providerMatch?.[1] === 'claude';
4182
+ const sessionRead = readSessionSafe(projectDir);
4183
+ return sessionRead.loaded && sessionRead.session?.active_provider === 'claude';
2888
4184
  }
2889
4185
 
2890
4186
  async function handleSpawnTeam(projectDir, args) {
@@ -3027,13 +4323,13 @@ async function handleSpawnTeam(projectDir, args) {
3027
4323
 
3028
4324
  // Build spawn command
3029
4325
  const spawnCmd = [
3030
- 'node', RUNNER('run-team.js'),
3031
- '--team-id', teamId,
3032
- '--team-type', teamType,
3033
- '--project-dir', JSON.stringify(resolve(projectDir)),
3034
- '--previous-agent', previousAgent,
3035
- '--provider', provider,
3036
- '--timeout', args.timeout || '1800000',
4326
+ 'node', shellQuote(RUNNER('run-team.js')),
4327
+ '--team-id', shellQuote(teamId),
4328
+ '--team-type', shellQuote(teamType),
4329
+ '--project-dir', shellQuote(resolve(projectDir)),
4330
+ '--previous-agent', shellQuote(previousAgent),
4331
+ '--provider', shellQuote(provider),
4332
+ '--timeout', shellQuote(args.timeout || '1800000'),
3037
4333
  ].join(' ');
3038
4334
 
3039
4335
  return {
@@ -3440,31 +4736,103 @@ export async function handleRunScriptTask(projectDir, args) {
3440
4736
  return { ran: false, reason: 'The execution_kinds feature is off. Run this task through the agent path.', fallback: 'agent' };
3441
4737
  }
3442
4738
 
3443
- const kind = String(args.kind || 'script');
4739
+ let runtimeRecord;
4740
+ try {
4741
+ let request = args;
4742
+ let runtimeClaim = null;
4743
+ if (args['request-file']) {
4744
+ try {
4745
+ runtimeRecord = captureRuntimePath(projectDir, 'script-task-request.json');
4746
+ const requestedPath = isAbsolute(args['request-file'])
4747
+ ? resolve(args['request-file'])
4748
+ : resolve(projectDir, args['request-file']);
4749
+ if (requestedPath !== runtimeRecord.target) {
4750
+ return scriptRequestError('INVALID_SCRIPT_TASK_REQUEST_PATH', 'The script task request file must be .chati/runtime/script-task-request.json.');
4751
+ }
4752
+ runtimeClaim = claimRuntimeRequest(runtimeRecord);
4753
+ request = readClaimedRuntimeJson(runtimeClaim);
4754
+ } catch (err) {
4755
+ runtimeClaim = runtimeClaim || err.runtimeClaim || null;
4756
+ if (runtimeClaim?.fd !== null && runtimeClaim?.fd !== undefined) {
4757
+ try { closeSync(runtimeClaim.fd); } catch { /* already closed */ }
4758
+ runtimeClaim.fd = null;
4759
+ }
4760
+ try { restoreRuntimeRequest(runtimeClaim); } catch { /* keep the claimed copy for manual recovery */ }
4761
+ const code = (err.code === 'ENOENT' && !runtimeClaim?.identity) || err.code === 'RUNTIME_REQUEST_IN_PROGRESS'
4762
+ ? 'INVALID_SCRIPT_TASK_REQUEST_PATH'
4763
+ : 'INVALID_SCRIPT_TASK_REQUEST';
4764
+ return scriptRequestError(code, code === 'INVALID_SCRIPT_TASK_REQUEST_PATH'
4765
+ ? 'The script task request file must be .chati/runtime/script-task-request.json.'
4766
+ : 'The script task request file is invalid or changed during validation.');
4767
+ } finally {
4768
+ if (runtimeClaim?.fd !== null && runtimeClaim?.fd !== undefined) {
4769
+ try { closeSync(runtimeClaim.fd); } catch { /* already closed */ }
4770
+ runtimeClaim.fd = null;
4771
+ }
4772
+ }
4773
+ if (!validateScriptRequestSchema(request) && !isExecutedScriptReceipt(projectDir, request)) {
4774
+ try { restoreRuntimeRequest(runtimeClaim); } catch { /* preserve the claimed copy */ }
4775
+ return scriptRequestError('INVALID_SCRIPT_TASK_REQUEST_SCHEMA', 'The script task request does not match the required kind/ref/id/title schema.');
4776
+ }
4777
+ }
4778
+
4779
+ let executionReceipt = isExecutedScriptReceipt(projectDir, request) ? request : null;
4780
+ if (executionReceipt) request = executionReceipt.request;
4781
+
4782
+ const kind = String(request.kind || 'script');
3444
4783
  if (kind !== 'script') {
3445
- return { ran: false, reason: `execution_kind "${kind}" is not auto-run by the script runner (only "script" is). Route through the normal path.`, kind };
4784
+ try { restoreRuntimeRequest(runtimeClaim); } catch { /* preserve the claimed copy */ }
4785
+ return scriptRequestError('UNSUPPORTED_EXECUTION_KIND', `execution_kind "${kind}" is not auto-run by the script runner (only "script" is).`);
3446
4786
  }
3447
4787
 
3448
- const ref = args.ref || '';
4788
+ const ref = request.ref || '';
3449
4789
  if (!ref) {
3450
- return { ran: false, reason: 'Missing --ref (the execution_ref, for example "npm-script:test").' };
4790
+ try { restoreRuntimeRequest(runtimeClaim); } catch { /* preserve the claimed copy */ }
4791
+ return scriptRequestError('MISSING_EXECUTION_REF', 'Missing --ref (the execution_ref, for example "npm-script:test").');
3451
4792
  }
3452
4793
 
3453
- const id = args.id || args.task || '';
3454
- const title = args.title || '';
3455
- const result = await runScriptTask(projectDir, { id, title, execution_ref: ref });
4794
+ const id = request.id || request.task || '';
4795
+ const title = request.title || '';
4796
+ let result = executionReceipt?.result;
4797
+ let timestamp = executionReceipt?.executed_at;
4798
+ if (!executionReceipt) {
4799
+ try {
4800
+ result = await runScriptTask(projectDir, { id, title, execution_ref: ref });
4801
+ } catch {
4802
+ try { restoreRuntimeRequest(runtimeClaim); } catch { /* preserve the claimed copy */ }
4803
+ return scriptRequestError('SCRIPT_TASK_EXECUTION_FAILED', 'The script task executor failed before producing a result. The request was preserved for retry.');
4804
+ }
4805
+ timestamp = new Date().toISOString();
4806
+ if (runtimeClaim) {
4807
+ try {
4808
+ executionReceipt = persistExecutedRuntimeRequest(runtimeRecord, runtimeClaim, request, result, timestamp);
4809
+ } catch {
4810
+ return scriptRequestError('SCRIPT_TASK_EXECUTION_RECEIPT_FAILED', 'The script task ran, but its durable execution receipt could not be persisted. The claimed request was quarantined to prevent automatic re-execution.');
4811
+ }
4812
+ }
4813
+ }
3456
4814
 
3457
4815
  // Write a synthetic handoff so the pipeline reads it like an agent handoff.
3458
- const timestamp = new Date().toISOString();
3459
4816
  const handoff = formatScriptHandoff({ id, title }, result, timestamp);
3460
- let handoffPath = null;
4817
+ let handoffPath;
3461
4818
  try {
3462
4819
  const dir = join(projectDir, resolveFrameworkDir(projectDir), 'artifacts', 'handoffs');
3463
- mkdirSync(dir, { recursive: true });
3464
4820
  handoffPath = join(dir, `script-${(id || 'task').replace(/[^A-Za-z0-9._-]/g, '_')}.md`);
3465
- writeFileSync(handoffPath, handoff, 'utf-8');
4821
+ writeDurableScriptHandoff(handoffPath, handoff);
4822
+ runRuntimeSecurityTestHook('afterScriptHandoffDurable', { handoffPath, runtimeClaimPath: runtimeClaim?.claimPath });
3466
4823
  } catch {
3467
- // best-effort; the structured result is still returned for the orchestrator
4824
+ try { restoreRuntimeRequest(runtimeClaim); } catch { /* preserve the claimed copy */ }
4825
+ return scriptRequestError('SCRIPT_TASK_HANDOFF_FAILED', 'The script task ran, but its handoff could not be persisted. Its execution receipt was preserved so retry will only persist the handoff.');
4826
+ }
4827
+
4828
+ if (runtimeClaim) {
4829
+ try {
4830
+ if (!consumeRuntimeRequest(runtimeClaim)) {
4831
+ return scriptRequestError('SCRIPT_TASK_REQUEST_FINALIZATION_FAILED', 'The script task ran and its handoff was persisted, but the claimed request changed before finalization.');
4832
+ }
4833
+ } catch {
4834
+ return scriptRequestError('SCRIPT_TASK_REQUEST_FINALIZATION_FAILED', 'The script task ran and its handoff was persisted, but the claimed request could not be finalized.');
4835
+ }
3468
4836
  }
3469
4837
 
3470
4838
  return {
@@ -3477,6 +4845,9 @@ export async function handleRunScriptTask(projectDir, args) {
3477
4845
  handoffPath,
3478
4846
  details: result.details,
3479
4847
  };
4848
+ } finally {
4849
+ closeRuntimeRecord(runtimeRecord);
4850
+ }
3480
4851
  }
3481
4852
 
3482
4853
  /**
@@ -3708,7 +5079,7 @@ export async function runOrchestrate(subCommand, argv, projectDir) {
3708
5079
  break;
3709
5080
  }
3710
5081
  } catch (err) {
3711
- output(errorResult(`Orchestrate error: ${err.message}`, 'INTERNAL_ERROR'));
5082
+ output(errorResult(`Orchestrate error: ${err.message}`, err.code || 'INTERNAL_ERROR'));
3712
5083
  process.exitCode = 1;
3713
5084
  }
3714
5085
  }