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.
- package/bin/chati.js +151 -123
- package/framework/agents/build/dev.md +4 -4
- package/framework/agents/deploy/devops.md +3 -3
- package/framework/agents/discover/brief.md +18 -6
- package/framework/agents/discover/brownfield-wu.md +3 -3
- package/framework/agents/discover/greenfield-wu.md +4 -4
- package/framework/agents/plan/architect.md +2 -2
- package/framework/agents/plan/detail.md +4 -4
- package/framework/agents/plan/phases.md +3 -3
- package/framework/agents/plan/tasks.md +47 -19
- package/framework/agents/plan/ux-brand-architect.md +1 -1
- package/framework/agents/plan/ux.md +26 -2
- package/framework/agents/quality/qa-implementation.md +8 -8
- package/framework/agents/quality/qa-planning.md +58 -4
- package/framework/agents/quality/qa-visual.md +4 -4
- package/framework/config.yaml +2 -2
- package/framework/constitution.md +12 -1
- package/framework/context/governance.md +2 -1
- package/framework/context/root.md +3 -1
- package/framework/domains/constitution.yaml +1 -1
- package/framework/domains/global.yaml +1 -1
- package/framework/domains/workflows/standard-flow.yaml +7 -8
- package/framework/hooks/advance-trigger.js +3 -2
- package/framework/hooks/brief-validator.js +1 -1
- package/framework/hooks/post-dev.js +1 -1
- package/framework/hooks/team-quality-gate.js +9 -2
- package/framework/i18n/en.yaml +1 -0
- package/framework/i18n/es.yaml +1 -0
- package/framework/i18n/fr.yaml +1 -0
- package/framework/i18n/pt.yaml +1 -0
- package/framework/manifest.json +83 -83
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati-router.js +14 -6
- package/framework/orchestrator/chati.md +61 -23
- package/framework/rules/human-writing-style.md +7 -0
- package/framework/schemas/session.schema.json +25 -2
- package/framework/schemas/task.schema.json +26 -0
- package/framework/tasks/orchestrator-handoff.md +10 -10
- package/framework/tasks/orchestrator-resume.md +3 -3
- package/framework/workflows/brownfield-fullstack.yaml +6 -3
- package/framework/workflows/brownfield-service.yaml +5 -3
- package/framework/workflows/brownfield-ui.yaml +5 -3
- package/framework/workflows/greenfield-fullstack.yaml +6 -3
- package/framework/workflows/standard-flow.yaml +8 -3
- package/node_modules/@chati/core/native/browser-proof-wire.h +29 -0
- package/node_modules/@chati/core/native/darwin-browser-listener.c +36 -0
- package/node_modules/@chati/core/native/darwin-browser-listener.h +32 -0
- package/node_modules/@chati/core/native/darwin-browser-proof.h +86 -0
- package/node_modules/@chati/core/native/darwin-code-identity.h +35 -0
- package/node_modules/@chati/core/native/darwin-launch-messages.h +37 -0
- package/node_modules/@chati/core/native/darwin-launcher.c +312 -0
- package/node_modules/@chati/core/native/descriptor-fs.c +407 -0
- package/node_modules/@chati/core/native/execution-admission.h +59 -0
- package/node_modules/@chati/core/native/execution-gate.c +41 -0
- package/node_modules/@chati/core/native/linux-process.c +41 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/darwin-launcher +0 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/descriptor-fs.node +0 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/execution-gate +0 -0
- package/node_modules/@chati/core/native/prebuilds/linux-x64/descriptor-fs.node +0 -0
- package/node_modules/@chati/core/native/prebuilds/linux-x64/execution-gate +0 -0
- package/node_modules/@chati/core/native/process-info-pipe.c +91 -0
- package/node_modules/@chati/core/package.json +2 -1
- package/node_modules/@chati/core/scripts/build-native.js +36 -0
- package/node_modules/@chati/core/src/darwin-browser-proof.js +87 -0
- package/node_modules/@chati/core/src/darwin-host-boot.js +58 -0
- package/node_modules/@chati/core/src/darwin-process.js +79 -0
- package/node_modules/@chati/core/src/darwin-supervisor-channels.js +82 -0
- package/node_modules/@chati/core/src/darwin-terminal-receipt.js +111 -0
- package/node_modules/@chati/core/src/descriptor-fs.js +222 -0
- package/node_modules/@chati/core/src/execution-gate.js +8 -0
- package/node_modules/@chati/core/src/host-boot-marker.js +45 -0
- package/node_modules/@chati/core/src/index.js +18 -0
- package/node_modules/@chati/core/src/linux-host-boot.js +218 -0
- package/node_modules/@chati/core/src/linux-process.js +133 -0
- package/node_modules/@chati/planning/src/browser-verification-plan.js +71 -0
- package/node_modules/@chati/planning/src/index.js +33 -0
- package/node_modules/@chati/planning/src/planning-material.js +64 -0
- package/node_modules/@chati/planning/src/verification-plan.js +57 -0
- package/node_modules/@chati/rail/package.json +1 -0
- package/node_modules/@chati/rail/src/execution-restoration.js +92 -0
- package/node_modules/@chati/rail/src/index.js +812 -167
- package/node_modules/@chati/rail/src/provider-processing.js +118 -0
- package/node_modules/@chati/rail/src/terminal-containment.js +23 -0
- package/node_modules/@chati/release-lane/src/index.js +40 -43
- package/node_modules/@chati/review-council/src/index.js +223 -0
- package/node_modules/@chati/tracking-clickup/src/index.js +67 -58
- package/package-artifact-manifest.json +1 -1
- package/package-artifact-manifest.sig +1 -1
- package/package.json +3 -2
- package/scripts/verify-real-harness-e2e.js +20 -8
- package/src/installer/core.js +147 -107
- package/src/installer/package-artifact.js +7 -0
- package/src/installer/preflight.js +13 -0
- package/src/installer/provider-overlay.js +1 -2
- package/src/installer/templates.js +58 -0
- package/src/installer-v2/catalog-client.js +31 -1
- package/src/installer-v2/clickup-connection-storage.js +92 -0
- package/src/installer-v2/clickup-mcp-client.js +155 -0
- package/src/installer-v2/clickup-oauth-loopback.js +96 -0
- package/src/installer-v2/clickup-oauth.js +233 -0
- package/src/installer-v2/index.js +141 -15
- package/src/installer-v2/installation-authority.js +98 -4
- package/src/installer-v2/provider-executable.js +117 -12
- package/src/installer-v2/wizard-installation.js +11 -0
- package/src/orchestrator/agent-selector.js +39 -1
- package/src/orchestrator/browser-artifact-store.js +131 -0
- package/src/orchestrator/cli.js +762 -205
- package/src/orchestrator/clickup-mcp-observer.js +226 -0
- package/src/orchestrator/clickup-runtime.js +45 -24
- package/src/orchestrator/council-gate-runtime.js +903 -0
- package/src/orchestrator/evidence-authority-enrollment.js +75 -0
- package/src/orchestrator/evidence-authority-request.js +46 -0
- package/src/orchestrator/handoff-engine.js +4 -11
- package/src/orchestrator/pipeline-manager.js +42 -4
- package/src/orchestrator/planning-contract-revision.js +711 -0
- package/src/orchestrator/planning-reconciliation.js +302 -0
- package/src/orchestrator/planning-runtime.js +476 -29
- package/src/orchestrator/rail-adjudication-evidence.js +15 -24
- package/src/orchestrator/rail-human-effort-authority.js +46 -0
- package/src/orchestrator/rail-human-effort.js +149 -0
- package/src/orchestrator/rail-runtime.js +291 -47
- package/src/orchestrator/rail-verification.js +278 -0
- package/src/orchestrator/review-runtime.js +37 -2
- package/src/orchestrator/runtime-installation-v2.js +143 -24
- package/src/orchestrator/session-manager.js +1457 -29
- package/src/tasks/handoff.js +57 -33
- package/src/terminal/adapters/claude-adapter.js +3 -0
- package/src/terminal/adapters/codex-adapter.js +13 -0
- package/src/terminal/adapters/grok-adapter.js +1 -0
- package/src/terminal/agent-attempt-budget.js +35 -0
- package/src/terminal/browser-endpoint-proxy.js +174 -0
- package/src/terminal/browser-observer-driver.js +94 -0
- package/src/terminal/browser-observer-supervisor.js +73 -0
- package/src/terminal/browser-observer.js +95 -0
- package/src/terminal/browser-process-facts.js +75 -0
- package/src/terminal/browser-runtime.js +131 -0
- package/src/terminal/darwin-authority-policy.js +213 -0
- package/src/terminal/darwin-browser-application.js +116 -0
- package/src/terminal/darwin-browser-observer.js +57 -0
- package/src/terminal/darwin-execution.js +185 -0
- package/src/terminal/executable-snapshot.js +202 -0
- package/src/terminal/handoff-parser.js +48 -2
- package/src/terminal/isolation.js +14 -13
- package/src/terminal/prompt-builder.js +176 -15
- package/src/terminal/provider-preflight.js +47 -27
- package/src/terminal/rail-execution-worktree.js +22 -6
- package/src/terminal/rail-process-observer.js +10 -0
- package/src/terminal/rail-prompts.js +21 -3
- package/src/terminal/rail-readonly-workspace.js +54 -5
- package/src/terminal/rail-worktree-checkpoint.js +315 -0
- package/src/terminal/rail-worktree-restoration.js +485 -0
- package/src/terminal/run-agent.js +164 -42
- package/src/terminal/run-council-gate.js +338 -0
- package/src/terminal/run-planning-reconciliation.js +49 -0
- package/src/terminal/run-rail-adjudication.js +29 -7
- package/src/terminal/run-rail-recovery.js +97 -0
- package/src/terminal/run-rail-review.js +36 -15
- package/src/terminal/run-rail-rework.js +26 -15
- package/src/terminal/run-rail-task.js +22 -9
- package/src/terminal/runtime-native-helper.js +85 -0
- package/src/terminal/spawner.js +899 -142
- package/src/terminal/verification-process-facts.js +49 -0
- package/src/upgrade/entrypoints.js +68 -0
- package/src/upgrade/generation.js +194 -0
- package/src/upgrade/maintenance.js +79 -0
- package/src/upgrade/paths.js +81 -0
- package/src/upgrade/upgrade.js +267 -0
- package/src/utils/execution-role.js +15 -0
- package/src/utils/handoff-paths.js +59 -0
- package/src/wizard/i18n.js +1 -0
- package/src/wizard/index.js +17 -5
- 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
|
+
}
|