agentxchain 2.157.0 → 2.158.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.
@@ -124,6 +124,8 @@ import { benchmarkCommand } from '../src/commands/benchmark.js';
124
124
  import { benchmarkWorkloadsCommand } from '../src/commands/benchmark-workloads.js';
125
125
  import { historyCommand } from '../src/commands/history.js';
126
126
  import { decisionsCommand } from '../src/commands/decisions.js';
127
+ import { shipStatusCommand } from '../src/commands/ship-status.js';
128
+ import { attentionCommand } from '../src/commands/attention.js';
127
129
  import { diffCommand } from '../src/commands/diff.js';
128
130
  import { eventsCommand } from '../src/commands/events.js';
129
131
  import { connectorCapabilitiesCommand, connectorCheckCommand, connectorValidateCommand } from '../src/commands/connector.js';
@@ -420,6 +422,22 @@ program
420
422
  .option('-d, --dir <path>', 'Project directory')
421
423
  .action(decisionsCommand);
422
424
 
425
+ program
426
+ .command('ship-status')
427
+ .description('Evaluate whether the current project is ready to ship')
428
+ .option('-j, --json', 'Machine-readable JSON output')
429
+ .option('-v, --verbose', 'Per-dimension detail breakdown')
430
+ .option('-d, --dir <path>', 'Project directory')
431
+ .action(shipStatusCommand);
432
+
433
+ program
434
+ .command('attention')
435
+ .description('Show the cross-run human-decision queue — what needs you, and nothing else (govern by exception)')
436
+ .option('-j, --json', 'Machine-readable JSON output (full HumanAttentionReport)')
437
+ .option('-a, --all', 'Include informational/non-blocking items (e.g. pending approved intents)')
438
+ .option('-d, --dir <path>', 'Project directory')
439
+ .action(attentionCommand);
440
+
423
441
  program
424
442
  .command('diff <left_ref> <right_ref>')
425
443
  .description('Compare two recorded governed runs or two export artifacts')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.157.0",
3
+ "version": "2.158.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,66 @@
1
+ /**
2
+ * CLI command: agentxchain attention
3
+ *
4
+ * Answers "what needs MY attention right now — and nothing else?" by composing the
5
+ * cross-category human-decision queue into a single HumanAttentionReport. All evaluation
6
+ * logic lives in lib/human-attention.js — this command is presentation only
7
+ * (Architecture Invariant #6).
8
+ *
9
+ * It is a status surface, not a gate: it exits 0 in BOTH the clear and attention states.
10
+ * When the queue is empty it prints "Nothing needs your attention." — the operational
11
+ * proof that the human can step back and let governed autonomy run (VISION.md:51).
12
+ */
13
+
14
+ import { resolve } from 'node:path';
15
+ import chalk from 'chalk';
16
+ import { findProjectRoot } from '../lib/config.js';
17
+ import { evaluateHumanAttention } from '../lib/human-attention.js';
18
+
19
+ export async function attentionCommand(opts) {
20
+ const dir = opts.dir ? resolve(opts.dir) : process.cwd();
21
+ const root = findProjectRoot(dir);
22
+ if (!root) {
23
+ console.error(chalk.red('No agentxchain project found. Run "agentxchain init" first.'));
24
+ process.exitCode = 1;
25
+ return;
26
+ }
27
+
28
+ const report = evaluateHumanAttention(root);
29
+
30
+ if (opts.json) {
31
+ // --json always emits the full, schema-valid HumanAttentionReport; exit 0 in both states.
32
+ console.log(JSON.stringify(report, null, 2));
33
+ return;
34
+ }
35
+
36
+ if (report.overall === 'clear') {
37
+ console.log(chalk.green('Nothing needs your attention.'));
38
+ return;
39
+ }
40
+
41
+ // Default view surfaces blocking + escalation items first; informational pending-intent
42
+ // items are summarized as a count unless --all is passed.
43
+ const visible = opts.all ? report.items : report.items.filter((item) => item.blocking);
44
+ const hidden = report.items.length - visible.length;
45
+
46
+ console.log(
47
+ `\n${chalk.bold('Attention needed')}: ${report.items_count} `
48
+ + `item${report.items_count === 1 ? '' : 's'} (${report.blocking_count} blocking)`,
49
+ );
50
+
51
+ visible.forEach((item, idx) => {
52
+ const tag = chalk.cyan(String(item.category).padEnd(16));
53
+ const run = item.run_id ? `${chalk.dim(item.run_id)} ` : '';
54
+ console.log(` [${idx + 1}] ${tag} ${run}${item.summary}`);
55
+ console.log(` ${chalk.dim('→')} ${item.action_hint}`);
56
+ });
57
+
58
+ if (hidden > 0) {
59
+ console.log(
60
+ chalk.dim(` (+${hidden} informational item${hidden === 1 ? '' : 's'} — run with --all to show)`),
61
+ );
62
+ }
63
+
64
+ console.log('');
65
+ // Status surface, not a gate — no non-zero exit even in the attention state.
66
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * CLI command: agentxchain ship-status
3
+ *
4
+ * Answers "is this ready to ship?" by composing five evidence dimensions into a
5
+ * single ShipStatusReport. All evaluation logic lives in lib/ship-status.js — this
6
+ * command is presentation only (Architecture Invariant #5).
7
+ */
8
+
9
+ import { existsSync } from 'node:fs';
10
+ import { resolve, join } from 'node:path';
11
+ import chalk from 'chalk';
12
+ import { findProjectRoot } from '../lib/config.js';
13
+ import { COORDINATOR_CONFIG_FILE } from '../lib/coordinator-config.js';
14
+ import { evaluateShipStatus, evaluateCoordinatorShipStatus } from '../lib/ship-status.js';
15
+
16
+ function statusIcon(status) {
17
+ if (status === 'pass') return chalk.green('✓');
18
+ if (status === 'fail') return chalk.red('✗');
19
+ return chalk.yellow('?');
20
+ }
21
+
22
+ function overallLabel(overall) {
23
+ if (overall === 'pass') return chalk.green.bold('YES');
24
+ if (overall === 'fail') return chalk.red.bold('NO');
25
+ return chalk.yellow.bold('PENDING');
26
+ }
27
+
28
+ function countPassed(report) {
29
+ return (report.dimensions || []).filter((d) => d.status === 'pass').length;
30
+ }
31
+
32
+ function printDimensions(dimensions, indent) {
33
+ for (const dim of dimensions || []) {
34
+ console.log(`${indent}${statusIcon(dim.status)} ${dim.name}: ${dim.detail}`);
35
+ }
36
+ }
37
+
38
+ function printSingle(report, opts) {
39
+ const passed = countPassed(report);
40
+ const total = (report.dimensions || []).length;
41
+ const blocking = report.blocking_reasons || [];
42
+ const suffix = blocking.length ? `, ${blocking.length} blocking` : '';
43
+ console.log(`\nShip Status: ${overallLabel(report.overall)} (${passed}/${total} dimensions pass${suffix})`);
44
+
45
+ if (opts.verbose) {
46
+ console.log('');
47
+ printDimensions(report.dimensions, ' ');
48
+ } else {
49
+ for (const reason of blocking) {
50
+ console.log(` ${chalk.dim('-')} ${reason}`);
51
+ }
52
+ }
53
+ console.log('');
54
+ }
55
+
56
+ export async function shipStatusCommand(opts) {
57
+ const dir = opts.dir ? resolve(opts.dir) : process.cwd();
58
+ const coordinatorConfigPath = join(dir, COORDINATOR_CONFIG_FILE);
59
+
60
+ let report;
61
+ let isCoordinator = false;
62
+
63
+ if (existsSync(coordinatorConfigPath)) {
64
+ isCoordinator = true;
65
+ report = evaluateCoordinatorShipStatus(dir);
66
+ } else {
67
+ const root = findProjectRoot(dir);
68
+ if (!root) {
69
+ console.error(chalk.red('No agentxchain project found. Run "agentxchain init" first.'));
70
+ process.exitCode = 1;
71
+ return;
72
+ }
73
+ report = evaluateShipStatus(root);
74
+ }
75
+
76
+ if (opts.json) {
77
+ console.log(JSON.stringify(report, null, 2));
78
+ } else if (isCoordinator) {
79
+ const blocking = report.blocking_repos || [];
80
+ const total = (report.repos || []).length;
81
+ const shippable = total - blocking.length;
82
+ console.log(`\nCoordinator Ship Status: ${overallLabel(report.overall)} (${shippable}/${total} repos shippable)`);
83
+ for (const entry of report.repos || []) {
84
+ const passed = countPassed(entry.ship_status);
85
+ const dimTotal = (entry.ship_status.dimensions || []).length;
86
+ console.log(`\n ${chalk.bold(entry.repo_id)}: ${overallLabel(entry.ship_status.overall)} (${passed}/${dimTotal})`);
87
+ if (opts.verbose) {
88
+ printDimensions(entry.ship_status.dimensions, ' ');
89
+ } else {
90
+ for (const reason of entry.ship_status.blocking_reasons || []) {
91
+ console.log(` ${chalk.dim('-')} ${reason}`);
92
+ }
93
+ }
94
+ }
95
+ console.log('');
96
+ } else {
97
+ printSingle(report, opts);
98
+ }
99
+
100
+ // Non-zero exit when not shippable (useful for CI gating).
101
+ if (report.overall === 'fail') {
102
+ process.exitCode = 1;
103
+ }
104
+ }
@@ -471,6 +471,18 @@ function renderPrompt(role, roleId, turn, state, config, root) {
471
471
  lines.push('');
472
472
  }
473
473
 
474
+ // Single-shot execution model — prevents ghost turns from async / background-wait patterns.
475
+ lines.push('## Single-Shot Execution (read carefully)');
476
+ lines.push('');
477
+ lines.push('You run as a **single, non-interactive `--print` subprocess**. You get exactly ONE invocation with no "later": there is no async wake-up, no second message, and nothing will notify you after you stop responding. The moment you end your turn the subprocess exits — if you have not written your turn result by then, the entire turn is **discarded as a ghost** (no result, wasted run).');
478
+ lines.push('');
479
+ lines.push('Therefore:');
480
+ lines.push('- Do ALL work **synchronously** within this one turn. Run every command to completion in the foreground and read its output inline before continuing.');
481
+ lines.push('- **NEVER** start a background job and then wait for it. Do not use `ScheduleWakeup`; do not start a `Monitor` or `tail -f` and wait for events; do not background a process with `&` and poll for a notification; do not defer any conclusion to "after the suite/build finishes". You will not be re-invoked to finish.');
482
+ lines.push('- If you need a long command or test suite result, run it **blocking in the foreground** (e.g. `npm test`) and wait for it to return in-line — the per-turn timeout is generous, so budget your time for it.');
483
+ lines.push('- Writing the structured turn result below is your **final action**: gather every piece of evidence first, then write the result before you finish.');
484
+ lines.push('');
485
+
474
486
  // Output format with complete JSON template
475
487
  lines.push('## Required Output');
476
488
  lines.push('');
@@ -0,0 +1,381 @@
1
+ /**
2
+ * M15: Govern Without Micromanaging — Human Attention Surface (VISION.md:51)
3
+ *
4
+ * Closes the final "Why This Must Exist" pain bullet: "humans lose the ability to
5
+ * govern without micromanaging." The triggers that legitimately require a human are
6
+ * real but scattered — pending phase/completion approvals live in `state.blocked_on`,
7
+ * open escalations in `human-escalations.jsonl`, approved-but-undispatched work in the
8
+ * intake system, credentialed gates in `approval-policy`, and budget/policy halts back
9
+ * in `state.blocked_on`. To govern today the operator must poll every surface
10
+ * (micromanage) or trust blindly (forbidden by VISION.md:220).
11
+ *
12
+ * This module is a govern-by-exception COMPOSITION layer (Architecture Invariant #1):
13
+ * it aggregates those already-existing signals into a single prioritized
14
+ * `HumanAttentionReport`, reaching each one through its public surface. It NEVER
15
+ * reimplements escalation, approval, or intake logic, and it is strictly READ-ONLY
16
+ * (Invariant #2) — it never mutates state, escalations, intents, artifacts, or config.
17
+ * Every category is evaluated independently; a failure or empty result in one never
18
+ * suppresses another (Invariant #4).
19
+ *
20
+ * The defining property: when no human decision is pending the queue is EMPTY and
21
+ * `overall === 'clear'` (Invariant #3). That empty state is the operational proof that
22
+ * the human can step back and let governed autonomy run.
23
+ *
24
+ * Public surface:
25
+ * evaluateHumanAttention(repoDir) — live single-repo cross-category queue
26
+ * buildHumanAttentionSummary(artifact) — compact summary embedded in a governance report
27
+ */
28
+
29
+ import { existsSync, readFileSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+
32
+ import { loadProjectContext } from './config.js';
33
+ import { readHumanEscalations } from './human-escalations.js';
34
+ import { findPendingApprovedIntents } from './intake.js';
35
+ import { isCredentialedExitGate } from './approval-policy.js';
36
+
37
+ const DEFAULT_STATE_PATH = '.agentxchain/state.json';
38
+
39
+ // Category ids surfaced by the attention queue.
40
+ export const HUMAN_ATTENTION_CATEGORIES = {
41
+ CREDENTIALED_GATE: 'credentialed_gate',
42
+ ESCALATION: 'escalation',
43
+ APPROVAL: 'approval',
44
+ MANUAL_ACTION: 'manual_action',
45
+ BUDGET_POLICY: 'budget_policy',
46
+ PENDING_INTENT: 'pending_intent',
47
+ };
48
+
49
+ // Deterministic priority key (lower = more urgent). Blocking categories are all < 100
50
+ // and informational categories >= 100, which — combined with the blocking-first sort —
51
+ // guarantees every blocking item precedes every non-blocking item (Ordering contract).
52
+ // Within the blocking tier: credentialed-gate and escalation outrank pending-approval,
53
+ // which outranks budget/policy; manual_action (gate_action/human blocks) sits just below
54
+ // approval. Pending-intent is the only informational tier.
55
+ const CATEGORY_PRIORITY = {
56
+ credentialed_gate: 10,
57
+ escalation: 20,
58
+ approval: 30,
59
+ manual_action: 35,
60
+ budget_policy: 40,
61
+ pending_intent: 100,
62
+ };
63
+
64
+ /**
65
+ * Read the governed state file WITHOUT writeback (loadProjectState may normalize and
66
+ * persist, which would violate the read-only invariant). Mirrors ship-status.js.
67
+ */
68
+ function readGovernedStateReadOnly(root, config) {
69
+ const rel = config?.files?.state || DEFAULT_STATE_PATH;
70
+ const filePath = join(root, rel);
71
+ if (!existsSync(filePath)) return null;
72
+ try {
73
+ return JSON.parse(readFileSync(filePath, 'utf8'));
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function blockedOnString(state) {
80
+ return typeof state?.blocked_on === 'string' ? state.blocked_on : null;
81
+ }
82
+
83
+ // ── category evaluators (pure; exported for focused unit testing) ─────────────
84
+
85
+ /**
86
+ * Identify the gate (if any) currently awaiting human approval, from the blocked
87
+ * state or a recorded pending transition / completion.
88
+ */
89
+ function pendingApprovalGate(state) {
90
+ if (!state) return null;
91
+ const blockedOn = blockedOnString(state);
92
+ if (blockedOn && blockedOn.startsWith('human_approval:')) {
93
+ return blockedOn.slice('human_approval:'.length) || null;
94
+ }
95
+ if (state.pending_run_completion?.gate) return state.pending_run_completion.gate;
96
+ if (state.pending_phase_transition?.gate) return state.pending_phase_transition.gate;
97
+ return null;
98
+ }
99
+
100
+ function approvalActionHint(state) {
101
+ if (state?.pending_run_completion) return 'agentxchain approve-completion';
102
+ return 'agentxchain approve-transition';
103
+ }
104
+
105
+ /**
106
+ * Categories 1 & 4 — Pending approvals (critical transitions / completion) and the
107
+ * credentialed-gate refinement. A pending human-approval gate is a single decision;
108
+ * it is classified as `credentialed_gate` when the current phase's exit gate is
109
+ * credentialed (VISION.md:36 — gates guarding irreversible or credentialed actions),
110
+ * otherwise as `approval`. Mutually exclusive, so the same decision is never
111
+ * double-counted while still honouring the ordering contract (credentialed outranks
112
+ * plain approval). Composes approval-policy.js `isCredentialedExitGate`.
113
+ */
114
+ export function evaluatePendingApprovalCategory(state, config) {
115
+ const gate = pendingApprovalGate(state);
116
+ if (!gate) return [];
117
+
118
+ const runId = state?.run_id || null;
119
+ const phase = state?.phase || null;
120
+ const phaseSuffix = phase ? ` (phase "${phase}")` : '';
121
+ const hint = approvalActionHint(state);
122
+
123
+ let credentialed = false;
124
+ try {
125
+ credentialed = isCredentialedExitGate(config, phase);
126
+ } catch {
127
+ credentialed = false;
128
+ }
129
+
130
+ if (credentialed) {
131
+ return [{
132
+ category: HUMAN_ATTENTION_CATEGORIES.CREDENTIALED_GATE,
133
+ priority: CATEGORY_PRIORITY.credentialed_gate,
134
+ blocking: true,
135
+ run_id: runId,
136
+ summary: `Credentialed gate "${gate}" requires human approval${phaseSuffix}.`,
137
+ action_hint: hint,
138
+ }];
139
+ }
140
+
141
+ return [{
142
+ category: HUMAN_ATTENTION_CATEGORIES.APPROVAL,
143
+ priority: CATEGORY_PRIORITY.approval,
144
+ blocking: true,
145
+ run_id: runId,
146
+ summary: `Gate "${gate}" awaits human approval${phaseSuffix}.`,
147
+ action_hint: hint,
148
+ }];
149
+ }
150
+
151
+ /**
152
+ * Category 2 — Open human escalations (intervene during escalation). Composes
153
+ * human-escalations.js `readHumanEscalations`; surfaces every record whose status is
154
+ * `open`. Each carries its own `resolution_command` action hint.
155
+ */
156
+ export function evaluateEscalationCategory(escalations) {
157
+ return (escalations || [])
158
+ .filter((record) => record && record.status === 'open')
159
+ .map((record) => {
160
+ const service = record.service ? ` (${record.service})` : '';
161
+ const what = record.detail || record.action || record.typed_reason || record.escalation_id;
162
+ return {
163
+ category: HUMAN_ATTENTION_CATEGORIES.ESCALATION,
164
+ priority: CATEGORY_PRIORITY.escalation,
165
+ blocking: true,
166
+ run_id: record.run_id || null,
167
+ summary: `${record.type || 'escalation'} escalated to human${service}: ${what}`,
168
+ action_hint: record.resolution_command || `agentxchain unblock ${record.escalation_id}`,
169
+ };
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Category 5 — Budget / policy blockers. A run halted on budget or policy needs a human
175
+ * to raise the budget or override the policy. Composes `state.blocked_on`
176
+ * (`budget:exhausted`, `policy:<id>`).
177
+ */
178
+ export function evaluateBudgetPolicyCategory(state) {
179
+ const blockedOn = blockedOnString(state);
180
+ if (!blockedOn) return [];
181
+ const runId = state?.run_id || null;
182
+
183
+ if (blockedOn === 'budget:exhausted') {
184
+ return [{
185
+ category: HUMAN_ATTENTION_CATEGORIES.BUDGET_POLICY,
186
+ priority: CATEGORY_PRIORITY.budget_policy,
187
+ blocking: true,
188
+ run_id: runId,
189
+ summary: 'Run halted: budget exhausted — raise the budget to continue.',
190
+ action_hint: 'agentxchain unblock',
191
+ }];
192
+ }
193
+
194
+ if (blockedOn.startsWith('policy:')) {
195
+ const policyId = blockedOn.slice('policy:'.length) || 'unknown';
196
+ return [{
197
+ category: HUMAN_ATTENTION_CATEGORIES.BUDGET_POLICY,
198
+ priority: CATEGORY_PRIORITY.budget_policy,
199
+ blocking: true,
200
+ run_id: runId,
201
+ summary: `Run halted by policy "${policyId}" — a human must override or adjust it.`,
202
+ action_hint: 'agentxchain unblock',
203
+ }];
204
+ }
205
+
206
+ return [];
207
+ }
208
+
209
+ /**
210
+ * Bonus category — Manual gate action / generic human block. Catches the remaining
211
+ * human-owned `state.blocked_on` encodings (`gate_action:<gate>`, `human:<detail>`) so a
212
+ * run blocked on operator intervention is never silently dropped from the queue. (The
213
+ * `escalation:<id>` encoding is already represented by its open escalation in Category 2.)
214
+ */
215
+ export function evaluateManualActionCategory(state) {
216
+ const blockedOn = blockedOnString(state);
217
+ if (!blockedOn) return [];
218
+ const runId = state?.run_id || null;
219
+
220
+ if (blockedOn.startsWith('gate_action:')) {
221
+ const gate = blockedOn.slice('gate_action:'.length) || 'unknown';
222
+ return [{
223
+ category: HUMAN_ATTENTION_CATEGORIES.MANUAL_ACTION,
224
+ priority: CATEGORY_PRIORITY.manual_action,
225
+ blocking: true,
226
+ run_id: runId,
227
+ summary: `Run awaits a manual gate action on "${gate}".`,
228
+ action_hint: 'agentxchain gate',
229
+ }];
230
+ }
231
+
232
+ if (blockedOn.startsWith('human:')) {
233
+ const detail = blockedOn.slice('human:'.length) || 'operator intervention required';
234
+ return [{
235
+ category: HUMAN_ATTENTION_CATEGORIES.MANUAL_ACTION,
236
+ priority: CATEGORY_PRIORITY.manual_action,
237
+ blocking: true,
238
+ run_id: runId,
239
+ summary: `Run awaits operator intervention: ${detail}.`,
240
+ action_hint: 'agentxchain unblock',
241
+ }];
242
+ }
243
+
244
+ return [];
245
+ }
246
+
247
+ /**
248
+ * Category 3 — Pending approved intents awaiting dispatch (set direction). Informational:
249
+ * work approved but not yet dispatched. Composes intake.js `findPendingApprovedIntents`.
250
+ */
251
+ export function evaluatePendingIntentCategory(intents) {
252
+ return (intents || []).map((intent) => {
253
+ const pri = intent.priority ? ` [${intent.priority}]` : '';
254
+ const charter = intent.charter ? `: ${intent.charter}` : '';
255
+ return {
256
+ category: HUMAN_ATTENTION_CATEGORIES.PENDING_INTENT,
257
+ priority: CATEGORY_PRIORITY.pending_intent,
258
+ blocking: false,
259
+ run_id: null,
260
+ summary: `Approved intent ${intent.intent_id}${pri} awaits dispatch${charter}.`,
261
+ action_hint: 'agentxchain start',
262
+ };
263
+ });
264
+ }
265
+
266
+ // ── ordering & assembly ──────────────────────────────────────────────────────
267
+
268
+ /**
269
+ * Deterministic Ordering contract: blocking items before non-blocking; within a tier,
270
+ * ascending `priority`; ties broken by `run_id` then `summary` for stability.
271
+ */
272
+ function sortItems(items) {
273
+ return [...items].sort((a, b) => {
274
+ if (a.blocking !== b.blocking) return a.blocking ? -1 : 1;
275
+ if (a.priority !== b.priority) return a.priority - b.priority;
276
+ const ra = a.run_id || '';
277
+ const rb = b.run_id || '';
278
+ if (ra !== rb) return ra < rb ? -1 : 1;
279
+ const sa = a.summary || '';
280
+ const sb = b.summary || '';
281
+ if (sa !== sb) return sa < sb ? -1 : 1;
282
+ return 0;
283
+ });
284
+ }
285
+
286
+ function assembleReport(items) {
287
+ const sorted = sortItems(items);
288
+ const blockingCount = sorted.filter((item) => item.blocking).length;
289
+ const categories = [...new Set(sorted.map((item) => item.category))];
290
+ const overall = sorted.length === 0 ? 'clear' : 'attention';
291
+
292
+ const evidence = overall === 'clear'
293
+ ? 'Nothing needs your attention; governed autonomy can run.'
294
+ : `${sorted.length} item${sorted.length === 1 ? '' : 's'} need human attention `
295
+ + `(${blockingCount} blocking) across ${categories.length} `
296
+ + `categor${categories.length === 1 ? 'y' : 'ies'}.`;
297
+
298
+ return {
299
+ overall,
300
+ items: sorted,
301
+ items_count: sorted.length,
302
+ blocking_count: blockingCount,
303
+ categories,
304
+ evidence_summary: evidence,
305
+ };
306
+ }
307
+
308
+ // Run each category in isolation so a throw or empty result in one never suppresses
309
+ // another (Architecture Invariant #4).
310
+ function collect(items, fn) {
311
+ try {
312
+ const result = fn();
313
+ if (Array.isArray(result)) items.push(...result);
314
+ } catch {
315
+ /* category isolated — a failure here must not blank the rest of the queue */
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Compose the cross-category human-decision queue for a single governed repo into a
321
+ * prioritized `HumanAttentionReport`. Read-only.
322
+ *
323
+ * @param {string} repoDir
324
+ * @returns {{ overall, items, items_count, blocking_count, categories, evidence_summary }}
325
+ */
326
+ export function evaluateHumanAttention(repoDir) {
327
+ const root = repoDir || process.cwd();
328
+
329
+ let config = null;
330
+ try {
331
+ const ctx = loadProjectContext(root);
332
+ config = ctx?.config || null;
333
+ } catch {
334
+ config = null;
335
+ }
336
+
337
+ const state = readGovernedStateReadOnly(root, config);
338
+
339
+ let escalations = [];
340
+ collect([], () => { escalations = readHumanEscalations(root) || []; return []; });
341
+
342
+ let intents = [];
343
+ collect([], () => { intents = findPendingApprovedIntents(root, { run_id: state?.run_id || null }) || []; return []; });
344
+
345
+ const items = [];
346
+ collect(items, () => evaluatePendingApprovalCategory(state, config));
347
+ collect(items, () => evaluateEscalationCategory(escalations));
348
+ collect(items, () => evaluateBudgetPolicyCategory(state));
349
+ collect(items, () => evaluateManualActionCategory(state));
350
+ collect(items, () => evaluatePendingIntentCategory(intents));
351
+
352
+ return assembleReport(items);
353
+ }
354
+
355
+ /**
356
+ * Build the compact human-attention summary embedded in a governance report. Operates on
357
+ * an export artifact (filesystem is not live), so only the state/config-derivable
358
+ * categories are evaluated — the live `agentxchain attention` command surfaces the full
359
+ * cross-category queue (escalations, pending intents).
360
+ *
361
+ * @param {object} artifact
362
+ * @returns {{ overall, items_count, blocking_count, categories } | null}
363
+ */
364
+ export function buildHumanAttentionSummary(artifact) {
365
+ if (!artifact) return null;
366
+ const state = artifact.state || null;
367
+ const config = artifact.config || null;
368
+
369
+ const items = [];
370
+ collect(items, () => evaluatePendingApprovalCategory(state, config));
371
+ collect(items, () => evaluateBudgetPolicyCategory(state));
372
+ collect(items, () => evaluateManualActionCategory(state));
373
+
374
+ const report = assembleReport(items);
375
+ return {
376
+ overall: report.overall,
377
+ items_count: report.items_count,
378
+ blocking_count: report.blocking_count,
379
+ categories: report.categories,
380
+ };
381
+ }
package/src/lib/report.js CHANGED
@@ -13,6 +13,8 @@ import { buildCoordinatorRepoStatusEntries } from './coordinator-repo-status-pre
13
13
  import { summarizeCoordinatorEvent } from './coordinator-event-narrative.js';
14
14
  import { extractGateActionDigest } from './gate-actions.js';
15
15
  import { buildRecoveryClassificationReport } from './recovery-classification.js';
16
+ import { buildShipStatusSummary } from './ship-status.js';
17
+ import { buildHumanAttentionSummary } from './human-attention.js';
16
18
 
17
19
  export const GOVERNANCE_REPORT_VERSION = '0.1';
18
20
 
@@ -1077,6 +1079,8 @@ function buildRunSubject(artifact) {
1077
1079
  continuity,
1078
1080
  workflow_kit_artifacts: extractWorkflowKitArtifacts(artifact),
1079
1081
  repo_decisions: artifact.summary?.repo_decisions || null,
1082
+ ship_status: buildShipStatusSummary(artifact),
1083
+ human_attention: buildHumanAttentionSummary(artifact),
1080
1084
  },
1081
1085
  artifacts: {
1082
1086
  history_entries: artifact.summary?.history_entries || 0,
@@ -1425,6 +1429,17 @@ export function formatGovernanceReportText(report) {
1425
1429
  }
1426
1430
  }
1427
1431
 
1432
+ if (run.ship_status) {
1433
+ const ss = run.ship_status;
1434
+ const label = ss.overall === 'pass' ? 'YES' : ss.overall === 'fail' ? 'NO' : 'PENDING';
1435
+ lines.push('', `Ship Status: ${label} (${ss.dimensions_passed}/${ss.dimensions_total} dimensions pass)`);
1436
+ if (ss.blocking_reasons.length > 0) {
1437
+ for (const reason of ss.blocking_reasons) {
1438
+ lines.push(` - ${reason}`);
1439
+ }
1440
+ }
1441
+ }
1442
+
1428
1443
  if (run.turns && run.turns.length > 0) {
1429
1444
  const { items: boundedTurns, omitted: turnsOmitted } = boundedSlice(run.turns);
1430
1445
  lines.push('', 'Turn Timeline:');
@@ -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) {