From 33bbc86f9f13f81642f9c2123b9070d897b22b67 Mon Sep 17 00:00:00 2001 From: Kishore Kumar Date: Thu, 27 Aug 2026 22:54:24 +0530 Subject: [PATCH 1/2] fix(model): resolve a dangling link's parent before judging where it points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage found this. src/loaders.ts had one unreachable branch — the verdict for a CLAUDE.md symlink orly cannot resolve — and it was unreachable because the write guard refused the install first, naming the file as "outside the target repository" while it pointed squarely inside. safeRealpath resolved a dangling link lexically: `resolve(dirname(path), readlink(path))` against a caller that compares the answer to a realpath'd root. Wherever the repository itself sits behind a symlink — every macOS temporary directory, and plenty of real checkouts — the two sides were never comparable, and an in-repo link lost the comparison. Resolving the parent makes both sides canonical. A link that genuinely points out of the repository is refused exactly as before. src/loaders.ts is now at 100% line and function coverage: the stale-block replacement, the refresh of a CLAUDE.md orly wrote itself, and the dangling link the guard used to swallow. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DTMFyrbXJo3UrzQygaNtcn --- .oracle/orly.json | 2 +- package.json | 2 +- src/loaders.test.ts | 50 ++++++++++++++++++++++++++++++++++++++++++++- src/model.ts | 12 ++++++++++- 4 files changed, 62 insertions(+), 4 deletions(-) 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/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)); } } From 058cb16ced94dc7cd9a61a403f84a0d3b8954601 Mon Sep 17 00:00:00 2001 From: Kishore Kumar Date: Thu, 27 Aug 2026 22:55:32 +0530 Subject: [PATCH 2/2] docs(readme): say what happens to a CLAUDE.md symlink orly cannot resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loader section promised orly leaves your own CLAUDE.md alone, symlink included. Until the previous commit a dangling one refused the install instead, so the sentence was aspirational — and it did not say which links are refused and why. Both halves are now stated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DTMFyrbXJo3UrzQygaNtcn --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.