/** * Engram — Update Manifest State Machine * ======================================= * * Manages .engram-update.jsonc — the per-category diff that powers /engram-update. * Generated by writeUpdateManifest() on version bump; consumed by the pseudo-command * template for interactive auto/manual/per-file update flows. * * diffCategory compares files by CONTENT (byte-level via Buffer.equals), not just * existence. Only files that actually differ between source and destination appear * as "skipped" — identical files and newly added files are silently ignored. * * writeUpdateManifest only writes a manifest when at least one file was genuinely * modified (skipped.length > 0 for some category). No diff → no manifest → no * pseudo-command → no notification. * * State machine: pending → in_progress → (file deleted on completion) * Categories: skills, agents, scripts, commands, gold, experiments, docs — * each with { added, skipped } arrays. * * Manual mode saves per-file checkpoints by removing processed files from skipped[] * via node -e (see pseudo-command template in index.ts). */ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync, copyFileSync, lstatSync, renameSync } from "node:fs" import { resolve, relative } from "node:path" import { parseFrontmatter } from "./parse-frontmatter.js" import { writeUpdateDiff } from "./diff.js" const AGENTS_DIR = "agents" /** * Every category this manifest can put in skipped[] must have a restore path * after auto mode deletes the file: "commands" is regenerated by * generateCommands, and everything else re-extracts via install.ts DIRS — * which is DERIVED from this list, so a category can never be added here * without extraction knowing how to put its files back (bug class #2). * gold/experiments/docs joined in v1.13.2 (issue #20): copyMissing never * overwrites, so this manifest is the only path by which a changed bundled * file can ever reach an existing install. */ export const MANIFEST_CATEGORIES = ["skills", "agents", "scripts", "commands", "gold", "experiments", "docs"] /** * Categories copied and diffed top-level only. docs/ subdirectories * (release-audits/, user-sessions/) are internal process records: no skill * cites them, and the AGENTS.md reference describes docs/ as "foundations, * architecture, roadmap". The set applies to BOTH the extraction copy and * the manifest walk — if the two disagreed, every bump would manifest files * that extraction never places (or vice versa, orphan extracted files). */ export const SHALLOW_CATEGORIES = new Set(["docs"]) /** Tracks files per category: added (new on disk), skipped (preserved, needs user decision). */ export interface DiffEntry { added: string[] skipped: string[] } /** Full update manifest. remaining tracks unprocessed categories; applied tracks completed. */ export interface Manifest { from: string to: string source: string categories: Record state: "pending" | "in_progress" applied: string[] remaining: string[] } /** Walks a directory, returning relative paths from base. Returns [] if dir * absent. shallow=true lists top-level files only (SHALLOW_CATEGORIES). */ function walkFiles(dir: string, base: string, shallow = false): string[] { const files: string[] = [] if (!existsSync(dir)) return files for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = resolve(dir, entry.name) const rel = relative(base, full) if (entry.isDirectory()) { if (!shallow) files.push(...walkFiles(full, base)) } else { files.push(rel) } } return files } /** * Byte-level comparison via Buffer.equals. Returns false if either path is * unreadable or absent. * * Normalization of line endings is intentionally NOT performed here — * that is handled by diffLines when generating the unified diff view. * Consequence: a file differing only in CRLF vs LF is flagged as modified * by contentsMatch (different bytes) but produces no unified diff entry * (diffLines normalizes away the difference). * The STEP 4e template guard covers the edge case of a missing .diff file. */ function contentsMatch(a: string, b: string): boolean { try { return readFileSync(a).equals(readFileSync(b)) } catch { return false } } /** * Compares source vs destination files per category by content (not just * existence). Identical files are silently ignored. * * Agents are compared through the SAME transform extraction applies * (mode: subagent, hidden: true, tools string → map): the extracted copy can * never byte-match the packaged source, so a raw compare manifested all * three agents as "preserved, needs user decision" on EVERY version bump — * even releases that changed no agent — and the rendered diff presented the * extraction transform played backwards ("v X.Y.Z removes mode: subagent"). */ function diffCategory(packageRoot: string, target: string, category: string): DiffEntry { const srcDir = resolve(packageRoot, category) const srcFiles = walkFiles(srcDir, packageRoot, SHALLOW_CATEGORIES.has(category)) const added: string[] = [] const skipped: string[] = [] for (const f of srcFiles) { const destRel = resolve(target, f) if (!existsSync(destRel)) { added.push(f) } else if (!sourceMatchesDest(category, resolve(packageRoot, f), destRel)) { skipped.push(f) } } return { added, skipped } } /** Category-aware content compare: agents/*.md through the extraction * transform, everything else byte-level. */ function sourceMatchesDest(category: string, srcPath: string, destPath: string): boolean { if (category === AGENTS_DIR && srcPath.endsWith(".md")) { try { return transformAgentContent(readFileSync(srcPath, "utf-8")) === readFileSync(destPath, "utf-8") } catch { return false } } return contentsMatch(srcPath, destPath) } /** Rewrites an agent markdown file in-place to OpenCode YAML format (mode: subagent, hidden: true). */ function transformAgentAt(filePath: string) { const content = readFileSync(filePath, "utf-8") const next = transformAgentContent(content) if (next !== content) writeFileSync(filePath, next) } /** Pure form of the agent transform — shared by transformAgentAt and the * transform-aware compare in diffCategory. Must stay behaviourally identical * to install.ts/transformAgentForOpenCode. */ export function transformAgentContent(content: string): string { const { attrs, body } = parseFrontmatter(content) // Same skip as install.ts/transformAgentForOpenCode: re-transforming an // already-transformed file strips the nested tools: map. if (attrs.mode === "subagent") return content const newAttrs: Record = {} if (attrs.name) newAttrs.name = attrs.name if (attrs.description) newAttrs.description = attrs.description newAttrs.mode = "subagent" newAttrs.hidden = true if (attrs.tools && typeof attrs.tools === "string") { const toolObj: Record = {} for (const tool of attrs.tools.split(",")) { const trimmed = tool.trim() if (trimmed) toolObj[trimmed] = true } if (Object.keys(toolObj).length > 0) newAttrs.tools = toolObj } const yamlLines: string[] = [] for (const [k, v] of Object.entries(newAttrs)) { if (v == null) continue if (typeof v === "object") { yamlLines.push(`${k}:`) for (const [kk, vv] of Object.entries(v)) yamlLines.push(` ${kk}: ${vv}`) } else { yamlLines.push(`${k}: ${v}`) } } return `---\n${yamlLines.join("\n")}\n---\n\n${body.trimEnd()}\n` } /** Generates .engram-update.jsonc on version bump. Only writes when at least one file was actually modified (skipped.length > 0). */ export function writeUpdateManifest(packageRoot: string, target: string, prevVersion: string, version: string) { const categories: Record = {} const remaining: string[] = [] for (const cat of MANIFEST_CATEGORIES) { categories[cat] = diffCategory(packageRoot, target, cat) if (categories[cat].skipped.length) remaining.push(cat) } if (remaining.length === 0) return const manifest: Manifest = { from: prevVersion, to: version, source: packageRoot, categories, state: "pending", applied: [], remaining, } atomicWrite(resolve(target, ".engram-update.jsonc"), JSON.stringify(manifest, null, 2)) writeUpdateDiff(packageRoot, target, categories, version, (file, content) => file.startsWith(AGENTS_DIR + "/") && file.endsWith(".md") ? transformAgentContent(content) : content, ) } /** Persists manifest to disk. Used by per-file checkpointing (node -e in the template). */ export function saveManifest(target: string, manifest: Manifest) { atomicWrite(resolve(target, ".engram-update.jsonc"), JSON.stringify(manifest, null, 2)) } /** The manifest is the update system's only state; a torn write used to * strand it in the (formerly unrecoverable) corrupt path. Same tmp+rename * the version file got in v1.12.0. */ function atomicWrite(path: string, content: string) { writeFileSync(path + ".tmp", content) renameSync(path + ".tmp", path) } /** True when .engram-update.jsonc exists (used to register /engram-update pseudo-command). */ export function hasPendingUpdate(target: string): boolean { return existsSync(resolve(target, ".engram-update.jsonc")) } /** Reads and parses .engram-update.jsonc. Returns null if absent or corrupt JSON. Normalises old `command` key to `commands`. */ export function readManifest(target: string): Manifest | null { const f = resolve(target, ".engram-update.jsonc") if (!existsSync(f)) return null try { const m = JSON.parse(readFileSync(f, "utf-8")) as Manifest const fix = (arr: string[]) => arr.map(k => k === "command" ? "commands" : k) if (m.categories.command && !m.categories.commands) { m.categories.commands = m.categories.command delete m.categories.command } if (m.remaining) m.remaining = fix(m.remaining) if (m.applied) m.applied = fix(m.applied) return m } catch { return null } } /** Human-readable summary for notifications: "Engram X → Y. N skills added, M agents preserved…". Returns null if no manifest. */ export function getUpdateSummary(target: string): string | null { const m = readManifest(target) if (!m) return null const lines: string[] = [`Engram ${m.from} → ${m.to}.`] for (const [cat, diff] of Object.entries(m.categories)) { const parts: string[] = [] if (diff.added.length) parts.push(`${diff.added.length} ${cat} added`) if (diff.skipped.length) parts.push(`${diff.skipped.length} ${cat} preserved`) if (parts.length) lines.push(` ${parts.join(", ")}.`) } lines.push("Run /engram-update to pick what to refresh: auto (all) or manual (per category).") return lines.join("\n") } export function applyUpdate(target: string, category: string, sources: string[]) { const m = readManifest(target) if (!m) return 0 let count = 0 for (const relPath of sources) { if (!relPath.startsWith(category + "/")) continue const src = resolve(m.source, relPath) const dest = resolve(target, relPath) if (!existsSync(src)) continue // Same guard as selfExtract's transform loop: copyFileSync and // writeFileSync both follow symlinks, and a symlinked dest points at a // file that is not ours to overwrite. try { if (lstatSync(dest).isSymbolicLink()) continue } catch {} mkdirSync(resolve(dest, ".."), { recursive: true }) copyFileSync(src, dest) if (category === AGENTS_DIR) transformAgentAt(dest) count++ } return count } /** Deletes .engram-update.jsonc. Called when update is resolved (auto/keep-as-is/cleanup). */ export function clearUpdate(target: string) { const f = resolve(target, ".engram-update.jsonc") if (existsSync(f)) unlinkSync(f) }