agentxchain 2.51.0 → 2.52.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.
- package/bin/agentxchain.js +2 -1
- package/package.json +1 -1
- package/src/commands/doctor.js +265 -17
package/bin/agentxchain.js
CHANGED
|
@@ -249,7 +249,8 @@ program
|
|
|
249
249
|
|
|
250
250
|
program
|
|
251
251
|
.command('doctor')
|
|
252
|
-
.description('Check local environment
|
|
252
|
+
.description('Check governed project readiness (v4) or local environment (v3)')
|
|
253
|
+
.option('-j, --json', 'Output as JSON')
|
|
253
254
|
.action(doctorCommand);
|
|
254
255
|
|
|
255
256
|
program
|
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -1,19 +1,258 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'fs';
|
|
2
|
-
import { execSync } from 'child_process';
|
|
2
|
+
import { execFileSync, execSync } from 'child_process';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
|
-
import { loadConfig, loadLock } from '../lib/config.js';
|
|
5
|
+
import { loadConfig, loadLock, findProjectRoot } from '../lib/config.js';
|
|
6
6
|
import { validateProject } from '../lib/validation.js';
|
|
7
7
|
import { getWatchPid } from './watch.js';
|
|
8
|
+
import { loadNormalizedConfig, detectConfigVersion } from '../lib/normalized-config.js';
|
|
9
|
+
import { readDaemonState, evaluateDaemonStatus } from '../lib/run-schedule.js';
|
|
8
10
|
|
|
9
|
-
export async function doctorCommand() {
|
|
10
|
-
const
|
|
11
|
+
export async function doctorCommand(opts = {}) {
|
|
12
|
+
const root = findProjectRoot(process.cwd());
|
|
13
|
+
if (!root) {
|
|
14
|
+
if (opts.json) {
|
|
15
|
+
console.log(JSON.stringify({ error: 'No agentxchain.json found' }));
|
|
16
|
+
} else {
|
|
17
|
+
console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
|
|
18
|
+
}
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Detect config version to dispatch
|
|
23
|
+
let rawConfig;
|
|
24
|
+
try {
|
|
25
|
+
rawConfig = JSON.parse(readFileSync(join(root, 'agentxchain.json'), 'utf8'));
|
|
26
|
+
} catch (err) {
|
|
27
|
+
if (opts.json) {
|
|
28
|
+
console.log(JSON.stringify({ error: `agentxchain.json is invalid JSON: ${err.message}` }));
|
|
29
|
+
} else {
|
|
30
|
+
console.log(chalk.red(`agentxchain.json is invalid JSON: ${err.message}`));
|
|
31
|
+
}
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const version = detectConfigVersion(rawConfig);
|
|
36
|
+
|
|
37
|
+
if (version === 4) {
|
|
38
|
+
return governedDoctor(root, rawConfig, opts);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Legacy v3 path — existing behavior
|
|
42
|
+
return legacyDoctor(root, opts);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Governed (v4) Doctor ────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function governedDoctor(root, rawConfig, opts) {
|
|
48
|
+
const checks = [];
|
|
49
|
+
|
|
50
|
+
// 1. Config validation
|
|
51
|
+
const configResult = loadNormalizedConfig(rawConfig, root);
|
|
52
|
+
if (configResult.ok) {
|
|
53
|
+
checks.push({ id: 'config_valid', name: 'Config validation', level: 'pass', detail: 'Config loads and validates' });
|
|
54
|
+
} else {
|
|
55
|
+
const errorSummary = configResult.errors.slice(0, 3).join('; ');
|
|
56
|
+
checks.push({ id: 'config_valid', name: 'Config validation', level: 'fail', detail: errorSummary });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const normalized = configResult.normalized;
|
|
60
|
+
|
|
61
|
+
// 2. Roles defined
|
|
62
|
+
const roles = normalized ? Object.keys(normalized.roles || {}) : [];
|
|
63
|
+
if (roles.length > 0) {
|
|
64
|
+
checks.push({ id: 'roles_defined', name: 'Roles defined', level: 'pass', detail: `${roles.length} role${roles.length > 1 ? 's' : ''}: ${roles.join(', ')}` });
|
|
65
|
+
} else {
|
|
66
|
+
checks.push({ id: 'roles_defined', name: 'Roles defined', level: 'fail', detail: 'No roles defined' });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 3. Runtime reachable — one sub-check per runtime
|
|
70
|
+
// Use normalized runtimes if available, otherwise fall back to raw config
|
|
71
|
+
const runtimes = (normalized && normalized.runtimes) || rawConfig.runtimes || {};
|
|
72
|
+
for (const [rtId, rt] of Object.entries(runtimes)) {
|
|
73
|
+
const check = checkRuntimeReachable(rtId, rt);
|
|
74
|
+
checks.push(check);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 4. State directory
|
|
78
|
+
const stateDir = join(root, '.agentxchain');
|
|
79
|
+
if (existsSync(stateDir)) {
|
|
80
|
+
checks.push({ id: 'state_dir', name: 'State directory', level: 'pass', detail: '.agentxchain/ exists' });
|
|
81
|
+
} else {
|
|
82
|
+
checks.push({ id: 'state_dir', name: 'State directory', level: 'warn', detail: '.agentxchain/ missing (created on first run)' });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 5. State health
|
|
86
|
+
const statePath = join(root, '.agentxchain', 'state.json');
|
|
87
|
+
if (existsSync(statePath)) {
|
|
88
|
+
try {
|
|
89
|
+
const stateData = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
90
|
+
if (stateData.schema_version) {
|
|
91
|
+
checks.push({ id: 'state_health', name: 'State health', level: 'pass', detail: `schema_version: ${stateData.schema_version}, status: ${stateData.status || 'unknown'}` });
|
|
92
|
+
} else {
|
|
93
|
+
checks.push({ id: 'state_health', name: 'State health', level: 'fail', detail: 'State file missing schema_version' });
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
checks.push({ id: 'state_health', name: 'State health', level: 'fail', detail: 'State file is malformed JSON' });
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
checks.push({ id: 'state_health', name: 'State health', level: 'warn', detail: 'No state file yet (first run pending)' });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 6. Schedule health (only when schedules configured)
|
|
103
|
+
const schedules = normalized?.schedules;
|
|
104
|
+
const hasSchedules = schedules && typeof schedules === 'object' && Object.keys(schedules).length > 0;
|
|
105
|
+
if (hasSchedules) {
|
|
106
|
+
const daemonState = readDaemonState(root);
|
|
107
|
+
const daemonEval = evaluateDaemonStatus(daemonState);
|
|
108
|
+
if (daemonEval.status === 'running') {
|
|
109
|
+
const detail = `Daemon running (last heartbeat ${daemonEval.heartbeat_age_seconds}s ago)`;
|
|
110
|
+
checks.push({ id: 'schedule_health', name: 'Schedule health', level: 'pass', detail });
|
|
111
|
+
} else {
|
|
112
|
+
const detail = `Daemon ${daemonEval.status}${daemonEval.warning ? `: ${daemonEval.warning}` : ''}`;
|
|
113
|
+
checks.push({ id: 'schedule_health', name: 'Schedule health', level: 'warn', detail });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 7. Workflow-kit artifacts (current phase)
|
|
118
|
+
if (normalized?.workflow_kit?.phases) {
|
|
119
|
+
const currentPhase = getCurrentPhase(root) || Object.keys(normalized.routing || {})[0] || 'planning';
|
|
120
|
+
const phaseKit = normalized.workflow_kit.phases[currentPhase];
|
|
121
|
+
if (phaseKit?.artifacts?.length > 0) {
|
|
122
|
+
const required = phaseKit.artifacts.filter(a => a.required !== false);
|
|
123
|
+
const missing = required.filter(a => !existsSync(join(root, a.path)));
|
|
124
|
+
if (missing.length === 0) {
|
|
125
|
+
checks.push({ id: 'workflow_kit', name: 'Workflow-kit artifacts', level: 'pass', detail: `All ${required.length} required artifacts present for ${currentPhase}` });
|
|
126
|
+
} else {
|
|
127
|
+
checks.push({ id: 'workflow_kit', name: 'Workflow-kit artifacts', level: 'warn', detail: `${missing.length}/${required.length} required artifacts missing for ${currentPhase}` });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Compute summary
|
|
133
|
+
const failCount = checks.filter(c => c.level === 'fail').length;
|
|
134
|
+
const warnCount = checks.filter(c => c.level === 'warn').length;
|
|
135
|
+
const overall = failCount > 0 ? 'fail' : warnCount > 0 ? 'warn' : 'pass';
|
|
136
|
+
|
|
137
|
+
if (opts.json) {
|
|
138
|
+
const projectId = rawConfig?.project?.id || rawConfig?.project?.name || 'unknown';
|
|
139
|
+
console.log(JSON.stringify({
|
|
140
|
+
project: projectId,
|
|
141
|
+
config_version: 4,
|
|
142
|
+
overall,
|
|
143
|
+
checks,
|
|
144
|
+
fail_count: failCount,
|
|
145
|
+
warn_count: warnCount,
|
|
146
|
+
}, null, 2));
|
|
147
|
+
} else {
|
|
148
|
+
const projectId = rawConfig?.project?.id || rawConfig?.project?.name || 'unknown';
|
|
149
|
+
console.log('');
|
|
150
|
+
console.log(chalk.bold(' AgentXchain Governed Doctor'));
|
|
151
|
+
console.log(chalk.dim(' ' + '─'.repeat(44)));
|
|
152
|
+
console.log(chalk.dim(` Project: ${projectId} (v4)`));
|
|
153
|
+
console.log('');
|
|
154
|
+
|
|
155
|
+
for (const c of checks) {
|
|
156
|
+
const badge = c.level === 'pass'
|
|
157
|
+
? chalk.green('PASS')
|
|
158
|
+
: c.level === 'warn'
|
|
159
|
+
? chalk.yellow('WARN')
|
|
160
|
+
: chalk.red('FAIL');
|
|
161
|
+
console.log(` ${badge} ${c.name.padEnd(24)} ${chalk.dim(c.detail)}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
console.log('');
|
|
165
|
+
if (failCount === 0 && warnCount === 0) {
|
|
166
|
+
console.log(chalk.green(' ✓ Governed project is ready.'));
|
|
167
|
+
} else if (failCount === 0) {
|
|
168
|
+
console.log(chalk.yellow(` Ready with ${warnCount} warning${warnCount > 1 ? 's' : ''}.`));
|
|
169
|
+
} else {
|
|
170
|
+
console.log(chalk.red(` Not ready: ${failCount} failure${failCount > 1 ? 's' : ''}, ${warnCount} warning${warnCount > 1 ? 's' : ''}.`));
|
|
171
|
+
}
|
|
172
|
+
console.log('');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
process.exit(failCount > 0 ? 1 : 0);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function checkRuntimeReachable(rtId, rt) {
|
|
179
|
+
const base = { id: `runtime_${rtId}`, name: `Runtime: ${rtId}` };
|
|
180
|
+
|
|
181
|
+
if (!rt || !rt.type) {
|
|
182
|
+
return { ...base, level: 'warn', detail: 'No runtime type specified' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
switch (rt.type) {
|
|
186
|
+
case 'manual':
|
|
187
|
+
return { ...base, level: 'pass', detail: 'Manual runtime (no binary needed)' };
|
|
188
|
+
|
|
189
|
+
case 'local_cli': {
|
|
190
|
+
const cmd = Array.isArray(rt.command) ? rt.command[0] : (typeof rt.command === 'string' ? rt.command.split(/\s+/)[0] : null);
|
|
191
|
+
if (!cmd) return { ...base, level: 'warn', detail: 'No command configured' };
|
|
192
|
+
try {
|
|
193
|
+
execSync(`command -v ${cmd}`, { stdio: 'ignore' });
|
|
194
|
+
return { ...base, level: 'pass', detail: `${cmd} binary found` };
|
|
195
|
+
} catch {
|
|
196
|
+
return { ...base, level: 'fail', detail: `${cmd} not found in PATH` };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
case 'api_proxy': {
|
|
201
|
+
const envVar = rt.auth_env;
|
|
202
|
+
if (!envVar) {
|
|
203
|
+
// ollama and similar providers may not require auth
|
|
204
|
+
return { ...base, level: 'pass', detail: `${rt.provider || 'unknown'} provider (no auth required)` };
|
|
205
|
+
}
|
|
206
|
+
if (process.env[envVar]) {
|
|
207
|
+
return { ...base, level: 'pass', detail: `${envVar} is set` };
|
|
208
|
+
}
|
|
209
|
+
return { ...base, level: 'fail', detail: `${envVar} not set` };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
case 'mcp': {
|
|
213
|
+
const transport = rt.transport || 'stdio';
|
|
214
|
+
if (transport === 'streamable_http') {
|
|
215
|
+
return { ...base, level: 'warn', detail: 'Remote MCP endpoint (cannot verify at doctor time)' };
|
|
216
|
+
}
|
|
217
|
+
const cmd = Array.isArray(rt.command) ? rt.command[0] : (typeof rt.command === 'string' ? rt.command.split(/\s+/)[0] : null);
|
|
218
|
+
if (!cmd) return { ...base, level: 'warn', detail: 'No MCP command configured' };
|
|
219
|
+
try {
|
|
220
|
+
execSync(`command -v ${cmd}`, { stdio: 'ignore' });
|
|
221
|
+
return { ...base, level: 'pass', detail: `${cmd} binary found` };
|
|
222
|
+
} catch {
|
|
223
|
+
return { ...base, level: 'fail', detail: `${cmd} not found in PATH` };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
case 'remote_agent':
|
|
228
|
+
return { ...base, level: 'warn', detail: 'Remote agent endpoint (cannot verify at doctor time)' };
|
|
229
|
+
|
|
230
|
+
default:
|
|
231
|
+
return { ...base, level: 'warn', detail: `Unknown runtime type: ${rt.type}` };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function getCurrentPhase(root) {
|
|
236
|
+
const statePath = join(root, '.agentxchain', 'state.json');
|
|
237
|
+
if (!existsSync(statePath)) return null;
|
|
238
|
+
try {
|
|
239
|
+
const state = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
240
|
+
return state.current_phase || null;
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Legacy (v3) Doctor ──────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
function legacyDoctor(root, opts) {
|
|
249
|
+
const result = loadConfig(root);
|
|
11
250
|
if (!result) {
|
|
12
251
|
console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
|
|
13
252
|
process.exit(1);
|
|
14
253
|
}
|
|
15
254
|
|
|
16
|
-
const {
|
|
255
|
+
const { config } = result;
|
|
17
256
|
const lock = loadLock(root);
|
|
18
257
|
const checks = [];
|
|
19
258
|
|
|
@@ -24,7 +263,7 @@ export async function doctorCommand() {
|
|
|
24
263
|
checks.push(checkBinary('osascript', 'osascript available (required for auto-nudge, macOS)'));
|
|
25
264
|
checks.push(checkPm(config));
|
|
26
265
|
checks.push(checkValidation(root, config));
|
|
27
|
-
checks.push(checkWatchProcess());
|
|
266
|
+
checks.push(checkWatchProcess(root));
|
|
28
267
|
checks.push(checkTrigger(root));
|
|
29
268
|
checks.push(checkAccessibility());
|
|
30
269
|
|
|
@@ -86,13 +325,10 @@ function checkPm(config) {
|
|
|
86
325
|
return { name: 'PM agent', level: 'warn', detail: 'No explicit PM agent. PM-first onboarding will be less clear.' };
|
|
87
326
|
}
|
|
88
327
|
|
|
89
|
-
function checkWatchProcess() {
|
|
90
|
-
const
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
if (pid) {
|
|
94
|
-
return { name: 'watch process', level: 'pass', detail: `watch running (PID: ${pid})` };
|
|
95
|
-
}
|
|
328
|
+
function checkWatchProcess(root) {
|
|
329
|
+
const pid = getWatchPid(root);
|
|
330
|
+
if (pid) {
|
|
331
|
+
return { name: 'watch process', level: 'pass', detail: `watch running (PID: ${pid})` };
|
|
96
332
|
}
|
|
97
333
|
try {
|
|
98
334
|
execSync('pgrep -f "agentxchain.*watch" >/dev/null', { stdio: 'ignore' });
|
|
@@ -127,12 +363,24 @@ function checkAccessibility() {
|
|
|
127
363
|
}
|
|
128
364
|
|
|
129
365
|
try {
|
|
130
|
-
|
|
131
|
-
'osascript
|
|
132
|
-
|
|
366
|
+
execFileSync(
|
|
367
|
+
'osascript',
|
|
368
|
+
['-e', 'tell application "System Events" to get name of first process'],
|
|
369
|
+
{
|
|
370
|
+
stdio: 'pipe',
|
|
371
|
+
timeout: 1500,
|
|
372
|
+
killSignal: 'SIGKILL',
|
|
373
|
+
},
|
|
133
374
|
);
|
|
134
375
|
return { name: 'macOS Accessibility', level: 'pass', detail: 'System Events access available' };
|
|
135
|
-
} catch {
|
|
376
|
+
} catch (err) {
|
|
377
|
+
if (err?.signal === 'SIGKILL' || err?.message?.includes('ETIMEDOUT')) {
|
|
378
|
+
return {
|
|
379
|
+
name: 'macOS Accessibility',
|
|
380
|
+
level: 'warn',
|
|
381
|
+
detail: 'Accessibility probe timed out. Grant Accessibility to Terminal and Cursor in System Settings.',
|
|
382
|
+
};
|
|
383
|
+
}
|
|
136
384
|
return {
|
|
137
385
|
name: 'macOS Accessibility',
|
|
138
386
|
level: 'warn',
|