agentxchain 2.101.0 → 2.103.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 +21 -0
- package/package.json +1 -1
- package/src/commands/benchmark-workloads.js +206 -0
- package/src/commands/benchmark.js +775 -0
- package/src/commands/decisions.js +29 -3
- package/src/commands/doctor.js +17 -0
- package/src/commands/role.js +24 -10
- package/src/lib/admission-control.js +247 -0
- package/src/lib/dispatch-bundle.js +1 -1
- package/src/lib/export-diff.js +19 -3
- package/src/lib/export-verifier.js +53 -23
- package/src/lib/export.js +4 -14
- package/src/lib/governed-state.js +3 -1
- package/src/lib/normalized-config.js +8 -72
- package/src/lib/repo-decisions.js +163 -3
- package/src/lib/report.js +40 -5
- package/src/lib/run-loop.js +8 -0
- package/src/lib/validation.js +5 -3
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { resolve } from 'path';
|
|
8
|
-
import { existsSync } from 'fs';
|
|
8
|
+
import { existsSync, readFileSync } from 'fs';
|
|
9
9
|
import chalk from 'chalk';
|
|
10
|
-
import { readRepoDecisions, getActiveRepoDecisions, getRepoDecisionById } from '../lib/repo-decisions.js';
|
|
10
|
+
import { readRepoDecisions, getActiveRepoDecisions, getRepoDecisionById, resolveDecisionAuthority } from '../lib/repo-decisions.js';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* @param {object} opts - { json?: boolean, all?: boolean, show?: string, dir?: string }
|
|
@@ -39,6 +39,18 @@ export async function decisionsCommand(opts) {
|
|
|
39
39
|
console.log(` Phase: ${dec.phase || '—'}`);
|
|
40
40
|
console.log(` Run: ${(dec.run_id || '—').slice(0, 16)}`);
|
|
41
41
|
console.log(` Turn: ${(dec.turn_id || '—').slice(0, 16)}`);
|
|
42
|
+
console.log(` Durability: ${dec.durability || 'repo'}`);
|
|
43
|
+
// Show decision authority if config has it
|
|
44
|
+
const config = loadConfig(root);
|
|
45
|
+
if (config && dec.role) {
|
|
46
|
+
const auth = resolveDecisionAuthority(dec.role, config);
|
|
47
|
+
if (auth !== null && !(typeof auth === 'object' && auth.unknown)) {
|
|
48
|
+
console.log(` Authority: ${auth} (${dec.role})`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (dec.overrides) {
|
|
52
|
+
console.log(` Supersedes: ${chalk.yellow(dec.overrides)}`);
|
|
53
|
+
}
|
|
42
54
|
console.log(` Created: ${dec.created_at || '—'}`);
|
|
43
55
|
if (dec.overridden_by) {
|
|
44
56
|
console.log(` Overridden: ${chalk.yellow(dec.overridden_by)}`);
|
|
@@ -69,7 +81,11 @@ export async function decisionsCommand(opts) {
|
|
|
69
81
|
for (const dec of decisions) {
|
|
70
82
|
const status = formatStatus(dec.status);
|
|
71
83
|
const runShort = (dec.run_id || '').slice(0, 12);
|
|
72
|
-
const override = dec.overridden_by
|
|
84
|
+
const override = dec.overridden_by
|
|
85
|
+
? chalk.dim(` → ${dec.overridden_by}`)
|
|
86
|
+
: dec.overrides
|
|
87
|
+
? chalk.dim(` ← supersedes ${dec.overrides}`)
|
|
88
|
+
: '';
|
|
73
89
|
console.log(` ${chalk.cyan(dec.id)} ${status} ${chalk.dim(dec.category)} ${dec.statement}${override}`);
|
|
74
90
|
console.log(` ${chalk.dim(`by ${dec.role || '?'} in ${runShort || '?'}`)}`);
|
|
75
91
|
}
|
|
@@ -92,3 +108,13 @@ function findProjectRoot(dir) {
|
|
|
92
108
|
}
|
|
93
109
|
return null;
|
|
94
110
|
}
|
|
111
|
+
|
|
112
|
+
function loadConfig(root) {
|
|
113
|
+
const configPath = resolve(root, 'agentxchain.json');
|
|
114
|
+
if (!existsSync(configPath)) return null;
|
|
115
|
+
try {
|
|
116
|
+
return JSON.parse(readFileSync(configPath, 'utf8'));
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -4,6 +4,7 @@ import { join } from 'path';
|
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { loadConfig, loadLock, findProjectRoot } from '../lib/config.js';
|
|
6
6
|
import { validateProject } from '../lib/validation.js';
|
|
7
|
+
import { runAdmissionControl } from '../lib/admission-control.js';
|
|
7
8
|
import { getWatchPid } from './watch.js';
|
|
8
9
|
import { getDashboardPid, getDashboardSession } from './dashboard.js';
|
|
9
10
|
import { loadNormalizedConfig, detectConfigVersion } from '../lib/normalized-config.js';
|
|
@@ -235,6 +236,22 @@ function governedDoctor(root, rawConfig, opts) {
|
|
|
235
236
|
}
|
|
236
237
|
}
|
|
237
238
|
|
|
239
|
+
// 10. Admission control — static dead-end detection
|
|
240
|
+
if (normalized) {
|
|
241
|
+
const admission = runAdmissionControl(normalized, rawConfig);
|
|
242
|
+
if (!admission.ok) {
|
|
243
|
+
const errSummary = admission.errors.slice(0, 2).map(e => e.replace(/^ADM-\d+: /, '')).join('; ');
|
|
244
|
+
checks.push({ id: 'admission_control', name: 'Admission control', level: 'fail', detail: errSummary });
|
|
245
|
+
} else if (admission.warnings.length > 0) {
|
|
246
|
+
// ADM-003 warnings are advisory (external approval is a legitimate pattern),
|
|
247
|
+
// so report as info rather than warn to avoid noisy defaults.
|
|
248
|
+
const infoSummary = admission.warnings.slice(0, 2).map(w => w.replace(/^ADM-\d+: /, '')).join('; ');
|
|
249
|
+
checks.push({ id: 'admission_control', name: 'Admission control', level: 'info', detail: infoSummary });
|
|
250
|
+
} else {
|
|
251
|
+
checks.push({ id: 'admission_control', name: 'Admission control', level: 'pass', detail: 'No dead-end configs detected' });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
238
255
|
// Compute summary
|
|
239
256
|
const failCount = checks.filter(c => c.level === 'fail').length;
|
|
240
257
|
const warnCount = checks.filter(c => c.level === 'warn').length;
|
package/src/commands/role.js
CHANGED
|
@@ -37,13 +37,19 @@ function listRoles(roles, roleIds, opts) {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
if (opts.json) {
|
|
40
|
-
const output = roleIds.map((id) =>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
40
|
+
const output = roleIds.map((id) => {
|
|
41
|
+
const entry = {
|
|
42
|
+
id,
|
|
43
|
+
title: roles[id].title,
|
|
44
|
+
mandate: roles[id].mandate,
|
|
45
|
+
write_authority: roles[id].write_authority,
|
|
46
|
+
runtime: roles[id].runtime,
|
|
47
|
+
};
|
|
48
|
+
if (typeof roles[id].decision_authority === 'number') {
|
|
49
|
+
entry.decision_authority = roles[id].decision_authority;
|
|
50
|
+
}
|
|
51
|
+
return entry;
|
|
52
|
+
});
|
|
47
53
|
console.log(JSON.stringify(output, null, 2));
|
|
48
54
|
return;
|
|
49
55
|
}
|
|
@@ -56,7 +62,8 @@ function listRoles(roles, roleIds, opts) {
|
|
|
56
62
|
: r.write_authority === 'proposed'
|
|
57
63
|
? chalk.yellow(r.write_authority)
|
|
58
64
|
: chalk.dim(r.write_authority);
|
|
59
|
-
|
|
65
|
+
const decAuth = typeof r.decision_authority === 'number' ? chalk.dim(` dec:${r.decision_authority}`) : '';
|
|
66
|
+
console.log(` ${chalk.cyan(id)} — ${r.title} [${authority}${decAuth}] → ${chalk.dim(r.runtime)}`);
|
|
60
67
|
}
|
|
61
68
|
console.log('');
|
|
62
69
|
console.log(chalk.dim(' Usage: agentxchain role show <role_id>\n'));
|
|
@@ -81,13 +88,17 @@ function showRole(roleId, roles, roleIds, opts) {
|
|
|
81
88
|
const r = roles[roleId];
|
|
82
89
|
|
|
83
90
|
if (opts.json) {
|
|
84
|
-
|
|
91
|
+
const entry = {
|
|
85
92
|
id: roleId,
|
|
86
93
|
title: r.title,
|
|
87
94
|
mandate: r.mandate,
|
|
88
95
|
write_authority: r.write_authority,
|
|
89
96
|
runtime: r.runtime,
|
|
90
|
-
}
|
|
97
|
+
};
|
|
98
|
+
if (typeof r.decision_authority === 'number') {
|
|
99
|
+
entry.decision_authority = r.decision_authority;
|
|
100
|
+
}
|
|
101
|
+
console.log(JSON.stringify(entry, null, 2));
|
|
91
102
|
return;
|
|
92
103
|
}
|
|
93
104
|
|
|
@@ -101,6 +112,9 @@ function showRole(roleId, roles, roleIds, opts) {
|
|
|
101
112
|
console.log(` Title: ${r.title}`);
|
|
102
113
|
console.log(` Mandate: ${r.mandate}`);
|
|
103
114
|
console.log(` Authority: ${authority}`);
|
|
115
|
+
if (typeof r.decision_authority === 'number') {
|
|
116
|
+
console.log(` Decision: ${r.decision_authority}`);
|
|
117
|
+
}
|
|
104
118
|
console.log(` Runtime: ${chalk.dim(r.runtime)}`);
|
|
105
119
|
console.log('');
|
|
106
120
|
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admission Control — pre-run static analysis that rejects governed configs
|
|
3
|
+
* which cannot possibly reach completion.
|
|
4
|
+
*
|
|
5
|
+
* Pure function: no filesystem access, no state reads.
|
|
6
|
+
*
|
|
7
|
+
* Checks:
|
|
8
|
+
* ADM-001 No file producer for gated phase
|
|
9
|
+
* ADM-002 Authoritative writer unreachable for owned artifacts
|
|
10
|
+
* ADM-003 Impossible human approval topology (warning only)
|
|
11
|
+
* ADM-004 Owned artifact owner cannot write
|
|
12
|
+
*
|
|
13
|
+
* See .planning/ADMISSION_CONTROL_SPEC.md for full spec.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { getEffectiveGateArtifacts } from './gate-evaluator.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run all admission control checks against a governed config.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} config - normalized governed config
|
|
22
|
+
* @param {object} rawConfig - raw agentxchain.json (for workflow_kit, gates, approval_policy)
|
|
23
|
+
* @returns {{ ok: boolean, errors: string[], warnings: string[] }}
|
|
24
|
+
*/
|
|
25
|
+
export function runAdmissionControl(config, rawConfig) {
|
|
26
|
+
const errors = [];
|
|
27
|
+
const warnings = [];
|
|
28
|
+
|
|
29
|
+
const routing = config?.routing;
|
|
30
|
+
const gates = config?.gates || rawConfig?.gates;
|
|
31
|
+
const roles = config?.roles;
|
|
32
|
+
const runtimes = config?.runtimes || rawConfig?.runtimes;
|
|
33
|
+
|
|
34
|
+
if (!routing) {
|
|
35
|
+
return { ok: true, errors, warnings };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ADM-001 + ADM-002 + ADM-004: per-phase gate analysis
|
|
39
|
+
if (gates) {
|
|
40
|
+
for (const [phase, route] of Object.entries(routing)) {
|
|
41
|
+
const exitGateId = route?.exit_gate;
|
|
42
|
+
if (!exitGateId || !gates[exitGateId]) continue;
|
|
43
|
+
|
|
44
|
+
const gateDef = gates[exitGateId];
|
|
45
|
+
const effectiveArtifacts = getEffectiveGateArtifacts(config, gateDef, phase);
|
|
46
|
+
const requiredArtifacts = effectiveArtifacts.filter(a => a.required);
|
|
47
|
+
|
|
48
|
+
if (requiredArtifacts.length === 0) continue;
|
|
49
|
+
|
|
50
|
+
// Collect all roles routed to this phase
|
|
51
|
+
const candidateRoleIds = [
|
|
52
|
+
route?.entry_role,
|
|
53
|
+
...(Array.isArray(route?.allowed_next_roles) ? route.allowed_next_roles : []),
|
|
54
|
+
].filter(Boolean);
|
|
55
|
+
|
|
56
|
+
const uniqueRoleIds = [...new Set(candidateRoleIds)];
|
|
57
|
+
|
|
58
|
+
// ADM-001: check if any role can produce files
|
|
59
|
+
// Manual runtime roles are excluded — human operators can produce files
|
|
60
|
+
// outside the governed turn mechanism regardless of write_authority.
|
|
61
|
+
// Note: normalized config uses runtime_id, raw config uses runtime.
|
|
62
|
+
const rolesWithAuthority = uniqueRoleIds
|
|
63
|
+
.map(id => {
|
|
64
|
+
const role = roles?.[id];
|
|
65
|
+
const rtKey = role?.runtime_id || role?.runtime;
|
|
66
|
+
return { id, role, runtime: runtimes?.[rtKey] };
|
|
67
|
+
})
|
|
68
|
+
.filter(({ role }) => role);
|
|
69
|
+
|
|
70
|
+
const hasFileProducer = rolesWithAuthority.some(({ role, runtime }) =>
|
|
71
|
+
canRoleProduceFiles(role, runtime));
|
|
72
|
+
|
|
73
|
+
// Only flag non-manual roles as review_only dead-ends
|
|
74
|
+
const nonManualRoles = rolesWithAuthority.filter(({ runtime }) => runtime?.type !== 'manual');
|
|
75
|
+
if (!hasFileProducer && nonManualRoles.length > 0) {
|
|
76
|
+
const roleSummary = nonManualRoles
|
|
77
|
+
.map(({ id, role }) => `${id}:${role.write_authority}`)
|
|
78
|
+
.join(', ');
|
|
79
|
+
const fileSummary = requiredArtifacts.map(a => a.path).join(', ');
|
|
80
|
+
errors.push(
|
|
81
|
+
`ADM-001: Phase "${phase}" gate "${exitGateId}" requires files (${fileSummary}) but all routed roles are review_only (${roleSummary}). No agent can produce the required artifacts.`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ADM-002: check owned_by roles are reachable in this phase
|
|
86
|
+
for (const artifact of requiredArtifacts) {
|
|
87
|
+
if (!artifact.owned_by) continue;
|
|
88
|
+
if (!uniqueRoleIds.includes(artifact.owned_by)) {
|
|
89
|
+
errors.push(
|
|
90
|
+
`ADM-002: Phase "${phase}" artifact "${artifact.path}" is owned_by "${artifact.owned_by}" but that role is not in the phase routing (entry_role or allowed_next_roles). The ownership predicate can never be satisfied.`
|
|
91
|
+
);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ownerRole = roles?.[artifact.owned_by];
|
|
96
|
+
if (!ownerRole) continue;
|
|
97
|
+
const ownerRuntimeKey = ownerRole.runtime_id || ownerRole.runtime;
|
|
98
|
+
const ownerRuntime = runtimes?.[ownerRuntimeKey];
|
|
99
|
+
|
|
100
|
+
if (!canRoleProduceFiles(ownerRole, ownerRuntime)) {
|
|
101
|
+
errors.push(
|
|
102
|
+
`ADM-004: Phase "${phase}" artifact "${artifact.path}" is owned_by "${artifact.owned_by}" but that role is ${ownerRole.write_authority} on runtime type "${ownerRuntime?.type || 'unknown'}". The owner can participate in the phase but cannot produce the required artifact.`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ADM-003: impossible human approval topology
|
|
110
|
+
checkHumanApprovalTopology(config, rawConfig, routing, gates, roles, runtimes, warnings);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
ok: errors.length === 0,
|
|
114
|
+
errors,
|
|
115
|
+
warnings,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* ADM-003: Check whether human approval requirements are reachable.
|
|
121
|
+
* Emits warnings (not errors) because external approval paths are legitimate.
|
|
122
|
+
*/
|
|
123
|
+
function checkHumanApprovalTopology(config, rawConfig, routing, gates, roles, runtimes, warnings) {
|
|
124
|
+
// Determine if any manual runtime exists
|
|
125
|
+
const hasManualRuntime = runtimes
|
|
126
|
+
? Object.values(runtimes).some(rt => rt?.type === 'manual')
|
|
127
|
+
: false;
|
|
128
|
+
|
|
129
|
+
// If there's a manual runtime, human approval is always reachable
|
|
130
|
+
if (hasManualRuntime) return;
|
|
131
|
+
|
|
132
|
+
const approvalPolicy = config?.approval_policy || rawConfig?.approval_policy;
|
|
133
|
+
|
|
134
|
+
// Collect phases/gates that require human approval
|
|
135
|
+
const humanApprovalPoints = [];
|
|
136
|
+
|
|
137
|
+
for (const [phase, route] of Object.entries(routing)) {
|
|
138
|
+
const exitGateId = route?.exit_gate;
|
|
139
|
+
if (!exitGateId || !gates?.[exitGateId]) continue;
|
|
140
|
+
|
|
141
|
+
const gateDef = gates[exitGateId];
|
|
142
|
+
if (gateDef.requires_human_approval) {
|
|
143
|
+
// Check if approval_policy overrides to auto_approve for this transition
|
|
144
|
+
if (!isAutoApprovedByPolicy(approvalPolicy, 'phase_transitions', phase)) {
|
|
145
|
+
humanApprovalPoints.push({ type: 'phase_transition', phase, gate: exitGateId });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check run_completion gates
|
|
151
|
+
const completionGateId = config?.completion_gate || rawConfig?.completion_gate;
|
|
152
|
+
if (completionGateId && gates?.[completionGateId]) {
|
|
153
|
+
const gateDef = gates[completionGateId];
|
|
154
|
+
if (gateDef.requires_human_approval) {
|
|
155
|
+
if (!isAutoApprovedByPolicy(approvalPolicy, 'run_completion', null)) {
|
|
156
|
+
humanApprovalPoints.push({ type: 'run_completion', gate: completionGateId });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Also check approval_policy for explicit require_human actions
|
|
162
|
+
if (approvalPolicy?.phase_transitions) {
|
|
163
|
+
const pt = approvalPolicy.phase_transitions;
|
|
164
|
+
if (pt.default === 'require_human') {
|
|
165
|
+
// Every phase transition defaults to human approval
|
|
166
|
+
for (const phase of Object.keys(routing)) {
|
|
167
|
+
const hasAutoOverride = (pt.rules || []).some(rule =>
|
|
168
|
+
rule.action === 'auto_approve' && matchesPhaseRule(rule, phase));
|
|
169
|
+
if (!hasAutoOverride) {
|
|
170
|
+
const already = humanApprovalPoints.some(p => p.type === 'phase_transition' && p.phase === phase);
|
|
171
|
+
if (!already) {
|
|
172
|
+
humanApprovalPoints.push({ type: 'phase_transition', phase, gate: routing[phase]?.exit_gate || '(policy)' });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (Array.isArray(pt.rules)) {
|
|
178
|
+
for (const rule of pt.rules) {
|
|
179
|
+
if (rule.action === 'require_human' && rule.from_phase) {
|
|
180
|
+
const already = humanApprovalPoints.some(p => p.type === 'phase_transition' && p.phase === rule.from_phase);
|
|
181
|
+
if (!already) {
|
|
182
|
+
humanApprovalPoints.push({ type: 'phase_transition', phase: rule.from_phase, gate: '(policy)' });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (approvalPolicy?.run_completion?.action === 'require_human') {
|
|
190
|
+
const already = humanApprovalPoints.some(p => p.type === 'run_completion');
|
|
191
|
+
if (!already) {
|
|
192
|
+
humanApprovalPoints.push({ type: 'run_completion', gate: '(policy)' });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const point of humanApprovalPoints) {
|
|
197
|
+
if (point.type === 'phase_transition') {
|
|
198
|
+
warnings.push(
|
|
199
|
+
`ADM-003: Phase "${point.phase}" requires human approval (gate "${point.gate}") but no role uses runtime type "manual". The run will pause at pending_phase_transition and require external approval (CLI, dashboard, or webhook).`
|
|
200
|
+
);
|
|
201
|
+
} else {
|
|
202
|
+
warnings.push(
|
|
203
|
+
`ADM-003: Run completion requires human approval (gate "${point.gate}") but no role uses runtime type "manual". The run will pause at pending_run_completion and require external approval.`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isAutoApprovedByPolicy(approvalPolicy, section, phase) {
|
|
210
|
+
if (!approvalPolicy) return false;
|
|
211
|
+
|
|
212
|
+
if (section === 'phase_transitions') {
|
|
213
|
+
const pt = approvalPolicy.phase_transitions;
|
|
214
|
+
if (!pt) return false;
|
|
215
|
+
|
|
216
|
+
// Check specific rules first
|
|
217
|
+
if (Array.isArray(pt.rules)) {
|
|
218
|
+
for (const rule of pt.rules) {
|
|
219
|
+
if (rule.action === 'auto_approve' && matchesPhaseRule(rule, phase)) {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Fall back to default
|
|
226
|
+
return pt.default === 'auto_approve';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (section === 'run_completion') {
|
|
230
|
+
return approvalPolicy.run_completion?.action === 'auto_approve';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function matchesPhaseRule(rule, phase) {
|
|
237
|
+
// A rule matches if from_phase is unset or matches the phase
|
|
238
|
+
if (rule.from_phase && rule.from_phase !== phase) return false;
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function canRoleProduceFiles(role, runtime) {
|
|
243
|
+
if (!role) return false;
|
|
244
|
+
return runtime?.type === 'manual'
|
|
245
|
+
|| role.write_authority === 'authoritative'
|
|
246
|
+
|| role.write_authority === 'proposed';
|
|
247
|
+
}
|
|
@@ -618,7 +618,7 @@ function renderContext(state, config, root, turn, role) {
|
|
|
618
618
|
|
|
619
619
|
// Repo-level decisions that persist across runs
|
|
620
620
|
if (state.repo_decisions && state.repo_decisions.length > 0) {
|
|
621
|
-
const repoDecMd = renderRepoDecisionsMarkdown(state.repo_decisions);
|
|
621
|
+
const repoDecMd = renderRepoDecisionsMarkdown(state.repo_decisions, config);
|
|
622
622
|
if (repoDecMd) {
|
|
623
623
|
lines.push(repoDecMd);
|
|
624
624
|
}
|
package/src/lib/export-diff.js
CHANGED
|
@@ -415,6 +415,22 @@ function detectRunRegressions(left, right) {
|
|
|
415
415
|
});
|
|
416
416
|
}
|
|
417
417
|
|
|
418
|
+
const leftHasPhaseOrder = Array.isArray(left.workflow_phase_order) && left.workflow_phase_order.length > 0;
|
|
419
|
+
const rightHasPhaseOrder = Array.isArray(right.workflow_phase_order) && right.workflow_phase_order.length > 0;
|
|
420
|
+
const phaseOrderDrift = leftHasPhaseOrder && rightHasPhaseOrder && !isEqual(left.workflow_phase_order, right.workflow_phase_order);
|
|
421
|
+
|
|
422
|
+
if (phaseOrderDrift) {
|
|
423
|
+
regressions.push({
|
|
424
|
+
id: `REG-PHASE-ORDER-${String(++counter).padStart(3, '0')}`,
|
|
425
|
+
category: 'phase',
|
|
426
|
+
severity: 'warning',
|
|
427
|
+
message: 'Workflow phase order changed between exports; directional phase comparison skipped',
|
|
428
|
+
field: 'workflow_phase_order',
|
|
429
|
+
left: left.workflow_phase_order,
|
|
430
|
+
right: right.workflow_phase_order,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
418
434
|
// Phase regression: backward movement in workflow phase order
|
|
419
435
|
if (left.phase && right.phase === null) {
|
|
420
436
|
// Phase disappeared — information loss
|
|
@@ -428,9 +444,9 @@ function detectRunRegressions(left, right) {
|
|
|
428
444
|
right: null,
|
|
429
445
|
});
|
|
430
446
|
} else if (left.phase && right.phase && left.phase !== right.phase) {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
447
|
+
const canCompareDirection = leftHasPhaseOrder && rightHasPhaseOrder && !phaseOrderDrift;
|
|
448
|
+
if (canCompareDirection) {
|
|
449
|
+
const phaseOrder = right.workflow_phase_order;
|
|
434
450
|
const leftIndex = phaseOrder.indexOf(left.phase);
|
|
435
451
|
const rightIndex = phaseOrder.indexOf(right.phase);
|
|
436
452
|
// Only flag when both phases are known and right is earlier than left
|
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
import { isDeepStrictEqual } from 'node:util';
|
|
5
|
+
import { summarizeRepoDecisions } from './repo-decisions.js';
|
|
5
6
|
|
|
6
7
|
const SUPPORTED_EXPORT_SCHEMA_VERSIONS = new Set(['0.2', '0.3']);
|
|
7
8
|
const VALID_FILE_FORMATS = new Set(['json', 'jsonl', 'text']);
|
|
@@ -31,6 +32,52 @@ function addError(errors, path, message) {
|
|
|
31
32
|
errors.push(`${path}: ${message}`);
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
function verifyWorkflowPhaseOrder(summary, errors, summaryPath = 'summary') {
|
|
36
|
+
const phaseOrder = summary?.workflow_phase_order;
|
|
37
|
+
if (phaseOrder === undefined || phaseOrder === null) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const path = `${summaryPath}.workflow_phase_order`;
|
|
42
|
+
if (!Array.isArray(phaseOrder)) {
|
|
43
|
+
addError(errors, path, 'must be an array or null');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (phaseOrder.length === 0) {
|
|
48
|
+
addError(errors, path, 'must not be empty when present');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
for (let index = 0; index < phaseOrder.length; index += 1) {
|
|
54
|
+
const entry = phaseOrder[index];
|
|
55
|
+
const entryPath = `${path}[${index}]`;
|
|
56
|
+
if (typeof entry !== 'string') {
|
|
57
|
+
addError(errors, entryPath, 'must be a string');
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const trimmed = entry.trim();
|
|
61
|
+
if (!trimmed) {
|
|
62
|
+
addError(errors, entryPath, 'must not be blank');
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (trimmed !== entry) {
|
|
66
|
+
addError(errors, entryPath, 'must be trimmed');
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (seen.has(entry)) {
|
|
70
|
+
addError(errors, path, `must not contain duplicate phase "${entry}"`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
seen.add(entry);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (summary.phase !== null && summary.phase !== undefined && !seen.has(summary.phase)) {
|
|
77
|
+
addError(errors, `${summaryPath}.phase`, 'must appear in summary.workflow_phase_order when workflow_phase_order is present');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
34
81
|
function verifyFileEntry(relPath, entry, errors) {
|
|
35
82
|
const path = `files.${relPath}`;
|
|
36
83
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
@@ -323,38 +370,18 @@ function verifyDelegationSummary(artifact, errors) {
|
|
|
323
370
|
}
|
|
324
371
|
}
|
|
325
372
|
|
|
326
|
-
function buildExpectedRepoDecisionsSummary(files) {
|
|
373
|
+
function buildExpectedRepoDecisionsSummary(files, config = null) {
|
|
327
374
|
const repoDecisionsData = files?.['.agentxchain/repo-decisions.jsonl']?.data;
|
|
328
375
|
if (!Array.isArray(repoDecisionsData) || repoDecisionsData.length === 0) {
|
|
329
376
|
return null;
|
|
330
377
|
}
|
|
331
|
-
|
|
332
|
-
const active = repoDecisionsData.filter((d) => d.status === 'active');
|
|
333
|
-
const overridden = repoDecisionsData.filter((d) => d.status === 'overridden');
|
|
334
|
-
|
|
335
|
-
return {
|
|
336
|
-
total: repoDecisionsData.length,
|
|
337
|
-
active_count: active.length,
|
|
338
|
-
overridden_count: overridden.length,
|
|
339
|
-
active: active.map((d) => ({
|
|
340
|
-
id: d.id,
|
|
341
|
-
category: d.category,
|
|
342
|
-
statement: d.statement,
|
|
343
|
-
role: d.role,
|
|
344
|
-
run_id: d.run_id,
|
|
345
|
-
})),
|
|
346
|
-
overridden: overridden.map((d) => ({
|
|
347
|
-
id: d.id,
|
|
348
|
-
overridden_by: d.overridden_by,
|
|
349
|
-
statement: d.statement,
|
|
350
|
-
})),
|
|
351
|
-
};
|
|
378
|
+
return summarizeRepoDecisions(repoDecisionsData, config);
|
|
352
379
|
}
|
|
353
380
|
|
|
354
381
|
function verifyRepoDecisionsSummary(artifact, errors) {
|
|
355
382
|
const summary = artifact.summary?.repo_decisions;
|
|
356
383
|
const hasFile = '.agentxchain/repo-decisions.jsonl' in (artifact.files || {});
|
|
357
|
-
const expected = buildExpectedRepoDecisionsSummary(artifact.files);
|
|
384
|
+
const expected = buildExpectedRepoDecisionsSummary(artifact.files, artifact.config || null);
|
|
358
385
|
|
|
359
386
|
if (summary === null && expected === null) {
|
|
360
387
|
return;
|
|
@@ -539,6 +566,8 @@ function verifyRunExport(artifact, errors) {
|
|
|
539
566
|
addError(errors, 'summary.phase', 'must match state.phase');
|
|
540
567
|
}
|
|
541
568
|
|
|
569
|
+
verifyWorkflowPhaseOrder(artifact.summary, errors);
|
|
570
|
+
|
|
542
571
|
const expectedHistoryEntries = countJsonl(artifact.files, '.agentxchain/history.jsonl');
|
|
543
572
|
const expectedDecisionEntries = countJsonl(artifact.files, '.agentxchain/decision-ledger.jsonl');
|
|
544
573
|
const expectedHookAuditEntries = countJsonl(artifact.files, '.agentxchain/hook-audit.jsonl');
|
|
@@ -628,6 +657,7 @@ function verifyCoordinatorExport(artifact, errors) {
|
|
|
628
657
|
if (artifact.summary.phase !== (coordinatorState?.phase || null)) {
|
|
629
658
|
addError(errors, 'summary.phase', 'must match coordinator state phase');
|
|
630
659
|
}
|
|
660
|
+
verifyWorkflowPhaseOrder(artifact.summary, errors);
|
|
631
661
|
if (!isDeepStrictEqual(artifact.summary.repo_run_statuses, expectedStatuses)) {
|
|
632
662
|
addError(errors, 'summary.repo_run_statuses', 'must match coordinator state repo run statuses');
|
|
633
663
|
}
|
package/src/lib/export.js
CHANGED
|
@@ -8,7 +8,7 @@ import { loadCoordinatorConfig, COORDINATOR_CONFIG_FILE } from './coordinator-co
|
|
|
8
8
|
import { loadCoordinatorState } from './coordinator-state.js';
|
|
9
9
|
import { normalizeRunProvenance } from './run-provenance.js';
|
|
10
10
|
import { getDashboardPid, getDashboardSession } from '../commands/dashboard.js';
|
|
11
|
-
import { readRepoDecisions } from './repo-decisions.js';
|
|
11
|
+
import { readRepoDecisions, summarizeRepoDecisions } from './repo-decisions.js';
|
|
12
12
|
import { RUN_EVENTS_PATH } from './run-events.js';
|
|
13
13
|
|
|
14
14
|
const EXPORT_SCHEMA_VERSION = '0.3';
|
|
@@ -211,18 +211,8 @@ function buildDashboardSessionSummary(root) {
|
|
|
211
211
|
};
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
-
export function buildRepoDecisionsSummary(root) {
|
|
215
|
-
|
|
216
|
-
if (!all || all.length === 0) return null;
|
|
217
|
-
const active = all.filter(d => d.status === 'active');
|
|
218
|
-
const overridden = all.filter(d => d.status === 'overridden');
|
|
219
|
-
return {
|
|
220
|
-
total: all.length,
|
|
221
|
-
active_count: active.length,
|
|
222
|
-
overridden_count: overridden.length,
|
|
223
|
-
active: active.map(d => ({ id: d.id, category: d.category, statement: d.statement, role: d.role, run_id: d.run_id })),
|
|
224
|
-
overridden: overridden.map(d => ({ id: d.id, overridden_by: d.overridden_by, statement: d.statement })),
|
|
225
|
-
};
|
|
214
|
+
export function buildRepoDecisionsSummary(root, config = null) {
|
|
215
|
+
return summarizeRepoDecisions(readRepoDecisions(root), config);
|
|
226
216
|
}
|
|
227
217
|
|
|
228
218
|
export function buildDelegationSummary(files) {
|
|
@@ -471,7 +461,7 @@ export function buildRunExport(startDir = process.cwd()) {
|
|
|
471
461
|
coordinator_present: Object.keys(files).some((path) => path.startsWith('.agentxchain/multirepo/')),
|
|
472
462
|
dashboard_session: buildDashboardSessionSummary(root),
|
|
473
463
|
delegation_summary: buildDelegationSummary(files),
|
|
474
|
-
repo_decisions: buildRepoDecisionsSummary(root),
|
|
464
|
+
repo_decisions: buildRepoDecisionsSummary(root, rawConfig),
|
|
475
465
|
},
|
|
476
466
|
workspace: buildRunWorkspaceMetadata(root),
|
|
477
467
|
files,
|
|
@@ -2437,7 +2437,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2437
2437
|
if (turnResult.decisions && turnResult.decisions.length > 0) {
|
|
2438
2438
|
for (const dec of turnResult.decisions) {
|
|
2439
2439
|
if (dec.overrides) {
|
|
2440
|
-
const overrideCheck = validateOverride(root, dec);
|
|
2440
|
+
const overrideCheck = validateOverride(root, { ...dec, role: dec.role || turnResult.role }, config);
|
|
2441
2441
|
if (!overrideCheck.ok) {
|
|
2442
2442
|
return {
|
|
2443
2443
|
ok: false,
|
|
@@ -3359,6 +3359,8 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3359
3359
|
category: dec.category,
|
|
3360
3360
|
statement: dec.statement,
|
|
3361
3361
|
rationale: dec.rationale,
|
|
3362
|
+
durability: dec.durability || 'repo',
|
|
3363
|
+
overrides: dec.overrides || null,
|
|
3362
3364
|
status: 'active',
|
|
3363
3365
|
overridden_by: null,
|
|
3364
3366
|
created_at: now,
|