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.
- package/bin/agentxchain.js +18 -0
- package/package.json +1 -1
- package/src/commands/attention.js +66 -0
- 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 +15 -0
- package/src/lib/ship-status.js +545 -0
- package/src/lib/turn-result-validator.js +42 -6
package/bin/agentxchain.js
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
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) {
|