agentxchain 2.24.3 → 2.25.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.24.3",
3
+ "version": "2.25.1",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -681,6 +681,7 @@ async function initGoverned(opts) {
681
681
  console.log(` ${chalk.dim('└──')} TALK.md`);
682
682
  console.log('');
683
683
  console.log(` ${chalk.dim('Roles:')} pm, dev, qa, eng_director`);
684
+ console.log(` ${chalk.dim('Phases:')} planning → implementation → qa ${chalk.dim('(default; extend via routing in agentxchain.json)')}`);
684
685
  console.log(` ${chalk.dim('Template:')} ${templateId}`);
685
686
  console.log(` ${chalk.dim('Dev runtime:')} ${formatGovernedRuntimeCommand(localDevRuntime)} ${chalk.dim(`(${localDevRuntime.prompt_transport})`)}`);
686
687
  console.log(` ${chalk.dim('Protocol:')} governed convergence`);
@@ -6,7 +6,8 @@ import { safeParseJson } from './schema.js';
6
6
  export const COORDINATOR_CONFIG_FILE = 'agentxchain-multi.json';
7
7
 
8
8
  const VALID_ID = /^[a-z0-9_-]+$/;
9
- const VALID_PHASES = new Set(['planning', 'implementation', 'qa']);
9
+ const DEFAULT_PHASES = new Set(['planning', 'implementation', 'qa']);
10
+ const VALID_PHASE_NAME = /^[a-z][a-z0-9_-]*$/;
10
11
  const VALID_BARRIER_TYPES = new Set([
11
12
  'all_repos_accepted',
12
13
  'interface_alignment',
@@ -88,11 +89,14 @@ function validateWorkstreams(raw, repoIds, errors) {
88
89
  continue;
89
90
  }
90
91
 
91
- if (!VALID_PHASES.has(workstream.phase)) {
92
+ // Derive valid phases from routing keys when present; fall back to defaults
93
+ const validPhases = raw.routing ? new Set(Object.keys(raw.routing)) : DEFAULT_PHASES;
94
+ if (!validPhases.has(workstream.phase)) {
95
+ const phaseList = [...validPhases].join(', ');
92
96
  pushError(
93
97
  errors,
94
98
  'workstream_phase_invalid',
95
- `workstream "${workstreamId}" phase must be one of: planning, implementation, qa`,
99
+ `workstream "${workstreamId}" phase must be one of: ${phaseList}`,
96
100
  );
97
101
  }
98
102
 
@@ -279,8 +283,8 @@ function validateRouting(raw, workstreamIds, errors) {
279
283
 
280
284
  const workstreamIdSet = new Set(workstreamIds);
281
285
  for (const [phase, route] of Object.entries(raw.routing)) {
282
- if (!VALID_PHASES.has(phase)) {
283
- pushError(errors, 'routing_phase_invalid', `routing phase "${phase}" must be one of: planning, implementation, qa`);
286
+ if (!VALID_PHASE_NAME.test(phase)) {
287
+ pushError(errors, 'routing_phase_invalid', `routing phase "${phase}" must be lowercase alphanumeric starting with a letter (hyphens and underscores allowed)`);
284
288
  }
285
289
 
286
290
  if (!route || typeof route !== 'object' || Array.isArray(route)) {
@@ -73,6 +73,15 @@ export function evaluatePhaseExit({ state, config, acceptedTurn, root }) {
73
73
  };
74
74
  }
75
75
 
76
+ const invalidOrderReason = getInvalidPhaseTransitionReason(currentPhase, transitionRequest, routing);
77
+ if (invalidOrderReason) {
78
+ return {
79
+ ...baseResult,
80
+ action: 'gate_failed',
81
+ reasons: [invalidOrderReason],
82
+ };
83
+ }
84
+
76
85
  // Find the exit gate for the current phase
77
86
  const currentRouting = routing[currentPhase];
78
87
  if (!currentRouting || !currentRouting.exit_gate) {
@@ -285,6 +294,43 @@ export function getPhaseOrder(routing) {
285
294
  return Object.keys(routing || {});
286
295
  }
287
296
 
297
+ /**
298
+ * Return the next declared phase after the current phase, or null when the
299
+ * current phase is final or not part of the routing config.
300
+ *
301
+ * @param {string} currentPhase
302
+ * @param {object} routing
303
+ * @returns {string|null}
304
+ */
305
+ export function getNextPhase(currentPhase, routing) {
306
+ const phases = getPhaseOrder(routing || {});
307
+ const currentIndex = phases.indexOf(currentPhase);
308
+ if (currentIndex === -1 || currentIndex >= phases.length - 1) {
309
+ return null;
310
+ }
311
+ return phases[currentIndex + 1];
312
+ }
313
+
314
+ /**
315
+ * Validate that a requested phase transition follows the declared routing
316
+ * order. Returns null when the request is valid.
317
+ *
318
+ * @param {string} currentPhase
319
+ * @param {string} requestedPhase
320
+ * @param {object} routing
321
+ * @returns {string|null}
322
+ */
323
+ export function getInvalidPhaseTransitionReason(currentPhase, requestedPhase, routing) {
324
+ const nextPhase = getNextPhase(currentPhase, routing);
325
+ if (!nextPhase) {
326
+ return `phase_transition_request "${requestedPhase}" is invalid in final phase "${currentPhase}"; use run_completion_request instead.`;
327
+ }
328
+ if (requestedPhase !== nextPhase) {
329
+ return `phase_transition_request "${requestedPhase}" is invalid from phase "${currentPhase}"; next phase is "${nextPhase}".`;
330
+ }
331
+ return null;
332
+ }
333
+
288
334
  /**
289
335
  * Check if a phase is the final phase in the routing config.
290
336
  *
@@ -21,7 +21,8 @@ const VALID_RUNTIME_TYPES = ['manual', 'local_cli', 'api_proxy', 'mcp'];
21
21
  const VALID_API_PROXY_PROVIDERS = ['anthropic', 'openai'];
22
22
  export const VALID_PROMPT_TRANSPORTS = ['argv', 'stdin', 'dispatch_bundle_only'];
23
23
  const VALID_MCP_TRANSPORTS = ['stdio', 'streamable_http'];
24
- const VALID_PHASES = ['planning', 'implementation', 'qa'];
24
+ const DEFAULT_PHASES = ['planning', 'implementation', 'qa'];
25
+ const VALID_PHASE_NAME = /^[a-z][a-z0-9_-]*$/;
25
26
  const VALID_API_PROXY_RETRY_JITTER = ['none', 'full'];
26
27
  const VALID_API_PROXY_RETRY_CLASSES = [
27
28
  'rate_limited',
@@ -400,10 +401,11 @@ export function validateV4Config(data, projectRoot) {
400
401
  }
401
402
 
402
403
  // Routing (optional but validated if present)
404
+ // Phase names are derived from routing keys when present; fall back to defaults
403
405
  if (data.routing) {
404
406
  for (const [phase, route] of Object.entries(data.routing)) {
405
- if (!VALID_PHASES.includes(phase)) {
406
- errors.push(`Routing references unknown phase: "${phase}"`);
407
+ if (!VALID_PHASE_NAME.test(phase)) {
408
+ errors.push(`Routing phase name "${phase}" must be lowercase alphanumeric starting with a letter (hyphens and underscores allowed)`);
407
409
  }
408
410
  if (route.entry_role && data.roles && !data.roles[route.entry_role]) {
409
411
  errors.push(`Routing "${phase}": entry_role "${route.entry_role}" is not a defined role`);
@@ -15,6 +15,7 @@
15
15
  import { existsSync, readFileSync } from 'fs';
16
16
  import { join } from 'path';
17
17
  import { getActiveTurn } from './governed-state.js';
18
+ import { getInvalidPhaseTransitionReason } from './gate-evaluator.js';
18
19
 
19
20
  // ── Constants ────────────────────────────────────────────────────────────────
20
21
 
@@ -519,6 +520,15 @@ function validateProtocol(tr, state, config) {
519
520
  errors.push(
520
521
  `phase_transition_request "${tr.phase_transition_request}" is not a defined phase in routing.`
521
522
  );
523
+ } else if (config.routing && state?.phase) {
524
+ const invalidOrderReason = getInvalidPhaseTransitionReason(
525
+ state.phase,
526
+ tr.phase_transition_request,
527
+ config.routing
528
+ );
529
+ if (invalidOrderReason) {
530
+ errors.push(invalidOrderReason);
531
+ }
522
532
  }
523
533
  }
524
534