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
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { TextDecoder } from 'node:util';
|
|
5
|
+
import { canonicalSerialize, sha256 } from '@chati/core';
|
|
6
|
+
import { sealHandoff, validateTaskVerificationPlan, validateTaskBrowserVerificationPlan } from '@chati/planning';
|
|
6
7
|
import { assertEligibleInvocation } from '@chati/provider-registry';
|
|
7
|
-
import {
|
|
8
|
+
import { persistImmutableInstallationFile, readSafeInstallationStorageFile, replaceInstallationStorageFile } from '../installer-v2/catalog-client.js';
|
|
9
|
+
import { assertV2PlanningPredecessors, loadRuntimeInstallationV2, resolveRuntimeTaskRouteV2 } from './runtime-installation-v2.js';
|
|
8
10
|
import { loadSession } from './session-manager.js';
|
|
11
|
+
import { openPinnedProviderExecutable } from '../installer-v2/provider-executable.js';
|
|
9
12
|
|
|
10
13
|
export const PLANNING_V2_TASKS_PATH = '.chati/v2/planning/tasks.json';
|
|
14
|
+
export const PLANNING_V2_TASKS_CANDIDATE_PATH = 'artifacts/6-Tasks/tasks.json';
|
|
15
|
+
const PLANNING_RUNTIME_SECURITY_TEST_HOOKS = Symbol.for('chati.planning-runtime.security-test-hooks');
|
|
16
|
+
const PLANNING_ARTIFACT_MAX_BYTES = 4 * 1024 * 1024;
|
|
17
|
+
const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
11
18
|
|
|
12
19
|
function fail(code, message, cause) {
|
|
13
20
|
const error = new Error(message, cause ? { cause } : undefined);
|
|
@@ -25,16 +32,237 @@ function trustedGitEnvironment(source = process.env) {
|
|
|
25
32
|
};
|
|
26
33
|
}
|
|
27
34
|
|
|
28
|
-
function
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
function runPlanningSecurityTestHook(name, payload) {
|
|
36
|
+
const hook = globalThis[PLANNING_RUNTIME_SECURITY_TEST_HOOKS]?.[name];
|
|
37
|
+
if (typeof hook === 'function') hook(payload);
|
|
31
38
|
}
|
|
32
39
|
|
|
33
|
-
function
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
function sameDirectory(left, right) {
|
|
41
|
+
return left?.dev === right?.dev && left?.ino === right?.ino && left?.isDirectory?.() && right?.isDirectory?.();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function openPinnedPlanningProjectRoot(projectDir) {
|
|
45
|
+
const path = resolve(projectDir);
|
|
46
|
+
let descriptor;
|
|
47
|
+
try {
|
|
48
|
+
const before = lstatSync(path, { bigint: true });
|
|
49
|
+
if (!before.isDirectory() || before.isSymbolicLink()) {
|
|
50
|
+
fail('UNSAFE_PLANNING_PROJECT_ROOT', 'planning requires a real project directory');
|
|
51
|
+
}
|
|
52
|
+
descriptor = openSync(path, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
53
|
+
const opened = fstatSync(descriptor, { bigint: true });
|
|
54
|
+
if (!sameDirectory(before, opened)) {
|
|
55
|
+
fail('UNSAFE_PLANNING_PROJECT_ROOT', 'planning project directory changed before it could be pinned');
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
path,
|
|
59
|
+
descriptor,
|
|
60
|
+
identity: opened,
|
|
61
|
+
reference: process.platform === 'linux' ? `/proc/self/fd/${descriptor}` : path,
|
|
62
|
+
artifactSnapshots: [],
|
|
63
|
+
};
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
66
|
+
if (error?.code?.startsWith?.('UNSAFE_PLANNING_')) throw error;
|
|
67
|
+
return fail('UNSAFE_PLANNING_PROJECT_ROOT', 'planning project directory cannot be opened safely', error);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function assertPinnedPlanningProjectRoot(project) {
|
|
72
|
+
const opened = fstatSync(project.descriptor, { bigint: true });
|
|
73
|
+
const current = lstatSync(project.path, { bigint: true });
|
|
74
|
+
if (!sameDirectory(project.identity, opened) || !sameDirectory(project.identity, current) || current.isSymbolicLink()) {
|
|
75
|
+
fail('UNSAFE_PLANNING_PROJECT_ROOT', 'planning project directory changed during compilation');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function samePlanningFileSnapshot(left, right) {
|
|
80
|
+
return left?.dev === right?.dev && left?.ino === right?.ino
|
|
81
|
+
&& left?.size === right?.size && left?.mode === right?.mode
|
|
82
|
+
&& left?.nlink === right?.nlink
|
|
83
|
+
&& left?.mtimeNs === right?.mtimeNs && left?.ctimeNs === right?.ctimeNs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function relativePlanningArtifact(project, relativePath, code) {
|
|
87
|
+
if (typeof relativePath !== 'string' || relativePath.trim() === '' || isAbsolute(relativePath)) {
|
|
88
|
+
fail(code, 'planning artifact path must be a non-empty relative path');
|
|
89
|
+
}
|
|
90
|
+
const absolute = resolve(project.path, relativePath);
|
|
91
|
+
const boundary = relative(project.path, absolute);
|
|
92
|
+
if (!boundary || boundary === '..' || boundary.startsWith(`..${sep}`) || isAbsolute(boundary)) {
|
|
93
|
+
fail(code, `planning artifact path escapes the project: ${relativePath}`);
|
|
94
|
+
}
|
|
95
|
+
return boundary;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function snapshotNamedPlanningArtifact(project, safeRelative, label) {
|
|
99
|
+
const segments = safeRelative.split(sep).filter(Boolean);
|
|
100
|
+
const snapshots = [];
|
|
101
|
+
let current = project.path;
|
|
102
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
103
|
+
current = join(current, segments[index]);
|
|
104
|
+
const final = index === segments.length - 1;
|
|
105
|
+
let stat;
|
|
106
|
+
try { stat = lstatSync(current, { bigint: true }); }
|
|
107
|
+
catch (error) {
|
|
108
|
+
return fail('UNSAFE_PLANNING_ARTIFACT', `cannot safely resolve ${label}`, error);
|
|
109
|
+
}
|
|
110
|
+
if (stat.isSymbolicLink() || (final ? !stat.isFile() || stat.nlink !== 1n : !stat.isDirectory())) {
|
|
111
|
+
fail('UNSAFE_PLANNING_ARTIFACT', `${label} path must contain only real directories and a single-link regular file`);
|
|
112
|
+
}
|
|
113
|
+
snapshots.push(Object.freeze({ path: current, final, stat }));
|
|
114
|
+
}
|
|
115
|
+
return Object.freeze(snapshots);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function samePlanningDirectorySnapshot(left, right) {
|
|
119
|
+
return left?.dev === right?.dev && left?.ino === right?.ino && left?.isDirectory?.() && right?.isDirectory?.();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function closePlanningArtifactDescriptors(artifact) {
|
|
123
|
+
for (const item of [...(artifact?.opened ?? [])].reverse()) {
|
|
124
|
+
try { closeSync(item.descriptor); } catch { /* preserve the original result */ }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function assertNamedPlanningArtifact(project, artifact) {
|
|
129
|
+
for (const snapshot of artifact.snapshots) {
|
|
130
|
+
let current;
|
|
131
|
+
try { current = lstatSync(snapshot.path, { bigint: true }); }
|
|
132
|
+
catch (error) {
|
|
133
|
+
return fail('UNSAFE_PLANNING_ARTIFACT', `${artifact.label} path changed during compilation`, error);
|
|
134
|
+
}
|
|
135
|
+
const valid = snapshot.final
|
|
136
|
+
? !current.isSymbolicLink() && current.isFile() && current.nlink === 1n && samePlanningFileSnapshot(snapshot.stat, current)
|
|
137
|
+
: !current.isSymbolicLink() && current.isDirectory() && current.dev === snapshot.stat.dev && current.ino === snapshot.stat.ino;
|
|
138
|
+
if (!valid) fail('UNSAFE_PLANNING_ARTIFACT', `${artifact.label} path changed during compilation`);
|
|
139
|
+
}
|
|
140
|
+
for (const opened of artifact.opened) {
|
|
141
|
+
let current;
|
|
142
|
+
try { current = fstatSync(opened.descriptor, { bigint: true }); }
|
|
143
|
+
catch (error) {
|
|
144
|
+
return fail('UNSAFE_PLANNING_ARTIFACT', `${artifact.label} descriptor changed during compilation`, error);
|
|
145
|
+
}
|
|
146
|
+
const valid = opened.final
|
|
147
|
+
? samePlanningFileSnapshot(opened.stat, current)
|
|
148
|
+
: samePlanningDirectorySnapshot(opened.stat, current);
|
|
149
|
+
if (!valid) fail('UNSAFE_PLANNING_ARTIFACT', `${artifact.label} descriptor changed during compilation`);
|
|
150
|
+
}
|
|
151
|
+
assertPinnedPlanningProjectRoot(project);
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function assertAllNamedPlanningArtifacts(project) {
|
|
156
|
+
for (const artifact of project.artifactSnapshots) {
|
|
157
|
+
assertNamedPlanningArtifact(project, artifact);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function descriptorRelativePlanningPath(project, descriptor, segments, index) {
|
|
162
|
+
if (process.platform === 'linux') return join(`/proc/self/fd/${descriptor}`, segments[index]);
|
|
163
|
+
return join(project.path, ...segments.slice(0, index + 1));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function readPinnedPlanningArtifactDescriptor(descriptor, snapshot, label) {
|
|
167
|
+
const before = fstatSync(descriptor, { bigint: true });
|
|
168
|
+
if (!samePlanningFileSnapshot(snapshot, before) || !before.isFile() || before.nlink !== 1n) {
|
|
169
|
+
fail('UNSAFE_PLANNING_ARTIFACT', `${label} changed before it could be read`);
|
|
170
|
+
}
|
|
171
|
+
if (before.size > BigInt(PLANNING_ARTIFACT_MAX_BYTES)) {
|
|
172
|
+
fail('PLANNING_ARTIFACT_SIZE_LIMIT', `${label} exceeds the ${PLANNING_ARTIFACT_MAX_BYTES} byte safety limit`);
|
|
173
|
+
}
|
|
174
|
+
const chunks = [];
|
|
175
|
+
let total = 0;
|
|
176
|
+
while (true) {
|
|
177
|
+
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, PLANNING_ARTIFACT_MAX_BYTES + 1 - total));
|
|
178
|
+
const count = readSync(descriptor, buffer, 0, buffer.length, null);
|
|
179
|
+
if (count === 0) break;
|
|
180
|
+
total += count;
|
|
181
|
+
if (total > PLANNING_ARTIFACT_MAX_BYTES) {
|
|
182
|
+
fail('PLANNING_ARTIFACT_SIZE_LIMIT', `${label} exceeds the ${PLANNING_ARTIFACT_MAX_BYTES} byte safety limit`);
|
|
183
|
+
}
|
|
184
|
+
chunks.push(buffer.subarray(0, count));
|
|
185
|
+
}
|
|
186
|
+
const after = fstatSync(descriptor, { bigint: true });
|
|
187
|
+
if (!samePlanningFileSnapshot(before, after)) {
|
|
188
|
+
fail('UNSAFE_PLANNING_ARTIFACT', `${label} changed while it was being read`);
|
|
189
|
+
}
|
|
190
|
+
try { return STRICT_UTF8.decode(Buffer.concat(chunks, total)); }
|
|
191
|
+
catch (error) { return fail('UNSAFE_PLANNING_ARTIFACT', `${label} must contain valid UTF-8 without lossy replacement`, error); }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function readPinnedPlanningArtifact(project, relativePath, code, label) {
|
|
195
|
+
const safeRelative = relativePlanningArtifact(project, relativePath, code);
|
|
196
|
+
const snapshots = snapshotNamedPlanningArtifact(project, safeRelative, label);
|
|
197
|
+
const segments = safeRelative.split(sep).filter(Boolean);
|
|
198
|
+
const opened = [];
|
|
199
|
+
try {
|
|
200
|
+
// Traverse once from the already pinned project root. Every descriptor
|
|
201
|
+
// read below is compared to the pathname snapshot made immediately before
|
|
202
|
+
// opening it, so a swap between lstat and open cannot change the bytes
|
|
203
|
+
// that are later sealed into a handoff.
|
|
204
|
+
runPlanningSecurityTestHook('beforePinnedPlanningArtifactOpen', { relativePath: safeRelative, label });
|
|
205
|
+
let parent = openSync(project.reference, constants.O_RDONLY | (constants.O_DIRECTORY || 0)
|
|
206
|
+
| (process.platform === 'linux' ? 0 : (constants.O_NOFOLLOW || 0)));
|
|
207
|
+
let root;
|
|
208
|
+
try { root = fstatSync(parent, { bigint: true }); }
|
|
209
|
+
catch (error) {
|
|
210
|
+
closeSync(parent);
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
if (!samePlanningDirectorySnapshot(project.identity, root)) {
|
|
214
|
+
closeSync(parent);
|
|
215
|
+
fail('UNSAFE_PLANNING_PROJECT_ROOT', 'planning project directory changed before an artifact could be opened');
|
|
216
|
+
}
|
|
217
|
+
opened.push(Object.freeze({ descriptor: parent, stat: root, final: false }));
|
|
218
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
219
|
+
const final = index === segments.length - 1;
|
|
220
|
+
let descriptor;
|
|
221
|
+
try {
|
|
222
|
+
descriptor = openSync(
|
|
223
|
+
descriptorRelativePlanningPath(project, parent, segments, index),
|
|
224
|
+
constants.O_RDONLY | (final ? 0 : (constants.O_DIRECTORY || 0)) | (constants.O_NOFOLLOW || 0) | (final ? (constants.O_NONBLOCK || 0) : 0),
|
|
225
|
+
);
|
|
226
|
+
const stat = fstatSync(descriptor, { bigint: true });
|
|
227
|
+
const expected = snapshots[index].stat;
|
|
228
|
+
const valid = final
|
|
229
|
+
? samePlanningFileSnapshot(expected, stat) && stat.isFile() && stat.nlink === 1n
|
|
230
|
+
: samePlanningDirectorySnapshot(expected, stat);
|
|
231
|
+
if (!valid) {
|
|
232
|
+
fail('UNSAFE_PLANNING_ARTIFACT', `${label} changed before it could be opened`);
|
|
233
|
+
}
|
|
234
|
+
opened.push(Object.freeze({ descriptor, stat, final }));
|
|
235
|
+
parent = descriptor;
|
|
236
|
+
descriptor = undefined;
|
|
237
|
+
} finally {
|
|
238
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const artifact = Object.freeze({ snapshots, opened: Object.freeze(opened), label });
|
|
242
|
+
const content = readPinnedPlanningArtifactDescriptor(parent, snapshots.at(-1).stat, label);
|
|
243
|
+
runPlanningSecurityTestHook('afterPinnedPlanningArtifactRead', { relativePath: safeRelative, label });
|
|
244
|
+
assertNamedPlanningArtifact(project, artifact);
|
|
245
|
+
project.artifactSnapshots.push(Object.freeze({ ...artifact, content, relativePath: safeRelative }));
|
|
246
|
+
return content;
|
|
247
|
+
} catch (error) {
|
|
248
|
+
closePlanningArtifactDescriptors({ opened });
|
|
249
|
+
return fail(error.code || code, `cannot safely read ${label}: ${error.message}`, error);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function readJsonSnapshot(project, relativePath, code) {
|
|
254
|
+
const content = readPinnedPlanningArtifact(project, relativePath, code, 'planning task graph');
|
|
255
|
+
try {
|
|
256
|
+
const value = JSON.parse(content);
|
|
257
|
+
runPlanningSecurityTestHook('afterPlanningTaskGraphSnapshot', { path: relativePath, content });
|
|
258
|
+
return Object.freeze({ value, content });
|
|
259
|
+
} catch (error) {
|
|
260
|
+
return fail(code, `invalid JSON planning artifact: ${relativePath}`, error);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function immutableFileRef(project, relativePath, code) {
|
|
265
|
+
return `local-artifact://sha256/${sha256(readPinnedPlanningArtifact(project, relativePath, code, 'planning source artifact'))}`;
|
|
38
266
|
}
|
|
39
267
|
|
|
40
268
|
function criterionRef(taskId, criterion) {
|
|
@@ -78,28 +306,179 @@ function taskExecutionBinding(task, installation, clock) {
|
|
|
78
306
|
return binding;
|
|
79
307
|
}
|
|
80
308
|
|
|
309
|
+
function planningUsesExecutionDesignV2(planning) {
|
|
310
|
+
const major = Number.parseInt(String(planning?.version || '1').split('.')[0], 10);
|
|
311
|
+
return Number.isInteger(major) && major >= 2;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function installationRequiresExecutionDesignV2(installation) {
|
|
315
|
+
return installation?.installation?.quality_council !== undefined;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function taskRoutingProfile(task) {
|
|
319
|
+
if (!task.routing_profile || typeof task.routing_profile !== 'object' || Array.isArray(task.routing_profile)) {
|
|
320
|
+
fail('TASK_ROUTING_PROFILE_REQUIRED', `task ${task.id} must declare a routing_profile for the signed catalog router`);
|
|
321
|
+
}
|
|
322
|
+
return task.routing_profile;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function durationRange(value, label) {
|
|
326
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('INVALID_TASK_EFFORT_PLAN', `${label} must be an object`);
|
|
327
|
+
const p50 = value.p50_minutes;
|
|
328
|
+
const p80 = value.p80_minutes;
|
|
329
|
+
if (!Number.isFinite(p50) || p50 < 0 || !Number.isFinite(p80) || p80 < p50) {
|
|
330
|
+
fail('INVALID_TASK_EFFORT_PLAN', `${label} must have non-negative p50_minutes and p80_minutes >= p50_minutes`);
|
|
331
|
+
}
|
|
332
|
+
return { p50_minutes: p50, p80_minutes: p80 };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function taskEffortPlan(task) {
|
|
336
|
+
const plan = task.effort_plan;
|
|
337
|
+
if (!plan || typeof plan !== 'object' || Array.isArray(plan)) {
|
|
338
|
+
fail('TASK_EFFORT_PLAN_REQUIRED', `task ${task.id} must declare a complete effort_plan`);
|
|
339
|
+
}
|
|
340
|
+
const parts = Object.fromEntries(['ai_build', 'ai_review', 'ai_rework_reserve', 'human_active', 'human_wait']
|
|
341
|
+
.map((part) => [part, durationRange(plan[part], `task ${task.id} effort_plan.${part}`)]));
|
|
342
|
+
if (!Number.isFinite(plan.rework_margin_pct) || plan.rework_margin_pct < 1 || plan.rework_margin_pct > 100) {
|
|
343
|
+
fail('INVALID_TASK_EFFORT_PLAN', `task ${task.id} effort_plan.rework_margin_pct must be between 1 and 100`);
|
|
344
|
+
}
|
|
345
|
+
if (parts.ai_rework_reserve.p80_minutes <= 0) {
|
|
346
|
+
fail('INVALID_TASK_EFFORT_PLAN', `task ${task.id} effort_plan.ai_rework_reserve must reserve rework time`);
|
|
347
|
+
}
|
|
348
|
+
const total = durationRange(plan.total, `task ${task.id} effort_plan.total`);
|
|
349
|
+
const expected = Object.values(parts).reduce((sum, value) => ({
|
|
350
|
+
p50_minutes: sum.p50_minutes + value.p50_minutes,
|
|
351
|
+
p80_minutes: sum.p80_minutes + value.p80_minutes,
|
|
352
|
+
}), { p50_minutes: 0, p80_minutes: 0 });
|
|
353
|
+
if (Math.abs(total.p50_minutes - expected.p50_minutes) > 0.001 || Math.abs(total.p80_minutes - expected.p80_minutes) > 0.001) {
|
|
354
|
+
fail('INVALID_TASK_EFFORT_PLAN', `task ${task.id} effort_plan.total must equal the sum of AI, rework, and human time`);
|
|
355
|
+
}
|
|
356
|
+
return { ...parts, rework_margin_pct: plan.rework_margin_pct, total };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function taskRequirementRefs(task) {
|
|
360
|
+
if (!Array.isArray(task.requirement_refs) || task.requirement_refs.length === 0
|
|
361
|
+
|| !task.requirement_refs.every((ref) => typeof ref === 'string' && ref.trim())
|
|
362
|
+
|| new Set(task.requirement_refs).size !== task.requirement_refs.length) {
|
|
363
|
+
fail('TASK_REQUIREMENT_TRACEABILITY_REQUIRED', `task ${task.id} must reference one or more unique discovery requirement IDs`);
|
|
364
|
+
}
|
|
365
|
+
return [...task.requirement_refs];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function validateDependencyGraph(tasks) {
|
|
369
|
+
const byId = new Map(tasks.map((task) => [task.task_id, task]));
|
|
370
|
+
for (const task of tasks) {
|
|
371
|
+
if (!Array.isArray(task.dependency_ids) || !task.dependency_ids.every((id) => typeof id === 'string' && id.trim())) {
|
|
372
|
+
fail('INVALID_TASK_DEPENDENCY', `task ${task.task_id} dependencies must be an array of task IDs`);
|
|
373
|
+
}
|
|
374
|
+
if (new Set(task.dependency_ids).size !== task.dependency_ids.length) {
|
|
375
|
+
fail('INVALID_TASK_DEPENDENCY', `task ${task.task_id} has duplicate dependencies`);
|
|
376
|
+
}
|
|
377
|
+
for (const dependency of task.dependency_ids) {
|
|
378
|
+
if (dependency === task.task_id || !byId.has(dependency)) {
|
|
379
|
+
fail('INVALID_TASK_DEPENDENCY', `task ${task.task_id} references an unknown or self dependency: ${dependency}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const visiting = new Set();
|
|
384
|
+
const visited = new Set();
|
|
385
|
+
const visit = (taskId) => {
|
|
386
|
+
if (visiting.has(taskId)) fail('TASK_DEPENDENCY_CYCLE', `planning task graph contains a dependency cycle at ${taskId}`);
|
|
387
|
+
if (visited.has(taskId)) return;
|
|
388
|
+
visiting.add(taskId);
|
|
389
|
+
for (const dependency of byId.get(taskId).dependency_ids) visit(dependency);
|
|
390
|
+
visiting.delete(taskId);
|
|
391
|
+
visited.add(taskId);
|
|
392
|
+
};
|
|
393
|
+
for (const task of tasks) visit(task.task_id);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function compileVerificationPlan(project, task) {
|
|
397
|
+
if (task.verification_checks === undefined) return {};
|
|
398
|
+
if (!Array.isArray(task.verification_checks) || task.verification_checks.length < 2 || task.verification_checks.length > 32) {
|
|
399
|
+
fail('VERIFICATION_PLAN_INVALID', 'verification_checks must contain 2 to 32 mandatory CI/acceptance checks');
|
|
400
|
+
}
|
|
401
|
+
const checks = task.verification_checks.map((check) => {
|
|
402
|
+
const fields = ['check_id', 'kind', 'criterion_indices', 'executable_binding', 'args', 'script_path', 'timeout_ms'];
|
|
403
|
+
if (!check || Object.keys(check).length !== fields.length || Object.keys(check).some((key) => !fields.includes(key))
|
|
404
|
+
|| !Array.isArray(check.criterion_indices)
|
|
405
|
+
|| check.criterion_indices.some((index) => !Number.isInteger(index) || index < 0 || index >= task.criteria.length)
|
|
406
|
+
|| typeof check.script_path !== 'string' || !check.script_path.startsWith('artifacts/6-Tasks/verification/')
|
|
407
|
+
|| check.script_path.includes('\0') || check.script_path.split('/').some((part) => !part || part === '.' || part === '..')) {
|
|
408
|
+
fail('VERIFICATION_PLAN_INVALID', 'verification check requires explicit fields, valid zero-based criterion indices and a Tasks verification script');
|
|
409
|
+
}
|
|
410
|
+
const stdin_ref = immutableFileRef(project, check.script_path, 'VERIFICATION_SCRIPT_REQUIRED');
|
|
411
|
+
return {
|
|
412
|
+
check_id: check.check_id, kind: check.kind,
|
|
413
|
+
criterion_refs: check.criterion_indices.map((index) => criterionRef(task.id, task.criteria[index])),
|
|
414
|
+
executable_binding: check.executable_binding, args: check.args, stdin_ref, timeout_ms: check.timeout_ms,
|
|
415
|
+
};
|
|
416
|
+
});
|
|
417
|
+
const verification_plan = { schema_version: 1, checks };
|
|
418
|
+
validateTaskVerificationPlan({ verification_plan, acceptance_criteria_refs: task.criteria.map((criterion) => criterionRef(task.id, criterion)) });
|
|
419
|
+
for (const check of checks) {
|
|
420
|
+
const pinned = openPinnedProviderExecutable(check.executable_binding, { projectDir: project.path, requireNativeImage: true });
|
|
421
|
+
closeSync(pinned.descriptor);
|
|
422
|
+
}
|
|
423
|
+
return { verification_plan };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function compileBrowserVerificationPlan(project, task) {
|
|
427
|
+
const source = task.browser_verification;
|
|
428
|
+
if (source === undefined) return {};
|
|
429
|
+
const fields = ['schema_version', 'tool_id', 'evidence_kind', 'criterion_indices', 'observation'];
|
|
430
|
+
const scriptPath = source?.observation?.application?.script_path;
|
|
431
|
+
if (!source || Object.keys(source).length !== fields.length || Object.keys(source).some((key) => !fields.includes(key))
|
|
432
|
+
|| !Array.isArray(source.criterion_indices)
|
|
433
|
+
|| source.criterion_indices.some((index) => !Number.isInteger(index) || index < 0 || index >= task.criteria.length)
|
|
434
|
+
|| typeof scriptPath !== 'string' || !scriptPath.startsWith('artifacts/6-Tasks/verification/')
|
|
435
|
+
|| scriptPath.includes('\0') || scriptPath.split('/').some((part) => !part || part === '.' || part === '..')) {
|
|
436
|
+
fail('BROWSER_VERIFICATION_PLAN_INVALID', 'browser verification requires explicit fields, criterion indices and a Tasks startup script');
|
|
437
|
+
}
|
|
438
|
+
const application = { ...source.observation.application }; delete application.script_path;
|
|
439
|
+
const stdin_ref = immutableFileRef(project, scriptPath, 'BROWSER_STARTUP_SCRIPT_REQUIRED');
|
|
440
|
+
const browser_verification_plan = { schema_version: source.schema_version, tool_id: source.tool_id,
|
|
441
|
+
evidence_kind: source.evidence_kind, criterion_refs: source.criterion_indices.map((index) => criterionRef(task.id, task.criteria[index])),
|
|
442
|
+
observation: { ...source.observation, application: { ...application, stdin_ref } } };
|
|
443
|
+
validateTaskBrowserVerificationPlan({ task_id: task.id, requires_browser: task.requires_browser,
|
|
444
|
+
browser_policy: task.browser_policy, acceptance_criteria_refs: task.criteria.map((criterion) => criterionRef(task.id, criterion)), browser_verification_plan });
|
|
445
|
+
for (const binding of [application.executable_binding, source.observation.browser.executable_binding]) {
|
|
446
|
+
const pinned = openPinnedProviderExecutable(binding, { projectDir: project.path, requireNativeImage: true });
|
|
447
|
+
closeSync(pinned.descriptor);
|
|
448
|
+
}
|
|
449
|
+
return { browser_verification_plan };
|
|
450
|
+
}
|
|
451
|
+
|
|
81
452
|
/**
|
|
82
453
|
* Compiles the structured output of the Tasks agent into the only artifact the
|
|
83
454
|
* RAIL runtime accepts. It does not infer scope, criteria, estimates, ClickUp
|
|
84
455
|
* mapping, or dependencies from prose.
|
|
456
|
+
* Only the trusted advance caller promotes an agent-owned candidate. The
|
|
457
|
+
* protected snapshot is council input, not permission to dispatch a task.
|
|
85
458
|
*/
|
|
86
|
-
export function compilePlanningHandoff({ projectDir, clock = () => new Date() } = {}) {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
459
|
+
export function compilePlanningHandoff({ projectDir, clock = () => new Date(), promoteCandidate = false } = {}) {
|
|
460
|
+
const project = openPinnedPlanningProjectRoot(projectDir);
|
|
461
|
+
try {
|
|
462
|
+
const installation = loadRuntimeInstallationV2(project.path, { clock });
|
|
463
|
+
if (!installation) fail('V2_INSTALLATION_REQUIRED', 'planning-to-RAIL compilation requires a v2 installation');
|
|
464
|
+
const { loaded, session } = loadSession(project.path);
|
|
465
|
+
if (!loaded || !session) fail('V2_SESSION_REQUIRED', 'planning-to-RAIL compilation requires a canonical project session');
|
|
466
|
+
assertV2PlanningPredecessors(session, 'tasks');
|
|
467
|
+
const taskGraphPath = promoteCandidate ? PLANNING_V2_TASKS_CANDIDATE_PATH : PLANNING_V2_TASKS_PATH;
|
|
468
|
+
const { value: planning, content: planningSnapshot } = readJsonSnapshot(project, taskGraphPath, 'PLANNING_TASK_GRAPH_REQUIRED');
|
|
469
|
+
const strictExecutionDesign = installationRequiresExecutionDesignV2(installation);
|
|
470
|
+
if (strictExecutionDesign && !planningUsesExecutionDesignV2(planning)) {
|
|
471
|
+
fail('PLANNING_EXECUTION_DESIGN_VERSION_REQUIRED', 'current installations require a v2 planning task graph and do not permit a legacy routing downgrade');
|
|
472
|
+
}
|
|
94
473
|
if (!Array.isArray(planning.tasks) || planning.tasks.length === 0) fail('PLANNING_TASK_GRAPH_REQUIRED', 'planning tasks must be a non-empty array');
|
|
95
474
|
if (!planning.source_artifacts || typeof planning.source_artifacts !== 'object') fail('PLANNING_SOURCE_ARTIFACTS_REQUIRED', 'planning source_artifacts are required');
|
|
96
475
|
const sources = planning.source_artifacts;
|
|
97
476
|
const source_artifacts = {
|
|
98
|
-
requirements_ref: immutableFileRef(
|
|
99
|
-
architecture_ref: immutableFileRef(
|
|
100
|
-
qa_plan_ref: immutableFileRef(
|
|
477
|
+
requirements_ref: immutableFileRef(project, sources.requirements_path, 'PLANNING_SOURCE_ARTIFACTS_REQUIRED'),
|
|
478
|
+
architecture_ref: immutableFileRef(project, sources.architecture_path, 'PLANNING_SOURCE_ARTIFACTS_REQUIRED'),
|
|
479
|
+
qa_plan_ref: immutableFileRef(project, sources.qa_plan_path, 'PLANNING_SOURCE_ARTIFACTS_REQUIRED'),
|
|
101
480
|
};
|
|
102
|
-
if (sources.ux_path) source_artifacts.ux_ref = immutableFileRef(
|
|
481
|
+
if (sources.ux_path) source_artifacts.ux_ref = immutableFileRef(project, sources.ux_path, 'PLANNING_SOURCE_ARTIFACTS_REQUIRED');
|
|
103
482
|
const ids = new Set();
|
|
104
483
|
const clickupRequired = installation.installation.external_integrations?.clickup === 'required';
|
|
105
484
|
const tasks = planning.tasks.map((task) => {
|
|
@@ -113,6 +492,21 @@ export function compilePlanningHandoff({ projectDir, clock = () => new Date() }
|
|
|
113
492
|
if (clickupRequired && (typeof task.clickup_ref !== 'string' || !task.clickup_ref.trim())) fail('MISSING_CLICKUP_REFERENCE', `internal task ${task.id} must have clickup_ref`);
|
|
114
493
|
if (task.clickup_ref !== undefined && (typeof task.clickup_ref !== 'string' || !task.clickup_ref.trim())) fail('INVALID_CLICKUP_REFERENCE', `task ${task.id} clickup_ref must be a non-empty string when provided`);
|
|
115
494
|
if (!task.estimate || task.estimate.ai_processing == null || task.estimate.human_effort == null || task.estimate.uncertainty == null) fail('INVALID_PLANNING_TASK', `task ${task.id} must have ai_processing, human_effort and uncertainty estimates`);
|
|
495
|
+
const route = strictExecutionDesign
|
|
496
|
+
? resolveRuntimeTaskRouteV2({
|
|
497
|
+
artifact: installation,
|
|
498
|
+
routing_profile: taskRoutingProfile(task),
|
|
499
|
+
required_independence_level: task.required_independence_level ?? 'A',
|
|
500
|
+
clock,
|
|
501
|
+
})
|
|
502
|
+
: null;
|
|
503
|
+
const execution_binding = route?.execution_binding ?? taskExecutionBinding(task, installation, clock);
|
|
504
|
+
const strictFields = strictExecutionDesign ? {
|
|
505
|
+
requirement_refs: taskRequirementRefs(task),
|
|
506
|
+
routing_profile: route.routing_profile,
|
|
507
|
+
review_binding: route.review_binding,
|
|
508
|
+
effort_plan: taskEffortPlan(task),
|
|
509
|
+
} : {};
|
|
116
510
|
return {
|
|
117
511
|
task_id: task.id,
|
|
118
512
|
dependency_ids: task.dependencies ?? [],
|
|
@@ -120,25 +514,78 @@ export function compilePlanningHandoff({ projectDir, clock = () => new Date() }
|
|
|
120
514
|
execution_scope_ref: task.execution_scope_ref,
|
|
121
515
|
routing_constraints_ref: task.routing_constraints_ref || `policy://installation/${installation.installation.installation_id}/task/${task.id}`,
|
|
122
516
|
required_independence_level: task.required_independence_level ?? 'A',
|
|
123
|
-
execution_binding
|
|
517
|
+
execution_binding,
|
|
124
518
|
estimate: task.estimate,
|
|
519
|
+
...strictFields,
|
|
520
|
+
...compileVerificationPlan(project, task),
|
|
521
|
+
...compileBrowserVerificationPlan(project, task),
|
|
125
522
|
...(task.clickup_ref === undefined ? {} : { clickup_ref: task.clickup_ref }),
|
|
126
523
|
...(task.requires_browser === undefined ? {} : { requires_browser: task.requires_browser }),
|
|
127
524
|
...(task.browser_policy === undefined ? {} : { browser_policy: task.browser_policy }),
|
|
128
525
|
};
|
|
129
526
|
});
|
|
130
|
-
|
|
131
|
-
|
|
527
|
+
validateDependencyGraph(tasks);
|
|
528
|
+
source_artifacts.task_details_ref = immutableFileRef(project, sources.task_details_path || 'artifacts/6-Tasks/tasks.md', 'PLANNING_TASK_DETAILS_REQUIRED');
|
|
529
|
+
const materialByRef = new Map(project.artifactSnapshots.map(({ content }) => [
|
|
530
|
+
`local-artifact://sha256/${sha256(content)}`, { ref: `local-artifact://sha256/${sha256(content)}`, sha256: sha256(content), content },
|
|
531
|
+
]));
|
|
532
|
+
for (const task of planning.tasks) {
|
|
533
|
+
for (const criterion of task.criteria) {
|
|
534
|
+
const content = canonicalSerialize({ task_id: task.id, criterion });
|
|
535
|
+
const ref = criterionRef(task.id, criterion);
|
|
536
|
+
materialByRef.set(ref, { ref, sha256: sha256(content), content });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const planning_material = {
|
|
540
|
+
schema_version: 1,
|
|
541
|
+
artifacts: [...materialByRef.values()].sort((left, right) => left.ref.localeCompare(right.ref)),
|
|
542
|
+
scope_bindings: tasks.map((task) => ({
|
|
543
|
+
task_id: task.task_id, ref: task.execution_scope_ref, artifact_ref: source_artifacts.task_details_ref,
|
|
544
|
+
})),
|
|
545
|
+
};
|
|
546
|
+
assertAllNamedPlanningArtifacts(project);
|
|
547
|
+
const commit = gitCommit(project.path);
|
|
548
|
+
// The sealed executable task list and its council-visible artifact reference
|
|
549
|
+
// must derive from one immutable in-memory snapshot. A second path read here
|
|
550
|
+
// would let a concurrent writer bind different tasks to an approved digest.
|
|
551
|
+
const task_graph_ref = `local-artifact://sha256/${sha256(planningSnapshot)}`;
|
|
132
552
|
const handoff_id = planning.handoff_id || `handoff-${sha256({ project_id: installation.project_id, task_graph_ref, git_commit_ref: commit }).slice(0, 24)}`;
|
|
133
553
|
const now = clock();
|
|
134
554
|
const sealedAt = (now instanceof Date ? now : new Date(now)).toISOString();
|
|
135
|
-
|
|
555
|
+
const handoff = sealHandoff({
|
|
136
556
|
handoff_id,
|
|
137
557
|
version: planning.version || '1.0.0',
|
|
138
558
|
project_id: installation.project_id,
|
|
139
559
|
source_artifacts,
|
|
140
560
|
task_graph_ref,
|
|
141
561
|
tasks,
|
|
562
|
+
planning_material,
|
|
142
563
|
seal: { sealed_by: planning.sealed_by || 'actor://chati/qa-planning', sealed_at: sealedAt, git_commit_ref: commit },
|
|
143
564
|
});
|
|
565
|
+
if (promoteCandidate) {
|
|
566
|
+
// Persist exactly the validated snapshot, never reread the mutable candidate.
|
|
567
|
+
// Invalid candidates leave the previous protected graph untouched. A new
|
|
568
|
+
// graph changes the council subject, so an old approval cannot authorize it.
|
|
569
|
+
assertAllNamedPlanningArtifacts(project);
|
|
570
|
+
const path = join(project.reference, PLANNING_V2_TASKS_PATH);
|
|
571
|
+
const previous = readSafeInstallationStorageFile(path, { allowMissing: true });
|
|
572
|
+
if (previous === null) {
|
|
573
|
+
persistImmutableInstallationFile(path, planningSnapshot, {
|
|
574
|
+
conflictCode: 'PLANNING_TASK_GRAPH_CONFLICT', label: 'validated planning task graph',
|
|
575
|
+
});
|
|
576
|
+
} else if (previous.content !== planningSnapshot) {
|
|
577
|
+
replaceInstallationStorageFile(path, planningSnapshot, previous);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return handoff;
|
|
581
|
+
} finally {
|
|
582
|
+
try {
|
|
583
|
+
assertPinnedPlanningProjectRoot(project);
|
|
584
|
+
assertAllNamedPlanningArtifacts(project);
|
|
585
|
+
}
|
|
586
|
+
finally {
|
|
587
|
+
for (const artifact of project.artifactSnapshots) closePlanningArtifactDescriptors(artifact);
|
|
588
|
+
closeSync(project.descriptor);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
144
591
|
}
|