agentxchain 2.123.0 → 2.125.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.
@@ -121,7 +121,7 @@ import { historyCommand } from '../src/commands/history.js';
121
121
  import { decisionsCommand } from '../src/commands/decisions.js';
122
122
  import { diffCommand } from '../src/commands/diff.js';
123
123
  import { eventsCommand } from '../src/commands/events.js';
124
- import { connectorCheckCommand } from '../src/commands/connector.js';
124
+ import { connectorCheckCommand, connectorValidateCommand } from '../src/commands/connector.js';
125
125
  import { scheduleDaemonCommand, scheduleListCommand, scheduleRunDueCommand, scheduleStatusCommand } from '../src/commands/schedule.js';
126
126
  import { chainLatestCommand, chainListCommand, chainShowCommand } from '../src/commands/chain.js';
127
127
  import { missionAttachChainCommand, missionListCommand, missionPlanApproveCommand, missionPlanAutopilotCommand, missionPlanCommand, missionPlanLaunchCommand, missionPlanListCommand, missionPlanShowCommand, missionShowCommand, missionStartCommand } from '../src/commands/mission.js';
@@ -297,6 +297,15 @@ connectorCmd
297
297
  .option('--timeout <ms>', 'Per-probe timeout in milliseconds', '8000')
298
298
  .action(connectorCheckCommand);
299
299
 
300
+ connectorCmd
301
+ .command('validate <runtime_id>')
302
+ .description('Dispatch one synthetic governed turn through a runtime and validate the staged turn result')
303
+ .option('--role <role_id>', 'Validate a specific role binding for the runtime')
304
+ .option('-j, --json', 'Output as JSON')
305
+ .option('--timeout <ms>', 'Synthetic dispatch timeout in milliseconds', '120000')
306
+ .option('--keep-artifacts', 'Keep the scratch validation workspace even on success')
307
+ .action(connectorValidateCommand);
308
+
300
309
  program
301
310
  .command('demo')
302
311
  .description('Run a complete governed lifecycle demo (no API keys required)')
@@ -918,6 +927,7 @@ intakeCmd
918
927
  .description('Start governed execution for a planned intent')
919
928
  .option('--intent <id>', 'Intent ID to start')
920
929
  .option('--role <role>', 'Override the default entry role for the governed phase')
930
+ .option('--restart-completed', 'Initialize a fresh governed run when state is already completed')
921
931
  .option('-j, --json', 'Output as JSON')
922
932
  .action(intakeStartCommand);
923
933
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.123.0",
3
+ "version": "2.125.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,6 +2,8 @@ import chalk from 'chalk';
2
2
  import { loadProjectContext, loadProjectState } from '../lib/config.js';
3
3
  import { approvePhaseTransition } from '../lib/governed-state.js';
4
4
  import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
5
+ import { resolveGovernedRole } from '../lib/role-resolution.js';
6
+ import { checkCleanBaseline } from '../lib/repo-observer.js';
5
7
 
6
8
  export async function approveTransitionCommand(opts) {
7
9
  const context = loadProjectContext();
@@ -72,8 +74,21 @@ export async function approveTransitionCommand(opts) {
72
74
  console.log(` ${chalk.dim('Gate actions:')} ${result.gateActionRun.actions.length} completed`);
73
75
  }
74
76
  console.log(chalk.dim(` Run status: ${result.state.status}`));
77
+ const nextRole = resolveGovernedRole({ state: result.state, config });
78
+ const nextRoleConfig = nextRole.roleId ? config.roles?.[nextRole.roleId] : null;
79
+ const cleanBaseline = nextRoleConfig
80
+ ? checkCleanBaseline(root, nextRoleConfig.write_authority || 'review_only')
81
+ : { clean: true };
75
82
  console.log('');
76
- console.log(chalk.dim(` Next: agentxchain step (to run the first turn in ${pt.to} phase)`));
83
+ if (!cleanBaseline.clean && nextRole.roleId) {
84
+ console.log(chalk.yellow(' Next turn blocked until the workspace is checkpointed.'));
85
+ console.log(` ${chalk.dim('Role:')} ${nextRole.roleId} (${nextRoleConfig?.write_authority || 'review_only'})`);
86
+ console.log(` ${chalk.dim('Why:')} ${cleanBaseline.reason}`);
87
+ console.log(chalk.dim(' Fix: git add -A && git commit -m "checkpoint accepted artifacts"'));
88
+ console.log(chalk.dim(` Then: agentxchain step (to run the first turn in ${pt.to} phase)`));
89
+ } else {
90
+ console.log(chalk.dim(` Next: agentxchain step (to run the first turn in ${pt.to} phase)`));
91
+ }
77
92
  console.log('');
78
93
  }
79
94
 
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
 
3
3
  import { loadProjectContext } from '../lib/config.js';
4
+ import { DEFAULT_VALIDATE_TIMEOUT_MS, validateConfiguredConnector } from '../lib/connector-validate.js';
4
5
  import { DEFAULT_TIMEOUT_MS, probeConfiguredConnectors } from '../lib/connector-probe.js';
5
6
 
6
7
  function printJson(result, exitCode) {
@@ -120,3 +121,95 @@ export async function connectorCheckCommand(runtimeId, options = {}) {
120
121
 
121
122
  printText(payload, result.exitCode);
122
123
  }
124
+
125
+ function printValidateText(result, exitCode) {
126
+ console.log('');
127
+ console.log(chalk.bold(' AgentXchain Connector Validate'));
128
+ console.log(chalk.dim(' ' + '─'.repeat(47)));
129
+ console.log(` ${chalk.dim(`Runtime:`)} ${result.runtime_id} (${result.runtime_type})`);
130
+ console.log(` ${chalk.dim(`Role:`)} ${result.role_id}`);
131
+ console.log(` ${chalk.dim(`Timeout:`)} ${result.timeout_ms}ms`);
132
+ console.log('');
133
+
134
+ const badge = result.overall === 'pass'
135
+ ? chalk.green('PASS')
136
+ : result.overall === 'error'
137
+ ? chalk.red('ERROR')
138
+ : chalk.red('FAIL');
139
+ const summary = result.overall === 'pass'
140
+ ? 'Synthetic governed dispatch produced a valid turn result'
141
+ : (result.error || result.dispatch?.error || result.validation?.errors?.[0] || 'Connector validation failed');
142
+ console.log(` ${badge} ${summary}`);
143
+
144
+ if (Array.isArray(result.warnings) && result.warnings.length > 0) {
145
+ console.log('');
146
+ for (const warning of result.warnings) {
147
+ console.log(` ${chalk.yellow('!')} ${warning}`);
148
+ }
149
+ }
150
+
151
+ if (result.dispatch) {
152
+ console.log('');
153
+ console.log(` ${chalk.dim('Dispatch:')} ${result.dispatch.ok ? chalk.green('ok') : chalk.red('failed')}`);
154
+ if (result.dispatch.error) {
155
+ console.log(` ${chalk.dim('Detail:')} ${result.dispatch.error}`);
156
+ }
157
+ }
158
+
159
+ if (result.validation) {
160
+ console.log(` ${chalk.dim('Validator:')} ${result.validation.ok ? chalk.green('ok') : chalk.red(result.validation.stage || 'failed')}`);
161
+ if (Array.isArray(result.validation.errors) && result.validation.errors.length > 0) {
162
+ console.log(` ${chalk.dim('Errors:')} ${result.validation.errors.join(' | ')}`);
163
+ }
164
+ }
165
+
166
+ if (typeof result.cost_usd === 'number') {
167
+ console.log(` ${chalk.dim('Cost:')} $${result.cost_usd.toFixed(3)}`);
168
+ }
169
+
170
+ if (result.scratch_root) {
171
+ console.log('');
172
+ console.log(` ${chalk.dim('Scratch:')} ${result.scratch_root}`);
173
+ }
174
+
175
+ console.log('');
176
+ process.exit(exitCode);
177
+ }
178
+
179
+ export async function connectorValidateCommand(runtimeId, options = {}) {
180
+ const context = loadProjectContext();
181
+ if (!context) {
182
+ const payload = { overall: 'error', error: 'No governed agentxchain.json found.' };
183
+ if (options.json) {
184
+ printJson(payload, 2);
185
+ return;
186
+ }
187
+ console.error(chalk.red('No governed agentxchain.json found. Run this inside a governed project.'));
188
+ process.exit(2);
189
+ }
190
+
191
+ const timeoutMs = Number.parseInt(options.timeout || DEFAULT_VALIDATE_TIMEOUT_MS, 10);
192
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
193
+ const payload = { overall: 'error', error: 'Timeout must be a positive integer.' };
194
+ if (options.json) {
195
+ printJson(payload, 2);
196
+ return;
197
+ }
198
+ console.error(chalk.red('Timeout must be a positive integer.'));
199
+ process.exit(2);
200
+ }
201
+
202
+ const result = await validateConfiguredConnector(context.root, {
203
+ runtimeId,
204
+ roleId: options.role || null,
205
+ timeoutMs,
206
+ keepArtifacts: options.keepArtifacts === true,
207
+ });
208
+
209
+ if (options.json) {
210
+ printJson(result, result.exitCode ?? 1);
211
+ return;
212
+ }
213
+
214
+ printValidateText(result, result.exitCode ?? 1);
215
+ }
@@ -423,7 +423,7 @@ function getConnectorProbeRecommendation(runtimes) {
423
423
  return {
424
424
  recommended: true,
425
425
  runtimeIds,
426
- detail: 'run `agentxchain connector check` to live-probe api / remote HTTP runtimes before the first governed turn.',
426
+ detail: 'run `agentxchain connector check` to live-probe api / remote HTTP runtimes, then `agentxchain connector validate <runtime_id>` to prove one binding produces valid governed turn results.',
427
427
  };
428
428
  }
429
429
 
@@ -495,6 +495,18 @@ function formatGovernedRuntimeCommand(runtime) {
495
495
  return Array.isArray(runtime?.command) ? runtime.command.join(' ') : String(runtime?.command || '');
496
496
  }
497
497
 
498
+ function formatRuntimeSummary(runtimeId, runtime) {
499
+ if (!runtimeId || !runtime) return 'unconfigured';
500
+ if (runtime.type === 'manual') return `${runtimeId} (manual)`;
501
+ const command = formatGovernedRuntimeCommand(runtime);
502
+ if (!command) return `${runtimeId} (${runtime.type})`;
503
+ return `${command} (${runtime.prompt_transport || runtime.type})`;
504
+ }
505
+
506
+ function hasTemplateDefinedRouting(template) {
507
+ return Boolean(template?.scaffold_blueprint?.routing && Object.keys(template.scaffold_blueprint.routing).length > 0);
508
+ }
509
+
498
510
  function resolveInitDirOption(dirOption) {
499
511
  if (dirOption == null) return null;
500
512
  const value = String(dirOption).trim();
@@ -862,10 +874,8 @@ async function initGoverned(opts) {
862
874
  const dir = resolve(process.cwd(), folderName);
863
875
  const targetLabel = formatInitTarget(dir);
864
876
  const projectId = slugify(projectName);
865
- let localDevRuntime;
866
-
867
877
  try {
868
- ({ runtime: localDevRuntime } = resolveGovernedLocalDevRuntime(opts));
878
+ resolveGovernedLocalDevRuntime(opts);
869
879
  } catch (err) {
870
880
  console.error(chalk.red(` Error: ${err.message}`));
871
881
  process.exit(1);
@@ -906,6 +916,9 @@ async function initGoverned(opts) {
906
916
  ? { ...opts, goal: projectGoal }
907
917
  : { ...opts };
908
918
  const { config, scaffoldWorkflowKitConfig } = scaffoldGoverned(dir, projectName, projectId, templateId, scaffoldOptions, workflowKitConfig);
919
+ const devRuntimeId = config.roles?.dev?.runtime;
920
+ const devRuntime = devRuntimeId ? config.runtimes?.[devRuntimeId] : null;
921
+ const hasLiveConnectorProbe = Object.values(config.runtimes || {}).some((runtime) => runtime?.type && runtime.type !== 'manual');
909
922
 
910
923
  console.log('');
911
924
  console.log(chalk.green(` ✓ Created governed project ${chalk.bold(targetLabel)}/`));
@@ -928,12 +941,12 @@ async function initGoverned(opts) {
928
941
  console.log(` ${chalk.dim('└──')} TALK.md`);
929
942
  console.log('');
930
943
  console.log(` ${chalk.dim('Roles:')} ${promptRoleIds.join(', ')}`);
931
- console.log(` ${chalk.dim('Phases:')} ${phaseNames.join(' → ')} ${chalk.dim(selectedTemplate.scaffold_blueprint ? '(template-defined; edit routing in agentxchain.json to customize)' : '(default; extend via routing in agentxchain.json)')}`);
944
+ console.log(` ${chalk.dim('Phases:')} ${phaseNames.join(' → ')} ${chalk.dim(hasTemplateDefinedRouting(selectedTemplate) ? '(template-defined; edit routing in agentxchain.json to customize)' : '(default; extend via routing in agentxchain.json)')}`);
932
945
  console.log(` ${chalk.dim('Template:')} ${templateId}`);
933
946
  if (config.project?.goal) {
934
947
  console.log(` ${chalk.dim('Goal:')} ${config.project.goal}`);
935
948
  }
936
- console.log(` ${chalk.dim('Dev runtime:')} ${formatGovernedRuntimeCommand(localDevRuntime)} ${chalk.dim(`(${localDevRuntime.prompt_transport})`)}`);
949
+ console.log(` ${chalk.dim('Dev runtime:')} ${formatRuntimeSummary(devRuntimeId, devRuntime)}`);
937
950
  console.log(` ${chalk.dim('Protocol:')} governed convergence`);
938
951
  console.log('');
939
952
 
@@ -962,6 +975,7 @@ async function initGoverned(opts) {
962
975
  console.log('');
963
976
  } else if (Object.entries(config.roles).every(([, role]) => allRuntimes[role.runtime]?.type === 'manual')) {
964
977
  console.log(` ${chalk.green('Ready:')} manual-only scaffold (${chalk.bold('no API keys')} and ${chalk.bold('no local coding CLI')} required).`);
978
+ console.log(` ${chalk.green(' ')}Use ${chalk.bold('agentxchain step')} for the first governed turn; ${chalk.bold('run')} requires automatable runtimes.`);
965
979
  console.log('');
966
980
  }
967
981
 
@@ -974,7 +988,13 @@ async function initGoverned(opts) {
974
988
  }
975
989
  console.log(` ${chalk.bold('agentxchain template validate')} ${chalk.dim('# prove the scaffold contract before the first turn')}`);
976
990
  console.log(` ${chalk.bold('agentxchain doctor')} ${chalk.dim('# verify runtimes, config, and readiness')}`);
977
- console.log(` ${chalk.bold('agentxchain connector check')} ${chalk.dim('# live-probe configured runtimes before the first turn')}`);
991
+ if (hasLiveConnectorProbe) {
992
+ console.log(` ${chalk.bold('agentxchain connector check')} ${chalk.dim('# live-probe configured runtimes before the first turn')}`);
993
+ const firstNonManualRtId = Object.entries(config.runtimes || {}).find(([, rt]) => rt?.type && rt.type !== 'manual')?.[0];
994
+ if (firstNonManualRtId) {
995
+ console.log(` ${chalk.bold(`agentxchain connector validate ${firstNonManualRtId}`)} ${chalk.dim('# prove the runtime produces valid governed turn results')}`);
996
+ }
997
+ }
978
998
  console.log(` ${chalk.bold('git add -A')} ${chalk.dim('# stage the governed scaffold')}`);
979
999
  console.log(` ${chalk.bold('git commit -m "initial governed scaffold"')} ${chalk.dim('# checkpoint the starting state')}`);
980
1000
  console.log(` ${chalk.bold('agentxchain step')} ${chalk.dim('# run the first governed turn')}`);
@@ -17,6 +17,7 @@ export async function intakeStartCommand(opts) {
17
17
 
18
18
  const result = startIntent(root, opts.intent, {
19
19
  role: opts.role || undefined,
20
+ allowTerminalRestart: opts.restartCompleted === true,
20
21
  });
21
22
 
22
23
  if (opts.json) {
@@ -32,6 +32,10 @@ export function printManualDispatchInstructions(state, config, options = {}) {
32
32
  const stagingPath = getTurnStagingResultPath(turn.turn_id);
33
33
  const phase = state.phase || 'planning';
34
34
  const roleId = turn.assigned_role;
35
+ const artifactType = getDefaultArtifactType(role);
36
+ const verificationExample = getVerificationExample(phase, config, role);
37
+ const phaseTransitionExample = getDefaultPhaseTransitionRequest(phase, config);
38
+ const runCompletionExample = getDefaultRunCompletionRequest(phase, config);
35
39
 
36
40
  const lines = [];
37
41
  lines.push('');
@@ -64,6 +68,15 @@ export function printManualDispatchInstructions(state, config, options = {}) {
64
68
  lines.push('');
65
69
  }
66
70
 
71
+ const completionHints = getPhaseCompletionHints(phase, role, config);
72
+ if (completionHints.length > 0) {
73
+ lines.push(' To exit this phase cleanly:');
74
+ for (const hint of completionHints) {
75
+ lines.push(` - ${hint}`);
76
+ }
77
+ lines.push('');
78
+ }
79
+
67
80
  // Minimal turn-result example
68
81
  lines.push(' Minimal turn-result.json:');
69
82
  lines.push(' {');
@@ -71,17 +84,17 @@ export function printManualDispatchInstructions(state, config, options = {}) {
71
84
  lines.push(` "run_id": "${state.run_id || 'run_...'}",`);
72
85
  lines.push(` "turn_id": "${turn.turn_id}",`);
73
86
  lines.push(` "role": "${roleId}",`);
74
- lines.push(` "runtime_id": "${role?.runtime || 'manual'}",`);
87
+ lines.push(` "runtime_id": "${turn.runtime_id || role?.runtime_id || role?.runtime || 'manual'}",`);
75
88
  lines.push(' "status": "completed",');
76
89
  lines.push(' "summary": "...",');
77
90
  lines.push(' "decisions": [{"id":"DEC-001","category":"scope","statement":"...","rationale":"..."}],');
78
91
  lines.push(' "objections": [{"id":"OBJ-001","severity":"medium","statement":"...","status":"raised"}],');
79
92
  lines.push(' "files_changed": [],');
80
- lines.push(' "verification": {"status":"skipped","commands":[],"evidence_summary":"..."},');
81
- lines.push(' "artifact": {"type":"review","ref":null},');
82
- lines.push(` "proposed_next_role": "${getDefaultNextRole(roleId, config)}",`);
83
- lines.push(' "phase_transition_request": null,');
84
- lines.push(' "run_completion_request": null');
93
+ lines.push(` "verification": ${verificationExample},`);
94
+ lines.push(` "artifact": {"type":"${artifactType}","ref":null},`);
95
+ lines.push(` "proposed_next_role": "${getDefaultNextRole(roleId, config, phase)}",`);
96
+ lines.push(` "phase_transition_request": ${phaseTransitionExample === null ? 'null' : `"${phaseTransitionExample}"`},`);
97
+ lines.push(` "run_completion_request": ${runCompletionExample === null ? 'null' : runCompletionExample}`);
85
98
  lines.push(' }');
86
99
  lines.push('');
87
100
  lines.push(' Docs: https://agentxchain.dev/docs/getting-started');
@@ -112,11 +125,48 @@ function getPhaseGateHints(phase, roleId, config) {
112
125
  return hints;
113
126
  }
114
127
 
128
+ function getPhaseCompletionHints(phase, role, config) {
129
+ const hints = [];
130
+ const writeAuthority = role?.write_authority || 'review_only';
131
+ const nextPhase = getNextPhase(phase, config);
132
+ const exitGate = config?.routing?.[phase]?.exit_gate;
133
+ const gateConfig = exitGate ? config?.gates?.[exitGate] : null;
134
+
135
+ if (gateConfig?.requires_verification_pass && writeAuthority !== 'review_only') {
136
+ hints.push('set `verification.status` to `pass` only when your listed checks actually passed');
137
+ }
138
+ if (writeAuthority === 'authoritative') {
139
+ hints.push('use `artifact.type: "workspace"` unless you created a real git commit during the turn');
140
+ } else if (writeAuthority === 'proposed') {
141
+ hints.push('use `artifact.type: "patch"` for non-completion turns');
142
+ } else {
143
+ hints.push('keep `artifact.type: "review"` because review-only roles cannot claim code-writing artifacts');
144
+ }
145
+ if (nextPhase) {
146
+ hints.push(`set \`phase_transition_request\` to \`${nextPhase}\` when this turn is ready to leave \`${phase}\``);
147
+ } else if (phase === 'qa') {
148
+ hints.push('set `run_completion_request` to `true` when QA evidence is complete and you are asking to end the run');
149
+ }
150
+
151
+ return hints;
152
+ }
153
+
115
154
  /**
116
155
  * Suggest a reasonable next role based on current role.
117
156
  */
118
- function getDefaultNextRole(roleId, config) {
157
+ function getDefaultNextRole(roleId, config, phase) {
119
158
  const routing = config.routing || {};
159
+ // Check phase-specific allowed_next first
160
+ const phaseAllowed = routing[phase]?.allowed_next_roles || routing[phase]?.allowed_next;
161
+ if (phase && phaseAllowed?.length > 0) {
162
+ const allowed = phaseAllowed;
163
+ // If the current role is in the allowlist, suggest it (another turn in same phase)
164
+ if (allowed.includes(roleId)) return roleId;
165
+ // Otherwise suggest the first non-human allowed role
166
+ const nonHuman = allowed.find(r => r !== 'human');
167
+ if (nonHuman) return nonHuman;
168
+ return allowed[0];
169
+ }
120
170
  if (routing[roleId]?.default_next) return routing[roleId].default_next;
121
171
  if (roleId === 'pm') return 'dev';
122
172
  if (roleId === 'dev') return 'qa';
@@ -124,6 +174,46 @@ function getDefaultNextRole(roleId, config) {
124
174
  return 'human';
125
175
  }
126
176
 
177
+ function getDefaultArtifactType(role) {
178
+ const writeAuthority = role?.write_authority || 'review_only';
179
+ if (writeAuthority === 'authoritative') return 'workspace';
180
+ if (writeAuthority === 'proposed') return 'patch';
181
+ return 'review';
182
+ }
183
+
184
+ function getVerificationExample(phase, config, role) {
185
+ const writeAuthority = role?.write_authority || 'review_only';
186
+ const exitGate = config?.routing?.[phase]?.exit_gate;
187
+ const gateConfig = exitGate ? config?.gates?.[exitGate] : null;
188
+ if (gateConfig?.requires_verification_pass && writeAuthority !== 'review_only') {
189
+ return '{"status":"pass","commands":["..."],"evidence_summary":"..."}';
190
+ }
191
+ return '{"status":"skipped","commands":[],"evidence_summary":"..."}';
192
+ }
193
+
194
+ function getDefaultPhaseTransitionRequest(phase, config) {
195
+ return getNextPhase(phase, config);
196
+ }
197
+
198
+ function getDefaultRunCompletionRequest(phase, config) {
199
+ const nextPhase = getNextPhase(phase, config);
200
+ if (!nextPhase && phase === 'qa') {
201
+ return 'true';
202
+ }
203
+ return null;
204
+ }
205
+
206
+ function getNextPhase(phase, config) {
207
+ const orderedPhases = Array.isArray(config?.phases) && config.phases.length > 0
208
+ ? config.phases.map((entry) => typeof entry === 'string' ? entry : entry?.id).filter(Boolean)
209
+ : Object.keys(config?.routing || {});
210
+ const index = orderedPhases.indexOf(phase);
211
+ if (index === -1 || index === orderedPhases.length - 1) {
212
+ return null;
213
+ }
214
+ return orderedPhases[index + 1];
215
+ }
216
+
127
217
  /**
128
218
  * Wait for the staged turn result file to appear.
129
219
  *
@@ -0,0 +1,504 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import {
3
+ cpSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readlinkSync,
8
+ readdirSync,
9
+ rmSync,
10
+ symlinkSync,
11
+ writeFileSync,
12
+ } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join, dirname } from 'node:path';
15
+
16
+ import { loadProjectContext } from './config.js';
17
+ import { writeDispatchBundle } from './dispatch-bundle.js';
18
+ import { getActiveTurn, initializeGovernedRun, assignGovernedTurn, readTurnCostUsd } from './governed-state.js';
19
+ import { dispatchApiProxy } from './adapters/api-proxy-adapter.js';
20
+ import { dispatchLocalCli, saveDispatchLogs } from './adapters/local-cli-adapter.js';
21
+ import { dispatchMcp } from './adapters/mcp-adapter.js';
22
+ import { dispatchRemoteAgent } from './adapters/remote-agent-adapter.js';
23
+ import { getDispatchPromptPath, getTurnStagingResultPath } from './turn-paths.js';
24
+ import { validateStagedTurnResult } from './turn-result-validator.js';
25
+
26
+ const VALIDATABLE_RUNTIME_TYPES = new Set(['local_cli', 'api_proxy', 'mcp', 'remote_agent']);
27
+ const DEFAULT_VALIDATE_TIMEOUT_MS = 120_000;
28
+
29
+ export async function validateConfiguredConnector(sourceRoot, options = {}) {
30
+ const sourceContext = loadProjectContext(sourceRoot);
31
+ if (!sourceContext) {
32
+ return {
33
+ ok: false,
34
+ exitCode: 2,
35
+ overall: 'error',
36
+ error: 'No governed agentxchain.json found.',
37
+ };
38
+ }
39
+
40
+ if (sourceContext.config.protocol_mode !== 'governed') {
41
+ return {
42
+ ok: false,
43
+ exitCode: 2,
44
+ overall: 'error',
45
+ error: 'connector validate only supports governed projects.',
46
+ };
47
+ }
48
+
49
+ const runtimeId = typeof options.runtimeId === 'string' ? options.runtimeId.trim() : '';
50
+ if (!runtimeId) {
51
+ return {
52
+ ok: false,
53
+ exitCode: 2,
54
+ overall: 'error',
55
+ error: 'Runtime id is required. Usage: agentxchain connector validate <runtime_id>',
56
+ };
57
+ }
58
+
59
+ const timeoutMs = Number.parseInt(options.timeoutMs ?? DEFAULT_VALIDATE_TIMEOUT_MS, 10);
60
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
61
+ return {
62
+ ok: false,
63
+ exitCode: 2,
64
+ overall: 'error',
65
+ error: 'Timeout must be a positive integer.',
66
+ };
67
+ }
68
+
69
+ const runtime = sourceContext.config.runtimes?.[runtimeId];
70
+ if (!runtime) {
71
+ return {
72
+ ok: false,
73
+ exitCode: 2,
74
+ overall: 'error',
75
+ error: `Unknown connector runtime "${runtimeId}"`,
76
+ };
77
+ }
78
+ if (runtime.type === 'manual') {
79
+ return {
80
+ ok: false,
81
+ exitCode: 2,
82
+ overall: 'error',
83
+ error: `Runtime "${runtimeId}" is manual. connector validate only supports automated runtimes.`,
84
+ };
85
+ }
86
+ if (!VALIDATABLE_RUNTIME_TYPES.has(runtime.type)) {
87
+ return {
88
+ ok: false,
89
+ exitCode: 2,
90
+ overall: 'error',
91
+ error: `Runtime "${runtimeId}" of type "${runtime.type}" cannot be validated by connector validate.`,
92
+ };
93
+ }
94
+
95
+ const roleSelection = resolveValidationRole(sourceContext.config, runtimeId, options.roleId);
96
+ if (!roleSelection.ok) {
97
+ return {
98
+ ok: false,
99
+ exitCode: 2,
100
+ overall: 'error',
101
+ error: roleSelection.error,
102
+ };
103
+ }
104
+
105
+ const tempBase = mkdtempSync(join(tmpdir(), 'axc-connector-validate-'));
106
+ const scratchRoot = join(tempBase, 'workspace');
107
+ const warnings = [...roleSelection.warnings];
108
+ let keepArtifacts = options.keepArtifacts === true;
109
+ let dispatch = null;
110
+ let validation = null;
111
+ let costUsd = null;
112
+
113
+ try {
114
+ copyRepoForValidation(sourceRoot, scratchRoot);
115
+ initializeScratchGit(scratchRoot);
116
+
117
+ const scratchContext = loadProjectContext(scratchRoot);
118
+ if (!scratchContext) {
119
+ return {
120
+ ok: false,
121
+ exitCode: 1,
122
+ overall: 'fail',
123
+ runtime_id: runtimeId,
124
+ runtime_type: runtime.type,
125
+ role_id: roleSelection.roleId,
126
+ timeout_ms: timeoutMs,
127
+ warnings,
128
+ error: 'Failed to load governed config inside scratch workspace.',
129
+ scratch_root: scratchRoot,
130
+ };
131
+ }
132
+
133
+ const initResult = initializeGovernedRun(scratchRoot, scratchContext.config, {
134
+ provenance: {
135
+ trigger: 'connector_validate',
136
+ runtime_id: runtimeId,
137
+ role_id: roleSelection.roleId,
138
+ },
139
+ });
140
+ if (!initResult.ok) {
141
+ return {
142
+ ok: false,
143
+ exitCode: 1,
144
+ overall: 'fail',
145
+ runtime_id: runtimeId,
146
+ runtime_type: runtime.type,
147
+ role_id: roleSelection.roleId,
148
+ timeout_ms: timeoutMs,
149
+ warnings,
150
+ error: initResult.error,
151
+ scratch_root: scratchRoot,
152
+ };
153
+ }
154
+
155
+ const assignResult = assignGovernedTurn(scratchRoot, scratchContext.config, roleSelection.roleId);
156
+ if (!assignResult.ok) {
157
+ return {
158
+ ok: false,
159
+ exitCode: 1,
160
+ overall: 'fail',
161
+ runtime_id: runtimeId,
162
+ runtime_type: runtime.type,
163
+ role_id: roleSelection.roleId,
164
+ timeout_ms: timeoutMs,
165
+ warnings,
166
+ error: assignResult.error,
167
+ scratch_root: scratchRoot,
168
+ };
169
+ }
170
+
171
+ const state = assignResult.state;
172
+ const turn = getActiveTurn(state);
173
+ if (!turn) {
174
+ return {
175
+ ok: false,
176
+ exitCode: 1,
177
+ overall: 'fail',
178
+ runtime_id: runtimeId,
179
+ runtime_type: runtime.type,
180
+ role_id: roleSelection.roleId,
181
+ timeout_ms: timeoutMs,
182
+ warnings,
183
+ error: 'Synthetic validation turn was not assigned.',
184
+ scratch_root: scratchRoot,
185
+ };
186
+ }
187
+
188
+ const bundleResult = writeDispatchBundle(scratchRoot, state, scratchContext.config, { turnId: turn.turn_id });
189
+ if (!bundleResult.ok) {
190
+ return {
191
+ ok: false,
192
+ exitCode: 1,
193
+ overall: 'fail',
194
+ runtime_id: runtimeId,
195
+ runtime_type: runtime.type,
196
+ role_id: roleSelection.roleId,
197
+ timeout_ms: timeoutMs,
198
+ warnings,
199
+ error: bundleResult.error,
200
+ scratch_root: scratchRoot,
201
+ };
202
+ }
203
+ if (Array.isArray(bundleResult.warnings)) {
204
+ warnings.push(...bundleResult.warnings);
205
+ }
206
+
207
+ appendValidationPrompt(scratchRoot, scratchContext.config, state, turn);
208
+ configureRuntimeValidationTimeout(scratchContext.config, runtimeId, timeoutMs);
209
+
210
+ const signal = AbortSignal.timeout(timeoutMs);
211
+ const adapterResult = await dispatchValidationTurn(
212
+ scratchRoot,
213
+ state,
214
+ scratchContext.config,
215
+ runtimeId,
216
+ { turnId: turn.turn_id, signal },
217
+ );
218
+
219
+ dispatch = {
220
+ ok: adapterResult.ok,
221
+ error: adapterResult.error || null,
222
+ timed_out: adapterResult.timedOut === true,
223
+ aborted: adapterResult.aborted === true,
224
+ log_count: Array.isArray(adapterResult.logs) ? adapterResult.logs.length : 0,
225
+ };
226
+
227
+ if (Array.isArray(adapterResult.logs) && adapterResult.logs.length > 0) {
228
+ saveDispatchLogs(scratchRoot, turn.turn_id, adapterResult.logs);
229
+ }
230
+
231
+ if (!adapterResult.ok) {
232
+ keepArtifacts = true;
233
+ return {
234
+ ok: false,
235
+ exitCode: 1,
236
+ overall: 'fail',
237
+ runtime_id: runtimeId,
238
+ runtime_type: runtime.type,
239
+ role_id: roleSelection.roleId,
240
+ timeout_ms: timeoutMs,
241
+ warnings,
242
+ dispatch,
243
+ validation: null,
244
+ scratch_root: scratchRoot,
245
+ };
246
+ }
247
+
248
+ validation = validateStagedTurnResult(scratchRoot, state, scratchContext.config, {
249
+ stagingPath: getTurnStagingResultPath(turn.turn_id),
250
+ });
251
+ costUsd = validation?.turnResult ? readTurnCostUsd(validation.turnResult) : null;
252
+
253
+ if (!validation.ok) {
254
+ keepArtifacts = true;
255
+ return {
256
+ ok: false,
257
+ exitCode: 1,
258
+ overall: 'fail',
259
+ runtime_id: runtimeId,
260
+ runtime_type: runtime.type,
261
+ role_id: roleSelection.roleId,
262
+ timeout_ms: timeoutMs,
263
+ warnings,
264
+ dispatch,
265
+ validation: {
266
+ ok: false,
267
+ stage: validation.stage,
268
+ error_class: validation.error_class,
269
+ errors: validation.errors,
270
+ warnings: validation.warnings,
271
+ },
272
+ cost_usd: costUsd,
273
+ scratch_root: scratchRoot,
274
+ };
275
+ }
276
+
277
+ return {
278
+ ok: true,
279
+ exitCode: 0,
280
+ overall: 'pass',
281
+ runtime_id: runtimeId,
282
+ runtime_type: runtime.type,
283
+ role_id: roleSelection.roleId,
284
+ timeout_ms: timeoutMs,
285
+ warnings,
286
+ dispatch,
287
+ validation: {
288
+ ok: true,
289
+ stage: null,
290
+ error_class: null,
291
+ errors: [],
292
+ warnings: validation.warnings,
293
+ },
294
+ cost_usd: costUsd,
295
+ scratch_root: keepArtifacts ? scratchRoot : null,
296
+ };
297
+ } catch (error) {
298
+ keepArtifacts = true;
299
+ return {
300
+ ok: false,
301
+ exitCode: 1,
302
+ overall: 'fail',
303
+ runtime_id: runtimeId,
304
+ runtime_type: runtime.type,
305
+ role_id: roleSelection.roleId,
306
+ timeout_ms: timeoutMs,
307
+ warnings,
308
+ dispatch,
309
+ validation,
310
+ error: error.message,
311
+ scratch_root: scratchRoot,
312
+ };
313
+ } finally {
314
+ if (!keepArtifacts) {
315
+ try {
316
+ rmSync(tempBase, { recursive: true, force: true });
317
+ } catch {}
318
+ }
319
+ }
320
+ }
321
+
322
+ function resolveValidationRole(config, runtimeId, requestedRoleId) {
323
+ const matchingRoles = Object.entries(config.roles || {})
324
+ .filter(([, role]) => (role.runtime_id || role.runtime) === runtimeId)
325
+ .map(([roleId]) => roleId)
326
+ .sort((a, b) => a.localeCompare(b, 'en'));
327
+
328
+ if (matchingRoles.length === 0) {
329
+ return { ok: false, error: `No roles are bound to runtime "${runtimeId}".` };
330
+ }
331
+
332
+ if (requestedRoleId) {
333
+ if (!config.roles?.[requestedRoleId]) {
334
+ return { ok: false, error: `Unknown role "${requestedRoleId}".` };
335
+ }
336
+ const boundRuntimeId = config.roles[requestedRoleId].runtime_id || config.roles[requestedRoleId].runtime;
337
+ if (boundRuntimeId !== runtimeId) {
338
+ return {
339
+ ok: false,
340
+ error: `Role "${requestedRoleId}" is not bound to runtime "${runtimeId}".`,
341
+ };
342
+ }
343
+ return { ok: true, roleId: requestedRoleId, warnings: [] };
344
+ }
345
+
346
+ const warnings = [];
347
+ if (matchingRoles.length > 1) {
348
+ warnings.push(
349
+ `Runtime "${runtimeId}" is shared by multiple roles (${matchingRoles.join(', ')}). ` +
350
+ `Validated the first binding "${matchingRoles[0]}". Use --role to target another binding.`,
351
+ );
352
+ }
353
+
354
+ return {
355
+ ok: true,
356
+ roleId: matchingRoles[0],
357
+ warnings,
358
+ };
359
+ }
360
+
361
+ function copyRepoForValidation(sourceRoot, scratchRoot) {
362
+ mkdirSync(scratchRoot, { recursive: true });
363
+ copyTree(sourceRoot, scratchRoot);
364
+ }
365
+
366
+ function copyTree(sourcePath, destPath) {
367
+ mkdirSync(destPath, { recursive: true });
368
+ for (const entry of readdirSync(sourcePath, { withFileTypes: true })) {
369
+ if (entry.name === '.git' || entry.name === '.agentxchain') {
370
+ continue;
371
+ }
372
+
373
+ const sourceEntry = join(sourcePath, entry.name);
374
+ const destEntry = join(destPath, entry.name);
375
+ const stats = lstatSync(sourceEntry);
376
+
377
+ if (stats.isSymbolicLink()) {
378
+ symlinkSync(readlinkSync(sourceEntry), destEntry);
379
+ continue;
380
+ }
381
+
382
+ if (stats.isDirectory()) {
383
+ if (entry.name === 'node_modules') {
384
+ symlinkSync(sourceEntry, destEntry, 'dir');
385
+ continue;
386
+ }
387
+ copyTree(sourceEntry, destEntry);
388
+ continue;
389
+ }
390
+
391
+ mkdirSync(dirname(destEntry), { recursive: true });
392
+ cpSync(sourceEntry, destEntry, { force: true, preserveTimestamps: true });
393
+ }
394
+ }
395
+
396
+ function initializeScratchGit(root) {
397
+ execFileSync('git', ['init', '-q'], { cwd: root, stdio: 'ignore' });
398
+ execFileSync('git', ['checkout', '-q', '-b', 'main'], { cwd: root, stdio: 'ignore' });
399
+ execFileSync('git', ['add', '-A'], { cwd: root, stdio: 'ignore' });
400
+ execFileSync('git', ['commit', '-q', '-m', 'connector validation baseline'], {
401
+ cwd: root,
402
+ stdio: 'ignore',
403
+ env: {
404
+ ...process.env,
405
+ GIT_AUTHOR_NAME: 'AgentXchain Validator',
406
+ GIT_AUTHOR_EMAIL: 'noreply@agentxchain.dev',
407
+ GIT_COMMITTER_NAME: 'AgentXchain Validator',
408
+ GIT_COMMITTER_EMAIL: 'noreply@agentxchain.dev',
409
+ },
410
+ });
411
+ }
412
+
413
+ function appendValidationPrompt(root, config, state, turn) {
414
+ const promptPath = join(root, getDispatchPromptPath(turn.turn_id));
415
+ const role = config.roles?.[turn.assigned_role];
416
+ const reviewOnly = role?.write_authority === 'review_only';
417
+ const validationTurn = {
418
+ schema_version: '1.0',
419
+ run_id: state.run_id,
420
+ turn_id: turn.turn_id,
421
+ role: turn.assigned_role,
422
+ runtime_id: turn.runtime_id,
423
+ status: 'completed',
424
+ summary: 'Connector validation synthetic turn completed without modifying product files.',
425
+ decisions: [
426
+ {
427
+ id: 'DEC-900',
428
+ category: 'process',
429
+ statement: 'The runtime emitted a schema-valid connector validation result.',
430
+ rationale: 'Synthetic dispatch completed and staged a governed turn result.',
431
+ },
432
+ ],
433
+ objections: reviewOnly ? [
434
+ {
435
+ id: 'OBJ-900',
436
+ severity: 'low',
437
+ statement: 'Validation objection: this is a synthetic proof turn, not delivery work.',
438
+ status: 'acknowledged',
439
+ },
440
+ ] : [],
441
+ files_changed: [],
442
+ verification: {
443
+ status: 'skipped',
444
+ evidence_summary: 'Synthetic connector validation turn; no repo work requested.',
445
+ },
446
+ artifact: {
447
+ type: 'review',
448
+ ref: null,
449
+ },
450
+ proposed_next_role: 'human',
451
+ };
452
+
453
+ const lines = [
454
+ '',
455
+ '## Connector Validation Override',
456
+ '',
457
+ 'This dispatch is a connector-validation proof turn, not real product work.',
458
+ '',
459
+ 'You must follow these extra constraints:',
460
+ '',
461
+ '1. Do not edit any product files.',
462
+ '2. Write exactly one governed turn result to the staged result path already provided in ASSIGNMENT.json.',
463
+ '3. Use `files_changed: []` and `artifact.type: "review"`.',
464
+ '4. Do not request a phase transition or run completion.',
465
+ '5. Set `proposed_next_role: "human"`.',
466
+ reviewOnly ? '6. Include at least one objection because this role is review_only.' : '6. Objections may be empty because this role is not review_only.',
467
+ '',
468
+ 'Use this JSON shape as your starting point and replace nothing except if you need equivalent wording:',
469
+ '',
470
+ '```json',
471
+ JSON.stringify(validationTurn, null, 2),
472
+ '```',
473
+ '',
474
+ ];
475
+
476
+ writeFileSync(promptPath, lines.join('\n'), { flag: 'a' });
477
+ }
478
+
479
+ function configureRuntimeValidationTimeout(config, runtimeId, timeoutMs) {
480
+ if (config?.runtimes?.[runtimeId] && config.runtimes[runtimeId].type === 'remote_agent') {
481
+ config.runtimes[runtimeId] = {
482
+ ...config.runtimes[runtimeId],
483
+ timeout_ms: timeoutMs,
484
+ };
485
+ }
486
+ }
487
+
488
+ async function dispatchValidationTurn(root, state, config, runtimeId, options = {}) {
489
+ const runtime = config.runtimes?.[runtimeId];
490
+ switch (runtime?.type) {
491
+ case 'local_cli':
492
+ return dispatchLocalCli(root, state, config, options);
493
+ case 'api_proxy':
494
+ return dispatchApiProxy(root, state, config, options);
495
+ case 'mcp':
496
+ return dispatchMcp(root, state, config, options);
497
+ case 'remote_agent':
498
+ return dispatchRemoteAgent(root, state, config, options);
499
+ default:
500
+ return { ok: false, error: `Unsupported runtime type "${runtime?.type || 'unknown'}"` };
501
+ }
502
+ }
503
+
504
+ export { DEFAULT_VALIDATE_TIMEOUT_MS, VALIDATABLE_RUNTIME_TYPES };
package/src/lib/intake.js CHANGED
@@ -711,9 +711,10 @@ export function startIntent(root, intentId, options = {}) {
711
711
  }
712
712
 
713
713
  if (state.status === 'completed' && !allowCompletedRestart) {
714
+ const restartCmd = `agentxchain intake start --intent ${intent.intent_id} --restart-completed`;
714
715
  return {
715
716
  ok: false,
716
- error: 'cannot start: governed run is already completed. Use "agentxchain init --force" to start a new run.',
717
+ error: `cannot start: governed run is already completed. Re-run with "${restartCmd}" to initialize a fresh governed run.`,
717
718
  exitCode: 1,
718
719
  };
719
720
  }
@@ -513,6 +513,20 @@ function validateArtifact(tr, config) {
513
513
  }
514
514
  }
515
515
 
516
+ // Authoritative roles with product-file changes must not claim artifact.type "review".
517
+ // "review" signals observation-only, but non-empty product files_changed means the actor
518
+ // wrote to the repo. Allowing the mismatch makes accepted_integration_ref look clean
519
+ // when the workspace is actually dirty — hiding state from the next authoritative turn.
520
+ if (writeAuthority === 'authoritative' && tr.artifact?.type === 'review') {
521
+ const productFiles = (tr.files_changed || []).filter(f => !isAllowedReviewPath(f));
522
+ if (productFiles.length > 0) {
523
+ errors.push(
524
+ `Role "${tr.role}" has authoritative write authority and changed product files (${productFiles.join(', ')}), but artifact type is "review". ` +
525
+ 'Use "workspace" or "commit" when product files are modified.'
526
+ );
527
+ }
528
+ }
529
+
516
530
  // Warn if files_changed is empty for authoritative + completed turns
517
531
  if (writeAuthority === 'authoritative' && tr.status === 'completed' && (tr.files_changed || []).length === 0) {
518
532
  warnings.push('Authoritative role completed with no files_changed — is this intentional?');