/** * The fs side of ADR 0008: apply / remove / check harnery's machine-owned * agent-facing content in a consumer repo. `init` calls `applyInstructions`, * `deinit` calls `removeInstructions`, and `init --check` calls * `checkInstructions`. The pure splice mechanics live in `splice.ts` and the * rendered content in `templates.ts`; this module only sequences the reads, * writes, and deletes so init/deinit stay thin and this stays integration- * testable against a temp dir. * * Three files, one dir: * - `AGENTS.md` — the always-on orientation block (a managed region; the * consumer owns the rest of the file). * - `CLAUDE.md` — claude-code only; Claude Code reads CLAUDE.md, not AGENTS.md, * so a fresh consumer gets a CLAUDE.md whose managed region imports * `@AGENTS.md`. A CLAUDE.md that already imports AGENTS.md or already carries * the block (a host that generates CLAUDE.md from AGENTS.md) is left alone. * - `.cursor/rules/harnery-turn-ritual.mdc` — cursor only; an Always Apply * rule that makes the Stop-enforced status footer visible in reply text. * - `//SKILL.md` — fully-owned files, honoring * `skills.exclude` in `.harnery/config.jsonc`. Claude Code uses * `.claude/skills/`; Cursor and Codex use `.agents/skills/`. */ import { existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, writeFileSync, } from "node:fs"; import { dirname, join } from "node:path"; import { endOfTurnStatusCommand, stripJsonComments } from "../../core/config.ts"; import { readHostAddendum } from "./host-addendum.ts"; import { checkOwnedSkill, checkRegion, isOwnedFile, type ManagedStatus, removeRegion, shortHash, spliceRegion, } from "./splice.ts"; import { type BlockSkills, HOST_ADDENDUM_REGION, IMPORT_REGION, INSTRUCTIONS_REGION, renderInstructionsBlock, SKILLS, } from "./templates.ts"; const AGENTS_FILE = "AGENTS.md"; const CLAUDE_FILE = "CLAUDE.md"; const CURSOR_RULE_FILE = join(".cursor", "rules", "harnery-turn-ritual.mdc"); const ADAPTER_SKILLS_DIR: Readonly> = { "claude-code": join(".claude", "skills"), cursor: join(".agents", "skills"), codex: join(".agents", "skills"), }; function adapterSkillsDir(adapter: string): string | null { return ADAPTER_SKILLS_DIR[adapter] ?? null; } /** CLAUDE.md import-shim body: points Claude Code (which reads CLAUDE.md, not AGENTS.md) at AGENTS.md. */ function importBody(): string { return "This project's agent instructions live in AGENTS.md.\n@AGENTS.md"; } /** The body harnery expects for a skill's file (everything after the ownership marker). */ function skillBody(render: (bin: string) => string, binName: string): string { const content = render(binName); return content.slice(content.indexOf("-->") + 3).trim(); } function cursorRuleBody(projectRoot: string, binName: string): string { const statusCommand = endOfTurnStatusCommand(projectRoot); return [ "# Harnery turn ritual", "", "On every turn that uses tools:", "", `1. Run \`${binName} agents set-task ""\` during the turn, even when the focus is unchanged.`, `2. Make \`${statusCommand}\` your final tool call.`, "3. Append that command's stdout verbatim in a fenced code block at the bottom of the same substantive reply.", "", "Cursor Stop enforces all three signals. A missing signal produces one follow-up that names every repair needed.", ].join("\n"); } function cursorRuleContent(projectRoot: string, binName: string): string { const body = cursorRuleBody(projectRoot, binName); const marker = `"; return [ "---", "description: Harnery end-of-turn coordination ritual", "alwaysApply: true", "---", marker, "", body, "", ].join("\n"); } /** Read `skills.exclude` from `.harnery/config.jsonc` (absent/unparseable → none). */ export function readSkillsExclude(projectRoot: string): Set { const p = join(projectRoot, ".harnery", "config.jsonc"); try { const cfg = JSON.parse(stripJsonComments(readFileSync(p, "utf8"))) as { skills?: { exclude?: unknown }; } | null; const ex = cfg?.skills?.exclude; if (Array.isArray(ex)) return new Set(ex.filter((x): x is string => typeof x === "string")); } catch { /* absent / unparseable → no exclusions */ } return new Set(); } /** * Which shipped skills exist for this project, so the block references only the * ones actually present for every supported adapter, unless excluded. Kept in * one place so `applyInstructions` and `checkInstructions` render byte-identical * blocks. */ function blockSkills(projectRoot: string, adapter: string): BlockSkills { const supported = adapterSkillsDir(adapter) !== null; const exclude = readSkillsExclude(projectRoot); return { decide: supported && !exclude.has("harn-decide"), council: supported && !exclude.has("harn-council"), end: supported && !exclude.has("harn-end"), team: supported && !exclude.has("harn-team"), }; } interface ApplyOpts { binName: string; adapter: string; dryRun: boolean; } export interface ApplyResult { actions: string[]; warnings: string[]; } /** * Inject / refresh the instructions block, the CLAUDE.md import shim (claude-code), * and the adapter-native shipped skills. Idempotent: a re-run on current content * writes nothing. `dryRun` reports without touching the fs. */ export function applyInstructions(projectRoot: string, opts: ApplyOpts): ApplyResult { const actions: string[] = []; const warnings: string[] = []; const claudeCode = opts.adapter === "claude-code"; const skillsDir = adapterSkillsDir(opts.adapter); // dry-run narrates the future ("would create"); a real run narrates the past. const verbed = (base: string, past: string) => (opts.dryRun ? `would ${base}` : past); // Resolve the host addendum before touching a single file, so a bad path // aborts the whole run instead of leaving AGENTS.md half-updated. const addendum = readHostAddendum(projectRoot); // ── AGENTS.md orientation block ───────────────────────────────────────── const agentsPath = join(projectRoot, AGENTS_FILE); const agentsExisted = existsSync(agentsPath); const agentsBefore = agentsExisted ? readFileSync(agentsPath, "utf8") : ""; const body = renderInstructionsBlock(opts.binName, blockSkills(projectRoot, opts.adapter)); const spliced = spliceRegion(agentsBefore, INSTRUCTIONS_REGION, body); if (!spliced.changed) { actions.push(`· ${AGENTS_FILE} instructions block already current`); } else if (!agentsExisted) { actions.push(`+ ${verbed("create", "created")} ${AGENTS_FILE} with the instructions block`); } else if (!spliced.had) { actions.push(`+ ${verbed("inject", "injected")} the instructions block into ${AGENTS_FILE}`); } else { actions.push(`~ ${verbed("update", "updated")} the instructions block in ${AGENTS_FILE}`); } // ── host addendum (a second region, content the host owns) ────────────── // Harnery places and versions it; what it says is none of harnery's business. // No configured source means any region left behind belongs to a config entry // that has since been deleted, so init takes it back out. let agentsAfter = spliced.text; if (addendum.configured) { const withAddendum = spliceRegion(agentsAfter, HOST_ADDENDUM_REGION, addendum.body); agentsAfter = withAddendum.text; if (!withAddendum.changed) { actions.push(`· ${AGENTS_FILE} host addendum already current (${addendum.relPath})`); } else if (!withAddendum.had) { actions.push( `+ ${verbed("splice", "spliced")} the host addendum into ${AGENTS_FILE} (${addendum.relPath})`, ); } else { actions.push( `~ ${verbed("update", "updated")} the host addendum in ${AGENTS_FILE} (${addendum.relPath})`, ); } } else { const dropped = removeRegion(agentsAfter, HOST_ADDENDUM_REGION); if (dropped.removed) { agentsAfter = dropped.text; actions.push( `+ ${verbed("remove", "removed")} the host addendum from ${AGENTS_FILE} (no longer configured)`, ); } } if (!opts.dryRun && agentsAfter !== agentsBefore) writeFileSync(agentsPath, agentsAfter); // ── CLAUDE.md import shim (claude-code only) ──────────────────────────── if (claudeCode) { const claudePath = join(projectRoot, CLAUDE_FILE); if (!existsSync(claudePath)) { const shim = spliceRegion("", IMPORT_REGION, importBody()); if (!opts.dryRun) writeFileSync(claudePath, shim.text); actions.push(`+ ${verbed("create", "created")} ${CLAUDE_FILE} importing @AGENTS.md`); } else { const claude = readFileSync(claudePath, "utf8"); const sees = claude.includes("@AGENTS.md") || claude.includes(`harnery:begin ${IMPORT_REGION}`) || claude.includes(`harnery:begin ${INSTRUCTIONS_REGION}`); if (sees) { actions.push(`· ${CLAUDE_FILE} already reaches AGENTS.md (left untouched)`); } else { warnings.push( `${CLAUDE_FILE} exists but neither imports @AGENTS.md nor carries the block; left ` + `untouched. For Claude Code to see the orientation, add \`@AGENTS.md\` to ${CLAUDE_FILE} ` + `(or generate ${CLAUDE_FILE} from ${AGENTS_FILE}).`, ); } } } // ── Cursor Always Apply rule ──────────────────────────────────────────── if (opts.adapter === "cursor") { const rulePath = join(projectRoot, CURSOR_RULE_FILE); const content = cursorRuleContent(projectRoot, opts.binName); const before = existsSync(rulePath) ? readFileSync(rulePath, "utf8") : null; if (before === content) { actions.push(`· ${CURSOR_RULE_FILE} already current`); } else if (before !== null && !isOwnedFile(before)) { warnings.push(`left ${CURSOR_RULE_FILE} (hand-edited; no harnery ownership marker)`); } else { if (!opts.dryRun) { mkdirSync(dirname(rulePath), { recursive: true }); writeFileSync(rulePath, content); } const fresh = before === null; actions.push( `${fresh ? "+" : "~"} ${verbed(fresh ? "write" : "update", fresh ? "wrote" : "updated")} ${CURSOR_RULE_FILE}`, ); } } // ── shipped skills ─────────────────────────────────────────────────────── if (skillsDir) { const exclude = readSkillsExclude(projectRoot); for (const skill of SKILLS) { if (exclude.has(skill.id)) { actions.push(`· skipped skill ${skill.id} (skills.exclude)`); continue; } const skillPath = join(projectRoot, skillsDir, skill.relPath); const content = skill.render(opts.binName); const before = existsSync(skillPath) ? readFileSync(skillPath, "utf8") : null; if (before === content) { actions.push(`· skill ${skill.id} already current`); continue; } if (!opts.dryRun) { mkdirSync(dirname(skillPath), { recursive: true }); writeFileSync(skillPath, content); } const fresh = before === null; actions.push( `${fresh ? "+" : "~"} ${verbed(fresh ? "write" : "update", fresh ? "wrote" : "updated")} skill ${skill.id}`, ); } } return { actions, warnings }; } interface RemoveOpts { adapter: string; dryRun: boolean; } /** * Reverse {@link applyInstructions}: strip the AGENTS.md block, the CLAUDE.md * import shim, and delete the shipped skill files (only ones harnery generated). * A file that becomes empty once our region is gone is deleted (init created it); * a hand-edited skill (no ownership marker) is left with a warning. */ export function removeInstructions(projectRoot: string, opts: RemoveOpts): ApplyResult { const actions: string[] = []; const warnings: string[] = []; const claudeCode = opts.adapter === "claude-code"; const skillsDir = adapterSkillsDir(opts.adapter); // ── AGENTS.md block + host addendum ───────────────────────────────────── const agentsPath = join(projectRoot, AGENTS_FILE); if (existsSync(agentsPath)) { // Both regions are harnery-placed, so deinit leaves neither behind. The // addendum's *source file* is the host's and stays where it is. const addendumGone = removeRegion(readFileSync(agentsPath, "utf8"), HOST_ADDENDUM_REGION); if (addendumGone.removed) { actions.push( `+ ${opts.dryRun ? "would remove" : "removed"} the host addendum from ${AGENTS_FILE}`, ); } const { text, removed } = removeRegion(addendumGone.text, INSTRUCTIONS_REGION); if (removed) { actions.push( `+ ${opts.dryRun ? "would remove" : "removed"} the instructions block from ${AGENTS_FILE}`, ); } else if (!addendumGone.removed) { actions.push(`· no instructions block in ${AGENTS_FILE}`); } if (removed || addendumGone.removed) { if (text === "") { if (!opts.dryRun) rmSync(agentsPath); actions.push( `+ ${opts.dryRun ? "would remove" : "removed"} ${AGENTS_FILE} (nothing left but ours)`, ); } else if (!opts.dryRun) { writeFileSync(agentsPath, text); } } } // ── CLAUDE.md import shim (claude-code) ───────────────────────────────── if (claudeCode) { const claudePath = join(projectRoot, CLAUDE_FILE); if (existsSync(claudePath)) { const { text, removed } = removeRegion(readFileSync(claudePath, "utf8"), IMPORT_REGION); if (removed) { if (text === "") { if (!opts.dryRun) rmSync(claudePath); actions.push( `+ ${opts.dryRun ? "would remove" : "removed"} ${CLAUDE_FILE} (was shim-only)`, ); } else { if (!opts.dryRun) writeFileSync(claudePath, text); actions.push( `+ ${opts.dryRun ? "would remove" : "removed"} the import shim from ${CLAUDE_FILE}`, ); } } } } if (opts.adapter === "cursor") { const rulePath = join(projectRoot, CURSOR_RULE_FILE); if (existsSync(rulePath)) { if (!isOwnedFile(readFileSync(rulePath, "utf8"))) { warnings.push(`left ${CURSOR_RULE_FILE} (hand-edited; no harnery ownership marker)`); } else { if (!opts.dryRun) rmSync(rulePath); actions.push(`+ ${opts.dryRun ? "would delete" : "deleted"} ${CURSOR_RULE_FILE}`); } } } // ── shipped skills ─────────────────────────────────────────────────────── if (skillsDir) { for (const skill of SKILLS) { const skillPath = join(projectRoot, skillsDir, skill.relPath); if (!existsSync(skillPath)) continue; if (!isOwnedFile(readFileSync(skillPath, "utf8"))) { warnings.push(`left ${skill.relPath} (hand-edited; no harnery ownership marker)`); continue; } if (!opts.dryRun) { rmSync(skillPath); // Drop the now-empty harn-* dir, leaving the adapter skills root intact. const dir = dirname(skillPath); try { if (readdirSync(dir).length === 0) rmdirSync(dir); } catch { /* dir not empty or gone → leave it */ } } actions.push(`+ ${opts.dryRun ? "would delete" : "deleted"} skill ${skill.id}`); } } return { actions, warnings }; } export interface CheckResult { status: "fresh" | "drift" | "error"; issues: string[]; } /** * Read-only drift report for `init --check`: the AGENTS.md block and each * shipped skill for the selected adapter. Fresh → exit 0; stale / missing / hand-edit * → drift (exit 2); an unreadable file → error (exit 1). Mirrors the wiki-theme * `--check-only` contract the first host wires into pre-commit. */ export function checkInstructions( projectRoot: string, opts: { binName: string; adapter: string }, ): CheckResult { const issues: string[] = []; let errored = false; const note = (label: string, status: ManagedStatus) => { if (status === "missing") issues.push(`${label}: missing`); else if (status === "stale") issues.push(`${label}: stale (re-run init)`); }; try { const agentsPath = join(projectRoot, AGENTS_FILE); const content = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : ""; note( `${AGENTS_FILE} block`, checkRegion( content, INSTRUCTIONS_REGION, renderInstructionsBlock(opts.binName, blockSkills(projectRoot, opts.adapter)), ), ); // A configured addendum is checked against its source; an unconfigured one // still sitting in the file is drift too, since init would take it out. const addendum = readHostAddendum(projectRoot); if (addendum.configured) { note( `${AGENTS_FILE} host addendum (${addendum.relPath})`, checkRegion(content, HOST_ADDENDUM_REGION, addendum.body), ); } else if (checkRegion(content, HOST_ADDENDUM_REGION, "") !== "missing") { issues.push(`${AGENTS_FILE} host addendum: present but no longer configured (re-run init)`); } const skillsDir = adapterSkillsDir(opts.adapter); if (skillsDir) { const exclude = readSkillsExclude(projectRoot); for (const skill of SKILLS) { if (exclude.has(skill.id)) continue; const skillPath = join(projectRoot, skillsDir, skill.relPath); const c = existsSync(skillPath) ? readFileSync(skillPath, "utf8") : ""; note(`skill ${skill.id}`, checkOwnedSkill(c, skillBody(skill.render, opts.binName))); } } if (opts.adapter === "cursor") { const rulePath = join(projectRoot, CURSOR_RULE_FILE); const content = existsSync(rulePath) ? readFileSync(rulePath, "utf8") : ""; note( `${CURSOR_RULE_FILE} rule`, checkOwnedSkill(content, cursorRuleBody(projectRoot, opts.binName)), ); } } catch (err) { errored = true; issues.push(`error reading instructions state: ${(err as Error).message}`); } if (errored) return { status: "error", issues }; return { status: issues.length === 0 ? "fresh" : "drift", issues }; }