UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

1,088 lines (1,009 loc) 44.4 kB
#!/usr/bin/env node /** * AIWG Doctor Command * Checks installation health and diagnoses common issues */ import fs from 'fs/promises'; import os from 'os'; import path from 'path'; import { pathToFileURL } from 'url'; import { execSync } from 'child_process'; import chalk from 'chalk'; import { importImpl } from '../_resolve-impl.mjs'; const { getFrameworkRoot, getVersionInfo } = await importImpl( import.meta.url, 'channel/manager.mjs' ); // AIWG_ROOT: env override > channel-manager resolved path > legacy edge path // getFrameworkRoot() resolves correctly for npm global installs, edge, and dev channels. const AIWG_ROOT = process.env.AIWG_ROOT || await getFrameworkRoot(); const checks = []; // ---- Provider awareness (#1057) ---------------------------------------- // doctor used to hardcode .claude/agents and .claude/commands. On a project // deployed to Factory, Codex, Cursor, etc. that produced misleading "No // agents deployed" output. The per-provider section below resolves paths // from the provider modules themselves (paths.agents / paths.commands) // instead of literal .claude/* strings. // Static registry of supported providers and their human-readable labels. // Each entry exposes .paths via dynamic import so we don't pull all ten // provider modules eagerly when the user hasn't deployed to any of them. const PROVIDER_LABELS = { claude: 'Claude Code', factory: 'Factory', codex: 'Codex', copilot: 'Copilot', cursor: 'Cursor', opencode: 'OpenCode', warp: 'Warp', windsurf: 'Windsurf', openclaw: 'OpenClaw', hermes: 'Hermes', }; // Quick-detect dirs (agents-only) — used when no --provider flag is given. // Mirrors the agents path each provider exports. Kept literal here so the // check is fast and string-greppable without loading every provider module. const PROVIDER_AGENT_DIRS = { claude: '.claude/agents', factory: '.factory/droids', codex: '.codex/agents', copilot: '.github/agents', cursor: '.cursor/agents', opencode: '.opencode/agent', warp: '.warp/agents', windsurf: '.windsurf/agents', // openclaw/hermes deploy to ~/.{provider}/ — handled separately }; // Parse doctor-specific flags from process.argv (no commander dependency). function parseDoctorArgs(argv) { const out = { provider: null, allProviders: false, noBudgetCheck: false }; for (let i = 0; i < argv.length; i += 1) { const a = argv[i]; if (a === '--provider' && argv[i + 1]) { out.provider = argv[i + 1]; i += 1; continue; } if (a.startsWith('--provider=')) { out.provider = a.slice('--provider='.length); continue; } if (a === '--all-providers') { out.allProviders = true; continue; } if (a === '--no-budget-check') { out.noBudgetCheck = true; continue; } } return out; } // ---- Skill listing budget check (#1150) ------------------------------- // // Estimates the size of the skill listing the platform will render at // session start and warns when it exceeds the platform's default budget, // before the operator sees post-hoc truncation in /doctor (#1147). // // Per-platform model (see issue body for sources): // claude — `skillListingBudgetFraction` × context window (default 1% // × 200k = 2000 tokens). User override read from // ~/.claude/settings.json. // codex — fixed 8000-char cap built into Codex itself. // others — skip (no documented budget). // // Token estimation: ~4 chars/token is the standard rough heuristic. Each // listing entry is approximately `- name: description\n` so we sum // `name.length + description.length + 5` per skill. const CLAUDE_DEFAULT_BUDGET_FRACTION = 0.01; const CLAUDE_DEFAULT_CONTEXT_WINDOW = 200_000; const CODEX_LISTING_CHAR_CAP = 8000; const CHARS_PER_TOKEN = 4; async function readClaudeBudgetOverride() { const candidates = [ path.join(os.homedir(), '.claude', 'settings.json'), path.join(os.homedir(), '.config', 'claude', 'settings.json'), ]; for (const p of candidates) { try { const txt = await fs.readFile(p, 'utf-8'); const data = JSON.parse(txt); const v = data.skillListingBudgetFraction; if (typeof v === 'number' && v > 0 && v <= 1) return { value: v, source: p }; } catch { /* missing or unreadable — try next */ } } return null; } async function readContextWindowDirective() { // Honor `<!-- AIWG_CONTEXT_WINDOW: N -->` declared in the project's // platform context file (CLAUDE.md and friends, per context-budget rule). const candidates = [ path.join(process.cwd(), 'CLAUDE.md'), path.join(process.cwd(), 'AGENTS.md'), path.join(process.cwd(), 'AIWG.md'), ]; for (const p of candidates) { try { const txt = await fs.readFile(p, 'utf-8'); const m = /AIWG_CONTEXT_WINDOW:\s*(\d+)/.exec(txt); if (m) { const n = parseInt(m[1], 10); if (n > 0) return { value: n, source: path.basename(p) }; } } catch { /* missing — try next */ } } return null; } // Strip a single ---\n...\n--- frontmatter block and pull `name:` and // `description:` keys. Cheaper than a full YAML parse and good enough — the // real listing render uses the same first-N-chars-from-frontmatter shape. function extractSkillFrontmatter(src) { const fmEnd = src.indexOf('\n---', 4); if (!src.startsWith('---') || fmEnd < 0) return null; const block = src.slice(3, fmEnd); // Multi-line description support: collapse continuation lines that don't // start with a top-level key into the previous value. const out = {}; const lines = block.split('\n'); let lastKey = null; for (const raw of lines) { const line = raw.trimEnd(); if (!line) { lastKey = null; continue; } const m = /^([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/.exec(line); if (m) { lastKey = m[1]; out[lastKey] = m[2].trim(); } else if (lastKey && line.startsWith(' ')) { out[lastKey] = `${out[lastKey]} ${line.trim()}`.trim(); } } return out; } async function measureSkillsListing(skillsDir) { let totalChars = 0; let count = 0; let totalDescChars = 0; let entries = []; try { entries = await fs.readdir(skillsDir, { withFileTypes: true }); } catch { return null; } for (const dirent of entries) { if (!dirent.isDirectory()) continue; const skillFile = path.join(skillsDir, dirent.name, 'SKILL.md'); let raw = ''; try { raw = await fs.readFile(skillFile, 'utf-8'); } catch { continue; } const fm = extractSkillFrontmatter(raw); if (!fm?.name) continue; const desc = (fm.description || '').replace(/^["']|["']$/g, ''); const entryChars = fm.name.length + desc.length + 5; // "- name: desc\n" totalChars += entryChars; totalDescChars += desc.length; count += 1; } if (count === 0) return null; return { count, totalChars, totalTokens: Math.ceil(totalChars / CHARS_PER_TOKEN), avgDescChars: Math.round(totalDescChars / count), }; } function mergeSkillMeasurements(measurements) { const present = measurements.filter(Boolean); if (present.length === 0) return null; const count = present.reduce((sum, item) => sum + item.count, 0); const totalChars = present.reduce((sum, item) => sum + item.totalChars, 0); return { count, totalChars, totalTokens: Math.ceil(totalChars / CHARS_PER_TOKEN), avgDescChars: Math.round( present.reduce((sum, item) => sum + (item.avgDescChars * item.count), 0) / count, ), }; } async function checkTotalDeployedSkillBudgetForProvider(provName, label, provider) { const paths = new Set(); if (provider?.kernelSkillsPath) paths.add(provider.kernelSkillsPath); if (provider?.paths?.skills) paths.add(provider.paths.skills); if (paths.size === 0) return; const measurements = []; for (const relPath of paths) { const skillsDir = resolveProviderPath(relPath); if (!skillsDir || !(await fileExists(skillsDir))) continue; measurements.push(await measureSkillsListing(skillsDir)); } const stats = mergeSkillMeasurements(measurements); if (!stats) return; if (provName === 'claude') { const defaultBudgetTokens = Math.floor( (CLAUDE_DEFAULT_CONTEXT_WINDOW * CLAUDE_DEFAULT_BUDGET_FRACTION) / CHARS_PER_TOKEN, ); if (stats.totalTokens > defaultBudgetTokens) { check( `${label} Deployed Skill Count`, 'warn', `${stats.count} deployed skills estimate ${stats.totalTokens.toLocaleString()} tokens, above Claude Code's default listing budget (${defaultBudgetTokens.toLocaleString()} tokens). Run \`aiwg use all\` for workspace-aware filtering or \`aiwg list --deployed\` to inspect include/exclude reasons.`, ); } } else if (provName === 'codex' && stats.totalChars > CODEX_LISTING_CHAR_CAP) { check( `${label} Deployed Skill Count`, 'warn', `${stats.count} deployed skills estimate ${stats.totalChars.toLocaleString()} chars, above Codex's default listing cap (${CODEX_LISTING_CHAR_CAP.toLocaleString()} chars). Run \`aiwg use all\` for workspace-aware filtering or \`aiwg list --deployed\` to inspect include/exclude reasons.`, ); } } async function checkSkillBudgetForProvider(provName, label, skillsPathRel) { if (!skillsPathRel || skillsPathRel === 'native' || skillsPathRel === true) { // Not a deployable skill path on this provider. return; } const skillsDir = resolveProviderPath(skillsPathRel); if (!skillsDir || !(await fileExists(skillsDir))) return; const stats = await measureSkillsListing(skillsDir); if (!stats) return; // Determine the budget for this provider. let budget = null; let budgetUnit = 'tokens'; let budgetSource = ''; let usage = stats.totalTokens; let usageUnit = 'tokens'; let recommendations = []; let usingOverride = false; if (provName === 'claude') { const ctxDirective = await readContextWindowDirective(); const ctx = ctxDirective?.value ?? CLAUDE_DEFAULT_CONTEXT_WINDOW; const override = await readClaudeBudgetOverride(); usingOverride = Boolean(override); const fraction = override?.value ?? CLAUDE_DEFAULT_BUDGET_FRACTION; budget = Math.floor((ctx * fraction) / CHARS_PER_TOKEN); budgetSource = override ? `${(fraction * 100).toFixed(2)}% × ${ctx.toLocaleString()} ctx (override in ${override.source.replace(os.homedir(), '~')})` : `${(fraction * 100).toFixed(2)}% × ${ctx.toLocaleString()} ctx (default)${ctxDirective ? ` — ctx from ${ctxDirective.source}` : ''}`; if (usage > budget) { // Round up to next 1% step, capped at 10%. const needed = (usage * CHARS_PER_TOKEN) / ctx; const recommendedFraction = Math.min(0.1, Math.ceil(needed * 100) / 100); const verb = override ? 'raise' : 'set'; recommendations.push( `${verb} skillListingBudgetFraction to ${recommendedFraction} (~${Math.round(recommendedFraction * 100)}%) in ~/.claude/settings.json`, ); recommendations.push('or remove unused frameworks (e.g. aiwg remove media-marketing)'); recommendations.push('see docs/skills-budget-guide.md for full options'); } } else if (provName === 'codex') { budget = CODEX_LISTING_CHAR_CAP; budgetUnit = 'chars'; usage = stats.totalChars; usageUnit = 'chars'; budgetSource = `${CODEX_LISTING_CHAR_CAP.toLocaleString()}-char built-in cap`; if (usage > budget) { recommendations.push('Codex caps the listing at 8 000 chars — trim skill descriptions or remove unused frameworks'); recommendations.push('see docs/skills-budget-guide.md'); } } else { // Other platforms: emit an info-level usage line without a verdict so // the operator still sees the surface area. check( `${label} Skill Budget`, 'info', `${stats.count} skills, ~${stats.totalTokens.toLocaleString()} tokens — no documented budget for ${provName}, skipping verdict`, ); return; } const ratio = usage / budget; const usageStr = `${usage.toLocaleString()} ${usageUnit}`; const budgetStr = `${budget.toLocaleString()} ${budgetUnit}`; const summary = `${stats.count} skills (avg ${stats.avgDescChars} chars desc), est. ${usageStr} vs ${budgetStr} budget — ${budgetSource}`; if (usage > budget) { const recBlock = recommendations.length ? ` | ${recommendations.join(' | ')}` : ''; const verdict = usingOverride ? 'EXCEEDS OVERRIDE' : 'EXCEEDS DEFAULT'; check(`${label} Skill Budget`, 'warn', `${verdict} (${ratio.toFixed(2)}×) — ${summary}${recBlock}`); } else { check(`${label} Skill Budget`, 'ok', `${ratio < 0.5 ? 'OK' : 'tight'} (${ratio.toFixed(2)}×) — ${summary}`); } } async function loadProvider(name) { try { const providerPath = path.join(AIWG_ROOT, 'tools/agents/providers', `${name}.mjs`); const mod = await import(pathToFileURL(providerPath).href); return mod.default || mod; } catch (err) { if (process.env.AIWG_DEBUG) { console.error(`loadProvider(${name}) failed: ${err?.message ?? err}`); } return null; } } // Resolve an absolute project path from a provider's paths.<kind> entry. // Some providers export absolute paths (openclaw, hermes); relative ones // resolve against process.cwd(). function resolveProviderPath(p) { if (!p) return null; return path.isAbsolute(p) ? p : path.join(process.cwd(), p); } async function detectDeployedProviders() { const detected = []; for (const [name, dir] of Object.entries(PROVIDER_AGENT_DIRS)) { if (await fileExists(path.join(process.cwd(), dir))) detected.push(name); } // Aggregated providers (Windsurf / Hermes) leave a project-root AGENTS.md. if (await fileExists(path.join(process.cwd(), 'AGENTS.md')) && !detected.includes('windsurf')) { detected.push('windsurf'); } return detected; } function check(name, status, message) { checks.push({ name, status, message }); } async function fileExists(filePath) { try { await fs.access(filePath); return true; } catch { return false; } } const BRAND_HEX = '#818CF8'; async function runDoctor() { const isTTY = Boolean(process.stdout.isTTY); const mark = isTTY ? chalk.hex(BRAND_HEX)('◆') : '◆'; const rule = isTTY ? chalk.dim(' ' + '─'.repeat(42)) : ' ' + '-'.repeat(42); console.log(''); console.log(isTTY ? ` ${mark} ${chalk.bold('AIWG Doctor')}` : ' ◆ AIWG Doctor'); console.log(rule); console.log(''); // 1. Check AIWG installation — use channel-manager resolved root, not legacy edge path const aiwgInstalled = await fileExists(AIWG_ROOT); if (aiwgInstalled) { check('AIWG Installation', 'ok', `Found at ${AIWG_ROOT}`); } else { check('AIWG Installation', 'error', `AIWG not found at ${AIWG_ROOT}. Run: npm install -g aiwg`); } // 2. Check version — include channel label (stable / next / nightly / edge) let versionInfo = null; try { versionInfo = await getVersionInfo(); const channelLabel = versionInfo.channel !== 'stable' ? ` [${versionInfo.channel}]` : ''; check('AIWG Version', 'ok', `${versionInfo.version}${channelLabel}`); } catch { try { const version = execSync('aiwg -version 2>/dev/null', { encoding: 'utf-8' }).trim(); check('AIWG Version', 'ok', version.split('\n')[0]); } catch { check('AIWG Version', 'warn', 'Could not determine version'); } } // 2b. Customize mode — upstream staleness check (fork mode only) if (versionInfo?.devMode && versionInfo?.edgePath) { try { // Check if upstream remote exists (fork mode) const remotes = execSync('git remote', { cwd: versionInfo.edgePath, encoding: 'utf-8' }).trim().split('\n'); if (remotes.includes('upstream')) { // Count commits upstream has that we don't let aheadCount = 0; try { execSync('git fetch upstream --dry-run', { cwd: versionInfo.edgePath, stdio: 'pipe' }); aheadCount = parseInt( execSync('git rev-list HEAD..upstream/main --count', { cwd: versionInfo.edgePath, encoding: 'utf-8' }).trim(), 10 ) || 0; } catch { // fetch dry-run can fail on no-network; skip count } const sourcePath = versionInfo.edgePath.replace(os.homedir(), '~'); if (aheadCount > 0) { check( 'Customize Mode', 'info', `Active — source: ${sourcePath} | upstream has ${aheadCount} commit(s) — tell Steward "sync my AIWG" to update`, ); } else { check('Customize Mode', 'ok', `Active (fork) — source: ${sourcePath} — up to date with upstream`); } } else { // Local clone mode (no upstream remote) const sourcePath = versionInfo.edgePath.replace(os.homedir(), '~'); check('Customize Mode', 'ok', `Active (local clone) — source: ${sourcePath}`); } } catch { const sourcePath = versionInfo.edgePath.replace(os.homedir(), '~'); check('Customize Mode', 'ok', `Active — source: ${sourcePath}`); } } // 3. Check .aiwg directory in current project const projectAiwg = path.join(process.cwd(), '.aiwg'); const hasProjectAiwg = await fileExists(projectAiwg); if (hasProjectAiwg) { check('Project .aiwg/', 'ok', 'Found in current directory'); } else { check('Project .aiwg/', 'info', 'No .aiwg/ in current directory (not an AIWG project)'); } // 4-5. Provider-aware agents + commands check (#1057). // Determine which providers to inspect: // --provider <name> → just that one // --all-providers → every supported provider // (default) → auto-detect deployed providers via PROVIDER_AGENT_DIRS const { provider: providerArg, allProviders, noBudgetCheck } = parseDoctorArgs(process.argv.slice(2)); let providersToCheck = []; if (providerArg) { providersToCheck = [providerArg]; } else if (allProviders) { providersToCheck = Object.keys(PROVIDER_LABELS); } else { providersToCheck = await detectDeployedProviders(); // Always include claude as a baseline so existing single-provider users // still get the "Claude Code Agents" line they're used to. if (!providersToCheck.includes('claude')) providersToCheck.unshift('claude'); } for (const provName of providersToCheck) { const provider = await loadProvider(provName); const label = PROVIDER_LABELS[provName] || provName; if (!provider || !provider.paths) { check(`${label} Agents`, 'warn', `Unknown provider: ${provName}`); continue; } // Agents const agentsPathRel = provider.paths.agents; const agentsPath = resolveProviderPath(agentsPathRel); if (agentsPath && await fileExists(agentsPath)) { try { const stat = await fs.stat(agentsPath); if (stat.isDirectory()) { const files = await fs.readdir(agentsPath); const agentCount = files.filter(f => f.endsWith('.md') || f.endsWith('.agent.md')).length; check(`${label} Agents`, 'ok', `${agentCount} agents deployed (${agentsPathRel})`); } else { // Aggregated single-file (e.g. Hermes/Windsurf AGENTS.md) check(`${label} Agents`, 'ok', `Aggregated at ${agentsPathRel}`); } } catch { check(`${label} Agents`, 'info', `No agents deployed at ${agentsPathRel}`); } } else if (providerArg || allProviders) { // User explicitly asked about this provider — be explicit when missing. check(`${label} Agents`, 'info', `No agents deployed (run: aiwg use sdlc --provider ${provName})`); } else if (provName === 'claude') { // Default-case fallback for back-compat output. check('Claude Code Agents', 'info', 'No agents deployed (run: aiwg use sdlc)'); } // Commands const commandsPathRel = provider.paths.commands; if (commandsPathRel) { const commandsPath = resolveProviderPath(commandsPathRel); if (commandsPath && await fileExists(commandsPath)) { try { const files = await fs.readdir(commandsPath); const cmdCount = files.filter(f => f.endsWith('.md') || f.endsWith('.prompt.md')).length; check(`${label} Commands`, 'ok', `${cmdCount} commands deployed (${commandsPathRel})`); } catch { // Skip silently — commands are optional for several providers } } else if (provName === 'claude') { // Claude Code uses a skill-only deployment model — `aiwg use` does not // deploy slash commands here. Capabilities are reached via natural // language ("create an intake form") or `aiwg discover` (#1228). check( 'Claude Code Commands', 'ok', 'Skill-only model — capabilities reached via natural language or `aiwg discover`' ); } } // Skill listing budget (#1150) — pre-flight warn before the operator // sees post-hoc truncation in /doctor inside the running session. // // Post-kernel-pivot (#1212): the platform's flat skill listing scans // `kernelSkillsPath` (e.g., `.claude/skills/`) — that's what the // budget actually applies to. Standard-tier skills under // `<provider>/.aiwg/skills/` are hidden from the platform scanner // and don't count against the budget. if (!noBudgetCheck) { const budgetPath = provider.kernelSkillsPath || provider.paths.skills; await checkSkillBudgetForProvider(provName, label, budgetPath); await checkTotalDeployedSkillBudgetForProvider(provName, label, provider); } } // 6. Check Skill Seekers (optional) const skillSeekersPath = path.join(AIWG_ROOT, 'skill-seekers'); const hasSkillSeekers = await fileExists(skillSeekersPath); if (hasSkillSeekers) { check('Skill Seekers', 'ok', 'Community skills available'); } else { // #1264(c): aiwg install-skill-seekers was never implemented. Stop directing // operators at a missing command. check('Skill Seekers', 'info', 'Not installed (optional). See agentic/code/addons/skill-factory/ for the canonical skill-authoring addon.'); } // 6b. Check Optional Features (#1219) — runtime-optional packages // tracked in the features catalog and installed only when needed. try { const statusPath = path.join(AIWG_ROOT, 'dist', 'src', 'features', 'status.js'); const { getAllFeatureStatuses } = await import(pathToFileURL(statusPath).href); const statuses = await getAllFeatureStatuses(); for (const s of statuses) { const label = `Optional: ${s.feature.name}`; if (s.available) { const versions = s.packages.map(p => `${p.name} ${p.version ?? '?'}`).join(', '); check(label, 'ok', `installed (${versions})`); } else { check(label, 'info', `not installed — \`aiwg features install ${s.feature.name}\` to enable`); } } } catch (err) { // Best-effort — if the features module isn't built yet (e.g. on // a fresh dev clone before `npm run build`), just skip the section. if (process.env.AIWG_DEBUG) { console.error(`Optional Features check skipped: ${err?.message ?? err}`); } } // 6c. Check PATH for `aiwg` (#1279). // // Migrated from the now-removed `bin/postinstall.mjs` lifecycle script. The // postinstall hook was deleted to eliminate a worm-amplification primitive // (audit finding F1 / threat scenario S3, Aikido report 2026-05-12): a // compromised AIWG release with a `postinstall` hook executes arbitrary // code on every machine that `npm install -g aiwg` touches, before the // operator ever invokes the CLI. Removing the hook removes the capability. // // The PATH-guidance UX it provided still has value, so it surfaces here // (and in README "Installation troubleshooting") instead. On success this // check is silent — no need to clutter doctor output when PATH works. On // failure it prints shell-specific guidance and the `npx aiwg` fallback. // Doctor's exit code is not affected (PATH guidance is informational). try { execSync('aiwg --version', { stdio: 'ignore' }); // aiwg is callable — no PATH issue to report. } catch { const isTTY = Boolean(process.stdout.isTTY); const cyan = isTTY ? chalk.cyan : (s) => s; const yellow = isTTY ? chalk.yellow : (s) => s; const shell = process.env.SHELL || ''; const lines = []; lines.push('aiwg installed but may not be in your PATH.'); lines.push(' If you get "command not found", add npm global bin to PATH:'); if (shell.includes('zsh')) { lines.push(` ${cyan('echo \'export PATH="$(npm config get prefix)/bin:$PATH"\' >> ~/.zshrc')}`); lines.push(` ${cyan('source ~/.zshrc')}`); } else if (shell.includes('bash')) { lines.push(` ${cyan('echo \'export PATH="$(npm config get prefix)/bin:$PATH"\' >> ~/.bashrc')}`); lines.push(` ${cyan('source ~/.bashrc')}`); } else { lines.push(` ${cyan('npm config get prefix')} # Find your npm global bin directory`); lines.push(' Add that path + /bin to your shell\'s PATH'); } lines.push(` Or run directly with npx: ${cyan('npx aiwg <command>')}`); check('PATH', 'warn', yellow(lines.join('\n '))); } // 7. Check Node.js version const nodeVersion = process.version; const major = parseInt(nodeVersion.slice(1).split('.')[0]); if (major >= 18) { check('Node.js', 'ok', nodeVersion); } else { check('Node.js', 'error', `${nodeVersion} (requires >= 18.0.0)`); } // 8. Check MCP server const mcpServer = path.join(AIWG_ROOT, 'src/mcp/server.mjs'); const hasMcp = await fileExists(mcpServer); if (hasMcp) { check('MCP Server', 'ok', 'Available (run: aiwg mcp serve)'); } else { check('MCP Server', 'warn', 'Not found'); } // 8b. Check CLI runtime integrity — catches older published packages that // shipped without helper scripts the current CLI depends on (e.g. 2026.3.3 // was published before tools/cli/deploy.mjs existed, causing `aiwg sync` to // fail with MODULE_NOT_FOUND). const requiredCliScripts = [ 'deploy.mjs', 'update.mjs', 'version.mjs', 'runtime-info.mjs', 'config-gitignore.mjs', ]; const missingCli = []; for (const script of requiredCliScripts) { const scriptPath = path.join(AIWG_ROOT, 'tools/cli', script); if (!(await fileExists(scriptPath))) { missingCli.push(script); } } if (missingCli.length === 0) { check('CLI Runtime Integrity', 'ok', `${requiredCliScripts.length} helper scripts present`); } else { check( 'CLI Runtime Integrity', 'error', `Missing tools/cli scripts: ${missingCli.join(', ')}. Your installed AIWG is missing files the CLI depends on. Upgrade: npm install -g aiwg@latest`, ); } // 8c. Discovery kernel availability (#1264(g)). // // Verify the discovery commands an AIWG-aware agent expects: discover, show, // index, runtime-info. Each is a smoke probe — non-fatal warning if the // command isn't routable, because doctor must work even on a partially // installed system. Surfaces missing surfaces loudly so an operator/agent // knows the install is degraded before they try to use it. const { spawnSync } = await import('node:child_process'); const aiwgBin = process.env.AIWG_BIN || 'aiwg'; const probeCommand = (name, args, expectStdout = null) => { try { const r = spawnSync(aiwgBin, args, { encoding: 'utf-8', timeout: 10_000, shell: process.platform === 'win32', }); if (r.error || r.status !== 0) { const detail = r.error ? `spawn failed: ${r.error.code || r.error.message}` : `exit=${r.status ?? '?'} ${(r.stderr || '').trim().split('\n')[0] || 'no stderr'}`; return { ok: false, detail }; } if (expectStdout && !(r.stdout || '').includes(expectStdout)) { return { ok: false, detail: `stdout missing expected marker '${expectStdout}'` }; } return { ok: true }; } catch (e) { return { ok: false, detail: e.message }; } }; const discoveryProbes = [ { label: 'Discovery: aiwg discover', args: ['discover', 'doctor', '--json', '--limit', '1'], hint: 'aiwg discover is unavailable — agents may bypass index-driven lookup', }, { label: 'Discovery: aiwg show', args: ['show', 'skill', 'aiwg-doctor'], hint: 'aiwg show cannot fetch a known kernel skill body', }, { label: 'Discovery: aiwg index', args: ['index', 'stats', '--json'], hint: 'aiwg index pipeline unavailable — project-local artifact index may be missing', }, { label: 'Discovery: aiwg runtime-info', args: ['runtime-info', '--check', 'aiwg'], hint: 'aiwg runtime-info cannot self-check — toolsmith catalog may be broken', }, ]; for (const probe of discoveryProbes) { const r = probeCommand(probe.label, probe.args); if (r.ok) { check(probe.label, 'ok', `\`aiwg ${probe.args.join(' ')}\` succeeded`); } else { // Warn (not error) — discovery is degraded but doctor itself still works. check(probe.label, 'warn', `${probe.hint}${r.detail}`); } } // 9. Check installed addons const addonChecks = [ { id: 'daemon', label: 'Daemon Addon', manifest: 'agentic/code/addons/daemon/manifest.json', artifacts: ['behaviors/concierge.behavior.md', 'agents/concierge.md', 'skills/daemon-status/SKILL.md', 'rules/daemon-interaction.md'] }, { id: 'agent-loop', label: 'Agent Loop Addon', manifest: 'agentic/code/addons/agent-loop/manifest.json', artifacts: ['agents/ralph-loop.md'] }, { id: 'rlm', label: 'RLM Addon', manifest: 'agentic/code/addons/rlm/manifest.json', artifacts: [] }, { id: 'ring', label: 'Ring Methodology', manifest: 'agentic/code/addons/ring-methodology/manifest.json', artifacts: [] }, ]; for (const addon of addonChecks) { const manifestPath = path.join(AIWG_ROOT, addon.manifest); const hasManifest = await fileExists(manifestPath); if (hasManifest) { // Check key artifacts exist const missing = []; for (const artifact of addon.artifacts) { const artifactPath = path.join(path.dirname(manifestPath), artifact); if (!(await fileExists(artifactPath))) { missing.push(artifact); } } if (missing.length > 0) { check(addon.label, 'warn', `Installed but missing: ${missing.join(', ')}`); } else { try { const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8')); check(addon.label, 'ok', `v${manifest.version || 'unknown'}`); } catch { check(addon.label, 'ok', 'Installed'); } } } // Skip silently if not installed — addons are optional } // 9b. Upstream addon manifest sweep (#1088) // Every directory under agentic/code/addons/ must declare itself via // either manifest.json (canonical) or WIP.md (deferred). Anything else // ships dark — discoverable by `aiwg use <name>` but invisible to the // catalog, registry, and validator. try { const upstreamAddonsDir = path.join(AIWG_ROOT, 'agentic/code/addons'); if (await fileExists(upstreamAddonsDir)) { const entries = await fs.readdir(upstreamAddonsDir, { withFileTypes: true }); const orphaned = []; for (const entry of entries) { if (!entry.isDirectory()) continue; const addonPath = path.join(upstreamAddonsDir, entry.name); const hasManifest = await fileExists(path.join(addonPath, 'manifest.json')); const hasWip = await fileExists(path.join(addonPath, 'WIP.md')); if (!hasManifest && !hasWip) { orphaned.push(entry.name); } } if (orphaned.length > 0) { check( 'Upstream addon manifests', 'warn', `${orphaned.length} addon(s) missing both manifest.json and WIP.md: ${orphaned.join(', ')}. ` + `Each upstream addon must declare itself as deployable (manifest.json) or deferred (WIP.md). See #1088.`, ); } else { check('Upstream addon manifests', 'ok', `${entries.filter(e => e.isDirectory()).length} addons declared`); } } } catch { // Sweep is best-effort; never block doctor on FS exceptions } // 10. Check behaviors (OpenClaw native or Claude emulated) const openclawBehaviors = path.join(os.homedir(), '.openclaw', 'behaviors'); const claudeHooks = path.join(process.cwd(), '.claude', 'hooks'); const hasOpenclawBehaviors = await fileExists(openclawBehaviors); const hasClaudeHooks = await fileExists(claudeHooks); if (hasOpenclawBehaviors) { const entries = await fs.readdir(openclawBehaviors, { withFileTypes: true }); const behaviorCount = entries.filter(e => e.isDirectory()).length; if (behaviorCount > 0) { check('OpenClaw Behaviors', 'ok', `${behaviorCount} behaviors deployed (native)`); } else { check('OpenClaw Behaviors', 'info', 'Behaviors directory exists but empty (run: aiwg use daemon)'); } } else if (hasClaudeHooks) { const entries = await fs.readdir(claudeHooks); const hookCount = entries.filter(f => f.endsWith('.md') || f.endsWith('.json')).length; if (hookCount > 0) { check('Behaviors (Claude)', 'ok', `${hookCount} behavior hooks deployed (emulated)`); } else { check('Behaviors (Claude)', 'info', 'Hooks directory exists but empty'); } } else { check('Behaviors', 'info', 'No behaviors deployed (run: aiwg use daemon)'); } // 11a. Storage config validation (no-op when .aiwg/storage.config absent) try { const projectDir = process.cwd(); const storageCfgPath = path.join(projectDir, '.aiwg', 'storage.config'); if (await fileExists(storageCfgPath)) { try { const raw = await fs.readFile(storageCfgPath, 'utf-8'); const parsed = JSON.parse(raw); // Lazy import the validator from the compiled storage module so we // don't duplicate the credential-walk logic in this script. const { validateStorageConfig } = await import(path.join(AIWG_ROOT, 'dist', 'src', 'storage', 'config.js')); validateStorageConfig(parsed, storageCfgPath); check('Storage Config', 'ok', `Valid: ${storageCfgPath}`); } catch (err) { check('Storage Config', 'error', err.message); } } else { check('Storage Config', 'info', 'No .aiwg/storage.config (using fs defaults)'); } } catch { // Validator import failed (e.g., dist not built in dev). Non-fatal — skip silently. } // 11b. Validate .aiwg/aiwg.config remotes block (#994) // Ensures any declared remote name actually exists in `git remote`. try { const projectDir = process.cwd(); const aiwgCfgPath = path.join(projectDir, '.aiwg', 'aiwg.config'); if (await fileExists(aiwgCfgPath)) { let raw; try { raw = JSON.parse(await fs.readFile(aiwgCfgPath, 'utf-8')); } catch (err) { check('Remotes Config', 'error', `Failed to parse .aiwg/aiwg.config: ${err.message}`); raw = null; } if (raw && raw.remotes) { // Collect actual git remote names; tolerate non-git directories. let gitRemotes = []; try { gitRemotes = execSync('git remote', { cwd: projectDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }) .trim() .split('\n') .filter(Boolean); } catch { // Not a git repo — skip silently } if (gitRemotes.length > 0) { const declared = []; if (raw.remotes.primary) declared.push({ field: 'primary', name: raw.remotes.primary }); if (raw.remotes.issue_tracker) declared.push({ field: 'issue_tracker', name: raw.remotes.issue_tracker }); if (raw.remotes.ci) declared.push({ field: 'ci', name: raw.remotes.ci }); for (const sec of raw.remotes.secondary || []) { if (sec && sec.name) declared.push({ field: `secondary.${sec.name}`, name: sec.name }); } const missing = declared.filter(d => !gitRemotes.includes(d.name)); if (missing.length === 0) { const primary = raw.remotes.primary || 'origin'; check('Remotes Config', 'ok', `primary=${primary} (${declared.length} declared, all present)`); } else { const list = missing.map(m => `${m.field}=${m.name}`).join(', '); check( 'Remotes Config', 'warn', `Declared remote(s) missing from git: ${list}. Available: ${gitRemotes.join(', ')}`, ); } } } } } catch { // Non-fatal — skip silently } // 11c. Validate .aiwg/aiwg.config delivery block (#995) // Sanity-check the resolved delivery policy against actual repo state. try { const projectDir = process.cwd(); const aiwgCfgPath = path.join(projectDir, '.aiwg', 'aiwg.config'); if (await fileExists(aiwgCfgPath)) { let raw; try { raw = JSON.parse(await fs.readFile(aiwgCfgPath, 'utf-8')); } catch { raw = null; } if (raw && raw.delivery) { const d = raw.delivery; const issues = []; // mode validation const validModes = ['direct', 'feature-branch', 'pr-required']; if (d.mode && !validModes.includes(d.mode)) { issues.push(`mode=${d.mode} (must be one of ${validModes.join(', ')})`); } // merge_style validation const validMergeStyles = ['rebase-merge', 'squash', 'merge', 'fast-forward-only']; if (d.merge_style && !validMergeStyles.includes(d.merge_style)) { issues.push(`merge_style=${d.merge_style} (must be one of ${validMergeStyles.join(', ')})`); } // force_push_policy validation const validForcePush = ['never', 'own-branch-only', 'allowed']; if (d.force_push_policy && !validForcePush.includes(d.force_push_policy)) { issues.push(`force_push_policy=${d.force_push_policy} (must be one of ${validForcePush.join(', ')})`); } // default_branch existence — best effort, only when in a git repo const defaultBranch = d.default_branch || 'main'; try { execSync(`git -C ${JSON.stringify(projectDir)} rev-parse --verify --quiet ${JSON.stringify(defaultBranch)}`, { stdio: 'pipe', }); } catch { // Branch may not exist locally on a fresh clone; downgrade to info, not error issues.push(`default_branch '${defaultBranch}' not found locally (may be remote-only — this is informational)`); } if (issues.length === 0) { const mode = d.mode || 'pr-required'; const merge = d.merge_style || 'rebase-merge'; check('Delivery Policy', 'ok', `mode=${mode} merge=${merge} default_branch=${defaultBranch}`); } else { check('Delivery Policy', 'warn', issues.join('; ')); } } // 11d. Validate .aiwg/aiwg.config parallelism block (#1359) // Provider-scoped parallelism caps for rate-limit awareness. if (raw && raw.parallelism) { const p = raw.parallelism; const issues = []; const checkRange = (field, min, max) => { if (p[field] !== undefined) { const n = p[field]; if (!Number.isInteger(n) || n < min || n > max) { issues.push(`${field}=${n} (must be integer ${min}-${max})`); } } }; checkRange('max_parallel_subagents', 1, 50); checkRange('max_parallel_ralph_loops', 1, 20); checkRange('max_parallel_mc_missions', 1, 20); // Detect operator override vs provider default const primary = Array.isArray(raw.providers) ? raw.providers[0] : undefined; const PROVIDER_DEFAULTS = { claude: { max_parallel_subagents: 4 }, codex: { max_parallel_subagents: 10 }, copilot: { max_parallel_subagents: 10 }, cursor: { max_parallel_subagents: 10 }, factory: { max_parallel_subagents: 10 }, opencode: { max_parallel_subagents: 10 }, warp: { max_parallel_subagents: 10 }, windsurf: { max_parallel_subagents: 10 }, openclaw: { max_parallel_subagents: 10 }, hermes: { max_parallel_subagents: 10 }, }; const expectedDefault = PROVIDER_DEFAULTS[primary]?.max_parallel_subagents ?? 4; const isOverride = p.max_parallel_subagents !== undefined && p.max_parallel_subagents !== expectedDefault; if (issues.length === 0) { const subs = p.max_parallel_subagents ?? expectedDefault; const label = isOverride ? `max_parallel_subagents=${subs} (operator override; provider default for ${primary || 'unknown'} = ${expectedDefault})` : `max_parallel_subagents=${subs} (provider default for ${primary || 'unknown'})`; check('Parallelism Cap', 'ok', label); } else { check('Parallelism Cap', 'warn', issues.join('; ')); } } else if (raw) { // No parallelism block — agents will fall back to resolveParallelism() // defaults, but visibility is reduced. Hint at the right command. check( 'Parallelism Cap', 'info', 'no parallelism block — agents fall back to provider defaults; run "aiwg config set --project parallelism.max_parallel_subagents N" to make it explicit', ); } } } catch { // Non-fatal — skip silently } // 11. Check .gitignore for AIWG runtime patterns (warning if missing) const AIWG_RUNTIME_PATTERNS = ['.aiwg/working/', '.aiwg/ralph/', '.aiwg/ralph-external/']; const gitignorePath = path.join(process.cwd(), '.gitignore'); try { const gitignoreContent = await fs.readFile(gitignorePath, 'utf-8'); const lines = gitignoreContent.split('\n').map(l => l.trim()); const isCovered = (pattern) => { if (lines.includes(pattern)) return true; if (lines.includes(pattern.replace(/\/$/, ''))) return true; const parts = pattern.split('/').filter(Boolean); for (let i = 1; i < parts.length; i++) { const parent = parts.slice(0, i).join('/') + '/'; if (lines.includes(parent) || lines.includes(parent.replace(/\/$/, ''))) return true; } return false; }; const missing = AIWG_RUNTIME_PATTERNS.filter(p => !isCovered(p)); if (missing.length === 0) { check('.gitignore', 'ok', 'AIWG runtime paths covered'); } else { check('.gitignore', 'warn', `Missing AIWG runtime patterns: ${missing.join(', ')} — run "aiwg config gitignore --fix"`); } } catch { // No .gitignore or unreadable — skip silently } // Print results console.log(''); const statusSymbols = { ok: '✓', warn: '⚠', error: '✗', info: '○' }; const colorFns = { ok: isTTY ? chalk.green : (s) => s, warn: isTTY ? chalk.yellow : (s) => s, error: isTTY ? chalk.red : (s) => s, info: isTTY ? chalk.cyan : (s) => s }; for (const { name, status, message } of checks) { const symbol = statusSymbols[status]; const colorFn = colorFns[status] || ((s) => s); console.log(` ${colorFn(symbol)} ${name}: ${message}`); } // Summary const pass = checks.filter(c => c.status === 'ok').length; const errors = checks.filter(c => c.status === 'error').length; const warnings = checks.filter(c => c.status === 'warn').length; console.log(rule); console.log(''); if (errors > 0) { const msg = `${errors} error(s), ${warnings} warning(s), ${pass} passed`; console.log(isTTY ? chalk.red(` ✗ ${msg}`) : ` FAIL ${msg}`); console.log(''); process.exit(1); } else if (warnings > 0) { const msg = `${warnings} warning(s), ${pass} passed`; console.log(isTTY ? chalk.yellow(` ⚠ ${msg}`) : ` WARN ${msg}`); } else { console.log(isTTY ? chalk.green(` ✓ All ${pass} checks passed`) : ` OK All ${pass} checks passed`); } console.log(''); } runDoctor().catch(error => { console.error('Doctor failed:', error.message); process.exit(1); });