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
255 lines • 11.1 kB
JavaScript
/**
* Project-Local Doctor Section
*
* Builds the "Project-local artifacts" section for `aiwg doctor` output
* per the spec at @.aiwg/architecture/design-doctor-log-promote.md (#1049).
*
* Pure function over (projectDir, frameworkRoot) — returns a string. The
* doctor handler in src/cli/handlers/utilities.ts is responsible for
* printing it. Section is fully suppressed when no project-local dirs
* are present.
*
* @design @.aiwg/architecture/design-doctor-log-promote.md
* @implements #1037
*/
import { resolve } from 'path';
import { homedir } from 'os';
import { discoverProjectLocalBundles } from './project-local-discovery.js';
import { buildUpstreamRegistry } from './upstream-registry.js';
import { resolveShadows } from './shadow-resolver.js';
import { checkBundleManifestIgnored } from './project-local-gitignore.js';
import { sha256OfFileRawAndNormalized } from './managed-marker.js';
/**
* Hash a deployed file in raw and managed-marker-normalized forms. Returns
* null on read errors (e.g., file missing — caller treats as
* deploy-not-present).
*
* Source files are recorded via the same normalization in
* `hashBundleArtifacts()`, so the equivalence relation is symmetric.
*
* @implements #1086
*/
async function hashDeployed(absPath) {
try {
return await sha256OfFileRawAndNormalized(absPath);
}
catch {
return null;
}
}
const TYPES = ['extension', 'addon', 'framework', 'plugin'];
const TYPE_DIR = {
extension: 'extensions',
addon: 'addons',
framework: 'frameworks',
plugin: 'plugins',
};
// Per PUW-026 (#1127): home-deploying providers get absolute prefixes so
// `resolve(projectDir, prefix)` correctly produces the home-rooted path
// (resolve treats absolute paths as authoritative). Previously these were
// `null`, which silently skipped lifecycle operations against home-deployed
// project-local bundles.
const PROVIDER_PREFIX = {
claude: '.claude',
cursor: '.cursor',
factory: '.factory',
opencode: '.opencode',
windsurf: '.windsurf',
warp: '.warp',
codex: '.codex',
copilot: '.github',
openclaw: resolve(homedir(), '.openclaw'),
hermes: resolve(homedir(), '.hermes'),
};
export async function buildProjectLocalDoctorSection(opts) {
const { projectDir, frameworkRoot, config, quiet = false } = opts;
const discovery = await discoverProjectLocalBundles(projectDir);
// No project-local content → no section at all
if (discovery.isEmpty && discovery.errors.length === 0) {
return { output: '', validationErrors: 0, denylistViolations: 0, driftCount: 0, hasFailures: false };
}
const lines = ['', '── Project-local artifacts ────────────────────────────────────'];
// Counts
if (!quiet) {
lines.push(` Discovered: ${discovery.bundles.length} bundle${discovery.bundles.length === 1 ? '' : 's'}`);
for (const t of TYPES) {
const dirName = TYPE_DIR[t];
const ofType = discovery.bundles.filter(b => b.type === t);
if (ofType.length === 0 && discovery.bundles.length > 0)
continue;
const idList = ofType.length > 0 ? ` (${ofType.map(b => b.id).join(', ')})` : '';
lines.push(` ${dirName.padEnd(11)} ${ofType.length}${idList}`);
}
lines.push('');
}
// Validation
const validationErrors = discovery.errors.length;
if (validationErrors === 0) {
if (!quiet)
lines.push(' Validation: ✓ all manifests valid');
}
else {
lines.push(` Validation: ✗ ${validationErrors} error${validationErrors === 1 ? '' : 's'}`);
for (const e of discovery.errors.slice(0, 10)) {
lines.push(` ✗ ${e.path}: ${e.field} — ${e.actual}`);
}
if (validationErrors > 10) {
lines.push(` + ${validationErrors - 10} more (run 'aiwg list --project-local' for full list)`);
}
}
lines.push('');
// Shadows + denylist
let denylistViolations = 0;
if (discovery.bundles.length > 0) {
try {
const upstream = await buildUpstreamRegistry({ frameworkRoot });
const shadowResult = await resolveShadows(discovery.bundles, upstream);
const refusals = shadowResult.resolutions.filter(r => r.verdict === 'refuse-unsafe' || r.verdict === 'refuse-phantom' || r.verdict === 'refuse-duplicate');
denylistViolations = refusals.length;
if (!quiet) {
const informational = shadowResult.shadows.filter(s => s.verdict === 'deploy-with-warning' || s.verdict === 'deploy-acknowledged');
if (informational.length > 0) {
lines.push(` Shadows (${informational.length}):`);
for (const s of informational) {
const marker = s.verdict === 'deploy-acknowledged' ? '!!' : '⚠';
const note = s.verdict === 'deploy-acknowledged' ? ' overrides safety-critical (acknowledged)' : ` overrides ${s.upstream?.source ?? 'upstream'}`;
lines.push(` ${marker} ${s.bundleId} :: ${s.artifactType}/${s.artifactId}${note}`);
}
lines.push('');
}
}
if (refusals.length > 0) {
lines.push(` Denylist violations (${refusals.length}):`);
for (const r of refusals) {
lines.push(` ✗ ${r.bundleId} :: ${r.artifactType}/${r.artifactId} [${r.verdict}]`);
}
lines.push('');
}
else if (!quiet) {
lines.push(' Denylist violations: 0');
lines.push('');
}
}
catch {
// Shadow resolution failure is non-fatal for doctor
}
}
// Drift detection (requires config and artifactHashes)
let driftCount = 0;
const driftLines = [];
let unhashedSeen = false;
if (config) {
for (const bundle of discovery.bundles) {
const entry = config.installed[bundle.id];
if (!entry || entry.source !== 'project-local')
continue;
const hashes = entry.artifactHashes;
if (!hashes) {
unhashedSeen = true;
continue;
}
for (const provider of Object.keys(entry.deployedTo)) {
const prefix = PROVIDER_PREFIX[provider];
if (!prefix)
continue;
for (const [sourceRel, expectedHash] of Object.entries(hashes)) {
const deployedAbs = resolve(projectDir, `${prefix}/${sourceRel}`);
const actualHash = await hashDeployed(deployedAbs);
if (actualHash === null) {
// Missing — not drift, deploy is just absent
continue;
}
if (actualHash.normalized !== expectedHash && actualHash.raw !== expectedHash) {
driftCount++;
driftLines.push(` ✗ ${bundle.id} :: ${sourceRel} @ ${provider} (deployed file differs from source)`);
}
}
}
}
}
if (driftCount > 0) {
lines.push(` Drift (${driftCount}):`);
lines.push(...driftLines);
lines.push('');
}
else if (!quiet) {
lines.push(' Drift: 0');
if (unhashedSeen) {
lines.push(' (some entries lack artifactHashes — re-run `aiwg use <bundle>` to record)');
}
lines.push('');
}
// Provider deployment matrix
if (!quiet && config) {
const projectLocalEntries = Object.entries(config.installed).filter(([, e]) => e.source === 'project-local');
if (projectLocalEntries.length > 0) {
const allProviders = new Set();
for (const [, entry] of projectLocalEntries) {
for (const p of Object.keys(entry.deployedTo))
allProviders.add(p);
}
const provList = [...allProviders].sort();
if (provList.length > 0) {
lines.push(' Provider deployment matrix:');
lines.push(` ${'bundle'.padEnd(20)}${provList.map(p => p.padEnd(8)).join('')}`);
for (const [name, entry] of projectLocalEntries) {
const cells = provList.map(p => {
const c = entry.deployedTo[p];
if (!c)
return '-'.padEnd(8);
const total = c.agents + c.commands + c.skills + c.rules;
return `✓ ${total}`.padEnd(8);
}).join('');
lines.push(` ${name.padEnd(20)}${cells}`);
}
lines.push('');
}
}
}
// #1085 — flag bundles whose source is silently git-ignored. Best-effort
// (uses `git check-ignore`); skipped silently outside git repos.
let gitignoredCount = 0;
if (discovery.bundles.length > 0) {
const ignored = [];
for (const b of discovery.bundles) {
const isIgnored = await checkBundleManifestIgnored(projectDir, b.manifestPath);
if (isIgnored === true)
ignored.push(`${b.type}/${b.id} (${b.manifestPath})`);
}
gitignoredCount = ignored.length;
if (ignored.length > 0) {
lines.push(` Git tracking: ✗ ${ignored.length} bundle${ignored.length === 1 ? '' : 's'} silently ignored`);
for (const i of ignored.slice(0, 5)) {
lines.push(` ✗ ${i}`);
}
if (ignored.length > 5) {
lines.push(` + ${ignored.length - 5} more`);
}
lines.push(' Project-local bundle source should be tracked. Add to .gitignore:');
lines.push(' !.aiwg/addons/');
lines.push(' !.aiwg/extensions/');
lines.push(' !.aiwg/frameworks/');
lines.push(' !.aiwg/plugins/');
lines.push(' Or run `aiwg new-bundle <name>` to have AIWG add this block automatically.');
lines.push('');
}
else if (!quiet) {
lines.push(' Git tracking: ✓ all bundle manifests visible to git');
lines.push('');
}
}
const hasFailures = validationErrors > 0 || denylistViolations > 0 || driftCount > 0 || gitignoredCount > 0;
return {
output: lines.join('\n'),
validationErrors,
denylistViolations,
driftCount,
hasFailures,
};
}
/** Convenience accessor: just the section text. */
export async function projectLocalDoctorSection(projectDir, frameworkRoot, config, quiet = false) {
const r = await buildProjectLocalDoctorSection({ projectDir, frameworkRoot, config, quiet });
return r.output;
}
//# sourceMappingURL=project-local-doctor.js.map