chati-dev 4.5.27 → 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 +137 -20
- 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
package/src/orchestrator/cli.js
CHANGED
|
@@ -12,9 +12,24 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { resolveFrameworkDir } from '../utils/framework-dir.js';
|
|
15
|
+
import { DELEGATED_EXECUTION_BOUNDARY } from '../utils/execution-role.js';
|
|
16
|
+
import { findAgentHandoffPath } from '../utils/handoff-paths.js';
|
|
17
|
+
import {
|
|
18
|
+
rejectLegacyAgentResult, rejectAgentResult, revalidateQaAfterCouncil, pinHeldCouncilQaSubject, releaseAgentExecution, summarizePendingInteraction,
|
|
19
|
+
inspectPlanningCorrectionRollback, rollbackPlanningCorrection, getPendingPlanningRollbackTarget,
|
|
20
|
+
reservePlanningContractRevision, settlePlanningContractRevision, getPlanningContractRevisionOwner,
|
|
21
|
+
MAX_PLANNING_RECONCILIATION_TARGETS,
|
|
22
|
+
} from './session-manager.js';
|
|
23
|
+
import { withUpgradeMaintenance } from '../upgrade/maintenance.js';
|
|
24
|
+
import { executePlanningReconciliation } from '../terminal/run-planning-reconciliation.js';
|
|
25
|
+
import { persistPlanningContractRevision, planningContractRevisionCapability } from './planning-contract-revision.js';
|
|
15
26
|
import { parseProviderConfig } from '../utils/config-parser.js';
|
|
16
27
|
import { isFeatureEnabled } from '../utils/feature-flags.js';
|
|
17
28
|
import { runScriptTask, formatScriptHandoff } from '../executors/runner.js';
|
|
29
|
+
import {
|
|
30
|
+
directoryPathSync, duplicateDirectorySync, linkAtSync, lstatAtSync, mkdirAtSync,
|
|
31
|
+
openAtSync, readDirectorySync, renameAtSync, unlinkAtSync,
|
|
32
|
+
} from '@chati/core';
|
|
18
33
|
|
|
19
34
|
import {
|
|
20
35
|
loadSession, readSessionSafe, initSession, updateSession, transitionSessionLifecycle, rollbackSessionLifecycle,
|
|
@@ -47,10 +62,13 @@ import {
|
|
|
47
62
|
import { analyzeDeviationImpact, applyDeviation } from './index.js';
|
|
48
63
|
import { detectQuickFlow, detectStandardFlow } from './index.js';
|
|
49
64
|
import { getAgentFile } from '../terminal/prompt-builder.js';
|
|
65
|
+
import { getQaThreshold, assertQaCorrectionTarget } from './pipeline-manager.js';
|
|
66
|
+
import { isPlanningQaOutcomeValid } from '../terminal/handoff-parser.js';
|
|
67
|
+
import { getAgentTimeout } from '../terminal/agent-attempt-budget.js';
|
|
50
68
|
import {
|
|
51
69
|
readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync,
|
|
52
|
-
|
|
53
|
-
fstatSync, fchmodSync,
|
|
70
|
+
openSync, closeSync, constants,
|
|
71
|
+
fstatSync, fchmodSync, readSync, fsyncSync, writeSync,
|
|
54
72
|
} from 'fs';
|
|
55
73
|
import { basename, join, parse, resolve, dirname, relative, isAbsolute, sep } from 'path';
|
|
56
74
|
import { fileURLToPath } from 'url';
|
|
@@ -67,10 +85,12 @@ import { updateProjectContext } from '../memory/magic-docs.js';
|
|
|
67
85
|
import {
|
|
68
86
|
loadRuntimeInstallationV2, resolveRuntimeInvocationV2,
|
|
69
87
|
resolveRuntimeAdjudicationV2, describeRuntimeInstallationV2, V2_PLANNING_AGENTS,
|
|
70
|
-
assertV2PlanningPredecessors,
|
|
88
|
+
assertV2PlanningPredecessors, resolveRuntimeCouncilGatePolicyV2,
|
|
71
89
|
} from './runtime-installation-v2.js';
|
|
72
|
-
import { compilePlanningHandoff,
|
|
73
|
-
import { persistRailHandoff } from './rail-runtime.js';
|
|
90
|
+
import { compilePlanningHandoff, PLANNING_V2_TASKS_CANDIDATE_PATH } from './planning-runtime.js';
|
|
91
|
+
import { persistRailHandoff, persistRailHandoffCouncilBinding } from './rail-runtime.js';
|
|
92
|
+
import { assertCouncilGateApproved } from './council-gate-runtime.js';
|
|
93
|
+
import { executeCouncilGate } from '../terminal/run-council-gate.js';
|
|
74
94
|
import {
|
|
75
95
|
installationAuthorityProjectScope, signHostAuthorityPayload, verifyHostAuthorityPayload,
|
|
76
96
|
} from '../installer-v2/installation-authority.js';
|
|
@@ -118,8 +138,8 @@ function samePublishedRuntimeFile(left, right) {
|
|
|
118
138
|
}
|
|
119
139
|
|
|
120
140
|
function openRuntimeDirectoryNoFollow(runtimePath) {
|
|
121
|
-
if (process.platform
|
|
122
|
-
throw Object.assign(new Error(
|
|
141
|
+
if (!['linux', 'darwin'].includes(process.platform)) {
|
|
142
|
+
throw Object.assign(new Error(`Runtime request authority requires descriptor-relative operations; unsupported platform: ${process.platform}`), { code: 'UNSUPPORTED_RUNTIME_REQUEST_PLATFORM' });
|
|
123
143
|
}
|
|
124
144
|
const absolute = resolve(runtimePath);
|
|
125
145
|
const root = parse(absolute).root;
|
|
@@ -128,15 +148,15 @@ function openRuntimeDirectoryNoFollow(runtimePath) {
|
|
|
128
148
|
let fd;
|
|
129
149
|
try {
|
|
130
150
|
fd = anchor
|
|
131
|
-
?
|
|
151
|
+
? duplicateDirectorySync(anchor.descriptor)
|
|
132
152
|
: openSync(root, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
133
153
|
} finally {
|
|
134
154
|
if (anchor) closeSync(anchor.descriptor);
|
|
135
155
|
}
|
|
136
156
|
try {
|
|
137
157
|
for (const segment of segments) {
|
|
138
|
-
const child =
|
|
139
|
-
|
|
158
|
+
const child = openAtSync(
|
|
159
|
+
fd, segment,
|
|
140
160
|
constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0),
|
|
141
161
|
);
|
|
142
162
|
const stat = fstatSync(child);
|
|
@@ -147,7 +167,8 @@ function openRuntimeDirectoryNoFollow(runtimePath) {
|
|
|
147
167
|
closeSync(fd);
|
|
148
168
|
fd = child;
|
|
149
169
|
}
|
|
150
|
-
|
|
170
|
+
// reference is diagnostic only. Persistence uses fd plus explicit names.
|
|
171
|
+
return { fd, stat: fstatSync(fd), absolute, reference: directoryPathSync(fd) };
|
|
151
172
|
} catch (error) {
|
|
152
173
|
closeSync(fd);
|
|
153
174
|
if (['ELOOP', 'ENOTDIR'].includes(error?.code)) {
|
|
@@ -174,8 +195,8 @@ function assertRuntimeDirectoryRecord(record) {
|
|
|
174
195
|
}
|
|
175
196
|
|
|
176
197
|
function openScriptHandoffDirectoryNoFollow(directoryPath, { create = false } = {}) {
|
|
177
|
-
if (process.platform
|
|
178
|
-
throw Object.assign(new Error(
|
|
198
|
+
if (!['linux', 'darwin'].includes(process.platform)) {
|
|
199
|
+
throw Object.assign(new Error(`Script handoff persistence requires descriptor-relative operations; unsupported platform: ${process.platform}`), { code: 'UNSUPPORTED_SCRIPT_HANDOFF_PLATFORM' });
|
|
179
200
|
}
|
|
180
201
|
const absolute = resolve(directoryPath);
|
|
181
202
|
const root = parse(absolute).root;
|
|
@@ -184,22 +205,21 @@ function openScriptHandoffDirectoryNoFollow(directoryPath, { create = false } =
|
|
|
184
205
|
let fd;
|
|
185
206
|
try {
|
|
186
207
|
fd = anchor
|
|
187
|
-
?
|
|
208
|
+
? duplicateDirectorySync(anchor.descriptor)
|
|
188
209
|
: openSync(root, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
189
210
|
} finally {
|
|
190
211
|
if (anchor) closeSync(anchor.descriptor);
|
|
191
212
|
}
|
|
192
213
|
try {
|
|
193
214
|
for (const segment of segments) {
|
|
194
|
-
const entry = join(`/proc/self/fd/${fd}`, segment);
|
|
195
215
|
let child;
|
|
196
216
|
try {
|
|
197
|
-
child =
|
|
217
|
+
child = openAtSync(fd, segment, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
198
218
|
} catch (error) {
|
|
199
219
|
if (error?.code !== 'ENOENT' || !create) throw error;
|
|
200
|
-
try {
|
|
220
|
+
try { mkdirAtSync(fd, segment, { mode: 0o700 }); }
|
|
201
221
|
catch (mkdirError) { if (mkdirError?.code !== 'EEXIST') throw mkdirError; }
|
|
202
|
-
child =
|
|
222
|
+
child = openAtSync(fd, segment, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
203
223
|
}
|
|
204
224
|
const stat = fstatSync(child);
|
|
205
225
|
if (!stat.isDirectory()) {
|
|
@@ -209,7 +229,8 @@ function openScriptHandoffDirectoryNoFollow(directoryPath, { create = false } =
|
|
|
209
229
|
closeSync(fd);
|
|
210
230
|
fd = child;
|
|
211
231
|
}
|
|
212
|
-
|
|
232
|
+
// reference is diagnostic only. Persistence uses fd plus explicit names.
|
|
233
|
+
return { fd, stat: fstatSync(fd), absolute, reference: directoryPathSync(fd) };
|
|
213
234
|
} catch (error) {
|
|
214
235
|
closeSync(fd);
|
|
215
236
|
if (['ELOOP', 'ENOTDIR'].includes(error?.code)) {
|
|
@@ -242,56 +263,52 @@ export function shellQuote(value) {
|
|
|
242
263
|
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
243
264
|
}
|
|
244
265
|
|
|
245
|
-
function
|
|
266
|
+
function captureRuntimePath(projectDir, filename) {
|
|
246
267
|
if (!/^[A-Za-z0-9_-]+\.json$/.test(filename)) {
|
|
247
268
|
throw Object.assign(new Error('Runtime response filename is not path-safe'), { code: 'UNSAFE_RUNTIME_PATH' });
|
|
248
269
|
}
|
|
249
270
|
const projectPath = resolve(projectDir);
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const target = join(realRuntime, filename);
|
|
273
|
-
if (existsSync(target)) {
|
|
274
|
-
const targetStat = lstatSync(target);
|
|
275
|
-
if (targetStat.isSymbolicLink() || !targetStat.isFile()) {
|
|
271
|
+
const runtimePath = join(projectPath, '.chati', 'runtime');
|
|
272
|
+
const project = openRuntimeDirectoryNoFollow(projectPath);
|
|
273
|
+
let chatiFd;
|
|
274
|
+
let fd;
|
|
275
|
+
try {
|
|
276
|
+
chatiFd = openAtSync(project.fd, '.chati', constants.O_RDONLY | constants.O_DIRECTORY);
|
|
277
|
+
runRuntimeSecurityTestHook('beforeRuntimeDirectoryCreate', { projectPath, runtimePath });
|
|
278
|
+
try { fd = openAtSync(chatiFd, 'runtime', constants.O_RDONLY | constants.O_DIRECTORY); }
|
|
279
|
+
catch (error) {
|
|
280
|
+
if (error.code !== 'ENOENT') throw error;
|
|
281
|
+
try { mkdirAtSync(chatiFd, 'runtime', { mode: 0o700 }); }
|
|
282
|
+
catch (mkdirError) { if (mkdirError.code !== 'EEXIST') throw mkdirError; }
|
|
283
|
+
fd = openAtSync(chatiFd, 'runtime', constants.O_RDONLY | constants.O_DIRECTORY);
|
|
284
|
+
}
|
|
285
|
+
const runtimeIdentity = fstatSync(fd);
|
|
286
|
+
if (typeof process.getuid !== 'function' || runtimeIdentity.uid === process.getuid()) fchmodSync(fd, 0o700);
|
|
287
|
+
let existing;
|
|
288
|
+
try { existing = lstatAtSync(fd, filename); }
|
|
289
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
290
|
+
// An interrupted claim restore may temporarily have exactly two links.
|
|
291
|
+
// Recovery authenticates that pair before normal single-link checks run.
|
|
292
|
+
if (existing && (existing.isSymbolicLink() || !existing.isFile())) {
|
|
276
293
|
throw Object.assign(new Error('The runtime response target must be a regular file'), { code: 'UNSAFE_RUNTIME_PATH' });
|
|
277
294
|
}
|
|
295
|
+
const reference = directoryPathSync(fd);
|
|
296
|
+
const record = {
|
|
297
|
+
projectDir: projectPath, target: join(runtimePath, filename), targetName: filename,
|
|
298
|
+
targetEntry: join(reference, filename), runtimePath, runtimeIdentity, fd, reference,
|
|
299
|
+
};
|
|
300
|
+
assertRuntimeDirectoryRecord(record);
|
|
301
|
+
return record;
|
|
302
|
+
} catch (error) {
|
|
303
|
+
if (fd !== undefined) closeSync(fd);
|
|
304
|
+
if (['ELOOP', 'ENOTDIR'].includes(error.code)) {
|
|
305
|
+
throw Object.assign(new Error('Runtime storage must contain only real directories'), { code: 'UNSAFE_RUNTIME_PATH', cause: error });
|
|
306
|
+
}
|
|
307
|
+
throw error;
|
|
308
|
+
} finally {
|
|
309
|
+
if (chatiFd !== undefined) closeSync(chatiFd);
|
|
310
|
+
closeSync(project.fd);
|
|
278
311
|
}
|
|
279
|
-
return target;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function captureRuntimePath(projectDir, filename) {
|
|
283
|
-
const target = ensureRuntimeResponsePath(projectDir, filename);
|
|
284
|
-
const runtimePath = dirname(target);
|
|
285
|
-
const opened = openRuntimeDirectoryNoFollow(runtimePath);
|
|
286
|
-
return {
|
|
287
|
-
projectDir: resolve(projectDir),
|
|
288
|
-
target,
|
|
289
|
-
targetEntry: join(opened.reference, basename(target)),
|
|
290
|
-
runtimePath,
|
|
291
|
-
runtimeIdentity: opened.stat,
|
|
292
|
-
fd: opened.fd,
|
|
293
|
-
reference: opened.reference,
|
|
294
|
-
};
|
|
295
312
|
}
|
|
296
313
|
|
|
297
314
|
function closeRuntimeRecord(record) {
|
|
@@ -305,10 +322,10 @@ function runtimeProcessAlive(pid) {
|
|
|
305
322
|
try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
|
|
306
323
|
}
|
|
307
324
|
|
|
308
|
-
function finalizeRuntimeRequestRestore(runtimeRecord,
|
|
325
|
+
function finalizeRuntimeRequestRestore(runtimeRecord, claimName, canonicalName, expectedIdentity) {
|
|
309
326
|
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
310
|
-
const claimStat =
|
|
311
|
-
const canonicalStat =
|
|
327
|
+
const claimStat = lstatAtSync(runtimeRecord.fd, claimName, { bigint: true });
|
|
328
|
+
const canonicalStat = lstatAtSync(runtimeRecord.fd, canonicalName, { bigint: true });
|
|
312
329
|
if (claimStat.isSymbolicLink() || !claimStat.isFile()
|
|
313
330
|
|| canonicalStat.isSymbolicLink() || !canonicalStat.isFile()
|
|
314
331
|
|| !sameRuntimeIdentity(claimStat, expectedIdentity)
|
|
@@ -317,10 +334,10 @@ function finalizeRuntimeRequestRestore(runtimeRecord, claimEntry, canonicalEntry
|
|
|
317
334
|
|| claimStat.nlink !== 2n || canonicalStat.nlink !== 2n) {
|
|
318
335
|
throw Object.assign(new Error('Runtime request restoration links do not identify one safe file'), { code: 'RUNTIME_REQUEST_CLAIM_CONFLICT' });
|
|
319
336
|
}
|
|
320
|
-
|
|
337
|
+
unlinkAtSync(runtimeRecord.fd, claimName);
|
|
321
338
|
fsyncSync(runtimeRecord.fd);
|
|
322
339
|
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
323
|
-
const restored =
|
|
340
|
+
const restored = lstatAtSync(runtimeRecord.fd, canonicalName, { bigint: true });
|
|
324
341
|
if (restored.isSymbolicLink() || !restored.isFile()
|
|
325
342
|
|| !sameRuntimeIdentity(restored, expectedIdentity) || restored.nlink !== 1n) {
|
|
326
343
|
throw Object.assign(new Error('Restored runtime request did not become one canonical link'), { code: 'RUNTIME_REQUEST_CLAIM_CONFLICT' });
|
|
@@ -330,7 +347,7 @@ function finalizeRuntimeRequestRestore(runtimeRecord, claimEntry, canonicalEntry
|
|
|
330
347
|
|
|
331
348
|
function recoverInterruptedRuntimeClaim(runtimeRecord) {
|
|
332
349
|
const claimPattern = /^\.claimed-(\d+)-[A-Fa-f0-9-]{36}\.json$/;
|
|
333
|
-
const claims =
|
|
350
|
+
const claims = readDirectorySync(runtimeRecord.fd)
|
|
334
351
|
.map((name) => ({ name, match: name.match(claimPattern) }))
|
|
335
352
|
.filter((entry) => entry.match);
|
|
336
353
|
if (claims.length === 0) return false;
|
|
@@ -339,18 +356,18 @@ function recoverInterruptedRuntimeClaim(runtimeRecord) {
|
|
|
339
356
|
}
|
|
340
357
|
const [{ name, match }] = claims;
|
|
341
358
|
const claimEntry = join(runtimeRecord.reference, name);
|
|
342
|
-
const claimStat =
|
|
359
|
+
const claimStat = lstatAtSync(runtimeRecord.fd, name, { bigint: true });
|
|
343
360
|
if (claimStat.isSymbolicLink() || !claimStat.isFile()
|
|
344
361
|
|| ![1n, 2n].includes(claimStat.nlink)
|
|
345
362
|
|| claimStat.size > BigInt(RUNTIME_JSON_MAX_BYTES)) {
|
|
346
363
|
throw Object.assign(new Error('Interrupted runtime claim is not a safe request file'), { code: 'INVALID_RUNTIME_JSON' });
|
|
347
364
|
}
|
|
348
365
|
let canonical;
|
|
349
|
-
try { canonical =
|
|
366
|
+
try { canonical = lstatAtSync(runtimeRecord.fd, runtimeRecord.targetName, { bigint: true }); }
|
|
350
367
|
catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
351
368
|
if (canonical) {
|
|
352
369
|
if (sameRuntimeIdentity(canonical, claimStat)) {
|
|
353
|
-
return finalizeRuntimeRequestRestore(runtimeRecord,
|
|
370
|
+
return finalizeRuntimeRequestRestore(runtimeRecord, name, runtimeRecord.targetName, claimStat);
|
|
354
371
|
}
|
|
355
372
|
}
|
|
356
373
|
if (runtimeProcessAlive(Number(match[1]))) {
|
|
@@ -362,17 +379,18 @@ function recoverInterruptedRuntimeClaim(runtimeRecord) {
|
|
|
362
379
|
if (claimStat.nlink !== 1n) {
|
|
363
380
|
throw Object.assign(new Error('Interrupted runtime claim is not a safe request file'), { code: 'INVALID_RUNTIME_JSON' });
|
|
364
381
|
}
|
|
365
|
-
|
|
382
|
+
linkAtSync(runtimeRecord.fd, name, runtimeRecord.fd, runtimeRecord.targetName);
|
|
366
383
|
runRuntimeSecurityTestHook('afterRuntimeClaimRestoreLink', { claimPath: claimEntry, canonicalPath: runtimeRecord.targetEntry });
|
|
367
|
-
return finalizeRuntimeRequestRestore(runtimeRecord,
|
|
384
|
+
return finalizeRuntimeRequestRestore(runtimeRecord, name, runtimeRecord.targetName, claimStat);
|
|
368
385
|
}
|
|
369
386
|
|
|
370
387
|
function writePrivateRuntimeJson(runtimeRecord, payload, { preserveExisting = false } = {}) {
|
|
371
388
|
if (runtimeRecord?.fd === undefined || runtimeRecord?.fd === null
|
|
372
|
-
|| !runtimeRecord?.
|
|
389
|
+
|| !runtimeRecord?.targetName) {
|
|
373
390
|
throw Object.assign(new Error('Runtime response persistence requires a pinned runtime directory'), { code: 'UNSAFE_RUNTIME_PATH' });
|
|
374
391
|
}
|
|
375
|
-
const
|
|
392
|
+
const temporaryName = `.pending-${process.pid}-${randomUUID()}.tmp`;
|
|
393
|
+
const temporary = join(runtimeRecord.reference, temporaryName);
|
|
376
394
|
let descriptor;
|
|
377
395
|
let written;
|
|
378
396
|
let publicationAttempted = false;
|
|
@@ -387,7 +405,7 @@ function writePrivateRuntimeJson(runtimeRecord, payload, { preserveExisting = fa
|
|
|
387
405
|
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
388
406
|
|
|
389
407
|
let existing;
|
|
390
|
-
try { existing =
|
|
408
|
+
try { existing = lstatAtSync(runtimeRecord.fd, runtimeRecord.targetName, { bigint: true }); }
|
|
391
409
|
catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
392
410
|
if (existing) {
|
|
393
411
|
if (existing.isSymbolicLink() || !existing.isFile() || existing.nlink !== 1n) {
|
|
@@ -396,8 +414,8 @@ function writePrivateRuntimeJson(runtimeRecord, payload, { preserveExisting = fa
|
|
|
396
414
|
if (preserveExisting) return;
|
|
397
415
|
}
|
|
398
416
|
|
|
399
|
-
descriptor =
|
|
400
|
-
|
|
417
|
+
descriptor = openAtSync(
|
|
418
|
+
runtimeRecord.fd, temporaryName,
|
|
401
419
|
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0),
|
|
402
420
|
0o600,
|
|
403
421
|
);
|
|
@@ -422,8 +440,8 @@ function writePrivateRuntimeJson(runtimeRecord, payload, { preserveExisting = fa
|
|
|
422
440
|
throw Object.assign(new Error('The temporary runtime response changed before publication'), { code: 'UNSAFE_RUNTIME_PATH' });
|
|
423
441
|
}
|
|
424
442
|
publicationAttempted = true;
|
|
425
|
-
|
|
426
|
-
const committed =
|
|
443
|
+
renameAtSync(runtimeRecord.fd, temporaryName, runtimeRecord.fd, runtimeRecord.targetName);
|
|
444
|
+
const committed = lstatAtSync(runtimeRecord.fd, runtimeRecord.targetName, { bigint: true });
|
|
427
445
|
if (committed.isSymbolicLink() || !committed.isFile() || !samePublishedRuntimeFile(committed, written)
|
|
428
446
|
|| !samePublishedRuntimeFile(fstatSync(descriptor, { bigint: true }), written)) {
|
|
429
447
|
throw Object.assign(new Error('The published runtime response differs from its pinned temporary file'), { code: 'UNSAFE_RUNTIME_PATH' });
|
|
@@ -433,11 +451,11 @@ function writePrivateRuntimeJson(runtimeRecord, payload, { preserveExisting = fa
|
|
|
433
451
|
published = true;
|
|
434
452
|
} finally {
|
|
435
453
|
if (descriptor !== undefined) closeSync(descriptor);
|
|
436
|
-
try {
|
|
454
|
+
try { unlinkAtSync(runtimeRecord.fd, temporaryName); } catch { /* preserve primary response result */ }
|
|
437
455
|
if (publicationAttempted && !published && written) {
|
|
438
456
|
try {
|
|
439
|
-
const current =
|
|
440
|
-
if (current.isFile() && !current.isSymbolicLink() && !sameRuntimeIdentity(current, written))
|
|
457
|
+
const current = lstatAtSync(runtimeRecord.fd, runtimeRecord.targetName, { bigint: true });
|
|
458
|
+
if (current.isFile() && !current.isSymbolicLink() && !sameRuntimeIdentity(current, written)) unlinkAtSync(runtimeRecord.fd, runtimeRecord.targetName);
|
|
441
459
|
} catch { /* preserve primary response result */ }
|
|
442
460
|
}
|
|
443
461
|
}
|
|
@@ -483,8 +501,8 @@ function claimRuntimeRequest(runtimeRecord) {
|
|
|
483
501
|
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
484
502
|
recoverInterruptedRuntimeClaim(runtimeRecord);
|
|
485
503
|
runRuntimeSecurityTestHook('beforeRuntimeRequestOpen', { canonicalPath, directory: runtimeRecord.runtimePath });
|
|
486
|
-
claim.fd =
|
|
487
|
-
|
|
504
|
+
claim.fd = openAtSync(
|
|
505
|
+
runtimeRecord.fd, runtimeRecord.targetName,
|
|
488
506
|
constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0),
|
|
489
507
|
);
|
|
490
508
|
claim.identity = fstatSync(claim.fd, { bigint: true });
|
|
@@ -494,9 +512,9 @@ function claimRuntimeRequest(runtimeRecord) {
|
|
|
494
512
|
}
|
|
495
513
|
runRuntimeSecurityTestHook('beforeRuntimeClaimRename', { canonicalPath, claimPath: claim.claimPath });
|
|
496
514
|
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
497
|
-
|
|
515
|
+
renameAtSync(runtimeRecord.fd, runtimeRecord.targetName, runtimeRecord.fd, claimName);
|
|
498
516
|
fsyncSync(runtimeRecord.fd);
|
|
499
|
-
const claimedPathStat =
|
|
517
|
+
const claimedPathStat = lstatAtSync(runtimeRecord.fd, claimName, { bigint: true });
|
|
500
518
|
if (claimedPathStat.isSymbolicLink() || !claimedPathStat.isFile()
|
|
501
519
|
|| !sameRuntimeIdentity(claimedPathStat, claim.identity)) {
|
|
502
520
|
throw Object.assign(new Error('Runtime JSON changed while it was being claimed'), { code: 'INVALID_RUNTIME_JSON' });
|
|
@@ -518,26 +536,26 @@ function claimRuntimeRequest(runtimeRecord) {
|
|
|
518
536
|
function restoreRuntimeRequest(claim) {
|
|
519
537
|
if (!claim?.claimPath || !claim.identity) return false;
|
|
520
538
|
let current;
|
|
521
|
-
try { current =
|
|
539
|
+
try { current = lstatAtSync(claim.runtimeRecord.fd, claim.claimName, { bigint: true }); }
|
|
522
540
|
catch (error) { if (error?.code === 'ENOENT') return false; throw error; }
|
|
523
541
|
if (current.isSymbolicLink() || !current.isFile() || !sameRuntimeIdentity(current, claim.identity)) return false;
|
|
524
542
|
try {
|
|
525
|
-
|
|
543
|
+
linkAtSync(claim.runtimeRecord.fd, claim.claimName, claim.runtimeRecord.fd, claim.runtimeRecord.targetName);
|
|
526
544
|
} catch (error) {
|
|
527
545
|
if (error?.code !== 'EEXIST') throw error;
|
|
528
|
-
const canonical =
|
|
546
|
+
const canonical = lstatAtSync(claim.runtimeRecord.fd, claim.runtimeRecord.targetName, { bigint: true });
|
|
529
547
|
if (!sameRuntimeIdentity(canonical, current)) return false;
|
|
530
|
-
return finalizeRuntimeRequestRestore(claim.runtimeRecord, claim.
|
|
548
|
+
return finalizeRuntimeRequestRestore(claim.runtimeRecord, claim.claimName, claim.runtimeRecord.targetName, claim.identity);
|
|
531
549
|
}
|
|
532
550
|
runRuntimeSecurityTestHook('afterRuntimeRequestRestoreLink', { claimPath: claim.claimPath, canonicalPath: claim.canonicalEntry });
|
|
533
|
-
return finalizeRuntimeRequestRestore(claim.runtimeRecord, claim.
|
|
551
|
+
return finalizeRuntimeRequestRestore(claim.runtimeRecord, claim.claimName, claim.runtimeRecord.targetName, claim.identity);
|
|
534
552
|
}
|
|
535
553
|
|
|
536
554
|
function consumeRuntimeRequest(claim) {
|
|
537
555
|
if (!claim?.claimPath || !claim.identity) return true;
|
|
538
|
-
const current =
|
|
556
|
+
const current = lstatAtSync(claim.runtimeRecord.fd, claim.claimName, { bigint: true });
|
|
539
557
|
if (current.isSymbolicLink() || !current.isFile() || !sameRuntimeIdentity(current, claim.identity)) return false;
|
|
540
|
-
|
|
558
|
+
unlinkAtSync(claim.runtimeRecord.fd, claim.claimName);
|
|
541
559
|
fsyncSync(claim.runtimeRecord.fd);
|
|
542
560
|
assertRuntimeDirectoryRecord(claim.runtimeRecord);
|
|
543
561
|
return true;
|
|
@@ -557,7 +575,7 @@ function scriptExecutionReceiptDigest(receipt) {
|
|
|
557
575
|
|
|
558
576
|
function persistExecutedRuntimeRequest(runtimeRecord, claim, request, result, executedAt) {
|
|
559
577
|
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
560
|
-
const current =
|
|
578
|
+
const current = lstatAtSync(runtimeRecord.fd, claim.claimName, { bigint: true });
|
|
561
579
|
if (current.isSymbolicLink() || !current.isFile() || !sameRuntimeIdentity(current, claim.identity)) {
|
|
562
580
|
throw Object.assign(new Error('The claimed request changed before its execution receipt was persisted'), { code: 'INVALID_RUNTIME_JSON' });
|
|
563
581
|
}
|
|
@@ -579,8 +597,9 @@ function persistExecutedRuntimeRequest(runtimeRecord, claim, request, result, ex
|
|
|
579
597
|
authority_id: proof.authority_id,
|
|
580
598
|
signature: proof.signature,
|
|
581
599
|
};
|
|
582
|
-
const
|
|
583
|
-
const
|
|
600
|
+
const temporaryName = `.executed-${process.pid}-${randomUUID()}.tmp`;
|
|
601
|
+
const temporary = join(runtimeRecord.reference, temporaryName);
|
|
602
|
+
const backupName = `.receipt-backup-${process.pid}-${randomUUID()}.tmp`;
|
|
584
603
|
const originalClaimIdentity = claim.identity;
|
|
585
604
|
let temporaryDescriptor;
|
|
586
605
|
let written;
|
|
@@ -589,7 +608,7 @@ function persistExecutedRuntimeRequest(runtimeRecord, claim, request, result, ex
|
|
|
589
608
|
let backupCreated = false;
|
|
590
609
|
try {
|
|
591
610
|
const bytes = Buffer.from(JSON.stringify(receipt));
|
|
592
|
-
temporaryDescriptor =
|
|
611
|
+
temporaryDescriptor = openAtSync(runtimeRecord.fd, temporaryName, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
593
612
|
let offset = 0;
|
|
594
613
|
while (offset < bytes.length) offset += writeSync(temporaryDescriptor, bytes, offset, bytes.length - offset);
|
|
595
614
|
fsyncSync(temporaryDescriptor);
|
|
@@ -599,7 +618,7 @@ function persistExecutedRuntimeRequest(runtimeRecord, claim, request, result, ex
|
|
|
599
618
|
throw Object.assign(new Error('The temporary execution receipt is not a private regular file'), { code: 'INVALID_RUNTIME_JSON' });
|
|
600
619
|
}
|
|
601
620
|
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
602
|
-
const beforeRename =
|
|
621
|
+
const beforeRename = lstatAtSync(runtimeRecord.fd, claim.claimName, { bigint: true });
|
|
603
622
|
if (beforeRename.isSymbolicLink() || !beforeRename.isFile() || !sameRuntimeIdentity(beforeRename, claim.identity)) {
|
|
604
623
|
throw Object.assign(new Error('The claimed request changed before its execution receipt was committed'), { code: 'INVALID_RUNTIME_JSON' });
|
|
605
624
|
}
|
|
@@ -608,43 +627,43 @@ function persistExecutedRuntimeRequest(runtimeRecord, claim, request, result, ex
|
|
|
608
627
|
if (!sameRuntimeFileSnapshot(fstatSync(temporaryDescriptor, { bigint: true }), written)) {
|
|
609
628
|
throw Object.assign(new Error('The temporary execution receipt changed before publication'), { code: 'INVALID_RUNTIME_JSON' });
|
|
610
629
|
}
|
|
611
|
-
|
|
630
|
+
linkAtSync(runtimeRecord.fd, claim.claimName, runtimeRecord.fd, backupName);
|
|
612
631
|
backupCreated = true;
|
|
613
|
-
const backup =
|
|
632
|
+
const backup = lstatAtSync(runtimeRecord.fd, backupName, { bigint: true });
|
|
614
633
|
if (backup.isSymbolicLink() || !backup.isFile() || !sameRuntimeIdentity(backup, originalClaimIdentity)
|
|
615
634
|
|| backup.nlink !== 2n) {
|
|
616
635
|
throw Object.assign(new Error('The original request could not be protected during receipt publication'), { code: 'INVALID_RUNTIME_JSON' });
|
|
617
636
|
}
|
|
618
637
|
publicationAttempted = true;
|
|
619
|
-
|
|
638
|
+
renameAtSync(runtimeRecord.fd, temporaryName, runtimeRecord.fd, claim.claimName);
|
|
620
639
|
fsyncSync(runtimeRecord.fd);
|
|
621
|
-
claim.identity =
|
|
640
|
+
claim.identity = lstatAtSync(runtimeRecord.fd, claim.claimName, { bigint: true });
|
|
622
641
|
if (!claim.identity.isFile() || claim.identity.isSymbolicLink?.() || !samePublishedRuntimeFile(claim.identity, written)
|
|
623
642
|
|| !samePublishedRuntimeFile(fstatSync(temporaryDescriptor, { bigint: true }), written)) {
|
|
624
643
|
throw Object.assign(new Error('The execution receipt is not a private regular file'), { code: 'INVALID_RUNTIME_JSON' });
|
|
625
644
|
}
|
|
626
645
|
assertRuntimeDirectoryRecord(runtimeRecord);
|
|
627
|
-
|
|
646
|
+
unlinkAtSync(runtimeRecord.fd, backupName);
|
|
628
647
|
backupCreated = false;
|
|
629
648
|
fsyncSync(runtimeRecord.fd);
|
|
630
649
|
published = true;
|
|
631
650
|
return receipt;
|
|
632
651
|
} finally {
|
|
633
652
|
if (temporaryDescriptor !== undefined) closeSync(temporaryDescriptor);
|
|
634
|
-
try {
|
|
653
|
+
try { unlinkAtSync(runtimeRecord.fd, temporaryName); } catch { /* preserve primary receipt result */ }
|
|
635
654
|
if (publicationAttempted && !published && backupCreated) {
|
|
636
655
|
try {
|
|
637
|
-
const backup =
|
|
656
|
+
const backup = lstatAtSync(runtimeRecord.fd, backupName, { bigint: true });
|
|
638
657
|
if (!backup.isSymbolicLink() && backup.isFile() && sameRuntimeIdentity(backup, originalClaimIdentity)) {
|
|
639
|
-
|
|
640
|
-
claim.identity =
|
|
658
|
+
renameAtSync(runtimeRecord.fd, backupName, runtimeRecord.fd, claim.claimName);
|
|
659
|
+
claim.identity = lstatAtSync(runtimeRecord.fd, claim.claimName, { bigint: true });
|
|
641
660
|
fsyncSync(runtimeRecord.fd);
|
|
642
661
|
backupCreated = false;
|
|
643
662
|
}
|
|
644
663
|
} catch { /* preserve primary receipt result */ }
|
|
645
664
|
}
|
|
646
665
|
if (backupCreated) {
|
|
647
|
-
try {
|
|
666
|
+
try { unlinkAtSync(runtimeRecord.fd, backupName); } catch { /* preserve primary receipt result */ }
|
|
648
667
|
}
|
|
649
668
|
}
|
|
650
669
|
}
|
|
@@ -657,8 +676,8 @@ function writeDurableScriptHandoff(path, content) {
|
|
|
657
676
|
closeSync(directory.fd);
|
|
658
677
|
throw Object.assign(new Error('The script handoff target is not canonical'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
|
|
659
678
|
}
|
|
660
|
-
const
|
|
661
|
-
const
|
|
679
|
+
const temporaryName = `.${name}.${process.pid}.${randomUUID()}.tmp`;
|
|
680
|
+
const temporary = join(directory.reference, temporaryName);
|
|
662
681
|
let descriptor;
|
|
663
682
|
let written;
|
|
664
683
|
let publicationAttempted = false;
|
|
@@ -667,7 +686,7 @@ function writeDurableScriptHandoff(path, content) {
|
|
|
667
686
|
assertScriptHandoffDirectoryRecord(directory);
|
|
668
687
|
runRuntimeSecurityTestHook('beforeScriptHandoffTempOpen', { handoffPath: path, temporary, directory: directory.absolute });
|
|
669
688
|
assertScriptHandoffDirectoryRecord(directory);
|
|
670
|
-
descriptor =
|
|
689
|
+
descriptor = openAtSync(directory.fd, temporaryName, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
671
690
|
const bytes = Buffer.from(content);
|
|
672
691
|
let offset = 0;
|
|
673
692
|
while (offset < bytes.length) offset += writeSync(descriptor, bytes, offset, bytes.length - offset);
|
|
@@ -680,7 +699,7 @@ function writeDurableScriptHandoff(path, content) {
|
|
|
680
699
|
runRuntimeSecurityTestHook('beforeScriptHandoffCommit', { handoffPath: path, temporary, directory: directory.absolute });
|
|
681
700
|
assertScriptHandoffDirectoryRecord(directory);
|
|
682
701
|
let existing;
|
|
683
|
-
try { existing =
|
|
702
|
+
try { existing = lstatAtSync(directory.fd, name, { bigint: true }); }
|
|
684
703
|
catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
685
704
|
if (existing && (existing.isSymbolicLink() || !existing.isFile() || existing.nlink !== 1n)) {
|
|
686
705
|
throw Object.assign(new Error('The script handoff destination must be a regular single-link file'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
|
|
@@ -689,8 +708,8 @@ function writeDurableScriptHandoff(path, content) {
|
|
|
689
708
|
throw Object.assign(new Error('The temporary script handoff changed before publication'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
|
|
690
709
|
}
|
|
691
710
|
publicationAttempted = true;
|
|
692
|
-
|
|
693
|
-
const committed =
|
|
711
|
+
renameAtSync(directory.fd, temporaryName, directory.fd, name);
|
|
712
|
+
const committed = lstatAtSync(directory.fd, name, { bigint: true });
|
|
694
713
|
if (committed.isSymbolicLink() || !committed.isFile() || !samePublishedRuntimeFile(committed, written)
|
|
695
714
|
|| !samePublishedRuntimeFile(fstatSync(descriptor, { bigint: true }), written)) {
|
|
696
715
|
throw Object.assign(new Error('The published script handoff differs from its pinned temporary file'), { code: 'UNSAFE_SCRIPT_HANDOFF_PATH' });
|
|
@@ -700,11 +719,11 @@ function writeDurableScriptHandoff(path, content) {
|
|
|
700
719
|
published = true;
|
|
701
720
|
} finally {
|
|
702
721
|
if (descriptor !== undefined) closeSync(descriptor);
|
|
703
|
-
try {
|
|
722
|
+
try { unlinkAtSync(directory.fd, temporaryName); } catch { /* preserve primary handoff result */ }
|
|
704
723
|
if (publicationAttempted && !published && written) {
|
|
705
724
|
try {
|
|
706
|
-
const current =
|
|
707
|
-
if (current.isFile() && !current.isSymbolicLink() && !sameRuntimeIdentity(current, written))
|
|
725
|
+
const current = lstatAtSync(directory.fd, name, { bigint: true });
|
|
726
|
+
if (current.isFile() && !current.isSymbolicLink() && !sameRuntimeIdentity(current, written)) unlinkAtSync(directory.fd, name);
|
|
708
727
|
} catch { /* preserve primary handoff result */ }
|
|
709
728
|
}
|
|
710
729
|
closeSync(directory.fd);
|
|
@@ -1002,6 +1021,55 @@ export function readBriefAndRefCorpus(projectDir) {
|
|
|
1002
1021
|
return corpus;
|
|
1003
1022
|
}
|
|
1004
1023
|
|
|
1024
|
+
/** Closed, canonical Brief declaration only. Prose/UX self-assessment is not authority. */
|
|
1025
|
+
export function hasExplicitNonVisualBrief(content) {
|
|
1026
|
+
if (typeof content !== 'string' || detectPremiumRefs(content).detected) return false;
|
|
1027
|
+
const source = content.replace(/\r\n/g, '\n');
|
|
1028
|
+
const sections = [...source.matchAll(/^## ([^\n]+)\n([\s\S]*?)(?=^## |$(?![\s\S]))/gm)];
|
|
1029
|
+
function declaration(title, key) {
|
|
1030
|
+
const matches = sections.filter(section => section[1].trim() === title);
|
|
1031
|
+
// A second declaration anywhere in the document is ambiguous, even when
|
|
1032
|
+
// a parser could otherwise select the preferred canonical section.
|
|
1033
|
+
const keyPattern = new RegExp(`^[ \\t]*(?:${key}|"${key}"|'${key}')[ \\t]*:`, 'gm');
|
|
1034
|
+
if (matches.length !== 1 || [...source.matchAll(keyPattern)].length !== 1) return null;
|
|
1035
|
+
const block = matches[0][2].trim().match(/^```ya?ml\n([\s\S]*?)\n```$/);
|
|
1036
|
+
if (!block || block[1].includes('```')) return null;
|
|
1037
|
+
try {
|
|
1038
|
+
const value = yaml.load(block[1], { schema: yaml.JSON_SCHEMA });
|
|
1039
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
1040
|
+
} catch { return null; }
|
|
1041
|
+
}
|
|
1042
|
+
const quality = declaration('Visual Quality', 'visual_quality');
|
|
1043
|
+
const references = declaration('Visual References', 'visual_references');
|
|
1044
|
+
return Boolean(quality && references
|
|
1045
|
+
&& Object.keys(quality).length === 1 && quality.visual_quality === 'not_applicable'
|
|
1046
|
+
&& Object.keys(references).every(key => key === 'visual_references' || key === 'reference_synthesis')
|
|
1047
|
+
&& (!Object.hasOwn(references, 'reference_synthesis') || typeof references.reference_synthesis === 'string')
|
|
1048
|
+
&& Array.isArray(references.visual_references) && references.visual_references.length === 0);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function resolveNonVisualUxApplicability(projectDir, artifact) {
|
|
1052
|
+
if (!artifact) return null;
|
|
1053
|
+
const council = resolveRuntimeCouncilGatePolicyV2({ artifact, stage: 'discovery' });
|
|
1054
|
+
if (!council.enabled) return null;
|
|
1055
|
+
try {
|
|
1056
|
+
// No new council inference here. Missing/rejected/stale/wrong-project or
|
|
1057
|
+
// malformed approval retains the existing mandatory visual path.
|
|
1058
|
+
const approved = assertCouncilGateApproved({ projectDir, artifact, stage: 'discovery', policy: council.policy });
|
|
1059
|
+
const brief = approved.material.find(item => item.path === 'artifacts/1-Brief/brief-report.md');
|
|
1060
|
+
if (!brief || !hasExplicitNonVisualBrief(brief.content)) return null;
|
|
1061
|
+
// A downstream visual reference analysis conflicts with an empty reference
|
|
1062
|
+
// declaration. It cannot grant an exemption or silently be ignored, even
|
|
1063
|
+
// if an animation inventory already exists.
|
|
1064
|
+
try {
|
|
1065
|
+
if (readFileSync(join(projectDir, 'artifacts/4-UX/reference-analysis.md'), 'utf8').trim()) return null;
|
|
1066
|
+
} catch (error) { if (error.code !== 'ENOENT') return null; }
|
|
1067
|
+
return Object.freeze({
|
|
1068
|
+
status: 'not_applicable', source: brief.path, sha256: brief.sha256, discovery_gate_id: approved.plan.gate_id,
|
|
1069
|
+
});
|
|
1070
|
+
} catch { return null; }
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1005
1073
|
// ---------------------------------------------------------------------------
|
|
1006
1074
|
// State-at-a-glance card (Fase 8)
|
|
1007
1075
|
//
|
|
@@ -1200,14 +1268,142 @@ function parseArgs(argv) {
|
|
|
1200
1268
|
return args;
|
|
1201
1269
|
}
|
|
1202
1270
|
|
|
1203
|
-
function
|
|
1271
|
+
function writeOutput(data) {
|
|
1204
1272
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
1205
1273
|
}
|
|
1206
1274
|
|
|
1275
|
+
/** True when every automatic Planning recovery route is consumed without a QA
|
|
1276
|
+
* authorization: all reconciliation targets settled as denied, none pending.
|
|
1277
|
+
* Exported for tests; the advance path reports this state as terminal. */
|
|
1278
|
+
export function planningRecoveryBudgetExhausted(session, agent = 'qa-planning') {
|
|
1279
|
+
const reconciliations = session?.agents?.[agent]?.planning_reconciliations;
|
|
1280
|
+
if (!Array.isArray(reconciliations) || reconciliations.length < MAX_PLANNING_RECONCILIATION_TARGETS) return false;
|
|
1281
|
+
if (reconciliations.some(entry => entry?.authorization)) return false;
|
|
1282
|
+
return !reconciliations.some(entry => ['reserved', 'running', 'outcome_unknown'].includes(entry?.status));
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1207
1285
|
function errorResult(message, code) {
|
|
1208
1286
|
return { action: 'error', error: message, code: code || 'UNKNOWN' };
|
|
1209
1287
|
}
|
|
1210
1288
|
|
|
1289
|
+
const COUNCIL_STAGE_BY_COMPLETING_AGENT = Object.freeze({
|
|
1290
|
+
brief: 'discovery',
|
|
1291
|
+
'qa-planning': 'planning',
|
|
1292
|
+
tasks: 'execution-design',
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
function councilGateSessionRecord(result) {
|
|
1296
|
+
return {
|
|
1297
|
+
gate_id: result.gate_id,
|
|
1298
|
+
stage: result.stage,
|
|
1299
|
+
verdict: result.decision.verdict,
|
|
1300
|
+
decision_path: result.decision_path,
|
|
1301
|
+
member_count: result.member_count,
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Applies the automatic quality council exactly once per content-addressed
|
|
1307
|
+
* artifact bundle. A prior rework decision remains blocking until planning
|
|
1308
|
+
* changes, so retries cannot consume providers on unchanged evidence.
|
|
1309
|
+
*/
|
|
1310
|
+
export async function enforceRuntimeCouncilGate({
|
|
1311
|
+
projectDir,
|
|
1312
|
+
agent,
|
|
1313
|
+
session,
|
|
1314
|
+
artifact,
|
|
1315
|
+
handoff,
|
|
1316
|
+
executeCouncil = executeCouncilGate,
|
|
1317
|
+
} = {}) {
|
|
1318
|
+
const stage = COUNCIL_STAGE_BY_COMPLETING_AGENT[agent];
|
|
1319
|
+
if (!stage) return Object.freeze({ required: false, reason: 'NO_COUNCIL_STAGE_FOR_AGENT' });
|
|
1320
|
+
if (agent === 'qa-planning') {
|
|
1321
|
+
const subject = pinHeldCouncilQaSubject(projectDir, session);
|
|
1322
|
+
try { subject?.assertCurrent(); } finally { subject?.close(); }
|
|
1323
|
+
}
|
|
1324
|
+
const configured = resolveRuntimeCouncilGatePolicyV2({ artifact, stage });
|
|
1325
|
+
if (!configured.enabled) {
|
|
1326
|
+
if (configured.reason === 'GUIDED_COUNCIL_REQUIRED') {
|
|
1327
|
+
return Object.freeze({
|
|
1328
|
+
required: true,
|
|
1329
|
+
approved: false,
|
|
1330
|
+
blocked: true,
|
|
1331
|
+
stage,
|
|
1332
|
+
code: 'GUIDED_COUNCIL_REQUIRED',
|
|
1333
|
+
message: 'this installation selected fewer than two eligible providers, so the automatic council cannot be bypassed. Reconfigure the installation with two selected review providers before advancing.',
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
return Object.freeze({ required: false, reason: configured.reason });
|
|
1337
|
+
}
|
|
1338
|
+
const options = {
|
|
1339
|
+
projectDir,
|
|
1340
|
+
stage,
|
|
1341
|
+
artifact,
|
|
1342
|
+
policy: configured.policy,
|
|
1343
|
+
language: session?.language || 'en',
|
|
1344
|
+
...(handoff === undefined ? {} : { handoff }),
|
|
1345
|
+
};
|
|
1346
|
+
try {
|
|
1347
|
+
const approved = assertCouncilGateApproved(options);
|
|
1348
|
+
const result = Object.freeze({
|
|
1349
|
+
ok: true,
|
|
1350
|
+
stage,
|
|
1351
|
+
gate_id: approved.plan.gate_id,
|
|
1352
|
+
member_count: approved.plan.selection.member_count,
|
|
1353
|
+
decision: approved.decision,
|
|
1354
|
+
decision_path: approved.path,
|
|
1355
|
+
reused: true,
|
|
1356
|
+
});
|
|
1357
|
+
await updateSession(projectDir, {
|
|
1358
|
+
council_gates: { ...(session?.council_gates || {}), [stage]: councilGateSessionRecord(result) },
|
|
1359
|
+
});
|
|
1360
|
+
return Object.freeze({ required: true, approved: true, result });
|
|
1361
|
+
} catch (error) {
|
|
1362
|
+
if (error?.code === 'COUNCIL_GATE_REWORK_REQUIRED') {
|
|
1363
|
+
return Object.freeze({
|
|
1364
|
+
required: true,
|
|
1365
|
+
stage,
|
|
1366
|
+
approved: false,
|
|
1367
|
+
blocked: true,
|
|
1368
|
+
code: error.code,
|
|
1369
|
+
message: error.message,
|
|
1370
|
+
blockers: error.details?.blockers || [],
|
|
1371
|
+
gate_id: error.details?.gate_id || null,
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
if (!['COUNCIL_GATE_DECISION_MISSING', 'COUNCIL_GATE_DECISION_STALE'].includes(error?.code)) throw error;
|
|
1375
|
+
}
|
|
1376
|
+
let result;
|
|
1377
|
+
try {
|
|
1378
|
+
result = await executeCouncil(options);
|
|
1379
|
+
} catch (error) {
|
|
1380
|
+
// A fresh real council verifies its persisted decision and throws on
|
|
1381
|
+
// rework. Classify that exactly like cached rework above, not as a broken
|
|
1382
|
+
// provider installation. All other execution/authority errors propagate.
|
|
1383
|
+
if (error?.code !== 'COUNCIL_GATE_REWORK_REQUIRED') throw error;
|
|
1384
|
+
return Object.freeze({
|
|
1385
|
+
required: true,
|
|
1386
|
+
stage,
|
|
1387
|
+
approved: false,
|
|
1388
|
+
blocked: true,
|
|
1389
|
+
code: error.code,
|
|
1390
|
+
message: error.message,
|
|
1391
|
+
blockers: error.details?.blockers || [],
|
|
1392
|
+
gate_id: error.details?.gate_id || null,
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
await updateSession(projectDir, {
|
|
1396
|
+
council_gates: { ...(session?.council_gates || {}), [stage]: councilGateSessionRecord(result) },
|
|
1397
|
+
});
|
|
1398
|
+
return Object.freeze({
|
|
1399
|
+
required: true,
|
|
1400
|
+
approved: result.ok,
|
|
1401
|
+
blocked: !result.ok,
|
|
1402
|
+
...(result.ok ? {} : { code: 'COUNCIL_GATE_REWORK_REQUIRED', blockers: result.decision.blocking_findings }),
|
|
1403
|
+
result,
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1211
1407
|
/**
|
|
1212
1408
|
* Build a pipeline state object from session data.
|
|
1213
1409
|
* The session-manager and pipeline-manager use slightly different shapes;
|
|
@@ -1273,7 +1469,7 @@ export function buildSpawnCommand(agent, projectDir, previousAgent, provider, ti
|
|
|
1273
1469
|
...(routing.reasoning_escalation ? ['--reasoning-escalation'] : []),
|
|
1274
1470
|
...(routing.execution_claim_id ? ['--execution-claim-id', shellQuote(routing.execution_claim_id)] : []),
|
|
1275
1471
|
...(strictProvider ? ['--strict-provider'] : []),
|
|
1276
|
-
'--timeout', String(timeout
|
|
1472
|
+
'--timeout', String(timeout ?? getAgentTimeout(agent)),
|
|
1277
1473
|
];
|
|
1278
1474
|
return parts.join(' ');
|
|
1279
1475
|
}
|
|
@@ -1461,11 +1657,11 @@ function closeNativeLockStorage(storage) {
|
|
|
1461
1657
|
}
|
|
1462
1658
|
|
|
1463
1659
|
function openWorkingDirectoryAnchor(absolute) {
|
|
1464
|
-
if (process.platform
|
|
1660
|
+
if (!['linux', 'darwin'].includes(process.platform)) return null;
|
|
1465
1661
|
let descriptor;
|
|
1466
1662
|
try {
|
|
1467
1663
|
descriptor = openSync('.', constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
1468
|
-
const workingDirectory =
|
|
1664
|
+
const workingDirectory = directoryPathSync(descriptor);
|
|
1469
1665
|
const targetRelative = relative(workingDirectory, absolute);
|
|
1470
1666
|
if (targetRelative === '..' || targetRelative.startsWith(`..${sep}`) || isAbsolute(targetRelative)) {
|
|
1471
1667
|
closeSync(descriptor);
|
|
@@ -1479,8 +1675,8 @@ function openWorkingDirectoryAnchor(absolute) {
|
|
|
1479
1675
|
}
|
|
1480
1676
|
|
|
1481
1677
|
function openNativeLockStorage(projectDir, targetPath) {
|
|
1482
|
-
if (process.platform
|
|
1483
|
-
return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH',
|
|
1678
|
+
if (!['linux', 'darwin'].includes(process.platform)) {
|
|
1679
|
+
return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', `native lock projection requires descriptor-relative storage; unsupported platform: ${process.platform}`);
|
|
1484
1680
|
}
|
|
1485
1681
|
const project = resolve(projectDir);
|
|
1486
1682
|
const target = resolve(targetPath);
|
|
@@ -1502,7 +1698,7 @@ function openNativeLockStorage(projectDir, targetPath) {
|
|
|
1502
1698
|
let descriptor;
|
|
1503
1699
|
try {
|
|
1504
1700
|
descriptor = anchor
|
|
1505
|
-
?
|
|
1701
|
+
? duplicateDirectorySync(anchor.descriptor)
|
|
1506
1702
|
: openSync(root, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
1507
1703
|
} finally {
|
|
1508
1704
|
if (anchor) closeSync(anchor.descriptor);
|
|
@@ -1510,8 +1706,8 @@ function openNativeLockStorage(projectDir, targetPath) {
|
|
|
1510
1706
|
descriptors.push(descriptor);
|
|
1511
1707
|
snapshots.push(nativeLockSnapshot(fstatSync(descriptor)));
|
|
1512
1708
|
for (const segment of segments) {
|
|
1513
|
-
descriptor =
|
|
1514
|
-
|
|
1709
|
+
descriptor = openAtSync(
|
|
1710
|
+
descriptor, segment,
|
|
1515
1711
|
constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0),
|
|
1516
1712
|
);
|
|
1517
1713
|
const stat = fstatSync(descriptor);
|
|
@@ -1548,12 +1744,11 @@ function assertNativeLockStorage(storage) {
|
|
|
1548
1744
|
}
|
|
1549
1745
|
|
|
1550
1746
|
function readHarnessProjection(storage) {
|
|
1551
|
-
const reference = join(`/proc/self/fd/${storage.descriptor}`, storage.name);
|
|
1552
1747
|
let descriptor;
|
|
1553
1748
|
try {
|
|
1554
|
-
const pathStat =
|
|
1749
|
+
const pathStat = lstatAtSync(storage.descriptor, storage.name, { bigint: true });
|
|
1555
1750
|
if (pathStat.isSymbolicLink()) return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock must not be a symlink');
|
|
1556
|
-
descriptor =
|
|
1751
|
+
descriptor = openAtSync(storage.descriptor, storage.name, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
|
|
1557
1752
|
const before = fstatSync(descriptor, { bigint: true });
|
|
1558
1753
|
if (!before.isFile() || before.nlink !== 1n) return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock must be a single-link regular file');
|
|
1559
1754
|
if (before.size > NATIVE_LOCK_MAX_BYTES_BIGINT) return nativeLockFailure('NATIVE_LOCK_SIZE_LIMIT', 'native lock exceeds the 256 KiB safety limit');
|
|
@@ -1565,7 +1760,7 @@ function readHarnessProjection(storage) {
|
|
|
1565
1760
|
offset += count;
|
|
1566
1761
|
}
|
|
1567
1762
|
const after = fstatSync(descriptor, { bigint: true });
|
|
1568
|
-
const finalPath =
|
|
1763
|
+
const finalPath = lstatAtSync(storage.descriptor, storage.name, { bigint: true });
|
|
1569
1764
|
const snapshot = (stat) => ({
|
|
1570
1765
|
dev: stat.dev, ino: stat.ino, mode: stat.mode, uid: stat.uid, gid: stat.gid,
|
|
1571
1766
|
nlink: stat.nlink, size: stat.size, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs,
|
|
@@ -1583,17 +1778,14 @@ function readHarnessProjection(storage) {
|
|
|
1583
1778
|
}
|
|
1584
1779
|
|
|
1585
1780
|
function writeHarnessProjectionAtomic(storage, content, expected) {
|
|
1586
|
-
const reference = `/proc/self/fd/${storage.descriptor}`;
|
|
1587
1781
|
const temporaryName = `.${storage.name}.${process.pid}.${randomUUID()}.tmp`;
|
|
1588
|
-
const temporary = join(reference, temporaryName);
|
|
1589
|
-
const destination = join(reference, storage.name);
|
|
1590
1782
|
let descriptor;
|
|
1591
1783
|
let replaced = false;
|
|
1592
1784
|
try {
|
|
1593
1785
|
if (Buffer.byteLength(content, 'utf8') > NATIVE_LOCK_MAX_BYTES) {
|
|
1594
1786
|
return nativeLockFailure('NATIVE_LOCK_SIZE_LIMIT', 'projected native lock exceeds the 256 KiB safety limit');
|
|
1595
1787
|
}
|
|
1596
|
-
descriptor =
|
|
1788
|
+
descriptor = openAtSync(storage.descriptor, temporaryName, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
1597
1789
|
const bytes = Buffer.from(content, 'utf8');
|
|
1598
1790
|
let offset = 0;
|
|
1599
1791
|
while (offset < bytes.length) offset += writeSync(descriptor, bytes, offset, bytes.length - offset, null);
|
|
@@ -1601,7 +1793,7 @@ function writeHarnessProjectionAtomic(storage, content, expected) {
|
|
|
1601
1793
|
closeSync(descriptor);
|
|
1602
1794
|
descriptor = undefined;
|
|
1603
1795
|
assertNativeLockStorage(storage);
|
|
1604
|
-
const current =
|
|
1796
|
+
const current = lstatAtSync(storage.descriptor, storage.name, { bigint: true });
|
|
1605
1797
|
const currentSnapshot = {
|
|
1606
1798
|
dev: current.dev, ino: current.ino, mode: current.mode, uid: current.uid, gid: current.gid,
|
|
1607
1799
|
nlink: current.nlink, size: current.size, mtimeNs: current.mtimeNs, ctimeNs: current.ctimeNs,
|
|
@@ -1609,7 +1801,7 @@ function writeHarnessProjectionAtomic(storage, content, expected) {
|
|
|
1609
1801
|
if (current.isSymbolicLink() || !sameNativeLockSnapshot(currentSnapshot, expected)) {
|
|
1610
1802
|
return nativeLockFailure('UNSAFE_NATIVE_LOCK_PATH', 'native lock changed before atomic replacement');
|
|
1611
1803
|
}
|
|
1612
|
-
|
|
1804
|
+
renameAtSync(storage.descriptor, temporaryName, storage.descriptor, storage.name);
|
|
1613
1805
|
replaced = true;
|
|
1614
1806
|
fsyncSync(storage.descriptor);
|
|
1615
1807
|
} catch (error) {
|
|
@@ -1617,7 +1809,7 @@ function writeHarnessProjectionAtomic(storage, content, expected) {
|
|
|
1617
1809
|
throw error;
|
|
1618
1810
|
} finally {
|
|
1619
1811
|
if (descriptor !== undefined) closeSync(descriptor);
|
|
1620
|
-
|
|
1812
|
+
unlinkAtSync(storage.descriptor, temporaryName, { force: true });
|
|
1621
1813
|
}
|
|
1622
1814
|
return undefined;
|
|
1623
1815
|
}
|
|
@@ -1651,7 +1843,9 @@ function projectSessionLocks(projectDir, { resumeMsg = null } = {}) {
|
|
|
1651
1843
|
const lockInner = active
|
|
1652
1844
|
? `## Session Lock: ACTIVE
|
|
1653
1845
|
|
|
1654
|
-
|
|
1846
|
+
${DELEGATED_EXECUTION_BOUNDARY}
|
|
1847
|
+
|
|
1848
|
+
**Chati.dev session is ACTIVE.** The user-facing coordinator follows these rules for EVERY user message:
|
|
1655
1849
|
|
|
1656
1850
|
1. Read \`${target.orchestratorPath}\` and follow its routing logic
|
|
1657
1851
|
2. Route ALL user messages through the current agent: \`${session.current_agent}\`
|
|
@@ -1731,6 +1925,24 @@ function removeSessionLock(projectDir, resumeMsg) {
|
|
|
1731
1925
|
// Sub-command handlers
|
|
1732
1926
|
// ---------------------------------------------------------------------------
|
|
1733
1927
|
|
|
1928
|
+
function classifyParallelExecutions(nextInfo, session) {
|
|
1929
|
+
if (!nextInfo?.isParallel || nextInfo.group.length < 2) return null;
|
|
1930
|
+
const incomplete = nextInfo.group.filter(agent => (
|
|
1931
|
+
!session.completed_agents?.includes(agent) && session.agents?.[agent]?.status !== 'completed'
|
|
1932
|
+
));
|
|
1933
|
+
const held = incomplete.filter(agent => session.agents?.[agent]?.status === 'awaiting_advance'
|
|
1934
|
+
|| session.agents?.[agent]?.execution_claim_state === 'awaiting_advance');
|
|
1935
|
+
const now = Date.now();
|
|
1936
|
+
const live = incomplete.filter(agent => {
|
|
1937
|
+
const state = session.agents?.[agent];
|
|
1938
|
+
const expiry = Date.parse(state?.execution_claim_expires_at || '');
|
|
1939
|
+
return state?.status === 'in_progress' && Boolean(state.execution_claim_id)
|
|
1940
|
+
&& Number.isFinite(expiry) && expiry > now;
|
|
1941
|
+
});
|
|
1942
|
+
return { held, live, dispatchable: incomplete.filter(agent => !held.includes(agent) && !live.includes(agent)) };
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
|
|
1734
1946
|
async function handleNext(projectDir) {
|
|
1735
1947
|
const result = await _handleNextInner(projectDir);
|
|
1736
1948
|
let dispatchMutation = null;
|
|
@@ -1755,8 +1967,9 @@ async function handleNext(projectDir) {
|
|
|
1755
1967
|
} : null;
|
|
1756
1968
|
if (result.model_info?.source === 'installation-v2') {
|
|
1757
1969
|
const dispatchedAgents = Array.isArray(result.parallel_spawn_commands) && result.parallel_spawn_commands.length > 0
|
|
1758
|
-
? result.parallel_spawn_commands.map((entry) => ({ agent: entry.agent,
|
|
1759
|
-
|
|
1970
|
+
? result.parallel_spawn_commands.map((entry) => ({ agent: entry.agent, taskId: `${entry.agent}-task`,
|
|
1971
|
+
binding: entry.agent === result.agent ? binding : resolveAgentModel(entry.agent, projectDir) }))
|
|
1972
|
+
: [{ agent: result.agent, taskId: `${result.agent}-task`, binding }];
|
|
1760
1973
|
const claimed = claimAgentExecutions(projectDir, dispatchedAgents);
|
|
1761
1974
|
if (!claimed.saved) {
|
|
1762
1975
|
return errorResult(`Could not persist canonical agent state: ${claimed.error}`, 'SESSION_WRITE_FAILED');
|
|
@@ -1886,6 +2099,9 @@ async function _handleNextInner(projectDir) {
|
|
|
1886
2099
|
const installationV2 = loadRuntimeInstallationV2(projectDir);
|
|
1887
2100
|
const runtimePipeline = getRuntimeAgentPipeline(projectDir, session);
|
|
1888
2101
|
|
|
2102
|
+
let revisionOwner;
|
|
2103
|
+
try { revisionOwner = getPlanningContractRevisionOwner(session, projectDir); }
|
|
2104
|
+
catch (error) { return errorResult(error.message, error.code); }
|
|
1889
2105
|
const pending = session.pending_interaction;
|
|
1890
2106
|
if (pending?.status === 'relay_in_flight') {
|
|
1891
2107
|
const relayStartedAt = Date.parse(pending.relay_started_at || '');
|
|
@@ -2067,22 +2283,48 @@ async function _handleNextInner(projectDir) {
|
|
|
2067
2283
|
}
|
|
2068
2284
|
|
|
2069
2285
|
// Check for in-progress agent (resume scenario)
|
|
2070
|
-
|
|
2286
|
+
let rollbackAgent;
|
|
2287
|
+
try { rollbackAgent = getPendingPlanningRollbackTarget(session); }
|
|
2288
|
+
catch (error) { return errorResult(error.message, error.code); }
|
|
2289
|
+
const currentAgent = revisionOwner || rollbackAgent || session.current_agent || null;
|
|
2071
2290
|
const completedAgents = session.completed_agents || [];
|
|
2072
|
-
|
|
2291
|
+
const lastCompletedAgent = completedAgents.includes(session.last_handoff) ? session.last_handoff
|
|
2292
|
+
: completedAgents.includes(session.current_agent) ? session.current_agent
|
|
2293
|
+
: completedAgents.findLast(agent => getAgentDefinition(agent)) || null;
|
|
2294
|
+
const peerRecovery = installationV2 && !rollbackAgent && !revisionOwner
|
|
2295
|
+
? getNextAgent(lastCompletedAgent || currentAgent, completedAgents, { recoverPlanningPeers: true }) : null;
|
|
2296
|
+
// A legacy QA/Phases cursor must not hide an unfinished Planning member.
|
|
2297
|
+
// Its claim or held result stays intact; the reservation/start guard prevents
|
|
2298
|
+
// a stale downstream command from launching before the predecessors finish.
|
|
2299
|
+
if (currentAgent && !completedAgents.includes(currentAgent)
|
|
2300
|
+
&& (!peerRecovery?.isParallel || peerRecovery.group.includes(currentAgent))) {
|
|
2073
2301
|
const agent = currentAgent;
|
|
2074
2302
|
const agentStatus = session.agents?.[agent]?.status;
|
|
2075
|
-
|
|
2303
|
+
const resumeGroup = installationV2 && !rollbackAgent && !revisionOwner
|
|
2304
|
+
? getNextAgent(session.last_handoff || currentAgent, completedAgents, { recoverPlanningPeers: true }) : null;
|
|
2305
|
+
const runnablePeer = !session.agents?.[agent]?.content_review_pending && resumeGroup?.group.includes(agent)
|
|
2306
|
+
&& classifyParallelExecutions(resumeGroup, session)?.dispatchable.some(peer => peer !== agent);
|
|
2307
|
+
if (agentStatus === 'awaiting_advance' && !runnablePeer) {
|
|
2076
2308
|
return {
|
|
2077
2309
|
action: 'awaiting_agent_advance',
|
|
2078
2310
|
agent,
|
|
2079
2311
|
code: 'AGENT_AWAITING_ADVANCE',
|
|
2312
|
+
result_status: session.agents[agent].execution_result_status || null,
|
|
2313
|
+
execution_claim_id: session.agents[agent].execution_claim_id || null,
|
|
2314
|
+
execution_finished_at: session.agents[agent].execution_finished_at || null,
|
|
2315
|
+
requires_recovery: session.agents[agent].execution_result_status !== 'complete',
|
|
2316
|
+
content_rejection_command: ['detail', 'architect', 'ux', 'qa-planning'].includes(agent)
|
|
2317
|
+
&& session.agents[agent].execution_result_status === 'complete'
|
|
2318
|
+
&& session.agents[agent].execution_claim_id && session.agents[agent].execution_finished_at
|
|
2319
|
+
? `node .chati.dev/orchestrator/chati-router.js reject-result --agent ${shellQuote(agent)} --execution-claim-id ${shellQuote(session.agents[agent].execution_claim_id)} --execution-finished-at ${shellQuote(session.agents[agent].execution_finished_at)} --reason <content-review-reason>` : null,
|
|
2080
2320
|
pipeline_progress: getPipelineProgress(pipelineState),
|
|
2081
2321
|
session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode },
|
|
2082
|
-
status_summary:
|
|
2322
|
+
status_summary: session.agents[agent].execution_result_status === 'complete'
|
|
2323
|
+
? `${agent} returned complete and is waiting for canonical advance.`
|
|
2324
|
+
: `${agent} has a legacy ambiguous result. Do not advance it as successful; inspect its original result before negative recovery.`,
|
|
2083
2325
|
};
|
|
2084
2326
|
}
|
|
2085
|
-
if (agentStatus === 'in_progress') {
|
|
2327
|
+
if (agentStatus === 'in_progress' || agentStatus === 'pending_rework' || rollbackAgent === agent) {
|
|
2086
2328
|
const agentFile = getAgentFile(agent, projectDir) || null;
|
|
2087
2329
|
const agentDef = getAgentDefinition(agent);
|
|
2088
2330
|
const isInteractive = INTERACTIVE_AGENTS.includes(agent);
|
|
@@ -2119,7 +2361,7 @@ async function _handleNextInner(projectDir) {
|
|
|
2119
2361
|
agent,
|
|
2120
2362
|
agent_file: agentFile,
|
|
2121
2363
|
phase: agentDef?.phase || session.mode,
|
|
2122
|
-
spawn_command: buildSpawnCommand(agent, projectDir, session.last_handoff || 'none', modelInfo.provider,
|
|
2364
|
+
spawn_command: buildSpawnCommand(agent, projectDir, session.last_handoff || 'none', modelInfo.provider, getAgentTimeout(agent), modelInfo.model, modelInfo.source === 'installation-v2', modelInfo),
|
|
2123
2365
|
parallel_spawn_command: null,
|
|
2124
2366
|
parallel_agents: [],
|
|
2125
2367
|
handoff_status: { valid: true, missing: [], warnings: ['Resuming in-progress agent'] },
|
|
@@ -2128,13 +2370,13 @@ async function _handleNextInner(projectDir) {
|
|
|
2128
2370
|
context_bracket: estimateContextBracket(completedAgents.length, runtimePipeline.length),
|
|
2129
2371
|
pipeline_progress: getPipelineProgress(pipelineState),
|
|
2130
2372
|
session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
|
|
2131
|
-
status_summary:
|
|
2373
|
+
status_summary: `${agentStatus === 'pending_rework' ? 'Reworking' : 'Resuming'} ${agent}.`,
|
|
2132
2374
|
};
|
|
2133
2375
|
}
|
|
2134
2376
|
}
|
|
2135
2377
|
|
|
2136
2378
|
// Determine last completed agent
|
|
2137
|
-
const lastAgent =
|
|
2379
|
+
const lastAgent = lastCompletedAgent;
|
|
2138
2380
|
|
|
2139
2381
|
// If no agent has started yet, route to first agent
|
|
2140
2382
|
if (!lastAgent || completedAgents.length === 0) {
|
|
@@ -2164,7 +2406,8 @@ async function _handleNextInner(projectDir) {
|
|
|
2164
2406
|
// Get next agent — if lastAgent is not in AGENT_PIPELINE (e.g. qa-planning
|
|
2165
2407
|
// which lives inside Planning Team), fall back to the last completed agent
|
|
2166
2408
|
// that IS in the pipeline so we can find the correct successor.
|
|
2167
|
-
let nextInfo =
|
|
2409
|
+
let nextInfo = revisionOwner ? { next: revisionOwner, isParallel: false, group: [revisionOwner] }
|
|
2410
|
+
: getNextAgent(lastAgent, completedAgents, { recoverPlanningPeers: Boolean(installationV2) });
|
|
2168
2411
|
|
|
2169
2412
|
if ((!nextInfo || !nextInfo.next) && lastAgent) {
|
|
2170
2413
|
const agentInPipeline = getAgentDefinition(lastAgent);
|
|
@@ -2173,7 +2416,7 @@ async function _handleNextInner(projectDir) {
|
|
|
2173
2416
|
const pipelineNames = AGENT_PIPELINE.map((a) => a.name);
|
|
2174
2417
|
for (let i = completedAgents.length - 1; i >= 0; i--) {
|
|
2175
2418
|
if (pipelineNames.includes(completedAgents[i])) {
|
|
2176
|
-
nextInfo = getNextAgent(completedAgents[i], completedAgents);
|
|
2419
|
+
nextInfo = getNextAgent(completedAgents[i], completedAgents, { recoverPlanningPeers: Boolean(installationV2) });
|
|
2177
2420
|
if (nextInfo && nextInfo.next) break;
|
|
2178
2421
|
}
|
|
2179
2422
|
}
|
|
@@ -2194,17 +2437,12 @@ async function _handleNextInner(projectDir) {
|
|
|
2194
2437
|
// Parallel groups can be resumed after only a subset has completed. Never
|
|
2195
2438
|
// send completed members back through the claim layer, and do not let a live
|
|
2196
2439
|
// claim for one member prevent an unclaimed sibling from being dispatched.
|
|
2197
|
-
//
|
|
2198
|
-
//
|
|
2440
|
+
// Held results cannot be redispatched or treated as accepted, but they must
|
|
2441
|
+
// not prevent a separate unfinished member from recovering its own attempt.
|
|
2199
2442
|
if (installationV2 && nextInfo.isParallel && nextInfo.group.length > 1) {
|
|
2200
|
-
const
|
|
2201
|
-
|
|
2202
|
-
)
|
|
2203
|
-
const awaitingAdvance = incompleteGroup.find((agent) => (
|
|
2204
|
-
session.agents?.[agent]?.status === 'awaiting_advance'
|
|
2205
|
-
|| session.agents?.[agent]?.execution_claim_state === 'awaiting_advance'
|
|
2206
|
-
));
|
|
2207
|
-
if (awaitingAdvance) {
|
|
2443
|
+
const { held, live: liveAgents, dispatchable: dispatchableGroup } = classifyParallelExecutions(nextInfo, session);
|
|
2444
|
+
const awaitingAdvance = held[0];
|
|
2445
|
+
if (dispatchableGroup.length === 0 && awaitingAdvance) {
|
|
2208
2446
|
return {
|
|
2209
2447
|
action: 'awaiting_agent_advance',
|
|
2210
2448
|
agent: awaitingAdvance,
|
|
@@ -2214,16 +2452,6 @@ async function _handleNextInner(projectDir) {
|
|
|
2214
2452
|
status_summary: `${awaitingAdvance} finished its routed process and is waiting for canonical advance.`,
|
|
2215
2453
|
};
|
|
2216
2454
|
}
|
|
2217
|
-
const now = Date.now();
|
|
2218
|
-
const liveAgents = incompleteGroup.filter((agent) => {
|
|
2219
|
-
const state = session.agents?.[agent];
|
|
2220
|
-
const expiry = Date.parse(state?.execution_claim_expires_at || '');
|
|
2221
|
-
return state?.status === 'in_progress'
|
|
2222
|
-
&& Boolean(state.execution_claim_id)
|
|
2223
|
-
&& Number.isFinite(expiry)
|
|
2224
|
-
&& expiry > now;
|
|
2225
|
-
});
|
|
2226
|
-
const dispatchableGroup = incompleteGroup.filter((agent) => !liveAgents.includes(agent));
|
|
2227
2455
|
if (dispatchableGroup.length === 0 && liveAgents.length > 0) {
|
|
2228
2456
|
return {
|
|
2229
2457
|
action: 'agent_execution_in_flight',
|
|
@@ -2305,10 +2533,10 @@ async function _handleNextInner(projectDir) {
|
|
|
2305
2533
|
teamData = { team_id: teamId, team_type: 'build', members: teamConfig.members };
|
|
2306
2534
|
} else if (isInteractive) {
|
|
2307
2535
|
action = 'spawn_routed_interactive';
|
|
2308
|
-
spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider,
|
|
2536
|
+
spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, getAgentTimeout(nextAgent), modelInfo.model, modelInfo.source === 'installation-v2', modelInfo);
|
|
2309
2537
|
} else {
|
|
2310
2538
|
action = 'spawn_autonomous';
|
|
2311
|
-
spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider,
|
|
2539
|
+
spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, getAgentTimeout(nextAgent), modelInfo.model, modelInfo.source === 'installation-v2', modelInfo);
|
|
2312
2540
|
}
|
|
2313
2541
|
|
|
2314
2542
|
// Surface criteria (Article XVII): even in autonomous mode, a high-stakes
|
|
@@ -2622,14 +2850,109 @@ async function handleScaffoldDecision(projectDir, session, decision, agent, scor
|
|
|
2622
2850
|
};
|
|
2623
2851
|
}
|
|
2624
2852
|
|
|
2853
|
+
async function handleRejectResult(projectDir, args) {
|
|
2854
|
+
if (!loadRuntimeInstallationV2(projectDir)) return errorResult('Content rejection requires Planning v2.', 'V2_INSTALLATION_REQUIRED');
|
|
2855
|
+
const binding = resolveAgentModel(args.agent, projectDir);
|
|
2856
|
+
const result = rejectAgentResult(projectDir, { agent: args.agent, claimId: args['execution-claim-id'],
|
|
2857
|
+
finishedAt: args['execution-finished-at'], reason: args.reason, binding });
|
|
2858
|
+
if (!result.saved || !result.rejected) return errorResult(result.error, result.code || 'SESSION_WRITE_FAILED');
|
|
2859
|
+
let projection;
|
|
2860
|
+
try { projection = writeSessionLock(projectDir, args.agent); }
|
|
2861
|
+
catch (error) { projection = { projected: false, error: error.message }; }
|
|
2862
|
+
return { rejected: true, advanced: false, retry_ready: true, agent: args.agent,
|
|
2863
|
+
projection: { projected: projection?.projected === true, ...(projection?.error ? { error: projection.error } : {}) },
|
|
2864
|
+
status_summary: 'Complete execution preserved with content rejection. No gate advanced; invoke next separately for rework.' };
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
async function handleRevalidateQa(projectDir, args) {
|
|
2868
|
+
const agent = args.agent || 'qa-planning';
|
|
2869
|
+
const result = revalidateQaAfterCouncil(projectDir, { agent, claimId: args['execution-claim-id'],
|
|
2870
|
+
finishedAt: args['execution-finished-at'], gateId: args['council-gate-id'] });
|
|
2871
|
+
if (!result.saved || !result.revalidated) return errorResult(result.error, result.code || 'SESSION_WRITE_FAILED');
|
|
2872
|
+
return projectPlanningQaReservation(projectDir, agent, result, 'revalidated');
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
async function handleReconcilePlanning(projectDir, args) {
|
|
2876
|
+
const agent = args.agent || 'qa-planning';
|
|
2877
|
+
const result = await executePlanningReconciliation({ projectDir, match: { agent,
|
|
2878
|
+
claimId: args['execution-claim-id'], finishedAt: args['execution-finished-at'], gateId: args['council-gate-id'] } });
|
|
2879
|
+
if (!result.settled) return errorResult(result.error, result.code || 'PLANNING_RECONCILIATION_FAILED');
|
|
2880
|
+
if (!result.authorized) return { reconciled: true, authorized: false, advanced: false, blocked: true,
|
|
2881
|
+
code: 'PLANNING_RECONCILIATION_DENIED',
|
|
2882
|
+
decision_path: result.decision_path, assessments: result.assessments,
|
|
2883
|
+
status_summary: 'The bounded assessment denied fresh QA. Preserve the held result and fix the cited material; unchanged bytes cannot launch another assessment.' };
|
|
2884
|
+
return projectPlanningQaReservation(projectDir, agent, result, 'reconciled');
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
async function handleRevisePlanningContract(projectDir, args) {
|
|
2888
|
+
const rawDirection = args['direction-json'];
|
|
2889
|
+
if (typeof rawDirection !== 'string' || !rawDirection || Buffer.byteLength(rawDirection, 'utf8') > 16 * 1024) {
|
|
2890
|
+
return errorResult('Missing or oversized --direction-json.', 'PLANNING_CONTRACT_REVISION_INPUT_INVALID');
|
|
2891
|
+
}
|
|
2892
|
+
let direction;
|
|
2893
|
+
try { direction = JSON.parse(rawDirection); }
|
|
2894
|
+
catch { return errorResult('--direction-json must be valid closed-schema JSON.', 'PLANNING_CONTRACT_REVISION_INPUT_INVALID'); }
|
|
2895
|
+
const match = { agent: args.agent || 'qa-planning', claimId: args['execution-claim-id'],
|
|
2896
|
+
finishedAt: args['execution-finished-at'], gateId: args['council-gate-id'], direction,
|
|
2897
|
+
...(args['previous-revision'] === undefined ? {} : { previousRevisionId: args['previous-revision'] }) };
|
|
2898
|
+
const reserved = reservePlanningContractRevision(projectDir, match);
|
|
2899
|
+
if (!reserved.saved || !reserved.reserved) return errorResult(reserved.error, reserved.code || 'PLANNING_CONTRACT_REVISION_FAILED');
|
|
2900
|
+
const reservation = reserved.reservation;
|
|
2901
|
+
let decision;
|
|
2902
|
+
try {
|
|
2903
|
+
if (reservation.status === 'reserved') decision = persistPlanningContractRevision({ projectDir, reservation });
|
|
2904
|
+
} catch (error) {
|
|
2905
|
+
return errorResult(error.message, error.code || 'PLANNING_CONTRACT_REVISION_FAILED');
|
|
2906
|
+
}
|
|
2907
|
+
const settled = settlePlanningContractRevision(projectDir, { ...match,
|
|
2908
|
+
revisionId: reservation.request.revision_id, launchNonce: reservation.launch_nonce,
|
|
2909
|
+
...(decision ? { decisionSha256: decision.sha256 } : {}) });
|
|
2910
|
+
if (!settled.saved || !settled.settled) return errorResult(settled.error, settled.code || 'PLANNING_CONTRACT_REVISION_FAILED');
|
|
2911
|
+
if (settled.reused && [2, 3].includes(settled.revision.request.schema_version)) return { revised: true, reused: true, advanced: false,
|
|
2912
|
+
revision_id: settled.revision.request.revision_id, decision_path: settled.revision.decision_path,
|
|
2913
|
+
status_summary: 'This explicit direction was already consumed. No owner or automatic budget was reopened; use next for the current canonical state.' };
|
|
2914
|
+
let projection;
|
|
2915
|
+
try { projection = writeSessionLock(projectDir, 'detail'); }
|
|
2916
|
+
catch (error) { projection = { projected: false, error: error.message }; }
|
|
2917
|
+
try {
|
|
2918
|
+
recordEvent(projectDir, EventType.CORRECTION_ESCALATED, 'orchestrator', {
|
|
2919
|
+
source: 'planning_contract_revision', revision_id: settled.revision.request.revision_id,
|
|
2920
|
+
source_gate_id: settled.revision.request.source_gate_id, target_agent: 'detail',
|
|
2921
|
+
});
|
|
2922
|
+
} catch { /* Canonical session and immutable decision remain authoritative. */ }
|
|
2923
|
+
return { revised: true, reused: settled.reused === true, advanced: false, retry_ready: true,
|
|
2924
|
+
revision_id: settled.revision.request.revision_id, decision_path: settled.revision.decision_path,
|
|
2925
|
+
target_agent: 'detail', projection: { projected: projection?.projected === true,
|
|
2926
|
+
...(projection?.error ? { error: projection.error } : {}) },
|
|
2927
|
+
status_summary: 'Planning ownership reopened at detail. Run detail, architect, UX, genuine fresh QA and the normal council; this direction approves no Planning content.' };
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
function projectPlanningQaReservation(projectDir, agent, result, operation) {
|
|
2931
|
+
let projection;
|
|
2932
|
+
try { projection = writeSessionLock(projectDir, agent); }
|
|
2933
|
+
catch (error) { projection = { projected: false, error: error.message }; }
|
|
2934
|
+
if (!projection?.projected) {
|
|
2935
|
+
const released = releaseAgentExecution(projectDir, { agent, claimId: result.claimId });
|
|
2936
|
+
return { [operation]: true, advanced: false, spawn_command: null,
|
|
2937
|
+
code: 'SESSION_PROJECTION_FAILED', error: projection?.error, retry_ready: released.saved && released.released,
|
|
2938
|
+
status_summary: 'Revalidation authorization is preserved. Repair the projection, then invoke next; no provider was started.' };
|
|
2939
|
+
}
|
|
2940
|
+
return { [operation]: true, reused: result.reused === true, advanced: false, action: 'spawn_autonomous', agent,
|
|
2941
|
+
execution_claim_id: result.claimId, model_info: result.binding,
|
|
2942
|
+
spawn_command: buildSpawnCommand(agent, projectDir, result.session.last_handoff || 'ux',
|
|
2943
|
+
result.binding.provider, getAgentTimeout(agent), result.binding.model, true,
|
|
2944
|
+
{ ...result.binding, execution_claim_id: result.claimId }),
|
|
2945
|
+
status_summary: 'Historical QA preserved. Run this one-shot command for a genuine new verdict, then canonical advance and a fresh council.' };
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2625
2948
|
async function handleAdvance(projectDir, args) {
|
|
2626
2949
|
const agent = args.agent;
|
|
2627
|
-
const score =
|
|
2628
|
-
const status = args.status || 'completed';
|
|
2950
|
+
const score = args.score === undefined || args.score === '' ? NaN : Number(args.score);
|
|
2951
|
+
const status = args.status === 'complete' ? 'completed' : (args.status || 'completed');
|
|
2629
2952
|
const decision = args.decision || null;
|
|
2630
2953
|
|
|
2631
2954
|
if (!agent) return errorResult('Missing --agent flag', 'MISSING_AGENT');
|
|
2632
|
-
if (
|
|
2955
|
+
if (!Number.isFinite(score) || score < 0 || score > 100) return errorResult('Missing or invalid --score flag', 'MISSING_SCORE');
|
|
2633
2956
|
|
|
2634
2957
|
const { loaded, session } = loadSession(projectDir);
|
|
2635
2958
|
if (!loaded || !session) return errorResult('No session found', 'NO_SESSION');
|
|
@@ -2666,6 +2989,21 @@ async function handleAdvance(projectDir, args) {
|
|
|
2666
2989
|
);
|
|
2667
2990
|
}
|
|
2668
2991
|
|
|
2992
|
+
// Non-complete outcomes cannot enter completion, artifact merge or pipeline
|
|
2993
|
+
// transitions. A legacy ambiguous hold can only be settled negatively by its
|
|
2994
|
+
// exact identity; callers must invoke next separately to reserve a retry.
|
|
2995
|
+
if (status !== 'completed') {
|
|
2996
|
+
if (runtimeV2 && status === 'error' && args['execution-finished-at']) {
|
|
2997
|
+
const recovery = rejectLegacyAgentResult(projectDir, { agent, finishedAt: args['execution-finished-at'] });
|
|
2998
|
+
if (!recovery.saved || !recovery.recovered) {
|
|
2999
|
+
return errorResult(recovery.error, recovery.code || 'SESSION_WRITE_FAILED');
|
|
3000
|
+
}
|
|
3001
|
+
return { advanced: false, recovered: true, retry_ready: true, agent,
|
|
3002
|
+
status_summary: 'Legacy result recorded as failure. No gate advanced; invoke next to retry.' };
|
|
3003
|
+
}
|
|
3004
|
+
return errorResult('Only a complete agent result can advance the pipeline.', 'AGENT_RESULT_NOT_COMPLETE');
|
|
3005
|
+
}
|
|
3006
|
+
|
|
2669
3007
|
if (runtimeV2 && agent === 'tasks' && status === 'completed' && score < 95) {
|
|
2670
3008
|
return {
|
|
2671
3009
|
advanced: false,
|
|
@@ -2704,13 +3042,32 @@ async function handleAdvance(projectDir, args) {
|
|
|
2704
3042
|
};
|
|
2705
3043
|
}
|
|
2706
3044
|
|
|
2707
|
-
if (runtimeV2 &&
|
|
3045
|
+
if (runtimeV2 && (agentState?.status !== 'awaiting_advance'
|
|
3046
|
+
|| agentState?.execution_claim_state !== 'awaiting_advance' || agentState?.execution_result_status !== 'complete')) {
|
|
2708
3047
|
return errorResult(
|
|
2709
3048
|
`Agent ${agent} has no successful routed result awaiting canonical advance.`,
|
|
2710
3049
|
'V2_AGENT_RESULT_NOT_HELD',
|
|
2711
3050
|
);
|
|
2712
3051
|
}
|
|
2713
3052
|
|
|
3053
|
+
let qaVerdict = null;
|
|
3054
|
+
if (runtimeV2 && agent === 'qa-planning') {
|
|
3055
|
+
const review = agentState.execution_qa_result;
|
|
3056
|
+
const threshold = getQaThreshold(agent, projectDir);
|
|
3057
|
+
if (review) {
|
|
3058
|
+
if (!isPlanningQaOutcomeValid(review, threshold)) {
|
|
3059
|
+
return errorResult('The held QA outcome is invalid.', 'QA_RESULT_UNVERIFIED');
|
|
3060
|
+
}
|
|
3061
|
+
if (review.score !== score) return errorResult('The supplied score does not match the held QA outcome.', 'QA_RESULT_SCORE_MISMATCH');
|
|
3062
|
+
qaVerdict = review.gate_result;
|
|
3063
|
+
} else {
|
|
3064
|
+
// Old complete holds prove execution, not approval. Compatibility is
|
|
3065
|
+
// strictly negative: never infer a passing plan from an old score100.
|
|
3066
|
+
if (score >= threshold) return errorResult('This legacy QA hold has no verified plan outcome; only negative correction is supported.', 'QA_RESULT_UNVERIFIED');
|
|
3067
|
+
qaVerdict = 'needs_correction';
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
|
|
2714
3071
|
// Handle user_preview decisions
|
|
2715
3072
|
if (decision) {
|
|
2716
3073
|
const pipelineState = sessionToPipelineState(session, projectDir);
|
|
@@ -2737,29 +3094,33 @@ async function handleAdvance(projectDir, args) {
|
|
|
2737
3094
|
// Agents (e.g. qa-implementation) write Decision Trail entries to their handoff
|
|
2738
3095
|
// instead of directly editing session.yaml (blocked by mode-governance hook).
|
|
2739
3096
|
let handoffData = {};
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
const handoffCandidates = [
|
|
2746
|
-
join(projectDir, 'artifacts', 'handoffs', `${agent}-handoff.md`),
|
|
2747
|
-
join(projectDir, fwDir, 'artifacts', 'handoffs', `${agent}-handoff.md`),
|
|
2748
|
-
];
|
|
2749
|
-
const handoffPath = handoffCandidates.find(p => existsSync(p));
|
|
2750
|
-
if (handoffPath) {
|
|
2751
|
-
try {
|
|
3097
|
+
let compiledRailHandoff = null;
|
|
3098
|
+
let visualApplicability = null;
|
|
3099
|
+
try {
|
|
3100
|
+
const handoffPath = findAgentHandoffPath(projectDir, agent);
|
|
3101
|
+
if (handoffPath) {
|
|
2752
3102
|
const handoffMarkdown = readFileSync(handoffPath, 'utf-8');
|
|
2753
3103
|
handoffData = parseHandoffDecisionTrail(handoffMarkdown);
|
|
2754
3104
|
// Fase 10 auto-gate — parse scaffold candidates/signals alongside Decision Trail.
|
|
2755
3105
|
const scaffold = parseHandoffScaffoldSignals(handoffMarkdown);
|
|
2756
3106
|
handoffData.scaffold_candidates = scaffold.scaffold_candidates;
|
|
2757
3107
|
handoffData.scaffold_signals = scaffold.scaffold_signals;
|
|
2758
|
-
} catch {
|
|
2759
|
-
// Non-fatal: if handoff can't be read, skip Decision Trail / scaffold merge
|
|
2760
3108
|
}
|
|
3109
|
+
} catch (error) {
|
|
3110
|
+
if (error.code === 'INVALID_HANDOFF_DECISION_TRAIL') return errorResult(error.message, error.code);
|
|
3111
|
+
// Non-fatal: unreadable handoffs do not merge a Decision Trail or scaffold.
|
|
3112
|
+
// Do not fall back to older handoffs after selecting a new-format output.
|
|
2761
3113
|
}
|
|
2762
3114
|
|
|
3115
|
+
if (runtimeV2 && agent === 'qa-planning' && qaVerdict === 'needs_correction'
|
|
3116
|
+
&& !handoffData.decision_trail_entries?.some(entry => entry.resolved === false)) {
|
|
3117
|
+
return errorResult('Negative QA correction requires preserved unresolved findings.', 'QA_FINDINGS_REQUIRED');
|
|
3118
|
+
}
|
|
3119
|
+
const correctionTarget = args['correction-target'] || null;
|
|
3120
|
+
try {
|
|
3121
|
+
assertQaCorrectionTarget(sessionToPipelineState(session, projectDir), agent, { score, qaVerdict, correctionTarget, handoffData });
|
|
3122
|
+
} catch (error) { return errorResult(error.message, error.code); }
|
|
3123
|
+
|
|
2763
3124
|
// Deterministic gates: block advance if required artifacts are missing.
|
|
2764
3125
|
|
|
2765
3126
|
// V2 replaces the legacy Build/Deploy entry with a sealed, project-local
|
|
@@ -2773,18 +3134,17 @@ async function handleAdvance(projectDir, args) {
|
|
|
2773
3134
|
// dependencies and ClickUp references.
|
|
2774
3135
|
if (agent === 'tasks' && status === 'completed' && score >= 95 && loadRuntimeInstallationV2(projectDir)) {
|
|
2775
3136
|
try {
|
|
2776
|
-
|
|
2777
|
-
handoffData.rail_handoff = persistRailHandoff({ projectDir, handoff });
|
|
3137
|
+
compiledRailHandoff = compilePlanningHandoff({ projectDir, promoteCandidate: true });
|
|
2778
3138
|
} catch (error) {
|
|
2779
3139
|
return {
|
|
2780
3140
|
advanced: false,
|
|
2781
3141
|
blocked: true,
|
|
2782
3142
|
reason: error.code || 'RAIL_HANDOFF_COMPILATION_FAILED',
|
|
2783
3143
|
message: error.message,
|
|
2784
|
-
expected_artifact:
|
|
3144
|
+
expected_artifact: PLANNING_V2_TASKS_CANDIDATE_PATH,
|
|
2785
3145
|
action_required: {
|
|
2786
3146
|
owner: 'Tasks and QA Planning agents',
|
|
2787
|
-
artifact:
|
|
3147
|
+
artifact: PLANNING_V2_TASKS_CANDIDATE_PATH,
|
|
2788
3148
|
requirements: ['source artifacts', 'task IDs and dependencies', 'Given-When-Then criteria', 'execution scopes', 'AI and human estimates', 'ClickUp references'],
|
|
2789
3149
|
},
|
|
2790
3150
|
};
|
|
@@ -3171,6 +3531,24 @@ async function handleAdvance(projectDir, args) {
|
|
|
3171
3531
|
|
|
3172
3532
|
// UX (brand-architect) gate
|
|
3173
3533
|
if (agent === 'ux') {
|
|
3534
|
+
visualApplicability = resolveNonVisualUxApplicability(projectDir, runtimeV2);
|
|
3535
|
+
if (visualApplicability) {
|
|
3536
|
+
const handoff = (await loadHandoffContext(projectDir, 'ux')).context.handoff;
|
|
3537
|
+
let specification = '';
|
|
3538
|
+
try { specification = readFileSync(join(projectDir, 'artifacts/4-UX/ux-specification.md'), 'utf8').trim(); }
|
|
3539
|
+
catch { /* A missing/unreadable specification cannot be exempted. */ }
|
|
3540
|
+
// The documented handoff sections do not require frontmatter. This v2
|
|
3541
|
+
// branch has already required a canonical complete routed result above;
|
|
3542
|
+
// absent metadata is not an inferred completion, while an explicit
|
|
3543
|
+
// non-complete declaration must still block even with a complete hold.
|
|
3544
|
+
const compatibleStatus = handoff?.status === 'complete' || handoff?.status_declared === false;
|
|
3545
|
+
if (!specification || !compatibleStatus || !validateHandoffIntegrity(handoff, 'qa-planning').valid) {
|
|
3546
|
+
return {
|
|
3547
|
+
advanced: false, blocked: true, reason: 'ux_non_visual_artifacts_missing',
|
|
3548
|
+
message: 'A non-visual Brief exempts only visual deliverables. A non-empty ux-specification.md and complete UX handoff with summary/outputs remain required.',
|
|
3549
|
+
};
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3174
3552
|
// Fase 7 — Task-to-UX fidelity. Before checking brandbook.html, verify that
|
|
3175
3553
|
// if the brief or reference-analysis cites premium animation references,
|
|
3176
3554
|
// the brand-architect has produced the animation-inventory artifact. The
|
|
@@ -3178,7 +3556,7 @@ async function handleAdvance(projectDir, args) {
|
|
|
3178
3556
|
// reveals" instead of the specific patterns the user's references
|
|
3179
3557
|
// encode, and qa-visual has no per-pattern checklist for Mode 2.
|
|
3180
3558
|
const inventoryPath = join(projectDir, 'artifacts', '4-UX', 'animation-inventory.md');
|
|
3181
|
-
if (!existsSync(inventoryPath)) {
|
|
3559
|
+
if (!visualApplicability && !existsSync(inventoryPath)) {
|
|
3182
3560
|
const corpus = readBriefAndRefCorpus(projectDir);
|
|
3183
3561
|
const refs = detectPremiumRefs(corpus);
|
|
3184
3562
|
if (refs.detected) {
|
|
@@ -3204,18 +3582,147 @@ async function handleAdvance(projectDir, args) {
|
|
|
3204
3582
|
}
|
|
3205
3583
|
|
|
3206
3584
|
const brandbookPath = join(projectDir, 'artifacts', '4-UX', 'brandbook.html');
|
|
3207
|
-
if (!existsSync(brandbookPath)) {
|
|
3585
|
+
if (!visualApplicability && !existsSync(brandbookPath)) {
|
|
3208
3586
|
return {
|
|
3209
3587
|
advanced: false,
|
|
3210
3588
|
blocked: true,
|
|
3211
3589
|
reason: 'brandbook_html_missing',
|
|
3212
|
-
message: 'brandbook.html is
|
|
3590
|
+
message: 'brandbook.html is required unless the current council-approved Brief explicitly declares non-visual scope. Write artifacts/4-UX/brandbook.html or resolve the upstream applicability declaration before advancing.',
|
|
3591
|
+
};
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
|
|
3595
|
+
// The product-level council follows deterministic artifact checks. It gates
|
|
3596
|
+
// the three user-visible planning boundaries and never substitutes an
|
|
3597
|
+
// unselected provider when a configured member is unavailable.
|
|
3598
|
+
if (runtimeV2 && status === 'completed' && qaVerdict !== 'needs_correction') {
|
|
3599
|
+
try {
|
|
3600
|
+
const council = await enforceRuntimeCouncilGate({
|
|
3601
|
+
projectDir,
|
|
3602
|
+
agent,
|
|
3603
|
+
session,
|
|
3604
|
+
artifact: runtimeV2,
|
|
3605
|
+
...(compiledRailHandoff ? { handoff: compiledRailHandoff } : {}),
|
|
3606
|
+
});
|
|
3607
|
+
if (council.required && !council.approved) {
|
|
3608
|
+
const revisionCapability = agent === 'qa-planning'
|
|
3609
|
+
&& (session.planning_contract_revisions?.length || session.agents[agent]?.planning_reconciliations?.some(entry => entry.authorization))
|
|
3610
|
+
? planningContractRevisionCapability(projectDir, session, council.result?.gate_id || council.gate_id)
|
|
3611
|
+
: null;
|
|
3612
|
+
// Every automatic recovery budget can be consumed without a single QA
|
|
3613
|
+
// authorization (all reconciliation targets denied). That state is
|
|
3614
|
+
// terminal for the automatic route and must be reported as such instead
|
|
3615
|
+
// of pointing the operator at a command that can only fail again.
|
|
3616
|
+
const reconciliationBudgetExhausted = agent === 'qa-planning' && !revisionCapability
|
|
3617
|
+
&& planningRecoveryBudgetExhausted(session, agent);
|
|
3618
|
+
return {
|
|
3619
|
+
advanced: false,
|
|
3620
|
+
blocked: true,
|
|
3621
|
+
reason: council.code || 'COUNCIL_GATE_REWORK_REQUIRED',
|
|
3622
|
+
message: council.message || `The ${council.result?.stage || council.stage} planning council recorded accepted blocking findings. Rework the frozen artifacts before advancing.`,
|
|
3623
|
+
stage: council.result?.stage || council.stage,
|
|
3624
|
+
gate_id: council.result?.gate_id || council.gate_id || null,
|
|
3625
|
+
blockers: council.blockers || council.result?.decision?.blocking_findings || [],
|
|
3626
|
+
...(reconciliationBudgetExhausted ? { recovery_budget_exhausted: true } : {}),
|
|
3627
|
+
action_required: {
|
|
3628
|
+
owner: 'planning artifact owner',
|
|
3629
|
+
next_step: agent === 'qa-planning'
|
|
3630
|
+
? reconciliationBudgetExhausted
|
|
3631
|
+
? 'Automatic recovery budgets are exhausted: rejection, council revalidation and every reconciliation target were consumed without a QA authorization. No automatic command can approve this Planning. Reopen Planning with new technical direction through deviation --type rollback --target <detail|architect|ux> --confirm true, correct the accepted findings, then run genuine fresh QA and the council again.'
|
|
3632
|
+
: revisionCapability
|
|
3633
|
+
? revisionCapability.available
|
|
3634
|
+
? `Automatic recovery budgets remain exhausted. A distinct explicit controller direction may use revise-planning-contract${revisionCapability.previousRevisionId ? ` --previous-revision ${revisionCapability.previousRevisionId}` : ''} with this exact held claim, finished-at, council-gate-id and closed direction JSON. Preserve current bytes until genuine owners reopen; this direction approves no Planning content.`
|
|
3635
|
+
: `Preserve the held QA, council findings and recovery history. Contract reopening is unavailable at this boundary (${revisionCapability.code}); resolve that boundary before requesting a direction.`
|
|
3636
|
+
: session.agents[agent]?.council_revalidations?.length
|
|
3637
|
+
? 'Correct the accepted findings without changing the held QA report. Use reconcile-planning with the exact held claim, finished-at and this council-gate-id. It assesses only readiness for genuine fresh QA; it cannot approve Planning.'
|
|
3638
|
+
: 'resolve the accepted findings without changing the held QA report. Use reject-result while its budget remains; at the limit use revalidate-qa with the exact held claim, finished-at and this council-gate-id. Execute genuine QA, then advance.'
|
|
3639
|
+
: 'resolve the accepted high or critical findings, then re-run the canonical advance after the artifact delta',
|
|
3640
|
+
},
|
|
3641
|
+
};
|
|
3642
|
+
}
|
|
3643
|
+
} catch (error) {
|
|
3644
|
+
return {
|
|
3645
|
+
advanced: false,
|
|
3646
|
+
blocked: true,
|
|
3647
|
+
reason: error.code || 'COUNCIL_GATE_EXECUTION_FAILED',
|
|
3648
|
+
message: error.message,
|
|
3649
|
+
action_required: error.code === 'COUNCIL_GATE_PROMPT_SIZE_LIMIT' ? {
|
|
3650
|
+
owner: 'planning artifact owner',
|
|
3651
|
+
next_step: 'reduce the frozen planning artifacts to fit the council material budget, preserve explicit scope and criteria, then re-run advance. No provider was invoked and no provider reconfiguration is needed.',
|
|
3652
|
+
...error.details,
|
|
3653
|
+
} : {
|
|
3654
|
+
owner: 'installation owner',
|
|
3655
|
+
next_step: 'restore the selected council provider or explicitly reconfigure the installation. No provider fallback was used.',
|
|
3656
|
+
},
|
|
3657
|
+
};
|
|
3658
|
+
}
|
|
3659
|
+
}
|
|
3660
|
+
|
|
3661
|
+
// A Tasks completion only becomes an executable RAIL authority after the
|
|
3662
|
+
// execution-design council approved the exact frozen planning bundle above.
|
|
3663
|
+
// Keep the compiler output in memory until that point, so a blocked council
|
|
3664
|
+
// cannot leave a dispatchable handoff behind for a manual RAIL invocation.
|
|
3665
|
+
if (compiledRailHandoff) {
|
|
3666
|
+
try {
|
|
3667
|
+
const council = resolveRuntimeCouncilGatePolicyV2({ artifact: runtimeV2, stage: 'execution-design' });
|
|
3668
|
+
if (council.reason === 'GUIDED_COUNCIL_REQUIRED') {
|
|
3669
|
+
return {
|
|
3670
|
+
advanced: false,
|
|
3671
|
+
blocked: true,
|
|
3672
|
+
reason: 'GUIDED_COUNCIL_REQUIRED',
|
|
3673
|
+
message: 'this installation has no autonomous council quorum. Reconfigure it with two selected review providers before creating a dispatchable RAIL handoff.',
|
|
3674
|
+
};
|
|
3675
|
+
}
|
|
3676
|
+
const persisted = persistRailHandoff({ projectDir, handoff: compiledRailHandoff });
|
|
3677
|
+
const council_binding = council.enabled
|
|
3678
|
+
? persistRailHandoffCouncilBinding({
|
|
3679
|
+
projectDir,
|
|
3680
|
+
handoff: compiledRailHandoff,
|
|
3681
|
+
artifact: runtimeV2,
|
|
3682
|
+
policy: council.policy,
|
|
3683
|
+
})
|
|
3684
|
+
: undefined;
|
|
3685
|
+
handoffData.rail_handoff = Object.freeze({
|
|
3686
|
+
...persisted,
|
|
3687
|
+
...(council_binding ? { council_binding } : {}),
|
|
3688
|
+
});
|
|
3689
|
+
} catch (error) {
|
|
3690
|
+
return {
|
|
3691
|
+
advanced: false,
|
|
3692
|
+
blocked: true,
|
|
3693
|
+
reason: error.code || 'RAIL_HANDOFF_PERSISTENCE_FAILED',
|
|
3694
|
+
message: error.message,
|
|
3695
|
+
expected_artifact: PLANNING_V2_TASKS_CANDIDATE_PATH,
|
|
3696
|
+
action_required: {
|
|
3697
|
+
owner: 'Tasks and QA Planning agents',
|
|
3698
|
+
artifact: PLANNING_V2_TASKS_CANDIDATE_PATH,
|
|
3699
|
+
requirements: ['source artifacts', 'task IDs and dependencies', 'Given-When-Then criteria', 'execution scopes', 'AI and human estimates', 'ClickUp references'],
|
|
3700
|
+
},
|
|
3701
|
+
};
|
|
3702
|
+
}
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
// Handoff/council awaits above may have yielded after the first applicability
|
|
3706
|
+
// snapshot. Revalidate the SAME approved subject and reference conflicts
|
|
3707
|
+
// immediately before the synchronous session mutation. Never commit a stale
|
|
3708
|
+
// visual exemption after an upstream edit.
|
|
3709
|
+
if (visualApplicability) {
|
|
3710
|
+
const current = resolveNonVisualUxApplicability(projectDir, runtimeV2);
|
|
3711
|
+
if (!current || current.sha256 !== visualApplicability.sha256
|
|
3712
|
+
|| current.discovery_gate_id !== visualApplicability.discovery_gate_id) {
|
|
3713
|
+
return {
|
|
3714
|
+
advanced: false, blocked: true, reason: 'ux_visual_applicability_changed',
|
|
3715
|
+
message: 'Visual applicability changed before UX completion. Review the current Brief and visual references; no completion was recorded.',
|
|
3213
3716
|
};
|
|
3214
3717
|
}
|
|
3215
3718
|
}
|
|
3216
3719
|
|
|
3217
3720
|
// Record completion (merges Decision Trail entries from handoff into session.yaml)
|
|
3218
|
-
|
|
3721
|
+
runRuntimeSecurityTestHook('beforeAgentCompletionCommit', { projectDir, agent });
|
|
3722
|
+
const completion = recordAgentCompletion(projectDir, { agent, status, score, handoffData,
|
|
3723
|
+
...(runtimeV2 ? { expectedResult: { claimId: agentState.execution_claim_id, finishedAt: agentState.execution_finished_at } } : {}),
|
|
3724
|
+
});
|
|
3725
|
+
if (!completion.saved) return errorResult(completion.error, completion.code || 'SESSION_WRITE_FAILED');
|
|
3219
3726
|
recordEvent(projectDir, EventType.AGENT_COMPLETED, agent, { score, status });
|
|
3220
3727
|
|
|
3221
3728
|
// A v2 task graph has become an immutable RAIL handoff. Do not silently
|
|
@@ -3270,7 +3777,8 @@ async function handleAdvance(projectDir, args) {
|
|
|
3270
3777
|
// unapplied. If so, return an action_required response with the 3-option
|
|
3271
3778
|
// decision payload. qa-planning stays marked complete; the user's next
|
|
3272
3779
|
// advance call must carry --decision apply|skip|dryrun.
|
|
3273
|
-
if (status === 'completed' && score >= 95 && agent === 'qa-planning'
|
|
3780
|
+
if (status === 'completed' && score >= 95 && agent === 'qa-planning'
|
|
3781
|
+
&& (!runtimeV2 || qaVerdict === 'approved')) {
|
|
3274
3782
|
const { session: postQa } = loadSession(projectDir);
|
|
3275
3783
|
const gate = checkScaffoldGate(postQa || session);
|
|
3276
3784
|
if (gate.fires) {
|
|
@@ -3292,6 +3800,8 @@ async function handleAdvance(projectDir, args) {
|
|
|
3292
3800
|
|
|
3293
3801
|
const advanceResult = advancePipeline(pipelineState, agent, {
|
|
3294
3802
|
score,
|
|
3803
|
+
qaVerdict,
|
|
3804
|
+
correctionTarget,
|
|
3295
3805
|
handoffData,
|
|
3296
3806
|
findings: handoffData?.decision_trail_entries || [],
|
|
3297
3807
|
});
|
|
@@ -3419,7 +3929,7 @@ async function handleAdvance(projectDir, args) {
|
|
|
3419
3929
|
max_cycles: 2,
|
|
3420
3930
|
findings_count: (c.findings || []).length,
|
|
3421
3931
|
pipeline_progress: getPipelineProgress(advanceResult.state || pipelineState),
|
|
3422
|
-
status_summary: `${agent}
|
|
3932
|
+
status_summary: `${agent} requires correction (score ${c.score ?? 'n/a'}). Fault origin: ${c.faultOrigin}. Re-opening ${c.target} (correction cycle ${c.cycle} of 2). The controller must invoke ${c.target} on its bound provider with the findings.`,
|
|
3423
3933
|
},
|
|
3424
3934
|
};
|
|
3425
3935
|
}
|
|
@@ -3474,7 +3984,10 @@ async function handleAdvance(projectDir, args) {
|
|
|
3474
3984
|
}
|
|
3475
3985
|
|
|
3476
3986
|
const next = await handleNext(projectDir);
|
|
3477
|
-
return {
|
|
3987
|
+
return {
|
|
3988
|
+
advanced: true, agent_completed: agent, score, phase_transition: phaseTransition, next,
|
|
3989
|
+
...(visualApplicability ? { visual_applicability: visualApplicability } : {}),
|
|
3990
|
+
};
|
|
3478
3991
|
}
|
|
3479
3992
|
|
|
3480
3993
|
async function handleInit(projectDir, args) {
|
|
@@ -3766,7 +4279,7 @@ async function handleStatus(projectDir) {
|
|
|
3766
4279
|
execution_mode: session.execution_mode || 'interactive',
|
|
3767
4280
|
active_harness: session.active_harness || null,
|
|
3768
4281
|
current_execution_binding: session.current_execution_binding || null,
|
|
3769
|
-
pending_interaction: session.pending_interaction
|
|
4282
|
+
pending_interaction: summarizePendingInteraction(session.pending_interaction),
|
|
3770
4283
|
};
|
|
3771
4284
|
}
|
|
3772
4285
|
|
|
@@ -3832,6 +4345,24 @@ async function handleDeviation(projectDir, args) {
|
|
|
3832
4345
|
const { loaded, session } = loadSession(projectDir);
|
|
3833
4346
|
if (!loaded || !session) return errorResult('No session found', 'NO_SESSION');
|
|
3834
4347
|
|
|
4348
|
+
if (type === 'rollback' && loadRuntimeInstallationV2(projectDir)) {
|
|
4349
|
+
let impact;
|
|
4350
|
+
try { impact = inspectPlanningCorrectionRollback(projectDir, session, target); }
|
|
4351
|
+
catch (error) { return errorResult(error.message, error.code || 'PLANNING_CORRECTION_INVALID'); }
|
|
4352
|
+
if (!confirm) return { impact, applied: false, changes: [], new_state: null };
|
|
4353
|
+
runRuntimeSecurityTestHook('beforePlanningCorrectionCommit', { projectDir, target });
|
|
4354
|
+
const result = rollbackPlanningCorrection(projectDir, target);
|
|
4355
|
+
if (!result.saved || !result.applied) return errorResult(result.error, result.code || 'SESSION_WRITE_FAILED');
|
|
4356
|
+
let projection;
|
|
4357
|
+
try {
|
|
4358
|
+
projection = writeSessionLock(projectDir, target, {
|
|
4359
|
+
phase: result.session.mode, mode: result.session.execution_mode || 'interactive',
|
|
4360
|
+
});
|
|
4361
|
+
} catch (error) { projection = { projected: false, error: error.message }; }
|
|
4362
|
+
return { impact: result.impact, applied: true, changes: result.changes,
|
|
4363
|
+
new_state: { current_agent: result.session.current_agent }, projection };
|
|
4364
|
+
}
|
|
4365
|
+
|
|
3835
4366
|
const pipelineState = sessionToPipelineState(session, projectDir);
|
|
3836
4367
|
|
|
3837
4368
|
const details = { targetAgent: target };
|
|
@@ -4997,8 +5528,21 @@ function handleAssessCoverage(args) {
|
|
|
4997
5528
|
// Main entry point
|
|
4998
5529
|
// ---------------------------------------------------------------------------
|
|
4999
5530
|
|
|
5000
|
-
export async function runOrchestrate(subCommand, argv, projectDir) {
|
|
5531
|
+
export async function runOrchestrate(subCommand, argv, projectDir, { write = writeOutput } = {}) {
|
|
5532
|
+
let result;
|
|
5533
|
+
try {
|
|
5534
|
+
result = await withUpgradeMaintenance(projectDir, () => runOrchestrateUnderMaintenance(subCommand, argv, projectDir));
|
|
5535
|
+
} catch (err) {
|
|
5536
|
+
result = errorResult(`Orchestrate error: ${err.message}`, err.code || 'INTERNAL_ERROR');
|
|
5537
|
+
process.exitCode = 1;
|
|
5538
|
+
}
|
|
5539
|
+
write(result);
|
|
5540
|
+
}
|
|
5541
|
+
|
|
5542
|
+
async function runOrchestrateUnderMaintenance(subCommand, argv, projectDir) {
|
|
5001
5543
|
const args = parseArgs(argv);
|
|
5544
|
+
let result;
|
|
5545
|
+
const output = (value) => { result = value; };
|
|
5002
5546
|
|
|
5003
5547
|
try {
|
|
5004
5548
|
switch (subCommand) {
|
|
@@ -5008,6 +5552,18 @@ export async function runOrchestrate(subCommand, argv, projectDir) {
|
|
|
5008
5552
|
case 'advance':
|
|
5009
5553
|
output(await handleAdvance(projectDir, args));
|
|
5010
5554
|
break;
|
|
5555
|
+
case 'reject-result':
|
|
5556
|
+
output(await handleRejectResult(projectDir, args));
|
|
5557
|
+
break;
|
|
5558
|
+
case 'revalidate-qa':
|
|
5559
|
+
output(await handleRevalidateQa(projectDir, args));
|
|
5560
|
+
break;
|
|
5561
|
+
case 'reconcile-planning':
|
|
5562
|
+
output(await handleReconcilePlanning(projectDir, args));
|
|
5563
|
+
break;
|
|
5564
|
+
case 'revise-planning-contract':
|
|
5565
|
+
output(await handleRevisePlanningContract(projectDir, args));
|
|
5566
|
+
break;
|
|
5011
5567
|
case 'init':
|
|
5012
5568
|
output(await handleInit(projectDir, args));
|
|
5013
5569
|
break;
|
|
@@ -5075,11 +5631,12 @@ export async function runOrchestrate(subCommand, argv, projectDir) {
|
|
|
5075
5631
|
output(await handleRunScriptTask(projectDir, args));
|
|
5076
5632
|
break;
|
|
5077
5633
|
default:
|
|
5078
|
-
output(errorResult(`Unknown sub-command: ${subCommand}. Valid: next, advance, init, validate-handoff, status, deviation, exit, providers, detect-flow, backlog, qa-plan-score, qa-impl-score, scan, spawn-team, team-status, team-dissolve`, 'UNKNOWN_COMMAND'));
|
|
5634
|
+
output(errorResult(`Unknown sub-command: ${subCommand}. Valid: next, advance, reject-result, revalidate-qa, reconcile-planning, revise-planning-contract, init, validate-handoff, status, deviation, exit, providers, detect-flow, backlog, qa-plan-score, qa-impl-score, scan, spawn-team, team-status, team-dissolve`, 'UNKNOWN_COMMAND'));
|
|
5079
5635
|
break;
|
|
5080
5636
|
}
|
|
5081
5637
|
} catch (err) {
|
|
5082
5638
|
output(errorResult(`Orchestrate error: ${err.message}`, err.code || 'INTERNAL_ERROR'));
|
|
5083
5639
|
process.exitCode = 1;
|
|
5084
5640
|
}
|
|
5641
|
+
return result;
|
|
5085
5642
|
}
|