Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .oracle/orly.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"orly_version": "0.9.0",
"orly_version": "0.9.1",
"packs": [
"universal.authoring",
"language.zig",
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
50 changes: 49 additions & 1 deletion src/loaders.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<!-- orly:begin -->\nstale pointer\n<!-- orly:end -->\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";
Expand Down
12 changes: 11 additions & 1 deletion src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}

Expand Down
Loading