agentxchain 0.8.8 → 2.2.0
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 +136 -136
- 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 +858 -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,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staged turn-result validator — the acceptance boundary for governed mode.
|
|
3
|
+
*
|
|
4
|
+
* Implements the 5-stage validation pipeline from the frozen spec (§9):
|
|
5
|
+
* A. Schema validation — structural JSON correctness
|
|
6
|
+
* B. Assignment validation — identity fields match current state
|
|
7
|
+
* C. Artifact validation — write authority / file-change consistency
|
|
8
|
+
* D. Verification validation — evidence consistency
|
|
9
|
+
* E. Protocol compliance — challenge requirement, routing legality
|
|
10
|
+
*
|
|
11
|
+
* Each stage returns a typed error (§10 error taxonomy) or passes.
|
|
12
|
+
* The pipeline short-circuits on the first stage failure.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync } from 'fs';
|
|
16
|
+
import { join } from 'path';
|
|
17
|
+
import { getActiveTurn } from './governed-state.js';
|
|
18
|
+
|
|
19
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const STAGING_PATH = '.agentxchain/staging/turn-result.json';
|
|
22
|
+
|
|
23
|
+
const VALID_STATUSES = ['completed', 'blocked', 'needs_human', 'failed'];
|
|
24
|
+
const VALID_SEVERITIES = ['low', 'medium', 'high', 'blocking'];
|
|
25
|
+
const VALID_CATEGORIES = ['implementation', 'architecture', 'scope', 'process', 'quality', 'release'];
|
|
26
|
+
const VALID_ARTIFACT_TYPES = ['workspace', 'patch', 'commit', 'review'];
|
|
27
|
+
const VALID_VERIFICATION_STATUSES = ['pass', 'fail', 'skipped'];
|
|
28
|
+
const VALID_OBJECTION_STATUSES = ['raised', 'acknowledged', 'resolved', 'escalated', 'resolved_by_human', 'resolved_by_director'];
|
|
29
|
+
|
|
30
|
+
const RESERVED_PATHS = [
|
|
31
|
+
'.agentxchain/state.json',
|
|
32
|
+
'.agentxchain/history.jsonl',
|
|
33
|
+
'.agentxchain/decision-ledger.jsonl',
|
|
34
|
+
'.agentxchain/lock.json',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Validate a staged turn result against state and config.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} root — project root directory
|
|
43
|
+
* @param {object} state — parsed .agentxchain/state.json
|
|
44
|
+
* @param {object} config — normalized config (from loadNormalizedConfig)
|
|
45
|
+
* @param {object} [opts]
|
|
46
|
+
* @param {string} [opts.stagingPath] — override the default staging path
|
|
47
|
+
* @returns {{ ok: boolean, stage: string|null, error_class: string|null, errors: string[], warnings: string[] }}
|
|
48
|
+
*/
|
|
49
|
+
export function validateStagedTurnResult(root, state, config, opts = {}) {
|
|
50
|
+
const stagingRel = opts.stagingPath || STAGING_PATH;
|
|
51
|
+
const stagingAbs = join(root, stagingRel);
|
|
52
|
+
|
|
53
|
+
// ── Read the staged file ───────────────────────────────────────────────
|
|
54
|
+
if (!existsSync(stagingAbs)) {
|
|
55
|
+
return result('schema', 'schema_error', [`Staged turn result not found: ${stagingRel}`]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let raw;
|
|
59
|
+
try {
|
|
60
|
+
raw = readFileSync(stagingAbs, 'utf8');
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return result('schema', 'schema_error', [`Cannot read ${stagingRel}: ${err.message}`]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let turnResult;
|
|
66
|
+
try {
|
|
67
|
+
turnResult = JSON.parse(raw);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return result('schema', 'schema_error', [`Invalid JSON in ${stagingRel}: ${err.message}`]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Stage A: Schema Validation ─────────────────────────────────────────
|
|
73
|
+
const schemaErrors = validateSchema(turnResult);
|
|
74
|
+
if (schemaErrors.length > 0) {
|
|
75
|
+
return result('schema', 'schema_error', schemaErrors);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Stage B: Assignment Validation ─────────────────────────────────────
|
|
79
|
+
const assignmentErrors = validateAssignment(turnResult, state);
|
|
80
|
+
if (assignmentErrors.length > 0) {
|
|
81
|
+
return result('assignment', 'assignment_error', assignmentErrors);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Stage C: Artifact Validation ───────────────────────────────────────
|
|
85
|
+
const artifactResult = validateArtifact(turnResult, config);
|
|
86
|
+
if (artifactResult.errors.length > 0) {
|
|
87
|
+
return result('artifact', 'artifact_error', artifactResult.errors, artifactResult.warnings);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Stage D: Verification Validation ───────────────────────────────────
|
|
91
|
+
const verificationResult = validateVerification(turnResult);
|
|
92
|
+
if (verificationResult.errors.length > 0) {
|
|
93
|
+
return result('verification', 'verification_error', verificationResult.errors, verificationResult.warnings);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Stage E: Protocol Compliance ───────────────────────────────────────
|
|
97
|
+
const protocolResult = validateProtocol(turnResult, state, config);
|
|
98
|
+
if (protocolResult.errors.length > 0) {
|
|
99
|
+
return result('protocol', 'protocol_error', protocolResult.errors, protocolResult.warnings);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── All stages passed ──────────────────────────────────────────────────
|
|
103
|
+
const allWarnings = [
|
|
104
|
+
...artifactResult.warnings,
|
|
105
|
+
...verificationResult.warnings,
|
|
106
|
+
...protocolResult.warnings,
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
ok: true,
|
|
111
|
+
stage: null,
|
|
112
|
+
error_class: null,
|
|
113
|
+
errors: [],
|
|
114
|
+
warnings: allWarnings,
|
|
115
|
+
turnResult,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Stage A: Schema Validation ───────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
function validateSchema(tr) {
|
|
122
|
+
const errors = [];
|
|
123
|
+
|
|
124
|
+
if (tr === null || typeof tr !== 'object' || Array.isArray(tr)) {
|
|
125
|
+
return ['Turn result must be a JSON object.'];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Required top-level fields
|
|
129
|
+
const required = [
|
|
130
|
+
'schema_version', 'run_id', 'turn_id', 'role', 'runtime_id',
|
|
131
|
+
'status', 'summary', 'decisions', 'objections', 'files_changed',
|
|
132
|
+
'verification', 'artifact', 'proposed_next_role',
|
|
133
|
+
];
|
|
134
|
+
for (const field of required) {
|
|
135
|
+
if (!(field in tr)) {
|
|
136
|
+
errors.push(`Missing required field: ${field}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (errors.length > 0) return errors; // can't validate further without required fields
|
|
140
|
+
|
|
141
|
+
// Type and enum checks
|
|
142
|
+
if (tr.schema_version !== '1.0') {
|
|
143
|
+
errors.push(`schema_version must be "1.0", got "${tr.schema_version}".`);
|
|
144
|
+
}
|
|
145
|
+
if (typeof tr.run_id !== 'string' || !tr.run_id.trim()) {
|
|
146
|
+
errors.push('run_id must be a non-empty string.');
|
|
147
|
+
}
|
|
148
|
+
if (typeof tr.turn_id !== 'string' || !tr.turn_id.trim()) {
|
|
149
|
+
errors.push('turn_id must be a non-empty string.');
|
|
150
|
+
}
|
|
151
|
+
if (typeof tr.role !== 'string' || !/^[a-z0-9_-]+$/.test(tr.role)) {
|
|
152
|
+
errors.push('role must match pattern ^[a-z0-9_-]+$.');
|
|
153
|
+
}
|
|
154
|
+
if (typeof tr.runtime_id !== 'string' || !tr.runtime_id.trim()) {
|
|
155
|
+
errors.push('runtime_id must be a non-empty string.');
|
|
156
|
+
}
|
|
157
|
+
if (!VALID_STATUSES.includes(tr.status)) {
|
|
158
|
+
errors.push(`status must be one of: ${VALID_STATUSES.join(', ')}. Got "${tr.status}".`);
|
|
159
|
+
}
|
|
160
|
+
if (typeof tr.summary !== 'string' || !tr.summary.trim()) {
|
|
161
|
+
errors.push('summary must be a non-empty string.');
|
|
162
|
+
}
|
|
163
|
+
if (typeof tr.proposed_next_role !== 'string' || !/^[a-z0-9_-]+$|^human$/.test(tr.proposed_next_role)) {
|
|
164
|
+
errors.push('proposed_next_role must match ^[a-z0-9_-]+$ or be "human".');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Arrays
|
|
168
|
+
if (!Array.isArray(tr.decisions)) {
|
|
169
|
+
errors.push('decisions must be an array.');
|
|
170
|
+
} else {
|
|
171
|
+
for (let i = 0; i < tr.decisions.length; i++) {
|
|
172
|
+
errors.push(...validateDecision(tr.decisions[i], i));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!Array.isArray(tr.objections)) {
|
|
177
|
+
errors.push('objections must be an array.');
|
|
178
|
+
} else {
|
|
179
|
+
for (let i = 0; i < tr.objections.length; i++) {
|
|
180
|
+
errors.push(...validateObjection(tr.objections[i], i));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!Array.isArray(tr.files_changed)) {
|
|
185
|
+
errors.push('files_changed must be an array.');
|
|
186
|
+
} else {
|
|
187
|
+
for (let i = 0; i < tr.files_changed.length; i++) {
|
|
188
|
+
if (typeof tr.files_changed[i] !== 'string') {
|
|
189
|
+
errors.push(`files_changed[${i}] must be a string.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if ('artifacts_created' in tr) {
|
|
195
|
+
if (!Array.isArray(tr.artifacts_created)) {
|
|
196
|
+
errors.push('artifacts_created must be an array.');
|
|
197
|
+
} else {
|
|
198
|
+
for (let i = 0; i < tr.artifacts_created.length; i++) {
|
|
199
|
+
if (typeof tr.artifacts_created[i] !== 'string') {
|
|
200
|
+
errors.push(`artifacts_created[${i}] must be a string.`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Verification object
|
|
207
|
+
if (tr.verification === null || typeof tr.verification !== 'object' || Array.isArray(tr.verification)) {
|
|
208
|
+
errors.push('verification must be an object.');
|
|
209
|
+
} else {
|
|
210
|
+
if (!('status' in tr.verification)) {
|
|
211
|
+
errors.push('verification.status is required.');
|
|
212
|
+
} else if (!VALID_VERIFICATION_STATUSES.includes(tr.verification.status)) {
|
|
213
|
+
errors.push(`verification.status must be one of: ${VALID_VERIFICATION_STATUSES.join(', ')}.`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Artifact object
|
|
218
|
+
if (tr.artifact === null || typeof tr.artifact !== 'object' || Array.isArray(tr.artifact)) {
|
|
219
|
+
errors.push('artifact must be an object.');
|
|
220
|
+
} else {
|
|
221
|
+
if (!('type' in tr.artifact)) {
|
|
222
|
+
errors.push('artifact.type is required.');
|
|
223
|
+
} else if (!VALID_ARTIFACT_TYPES.includes(tr.artifact.type)) {
|
|
224
|
+
errors.push(`artifact.type must be one of: ${VALID_ARTIFACT_TYPES.join(', ')}.`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Optional fields type checks
|
|
229
|
+
if ('phase_transition_request' in tr && tr.phase_transition_request !== null && typeof tr.phase_transition_request !== 'string') {
|
|
230
|
+
errors.push('phase_transition_request must be a string or null.');
|
|
231
|
+
}
|
|
232
|
+
if ('run_completion_request' in tr && tr.run_completion_request !== null && typeof tr.run_completion_request !== 'boolean') {
|
|
233
|
+
errors.push('run_completion_request must be a boolean or null.');
|
|
234
|
+
}
|
|
235
|
+
if ('needs_human_reason' in tr && tr.needs_human_reason !== null && typeof tr.needs_human_reason !== 'string') {
|
|
236
|
+
errors.push('needs_human_reason must be a string or null.');
|
|
237
|
+
}
|
|
238
|
+
if ('cost' in tr && tr.cost !== null) {
|
|
239
|
+
if (typeof tr.cost !== 'object' || Array.isArray(tr.cost)) {
|
|
240
|
+
errors.push('cost must be an object.');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return errors;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function validateDecision(dec, index) {
|
|
248
|
+
const errors = [];
|
|
249
|
+
const prefix = `decisions[${index}]`;
|
|
250
|
+
|
|
251
|
+
if (dec === null || typeof dec !== 'object') {
|
|
252
|
+
return [`${prefix} must be an object.`];
|
|
253
|
+
}
|
|
254
|
+
if (typeof dec.id !== 'string' || !/^DEC-\d+$/.test(dec.id)) {
|
|
255
|
+
errors.push(`${prefix}.id must match pattern DEC-NNN.`);
|
|
256
|
+
}
|
|
257
|
+
if (!VALID_CATEGORIES.includes(dec.category)) {
|
|
258
|
+
errors.push(`${prefix}.category must be one of: ${VALID_CATEGORIES.join(', ')}.`);
|
|
259
|
+
}
|
|
260
|
+
if (typeof dec.statement !== 'string' || !dec.statement.trim()) {
|
|
261
|
+
errors.push(`${prefix}.statement must be a non-empty string.`);
|
|
262
|
+
}
|
|
263
|
+
if (typeof dec.rationale !== 'string' || !dec.rationale.trim()) {
|
|
264
|
+
errors.push(`${prefix}.rationale must be a non-empty string.`);
|
|
265
|
+
}
|
|
266
|
+
return errors;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function validateObjection(obj, index) {
|
|
270
|
+
const errors = [];
|
|
271
|
+
const prefix = `objections[${index}]`;
|
|
272
|
+
|
|
273
|
+
if (obj === null || typeof obj !== 'object') {
|
|
274
|
+
return [`${prefix} must be an object.`];
|
|
275
|
+
}
|
|
276
|
+
if (typeof obj.id !== 'string' || !/^OBJ-\d+$/.test(obj.id)) {
|
|
277
|
+
errors.push(`${prefix}.id must match pattern OBJ-NNN.`);
|
|
278
|
+
}
|
|
279
|
+
if (!VALID_SEVERITIES.includes(obj.severity)) {
|
|
280
|
+
errors.push(`${prefix}.severity must be one of: ${VALID_SEVERITIES.join(', ')}.`);
|
|
281
|
+
}
|
|
282
|
+
if (typeof obj.statement !== 'string' || !obj.statement.trim()) {
|
|
283
|
+
errors.push(`${prefix}.statement must be a non-empty string.`);
|
|
284
|
+
}
|
|
285
|
+
if ('status' in obj && !VALID_OBJECTION_STATUSES.includes(obj.status)) {
|
|
286
|
+
errors.push(`${prefix}.status must be one of: ${VALID_OBJECTION_STATUSES.join(', ')}.`);
|
|
287
|
+
}
|
|
288
|
+
return errors;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Stage B: Assignment Validation ───────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
function validateAssignment(tr, state) {
|
|
294
|
+
const errors = [];
|
|
295
|
+
|
|
296
|
+
if (!state) {
|
|
297
|
+
errors.push('Cannot validate assignment: state.json is not loaded.');
|
|
298
|
+
return errors;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (state.run_id && tr.run_id !== state.run_id) {
|
|
302
|
+
errors.push(`run_id mismatch: turn result has "${tr.run_id}", state has "${state.run_id}".`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const currentTurn = getActiveTurn(state) || state.current_turn;
|
|
306
|
+
if (!currentTurn) {
|
|
307
|
+
errors.push('No active turn in state.json — cannot validate assignment.');
|
|
308
|
+
return errors;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (currentTurn.turn_id && tr.turn_id !== currentTurn.turn_id) {
|
|
312
|
+
errors.push(`turn_id mismatch: turn result has "${tr.turn_id}", state has "${currentTurn.turn_id}".`);
|
|
313
|
+
}
|
|
314
|
+
if (currentTurn.assigned_role && tr.role !== currentTurn.assigned_role) {
|
|
315
|
+
errors.push(`role mismatch: turn result has "${tr.role}", state assigns "${currentTurn.assigned_role}".`);
|
|
316
|
+
}
|
|
317
|
+
if (currentTurn.runtime_id && tr.runtime_id !== currentTurn.runtime_id) {
|
|
318
|
+
errors.push(`runtime_id mismatch: turn result has "${tr.runtime_id}", state has "${currentTurn.runtime_id}".`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return errors;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── Stage C: Artifact Validation ─────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
function validateArtifact(tr, config) {
|
|
327
|
+
const errors = [];
|
|
328
|
+
const warnings = [];
|
|
329
|
+
|
|
330
|
+
const role = config.roles?.[tr.role];
|
|
331
|
+
const writeAuthority = role?.write_authority;
|
|
332
|
+
|
|
333
|
+
// review_only roles must not declare product file changes
|
|
334
|
+
if (writeAuthority === 'review_only') {
|
|
335
|
+
const productFiles = (tr.files_changed || []).filter(f => !isAllowedReviewPath(f));
|
|
336
|
+
if (productFiles.length > 0) {
|
|
337
|
+
errors.push(
|
|
338
|
+
`Role "${tr.role}" has review_only write authority but claims product file changes: ${productFiles.join(', ')}`
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
if (tr.artifact?.type && tr.artifact.type !== 'review') {
|
|
342
|
+
errors.push(
|
|
343
|
+
`Role "${tr.role}" has review_only write authority but artifact type is "${tr.artifact.type}" (must be "review").`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// workspace artifact only allowed for authoritative + local_cli
|
|
349
|
+
if (tr.artifact?.type === 'workspace') {
|
|
350
|
+
if (writeAuthority && writeAuthority !== 'authoritative') {
|
|
351
|
+
errors.push(
|
|
352
|
+
`Artifact type "workspace" requires authoritative write authority, but role "${tr.role}" has "${writeAuthority}".`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Check for reserved path modifications
|
|
358
|
+
for (const file of tr.files_changed || []) {
|
|
359
|
+
if (RESERVED_PATHS.includes(file)) {
|
|
360
|
+
errors.push(`Turn result claims modification of reserved path: ${file}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Warn if files_changed is empty for authoritative + completed turns
|
|
365
|
+
if (writeAuthority === 'authoritative' && tr.status === 'completed' && (tr.files_changed || []).length === 0) {
|
|
366
|
+
warnings.push('Authoritative role completed with no files_changed — is this intentional?');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { errors, warnings };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Paths that review_only roles are allowed to create/modify.
|
|
374
|
+
*/
|
|
375
|
+
function isAllowedReviewPath(filePath) {
|
|
376
|
+
return filePath.startsWith('.planning/') || filePath.startsWith('.agentxchain/reviews/');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── Stage D: Verification Validation ─────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
function validateVerification(tr) {
|
|
382
|
+
const errors = [];
|
|
383
|
+
const warnings = [];
|
|
384
|
+
|
|
385
|
+
const v = tr.verification;
|
|
386
|
+
if (!v) return { errors, warnings };
|
|
387
|
+
|
|
388
|
+
// If status is pass, there should be some evidence
|
|
389
|
+
if (v.status === 'pass') {
|
|
390
|
+
const hasCommands = Array.isArray(v.commands) && v.commands.length > 0;
|
|
391
|
+
const hasMachineEvidence = Array.isArray(v.machine_evidence) && v.machine_evidence.length > 0;
|
|
392
|
+
const hasEvidenceSummary = typeof v.evidence_summary === 'string' && v.evidence_summary.trim();
|
|
393
|
+
|
|
394
|
+
if (!hasCommands && !hasMachineEvidence && !hasEvidenceSummary) {
|
|
395
|
+
warnings.push('verification.status is "pass" but no evidence provided (commands, machine_evidence, or evidence_summary).');
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// machine_evidence exit codes should be consistent with status
|
|
400
|
+
if (Array.isArray(v.machine_evidence)) {
|
|
401
|
+
for (let i = 0; i < v.machine_evidence.length; i++) {
|
|
402
|
+
const entry = v.machine_evidence[i];
|
|
403
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
404
|
+
errors.push(`verification.machine_evidence[${i}] must be an object.`);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (typeof entry.command !== 'string') {
|
|
408
|
+
errors.push(`verification.machine_evidence[${i}].command must be a string.`);
|
|
409
|
+
}
|
|
410
|
+
if (typeof entry.exit_code !== 'number' || !Number.isInteger(entry.exit_code)) {
|
|
411
|
+
errors.push(`verification.machine_evidence[${i}].exit_code must be an integer.`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// If status is pass but any command has non-zero exit code, that's suspicious
|
|
416
|
+
if (v.status === 'pass') {
|
|
417
|
+
const failedCommands = v.machine_evidence.filter(e => typeof e.exit_code === 'number' && e.exit_code !== 0);
|
|
418
|
+
if (failedCommands.length > 0) {
|
|
419
|
+
errors.push(
|
|
420
|
+
`verification.status is "pass" but ${failedCommands.length} command(s) have non-zero exit codes.`
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return { errors, warnings };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── Stage E: Protocol Compliance ─────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
function validateProtocol(tr, state, config) {
|
|
432
|
+
const errors = [];
|
|
433
|
+
const warnings = [];
|
|
434
|
+
|
|
435
|
+
const role = config.roles?.[tr.role];
|
|
436
|
+
const writeAuthority = role?.write_authority;
|
|
437
|
+
|
|
438
|
+
// Challenge requirement: review_only roles MUST raise at least one objection
|
|
439
|
+
if (config.rules?.challenge_required !== false) {
|
|
440
|
+
if (writeAuthority === 'review_only') {
|
|
441
|
+
if (!Array.isArray(tr.objections) || tr.objections.length === 0) {
|
|
442
|
+
errors.push(
|
|
443
|
+
`Protocol violation: role "${tr.role}" has review_only authority and must raise at least one objection (challenge requirement).`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// proposed_next_role must be routing-legal
|
|
450
|
+
const phase = state?.phase;
|
|
451
|
+
const routing = config.routing?.[phase];
|
|
452
|
+
if (routing && tr.proposed_next_role) {
|
|
453
|
+
const allowed = routing.allowed_next_roles || [];
|
|
454
|
+
if (!allowed.includes(tr.proposed_next_role) && tr.proposed_next_role !== 'human') {
|
|
455
|
+
errors.push(
|
|
456
|
+
`proposed_next_role "${tr.proposed_next_role}" is not in the allowed_next_roles for phase "${phase}": [${allowed.join(', ')}].`
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// phase_transition_request must reference a valid phase
|
|
462
|
+
if (tr.phase_transition_request) {
|
|
463
|
+
if (config.routing && !config.routing[tr.phase_transition_request]) {
|
|
464
|
+
errors.push(
|
|
465
|
+
`phase_transition_request "${tr.phase_transition_request}" is not a defined phase in routing.`
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// run_completion_request and phase_transition_request are mutually exclusive
|
|
471
|
+
if (tr.run_completion_request && tr.phase_transition_request) {
|
|
472
|
+
errors.push('run_completion_request and phase_transition_request are mutually exclusive — set one or neither, not both.');
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// needs_human status must have a reason
|
|
476
|
+
if (tr.status === 'needs_human' && (!tr.needs_human_reason || !tr.needs_human_reason.trim())) {
|
|
477
|
+
warnings.push('status is "needs_human" but needs_human_reason is empty.');
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return { errors, warnings };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
484
|
+
|
|
485
|
+
function result(stage, errorClass, errors, warnings = []) {
|
|
486
|
+
return {
|
|
487
|
+
ok: false,
|
|
488
|
+
stage,
|
|
489
|
+
error_class: errorClass,
|
|
490
|
+
errors,
|
|
491
|
+
warnings,
|
|
492
|
+
turnResult: null,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export { STAGING_PATH };
|
package/src/lib/validation.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
+
import { validateStagedTurnResult, STAGING_PATH } from './turn-result-validator.js';
|
|
4
|
+
import { getActiveTurn } from './governed-state.js';
|
|
3
5
|
|
|
4
6
|
const DEFAULT_REQUIRED_FILES = [
|
|
5
7
|
'.planning/PROJECT.md',
|
|
@@ -79,6 +81,108 @@ export function validateProject(root, config, opts = {}) {
|
|
|
79
81
|
return { ok: errors.length === 0, mode, errors, warnings };
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
export function validateGovernedProject(root, rawConfig, config, opts = {}) {
|
|
85
|
+
const mode = opts.mode || 'full';
|
|
86
|
+
const expectedRole = opts.expectedAgent || null;
|
|
87
|
+
const errors = [];
|
|
88
|
+
const warnings = [];
|
|
89
|
+
|
|
90
|
+
const mustExist = [
|
|
91
|
+
config.files?.state || '.agentxchain/state.json',
|
|
92
|
+
config.files?.history || '.agentxchain/history.jsonl',
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
for (const rel of mustExist) {
|
|
96
|
+
if (!existsSync(join(root, rel))) {
|
|
97
|
+
errors.push(`Missing required file: ${rel}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const prompts = rawConfig?.prompts && typeof rawConfig.prompts === 'object' ? rawConfig.prompts : {};
|
|
102
|
+
for (const [roleId, rel] of Object.entries(prompts)) {
|
|
103
|
+
if (typeof rel !== 'string' || !rel.trim()) {
|
|
104
|
+
errors.push(`Prompt path for role "${roleId}" must be a non-empty string.`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!existsSync(join(root, rel))) {
|
|
108
|
+
errors.push(`Missing prompt file for role "${roleId}": ${rel}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const [gateId, gate] of Object.entries(config.gates || {})) {
|
|
113
|
+
for (const rel of gate.requires_files || []) {
|
|
114
|
+
if (!existsSync(join(root, rel))) {
|
|
115
|
+
errors.push(`Gate "${gateId}" requires missing file: ${rel}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const statePath = join(root, config.files?.state || '.agentxchain/state.json');
|
|
121
|
+
const state = readJson(statePath);
|
|
122
|
+
if (!state) {
|
|
123
|
+
errors.push(`Unable to parse ${config.files?.state || '.agentxchain/state.json'}.`);
|
|
124
|
+
} else {
|
|
125
|
+
if (state.phase && config.routing && !config.routing[state.phase]) {
|
|
126
|
+
errors.push(`State phase "${state.phase}" is not defined in routing.`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (state.phase_gate_status && typeof state.phase_gate_status === 'object') {
|
|
130
|
+
for (const gateId of Object.keys(state.phase_gate_status)) {
|
|
131
|
+
if (!config.gates?.[gateId]) {
|
|
132
|
+
errors.push(`state.phase_gate_status references unknown gate "${gateId}".`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (mode === 'turn') {
|
|
138
|
+
const activeTurn = getActiveTurn(state) || state.current_turn;
|
|
139
|
+
if (!activeTurn) {
|
|
140
|
+
errors.push('Governed turn validation requires an active turn.');
|
|
141
|
+
} else if (expectedRole && activeTurn.assigned_role !== expectedRole) {
|
|
142
|
+
errors.push(`Current turn role "${activeTurn.assigned_role}" does not match expected "${expectedRole}".`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!getActiveTurn(state) && !state.current_turn) {
|
|
147
|
+
warnings.push('No active turn present in governed state. The run may be idle or paused.');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const historyPath = join(root, config.files?.history || '.agentxchain/history.jsonl');
|
|
152
|
+
const historyLines = readJsonLines(historyPath);
|
|
153
|
+
if (historyLines.error) {
|
|
154
|
+
errors.push(historyLines.error);
|
|
155
|
+
} else if (historyLines.lines.length === 0) {
|
|
156
|
+
warnings.push(`${config.files?.history || '.agentxchain/history.jsonl'} has no accepted turn entries yet.`);
|
|
157
|
+
} else {
|
|
158
|
+
const last = historyLines.lines[historyLines.lines.length - 1];
|
|
159
|
+
if (last && typeof last === 'object') {
|
|
160
|
+
if (!last.turn_id) warnings.push('Last governed history entry has no turn_id.');
|
|
161
|
+
if (!last.role) warnings.push('Last governed history entry has no role.');
|
|
162
|
+
if (!last.status) warnings.push('Last governed history entry has no status.');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Staged turn-result validation (the acceptance boundary) ─────────────
|
|
167
|
+
if (mode === 'turn' && state) {
|
|
168
|
+
const stagingAbs = join(root, STAGING_PATH);
|
|
169
|
+
if (!existsSync(stagingAbs)) {
|
|
170
|
+
warnings.push(`No staged turn result found at ${STAGING_PATH}. Agent has not yet emitted a turn result.`);
|
|
171
|
+
} else {
|
|
172
|
+
const turnValidation = validateStagedTurnResult(root, state, config);
|
|
173
|
+
if (!turnValidation.ok) {
|
|
174
|
+
errors.push(
|
|
175
|
+
`Staged turn result failed at stage "${turnValidation.stage}" (${turnValidation.error_class}):`,
|
|
176
|
+
...turnValidation.errors.map(e => ` • ${e}`)
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
warnings.push(...(turnValidation.warnings || []));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { ok: errors.length === 0, mode, errors, warnings };
|
|
184
|
+
}
|
|
185
|
+
|
|
82
186
|
function validatePhaseArtifacts(root) {
|
|
83
187
|
const result = { errors: [], warnings: [] };
|
|
84
188
|
const phasesDir = join(root, '.planning', 'phases');
|
|
@@ -214,3 +318,36 @@ function safeReadDir(path) {
|
|
|
214
318
|
return [];
|
|
215
319
|
}
|
|
216
320
|
}
|
|
321
|
+
|
|
322
|
+
function readJson(path) {
|
|
323
|
+
if (!existsSync(path)) return null;
|
|
324
|
+
try {
|
|
325
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
326
|
+
} catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function readJsonLines(path) {
|
|
332
|
+
if (!existsSync(path)) {
|
|
333
|
+
return { lines: [], error: `${path.split('/').pop()} is missing.` };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const raw = readFileSync(path, 'utf8');
|
|
338
|
+
const lines = raw
|
|
339
|
+
.split(/\r?\n/)
|
|
340
|
+
.map(line => line.trim())
|
|
341
|
+
.filter(Boolean)
|
|
342
|
+
.map((line, index) => {
|
|
343
|
+
try {
|
|
344
|
+
return JSON.parse(line);
|
|
345
|
+
} catch {
|
|
346
|
+
throw new Error(`Invalid JSONL entry at line ${index + 1}.`);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
return { lines, error: null };
|
|
350
|
+
} catch (err) {
|
|
351
|
+
return { lines: [], error: err.message };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "api-service",
|
|
3
|
+
"display_name": "API Service",
|
|
4
|
+
"description": "Governed scaffold for backend services with explicit API, operational, and failure-budget planning.",
|
|
5
|
+
"version": "1",
|
|
6
|
+
"protocol_compatibility": ["1.0", "1.1"],
|
|
7
|
+
"planning_artifacts": [
|
|
8
|
+
{
|
|
9
|
+
"filename": "api-contract.md",
|
|
10
|
+
"content_template": "# API Contract — {{project_name}}\n\n## Consumers\n- Primary caller:\n- Authentication expectations:\n- Backward-compatibility policy:\n\n## Endpoints\n| Method | Path | Purpose | Auth | Status |\n|--------|------|---------|------|--------|\n| | | | | |\n\n## Error Cases\n| Scenario | HTTP Status | Error Shape | Recovery |\n|----------|-------------|-------------|----------|\n| | | | |\n"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"filename": "operational-readiness.md",
|
|
14
|
+
"content_template": "# Operational Readiness — {{project_name}}\n\n## Runtime Dependencies\n- Data stores:\n- Queues / cron jobs:\n- External APIs:\n\n## Observability\n- Required logs:\n- Metrics / alerts:\n- Smoke checks:\n\n## Recovery\n- Rollback plan:\n- On-call notes:\n- Known operational risks:\n"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"filename": "error-budget.md",
|
|
18
|
+
"content_template": "# Error Budget — {{project_name}}\n\n## Service Objective\n- Target availability / reliability:\n- Measurement window:\n\n## Failure Modes\n| Failure mode | User impact | Detection | Mitigation |\n|--------------|-------------|-----------|------------|\n| | | | |\n\n## Escalation Thresholds\n- Immediate ship blocker:\n- Needs mitigation before release:\n- Post-release follow-up allowed when:\n"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"prompt_overrides": {
|
|
22
|
+
"pm": "Define the external contract, compatibility expectations, and operational constraints before the team treats the API as ready for implementation.",
|
|
23
|
+
"dev": "Treat schema changes, API contract drift, and migration safety as first-class implementation risks. Call out any hidden operational coupling.",
|
|
24
|
+
"qa": "Verify API contract conformance, error handling, auth failures, and rollback safety. Do not sign off without explicit coverage of unhappy paths."
|
|
25
|
+
},
|
|
26
|
+
"acceptance_hints": [
|
|
27
|
+
"API contract reviewed and endpoints listed",
|
|
28
|
+
"Error cases enumerated with recovery expectations",
|
|
29
|
+
"Verification command covers automated tests or smoke checks"
|
|
30
|
+
]
|
|
31
|
+
}
|