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.
@@ -366,6 +366,11 @@ export function validateV4Config(data, projectRoot) {
366
366
  if (!VALID_WRITE_AUTHORITIES.includes(role.write_authority)) {
367
367
  errors.push(`Role "${id}": write_authority must be one of: ${VALID_WRITE_AUTHORITIES.join(', ')}`);
368
368
  }
369
+ if (role.decision_authority !== undefined && role.decision_authority !== null) {
370
+ if (!Number.isInteger(role.decision_authority) || role.decision_authority < 0 || role.decision_authority > 99) {
371
+ errors.push(`Role "${id}": decision_authority must be an integer between 0 and 99`);
372
+ }
373
+ }
369
374
  if (typeof role.runtime !== 'string' || !role.runtime.trim()) errors.push(`Role "${id}": runtime required`);
370
375
  }
371
376
  }
@@ -556,82 +561,13 @@ export function validateV4Config(data, projectRoot) {
556
561
  errors.push(...timeoutValidation.errors);
557
562
  }
558
563
 
559
- warnings.push(...collectRemoteReviewOnlyGateWarnings(data));
564
+ // Admission control (ADM-001..004) is handled by the validate, doctor, and
565
+ // run-loop paths which call runAdmissionControl() directly. Config schema
566
+ // validation here should not duplicate that surface.
560
567
 
561
568
  return { ok: errors.length === 0, errors, warnings };
562
569
  }
563
570
 
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
571
  export function validateBudgetConfig(budget) {
636
572
  const errors = [];
637
573
 
@@ -37,6 +37,74 @@ export function getRepoDecisionById(root, decisionId) {
37
37
  return readRepoDecisions(root).find(d => d.id === decisionId) || null;
38
38
  }
39
39
 
40
+ export function getDecisionAuthorityMetadata(roleId, config) {
41
+ const resolved = resolveDecisionAuthority(roleId, config);
42
+ if (resolved === null) return null;
43
+ if (typeof resolved === 'object' && resolved.unknown) {
44
+ return {
45
+ level: resolved.level,
46
+ source: 'unknown_role',
47
+ role: roleId || null,
48
+ };
49
+ }
50
+ if (roleId === 'human') {
51
+ const explicitHumanAuthority = typeof config?.roles?.human?.decision_authority === 'number';
52
+ return {
53
+ level: resolved,
54
+ source: explicitHumanAuthority ? 'configured' : 'human_default',
55
+ role: roleId,
56
+ };
57
+ }
58
+ return {
59
+ level: resolved,
60
+ source: 'configured',
61
+ role: roleId || null,
62
+ };
63
+ }
64
+
65
+ export function summarizeRepoDecisions(decisions, config) {
66
+ if (!Array.isArray(decisions) || decisions.length === 0) return null;
67
+ const active = decisions.filter((d) => d.status === 'active');
68
+ const overridden = decisions.filter((d) => d.status === 'overridden');
69
+ const addAuthority = (decision) => {
70
+ const authority = getDecisionAuthorityMetadata(decision.role, config);
71
+ return {
72
+ id: decision.id,
73
+ category: decision.category,
74
+ statement: decision.statement,
75
+ role: decision.role,
76
+ run_id: decision.run_id,
77
+ overrides: decision.overrides || null,
78
+ durability: decision.durability || 'repo',
79
+ authority_level: authority?.level ?? null,
80
+ authority_source: authority?.source || null,
81
+ };
82
+ };
83
+ return {
84
+ total: decisions.length,
85
+ active_count: active.length,
86
+ overridden_count: overridden.length,
87
+ active: active.map(addAuthority),
88
+ overridden: overridden.map((d) => {
89
+ const authority = getDecisionAuthorityMetadata(d.role, config);
90
+ return {
91
+ id: d.id,
92
+ overridden_by: d.overridden_by,
93
+ statement: d.statement,
94
+ overrides: d.overrides || null,
95
+ durability: d.durability || 'repo',
96
+ role: d.role || null,
97
+ authority_level: authority?.level ?? null,
98
+ authority_source: authority?.source || null,
99
+ };
100
+ }),
101
+ };
102
+ }
103
+
104
+ export function buildRepoDecisionsSummary(decisions) {
105
+ return summarizeRepoDecisions(decisions, null);
106
+ }
107
+
40
108
  // ── Write ───────────────────────────────────────────────────────────────────
41
109
 
42
110
  export function appendRepoDecision(root, entry) {
@@ -62,7 +130,16 @@ export function overrideRepoDecision(root, targetId, overridingId) {
62
130
 
63
131
  // ── Validate Override ───────────────────────────────────────────────────────
64
132
 
65
- export function validateOverride(root, decision) {
133
+ /**
134
+ * Validate that an override is allowed.
135
+ * @param {string} root - project root
136
+ * @param {object} decision - the overriding decision (must have .overrides, .id, optionally .role)
137
+ * @param {object} [config] - agentxchain config (used for authority enforcement)
138
+ * @returns {{ ok: boolean, error?: string, warning?: string }}
139
+ *
140
+ * DEC-SPEC: .planning/DECISION_AUTHORITY_SPEC.md
141
+ */
142
+ export function validateOverride(root, decision, config) {
66
143
  if (!decision.overrides) return { ok: true };
67
144
  const targetId = decision.overrides;
68
145
  const target = getRepoDecisionById(root, targetId);
@@ -75,21 +152,104 @@ export function validateOverride(root, decision) {
75
152
  if (target.status !== 'active') {
76
153
  return { ok: false, error: `decisions: ${targetId} has status "${target.status}", only active repo decisions can be overridden.` };
77
154
  }
155
+
156
+ // Authority enforcement (opt-in via decision_authority on roles)
157
+ const authorityResult = checkOverrideAuthority(decision, target, config);
158
+ if (!authorityResult.ok) return authorityResult;
159
+
160
+ return authorityResult.warning ? { ok: true, warning: authorityResult.warning } : { ok: true };
161
+ }
162
+
163
+ /**
164
+ * Resolve the decision_authority level for a role.
165
+ * - 'human' defaults to 100 unless explicitly configured.
166
+ * - Unknown roles default to 0 (with warning).
167
+ * - Null means opt-out (no enforcement).
168
+ */
169
+ export function resolveDecisionAuthority(roleId, config) {
170
+ if (!config || !config.roles) return null;
171
+ if (roleId === 'human') {
172
+ const humanRole = config.roles.human;
173
+ if (humanRole && typeof humanRole.decision_authority === 'number') {
174
+ return humanRole.decision_authority;
175
+ }
176
+ return 100; // human default
177
+ }
178
+ const role = config.roles[roleId];
179
+ if (!role) return { level: 0, unknown: true };
180
+ if (typeof role.decision_authority !== 'number') return null;
181
+ return role.decision_authority;
182
+ }
183
+
184
+ /**
185
+ * Check whether the overriding role has sufficient authority to override
186
+ * a decision made by the target role.
187
+ */
188
+ function checkOverrideAuthority(overridingDecision, targetDecision, config) {
189
+ if (!config || !config.roles) return { ok: true };
190
+
191
+ const overridingRole = overridingDecision.role;
192
+ const targetRole = targetDecision.role;
193
+
194
+ // Same-role override is always allowed
195
+ if (overridingRole && targetRole && overridingRole === targetRole) return { ok: true };
196
+
197
+ const targetAuth = resolveDecisionAuthority(targetRole, config);
198
+ const overridingAuth = resolveDecisionAuthority(overridingRole, config);
199
+
200
+ // Handle unknown target role
201
+ let warning;
202
+ if (targetAuth && typeof targetAuth === 'object' && targetAuth.unknown) {
203
+ warning = `decisions: target decision role '${targetRole}' not found in current config, treating as authority 0.`;
204
+ // targetAuth is effectively 0, allow override
205
+ return { ok: true, warning };
206
+ }
207
+
208
+ // Opt-in: if either side is null (not configured), allow
209
+ if (targetAuth === null || overridingAuth === null) return { ok: true };
210
+
211
+ // Handle unknown overriding role (shouldn't normally happen, but be safe)
212
+ const overridingLevel = (typeof overridingAuth === 'object' && overridingAuth.unknown) ? 0 : overridingAuth;
213
+ const targetLevel = (typeof targetAuth === 'object') ? 0 : targetAuth;
214
+
215
+ if (overridingLevel < targetLevel) {
216
+ return {
217
+ ok: false,
218
+ error: `decisions: role '${overridingRole}' (authority ${overridingLevel}) cannot override ${targetDecision.id} made by '${targetRole}' (authority ${targetLevel}). Override requires authority >= ${targetLevel}.`,
219
+ };
220
+ }
221
+
78
222
  return { ok: true };
79
223
  }
80
224
 
81
225
  // ── Render ──────────────────────────────────────────────────────────────────
82
226
 
83
- export function renderRepoDecisionsMarkdown(activeDecisions) {
227
+ export function renderRepoDecisionsMarkdown(activeDecisions, config) {
84
228
  if (!activeDecisions || activeDecisions.length === 0) return '';
229
+ const hasAuthorityPolicy = Object.values(config?.roles || {}).some((role) => (
230
+ role && typeof role.decision_authority === 'number'
231
+ ));
85
232
  const lines = [
86
233
  '## Active Repo Decisions',
87
234
  '',
88
235
  'These decisions persist from prior governed runs. Comply or explicitly override with rationale.',
89
236
  '',
90
237
  ];
238
+ if (hasAuthorityPolicy) {
239
+ lines.push('When both roles declare `decision_authority`, overrides require authority greater than or equal to the originating role.');
240
+ lines.push('');
241
+ }
91
242
  for (const d of activeDecisions) {
92
- lines.push(`- **${d.id}** (${d.category}): ${d.statement}`);
243
+ const authority = getDecisionAuthorityMetadata(d.role, config);
244
+ const authorityText = authority
245
+ ? authority.source === 'human_default'
246
+ ? ' authority 100 (human default)'
247
+ : authority.source === 'unknown_role'
248
+ ? ' authority 0 (role no longer in config)'
249
+ : ` authority ${authority.level}`
250
+ : '';
251
+ const supersedes = d.overrides ? ` Supersedes ${d.overrides}.` : '';
252
+ lines.push(`- **${d.id}** (${d.category}, by ${d.role || 'unknown'}${authorityText}): ${d.statement}${supersedes}`);
93
253
  }
94
254
  lines.push('');
95
255
  return lines.join('\n');
package/src/lib/report.js CHANGED
@@ -1320,7 +1320,13 @@ export function formatGovernanceReportText(report) {
1320
1320
  lines.push('', 'Repo Decisions:');
1321
1321
  lines.push(` Active: ${run.repo_decisions.active_count} Overridden: ${run.repo_decisions.overridden_count}`);
1322
1322
  for (const d of run.repo_decisions.active) {
1323
- lines.push(` - ${d.id} (${d.category}): ${d.statement}`);
1323
+ const supersedes = d.overrides ? ` | supersedes ${d.overrides}` : '';
1324
+ const authority = d.authority_level == null ? '' : ` | authority ${d.authority_level}${d.authority_source === 'human_default' ? ' (human default)' : ''}`;
1325
+ lines.push(` - ${d.id} (${d.category}): ${d.statement}${supersedes}${authority}`);
1326
+ }
1327
+ for (const d of run.repo_decisions.overridden || []) {
1328
+ const authority = d.authority_level == null ? '' : ` | authority ${d.authority_level}${d.authority_source === 'human_default' ? ' (human default)' : ''}`;
1329
+ lines.push(` - ${d.id} (overridden by ${d.overridden_by || 'unknown'}${authority})`);
1324
1330
  }
1325
1331
  }
1326
1332
 
@@ -1825,10 +1831,20 @@ export function formatGovernanceReportMarkdown(report) {
1825
1831
  if (run.repo_decisions?.active?.length > 0) {
1826
1832
  lines.push('', '## Repo Decisions', '');
1827
1833
  lines.push(`Active: ${run.repo_decisions.active_count} | Overridden: ${run.repo_decisions.overridden_count}`, '');
1828
- lines.push('| ID | Category | Statement | Role | Run |', '|----|----------|-----------|------|-----|');
1834
+ lines.push('| ID | Category | Statement | Role | Authority | Run | Supersedes |', '|----|----------|-----------|------|-----------|-----|------------|');
1829
1835
  for (const d of run.repo_decisions.active) {
1830
1836
  const stmt = (d.statement || '').replace(/\|/g, '\\|');
1831
- lines.push(`| ${d.id} | ${d.category} | ${stmt} | ${d.role || ''} | \`${(d.run_id || '').slice(0, 12)}\` |`);
1837
+ const authority = d.authority_level == null ? '—' : `${d.authority_level}${d.authority_source === 'human_default' ? ' (human default)' : ''}`;
1838
+ lines.push(`| ${d.id} | ${d.category} | ${stmt} | ${d.role || '—'} | ${authority} | \`${(d.run_id || '').slice(0, 12)}\` | ${d.overrides || '—'} |`);
1839
+ }
1840
+ if (run.repo_decisions.overridden?.length > 0) {
1841
+ lines.push('', 'Overridden decisions:', '');
1842
+ lines.push('| ID | Statement | Authority | Overridden By |', '|----|-----------|-----------|---------------|');
1843
+ for (const d of run.repo_decisions.overridden) {
1844
+ const stmt = (d.statement || '').replace(/\|/g, '\\|');
1845
+ const authority = d.authority_level == null ? '—' : `${d.authority_level}${d.authority_source === 'human_default' ? ' (human default)' : ''}`;
1846
+ lines.push(`| ${d.id} | ${stmt} | ${authority} | ${d.overridden_by || '—'} |`);
1847
+ }
1832
1848
  }
1833
1849
  }
1834
1850
 
@@ -2453,9 +2469,28 @@ function renderRunHtml(report) {
2453
2469
  if (run.repo_decisions?.active?.length > 0) {
2454
2470
  let rdHtml = `<p>Active: ${run.repo_decisions.active_count} | Overridden: ${run.repo_decisions.overridden_count}</p>`;
2455
2471
  rdHtml += htmlTable(
2456
- ['ID', 'Category', 'Statement', 'Role', 'Run'],
2457
- run.repo_decisions.active.map((d) => [esc(d.id), esc(d.category), esc(d.statement || ''), esc(d.role || '\u2014'), `<code>${esc((d.run_id || '').slice(0, 12))}</code>`]),
2472
+ ['ID', 'Category', 'Statement', 'Role', 'Authority', 'Run', 'Supersedes'],
2473
+ run.repo_decisions.active.map((d) => [
2474
+ esc(d.id),
2475
+ esc(d.category),
2476
+ esc(d.statement || ''),
2477
+ esc(d.role || '\u2014'),
2478
+ esc(d.authority_level == null ? '\u2014' : `${d.authority_level}${d.authority_source === 'human_default' ? ' (human default)' : ''}`),
2479
+ `<code>${esc((d.run_id || '').slice(0, 12))}</code>`,
2480
+ esc(d.overrides || '\u2014'),
2481
+ ]),
2458
2482
  );
2483
+ if (run.repo_decisions.overridden?.length > 0) {
2484
+ rdHtml += htmlTable(
2485
+ ['ID', 'Statement', 'Authority', 'Overridden By'],
2486
+ run.repo_decisions.overridden.map((d) => [
2487
+ esc(d.id),
2488
+ esc(d.statement || ''),
2489
+ esc(d.authority_level == null ? '\u2014' : `${d.authority_level}${d.authority_source === 'human_default' ? ' (human default)' : ''}`),
2490
+ esc(d.overridden_by || '\u2014'),
2491
+ ]),
2492
+ );
2493
+ }
2459
2494
  sections.push(`<div class="section">${htmlSection('Repo Decisions', rdHtml)}</div>`);
2460
2495
  }
2461
2496
 
@@ -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',