chati-dev 4.5.27 → 4.5.29

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 (172) hide show
  1. package/bin/chati.js +151 -123
  2. package/framework/agents/build/dev.md +4 -4
  3. package/framework/agents/deploy/devops.md +3 -3
  4. package/framework/agents/discover/brief.md +18 -6
  5. package/framework/agents/discover/brownfield-wu.md +3 -3
  6. package/framework/agents/discover/greenfield-wu.md +4 -4
  7. package/framework/agents/plan/architect.md +2 -2
  8. package/framework/agents/plan/detail.md +4 -4
  9. package/framework/agents/plan/phases.md +3 -3
  10. package/framework/agents/plan/tasks.md +47 -19
  11. package/framework/agents/plan/ux-brand-architect.md +1 -1
  12. package/framework/agents/plan/ux.md +26 -2
  13. package/framework/agents/quality/qa-implementation.md +8 -8
  14. package/framework/agents/quality/qa-planning.md +58 -4
  15. package/framework/agents/quality/qa-visual.md +4 -4
  16. package/framework/config.yaml +2 -2
  17. package/framework/constitution.md +12 -1
  18. package/framework/context/governance.md +2 -1
  19. package/framework/context/root.md +3 -1
  20. package/framework/domains/constitution.yaml +1 -1
  21. package/framework/domains/global.yaml +1 -1
  22. package/framework/domains/workflows/standard-flow.yaml +7 -8
  23. package/framework/hooks/advance-trigger.js +3 -2
  24. package/framework/hooks/brief-validator.js +1 -1
  25. package/framework/hooks/post-dev.js +1 -1
  26. package/framework/hooks/team-quality-gate.js +9 -2
  27. package/framework/i18n/en.yaml +1 -0
  28. package/framework/i18n/es.yaml +1 -0
  29. package/framework/i18n/fr.yaml +1 -0
  30. package/framework/i18n/pt.yaml +1 -0
  31. package/framework/manifest.json +83 -83
  32. package/framework/manifest.sig +1 -1
  33. package/framework/orchestrator/chati-router.js +14 -6
  34. package/framework/orchestrator/chati.md +61 -23
  35. package/framework/rules/human-writing-style.md +7 -0
  36. package/framework/schemas/session.schema.json +25 -2
  37. package/framework/schemas/task.schema.json +26 -0
  38. package/framework/tasks/orchestrator-handoff.md +10 -10
  39. package/framework/tasks/orchestrator-resume.md +3 -3
  40. package/framework/workflows/brownfield-fullstack.yaml +6 -3
  41. package/framework/workflows/brownfield-service.yaml +5 -3
  42. package/framework/workflows/brownfield-ui.yaml +5 -3
  43. package/framework/workflows/greenfield-fullstack.yaml +6 -3
  44. package/framework/workflows/standard-flow.yaml +8 -3
  45. package/node_modules/@chati/core/native/browser-proof-wire.h +29 -0
  46. package/node_modules/@chati/core/native/darwin-browser-listener.c +36 -0
  47. package/node_modules/@chati/core/native/darwin-browser-listener.h +32 -0
  48. package/node_modules/@chati/core/native/darwin-browser-proof.h +86 -0
  49. package/node_modules/@chati/core/native/darwin-code-identity.h +35 -0
  50. package/node_modules/@chati/core/native/darwin-launch-messages.h +37 -0
  51. package/node_modules/@chati/core/native/darwin-launcher.c +312 -0
  52. package/node_modules/@chati/core/native/descriptor-fs.c +407 -0
  53. package/node_modules/@chati/core/native/execution-admission.h +59 -0
  54. package/node_modules/@chati/core/native/execution-gate.c +41 -0
  55. package/node_modules/@chati/core/native/linux-process.c +41 -0
  56. package/node_modules/@chati/core/native/prebuilds/darwin-arm64/darwin-launcher +0 -0
  57. package/node_modules/@chati/core/native/prebuilds/darwin-arm64/descriptor-fs.node +0 -0
  58. package/node_modules/@chati/core/native/prebuilds/darwin-arm64/execution-gate +0 -0
  59. package/node_modules/@chati/core/native/prebuilds/linux-x64/descriptor-fs.node +0 -0
  60. package/node_modules/@chati/core/native/prebuilds/linux-x64/execution-gate +0 -0
  61. package/node_modules/@chati/core/native/process-info-pipe.c +91 -0
  62. package/node_modules/@chati/core/package.json +2 -1
  63. package/node_modules/@chati/core/scripts/build-native.js +36 -0
  64. package/node_modules/@chati/core/src/darwin-browser-proof.js +87 -0
  65. package/node_modules/@chati/core/src/darwin-host-boot.js +58 -0
  66. package/node_modules/@chati/core/src/darwin-process.js +79 -0
  67. package/node_modules/@chati/core/src/darwin-supervisor-channels.js +82 -0
  68. package/node_modules/@chati/core/src/darwin-terminal-receipt.js +111 -0
  69. package/node_modules/@chati/core/src/descriptor-fs.js +222 -0
  70. package/node_modules/@chati/core/src/execution-gate.js +8 -0
  71. package/node_modules/@chati/core/src/host-boot-marker.js +45 -0
  72. package/node_modules/@chati/core/src/index.js +18 -0
  73. package/node_modules/@chati/core/src/linux-host-boot.js +218 -0
  74. package/node_modules/@chati/core/src/linux-process.js +133 -0
  75. package/node_modules/@chati/planning/src/browser-verification-plan.js +71 -0
  76. package/node_modules/@chati/planning/src/index.js +33 -0
  77. package/node_modules/@chati/planning/src/planning-material.js +64 -0
  78. package/node_modules/@chati/planning/src/verification-plan.js +57 -0
  79. package/node_modules/@chati/rail/package.json +1 -0
  80. package/node_modules/@chati/rail/src/execution-restoration.js +92 -0
  81. package/node_modules/@chati/rail/src/index.js +812 -167
  82. package/node_modules/@chati/rail/src/provider-processing.js +118 -0
  83. package/node_modules/@chati/rail/src/terminal-containment.js +23 -0
  84. package/node_modules/@chati/release-lane/src/index.js +40 -43
  85. package/node_modules/@chati/review-council/src/index.js +223 -0
  86. package/node_modules/@chati/tracking-clickup/src/index.js +67 -58
  87. package/package-artifact-manifest.json +1 -1
  88. package/package-artifact-manifest.sig +1 -1
  89. package/package.json +3 -2
  90. package/scripts/verify-real-harness-e2e.js +20 -8
  91. package/src/installer/core.js +147 -107
  92. package/src/installer/package-artifact.js +7 -0
  93. package/src/installer/preflight.js +13 -0
  94. package/src/installer/provider-overlay.js +1 -2
  95. package/src/installer/templates.js +58 -0
  96. package/src/installer-v2/catalog-client.js +31 -1
  97. package/src/installer-v2/clickup-connection-storage.js +92 -0
  98. package/src/installer-v2/clickup-mcp-client.js +155 -0
  99. package/src/installer-v2/clickup-oauth-loopback.js +96 -0
  100. package/src/installer-v2/clickup-oauth.js +233 -0
  101. package/src/installer-v2/index.js +141 -15
  102. package/src/installer-v2/installation-authority.js +98 -4
  103. package/src/installer-v2/provider-executable.js +137 -20
  104. package/src/installer-v2/wizard-installation.js +11 -0
  105. package/src/orchestrator/agent-selector.js +39 -1
  106. package/src/orchestrator/browser-artifact-store.js +131 -0
  107. package/src/orchestrator/cli.js +762 -205
  108. package/src/orchestrator/clickup-mcp-observer.js +226 -0
  109. package/src/orchestrator/clickup-runtime.js +45 -24
  110. package/src/orchestrator/council-gate-runtime.js +903 -0
  111. package/src/orchestrator/evidence-authority-enrollment.js +75 -0
  112. package/src/orchestrator/evidence-authority-request.js +46 -0
  113. package/src/orchestrator/handoff-engine.js +4 -11
  114. package/src/orchestrator/pipeline-manager.js +42 -4
  115. package/src/orchestrator/planning-contract-revision.js +711 -0
  116. package/src/orchestrator/planning-reconciliation.js +302 -0
  117. package/src/orchestrator/planning-runtime.js +476 -29
  118. package/src/orchestrator/rail-adjudication-evidence.js +15 -24
  119. package/src/orchestrator/rail-human-effort-authority.js +46 -0
  120. package/src/orchestrator/rail-human-effort.js +149 -0
  121. package/src/orchestrator/rail-runtime.js +291 -47
  122. package/src/orchestrator/rail-verification.js +278 -0
  123. package/src/orchestrator/review-runtime.js +37 -2
  124. package/src/orchestrator/runtime-installation-v2.js +143 -24
  125. package/src/orchestrator/session-manager.js +1457 -29
  126. package/src/tasks/handoff.js +57 -33
  127. package/src/terminal/adapters/claude-adapter.js +3 -0
  128. package/src/terminal/adapters/codex-adapter.js +13 -0
  129. package/src/terminal/adapters/grok-adapter.js +1 -0
  130. package/src/terminal/agent-attempt-budget.js +35 -0
  131. package/src/terminal/browser-endpoint-proxy.js +174 -0
  132. package/src/terminal/browser-observer-driver.js +94 -0
  133. package/src/terminal/browser-observer-supervisor.js +73 -0
  134. package/src/terminal/browser-observer.js +95 -0
  135. package/src/terminal/browser-process-facts.js +75 -0
  136. package/src/terminal/browser-runtime.js +131 -0
  137. package/src/terminal/darwin-authority-policy.js +213 -0
  138. package/src/terminal/darwin-browser-application.js +116 -0
  139. package/src/terminal/darwin-browser-observer.js +57 -0
  140. package/src/terminal/darwin-execution.js +185 -0
  141. package/src/terminal/executable-snapshot.js +202 -0
  142. package/src/terminal/handoff-parser.js +48 -2
  143. package/src/terminal/isolation.js +14 -13
  144. package/src/terminal/prompt-builder.js +176 -15
  145. package/src/terminal/provider-preflight.js +47 -27
  146. package/src/terminal/rail-execution-worktree.js +22 -6
  147. package/src/terminal/rail-process-observer.js +10 -0
  148. package/src/terminal/rail-prompts.js +21 -3
  149. package/src/terminal/rail-readonly-workspace.js +54 -5
  150. package/src/terminal/rail-worktree-checkpoint.js +315 -0
  151. package/src/terminal/rail-worktree-restoration.js +485 -0
  152. package/src/terminal/run-agent.js +164 -42
  153. package/src/terminal/run-council-gate.js +338 -0
  154. package/src/terminal/run-planning-reconciliation.js +49 -0
  155. package/src/terminal/run-rail-adjudication.js +29 -7
  156. package/src/terminal/run-rail-recovery.js +97 -0
  157. package/src/terminal/run-rail-review.js +36 -15
  158. package/src/terminal/run-rail-rework.js +26 -15
  159. package/src/terminal/run-rail-task.js +22 -9
  160. package/src/terminal/runtime-native-helper.js +85 -0
  161. package/src/terminal/spawner.js +899 -142
  162. package/src/terminal/verification-process-facts.js +49 -0
  163. package/src/upgrade/entrypoints.js +68 -0
  164. package/src/upgrade/generation.js +194 -0
  165. package/src/upgrade/maintenance.js +79 -0
  166. package/src/upgrade/paths.js +81 -0
  167. package/src/upgrade/upgrade.js +267 -0
  168. package/src/utils/execution-role.js +15 -0
  169. package/src/utils/handoff-paths.js +59 -0
  170. package/src/wizard/i18n.js +1 -0
  171. package/src/wizard/index.js +17 -5
  172. package/src/wizard/questions.js +24 -12
@@ -12,9 +12,24 @@
12
12
  */
13
13
 
14
14
  import { resolveFrameworkDir } from '../utils/framework-dir.js';
15
+ import { DELEGATED_EXECUTION_BOUNDARY } from '../utils/execution-role.js';
16
+ import { findAgentHandoffPath } from '../utils/handoff-paths.js';
17
+ import {
18
+ rejectLegacyAgentResult, rejectAgentResult, revalidateQaAfterCouncil, pinHeldCouncilQaSubject, releaseAgentExecution, summarizePendingInteraction,
19
+ inspectPlanningCorrectionRollback, rollbackPlanningCorrection, getPendingPlanningRollbackTarget,
20
+ reservePlanningContractRevision, settlePlanningContractRevision, getPlanningContractRevisionOwner,
21
+ MAX_PLANNING_RECONCILIATION_TARGETS,
22
+ } from './session-manager.js';
23
+ import { withUpgradeMaintenance } from '../upgrade/maintenance.js';
24
+ import { executePlanningReconciliation } from '../terminal/run-planning-reconciliation.js';
25
+ import { persistPlanningContractRevision, planningContractRevisionCapability } from './planning-contract-revision.js';
15
26
  import { parseProviderConfig } from '../utils/config-parser.js';
16
27
  import { isFeatureEnabled } from '../utils/feature-flags.js';
17
28
  import { runScriptTask, formatScriptHandoff } from '../executors/runner.js';
29
+ import {
30
+ directoryPathSync, duplicateDirectorySync, linkAtSync, lstatAtSync, mkdirAtSync,
31
+ openAtSync, readDirectorySync, renameAtSync, unlinkAtSync,
32
+ } from '@chati/core';
18
33
 
19
34
  import {
20
35
  loadSession, readSessionSafe, initSession, updateSession, transitionSessionLifecycle, rollbackSessionLifecycle,
@@ -47,10 +62,13 @@ import {
47
62
  import { analyzeDeviationImpact, applyDeviation } from './index.js';
48
63
  import { detectQuickFlow, detectStandardFlow } from './index.js';
49
64
  import { getAgentFile } from '../terminal/prompt-builder.js';
65
+ import { getQaThreshold, assertQaCorrectionTarget } from './pipeline-manager.js';
66
+ import { isPlanningQaOutcomeValid } from '../terminal/handoff-parser.js';
67
+ import { getAgentTimeout } from '../terminal/agent-attempt-budget.js';
50
68
  import {
51
69
  readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync,
52
- lstatSync, realpathSync, renameSync, rmSync, openSync, closeSync, constants,
53
- fstatSync, fchmodSync, chmodSync, readSync, linkSync, unlinkSync, fsyncSync, writeSync,
70
+ openSync, closeSync, constants,
71
+ fstatSync, fchmodSync, readSync, fsyncSync, writeSync,
54
72
  } from 'fs';
55
73
  import { basename, join, parse, resolve, dirname, relative, isAbsolute, sep } from 'path';
56
74
  import { fileURLToPath } from 'url';
@@ -67,10 +85,12 @@ import { updateProjectContext } from '../memory/magic-docs.js';
67
85
  import {
68
86
  loadRuntimeInstallationV2, resolveRuntimeInvocationV2,
69
87
  resolveRuntimeAdjudicationV2, describeRuntimeInstallationV2, V2_PLANNING_AGENTS,
70
- assertV2PlanningPredecessors,
88
+ assertV2PlanningPredecessors, resolveRuntimeCouncilGatePolicyV2,
71
89
  } from './runtime-installation-v2.js';
72
- import { compilePlanningHandoff, PLANNING_V2_TASKS_PATH } from './planning-runtime.js';
73
- import { persistRailHandoff } from './rail-runtime.js';
90
+ import { compilePlanningHandoff, PLANNING_V2_TASKS_CANDIDATE_PATH } from './planning-runtime.js';
91
+ import { persistRailHandoff, persistRailHandoffCouncilBinding } from './rail-runtime.js';
92
+ import { assertCouncilGateApproved } from './council-gate-runtime.js';
93
+ import { executeCouncilGate } from '../terminal/run-council-gate.js';
74
94
  import {
75
95
  installationAuthorityProjectScope, signHostAuthorityPayload, verifyHostAuthorityPayload,
76
96
  } from '../installer-v2/installation-authority.js';
@@ -118,8 +138,8 @@ function samePublishedRuntimeFile(left, right) {
118
138
  }
119
139
 
120
140
  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' });
141
+ if (!['linux', 'darwin'].includes(process.platform)) {
142
+ throw Object.assign(new Error(`Runtime request authority requires descriptor-relative operations; unsupported platform: ${process.platform}`), { code: 'UNSUPPORTED_RUNTIME_REQUEST_PLATFORM' });
123
143
  }
124
144
  const absolute = resolve(runtimePath);
125
145
  const root = parse(absolute).root;
@@ -128,15 +148,15 @@ function openRuntimeDirectoryNoFollow(runtimePath) {
128
148
  let fd;
129
149
  try {
130
150
  fd = anchor
131
- ? openSync(`/proc/self/fd/${anchor.descriptor}`, constants.O_RDONLY | (constants.O_DIRECTORY || 0))
151
+ ? duplicateDirectorySync(anchor.descriptor)
132
152
  : openSync(root, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
133
153
  } finally {
134
154
  if (anchor) closeSync(anchor.descriptor);
135
155
  }
136
156
  try {
137
157
  for (const segment of segments) {
138
- const child = openSync(
139
- join(`/proc/self/fd/${fd}`, segment),
158
+ const child = openAtSync(
159
+ fd, segment,
140
160
  constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0),
141
161
  );
142
162
  const stat = fstatSync(child);
@@ -147,7 +167,8 @@ function openRuntimeDirectoryNoFollow(runtimePath) {
147
167
  closeSync(fd);
148
168
  fd = child;
149
169
  }
150
- return { fd, stat: fstatSync(fd), absolute, reference: `/proc/self/fd/${fd}` };
170
+ // reference is diagnostic only. Persistence uses fd plus explicit names.
171
+ return { fd, stat: fstatSync(fd), absolute, reference: directoryPathSync(fd) };
151
172
  } catch (error) {
152
173
  closeSync(fd);
153
174
  if (['ELOOP', 'ENOTDIR'].includes(error?.code)) {
@@ -174,8 +195,8 @@ function assertRuntimeDirectoryRecord(record) {
174
195
  }
175
196
 
176
197
  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' });
198
+ if (!['linux', 'darwin'].includes(process.platform)) {
199
+ throw Object.assign(new Error(`Script handoff persistence requires descriptor-relative operations; unsupported platform: ${process.platform}`), { code: 'UNSUPPORTED_SCRIPT_HANDOFF_PLATFORM' });
179
200
  }
180
201
  const absolute = resolve(directoryPath);
181
202
  const root = parse(absolute).root;
@@ -184,22 +205,21 @@ function openScriptHandoffDirectoryNoFollow(directoryPath, { create = false } =
184
205
  let fd;
185
206
  try {
186
207
  fd = anchor
187
- ? openSync(`/proc/self/fd/${anchor.descriptor}`, constants.O_RDONLY | (constants.O_DIRECTORY || 0))
208
+ ? duplicateDirectorySync(anchor.descriptor)
188
209
  : openSync(root, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
189
210
  } finally {
190
211
  if (anchor) closeSync(anchor.descriptor);
191
212
  }
192
213
  try {
193
214
  for (const segment of segments) {
194
- const entry = join(`/proc/self/fd/${fd}`, segment);
195
215
  let child;
196
216
  try {
197
- child = openSync(entry, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
217
+ child = openAtSync(fd, segment, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
198
218
  } catch (error) {
199
219
  if (error?.code !== 'ENOENT' || !create) throw error;
200
- try { mkdirSync(entry, { mode: 0o700 }); }
220
+ try { mkdirAtSync(fd, segment, { mode: 0o700 }); }
201
221
  catch (mkdirError) { if (mkdirError?.code !== 'EEXIST') throw mkdirError; }
202
- child = openSync(entry, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
222
+ child = openAtSync(fd, segment, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
203
223
  }
204
224
  const stat = fstatSync(child);
205
225
  if (!stat.isDirectory()) {
@@ -209,7 +229,8 @@ function openScriptHandoffDirectoryNoFollow(directoryPath, { create = false } =
209
229
  closeSync(fd);
210
230
  fd = child;
211
231
  }
212
- return { fd, stat: fstatSync(fd), absolute, reference: `/proc/self/fd/${fd}` };
232
+ // reference is diagnostic only. Persistence uses fd plus explicit names.
233
+ return { fd, stat: fstatSync(fd), absolute, reference: directoryPathSync(fd) };
213
234
  } catch (error) {
214
235
  closeSync(fd);
215
236
  if (['ELOOP', 'ENOTDIR'].includes(error?.code)) {
@@ -242,56 +263,52 @@ export function shellQuote(value) {
242
263
  return `'${String(value).replaceAll("'", `'"'"'`)}'`;
243
264
  }
244
265
 
245
- function ensureRuntimeResponsePath(projectDir, filename) {
266
+ function captureRuntimePath(projectDir, filename) {
246
267
  if (!/^[A-Za-z0-9_-]+\.json$/.test(filename)) {
247
268
  throw Object.assign(new Error('Runtime response filename is not path-safe'), { code: 'UNSAFE_RUNTIME_PATH' });
248
269
  }
249
270
  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()) {
271
+ const runtimePath = join(projectPath, '.chati', 'runtime');
272
+ const project = openRuntimeDirectoryNoFollow(projectPath);
273
+ let chatiFd;
274
+ let fd;
275
+ try {
276
+ chatiFd = openAtSync(project.fd, '.chati', constants.O_RDONLY | constants.O_DIRECTORY);
277
+ runRuntimeSecurityTestHook('beforeRuntimeDirectoryCreate', { projectPath, runtimePath });
278
+ try { fd = openAtSync(chatiFd, 'runtime', constants.O_RDONLY | constants.O_DIRECTORY); }
279
+ catch (error) {
280
+ if (error.code !== 'ENOENT') throw error;
281
+ try { mkdirAtSync(chatiFd, 'runtime', { mode: 0o700 }); }
282
+ catch (mkdirError) { if (mkdirError.code !== 'EEXIST') throw mkdirError; }
283
+ fd = openAtSync(chatiFd, 'runtime', constants.O_RDONLY | constants.O_DIRECTORY);
284
+ }
285
+ const runtimeIdentity = fstatSync(fd);
286
+ if (typeof process.getuid !== 'function' || runtimeIdentity.uid === process.getuid()) fchmodSync(fd, 0o700);
287
+ let existing;
288
+ try { existing = lstatAtSync(fd, filename); }
289
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
290
+ // An interrupted claim restore may temporarily have exactly two links.
291
+ // Recovery authenticates that pair before normal single-link checks run.
292
+ if (existing && (existing.isSymbolicLink() || !existing.isFile())) {
276
293
  throw Object.assign(new Error('The runtime response target must be a regular file'), { code: 'UNSAFE_RUNTIME_PATH' });
277
294
  }
295
+ const reference = directoryPathSync(fd);
296
+ const record = {
297
+ projectDir: projectPath, target: join(runtimePath, filename), targetName: filename,
298
+ targetEntry: join(reference, filename), runtimePath, runtimeIdentity, fd, reference,
299
+ };
300
+ assertRuntimeDirectoryRecord(record);
301
+ return record;
302
+ } catch (error) {
303
+ if (fd !== undefined) closeSync(fd);
304
+ if (['ELOOP', 'ENOTDIR'].includes(error.code)) {
305
+ throw Object.assign(new Error('Runtime storage must contain only real directories'), { code: 'UNSAFE_RUNTIME_PATH', cause: error });
306
+ }
307
+ throw error;
308
+ } finally {
309
+ if (chatiFd !== undefined) closeSync(chatiFd);
310
+ closeSync(project.fd);
278
311
  }
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
312
  }
296
313
 
297
314
  function closeRuntimeRecord(record) {
@@ -305,10 +322,10 @@ function runtimeProcessAlive(pid) {
305
322
  try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
306
323
  }
307
324
 
308
- function finalizeRuntimeRequestRestore(runtimeRecord, claimEntry, canonicalEntry, expectedIdentity) {
325
+ function finalizeRuntimeRequestRestore(runtimeRecord, claimName, canonicalName, expectedIdentity) {
309
326
  assertRuntimeDirectoryRecord(runtimeRecord);
310
- const claimStat = lstatSync(claimEntry, { bigint: true });
311
- const canonicalStat = lstatSync(canonicalEntry, { bigint: true });
327
+ const claimStat = lstatAtSync(runtimeRecord.fd, claimName, { bigint: true });
328
+ const canonicalStat = lstatAtSync(runtimeRecord.fd, canonicalName, { bigint: true });
312
329
  if (claimStat.isSymbolicLink() || !claimStat.isFile()
313
330
  || canonicalStat.isSymbolicLink() || !canonicalStat.isFile()
314
331
  || !sameRuntimeIdentity(claimStat, expectedIdentity)
@@ -317,10 +334,10 @@ function finalizeRuntimeRequestRestore(runtimeRecord, claimEntry, canonicalEntry
317
334
  || claimStat.nlink !== 2n || canonicalStat.nlink !== 2n) {
318
335
  throw Object.assign(new Error('Runtime request restoration links do not identify one safe file'), { code: 'RUNTIME_REQUEST_CLAIM_CONFLICT' });
319
336
  }
320
- unlinkSync(claimEntry);
337
+ unlinkAtSync(runtimeRecord.fd, claimName);
321
338
  fsyncSync(runtimeRecord.fd);
322
339
  assertRuntimeDirectoryRecord(runtimeRecord);
323
- const restored = lstatSync(canonicalEntry, { bigint: true });
340
+ const restored = lstatAtSync(runtimeRecord.fd, canonicalName, { bigint: true });
324
341
  if (restored.isSymbolicLink() || !restored.isFile()
325
342
  || !sameRuntimeIdentity(restored, expectedIdentity) || restored.nlink !== 1n) {
326
343
  throw Object.assign(new Error('Restored runtime request did not become one canonical link'), { code: 'RUNTIME_REQUEST_CLAIM_CONFLICT' });
@@ -330,7 +347,7 @@ function finalizeRuntimeRequestRestore(runtimeRecord, claimEntry, canonicalEntry
330
347
 
331
348
  function recoverInterruptedRuntimeClaim(runtimeRecord) {
332
349
  const claimPattern = /^\.claimed-(\d+)-[A-Fa-f0-9-]{36}\.json$/;
333
- const claims = readdirSync(runtimeRecord.reference)
350
+ const claims = readDirectorySync(runtimeRecord.fd)
334
351
  .map((name) => ({ name, match: name.match(claimPattern) }))
335
352
  .filter((entry) => entry.match);
336
353
  if (claims.length === 0) return false;
@@ -339,18 +356,18 @@ function recoverInterruptedRuntimeClaim(runtimeRecord) {
339
356
  }
340
357
  const [{ name, match }] = claims;
341
358
  const claimEntry = join(runtimeRecord.reference, name);
342
- const claimStat = lstatSync(claimEntry, { bigint: true });
359
+ const claimStat = lstatAtSync(runtimeRecord.fd, name, { bigint: true });
343
360
  if (claimStat.isSymbolicLink() || !claimStat.isFile()
344
361
  || ![1n, 2n].includes(claimStat.nlink)
345
362
  || claimStat.size > BigInt(RUNTIME_JSON_MAX_BYTES)) {
346
363
  throw Object.assign(new Error('Interrupted runtime claim is not a safe request file'), { code: 'INVALID_RUNTIME_JSON' });
347
364
  }
348
365
  let canonical;
349
- try { canonical = lstatSync(runtimeRecord.targetEntry, { bigint: true }); }
366
+ try { canonical = lstatAtSync(runtimeRecord.fd, runtimeRecord.targetName, { bigint: true }); }
350
367
  catch (error) { if (error?.code !== 'ENOENT') throw error; }
351
368
  if (canonical) {
352
369
  if (sameRuntimeIdentity(canonical, claimStat)) {
353
- return finalizeRuntimeRequestRestore(runtimeRecord, claimEntry, runtimeRecord.targetEntry, claimStat);
370
+ return finalizeRuntimeRequestRestore(runtimeRecord, name, runtimeRecord.targetName, claimStat);
354
371
  }
355
372
  }
356
373
  if (runtimeProcessAlive(Number(match[1]))) {
@@ -362,17 +379,18 @@ function recoverInterruptedRuntimeClaim(runtimeRecord) {
362
379
  if (claimStat.nlink !== 1n) {
363
380
  throw Object.assign(new Error('Interrupted runtime claim is not a safe request file'), { code: 'INVALID_RUNTIME_JSON' });
364
381
  }
365
- linkSync(claimEntry, runtimeRecord.targetEntry);
382
+ linkAtSync(runtimeRecord.fd, name, runtimeRecord.fd, runtimeRecord.targetName);
366
383
  runRuntimeSecurityTestHook('afterRuntimeClaimRestoreLink', { claimPath: claimEntry, canonicalPath: runtimeRecord.targetEntry });
367
- return finalizeRuntimeRequestRestore(runtimeRecord, claimEntry, runtimeRecord.targetEntry, claimStat);
384
+ return finalizeRuntimeRequestRestore(runtimeRecord, name, runtimeRecord.targetName, claimStat);
368
385
  }
369
386
 
370
387
  function writePrivateRuntimeJson(runtimeRecord, payload, { preserveExisting = false } = {}) {
371
388
  if (runtimeRecord?.fd === undefined || runtimeRecord?.fd === null
372
- || !runtimeRecord?.targetEntry || !runtimeRecord?.reference) {
389
+ || !runtimeRecord?.targetName) {
373
390
  throw Object.assign(new Error('Runtime response persistence requires a pinned runtime directory'), { code: 'UNSAFE_RUNTIME_PATH' });
374
391
  }
375
- const temporary = join(runtimeRecord.reference, `.pending-${process.pid}-${randomUUID()}.tmp`);
392
+ const temporaryName = `.pending-${process.pid}-${randomUUID()}.tmp`;
393
+ const temporary = join(runtimeRecord.reference, temporaryName);
376
394
  let descriptor;
377
395
  let written;
378
396
  let publicationAttempted = false;
@@ -387,7 +405,7 @@ function writePrivateRuntimeJson(runtimeRecord, payload, { preserveExisting = fa
387
405
  assertRuntimeDirectoryRecord(runtimeRecord);
388
406
 
389
407
  let existing;
390
- try { existing = lstatSync(runtimeRecord.targetEntry, { bigint: true }); }
408
+ try { existing = lstatAtSync(runtimeRecord.fd, runtimeRecord.targetName, { bigint: true }); }
391
409
  catch (error) { if (error?.code !== 'ENOENT') throw error; }
392
410
  if (existing) {
393
411
  if (existing.isSymbolicLink() || !existing.isFile() || existing.nlink !== 1n) {
@@ -396,8 +414,8 @@ function writePrivateRuntimeJson(runtimeRecord, payload, { preserveExisting = fa
396
414
  if (preserveExisting) return;
397
415
  }
398
416
 
399
- descriptor = openSync(
400
- temporary,
417
+ descriptor = openAtSync(
418
+ runtimeRecord.fd, temporaryName,
401
419
  constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0),
402
420
  0o600,
403
421
  );
@@ -422,8 +440,8 @@ function writePrivateRuntimeJson(runtimeRecord, payload, { preserveExisting = fa
422
440
  throw Object.assign(new Error('The temporary runtime response changed before publication'), { code: 'UNSAFE_RUNTIME_PATH' });
423
441
  }
424
442
  publicationAttempted = true;
425
- renameSync(temporary, runtimeRecord.targetEntry);
426
- const committed = lstatSync(runtimeRecord.targetEntry, { bigint: true });
443
+ renameAtSync(runtimeRecord.fd, temporaryName, runtimeRecord.fd, runtimeRecord.targetName);
444
+ const committed = lstatAtSync(runtimeRecord.fd, runtimeRecord.targetName, { bigint: true });
427
445
  if (committed.isSymbolicLink() || !committed.isFile() || !samePublishedRuntimeFile(committed, written)
428
446
  || !samePublishedRuntimeFile(fstatSync(descriptor, { bigint: true }), written)) {
429
447
  throw Object.assign(new Error('The published runtime response differs from its pinned temporary file'), { code: 'UNSAFE_RUNTIME_PATH' });
@@ -433,11 +451,11 @@ function writePrivateRuntimeJson(runtimeRecord, payload, { preserveExisting = fa
433
451
  published = true;
434
452
  } finally {
435
453
  if (descriptor !== undefined) closeSync(descriptor);
436
- try { unlinkSync(temporary); } catch { /* preserve primary response result */ }
454
+ try { unlinkAtSync(runtimeRecord.fd, temporaryName); } catch { /* preserve primary response result */ }
437
455
  if (publicationAttempted && !published && written) {
438
456
  try {
439
- const current = lstatSync(runtimeRecord.targetEntry, { bigint: true });
440
- if (current.isFile() && !current.isSymbolicLink() && !sameRuntimeIdentity(current, written)) unlinkSync(runtimeRecord.targetEntry);
457
+ const current = lstatAtSync(runtimeRecord.fd, runtimeRecord.targetName, { bigint: true });
458
+ if (current.isFile() && !current.isSymbolicLink() && !sameRuntimeIdentity(current, written)) unlinkAtSync(runtimeRecord.fd, runtimeRecord.targetName);
441
459
  } catch { /* preserve primary response result */ }
442
460
  }
443
461
  }
@@ -483,8 +501,8 @@ function claimRuntimeRequest(runtimeRecord) {
483
501
  assertRuntimeDirectoryRecord(runtimeRecord);
484
502
  recoverInterruptedRuntimeClaim(runtimeRecord);
485
503
  runRuntimeSecurityTestHook('beforeRuntimeRequestOpen', { canonicalPath, directory: runtimeRecord.runtimePath });
486
- claim.fd = openSync(
487
- claim.canonicalEntry,
504
+ claim.fd = openAtSync(
505
+ runtimeRecord.fd, runtimeRecord.targetName,
488
506
  constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0),
489
507
  );
490
508
  claim.identity = fstatSync(claim.fd, { bigint: true });
@@ -494,9 +512,9 @@ function claimRuntimeRequest(runtimeRecord) {
494
512
  }
495
513
  runRuntimeSecurityTestHook('beforeRuntimeClaimRename', { canonicalPath, claimPath: claim.claimPath });
496
514
  assertRuntimeDirectoryRecord(runtimeRecord);
497
- renameSync(claim.canonicalEntry, claim.claimPath);
515
+ renameAtSync(runtimeRecord.fd, runtimeRecord.targetName, runtimeRecord.fd, claimName);
498
516
  fsyncSync(runtimeRecord.fd);
499
- const claimedPathStat = lstatSync(claim.claimPath, { bigint: true });
517
+ const claimedPathStat = lstatAtSync(runtimeRecord.fd, claimName, { bigint: true });
500
518
  if (claimedPathStat.isSymbolicLink() || !claimedPathStat.isFile()
501
519
  || !sameRuntimeIdentity(claimedPathStat, claim.identity)) {
502
520
  throw Object.assign(new Error('Runtime JSON changed while it was being claimed'), { code: 'INVALID_RUNTIME_JSON' });
@@ -518,26 +536,26 @@ function claimRuntimeRequest(runtimeRecord) {
518
536
  function restoreRuntimeRequest(claim) {
519
537
  if (!claim?.claimPath || !claim.identity) return false;
520
538
  let current;
521
- try { current = lstatSync(claim.claimPath, { bigint: true }); }
539
+ try { current = lstatAtSync(claim.runtimeRecord.fd, claim.claimName, { bigint: true }); }
522
540
  catch (error) { if (error?.code === 'ENOENT') return false; throw error; }
523
541
  if (current.isSymbolicLink() || !current.isFile() || !sameRuntimeIdentity(current, claim.identity)) return false;
524
542
  try {
525
- linkSync(claim.claimPath, claim.canonicalEntry);
543
+ linkAtSync(claim.runtimeRecord.fd, claim.claimName, claim.runtimeRecord.fd, claim.runtimeRecord.targetName);
526
544
  } catch (error) {
527
545
  if (error?.code !== 'EEXIST') throw error;
528
- const canonical = lstatSync(claim.canonicalEntry, { bigint: true });
546
+ const canonical = lstatAtSync(claim.runtimeRecord.fd, claim.runtimeRecord.targetName, { bigint: true });
529
547
  if (!sameRuntimeIdentity(canonical, current)) return false;
530
- return finalizeRuntimeRequestRestore(claim.runtimeRecord, claim.claimPath, claim.canonicalEntry, claim.identity);
548
+ return finalizeRuntimeRequestRestore(claim.runtimeRecord, claim.claimName, claim.runtimeRecord.targetName, claim.identity);
531
549
  }
532
550
  runRuntimeSecurityTestHook('afterRuntimeRequestRestoreLink', { claimPath: claim.claimPath, canonicalPath: claim.canonicalEntry });
533
- return finalizeRuntimeRequestRestore(claim.runtimeRecord, claim.claimPath, claim.canonicalEntry, claim.identity);
551
+ return finalizeRuntimeRequestRestore(claim.runtimeRecord, claim.claimName, claim.runtimeRecord.targetName, claim.identity);
534
552
  }
535
553
 
536
554
  function consumeRuntimeRequest(claim) {
537
555
  if (!claim?.claimPath || !claim.identity) return true;
538
- const current = lstatSync(claim.claimPath, { bigint: true });
556
+ const current = lstatAtSync(claim.runtimeRecord.fd, claim.claimName, { bigint: true });
539
557
  if (current.isSymbolicLink() || !current.isFile() || !sameRuntimeIdentity(current, claim.identity)) return false;
540
- unlinkSync(claim.claimPath);
558
+ unlinkAtSync(claim.runtimeRecord.fd, claim.claimName);
541
559
  fsyncSync(claim.runtimeRecord.fd);
542
560
  assertRuntimeDirectoryRecord(claim.runtimeRecord);
543
561
  return true;
@@ -557,7 +575,7 @@ function scriptExecutionReceiptDigest(receipt) {
557
575
 
558
576
  function persistExecutedRuntimeRequest(runtimeRecord, claim, request, result, executedAt) {
559
577
  assertRuntimeDirectoryRecord(runtimeRecord);
560
- const current = lstatSync(claim.claimPath, { bigint: true });
578
+ const current = lstatAtSync(runtimeRecord.fd, claim.claimName, { bigint: true });
561
579
  if (current.isSymbolicLink() || !current.isFile() || !sameRuntimeIdentity(current, claim.identity)) {
562
580
  throw Object.assign(new Error('The claimed request changed before its execution receipt was persisted'), { code: 'INVALID_RUNTIME_JSON' });
563
581
  }
@@ -579,8 +597,9 @@ function persistExecutedRuntimeRequest(runtimeRecord, claim, request, result, ex
579
597
  authority_id: proof.authority_id,
580
598
  signature: proof.signature,
581
599
  };
582
- const temporary = join(runtimeRecord.reference, `.executed-${process.pid}-${randomUUID()}.tmp`);
583
- const claimBackup = join(runtimeRecord.reference, `.receipt-backup-${process.pid}-${randomUUID()}.tmp`);
600
+ const temporaryName = `.executed-${process.pid}-${randomUUID()}.tmp`;
601
+ const temporary = join(runtimeRecord.reference, temporaryName);
602
+ const backupName = `.receipt-backup-${process.pid}-${randomUUID()}.tmp`;
584
603
  const originalClaimIdentity = claim.identity;
585
604
  let temporaryDescriptor;
586
605
  let written;
@@ -589,7 +608,7 @@ function persistExecutedRuntimeRequest(runtimeRecord, claim, request, result, ex
589
608
  let backupCreated = false;
590
609
  try {
591
610
  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);
611
+ temporaryDescriptor = openAtSync(runtimeRecord.fd, temporaryName, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
593
612
  let offset = 0;
594
613
  while (offset < bytes.length) offset += writeSync(temporaryDescriptor, bytes, offset, bytes.length - offset);
595
614
  fsyncSync(temporaryDescriptor);
@@ -599,7 +618,7 @@ function persistExecutedRuntimeRequest(runtimeRecord, claim, request, result, ex
599
618
  throw Object.assign(new Error('The temporary execution receipt is not a private regular file'), { code: 'INVALID_RUNTIME_JSON' });
600
619
  }
601
620
  assertRuntimeDirectoryRecord(runtimeRecord);
602
- const beforeRename = lstatSync(claim.claimPath, { bigint: true });
621
+ const beforeRename = lstatAtSync(runtimeRecord.fd, claim.claimName, { bigint: true });
603
622
  if (beforeRename.isSymbolicLink() || !beforeRename.isFile() || !sameRuntimeIdentity(beforeRename, claim.identity)) {
604
623
  throw Object.assign(new Error('The claimed request changed before its execution receipt was committed'), { code: 'INVALID_RUNTIME_JSON' });
605
624
  }
@@ -608,43 +627,43 @@ function persistExecutedRuntimeRequest(runtimeRecord, claim, request, result, ex
608
627
  if (!sameRuntimeFileSnapshot(fstatSync(temporaryDescriptor, { bigint: true }), written)) {
609
628
  throw Object.assign(new Error('The temporary execution receipt changed before publication'), { code: 'INVALID_RUNTIME_JSON' });
610
629
  }
611
- linkSync(claim.claimPath, claimBackup);
630
+ linkAtSync(runtimeRecord.fd, claim.claimName, runtimeRecord.fd, backupName);
612
631
  backupCreated = true;
613
- const backup = lstatSync(claimBackup, { bigint: true });
632
+ const backup = lstatAtSync(runtimeRecord.fd, backupName, { bigint: true });
614
633
  if (backup.isSymbolicLink() || !backup.isFile() || !sameRuntimeIdentity(backup, originalClaimIdentity)
615
634
  || backup.nlink !== 2n) {
616
635
  throw Object.assign(new Error('The original request could not be protected during receipt publication'), { code: 'INVALID_RUNTIME_JSON' });
617
636
  }
618
637
  publicationAttempted = true;
619
- renameSync(temporary, claim.claimPath);
638
+ renameAtSync(runtimeRecord.fd, temporaryName, runtimeRecord.fd, claim.claimName);
620
639
  fsyncSync(runtimeRecord.fd);
621
- claim.identity = lstatSync(claim.claimPath, { bigint: true });
640
+ claim.identity = lstatAtSync(runtimeRecord.fd, claim.claimName, { bigint: true });
622
641
  if (!claim.identity.isFile() || claim.identity.isSymbolicLink?.() || !samePublishedRuntimeFile(claim.identity, written)
623
642
  || !samePublishedRuntimeFile(fstatSync(temporaryDescriptor, { bigint: true }), written)) {
624
643
  throw Object.assign(new Error('The execution receipt is not a private regular file'), { code: 'INVALID_RUNTIME_JSON' });
625
644
  }
626
645
  assertRuntimeDirectoryRecord(runtimeRecord);
627
- unlinkSync(claimBackup);
646
+ unlinkAtSync(runtimeRecord.fd, backupName);
628
647
  backupCreated = false;
629
648
  fsyncSync(runtimeRecord.fd);
630
649
  published = true;
631
650
  return receipt;
632
651
  } finally {
633
652
  if (temporaryDescriptor !== undefined) closeSync(temporaryDescriptor);
634
- try { unlinkSync(temporary); } catch { /* preserve primary receipt result */ }
653
+ try { unlinkAtSync(runtimeRecord.fd, temporaryName); } catch { /* preserve primary receipt result */ }
635
654
  if (publicationAttempted && !published && backupCreated) {
636
655
  try {
637
- const backup = lstatSync(claimBackup, { bigint: true });
656
+ const backup = lstatAtSync(runtimeRecord.fd, backupName, { bigint: true });
638
657
  if (!backup.isSymbolicLink() && backup.isFile() && sameRuntimeIdentity(backup, originalClaimIdentity)) {
639
- renameSync(claimBackup, claim.claimPath);
640
- claim.identity = lstatSync(claim.claimPath, { bigint: true });
658
+ renameAtSync(runtimeRecord.fd, backupName, runtimeRecord.fd, claim.claimName);
659
+ claim.identity = lstatAtSync(runtimeRecord.fd, claim.claimName, { bigint: true });
641
660
  fsyncSync(runtimeRecord.fd);
642
661
  backupCreated = false;
643
662
  }
644
663
  } catch { /* preserve primary receipt result */ }
645
664
  }
646
665
  if (backupCreated) {
647
- try { unlinkSync(claimBackup); } catch { /* preserve primary receipt result */ }
666
+ try { unlinkAtSync(runtimeRecord.fd, backupName); } catch { /* preserve primary receipt result */ }
648
667
  }
649
668
  }
650
669
  }
@@ -657,8 +676,8 @@ function writeDurableScriptHandoff(path, content) {
657
676
  closeSync(directory.fd);
658
677
  throw Object.assign(new Error('The script handoff target is not canonical'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
659
678
  }
660
- const temporary = join(directory.reference, `.${name}.${process.pid}.${randomUUID()}.tmp`);
661
- const destination = join(directory.reference, name);
679
+ const temporaryName = `.${name}.${process.pid}.${randomUUID()}.tmp`;
680
+ const temporary = join(directory.reference, temporaryName);
662
681
  let descriptor;
663
682
  let written;
664
683
  let publicationAttempted = false;
@@ -667,7 +686,7 @@ function writeDurableScriptHandoff(path, content) {
667
686
  assertScriptHandoffDirectoryRecord(directory);
668
687
  runRuntimeSecurityTestHook('beforeScriptHandoffTempOpen', { handoffPath: path, temporary, directory: directory.absolute });
669
688
  assertScriptHandoffDirectoryRecord(directory);
670
- descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
689
+ descriptor = openAtSync(directory.fd, temporaryName, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
671
690
  const bytes = Buffer.from(content);
672
691
  let offset = 0;
673
692
  while (offset < bytes.length) offset += writeSync(descriptor, bytes, offset, bytes.length - offset);
@@ -680,7 +699,7 @@ function writeDurableScriptHandoff(path, content) {
680
699
  runRuntimeSecurityTestHook('beforeScriptHandoffCommit', { handoffPath: path, temporary, directory: directory.absolute });
681
700
  assertScriptHandoffDirectoryRecord(directory);
682
701
  let existing;
683
- try { existing = lstatSync(destination, { bigint: true }); }
702
+ try { existing = lstatAtSync(directory.fd, name, { bigint: true }); }
684
703
  catch (error) { if (error?.code !== 'ENOENT') throw error; }
685
704
  if (existing && (existing.isSymbolicLink() || !existing.isFile() || existing.nlink !== 1n)) {
686
705
  throw Object.assign(new Error('The script handoff destination must be a regular single-link file'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
@@ -689,8 +708,8 @@ function writeDurableScriptHandoff(path, content) {
689
708
  throw Object.assign(new Error('The temporary script handoff changed before publication'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
690
709
  }
691
710
  publicationAttempted = true;
692
- renameSync(temporary, destination);
693
- const committed = lstatSync(destination, { bigint: true });
711
+ renameAtSync(directory.fd, temporaryName, directory.fd, name);
712
+ const committed = lstatAtSync(directory.fd, name, { bigint: true });
694
713
  if (committed.isSymbolicLink() || !committed.isFile() || !samePublishedRuntimeFile(committed, written)
695
714
  || !samePublishedRuntimeFile(fstatSync(descriptor, { bigint: true }), written)) {
696
715
  throw Object.assign(new Error('The published script handoff differs from its pinned temporary file'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
@@ -700,11 +719,11 @@ function writeDurableScriptHandoff(path, content) {
700
719
  published = true;
701
720
  } finally {
702
721
  if (descriptor !== undefined) closeSync(descriptor);
703
- try { unlinkSync(temporary); } catch { /* preserve primary handoff result */ }
722
+ try { unlinkAtSync(directory.fd, temporaryName); } catch { /* preserve primary handoff result */ }
704
723
  if (publicationAttempted && !published && written) {
705
724
  try {
706
- const current = lstatSync(destination, { bigint: true });
707
- if (current.isFile() && !current.isSymbolicLink() && !sameRuntimeIdentity(current, written)) unlinkSync(destination);
725
+ const current = lstatAtSync(directory.fd, name, { bigint: true });
726
+ if (current.isFile() && !current.isSymbolicLink() && !sameRuntimeIdentity(current, written)) unlinkAtSync(directory.fd, name);
708
727
  } catch { /* preserve primary handoff result */ }
709
728
  }
710
729
  closeSync(directory.fd);
@@ -1002,6 +1021,55 @@ export function readBriefAndRefCorpus(projectDir) {
1002
1021
  return corpus;
1003
1022
  }
1004
1023
 
1024
+ /** Closed, canonical Brief declaration only. Prose/UX self-assessment is not authority. */
1025
+ export function hasExplicitNonVisualBrief(content) {
1026
+ if (typeof content !== 'string' || detectPremiumRefs(content).detected) return false;
1027
+ const source = content.replace(/\r\n/g, '\n');
1028
+ const sections = [...source.matchAll(/^## ([^\n]+)\n([\s\S]*?)(?=^## |$(?![\s\S]))/gm)];
1029
+ function declaration(title, key) {
1030
+ const matches = sections.filter(section => section[1].trim() === title);
1031
+ // A second declaration anywhere in the document is ambiguous, even when
1032
+ // a parser could otherwise select the preferred canonical section.
1033
+ const keyPattern = new RegExp(`^[ \\t]*(?:${key}|"${key}"|'${key}')[ \\t]*:`, 'gm');
1034
+ if (matches.length !== 1 || [...source.matchAll(keyPattern)].length !== 1) return null;
1035
+ const block = matches[0][2].trim().match(/^```ya?ml\n([\s\S]*?)\n```$/);
1036
+ if (!block || block[1].includes('```')) return null;
1037
+ try {
1038
+ const value = yaml.load(block[1], { schema: yaml.JSON_SCHEMA });
1039
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
1040
+ } catch { return null; }
1041
+ }
1042
+ const quality = declaration('Visual Quality', 'visual_quality');
1043
+ const references = declaration('Visual References', 'visual_references');
1044
+ return Boolean(quality && references
1045
+ && Object.keys(quality).length === 1 && quality.visual_quality === 'not_applicable'
1046
+ && Object.keys(references).every(key => key === 'visual_references' || key === 'reference_synthesis')
1047
+ && (!Object.hasOwn(references, 'reference_synthesis') || typeof references.reference_synthesis === 'string')
1048
+ && Array.isArray(references.visual_references) && references.visual_references.length === 0);
1049
+ }
1050
+
1051
+ function resolveNonVisualUxApplicability(projectDir, artifact) {
1052
+ if (!artifact) return null;
1053
+ const council = resolveRuntimeCouncilGatePolicyV2({ artifact, stage: 'discovery' });
1054
+ if (!council.enabled) return null;
1055
+ try {
1056
+ // No new council inference here. Missing/rejected/stale/wrong-project or
1057
+ // malformed approval retains the existing mandatory visual path.
1058
+ const approved = assertCouncilGateApproved({ projectDir, artifact, stage: 'discovery', policy: council.policy });
1059
+ const brief = approved.material.find(item => item.path === 'artifacts/1-Brief/brief-report.md');
1060
+ if (!brief || !hasExplicitNonVisualBrief(brief.content)) return null;
1061
+ // A downstream visual reference analysis conflicts with an empty reference
1062
+ // declaration. It cannot grant an exemption or silently be ignored, even
1063
+ // if an animation inventory already exists.
1064
+ try {
1065
+ if (readFileSync(join(projectDir, 'artifacts/4-UX/reference-analysis.md'), 'utf8').trim()) return null;
1066
+ } catch (error) { if (error.code !== 'ENOENT') return null; }
1067
+ return Object.freeze({
1068
+ status: 'not_applicable', source: brief.path, sha256: brief.sha256, discovery_gate_id: approved.plan.gate_id,
1069
+ });
1070
+ } catch { return null; }
1071
+ }
1072
+
1005
1073
  // ---------------------------------------------------------------------------
1006
1074
  // State-at-a-glance card (Fase 8)
1007
1075
  //
@@ -1200,14 +1268,142 @@ function parseArgs(argv) {
1200
1268
  return args;
1201
1269
  }
1202
1270
 
1203
- function output(data) {
1271
+ function writeOutput(data) {
1204
1272
  process.stdout.write(JSON.stringify(data, null, 2) + '\n');
1205
1273
  }
1206
1274
 
1275
+ /** True when every automatic Planning recovery route is consumed without a QA
1276
+ * authorization: all reconciliation targets settled as denied, none pending.
1277
+ * Exported for tests; the advance path reports this state as terminal. */
1278
+ export function planningRecoveryBudgetExhausted(session, agent = 'qa-planning') {
1279
+ const reconciliations = session?.agents?.[agent]?.planning_reconciliations;
1280
+ if (!Array.isArray(reconciliations) || reconciliations.length < MAX_PLANNING_RECONCILIATION_TARGETS) return false;
1281
+ if (reconciliations.some(entry => entry?.authorization)) return false;
1282
+ return !reconciliations.some(entry => ['reserved', 'running', 'outcome_unknown'].includes(entry?.status));
1283
+ }
1284
+
1207
1285
  function errorResult(message, code) {
1208
1286
  return { action: 'error', error: message, code: code || 'UNKNOWN' };
1209
1287
  }
1210
1288
 
1289
+ const COUNCIL_STAGE_BY_COMPLETING_AGENT = Object.freeze({
1290
+ brief: 'discovery',
1291
+ 'qa-planning': 'planning',
1292
+ tasks: 'execution-design',
1293
+ });
1294
+
1295
+ function councilGateSessionRecord(result) {
1296
+ return {
1297
+ gate_id: result.gate_id,
1298
+ stage: result.stage,
1299
+ verdict: result.decision.verdict,
1300
+ decision_path: result.decision_path,
1301
+ member_count: result.member_count,
1302
+ };
1303
+ }
1304
+
1305
+ /**
1306
+ * Applies the automatic quality council exactly once per content-addressed
1307
+ * artifact bundle. A prior rework decision remains blocking until planning
1308
+ * changes, so retries cannot consume providers on unchanged evidence.
1309
+ */
1310
+ export async function enforceRuntimeCouncilGate({
1311
+ projectDir,
1312
+ agent,
1313
+ session,
1314
+ artifact,
1315
+ handoff,
1316
+ executeCouncil = executeCouncilGate,
1317
+ } = {}) {
1318
+ const stage = COUNCIL_STAGE_BY_COMPLETING_AGENT[agent];
1319
+ if (!stage) return Object.freeze({ required: false, reason: 'NO_COUNCIL_STAGE_FOR_AGENT' });
1320
+ if (agent === 'qa-planning') {
1321
+ const subject = pinHeldCouncilQaSubject(projectDir, session);
1322
+ try { subject?.assertCurrent(); } finally { subject?.close(); }
1323
+ }
1324
+ const configured = resolveRuntimeCouncilGatePolicyV2({ artifact, stage });
1325
+ if (!configured.enabled) {
1326
+ if (configured.reason === 'GUIDED_COUNCIL_REQUIRED') {
1327
+ return Object.freeze({
1328
+ required: true,
1329
+ approved: false,
1330
+ blocked: true,
1331
+ stage,
1332
+ code: 'GUIDED_COUNCIL_REQUIRED',
1333
+ message: 'this installation selected fewer than two eligible providers, so the automatic council cannot be bypassed. Reconfigure the installation with two selected review providers before advancing.',
1334
+ });
1335
+ }
1336
+ return Object.freeze({ required: false, reason: configured.reason });
1337
+ }
1338
+ const options = {
1339
+ projectDir,
1340
+ stage,
1341
+ artifact,
1342
+ policy: configured.policy,
1343
+ language: session?.language || 'en',
1344
+ ...(handoff === undefined ? {} : { handoff }),
1345
+ };
1346
+ try {
1347
+ const approved = assertCouncilGateApproved(options);
1348
+ const result = Object.freeze({
1349
+ ok: true,
1350
+ stage,
1351
+ gate_id: approved.plan.gate_id,
1352
+ member_count: approved.plan.selection.member_count,
1353
+ decision: approved.decision,
1354
+ decision_path: approved.path,
1355
+ reused: true,
1356
+ });
1357
+ await updateSession(projectDir, {
1358
+ council_gates: { ...(session?.council_gates || {}), [stage]: councilGateSessionRecord(result) },
1359
+ });
1360
+ return Object.freeze({ required: true, approved: true, result });
1361
+ } catch (error) {
1362
+ if (error?.code === 'COUNCIL_GATE_REWORK_REQUIRED') {
1363
+ return Object.freeze({
1364
+ required: true,
1365
+ stage,
1366
+ approved: false,
1367
+ blocked: true,
1368
+ code: error.code,
1369
+ message: error.message,
1370
+ blockers: error.details?.blockers || [],
1371
+ gate_id: error.details?.gate_id || null,
1372
+ });
1373
+ }
1374
+ if (!['COUNCIL_GATE_DECISION_MISSING', 'COUNCIL_GATE_DECISION_STALE'].includes(error?.code)) throw error;
1375
+ }
1376
+ let result;
1377
+ try {
1378
+ result = await executeCouncil(options);
1379
+ } catch (error) {
1380
+ // A fresh real council verifies its persisted decision and throws on
1381
+ // rework. Classify that exactly like cached rework above, not as a broken
1382
+ // provider installation. All other execution/authority errors propagate.
1383
+ if (error?.code !== 'COUNCIL_GATE_REWORK_REQUIRED') throw error;
1384
+ return Object.freeze({
1385
+ required: true,
1386
+ stage,
1387
+ approved: false,
1388
+ blocked: true,
1389
+ code: error.code,
1390
+ message: error.message,
1391
+ blockers: error.details?.blockers || [],
1392
+ gate_id: error.details?.gate_id || null,
1393
+ });
1394
+ }
1395
+ await updateSession(projectDir, {
1396
+ council_gates: { ...(session?.council_gates || {}), [stage]: councilGateSessionRecord(result) },
1397
+ });
1398
+ return Object.freeze({
1399
+ required: true,
1400
+ approved: result.ok,
1401
+ blocked: !result.ok,
1402
+ ...(result.ok ? {} : { code: 'COUNCIL_GATE_REWORK_REQUIRED', blockers: result.decision.blocking_findings }),
1403
+ result,
1404
+ });
1405
+ }
1406
+
1211
1407
  /**
1212
1408
  * Build a pipeline state object from session data.
1213
1409
  * The session-manager and pipeline-manager use slightly different shapes;
@@ -1273,7 +1469,7 @@ export function buildSpawnCommand(agent, projectDir, previousAgent, provider, ti
1273
1469
  ...(routing.reasoning_escalation ? ['--reasoning-escalation'] : []),
1274
1470
  ...(routing.execution_claim_id ? ['--execution-claim-id', shellQuote(routing.execution_claim_id)] : []),
1275
1471
  ...(strictProvider ? ['--strict-provider'] : []),
1276
- '--timeout', String(timeout || 600000),
1472
+ '--timeout', String(timeout ?? getAgentTimeout(agent)),
1277
1473
  ];
1278
1474
  return parts.join(' ');
1279
1475
  }
@@ -1461,11 +1657,11 @@ function closeNativeLockStorage(storage) {
1461
1657
  }
1462
1658
 
1463
1659
  function openWorkingDirectoryAnchor(absolute) {
1464
- if (process.platform !== 'linux') return null;
1660
+ if (!['linux', 'darwin'].includes(process.platform)) return null;
1465
1661
  let descriptor;
1466
1662
  try {
1467
1663
  descriptor = openSync('.', constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
1468
- const workingDirectory = realpathSync(`/proc/self/fd/${descriptor}`);
1664
+ const workingDirectory = directoryPathSync(descriptor);
1469
1665
  const targetRelative = relative(workingDirectory, absolute);
1470
1666
  if (targetRelative === '..' || targetRelative.startsWith(`..${sep}`) || isAbsolute(targetRelative)) {
1471
1667
  closeSync(descriptor);
@@ -1479,8 +1675,8 @@ function openWorkingDirectoryAnchor(absolute) {
1479
1675
  }
1480
1676
 
1481
1677
  function openNativeLockStorage(projectDir, targetPath) {
1482
- if (process.platform !== 'linux') {
1483
- return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock projection requires Linux descriptor-relative storage');
1678
+ if (!['linux', 'darwin'].includes(process.platform)) {
1679
+ return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', `native lock projection requires descriptor-relative storage; unsupported platform: ${process.platform}`);
1484
1680
  }
1485
1681
  const project = resolve(projectDir);
1486
1682
  const target = resolve(targetPath);
@@ -1502,7 +1698,7 @@ function openNativeLockStorage(projectDir, targetPath) {
1502
1698
  let descriptor;
1503
1699
  try {
1504
1700
  descriptor = anchor
1505
- ? openSync(`/proc/self/fd/${anchor.descriptor}`, constants.O_RDONLY | (constants.O_DIRECTORY || 0))
1701
+ ? duplicateDirectorySync(anchor.descriptor)
1506
1702
  : openSync(root, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
1507
1703
  } finally {
1508
1704
  if (anchor) closeSync(anchor.descriptor);
@@ -1510,8 +1706,8 @@ function openNativeLockStorage(projectDir, targetPath) {
1510
1706
  descriptors.push(descriptor);
1511
1707
  snapshots.push(nativeLockSnapshot(fstatSync(descriptor)));
1512
1708
  for (const segment of segments) {
1513
- descriptor = openSync(
1514
- join(`/proc/self/fd/${descriptor}`, segment),
1709
+ descriptor = openAtSync(
1710
+ descriptor, segment,
1515
1711
  constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0),
1516
1712
  );
1517
1713
  const stat = fstatSync(descriptor);
@@ -1548,12 +1744,11 @@ function assertNativeLockStorage(storage) {
1548
1744
  }
1549
1745
 
1550
1746
  function readHarnessProjection(storage) {
1551
- const reference = join(`/proc/self/fd/${storage.descriptor}`, storage.name);
1552
1747
  let descriptor;
1553
1748
  try {
1554
- const pathStat = lstatSync(reference, { bigint: true });
1749
+ const pathStat = lstatAtSync(storage.descriptor, storage.name, { bigint: true });
1555
1750
  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));
1751
+ descriptor = openAtSync(storage.descriptor, storage.name, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
1557
1752
  const before = fstatSync(descriptor, { bigint: true });
1558
1753
  if (!before.isFile() || before.nlink !== 1n) return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock must be a single-link regular file');
1559
1754
  if (before.size > NATIVE_LOCK_MAX_BYTES_BIGINT) return nativeLockFailure('NATIVE_LOCK_SIZE_LIMIT', 'native lock exceeds the 256 KiB safety limit');
@@ -1565,7 +1760,7 @@ function readHarnessProjection(storage) {
1565
1760
  offset += count;
1566
1761
  }
1567
1762
  const after = fstatSync(descriptor, { bigint: true });
1568
- const finalPath = lstatSync(reference, { bigint: true });
1763
+ const finalPath = lstatAtSync(storage.descriptor, storage.name, { bigint: true });
1569
1764
  const snapshot = (stat) => ({
1570
1765
  dev: stat.dev, ino: stat.ino, mode: stat.mode, uid: stat.uid, gid: stat.gid,
1571
1766
  nlink: stat.nlink, size: stat.size, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs,
@@ -1583,17 +1778,14 @@ function readHarnessProjection(storage) {
1583
1778
  }
1584
1779
 
1585
1780
  function writeHarnessProjectionAtomic(storage, content, expected) {
1586
- const reference = `/proc/self/fd/${storage.descriptor}`;
1587
1781
  const temporaryName = `.${storage.name}.${process.pid}.${randomUUID()}.tmp`;
1588
- const temporary = join(reference, temporaryName);
1589
- const destination = join(reference, storage.name);
1590
1782
  let descriptor;
1591
1783
  let replaced = false;
1592
1784
  try {
1593
1785
  if (Buffer.byteLength(content, 'utf8') > NATIVE_LOCK_MAX_BYTES) {
1594
1786
  return nativeLockFailure('NATIVE_LOCK_SIZE_LIMIT', 'projected native lock exceeds the 256 KiB safety limit');
1595
1787
  }
1596
- descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
1788
+ descriptor = openAtSync(storage.descriptor, temporaryName, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
1597
1789
  const bytes = Buffer.from(content, 'utf8');
1598
1790
  let offset = 0;
1599
1791
  while (offset < bytes.length) offset += writeSync(descriptor, bytes, offset, bytes.length - offset, null);
@@ -1601,7 +1793,7 @@ function writeHarnessProjectionAtomic(storage, content, expected) {
1601
1793
  closeSync(descriptor);
1602
1794
  descriptor = undefined;
1603
1795
  assertNativeLockStorage(storage);
1604
- const current = lstatSync(destination, { bigint: true });
1796
+ const current = lstatAtSync(storage.descriptor, storage.name, { bigint: true });
1605
1797
  const currentSnapshot = {
1606
1798
  dev: current.dev, ino: current.ino, mode: current.mode, uid: current.uid, gid: current.gid,
1607
1799
  nlink: current.nlink, size: current.size, mtimeNs: current.mtimeNs, ctimeNs: current.ctimeNs,
@@ -1609,7 +1801,7 @@ function writeHarnessProjectionAtomic(storage, content, expected) {
1609
1801
  if (current.isSymbolicLink() || !sameNativeLockSnapshot(currentSnapshot, expected)) {
1610
1802
  return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock changed before atomic replacement');
1611
1803
  }
1612
- renameSync(temporary, destination);
1804
+ renameAtSync(storage.descriptor, temporaryName, storage.descriptor, storage.name);
1613
1805
  replaced = true;
1614
1806
  fsyncSync(storage.descriptor);
1615
1807
  } catch (error) {
@@ -1617,7 +1809,7 @@ function writeHarnessProjectionAtomic(storage, content, expected) {
1617
1809
  throw error;
1618
1810
  } finally {
1619
1811
  if (descriptor !== undefined) closeSync(descriptor);
1620
- rmSync(temporary, { force: true });
1812
+ unlinkAtSync(storage.descriptor, temporaryName, { force: true });
1621
1813
  }
1622
1814
  return undefined;
1623
1815
  }
@@ -1651,7 +1843,9 @@ function projectSessionLocks(projectDir, { resumeMsg = null } = {}) {
1651
1843
  const lockInner = active
1652
1844
  ? `## Session Lock: ACTIVE
1653
1845
 
1654
- **Chati.dev session is ACTIVE.** Follow these rules for EVERY message:
1846
+ ${DELEGATED_EXECUTION_BOUNDARY}
1847
+
1848
+ **Chati.dev session is ACTIVE.** The user-facing coordinator follows these rules for EVERY user message:
1655
1849
 
1656
1850
  1. Read \`${target.orchestratorPath}\` and follow its routing logic
1657
1851
  2. Route ALL user messages through the current agent: \`${session.current_agent}\`
@@ -1731,6 +1925,24 @@ function removeSessionLock(projectDir, resumeMsg) {
1731
1925
  // Sub-command handlers
1732
1926
  // ---------------------------------------------------------------------------
1733
1927
 
1928
+ function classifyParallelExecutions(nextInfo, session) {
1929
+ if (!nextInfo?.isParallel || nextInfo.group.length < 2) return null;
1930
+ const incomplete = nextInfo.group.filter(agent => (
1931
+ !session.completed_agents?.includes(agent) && session.agents?.[agent]?.status !== 'completed'
1932
+ ));
1933
+ const held = incomplete.filter(agent => session.agents?.[agent]?.status === 'awaiting_advance'
1934
+ || session.agents?.[agent]?.execution_claim_state === 'awaiting_advance');
1935
+ const now = Date.now();
1936
+ const live = incomplete.filter(agent => {
1937
+ const state = session.agents?.[agent];
1938
+ const expiry = Date.parse(state?.execution_claim_expires_at || '');
1939
+ return state?.status === 'in_progress' && Boolean(state.execution_claim_id)
1940
+ && Number.isFinite(expiry) && expiry > now;
1941
+ });
1942
+ return { held, live, dispatchable: incomplete.filter(agent => !held.includes(agent) && !live.includes(agent)) };
1943
+ }
1944
+
1945
+
1734
1946
  async function handleNext(projectDir) {
1735
1947
  const result = await _handleNextInner(projectDir);
1736
1948
  let dispatchMutation = null;
@@ -1755,8 +1967,9 @@ async function handleNext(projectDir) {
1755
1967
  } : null;
1756
1968
  if (result.model_info?.source === 'installation-v2') {
1757
1969
  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 }];
1970
+ ? result.parallel_spawn_commands.map((entry) => ({ agent: entry.agent, taskId: `${entry.agent}-task`,
1971
+ binding: entry.agent === result.agent ? binding : resolveAgentModel(entry.agent, projectDir) }))
1972
+ : [{ agent: result.agent, taskId: `${result.agent}-task`, binding }];
1760
1973
  const claimed = claimAgentExecutions(projectDir, dispatchedAgents);
1761
1974
  if (!claimed.saved) {
1762
1975
  return errorResult(`Could not persist canonical agent state: ${claimed.error}`, 'SESSION_WRITE_FAILED');
@@ -1886,6 +2099,9 @@ async function _handleNextInner(projectDir) {
1886
2099
  const installationV2 = loadRuntimeInstallationV2(projectDir);
1887
2100
  const runtimePipeline = getRuntimeAgentPipeline(projectDir, session);
1888
2101
 
2102
+ let revisionOwner;
2103
+ try { revisionOwner = getPlanningContractRevisionOwner(session, projectDir); }
2104
+ catch (error) { return errorResult(error.message, error.code); }
1889
2105
  const pending = session.pending_interaction;
1890
2106
  if (pending?.status === 'relay_in_flight') {
1891
2107
  const relayStartedAt = Date.parse(pending.relay_started_at || '');
@@ -2067,22 +2283,48 @@ async function _handleNextInner(projectDir) {
2067
2283
  }
2068
2284
 
2069
2285
  // Check for in-progress agent (resume scenario)
2070
- const currentAgent = session.current_agent || null;
2286
+ let rollbackAgent;
2287
+ try { rollbackAgent = getPendingPlanningRollbackTarget(session); }
2288
+ catch (error) { return errorResult(error.message, error.code); }
2289
+ const currentAgent = revisionOwner || rollbackAgent || session.current_agent || null;
2071
2290
  const completedAgents = session.completed_agents || [];
2072
- if (currentAgent && !completedAgents.includes(currentAgent)) {
2291
+ const lastCompletedAgent = completedAgents.includes(session.last_handoff) ? session.last_handoff
2292
+ : completedAgents.includes(session.current_agent) ? session.current_agent
2293
+ : completedAgents.findLast(agent => getAgentDefinition(agent)) || null;
2294
+ const peerRecovery = installationV2 && !rollbackAgent && !revisionOwner
2295
+ ? getNextAgent(lastCompletedAgent || currentAgent, completedAgents, { recoverPlanningPeers: true }) : null;
2296
+ // A legacy QA/Phases cursor must not hide an unfinished Planning member.
2297
+ // Its claim or held result stays intact; the reservation/start guard prevents
2298
+ // a stale downstream command from launching before the predecessors finish.
2299
+ if (currentAgent && !completedAgents.includes(currentAgent)
2300
+ && (!peerRecovery?.isParallel || peerRecovery.group.includes(currentAgent))) {
2073
2301
  const agent = currentAgent;
2074
2302
  const agentStatus = session.agents?.[agent]?.status;
2075
- if (agentStatus === 'awaiting_advance') {
2303
+ const resumeGroup = installationV2 && !rollbackAgent && !revisionOwner
2304
+ ? getNextAgent(session.last_handoff || currentAgent, completedAgents, { recoverPlanningPeers: true }) : null;
2305
+ const runnablePeer = !session.agents?.[agent]?.content_review_pending && resumeGroup?.group.includes(agent)
2306
+ && classifyParallelExecutions(resumeGroup, session)?.dispatchable.some(peer => peer !== agent);
2307
+ if (agentStatus === 'awaiting_advance' && !runnablePeer) {
2076
2308
  return {
2077
2309
  action: 'awaiting_agent_advance',
2078
2310
  agent,
2079
2311
  code: 'AGENT_AWAITING_ADVANCE',
2312
+ result_status: session.agents[agent].execution_result_status || null,
2313
+ execution_claim_id: session.agents[agent].execution_claim_id || null,
2314
+ execution_finished_at: session.agents[agent].execution_finished_at || null,
2315
+ requires_recovery: session.agents[agent].execution_result_status !== 'complete',
2316
+ content_rejection_command: ['detail', 'architect', 'ux', 'qa-planning'].includes(agent)
2317
+ && session.agents[agent].execution_result_status === 'complete'
2318
+ && session.agents[agent].execution_claim_id && session.agents[agent].execution_finished_at
2319
+ ? `node .chati.dev/orchestrator/chati-router.js reject-result --agent ${shellQuote(agent)} --execution-claim-id ${shellQuote(session.agents[agent].execution_claim_id)} --execution-finished-at ${shellQuote(session.agents[agent].execution_finished_at)} --reason <content-review-reason>` : null,
2080
2320
  pipeline_progress: getPipelineProgress(pipelineState),
2081
2321
  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.`,
2322
+ status_summary: session.agents[agent].execution_result_status === 'complete'
2323
+ ? `${agent} returned complete and is waiting for canonical advance.`
2324
+ : `${agent} has a legacy ambiguous result. Do not advance it as successful; inspect its original result before negative recovery.`,
2083
2325
  };
2084
2326
  }
2085
- if (agentStatus === 'in_progress') {
2327
+ if (agentStatus === 'in_progress' || agentStatus === 'pending_rework' || rollbackAgent === agent) {
2086
2328
  const agentFile = getAgentFile(agent, projectDir) || null;
2087
2329
  const agentDef = getAgentDefinition(agent);
2088
2330
  const isInteractive = INTERACTIVE_AGENTS.includes(agent);
@@ -2119,7 +2361,7 @@ async function _handleNextInner(projectDir) {
2119
2361
  agent,
2120
2362
  agent_file: agentFile,
2121
2363
  phase: agentDef?.phase || session.mode,
2122
- spawn_command: buildSpawnCommand(agent, projectDir, session.last_handoff || 'none', modelInfo.provider, 600000, modelInfo.model, modelInfo.source === 'installation-v2', modelInfo),
2364
+ spawn_command: buildSpawnCommand(agent, projectDir, session.last_handoff || 'none', modelInfo.provider, getAgentTimeout(agent), modelInfo.model, modelInfo.source === 'installation-v2', modelInfo),
2123
2365
  parallel_spawn_command: null,
2124
2366
  parallel_agents: [],
2125
2367
  handoff_status: { valid: true, missing: [], warnings: ['Resuming in-progress agent'] },
@@ -2128,13 +2370,13 @@ async function _handleNextInner(projectDir) {
2128
2370
  context_bracket: estimateContextBracket(completedAgents.length, runtimePipeline.length),
2129
2371
  pipeline_progress: getPipelineProgress(pipelineState),
2130
2372
  session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
2131
- status_summary: `Resuming ${agent} (in progress).`,
2373
+ status_summary: `${agentStatus === 'pending_rework' ? 'Reworking' : 'Resuming'} ${agent}.`,
2132
2374
  };
2133
2375
  }
2134
2376
  }
2135
2377
 
2136
2378
  // Determine last completed agent
2137
- const lastAgent = session.last_handoff || session.current_agent || null;
2379
+ const lastAgent = lastCompletedAgent;
2138
2380
 
2139
2381
  // If no agent has started yet, route to first agent
2140
2382
  if (!lastAgent || completedAgents.length === 0) {
@@ -2164,7 +2406,8 @@ async function _handleNextInner(projectDir) {
2164
2406
  // Get next agent — if lastAgent is not in AGENT_PIPELINE (e.g. qa-planning
2165
2407
  // which lives inside Planning Team), fall back to the last completed agent
2166
2408
  // that IS in the pipeline so we can find the correct successor.
2167
- let nextInfo = getNextAgent(lastAgent, completedAgents);
2409
+ let nextInfo = revisionOwner ? { next: revisionOwner, isParallel: false, group: [revisionOwner] }
2410
+ : getNextAgent(lastAgent, completedAgents, { recoverPlanningPeers: Boolean(installationV2) });
2168
2411
 
2169
2412
  if ((!nextInfo || !nextInfo.next) && lastAgent) {
2170
2413
  const agentInPipeline = getAgentDefinition(lastAgent);
@@ -2173,7 +2416,7 @@ async function _handleNextInner(projectDir) {
2173
2416
  const pipelineNames = AGENT_PIPELINE.map((a) => a.name);
2174
2417
  for (let i = completedAgents.length - 1; i >= 0; i--) {
2175
2418
  if (pipelineNames.includes(completedAgents[i])) {
2176
- nextInfo = getNextAgent(completedAgents[i], completedAgents);
2419
+ nextInfo = getNextAgent(completedAgents[i], completedAgents, { recoverPlanningPeers: Boolean(installationV2) });
2177
2420
  if (nextInfo && nextInfo.next) break;
2178
2421
  }
2179
2422
  }
@@ -2194,17 +2437,12 @@ async function _handleNextInner(projectDir) {
2194
2437
  // Parallel groups can be resumed after only a subset has completed. Never
2195
2438
  // send completed members back through the claim layer, and do not let a live
2196
2439
  // 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.
2440
+ // Held results cannot be redispatched or treated as accepted, but they must
2441
+ // not prevent a separate unfinished member from recovering its own attempt.
2199
2442
  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) {
2443
+ const { held, live: liveAgents, dispatchable: dispatchableGroup } = classifyParallelExecutions(nextInfo, session);
2444
+ const awaitingAdvance = held[0];
2445
+ if (dispatchableGroup.length === 0 && awaitingAdvance) {
2208
2446
  return {
2209
2447
  action: 'awaiting_agent_advance',
2210
2448
  agent: awaitingAdvance,
@@ -2214,16 +2452,6 @@ async function _handleNextInner(projectDir) {
2214
2452
  status_summary: `${awaitingAdvance} finished its routed process and is waiting for canonical advance.`,
2215
2453
  };
2216
2454
  }
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
2455
  if (dispatchableGroup.length === 0 && liveAgents.length > 0) {
2228
2456
  return {
2229
2457
  action: 'agent_execution_in_flight',
@@ -2305,10 +2533,10 @@ async function _handleNextInner(projectDir) {
2305
2533
  teamData = { team_id: teamId, team_type: 'build', members: teamConfig.members };
2306
2534
  } else if (isInteractive) {
2307
2535
  action = 'spawn_routed_interactive';
2308
- spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000, modelInfo.model, modelInfo.source === 'installation-v2', modelInfo);
2536
+ spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, getAgentTimeout(nextAgent), modelInfo.model, modelInfo.source === 'installation-v2', modelInfo);
2309
2537
  } else {
2310
2538
  action = 'spawn_autonomous';
2311
- spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000, modelInfo.model, modelInfo.source === 'installation-v2', modelInfo);
2539
+ spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, getAgentTimeout(nextAgent), modelInfo.model, modelInfo.source === 'installation-v2', modelInfo);
2312
2540
  }
2313
2541
 
2314
2542
  // Surface criteria (Article XVII): even in autonomous mode, a high-stakes
@@ -2622,14 +2850,109 @@ async function handleScaffoldDecision(projectDir, session, decision, agent, scor
2622
2850
  };
2623
2851
  }
2624
2852
 
2853
+ async function handleRejectResult(projectDir, args) {
2854
+ if (!loadRuntimeInstallationV2(projectDir)) return errorResult('Content rejection requires Planning v2.', 'V2_INSTALLATION_REQUIRED');
2855
+ const binding = resolveAgentModel(args.agent, projectDir);
2856
+ const result = rejectAgentResult(projectDir, { agent: args.agent, claimId: args['execution-claim-id'],
2857
+ finishedAt: args['execution-finished-at'], reason: args.reason, binding });
2858
+ if (!result.saved || !result.rejected) return errorResult(result.error, result.code || 'SESSION_WRITE_FAILED');
2859
+ let projection;
2860
+ try { projection = writeSessionLock(projectDir, args.agent); }
2861
+ catch (error) { projection = { projected: false, error: error.message }; }
2862
+ return { rejected: true, advanced: false, retry_ready: true, agent: args.agent,
2863
+ projection: { projected: projection?.projected === true, ...(projection?.error ? { error: projection.error } : {}) },
2864
+ status_summary: 'Complete execution preserved with content rejection. No gate advanced; invoke next separately for rework.' };
2865
+ }
2866
+
2867
+ async function handleRevalidateQa(projectDir, args) {
2868
+ const agent = args.agent || 'qa-planning';
2869
+ const result = revalidateQaAfterCouncil(projectDir, { agent, claimId: args['execution-claim-id'],
2870
+ finishedAt: args['execution-finished-at'], gateId: args['council-gate-id'] });
2871
+ if (!result.saved || !result.revalidated) return errorResult(result.error, result.code || 'SESSION_WRITE_FAILED');
2872
+ return projectPlanningQaReservation(projectDir, agent, result, 'revalidated');
2873
+ }
2874
+
2875
+ async function handleReconcilePlanning(projectDir, args) {
2876
+ const agent = args.agent || 'qa-planning';
2877
+ const result = await executePlanningReconciliation({ projectDir, match: { agent,
2878
+ claimId: args['execution-claim-id'], finishedAt: args['execution-finished-at'], gateId: args['council-gate-id'] } });
2879
+ if (!result.settled) return errorResult(result.error, result.code || 'PLANNING_RECONCILIATION_FAILED');
2880
+ if (!result.authorized) return { reconciled: true, authorized: false, advanced: false, blocked: true,
2881
+ code: 'PLANNING_RECONCILIATION_DENIED',
2882
+ decision_path: result.decision_path, assessments: result.assessments,
2883
+ status_summary: 'The bounded assessment denied fresh QA. Preserve the held result and fix the cited material; unchanged bytes cannot launch another assessment.' };
2884
+ return projectPlanningQaReservation(projectDir, agent, result, 'reconciled');
2885
+ }
2886
+
2887
+ async function handleRevisePlanningContract(projectDir, args) {
2888
+ const rawDirection = args['direction-json'];
2889
+ if (typeof rawDirection !== 'string' || !rawDirection || Buffer.byteLength(rawDirection, 'utf8') > 16 * 1024) {
2890
+ return errorResult('Missing or oversized --direction-json.', 'PLANNING_CONTRACT_REVISION_INPUT_INVALID');
2891
+ }
2892
+ let direction;
2893
+ try { direction = JSON.parse(rawDirection); }
2894
+ catch { return errorResult('--direction-json must be valid closed-schema JSON.', 'PLANNING_CONTRACT_REVISION_INPUT_INVALID'); }
2895
+ const match = { agent: args.agent || 'qa-planning', claimId: args['execution-claim-id'],
2896
+ finishedAt: args['execution-finished-at'], gateId: args['council-gate-id'], direction,
2897
+ ...(args['previous-revision'] === undefined ? {} : { previousRevisionId: args['previous-revision'] }) };
2898
+ const reserved = reservePlanningContractRevision(projectDir, match);
2899
+ if (!reserved.saved || !reserved.reserved) return errorResult(reserved.error, reserved.code || 'PLANNING_CONTRACT_REVISION_FAILED');
2900
+ const reservation = reserved.reservation;
2901
+ let decision;
2902
+ try {
2903
+ if (reservation.status === 'reserved') decision = persistPlanningContractRevision({ projectDir, reservation });
2904
+ } catch (error) {
2905
+ return errorResult(error.message, error.code || 'PLANNING_CONTRACT_REVISION_FAILED');
2906
+ }
2907
+ const settled = settlePlanningContractRevision(projectDir, { ...match,
2908
+ revisionId: reservation.request.revision_id, launchNonce: reservation.launch_nonce,
2909
+ ...(decision ? { decisionSha256: decision.sha256 } : {}) });
2910
+ if (!settled.saved || !settled.settled) return errorResult(settled.error, settled.code || 'PLANNING_CONTRACT_REVISION_FAILED');
2911
+ if (settled.reused && [2, 3].includes(settled.revision.request.schema_version)) return { revised: true, reused: true, advanced: false,
2912
+ revision_id: settled.revision.request.revision_id, decision_path: settled.revision.decision_path,
2913
+ status_summary: 'This explicit direction was already consumed. No owner or automatic budget was reopened; use next for the current canonical state.' };
2914
+ let projection;
2915
+ try { projection = writeSessionLock(projectDir, 'detail'); }
2916
+ catch (error) { projection = { projected: false, error: error.message }; }
2917
+ try {
2918
+ recordEvent(projectDir, EventType.CORRECTION_ESCALATED, 'orchestrator', {
2919
+ source: 'planning_contract_revision', revision_id: settled.revision.request.revision_id,
2920
+ source_gate_id: settled.revision.request.source_gate_id, target_agent: 'detail',
2921
+ });
2922
+ } catch { /* Canonical session and immutable decision remain authoritative. */ }
2923
+ return { revised: true, reused: settled.reused === true, advanced: false, retry_ready: true,
2924
+ revision_id: settled.revision.request.revision_id, decision_path: settled.revision.decision_path,
2925
+ target_agent: 'detail', projection: { projected: projection?.projected === true,
2926
+ ...(projection?.error ? { error: projection.error } : {}) },
2927
+ status_summary: 'Planning ownership reopened at detail. Run detail, architect, UX, genuine fresh QA and the normal council; this direction approves no Planning content.' };
2928
+ }
2929
+
2930
+ function projectPlanningQaReservation(projectDir, agent, result, operation) {
2931
+ let projection;
2932
+ try { projection = writeSessionLock(projectDir, agent); }
2933
+ catch (error) { projection = { projected: false, error: error.message }; }
2934
+ if (!projection?.projected) {
2935
+ const released = releaseAgentExecution(projectDir, { agent, claimId: result.claimId });
2936
+ return { [operation]: true, advanced: false, spawn_command: null,
2937
+ code: 'SESSION_PROJECTION_FAILED', error: projection?.error, retry_ready: released.saved && released.released,
2938
+ status_summary: 'Revalidation authorization is preserved. Repair the projection, then invoke next; no provider was started.' };
2939
+ }
2940
+ return { [operation]: true, reused: result.reused === true, advanced: false, action: 'spawn_autonomous', agent,
2941
+ execution_claim_id: result.claimId, model_info: result.binding,
2942
+ spawn_command: buildSpawnCommand(agent, projectDir, result.session.last_handoff || 'ux',
2943
+ result.binding.provider, getAgentTimeout(agent), result.binding.model, true,
2944
+ { ...result.binding, execution_claim_id: result.claimId }),
2945
+ status_summary: 'Historical QA preserved. Run this one-shot command for a genuine new verdict, then canonical advance and a fresh council.' };
2946
+ }
2947
+
2625
2948
  async function handleAdvance(projectDir, args) {
2626
2949
  const agent = args.agent;
2627
- const score = parseInt(args.score, 10);
2628
- const status = args.status || 'completed';
2950
+ const score = args.score === undefined || args.score === '' ? NaN : Number(args.score);
2951
+ const status = args.status === 'complete' ? 'completed' : (args.status || 'completed');
2629
2952
  const decision = args.decision || null;
2630
2953
 
2631
2954
  if (!agent) return errorResult('Missing --agent flag', 'MISSING_AGENT');
2632
- if (isNaN(score)) return errorResult('Missing or invalid --score flag', 'MISSING_SCORE');
2955
+ if (!Number.isFinite(score) || score < 0 || score > 100) return errorResult('Missing or invalid --score flag', 'MISSING_SCORE');
2633
2956
 
2634
2957
  const { loaded, session } = loadSession(projectDir);
2635
2958
  if (!loaded || !session) return errorResult('No session found', 'NO_SESSION');
@@ -2666,6 +2989,21 @@ async function handleAdvance(projectDir, args) {
2666
2989
  );
2667
2990
  }
2668
2991
 
2992
+ // Non-complete outcomes cannot enter completion, artifact merge or pipeline
2993
+ // transitions. A legacy ambiguous hold can only be settled negatively by its
2994
+ // exact identity; callers must invoke next separately to reserve a retry.
2995
+ if (status !== 'completed') {
2996
+ if (runtimeV2 && status === 'error' && args['execution-finished-at']) {
2997
+ const recovery = rejectLegacyAgentResult(projectDir, { agent, finishedAt: args['execution-finished-at'] });
2998
+ if (!recovery.saved || !recovery.recovered) {
2999
+ return errorResult(recovery.error, recovery.code || 'SESSION_WRITE_FAILED');
3000
+ }
3001
+ return { advanced: false, recovered: true, retry_ready: true, agent,
3002
+ status_summary: 'Legacy result recorded as failure. No gate advanced; invoke next to retry.' };
3003
+ }
3004
+ return errorResult('Only a complete agent result can advance the pipeline.', 'AGENT_RESULT_NOT_COMPLETE');
3005
+ }
3006
+
2669
3007
  if (runtimeV2 && agent === 'tasks' && status === 'completed' && score < 95) {
2670
3008
  return {
2671
3009
  advanced: false,
@@ -2704,13 +3042,32 @@ async function handleAdvance(projectDir, args) {
2704
3042
  };
2705
3043
  }
2706
3044
 
2707
- if (runtimeV2 && status === 'completed' && agentState?.status !== 'awaiting_advance') {
3045
+ if (runtimeV2 && (agentState?.status !== 'awaiting_advance'
3046
+ || agentState?.execution_claim_state !== 'awaiting_advance' || agentState?.execution_result_status !== 'complete')) {
2708
3047
  return errorResult(
2709
3048
  `Agent ${agent} has no successful routed result awaiting canonical advance.`,
2710
3049
  'V2_AGENT_RESULT_NOT_HELD',
2711
3050
  );
2712
3051
  }
2713
3052
 
3053
+ let qaVerdict = null;
3054
+ if (runtimeV2 && agent === 'qa-planning') {
3055
+ const review = agentState.execution_qa_result;
3056
+ const threshold = getQaThreshold(agent, projectDir);
3057
+ if (review) {
3058
+ if (!isPlanningQaOutcomeValid(review, threshold)) {
3059
+ return errorResult('The held QA outcome is invalid.', 'QA_RESULT_UNVERIFIED');
3060
+ }
3061
+ if (review.score !== score) return errorResult('The supplied score does not match the held QA outcome.', 'QA_RESULT_SCORE_MISMATCH');
3062
+ qaVerdict = review.gate_result;
3063
+ } else {
3064
+ // Old complete holds prove execution, not approval. Compatibility is
3065
+ // strictly negative: never infer a passing plan from an old score100.
3066
+ if (score >= threshold) return errorResult('This legacy QA hold has no verified plan outcome; only negative correction is supported.', 'QA_RESULT_UNVERIFIED');
3067
+ qaVerdict = 'needs_correction';
3068
+ }
3069
+ }
3070
+
2714
3071
  // Handle user_preview decisions
2715
3072
  if (decision) {
2716
3073
  const pipelineState = sessionToPipelineState(session, projectDir);
@@ -2737,29 +3094,33 @@ async function handleAdvance(projectDir, args) {
2737
3094
  // Agents (e.g. qa-implementation) write Decision Trail entries to their handoff
2738
3095
  // instead of directly editing session.yaml (blocked by mode-governance hook).
2739
3096
  let handoffData = {};
2740
- // Handoff files live at <frameworkDir>/artifacts/handoffs/<agent>-handoff.md
2741
- // EXCEPT when artifacts/ is user-facing (non-framework). Currently install
2742
- // writes artifacts to project root <projectDir>/artifacts/. Check both locations
2743
- // to cover legacy + current layouts.
2744
- const fwDir = resolveFrameworkDir(projectDir);
2745
- const handoffCandidates = [
2746
- join(projectDir, 'artifacts', 'handoffs', `${agent}-handoff.md`),
2747
- join(projectDir, fwDir, 'artifacts', 'handoffs', `${agent}-handoff.md`),
2748
- ];
2749
- const handoffPath = handoffCandidates.find(p => existsSync(p));
2750
- if (handoffPath) {
2751
- try {
3097
+ let compiledRailHandoff = null;
3098
+ let visualApplicability = null;
3099
+ try {
3100
+ const handoffPath = findAgentHandoffPath(projectDir, agent);
3101
+ if (handoffPath) {
2752
3102
  const handoffMarkdown = readFileSync(handoffPath, 'utf-8');
2753
3103
  handoffData = parseHandoffDecisionTrail(handoffMarkdown);
2754
3104
  // Fase 10 auto-gate — parse scaffold candidates/signals alongside Decision Trail.
2755
3105
  const scaffold = parseHandoffScaffoldSignals(handoffMarkdown);
2756
3106
  handoffData.scaffold_candidates = scaffold.scaffold_candidates;
2757
3107
  handoffData.scaffold_signals = scaffold.scaffold_signals;
2758
- } catch {
2759
- // Non-fatal: if handoff can't be read, skip Decision Trail / scaffold merge
2760
3108
  }
3109
+ } catch (error) {
3110
+ if (error.code === 'INVALID_HANDOFF_DECISION_TRAIL') return errorResult(error.message, error.code);
3111
+ // Non-fatal: unreadable handoffs do not merge a Decision Trail or scaffold.
3112
+ // Do not fall back to older handoffs after selecting a new-format output.
2761
3113
  }
2762
3114
 
3115
+ if (runtimeV2 && agent === 'qa-planning' && qaVerdict === 'needs_correction'
3116
+ && !handoffData.decision_trail_entries?.some(entry => entry.resolved === false)) {
3117
+ return errorResult('Negative QA correction requires preserved unresolved findings.', 'QA_FINDINGS_REQUIRED');
3118
+ }
3119
+ const correctionTarget = args['correction-target'] || null;
3120
+ try {
3121
+ assertQaCorrectionTarget(sessionToPipelineState(session, projectDir), agent, { score, qaVerdict, correctionTarget, handoffData });
3122
+ } catch (error) { return errorResult(error.message, error.code); }
3123
+
2763
3124
  // Deterministic gates: block advance if required artifacts are missing.
2764
3125
 
2765
3126
  // V2 replaces the legacy Build/Deploy entry with a sealed, project-local
@@ -2773,18 +3134,17 @@ async function handleAdvance(projectDir, args) {
2773
3134
  // dependencies and ClickUp references.
2774
3135
  if (agent === 'tasks' && status === 'completed' && score >= 95 && loadRuntimeInstallationV2(projectDir)) {
2775
3136
  try {
2776
- const handoff = compilePlanningHandoff({ projectDir });
2777
- handoffData.rail_handoff = persistRailHandoff({ projectDir, handoff });
3137
+ compiledRailHandoff = compilePlanningHandoff({ projectDir, promoteCandidate: true });
2778
3138
  } catch (error) {
2779
3139
  return {
2780
3140
  advanced: false,
2781
3141
  blocked: true,
2782
3142
  reason: error.code || 'RAIL_HANDOFF_COMPILATION_FAILED',
2783
3143
  message: error.message,
2784
- expected_artifact: PLANNING_V2_TASKS_PATH,
3144
+ expected_artifact: PLANNING_V2_TASKS_CANDIDATE_PATH,
2785
3145
  action_required: {
2786
3146
  owner: 'Tasks and QA Planning agents',
2787
- artifact: PLANNING_V2_TASKS_PATH,
3147
+ artifact: PLANNING_V2_TASKS_CANDIDATE_PATH,
2788
3148
  requirements: ['source artifacts', 'task IDs and dependencies', 'Given-When-Then criteria', 'execution scopes', 'AI and human estimates', 'ClickUp references'],
2789
3149
  },
2790
3150
  };
@@ -3171,6 +3531,24 @@ async function handleAdvance(projectDir, args) {
3171
3531
 
3172
3532
  // UX (brand-architect) gate
3173
3533
  if (agent === 'ux') {
3534
+ visualApplicability = resolveNonVisualUxApplicability(projectDir, runtimeV2);
3535
+ if (visualApplicability) {
3536
+ const handoff = (await loadHandoffContext(projectDir, 'ux')).context.handoff;
3537
+ let specification = '';
3538
+ try { specification = readFileSync(join(projectDir, 'artifacts/4-UX/ux-specification.md'), 'utf8').trim(); }
3539
+ catch { /* A missing/unreadable specification cannot be exempted. */ }
3540
+ // The documented handoff sections do not require frontmatter. This v2
3541
+ // branch has already required a canonical complete routed result above;
3542
+ // absent metadata is not an inferred completion, while an explicit
3543
+ // non-complete declaration must still block even with a complete hold.
3544
+ const compatibleStatus = handoff?.status === 'complete' || handoff?.status_declared === false;
3545
+ if (!specification || !compatibleStatus || !validateHandoffIntegrity(handoff, 'qa-planning').valid) {
3546
+ return {
3547
+ advanced: false, blocked: true, reason: 'ux_non_visual_artifacts_missing',
3548
+ message: 'A non-visual Brief exempts only visual deliverables. A non-empty ux-specification.md and complete UX handoff with summary/outputs remain required.',
3549
+ };
3550
+ }
3551
+ }
3174
3552
  // Fase 7 — Task-to-UX fidelity. Before checking brandbook.html, verify that
3175
3553
  // if the brief or reference-analysis cites premium animation references,
3176
3554
  // the brand-architect has produced the animation-inventory artifact. The
@@ -3178,7 +3556,7 @@ async function handleAdvance(projectDir, args) {
3178
3556
  // reveals" instead of the specific patterns the user's references
3179
3557
  // encode, and qa-visual has no per-pattern checklist for Mode 2.
3180
3558
  const inventoryPath = join(projectDir, 'artifacts', '4-UX', 'animation-inventory.md');
3181
- if (!existsSync(inventoryPath)) {
3559
+ if (!visualApplicability && !existsSync(inventoryPath)) {
3182
3560
  const corpus = readBriefAndRefCorpus(projectDir);
3183
3561
  const refs = detectPremiumRefs(corpus);
3184
3562
  if (refs.detected) {
@@ -3204,18 +3582,147 @@ async function handleAdvance(projectDir, args) {
3204
3582
  }
3205
3583
 
3206
3584
  const brandbookPath = join(projectDir, 'artifacts', '4-UX', 'brandbook.html');
3207
- if (!existsSync(brandbookPath)) {
3585
+ if (!visualApplicability && !existsSync(brandbookPath)) {
3208
3586
  return {
3209
3587
  advanced: false,
3210
3588
  blocked: true,
3211
3589
  reason: 'brandbook_html_missing',
3212
- message: 'brandbook.html is a mandatory deliverable for the UX agent. Write artifacts/4-UX/brandbook.html before advancing.',
3590
+ message: 'brandbook.html is required unless the current council-approved Brief explicitly declares non-visual scope. Write artifacts/4-UX/brandbook.html or resolve the upstream applicability declaration before advancing.',
3591
+ };
3592
+ }
3593
+ }
3594
+
3595
+ // The product-level council follows deterministic artifact checks. It gates
3596
+ // the three user-visible planning boundaries and never substitutes an
3597
+ // unselected provider when a configured member is unavailable.
3598
+ if (runtimeV2 && status === 'completed' && qaVerdict !== 'needs_correction') {
3599
+ try {
3600
+ const council = await enforceRuntimeCouncilGate({
3601
+ projectDir,
3602
+ agent,
3603
+ session,
3604
+ artifact: runtimeV2,
3605
+ ...(compiledRailHandoff ? { handoff: compiledRailHandoff } : {}),
3606
+ });
3607
+ if (council.required && !council.approved) {
3608
+ const revisionCapability = agent === 'qa-planning'
3609
+ && (session.planning_contract_revisions?.length || session.agents[agent]?.planning_reconciliations?.some(entry => entry.authorization))
3610
+ ? planningContractRevisionCapability(projectDir, session, council.result?.gate_id || council.gate_id)
3611
+ : null;
3612
+ // Every automatic recovery budget can be consumed without a single QA
3613
+ // authorization (all reconciliation targets denied). That state is
3614
+ // terminal for the automatic route and must be reported as such instead
3615
+ // of pointing the operator at a command that can only fail again.
3616
+ const reconciliationBudgetExhausted = agent === 'qa-planning' && !revisionCapability
3617
+ && planningRecoveryBudgetExhausted(session, agent);
3618
+ return {
3619
+ advanced: false,
3620
+ blocked: true,
3621
+ reason: council.code || 'COUNCIL_GATE_REWORK_REQUIRED',
3622
+ message: council.message || `The ${council.result?.stage || council.stage} planning council recorded accepted blocking findings. Rework the frozen artifacts before advancing.`,
3623
+ stage: council.result?.stage || council.stage,
3624
+ gate_id: council.result?.gate_id || council.gate_id || null,
3625
+ blockers: council.blockers || council.result?.decision?.blocking_findings || [],
3626
+ ...(reconciliationBudgetExhausted ? { recovery_budget_exhausted: true } : {}),
3627
+ action_required: {
3628
+ owner: 'planning artifact owner',
3629
+ next_step: agent === 'qa-planning'
3630
+ ? reconciliationBudgetExhausted
3631
+ ? 'Automatic recovery budgets are exhausted: rejection, council revalidation and every reconciliation target were consumed without a QA authorization. No automatic command can approve this Planning. Reopen Planning with new technical direction through deviation --type rollback --target <detail|architect|ux> --confirm true, correct the accepted findings, then run genuine fresh QA and the council again.'
3632
+ : revisionCapability
3633
+ ? revisionCapability.available
3634
+ ? `Automatic recovery budgets remain exhausted. A distinct explicit controller direction may use revise-planning-contract${revisionCapability.previousRevisionId ? ` --previous-revision ${revisionCapability.previousRevisionId}` : ''} with this exact held claim, finished-at, council-gate-id and closed direction JSON. Preserve current bytes until genuine owners reopen; this direction approves no Planning content.`
3635
+ : `Preserve the held QA, council findings and recovery history. Contract reopening is unavailable at this boundary (${revisionCapability.code}); resolve that boundary before requesting a direction.`
3636
+ : session.agents[agent]?.council_revalidations?.length
3637
+ ? 'Correct the accepted findings without changing the held QA report. Use reconcile-planning with the exact held claim, finished-at and this council-gate-id. It assesses only readiness for genuine fresh QA; it cannot approve Planning.'
3638
+ : 'resolve the accepted findings without changing the held QA report. Use reject-result while its budget remains; at the limit use revalidate-qa with the exact held claim, finished-at and this council-gate-id. Execute genuine QA, then advance.'
3639
+ : 'resolve the accepted high or critical findings, then re-run the canonical advance after the artifact delta',
3640
+ },
3641
+ };
3642
+ }
3643
+ } catch (error) {
3644
+ return {
3645
+ advanced: false,
3646
+ blocked: true,
3647
+ reason: error.code || 'COUNCIL_GATE_EXECUTION_FAILED',
3648
+ message: error.message,
3649
+ action_required: error.code === 'COUNCIL_GATE_PROMPT_SIZE_LIMIT' ? {
3650
+ owner: 'planning artifact owner',
3651
+ next_step: 'reduce the frozen planning artifacts to fit the council material budget, preserve explicit scope and criteria, then re-run advance. No provider was invoked and no provider reconfiguration is needed.',
3652
+ ...error.details,
3653
+ } : {
3654
+ owner: 'installation owner',
3655
+ next_step: 'restore the selected council provider or explicitly reconfigure the installation. No provider fallback was used.',
3656
+ },
3657
+ };
3658
+ }
3659
+ }
3660
+
3661
+ // A Tasks completion only becomes an executable RAIL authority after the
3662
+ // execution-design council approved the exact frozen planning bundle above.
3663
+ // Keep the compiler output in memory until that point, so a blocked council
3664
+ // cannot leave a dispatchable handoff behind for a manual RAIL invocation.
3665
+ if (compiledRailHandoff) {
3666
+ try {
3667
+ const council = resolveRuntimeCouncilGatePolicyV2({ artifact: runtimeV2, stage: 'execution-design' });
3668
+ if (council.reason === 'GUIDED_COUNCIL_REQUIRED') {
3669
+ return {
3670
+ advanced: false,
3671
+ blocked: true,
3672
+ reason: 'GUIDED_COUNCIL_REQUIRED',
3673
+ message: 'this installation has no autonomous council quorum. Reconfigure it with two selected review providers before creating a dispatchable RAIL handoff.',
3674
+ };
3675
+ }
3676
+ const persisted = persistRailHandoff({ projectDir, handoff: compiledRailHandoff });
3677
+ const council_binding = council.enabled
3678
+ ? persistRailHandoffCouncilBinding({
3679
+ projectDir,
3680
+ handoff: compiledRailHandoff,
3681
+ artifact: runtimeV2,
3682
+ policy: council.policy,
3683
+ })
3684
+ : undefined;
3685
+ handoffData.rail_handoff = Object.freeze({
3686
+ ...persisted,
3687
+ ...(council_binding ? { council_binding } : {}),
3688
+ });
3689
+ } catch (error) {
3690
+ return {
3691
+ advanced: false,
3692
+ blocked: true,
3693
+ reason: error.code || 'RAIL_HANDOFF_PERSISTENCE_FAILED',
3694
+ message: error.message,
3695
+ expected_artifact: PLANNING_V2_TASKS_CANDIDATE_PATH,
3696
+ action_required: {
3697
+ owner: 'Tasks and QA Planning agents',
3698
+ artifact: PLANNING_V2_TASKS_CANDIDATE_PATH,
3699
+ requirements: ['source artifacts', 'task IDs and dependencies', 'Given-When-Then criteria', 'execution scopes', 'AI and human estimates', 'ClickUp references'],
3700
+ },
3701
+ };
3702
+ }
3703
+ }
3704
+
3705
+ // Handoff/council awaits above may have yielded after the first applicability
3706
+ // snapshot. Revalidate the SAME approved subject and reference conflicts
3707
+ // immediately before the synchronous session mutation. Never commit a stale
3708
+ // visual exemption after an upstream edit.
3709
+ if (visualApplicability) {
3710
+ const current = resolveNonVisualUxApplicability(projectDir, runtimeV2);
3711
+ if (!current || current.sha256 !== visualApplicability.sha256
3712
+ || current.discovery_gate_id !== visualApplicability.discovery_gate_id) {
3713
+ return {
3714
+ advanced: false, blocked: true, reason: 'ux_visual_applicability_changed',
3715
+ message: 'Visual applicability changed before UX completion. Review the current Brief and visual references; no completion was recorded.',
3213
3716
  };
3214
3717
  }
3215
3718
  }
3216
3719
 
3217
3720
  // Record completion (merges Decision Trail entries from handoff into session.yaml)
3218
- await recordAgentCompletion(projectDir, { agent, status, score, handoffData });
3721
+ runRuntimeSecurityTestHook('beforeAgentCompletionCommit', { projectDir, agent });
3722
+ const completion = recordAgentCompletion(projectDir, { agent, status, score, handoffData,
3723
+ ...(runtimeV2 ? { expectedResult: { claimId: agentState.execution_claim_id, finishedAt: agentState.execution_finished_at } } : {}),
3724
+ });
3725
+ if (!completion.saved) return errorResult(completion.error, completion.code || 'SESSION_WRITE_FAILED');
3219
3726
  recordEvent(projectDir, EventType.AGENT_COMPLETED, agent, { score, status });
3220
3727
 
3221
3728
  // A v2 task graph has become an immutable RAIL handoff. Do not silently
@@ -3270,7 +3777,8 @@ async function handleAdvance(projectDir, args) {
3270
3777
  // unapplied. If so, return an action_required response with the 3-option
3271
3778
  // decision payload. qa-planning stays marked complete; the user's next
3272
3779
  // advance call must carry --decision apply|skip|dryrun.
3273
- if (status === 'completed' && score >= 95 && agent === 'qa-planning') {
3780
+ if (status === 'completed' && score >= 95 && agent === 'qa-planning'
3781
+ && (!runtimeV2 || qaVerdict === 'approved')) {
3274
3782
  const { session: postQa } = loadSession(projectDir);
3275
3783
  const gate = checkScaffoldGate(postQa || session);
3276
3784
  if (gate.fires) {
@@ -3292,6 +3800,8 @@ async function handleAdvance(projectDir, args) {
3292
3800
 
3293
3801
  const advanceResult = advancePipeline(pipelineState, agent, {
3294
3802
  score,
3803
+ qaVerdict,
3804
+ correctionTarget,
3295
3805
  handoffData,
3296
3806
  findings: handoffData?.decision_trail_entries || [],
3297
3807
  });
@@ -3419,7 +3929,7 @@ async function handleAdvance(projectDir, args) {
3419
3929
  max_cycles: 2,
3420
3930
  findings_count: (c.findings || []).length,
3421
3931
  pipeline_progress: getPipelineProgress(advanceResult.state || pipelineState),
3422
- status_summary: `${agent} scored ${c.score ?? 'n/a'} (below threshold). Fault origin: ${c.faultOrigin}. Re-opening ${c.target} (correction cycle ${c.cycle} of 2). Claude must invoke ${c.target} with the findings.`,
3932
+ status_summary: `${agent} requires correction (score ${c.score ?? 'n/a'}). Fault origin: ${c.faultOrigin}. Re-opening ${c.target} (correction cycle ${c.cycle} of 2). The controller must invoke ${c.target} on its bound provider with the findings.`,
3423
3933
  },
3424
3934
  };
3425
3935
  }
@@ -3474,7 +3984,10 @@ async function handleAdvance(projectDir, args) {
3474
3984
  }
3475
3985
 
3476
3986
  const next = await handleNext(projectDir);
3477
- return { advanced: true, agent_completed: agent, score, phase_transition: phaseTransition, next };
3987
+ return {
3988
+ advanced: true, agent_completed: agent, score, phase_transition: phaseTransition, next,
3989
+ ...(visualApplicability ? { visual_applicability: visualApplicability } : {}),
3990
+ };
3478
3991
  }
3479
3992
 
3480
3993
  async function handleInit(projectDir, args) {
@@ -3766,7 +4279,7 @@ async function handleStatus(projectDir) {
3766
4279
  execution_mode: session.execution_mode || 'interactive',
3767
4280
  active_harness: session.active_harness || null,
3768
4281
  current_execution_binding: session.current_execution_binding || null,
3769
- pending_interaction: session.pending_interaction || null,
4282
+ pending_interaction: summarizePendingInteraction(session.pending_interaction),
3770
4283
  };
3771
4284
  }
3772
4285
 
@@ -3832,6 +4345,24 @@ async function handleDeviation(projectDir, args) {
3832
4345
  const { loaded, session } = loadSession(projectDir);
3833
4346
  if (!loaded || !session) return errorResult('No session found', 'NO_SESSION');
3834
4347
 
4348
+ if (type === 'rollback' && loadRuntimeInstallationV2(projectDir)) {
4349
+ let impact;
4350
+ try { impact = inspectPlanningCorrectionRollback(projectDir, session, target); }
4351
+ catch (error) { return errorResult(error.message, error.code || 'PLANNING_CORRECTION_INVALID'); }
4352
+ if (!confirm) return { impact, applied: false, changes: [], new_state: null };
4353
+ runRuntimeSecurityTestHook('beforePlanningCorrectionCommit', { projectDir, target });
4354
+ const result = rollbackPlanningCorrection(projectDir, target);
4355
+ if (!result.saved || !result.applied) return errorResult(result.error, result.code || 'SESSION_WRITE_FAILED');
4356
+ let projection;
4357
+ try {
4358
+ projection = writeSessionLock(projectDir, target, {
4359
+ phase: result.session.mode, mode: result.session.execution_mode || 'interactive',
4360
+ });
4361
+ } catch (error) { projection = { projected: false, error: error.message }; }
4362
+ return { impact: result.impact, applied: true, changes: result.changes,
4363
+ new_state: { current_agent: result.session.current_agent }, projection };
4364
+ }
4365
+
3835
4366
  const pipelineState = sessionToPipelineState(session, projectDir);
3836
4367
 
3837
4368
  const details = { targetAgent: target };
@@ -4997,8 +5528,21 @@ function handleAssessCoverage(args) {
4997
5528
  // Main entry point
4998
5529
  // ---------------------------------------------------------------------------
4999
5530
 
5000
- export async function runOrchestrate(subCommand, argv, projectDir) {
5531
+ export async function runOrchestrate(subCommand, argv, projectDir, { write = writeOutput } = {}) {
5532
+ let result;
5533
+ try {
5534
+ result = await withUpgradeMaintenance(projectDir, () => runOrchestrateUnderMaintenance(subCommand, argv, projectDir));
5535
+ } catch (err) {
5536
+ result = errorResult(`Orchestrate error: ${err.message}`, err.code || 'INTERNAL_ERROR');
5537
+ process.exitCode = 1;
5538
+ }
5539
+ write(result);
5540
+ }
5541
+
5542
+ async function runOrchestrateUnderMaintenance(subCommand, argv, projectDir) {
5001
5543
  const args = parseArgs(argv);
5544
+ let result;
5545
+ const output = (value) => { result = value; };
5002
5546
 
5003
5547
  try {
5004
5548
  switch (subCommand) {
@@ -5008,6 +5552,18 @@ export async function runOrchestrate(subCommand, argv, projectDir) {
5008
5552
  case 'advance':
5009
5553
  output(await handleAdvance(projectDir, args));
5010
5554
  break;
5555
+ case 'reject-result':
5556
+ output(await handleRejectResult(projectDir, args));
5557
+ break;
5558
+ case 'revalidate-qa':
5559
+ output(await handleRevalidateQa(projectDir, args));
5560
+ break;
5561
+ case 'reconcile-planning':
5562
+ output(await handleReconcilePlanning(projectDir, args));
5563
+ break;
5564
+ case 'revise-planning-contract':
5565
+ output(await handleRevisePlanningContract(projectDir, args));
5566
+ break;
5011
5567
  case 'init':
5012
5568
  output(await handleInit(projectDir, args));
5013
5569
  break;
@@ -5075,11 +5631,12 @@ export async function runOrchestrate(subCommand, argv, projectDir) {
5075
5631
  output(await handleRunScriptTask(projectDir, args));
5076
5632
  break;
5077
5633
  default:
5078
- output(errorResult(`Unknown sub-command: ${subCommand}. Valid: next, advance, init, validate-handoff, status, deviation, exit, providers, detect-flow, backlog, qa-plan-score, qa-impl-score, scan, spawn-team, team-status, team-dissolve`, 'UNKNOWN_COMMAND'));
5634
+ output(errorResult(`Unknown sub-command: ${subCommand}. Valid: next, advance, reject-result, revalidate-qa, reconcile-planning, revise-planning-contract, init, validate-handoff, status, deviation, exit, providers, detect-flow, backlog, qa-plan-score, qa-impl-score, scan, spawn-team, team-status, team-dissolve`, 'UNKNOWN_COMMAND'));
5079
5635
  break;
5080
5636
  }
5081
5637
  } catch (err) {
5082
5638
  output(errorResult(`Orchestrate error: ${err.message}`, err.code || 'INTERNAL_ERROR'));
5083
5639
  process.exitCode = 1;
5084
5640
  }
5641
+ return result;
5085
5642
  }