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.
@@ -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
- errors.push(
737
- `Role "${tr.role}" completed an implementation turn without product code changes in files_changed. ` +
738
- 'Implementation-phase completion requires at least one non-planning, non-review repo path; planning artifacts alone are not sufficient.'
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) {