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
@@ -0,0 +1,711 @@
1
+ /**
2
+ * A Planning contract revision reopens genuine upstream ownership after the
3
+ * bounded QA recovery history is exhausted. It is pipeline authority only.
4
+ */
5
+ import { createHash } from 'node:crypto';
6
+ import { lstatSync, readFileSync, realpathSync } from 'node:fs';
7
+ import { isAbsolute, join, relative } from 'node:path';
8
+ import { canonicalize, canonicalSerialize, sha256 } from '@chati/core';
9
+ import { pinPlanningCouncilRework, pinPlanningQaSubject } from './council-gate-runtime.js';
10
+ import { assertPlanningContractRevisionBoundary, loadSession } from './session-manager.js';
11
+ import { readAttestedPlanningReconciliationDecision } from './planning-reconciliation.js';
12
+ import { loadRuntimeInstallationV2, resolveRuntimeInvocationV2 } from './runtime-installation-v2.js';
13
+ import { findAgentHandoffPath } from '../utils/handoff-paths.js';
14
+ import { MAX_CORRECTION_CYCLES } from './pipeline-manager.js';
15
+ import { persistImmutableInstallationFile, readSafeInstallationStorageFile } from '../installer-v2/catalog-client.js';
16
+ import { signHostAuthorityPayload, verifyHostAuthorityPayload } from '../installer-v2/installation-authority.js';
17
+
18
+ export const PLANNING_CONTRACT_REVISION_DOMAIN = 'chati.planning-contract-revision.v1';
19
+ const CONTINUATION_DOMAIN = 'chati.planning-contract-revision.v2';
20
+ const CORRECTION_CONTINUATION_DOMAIN = 'chati.planning-contract-revision.v3';
21
+ export const PLANNING_CONTRACT_REVISION_TARGET = 'detail';
22
+ export const PLANNING_CONTRACT_REVISION_OWNERS = Object.freeze(['detail', 'architect', 'ux', 'qa-planning']);
23
+ const INSTRUCTION_MAX_BYTES = 8192;
24
+ const BRIEF_PATH = 'artifacts/1-Brief/brief-report.md';
25
+ const OWNER_ARTIFACTS = Object.freeze({
26
+ detail: Object.freeze(['artifacts/2-PRD/prd.md']),
27
+ architect: Object.freeze([
28
+ 'artifacts/3-Architecture/architecture.md',
29
+ 'artifacts/3-Architecture/system-architecture.md',
30
+ 'artifacts/3-Architecture/data-architecture.md',
31
+ ]),
32
+ ux: Object.freeze([
33
+ 'artifacts/4-UX/ux-specification.md',
34
+ 'artifacts/4-UX/visual-surface-assessment.md',
35
+ ]),
36
+ 'qa-planning': Object.freeze(['artifacts/7-QA-Planning/qa-planning-report.md']),
37
+ });
38
+
39
+ function fail(code, message) {
40
+ throw Object.assign(new Error(message), { code });
41
+ }
42
+
43
+ function equal(left, right) {
44
+ return canonicalSerialize(left) === canonicalSerialize(right);
45
+ }
46
+
47
+ function revisionDomain(request) {
48
+ return request.schema_version === 3 ? CORRECTION_CONTINUATION_DOMAIN
49
+ : request.schema_version === 2 ? CONTINUATION_DOMAIN : PLANNING_CONTRACT_REVISION_DOMAIN;
50
+ }
51
+
52
+ function rawDigest(value) {
53
+ return createHash('sha256').update(value).digest('hex');
54
+ }
55
+
56
+ function closed(value, keys, label) {
57
+ if (!value || Array.isArray(value) || typeof value !== 'object'
58
+ || Object.keys(value).sort().join(',') !== [...keys].sort().join(',')) {
59
+ fail('PLANNING_CONTRACT_REVISION_INPUT_INVALID', `${label} must use the closed Planning contract revision schema.`);
60
+ }
61
+ }
62
+
63
+ export function normalizePlanningContractDirection(value) {
64
+ closed(value, ['accepted_blocker_ids', 'instruction', 'target_agent'], 'Direction');
65
+ if (value.target_agent !== PLANNING_CONTRACT_REVISION_TARGET
66
+ || typeof value.instruction !== 'string' || !value.instruction.trim()
67
+ || Buffer.byteLength(value.instruction.trim(), 'utf8') > INSTRUCTION_MAX_BYTES
68
+ || !Array.isArray(value.accepted_blocker_ids) || value.accepted_blocker_ids.length === 0
69
+ || value.accepted_blocker_ids.some(id => typeof id !== 'string' || !id.trim() || Buffer.byteLength(id, 'utf8') > 512)
70
+ || new Set(value.accepted_blocker_ids).size !== value.accepted_blocker_ids.length) {
71
+ fail('PLANNING_CONTRACT_REVISION_INPUT_INVALID', 'Direction requires target detail, a bounded instruction and unique accepted blocker ids.');
72
+ }
73
+ return canonicalize({ target_agent: value.target_agent,
74
+ accepted_blocker_ids: [...value.accepted_blocker_ids].sort(), instruction: value.instruction.trim() });
75
+ }
76
+
77
+ function handoffRef(projectDir, agent, { allowMissing = false } = {}) {
78
+ const root = realpathSync(projectDir);
79
+ const path = findAgentHandoffPath(root, agent);
80
+ if (!path) {
81
+ if (allowMissing) return null;
82
+ fail('PLANNING_CONTRACT_REVISION_HANDOFF_MISSING', `Planning contract revision requires the existing ${agent} handoff.`);
83
+ }
84
+ const stat = lstatSync(path);
85
+ if (!stat.isFile() || stat.isSymbolicLink()) fail('PLANNING_CONTRACT_REVISION_HANDOFF_INVALID', `${agent} handoff must be a regular file.`);
86
+ const resolved = realpathSync(path), rel = relative(root, resolved);
87
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) fail('PLANNING_CONTRACT_REVISION_HANDOFF_INVALID', `${agent} handoff must remain inside the project.`);
88
+ return canonicalize({ path: rel.split('\\').join('/'), sha256: rawDigest(readFileSync(resolved)) });
89
+ }
90
+
91
+ function qaBinding(artifact) {
92
+ const invocation = resolveRuntimeInvocationV2({ artifact, agent: 'qa-planning' });
93
+ return canonicalize({ provider: invocation.harness_id, provider_id: invocation.provider_id,
94
+ model: invocation.model_pin.model_id, reasoning_configuration: invocation.model_pin.reasoning_configuration,
95
+ catalog_snapshot_ref: invocation.model_pin.catalog_snapshot_ref });
96
+ }
97
+
98
+ function blockerIds(blockers) {
99
+ return blockers.map(finding => `${finding.member_id}/${finding.finding_id}`).sort();
100
+ }
101
+
102
+ /** Historical outputs are bound, not replaced when a later direction is consumed. */
103
+ export function planningRevisionResultDigest(session, entry) {
104
+ const subjects = session.planning_contract_revision_subjects?.filter(item => item.revision_id === entry.request.revision_id);
105
+ if (subjects?.length !== 1 || !entry.result || !entry.owner_results
106
+ || !equal(Object.keys(entry.owner_results).sort(), ['architect', 'detail', 'ux'])
107
+ || subjects[0].execution_claim_id !== entry.result.execution_claim_id
108
+ || subjects[0].execution_finished_at !== entry.result.execution_finished_at
109
+ || !equal(subjects[0].artifacts, entry.result.artifacts)) {
110
+ fail('PLANNING_CONTRACT_REVISION_PREDECESSOR_INVALID', 'Continuation requires the predecessor\'s single genuine held QA subject and three completed owners.');
111
+ }
112
+ return sha256({ owner_results: entry.owner_results, result: entry.result, subject: subjects[0] });
113
+ }
114
+
115
+ /** Terminal negative QA has been consumed and is a fourth preserved owner. */
116
+ export function planningRevisionTerminalDigest(session, entry) {
117
+ const subjects = session.planning_contract_revision_subjects?.filter(item => item.revision_id === entry.request.revision_id);
118
+ const result = entry.result, qa = entry.owner_results?.['qa-planning'];
119
+ if (!['completed', 'superseded'].includes(entry.status) || subjects?.length !== 1
120
+ || !equal(Object.keys(entry.owner_results || {}).sort(), [...PLANNING_CONTRACT_REVISION_OWNERS].sort())
121
+ || result?.qa_result?.gate_result !== 'needs_correction'
122
+ || !Number.isFinite(result.qa_result.score) || result.qa_result.score < 0 || result.qa_result.score > 100
123
+ || !Number.isSafeInteger(result.qa_result.blocking_count) || result.qa_result.blocking_count < 1
124
+ || !Number.isFinite(Date.parse(entry.completed_at)) || qa?.completed_at !== entry.completed_at
125
+ || qa.execution_claim_id !== result.execution_claim_id || qa.execution_finished_at !== result.execution_finished_at
126
+ || Date.parse(result.execution_finished_at) > Date.parse(entry.completed_at)
127
+ || subjects[0].execution_claim_id !== result.execution_claim_id
128
+ || subjects[0].execution_finished_at !== result.execution_finished_at
129
+ || !equal(subjects[0].artifacts, result.artifacts)
130
+ || !equal(qa.subject, { gate_id: subjects[0].gate_id, artifacts: result.artifacts })) {
131
+ fail('PLANNING_CONTRACT_REVISION_PREDECESSOR_INVALID', 'Post-correction continuation requires the complete terminal negative QA and all four preserved owner results.');
132
+ }
133
+ return sha256({ kind: 'completed-negative', completed_at: entry.completed_at,
134
+ owner_results: entry.owner_results, result, subject: subjects[0] });
135
+ }
136
+
137
+ function ownerCompletion(session, agent) {
138
+ const state = session.agents?.[agent], result = session.agent_results?.[agent];
139
+ if (!state || state.status !== 'completed' || !session.completed_agents?.includes(agent)
140
+ || state.execution_claim_id || state.execution_claim_state || state.planning_contract_revision_id
141
+ || result?.status !== 'completed' || result.score !== state.score || result.completed_at !== state.completed_at
142
+ || !Array.isArray(result.outputs) || !Number.isFinite(Date.parse(state.completed_at))) {
143
+ fail('PLANNING_CONTRACT_REVISION_CORRECTION_INVALID', 'Normal correction requires each canonical completed owner and its unchanged result.');
144
+ }
145
+ const binding = canonicalize(Object.fromEntries(['provider', 'provider_id', 'model', 'reasoning_configuration', 'catalog_snapshot_ref']
146
+ .map(key => [key, state.execution_binding?.[key] ?? null])));
147
+ return canonicalize({ agent, binding, result });
148
+ }
149
+
150
+ // Public exit/activate-harness/init-resume append lifecycle observations, not
151
+ // owner transitions. Keep their closed producer shapes in the signed snapshot
152
+ // without treating them as evidence that an owner completed or a QA ran.
153
+ function isCorrectionLifecycle(event) {
154
+ if (!['session_ended', 'session_started', 'harness_activated'].includes(event.type)) return false;
155
+ const invalid = () => fail('PLANNING_CONTRACT_REVISION_CORRECTION_INVALID', 'Correction lifecycle must match the public Planning pause/resume contract.');
156
+ const text = value => typeof value === 'string' && Boolean(value.trim());
157
+ const harness = value => value === null || text(value);
158
+ if (event.agent !== 'orchestrator' && !PLANNING_CONTRACT_REVISION_OWNERS.includes(event.agent)) invalid();
159
+ if (event.type === 'session_ended') {
160
+ closed(event.data, ['mode', 'harness'], 'Correction exit');
161
+ if (event.data.mode !== 'plan' || !harness(event.data.harness)) invalid();
162
+ } else if (event.type === 'harness_activated') {
163
+ closed(event.data, ['harness', 'previous_harness'], 'Correction harness activation');
164
+ if (event.agent !== 'orchestrator' || !text(event.data.harness) || !harness(event.data.previous_harness)
165
+ || event.data.harness === event.data.previous_harness) invalid();
166
+ } else if (Object.hasOwn(event.data || {}, 'resume')) {
167
+ closed(event.data, ['resume', 'workflow',
168
+ ...(Object.hasOwn(event.data, 'concurrent_init_preserved') ? ['concurrent_init_preserved'] : [])], 'Correction init resume');
169
+ if (event.agent !== 'orchestrator' || event.data.resume !== true || !text(event.data.workflow)
170
+ || (Object.hasOwn(event.data, 'concurrent_init_preserved') && event.data.concurrent_init_preserved !== true)) invalid();
171
+ } else {
172
+ closed(event.data, ['harness', 'resumed'], 'Correction session start');
173
+ if (!text(event.data.harness) || typeof event.data.resumed !== 'boolean') invalid();
174
+ }
175
+ return true;
176
+ }
177
+
178
+ function validateCorrectionSource(source, request, previous) {
179
+ closed(source, ['kind', 'events', 'owners', 'correction_cycles', 'qa_claim_started_at'], 'Correction source');
180
+ const invalid = () => fail('PLANNING_CONTRACT_REVISION_CORRECTION_INVALID', 'Continuation requires one unambiguous normal correction descent to this later approved QA.');
181
+ if (source.kind !== 'post-negative-correction' || !Array.isArray(source.events) || !source.events.length
182
+ || !Array.isArray(source.owners) || !equal(source.owners.map(item => item.agent), ['detail', 'architect', 'ux'])
183
+ || !source.correction_cycles || typeof source.correction_cycles !== 'object') invalid();
184
+ let last = -Infinity;
185
+ const ids = new Set();
186
+ const events = [];
187
+ for (const event of source.events) {
188
+ closed(event, ['id', 'type', 'agent', 'data', 'timestamp'], 'Correction event');
189
+ const at = Date.parse(event.timestamp);
190
+ if (typeof event.id !== 'string' || !event.id || ids.has(event.id) || !Number.isFinite(at) || at < last) invalid();
191
+ ids.add(event.id); last = at;
192
+ if (isCorrectionLifecycle(event)) continue;
193
+ if (at > Date.parse(request.source_held_qa.finished_at)
194
+ || !PLANNING_CONTRACT_REVISION_OWNERS.includes(event.agent)
195
+ || !['agent_completed', 'agent_activated', 'correction_triggered', 'agent_waiting_for_input'].includes(event.type)) invalid();
196
+ events.push(event);
197
+ }
198
+ if (!events.length) invalid();
199
+ const negative = events[0];
200
+ if (request.source_held_qa.qa_result?.gate_result !== 'approved'
201
+ || request.source_held_qa.qa_result.blocking_count !== 0
202
+ || !Number.isFinite(source.qa_claim_started_at && Date.parse(source.qa_claim_started_at))
203
+ || Date.parse(source.qa_claim_started_at) > Date.parse(request.source_held_qa.finished_at)
204
+ || !Number.isFinite(Date.parse(request.source_held_qa.finished_at))) invalid();
205
+ if (previous && (Date.parse(negative.timestamp) < Date.parse(previous.completed_at)
206
+ || negative.data?.score !== previous.result.qa_result.score
207
+ || request.source_held_qa.claim_id === previous.result.execution_claim_id
208
+ || Date.parse(request.source_held_qa.finished_at) <= Date.parse(previous.completed_at))) invalid();
209
+ // Each public correction must complete its affected suffix before another QA.
210
+ // Intermediate normal QA has timeline facts only, never invented claim pins.
211
+ const upstream = ['detail', 'architect', 'ux'], cycles = new Map(), completions = new Map();
212
+ const assertWaiting = (event, agent) => {
213
+ closed(event.data, ['task_id'], 'Correction waiting event');
214
+ // The runner preserves caller-selected task ids. Completion events do not
215
+ // carry task pins, so only the available owner/phase linkage is asserted.
216
+ if (event.agent !== agent || typeof event.data.task_id !== 'string' || !event.data.task_id.trim()) invalid();
217
+ };
218
+ let cursor = 0;
219
+ while (cursor < events.length) {
220
+ const rejected = events[cursor++], correctionEvent = events[cursor++];
221
+ const correction = correctionEvent?.data?.correction, target = upstream.indexOf(correction?.target);
222
+ const key = `qa-planning->${correction?.target}`;
223
+ if (rejected.type !== 'agent_completed' || rejected.agent !== 'qa-planning' || rejected.data?.status !== 'completed'
224
+ || !Number.isFinite(rejected.data.score) || rejected.data.score < 0 || rejected.data.score > 100
225
+ || correctionEvent?.type !== 'correction_triggered' || target < 0 || correctionEvent.agent !== correction.target
226
+ || correction.source !== 'qa-planning' || correction.score !== rejected.data.score || correction.exceeded !== false
227
+ || !Number.isSafeInteger(correction.cycle) || correction.cycle < 1 || correction.cycle > MAX_CORRECTION_CYCLES
228
+ || (cycles.has(key) && correction.cycle !== cycles.get(key) + 1)
229
+ || !Array.isArray(correction.findings) || !correction.findings.length
230
+ || correction.findings.some(item => !item.id || item.resolved !== false)) invalid();
231
+ cycles.set(key, correction.cycle);
232
+ const affected = upstream.slice(target), completed = new Set(), active = new Set([correction.target]);
233
+ const ownerWaits = new Map();
234
+ let waitingOwner = null;
235
+ while (cursor < events.length && events[cursor].agent !== 'qa-planning') {
236
+ const event = events[cursor++];
237
+ if (!affected.includes(event.agent) || !['agent_completed', 'agent_activated', 'agent_waiting_for_input'].includes(event.type)) invalid();
238
+ if (event.type === 'agent_waiting_for_input') {
239
+ assertWaiting(event, event.agent);
240
+ if (!active.has(event.agent) || completed.has(event.agent) || (waitingOwner && waitingOwner !== event.agent)) invalid();
241
+ waitingOwner = event.agent;
242
+ ownerWaits.set(event.agent, event);
243
+ }
244
+ if (event.type === 'agent_activated') {
245
+ if (!active.has(event.agent) || completed.has(event.agent) || event.data?.phase !== 'plan') invalid();
246
+ if (event.agent === 'architect' && event.data?.action === 'spawn_parallel' && affected.includes('ux')) active.add('ux');
247
+ }
248
+ if (event.type === 'agent_completed') {
249
+ if (completed.has(event.agent) || event.data?.status !== 'completed' || !Number.isFinite(event.data.score)) invalid();
250
+ completed.add(event.agent);
251
+ active.delete(event.agent);
252
+ if (waitingOwner === event.agent) waitingOwner = null;
253
+ const next = affected.find(owner => !completed.has(owner));
254
+ if (next) active.add(next);
255
+ completions.set(event.agent, { event, correctionEvent, lastWait: ownerWaits.get(event.agent) });
256
+ }
257
+ }
258
+ const activation = events[cursor++];
259
+ if (completed.size !== affected.length || activation?.type !== 'agent_activated' || activation.agent !== 'qa-planning'
260
+ || activation.data?.phase !== 'plan'
261
+ || !['spawn_autonomous', 'spawn_routed_interactive', 'spawn_parallel', 'spawn_team'].includes(activation.data.action)) invalid();
262
+ // The runner also supports QA questions. Its final relay claim is the held
263
+ // QA identity; pending_interaction must already be consumed at admission.
264
+ let qaWait;
265
+ while (events[cursor]?.type === 'agent_waiting_for_input') {
266
+ qaWait = events[cursor++];
267
+ assertWaiting(qaWait, 'qa-planning');
268
+ }
269
+ if (cursor === events.length && qaWait
270
+ && Date.parse(source.qa_claim_started_at) < Date.parse(qaWait.timestamp)) invalid();
271
+ }
272
+ if (!cycles.size || [...cycles].some(([key, cycle]) => source.correction_cycles[key] !== cycle)) invalid();
273
+ for (const owner of source.owners) {
274
+ closed(owner, ['agent', 'binding', 'result'], 'Corrected owner');
275
+ closed(owner.binding, ['provider', 'provider_id', 'model', 'reasoning_configuration', 'catalog_snapshot_ref'], 'Corrected owner binding');
276
+ closed(owner.result, ['status', 'score', 'outputs', 'completed_at',
277
+ ...(Object.hasOwn(owner.result || {}, 'started_at') ? ['started_at'] : [])], 'Corrected owner result');
278
+ const completed = completions.get(owner.agent);
279
+ const completedAt = Date.parse(owner.result.completed_at);
280
+ if (owner.result.status !== 'completed' || !Number.isFinite(owner.result.score) || !Array.isArray(owner.result.outputs)
281
+ || !Number.isFinite(completedAt)
282
+ || completedAt >= Date.parse(source.qa_claim_started_at)
283
+ || Object.values(owner.binding).some(value => typeof value !== 'string' || !value)) invalid();
284
+ if (completed) {
285
+ if (completed.event.data.score !== owner.result.score || completedAt <= Date.parse(completed.correctionEvent.timestamp)
286
+ || (completed.lastWait && completedAt < Date.parse(completed.lastWait.timestamp))
287
+ || completedAt > Date.parse(completed.event.timestamp)) invalid();
288
+ } else if (completedAt > Date.parse(negative.timestamp)
289
+ || (previous && owner.result.completed_at !== previous.owner_results[owner.agent]?.completed_at)) invalid();
290
+ }
291
+ return source;
292
+ }
293
+
294
+ function correctedOwners(source) {
295
+ return new Set(source.events.filter(event => event.type === 'agent_completed' && event.agent !== 'qa-planning').map(event => event.agent));
296
+ }
297
+
298
+ /** Canonical chronology is evidence of descent, never retroactive artifact pins. */
299
+ export function derivePlanningCorrectionSource(projectDir, session, previous, held) {
300
+ planningRevisionTerminalDigest(session, previous);
301
+ const file = readSafeInstallationStorageFile(join(projectDir, '.chati/timeline.json'), { label: 'Planning correction timeline' });
302
+ let timeline;
303
+ try { timeline = JSON.parse(file.content); } catch { fail('PLANNING_CONTRACT_REVISION_CORRECTION_INVALID', 'Correction timeline is not JSON.'); }
304
+ if (!Array.isArray(timeline) || timeline.some(event => !Number.isFinite(Date.parse(event?.timestamp)))
305
+ || new Set(timeline.map(event => event.id)).size !== timeline.length) {
306
+ fail('PLANNING_CONTRACT_REVISION_CORRECTION_INVALID', 'Correction timeline is malformed or ambiguous.');
307
+ }
308
+ const events = timeline.filter(event => Date.parse(event.timestamp) >= Date.parse(previous.completed_at));
309
+ const source = canonicalize({ kind: 'post-negative-correction', events,
310
+ owners: ['detail', 'architect', 'ux'].map(agent => ownerCompletion(session, agent)),
311
+ qa_claim_started_at: session.agents?.['qa-planning']?.execution_claim_started_at || null,
312
+ correction_cycles: session.correction_cycles || {} });
313
+ validateCorrectionSource(source, { source_held_qa: held }, previous);
314
+ const pending = session.planning_contract_revisions?.at(-1);
315
+ if (pending?.status === 'reserved' && pending.request.schema_version === 3
316
+ && pending.request.previous_revision.revision_id === previous.request.revision_id
317
+ && equal(pending.request.source_held_qa, held)) {
318
+ // Reservation freezes its source. Subsequent public lifecycle may append
319
+ // observations, never rewrite the prefix or extend the work descent. The
320
+ // full current source above is still checked; canonical history is untouched.
321
+ const captured = pending.request.correction_source.events;
322
+ if (!equal(events.slice(0, captured.length), captured)
323
+ || events.slice(captured.length).some(event => !isCorrectionLifecycle(event))) {
324
+ fail('PLANNING_CONTRACT_REVISION_CORRECTION_INVALID', 'Reserved correction chronology changed beyond public lifecycle append.');
325
+ }
326
+ source.events = captured;
327
+ }
328
+ return source;
329
+ }
330
+
331
+ /** Pure topology guard shared by routing and every canonical writer. */
332
+ export function planningContractRevisionHistory(session) {
333
+ const history = session.planning_contract_revisions || [];
334
+ const invalid = () => fail('PLANNING_CONTRACT_REVISION_HISTORY_INVALID', 'Planning directions require one non-branching authenticated lineage and one current tip.');
335
+ if (!Array.isArray(history)) invalid();
336
+ const ids = new Set(), sources = new Set(), claims = new Set();
337
+ for (const [index, entry] of history.entries()) {
338
+ const request = validateStoredRequest(entry?.request);
339
+ if (ids.has(request.revision_id) || sources.has(request.source_gate_id)
340
+ || claims.has(request.source_held_qa?.claim_id)) invalid();
341
+ ids.add(request.revision_id); sources.add(request.source_gate_id); claims.add(request.source_held_qa?.claim_id);
342
+ if (index === 0) {
343
+ if (request.schema_version !== 1) invalid();
344
+ } else {
345
+ const prior = history[index - 1], ref = request.previous_revision;
346
+ if (![2, 3].includes(request.schema_version) || ref.revision_id !== prior.request.revision_id
347
+ || ref.launch_nonce !== prior.launch_nonce || ref.decision_sha256 !== prior.decision_sha256
348
+ || ref.result_sha256 !== (request.schema_version === 3 ? planningRevisionTerminalDigest(session, prior) : planningRevisionResultDigest(session, prior))
349
+ || !equal(request.source_reconciliation, prior.request.source_reconciliation)
350
+ || request.language !== prior.request.language || request.project_id !== prior.request.project_id
351
+ || request.installation_digest !== prior.request.installation_digest
352
+ || request.direction.instruction === prior.request.direction.instruction) invalid();
353
+ if (request.schema_version === 3) {
354
+ validateCorrectionSource(request.correction_source, request, prior);
355
+ if (!equal(request.source_held_qa.binding, prior.request.source_held_qa.binding)
356
+ || !equal(request.old_subject.artifacts.find(item => item.path === BRIEF_PATH),
357
+ prior.request.old_subject.artifacts.find(item => item.path === BRIEF_PATH))) invalid();
358
+ if (entry.status !== 'reserved') {
359
+ const archived = entry.archived;
360
+ const owners = ['detail', 'architect', 'ux'].map(agent => ownerCompletion({
361
+ agents: archived?.previous_agents, agent_results: archived?.previous_agent_results,
362
+ completed_agents: archived?.completed_agents }, agent));
363
+ if (!equal(owners, request.correction_source.owners)) invalid();
364
+ }
365
+ } else if (request.source_held_qa.claim_id !== prior.result.execution_claim_id
366
+ || request.source_held_qa.finished_at !== prior.result.execution_finished_at
367
+ || !equal(request.source_held_qa.qa_result, prior.result.qa_result)
368
+ || !equal(request.old_subject.artifacts, prior.result.artifacts)
369
+ || session.planning_contract_revision_subjects.find(item => item.revision_id === prior.request.revision_id)?.gate_id !== request.source_gate_id) invalid();
370
+ }
371
+ const pendingSuccessor = index === history.length - 2 && history.at(-1).status === 'reserved';
372
+ const pendingStatus = history[index + 1]?.request.schema_version === 3 ? 'completed' : 'active';
373
+ if (index < history.length - 1 ? entry.status !== (pendingSuccessor ? pendingStatus : 'superseded')
374
+ : !['reserved', 'active', 'completed'].includes(entry.status)) invalid();
375
+ if (entry.status !== 'reserved' && !/^[a-f0-9]{64}$/.test(entry.decision_sha256 || '')) invalid();
376
+ if (entry.status === 'superseded' && entry.superseded_by !== history[index + 1].request.revision_id) invalid();
377
+ }
378
+ const subjects = session.planning_contract_revision_subjects || [];
379
+ if (!Array.isArray(subjects) || new Set(subjects.map(item => item.revision_id)).size !== subjects.length
380
+ || subjects.some(item => !ids.has(item.revision_id))) invalid();
381
+ const tip = history.at(-1) || null;
382
+ return { history, tip, active: history.find(entry => entry.status === 'active') || null,
383
+ pending: tip?.status === 'reserved' ? tip : null };
384
+ }
385
+
386
+ export function authenticatePlanningContractRevisionHistory(projectDir, session) {
387
+ const lineage = planningContractRevisionHistory(session);
388
+ const entry = lineage.history.findLast(item => item.status !== 'reserved');
389
+ // Reading the tip authenticates all immutable ancestors, without quadratic reads.
390
+ if (entry) readAttestedPlanningContractRevision(projectDir, entry.request, entry.launch_nonce, entry.decision_sha256);
391
+ return lineage;
392
+ }
393
+
394
+ function authenticatePositiveReconciliation(projectDir, current, provenance) {
395
+ closed(provenance, ['decision_sha256', 'launch_nonce', 'request'], 'Source reconciliation');
396
+ if (typeof provenance.launch_nonce !== 'string' || !provenance.launch_nonce
397
+ || !/^[a-f0-9]{64}$/.test(provenance.decision_sha256 || '')) {
398
+ fail('PLANNING_CONTRACT_REVISION_RECONCILIATION_UNTRUSTED', 'Source reconciliation provenance is malformed.');
399
+ }
400
+ const decision = readAttestedPlanningReconciliationDecision(projectDir, current,
401
+ provenance.request, provenance.decision_sha256);
402
+ if (decision.record.decision.decision !== 'authorize-qa-revalidation'
403
+ || decision.record.decision.launch_nonce !== provenance.launch_nonce) {
404
+ fail('PLANNING_CONTRACT_REVISION_RECONCILIATION_UNTRUSTED',
405
+ 'Source reconciliation must be the exact immutable positive decision and nonce.');
406
+ }
407
+ return decision;
408
+ }
409
+
410
+ /** Pin the exact rejected hold and derive one closed controller direction. */
411
+ export function pinPlanningContractRevision(projectDir, match, session) {
412
+ const direction = normalizePlanningContractDirection(match.direction);
413
+ const subject = pinPlanningCouncilRework(projectDir, match.gateId);
414
+ let oldHandoffs;
415
+ try {
416
+ const binding = qaBinding(subject.artifact);
417
+ const boundary = assertPlanningContractRevisionBoundary(projectDir, session, match, binding, subject);
418
+ const accepted = blockerIds(subject.evidence.blockers);
419
+ if (!equal(direction.accepted_blocker_ids, accepted)) {
420
+ fail('PLANNING_CONTRACT_REVISION_BLOCKERS_MISMATCH', 'Direction must name every exact accepted blocker from the authenticated REWORK.');
421
+ }
422
+ oldHandoffs = PLANNING_CONTRACT_REVISION_OWNERS.map(agent => ({ agent, ...handoffRef(projectDir, agent) }));
423
+ if (boundary.previous) {
424
+ const previous = session.planning_contract_revisions.find(item => item.request.revision_id === boundary.previous.revision_id);
425
+ const current = new Map(subject.plan.artifacts.map(item => [item.path, item.sha256]));
426
+ const brief = previous.request.old_subject.artifacts.find(item => item.path === BRIEF_PATH);
427
+ if (!brief || current.get(BRIEF_PATH) !== brief.sha256) fail('PLANNING_CONTRACT_REVISION_BRIEF_CHANGED', 'Continuation cannot change the original frozen Brief.');
428
+ const corrected = boundary.correction ? correctedOwners(boundary.correction) : null;
429
+ for (const item of oldHandoffs) {
430
+ if (corrected && (item.agent === 'qa-planning' || corrected.has(item.agent))) continue;
431
+ const produced = item.agent === 'qa-planning' ? session.agents[item.agent].execution_planning_revision_result
432
+ : previous.owner_results[item.agent];
433
+ if (!produced?.handoff || !equal(produced.handoff, { path: item.path, sha256: item.sha256 })
434
+ || !produced.owned_artifacts?.length
435
+ || produced.owned_artifacts.some(ref => current.get(ref.path) !== ref.sha256)) {
436
+ fail('PLANNING_CONTRACT_REVISION_HELD_OUTPUT_CHANGED', 'Continuation must preserve the preceding genuine owner outputs and handoffs.');
437
+ }
438
+ }
439
+ }
440
+ const material = canonicalize({ schema_version: boundary.correction ? 3 : boundary.previous ? 2 : 1, project_id: subject.artifact.project_id,
441
+ installation_digest: sha256(subject.artifact), language: session.language,
442
+ target_agent: PLANNING_CONTRACT_REVISION_TARGET, source_gate_id: subject.evidence.source_gate_id,
443
+ source_decision_sha256: subject.evidence.decision_sha256,
444
+ source_reconciliation: boundary.reconciliation,
445
+ source_held_qa: boundary.held, old_subject: { gate_id: subject.plan.gate_id,
446
+ artifacts: subject.plan.artifacts }, old_handoffs: oldHandoffs,
447
+ accepted_blocker_ids: accepted, direction, new_subject: null,
448
+ ...(boundary.previous ? { previous_revision: boundary.previous } : {}),
449
+ ...(boundary.correction ? { correction_source: boundary.correction } : {}) });
450
+ const request = canonicalize({ ...material, revision_id: `planning-contract-revision-${sha256(material)}` });
451
+ const correctionState = boundary.correction ? structuredClone(session) : null;
452
+ const assertCurrent = () => {
453
+ subject.assertCurrent();
454
+ authenticatePositiveReconciliation(projectDir, subject, boundary.reconciliation);
455
+ if (boundary.previous) authenticatePlanningContractRevisionHistory(projectDir, session);
456
+ if (correctionState) {
457
+ const previous = correctionState.planning_contract_revisions.find(item => item.request.revision_id === boundary.previous.revision_id);
458
+ if (!equal(derivePlanningCorrectionSource(projectDir, correctionState, previous, boundary.held), boundary.correction)) {
459
+ fail('PLANNING_CONTRACT_REVISION_SUBJECT_CHANGED', 'The normal correction evidence changed while reopening was reserved.');
460
+ }
461
+ }
462
+ for (const item of oldHandoffs) {
463
+ if (!equal(handoffRef(projectDir, item.agent), { path: item.path, sha256: item.sha256 })) {
464
+ fail('PLANNING_CONTRACT_REVISION_SUBJECT_CHANGED', 'A Planning handoff changed while contract revision was being reserved.');
465
+ }
466
+ }
467
+ };
468
+ assertCurrent();
469
+ return Object.freeze({ ...subject, request, binding, assertCurrent,
470
+ close() { subject.close(); } });
471
+ } catch (error) { subject.close(); throw error; }
472
+ }
473
+
474
+ /** Public recovery guidance uses the same admission checks as reservation. */
475
+ export function planningContractRevisionCapability(projectDir, session, gateId) {
476
+ let council, pinned;
477
+ try {
478
+ const { tip, pending } = authenticatePlanningContractRevisionHistory(projectDir, session);
479
+ if (pending) return { available: false, code: 'PLANNING_CONTRACT_REVISION_PENDING' };
480
+ council = pinPlanningCouncilRework(projectDir, gateId);
481
+ const qa = session.agents?.['qa-planning'];
482
+ const previousRevisionId = tip?.request.revision_id;
483
+ pinned = pinPlanningContractRevision(projectDir, { agent: 'qa-planning', gateId,
484
+ claimId: qa?.execution_claim_id, finishedAt: qa?.execution_finished_at,
485
+ ...(previousRevisionId ? { previousRevisionId } : {}),
486
+ direction: { target_agent: 'detail', accepted_blocker_ids: blockerIds(council.evidence.blockers),
487
+ instruction: `Inspect reopening capability for ${gateId}.` } }, session);
488
+ return { available: true, ...(previousRevisionId ? { previousRevisionId } : {}) };
489
+ } catch (error) { return { available: false, code: error.code || 'PLANNING_CONTRACT_REVISION_UNAVAILABLE' }; }
490
+ finally { pinned?.close(); council?.close(); }
491
+ }
492
+
493
+ function decisionPath(projectDir, revisionId) {
494
+ return join(realpathSync(projectDir), '.chati/v2/planning-contract-revisions', `${revisionId}.json`);
495
+ }
496
+
497
+ function validateStoredRequest(request) {
498
+ closed(request, ['accepted_blocker_ids', 'direction', 'installation_digest', 'language', 'new_subject',
499
+ 'old_handoffs', 'old_subject', 'project_id', 'revision_id', 'schema_version', 'source_decision_sha256',
500
+ 'source_gate_id', 'source_held_qa', 'source_reconciliation', 'target_agent',
501
+ ...([2, 3].includes(request?.schema_version) ? ['previous_revision'] : []),
502
+ ...(request?.schema_version === 3 ? ['correction_source'] : [])], 'Request');
503
+ const { revision_id: id, ...material } = request;
504
+ normalizePlanningContractDirection(request.direction);
505
+ if (![1, 2, 3].includes(request.schema_version) || request.target_agent !== PLANNING_CONTRACT_REVISION_TARGET
506
+ || request.new_subject !== null || !/^planning-contract-revision-[a-f0-9]{64}$/.test(id || '')
507
+ || id !== `planning-contract-revision-${sha256(material)}`
508
+ || !/^council-planning-[a-f0-9]{64}$/.test(request.source_gate_id || '')
509
+ || !/^[a-f0-9]{64}$/.test(request.source_decision_sha256 || '')
510
+ || request.old_subject?.gate_id !== request.source_gate_id
511
+ || !equal(request.accepted_blocker_ids, request.direction.accepted_blocker_ids)
512
+ || !Array.isArray(request.old_subject?.artifacts) || !Array.isArray(request.old_handoffs)
513
+ || request.old_handoffs.length !== PLANNING_CONTRACT_REVISION_OWNERS.length) {
514
+ fail('PLANNING_CONTRACT_REVISION_REQUEST_INVALID', 'Planning contract revision request is malformed or not content-addressed.');
515
+ }
516
+ if ([2, 3].includes(request.schema_version)) {
517
+ closed(request.previous_revision, ['revision_id', 'decision_sha256', 'launch_nonce', 'result_sha256'], 'Previous revision');
518
+ const previous = request.previous_revision;
519
+ if (!/^planning-contract-revision-[a-f0-9]{64}$/.test(previous.revision_id || '')
520
+ || previous.revision_id === id || !/^[a-f0-9]{64}$/.test(previous.decision_sha256 || '')
521
+ || !/^[a-f0-9]{64}$/.test(previous.result_sha256 || '')
522
+ || typeof previous.launch_nonce !== 'string' || !previous.launch_nonce) {
523
+ fail('PLANNING_CONTRACT_REVISION_REQUEST_INVALID', 'Continuation requires an exact predecessor reference.');
524
+ }
525
+ }
526
+ if (request.schema_version === 3) validateCorrectionSource(request.correction_source, request);
527
+ closed(request.source_reconciliation, ['decision_sha256', 'launch_nonce', 'request'], 'Source reconciliation');
528
+ if (typeof request.source_reconciliation.launch_nonce !== 'string' || !request.source_reconciliation.launch_nonce
529
+ || !/^[a-f0-9]{64}$/.test(request.source_reconciliation.decision_sha256 || '')
530
+ || typeof request.source_reconciliation.request !== 'object' || !request.source_reconciliation.request) {
531
+ fail('PLANNING_CONTRACT_REVISION_REQUEST_INVALID', 'Planning contract revision source reconciliation is malformed.');
532
+ }
533
+ return request;
534
+ }
535
+
536
+ /** Persist the reserved controller decision. No Planning quality is approved. */
537
+ export function persistPlanningContractRevision({ projectDir, reservation } = {}) {
538
+ const request = validateStoredRequest(reservation?.request);
539
+ if (typeof reservation.launch_nonce !== 'string' || !reservation.launch_nonce
540
+ || typeof reservation.reserved_at !== 'string' || !Number.isFinite(Date.parse(reservation.reserved_at))) {
541
+ fail('PLANNING_CONTRACT_REVISION_RESERVATION_INVALID', 'An exact reserved nonce and timestamp are required.');
542
+ }
543
+ const current = pinPlanningCouncilRework(projectDir, request.source_gate_id);
544
+ try {
545
+ if (request.installation_digest !== sha256(current.artifact)
546
+ || request.project_id !== current.artifact.project_id
547
+ || !equal(request.old_subject, { gate_id: current.plan.gate_id, artifacts: current.plan.artifacts })
548
+ || request.source_decision_sha256 !== current.evidence.decision_sha256) {
549
+ fail('PLANNING_CONTRACT_REVISION_REQUEST_MISMATCH', 'Reserved direction no longer matches its installation, project, council or old subject.');
550
+ }
551
+ authenticatePositiveReconciliation(projectDir, current, request.source_reconciliation);
552
+ if ([2, 3].includes(request.schema_version)) {
553
+ const session = loadSession(projectDir).session;
554
+ const lineage = authenticatePlanningContractRevisionHistory(projectDir, session);
555
+ if (!lineage.pending || !equal(lineage.pending.request, request)
556
+ || lineage.pending.launch_nonce !== reservation.launch_nonce) {
557
+ fail('PLANNING_CONTRACT_REVISION_RESERVATION_INVALID', 'Only the exact reserved continuation may be signed.');
558
+ }
559
+ if (request.schema_version === 3) {
560
+ const pinned = pinPlanningContractRevision(projectDir, { agent: 'qa-planning', gateId: request.source_gate_id,
561
+ claimId: request.source_held_qa.claim_id, finishedAt: request.source_held_qa.finished_at,
562
+ previousRevisionId: request.previous_revision.revision_id, direction: request.direction }, session);
563
+ try {
564
+ if (!equal(pinned.request, request)) fail('PLANNING_CONTRACT_REVISION_SUBJECT_CHANGED', 'The reserved correction source changed before signing.');
565
+ pinned.assertCurrent();
566
+ } finally { pinned.close(); }
567
+ }
568
+ }
569
+ const material = canonicalize({ record_type: 'chati-planning-contract-revision', request,
570
+ decision: { decision: 'reopen-planning-contract', authority_scope: 'pipeline-reopening-only',
571
+ launch_nonce: reservation.launch_nonce, recorded_at: reservation.reserved_at } });
572
+ const signed = signHostAuthorityPayload(material, { domain: revisionDomain(request) });
573
+ const record = canonicalize({ ...material, authority_receipt: {
574
+ schema_version: 1, authority_id: signed.authority_id, signature: signed.signature } });
575
+ const content = `${canonicalSerialize(record)}\n`;
576
+ const path = decisionPath(projectDir, request.revision_id);
577
+ current.assertCurrent();
578
+ persistImmutableInstallationFile(path, content, {
579
+ conflictCode: 'PLANNING_CONTRACT_REVISION_DECISION_CONFLICT', label: 'Planning contract revision decision' });
580
+ return Object.freeze({ path, record, sha256: rawDigest(content) });
581
+ } finally { current.close(); }
582
+ }
583
+
584
+ /** Read a previously created host record after its old subject has been reopened. */
585
+ function readRevisionRecord(projectDir, request, launchNonce, expectedDigest) {
586
+ validateStoredRequest(request);
587
+ const artifact = loadRuntimeInstallationV2(projectDir);
588
+ if (request.installation_digest !== sha256(artifact) || request.project_id !== artifact.project_id) {
589
+ fail('PLANNING_CONTRACT_REVISION_INSTALLATION_CHANGED', 'Planning contract revision belongs to another installation or project.');
590
+ }
591
+ const path = decisionPath(projectDir, request.revision_id);
592
+ const source = readSafeInstallationStorageFile(path, { label: 'Planning contract revision decision' });
593
+ let record;
594
+ try { record = JSON.parse(source.content); }
595
+ catch { fail('PLANNING_CONTRACT_REVISION_DECISION_INVALID', 'Planning contract revision record is not JSON.'); }
596
+ closed(record, ['authority_receipt', 'decision', 'record_type', 'request'], 'Decision record');
597
+ closed(record.decision, ['authority_scope', 'decision', 'launch_nonce', 'recorded_at'], 'Decision');
598
+ closed(record.authority_receipt, ['authority_id', 'schema_version', 'signature'], 'Authority receipt');
599
+ const { authority_receipt, ...material } = record;
600
+ const digest = rawDigest(source.content);
601
+ if (record.record_type !== 'chati-planning-contract-revision' || !equal(record.request, request)
602
+ || record.decision.decision !== 'reopen-planning-contract'
603
+ || record.decision.authority_scope !== 'pipeline-reopening-only'
604
+ || record.decision.launch_nonce !== launchNonce || authority_receipt.schema_version !== 1
605
+ || (expectedDigest && digest !== expectedDigest)
606
+ || !verifyHostAuthorityPayload(material, { domain: revisionDomain(request),
607
+ authorityId: authority_receipt.authority_id, signature: authority_receipt.signature })) {
608
+ fail('PLANNING_CONTRACT_REVISION_DECISION_UNTRUSTED', 'Planning contract revision is not authoritative for this exact reserved direction.');
609
+ }
610
+ return Object.freeze({ path, record, sha256: digest });
611
+ }
612
+
613
+ /** Verify every immutable predecessor, iteratively and without trusting session pointers. */
614
+ export function readAttestedPlanningContractRevision(projectDir, request, launchNonce, expectedDigest) {
615
+ const result = readRevisionRecord(projectDir, request, launchNonce, expectedDigest);
616
+ const visited = new Set([request.revision_id]);
617
+ let current = request;
618
+ while ([2, 3].includes(current.schema_version)) {
619
+ const previous = current.previous_revision;
620
+ if (visited.has(previous.revision_id)) fail('PLANNING_CONTRACT_REVISION_HISTORY_INVALID', 'Cyclic Planning revision provenance.');
621
+ visited.add(previous.revision_id);
622
+ const source = readSafeInstallationStorageFile(decisionPath(projectDir, previous.revision_id), { label: 'Previous Planning direction' });
623
+ let record;
624
+ try { record = JSON.parse(source.content); }
625
+ catch { fail('PLANNING_CONTRACT_REVISION_DECISION_INVALID', 'Previous direction is not JSON.'); }
626
+ const authenticated = readRevisionRecord(projectDir, record.request, previous.launch_nonce, previous.decision_sha256);
627
+ if (authenticated.record.request.revision_id !== previous.revision_id
628
+ || current.installation_digest !== record.request.installation_digest
629
+ || current.project_id !== record.request.project_id || current.language !== record.request.language
630
+ || !equal(current.source_reconciliation, record.request.source_reconciliation)) {
631
+ fail('PLANNING_CONTRACT_REVISION_DECISION_UNTRUSTED', 'Continuation does not bind its exact predecessor installation and source authority.');
632
+ }
633
+ current = record.request;
634
+ }
635
+ return result;
636
+ }
637
+
638
+ function pinOwnerSnapshot(projectDir, revision, agent) {
639
+ if (!PLANNING_CONTRACT_REVISION_OWNERS.includes(agent)) {
640
+ fail('PLANNING_CONTRACT_REVISION_OWNER_INVALID', 'Output provenance is limited to reopened Planning owners.');
641
+ }
642
+ const request = validateStoredRequest(revision.request);
643
+ readAttestedPlanningContractRevision(projectDir, request, revision.launch_nonce, revision.decision_sha256);
644
+ const subject = pinPlanningQaSubject(projectDir);
645
+ let handoff;
646
+ try {
647
+ const current = new Map(subject.plan.artifacts.map(item => [item.path, item.sha256]));
648
+ const owned = OWNER_ARTIFACTS[agent].filter(path => current.has(path));
649
+ handoff = handoffRef(projectDir, agent);
650
+ if (!owned.length) fail('PLANNING_CONTRACT_REVISION_OUTPUT_MISSING', `${agent} has no owned Planning artifact in the current subject.`);
651
+ const original = new Map(request.old_subject.artifacts.map(item => [item.path, item.sha256]));
652
+ if (!original.has(BRIEF_PATH) || current.get(BRIEF_PATH) !== original.get(BRIEF_PATH)) {
653
+ fail('PLANNING_CONTRACT_REVISION_BRIEF_CHANGED', 'Planning contract revision cannot change the frozen Brief.');
654
+ }
655
+ for (const [priorAgent, result] of Object.entries(revision.owner_results || {})) {
656
+ if (!PLANNING_CONTRACT_REVISION_OWNERS.includes(priorAgent) || priorAgent === agent) continue;
657
+ const prior = new Map(result.subject?.artifacts?.map(item => [item.path, item.sha256]) || []);
658
+ if (OWNER_ARTIFACTS[priorAgent].some(path => current.get(path) !== prior.get(path))) {
659
+ fail('PLANNING_CONTRACT_REVISION_OWNER_OUTPUT_CHANGED', `${priorAgent} owned output changed after its recorded completion.`);
660
+ }
661
+ }
662
+ const assertCurrent = () => {
663
+ subject.assertCurrent();
664
+ if (!equal(handoffRef(projectDir, agent), handoff)) {
665
+ fail('PLANNING_CONTRACT_REVISION_OUTPUT_CHANGED', `${agent} handoff changed during completion.`);
666
+ }
667
+ };
668
+ assertCurrent();
669
+ return Object.freeze({ ...subject, request, handoff, subject_artifacts: subject.plan.artifacts,
670
+ owned_artifacts: subject.plan.artifacts.filter(item => owned.includes(item.path)),
671
+ assertCurrent, close() { subject.close(); } });
672
+ } catch (error) { subject.close(); throw error; }
673
+ }
674
+
675
+ /** Capture the exact owned bytes immediately before one genuine execution starts. */
676
+ export function pinPlanningContractRevisionOwnerBaseline(projectDir, revision, agent) {
677
+ return pinOwnerSnapshot(projectDir, revision, agent);
678
+ }
679
+
680
+ /** Require replacement relative to both the original direction and this execution attempt. */
681
+ export function pinPlanningContractRevisionOwnerOutput(projectDir, revision, agent, attemptBaseline) {
682
+ const snapshot = pinOwnerSnapshot(projectDir, revision, agent);
683
+ try {
684
+ const oldArtifacts = new Map(snapshot.request.old_subject.artifacts.map(item => [item.path, item.sha256]));
685
+ const current = new Map(snapshot.owned_artifacts.map(item => [item.path, item.sha256]));
686
+ const attempt = new Map(attemptBaseline?.owned_artifacts?.map(item => [item.path, item.sha256]) || []);
687
+ const attemptSubject = new Map(attemptBaseline?.subject_artifacts?.map(item => [item.path, item.sha256]) || []);
688
+ const ownedPaths = [...current.keys()];
689
+ if (!ownedPaths.some(path => current.get(path) !== oldArtifacts.get(path))) {
690
+ fail('PLANNING_CONTRACT_REVISION_OUTPUT_NOT_REGENERATED', `${agent} must materially regenerate an owned Planning artifact.`);
691
+ }
692
+ if (attempt.size !== current.size || ownedPaths.some(path => !attempt.has(path))
693
+ || !ownedPaths.some(path => current.get(path) !== attempt.get(path))) {
694
+ fail('PLANNING_CONTRACT_REVISION_ATTEMPT_OUTPUT_NOT_REGENERATED', `${agent} must materially regenerate an owned artifact during this execution attempt.`);
695
+ }
696
+ const currentSubject = new Map(snapshot.subject_artifacts.map(item => [item.path, item.sha256]));
697
+ if (attemptSubject.size !== currentSubject.size
698
+ || [...currentSubject].some(([path, digest]) => !current.has(path) && attemptSubject.get(path) !== digest)) {
699
+ fail('PLANNING_CONTRACT_REVISION_NON_OWNER_INPUT_CHANGED', `${agent} execution changed a Planning input it does not own.`);
700
+ }
701
+ const originalHandoff = snapshot.request.old_handoffs.find(item => item.agent === agent);
702
+ if (!originalHandoff || snapshot.handoff.sha256 === originalHandoff.sha256) {
703
+ fail('PLANNING_CONTRACT_REVISION_HANDOFF_NOT_REGENERATED', `${agent} must produce a fresh handoff for this revision.`);
704
+ }
705
+ if (!attemptBaseline?.handoff || snapshot.handoff.path !== attemptBaseline.handoff.path
706
+ || snapshot.handoff.sha256 === attemptBaseline.handoff.sha256) {
707
+ fail('PLANNING_CONTRACT_REVISION_ATTEMPT_HANDOFF_NOT_REGENERATED', `${agent} must produce a fresh handoff during this execution attempt.`);
708
+ }
709
+ return snapshot;
710
+ } catch (error) { snapshot.close(); throw error; }
711
+ }