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

212 lines 9.17 kB
/** * AGENTS.md size validation and auto-split (PUW-029 / #1130). * * Per ADR-1 §6: when AGENTS.md content exceeds the 30KB warn threshold or * Codex's 32KB hard cap, auto-split moves entries to the spillover block in * AGENTS.override.md. The cap (32KB) comes from `codex-rs/config_toml.rs:68`. * AGENTS.override.md takes precedence at load time (`agents_md.rs:65`). * * Priority semantics: * - 1 (high): pinned to AGENTS.md; never moves to spillover * - 2 (medium): default; moves second * - 3 (low): moves first * - safety-critical: always pinned to priority 1, regardless of manifest * * If priority-1 alone exceeds 32KB, that is a hard error (per ADR-1 §6 — * operator must split the framework, not silently lose safeguards). */ export const SOFT_WARN_BYTES = 30 * 1024; export const HARD_ERROR_BYTES = 32 * 1024; export const SPILLOVER_START = '<!-- spillover-from-AGENTS.md:START -->'; export const SPILLOVER_END = '<!-- spillover-from-AGENTS.md:END -->'; /** * Resolved priority for one entry — combines safety-critical pinning with * the manifest priority lookup. Safety-critical always wins. */ function resolvePriority(entry, map) { if (entry.safetyCritical) return 1; if (map[entry.id] !== undefined) return map[entry.id]; if (map['*'] !== undefined) return map['*']; return 2; } /** * Errors emitted when the safety-critical floor cannot fit. * * Thrown rather than silently truncating safeguards. Operator must break * the framework into smaller bundles. */ export class SafetyCriticalOverflowError extends Error { bytes; constructor(bytes) { super(`AGENTS.md priority-1 content alone is ${bytes} bytes, which exceeds the ${HARD_ERROR_BYTES}-byte hard cap. ` + `Operator must split the framework or reduce safety-critical pinning. ` + `Per ADR-1 §6, safety-critical content cannot be moved to the spillover block.`); this.bytes = bytes; this.name = 'SafetyCriticalOverflowError'; } } /** * Split an entry list by priority, returning [keep, overflow] arrays where * `keep` stays in the main file and `overflow` goes to the spillover block. * * Strategy: estimate bytes per entry; remove priority-3 first (alphabetical * within tier), then priority-2 (alphabetical within tier), until the keep * total is under the soft-warn threshold (30KB). Priority-1 stays in keep * regardless. Returns the unsplit list when total is already under threshold. */ function splitEntriesByPriority(entries, map, estimateBytes, budgetBytes) { // Annotate each entry with its priority and bytes. const annotated = entries.map((e) => ({ entry: e, priority: resolvePriority(e, map), bytes: estimateBytes(e), })); // Sort: priority asc (1 stays first), then by id alphabetical for determinism. annotated.sort((a, b) => a.priority - b.priority || a.entry.id.localeCompare(b.entry.id)); const keep = []; const overflow = []; let totalKept = 0; for (const a of annotated) { if (a.priority === 1) { keep.push(a.entry); totalKept += a.bytes; } else if (totalKept + a.bytes <= budgetBytes) { keep.push(a.entry); totalKept += a.bytes; } else { overflow.push(a.entry); } } // Re-sort keep array to its natural id order (callers expect alpha). keep.sort((a, b) => a.id.localeCompare(b.id)); overflow.sort((a, b) => a.id.localeCompare(b.id)); return { keep, overflow }; } /** * Estimate the rendered byte size of one entry, including markdown overhead. * Rough but stable: id + description + path + per-entry markdown skeleton. */ function defaultEstimateEntry(entry) { let bytes = 6; // bullet + emphasis markers bytes += entry.id.length; bytes += entry.description.length; bytes += entry.path.length; bytes += 12; // " - Path: `…`" overhead if (entry.tags && entry.tags.length > 0) { bytes += entry.tags.join(', ').length + 12; } if (entry.safetyCritical) bytes += 24; // "(SAFETY-CRITICAL)" + emphasis bytes += 4; // trailing whitespace + newline return bytes; } /** * Partition all sections into "in main" vs "in spillover" entry lists, * preserving safety-critical pinning and respecting the priority map. * * Strategy: each section is split independently using a per-section budget * proportional to the total budget. This is intentionally conservative — * we'd rather overflow a few low-priority entries from each section than * preferentially overflow one whole section. * * Throws SafetyCriticalOverflowError when priority-1 alone exceeds the * hard cap. Otherwise returns the partition. */ export function partitionForOverflow(sections, priorityMap, totalBudgetBytes = SOFT_WARN_BYTES, estimateEntry = defaultEstimateEntry) { // First, compute the priority-1 (pinned) byte total across all sections. // If that alone exceeds the hard cap, throw — operator must intervene. let priorityOneBytes = 0; for (const section of sections) { for (const entry of section.entries) { if (resolvePriority(entry, priorityMap) === 1) { priorityOneBytes += estimateEntry(entry); } } } if (priorityOneBytes > HARD_ERROR_BYTES) { throw new SafetyCriticalOverflowError(priorityOneBytes); } // Compute total budget per section. Allocate proportionally to current // section size so a small section doesn't get an outsized share. const totalBytes = sections.reduce((acc, s) => acc + s.entries.reduce((a, e) => a + estimateEntry(e), 0), 0); if (totalBytes <= totalBudgetBytes) { return { mainSections: sections, spilloverSections: [], estimatedMainBytes: totalBytes, estimatedSpilloverBytes: 0, splitOccurred: false, }; } const mainSections = []; const spilloverSections = []; let mainTotal = 0; let spilloverTotal = 0; for (const section of sections) { const sectionBytes = section.entries.reduce((a, e) => a + estimateEntry(e), 0); // Per-section share: proportional to section's contribution, but never // exceeding the overall budget (and never zero). Earlier versions floored // at 1024 unconditionally — that broke small-budget unit tests and // arguably violated the global cap. The cap is the real invariant. const share = totalBytes > 0 ? Math.min(totalBudgetBytes, Math.max(64, Math.floor((sectionBytes / totalBytes) * totalBudgetBytes))) : totalBudgetBytes; const { keep, overflow } = splitEntriesByPriority(section.entries, priorityMap, estimateEntry, share); if (keep.length > 0) { mainSections.push({ type: section.type, entries: keep }); mainTotal += keep.reduce((a, e) => a + estimateEntry(e), 0); } if (overflow.length > 0) { spilloverSections.push({ type: section.type, entries: overflow }); spilloverTotal += overflow.reduce((a, e) => a + estimateEntry(e), 0); } } return { mainSections, spilloverSections, estimatedMainBytes: mainTotal, estimatedSpilloverBytes: spilloverTotal, splitOccurred: spilloverSections.length > 0, }; } /** * Inject or update the spillover block within an existing AGENTS.override.md * content string. Operator-authored content (everything outside the spillover * markers) is preserved byte-for-byte. * * Returns the updated content. The caller writes this back to disk. */ export function injectSpilloverBlock(existingContent, spilloverMarkdown) { const startIdx = existingContent.indexOf(SPILLOVER_START); const endIdx = existingContent.indexOf(SPILLOVER_END); if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) { // No existing spillover block. Append a new one at the end. const trimmed = existingContent.replace(/\n+$/, ''); const prefix = trimmed.length > 0 ? trimmed + '\n\n' : ''; return `${prefix}${SPILLOVER_START}\n${spilloverMarkdown}\n${SPILLOVER_END}\n`; } // Replace existing block in place. const before = existingContent.slice(0, startIdx); const after = existingContent.slice(endIdx + SPILLOVER_END.length); return `${before}${SPILLOVER_START}\n${spilloverMarkdown}\n${SPILLOVER_END}${after}`; } /** * Extract the operator-authored portion of AGENTS.override.md (everything * outside the spillover block). Used for hash-protection diffs. */ export function extractNonSpillover(content) { const startIdx = content.indexOf(SPILLOVER_START); const endIdx = content.indexOf(SPILLOVER_END); if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) { return content; } const before = content.slice(0, startIdx).replace(/\n+$/, ''); const after = content.slice(endIdx + SPILLOVER_END.length).replace(/^\n+/, ''); return [before, after].filter(Boolean).join('\n\n'); } //# sourceMappingURL=overflow.js.map