agentxchain 2.9.0 → 2.11.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.
@@ -762,7 +762,7 @@ export async function dispatchApiProxy(root, state, config, options = {}) {
762
762
  return errorReturn(root, turn.turn_id, classified);
763
763
  }
764
764
 
765
- const endpoint = PROVIDER_ENDPOINTS[provider];
765
+ const endpoint = runtime.base_url || PROVIDER_ENDPOINTS[provider];
766
766
  if (!endpoint) {
767
767
  const classified = classifyError(
768
768
  'unsupported_provider',
@@ -248,6 +248,42 @@ export function validateProjectPlanningArtifacts(root, templateId) {
248
248
  }
249
249
 
250
250
  const TEMPLATE_GUIDANCE_HEADER = '## Template Guidance';
251
+ const GOVERNED_WORKFLOW_KIT_BASE_FILES = Object.freeze([
252
+ '.planning/PM_SIGNOFF.md',
253
+ '.planning/ROADMAP.md',
254
+ '.planning/acceptance-matrix.md',
255
+ '.planning/ship-verdict.md',
256
+ ]);
257
+ const GOVERNED_WORKFLOW_KIT_STRUCTURAL_CHECKS = Object.freeze([
258
+ {
259
+ id: 'pm_signoff_approved_field',
260
+ file: '.planning/PM_SIGNOFF.md',
261
+ pattern: /^Approved\s*:/im,
262
+ description: 'PM signoff declares an Approved field',
263
+ },
264
+ {
265
+ id: 'roadmap_phases_section',
266
+ file: '.planning/ROADMAP.md',
267
+ pattern: /^##\s+Phases\b/im,
268
+ description: 'Roadmap defines a ## Phases section',
269
+ },
270
+ {
271
+ id: 'acceptance_matrix_table_header',
272
+ file: '.planning/acceptance-matrix.md',
273
+ pattern: /^\|\s*Req\s*#\s*\|/im,
274
+ description: 'Acceptance matrix includes the requirement table header',
275
+ },
276
+ {
277
+ id: 'ship_verdict_heading',
278
+ file: '.planning/ship-verdict.md',
279
+ pattern: /^##\s+Verdict\s*:/im,
280
+ description: 'Ship verdict declares a verdict heading',
281
+ },
282
+ ]);
283
+
284
+ function uniqueStrings(values) {
285
+ return [...new Set(values.filter((value) => typeof value === 'string' && value.trim()))];
286
+ }
251
287
 
252
288
  export function validateAcceptanceHintCompletion(root, templateId) {
253
289
  const effectiveTemplateId = templateId || 'generic';
@@ -358,6 +394,64 @@ export function validateAcceptanceHintCompletion(root, templateId) {
358
394
  };
359
395
  }
360
396
 
397
+ export function validateGovernedWorkflowKit(root, config = {}) {
398
+ const errors = [];
399
+ const warnings = [];
400
+ const gateRequiredFiles = uniqueStrings(
401
+ Object.values(config?.gates || {}).flatMap((gate) => Array.isArray(gate?.requires_files) ? gate.requires_files : [])
402
+ );
403
+ const requiredFiles = uniqueStrings([...GOVERNED_WORKFLOW_KIT_BASE_FILES, ...gateRequiredFiles]);
404
+ const present = [];
405
+ const missing = [];
406
+
407
+ for (const relPath of requiredFiles) {
408
+ if (existsSync(join(root, relPath))) {
409
+ present.push(relPath);
410
+ } else {
411
+ missing.push(relPath);
412
+ errors.push(`Workflow kit requires "${relPath}" but it is missing.`);
413
+ }
414
+ }
415
+
416
+ const structuralChecks = GOVERNED_WORKFLOW_KIT_STRUCTURAL_CHECKS.map((check) => {
417
+ const absPath = join(root, check.file);
418
+ if (!existsSync(absPath)) {
419
+ return {
420
+ id: check.id,
421
+ file: check.file,
422
+ ok: false,
423
+ skipped: true,
424
+ description: check.description,
425
+ };
426
+ }
427
+
428
+ const content = readFileSync(absPath, 'utf8');
429
+ const ok = check.pattern.test(content);
430
+ if (!ok) {
431
+ errors.push(`Workflow kit file "${check.file}" must preserve its structural marker: ${check.description}.`);
432
+ }
433
+
434
+ return {
435
+ id: check.id,
436
+ file: check.file,
437
+ ok,
438
+ skipped: false,
439
+ description: check.description,
440
+ };
441
+ });
442
+
443
+ return {
444
+ ok: errors.length === 0,
445
+ required_files: requiredFiles,
446
+ gate_required_files: gateRequiredFiles,
447
+ present,
448
+ missing,
449
+ structural_checks: structuralChecks,
450
+ errors,
451
+ warnings,
452
+ };
453
+ }
454
+
361
455
  export function validateGovernedProjectTemplate(templateId, source = 'agentxchain.json') {
362
456
  const effectiveTemplateId = templateId || 'generic';
363
457
  const effectiveSource = templateId ? source : 'implicit_default';
@@ -344,6 +344,20 @@ export function validateV4Config(data, projectRoot) {
344
344
  if (typeof rt.auth_env !== 'string' || !rt.auth_env.trim()) {
345
345
  errors.push(`Runtime "${id}": api_proxy requires "auth_env" (environment variable name for API key)`);
346
346
  }
347
+ if ('base_url' in rt) {
348
+ if (typeof rt.base_url !== 'string' || !rt.base_url.trim()) {
349
+ errors.push(`Runtime "${id}": api_proxy base_url must be a non-empty string when provided`);
350
+ } else {
351
+ try {
352
+ const parsed = new URL(rt.base_url);
353
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
354
+ errors.push(`Runtime "${id}": api_proxy base_url must use http or https`);
355
+ }
356
+ } catch {
357
+ errors.push(`Runtime "${id}": api_proxy base_url must be a valid absolute URL`);
358
+ }
359
+ }
360
+ }
347
361
  if ('retry_policy' in rt) {
348
362
  validateApiProxyRetryPolicy(id, rt.retry_policy, errors);
349
363
  }
@@ -1,10 +1,13 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
2
  import { spawnSync } from 'node:child_process';
3
+ import { request as httpRequest } from 'node:http';
4
+ import { request as httpsRequest } from 'node:https';
3
5
  import { dirname, join, resolve } from 'node:path';
4
6
  import { fileURLToPath } from 'node:url';
5
7
 
6
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
9
  const DEFAULT_FIXTURE_ROOT = resolve(__dirname, '..', '..', '..', '.agentxchain-conformance', 'fixtures');
10
+ const DEFAULT_REMOTE_TIMEOUT_MS = 30_000;
8
11
  const VALID_RESPONSE_STATUSES = new Set(['pass', 'fail', 'error', 'not_implemented']);
9
12
  const VALID_TIERS = new Set([1, 2, 3]);
10
13
 
@@ -56,13 +59,7 @@ function validateFixtureShape(fixture, filePath) {
56
59
  return errors;
57
60
  }
58
61
 
59
- function loadCapabilities(targetRoot) {
60
- const capabilitiesPath = join(targetRoot, '.agentxchain-conformance', 'capabilities.json');
61
- if (!existsSync(capabilitiesPath)) {
62
- throw new Error(`Missing capabilities file at ${capabilitiesPath}`);
63
- }
64
-
65
- const capabilities = readJsonFile(capabilitiesPath);
62
+ function validateCapabilities(capabilities, { remote = false } = {}) {
66
63
  const errors = [];
67
64
 
68
65
  if (typeof capabilities.implementation !== 'string' || !capabilities.implementation.trim()) {
@@ -80,14 +77,26 @@ function loadCapabilities(targetRoot) {
80
77
  if (!capabilities.adapter || typeof capabilities.adapter !== 'object' || Array.isArray(capabilities.adapter)) {
81
78
  errors.push('capabilities.adapter must be an object');
82
79
  } else {
83
- if (capabilities.adapter.protocol !== 'stdio-fixture-v1') {
84
- errors.push('capabilities.adapter.protocol must be "stdio-fixture-v1"');
80
+ const expectedProtocol = remote ? 'http-fixture-v1' : 'stdio-fixture-v1';
81
+ if (capabilities.adapter.protocol !== expectedProtocol) {
82
+ errors.push(`capabilities.adapter.protocol must be "${expectedProtocol}"`);
85
83
  }
86
- if (!Array.isArray(capabilities.adapter.command) || capabilities.adapter.command.length === 0) {
84
+ if (!remote && (!Array.isArray(capabilities.adapter.command) || capabilities.adapter.command.length === 0)) {
87
85
  errors.push('capabilities.adapter.command must be a non-empty array');
88
86
  }
89
87
  }
90
88
 
89
+ return errors;
90
+ }
91
+
92
+ function loadLocalCapabilities(targetRoot) {
93
+ const capabilitiesPath = join(targetRoot, '.agentxchain-conformance', 'capabilities.json');
94
+ if (!existsSync(capabilitiesPath)) {
95
+ throw new Error(`Missing capabilities file at ${capabilitiesPath}`);
96
+ }
97
+
98
+ const capabilities = readJsonFile(capabilitiesPath);
99
+ const errors = validateCapabilities(capabilities);
91
100
  if (errors.length > 0) {
92
101
  throw new Error(`Invalid capabilities.json: ${errors.join('; ')}`);
93
102
  }
@@ -95,6 +104,84 @@ function loadCapabilities(targetRoot) {
95
104
  return capabilities;
96
105
  }
97
106
 
107
+ function normalizeRemoteBase(remote) {
108
+ let url;
109
+ try {
110
+ url = new URL(remote);
111
+ } catch (error) {
112
+ throw new Error(`Invalid remote URL "${remote}": ${error.message}`);
113
+ }
114
+
115
+ const normalized = url.toString();
116
+ return normalized.endsWith('/') ? normalized.slice(0, -1) : normalized;
117
+ }
118
+
119
+ function buildRemoteUrl(remoteBase, path) {
120
+ return new URL(path.replace(/^\//, ''), `${remoteBase}/`).toString();
121
+ }
122
+
123
+ function buildRemoteHeaders(token, extraHeaders = {}) {
124
+ return {
125
+ ...extraHeaders,
126
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
127
+ };
128
+ }
129
+
130
+ function isTimeoutError(error) {
131
+ return error?.name === 'TimeoutError'
132
+ || error?.name === 'AbortError'
133
+ || error?.cause?.name === 'TimeoutError'
134
+ || error?.cause?.name === 'AbortError';
135
+ }
136
+
137
+ function formatRemoteFetchError(error, timeout, prefix) {
138
+ if (isTimeoutError(error)) {
139
+ return `${prefix} timeout after ${timeout}ms`;
140
+ }
141
+ return `${prefix} network error: ${error.message}`;
142
+ }
143
+
144
+ async function loadRemoteCapabilities(remote, token, timeout) {
145
+ const remoteBase = normalizeRemoteBase(remote);
146
+ const capabilitiesUrl = buildRemoteUrl(remoteBase, '/conform/capabilities');
147
+
148
+ try {
149
+ const response = await requestRemote(capabilitiesUrl, {
150
+ headers: buildRemoteHeaders(token),
151
+ timeout,
152
+ });
153
+
154
+ if (response.statusCode !== 200) {
155
+ throw new Error(`Failed to fetch remote capabilities: HTTP ${response.statusCode}`);
156
+ }
157
+
158
+ let capabilities;
159
+ try {
160
+ capabilities = JSON.parse(response.body);
161
+ } catch (error) {
162
+ throw new Error(`Invalid capabilities response: ${error.message}`);
163
+ }
164
+
165
+ const errors = validateCapabilities(capabilities, { remote: true });
166
+ if (errors.length > 0) {
167
+ throw new Error(`Invalid capabilities.json: ${errors.join('; ')}`);
168
+ }
169
+
170
+ return {
171
+ capabilities,
172
+ remoteBase,
173
+ executeUrl: buildRemoteUrl(remoteBase, '/conform/execute'),
174
+ };
175
+ } catch (error) {
176
+ if (error.message.startsWith('Failed to fetch remote capabilities:')
177
+ || error.message.startsWith('Invalid capabilities response:')
178
+ || error.message.startsWith('Invalid capabilities.json:')) {
179
+ throw error;
180
+ }
181
+ throw new Error(formatRemoteFetchError(error, timeout, 'Failed to fetch remote capabilities'));
182
+ }
183
+ }
184
+
98
185
  function selectFixtureFiles(fixtureRoot, requestedTier, surface) {
99
186
  if (!existsSync(fixtureRoot)) {
100
187
  throw new Error(`Fixture root not found at ${fixtureRoot}`);
@@ -135,7 +222,7 @@ function ensureSurfaceSummary(tierSummary, surface) {
135
222
  return tierSummary.surfaces[surface];
136
223
  }
137
224
 
138
- function executeFixture(targetRoot, adapterCommand, fixture) {
225
+ function executeLocalFixture(targetRoot, adapterCommand, fixture) {
139
226
  const [executable, ...args] = adapterCommand;
140
227
  const result = spawnSync(executable, args, {
141
228
  cwd: targetRoot,
@@ -196,12 +283,128 @@ function executeFixture(targetRoot, adapterCommand, fixture) {
196
283
  return parsed;
197
284
  }
198
285
 
286
+ async function executeRemoteFixture(executeUrl, token, timeout, fixture) {
287
+ let response;
288
+ try {
289
+ response = await requestRemote(executeUrl, {
290
+ method: 'POST',
291
+ headers: buildRemoteHeaders(token, {
292
+ 'content-type': 'application/json',
293
+ }),
294
+ body: JSON.stringify(fixture),
295
+ timeout,
296
+ });
297
+ } catch (error) {
298
+ return {
299
+ status: 'error',
300
+ message: formatRemoteFetchError(error, timeout, 'HTTP fixture execution'),
301
+ actual: null,
302
+ };
303
+ }
304
+
305
+ const rawBody = response.body;
306
+
307
+ if (response.statusCode !== 200) {
308
+ let actual = null;
309
+ let message = rawBody.trim() || `HTTP ${response.statusCode}`;
310
+
311
+ if (rawBody.trim()) {
312
+ try {
313
+ const parsed = JSON.parse(rawBody);
314
+ actual = parsed;
315
+ if (typeof parsed.message === 'string' && parsed.message.trim()) {
316
+ message = parsed.message.trim();
317
+ }
318
+ } catch {
319
+ actual = { body: rawBody };
320
+ }
321
+ }
322
+
323
+ return {
324
+ status: 'error',
325
+ message: `HTTP ${response.statusCode}: ${message}`,
326
+ actual,
327
+ };
328
+ }
329
+
330
+ let parsed;
331
+ try {
332
+ parsed = JSON.parse(rawBody.trim() || '{}');
333
+ } catch (error) {
334
+ return {
335
+ status: 'error',
336
+ message: `Malformed response: ${error.message}`,
337
+ actual: {
338
+ body: rawBody,
339
+ },
340
+ };
341
+ }
342
+
343
+ if (!VALID_RESPONSE_STATUSES.has(parsed.status)) {
344
+ return {
345
+ status: 'error',
346
+ message: 'Adapter response missing valid "status"',
347
+ actual: parsed,
348
+ };
349
+ }
350
+
351
+ return parsed;
352
+ }
353
+
354
+ function requestRemote(urlString, { method = 'GET', headers = {}, body = null, timeout }) {
355
+ const url = new URL(urlString);
356
+ const requestImpl = url.protocol === 'https:' ? httpsRequest : httpRequest;
357
+ const requestHeaders = {
358
+ connection: 'close',
359
+ ...headers,
360
+ };
361
+
362
+ if (body != null && requestHeaders['content-length'] == null && requestHeaders['Content-Length'] == null) {
363
+ requestHeaders['content-length'] = Buffer.byteLength(body);
364
+ }
365
+
366
+ return new Promise((resolveRequest, rejectRequest) => {
367
+ const req = requestImpl(url, {
368
+ method,
369
+ headers: requestHeaders,
370
+ }, (res) => {
371
+ let responseBody = '';
372
+ res.setEncoding('utf8');
373
+ res.on('data', (chunk) => {
374
+ responseBody += chunk;
375
+ });
376
+ res.on('end', () => {
377
+ resolveRequest({
378
+ statusCode: res.statusCode ?? 0,
379
+ body: responseBody,
380
+ });
381
+ });
382
+ });
383
+
384
+ req.on('error', rejectRequest);
385
+ req.setTimeout(timeout, () => {
386
+ const error = new Error(`timeout after ${timeout}ms`);
387
+ error.name = 'TimeoutError';
388
+ req.destroy(error);
389
+ });
390
+
391
+ if (body) {
392
+ req.write(body);
393
+ }
394
+
395
+ req.end();
396
+ });
397
+ }
398
+
199
399
  export function getDefaultFixtureRoot() {
200
400
  return DEFAULT_FIXTURE_ROOT;
201
401
  }
202
402
 
203
- export function verifyProtocolConformance({
403
+ export async function verifyProtocolConformance({
204
404
  targetRoot,
405
+ remote = null,
406
+ token = null,
407
+ timeout = DEFAULT_REMOTE_TIMEOUT_MS,
205
408
  requestedTier = 1,
206
409
  surface = null,
207
410
  fixtureRoot = DEFAULT_FIXTURE_ROOT,
@@ -209,9 +412,18 @@ export function verifyProtocolConformance({
209
412
  if (!Number.isInteger(requestedTier) || !VALID_TIERS.has(requestedTier)) {
210
413
  throw new Error(`Tier must be 1, 2, or 3. Received "${requestedTier}"`);
211
414
  }
415
+ if (!Number.isInteger(timeout) || timeout <= 0) {
416
+ throw new Error(`Timeout must be a positive integer number of milliseconds. Received "${timeout}"`);
417
+ }
418
+ if (!!targetRoot === !!remote) {
419
+ throw new Error('Specify exactly one of targetRoot or remote');
420
+ }
212
421
 
213
- const resolvedTargetRoot = resolve(targetRoot);
214
- const capabilities = loadCapabilities(resolvedTargetRoot);
422
+ const resolvedTargetRoot = targetRoot ? resolve(targetRoot) : null;
423
+ const remoteTarget = remote ? normalizeRemoteBase(remote) : null;
424
+ const localCapabilities = resolvedTargetRoot ? loadLocalCapabilities(resolvedTargetRoot) : null;
425
+ const remoteCapabilities = remoteTarget ? await loadRemoteCapabilities(remoteTarget, token, timeout) : null;
426
+ const capabilities = localCapabilities || remoteCapabilities.capabilities;
215
427
 
216
428
  // Enforce surface claims when capabilities.surfaces exists and --surface is requested
217
429
  if (surface && capabilities.surfaces && typeof capabilities.surfaces === 'object') {
@@ -231,6 +443,7 @@ export function verifyProtocolConformance({
231
443
  tier_requested: requestedTier,
232
444
  timestamp: new Date().toISOString(),
233
445
  target_root: resolvedTargetRoot,
446
+ remote: remoteTarget,
234
447
  fixture_root: fixtureRoot,
235
448
  results: {},
236
449
  overall: 'pass',
@@ -253,7 +466,9 @@ export function verifyProtocolConformance({
253
466
  }
254
467
 
255
468
  const surfaceSummary = ensureSurfaceSummary(tierSummary, fixture.surface);
256
- const adapterResult = executeFixture(resolvedTargetRoot, capabilities.adapter.command, fixture);
469
+ const adapterResult = resolvedTargetRoot
470
+ ? executeLocalFixture(resolvedTargetRoot, capabilities.adapter.command, fixture)
471
+ : await executeRemoteFixture(remoteCapabilities.executeUrl, token, timeout, fixture);
257
472
 
258
473
  tierSummary.fixtures_run += 1;
259
474
 
@@ -283,6 +283,33 @@ function isAssertionObject(value) {
283
283
  return value && typeof value === 'object' && !Array.isArray(value) && typeof value.assert === 'string';
284
284
  }
285
285
 
286
+ function matchUnorderedArray(expectedItems, actual) {
287
+ if (!Array.isArray(expectedItems) || !Array.isArray(actual) || expectedItems.length !== actual.length) {
288
+ return false;
289
+ }
290
+
291
+ const used = new Set();
292
+
293
+ for (const expectedItem of expectedItems) {
294
+ let matchedIndex = -1;
295
+ for (let index = 0; index < actual.length; index += 1) {
296
+ if (used.has(index)) continue;
297
+ if (matchExpected(expectedItem, actual[index])) {
298
+ matchedIndex = index;
299
+ break;
300
+ }
301
+ }
302
+
303
+ if (matchedIndex === -1) {
304
+ return false;
305
+ }
306
+
307
+ used.add(matchedIndex);
308
+ }
309
+
310
+ return true;
311
+ }
312
+
286
313
  function matchExpected(expected, actual) {
287
314
  if (isAssertionObject(expected)) {
288
315
  if (expected.assert === 'nonempty_string') {
@@ -294,6 +321,9 @@ function matchExpected(expected, actual) {
294
321
  if (expected.assert === 'present') {
295
322
  return actual !== undefined;
296
323
  }
324
+ if (expected.assert === 'unordered_array') {
325
+ return matchUnorderedArray(expected.items, actual);
326
+ }
297
327
  return false;
298
328
  }
299
329
 
@@ -520,6 +550,22 @@ function applyManifestFixtureMutations(root, fixture, turnId) {
520
550
  // Missing files are surfaced by manifest verification, not fixture setup.
521
551
  }
522
552
  }
553
+
554
+ if (fixture.setup.post_finalize_delete_manifest) {
555
+ try {
556
+ unlinkSync(join(bundleDir, 'MANIFEST.json'));
557
+ } catch {
558
+ // Missing manifest is surfaced by manifest verification, not fixture setup.
559
+ }
560
+ }
561
+
562
+ const corruptedManifest = fixture.setup.post_finalize_corrupt_manifest?.[turnId];
563
+ if (corruptedManifest !== undefined) {
564
+ const nextManifestContent = typeof corruptedManifest === 'string'
565
+ ? corruptedManifest
566
+ : JSON.stringify(corruptedManifest, null, 2);
567
+ writeFileSync(join(bundleDir, 'MANIFEST.json'), nextManifestContent);
568
+ }
523
569
  }
524
570
 
525
571
  function executeFixtureOperation(workspace, fixture) {
@@ -757,6 +803,7 @@ function executeFixtureOperation(workspace, fixture) {
757
803
  hook_ok: hookResult.ok,
758
804
  blocked: hookResult.blocked || false,
759
805
  audit_entry: auditEntry,
806
+ audit_entries: hookResult.results || [],
760
807
  };
761
808
  }
762
809
 
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Shared governed role-resolution contract for operator commands.
3
+ *
4
+ * Keeps `step` and `run` aligned on:
5
+ * - explicit role override validation
6
+ * - routing-legal next-role recommendations
7
+ * - phase entry-role fallback
8
+ * - first-role final fallback
9
+ */
10
+
11
+ function getPhaseId(state, config) {
12
+ if (state?.phase) {
13
+ return state.phase;
14
+ }
15
+
16
+ const firstPhase = config?.phases?.[0];
17
+ if (typeof firstPhase === 'string') {
18
+ return firstPhase;
19
+ }
20
+ if (firstPhase?.id) {
21
+ return firstPhase.id;
22
+ }
23
+
24
+ return null;
25
+ }
26
+
27
+ export function resolveGovernedRole({ override = null, state = null, config }) {
28
+ const roles = Object.keys(config?.roles || {});
29
+ const phase = getPhaseId(state, config);
30
+ const routing = phase ? config?.routing?.[phase] : null;
31
+ const warnings = [];
32
+
33
+ if (override) {
34
+ if (!config?.roles?.[override]) {
35
+ return {
36
+ roleId: null,
37
+ warnings,
38
+ error: `Unknown role: "${override}"`,
39
+ availableRoles: roles,
40
+ phase,
41
+ };
42
+ }
43
+
44
+ if (routing?.allowed_next_roles && !routing.allowed_next_roles.includes(override) && override !== 'human') {
45
+ warnings.push(`role "${override}" is not in allowed_next_roles for phase "${phase}"`);
46
+ }
47
+
48
+ return {
49
+ roleId: override,
50
+ warnings,
51
+ error: null,
52
+ availableRoles: roles,
53
+ phase,
54
+ };
55
+ }
56
+
57
+ if (state?.next_recommended_role && config?.roles?.[state.next_recommended_role]) {
58
+ const recommended = state.next_recommended_role;
59
+ const isLegal = !routing?.allowed_next_roles || routing.allowed_next_roles.includes(recommended);
60
+ if (isLegal) {
61
+ return {
62
+ roleId: recommended,
63
+ warnings,
64
+ error: null,
65
+ availableRoles: roles,
66
+ phase,
67
+ };
68
+ }
69
+ }
70
+
71
+ if (routing?.entry_role && config?.roles?.[routing.entry_role]) {
72
+ return {
73
+ roleId: routing.entry_role,
74
+ warnings,
75
+ error: null,
76
+ availableRoles: roles,
77
+ phase,
78
+ };
79
+ }
80
+
81
+ if (roles.length > 0) {
82
+ warnings.push(
83
+ phase
84
+ ? `No entry_role for phase "${phase}". Defaulting to "${roles[0]}".`
85
+ : `No routing phase resolved. Defaulting to "${roles[0]}".`,
86
+ );
87
+ return {
88
+ roleId: roles[0],
89
+ warnings,
90
+ error: null,
91
+ availableRoles: roles,
92
+ phase,
93
+ };
94
+ }
95
+
96
+ return {
97
+ roleId: null,
98
+ warnings,
99
+ error: 'No roles defined in config.',
100
+ availableRoles: roles,
101
+ phase,
102
+ };
103
+ }