agentxchain 2.157.0 → 2.159.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/bin/agentxchain.js +24 -0
- package/package.json +1 -1
- package/src/commands/attention.js +66 -0
- package/src/commands/role.js +69 -1
- package/src/commands/ship-status.js +104 -0
- package/src/lib/dispatch-bundle.js +12 -0
- package/src/lib/human-attention.js +381 -0
- package/src/lib/report.js +17 -0
- package/src/lib/role-charter.js +294 -0
- package/src/lib/ship-status.js +545 -0
- package/src/lib/turn-result-validator.js +42 -6
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M14: Shippability Visibility — Vision Closure (VISION.md:50)
|
|
3
|
+
*
|
|
4
|
+
* Composes five independent evidence dimensions into a single, operator-queryable
|
|
5
|
+
* shippability assessment, answering "is this ready to ship?" without forcing the
|
|
6
|
+
* operator to inspect run state, QA verdicts, gates, release artifacts, and test
|
|
7
|
+
* evidence by hand.
|
|
8
|
+
*
|
|
9
|
+
* This module is a COMPOSITION layer (Architecture Invariant #1): it reaches the
|
|
10
|
+
* existing governance logic through its public surface and never reimplements gate
|
|
11
|
+
* evaluation, release alignment, or verification. It is strictly read-only
|
|
12
|
+
* (Invariant #2) — it never mutates run state, artifacts, or config. Every
|
|
13
|
+
* dimension is evaluated independently; a failure in one never short-circuits the
|
|
14
|
+
* others (Invariant #3). Coordinator aggregation uses worst-case semantics
|
|
15
|
+
* (Invariant #4).
|
|
16
|
+
*
|
|
17
|
+
* Public surface:
|
|
18
|
+
* evaluateShipStatus(repoDir, options) — single governed repo
|
|
19
|
+
* evaluateCoordinatorShipStatus(coordDir, options) — multi-repo aggregation
|
|
20
|
+
* buildShipStatusSummary(artifact) — export-artifact summary (report.js)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
import { loadProjectContext } from './config.js';
|
|
27
|
+
import { queryAcceptedTurnHistory } from './accepted-turn-history.js';
|
|
28
|
+
import { evaluateWorkflowGateSemantics, SHIP_VERDICT_PATH } from './workflow-gate-semantics.js';
|
|
29
|
+
import { validateReleaseAlignment } from './release-alignment.js';
|
|
30
|
+
import { loadCoordinatorConfig, resolveRepoPaths } from './coordinator-config.js';
|
|
31
|
+
|
|
32
|
+
export const SHIP_STATUS_DIMENSIONS = [
|
|
33
|
+
'run_completion',
|
|
34
|
+
'qa_ship_verdict',
|
|
35
|
+
'gate_clearance',
|
|
36
|
+
'release_alignment',
|
|
37
|
+
'test_verification',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const DEFAULT_STATE_PATH = '.agentxchain/state.json';
|
|
41
|
+
const HISTORY_PATH = '.agentxchain/history.jsonl';
|
|
42
|
+
|
|
43
|
+
const PASSING_VERIFICATION = new Set(['pass', 'attested_pass']);
|
|
44
|
+
// Explicitly skipped verification (planning/review turns that run no test gate) is NEUTRAL:
|
|
45
|
+
// it provides no test evidence but must not pin the shippability signal to "pending" forever.
|
|
46
|
+
const NEUTRAL_VERIFICATION = new Set(['skipped']);
|
|
47
|
+
const PASSING_RUN_STATUSES = new Set(['completed']);
|
|
48
|
+
const FAILING_RUN_STATUSES = new Set(['failed', 'blocked', 'idle']);
|
|
49
|
+
|
|
50
|
+
function dimension(name, status, detail, blockingReason) {
|
|
51
|
+
return {
|
|
52
|
+
name,
|
|
53
|
+
status,
|
|
54
|
+
detail,
|
|
55
|
+
blocking_reason: status === 'pass' ? null : (blockingReason || detail),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read the governed state file WITHOUT writeback (loadProjectState may normalize
|
|
61
|
+
* and persist, which would violate the read-only invariant).
|
|
62
|
+
*/
|
|
63
|
+
function readGovernedStateReadOnly(root, config) {
|
|
64
|
+
const rel = config?.files?.state || DEFAULT_STATE_PATH;
|
|
65
|
+
const filePath = join(root, rel);
|
|
66
|
+
if (!existsSync(filePath)) return null;
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── dimension evaluators (pure; exported for focused unit testing) ────────
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Dimension 1 — Run completion status. Source: governed run state `status`.
|
|
78
|
+
*/
|
|
79
|
+
export function evaluateRunCompletionDimension(state) {
|
|
80
|
+
const status = state?.status || null;
|
|
81
|
+
if (PASSING_RUN_STATUSES.has(status)) {
|
|
82
|
+
return dimension('run_completion', 'pass', `Run status is "${status}".`, null);
|
|
83
|
+
}
|
|
84
|
+
if (FAILING_RUN_STATUSES.has(status)) {
|
|
85
|
+
return dimension(
|
|
86
|
+
'run_completion',
|
|
87
|
+
'fail',
|
|
88
|
+
`Run status is "${status}".`,
|
|
89
|
+
`Run is not shippable: status "${status}".`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const label = status || 'unknown';
|
|
93
|
+
return dimension(
|
|
94
|
+
'run_completion',
|
|
95
|
+
'pending',
|
|
96
|
+
`Run status is "${label}"; not yet completed.`,
|
|
97
|
+
`Run has not reached completion (status "${label}").`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function phaseOrder(config) {
|
|
102
|
+
return Object.keys(config?.routing || {});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function finalPhaseReached(state, config) {
|
|
106
|
+
const phases = phaseOrder(config);
|
|
107
|
+
const finalPhase = phases.length ? phases[phases.length - 1] : 'qa';
|
|
108
|
+
const currentPhase = state?.phase || null;
|
|
109
|
+
if (state?.status === 'completed') return { reached: true, finalPhase };
|
|
110
|
+
if (currentPhase == null) return { reached: false, finalPhase };
|
|
111
|
+
const idx = phases.indexOf(currentPhase);
|
|
112
|
+
const finalIdx = phases.indexOf(finalPhase);
|
|
113
|
+
return { reached: idx >= 0 && idx >= finalIdx, finalPhase };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Dimension 2 — QA ship verdict. Source: workflow-gate-semantics public surface
|
|
118
|
+
* evaluated against .planning/ship-verdict.md (Architecture Invariant #1).
|
|
119
|
+
*/
|
|
120
|
+
export function evaluateShipVerdictDimension(root, state, config, semanticsEvaluator) {
|
|
121
|
+
const evaluate = semanticsEvaluator || evaluateWorkflowGateSemantics;
|
|
122
|
+
const result = evaluate(root, SHIP_VERDICT_PATH); // null when file missing, else { ok, reason? }
|
|
123
|
+
const { reached, finalPhase } = finalPhaseReached(state, config);
|
|
124
|
+
|
|
125
|
+
if (result == null) {
|
|
126
|
+
if (reached) {
|
|
127
|
+
return dimension(
|
|
128
|
+
'qa_ship_verdict',
|
|
129
|
+
'fail',
|
|
130
|
+
`Ship verdict file ${SHIP_VERDICT_PATH} is missing.`,
|
|
131
|
+
`QA ship verdict (${SHIP_VERDICT_PATH}) is missing after reaching the "${finalPhase}" phase.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return dimension(
|
|
135
|
+
'qa_ship_verdict',
|
|
136
|
+
'pending',
|
|
137
|
+
`QA ship verdict not yet produced (current phase "${state?.phase || 'unknown'}").`,
|
|
138
|
+
`QA phase ("${finalPhase}") not yet reached; ship verdict pending.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (result.ok) {
|
|
143
|
+
return dimension('qa_ship_verdict', 'pass', 'QA ship verdict is affirmative (## Verdict: YES).', null);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const reason = result.reason || 'Ship verdict is not affirmative.';
|
|
147
|
+
return dimension('qa_ship_verdict', 'fail', reason, `QA did not approve shipping: ${reason}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Dimension 3 — Gate clearance. Source: recorded phase_gate_status (string values
|
|
152
|
+
* "passed"/"pending"/"failed") against the gates declared in config.
|
|
153
|
+
*/
|
|
154
|
+
export function evaluateGateClearanceDimension(state, config) {
|
|
155
|
+
const gateIds = Object.keys(config?.gates || {});
|
|
156
|
+
if (gateIds.length === 0) {
|
|
157
|
+
return dimension(
|
|
158
|
+
'gate_clearance',
|
|
159
|
+
'pending',
|
|
160
|
+
'No governance gates defined in config.',
|
|
161
|
+
'No governance gates defined to evaluate.',
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const statusMap = state?.phase_gate_status || {};
|
|
166
|
+
const perGate = gateIds.map((id) => ({ id, status: normalizeGateStatus(statusMap[id]) }));
|
|
167
|
+
const failed = perGate.filter((g) => g.status === 'failed');
|
|
168
|
+
const pending = perGate.filter((g) => g.status !== 'passed' && g.status !== 'failed');
|
|
169
|
+
const detailParts = perGate.map((g) => `${g.id}=${g.status}`).join(', ');
|
|
170
|
+
|
|
171
|
+
if (failed.length > 0) {
|
|
172
|
+
return dimension(
|
|
173
|
+
'gate_clearance',
|
|
174
|
+
'fail',
|
|
175
|
+
`Gate status: ${detailParts}.`,
|
|
176
|
+
`Gate(s) failed: ${failed.map((g) => g.id).join(', ')}.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (pending.length > 0) {
|
|
180
|
+
return dimension(
|
|
181
|
+
'gate_clearance',
|
|
182
|
+
'pending',
|
|
183
|
+
`Gate status: ${detailParts}.`,
|
|
184
|
+
`Gate(s) not yet satisfied: ${pending.map((g) => g.id).join(', ')}.`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return dimension('gate_clearance', 'pass', `All ${gateIds.length} gates passed.`, null);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// phase_gate_status values are strings in governed state, but tolerate the
|
|
191
|
+
// legacy { outcome | status } object shape defensively.
|
|
192
|
+
function normalizeGateStatus(value) {
|
|
193
|
+
if (typeof value === 'string') return value;
|
|
194
|
+
if (value && typeof value === 'object') return value.outcome || value.status || 'pending';
|
|
195
|
+
return 'pending';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Dimension 4 — Release alignment. Source: release-alignment.validateReleaseAlignment.
|
|
200
|
+
* The evaluator is injectable so composition can be tested without reconstructing
|
|
201
|
+
* release-alignment's ~18 release surfaces (already covered by release-alignment.test.js).
|
|
202
|
+
* When the release context cannot be built (pre-release, no package/changelog) the
|
|
203
|
+
* dimension is pending rather than fail.
|
|
204
|
+
*/
|
|
205
|
+
export function evaluateReleaseAlignmentDimension(root, releaseEvaluator) {
|
|
206
|
+
const evaluate = releaseEvaluator || ((repoRoot) => validateReleaseAlignment(repoRoot, {}));
|
|
207
|
+
let result;
|
|
208
|
+
try {
|
|
209
|
+
result = evaluate(root);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
212
|
+
return dimension(
|
|
213
|
+
'release_alignment',
|
|
214
|
+
'pending',
|
|
215
|
+
`Release alignment not yet evaluable: ${message}`,
|
|
216
|
+
`Release alignment not yet evaluated (pre-release): ${message}`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!result) {
|
|
221
|
+
return dimension(
|
|
222
|
+
'release_alignment',
|
|
223
|
+
'pending',
|
|
224
|
+
'Release alignment produced no result.',
|
|
225
|
+
'Release alignment not yet evaluated.',
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (result.ok) {
|
|
230
|
+
const surfaces = result.checkedSurfaceCount ?? (result.checkedSurfaceIds?.length || 0);
|
|
231
|
+
return dimension('release_alignment', 'pass', `Release alignment OK (${surfaces} surfaces checked).`, null);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const reasons = (result.errors || []).map((e) => e?.message || String(e));
|
|
235
|
+
const preview = reasons.slice(0, 3).join('; ');
|
|
236
|
+
const more = reasons.length > 3 ? ` (+${reasons.length - 3} more)` : '';
|
|
237
|
+
return dimension(
|
|
238
|
+
'release_alignment',
|
|
239
|
+
'fail',
|
|
240
|
+
`Release alignment failed: ${reasons.length} issue(s).`,
|
|
241
|
+
`Release artifacts not aligned: ${preview}${more}.`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Dimension 5 — Test verification. Source: verification.status across accepted turns.
|
|
247
|
+
*/
|
|
248
|
+
export function evaluateTestVerificationDimension(history) {
|
|
249
|
+
if (!Array.isArray(history) || history.length === 0) {
|
|
250
|
+
return dimension(
|
|
251
|
+
'test_verification',
|
|
252
|
+
'pending',
|
|
253
|
+
'No accepted turns with verification evidence yet.',
|
|
254
|
+
'No accepted turns with verification evidence yet.',
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const failed = history.filter((h) => normalizeVerificationStatus(h) === 'fail');
|
|
259
|
+
if (failed.length > 0) {
|
|
260
|
+
const ids = failed.map((h) => h.turn_id).filter(Boolean).slice(0, 3).join(', ');
|
|
261
|
+
return dimension(
|
|
262
|
+
'test_verification',
|
|
263
|
+
'fail',
|
|
264
|
+
`${failed.length} accepted turn(s) failed verification.`,
|
|
265
|
+
`Verification failed on turn(s): ${ids || '(unknown)'}.`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Turns that explicitly SKIPPED verification (e.g. planning/review turns with no test gate)
|
|
270
|
+
// are neutral — they neither contribute nor withhold test evidence. Excluding them prevents a
|
|
271
|
+
// shippable run from being pinned to "pending" forever just because one planning turn skipped.
|
|
272
|
+
const evidenceBearing = history.filter(
|
|
273
|
+
(h) => !NEUTRAL_VERIFICATION.has(normalizeVerificationStatus(h)),
|
|
274
|
+
);
|
|
275
|
+
if (evidenceBearing.length === 0) {
|
|
276
|
+
return dimension(
|
|
277
|
+
'test_verification',
|
|
278
|
+
'pending',
|
|
279
|
+
`No accepted turns with passing verification evidence yet (all ${history.length} skipped).`,
|
|
280
|
+
'No accepted turns have produced passing verification evidence yet.',
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const nonPass = evidenceBearing.filter((h) => !PASSING_VERIFICATION.has(normalizeVerificationStatus(h)));
|
|
285
|
+
if (nonPass.length > 0) {
|
|
286
|
+
return dimension(
|
|
287
|
+
'test_verification',
|
|
288
|
+
'pending',
|
|
289
|
+
`${nonPass.length} accepted turn(s) without a passing verification.`,
|
|
290
|
+
`Verification not yet passing on ${nonPass.length} accepted turn(s).`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return dimension(
|
|
295
|
+
'test_verification',
|
|
296
|
+
'pass',
|
|
297
|
+
`All ${evidenceBearing.length} verification-bearing accepted turns passed.`,
|
|
298
|
+
null,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function normalizeVerificationStatus(entry) {
|
|
303
|
+
const status = entry?.verification?.status;
|
|
304
|
+
return typeof status === 'string' ? status.toLowerCase() : null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Aggregate dimension verdicts with worst-case semantics:
|
|
309
|
+
* any fail → fail; else any pending → pending; else pass.
|
|
310
|
+
*/
|
|
311
|
+
export function aggregateShipStatus(dimensions) {
|
|
312
|
+
const hasFail = dimensions.some((d) => d.status === 'fail');
|
|
313
|
+
const hasPending = dimensions.some((d) => d.status === 'pending');
|
|
314
|
+
const overall = hasFail ? 'fail' : hasPending ? 'pending' : 'pass';
|
|
315
|
+
const passed = dimensions.filter((d) => d.status === 'pass').length;
|
|
316
|
+
const blocking_reasons = dimensions
|
|
317
|
+
.filter((d) => d.status !== 'pass')
|
|
318
|
+
.map((d) => d.blocking_reason)
|
|
319
|
+
.filter(Boolean);
|
|
320
|
+
|
|
321
|
+
const blockingTag = blocking_reasons.length ? `, ${blocking_reasons.length} blocking` : '';
|
|
322
|
+
const evidence_summary = `Ship status: ${overall.toUpperCase()} — ${passed}/${dimensions.length} dimensions pass${blockingTag}.`;
|
|
323
|
+
|
|
324
|
+
return { overall, dimensions, blocking_reasons, evidence_summary };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── public API ────────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Compose the five evidence dimensions into a single ShipStatusReport.
|
|
331
|
+
*
|
|
332
|
+
* @param {string} repoDir
|
|
333
|
+
* @param {object} [options]
|
|
334
|
+
* @param {object} [options.context] - preloaded { root, config }
|
|
335
|
+
* @param {object} [options.state] - preloaded run state
|
|
336
|
+
* @param {Array} [options.history] - preloaded accepted-turn history
|
|
337
|
+
* @param {Function} [options.releaseAlignmentEvaluator] - (root) => { ok, errors }
|
|
338
|
+
* @param {Function} [options.semanticsEvaluator] - (root, relPath) => { ok, reason? } | null
|
|
339
|
+
* @returns {{ overall: string, dimensions: object[], blocking_reasons: string[], evidence_summary: string }}
|
|
340
|
+
*/
|
|
341
|
+
export function evaluateShipStatus(repoDir, options = {}) {
|
|
342
|
+
const context = options.context || loadProjectContext(repoDir);
|
|
343
|
+
if (!context) {
|
|
344
|
+
const reason = `Not a governed AgentXchain project: no agentxchain.json found at ${repoDir}.`;
|
|
345
|
+
return {
|
|
346
|
+
overall: 'fail',
|
|
347
|
+
dimensions: [],
|
|
348
|
+
blocking_reasons: [reason],
|
|
349
|
+
evidence_summary: `Ship status: FAIL — ${reason}`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const { root, config } = context;
|
|
354
|
+
const state = options.state || readGovernedStateReadOnly(root, config) || {};
|
|
355
|
+
const history = options.history || queryAcceptedTurnHistory(root);
|
|
356
|
+
|
|
357
|
+
const dimensions = [
|
|
358
|
+
evaluateRunCompletionDimension(state),
|
|
359
|
+
evaluateShipVerdictDimension(root, state, config, options.semanticsEvaluator),
|
|
360
|
+
evaluateGateClearanceDimension(state, config),
|
|
361
|
+
evaluateReleaseAlignmentDimension(root, options.releaseAlignmentEvaluator),
|
|
362
|
+
evaluateTestVerificationDimension(history),
|
|
363
|
+
];
|
|
364
|
+
|
|
365
|
+
return aggregateShipStatus(dimensions);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Aggregate per-repo shippability for a multi-repo coordinator run.
|
|
370
|
+
*
|
|
371
|
+
* @param {string} coordinatorDir
|
|
372
|
+
* @param {object} [options] - dimension-evaluator overrides forwarded to each repo
|
|
373
|
+
* @returns {{ overall: string, repos: object[], blocking_repos: string[], evidence_summary: string }}
|
|
374
|
+
*/
|
|
375
|
+
export function evaluateCoordinatorShipStatus(coordinatorDir, options = {}) {
|
|
376
|
+
const loaded = options.coordinatorConfig
|
|
377
|
+
? { ok: true, config: options.coordinatorConfig, errors: [] }
|
|
378
|
+
: loadCoordinatorConfig(coordinatorDir);
|
|
379
|
+
|
|
380
|
+
if (!loaded.ok || !loaded.config) {
|
|
381
|
+
const reason = `Cannot load coordinator config: ${(loaded.errors || []).join('; ') || 'unknown error'}`;
|
|
382
|
+
return {
|
|
383
|
+
overall: 'fail',
|
|
384
|
+
repos: [],
|
|
385
|
+
blocking_repos: [],
|
|
386
|
+
evidence_summary: `Coordinator ship status: FAIL — ${reason}`,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const { resolved } = resolveRepoPaths(loaded.config, coordinatorDir);
|
|
391
|
+
const repoIds = loaded.config.repo_order || Object.keys(loaded.config.repos || {});
|
|
392
|
+
|
|
393
|
+
// Only dimension-evaluator overrides are forwarded; per-repo state/context/history
|
|
394
|
+
// must be loaded fresh for each repo.
|
|
395
|
+
const forwarded = {
|
|
396
|
+
releaseAlignmentEvaluator: options.releaseAlignmentEvaluator,
|
|
397
|
+
semanticsEvaluator: options.semanticsEvaluator,
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const repos = repoIds.map((repo_id) => {
|
|
401
|
+
const repoPath = resolved[repo_id];
|
|
402
|
+
if (!repoPath) {
|
|
403
|
+
const reason = `repo "${repo_id}" path could not be resolved.`;
|
|
404
|
+
return {
|
|
405
|
+
repo_id,
|
|
406
|
+
ship_status: {
|
|
407
|
+
overall: 'fail',
|
|
408
|
+
dimensions: [],
|
|
409
|
+
blocking_reasons: [reason],
|
|
410
|
+
evidence_summary: `Ship status: FAIL — ${reason}`,
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
return { repo_id, ship_status: evaluateShipStatus(repoPath, forwarded) };
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
const hasFail = repos.some((r) => r.ship_status.overall === 'fail');
|
|
418
|
+
const hasPending = repos.some((r) => r.ship_status.overall === 'pending');
|
|
419
|
+
const overall = hasFail ? 'fail' : hasPending ? 'pending' : 'pass';
|
|
420
|
+
const blocking_repos = repos
|
|
421
|
+
.filter((r) => r.ship_status.overall !== 'pass')
|
|
422
|
+
.map((r) => r.repo_id);
|
|
423
|
+
const passed = repos.filter((r) => r.ship_status.overall === 'pass').length;
|
|
424
|
+
const blockingTag = blocking_repos.length ? `, blocking: ${blocking_repos.join(', ')}` : '';
|
|
425
|
+
const evidence_summary = `Coordinator ship status: ${overall.toUpperCase()} — ${passed}/${repos.length} repos shippable${blockingTag}.`;
|
|
426
|
+
|
|
427
|
+
return { overall, repos, blocking_repos, evidence_summary };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ── governance-report integration (export-artifact based) ───────────────────
|
|
431
|
+
|
|
432
|
+
function readArtifactFileContent(artifact, relPath) {
|
|
433
|
+
const entry = artifact?.files?.[relPath];
|
|
434
|
+
if (!entry) return null;
|
|
435
|
+
if (typeof entry === 'string') return entry;
|
|
436
|
+
if (typeof entry.data === 'string') return entry.data;
|
|
437
|
+
if (typeof entry.content_base64 === 'string') {
|
|
438
|
+
try {
|
|
439
|
+
return Buffer.from(entry.content_base64, 'base64').toString('utf8');
|
|
440
|
+
} catch {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function parseHistoryJsonl(content) {
|
|
448
|
+
if (typeof content !== 'string' || !content.trim()) return [];
|
|
449
|
+
return content
|
|
450
|
+
.split('\n')
|
|
451
|
+
.filter(Boolean)
|
|
452
|
+
.map((line) => {
|
|
453
|
+
try {
|
|
454
|
+
return JSON.parse(line);
|
|
455
|
+
} catch {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
})
|
|
459
|
+
.filter(Boolean);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Affirmative ship-verdict tokens, mirroring workflow-gate-semantics
|
|
463
|
+
// (evaluateShipVerdict is not exported, so the artifact path checks the recorded
|
|
464
|
+
// gate outcome first and only falls back to a minimal verdict-line parse).
|
|
465
|
+
const AFFIRMATIVE_VERDICTS = new Set(['yes', 'ship', 'ship it']);
|
|
466
|
+
|
|
467
|
+
function evaluateShipVerdictFromArtifact(state, verdictContent, config) {
|
|
468
|
+
const gateStatus = normalizeGateStatus(state?.phase_gate_status?.qa_ship_verdict);
|
|
469
|
+
if (gateStatus === 'passed') {
|
|
470
|
+
return dimension('qa_ship_verdict', 'pass', 'QA ship verdict gate passed.', null);
|
|
471
|
+
}
|
|
472
|
+
if (gateStatus === 'failed') {
|
|
473
|
+
return dimension('qa_ship_verdict', 'fail', 'QA ship verdict gate failed.', 'QA did not approve shipping.');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (typeof verdictContent === 'string' && verdictContent.trim()) {
|
|
477
|
+
const match = verdictContent.match(/^##\s+Verdict\s*:\s*(.+)$/im);
|
|
478
|
+
if (match) {
|
|
479
|
+
const token = match[1].trim().toLowerCase().replace(/[.!]+$/, '');
|
|
480
|
+
if (AFFIRMATIVE_VERDICTS.has(token)) {
|
|
481
|
+
return dimension('qa_ship_verdict', 'pass', 'QA ship verdict is affirmative.', null);
|
|
482
|
+
}
|
|
483
|
+
return dimension(
|
|
484
|
+
'qa_ship_verdict',
|
|
485
|
+
'fail',
|
|
486
|
+
`QA ship verdict is "${match[1].trim()}".`,
|
|
487
|
+
`QA did not approve shipping: verdict "${match[1].trim()}".`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const { reached, finalPhase } = finalPhaseReached(state, config);
|
|
493
|
+
if (reached) {
|
|
494
|
+
return dimension(
|
|
495
|
+
'qa_ship_verdict',
|
|
496
|
+
'fail',
|
|
497
|
+
'QA ship verdict missing after QA phase.',
|
|
498
|
+
`QA ship verdict missing after reaching the "${finalPhase}" phase.`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
return dimension(
|
|
502
|
+
'qa_ship_verdict',
|
|
503
|
+
'pending',
|
|
504
|
+
'QA ship verdict not yet produced.',
|
|
505
|
+
`QA phase ("${finalPhase}") not yet reached; ship verdict pending.`,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Build the compact ship-status summary embedded in a governance report.
|
|
511
|
+
* Operates on an export artifact (filesystem is not live), so release_alignment
|
|
512
|
+
* is reported as pending — the live `agentxchain ship-status` command surfaces it.
|
|
513
|
+
*
|
|
514
|
+
* @param {object} artifact
|
|
515
|
+
* @returns {{ overall, dimensions_passed, dimensions_total, blocking_reasons } | null}
|
|
516
|
+
*/
|
|
517
|
+
export function buildShipStatusSummary(artifact) {
|
|
518
|
+
if (!artifact) return null;
|
|
519
|
+
|
|
520
|
+
const state = artifact.state || null;
|
|
521
|
+
const config = artifact.config || null;
|
|
522
|
+
const history = parseHistoryJsonl(readArtifactFileContent(artifact, HISTORY_PATH));
|
|
523
|
+
const verdictContent = readArtifactFileContent(artifact, SHIP_VERDICT_PATH);
|
|
524
|
+
|
|
525
|
+
const dimensions = [
|
|
526
|
+
evaluateRunCompletionDimension(state),
|
|
527
|
+
evaluateShipVerdictFromArtifact(state, verdictContent, config),
|
|
528
|
+
evaluateGateClearanceDimension(state, config),
|
|
529
|
+
dimension(
|
|
530
|
+
'release_alignment',
|
|
531
|
+
'pending',
|
|
532
|
+
'Release alignment is not evaluable from an export artifact.',
|
|
533
|
+
'Release alignment not evaluated (run `agentxchain ship-status` for live evaluation).',
|
|
534
|
+
),
|
|
535
|
+
evaluateTestVerificationDimension(history),
|
|
536
|
+
];
|
|
537
|
+
|
|
538
|
+
const result = aggregateShipStatus(dimensions);
|
|
539
|
+
return {
|
|
540
|
+
overall: result.overall,
|
|
541
|
+
dimensions_passed: result.dimensions.filter((d) => d.status === 'pass').length,
|
|
542
|
+
dimensions_total: result.dimensions.length,
|
|
543
|
+
blocking_reasons: result.blocking_reasons,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
@@ -138,7 +138,7 @@ export function validateStagedTurnResult(root, state, config, opts = {}) {
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
// ── Stage C: Artifact Validation ───────────────────────────────────────
|
|
141
|
-
const artifactResult = validateArtifact(turnResult, config, state);
|
|
141
|
+
const artifactResult = validateArtifact(turnResult, config, state, root);
|
|
142
142
|
if (artifactResult.errors.length > 0) {
|
|
143
143
|
return result('artifact', 'artifact_error', artifactResult.errors, artifactResult.warnings);
|
|
144
144
|
}
|
|
@@ -670,7 +670,7 @@ function validateAssignment(tr, state) {
|
|
|
670
670
|
|
|
671
671
|
// ── Stage C: Artifact Validation ─────────────────────────────────────────────
|
|
672
672
|
|
|
673
|
-
function validateArtifact(tr, config, state = null) {
|
|
673
|
+
function validateArtifact(tr, config, state = null, root = null) {
|
|
674
674
|
const errors = [];
|
|
675
675
|
const warnings = [];
|
|
676
676
|
|
|
@@ -733,10 +733,18 @@ function validateArtifact(tr, config, state = null) {
|
|
|
733
733
|
if (writeAuthority === 'authoritative' && state?.phase === 'implementation' && tr.status === 'completed') {
|
|
734
734
|
const productFiles = (tr.files_changed || []).filter(f => isProductChangePath(f));
|
|
735
735
|
if (productFiles.length === 0) {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
)
|
|
736
|
+
// A planning/review-only completion is legitimate ONLY as a follow-on once the
|
|
737
|
+
// objective's product code has already been delivered in a prior accepted turn of this
|
|
738
|
+
// run (e.g. QA, or a Dev re-run, finalizing the gate-required IMPLEMENTATION_NOTES
|
|
739
|
+
// ## Changes / ## Verification sections after the implementation itself was committed).
|
|
740
|
+
// If no product code has been committed for the run yet, planning artifacts alone still
|
|
741
|
+
// do not satisfy the implementation_complete gate.
|
|
742
|
+
if (!runHasCommittedProductCode(root, state?.run_id)) {
|
|
743
|
+
errors.push(
|
|
744
|
+
`Role "${tr.role}" completed an implementation turn without product code changes in files_changed. ` +
|
|
745
|
+
'Implementation-phase completion requires at least one non-planning, non-review repo path; planning artifacts alone are not sufficient.'
|
|
746
|
+
);
|
|
747
|
+
}
|
|
740
748
|
} else if (productFiles.every(f => isTestPath(f))) {
|
|
741
749
|
// Work-substance signal: an implementation turn that changed ONLY test files produced
|
|
742
750
|
// no implementation source. That is legitimate for acceptance/verification objectives,
|
|
@@ -802,6 +810,34 @@ function isProductChangePath(filePath) {
|
|
|
802
810
|
&& !filePath.startsWith('.agentxchain/staging/');
|
|
803
811
|
}
|
|
804
812
|
|
|
813
|
+
// Whether any prior accepted turn in this run already committed product code. A follow-on
|
|
814
|
+
// implementation-phase turn that only finalizes planning artifacts (e.g. the gate-required
|
|
815
|
+
// IMPLEMENTATION_NOTES ## Changes / ## Verification sections) is legitimate once the
|
|
816
|
+
// objective's implementation is delivered; a run that has produced no product code at all is
|
|
817
|
+
// still held to the "code, not just docs" requirement. Reads the governed accepted-turn log.
|
|
818
|
+
function runHasCommittedProductCode(root, runId) {
|
|
819
|
+
if (!root || !runId) return false;
|
|
820
|
+
try {
|
|
821
|
+
const histPath = join(root, '.agentxchain', 'history.jsonl');
|
|
822
|
+
if (!existsSync(histPath)) return false;
|
|
823
|
+
for (const line of readFileSync(histPath, 'utf8').split('\n')) {
|
|
824
|
+
if (!line.trim()) continue;
|
|
825
|
+
let entry;
|
|
826
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
827
|
+
if (entry.run_id !== runId) continue;
|
|
828
|
+
// Only a COMPLETED IMPLEMENTATION turn counts as "the objective's code was delivered" — a
|
|
829
|
+
// planning-phase or non-completed turn that incidentally touched a file must not disarm the
|
|
830
|
+
// guard (adversarial-review hardening).
|
|
831
|
+
if (entry.status !== 'completed' || entry.phase !== 'implementation') continue;
|
|
832
|
+
const files = Array.isArray(entry.files_changed) ? entry.files_changed : [];
|
|
833
|
+
if (files.some((f) => isProductChangePath(f))) return true;
|
|
834
|
+
}
|
|
835
|
+
} catch {
|
|
836
|
+
// History unreadable — fall back to the strict requirement (treat as no prior product code).
|
|
837
|
+
}
|
|
838
|
+
return false;
|
|
839
|
+
}
|
|
840
|
+
|
|
805
841
|
// A test/spec file path — used to flag implementation turns that produced only tests
|
|
806
842
|
// (no implementation source) so test-only work is reviewed rather than silently accepted.
|
|
807
843
|
function isTestPath(filePath) {
|