chati-dev 4.5.28 → 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 +117 -12
  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
@@ -9,16 +9,35 @@ import * as yaml from 'js-yaml';
9
9
  import {
10
10
  closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync,
11
11
  openSync, readSync, realpathSync, renameSync, rmSync, rmdirSync,
12
- unlinkSync, writeFileSync, readFileSync,
12
+ unlinkSync, writeFileSync, readFileSync, readdirSync,
13
13
  } from 'fs';
14
14
  import { basename, join, dirname, resolve } from 'path';
15
15
  import { randomUUID } from 'crypto';
16
16
  import { hostname } from 'node:os';
17
+ import { canonicalize, canonicalSerialize } from '@chati/core';
18
+ import { isPlanningQaOutcomeValid } from '../terminal/handoff-parser.js';
19
+ import { getQaThreshold, resetPipelineTo, MAX_CORRECTION_CYCLES } from './pipeline-manager.js';
20
+ import { AGENT_PIPELINE, assertV2PlanningPredecessors } from './agent-selector.js';
21
+ import { pinCouncilQaRevalidation, pinPlanningQaSubject, pinPlanningQaReport } from './council-gate-runtime.js';
22
+ import { pinPlanningReconciliation, readPlanningReconciliationDecision,
23
+ readAttestedPlanningReconciliationDecision } from './planning-reconciliation.js';
24
+ import { PLANNING_CONTRACT_REVISION_OWNERS, PLANNING_CONTRACT_REVISION_TARGET, normalizePlanningContractDirection,
25
+ pinPlanningContractRevision, pinPlanningContractRevisionOwnerBaseline, pinPlanningContractRevisionOwnerOutput,
26
+ readAttestedPlanningContractRevision, planningContractRevisionHistory, planningRevisionResultDigest,
27
+ planningRevisionTerminalDigest, derivePlanningCorrectionSource,
28
+ authenticatePlanningContractRevisionHistory } from './planning-contract-revision.js';
29
+ import { resolveRuntimeInvocationV2 } from './runtime-installation-v2.js';
17
30
 
18
31
  const SESSION_FILE = '.chati/session.yaml';
19
32
  const SESSION_MAX_BYTES = 4 * 1024 * 1024;
20
33
  const SESSION_LOCK_OWNER_MAX_BYTES = 16 * 1024;
21
34
  const SESSION_LOCK_STALE_AFTER_MS = 30_000;
35
+ // Concurrent fsync-backed writers can legitimately queue longer than two
36
+ // seconds. Bound the wait without stealing live locks or shortening stale leases.
37
+ const SESSION_LOCK_WAIT_MS = 10_000;
38
+ const RELAY_HISTORY_MAX_TURNS = 64;
39
+ const RELAY_HISTORY_MAX_BYTES = 1024 * 1024;
40
+ const INITIAL_TASK_CONTEXT_MAX_BYTES = 256 * 1024;
22
41
  const SESSION_TEST_HOOKS = Symbol.for('chati.session-manager.security-test-hooks');
23
42
  const UNSAFE_SESSION_LOCK_OWNER = Symbol('unsafe-session-lock-owner');
24
43
 
@@ -142,6 +161,7 @@ function readRegularFileBounded(opened) {
142
161
  function writeFileAtomic(path, content, encoding = 'utf-8', {
143
162
  expectedDestinationIdentity,
144
163
  expectedDirectoryIdentity,
164
+ beforeCommit,
145
165
  } = {}) {
146
166
  if (Buffer.byteLength(String(content), encoding) > SESSION_MAX_BYTES) {
147
167
  throw new Error('session.yaml exceeds the 4 MiB safety limit');
@@ -187,6 +207,7 @@ function writeFileAtomic(path, content, encoding = 'utf-8', {
187
207
  throw new Error('Session temporary changed before atomic rename');
188
208
  }
189
209
  runSecurityTestHook('beforeAtomicRenameCommit', { path, tmp });
210
+ beforeCommit?.();
190
211
  assertDirectoryIdentity(directoryPath, directory.stat);
191
212
  assertPathIdentity(destination, expectedDestination, { allowMissing: true });
192
213
  renameSync(tmp, destination);
@@ -352,7 +373,7 @@ function recoverAbandonedSessionLock(lockPath) {
352
373
  finally { if (electionFd !== undefined) closeSync(electionFd); }
353
374
  }
354
375
 
355
- function waitForSessionWriteLock(sessionPath, maxWaitMs = 2000) {
376
+ function waitForSessionWriteLock(sessionPath, maxWaitMs = SESSION_LOCK_WAIT_MS) {
356
377
  const startedAt = Date.now();
357
378
  const lockPath = `${sessionPath}.lock`;
358
379
  const parentPath = dirname(lockPath);
@@ -641,6 +662,10 @@ const DEFAULT_SESSION = {
641
662
  // 2026-04-18: execution_profile + profile_transitions were folded into
642
663
  // execution_mode (set in DEFAULT_SESSION above as 'interactive'/'autonomous').
643
664
  providers_enabled: [],
665
+ // Content-addressed planning council outcomes. Each value points only to
666
+ // normalized evidence stored under .chati/v2/council/, never raw provider
667
+ // transcripts or credentials.
668
+ council_gates: {},
644
669
  // BUILD → DEPLOY preview state (user approves before devops ships).
645
670
  preview: {
646
671
  status: 'inactive',
@@ -1008,6 +1033,88 @@ export function updateSession(projectDir, updates) {
1008
1033
  });
1009
1034
  }
1010
1035
 
1036
+ /** Entries that can be stored RAIL handoffs. Hidden files dropped by the OS
1037
+ * or editors (.DS_Store, swap files) are not handoffs and must not seal
1038
+ * Planning corrections; anything else remains fail-closed. */
1039
+ function storedRailHandoffEntries(handoffs) {
1040
+ return readdirSync(handoffs).filter((name) => !name.startsWith('.'));
1041
+ }
1042
+
1043
+ /** Preview a negative Planning correction retarget, never an approval/reset of RAIL. */
1044
+ export function inspectPlanningCorrectionRollback(projectDir, session, targetAgent) {
1045
+ const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
1046
+ if (session.mode !== 'plan' || session.rail_handoff_id || session.rail_handoff_path || session.rail_state) {
1047
+ fail('PLANNING_CORRECTION_SEALED', 'Only unsealed Planning corrections may be retargeted.');
1048
+ }
1049
+ const handoffs = join(projectDir, '.chati/v2/rail/handoffs');
1050
+ try {
1051
+ const stat = lstatSync(handoffs);
1052
+ if (!stat.isDirectory() || stat.isSymbolicLink() || storedRailHandoffEntries(handoffs).length > 0) {
1053
+ fail('PLANNING_CORRECTION_SEALED', 'Stored RAIL handoffs prohibit Planning rollback.');
1054
+ }
1055
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
1056
+ const targets = ['detail', 'architect', 'ux'];
1057
+ const current = session.current_agent;
1058
+ if (!targets.includes(targetAgent) || !targets.includes(current)
1059
+ || targets.indexOf(targetAgent) >= targets.indexOf(current)
1060
+ || session.agents?.[targetAgent]?.status !== 'completed'
1061
+ || !session.completed_agents?.includes(targetAgent)) {
1062
+ fail('INVALID_QA_CORRECTION_TARGET', 'The target must be a completed upstream Planning agent.');
1063
+ }
1064
+ const targetIndex = AGENT_PIPELINE.findIndex(agent => agent.name === targetAgent);
1065
+ const affectedAgents = AGENT_PIPELINE.slice(targetIndex)
1066
+ .filter(agent => agent.phase === 'plan' && Object.hasOwn(session.agents || {}, agent.name))
1067
+ .map(agent => agent.name);
1068
+ for (const name of affectedAgents) {
1069
+ const agent = session.agents[name];
1070
+ if (agent.execution_claim_id || agent.execution_claim_state
1071
+ || ['in_progress', 'awaiting_advance', 'waiting_for_user'].includes(agent.status)) {
1072
+ fail('PLANNING_CORRECTION_IN_FLIGHT', `Agent ${name} must settle its claim, result or interaction first.`);
1073
+ }
1074
+ }
1075
+ if (session.pending_interaction) {
1076
+ fail('PLANNING_CORRECTION_IN_FLIGHT', 'A pending interaction must be settled before rollback.');
1077
+ }
1078
+ const cycle = session.correction_cycles?.[`qa-planning->${current}`];
1079
+ if (session.last_handoff !== 'qa-planning' || session.agents[current].status !== 'pending_rework'
1080
+ || session.agents['qa-planning']?.status !== 'pending'
1081
+ || !Number.isSafeInteger(cycle) || cycle < 1 || cycle > 2
1082
+ || !session.decision_trail?.some(entry => entry.resolved === false)) {
1083
+ fail('PLANNING_CORRECTION_NOT_PENDING', 'Retargeting requires an unstarted, bounded negative QA correction with preserved findings.');
1084
+ }
1085
+ return { impact: 'high', affectedAgents, requiresConfirmation: true,
1086
+ recommendation: `Reopen ${targetAgent} and invalidate ${affectedAgents.length} Planning statuses; preserve artifacts, findings and the recorded correction cycle.` };
1087
+ }
1088
+
1089
+ /** Reuse the pipeline reset under the canonical writer lock; reserve nothing. */
1090
+ export function rollbackPlanningCorrection(projectDir, targetAgent) {
1091
+ let impact;
1092
+ let failureCode;
1093
+ const saved = mutateSession(projectDir, session => {
1094
+ try { impact = inspectPlanningCorrectionRollback(projectDir, session, targetAgent); }
1095
+ catch (error) { failureCode = error.code; throw error; }
1096
+ const previousAgents = Object.fromEntries(impact.affectedAgents.map(name => [name, { ...session.agents[name] }]));
1097
+ const reset = resetPipelineTo({
1098
+ agents: structuredClone(session.agents), completedAgents: [...session.completed_agents], history: [],
1099
+ }, targetAgent);
1100
+ for (const name of impact.affectedAgents) {
1101
+ session.agents[name] = { ...session.agents[name], status: name === targetAgent ? 'pending_rework' : reset.agents[name].status,
1102
+ score: null, criteria_count: 0, started_at: null, completed_at: null, startedAt: null, completedAt: null };
1103
+ }
1104
+ session.completed_agents = reset.completedAgents;
1105
+ session.current_agent = targetAgent;
1106
+ session.pipeline_position = reset.completedAgents.length;
1107
+ session.deviations = [...(session.deviations || []), {
1108
+ type: 'rollback', details: { targetAgent, source: 'pending_qa_correction' },
1109
+ previous_agents: previousAgents, applied_at: new Date().toISOString(),
1110
+ changes: impact.affectedAgents.map(name => `Reset agent: ${name}`),
1111
+ }];
1112
+ return session;
1113
+ });
1114
+ if (!saved.saved) return { ...saved, applied: false, code: failureCode || 'SESSION_WRITE_FAILED' };
1115
+ return { ...saved, applied: true, impact, changes: impact.affectedAgents.map(name => `Reset agent: ${name}`) };
1116
+ }
1117
+
1011
1118
  /**
1012
1119
  * Atomically transition conversation ownership and lifecycle state. Returning
1013
1120
  * the prior values lets callers record timeline evidence without performing a
@@ -1202,7 +1309,7 @@ export function updateContextUsage(projectDir, {
1202
1309
  };
1203
1310
  }
1204
1311
 
1205
- function mutateSession(projectDir, mutator) {
1312
+ function mutateSession(projectDir, mutator, { beforeCommit } = {}) {
1206
1313
  let sessionPath;
1207
1314
  try {
1208
1315
  sessionPath = validateSessionPath(projectDir, { requireFile: true });
@@ -1222,10 +1329,11 @@ function mutateSession(projectDir, mutator) {
1222
1329
  writeFileAtomic(sessionPath, yamlContent, 'utf-8', {
1223
1330
  expectedDestinationIdentity: record.identity,
1224
1331
  expectedDirectoryIdentity: record.directoryIdentity,
1332
+ beforeCommit,
1225
1333
  });
1226
1334
  outcome = { saved: true, session: nextSession };
1227
1335
  } catch (err) {
1228
- outcome = { saved: false, error: `Failed to update session: ${err.message}` };
1336
+ outcome = { saved: false, error: `Failed to update session: ${err.message}`, ...(err.code ? { code: err.code } : {}) };
1229
1337
  } finally {
1230
1338
  if (releaseLock) {
1231
1339
  try {
@@ -1296,7 +1404,39 @@ export function recordModelSelection(projectDir, selection) {
1296
1404
  });
1297
1405
  }
1298
1406
 
1299
- /** Persist an interactive question so another harness resumes it verbatim. */
1407
+ export function validateInitialTaskContext(context) {
1408
+ if (context !== undefined && context !== null && (typeof context !== 'string'
1409
+ || Buffer.byteLength(context, 'utf8') > INITIAL_TASK_CONTEXT_MAX_BYTES)) {
1410
+ throw Object.assign(new Error('Initial task context must be text of at most 256 KiB; no content was truncated.'),
1411
+ { code: 'INITIAL_TASK_CONTEXT_INVALID' });
1412
+ }
1413
+ }
1414
+
1415
+ function validateRelayHistory(history, question = '', initialTaskContext = undefined) {
1416
+ validateInitialTaskContext(initialTaskContext);
1417
+ if (!Array.isArray(history) || history.some((entry) => !entry
1418
+ || typeof entry.interaction_id !== 'string'
1419
+ || !['user_answer', 'controller_instruction'].includes(entry.kind)
1420
+ || typeof entry.response !== 'string'
1421
+ || (entry.kind === 'user_answer' && typeof entry.question !== 'string'))) {
1422
+ throw new Error('Invalid retained interactive history');
1423
+ }
1424
+ if (history.length > RELAY_HISTORY_MAX_TURNS
1425
+ || Buffer.byteLength(JSON.stringify({ history, question, initialTaskContext })) > RELAY_HISTORY_MAX_BYTES) {
1426
+ throw new Error('Interactive history exceeds its safety limit; no content was truncated');
1427
+ }
1428
+ }
1429
+
1430
+ /** Public diagnostics must not disclose retained answers or in-flight input. */
1431
+ export function summarizePendingInteraction(pending) {
1432
+ if (!pending) return null;
1433
+ const metadata = { ...pending };
1434
+ for (const key of ['history', 'relay_response', 'relay_response_kind', 'relay_next_task_id', 'initial_task_context']) delete metadata[key];
1435
+ return { ...metadata, history_turn_count: Array.isArray(pending.history) ? pending.history.length : 0,
1436
+ history_complete: pending.history_complete === true };
1437
+ }
1438
+
1439
+ /** Persist an interactive question and atomically commit only its owned relay. */
1300
1440
  export function recordPendingInteraction(projectDir, interaction) {
1301
1441
  const sessionProjectDir = findSessionProjectDir(projectDir);
1302
1442
  if (!sessionProjectDir) return { saved: false, error: 'Session file not found in working directory or its parents' };
@@ -1314,7 +1454,31 @@ export function recordPendingInteraction(projectDir, interaction) {
1314
1454
  catalog_snapshot_ref: interaction.catalogSnapshotRef || null,
1315
1455
  };
1316
1456
  const sameRelay = previous?.agent === interaction.agent && previous?.task_id === taskId;
1317
- const relayAdvanced = sameRelay && interaction.advanceRelay === true;
1457
+ const relayAdvanced = interaction.advanceRelay === true;
1458
+ if (relayAdvanced && (!previous || previous.status !== 'relay_in_flight'
1459
+ || previous.agent !== interaction.agent
1460
+ || previous.interaction_id !== interaction.interactionId
1461
+ || !interaction.claimId || previous.relay_claim_id !== interaction.claimId
1462
+ || previous.relay_next_task_id !== taskId
1463
+ || typeof previous.relay_response !== 'string')) {
1464
+ throw new Error('Pending interaction commit rejected: relay ownership mismatch');
1465
+ }
1466
+ if (!relayAdvanced && previous && (!sameRelay || previous.status !== 'waiting_for_user')) {
1467
+ throw new Error('Pending interaction cannot replace another active interaction');
1468
+ }
1469
+ const expectedBinding = previous?.escalation_execution_binding || previous?.execution_binding;
1470
+ if (previous && Object.entries(binding).some(([key, value]) => value !== (expectedBinding?.[key] || null))) {
1471
+ throw new Error('Pending interaction commit rejected: execution binding mismatch');
1472
+ }
1473
+ const history = previous?.history || [];
1474
+ const nextHistory = relayAdvanced ? [...history, {
1475
+ interaction_id: previous.interaction_id,
1476
+ kind: previous.relay_response_kind,
1477
+ ...(previous.relay_response_kind === 'user_answer' ? { question: previous.question } : {}),
1478
+ response: previous.relay_response,
1479
+ }] : history;
1480
+ const initialTaskContext = previous ? previous.initial_task_context : interaction.initialTaskContext;
1481
+ validateRelayHistory(nextHistory, interaction.question, initialTaskContext);
1318
1482
  const interactionId = (!sameRelay || relayAdvanced || !previous?.interaction_id)
1319
1483
  ? randomUUID()
1320
1484
  : previous.interaction_id;
@@ -1323,6 +1487,23 @@ export function recordPendingInteraction(projectDir, interaction) {
1323
1487
  session.current_execution_binding = binding;
1324
1488
  session.agents = session.agents || {};
1325
1489
  const existingAgent = session.agents[interaction.agent] || {};
1490
+ let qaBaseline;
1491
+ let revisionBaseline;
1492
+ if (hasPlanningQaRecovery(existingAgent, interaction.agent)) {
1493
+ qaBaseline = assertPlanningQaReportBaseline(existingAgent, { binding, taskId,
1494
+ ...(previous ? { pending: previous } : { claimId: existingAgent.execution_claim_id }) });
1495
+ if (!previous && existingAgent.execution_claim_state !== 'running') {
1496
+ throw Object.assign(new Error('Only a started QA may carry its report baseline into an interaction.'), { code: 'COUNCIL_QA_REPORT_BASELINE_INVALID' });
1497
+ }
1498
+ }
1499
+ if (existingAgent.planning_contract_revision_id) {
1500
+ revisionBaseline = assertPlanningContractRevisionExecutionBaseline(existingAgent, { binding, taskId,
1501
+ ...(previous ? { pending: previous } : { claimId: existingAgent.execution_claim_id }) });
1502
+ if (!previous && existingAgent.execution_claim_state !== 'running') {
1503
+ throw Object.assign(new Error('Only a started Planning revision owner may carry its attempt baseline into an interaction.'),
1504
+ { code: 'PLANNING_CONTRACT_REVISION_EXECUTION_BASELINE_INVALID' });
1505
+ }
1506
+ }
1326
1507
  session.agents[interaction.agent] = {
1327
1508
  ...existingAgent,
1328
1509
  status: 'waiting_for_user',
@@ -1332,6 +1513,7 @@ export function recordPendingInteraction(projectDir, interaction) {
1332
1513
  delete session.agents[interaction.agent].execution_claim_state;
1333
1514
  delete session.agents[interaction.agent].execution_claim_started_at;
1334
1515
  delete session.agents[interaction.agent].execution_claim_expires_at;
1516
+ delete session.agents[interaction.agent].execution_resume_status;
1335
1517
  session.pending_interaction = {
1336
1518
  interaction_id: interactionId,
1337
1519
  status: 'waiting_for_user',
@@ -1339,15 +1521,102 @@ export function recordPendingInteraction(projectDir, interaction) {
1339
1521
  task_id: taskId,
1340
1522
  language: session.language || 'en',
1341
1523
  question: interaction.question,
1342
- relay_cycles: relayAdvanced ? (previous.relay_cycles || 1) + 1 : (sameRelay ? (previous.relay_cycles || 1) : 1),
1524
+ history: nextHistory,
1525
+ ...(initialTaskContext ? { initial_task_context: initialTaskContext } : {}),
1526
+ history_complete: previous ? previous.history_complete === true : true,
1527
+ relay_cycles: relayAdvanced && sameRelay ? (previous.relay_cycles || 1) + 1 : (sameRelay ? (previous.relay_cycles || 1) : 1),
1343
1528
  execution_binding: binding,
1344
- created_at: sameRelay ? previous.created_at : now,
1529
+ ...(qaBaseline ? { qa_report_baseline: cloneSessionValue(qaBaseline) } : {}),
1530
+ ...(revisionBaseline ? { planning_revision_baseline: cloneSessionValue(revisionBaseline) } : {}),
1531
+ created_at: previous ? previous.created_at : now,
1345
1532
  updated_at: now,
1346
1533
  };
1347
1534
  return session;
1348
1535
  });
1349
1536
  }
1350
1537
 
1538
+ // Older releases lost pending_rework when a reserved/running command failed.
1539
+ // Recover only the recorded, still-open rollback. A negative QA handoff is
1540
+ // context, never proof that QA completed or that downstream work may start.
1541
+ export function getPendingPlanningRollbackTarget(session) {
1542
+ if (session.mode !== 'plan' || session.last_handoff !== 'qa-planning'
1543
+ || session.completed_agents?.includes('qa-planning')) return null;
1544
+ const rollback = session.deviations?.findLast(entry => entry.type === 'rollback');
1545
+ if (rollback?.details?.source !== 'pending_qa_correction') return null;
1546
+ const target = rollback.details.targetAgent;
1547
+ if (session.completed_agents?.includes(target) && session.agents?.[target]?.status === 'completed') return null;
1548
+ const invalid = () => { throw Object.assign(new Error('Pending Planning rollback is inconsistent; no execution was reserved.'),
1549
+ { code: 'PLANNING_CORRECTION_STATE_INVALID' }); };
1550
+ if (!['detail', 'architect', 'ux'].includes(target)
1551
+ || session.completed_agents?.includes(target)
1552
+ || !['pending', 'pending_rework', 'in_progress', 'awaiting_advance'].includes(session.agents?.[target]?.status)
1553
+ || session.agents?.['qa-planning']?.status !== 'pending'
1554
+ || !session.decision_trail?.some(entry => entry.resolved === false)) invalid();
1555
+ const source = Object.entries(rollback.previous_agents || {}).find(([name, state]) =>
1556
+ ['detail', 'architect', 'ux'].includes(name) && state.status === 'pending_rework')?.[0];
1557
+ const cycle = session.correction_cycles?.[`qa-planning->${source}`];
1558
+ if (!source || !Number.isSafeInteger(cycle) || cycle < 1 || cycle > 2) invalid();
1559
+ const affected = AGENT_PIPELINE.slice(AGENT_PIPELINE.findIndex(entry => entry.name === target))
1560
+ .filter(entry => entry.phase === 'plan' && entry.name !== target);
1561
+ if (session.pending_interaction || affected.some(({ name }) => {
1562
+ const state = session.agents?.[name];
1563
+ return state?.execution_claim_id || state?.execution_claim_state
1564
+ || ['in_progress', 'awaiting_advance', 'waiting_for_user'].includes(state?.status);
1565
+ })) {
1566
+ throw Object.assign(new Error('Pending Planning rollback has conflicting execution or interaction ownership.'),
1567
+ { code: 'PLANNING_CORRECTION_IN_FLIGHT' });
1568
+ }
1569
+ return target;
1570
+ }
1571
+
1572
+ /** Revision owners consume each other's pinned output, so only one may run at a time. */
1573
+ export function getPlanningContractRevisionOwner(session, projectDir) {
1574
+ const lineage = projectDir ? authenticatePlanningContractRevisionHistory(projectDir, session)
1575
+ : planningContractRevisionHistory(session);
1576
+ const { history } = lineage;
1577
+ if (lineage.pending) throw Object.assign(new Error('The exact reserved Planning direction must settle before any owner may execute.'),
1578
+ { code: 'PLANNING_CONTRACT_REVISION_PENDING' });
1579
+ const active = history.filter(entry => entry.status === 'active');
1580
+ const hasOwner = Object.values(session.agents || {}).some(agent => agent.planning_contract_revision_id);
1581
+ if (!active.length && !hasOwner) return null;
1582
+ const fail = () => { throw Object.assign(new Error('Planning contract revision requires one sequential owner with all prior completions recorded.'),
1583
+ { code: 'PLANNING_CONTRACT_REVISION_ORDER_INVALID' }); };
1584
+ const revision = active[0];
1585
+ if (active.length !== 1 || lineage.tip !== revision || !revision.request?.revision_id
1586
+ || revision.request.language !== session.language || !revision.owner_results
1587
+ || Array.isArray(revision.owner_results)
1588
+ || Object.keys(revision.owner_results).some(owner => !PLANNING_CONTRACT_REVISION_OWNERS.includes(owner))) fail();
1589
+ let next = null;
1590
+ for (const owner of PLANNING_CONTRACT_REVISION_OWNERS) {
1591
+ const result = revision.owner_results[owner], agent = session.agents?.[owner];
1592
+ if (result) {
1593
+ if (next || agent?.status !== 'completed' || !session.completed_agents?.includes(owner)
1594
+ || !result.execution_claim_id || !result.execution_finished_at || !result.subject?.artifacts?.length) fail();
1595
+ } else {
1596
+ next ||= owner;
1597
+ if (!agent || agent.planning_contract_revision_id !== revision.request.revision_id
1598
+ || !agent.content_review_pending || agent.status === 'completed' || session.completed_agents?.includes(owner)) fail();
1599
+ }
1600
+ }
1601
+ if (!next || (session.pending_interaction && session.pending_interaction.agent !== next)) fail();
1602
+ for (const owner of PLANNING_CONTRACT_REVISION_OWNERS) {
1603
+ if (owner === next) continue;
1604
+ const agent = session.agents[owner];
1605
+ if (agent.execution_claim_id || agent.execution_claim_state
1606
+ || ['in_progress', 'awaiting_advance', 'waiting_for_user'].includes(agent.status)) fail();
1607
+ }
1608
+ return next;
1609
+ }
1610
+
1611
+ function assertPlanningContractRevisionOwner(session, agents, projectDir) {
1612
+ const owner = getPlanningContractRevisionOwner(session, projectDir);
1613
+ if (owner && (agents.length !== 1 || agents[0] !== owner)) {
1614
+ throw Object.assign(new Error(`Planning contract revision must execute ${owner} alone before later owners.`),
1615
+ { code: 'PLANNING_CONTRACT_REVISION_ORDER_INVALID' });
1616
+ }
1617
+ return owner;
1618
+ }
1619
+
1351
1620
  /** Atomically reserve one or more agent executions before returning commands. */
1352
1621
  export function claimAgentExecutions(projectDir, executions = [], claim = {}) {
1353
1622
  const sessionProjectDir = findSessionProjectDir(projectDir);
@@ -1360,6 +1629,22 @@ export function claimAgentExecutions(projectDir, executions = [], claim = {}) {
1360
1629
  const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
1361
1630
  const result = mutateSession(sessionProjectDir, (session) => {
1362
1631
  session.agents = session.agents || {};
1632
+ // Recheck the recovery contract under the same writer lock as reservation.
1633
+ // A sibling may have claimed work after the router's read-only selection.
1634
+ assertPlanningContractRevisionOwner(session, requested.map(({ agent }) => agent), sessionProjectDir);
1635
+ try {
1636
+ const rollbackTarget = getPendingPlanningRollbackTarget(session);
1637
+ if (rollbackTarget && (requested.length !== 1 || requested[0].agent !== rollbackTarget)) {
1638
+ throw Object.assign(new Error('Pending Planning rollback must resume its recorded target first.'),
1639
+ { code: 'PLANNING_CORRECTION_STATE_INVALID' });
1640
+ }
1641
+ if (session.runtime_version === 2 || existsSync(join(sessionProjectDir, '.chati', 'v2'))) {
1642
+ for (const { agent } of requested) assertV2PlanningPredecessors(session, agent);
1643
+ }
1644
+ } catch (error) {
1645
+ outcome = { claimed: false, code: error.code, error: error.message };
1646
+ return session;
1647
+ }
1363
1648
  for (const entry of requested) {
1364
1649
  const current = session.agents[entry.agent] || {};
1365
1650
  if (current.status === 'completed' || session.completed_agents?.includes(entry.agent)) {
@@ -1407,15 +1692,27 @@ export function claimAgentExecutions(projectDir, executions = [], claim = {}) {
1407
1692
  outcome.previous = snapshotAgentDispatch(session, requested.map(({ agent }) => agent));
1408
1693
  for (const entry of requested) {
1409
1694
  const claimId = randomUUID();
1695
+ const previous = session.agents[entry.agent] || {};
1696
+ const resumeStatus = previous.status === 'pending_rework'
1697
+ || (previous.status === 'in_progress' && previous.execution_resume_status === 'pending_rework')
1698
+ ? 'pending_rework' : 'pending';
1410
1699
  session.agents[entry.agent] = {
1411
- ...(session.agents[entry.agent] || {}),
1700
+ ...previous,
1412
1701
  status: 'in_progress',
1702
+ execution_resume_status: resumeStatus,
1413
1703
  execution_claim_id: claimId,
1414
1704
  execution_claim_state: 'reserved',
1415
1705
  execution_claim_started_at: now.toISOString(),
1416
1706
  execution_claim_expires_at: expiresAt,
1707
+ ...(entry.taskId ? { execution_task_id: entry.taskId } : {}),
1708
+ ...(entry.binding ? { execution_binding: cloneSessionValue(entry.binding) } : {}),
1417
1709
  };
1710
+ if (session.agents[entry.agent].content_review_pending && !session.agents[entry.agent].content_review_task_id) {
1711
+ session.agents[entry.agent].content_review_task_id = entry.taskId;
1712
+ }
1418
1713
  outcome.claims[entry.agent] = claimId;
1714
+ delete session.agents[entry.agent].execution_result_status;
1715
+ delete session.agents[entry.agent].execution_qa_result;
1419
1716
  }
1420
1717
  session.current_agent = requested[0].agent;
1421
1718
  if (requested[0].binding) session.current_execution_binding = requested[0].binding;
@@ -1430,9 +1727,14 @@ export function startAgentExecution(projectDir, match = {}) {
1430
1727
  const sessionProjectDir = findSessionProjectDir(projectDir);
1431
1728
  if (!sessionProjectDir) return { saved: false, started: false, error: 'Session file not found in working directory or its parents' };
1432
1729
  let outcome = { started: false };
1730
+ let subject;
1731
+ let reportPin;
1732
+ let revisionBaselinePin;
1733
+ try {
1433
1734
  const result = mutateSession(sessionProjectDir, (session) => {
1434
1735
  const agent = session.agents?.[match.agent];
1435
- const expired = Date.parse(agent?.execution_claim_expires_at || '') <= Date.now();
1736
+ const expiry = Date.parse(agent?.execution_claim_expires_at || '');
1737
+ const expired = !Number.isFinite(expiry) || expiry <= Date.now();
1436
1738
  if (!agent || agent.execution_claim_id !== match.claimId || agent.execution_claim_state !== 'reserved' || expired) {
1437
1739
  outcome = {
1438
1740
  started: false,
@@ -1441,36 +1743,983 @@ export function startAgentExecution(projectDir, match = {}) {
1441
1743
  };
1442
1744
  return session;
1443
1745
  }
1746
+ let contentReviewContext;
1747
+ assertPlanningContractRevisionOwner(session, [match.agent], sessionProjectDir);
1748
+ try {
1749
+ if (session.runtime_version === 2 || existsSync(join(sessionProjectDir, '.chati', 'v2'))) {
1750
+ assertV2PlanningPredecessors(session, match.agent);
1751
+ }
1752
+ contentReviewContext = claimContentReviewContext(sessionProjectDir, session, match);
1753
+ if (agent.planning_contract_revision_id) {
1754
+ const revision = session.planning_contract_revisions?.find(item => item.request?.revision_id === agent.planning_contract_revision_id);
1755
+ if (!revision) throw Object.assign(new Error('The claimed Planning revision no longer exists.'),
1756
+ { code: 'PLANNING_CONTRACT_REVISION_CONTEXT_INVALID' });
1757
+ revisionBaselinePin = pinPlanningContractRevisionOwnerBaseline(sessionProjectDir, revision, match.agent);
1758
+ agent.execution_planning_revision_baseline = { origin_claim_id: match.claimId, task_id: match.taskId,
1759
+ binding: cloneSessionValue(match.binding),
1760
+ owned_artifacts: cloneSessionValue(revisionBaselinePin.owned_artifacts),
1761
+ subject_artifacts: cloneSessionValue(revisionBaselinePin.subject_artifacts),
1762
+ handoff: cloneSessionValue(revisionBaselinePin.handoff) };
1763
+ contentReviewContext.planning_contract_revision.execution_subject = cloneSessionValue(revisionBaselinePin.subject_artifacts);
1764
+ }
1765
+ if (hasPlanningQaRecovery(agent, match.agent)) {
1766
+ subject = pinClaimedCouncilQa(sessionProjectDir, session, match.agent, { excludeQaReport: true });
1767
+ reportPin = pinPlanningQaReport(sessionProjectDir);
1768
+ agent.execution_qa_report_baseline = { origin_claim_id: match.claimId, task_id: match.taskId,
1769
+ binding: cloneSessionValue(match.binding), sha256: reportPin.sha256 };
1770
+ }
1771
+ }
1772
+ catch (error) { outcome = { started: false, code: error.code, error: error.message }; return session; }
1444
1773
  agent.execution_claim_state = 'running';
1445
1774
  agent.execution_started_at = match.timestamp || new Date().toISOString();
1446
- outcome = { started: true };
1775
+ outcome = { started: true, expiresAt: agent.execution_claim_expires_at, contentReviewContext };
1447
1776
  return session;
1448
- });
1449
- return { ...result, ...outcome };
1777
+ }, { beforeCommit: () => { subject?.assertCurrent(); reportPin?.assertCurrent(); revisionBaselinePin?.assertCurrent(); } });
1778
+ return { ...result, ...outcome, started: result.saved && outcome.started };
1779
+ } finally { subject?.close(); reportPin?.close(); revisionBaselinePin?.close(); }
1450
1780
  }
1451
1781
 
1452
1782
  /** Keep a successful routed result durable until the canonical advance records completion. */
1453
1783
  export function holdAgentExecutionForAdvance(projectDir, match = {}) {
1784
+ if (match.resultStatus !== 'complete') {
1785
+ return { saved: false, held: false, code: 'AGENT_RESULT_NOT_COMPLETE', error: 'Only an explicit complete result can await advance.' };
1786
+ }
1787
+ if (match.qaResult !== undefined && (match.agent !== 'qa-planning'
1788
+ || !isPlanningQaOutcomeValid(match.qaResult, getQaThreshold(match.agent, projectDir)))) {
1789
+ return { saved: false, held: false, code: 'QA_RESULT_INVALID', error: 'Invalid normalized planning QA result.' };
1790
+ }
1454
1791
  const sessionProjectDir = findSessionProjectDir(projectDir);
1455
1792
  if (!sessionProjectDir) return { saved: false, held: false, error: 'Session file not found in working directory or its parents' };
1456
1793
  let outcome = { held: false };
1794
+ let failureCode;
1795
+ let subject;
1796
+ let revisionOutput;
1797
+ let revisionAttemptBaseline;
1798
+ try {
1457
1799
  const result = mutateSession(sessionProjectDir, (session) => {
1458
1800
  const agent = session.agents?.[match.agent];
1459
1801
  if (!agent) {
1460
1802
  outcome = { held: false, code: 'AGENT_EXECUTION_NOT_FOUND', error: `Agent ${match.agent || 'unknown'} is not present in the canonical session.` };
1461
1803
  return session;
1462
1804
  }
1463
- if (match.claimId && agent.execution_claim_id !== match.claimId) {
1805
+ assertPlanningContractRevisionOwner(session, [match.agent], sessionProjectDir);
1806
+ if (match.interactionId) {
1807
+ const pending = session.pending_interaction;
1808
+ const binding = pending?.escalation_execution_binding || pending?.execution_binding;
1809
+ if (!pending || pending.status !== 'relay_in_flight' || !match.claimId
1810
+ || pending.relay_claim_id !== match.claimId || pending.interaction_id !== match.interactionId
1811
+ || pending.agent !== match.agent || pending.task_id !== match.taskId
1812
+ || !sameContentReviewBinding(binding, match.binding, false)) {
1813
+ failureCode = 'PENDING_INTERACTION_BINDING_MISMATCH';
1814
+ throw new Error('Relay completion requires the exact claimed interaction, task and binding.');
1815
+ }
1816
+ if (hasPlanningQaRecovery(agent, match.agent)) assertPlanningQaReportBaseline(agent, { pending, binding, taskId: match.taskId });
1817
+ if (agent.planning_contract_revision_id) {
1818
+ revisionAttemptBaseline = assertPlanningContractRevisionExecutionBaseline(
1819
+ agent, { pending, binding, taskId: match.taskId });
1820
+ agent.execution_planning_revision_relay_claim_id = pending.relay_claim_id;
1821
+ }
1822
+ // Consume the real relay identity and install its complete hold in ONE
1823
+ // atomic session commit. Never clear the answer first or mint a new ID.
1824
+ agent.execution_claim_id = pending.relay_claim_id;
1825
+ agent.execution_claim_started_at = pending.relay_started_at;
1826
+ agent.execution_task_id = pending.task_id;
1827
+ agent.execution_binding = cloneSessionValue(binding);
1828
+ session.pending_interaction = null;
1829
+ } else if (session.pending_interaction?.agent === match.agent
1830
+ || (session.pending_interaction && !match.claimId)) {
1831
+ failureCode = 'PENDING_INTERACTION_BINDING_MISMATCH';
1832
+ throw new Error('A fresh completion cannot consume its own interaction or proceed unclaimed beside a pending peer.');
1833
+ } else if (match.claimId && agent.execution_claim_id !== match.claimId) {
1464
1834
  outcome = { held: false, code: 'AGENT_EXECUTION_CLAIM_INVALID', error: `Execution claim for ${match.agent} does not own this result.` };
1465
1835
  return session;
1466
1836
  }
1837
+ if (agent.planning_contract_revision_id && !match.interactionId) {
1838
+ if (agent.execution_claim_state !== 'running') {
1839
+ throw Object.assign(new Error('Planning revision completion requires a genuinely started claim.'),
1840
+ { code: 'PLANNING_CONTRACT_REVISION_EXECUTION_REQUIRED' });
1841
+ }
1842
+ revisionAttemptBaseline = assertPlanningContractRevisionExecutionBaseline(
1843
+ agent, { claimId: match.claimId, binding: agent.execution_binding, taskId: agent.execution_task_id });
1844
+ delete agent.execution_planning_revision_relay_claim_id;
1845
+ }
1846
+ if (hasPlanningQaRecovery(agent, match.agent)) {
1847
+ if (!match.claimId || (!match.interactionId && agent.execution_claim_state !== 'running')) {
1848
+ throw Object.assign(new Error('Council QA must complete its genuinely started fresh claim.'), { code: 'COUNCIL_QA_EXECUTION_REQUIRED' });
1849
+ }
1850
+ subject = pinClaimedCouncilQa(sessionProjectDir, session, match.agent);
1851
+ const baseline = match.interactionId ? agent.execution_qa_report_baseline
1852
+ : assertPlanningQaReportBaseline(agent, { claimId: match.claimId, binding: agent.execution_binding, taskId: agent.execution_task_id });
1853
+ const report = subject.plan.artifacts.find(item => item.path === 'artifacts/7-QA-Planning/qa-planning-report.md');
1854
+ if (!report || report.sha256 === baseline.sha256) {
1855
+ throw Object.assign(new Error('This QA execution must produce a report different from its own pre-spawn baseline.'), { code: 'COUNCIL_QA_REPORT_NOT_REGENERATED' });
1856
+ }
1857
+ }
1858
+ if (agent.planning_contract_revision_id) {
1859
+ const revision = session.planning_contract_revisions?.find(
1860
+ item => item.request?.revision_id === agent.planning_contract_revision_id);
1861
+ if (!revision || revision.status !== 'active') {
1862
+ throw Object.assign(new Error('Planning revision output requires the single active direction.'),
1863
+ { code: 'PLANNING_CONTRACT_REVISION_CONTEXT_INVALID' });
1864
+ }
1865
+ revisionOutput = pinPlanningContractRevisionOwnerOutput(sessionProjectDir, revision, match.agent,
1866
+ revisionAttemptBaseline);
1867
+ }
1467
1868
  agent.status = 'awaiting_advance';
1468
1869
  agent.execution_claim_state = 'awaiting_advance';
1469
1870
  agent.execution_finished_at = match.timestamp || new Date().toISOString();
1871
+ agent.execution_result_status = match.resultStatus;
1872
+ delete agent.execution_resume_status;
1873
+ if (match.qaResult !== undefined) agent.execution_qa_result = { ...match.qaResult };
1874
+ if (revisionOutput) {
1875
+ agent.execution_planning_revision_result = {
1876
+ execution_claim_id: agent.execution_claim_id,
1877
+ execution_finished_at: agent.execution_finished_at,
1878
+ subject: { gate_id: revisionOutput.plan.gate_id,
1879
+ artifacts: cloneSessionValue(revisionOutput.plan.artifacts) },
1880
+ owned_artifacts: cloneSessionValue(revisionOutput.owned_artifacts),
1881
+ handoff: cloneSessionValue(revisionOutput.handoff),
1882
+ };
1883
+ }
1884
+ if (subject) {
1885
+ const active = activePlanningQaHistory(session, match.agent);
1886
+ active.entry.result = {
1887
+ execution_claim_id: agent.execution_claim_id, execution_finished_at: agent.execution_finished_at,
1888
+ artifacts: cloneSessionValue(subject.plan.artifacts),
1889
+ ...(match.qaResult ? { qa_result: cloneSessionValue(match.qaResult) } : {}),
1890
+ };
1891
+ if (active.kind === 'planning_contract_revision') {
1892
+ const previousSubjects = session.planning_contract_revision_subjects || [];
1893
+ if (!Array.isArray(previousSubjects)
1894
+ || previousSubjects.some(item => item.revision_id === active.entry.request.revision_id)) {
1895
+ throw Object.assign(new Error('The Planning revision already recorded its one genuine QA subject.'),
1896
+ { code: 'PLANNING_CONTRACT_REVISION_SUBJECT_LIMIT' });
1897
+ }
1898
+ session.planning_contract_revision_subjects = [...previousSubjects, {
1899
+ revision_id: active.entry.request.revision_id, gate_id: subject.plan.gate_id,
1900
+ artifacts: cloneSessionValue(subject.plan.artifacts), execution_claim_id: agent.execution_claim_id,
1901
+ execution_finished_at: agent.execution_finished_at, recorded_at: new Date().toISOString() }];
1902
+ }
1903
+ }
1470
1904
  delete agent.execution_claim_expires_at;
1471
1905
  delete agent.execution_started_at;
1906
+ delete agent.execution_qa_report_baseline;
1472
1907
  outcome = { held: true };
1473
1908
  return session;
1909
+ }, { beforeCommit: () => { subject?.assertCurrent(); revisionOutput?.assertCurrent(); match.verifySubject?.(); } });
1910
+ return { ...result, ...outcome, held: result.saved && outcome.held,
1911
+ ...(!result.saved ? { code: failureCode || result.code || 'SESSION_WRITE_FAILED' } : {}) };
1912
+ } finally { subject?.close(); revisionOutput?.close(); }
1913
+ }
1914
+
1915
+ const CONTENT_REVIEW_AGENTS = new Set(['detail', 'architect', 'ux', 'qa-planning']);
1916
+ const CONTENT_REVIEW_BINDING_KEYS = ['provider', 'provider_id', 'model', 'reasoning_configuration', 'catalog_snapshot_ref'];
1917
+ const CONTENT_REVIEW_REASON_MAX_BYTES = 8192;
1918
+ // Separate authority and history from content rejection, but retain a finite
1919
+ // total recovery budget. New gate ids or cosmetic edits cannot reset it.
1920
+ export const MAX_COUNCIL_QA_REVALIDATIONS = 1;
1921
+
1922
+ function sameContentReviewBinding(left, right, requireCatalog = true) {
1923
+ return Boolean(left && right && left.provider && left.model && (!requireCatalog || left.catalog_snapshot_ref)
1924
+ && CONTENT_REVIEW_BINDING_KEYS.every(key => (left[key] || null) === (right[key] || null)));
1925
+ }
1926
+
1927
+ function qaInvocationBinding(artifact) {
1928
+ const invocation = resolveRuntimeInvocationV2({ artifact, agent: 'qa-planning' });
1929
+ return { provider: invocation.harness_id, provider_id: invocation.provider_id,
1930
+ model: invocation.model_pin.model_id, reasoning_configuration: invocation.model_pin.reasoning_configuration,
1931
+ catalog_snapshot_ref: invocation.model_pin.catalog_snapshot_ref };
1932
+ }
1933
+
1934
+ function hasPlanningQaRecovery(agent, agentName = 'qa-planning') {
1935
+ return agentName === 'qa-planning' && Boolean(agent?.council_revalidation_pending || agent?.planning_reconciliation_pending
1936
+ || agent?.planning_contract_revision_id);
1937
+ }
1938
+
1939
+ function assertPlanningQaReportBaseline(agent, { claimId, pending, binding, taskId }) {
1940
+ const baseline = agent.execution_qa_report_baseline;
1941
+ if (!baseline || typeof baseline.origin_claim_id !== 'string' || !baseline.origin_claim_id
1942
+ || !(baseline.sha256 === null || /^[a-f0-9]{64}$/.test(baseline.sha256 || ''))
1943
+ || baseline.task_id !== taskId || !sameContentReviewBinding(baseline.binding, binding)
1944
+ || (pending ? canonicalSerialize(pending.qa_report_baseline) !== canonicalSerialize(baseline)
1945
+ : baseline.origin_claim_id !== claimId)) {
1946
+ throw Object.assign(new Error('QA report baseline must belong to this exact started execution and relay lineage.'), { code: 'COUNCIL_QA_REPORT_BASELINE_INVALID' });
1947
+ }
1948
+ return baseline;
1949
+ }
1950
+
1951
+ function assertPlanningContractRevisionExecutionBaseline(agent, { claimId, pending, binding, taskId }) {
1952
+ const baseline = agent.execution_planning_revision_baseline;
1953
+ if (!baseline || typeof baseline.origin_claim_id !== 'string' || !baseline.origin_claim_id
1954
+ || baseline.task_id !== taskId || !sameContentReviewBinding(baseline.binding, binding)
1955
+ || !Array.isArray(baseline.owned_artifacts) || baseline.owned_artifacts.length === 0
1956
+ || !baseline.owned_artifacts.every(item => typeof item?.path === 'string' && /^[a-f0-9]{64}$/.test(item.sha256 || ''))
1957
+ || !Array.isArray(baseline.subject_artifacts) || baseline.subject_artifacts.length < baseline.owned_artifacts.length
1958
+ || !baseline.subject_artifacts.every(item => typeof item?.path === 'string' && /^[a-f0-9]{64}$/.test(item.sha256 || ''))
1959
+ || typeof baseline.handoff?.path !== 'string' || !/^[a-f0-9]{64}$/.test(baseline.handoff.sha256 || '')
1960
+ || (pending ? canonicalSerialize(pending.planning_revision_baseline) !== canonicalSerialize(baseline)
1961
+ : baseline.origin_claim_id !== claimId)) {
1962
+ throw Object.assign(new Error('Planning contract revision baseline must belong to this exact execution and relay lineage.'),
1963
+ { code: 'PLANNING_CONTRACT_REVISION_EXECUTION_BASELINE_INVALID' });
1964
+ }
1965
+ return baseline;
1966
+ }
1967
+
1968
+ /** Exactly one active authority, with separate immutable recovery histories. */
1969
+ function activePlanningQaHistory(session, agentName) {
1970
+ const agent = session.agents?.[agentName];
1971
+ const fail = message => { throw Object.assign(new Error(message), { code: 'COUNCIL_QA_EXECUTION_REQUIRED' }); };
1972
+ if (agentName !== 'qa-planning' || !hasPlanningQaRecovery(agent)) {
1973
+ fail('Exactly one active Planning QA recovery authority is required.');
1974
+ }
1975
+ if (agent.planning_contract_revision_id) {
1976
+ const { history } = planningContractRevisionHistory(session);
1977
+ const active = history.filter(item => item.status === 'active');
1978
+ const entry = active[0];
1979
+ if (active.length !== 1 || entry.request?.revision_id !== agent.planning_contract_revision_id
1980
+ || agent.council_revalidation_pending || agent.planning_reconciliation_pending
1981
+ || !Array.isArray(agent.council_revalidations) || agent.council_revalidations.length !== 1
1982
+ || !Array.isArray(agent.planning_reconciliations)
1983
+ || agent.planning_reconciliations.filter(item => item.authorization).length !== 1
1984
+ || session.language !== entry.request.language
1985
+ || !['detail', 'architect', 'ux'].every(owner => entry.owner_results?.[owner])) {
1986
+ fail('Fresh QA requires the single active contract revision and genuine recompletion of every upstream owner.');
1987
+ }
1988
+ return { kind: 'planning_contract_revision', entry, binding: entry.request.source_held_qa.binding };
1989
+ }
1990
+ if ((agent.council_revalidation_pending && agent.planning_reconciliation_pending)
1991
+ || !Array.isArray(agent.council_revalidations) || agent.council_revalidations.length !== 1) {
1992
+ fail('Exactly one active Planning QA recovery authority is required.');
1993
+ }
1994
+ if (!agent.planning_reconciliation_pending) return { kind: 'council_revalidation', entry: agent.council_revalidations[0] };
1995
+ const history = agent.planning_reconciliations;
1996
+ const authorized = Array.isArray(history) ? history.filter(item => item.authorization) : [];
1997
+ if (authorized.length !== 1 || history.length > MAX_PLANNING_RECONCILIATION_TARGETS) fail('A single consumed reconciliation authority is required.');
1998
+ const entry = authorized[0], prior = agent.council_revalidations[0], held = entry.request?.held_qa;
1999
+ if (entry.status !== 'authorized' || !entry.authorization.fresh_claim_id
2000
+ || prior.result?.execution_claim_id !== held?.claim_id
2001
+ || prior.result?.execution_finished_at !== held?.finished_at
2002
+ || canonicalSerialize(prior.result?.artifacts) !== canonicalSerialize(entry.request?.source_artifacts)
2003
+ || canonicalSerialize(prior.result?.qa_result) !== canonicalSerialize(held?.qa_result)
2004
+ || !sameContentReviewBinding(prior.binding, held?.binding)) {
2005
+ fail('Consumed reconciliation must retain the original held QA and source manifest.');
2006
+ }
2007
+ return { kind: 'planning_reconciliation', entry, artifacts: entry.request.target_artifacts, binding: held.binding };
2008
+ }
2009
+
2010
+ function pinClaimedCouncilQa(projectDir, session, agentName, { allowOwnInteraction = false, expectedArtifacts, inputsOnly = true, excludeQaReport = false } = {}) {
2011
+ const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
2012
+ const active = activePlanningQaHistory(session, agentName);
2013
+ const subject = pinPlanningQaSubject(projectDir, expectedArtifacts || active.artifacts || active.entry.artifacts, { inputsOnly, excludeQaReport });
2014
+ try {
2015
+ const binding = qaInvocationBinding(subject.artifact);
2016
+ if (!sameContentReviewBinding(binding, active.binding || active.entry.binding)) {
2017
+ fail('CONTENT_REVIEW_BINDING_MISMATCH', 'The selected QA route changed after council revalidation.');
2018
+ }
2019
+ const verifyRecovery = () => {
2020
+ if (active.kind === 'planning_contract_revision') {
2021
+ authenticatePlanningContractRevisionHistory(projectDir, session);
2022
+ readAttestedPlanningContractRevision(projectDir, active.entry.request,
2023
+ active.entry.launch_nonce, active.entry.decision_sha256);
2024
+ return;
2025
+ }
2026
+ if (active.kind === 'planning_reconciliation') {
2027
+ const decision = readAttestedPlanningReconciliationDecision(projectDir, subject, active.entry.request, active.entry.authorization.decision_sha256);
2028
+ if (decision.record.decision.decision !== 'authorize-qa-revalidation'
2029
+ || decision.record.decision.launch_nonce !== active.entry.launch_nonce
2030
+ || active.entry.request.language !== session.language) {
2031
+ fail('PLANNING_RECONCILIATION_DECISION_UNTRUSTED', 'Fresh QA requires the exact consumed positive reconciliation and original language.');
2032
+ }
2033
+ }
2034
+ };
2035
+ verifyRecovery();
2036
+ assertPlanningContentBoundary(projectDir, session, { agent: agentName, binding }, fail, { allowOwnInteraction });
2037
+ assertV2PlanningPredecessors(session, agentName);
2038
+ return Object.freeze({ ...subject, assertCurrent() { subject.assertCurrent(); verifyRecovery(); } });
2039
+ } catch (error) { subject.close(); throw error; }
2040
+ }
2041
+
2042
+ /** The fresh QA verdict is authority only for the exact bytes it actually held. */
2043
+ export function pinHeldCouncilQaSubject(projectDir, session) {
2044
+ const agent = session?.agents?.['qa-planning'];
2045
+ if (!hasPlanningQaRecovery(agent, 'qa-planning')) return null;
2046
+ const active = activePlanningQaHistory(session, 'qa-planning');
2047
+ const result = active.entry.result;
2048
+ const revisionSubject = active.kind === 'planning_contract_revision'
2049
+ ? session.planning_contract_revision_subjects?.filter(item => item.revision_id === active.entry.request.revision_id)
2050
+ : null;
2051
+ if (agent.status !== 'awaiting_advance' || agent.execution_claim_state !== 'awaiting_advance'
2052
+ || agent.execution_result_status !== 'complete' || !result?.artifacts
2053
+ || result.execution_claim_id !== agent.execution_claim_id || result.execution_finished_at !== agent.execution_finished_at
2054
+ || !result.qa_result || !['score', 'gate_result', 'blocking_count'].every(key => result.qa_result[key] === agent.execution_qa_result?.[key])
2055
+ || (revisionSubject && (revisionSubject.length !== 1
2056
+ || revisionSubject[0].execution_claim_id !== result.execution_claim_id
2057
+ || revisionSubject[0].execution_finished_at !== result.execution_finished_at
2058
+ || canonicalSerialize(revisionSubject[0].artifacts) !== canonicalSerialize(result.artifacts)))) {
2059
+ throw Object.assign(new Error('The new QA verdict must be held with its exact claim and result manifest.'), { code: 'COUNCIL_QA_HELD_RESULT_MISMATCH' });
2060
+ }
2061
+ return pinClaimedCouncilQa(projectDir, session, 'qa-planning', { expectedArtifacts: result.artifacts, inputsOnly: false });
2062
+ }
2063
+
2064
+ /** Feedback is data for the validated next execution/relay, never an approval. */
2065
+ function claimContentReviewContext(projectDir, session, match) {
2066
+ assertPlanningContractRevisionOwner(session, [match.agent], projectDir);
2067
+ const agent = session.agents?.[match.agent];
2068
+ if (!agent?.content_review_pending) return null;
2069
+ const reviews = agent.content_rejections;
2070
+ const revision = agent.planning_contract_revision_id
2071
+ ? session.planning_contract_revisions?.find(item => item.request?.revision_id === agent.planning_contract_revision_id)
2072
+ : null;
2073
+ let historicalRejectionCount;
2074
+ if (!match.claimId || !match.taskId || agent.content_review_task_id !== match.taskId
2075
+ || !sameContentReviewBinding(agent.execution_binding, match.binding)
2076
+ || (!revision && (!Array.isArray(reviews) || !reviews.length || reviews.length > MAX_CORRECTION_CYCLES
2077
+ || !sameContentReviewBinding(reviews.at(-1).binding, match.binding)))) {
2078
+ throw Object.assign(new Error('Content-review feedback does not match this claimed task and binding.'),
2079
+ { code: 'CONTENT_REVIEW_BINDING_MISMATCH' });
2080
+ }
2081
+ if (revision) {
2082
+ if (revision.status !== 'active' || planningContractRevisionHistory(session).tip !== revision
2083
+ || !PLANNING_CONTRACT_REVISION_OWNERS.includes(match.agent)
2084
+ || revision.request.language !== session.language) {
2085
+ throw Object.assign(new Error('Planning contract direction is not active for this exact owner and language.'),
2086
+ { code: 'PLANNING_CONTRACT_REVISION_CONTEXT_INVALID' });
2087
+ }
2088
+ readAttestedPlanningContractRevision(projectDir, revision.request, revision.launch_nonce, revision.decision_sha256);
2089
+ // Settlement archives the exact rejection prefix. Later rejected owner
2090
+ // results append to it; dates cannot distinguish inherited and current work.
2091
+ const archivedOwner = revision.archived?.previous_agents?.[match.agent];
2092
+ const historicalReviews = archivedOwner?.content_rejections === undefined ? [] : archivedOwner.content_rejections;
2093
+ const currentReviews = reviews === undefined ? [] : reviews;
2094
+ if (!archivedOwner || typeof archivedOwner !== 'object' || Array.isArray(archivedOwner)
2095
+ || !Array.isArray(historicalReviews) || !Array.isArray(currentReviews)
2096
+ || historicalReviews.length > currentReviews.length
2097
+ || canonicalSerialize(currentReviews.slice(0, historicalReviews.length)) !== canonicalSerialize(historicalReviews)) {
2098
+ throw Object.assign(new Error('Planning revision rejection history must retain its exact archived prefix.'),
2099
+ { code: 'PLANNING_CONTRACT_REVISION_CONTEXT_INVALID' });
2100
+ }
2101
+ historicalRejectionCount = historicalReviews.length;
2102
+ }
2103
+ const revalidation = agent.council_revalidation_pending ? agent.council_revalidations?.at(-1) : null;
2104
+ if (agent.council_revalidation_pending && (!revalidation || match.agent !== 'qa-planning'
2105
+ || !sameContentReviewBinding(revalidation.binding, match.binding))) {
2106
+ throw Object.assign(new Error('Council QA revalidation does not match this execution.'), { code: 'CONTENT_REVIEW_BINDING_MISMATCH' });
2107
+ }
2108
+ const reconciliation = agent.planning_reconciliation_pending ? activePlanningQaHistory(session, match.agent) : null;
2109
+ if (reconciliation && !sameContentReviewBinding(reconciliation.binding, match.binding)) {
2110
+ throw Object.assign(new Error('Planning reconciliation does not match this QA execution.'), { code: 'CONTENT_REVIEW_BINDING_MISMATCH' });
2111
+ }
2112
+ return { agent: match.agent, taskId: match.taskId, claimId: match.claimId,
2113
+ binding: cloneSessionValue(match.binding),
2114
+ rejections: (reviews || []).map(review => ({ reason: review.reason, rejected_at: review.rejected_at,
2115
+ execution_claim_id: review.execution.execution_claim_id,
2116
+ execution_finished_at: review.execution.execution_finished_at })),
2117
+ ...(revalidation ? { council_revalidation: {
2118
+ source_gate_id: revalidation.source_gate_id, artifacts: cloneSessionValue(revalidation.artifacts),
2119
+ blockers: cloneSessionValue(revalidation.blockers),
2120
+ } } : {}),
2121
+ ...(reconciliation ? { planning_reconciliation: {
2122
+ reconciliation_id: reconciliation.entry.request.reconciliation_id,
2123
+ decision_sha256: reconciliation.entry.authorization.decision_sha256,
2124
+ source_gate_id: reconciliation.entry.request.source_gate_id,
2125
+ artifacts: cloneSessionValue(reconciliation.artifacts),
2126
+ blockers: cloneSessionValue(reconciliation.entry.request.blockers),
2127
+ } } : {}),
2128
+ ...(revision ? { planning_contract_revision: {
2129
+ revision_id: revision.request.revision_id, source_gate_id: revision.request.source_gate_id,
2130
+ source_decision_sha256: revision.request.source_decision_sha256,
2131
+ old_subject: cloneSessionValue(revision.request.old_subject),
2132
+ accepted_blocker_ids: cloneSessionValue(revision.request.accepted_blocker_ids),
2133
+ direction: cloneSessionValue(revision.request.direction), authority_scope: 'pipeline-reopening-only',
2134
+ historical_rejection_count: historicalRejectionCount,
2135
+ } } : {}),
2136
+ };
2137
+ }
2138
+
2139
+ function assertHeldPlanningContent(projectDir, session, match, fail) {
2140
+ const agent = session.agents?.[match.agent];
2141
+ if (!CONTENT_REVIEW_AGENTS.has(match.agent) || session.current_agent !== match.agent
2142
+ || agent?.status !== 'awaiting_advance' || agent.execution_claim_state !== 'awaiting_advance'
2143
+ || agent.execution_result_status !== 'complete' || session.completed_agents?.includes(match.agent)
2144
+ || typeof match.claimId !== 'string' || !match.claimId || agent.execution_claim_id !== match.claimId
2145
+ || typeof match.finishedAt !== 'string' || !Number.isFinite(Date.parse(match.finishedAt))
2146
+ || agent.execution_finished_at !== match.finishedAt) {
2147
+ fail('AGENT_RESULT_REJECTION_MISMATCH', 'Rejection requires the exact current, unapproved complete Planning result.');
2148
+ }
2149
+ assertPlanningContentBoundary(projectDir, session, match, fail);
2150
+ return agent;
2151
+ }
2152
+
2153
+ function assertPlanningContentBoundary(projectDir, session, match, fail, { allowOwnInteraction = false } = {}) {
2154
+ const agent = session.agents?.[match.agent];
2155
+ if (session.current_agent !== match.agent || session.completed_agents?.includes(match.agent)) {
2156
+ fail('AGENT_RESULT_REJECTION_MISMATCH', 'The unapproved Planning agent must still own the session.');
2157
+ }
2158
+ if (session.mode !== 'plan' || session.rail_handoff_id || session.rail_handoff_path || session.rail_state) {
2159
+ fail('CONTENT_REVIEW_SEALED', 'Content rejection cannot reopen sealed Planning or RAIL.');
2160
+ }
2161
+ const handoffs = join(projectDir, '.chati/v2/rail/handoffs');
2162
+ try {
2163
+ const stat = lstatSync(handoffs);
2164
+ if (!stat.isDirectory() || stat.isSymbolicLink() || storedRailHandoffEntries(handoffs).length) {
2165
+ fail('CONTENT_REVIEW_SEALED', 'Stored RAIL handoffs prohibit content rejection.');
2166
+ }
2167
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
2168
+ if (!sameContentReviewBinding(session.current_execution_binding, match.binding)
2169
+ || (agent?.execution_binding && !sameContentReviewBinding(agent.execution_binding, match.binding))) {
2170
+ fail('CONTENT_REVIEW_BINDING_MISMATCH', 'The held execution binding must match the validated installation route.');
2171
+ }
2172
+ const later = AGENT_PIPELINE.slice(AGENT_PIPELINE.findIndex(entry => entry.name === match.agent) + 1)
2173
+ .filter(entry => entry.phase === 'plan').map(entry => entry.name);
2174
+ if ((session.pending_interaction && !(allowOwnInteraction && session.pending_interaction.agent === match.agent))
2175
+ || Object.entries(session.agents || {}).some(([name, state]) => name !== match.agent
2176
+ && (state.execution_claim_id || state.execution_claim_state || ['in_progress', 'awaiting_advance', 'waiting_for_user'].includes(state.status)
2177
+ || (later.includes(name) && (state.status === 'completed' || session.completed_agents?.includes(name)))))) {
2178
+ fail('CONTENT_REVIEW_DEPENDENTS_ACTIVE', 'Settle other claims, interactions and dependent work before rejecting this result.');
2179
+ }
2180
+ }
2181
+
2182
+ /** Read-only precondition for a separately authenticated final QA recovery. */
2183
+ export function assertPlanningReconciliationBoundary(projectDir, session, match, binding) {
2184
+ const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
2185
+ if (match.agent !== 'qa-planning') fail('PLANNING_RECONCILIATION_AGENT_REQUIRED', 'Planning reconciliation requires QA-Planning.');
2186
+ const agent = assertHeldPlanningContent(projectDir, session, { ...match, binding }, fail);
2187
+ assertV2PlanningPredecessors(session, match.agent);
2188
+ if (agent.content_rejections?.length !== MAX_CORRECTION_CYCLES
2189
+ || agent.council_revalidations?.length !== MAX_COUNCIL_QA_REVALIDATIONS
2190
+ || !agent.council_revalidation_pending) {
2191
+ fail('PLANNING_RECONCILIATION_HISTORY_REQUIRED', 'Planning reconciliation requires the exact exhausted QA recovery history.');
2192
+ }
2193
+ const prior = agent.council_revalidations[0];
2194
+ const held = prior.result;
2195
+ if (!held?.artifacts || held.execution_claim_id !== match.claimId
2196
+ || held.execution_finished_at !== match.finishedAt
2197
+ || !sameContentReviewBinding(prior.binding, binding)
2198
+ || !isPlanningQaOutcomeValid(agent.execution_qa_result, getQaThreshold(match.agent, projectDir))
2199
+ || agent.execution_qa_result.gate_result !== 'approved'
2200
+ || !['score', 'gate_result', 'blocking_count'].every(key => held.qa_result?.[key] === agent.execution_qa_result[key])) {
2201
+ fail('PLANNING_RECONCILIATION_HELD_QA_MISMATCH', 'The held QA must be the genuine result of the single prior council revalidation.');
2202
+ }
2203
+ return prior;
2204
+ }
2205
+
2206
+ // A distinct recovery authority does not reset either existing correction budget.
2207
+ // At most three distinct targets may be assessed, and at most one may authorize QA.
2208
+ export const MAX_PLANNING_RECONCILIATION_TARGETS = 3;
2209
+
2210
+ /** Reserve before inference; identical requests can only retrieve the same slot. */
2211
+ export function reservePlanningReconciliation(projectDir, match = {}) {
2212
+ let subject;
2213
+ let outcome;
2214
+ try {
2215
+ const result = mutateSession(projectDir, session => {
2216
+ subject = pinPlanningReconciliation(projectDir, match, session);
2217
+ const request = subject.request;
2218
+ const qa = session.agents['qa-planning'];
2219
+ const history = qa.planning_reconciliations || [];
2220
+ if (!Array.isArray(history) || history.length > MAX_PLANNING_RECONCILIATION_TARGETS) {
2221
+ throw Object.assign(new Error('Planning reconciliation history is invalid.'), { code: 'PLANNING_RECONCILIATION_HISTORY_INVALID' });
2222
+ }
2223
+ const previous = history.find(item => item.request?.reconciliation_id === request.reconciliation_id);
2224
+ if (previous) {
2225
+ if (canonicalSerialize(previous.request) !== canonicalSerialize(request)) {
2226
+ throw Object.assign(new Error('Stored Planning reconciliation request differs from its recomputed subject.'), { code: 'PLANNING_RECONCILIATION_REQUEST_MISMATCH' });
2227
+ }
2228
+ outcome = { reservation: cloneSessionValue(previous), reused: true };
2229
+ return session;
2230
+ }
2231
+ if (history.some(item => item.authorization)) {
2232
+ throw Object.assign(new Error('The final Planning reconciliation QA authorization was already consumed.'), { code: 'PLANNING_RECONCILIATION_QA_LIMIT' });
2233
+ }
2234
+ if (history.some(item => ['reserved', 'running', 'outcome_unknown'].includes(item.status))) {
2235
+ throw Object.assign(new Error('An unresolved Planning reconciliation cannot be replaced or relaunched by changing its target.'), { code: 'PLANNING_RECONCILIATION_IN_FLIGHT' });
2236
+ }
2237
+ if (history.length >= MAX_PLANNING_RECONCILIATION_TARGETS) {
2238
+ throw Object.assign(new Error('Planning reconciliation target budget exhausted; new technical direction is required.'), { code: 'PLANNING_RECONCILIATION_TARGET_LIMIT' });
2239
+ }
2240
+ const reservation = { request: cloneSessionValue(request), status: 'reserved',
2241
+ launch_nonce: randomUUID(), reserved_at: new Date().toISOString() };
2242
+ qa.planning_reconciliations = [...history, reservation];
2243
+ outcome = { reservation: cloneSessionValue(reservation), reused: false };
2244
+ return session;
2245
+ }, { beforeCommit: () => subject?.assertCurrent() });
2246
+ return { ...result, reserved: result.saved, ...(result.saved ? outcome : {}) };
2247
+ } finally { subject?.close(); }
2248
+ }
2249
+
2250
+ /** Single pre-spawn admission. Expiration never resurrects an unknown launch. */
2251
+ export function startPlanningReconciliation(projectDir, match = {}) {
2252
+ let subject;
2253
+ let outcome;
2254
+ try {
2255
+ const result = mutateSession(projectDir, session => {
2256
+ subject = pinPlanningReconciliation(projectDir, match, session);
2257
+ const reservation = session.agents['qa-planning'].planning_reconciliations?.find(
2258
+ item => item.request?.reconciliation_id === subject.request.reconciliation_id);
2259
+ if (!reservation || reservation.status !== 'reserved'
2260
+ || canonicalSerialize(reservation.request) !== canonicalSerialize(subject.request)
2261
+ || reservation.launch_nonce !== match.launchNonce
2262
+ || subject.request.reconciliation_id !== match.reconciliationId) {
2263
+ throw Object.assign(new Error('Only the exact never-started Planning reconciliation reservation may launch.'), { code: 'PLANNING_RECONCILIATION_LAUNCH_MISMATCH' });
2264
+ }
2265
+ reservation.status = 'running';
2266
+ reservation.started_at = new Date().toISOString();
2267
+ outcome = { reservation: cloneSessionValue(reservation) };
2268
+ return session;
2269
+ }, { beforeCommit: () => subject?.assertCurrent() });
2270
+ return { ...result, started: result.saved, ...(result.saved ? outcome : {}) };
2271
+ } finally { subject?.close(); }
2272
+ }
2273
+
2274
+ /** Consume a completed attested assessment atomically, never a caller's verdict. */
2275
+ export function settlePlanningReconciliation(projectDir, match = {}) {
2276
+ let subject;
2277
+ let outcome;
2278
+ try {
2279
+ const result = mutateSession(projectDir, session => {
2280
+ const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
2281
+ const agent = session.agents?.['qa-planning'];
2282
+ const entry = agent?.planning_reconciliations?.find(item => item.request?.reconciliation_id === match.reconciliationId);
2283
+ if (match.agent !== 'qa-planning' || !entry || entry.launch_nonce !== match.launchNonce
2284
+ || entry.request.held_qa.claim_id !== match.claimId || entry.request.held_qa.finished_at !== match.finishedAt
2285
+ || entry.request.source_gate_id !== match.gateId) {
2286
+ fail('PLANNING_RECONCILIATION_LAUNCH_MISMATCH', 'Settlement requires the exact reserved request, held result and nonce.');
2287
+ }
2288
+ if (entry.authorization) {
2289
+ if (!agent.planning_reconciliation_pending || agent.status !== 'in_progress'
2290
+ || agent.execution_claim_state !== 'reserved' || Date.parse(agent.execution_claim_expires_at) <= Date.now()
2291
+ || !Number.isFinite(Date.parse(agent.execution_claim_expires_at))
2292
+ || agent.execution_claim_id !== entry.authorization.fresh_claim_id) {
2293
+ fail('PLANNING_RECONCILIATION_QA_LIMIT', 'Consumed authorization cannot create or resurrect another QA claim.');
2294
+ }
2295
+ subject = pinClaimedCouncilQa(projectDir, session, 'qa-planning', { excludeQaReport: true });
2296
+ outcome = { authorized: true, claimId: agent.execution_claim_id, binding: qaInvocationBinding(subject.artifact), reused: true };
2297
+ return session;
2298
+ }
2299
+ if (!['running', 'outcome_unknown', 'denied'].includes(entry.status)) {
2300
+ fail('PLANNING_RECONCILIATION_LAUNCH_MISMATCH', 'Only a started assessment or its cached denial may settle.');
2301
+ }
2302
+ subject = pinPlanningReconciliation(projectDir, match, session);
2303
+ if (canonicalSerialize(entry.request) !== canonicalSerialize(subject.request)) {
2304
+ fail('PLANNING_RECONCILIATION_REQUEST_MISMATCH', 'Reconciliation target changed before settlement.');
2305
+ }
2306
+ const decision = readPlanningReconciliationDecision(subject);
2307
+ if (decision.record.decision.launch_nonce !== entry.launch_nonce) {
2308
+ fail('PLANNING_RECONCILIATION_LAUNCH_MISMATCH', 'Decision does not belong to the started reservation.');
2309
+ }
2310
+ if (decision.record.decision.decision === 'deny-qa-revalidation') {
2311
+ entry.status = 'denied';
2312
+ entry.decision_sha256 = decision.sha256;
2313
+ entry.settled_at ||= new Date().toISOString();
2314
+ outcome = { authorized: false, decision: 'deny-qa-revalidation', decision_path: decision.path,
2315
+ assessments: cloneSessionValue(decision.record.decision.assessments) };
2316
+ return session;
2317
+ }
2318
+ if (!['running', 'outcome_unknown'].includes(entry.status) || agent.planning_reconciliations.some(item => item.authorization)) {
2319
+ fail('PLANNING_RECONCILIATION_QA_LIMIT', 'Only one positive reconciliation may authorize fresh QA.');
2320
+ }
2321
+ const now = new Date(), claimId = randomUUID(), binding = subject.binding;
2322
+ const execution = Object.fromEntries(Object.entries(agent).filter(([key]) => key.startsWith('execution_') || key === 'status' || key === 'score'));
2323
+ entry.status = 'authorized';
2324
+ entry.authorization = { decision_sha256: decision.sha256, consumed_at: now.toISOString(), fresh_claim_id: claimId,
2325
+ execution: cloneSessionValue(execution) };
2326
+ // Keep the original council history untouched. The new history becomes
2327
+ // the sole active authority, without resetting either earlier budget.
2328
+ delete agent.council_revalidation_pending;
2329
+ agent.planning_reconciliation_pending = true;
2330
+ agent.content_review_pending = true;
2331
+ agent.content_review_task_id = 'qa-planning-task';
2332
+ agent.status = 'in_progress';
2333
+ agent.execution_resume_status = 'pending_rework';
2334
+ agent.execution_claim_id = claimId;
2335
+ agent.execution_claim_state = 'reserved';
2336
+ agent.execution_claim_started_at = now.toISOString();
2337
+ agent.execution_claim_expires_at = new Date(now.getTime() + 30 * 60 * 1000).toISOString();
2338
+ agent.execution_task_id = 'qa-planning-task';
2339
+ agent.execution_binding = binding;
2340
+ for (const key of ['execution_started_at', 'execution_finished_at', 'execution_result_status', 'execution_qa_result']) delete agent[key];
2341
+ outcome = { authorized: true, claimId, binding, reused: false };
2342
+ return session;
2343
+ }, { beforeCommit: () => subject?.assertCurrent() });
2344
+ return { ...result, settled: result.saved, ...(result.saved ? outcome : {}) };
2345
+ } finally { subject?.close(); }
2346
+ }
2347
+
2348
+ /** A lost/unverifiable result never makes a started assessment launchable again. */
2349
+ export function recordPlanningReconciliationFailure(projectDir, match = {}, code = 'PLANNING_RECONCILIATION_FAILED') {
2350
+ const result = mutateSession(projectDir, session => {
2351
+ const entry = session.agents?.['qa-planning']?.planning_reconciliations?.find(
2352
+ item => item.request?.reconciliation_id === match.reconciliationId);
2353
+ if (!entry || entry.status !== 'running' || entry.launch_nonce !== match.launchNonce
2354
+ || entry.request.held_qa.claim_id !== match.claimId || entry.request.held_qa.finished_at !== match.finishedAt
2355
+ || entry.request.source_gate_id !== match.gateId || match.agent !== 'qa-planning') {
2356
+ throw Object.assign(new Error('Failure does not match a started reconciliation.'), { code: 'PLANNING_RECONCILIATION_LAUNCH_MISMATCH' });
2357
+ }
2358
+ entry.status = 'outcome_unknown';
2359
+ entry.failure = { code: /^[A-Z][A-Z0-9_]{0,100}$/.test(code) ? code : 'PLANNING_RECONCILIATION_FAILED',
2360
+ recorded_at: new Date().toISOString() };
2361
+ return session;
2362
+ });
2363
+ return { ...result, recorded: result.saved };
2364
+ }
2365
+
2366
+ /** Exact exhausted hold required before a controller may reopen Planning ownership. */
2367
+ export function assertPlanningContractRevisionBoundary(projectDir, session, match, binding, subject) {
2368
+ const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
2369
+ if (match.agent !== 'qa-planning') {
2370
+ fail('PLANNING_CONTRACT_REVISION_AGENT_REQUIRED', 'Planning contract revision requires the held QA-Planning result.');
2371
+ }
2372
+ const agent = assertHeldPlanningContent(projectDir, session, { ...match, binding }, fail);
2373
+ assertV2PlanningPredecessors(session, 'qa-planning');
2374
+ if (match.previousRevisionId !== undefined) {
2375
+ const lineage = authenticatePlanningContractRevisionHistory(projectDir, session);
2376
+ const previous = lineage.pending ? lineage.history.at(-2) : lineage.tip;
2377
+ if (previous?.status === 'completed' && previous.request.revision_id === match.previousRevisionId) {
2378
+ if (agent.planning_contract_revision_id || agent.council_revalidation_pending || agent.planning_reconciliation_pending
2379
+ || agent.execution_planning_revision_result || session.language !== previous.request.language
2380
+ || !sameContentReviewBinding(previous.request.source_held_qa.binding, binding)
2381
+ || !isPlanningQaOutcomeValid(agent.execution_qa_result, getQaThreshold('qa-planning', projectDir))
2382
+ || agent.execution_qa_result.gate_result !== 'approved'
2383
+ || agent.content_rejections?.length !== MAX_CORRECTION_CYCLES
2384
+ || agent.council_revalidations?.length !== MAX_COUNCIL_QA_REVALIDATIONS
2385
+ || !Array.isArray(agent.planning_reconciliations)
2386
+ || agent.planning_reconciliations.some(item => !['authorized', 'denied'].includes(item.status))
2387
+ || agent.planning_reconciliations.filter(item => item.authorization).length !== 1) {
2388
+ fail('PLANNING_CONTRACT_REVISION_HELD_QA_MISMATCH', 'Post-correction continuation requires a fresh normal approved QA on the original route and language with exhausted, terminal recovery history.');
2389
+ }
2390
+ const held = canonicalize({ claim_id: match.claimId, finished_at: match.finishedAt, binding, qa_result: agent.execution_qa_result });
2391
+ const correction = derivePlanningCorrectionSource(projectDir, session, previous, held);
2392
+ for (const owner of correction.owners) {
2393
+ if (!sameContentReviewBinding(owner.binding, previous.owner_results[owner.agent]?.execution_binding)) {
2394
+ fail('CONTENT_REVIEW_BINDING_MISMATCH', 'Corrected owners must retain their preceding installed execution bindings.');
2395
+ }
2396
+ }
2397
+ return { agent, held, correction, reconciliation: cloneSessionValue(previous.request.source_reconciliation),
2398
+ previous: canonicalize({ revision_id: previous.request.revision_id, launch_nonce: previous.launch_nonce,
2399
+ decision_sha256: previous.decision_sha256, result_sha256: planningRevisionTerminalDigest(session, previous) }) };
2400
+ }
2401
+ if (!previous || previous.request.revision_id !== match.previousRevisionId
2402
+ || previous !== lineage.active
2403
+ || agent.planning_contract_revision_id !== previous.request.revision_id
2404
+ || (lineage.pending && lineage.pending.request.previous_revision?.revision_id !== previous.request.revision_id)
2405
+ || (!lineage.pending && lineage.tip !== previous)
2406
+ || agent.council_revalidation_pending || agent.planning_reconciliation_pending
2407
+ || agent.planning_reconciliations?.some(item => ['reserved', 'running', 'outcome_unknown'].includes(item.status))) {
2408
+ fail('PLANNING_CONTRACT_REVISION_PREDECESSOR_INVALID', 'Explicit continuation requires the active predecessor with a terminal held QA and no unresolved recovery.');
2409
+ }
2410
+ const active = activePlanningQaHistory(session, 'qa-planning');
2411
+ const held = active.entry.result;
2412
+ if (active.kind !== 'planning_contract_revision' || active.entry !== previous
2413
+ || held?.execution_claim_id !== match.claimId || held.execution_finished_at !== match.finishedAt
2414
+ || !sameContentReviewBinding(active.binding, binding)
2415
+ || !isPlanningQaOutcomeValid(agent.execution_qa_result, getQaThreshold('qa-planning', projectDir))
2416
+ || agent.execution_qa_result.gate_result !== 'approved'
2417
+ || canonicalSerialize(held.qa_result) !== canonicalSerialize(agent.execution_qa_result)
2418
+ || canonicalSerialize(held.artifacts) !== canonicalSerialize(subject.plan.artifacts)
2419
+ || agent.content_rejections?.length !== MAX_CORRECTION_CYCLES
2420
+ || agent.council_revalidations?.length !== MAX_COUNCIL_QA_REVALIDATIONS) {
2421
+ fail('PLANNING_CONTRACT_REVISION_HELD_QA_MISMATCH', 'Continuation must consume the exact genuine QA subject of the preceding direction.');
2422
+ }
2423
+ const resultDigest = planningRevisionResultDigest(session, previous);
2424
+ if (session.planning_contract_revision_subjects.find(item => item.revision_id === previous.request.revision_id).gate_id !== subject.plan.gate_id) {
2425
+ fail('PLANNING_CONTRACT_REVISION_HELD_QA_MISMATCH', 'The preceding QA subject must name this exact council gate.');
2426
+ }
2427
+ const ownedHold = agent.execution_planning_revision_result;
2428
+ if (ownedHold?.execution_claim_id !== match.claimId || ownedHold.execution_finished_at !== match.finishedAt
2429
+ || canonicalSerialize(ownedHold.subject) !== canonicalSerialize({ gate_id: subject.plan.gate_id, artifacts: subject.plan.artifacts })) {
2430
+ fail('PLANNING_CONTRACT_REVISION_HELD_QA_MISMATCH', 'Continuation cannot replace the produced QA subject with caller-authored bytes.');
2431
+ }
2432
+ return { agent, held: canonicalize({ claim_id: match.claimId, finished_at: match.finishedAt, binding, qa_result: held.qa_result }),
2433
+ reconciliation: cloneSessionValue(previous.request.source_reconciliation),
2434
+ previous: canonicalize({ revision_id: previous.request.revision_id, launch_nonce: previous.launch_nonce,
2435
+ decision_sha256: previous.decision_sha256, result_sha256: resultDigest }) };
2436
+ }
2437
+ const active = activePlanningQaHistory(session, 'qa-planning');
2438
+ const reconciliation = agent.planning_reconciliations;
2439
+ if (active.kind !== 'planning_reconciliation' || !agent.planning_reconciliation_pending
2440
+ || agent.content_rejections?.length !== MAX_CORRECTION_CYCLES
2441
+ || agent.council_revalidations?.length !== MAX_COUNCIL_QA_REVALIDATIONS
2442
+ || !Array.isArray(reconciliation) || reconciliation.filter(item => item.authorization).length !== 1
2443
+ || reconciliation.find(item => item.authorization)?.status !== 'authorized') {
2444
+ fail('PLANNING_CONTRACT_REVISION_HISTORY_REQUIRED', 'Contract revision requires the exact exhausted rejection, council revalidation and consumed reconciliation history.');
2445
+ }
2446
+ const held = active.entry.result;
2447
+ if (!held?.artifacts || held.execution_claim_id !== match.claimId
2448
+ || held.execution_finished_at !== match.finishedAt
2449
+ || canonicalSerialize(held.artifacts) !== canonicalSerialize(subject.plan.artifacts)
2450
+ || !sameContentReviewBinding(active.binding, binding)
2451
+ || !isPlanningQaOutcomeValid(agent.execution_qa_result, getQaThreshold('qa-planning', projectDir))
2452
+ || !['score', 'gate_result', 'blocking_count'].every(key => held.qa_result?.[key] === agent.execution_qa_result?.[key])) {
2453
+ fail('PLANNING_CONTRACT_REVISION_HELD_QA_MISMATCH', 'Contract revision requires the exact held QA claim, result and council subject.');
2454
+ }
2455
+ const positive = readAttestedPlanningReconciliationDecision(projectDir, subject, active.entry.request,
2456
+ active.entry.authorization.decision_sha256);
2457
+ if (positive.record.decision.decision !== 'authorize-qa-revalidation'
2458
+ || positive.record.decision.launch_nonce !== active.entry.launch_nonce) {
2459
+ fail('PLANNING_CONTRACT_REVISION_RECONCILIATION_UNTRUSTED',
2460
+ 'Contract revision requires the exact immutable positive reconciliation and launch nonce.');
2461
+ }
2462
+ return { agent, held: canonicalize({ claim_id: match.claimId, finished_at: match.finishedAt,
2463
+ binding, qa_result: held.qa_result }), reconciliation: canonicalize({ request: active.entry.request,
2464
+ launch_nonce: active.entry.launch_nonce, decision_sha256: positive.sha256 }) };
2465
+ }
2466
+
2467
+ /** Reserve one content-addressed controller direction before its host record is persisted. */
2468
+ export function reservePlanningContractRevision(projectDir, match = {}) {
2469
+ let subject;
2470
+ let outcome;
2471
+ try {
2472
+ const result = mutateSession(projectDir, session => {
2473
+ const direction = normalizePlanningContractDirection(match.direction);
2474
+ const { history, tip } = authenticatePlanningContractRevisionHistory(projectDir, session);
2475
+ if (!history.length && session.planning_contract_revision_subjects !== undefined
2476
+ && (!Array.isArray(session.planning_contract_revision_subjects)
2477
+ || session.planning_contract_revision_subjects.length)) {
2478
+ throw Object.assign(new Error('Planning contract revision subject history exists without its immutable direction.'),
2479
+ { code: 'PLANNING_CONTRACT_REVISION_HISTORY_INVALID' });
2480
+ }
2481
+ const previous = history.find(item => item.request.source_gate_id === match.gateId);
2482
+ if (previous) {
2483
+ const same = previous.request?.source_gate_id === match.gateId
2484
+ && previous.request?.source_held_qa?.claim_id === match.claimId
2485
+ && previous.request?.source_held_qa?.finished_at === match.finishedAt
2486
+ && canonicalSerialize(previous.request?.direction) === canonicalSerialize(direction)
2487
+ && (previous.request.previous_revision?.revision_id ?? undefined) === match.previousRevisionId;
2488
+ if (!same) {
2489
+ throw Object.assign(new Error('Only one bounded Planning contract direction is allowed for this history.'), { code: 'PLANNING_CONTRACT_REVISION_LIMIT' });
2490
+ }
2491
+ if (previous !== tip) throw Object.assign(new Error('A superseded direction cannot be replayed as current authority.'),
2492
+ { code: 'PLANNING_CONTRACT_REVISION_SUPERSEDED' });
2493
+ if (previous.status === 'reserved' && previous.request.schema_version === 3) {
2494
+ subject = pinPlanningContractRevision(projectDir, { ...match, direction }, session);
2495
+ if (canonicalSerialize(subject.request) !== canonicalSerialize(previous.request)) {
2496
+ throw Object.assign(new Error('The reserved normal correction source changed before retry.'),
2497
+ { code: 'PLANNING_CONTRACT_REVISION_SUBJECT_CHANGED' });
2498
+ }
2499
+ }
2500
+ outcome = { reservation: cloneSessionValue(previous), reused: true };
2501
+ return session;
2502
+ }
2503
+ if (history.length && match.previousRevisionId === undefined) {
2504
+ throw Object.assign(new Error('Automatic recovery is exhausted. A new controller direction requires the exact --previous-revision; no budget was renewed.'),
2505
+ { code: 'PLANNING_CONTRACT_REVISION_LIMIT' });
2506
+ }
2507
+ if (match.previousRevisionId !== undefined && (!tip || !['active', 'completed'].includes(tip.status)
2508
+ || tip.request.revision_id !== match.previousRevisionId
2509
+ || tip.request.direction.instruction === direction.instruction
2510
+ || history.some(item => item.request.source_held_qa.claim_id === match.claimId))) {
2511
+ throw Object.assign(new Error('Continuation requires a distinct explicit direction, a qualified tip and a previously unconsumed genuine QA result.'),
2512
+ { code: 'PLANNING_CONTRACT_REVISION_PREDECESSOR_INVALID' });
2513
+ }
2514
+ subject = pinPlanningContractRevision(projectDir, { ...match, direction }, session);
2515
+ const reservation = { kind: 'planning_contract_revision', request: cloneSessionValue(subject.request),
2516
+ status: 'reserved', launch_nonce: randomUUID(), reserved_at: new Date().toISOString() };
2517
+ session.planning_contract_revisions = [...history, reservation];
2518
+ outcome = { reservation: cloneSessionValue(reservation), reused: false };
2519
+ return session;
2520
+ }, { beforeCommit: () => subject?.assertCurrent() });
2521
+ return { ...result, reserved: result.saved, ...(result.saved ? outcome : {}) };
2522
+ } finally { subject?.close(); }
2523
+ }
2524
+
2525
+ /** Consume the immutable controller record and reopen real owners without resetting history. */
2526
+ export function settlePlanningContractRevision(projectDir, match = {}) {
2527
+ let subject;
2528
+ let outcome;
2529
+ try {
2530
+ const result = mutateSession(projectDir, session => {
2531
+ const lineage = authenticatePlanningContractRevisionHistory(projectDir, session);
2532
+ const entry = lineage.tip;
2533
+ if (!entry || entry.request?.revision_id !== match.revisionId
2534
+ || entry.launch_nonce !== match.launchNonce
2535
+ || entry.request.source_gate_id !== match.gateId
2536
+ || entry.request.source_held_qa.claim_id !== match.claimId
2537
+ || entry.request.source_held_qa.finished_at !== match.finishedAt
2538
+ || (entry.request.previous_revision?.revision_id ?? undefined) !== match.previousRevisionId) {
2539
+ throw Object.assign(new Error('Settlement requires the exact reserved Planning contract direction.'), { code: 'PLANNING_CONTRACT_REVISION_RESERVATION_MISMATCH' });
2540
+ }
2541
+ if (['active', 'completed'].includes(entry.status)) {
2542
+ readAttestedPlanningContractRevision(projectDir, entry.request, entry.launch_nonce, entry.decision_sha256);
2543
+ outcome = { revision: cloneSessionValue(entry), reused: true };
2544
+ return session;
2545
+ }
2546
+ if (entry.status !== 'reserved') {
2547
+ throw Object.assign(new Error('Only the never-consumed Planning contract direction may settle.'), { code: 'PLANNING_CONTRACT_REVISION_RESERVATION_MISMATCH' });
2548
+ }
2549
+ subject = pinPlanningContractRevision(projectDir, { agent: 'qa-planning', claimId: match.claimId,
2550
+ finishedAt: match.finishedAt, gateId: match.gateId, direction: entry.request.direction,
2551
+ ...(match.previousRevisionId === undefined ? {} : { previousRevisionId: match.previousRevisionId }) }, session);
2552
+ if (canonicalSerialize(subject.request) !== canonicalSerialize(entry.request)) {
2553
+ throw Object.assign(new Error('Planning contract revision subject changed before settlement.'), { code: 'PLANNING_CONTRACT_REVISION_SUBJECT_CHANGED' });
2554
+ }
2555
+ const decision = readAttestedPlanningContractRevision(projectDir, entry.request, entry.launch_nonce, match.decisionSha256);
2556
+ const affected = [...PLANNING_CONTRACT_REVISION_OWNERS];
2557
+ const previousAgents = Object.fromEntries(affected.map(name => [name, cloneSessionValue(session.agents[name])]));
2558
+ const previousResults = Object.fromEntries(affected.filter(name => session.agent_results?.[name])
2559
+ .map(name => [name, cloneSessionValue(session.agent_results[name])]));
2560
+ if ([2, 3].includes(entry.request.schema_version)) {
2561
+ const previous = lineage.history.at(-2);
2562
+ previous.status = 'superseded';
2563
+ previous.superseded_by = entry.request.revision_id;
2564
+ }
2565
+ entry.status = 'active';
2566
+ entry.decision_path = decision.path;
2567
+ entry.decision_sha256 = decision.sha256;
2568
+ entry.applied_at = new Date().toISOString();
2569
+ entry.owner_results = {};
2570
+ entry.archived = { previous_agents: previousAgents, previous_agent_results: previousResults,
2571
+ completed_agents: [...(session.completed_agents || [])], last_handoff: session.last_handoff || null,
2572
+ current_execution_binding: cloneSessionValue(session.current_execution_binding || null) };
2573
+ const reset = resetPipelineTo({ agents: structuredClone(session.agents),
2574
+ completedAgents: [...(session.completed_agents || [])], history: [] }, PLANNING_CONTRACT_REVISION_TARGET);
2575
+ for (const name of affected) {
2576
+ if (session.agent_results) delete session.agent_results[name];
2577
+ const agent = session.agents[name] || {};
2578
+ for (const key of Object.keys(agent)) {
2579
+ if (key.startsWith('execution_')) delete agent[key];
2580
+ }
2581
+ for (const key of ['started_at', 'completed_at', 'startedAt', 'completedAt',
2582
+ 'council_revalidation_pending', 'planning_reconciliation_pending']) delete agent[key];
2583
+ agent.status = name === PLANNING_CONTRACT_REVISION_TARGET ? 'pending_rework' : reset.agents[name].status;
2584
+ agent.score = null;
2585
+ agent.criteria_count = 0;
2586
+ agent.content_review_pending = true;
2587
+ agent.content_review_task_id = `${name}-task`;
2588
+ agent.planning_contract_revision_id = entry.request.revision_id;
2589
+ session.agents[name] = agent;
2590
+ }
2591
+ session.completed_agents = reset.completedAgents;
2592
+ session.current_agent = PLANNING_CONTRACT_REVISION_TARGET;
2593
+ session.pipeline_position = reset.completedAgents.length;
2594
+ session.last_handoff = 'brief';
2595
+ // Persist the normalized empty binding so exact retries are byte-idempotent.
2596
+ if (entry.request.schema_version === 3) session.current_execution_binding = null;
2597
+ else delete session.current_execution_binding;
2598
+ session.deviations = [...(session.deviations || []), { type: 'rollback',
2599
+ details: { targetAgent: PLANNING_CONTRACT_REVISION_TARGET, source: 'planning_contract_revision',
2600
+ revision_id: entry.request.revision_id, source_gate_id: entry.request.source_gate_id },
2601
+ applied_at: entry.applied_at, changes: affected.map(name => `Reopen agent: ${name}`) }];
2602
+ outcome = { revision: cloneSessionValue(entry), reused: false };
2603
+ return session;
2604
+ }, { beforeCommit: () => subject?.assertCurrent() });
2605
+ return { ...result, settled: result.saved, ...(result.saved ? outcome : {}) };
2606
+ } finally { subject?.close(); }
2607
+ }
2608
+
2609
+ /** Reject only unapproved complete Planning content, without inventing an execution failure. */
2610
+ export function rejectAgentResult(projectDir, match = {}) {
2611
+ let failureCode;
2612
+ const result = mutateSession(projectDir, session => {
2613
+ const fail = (code, message) => { failureCode = code; throw new Error(message); };
2614
+ const agent = assertHeldPlanningContent(projectDir, session, match, fail);
2615
+ if (typeof match.reason !== 'string' || !match.reason.trim()
2616
+ || Buffer.byteLength(match.reason, 'utf8') > CONTENT_REVIEW_REASON_MAX_BYTES) {
2617
+ fail('CONTENT_REVIEW_REASON_INVALID', 'A nonempty content-review reason of at most 8192 UTF-8 bytes is required.');
2618
+ }
2619
+ const reviews = agent.content_rejections || [];
2620
+ if (!Array.isArray(reviews) || reviews.length >= MAX_CORRECTION_CYCLES) {
2621
+ fail('CONTENT_REJECTION_LIMIT', `At most ${MAX_CORRECTION_CYCLES} content rejections per agent are allowed; direction is required.`);
2622
+ }
2623
+ const execution = Object.fromEntries(Object.entries(agent).filter(([key]) => key.startsWith('execution_') || key === 'status' || key === 'score'));
2624
+ agent.content_rejections = [...reviews, { kind: 'content_rejection', reason: match.reason,
2625
+ rejected_at: new Date().toISOString(), binding: cloneSessionValue(match.binding), execution: cloneSessionValue(execution) }];
2626
+ agent.content_review_pending = true;
2627
+ if (agent.execution_task_id) agent.content_review_task_id = agent.execution_task_id;
2628
+ agent.status = 'pending_rework';
2629
+ for (const key of ['execution_claim_id', 'execution_claim_state', 'execution_claim_started_at',
2630
+ 'execution_claim_expires_at', 'execution_started_at', 'execution_finished_at', 'execution_result_status',
2631
+ 'execution_qa_result']) delete agent[key];
2632
+ return session;
2633
+ });
2634
+ return result.saved ? { ...result, rejected: true } : { ...result, rejected: false, code: failureCode || 'SESSION_WRITE_FAILED' };
2635
+ }
2636
+
2637
+ /** Council-directed QA is a fresh execution, never a third content rejection. */
2638
+ export function revalidateQaAfterCouncil(projectDir, match = {}) {
2639
+ let proof;
2640
+ let outcome;
2641
+ try {
2642
+ const result = mutateSession(projectDir, session => {
2643
+ const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
2644
+ if (match.agent !== 'qa-planning') fail('COUNCIL_QA_AGENT_REQUIRED', 'Only QA-Planning can revalidate a Planning council.');
2645
+ proof = pinCouncilQaRevalidation(projectDir, match.gateId);
2646
+ const binding = qaInvocationBinding(proof.artifact);
2647
+ assertPlanningContentBoundary(projectDir, session, { ...match, binding }, fail);
2648
+ assertV2PlanningPredecessors(session, match.agent);
2649
+ const pending = session.agents?.[match.agent];
2650
+ const previous = pending?.council_revalidations?.at(-1);
2651
+ // A lost response can retrieve the same still-reserved command. It may
2652
+ // neither allocate a second claim nor resurrect one already started.
2653
+ if (pending?.council_revalidation_pending && pending.status === 'in_progress'
2654
+ && pending.execution_claim_state === 'reserved' && Date.parse(pending.execution_claim_expires_at) > Date.now()
2655
+ && previous?.execution?.execution_claim_id === match.claimId
2656
+ && previous.execution.execution_finished_at === match.finishedAt
2657
+ && previous.source_gate_id === match.gateId && previous.subject_gate_id === proof.evidence.subject_gate_id
2658
+ && previous.fresh_claim_id === pending.execution_claim_id
2659
+ && sameContentReviewBinding(binding, pending.execution_binding)
2660
+ && sameContentReviewBinding(binding, session.current_execution_binding)) {
2661
+ outcome = { claimId: pending.execution_claim_id, binding, reused: true };
2662
+ return session;
2663
+ }
2664
+ const agent = assertHeldPlanningContent(projectDir, session, { ...match, binding }, fail);
2665
+ assertV2PlanningPredecessors(session, match.agent);
2666
+ if (agent.content_rejections?.length !== MAX_CORRECTION_CYCLES) {
2667
+ fail('COUNCIL_QA_CONTENT_LIMIT_REQUIRED', 'Use the existing content-rejection route before its finite limit is reached.');
2668
+ }
2669
+ if (!isPlanningQaOutcomeValid(agent.execution_qa_result, getQaThreshold(match.agent, projectDir))
2670
+ || agent.execution_qa_result.gate_result !== 'approved') {
2671
+ fail('COUNCIL_QA_HELD_VERDICT_REQUIRED', 'Revalidation requires the genuine held QA approval, not an unresolved negative result.');
2672
+ }
2673
+ const history = agent.council_revalidations || [];
2674
+ if (!Array.isArray(history) || history.length >= MAX_COUNCIL_QA_REVALIDATIONS) {
2675
+ fail('COUNCIL_QA_REVALIDATION_LIMIT', 'Council-directed QA revalidation budget exhausted; adjudicate the remaining findings.');
2676
+ }
2677
+ if (history.some(item => item.source_gate_id === match.gateId
2678
+ || item.execution?.execution_claim_id === match.claimId
2679
+ || item.subject_gate_id === proof.evidence.subject_gate_id)) {
2680
+ fail('COUNCIL_QA_REVALIDATION_REPLAY', 'This council decision, held result or corrected subject was already consumed.');
2681
+ }
2682
+ const execution = Object.fromEntries(Object.entries(agent).filter(([key]) => key.startsWith('execution_') || key === 'status' || key === 'score'));
2683
+ const now = new Date();
2684
+ const claimId = randomUUID();
2685
+ agent.council_revalidations = [...history, { kind: 'council_qa_revalidation', ...proof.evidence,
2686
+ requested_at: now.toISOString(), binding, execution: cloneSessionValue(execution), fresh_claim_id: claimId }];
2687
+ agent.council_revalidation_pending = true;
2688
+ agent.content_review_pending = true;
2689
+ agent.content_review_task_id = 'qa-planning-task';
2690
+ agent.status = 'in_progress';
2691
+ agent.execution_resume_status = 'pending_rework';
2692
+ agent.execution_claim_id = claimId;
2693
+ agent.execution_claim_state = 'reserved';
2694
+ agent.execution_claim_started_at = now.toISOString();
2695
+ agent.execution_claim_expires_at = new Date(now.getTime() + 30 * 60 * 1000).toISOString();
2696
+ agent.execution_task_id = 'qa-planning-task';
2697
+ agent.execution_binding = binding;
2698
+ for (const key of ['execution_started_at', 'execution_finished_at', 'execution_result_status', 'execution_qa_result']) delete agent[key];
2699
+ outcome = { claimId, binding };
2700
+ return session;
2701
+ }, { beforeCommit: () => proof?.assertCurrent() });
2702
+ return { ...result, revalidated: result.saved, ...(result.saved ? outcome : {}) };
2703
+ } finally { proof?.close(); }
2704
+ }
2705
+
2706
+ /** Negatively settle a legacy ambiguous hold. Never converts it to completion. */
2707
+ export function rejectLegacyAgentResult(projectDir, match = {}) {
2708
+ let outcome = { recovered: false, code: 'AGENT_RESULT_RECOVERY_MISMATCH',
2709
+ error: 'Recovery requires the exact unfinished legacy result identity.' };
2710
+ const result = mutateSession(projectDir, (session) => {
2711
+ const agent = session.agents?.[match.agent];
2712
+ if (!agent || agent.status !== 'awaiting_advance' || agent.execution_claim_state !== 'awaiting_advance'
2713
+ || agent.execution_result_status !== undefined || session.completed_agents?.includes(match.agent)
2714
+ || typeof match.finishedAt !== 'string' || !Number.isFinite(Date.parse(match.finishedAt))
2715
+ || agent.execution_finished_at !== match.finishedAt) return session;
2716
+ agent.execution_failure = { status: 'error', reason: 'legacy_ambiguous_result_rejected',
2717
+ execution_finished_at: agent.execution_finished_at, rejected_at: new Date().toISOString() };
2718
+ agent.status = 'pending';
2719
+ for (const key of ['execution_claim_id', 'execution_claim_state', 'execution_claim_started_at',
2720
+ 'execution_claim_expires_at', 'execution_started_at', 'execution_finished_at']) delete agent[key];
2721
+ outcome = { recovered: true };
2722
+ return session;
1474
2723
  });
1475
2724
  return { ...result, ...outcome };
1476
2725
  }
@@ -1484,12 +2733,17 @@ export function releaseAgentExecution(projectDir, match = {}) {
1484
2733
  const agent = session.agents?.[match.agent];
1485
2734
  if (!agent || agent.execution_claim_id !== match.claimId) return session;
1486
2735
  if (agent.execution_claim_state === 'awaiting_advance' || agent.status === 'awaiting_advance') return session;
1487
- if (agent.status === 'in_progress') agent.status = 'pending';
2736
+ if (agent.status === 'in_progress') agent.status = agent.content_review_pending
2737
+ || agent.execution_resume_status === 'pending_rework' ? 'pending_rework' : 'pending';
2738
+ delete agent.execution_resume_status;
1488
2739
  delete agent.execution_claim_id;
1489
2740
  delete agent.execution_claim_state;
1490
2741
  delete agent.execution_claim_started_at;
1491
2742
  delete agent.execution_claim_expires_at;
1492
2743
  delete agent.execution_started_at;
2744
+ delete agent.execution_qa_report_baseline;
2745
+ delete agent.execution_planning_revision_baseline;
2746
+ delete agent.execution_planning_revision_relay_claim_id;
1493
2747
  released = true;
1494
2748
  return session;
1495
2749
  });
@@ -1504,7 +2758,12 @@ export function releaseReservedAgentExecutions(projectDir) {
1504
2758
  const result = mutateSession(sessionProjectDir, (session) => {
1505
2759
  for (const agent of Object.values(session.agents || {})) {
1506
2760
  if (agent.execution_claim_state !== 'reserved') continue;
1507
- if (agent.status === 'in_progress') agent.status = 'pending';
2761
+ if (agent.status === 'in_progress') agent.status = agent.content_review_pending
2762
+ || agent.execution_resume_status === 'pending_rework' ? 'pending_rework' : 'pending';
2763
+ delete agent.execution_resume_status;
2764
+ delete agent.execution_qa_report_baseline;
2765
+ delete agent.execution_planning_revision_baseline;
2766
+ delete agent.execution_planning_revision_relay_claim_id;
1508
2767
  delete agent.execution_claim_id;
1509
2768
  delete agent.execution_claim_state;
1510
2769
  delete agent.execution_claim_started_at;
@@ -1550,6 +2809,8 @@ export function claimPendingInteraction(projectDir, claim = {}) {
1550
2809
  const sessionProjectDir = findSessionProjectDir(projectDir);
1551
2810
  if (!sessionProjectDir) return { saved: false, claimed: false, error: 'Session file not found in working directory or its parents' };
1552
2811
  let outcome = { allowed: true, claimed: false };
2812
+ let subject;
2813
+ try {
1553
2814
  const result = mutateSession(sessionProjectDir, (session) => {
1554
2815
  const pending = session.pending_interaction;
1555
2816
  if (!pending) {
@@ -1583,8 +2844,8 @@ export function claimPendingInteraction(projectDir, claim = {}) {
1583
2844
  };
1584
2845
  return session;
1585
2846
  }
1586
- const response = typeof claim.response === 'string' ? claim.response.trim() : '';
1587
- if (!response) {
2847
+ const response = typeof claim.response === 'string' ? claim.response : '';
2848
+ if (!response.trim()) {
1588
2849
  outcome = {
1589
2850
  allowed: false, claimed: false, code: 'PENDING_INTERACTION_REQUIRES_RESPONSE',
1590
2851
  error: `Agent ${pending.agent} is waiting for the user's response.`,
@@ -1615,19 +2876,45 @@ export function claimPendingInteraction(projectDir, claim = {}) {
1615
2876
  };
1616
2877
  return session;
1617
2878
  }
2879
+ const history = pending.history || [];
2880
+ const responseKind = reasoningEscalation ? 'controller_instruction' : 'user_answer';
2881
+ validateRelayHistory([...history, {
2882
+ interaction_id: pending.interaction_id, kind: responseKind,
2883
+ ...(reasoningEscalation ? {} : { question: pending.question }), response,
2884
+ }], pending.question, pending.initial_task_context);
1618
2885
  const claimId = randomUUID();
2886
+ const contentReviewContext = claimContentReviewContext(projectDir, session, {
2887
+ agent: pending.agent, taskId: pending.task_id, claimId, binding,
2888
+ });
2889
+ if (hasPlanningQaRecovery(session.agents?.[pending.agent], pending.agent)) {
2890
+ assertPlanningQaReportBaseline(session.agents[pending.agent], { pending, binding, taskId: pending.task_id });
2891
+ subject = pinClaimedCouncilQa(sessionProjectDir, session, pending.agent, { allowOwnInteraction: true, excludeQaReport: true });
2892
+ }
2893
+ if (session.agents?.[pending.agent]?.planning_contract_revision_id) {
2894
+ const baseline = assertPlanningContractRevisionExecutionBaseline(session.agents[pending.agent], { pending, binding, taskId: pending.task_id });
2895
+ contentReviewContext.planning_contract_revision.execution_subject = cloneSessionValue(baseline.subject_artifacts);
2896
+ }
1619
2897
  session.pending_interaction = {
1620
2898
  ...pending,
1621
2899
  status: 'relay_in_flight',
1622
2900
  ...(reasoningEscalation ? { escalation_execution_binding: binding } : {}),
1623
2901
  relay_claim_id: claimId,
2902
+ relay_response: response,
2903
+ relay_response_kind: responseKind,
2904
+ relay_next_task_id: reasoningEscalation ? `${pending.task_id}-post-escalation` : pending.task_id,
1624
2905
  relay_started_at: claim.timestamp || new Date().toISOString(),
1625
2906
  relay_lease_expires_at: claim.leaseExpiresAt || null,
1626
2907
  };
1627
- outcome = { allowed: true, claimed: true, claimId };
2908
+ outcome = { allowed: true, claimed: true, claimId, contentReviewContext, relayContext: {
2909
+ agent: pending.agent, taskId: pending.task_id, interactionId: pending.interaction_id,
2910
+ binding, history, historyComplete: pending.history_complete === true,
2911
+ initialTaskContext: pending.initial_task_context,
2912
+ question: pending.question, response, responseKind,
2913
+ } };
1628
2914
  return session;
1629
- });
1630
- return { ...result, ...outcome };
2915
+ }, { beforeCommit: () => subject?.assertCurrent() });
2916
+ return { ...result, ...outcome, claimed: result.saved && outcome.claimed };
2917
+ } finally { subject?.close(); }
1631
2918
  }
1632
2919
 
1633
2920
  /** Restore a claimed relay after a controlled provider or contract failure. */
@@ -1654,6 +2941,10 @@ export function releasePendingInteraction(projectDir, match = {}) {
1654
2941
  delete session.pending_interaction.relay_claim_id;
1655
2942
  delete session.pending_interaction.relay_started_at;
1656
2943
  delete session.pending_interaction.relay_lease_expires_at;
2944
+ delete session.pending_interaction.relay_response;
2945
+ delete session.pending_interaction.relay_response_kind;
2946
+ delete session.pending_interaction.relay_next_task_id;
2947
+ delete session.pending_interaction.escalation_execution_binding;
1657
2948
  released = true;
1658
2949
  settled = true;
1659
2950
  return session;
@@ -1668,6 +2959,12 @@ export function clearPendingInteraction(projectDir, match = {}) {
1668
2959
  let cleared = false;
1669
2960
  const result = mutateSession(sessionProjectDir, (session) => {
1670
2961
  const pending = session.pending_interaction;
2962
+ if (match.requireClaim && (!pending || pending.status !== 'relay_in_flight'
2963
+ || !match.claimId || pending.relay_claim_id !== match.claimId
2964
+ || pending.interaction_id !== match.interactionId
2965
+ || pending.agent !== match.agent || pending.task_id !== match.taskId)) {
2966
+ throw new Error('Pending interaction completion rejected: relay ownership mismatch');
2967
+ }
1671
2968
  if (!pending) return session;
1672
2969
  if (match.agent && pending.agent !== match.agent) return session;
1673
2970
  if (match.taskId && pending.task_id !== match.taskId) return session;
@@ -1710,13 +3007,65 @@ export function recordModeTransition(projectDir, transition) {
1710
3007
  * @param {string} projectDir
1711
3008
  * @param {object} completion - { agent, status, score, outputs, handoffData }
1712
3009
  * handoffData may include:
1713
- * - decision_trail_entries: array of new Decision Trail objects to append
3010
+ * - decision_trail_entries: new findings or compatible repeated findings
1714
3011
  * - resolved_decision_trail: array of DT entry IDs to mark resolved: true
1715
3012
  * @returns {{ saved: boolean }}
1716
3013
  */
1717
3014
  export function recordAgentCompletion(projectDir, completion) {
1718
3015
  const { agent, status, score, outputs = [], handoffData = {} } = completion;
1719
- return mutateSession(projectDir, (session) => {
3016
+ let failureCode;
3017
+ let subject;
3018
+ let revisionOutput;
3019
+ let revisionHeldResult;
3020
+ try {
3021
+ const result = mutateSession(projectDir, (session) => {
3022
+ // Consume the SAME hold validated before any async artifact/council checks.
3023
+ // A concurrent content rejection must win without later DT merges or events.
3024
+ const expected = completion.expectedResult;
3025
+ const held = session.agents?.[agent];
3026
+ assertPlanningContractRevisionOwner(session, [agent], projectDir);
3027
+ if (expected && (held?.status !== 'awaiting_advance' || held.execution_claim_state !== 'awaiting_advance'
3028
+ || held.execution_result_status !== 'complete' || held.execution_claim_id !== expected.claimId
3029
+ || held.execution_finished_at !== expected.finishedAt || session.completed_agents?.includes(agent))) {
3030
+ failureCode = 'AGENT_RESULT_HOLD_CHANGED';
3031
+ throw new Error('The complete result changed or was consumed before canonical advance.');
3032
+ }
3033
+ if (agent === 'qa-planning') subject = pinHeldCouncilQaSubject(projectDir, session);
3034
+ const revision = held?.planning_contract_revision_id
3035
+ ? session.planning_contract_revisions?.find(item => item.request?.revision_id === held.planning_contract_revision_id)
3036
+ : null;
3037
+ if (revision) {
3038
+ if (!expected || status !== 'completed' || revision.status !== 'active'
3039
+ || planningContractRevisionHistory(session).tip !== revision) {
3040
+ failureCode = 'PLANNING_CONTRACT_REVISION_CONTEXT_INVALID';
3041
+ throw new Error('Planning owner completion requires its exact held claim and the single active contract revision.');
3042
+ }
3043
+ const relayClaimId = held.execution_planning_revision_relay_claim_id;
3044
+ if (relayClaimId && relayClaimId !== expected.claimId) {
3045
+ failureCode = 'PLANNING_CONTRACT_REVISION_EXECUTION_BASELINE_INVALID';
3046
+ throw new Error('Planning owner completion does not belong to the recorded relay lineage.');
3047
+ }
3048
+ const attemptBaseline = assertPlanningContractRevisionExecutionBaseline(held, {
3049
+ ...(relayClaimId
3050
+ ? { pending: { planning_revision_baseline: held.execution_planning_revision_baseline } }
3051
+ : { claimId: expected.claimId }),
3052
+ binding: held.execution_binding, taskId: held.execution_task_id,
3053
+ });
3054
+ revisionOutput = pinPlanningContractRevisionOwnerOutput(projectDir, revision, agent, attemptBaseline);
3055
+ revisionHeldResult = cloneSessionValue(held.execution_planning_revision_result);
3056
+ if (!revisionHeldResult || revisionHeldResult.execution_claim_id !== expected.claimId
3057
+ || revisionHeldResult.execution_finished_at !== expected.finishedAt
3058
+ || canonicalSerialize(revisionHeldResult.subject) !== canonicalSerialize({ gate_id: revisionOutput.plan.gate_id,
3059
+ artifacts: revisionOutput.plan.artifacts })
3060
+ || canonicalSerialize(revisionHeldResult.owned_artifacts) !== canonicalSerialize(revisionOutput.owned_artifacts)
3061
+ || canonicalSerialize(revisionHeldResult.handoff) !== canonicalSerialize(revisionOutput.handoff)) {
3062
+ failureCode = 'PLANNING_CONTRACT_REVISION_HELD_OUTPUT_CHANGED';
3063
+ throw new Error('Planning owner artifacts or handoff changed after the genuine execution was held.');
3064
+ }
3065
+ }
3066
+ const heldExecution = revision ? { execution_claim_id: held.execution_claim_id,
3067
+ execution_finished_at: held.execution_finished_at,
3068
+ execution_binding: cloneSessionValue(held.execution_binding || null) } : null;
1720
3069
 
1721
3070
  // Update agent data
1722
3071
  if (!session.agents[agent]) {
@@ -1735,8 +3084,19 @@ export function recordAgentCompletion(projectDir, completion) {
1735
3084
  delete session.agents[agent].execution_claim_state;
1736
3085
  delete session.agents[agent].execution_claim_started_at;
1737
3086
  delete session.agents[agent].execution_claim_expires_at;
3087
+ delete session.agents[agent].execution_resume_status;
1738
3088
  delete session.agents[agent].execution_started_at;
1739
3089
  delete session.agents[agent].execution_finished_at;
3090
+ delete session.agents[agent].execution_result_status;
3091
+ delete session.agents[agent].content_review_pending;
3092
+ delete session.agents[agent].content_review_task_id;
3093
+ delete session.agents[agent].council_revalidation_pending;
3094
+ delete session.agents[agent].planning_reconciliation_pending;
3095
+ delete session.agents[agent].execution_qa_report_baseline;
3096
+ delete session.agents[agent].execution_planning_revision_baseline;
3097
+ delete session.agents[agent].execution_planning_revision_relay_claim_id;
3098
+ delete session.agents[agent].execution_planning_revision_result;
3099
+ delete session.agents[agent].planning_contract_revision_id;
1740
3100
 
1741
3101
  // Add to completed agents list
1742
3102
  session.completed_agents = session.completed_agents || [];
@@ -1751,7 +3111,18 @@ export function recordAgentCompletion(projectDir, completion) {
1751
3111
  score,
1752
3112
  outputs,
1753
3113
  completed_at: session.agents[agent].completed_at,
3114
+ ...(session.agents[agent].execution_qa_result ? { qa_result: session.agents[agent].execution_qa_result } : {}),
1754
3115
  };
3116
+ if (revision) {
3117
+ revision.owner_results[agent] = { ...heldExecution,
3118
+ subject: cloneSessionValue(revisionHeldResult.subject), owned_artifacts: cloneSessionValue(revisionHeldResult.owned_artifacts),
3119
+ handoff: cloneSessionValue(revisionHeldResult.handoff), completed_at: session.agents[agent].completed_at };
3120
+ if (agent === 'qa-planning') {
3121
+ revision.status = 'completed';
3122
+ revision.completed_at = session.agents[agent].completed_at;
3123
+ }
3124
+ }
3125
+ delete session.agents[agent].execution_qa_result;
1755
3126
 
1756
3127
  // Update last handoff
1757
3128
  session.last_handoff = agent;
@@ -1761,10 +3132,20 @@ export function recordAgentCompletion(projectDir, completion) {
1761
3132
  // document instead of directly editing session.yaml (which is blocked by the
1762
3133
  // mode-governance hook). The CLI reads them here and merges into session.yaml.
1763
3134
  if (Array.isArray(handoffData.decision_trail_entries) && handoffData.decision_trail_entries.length > 0) {
1764
- session.decision_trail = [
1765
- ...(session.decision_trail || []),
1766
- ...handoffData.decision_trail_entries,
1767
- ];
3135
+ const trail = [...(session.decision_trail || [])];
3136
+ for (const incoming of handoffData.decision_trail_entries) {
3137
+ const existing = trail.find(entry => entry.id === incoming.id);
3138
+ if (!existing) { trail.push(incoming); continue; }
3139
+ // A negative recheck must keep its open finding for QA/FVP routing, but
3140
+ // recurrence is not a new identity and must not overwrite its history.
3141
+ // In particular, do not silently reopen or resolve a prior record.
3142
+ if (existing.fault_origin !== incoming.fault_origin
3143
+ || (existing.resolved === true) !== (incoming.resolved === true)) {
3144
+ failureCode = 'DECISION_TRAIL_ID_CONFLICT';
3145
+ throw new Error(`Decision Trail identity ${incoming.id} conflicts with its canonical origin or resolution.`);
3146
+ }
3147
+ }
3148
+ session.decision_trail = trail;
1768
3149
  }
1769
3150
 
1770
3151
  // Mark resolved Decision Trail entries from agent handoff document.
@@ -1803,7 +3184,9 @@ export function recordAgentCompletion(projectDir, completion) {
1803
3184
  }
1804
3185
 
1805
3186
  return session;
1806
- });
3187
+ }, { beforeCommit: () => { subject?.assertCurrent(); revisionOutput?.assertCurrent(); } });
3188
+ return result.saved ? result : { ...result, code: failureCode || result.code || 'SESSION_WRITE_FAILED' };
3189
+ } finally { subject?.close(); revisionOutput?.close(); }
1807
3190
  }
1808
3191
 
1809
3192
  /**
@@ -1824,6 +3207,41 @@ export function parseHandoffDecisionTrail(handoffMarkdown) {
1824
3207
  return { decision_trail_entries, resolved_decision_trail };
1825
3208
  }
1826
3209
 
3210
+ const invalid = message => Object.assign(new Error(message), { code: 'INVALID_HANDOFF_DECISION_TRAIL' });
3211
+ // Read the structured field, not an incidental heading chosen by a model.
3212
+ // The normal writer contract nests this YAML inside Summary/Outputs/Deep Context.
3213
+ for (const match of handoffMarkdown.matchAll(/^```(?:yaml|yml)\s*\r?\n([\s\S]*?)^```\s*$/gm)) {
3214
+ if (!/^(?:decision_trail|resolved_decision_trail):/m.test(match[1])) continue;
3215
+ let document;
3216
+ try { document = yaml.load(match[1], { schema: yaml.JSON_SCHEMA }); }
3217
+ catch (error) { throw invalid(`Malformed Decision Trail YAML: ${error.message}`); }
3218
+ if (!document || typeof document !== 'object' || Array.isArray(document)) throw invalid('Decision Trail requires a mapping.');
3219
+ if (Object.hasOwn(document, 'decision_trail')) {
3220
+ const entries = document.decision_trail;
3221
+ if (!Array.isArray(entries) || entries.length > 256) throw invalid('decision_trail must be a bounded array.');
3222
+ for (const entry of entries) {
3223
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)
3224
+ || typeof entry.id !== 'string' || !/^DT-[A-Za-z0-9][A-Za-z0-9-]*$/.test(entry.id)
3225
+ || !['INTENT', 'SPEC', 'CODE', 'DEFER', 'UNKNOWN'].includes(entry.fault_origin)
3226
+ || typeof entry.resolved !== 'boolean') throw invalid('Decision Trail entry identity, fault_origin, or resolved type is invalid.');
3227
+ for (const [key, value] of Object.entries(entry)) {
3228
+ if (['id', 'fault_origin', 'resolved'].includes(key)) continue;
3229
+ if (key === 'correction_loop') {
3230
+ if (!Number.isSafeInteger(value) || value < 0) throw invalid('correction_loop must be a nonnegative integer.');
3231
+ } else if (typeof value !== 'string') throw invalid(`Decision Trail ${key} must be a string.`);
3232
+ }
3233
+ decision_trail_entries.push(entry);
3234
+ }
3235
+ }
3236
+ if (Object.hasOwn(document, 'resolved_decision_trail')) {
3237
+ if (!Array.isArray(document.resolved_decision_trail)
3238
+ || !document.resolved_decision_trail.every(id => typeof id === 'string' && /^DT-[A-Za-z0-9][A-Za-z0-9-]*$/.test(id))) {
3239
+ throw invalid('resolved_decision_trail must contain Decision Trail IDs.');
3240
+ }
3241
+ resolved_decision_trail.push(...document.resolved_decision_trail);
3242
+ }
3243
+ }
3244
+
1827
3245
  // Extract ## Decision Trail section (stop at next ## heading or end of input).
1828
3246
  // Note: JS regex does not support \z (end-of-input anchor). Using $(?![\s\S])
1829
3247
  // — an $ followed by negative lookahead "no more chars" — gives the same
@@ -1833,7 +3251,10 @@ export function parseHandoffDecisionTrail(handoffMarkdown) {
1833
3251
  /^## Decision Trail\s*\n([\s\S]*?)(?=^## |$(?![\s\S]))/m,
1834
3252
  );
1835
3253
  if (dtMatch) {
1836
- const block = dtMatch[1];
3254
+ // Structured fences were consumed above, including when placed under the
3255
+ // legacy heading. Do not parse those same entries a second time.
3256
+ const block = dtMatch[1].replace(/^```(?:yaml|yml)\s*\r?\n([\s\S]*?)^```\s*$/gm,
3257
+ (fence, body) => /^(?:decision_trail|resolved_decision_trail):/m.test(body) ? '' : fence);
1837
3258
  // Parse YAML-like bullet blocks: lines starting with " id: DT-"
1838
3259
  // Split on entries that start a new id: field
1839
3260
  const entryBlocks = block.split(/(?=^\s*-?\s*id:\s*DT-)/m).filter(Boolean);
@@ -1877,6 +3298,13 @@ export function parseHandoffDecisionTrail(handoffMarkdown) {
1877
3298
  }
1878
3299
  }
1879
3300
 
3301
+ const ids = decision_trail_entries.map(entry => entry.id);
3302
+ if (new Set(ids).size !== ids.length || new Set(resolved_decision_trail).size !== resolved_decision_trail.length) {
3303
+ throw invalid('Duplicate Decision Trail IDs are ambiguous.');
3304
+ }
3305
+ if (decision_trail_entries.some(entry => entry.resolved === false && resolved_decision_trail.includes(entry.id))) {
3306
+ throw invalid('A Decision Trail entry cannot be both unresolved and resolved in the same handoff.');
3307
+ }
1880
3308
  return { decision_trail_entries, resolved_decision_trail };
1881
3309
  }
1882
3310