diff --git a/.oracle/orly.json b/.oracle/orly.json index dd8d4c6..daa5aaa 100644 --- a/.oracle/orly.json +++ b/.oracle/orly.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "orly_version": "0.9.0", + "orly_version": "0.9.1", "packs": [ "universal.authoring", "language.zig", diff --git a/README.md b/README.md index a5b9f59..2235dd0 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ Each edit activates the rule page for that file kind. A gate proves whether the `AGENTS.md` stays yours — orly writes its rules beside it and points at them from one delimited block. -The last two files exist because installing a rules file is not the same as delivering it, and no runtime loads `AGENTS.orly.md` on its own. Codex and Amp auto-load `AGENTS.md`, which carries the pointer block onward. Claude Code loads `CLAUDE.md` and nothing else. opencode loads only what its `instructions` name. So orly writes the one import line each of those needs, and leaves any file you already wrote alone — a `CLAUDE.md` of your own, symlink included, is your answer to the question and orly does not touch it. +The last two files exist because installing a rules file is not the same as delivering it, and no runtime loads `AGENTS.orly.md` on its own. Codex and Amp auto-load `AGENTS.md`, which carries the pointer block onward. Claude Code loads `CLAUDE.md` and nothing else. opencode loads only what its `instructions` name. So orly writes the one import line each of those needs, and leaves any file you already wrote alone — a `CLAUDE.md` of your own is your answer to the question and orly does not touch it — including a symlink, whether it points at your rules file, somewhere else in the repository, or at nothing yet. Only a link out of the repository is refused, because a write through it would land outside the repository you ran orly in. > [!WARNING] > orly refuses to replace a hook or rule page it did not write. `--force` and `--no-hooks` are the ways through. A refused run changes nothing. diff --git a/package.json b/package.json index 6d4a56b..b530bac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agentsfleet/orly", - "version": "0.9.0", + "version": "0.9.1", "description": "Repository-scoped engineering harness: renders agent rules, materialises the gates that enforce them, and proves the PR boundary \u2014 for any coding agent.", "license": "MIT", "type": "module", diff --git a/src/loaders.test.ts b/src/loaders.test.ts index 2ffc783..25e1796 100644 --- a/src/loaders.test.ts +++ b/src/loaders.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { existsSync, symlinkSync } from "node:fs"; +import { existsSync, lstatSync, symlinkSync } from "node:fs"; import { join } from "node:path"; import { cleanupTemporaryDirectories, newRepository, ROOT } from "./gates_test_support"; @@ -73,6 +73,54 @@ describe("runtime loaders", () => { expect(managed).not.toContain("AGENTS.md"); }); + // The block is delimited so a re-run replaces exactly what the last run + // wrote. What surrounds it is the repository's prose and must survive an + // update that changes the block's own wording — as this release does. + test("a stale pointer block is replaced in place, and nothing either side of it", async () => { + const repo = newRepository(); + await Bun.write(join(repo, "AGENTS.md"), "# Ours\n\nkeep me\n\n\nstale pointer\n\n\ntrailing prose\n"); + + const result = await installInto(repo); + + expect(result.written).toContain("AGENTS.md"); + const host = await Bun.file(join(repo, "AGENTS.md")).text(); + expect(host).toContain("keep me"); + expect(host).toContain("trailing prose"); + expect(host).not.toContain("stale pointer"); + expect(host).toContain("\n@AGENTS.orly.md\n"); + }); + + // The migration path for every repository installed before the loaders + // existed, and for every later change to the loader's own wording: the + // banner is the authorship evidence, so orly's own file is orly's to refresh. + test("a CLAUDE.md orly wrote itself is refreshed, not left stale", async () => { + const repo = newRepository(); + await Bun.write(join(repo, "CLAUDE.md"), "> **Generated by `orly`.**\n> an older loader\n\n@ELSEWHERE.md\n"); + + const result = await installInto(repo); + + expect(result.written).toContain("CLAUDE.md"); + const loader = await Bun.file(join(repo, "CLAUDE.md")).text(); + expect(loader).toContain("\n@AGENTS.md\n"); + expect(loader).not.toContain("ELSEWHERE"); + }); + + // A link orly cannot resolve is still someone's deliberate wiring. Writing + // through it would create the file it points at, which is a decision the + // repository never asked orly to make. + test("a dangling CLAUDE.md symlink is left alone, not written through", async () => { + const repo = newRepository(); + symlinkSync("nowhere.md", join(repo, "CLAUDE.md")); + + const result = await installInto(repo); + + expect(result.ok).toBe(true); + expect(result.written).not.toContain("CLAUDE.md"); + expect(result.skipped).toContain("CLAUDE.md"); + expect(existsSync(join(repo, "nowhere.md"))).toBe(false); + expect(lstatSync(join(repo, "CLAUDE.md")).isSymbolicLink()).toBe(true); + }); + test("a repository's own CLAUDE.md is reported, never edited", async () => { const repo = newRepository(); const theirs = "# Our own Claude instructions\n"; diff --git a/src/model.ts b/src/model.ts index 64629b3..2c9fca2 100644 --- a/src/model.ts +++ b/src/model.ts @@ -83,11 +83,21 @@ function isSymbolicLink(path: string): boolean { // A dangling link resolves nowhere; judge it by where it points, not where it // lands, so a link to a not-yet-created file outside the repository still fails. +// +// The directory holding it is resolved too, and has to be: the caller compares +// this against a realpath'd root, and a lexical answer put a link pointing +// squarely INSIDE the repository on the wrong side of that comparison wherever +// the repository itself sits behind a symlink — which is every macOS temporary +// directory, and plenty of real checkouts. The refusal even named the file it +// was about to protect, one path component at a time. +// +// Resolving the parent cannot fail here: this runs only where the destination +// itself lstat'd as a link, which a missing parent makes impossible. function safeRealpath(path: string): string | undefined { try { return realpathSync(path); } catch { - return resolve(dirname(path), readlinkSync(path)); + return resolve(realpathSync(dirname(path)), readlinkSync(path)); } }