import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { extname, join, relative } from "node:path"; // ponytail: fixed ignore list instead of a full .gitignore parser. const IGNORE = new Set([ ".git", "node_modules", "dist", "build", "target", ".next", "out", "coverage", "__pycache__", ".venv", "venv", ".mypy_cache", ".pytest_cache", ".idea", ".vscode", "vendor", ".turbo", ".cache", ]); const MANIFESTS = new Set([ "package.json", "Cargo.toml", "pyproject.toml", "go.mod", "pom.xml", "build.gradle", "Gemfile", // Monorepo structure declarations. turbo.json/nx.json are listed for context // only; package locations come from the package manager's workspace config. "pnpm-workspace.yaml", "turbo.json", "nx.json", ]); const MAX_DEPTH = 3; const MAX_MEMBER_DEPTH = 3; const MAX_TREE_LINES = 300; const MAX_MANIFEST_PARSE_BYTES = 262144; const MAX_DIR_ENTRIES = 40; // Bumping this constant is how generator upgrades invalidate every // previously-written guide: it is a fingerprint input. const GUIDE_SCHEMA_VERSION = 1; type Crawl = { tree: string[]; extCounts: Map; manifests: { path: string; content: string }[]; workspaceMembers: string[]; skippedGlobs: string[]; seen: Set; }; // Marker the model appends to AGENTS.md so future runs can detect staleness exactly. const FP_MARKER = //; // Exact snapshot of git-tracked + untracked state. Respects .gitignore for free. // Content-based: HEAD + per-path content hashes of every dirty tracked file // (vs HEAD, so staged changes count) and every untracked file. Only the root // AGENTS.md is excluded, so writing the guide does not make it stale; // subdirectory AGENTS.md files are repository facts. // /init computes the fingerprint synchronously on the command path, and a workspace can // hold a dataset, model weights or a recording. Content-hashing everything cost 135 ms and // 245 MB RSS for a single 200 MB file, so large files are represented by their metadata and // the whole pass runs under a path and byte budget. // Known blind spot, accepted deliberately: a rewrite that keeps size and mtime identical, or // any change past the caps, is invisible until a change the fingerprint can see happens. const FP_LIMITS = { maxFileBytes: 2 * 1024 * 1024, maxTotalBytes: 32 * 1024 * 1024, maxPaths: 5000, }; type FpLimits = typeof FP_LIMITS; type FpBudget = { bytes: number; paths: number; capped: boolean }; // Returns null for non-git directories (caller falls back to fingerprintFallback). function fingerprintGit(root: string, limits: FpLimits = FP_LIMITS): string | null { const head = gitOutput(root, ["rev-parse", "HEAD"]); if (head === null) return null; const hash = createHash("sha256").update(`schema:${GUIDE_SCHEMA_VERSION}\0head:${head}\0`); const budget: FpBudget = { bytes: 0, paths: 0, capped: false }; // --relative keeps diff paths cwd-relative and cwd-scoped, matching // ls-files --others and the crawl; without it a subdirectory session would // resolve every dirty path to a nonexistent file and hash "DELETED". for (const path of (gitOutput(root, ["diff", "HEAD", "--name-only", "-z", "--relative"]) ?? "").split("\0")) { hashFingerprintPath(hash, root, path, budget, limits); } for (const path of (gitOutput(root, ["ls-files", "--others", "--exclude-standard", "-z"]) ?? "").split("\0")) { hashFingerprintPath(hash, root, path, budget, limits); } // Fold the budget state in so crossing a cap changes the digest once and then stays // stable: an oscillating digest would make every /init run regenerate the guide. hash.update(`budget:${budget.paths}:${budget.bytes}:${budget.capped ? 1 : 0}\0`); return hash.digest("hex").slice(0, 16); } function hashFingerprintPath( hash: ReturnType, root: string, path: string, budget: FpBudget, limits: FpLimits, ): void { if (!path || path === "AGENTS.md") return; // The name is always hashed even past a cap, so adding or removing a file is still // visible; only content stops being read. hash.update(`${path}\0`).update(budget.paths >= limits.maxPaths ? "OVER_CAP\0" : hashFile(join(root, path), budget, limits)).update("\0"); budget.paths++; if (budget.paths >= limits.maxPaths) budget.capped = true; } // BEGIN SHARED GIT GUARD // A repository is attacker-chosen data: its local config, `.gitattributes` and system // config can name programs that git runs while answering what looks like a read-only query. // Measured on git 2.54: `git diff HEAD` executes a declared `[diff "x"] textconv` driver, // while `--name-only`, `status --porcelain`, `ls-files` and `rev-parse` do not. Every call // still goes through this guard so a future content-diff call cannot reintroduce the // execution path. Duplicated per extension because install.sh installs one directory at a // time; tests/net-guard-parity.test.mjs fails if the copies drift. const SAFE_GIT_GLOBAL_ARGS = [ "--no-pager", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=", "-c", "protocol.ext.allow=never", "-c", "credential.helper=", ]; /** Refuse external content handlers. Diff-family subcommands only: plumbing rejects them. */ const SAFE_GIT_DIFF_FLAGS = ["--no-ext-diff", "--no-textconv"]; const GIT_DIFF_COMMANDS = new Set(["diff", "log", "show", "stash", "range-diff", "format-patch"]); const SAFE_GIT_ENV: Record = { // /etc/gitconfig and the user's own config can name helpers; a repo probe must not read them. GIT_CONFIG_NOSYSTEM: "1", GIT_ATTR_NOSYSTEM: "1", GIT_PAGER: "cat", // A credential prompt would hang the caller instead of failing fast. GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", SSH_ASKPASS: "", // Read-only plumbing must not create or wait on index.lock. GIT_OPTIONAL_LOCKS: "0", }; export function safeGitInvocation(args: string[]): string[] { const [subcommand, ...rest] = args; const diffFlags = subcommand && GIT_DIFF_COMMANDS.has(subcommand) ? SAFE_GIT_DIFF_FLAGS : []; // A missing subcommand must not reach execFileSync/spawn as an undefined argv slot. return subcommand ? [...SAFE_GIT_GLOBAL_ARGS, subcommand, ...diffFlags, ...rest] : [...SAFE_GIT_GLOBAL_ARGS, ...rest]; } export function gitGuardEnv(base: Record = process.env): Record { const env: Record = {}; for (const [key, value] of Object.entries(base)) { if (typeof value === "string") env[key] = value; } return { ...env, ...SAFE_GIT_ENV }; } // END SHARED GIT GUARD function gitOutput(root: string, args: string[]): string | null { // Name-only/porcelain plumbing only; the guard above refuses patch content. try { return execFileSync("git", safeGitInvocation(args), { cwd: root, env: gitGuardEnv(), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], // Name-only and porcelain output is path lists: 4 MB is on the order of 100k paths. // A repo that needs more makes execFileSync throw, which reads as "not a git // directory" and falls through to the stat-only fingerprintFallback. maxBuffer: 4 * 1024 * 1024, }); } catch { return null; } } function hashFile(path: string, budget: FpBudget, limits: FpLimits): string { let stats; try { stats = statSync(path); } catch { return "DELETED"; } // Above either threshold the entry is represented by metadata instead of bytes: a // change to size or mtime is still detected, and the pass stays bounded. if (!stats.isFile() || stats.size > limits.maxFileBytes || budget.bytes + stats.size > limits.maxTotalBytes) { budget.capped = true; return `meta:${stats.size}:${Math.round(stats.mtimeMs)}`; } try { const body = readFileSync(path); budget.bytes += body.length; return createHash("sha256").update(body).digest("hex"); } catch { return "DELETED"; } } // ponytail: stat-only (path|size|mtime), not content hashing. Catches add/delete/ // rename but shares the touch-without-change blind spot; only used for non-git dirs. function fingerprintFallback(root: string): string { const parts: string[] = []; function walk(dir: string) { let entries: string[]; try { entries = readdirSync(dir); } catch { return; } for (const entry of entries) { if (IGNORE.has(entry)) continue; const path = join(dir, entry); let stats; try { stats = statSync(path); } catch { continue; } if (stats.isDirectory()) { walk(path); } else if (entry !== "AGENTS.md") { parts.push(`${relative(root, path)}|${stats.size}|${stats.mtimeMs}`); } } } walk(root); parts.sort(); return createHash("sha256").update(`schema:${GUIDE_SCHEMA_VERSION}\n`).update(parts.join("\n")).digest("hex").slice(0, 16); } function fingerprint(root: string): string { return fingerprintGit(root) ?? fingerprintFallback(root); } function crawl(root: string): Crawl { const result: Crawl = { tree: [], extCounts: new Map(), manifests: [], workspaceMembers: [], skippedGlobs: [], seen: new Set(), }; function walk(dir: string, depth: number, prefix: string, maxDepth: number) { if (result.tree.length >= MAX_TREE_LINES) return; let entries: string[]; try { entries = readdirSync(dir); } catch { return; } const dirs: string[] = []; const files: string[] = []; for (const entry of entries) { if (IGNORE.has(entry)) continue; const path = join(dir, entry); let stats; try { stats = statSync(path); } catch { continue; } if (stats.isDirectory()) { dirs.push(entry); continue; } files.push(entry); // Dedupe by absolute path: declared-member re-walks overlap the root // walk, and the inventory must not double-count. Tree lines still // accept shallow overlap for completeness. if (!result.seen.has(path)) { result.seen.add(path); const extension = extname(entry) || entry; result.extCounts.set(extension, (result.extCounts.get(extension) ?? 0) + 1); if (MANIFESTS.has(entry)) { try { result.manifests.push({ path: relative(root, path), content: readFileSync(path, "utf8").slice(0, MAX_MANIFEST_PARSE_BYTES), }); } catch { // Skip unreadable manifests. } } } } dirs.sort(); files.sort(); // ponytail: single combined per-directory cap; one omission line instead of // a priority queue. Revisit if real repos starve important branches. const all = [...dirs.map((d) => `${d}/`), ...files]; for (let i = 0; i < all.length; i++) { if (result.tree.length >= MAX_TREE_LINES) break; if (i >= MAX_DIR_ENTRIES) { result.tree.push(`${prefix}... (${all.length - MAX_DIR_ENTRIES} more entries omitted)`); break; } const entry = all[i]; if (entry === undefined) continue; result.tree.push(`${prefix}${entry}`); if (entry.endsWith("/") && depth < maxDepth) { walk(join(dir, entry.slice(0, -1)), depth + 1, `${prefix} `, maxDepth); } } } walk(root, 0, "", MAX_DEPTH); // Manifest-first monorepo expansion: enumerate declared workspace members // with a fresh depth budget so packages/foo/src is not penalized by its // grouping prefix. for (const member of workspaceMembers(root, result.skippedGlobs)) { if (result.tree.length >= MAX_TREE_LINES) break; const abs = join(root, member); let stats; try { stats = statSync(abs); } catch { continue; } if (!stats.isDirectory()) continue; // Always re-walk declared members with a full fresh budget: partial root- // walk coverage must not truncate member internals. Shallow overlap with // the root tree is accepted as the cost of completeness. result.workspaceMembers.push(member); result.tree.push(`${member}/`); walk(abs, 0, " ", MAX_MEMBER_DEPTH); } // Explicit truncation signal — never leave the model guessing whether the // tree is complete. if (result.tree.length >= MAX_TREE_LINES) { result.tree.push(`(tree truncated at ${MAX_TREE_LINES} entries)`); } if (result.skippedGlobs.length > 0) { result.tree.push( `(skipped workspace glob(s) that could not be compiled: ${result.skippedGlobs.join(", ")})`, ); } return result; } // ponytail: naive glob matcher supporting only what workspace files use in // practice: `**` across segments, `*` within a segment, `?` for one character, // optional trailing "/". Everything else is escaped, so a directory name that is // itself regex syntax (`libs/c++/*`) matches literally instead of throwing. // Returns null for a pattern that cannot be compiled: workspace files are // user-authored, and one bad line must not abort /init. function globToRegExp(pattern: string): RegExp | null { const cleaned = pattern.replace(/\/$/, ""); if (cleaned === "") return null; try { const source = cleaned .split("/**/") .map((segment) => segment .replace(/[.+^${}()|[\]\\]/g, "\\$&") .replace(/\*/g, "[^/]*") .replace(/\?/g, "[^/]"), ) .join("(?:/.*)?"); return new RegExp(`^${source}$`); } catch { return null; } } // ponytail: line-based subset parsing of pnpm-workspace.yaml and Cargo.toml // [workspace] members. Full YAML/TOML parsing not warranted for glob lists. function workspaceMembers(root: string, skipped: string[]): string[] { const globs: string[] = []; try { const yaml = readFileSync(join(root, "pnpm-workspace.yaml"), "utf8"); let inPackages = false; for (const line of yaml.split("\n")) { if (/^packages:\s*$/.test(line)) { inPackages = true; } else if (/^\S/.test(line)) { inPackages = false; } else if (inPackages) { const match = line.match(/^\s*-\s*["']?([^"'#]+)/); const pkg = match?.[1]; if (pkg) globs.push(pkg.trim()); } } } catch { // No pnpm-workspace.yaml. } try { const toml = readFileSync(join(root, "Cargo.toml"), "utf8"); const section = toml.match(/\[workspace\]([\s\S]*?)(?:\n\[|\s*$)/); const membersBlock = section?.[1]?.match(/members\s*=\s*\[([^\]]*)\]/s); if (membersBlock) { const memberList = membersBlock[1]; if (memberList) { for (const m of memberList.matchAll(/["']([^"']+)["']/g)) { const name = m[1]; if (name) globs.push(name); } } } } catch { // No Cargo.toml. } const members = new Set(); for (const glob of globs) { const re = globToRegExp(glob); if (!re) { if (!skipped.includes(glob)) skipped.push(glob); continue; } // Match against shallow candidate paths from the tree we already walked, // plus one extra readdir of likely parent dirs. Simple approach: test every // tree dir line's relative path. for (const line of result_tree_paths(root)) { if (re.test(line)) members.add(line); } } return [...members].sort(); } // Candidate relative paths for glob matching: all directory paths up to // MAX_DEPTH derived from a fresh cheap listing (not the truncated tree). function result_tree_paths(root: string): string[] { const paths: string[] = []; function walkList(dir: string, rel: string, depth: number) { let entries: string[]; try { entries = readdirSync(dir); } catch { return; } for (const entry of entries) { if (IGNORE.has(entry)) continue; try { if (!statSync(join(dir, entry)).isDirectory()) continue; } catch { continue; } const childRel = rel ? `${rel}/${entry}` : entry; paths.push(childRel); if (depth < MAX_DEPTH + 1) walkList(join(dir, entry), childRel, depth + 1); } } walkList(root, "", 0); return paths; } // Root-first, per-package-labeled script lines, bounded at 30 entries total. function aggregateScripts(manifests: { path: string; content: string }[]): string | null { const lines: string[] = []; const sorted = [...manifests].sort((a, b) => a.path === "package.json" ? -1 : b.path === "package.json" ? 1 : a.path.localeCompare(b.path)); for (const manifest of sorted) { if (!manifest.path.endsWith("package.json") || lines.length >= 30) continue; let parsed; try { parsed = JSON.parse(manifest.content); } catch { continue; } const scripts = parsed?.scripts; if (!scripts || typeof scripts !== "object") continue; const label = manifest.path === "package.json" ? "" : `${manifest.path.replace(/\/package\.json$/, "")}/ `; for (const [name, command] of Object.entries(scripts)) { if (lines.length >= 30) break; lines.push(` ${label}${name}: ${command}`); } } return lines.length ? lines.join("\n") : null; } function buildGuide(root: string, crawlResult: Crawl, marker: string): string { const topLevel = crawlResult.tree.filter(line => !line.startsWith(" ")).slice(0, 30).join(", "); const scripts = aggregateScripts(crawlResult.manifests); const commandBlock = scripts ? "Package scripts:\n```\n" + scripts + "\n```" : "No package scripts were detected by the repository crawl."; return `# Repository Guide\n\n## What this is\n\nRepository at \`${root}\`. Use the repository files as the source of truth; the top-level inventory includes ${topLevel || "(not available)"}.\n\n## Commands\n\n${commandBlock}\n\n## Repository inventory\n\n- File types: ${[...crawlResult.extCounts.entries()].map(([extension, count]) => `${extension} (${count})`).join(", ") || "none detected"}.\n- Inspect specific files before changing behavior; this guide is a starting point, not a substitute for reading the code.\n\n## Agent workflow\n\nKeep changes focused on the requested behavior, preserve existing interfaces, and run the narrowest relevant test before the full suite. Keep secrets and generated output out of tracked configuration.\n${marker}\n`; } const MAX_EVIDENCE_BYTES = 24576; const MAX_README_HEAD_BYTES = 2048; // Evidence the model cannot fetch itself: the deterministic stand-in for the // tools the refine call does not have, under a hard byte budget. The spec's // three parts: baseline guide, full per-package scripts blocks, readme heads. function evidencePacket( root: string, baseline: string, crawlResult: { manifests: { path: string; content: string }[]; workspaceMembers: string[] }, ): string { const parts = [baseline]; let used = Buffer.byteLength(baseline, "utf8"); const push = (block: string): boolean => { const bytes = Buffer.byteLength(block, "utf8") + 1; if (used + bytes > MAX_EVIDENCE_BYTES) { parts.push("(evidence truncated)"); return false; } used += bytes; parts.push(block); return true; }; for (const manifest of crawlResult.manifests) { if (!manifest.path.endsWith("package.json")) continue; let parsed; try { parsed = JSON.parse(manifest.content); } catch { continue; } const scripts = parsed?.scripts; if (!scripts || typeof scripts !== "object") continue; const block = `=== scripts: ${manifest.path} ===\n${Object.entries(scripts).map(([name, command]) => `${name}: ${command}`).join("\n")}`; if (!push(block)) return parts.join("\n"); } const candidates = ["README.md", "readme.md", "README", "CLAUDE.md"]; const paths = [...candidates, ...crawlResult.workspaceMembers.flatMap((m) => candidates.map((c) => `${m}/${c}`))]; for (const rel of paths) { let head: string; try { head = readFileSync(join(root, rel), "utf8").slice(0, MAX_README_HEAD_BYTES); } catch { continue; } if (!push(`=== ${rel} ===\n${head}`)) break; } return parts.join("\n"); } const REFINE_SYSTEM_PROMPT = [ "You are rewriting a repository guide for an AI coding agent.", "Input: a draft guide generated from a deterministic repository crawl, plus bounded file excerpts.", "Return ONLY the final Markdown document: no preamble, no code fence around the document, no HTML comments.", "Do not invent facts unsupported by the input; keep the factual inventory (commands, file types, workspace members).", 'Prefer these sections when evidence supports them: "## What this is", "## Commands", "## Architecture", "## Configuration and installation", "## Testing and operational quirks", "## Key files". Omit unsupported sections.', "Usually 250-700 words. Avoid generic contribution, Git, or pull-request advice.", ].join("\n"); // The model never owns the marker line: every opl-init:fp comment is stripped // and the extension's exact marker is appended as the final line. function finalizeRefinedGuide(text: string, marker: string): string { let body = text.trim(); // Strip a wrapper fence only when it opens *and* closes; a guide ending in // a legitimate code block keeps its closing fence. if (/^```[a-zA-Z]*\s*\n/.test(body)) { body = body.replace(/^```[a-zA-Z]*\s*\n/, "").replace(/\n```\s*$/, ""); } body = body.replace(//g, "").trimEnd(); return `${body}\n${marker}\n`; } // One out-of-band completion on the current model. No tools, no session // message: any failure returns null and the caller writes the baseline. // Bounded so a stalled provider cannot leave /init hanging with no UI signal // (the old injected turn was Esc-cancellable; this replaces that escape hatch). const REFINE_TIMEOUT_MS = 120000; async function refineGuide(ctx: any, evidence: string, marker: string): Promise { const model = ctx.model; if (!model || typeof ctx.modelRegistry?.streamSimple !== "function") return null; ctx.ui?.notify?.("opl-init: refining the guide with the current model...", "info"); try { const stream = ctx.modelRegistry.streamSimple( model, { systemPrompt: REFINE_SYSTEM_PROMPT, messages: [{ role: "user", content: evidence, timestamp: Date.now() }], tools: [] }, // ThinkingLevel has no "off": "minimal" is the cheapest level the adapters accept. // `false` was silently dropped as "unset", so the refinement paid the provider default. { reasoning: "minimal", signal: AbortSignal.timeout(REFINE_TIMEOUT_MS) }, ); const result = await stream.result(); if (!result || result.stopReason === "error" || result.stopReason === "aborted") return null; const text = (result.content ?? []) .filter((part: any) => part.type === "text") .map((part: any) => part.text) .join("\n"); return text.trim() ? finalizeRefinedGuide(text, marker) : null; } catch { return null; } } // Offsets retain the user's original line endings and whitespace. Only ATX // headings outside fenced code blocks delimit the owned section. function operationalSections(text: string): { start: number; end: number }[] { const sections: { start: number; end: number }[] = []; let active: { start: number; end: number } | undefined; let fence: { char: string; length: number } | undefined; for (const match of text.matchAll(/[^\n]*(?:\n|$)/g)) { const line = match[0].replace(/\r?\n$/, ""); const offset = match.index; const fenced = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/); if (fence) { if (fenced?.[1]?.[0] === fence.char && fenced[1].length >= fence.length && !fenced[2]?.trim()) fence = undefined; continue; } if (fenced?.[1] && (fenced[1][0] !== "`" || !fenced[2]?.includes("`"))) { fence = { char: fenced[1][0]!, length: fenced[1].length }; continue; } const heading = line.match(/^ {0,3}(#{1,2})(?:[ \t]+(.*)|[ \t]*)$/); if (!heading) continue; if (active) { active.end = offset; active = undefined; } if (heading[1] === "##" && heading[2]?.replace(/[ \t]+#+[ \t]*$/, "").trim() === "Operational notes") { active = { start: offset, end: text.length }; sections.push(active); } } return sections; } function guideWithoutFinalMarker(text: string): string { return text.replace(/(^|\n)[ \t]*(?:\r?\n)?$/, "$1"); } function mergeOperationalNotes(generated: string, owned: string, marker: string): string { let body = guideWithoutFinalMarker(generated); // Discard generated versions: the existing user section is authoritative. for (const section of operationalSections(body).reverse()) { body = body.slice(0, section.start) + body.slice(section.end); } return `${body.trimEnd()}\n\n${owned}${owned.endsWith("\n") ? "" : "\n"}${marker}\n`; } // Named exports for fixture tests (tests/opl-init-*.test.mjs). export { crawl, fingerprint, fingerprintGit, FP_LIMITS, evidencePacket, finalizeRefinedGuide, refineGuide }; export default function (pi: ExtensionAPI) { pi.registerCommand("init", { description: "[opl-init] Crawl + refine AGENTS.md out-of-band; zero model calls when current", handler: async (_args, ctx) => { const root = ctx.cwd; const agentsPath = join(root, "AGENTS.md"); const storedFp = () => { try { return readFileSync(agentsPath, "utf8").match(FP_MARKER)?.[1] ?? null; } catch { return null; } }; let currentFp = fingerprint(root); if (existsSync(agentsPath) && storedFp() === currentFp) { ctx.ui.notify("AGENTS.md is current; /init will not modify it.", "info"); return; } if (!ctx.isIdle()) { ctx.ui.notify("opl-init: waiting for the current agent run to settle before writing AGENTS.md.", "info"); await ctx.waitForIdle(); // The settling run may have moved the tree; recompute before crawling. currentFp = fingerprint(root); if (existsSync(agentsPath) && storedFp() === currentFp) { ctx.ui.notify("AGENTS.md is current; /init will not modify it.", "info"); return; } } const marker = ``; let original: string | null; let owned: string | undefined; try { original = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : null; const body = guideWithoutFinalMarker(original ?? ""); const sections = operationalSections(body); if (sections.length > 1) { ctx.ui.notify("AGENTS.md has duplicate Operational notes sections; /init will not modify it.", "error"); return; } const section = sections[0]; if (section) owned = body.slice(section.start, section.end); } catch (error: any) { ctx.ui.notify(`Could not read AGENTS.md: ${error?.message || error}`, "error"); return; } const crawlResult = crawl(root); const baseline = buildGuide(root, crawlResult, marker); const refined = await refineGuide(ctx, evidencePacket(root, baseline, crawlResult), marker); try { const latest = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : null; if (latest !== original) { ctx.ui.notify("AGENTS.md changed during /init; update cancelled. Run /init again.", "error"); return; } const generated = refined ?? baseline; writeFileSync(agentsPath, owned === undefined ? generated : mergeOperationalNotes(generated, owned, marker), "utf8"); } catch (error: any) { ctx.ui.notify(`Could not write AGENTS.md: ${error?.message || error}`, "error"); return; } ctx.ui.notify( refined ? "AGENTS.md written from the repository crawl (model-refined)." : "refine failed; wrote deterministic AGENTS.md from the repository crawl.", "info", ); // Terminal: pi docs say code after ctx.reload() runs in the old frame. await ctx.reload(); return; }, }); }