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

417 lines 17.4 kB
/** * AIWG.md and AGENTS.md generator. * * Per ADR-1: this module assembles the two project-root context files at the end of * `aiwg use`. AIWG.md is CLAUDE.md-shaped framework context for non-Claude providers; * AGENTS.md is a link-indexed bridge that points to AIWG.md and to deployed artifact * files. See `.aiwg/architecture/adr-agents-md-aggregation.md` for the full spec. * * This file contains the public generator API. Callers are expected to have already * deployed artifacts and to pass pre-aggregated `AgentsMdSection[]` describing what * was deployed. The generator does not walk the filesystem to discover artifacts; * that's the caller's responsibility (per ADR-1 §7 generator-runs-after-deploy * invariant — the caller knows what landed). */ import { promises as fs } from 'node:fs'; import * as path from 'node:path'; import { sanitizeDescription, sanitizeTags } from './sanitizer.js'; import { checkPathAllowed } from './allowlist.js'; import { generateAiwgMd } from './aiwg-md.js'; import { injectSpilloverBlock } from './overflow.js'; import { buildParallelismSection } from './parallelism-section.js'; import { buildContextFinalizationBlock, writeNormalizedAiwgMd } from './finalization.js'; const SECTION_TITLES = { agents: 'Agents', rules: 'Rules', skills: 'Skills', behaviors: 'Behaviors', }; const AIWG_SIGNATURE_COMMENT = '<!-- aiwg-managed -->'; /** * Render a single link-index entry as markdown. * * Returns null if the entry is rejected by sanitizer or allowlist; the caller can * accumulate warnings about dropped entries. */ export function renderEntry(entry) { const desc = sanitizeDescription(entry.description); if (!desc.ok) { return { markdown: null, warning: `dropped entry '${entry.id}': description rejected (${desc.rejectedFor})`, }; } const allowed = checkPathAllowed(entry.path); if (!allowed.ok) { return { markdown: null, warning: `dropped entry '${entry.id}': path rejected (${allowed.rejectedFor})`, }; } let suffix = ''; if (entry.safetyCritical) { suffix = entry.shadowedBy ? ` *(SAFETY-CRITICAL, SHADOWED → ${entry.shadowedBy})*` : ' *(SAFETY-CRITICAL)*'; } const tagPart = entry.tags && entry.tags.length > 0 ? `\n - Tags: ${sanitizeTags(entry.tags).kept.join(', ')}` : ''; const userScopeMarker = allowed.isUserScope ? '\n <!-- user-scope; loader may not auto-resolve -->' : ''; const markdown = `- **${entry.id}**${suffix}${desc.value}\n - Path: \`${entry.path}\`${tagPart}${userScopeMarker}`; return { markdown, warning: '' }; } /** * Render one section (e.g. ## Agents) of an AGENTS.md. */ export function renderSection(section) { const title = SECTION_TITLES[section.type]; const warnings = []; const lines = []; for (const entry of section.entries) { const { markdown, warning } = renderEntry(entry); if (markdown) { lines.push(markdown); } else if (warning) { warnings.push(warning); } } if (lines.length === 0) { // Empty sections are omitted per ADR-1 §1 return { markdown: '', warnings }; } return { markdown: `## ${title}\n\n${lines.join('\n\n')}\n`, warnings, }; } /** * Build the full AGENTS.md content (in-memory; does not write). * * Per operator direction (#1239): AGENTS.md is a thin pointer to AIWG.md at * project root. AIWG.md mirrors CLAUDE.md (ADR-1 §0.5) and contains the full * framework guidance — including the kernel-skill discovery pattern * (`aiwg discover` / `aiwg show`). Inlining a full link-index here pushed the * file past Codex's 32KB cap on real deploys, triggered auto-split, and ran * every artifact description through the sanitizer (rejecting any with * backticks). The thin-pointer body sidesteps all of that: zero warnings on * any provider, regardless of how many artifacts are deployed. * * `opts.sections` is accepted for backwards compatibility with callers that * still aggregate a link-index (no internal use). `splitOccurred` is always * false; `spilloverContent` is always empty. The `partitionForOverflow` / * spillover utilities remain exported for any external consumer. */ export async function buildAgentsMd(opts) { const warnings = []; const parts = []; parts.push('# AGENTS.md'); parts.push(`${AIWG_SIGNATURE_COMMENT}`); parts.push('<!-- Generated by AIWG. Edit AGENTS.override.md for operator additions. -->'); parts.push(''); parts.push('## Framework Context'); parts.push(''); parts.push('See [AIWG.md](./AIWG.md) for the full AIWG framework context'); parts.push('(active frameworks, addons, agents, behaviors, rules).'); parts.push(''); parts.push('Deployed artifacts live under your provider\'s native directory'); parts.push('(for example `.codex/agents/`, `.warp/agents/`, `.github/agents/`).'); parts.push('Use `aiwg discover "<intent>"` and `aiwg show <type> <name>` to browse'); parts.push('skills, agents, rules, and commands across the installation.'); parts.push(''); parts.push(await buildContextFinalizationBlock(opts.projectPath)); if (opts.projectContext && opts.projectContext.trim().length > 0) { const desc = sanitizeDescription(opts.projectContext.slice(0, 1000)); if (desc.ok) { parts.push('## Project Context'); parts.push(''); parts.push(desc.value); parts.push(''); } else { warnings.push(`dropped Project Context section: ${desc.rejectedFor}`); } } // #1362: surface the parallelism cap when one is configured. Injected // before the divider so agents see it as part of the framework context, // not as a trailing afterthought. const parallelismSection = await buildParallelismSection(opts.projectPath); if (parallelismSection) { parts.push(parallelismSection); } parts.push('---'); parts.push(''); parts.push('*See `AGENTS.override.md` for operator-authored additions.*'); parts.push(''); return { content: parts.join('\n'), warnings, spilloverContent: '', splitOccurred: false }; } /** * Detect whether a pre-existing file at `filePath` carries the AIWG signature. * * Returns `true` if the file is missing or is AIWG-managed (safe to overwrite). * Returns `false` if the file exists and does not carry the signature comment * (operator-claimed). */ export async function isOverwriteSafe(filePath) { try { const content = await fs.readFile(filePath, 'utf8'); // First 4 lines: canonical signature lookup. const head = content.split('\n').slice(0, 4).join('\n'); if (head.includes(AIWG_SIGNATURE_COMMENT)) return true; if (/Generated by\s+(AIWG|aiwg)/i.test(head)) return true; // Backwards-compatibility scan: pre-ADR-1 emitters wrote AGENTS.md / // WARP.md / .hermes.md without the canonical signature. Those files // typically include AIWG-specific markers somewhere in the first ~50 // lines. Detect them so existing repos upgrade cleanly without // requiring --force. const headExtended = content.split('\n').slice(0, 50).join('\n'); const legacyMarkers = [ /AIWG\s+SDLC\s+(Framework|framework)/i, /AIWG\s+(auto-updated|managed)/i, /AIWG\s+Integration/i, /<!--\s*AIWG\s+/i, /aiwg\s+use\s+sdlc/i, ]; return legacyMarkers.some((re) => re.test(headExtended)); } catch (err) { if (err.code === 'ENOENT') { return true; } throw err; } } /** * Atomically write a file via tmpfile + rename. * * Per ADR-4 §4 atomic write requirement; on failure no partial state is persisted. */ async function atomicWrite(filePath, content) { const dir = path.dirname(filePath); const tmp = path.join(dir, `.${path.basename(filePath)}.tmp.${process.pid}`); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(tmp, content, 'utf8'); try { await fs.rename(tmp, filePath); } catch (err) { // Best-effort cleanup of tmpfile on rename failure. await fs.unlink(tmp).catch(() => undefined); throw err; } } /** * Per-provider twin-file names for AGENTS.md emission per ADR-1 §4. * * Hermes scans `.hermes.md` first (priority 1) and falls back to AGENTS.md. * Warp prefers AGENTS.md but tooling expects `WARP.md` to exist. Both are * written with the same content as AGENTS.md, except Hermes — see * `applyTwinSuffix` below for the Hermes-specific MCP guidance suffix * (#1242). */ const TWIN_FILES_BY_PROVIDER = { copilot: ['.github/copilot-instructions.md'], hermes: ['.hermes.md'], warp: ['WARP.md'], }; /** * Hermes-specific suffix appended to `.hermes.md` (#1242). * * The cross-platform AGENTS.md thin pointer (#1239) says *"See [AIWG.md] for * the full AIWG framework context."* From inside a Hermes turn that link is * a dead end — Hermes auto-loads AGENTS.md and CLAUDE.md but not AIWG.md. * The twin file gets a Hermes-correct alternative path so a model running * inside Hermes knows to call `artifact-read` over MCP rather than chase the * AIWG.md link. * * Suffix is ~390 chars (~95 tokens) — keeps the .hermes.md twin total under * Hermes's <1,000-char turn-budget target on top of the 579-byte primary. */ const HERMES_TWIN_SUFFIX = [ '', '## For Hermes Sessions', '', 'From inside a Hermes turn, the AIWG.md link above is a CLI-side reference — Hermes does', 'not auto-load AIWG.md. To browse AIWG capabilities live, use one of:', '', '- `artifact-read AIWG.md` via the AIWG MCP server (loads on demand, costs tokens once).', '- `aiwg discover "<intent>"` and `aiwg show <type> <name>` from the CLI sidecar.', '- `delegate_task` to the AIWG-orchestrate skill for structured artifact workflows.', '', ].join('\n'); /** * Apply provider-specific transformations to twin-file content. * * Currently used only for Hermes (#1242). Extend by adding more * provider-keyed branches as new twin formats need divergence. */ function applyTwinSuffix(provider, baseContent) { if (provider === 'hermes') { return `${baseContent}${HERMES_TWIN_SUFFIX}`; } return baseContent; } /** * Write per-provider twin files (.hermes.md, WARP.md). Twin content is the * AGENTS.md body plus any provider-specific suffix from `applyTwinSuffix`. * Operator-claimed twin files are not overwritten unless --force is set, * matching the AGENTS.md guard. */ async function writeTwinFiles(provider, projectPath, agentsMdContent, opts, result) { const twinNames = TWIN_FILES_BY_PROVIDER[provider]; if (!twinNames || twinNames.length === 0) return; const twinContent = applyTwinSuffix(provider, agentsMdContent); for (const twinName of twinNames) { const twinPath = path.join(projectPath, twinName); if (opts.detectExistingFiles && !opts.force) { const safe = await isOverwriteSafe(twinPath); if (!safe) { result.warnings.push(`${twinName} exists and is not AIWG-managed; not overwritten. Pass --force to back up and replace.`); continue; } } else if (opts.force) { try { const original = await fs.readFile(twinPath, 'utf8'); if (!original.includes(AIWG_SIGNATURE_COMMENT) && !/Generated by\s+(AIWG|aiwg)/i.test(original.split('\n').slice(0, 4).join('\n'))) { const backupPath = `${twinPath}.bak.${new Date().toISOString().replace(/[:.]/g, '-')}`; await fs.writeFile(backupPath, original, 'utf8'); result.backupPaths.push(backupPath); } } catch (err) { if (err.code !== 'ENOENT') throw err; } } await atomicWrite(twinPath, twinContent); result.twinPaths.push(twinPath); } } /** * Inject a spillover block into AGENTS.override.md, preserving operator-authored * content outside the block. Per ADR-1 §5: shared file partitioning with the * `<!-- spillover-from-AGENTS.md:START/END -->` markers. Generator only writes * inside that block. */ async function writeSpilloverBlock(projectPath, spilloverMarkdown, result) { const overridePath = path.join(projectPath, 'AGENTS.override.md'); let existing = ''; try { existing = await fs.readFile(overridePath, 'utf8'); } catch (err) { if (err.code !== 'ENOENT') { throw err; } // Create AGENTS.override.md with a header explaining the partition. existing = [ '# AGENTS.override.md', '', '<!-- Operator-authored additions go here. The block below is AIWG-managed spillover; -->', '<!-- do not edit between the spillover markers. Content outside is preserved across runs. -->', '', ].join('\n'); } const updated = injectSpilloverBlock(existing, spilloverMarkdown); await atomicWrite(overridePath, updated); result.warnings.push(`spillover written to AGENTS.override.md (${Buffer.byteLength(spilloverMarkdown, 'utf8')}b)`); } /** * Generate AIWG.md and AGENTS.md at the project root. * * Skeleton implementation: emits AGENTS.md with link-index sections; AIWG.md * generation is wired in commit 3 alongside the first issue closure (#1104). * For now AIWG.md emission delegates to a copy of CLAUDE.md when the source * is present; otherwise it is skipped with a warning. */ export async function generate(opts) { const result = { aiwgMdPath: '', normalizedAiwgMdPath: '', agentsMdPath: '', twinPaths: [], backupPaths: [], agentsMdBytes: 0, warnings: [], }; const normalizedAiwgPath = await writeNormalizedAiwgMd(opts.projectPath); result.normalizedAiwgMdPath = normalizedAiwgPath; if (!opts.skip?.agentsMd) { const agentsMdPath = path.join(opts.projectPath, 'AGENTS.md'); let canWrite = true; if (opts.detectExistingFiles && !opts.force) { canWrite = await isOverwriteSafe(agentsMdPath); if (!canWrite) { result.warnings.push(`AGENTS.md exists and is not AIWG-managed; not overwritten. Pass --force to back up and replace.`); } } else if (opts.force) { // Backup before overwrite per ADR-1 §5 R1 mitigation. try { const original = await fs.readFile(agentsMdPath, 'utf8'); if (!original.includes(AIWG_SIGNATURE_COMMENT)) { const backupPath = `${agentsMdPath}.bak.${new Date().toISOString().replace(/[:.]/g, '-')}`; await fs.writeFile(backupPath, original, 'utf8'); result.backupPaths.push(backupPath); } } catch (err) { if (err.code !== 'ENOENT') { throw err; } } } if (canWrite) { const built = await buildAgentsMd(opts); await atomicWrite(agentsMdPath, built.content); result.agentsMdPath = agentsMdPath; result.agentsMdBytes = Buffer.byteLength(built.content, 'utf8'); result.warnings.push(...built.warnings); if (built.splitOccurred) { await writeSpilloverBlock(opts.projectPath, built.spilloverContent, result); } // Per-provider twin-file emission per ADR-1 §4 (Hermes .hermes.md, Warp WARP.md). await writeTwinFiles(opts.provider, opts.projectPath, built.content, opts, result); } } if (!opts.skip?.aiwgMd) { const aiwgMdPath = path.join(opts.projectPath, 'AIWG.md'); let canWrite = true; if (opts.detectExistingFiles && !opts.force) { canWrite = await isOverwriteSafe(aiwgMdPath); if (!canWrite) { result.warnings.push('AIWG.md exists and is not AIWG-managed; not overwritten. Pass --force to back up and replace.'); } } else if (opts.force) { // Backup before overwrite per ADR-1 §5 R1 mitigation. try { const original = await fs.readFile(aiwgMdPath, 'utf8'); if (!original.includes(AIWG_SIGNATURE_COMMENT)) { const backupPath = `${aiwgMdPath}.bak.${new Date().toISOString().replace(/[:.]/g, '-')}`; await fs.writeFile(backupPath, original, 'utf8'); result.backupPaths.push(backupPath); } } catch (err) { if (err.code !== 'ENOENT') { throw err; } } } if (canWrite) { const content = await generateAiwgMd(opts.projectPath); await atomicWrite(aiwgMdPath, content); result.aiwgMdPath = aiwgMdPath; } } return result; } //# sourceMappingURL=generator.js.map