agentxchain 0.8.8 → 2.1.1
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/README.md +126 -142
- package/bin/agentxchain.js +186 -5
- package/dashboard/app.js +305 -0
- package/dashboard/components/blocked.js +145 -0
- package/dashboard/components/cross-repo.js +126 -0
- package/dashboard/components/gate.js +311 -0
- package/dashboard/components/hooks.js +177 -0
- package/dashboard/components/initiative.js +147 -0
- package/dashboard/components/ledger.js +165 -0
- package/dashboard/components/timeline.js +222 -0
- package/dashboard/index.html +352 -0
- package/package.json +14 -6
- package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
- package/scripts/publish-from-tag.sh +88 -0
- package/scripts/release-postflight.sh +231 -0
- package/scripts/release-preflight.sh +167 -0
- package/src/commands/accept-turn.js +160 -0
- package/src/commands/approve-completion.js +80 -0
- package/src/commands/approve-transition.js +85 -0
- package/src/commands/dashboard.js +70 -0
- package/src/commands/init.js +516 -0
- package/src/commands/migrate.js +348 -0
- package/src/commands/multi.js +549 -0
- package/src/commands/plugin.js +157 -0
- package/src/commands/reject-turn.js +204 -0
- package/src/commands/resume.js +389 -0
- package/src/commands/status.js +196 -3
- package/src/commands/step.js +947 -0
- package/src/commands/template-list.js +33 -0
- package/src/commands/template-set.js +279 -0
- package/src/commands/validate.js +20 -11
- package/src/commands/verify.js +71 -0
- package/src/lib/adapters/api-proxy-adapter.js +1076 -0
- package/src/lib/adapters/local-cli-adapter.js +337 -0
- package/src/lib/adapters/manual-adapter.js +169 -0
- package/src/lib/blocked-state.js +94 -0
- package/src/lib/config.js +97 -1
- package/src/lib/context-compressor.js +121 -0
- package/src/lib/context-section-parser.js +220 -0
- package/src/lib/coordinator-acceptance.js +428 -0
- package/src/lib/coordinator-config.js +461 -0
- package/src/lib/coordinator-dispatch.js +276 -0
- package/src/lib/coordinator-gates.js +487 -0
- package/src/lib/coordinator-hooks.js +239 -0
- package/src/lib/coordinator-recovery.js +523 -0
- package/src/lib/coordinator-state.js +365 -0
- package/src/lib/cross-repo-context.js +247 -0
- package/src/lib/dashboard/bridge-server.js +284 -0
- package/src/lib/dashboard/file-watcher.js +93 -0
- package/src/lib/dashboard/state-reader.js +96 -0
- package/src/lib/dispatch-bundle.js +568 -0
- package/src/lib/dispatch-manifest.js +252 -0
- package/src/lib/gate-evaluator.js +285 -0
- package/src/lib/governed-state.js +2139 -0
- package/src/lib/governed-templates.js +145 -0
- package/src/lib/hook-runner.js +788 -0
- package/src/lib/normalized-config.js +539 -0
- package/src/lib/plugin-config-schema.js +192 -0
- package/src/lib/plugins.js +692 -0
- package/src/lib/protocol-conformance.js +291 -0
- package/src/lib/reference-conformance-adapter.js +717 -0
- package/src/lib/repo-observer.js +597 -0
- package/src/lib/repo.js +0 -31
- package/src/lib/schema.js +121 -0
- package/src/lib/schemas/turn-result.schema.json +205 -0
- package/src/lib/token-budget.js +206 -0
- package/src/lib/token-counter.js +27 -0
- package/src/lib/turn-paths.js +67 -0
- package/src/lib/turn-result-validator.js +496 -0
- package/src/lib/validation.js +137 -0
- package/src/templates/governed/api-service.json +31 -0
- package/src/templates/governed/cli-tool.json +30 -0
- package/src/templates/governed/generic.json +10 -0
- package/src/templates/governed/web-app.json +30 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatch manifest integrity — content-addressed bundle verification.
|
|
3
|
+
*
|
|
4
|
+
* Implements V2.1-F1: each finalized dispatch bundle gets a MANIFEST.json
|
|
5
|
+
* containing SHA-256 digests and byte sizes for every file in the bundle.
|
|
6
|
+
* Adapters verify the manifest before consuming bundle files.
|
|
7
|
+
*
|
|
8
|
+
* Timing:
|
|
9
|
+
* writeDispatchBundle() → core files written
|
|
10
|
+
* after_dispatch hooks → supplements added
|
|
11
|
+
* finalizeDispatchManifest() → MANIFEST.json sealed
|
|
12
|
+
* verifyDispatchManifest() → adapter checks before execution
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash } from 'crypto';
|
|
16
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, statSync } from 'fs';
|
|
17
|
+
import { join, relative } from 'path';
|
|
18
|
+
import { getDispatchTurnDir, getDispatchManifestPath } from './turn-paths.js';
|
|
19
|
+
|
|
20
|
+
const MANIFEST_VERSION = '1.0';
|
|
21
|
+
const MANIFEST_FILENAME = 'MANIFEST.json';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Finalize a dispatch bundle by writing MANIFEST.json with content-addressed entries.
|
|
25
|
+
*
|
|
26
|
+
* Must be called AFTER writeDispatchBundle() and after_dispatch hooks have completed.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} root - project root directory
|
|
29
|
+
* @param {string} turnId - turn identifier
|
|
30
|
+
* @param {{ run_id: string, role: string }} identity - turn identity for manifest metadata
|
|
31
|
+
* @returns {{ ok: boolean, manifestPath?: string, fileCount?: number, error?: string }}
|
|
32
|
+
*/
|
|
33
|
+
export function finalizeDispatchManifest(root, turnId, identity) {
|
|
34
|
+
const bundleDir = join(root, getDispatchTurnDir(turnId));
|
|
35
|
+
|
|
36
|
+
if (!existsSync(bundleDir)) {
|
|
37
|
+
return { ok: false, error: 'Bundle directory does not exist' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const entries = scanBundleFiles(bundleDir);
|
|
41
|
+
if (entries.length === 0) {
|
|
42
|
+
return { ok: false, error: 'Bundle directory is empty — no files to manifest' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const manifest = {
|
|
46
|
+
manifest_version: MANIFEST_VERSION,
|
|
47
|
+
run_id: identity.run_id,
|
|
48
|
+
turn_id: turnId,
|
|
49
|
+
role: identity.role,
|
|
50
|
+
finalized_at: new Date().toISOString(),
|
|
51
|
+
files: entries,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const manifestPath = join(root, getDispatchManifestPath(turnId));
|
|
55
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
56
|
+
|
|
57
|
+
return { ok: true, manifestPath, fileCount: entries.length };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Return whether a finalized manifest exists for the dispatch bundle.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} root
|
|
64
|
+
* @param {string} turnId
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
export function hasDispatchManifest(root, turnId) {
|
|
68
|
+
return existsSync(join(root, getDispatchManifestPath(turnId)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Verify a dispatch bundle against its MANIFEST.json.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} root - project root directory
|
|
75
|
+
* @param {string} turnId - turn identifier
|
|
76
|
+
* @returns {{ ok: boolean, errors?: Array<{ type: string, path?: string, detail: string }>, manifest?: object }}
|
|
77
|
+
*/
|
|
78
|
+
export function verifyDispatchManifest(root, turnId) {
|
|
79
|
+
const bundleDir = join(root, getDispatchTurnDir(turnId));
|
|
80
|
+
const manifestPath = join(root, getDispatchManifestPath(turnId));
|
|
81
|
+
|
|
82
|
+
// Check manifest exists
|
|
83
|
+
if (!existsSync(manifestPath)) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
errors: [{ type: 'missing_manifest', detail: 'MANIFEST.json does not exist in bundle directory' }],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Parse manifest
|
|
91
|
+
let manifest;
|
|
92
|
+
try {
|
|
93
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
errors: [{ type: 'invalid_manifest', detail: `MANIFEST.json is malformed: ${err.message}` }],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Validate schema
|
|
102
|
+
if (!manifest.manifest_version || !Array.isArray(manifest.files)) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
errors: [{ type: 'invalid_manifest', detail: 'MANIFEST.json missing required fields (manifest_version, files)' }],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const errors = [];
|
|
110
|
+
|
|
111
|
+
// Check each declared file
|
|
112
|
+
for (const entry of manifest.files) {
|
|
113
|
+
const filePath = join(bundleDir, entry.path);
|
|
114
|
+
|
|
115
|
+
if (!existsSync(filePath)) {
|
|
116
|
+
errors.push({ type: 'missing_file', path: entry.path, detail: `Declared file "${entry.path}" is missing from bundle` });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const content = readFileSync(filePath);
|
|
121
|
+
const actualSize = content.length;
|
|
122
|
+
const actualDigest = createHash('sha256').update(content).digest('hex');
|
|
123
|
+
|
|
124
|
+
if (actualSize !== entry.size) {
|
|
125
|
+
errors.push({
|
|
126
|
+
type: 'size_mismatch',
|
|
127
|
+
path: entry.path,
|
|
128
|
+
detail: `"${entry.path}" size mismatch: manifest=${entry.size}, actual=${actualSize}`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (actualDigest !== entry.sha256) {
|
|
133
|
+
errors.push({
|
|
134
|
+
type: 'digest_mismatch',
|
|
135
|
+
path: entry.path,
|
|
136
|
+
detail: `"${entry.path}" SHA-256 mismatch: manifest=${entry.sha256}, actual=${actualDigest}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Check for unexpected files
|
|
142
|
+
const declaredPaths = new Set(manifest.files.map((f) => f.path));
|
|
143
|
+
const actualFiles = scanFilenames(bundleDir);
|
|
144
|
+
for (const actual of actualFiles) {
|
|
145
|
+
if (!declaredPaths.has(actual)) {
|
|
146
|
+
errors.push({
|
|
147
|
+
type: 'unexpected_file',
|
|
148
|
+
path: actual,
|
|
149
|
+
detail: `Unexpected file "${actual}" not declared in manifest`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (errors.length > 0) {
|
|
155
|
+
return { ok: false, errors, manifest };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { ok: true, manifest };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Verify dispatch bundle integrity according to adapter consumption policy.
|
|
163
|
+
*
|
|
164
|
+
* Policy:
|
|
165
|
+
* - default: verify automatically when MANIFEST.json exists
|
|
166
|
+
* - verifyManifest: true → manifest is required and verified
|
|
167
|
+
* - skipManifestVerification: true → explicit escape hatch; skip entirely
|
|
168
|
+
*
|
|
169
|
+
* @param {string} root
|
|
170
|
+
* @param {string} turnId
|
|
171
|
+
* @param {{ verifyManifest?: boolean, skipManifestVerification?: boolean }} [options]
|
|
172
|
+
* @returns {{ ok: boolean, skipped?: boolean, manifestPresent?: boolean, error?: string, manifest?: object, errors?: Array<{ type: string, path?: string, detail: string }> }}
|
|
173
|
+
*/
|
|
174
|
+
export function verifyDispatchManifestForAdapter(root, turnId, options = {}) {
|
|
175
|
+
const requireManifest = options.verifyManifest === true;
|
|
176
|
+
const skipManifestVerification = options.skipManifestVerification === true;
|
|
177
|
+
|
|
178
|
+
if (skipManifestVerification) {
|
|
179
|
+
return { ok: true, skipped: true, manifestPresent: hasDispatchManifest(root, turnId) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const manifestPresent = hasDispatchManifest(root, turnId);
|
|
183
|
+
if (!manifestPresent && !requireManifest) {
|
|
184
|
+
return { ok: true, skipped: true, manifestPresent: false };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const manifestCheck = verifyDispatchManifest(root, turnId);
|
|
188
|
+
if (!manifestCheck.ok) {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
manifestPresent,
|
|
192
|
+
errors: manifestCheck.errors,
|
|
193
|
+
error: formatDispatchManifestErrors(manifestCheck.errors),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
ok: true,
|
|
199
|
+
manifestPresent,
|
|
200
|
+
manifest: manifestCheck.manifest,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Format structured manifest errors for CLI/operator display.
|
|
206
|
+
*
|
|
207
|
+
* @param {Array<{ type: string, detail: string }>} errors
|
|
208
|
+
* @returns {string}
|
|
209
|
+
*/
|
|
210
|
+
export function formatDispatchManifestErrors(errors = []) {
|
|
211
|
+
return errors.map((entry) => `${entry.type}: ${entry.detail}`).join('; ');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Scan a bundle directory and return manifest file entries.
|
|
218
|
+
* Excludes MANIFEST.json itself.
|
|
219
|
+
*/
|
|
220
|
+
function scanBundleFiles(bundleDir) {
|
|
221
|
+
const entries = [];
|
|
222
|
+
const files = readdirSync(bundleDir);
|
|
223
|
+
|
|
224
|
+
for (const filename of files) {
|
|
225
|
+
if (filename === MANIFEST_FILENAME) continue;
|
|
226
|
+
|
|
227
|
+
const filePath = join(bundleDir, filename);
|
|
228
|
+
const stat = statSync(filePath);
|
|
229
|
+
if (!stat.isFile()) continue;
|
|
230
|
+
|
|
231
|
+
const content = readFileSync(filePath);
|
|
232
|
+
const digest = createHash('sha256').update(content).digest('hex');
|
|
233
|
+
|
|
234
|
+
entries.push({
|
|
235
|
+
path: filename,
|
|
236
|
+
sha256: digest,
|
|
237
|
+
size: content.length,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Sort for deterministic manifest output
|
|
242
|
+
entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
243
|
+
return entries;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Get all filenames in a bundle directory, excluding MANIFEST.json.
|
|
248
|
+
*/
|
|
249
|
+
function scanFilenames(bundleDir) {
|
|
250
|
+
return readdirSync(bundleDir)
|
|
251
|
+
.filter((f) => f !== MANIFEST_FILENAME && statSync(join(bundleDir, f)).isFile());
|
|
252
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate evaluator — pure library for phase exit gate evaluation.
|
|
3
|
+
*
|
|
4
|
+
* Implements the frozen spec (§40-§43):
|
|
5
|
+
*
|
|
6
|
+
* - evaluatePhaseExit() is a pure function: (state, config, acceptedTurn, root) → GateResult
|
|
7
|
+
* - Phase advancement happens only on accepted turns
|
|
8
|
+
* - phase_transition_request must be explicitly present in the turn result
|
|
9
|
+
* - Human-approval gates pause the run instead of auto-advancing
|
|
10
|
+
*
|
|
11
|
+
* Rules from §43:
|
|
12
|
+
* Rule 1: No phase_transition_request → stay in current phase
|
|
13
|
+
* Rule 2: Unknown target phase → gate_error
|
|
14
|
+
* Rule 3: Request present but gate fails → accept turn, stay in phase
|
|
15
|
+
* Rule 4: Request present + gate passes (no human approval) → advance immediately
|
|
16
|
+
* Rule 5: Request present + gate passes + requires_human_approval → pause with pending_phase_transition
|
|
17
|
+
* Rule 6: Acceptance never auto-assigns the next turn
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync } from 'fs';
|
|
21
|
+
import { join } from 'path';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Evaluate whether the current phase exit gate is satisfied.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} params
|
|
27
|
+
* @param {object} params.state - current run state (post-acceptance)
|
|
28
|
+
* @param {object} params.config - normalized config
|
|
29
|
+
* @param {object} params.acceptedTurn - the accepted turn result
|
|
30
|
+
* @param {string} params.root - project root directory
|
|
31
|
+
* @returns {GateResult}
|
|
32
|
+
*
|
|
33
|
+
* @typedef {object} GateResult
|
|
34
|
+
* @property {string|null} gate_id - the gate that was evaluated, or null if no gate
|
|
35
|
+
* @property {boolean} passed - whether all predicates passed
|
|
36
|
+
* @property {boolean} blocked_by_human_approval - gate passed structurally but needs human sign-off
|
|
37
|
+
* @property {string[]} reasons - human-readable failure reasons
|
|
38
|
+
* @property {string[]} missing_files - files required by gate but not found
|
|
39
|
+
* @property {boolean} missing_verification - verification required but not passed
|
|
40
|
+
* @property {string|null} next_phase - the target phase if transition was requested and gate passed
|
|
41
|
+
* @property {string|null} transition_request - the raw phase_transition_request value
|
|
42
|
+
* @property {'no_request'|'unknown_phase'|'gate_failed'|'advance'|'awaiting_human_approval'|'no_gate'} action
|
|
43
|
+
*/
|
|
44
|
+
export function evaluatePhaseExit({ state, config, acceptedTurn, root }) {
|
|
45
|
+
const currentPhase = state.phase;
|
|
46
|
+
const transitionRequest = acceptedTurn.phase_transition_request || null;
|
|
47
|
+
|
|
48
|
+
const baseResult = {
|
|
49
|
+
gate_id: null,
|
|
50
|
+
passed: false,
|
|
51
|
+
blocked_by_human_approval: false,
|
|
52
|
+
reasons: [],
|
|
53
|
+
missing_files: [],
|
|
54
|
+
missing_verification: false,
|
|
55
|
+
next_phase: null,
|
|
56
|
+
transition_request: transitionRequest,
|
|
57
|
+
action: 'no_request',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Rule 1: No phase_transition_request → stay in current phase
|
|
61
|
+
if (!transitionRequest) {
|
|
62
|
+
return { ...baseResult, action: 'no_request' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Rule 2: Unknown target phase → error
|
|
66
|
+
const routing = config.routing || {};
|
|
67
|
+
if (!routing[transitionRequest]) {
|
|
68
|
+
return {
|
|
69
|
+
...baseResult,
|
|
70
|
+
action: 'unknown_phase',
|
|
71
|
+
reasons: [`Requested phase "${transitionRequest}" does not exist in routing config`],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Find the exit gate for the current phase
|
|
76
|
+
const currentRouting = routing[currentPhase];
|
|
77
|
+
if (!currentRouting || !currentRouting.exit_gate) {
|
|
78
|
+
// No gate defined for current phase → auto-advance
|
|
79
|
+
return {
|
|
80
|
+
...baseResult,
|
|
81
|
+
passed: true,
|
|
82
|
+
next_phase: transitionRequest,
|
|
83
|
+
action: 'advance',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const gateId = currentRouting.exit_gate;
|
|
88
|
+
const gateDef = (config.gates || {})[gateId];
|
|
89
|
+
|
|
90
|
+
if (!gateDef) {
|
|
91
|
+
// Gate referenced but not defined → treat as no gate (advance)
|
|
92
|
+
return {
|
|
93
|
+
...baseResult,
|
|
94
|
+
gate_id: gateId,
|
|
95
|
+
passed: true,
|
|
96
|
+
next_phase: transitionRequest,
|
|
97
|
+
action: 'advance',
|
|
98
|
+
reasons: [`Gate "${gateId}" referenced by routing but not defined in gates config — treated as open`],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Evaluate gate predicates
|
|
103
|
+
const result = {
|
|
104
|
+
...baseResult,
|
|
105
|
+
gate_id: gateId,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const failures = [];
|
|
109
|
+
|
|
110
|
+
// Predicate: requires_files
|
|
111
|
+
if (gateDef.requires_files && Array.isArray(gateDef.requires_files)) {
|
|
112
|
+
for (const filePath of gateDef.requires_files) {
|
|
113
|
+
if (!existsSync(join(root, filePath))) {
|
|
114
|
+
result.missing_files.push(filePath);
|
|
115
|
+
failures.push(`Required file missing: ${filePath}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Predicate: requires_verification_pass
|
|
121
|
+
if (gateDef.requires_verification_pass) {
|
|
122
|
+
const verificationStatus = acceptedTurn.verification?.status;
|
|
123
|
+
if (verificationStatus !== 'pass' && verificationStatus !== 'attested_pass') {
|
|
124
|
+
result.missing_verification = true;
|
|
125
|
+
failures.push(`Verification status is "${verificationStatus || 'missing'}", requires "pass" or "attested_pass"`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (failures.length > 0) {
|
|
130
|
+
// Rule 3: Gate fails → accept turn but stay in phase
|
|
131
|
+
result.passed = false;
|
|
132
|
+
result.reasons = failures;
|
|
133
|
+
result.action = 'gate_failed';
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// All structural predicates passed
|
|
138
|
+
result.passed = true;
|
|
139
|
+
result.next_phase = transitionRequest;
|
|
140
|
+
|
|
141
|
+
// Rule 5: requires_human_approval → pause instead of advancing
|
|
142
|
+
if (gateDef.requires_human_approval) {
|
|
143
|
+
result.blocked_by_human_approval = true;
|
|
144
|
+
result.action = 'awaiting_human_approval';
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Rule 4: Gate passes, no human approval needed → advance
|
|
149
|
+
result.action = 'advance';
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Evaluate whether a run should complete (terminal state).
|
|
155
|
+
*
|
|
156
|
+
* Called when a turn result contains `run_completion_request: true` in the
|
|
157
|
+
* final phase. Uses the same gate predicate system as phase transitions.
|
|
158
|
+
*
|
|
159
|
+
* @param {object} params
|
|
160
|
+
* @param {object} params.state - current run state (post-acceptance)
|
|
161
|
+
* @param {object} params.config - normalized config
|
|
162
|
+
* @param {object} params.acceptedTurn - the accepted turn result
|
|
163
|
+
* @param {string} params.root - project root directory
|
|
164
|
+
* @returns {RunCompletionResult}
|
|
165
|
+
*
|
|
166
|
+
* @typedef {object} RunCompletionResult
|
|
167
|
+
* @property {string|null} gate_id - the gate evaluated, or null
|
|
168
|
+
* @property {boolean} passed - whether all predicates passed
|
|
169
|
+
* @property {boolean} blocked_by_human_approval - gate passed structurally but needs human sign-off
|
|
170
|
+
* @property {string[]} reasons - human-readable failure reasons
|
|
171
|
+
* @property {string[]} missing_files - files required by gate but not found
|
|
172
|
+
* @property {boolean} missing_verification - verification required but not passed
|
|
173
|
+
* @property {'no_request'|'not_final_phase'|'gate_failed'|'complete'|'awaiting_human_approval'} action
|
|
174
|
+
*/
|
|
175
|
+
export function evaluateRunCompletion({ state, config, acceptedTurn, root }) {
|
|
176
|
+
const baseResult = {
|
|
177
|
+
gate_id: null,
|
|
178
|
+
passed: false,
|
|
179
|
+
blocked_by_human_approval: false,
|
|
180
|
+
reasons: [],
|
|
181
|
+
missing_files: [],
|
|
182
|
+
missing_verification: false,
|
|
183
|
+
action: 'no_request',
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Must have explicit run_completion_request
|
|
187
|
+
if (!acceptedTurn.run_completion_request) {
|
|
188
|
+
return { ...baseResult, action: 'no_request' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Must be in the final phase
|
|
192
|
+
const phases = getPhaseOrder(config.routing || {});
|
|
193
|
+
const currentPhase = state.phase;
|
|
194
|
+
if (phases.length > 0 && phases[phases.length - 1] !== currentPhase) {
|
|
195
|
+
return {
|
|
196
|
+
...baseResult,
|
|
197
|
+
action: 'not_final_phase',
|
|
198
|
+
reasons: [`Run completion requested but current phase "${currentPhase}" is not the final phase "${phases[phases.length - 1]}"`],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Find the exit gate for the current (final) phase
|
|
203
|
+
const currentRouting = (config.routing || {})[currentPhase];
|
|
204
|
+
if (!currentRouting || !currentRouting.exit_gate) {
|
|
205
|
+
// No gate → auto-complete
|
|
206
|
+
return { ...baseResult, passed: true, action: 'complete' };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const gateId = currentRouting.exit_gate;
|
|
210
|
+
const gateDef = (config.gates || {})[gateId];
|
|
211
|
+
|
|
212
|
+
if (!gateDef) {
|
|
213
|
+
// Gate referenced but not defined → complete
|
|
214
|
+
return {
|
|
215
|
+
...baseResult,
|
|
216
|
+
gate_id: gateId,
|
|
217
|
+
passed: true,
|
|
218
|
+
action: 'complete',
|
|
219
|
+
reasons: [`Gate "${gateId}" referenced but not defined — treated as open`],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Evaluate gate predicates (same logic as phase exit)
|
|
224
|
+
const result = { ...baseResult, gate_id: gateId };
|
|
225
|
+
const failures = [];
|
|
226
|
+
|
|
227
|
+
if (gateDef.requires_files && Array.isArray(gateDef.requires_files)) {
|
|
228
|
+
for (const filePath of gateDef.requires_files) {
|
|
229
|
+
if (!existsSync(join(root, filePath))) {
|
|
230
|
+
result.missing_files.push(filePath);
|
|
231
|
+
failures.push(`Required file missing: ${filePath}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (gateDef.requires_verification_pass) {
|
|
237
|
+
const verificationStatus = acceptedTurn.verification?.status;
|
|
238
|
+
if (verificationStatus !== 'pass' && verificationStatus !== 'attested_pass') {
|
|
239
|
+
result.missing_verification = true;
|
|
240
|
+
failures.push(`Verification status is "${verificationStatus || 'missing'}", requires "pass" or "attested_pass"`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (failures.length > 0) {
|
|
245
|
+
result.passed = false;
|
|
246
|
+
result.reasons = failures;
|
|
247
|
+
result.action = 'gate_failed';
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// All structural predicates passed
|
|
252
|
+
result.passed = true;
|
|
253
|
+
|
|
254
|
+
if (gateDef.requires_human_approval) {
|
|
255
|
+
result.blocked_by_human_approval = true;
|
|
256
|
+
result.action = 'awaiting_human_approval';
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
result.action = 'complete';
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Determine the phase ordering from routing config.
|
|
266
|
+
* Returns the list of phase names in declaration order.
|
|
267
|
+
*
|
|
268
|
+
* @param {object} routing - routing config object
|
|
269
|
+
* @returns {string[]}
|
|
270
|
+
*/
|
|
271
|
+
export function getPhaseOrder(routing) {
|
|
272
|
+
return Object.keys(routing || {});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Check if a phase is the final phase in the routing config.
|
|
277
|
+
*
|
|
278
|
+
* @param {string} phase - the phase to check
|
|
279
|
+
* @param {object} routing - routing config object
|
|
280
|
+
* @returns {boolean}
|
|
281
|
+
*/
|
|
282
|
+
export function isFinalPhase(phase, routing) {
|
|
283
|
+
const phases = getPhaseOrder(routing || {});
|
|
284
|
+
return phases.length > 0 && phases[phases.length - 1] === phase;
|
|
285
|
+
}
|