chati-dev 4.5.28 → 4.5.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/bin/chati.js +151 -123
  2. package/framework/agents/build/dev.md +4 -4
  3. package/framework/agents/deploy/devops.md +3 -3
  4. package/framework/agents/discover/brief.md +18 -6
  5. package/framework/agents/discover/brownfield-wu.md +3 -3
  6. package/framework/agents/discover/greenfield-wu.md +4 -4
  7. package/framework/agents/plan/architect.md +2 -2
  8. package/framework/agents/plan/detail.md +4 -4
  9. package/framework/agents/plan/phases.md +3 -3
  10. package/framework/agents/plan/tasks.md +47 -19
  11. package/framework/agents/plan/ux-brand-architect.md +1 -1
  12. package/framework/agents/plan/ux.md +26 -2
  13. package/framework/agents/quality/qa-implementation.md +8 -8
  14. package/framework/agents/quality/qa-planning.md +58 -4
  15. package/framework/agents/quality/qa-visual.md +4 -4
  16. package/framework/config.yaml +2 -2
  17. package/framework/constitution.md +12 -1
  18. package/framework/context/governance.md +2 -1
  19. package/framework/context/root.md +3 -1
  20. package/framework/domains/constitution.yaml +1 -1
  21. package/framework/domains/global.yaml +1 -1
  22. package/framework/domains/workflows/standard-flow.yaml +7 -8
  23. package/framework/hooks/advance-trigger.js +3 -2
  24. package/framework/hooks/brief-validator.js +1 -1
  25. package/framework/hooks/post-dev.js +1 -1
  26. package/framework/hooks/team-quality-gate.js +9 -2
  27. package/framework/i18n/en.yaml +1 -0
  28. package/framework/i18n/es.yaml +1 -0
  29. package/framework/i18n/fr.yaml +1 -0
  30. package/framework/i18n/pt.yaml +1 -0
  31. package/framework/manifest.json +83 -83
  32. package/framework/manifest.sig +1 -1
  33. package/framework/orchestrator/chati-router.js +14 -6
  34. package/framework/orchestrator/chati.md +61 -23
  35. package/framework/rules/human-writing-style.md +7 -0
  36. package/framework/schemas/session.schema.json +25 -2
  37. package/framework/schemas/task.schema.json +26 -0
  38. package/framework/tasks/orchestrator-handoff.md +10 -10
  39. package/framework/tasks/orchestrator-resume.md +3 -3
  40. package/framework/workflows/brownfield-fullstack.yaml +6 -3
  41. package/framework/workflows/brownfield-service.yaml +5 -3
  42. package/framework/workflows/brownfield-ui.yaml +5 -3
  43. package/framework/workflows/greenfield-fullstack.yaml +6 -3
  44. package/framework/workflows/standard-flow.yaml +8 -3
  45. package/node_modules/@chati/core/native/browser-proof-wire.h +29 -0
  46. package/node_modules/@chati/core/native/darwin-browser-listener.c +36 -0
  47. package/node_modules/@chati/core/native/darwin-browser-listener.h +32 -0
  48. package/node_modules/@chati/core/native/darwin-browser-proof.h +86 -0
  49. package/node_modules/@chati/core/native/darwin-code-identity.h +35 -0
  50. package/node_modules/@chati/core/native/darwin-launch-messages.h +37 -0
  51. package/node_modules/@chati/core/native/darwin-launcher.c +312 -0
  52. package/node_modules/@chati/core/native/descriptor-fs.c +407 -0
  53. package/node_modules/@chati/core/native/execution-admission.h +59 -0
  54. package/node_modules/@chati/core/native/execution-gate.c +41 -0
  55. package/node_modules/@chati/core/native/linux-process.c +41 -0
  56. package/node_modules/@chati/core/native/prebuilds/darwin-arm64/darwin-launcher +0 -0
  57. package/node_modules/@chati/core/native/prebuilds/darwin-arm64/descriptor-fs.node +0 -0
  58. package/node_modules/@chati/core/native/prebuilds/darwin-arm64/execution-gate +0 -0
  59. package/node_modules/@chati/core/native/prebuilds/linux-x64/descriptor-fs.node +0 -0
  60. package/node_modules/@chati/core/native/prebuilds/linux-x64/execution-gate +0 -0
  61. package/node_modules/@chati/core/native/process-info-pipe.c +91 -0
  62. package/node_modules/@chati/core/package.json +2 -1
  63. package/node_modules/@chati/core/scripts/build-native.js +36 -0
  64. package/node_modules/@chati/core/src/darwin-browser-proof.js +87 -0
  65. package/node_modules/@chati/core/src/darwin-host-boot.js +58 -0
  66. package/node_modules/@chati/core/src/darwin-process.js +79 -0
  67. package/node_modules/@chati/core/src/darwin-supervisor-channels.js +82 -0
  68. package/node_modules/@chati/core/src/darwin-terminal-receipt.js +111 -0
  69. package/node_modules/@chati/core/src/descriptor-fs.js +222 -0
  70. package/node_modules/@chati/core/src/execution-gate.js +8 -0
  71. package/node_modules/@chati/core/src/host-boot-marker.js +45 -0
  72. package/node_modules/@chati/core/src/index.js +18 -0
  73. package/node_modules/@chati/core/src/linux-host-boot.js +218 -0
  74. package/node_modules/@chati/core/src/linux-process.js +133 -0
  75. package/node_modules/@chati/planning/src/browser-verification-plan.js +71 -0
  76. package/node_modules/@chati/planning/src/index.js +33 -0
  77. package/node_modules/@chati/planning/src/planning-material.js +64 -0
  78. package/node_modules/@chati/planning/src/verification-plan.js +57 -0
  79. package/node_modules/@chati/rail/package.json +1 -0
  80. package/node_modules/@chati/rail/src/execution-restoration.js +92 -0
  81. package/node_modules/@chati/rail/src/index.js +812 -167
  82. package/node_modules/@chati/rail/src/provider-processing.js +118 -0
  83. package/node_modules/@chati/rail/src/terminal-containment.js +23 -0
  84. package/node_modules/@chati/release-lane/src/index.js +40 -43
  85. package/node_modules/@chati/review-council/src/index.js +223 -0
  86. package/node_modules/@chati/tracking-clickup/src/index.js +67 -58
  87. package/package-artifact-manifest.json +1 -1
  88. package/package-artifact-manifest.sig +1 -1
  89. package/package.json +3 -2
  90. package/scripts/verify-real-harness-e2e.js +20 -8
  91. package/src/installer/core.js +147 -107
  92. package/src/installer/package-artifact.js +7 -0
  93. package/src/installer/preflight.js +13 -0
  94. package/src/installer/provider-overlay.js +1 -2
  95. package/src/installer/templates.js +58 -0
  96. package/src/installer-v2/catalog-client.js +31 -1
  97. package/src/installer-v2/clickup-connection-storage.js +92 -0
  98. package/src/installer-v2/clickup-mcp-client.js +155 -0
  99. package/src/installer-v2/clickup-oauth-loopback.js +96 -0
  100. package/src/installer-v2/clickup-oauth.js +233 -0
  101. package/src/installer-v2/index.js +141 -15
  102. package/src/installer-v2/installation-authority.js +98 -4
  103. package/src/installer-v2/provider-executable.js +117 -12
  104. package/src/installer-v2/wizard-installation.js +11 -0
  105. package/src/orchestrator/agent-selector.js +39 -1
  106. package/src/orchestrator/browser-artifact-store.js +131 -0
  107. package/src/orchestrator/cli.js +762 -205
  108. package/src/orchestrator/clickup-mcp-observer.js +226 -0
  109. package/src/orchestrator/clickup-runtime.js +45 -24
  110. package/src/orchestrator/council-gate-runtime.js +903 -0
  111. package/src/orchestrator/evidence-authority-enrollment.js +75 -0
  112. package/src/orchestrator/evidence-authority-request.js +46 -0
  113. package/src/orchestrator/handoff-engine.js +4 -11
  114. package/src/orchestrator/pipeline-manager.js +42 -4
  115. package/src/orchestrator/planning-contract-revision.js +711 -0
  116. package/src/orchestrator/planning-reconciliation.js +302 -0
  117. package/src/orchestrator/planning-runtime.js +476 -29
  118. package/src/orchestrator/rail-adjudication-evidence.js +15 -24
  119. package/src/orchestrator/rail-human-effort-authority.js +46 -0
  120. package/src/orchestrator/rail-human-effort.js +149 -0
  121. package/src/orchestrator/rail-runtime.js +291 -47
  122. package/src/orchestrator/rail-verification.js +278 -0
  123. package/src/orchestrator/review-runtime.js +37 -2
  124. package/src/orchestrator/runtime-installation-v2.js +143 -24
  125. package/src/orchestrator/session-manager.js +1457 -29
  126. package/src/tasks/handoff.js +57 -33
  127. package/src/terminal/adapters/claude-adapter.js +3 -0
  128. package/src/terminal/adapters/codex-adapter.js +13 -0
  129. package/src/terminal/adapters/grok-adapter.js +1 -0
  130. package/src/terminal/agent-attempt-budget.js +35 -0
  131. package/src/terminal/browser-endpoint-proxy.js +174 -0
  132. package/src/terminal/browser-observer-driver.js +94 -0
  133. package/src/terminal/browser-observer-supervisor.js +73 -0
  134. package/src/terminal/browser-observer.js +95 -0
  135. package/src/terminal/browser-process-facts.js +75 -0
  136. package/src/terminal/browser-runtime.js +131 -0
  137. package/src/terminal/darwin-authority-policy.js +213 -0
  138. package/src/terminal/darwin-browser-application.js +116 -0
  139. package/src/terminal/darwin-browser-observer.js +57 -0
  140. package/src/terminal/darwin-execution.js +185 -0
  141. package/src/terminal/executable-snapshot.js +202 -0
  142. package/src/terminal/handoff-parser.js +48 -2
  143. package/src/terminal/isolation.js +14 -13
  144. package/src/terminal/prompt-builder.js +176 -15
  145. package/src/terminal/provider-preflight.js +47 -27
  146. package/src/terminal/rail-execution-worktree.js +22 -6
  147. package/src/terminal/rail-process-observer.js +10 -0
  148. package/src/terminal/rail-prompts.js +21 -3
  149. package/src/terminal/rail-readonly-workspace.js +54 -5
  150. package/src/terminal/rail-worktree-checkpoint.js +315 -0
  151. package/src/terminal/rail-worktree-restoration.js +485 -0
  152. package/src/terminal/run-agent.js +164 -42
  153. package/src/terminal/run-council-gate.js +338 -0
  154. package/src/terminal/run-planning-reconciliation.js +49 -0
  155. package/src/terminal/run-rail-adjudication.js +29 -7
  156. package/src/terminal/run-rail-recovery.js +97 -0
  157. package/src/terminal/run-rail-review.js +36 -15
  158. package/src/terminal/run-rail-rework.js +26 -15
  159. package/src/terminal/run-rail-task.js +22 -9
  160. package/src/terminal/runtime-native-helper.js +85 -0
  161. package/src/terminal/spawner.js +899 -142
  162. package/src/terminal/verification-process-facts.js +49 -0
  163. package/src/upgrade/entrypoints.js +68 -0
  164. package/src/upgrade/generation.js +194 -0
  165. package/src/upgrade/maintenance.js +79 -0
  166. package/src/upgrade/paths.js +81 -0
  167. package/src/upgrade/upgrade.js +267 -0
  168. package/src/utils/execution-role.js +15 -0
  169. package/src/utils/handoff-paths.js +59 -0
  170. package/src/wizard/i18n.js +1 -0
  171. package/src/wizard/index.js +17 -5
  172. package/src/wizard/questions.js +24 -12
@@ -1,13 +1,20 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { existsSync, readFileSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { sha256 } from '@chati/core';
5
- import { sealHandoff } from '@chati/planning';
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 { assertV2PlanningPredecessors, loadRuntimeInstallationV2 } from './runtime-installation-v2.js';
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 readJson(path, code) {
29
- if (!existsSync(path)) fail(code, `missing required planning artifact: ${path}`);
30
- try { return JSON.parse(readFileSync(path, 'utf8')); } catch (error) { return fail(code, `invalid JSON planning artifact: ${path}`, error); }
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 immutableFileRef(projectDir, relativePath, code) {
34
- if (typeof relativePath !== 'string' || relativePath.trim() === '') fail(code, 'planning source path is required');
35
- const path = join(projectDir, relativePath);
36
- if (!existsSync(path)) fail(code, `planning source file does not exist: ${relativePath}`);
37
- return `local-artifact://sha256/${sha256(readFileSync(path, 'utf8'))}`;
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 installation = loadRuntimeInstallationV2(projectDir, { clock });
88
- if (!installation) fail('V2_INSTALLATION_REQUIRED', 'planning-to-RAIL compilation requires a v2 installation');
89
- const { loaded, session } = loadSession(projectDir);
90
- if (!loaded || !session) fail('V2_SESSION_REQUIRED', 'planning-to-RAIL compilation requires a canonical project session');
91
- assertV2PlanningPredecessors(session, 'tasks');
92
- const inputPath = join(projectDir, PLANNING_V2_TASKS_PATH);
93
- const planning = readJson(inputPath, 'PLANNING_TASK_GRAPH_REQUIRED');
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(projectDir, sources.requirements_path, 'PLANNING_SOURCE_ARTIFACTS_REQUIRED'),
99
- architecture_ref: immutableFileRef(projectDir, sources.architecture_path, 'PLANNING_SOURCE_ARTIFACTS_REQUIRED'),
100
- qa_plan_ref: immutableFileRef(projectDir, sources.qa_plan_path, 'PLANNING_SOURCE_ARTIFACTS_REQUIRED'),
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(projectDir, sources.ux_path, 'PLANNING_SOURCE_ARTIFACTS_REQUIRED');
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: taskExecutionBinding(task, installation, clock),
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
- const commit = gitCommit(projectDir);
131
- const task_graph_ref = `local-artifact://sha256/${sha256(readFileSync(inputPath, 'utf8'))}`;
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
- return sealHandoff({
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
  }