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
JavaScript
/**
* 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