#!/usr/bin/env node /** * atlas_archive.ts — sweep action for archiving deprecated atlas pages. * * Core operation: walk live wiki pages, identify atlas-tagged pages whose * source paths no longer exist on disk, and move them to wiki/_archive/. * * CLI: * node atlas_archive.js sweep --wiki-root

--repo-root

--run-id * [--autonomy conservative|balanced|autonomous|auto] * [--dry-run] [--threshold ] * node atlas_archive.js unarchive --wiki-root

--page-or-slug * [--target ] [--inbound-links rewrite|drop|leave] * node atlas_archive.js rebuild-index --wiki-root

(Task 8 stub) */ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import * as yaml from "js-yaml"; import { walkLivePages, type WikiPage } from "../../skills/doc-wiki/scripts/_wiki_fs.js"; import { parseFrontmatter } from "../../skills/doc-wiki/scripts/_frontmatter.js"; import { sourceExistence, type SourceExistenceResult, } from "../../skills/doc-wiki/scripts/atlas_validate.js"; import { parseFlags } from "../../skills/doc-wiki/scripts/_cli_args.js"; import { checkPathContainment } from "narai-primitives/toolkit"; import { parseConfig, ConfigFileNotFoundError, type ArchiveConfig } from "./parse_config.js"; // ── Public types ─────────────────────────────────────────────────────────────── export type Autonomy = "conservative" | "balanced" | "autonomous" | "auto"; export const AUTONOMY_MODES = ["conservative", "balanced", "autonomous", "auto"] as const; export const REWRITE_MODES = ["rewrite", "drop", "leave"] as const; export type RewriteMode = (typeof REWRITE_MODES)[number]; export interface UnarchiveOptions { wikiRoot: string; pageOrSlug: string; // "wiki/_archive/billing/architecture.md" OR "architecture" target?: string; // override the archived_from value inboundLinks: RewriteMode; } export interface UnarchiveResult { from: string; to: string; linksReverted: number; } export interface UnarchiveEvent { ts: string; op: "unarchive"; from: string; // wiki/_archive//.md to: string; // wiki//.md } export interface SweepOptions { wikiRoot: string; repoRoot: string; autonomy: Autonomy; runId: string; dryRun?: boolean; /** Fraction of missing sources to trigger archive. Default: 1.0 (all missing). */ partialThreshold?: number; inboundLinks?: RewriteMode; } export interface ArchiveEvent { ts: string; op: "archive"; atlas_run_id: string; from: string; to: string; reason: string; missing_sources: string[]; } export interface CandidateReport { page: string; ratio: number; missing: string[]; } export interface ErrorReport { page: string; error: string; } export interface PendingArchive { page: string; existence: SourceExistenceResult; } /** An archive candidate surfaced in conservative mode (report-only). * The page was NOT moved; it is included here for visibility. */ export interface ReportedArchive { page: string; status: "orphan" | "candidate"; reason: string; missing_sources: string[]; } export interface SweepResult { archived: ArchiveEvent[]; candidates: CandidateReport[]; errors: ErrorReport[]; pendingConfirmation: PendingArchive[]; /** Populated in conservative mode: pages that would be archived/candidated * if autonomy permitted. Empty in balanced/autonomous/auto modes. */ reported: ReportedArchive[]; } // ── Autonomy decision ────────────────────────────────────────────────────────── type Decision = "auto" | "ask" | "report-only"; function decideAutonomy(autonomy: Autonomy, _kind: "orphan"): Decision { switch (autonomy) { case "conservative": return "report-only"; case "balanced": return "ask"; case "autonomous": case "auto": return "auto"; } } // ── Atlas page filter ───────────────────────────────────────────────────────── interface AtlasPage extends WikiPage { // narrowed: confirmed to have atlas_facet in frontmatter } function filterAtlasPages(pages: WikiPage[]): AtlasPage[] { const out: AtlasPage[] = []; for (const page of pages) { let raw: string; try { raw = fs.readFileSync(page.absPath, "utf-8"); } catch { continue; } const { frontmatter } = parseFrontmatter(raw); if (frontmatter && "atlas_facet" in frontmatter && frontmatter["atlas_facet"] != null) { out.push(page); } } return out; } // ── Archive path computation ─────────────────────────────────────────────────── /** * Map a live page relPath (e.g. "wiki/billing/architecture.md") to the * corresponding archive relPath ("wiki/_archive/billing/architecture.md"). * Input must start with "wiki/". */ function toArchiveRelPath(relPath: string): string { if (!relPath.startsWith("wiki/")) { throw new Error(`unexpected relPath without wiki/ prefix: ${relPath}`); } return "wiki/_archive/" + relPath.slice("wiki/".length); } // ── Frontmatter stamping ─────────────────────────────────────────────────────── /** ISO-8601 date string YYYY-MM-DD from an ISO timestamp. */ function isoDate(isoTs: string): string { return isoTs.slice(0, 10); } /** * Read the file at `absPath`, inject archive frontmatter fields, write it * back in place. Returns the modified content. */ function stampFrontmatter( absPath: string, fields: { archived_from: string; archived_at: string; archive_reason: string }, ): void { const raw = fs.readFileSync(absPath, "utf-8"); const { frontmatter, body } = parseFrontmatter(raw); const fm: Record = frontmatter ? { ...frontmatter } : {}; fm["status"] = "deprecated"; fm["archived_from"] = fields.archived_from; fm["archived_at"] = fields.archived_at; fm["archive_reason"] = fields.archive_reason; const newContent = `---\n${yaml.dump(fm)}---\n${body}`; fs.writeFileSync(absPath, newContent, "utf-8"); } // ── ArchiveEvent builder ─────────────────────────────────────────────────────── function buildEvent( page: WikiPage, existence: SourceExistenceResult, opts: SweepOptions, ts: string, ): ArchiveEvent { const archRelPath = toArchiveRelPath(page.relPath); const reason = existence.ratio === 1.0 ? `all sources removed (${existence.missing.join(", ")})` : `${existence.missing.length} of ${existence.total} sources removed (${existence.missing.join(", ")})`; return { ts, op: "archive", atlas_run_id: opts.runId, from: page.relPath, to: archRelPath, reason, missing_sources: [...existence.missing], }; } // ── applyArchive ─────────────────────────────────────────────────────────────── async function applyArchive( page: WikiPage, existence: SourceExistenceResult, opts: SweepOptions, ts: string, ): Promise { const archRelPath = toArchiveRelPath(page.relPath); const archAbsPath = path.join(opts.wikiRoot, archRelPath); fs.mkdirSync(path.dirname(archAbsPath), { recursive: true }); // Rename first — if this fails, the live file is untouched. // If stamp fails after rename, the file is at the archive path with original // frontmatter; the next sweep won't see it (outside walkLivePages). fs.renameSync(page.absPath, archAbsPath); const archiveReason = existence.ratio === 1.0 ? `all sources removed (${existence.missing.join(", ")})` : `${existence.missing.length} of ${existence.total} sources removed (${existence.missing.join(", ")})`; stampFrontmatter(archAbsPath, { archived_from: page.relPath, archived_at: isoDate(ts), archive_reason: archiveReason, }); } // ── History journal ──────────────────────────────────────────────────────────── async function appendHistory( wikiRoot: string, events: Array, ): Promise { if (events.length === 0) return; const journalPath = path.join(wikiRoot, "_archive_history.jsonl"); const lines = events.map((e) => JSON.stringify(e)).join("\n") + "\n"; fs.appendFileSync(journalPath, lines, "utf-8"); } // ── Archive index rebuild ───────────────────────────────────────────────────── export async function rebuildArchiveIndex(wikiRoot: string): Promise { const journalPath = path.join(wikiRoot, "_archive_history.jsonl"); let allEvents: Array = []; if (fs.existsSync(journalPath)) { const lines = fs.readFileSync(journalPath, "utf-8").split("\n").filter(Boolean); for (const line of lines) { try { allEvents.push(JSON.parse(line) as ArchiveEvent | UnarchiveEvent); } catch { // skip malformed lines } } } // Only keep archive events (op !== "unarchive") whose archived file still exists. const events: ArchiveEvent[] = allEvents.filter((e): e is ArchiveEvent => { if (e.op === "unarchive") return false; const archAbsPath = path.join(wikiRoot, e.to); return fs.existsSync(archAbsPath); }); // Sort newest-first by ts events.sort((a, b) => (a.ts > b.ts ? -1 : a.ts < b.ts ? 1 : 0)); // Group by YYYY-MM const groups = new Map(); for (const e of events) { const month = e.ts.slice(0, 7); // "YYYY-MM" let group = groups.get(month); if (!group) { group = []; groups.set(month, group); } group.push(e); } const lines: string[] = [ "# Archived Pages", "", "This index lists pages atlas has archived. Pages here are preserved for historical", "reference but excluded from the main wiki indexes, summaries, search, and synthesis.", "", "**To restore an archived page:** run `/doc-wiki:unarchive ` — the command moves", "the file back, strips deprecation frontmatter, and rewrites inbound `(archived)` links.", "No manual git or frontmatter edits.", "", ]; const sortedMonths = [...groups.keys()].sort((a, b) => (a > b ? -1 : 1)); if (sortedMonths.length === 0) { lines.push("No pages currently archived."); lines.push(""); } for (const month of sortedMonths) { lines.push(`## ${month}`); lines.push(""); for (const e of groups.get(month)!) { const archRelPath = e.to; // e.g. "wiki/_archive/billing/architecture.md" const linkTarget = archRelPath.replace(/^wiki\/_archive\//, ""); const dateStr = isoDate(e.ts); lines.push(`- [${linkTarget}](${linkTarget}) — archived ${dateStr}, ${e.reason}`); } lines.push(""); } const indexDir = path.join(wikiRoot, "wiki", "_archive"); fs.mkdirSync(indexDir, { recursive: true }); fs.writeFileSync(path.join(indexDir, "index.md"), lines.join("\n"), "utf-8"); } // ── resolveArchivePath ──────────────────────────────────────────────────────── interface ResolvedArchivePath { absPath: string; relPath: string; // relative to wikiRoot } /** * Resolve pageOrSlug to the absolute path of an archived page. * * If pageOrSlug looks like a path under wiki/_archive/ (relative or absolute), * use it directly. Otherwise, substring-match against all *.md files under * wiki/_archive/ — throwing on ambiguous or no-match. */ async function resolveArchivePath( wikiRoot: string, pageOrSlug: string, ): Promise { const archiveRoot = path.join(wikiRoot, "wiki", "_archive"); // Direct path check: strip wikiRoot prefix if absolute, then test existence. const candidateRel = path.isAbsolute(pageOrSlug) ? path.relative(wikiRoot, pageOrSlug) : pageOrSlug; const candidateRelRaw = candidateRel.replace(/\\/g, "/"); const candidateRelPosix = path.posix.normalize(candidateRelRaw); // If the raw input looked like a direct wiki/_archive/ path but normalization // resolved ".." segments that escape the archive root, it is a traversal attempt. if (candidateRelRaw.startsWith("wiki/_archive/") && !candidateRelPosix.startsWith("wiki/_archive/")) { throw new Error( `path traversal detected: "${pageOrSlug}" resolves outside wiki/_archive/`, ); } if (candidateRelPosix.startsWith("wiki/_archive/")) { const absPath = path.resolve(wikiRoot, candidateRelPosix); if (!checkPathContainment(absPath, path.join(wikiRoot, "wiki", "_archive"))) { throw new Error( `path traversal detected: "${pageOrSlug}" resolves outside wiki/_archive/`, ); } if (fs.existsSync(absPath)) { return { absPath, relPath: candidateRelPosix }; } } // Substring slug match: collect all *.md under wiki/_archive/ if (!fs.existsSync(archiveRoot)) { throw new Error(`no archived page matching "${pageOrSlug}" — archive directory does not exist`); } const matches: ResolvedArchivePath[] = []; function walk(dir: string): void { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const abs = path.join(dir, entry.name); if (entry.isDirectory()) { walk(abs); } else if (entry.isFile() && entry.name.endsWith(".md") && entry.name !== "index.md") { const relPath = path.relative(wikiRoot, abs).replace(/\\/g, "/"); if (relPath.includes(pageOrSlug)) { matches.push({ absPath: abs, relPath }); } } } } walk(archiveRoot); if (matches.length === 0) { throw new Error(`no archived page matching "${pageOrSlug}"`); } if (matches.length > 1) { const paths = matches.map((m) => m.relPath).join(", "); throw new Error(`ambiguous slug "${pageOrSlug}" matches: ${paths}`); } return matches[0]!; } // ── stripArchiveFrontmatter ─────────────────────────────────────────────────── const ARCHIVE_FM_KEYS = new Set(["status", "archived_at", "archive_reason", "archived_from"]); /** * Read the file at absPath, remove the four deprecation frontmatter fields, * preserve everything else, write it back in place. */ async function stripArchiveFrontmatter(absPath: string): Promise { const raw = await fs.promises.readFile(absPath, "utf-8"); const { frontmatter, body } = parseFrontmatter(raw); const fm: Record = {}; if (frontmatter) { for (const [k, v] of Object.entries(frontmatter)) { if (!ARCHIVE_FM_KEYS.has(k)) { fm[k] = v; } } } const newContent = Object.keys(fm).length === 0 ? body : `---\n${yaml.dump(fm)}---\n${body}`; await fs.promises.writeFile(absPath, newContent, "utf-8"); } // ── Link-rewrite public types ────────────────────────────────────────────────── export interface RewriteArchiveLinksOptions { wikiRoot: string; events: ArchiveEvent[]; mode: RewriteMode; } export interface RewriteUnarchiveLinksOptions { wikiRoot: string; event: UnarchiveEvent; } // ── Code-block-aware text rewriter ──────────────────────────────────────────── /** * Split body text into alternating [non-code, code, non-code, ...] segments. * Fenced code blocks (triple backtick) are "code" segments; everything else is * "non-code". Returns an array of { text, isCode } records. */ function splitOnFencedBlocks(body: string): Array<{ text: string; isCode: boolean }> { const segments: Array<{ text: string; isCode: boolean }> = []; const fenceRe = /^```/m; let remaining = body; let inCode = false; while (remaining.length > 0) { const match = fenceRe.exec(remaining); if (match === null) { segments.push({ text: remaining, isCode: inCode }); break; } // Text before the fence delimiter (including the delimiter line itself) const fenceLineEnd = remaining.indexOf("\n", match.index); const splitAt = fenceLineEnd === -1 ? remaining.length : fenceLineEnd + 1; const before = remaining.slice(0, splitAt); segments.push({ text: before, isCode: inCode }); remaining = remaining.slice(splitAt); inCode = !inCode; } return segments; } // ── rewriteInboundLinks ──────────────────────────────────────────────────────── /** * Walk all live wiki pages and update links that point to any of the just-archived * pages, according to `mode`. * * Returns the total number of link substitutions made. */ export async function rewriteInboundLinks(opts: RewriteArchiveLinksOptions): Promise { if (!(REWRITE_MODES as readonly string[]).includes(opts.mode)) { throw new Error( `invalid mode '${opts.mode}'; expected one of: ${REWRITE_MODES.join(", ")}`, ); } if (opts.mode === "leave" || opts.events.length === 0) return 0; // Build a lookup: wiki-relative from-path → to-path (archive destination) const eventMap = new Map(); for (const e of opts.events) { eventMap.set(e.from, e.to); } const livePages = walkLivePages(opts.wikiRoot); let totalRewrites = 0; const LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g; for (const page of livePages) { const raw = await fs.promises.readFile(page.absPath, "utf-8"); const segments = splitOnFencedBlocks(raw); let changed = false; const rewritten = segments.map(({ text, isCode }) => { if (isCode) return text; let m: RegExpExecArray | null; const re = new RegExp(LINK_RE.source, "g"); let lastIndex = 0; const out: string[] = []; while ((m = re.exec(text)) !== null) { const [whole, label, rawTarget] = m as RegExpExecArray & [string, string, string]; // Skip already-rewritten links if (label.endsWith(" (archived)") && rawTarget.includes("_archive/")) { out.push(text.slice(lastIndex, m.index + whole.length)); lastIndex = m.index + whole.length; continue; } // Split off fragment/query so resolution works on the bare path const fragIdx = rawTarget.search(/[#?]/); const pathPart = fragIdx === -1 ? rawTarget : rawTarget.slice(0, fragIdx); const suffix = fragIdx === -1 ? "" : rawTarget.slice(fragIdx); // Resolve target to wiki-relative path const pageDir = path.dirname(page.absPath); const resolvedAbs = path.resolve(pageDir, pathPart); const resolvedRel = path .relative(opts.wikiRoot, resolvedAbs) .replace(/\\/g, "/"); const archiveTo = eventMap.get(resolvedRel); if (!archiveTo) { out.push(text.slice(lastIndex, m.index + whole.length)); lastIndex = m.index + whole.length; continue; } out.push(text.slice(lastIndex, m.index)); if (opts.mode === "rewrite") { const archAbsPath = path.join(opts.wikiRoot, archiveTo); const newRelTarget = path .relative(pageDir, archAbsPath) .replace(/\\/g, "/"); out.push(`[${label} (archived)](${newRelTarget}${suffix})`); } else { // drop mode: replace [label](target) with plain label text out.push(label); } totalRewrites++; changed = true; lastIndex = m.index + whole.length; } out.push(text.slice(lastIndex)); return out.join(""); }); if (changed) { await fs.promises.writeFile(page.absPath, rewritten.join(""), "utf-8"); } } return totalRewrites; } // ── rewriteInboundLinksForUnarchive ─────────────────────────────────────────── /** * Walk all live wiki pages and revert any links that were rewritten when the page * was archived: `[label (archived)](…/_archive/…)` → `[label](…/original/…)`. * * Returns the number of link reversions made. */ export async function rewriteInboundLinksForUnarchive( opts: RewriteUnarchiveLinksOptions, ): Promise { const { event, wikiRoot } = opts; // The archived path and the restored (live) path, both wiki-relative. const archiveRelPath = event.from; // "wiki/_archive/billing/architecture.md" const liveRelPath = event.to; // "wiki/billing/architecture.md" const livePages = walkLivePages(wikiRoot); let totalReverted = 0; const LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g; for (const page of livePages) { const raw = await fs.promises.readFile(page.absPath, "utf-8"); const segments = splitOnFencedBlocks(raw); let changed = false; const rewritten = segments.map(({ text, isCode }) => { if (isCode) return text; let m: RegExpExecArray | null; const re = new RegExp(LINK_RE.source, "g"); let lastIndex = 0; const out: string[] = []; while ((m = re.exec(text)) !== null) { const [whole, label, rawTarget] = m as RegExpExecArray & [string, string, string]; // Only process links that look like rewritten archive links: // label must end with " (archived)" AND path must be under _archive/. if (!label.endsWith(" (archived)") || !rawTarget.includes("_archive/")) { out.push(text.slice(lastIndex, m.index + whole.length)); lastIndex = m.index + whole.length; continue; } // Split off fragment/query so resolution works on the bare path const fragIdx = rawTarget.search(/[#?]/); const pathPart = fragIdx === -1 ? rawTarget : rawTarget.slice(0, fragIdx); const suffix = fragIdx === -1 ? "" : rawTarget.slice(fragIdx); // Resolve to wiki-relative to check it matches the archive path const pageDir = path.dirname(page.absPath); const resolvedAbs = path.resolve(pageDir, pathPart); const resolvedRel = path .relative(wikiRoot, resolvedAbs) .replace(/\\/g, "/"); if (resolvedRel !== archiveRelPath) { out.push(text.slice(lastIndex, m.index + whole.length)); lastIndex = m.index + whole.length; continue; } // Strip " (archived)" suffix and repoint to the live path const originalLabel = label.slice(0, -" (archived)".length); const liveAbsPath = path.join(wikiRoot, liveRelPath); const newRelTarget = path .relative(pageDir, liveAbsPath) .replace(/\\/g, "/"); out.push(text.slice(lastIndex, m.index)); out.push(`[${originalLabel}](${newRelTarget}${suffix})`); totalReverted++; changed = true; lastIndex = m.index + whole.length; } out.push(text.slice(lastIndex)); return out.join(""); }); if (changed) { await fs.promises.writeFile(page.absPath, rewritten.join(""), "utf-8"); } } return totalReverted; } // ── unarchive ───────────────────────────────────────────────────────────────── export async function unarchive(opts: UnarchiveOptions): Promise { const resolved = await resolveArchivePath(opts.wikiRoot, opts.pageOrSlug); const raw = await fs.promises.readFile(resolved.absPath, "utf-8"); const { frontmatter } = parseFrontmatter(raw); const archivedFrom = opts.target ?? (frontmatter as Record | null)?.[ "archived_from" ] as string | undefined; if (!archivedFrom) { throw new Error( `page ${resolved.relPath} lacks archived_from frontmatter; pass --target to specify restore path`, ); } const targetRel = opts.target ?? archivedFrom; const targetAbs = path.resolve(opts.wikiRoot, targetRel); const wikiSubtreeRoot = path.join(opts.wikiRoot, "wiki"); if (!checkPathContainment(targetAbs, wikiSubtreeRoot)) { throw new Error( `path containment violation: restore target "${targetRel}" resolves outside wiki/ subtree`, ); } // Relative paths must start with "wiki/" (catches e.g. "wiki.config.yaml" or // an absolute path that somehow passed containment but is not in wiki/). const targetRelPosix = targetRel.replace(/\\/g, "/"); if (!path.isAbsolute(targetRel) && !targetRelPosix.startsWith("wiki/")) { throw new Error( `path containment violation: restore target "${targetRel}" is not inside wiki/ subtree`, ); } if (fs.existsSync(targetAbs)) { throw new Error( `target ${targetRel} already exists; pass --target to restore elsewhere`, ); } // rename-first ordering for partial-failure safety await fs.promises.mkdir(path.dirname(targetAbs), { recursive: true }); await fs.promises.rename(resolved.absPath, targetAbs); await stripArchiveFrontmatter(targetAbs); const event: UnarchiveEvent = { ts: new Date().toISOString(), op: "unarchive", from: resolved.relPath, to: targetRel, }; await appendHistory(opts.wikiRoot, [event]); await rebuildArchiveIndex(opts.wikiRoot); // "drop" was forward-only (drop links to a page being archived); on unarchive // there are no archive-style links to drop, so treat it as "leave". const effectiveMode = opts.inboundLinks === "drop" ? "leave" : opts.inboundLinks; const linksReverted = effectiveMode === "rewrite" ? await rewriteInboundLinksForUnarchive({ wikiRoot: opts.wikiRoot, event }) : 0; return { from: event.from, to: event.to, linksReverted }; } // ── Main sweep ──────────────────────────────────────────────────────────────── export async function sweep(opts: SweepOptions): Promise { if (!(AUTONOMY_MODES as readonly string[]).includes(opts.autonomy)) { throw new Error( `invalid autonomy '${opts.autonomy}'; expected one of: ${AUTONOMY_MODES.join(", ")}`, ); } const partialThreshold = opts.partialThreshold ?? 1.0; const resolvedOpts: SweepOptions = { inboundLinks: "rewrite", ...opts, partialThreshold }; const livePages = walkLivePages(opts.wikiRoot); const atlasPages = filterAtlasPages(livePages); const archived: ArchiveEvent[] = []; const candidates: CandidateReport[] = []; const errors: ErrorReport[] = []; const pendingConfirmation: PendingArchive[] = []; const reported: ReportedArchive[] = []; const ts = new Date().toISOString(); for (const page of atlasPages) { let existence: SourceExistenceResult; try { // Call without a threshold so we always get the raw ratio/missing from // sourceExistence. We apply the sweep's own partialThreshold below to // decide whether a partial-removal page is archived or just reported. existence = sourceExistence({ wikiRoot: opts.wikiRoot, repoRoot: opts.repoRoot, page: page.absPath, }); } catch (e) { errors.push({ page: page.relPath, error: String(e) }); continue; } if (existence.missing.length === 0) { // All sources present — live, nothing to do. continue; } if (existence.ratio === 1.0) { // All sources missing → orphan. const decision = decideAutonomy(opts.autonomy, "orphan"); if (decision === "auto") { try { if (!opts.dryRun) await applyArchive(page, existence, resolvedOpts, ts); const event = buildEvent(page, existence, resolvedOpts, ts); archived.push(event); } catch (e) { errors.push({ page: page.relPath, error: String(e) }); continue; } } else if (decision === "ask") { pendingConfirmation.push({ page: page.relPath, existence }); } else { // "report-only" (conservative) → surface in reported[] reported.push({ page: page.relPath, status: "orphan", reason: `All sources missing: ${existence.missing.join(", ")}`, missing_sources: [...existence.missing], }); } } else if (existence.ratio >= partialThreshold) { // Partial removal exceeds threshold → treat as archive-eligible. const decision = decideAutonomy(opts.autonomy, "orphan"); if (decision === "auto") { try { if (!opts.dryRun) await applyArchive(page, existence, resolvedOpts, ts); const event = buildEvent(page, existence, resolvedOpts, ts); archived.push(event); } catch (e) { errors.push({ page: page.relPath, error: String(e) }); continue; } } else if (decision === "ask") { pendingConfirmation.push({ page: page.relPath, existence }); } else { // "report-only" (conservative) → surface in reported[] reported.push({ page: page.relPath, status: "candidate", reason: `${Math.round(existence.ratio * 100)}% of sources missing: ${existence.missing.join(", ")}`, missing_sources: [...existence.missing], }); } } else { // Partial removal below threshold → candidate (reported, not archived). candidates.push({ page: page.relPath, ratio: existence.ratio, missing: [...existence.missing], }); } } if (!opts.dryRun && archived.length > 0) { await appendHistory(opts.wikiRoot, archived); await rebuildArchiveIndex(opts.wikiRoot); await rewriteInboundLinks({ wikiRoot: opts.wikiRoot, events: archived, mode: resolvedOpts.inboundLinks ?? "rewrite", }); } return { archived, candidates, errors, pendingConfirmation, reported }; } // ── CLI ──────────────────────────────────────────────────────────────────────── const FLAG_SPEC = { "--wiki-root": "wikiRoot", "--repo-root": "repoRoot", "--run-id": "runId", "--autonomy": "autonomy", "--threshold": "threshold", "--dry-run": "dryRun", "--page-or-slug": "pageOrSlug", "--target": "target", "--inbound-links": "inboundLinks", "--events-file": "eventsFile", "--mode": "mode", "--event-file": "eventFile", } as const; function usage(): void { process.stdout.write( `usage: atlas_archive.js {sweep,unarchive,rebuild-index,rewrite-inbound-links,rewrite-inbound-links-unarchive} [...] Subcommands: sweep --wiki-root

--repo-root

--run-id [--autonomy conservative|balanced|autonomous|auto] [--threshold ] [--dry-run] [--inbound-links rewrite|drop|leave] Archive deprecated atlas pages. Stdout: JSON SweepResult. --inbound-links overrides ecosystem.archive.inbound_links from wiki.config.yaml. unarchive --wiki-root

--page-or-slug [--target ] [--inbound-links rewrite|drop|leave] Restore an archived page to the live wiki. rebuild-index --wiki-root

Regenerate wiki/_archive/index.md from _archive_history.jsonl. rewrite-inbound-links --wiki-root

--events-file --mode rewrite|drop|leave Rewrite links pointing to pages listed in events-file (JSON array). rewrite-inbound-links-unarchive --wiki-root

--event-file Revert (archived) links for a single UnarchiveEvent (JSON object). `, ); } export async function main(): Promise { const argv = process.argv.slice(2); if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") { usage(); return 0; } const sub = argv[0]!; if (sub === "sweep") { let parsed; try { parsed = parseFlags(argv.slice(1), FLAG_SPEC); } catch (e) { process.stderr.write(`${(e as Error).message}\n`); return 2; } const wikiRoot = parsed.values["wikiRoot"]; const repoRoot = parsed.values["repoRoot"]; const runId = parsed.values["runId"]; if ( typeof wikiRoot !== "string" || wikiRoot.length === 0 || typeof repoRoot !== "string" || repoRoot.length === 0 || typeof runId !== "string" || runId.length === 0 ) { process.stderr.write("--wiki-root, --repo-root, and --run-id are required\n"); return 2; } const autonomyRaw = parsed.values["autonomy"] ?? "autonomous"; if ( typeof autonomyRaw !== "string" || !(AUTONOMY_MODES as readonly string[]).includes(autonomyRaw) ) { process.stderr.write( `error: invalid --autonomy value '${autonomyRaw}'; expected one of: ${AUTONOMY_MODES.join(", ")}\n`, ); return 2; } const autonomy = autonomyRaw as Autonomy; const thresholdRaw = parsed.values["threshold"]; let partialThreshold: number | undefined; if (typeof thresholdRaw === "string" && thresholdRaw.length > 0) { const n = Number(thresholdRaw); if (isNaN(n) || n < 0.0 || n > 1.0) { process.stderr.write( `error: --partial-threshold must be a number in [0.0, 1.0]; got '${thresholdRaw}'\n`, ); return 2; } partialThreshold = n; } const dryRun = "dryRun" in parsed.values; // Resolve inboundLinks: CLI flag overrides config; config defaults to "rewrite". let configInboundLinks: RewriteMode = "rewrite"; const configPath = path.join(wikiRoot, "wiki.config.yaml"); try { const cfg = parseConfig(configPath); const archiveCfg = (cfg["ecosystem"] as Record | undefined)?.["archive"] as ArchiveConfig | undefined; if (archiveCfg?.inbound_links) { configInboundLinks = archiveCfg.inbound_links; } } catch { // Config missing or invalid — fall back to default "rewrite". } const inboundLinksRaw = parsed.values["inboundLinks"]; let inboundLinks: RewriteMode; if (typeof inboundLinksRaw === "string" && inboundLinksRaw.length > 0) { if (!(REWRITE_MODES as readonly string[]).includes(inboundLinksRaw)) { process.stderr.write( `error: invalid --inbound-links value '${inboundLinksRaw}'; expected one of: ${REWRITE_MODES.join(", ")}\n`, ); return 2; } inboundLinks = inboundLinksRaw as RewriteMode; } else { inboundLinks = configInboundLinks; } const result = await sweep({ wikiRoot, repoRoot, runId, autonomy, partialThreshold, dryRun, inboundLinks }); process.stdout.write(JSON.stringify(result) + "\n"); return 0; } if (sub === "unarchive") { let parsed; try { parsed = parseFlags(argv.slice(1), FLAG_SPEC); } catch (e) { process.stderr.write(`${(e as Error).message}\n`); return 2; } const wikiRoot = parsed.values["wikiRoot"]; const pageOrSlug = parsed.values["pageOrSlug"]; if ( typeof wikiRoot !== "string" || wikiRoot.length === 0 || typeof pageOrSlug !== "string" || pageOrSlug.length === 0 ) { process.stderr.write("--wiki-root and --page-or-slug are required\n"); return 2; } const target = typeof parsed.values["target"] === "string" && parsed.values["target"].length > 0 ? parsed.values["target"] : undefined; const inboundLinksRaw = parsed.values["inboundLinks"] ?? "rewrite"; if ( typeof inboundLinksRaw !== "string" || !(REWRITE_MODES as readonly string[]).includes(inboundLinksRaw) ) { process.stderr.write( `error: invalid --inbound-links value '${inboundLinksRaw}'; expected one of: ${REWRITE_MODES.join(", ")}\n`, ); return 2; } const inboundLinks = inboundLinksRaw as RewriteMode; try { const result = await unarchive({ wikiRoot, pageOrSlug, target, inboundLinks }); process.stdout.write(JSON.stringify(result) + "\n"); return 0; } catch (e) { process.stderr.write(`${(e as Error).message}\n`); return 1; } } if (sub === "rebuild-index") { let parsed; try { parsed = parseFlags(argv.slice(1), FLAG_SPEC); } catch (e) { process.stderr.write(`${(e as Error).message}\n`); return 2; } const wikiRoot = parsed.values["wikiRoot"]; if (typeof wikiRoot !== "string" || wikiRoot.length === 0) { process.stderr.write("--wiki-root is required\n"); return 2; } await rebuildArchiveIndex(wikiRoot); process.stdout.write(JSON.stringify({ ok: true }) + "\n"); return 0; } if (sub === "rewrite-inbound-links") { let parsed; try { parsed = parseFlags(argv.slice(1), FLAG_SPEC); } catch (e) { process.stderr.write(`${(e as Error).message}\n`); return 2; } const wikiRoot = parsed.values["wikiRoot"]; const eventsFile = parsed.values["eventsFile"]; const modeRaw = parsed.values["mode"] ?? "rewrite"; if ( typeof wikiRoot !== "string" || wikiRoot.length === 0 || typeof eventsFile !== "string" || eventsFile.length === 0 ) { process.stderr.write("--wiki-root and --events-file are required\n"); return 2; } if (typeof modeRaw !== "string" || !(REWRITE_MODES as readonly string[]).includes(modeRaw)) { process.stderr.write( `error: invalid --mode value '${modeRaw}'; expected one of: ${REWRITE_MODES.join(", ")}\n`, ); return 2; } const mode = modeRaw as RewriteMode; const events = JSON.parse( fs.readFileSync(eventsFile, "utf-8"), ) as ArchiveEvent[]; const count = await rewriteInboundLinks({ wikiRoot, events, mode }); process.stdout.write(JSON.stringify({ rewrites: count }) + "\n"); return 0; } if (sub === "rewrite-inbound-links-unarchive") { let parsed; try { parsed = parseFlags(argv.slice(1), FLAG_SPEC); } catch (e) { process.stderr.write(`${(e as Error).message}\n`); return 2; } const wikiRoot = parsed.values["wikiRoot"]; const eventFile = parsed.values["eventFile"]; if ( typeof wikiRoot !== "string" || wikiRoot.length === 0 || typeof eventFile !== "string" || eventFile.length === 0 ) { process.stderr.write("--wiki-root and --event-file are required\n"); return 2; } const event = JSON.parse( fs.readFileSync(eventFile, "utf-8"), ) as UnarchiveEvent; const count = await rewriteInboundLinksForUnarchive({ wikiRoot, event }); process.stdout.write(JSON.stringify({ reverted: count }) + "\n"); return 0; } process.stderr.write(`unknown subcommand: ${sub}\n`); usage(); return 2; } const thisFile = fileURLToPath(import.meta.url); if (process.argv[1] && path.resolve(process.argv[1]) === thisFile) { main().then(process.exit).catch((e) => { process.stderr.write(`${(e as Error).message}\n`); process.exit(1); }); }