agentxchain 2.101.0 → 2.102.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.
@@ -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;
@@ -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
+ }
@@ -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
- // Use right export's phase order as canonical (or left if right doesn't have one)
432
- const phaseOrder = right.workflow_phase_order || left.workflow_phase_order;
433
- if (Array.isArray(phaseOrder) && phaseOrder.length > 0) {
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
@@ -31,6 +31,52 @@ function addError(errors, path, message) {
31
31
  errors.push(`${path}: ${message}`);
32
32
  }
33
33
 
34
+ function verifyWorkflowPhaseOrder(summary, errors, summaryPath = 'summary') {
35
+ const phaseOrder = summary?.workflow_phase_order;
36
+ if (phaseOrder === undefined || phaseOrder === null) {
37
+ return;
38
+ }
39
+
40
+ const path = `${summaryPath}.workflow_phase_order`;
41
+ if (!Array.isArray(phaseOrder)) {
42
+ addError(errors, path, 'must be an array or null');
43
+ return;
44
+ }
45
+
46
+ if (phaseOrder.length === 0) {
47
+ addError(errors, path, 'must not be empty when present');
48
+ return;
49
+ }
50
+
51
+ const seen = new Set();
52
+ for (let index = 0; index < phaseOrder.length; index += 1) {
53
+ const entry = phaseOrder[index];
54
+ const entryPath = `${path}[${index}]`;
55
+ if (typeof entry !== 'string') {
56
+ addError(errors, entryPath, 'must be a string');
57
+ continue;
58
+ }
59
+ const trimmed = entry.trim();
60
+ if (!trimmed) {
61
+ addError(errors, entryPath, 'must not be blank');
62
+ continue;
63
+ }
64
+ if (trimmed !== entry) {
65
+ addError(errors, entryPath, 'must be trimmed');
66
+ continue;
67
+ }
68
+ if (seen.has(entry)) {
69
+ addError(errors, path, `must not contain duplicate phase "${entry}"`);
70
+ continue;
71
+ }
72
+ seen.add(entry);
73
+ }
74
+
75
+ if (summary.phase !== null && summary.phase !== undefined && !seen.has(summary.phase)) {
76
+ addError(errors, `${summaryPath}.phase`, 'must appear in summary.workflow_phase_order when workflow_phase_order is present');
77
+ }
78
+ }
79
+
34
80
  function verifyFileEntry(relPath, entry, errors) {
35
81
  const path = `files.${relPath}`;
36
82
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
@@ -539,6 +585,8 @@ function verifyRunExport(artifact, errors) {
539
585
  addError(errors, 'summary.phase', 'must match state.phase');
540
586
  }
541
587
 
588
+ verifyWorkflowPhaseOrder(artifact.summary, errors);
589
+
542
590
  const expectedHistoryEntries = countJsonl(artifact.files, '.agentxchain/history.jsonl');
543
591
  const expectedDecisionEntries = countJsonl(artifact.files, '.agentxchain/decision-ledger.jsonl');
544
592
  const expectedHookAuditEntries = countJsonl(artifact.files, '.agentxchain/hook-audit.jsonl');
@@ -628,6 +676,7 @@ function verifyCoordinatorExport(artifact, errors) {
628
676
  if (artifact.summary.phase !== (coordinatorState?.phase || null)) {
629
677
  addError(errors, 'summary.phase', 'must match coordinator state phase');
630
678
  }
679
+ verifyWorkflowPhaseOrder(artifact.summary, errors);
631
680
  if (!isDeepStrictEqual(artifact.summary.repo_run_statuses, expectedStatuses)) {
632
681
  addError(errors, 'summary.repo_run_statuses', 'must match coordinator state repo run statuses');
633
682
  }
@@ -556,82 +556,13 @@ export function validateV4Config(data, projectRoot) {
556
556
  errors.push(...timeoutValidation.errors);
557
557
  }
558
558
 
559
- warnings.push(...collectRemoteReviewOnlyGateWarnings(data));
559
+ // Admission control (ADM-001..004) is handled by the validate, doctor, and
560
+ // run-loop paths which call runAdmissionControl() directly. Config schema
561
+ // validation here should not duplicate that surface.
560
562
 
561
563
  return { ok: errors.length === 0, errors, warnings };
562
564
  }
563
565
 
564
- export function collectRemoteReviewOnlyGateWarnings(data) {
565
- const warnings = [];
566
- const routing = data?.routing;
567
- const gates = data?.gates;
568
- const roles = data?.roles;
569
- const runtimes = data?.runtimes;
570
-
571
- if (!routing || !gates || !roles || !runtimes) {
572
- return warnings;
573
- }
574
-
575
- for (const [phase, route] of Object.entries(routing)) {
576
- const exitGateId = route?.exit_gate;
577
- if (!exitGateId || !gates[exitGateId]) {
578
- continue;
579
- }
580
-
581
- const requiredFiles = Array.isArray(gates[exitGateId]?.requires_files)
582
- ? gates[exitGateId].requires_files.filter(filePath => typeof filePath === 'string' && filePath.trim())
583
- : [];
584
- if (requiredFiles.length === 0) {
585
- continue;
586
- }
587
-
588
- const candidateRoleIds = [
589
- route?.entry_role,
590
- ...(Array.isArray(route?.allowed_next_roles) ? route.allowed_next_roles : []),
591
- ].filter((roleId) => roleId && roleId !== 'human');
592
-
593
- if (candidateRoleIds.length === 0) {
594
- continue;
595
- }
596
-
597
- const candidateRoles = [...new Set(candidateRoleIds)]
598
- .map((roleId) => {
599
- const role = roles[roleId];
600
- const runtime = role?.runtime ? runtimes[role.runtime] : null;
601
- if (!role || !runtime) {
602
- return null;
603
- }
604
- return { roleId, role, runtime };
605
- })
606
- .filter(Boolean);
607
-
608
- if (candidateRoles.length === 0) {
609
- continue;
610
- }
611
-
612
- const hasFileProducingRole = candidateRoles.some(({ role }) =>
613
- role.write_authority === 'authoritative' || role.write_authority === 'proposed');
614
- if (hasFileProducingRole) {
615
- continue;
616
- }
617
-
618
- const allRemoteReviewOnly = candidateRoles.every(({ role, runtime }) =>
619
- role.write_authority === 'review_only' && (runtime.type === 'api_proxy' || runtime.type === 'remote_agent'));
620
- if (!allRemoteReviewOnly) {
621
- continue;
622
- }
623
-
624
- const roleSummary = candidateRoles
625
- .map(({ roleId, runtime }) => `${roleId}:${runtime.type}`)
626
- .join(', ');
627
- warnings.push(
628
- `Routing "${phase}" exits through gate "${exitGateId}" with requires_files (${requiredFiles.join(', ')}) but all participating roles are review_only remote runtimes (${roleSummary}). Those files cannot be produced through governed turns; add a proposed/authoritative writer, remove the gate files, or expect operator-managed out-of-band artifacts.`,
629
- );
630
- }
631
-
632
- return warnings;
633
- }
634
-
635
566
  export function validateBudgetConfig(budget) {
636
567
  const errors = [];
637
568
 
@@ -32,6 +32,7 @@ import {
32
32
  RUNNER_INTERFACE_VERSION,
33
33
  } from './runner-interface.js';
34
34
 
35
+ import { runAdmissionControl } from './admission-control.js';
35
36
  import { mkdirSync, writeFileSync } from 'fs';
36
37
  import { join, dirname } from 'path';
37
38
 
@@ -65,6 +66,13 @@ export async function runLoop(root, config, callbacks, options = {}) {
65
66
  }
66
67
  };
67
68
 
69
+ // ── Admission control — reject provably dead-end configs ────────────────
70
+ const admission = runAdmissionControl(config, config);
71
+ if (!admission.ok) {
72
+ return makeResult(false, 'admission_rejected', null, 0, [], 0,
73
+ admission.errors.map(e => `Admission control: ${e}`));
74
+ }
75
+
68
76
  // ── Initialize if idle ──────────────────────────────────────────────────
69
77
  let state = loadState(root, config);
70
78
  const shouldRestartCompleted = state?.status === 'completed' && options.startNewRunFromCompleted === true;
@@ -9,7 +9,7 @@ import {
9
9
  validateAcceptanceHintCompletion,
10
10
  validateGovernedWorkflowKit,
11
11
  } from './governed-templates.js';
12
- import { collectRemoteReviewOnlyGateWarnings } from './normalized-config.js';
12
+ import { runAdmissionControl } from './admission-control.js';
13
13
 
14
14
  const DEFAULT_REQUIRED_FILES = [
15
15
  '.planning/PROJECT.md',
@@ -116,8 +116,10 @@ export function validateGovernedProject(root, rawConfig, config, opts = {}) {
116
116
  errors.push(...workflowKit.errors);
117
117
  warnings.push(...workflowKit.warnings);
118
118
 
119
- // Config-shape warnings (dead-end gates, etc.) mirrors doctor/config --set surfaces
120
- warnings.push(...collectRemoteReviewOnlyGateWarnings(rawConfig));
119
+ // Admission controlreject provably dead-end configs
120
+ const admission = runAdmissionControl(config, rawConfig);
121
+ errors.push(...admission.errors);
122
+ warnings.push(...admission.warnings);
121
123
 
122
124
  const mustExist = [
123
125
  config.files?.state || '.agentxchain/state.json',