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.8.0",
"orly_version": "0.9.0",
"packs": [
"universal.authoring",
"language.zig",
Expand Down
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
> **Generated by `orly`.**
> Claude Code loads this file at session start and no other, so this is where
> this repository's rules get in. The import below pulls in `AGENTS.md`, and
> whatever that file imports in turn — orly's rules among them.
>
> Delete this file to opt out; orly writes it only when it is missing, and
> never edits a CLAUDE.md it did not write.

@AGENTS.md
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ your-repo/
├── AGENTS.md ─────────────── yours. untouched, except one delimited pointer block
├── AGENTS.orly.md ────────── the generated rules: safety, dispatch router, lifecycle
├── CLAUDE.md ─────────────── one import line; the only file Claude Code loads itself
├── opencode.json ─────────── names both rule files; opencode loads nothing by default
├── dispatch/*.md ─────────── one rule page per kind of work
├── audits/*.sh ───────────── the deterministic gates
├── docs/*.md ─────────────── the standards those rules cite
Expand Down Expand Up @@ -191,8 +194,12 @@ Each edit activates the rule page for that file kind. A gate proves whether the
|---|---|---|
| `AGENTS.md` | **you** | untouched, except one delimited pointer block |
| `AGENTS.orly.md` | orly | rewritten |
| `CLAUDE.md` | **you** | written only when missing; never edited after |
| `opencode.json` | **you** | gains the two rule files in `instructions`; nothing else touched |

`AGENTS.md` stays yours — orly writes its rules beside it and points at them from one delimited block.

Every agent runtime auto-loads `AGENTS.md`, so it stays yours. orly writes its rules beside it and adds a pointer so they get read.
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.

> [!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: 2 additions & 0 deletions audits/agents-md.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,8 @@ siblings.
| 26.5 | Does the `agentsfleet` profile map CONFORM to `make harness-verify` while VERIFY remains behavior proof? | YES |
| 26.6 | Does `README.md` document initialization, explicit synchronization, status, and doctor commands for new repositories? | YES |
| 26.7 | Does `orly gate pr` discover a spec closed to `done/` on the branch (its `Branch:` header names the branch) and run the spec criteria — including `spec.moved`, `spec.baseline`, `spec.ordering`, and `spec.deferrals` — instead of skip-passing as spec-less? | YES |
| 26.8 | Does the install write one loader per runtime that has a deterministic way into context — a bare, unbackticked `@AGENTS.orly.md` import line inside the pointer block, a `CLAUDE.md` carrying `@AGENTS.md` where the repository has none, and both rule files in `opencode.json`'s `instructions` — so delivery never depends on an agent noticing a markdown link? | YES |
| 26.9 | Are those loaders the repository's files rather than orly's — absent from `managed`, `CLAUDE.md` written only when missing (a symlink at the host counted as delivery, any other symlink left alone), `opencode.json` edited only by appending a missing `instructions` entry, and an unparseable one left byte for byte? | YES |

### Scenario 27 — Rule-path residence & reachability

Expand Down
6 changes: 6 additions & 0 deletions opencode.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "https://opencode.ai/config.json",
"instructions": [
"AGENTS.md"
]
}
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.8.0",
"version": "0.9.0",
"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
6 changes: 5 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,11 @@ Install (the repository is the unit — no checkout of this package required):
seeded .oracle/orly.json. A repository that
already has an AGENTS.md keeps it: orly's
rules land as AGENTS.orly.md, reached by a
pointer block in the file you own.
pointer block in the file you own. One
import line lands per runtime that needs
one (CLAUDE.md, opencode.json), so the
rules load rather than wait to be noticed;
a loader you wrote yourself is left alone.
orly update [--force] [--with <PACK>] [--dry-run] [--json]
re-materialise at the installed engine version

Expand Down
83 changes: 12 additions & 71 deletions src/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,11 @@ import { dirname, extname, join, relative, resolve } from "node:path";
import { UNSCOPED_ENVIRONMENT } from "./git_env";
import { CONFIG_PATH, readConfig, seedConfig, selectPacks, writeConfig } from "./config";
import { applyMode, assertWritableInside, hashContent, isBelow, isString, JsonObject, modeLabel, objectArray, objectValue, OrlyError, RulesModel, stringArray } from "./model";
import { AGENTS_FILENAME, GENERATED_BANNER, installLoaders, Layout, loaderTargets, ORLY_AGENTS_FILENAME } from "./loaders";
import { referenceClosureErrors, renderProfileText } from "./references";
import { Renderer } from "./render";
import { HOOK_INVOCATION_ENV, HOOK_INVOCATION_VALUE } from "./telemetry";

const AGENTS_FILENAME = "AGENTS.md";
const ORLY_AGENTS_FILENAME = "AGENTS.orly.md";
const GENERATED_BANNER = "> **Generated by `orly`.**";
const NEWLINE = "\n";
const POINTER_OPEN = "<!-- orly:begin -->";
const POINTER_CLOSE = "<!-- orly:end -->";
const HOOKS_DIRECTORY = ".githooks";
const REGISTRY_PACKS_LABEL = "registry packs";
const STAGE_PREFIX = "orly-install-";
Expand All @@ -29,7 +24,6 @@ const GIT_CONFIG_SUBCOMMAND = "config";
const HOOKS_PATH_KEY = "core.hooksPath";
const MANAGED_FILE_KIND = "managed file";
const HOOK_KIND = "hook";
const POINTER_HOST_KIND = "pointer host";

export type InstallError = { path: string; message: string; suggestion: string };

Expand Down Expand Up @@ -99,22 +93,24 @@ export async function install(model: RulesModel, options: InstallOptions): Promi
}
const configEscape = escapesTarget(targetRoot, CONFIG_PATH, "config");
if (configEscape) refusals.push(configEscape);
// The pointer host is a write like any other, and the only one orly aims at
// a file it does not own — so it needs the guard most, not least. A committed
// The loaders are writes like any other, and the only ones orly aims at files
// it does not own — so they need the guard most, not least. A committed
// AGENTS.md symlink pointing outside the repository otherwise carried the
// block wherever the link led, and reported it as an ordinary success.
if (layout.pointerHost) {
const hostEscape = escapesTarget(targetRoot, layout.pointerHost, POINTER_HOST_KIND);
if (hostEscape) refusals.push(hostEscape);
// pointer block wherever the link led, and reported it as an ordinary success.
for (const [target, kind] of loaderTargets(layout)) {
const escape = escapesTarget(targetRoot, target, kind);
if (escape) refusals.push(escape);
}
if (refusals.length > 0) return { ok: false, packs, written: [], skipped, errors: refusals };

const closure = await stageAndCommit(model, targetRoot, planned, written);
if (closure.length > 0) return { ok: false, packs, written: [], skipped, errors: closure };

// After the managed files land, so a repository whose install refused for any
// other reason never carries a pointer to rules that were not written.
if (layout.pointerHost && await upsertPointer(targetRoot, layout.pointerHost, layout.orlyFile)) written.push(layout.pointerHost);
// other reason never carries a loader pointing at rules that were not written.
const loaders = await installLoaders(targetRoot, layout);
written.push(...loaders.written);
skipped.push(...loaders.skipped);
const hooks = options.installHooks ? await installHooks(targetRoot) : { written: [], all: [] };
written.push(...hooks.written);
skipped.push(...hooks.all.filter((path) => !hooks.written.includes(path)));
Expand Down Expand Up @@ -327,7 +323,7 @@ async function planFiles(model: RulesModel, packs: string[], commands: Record<st
// checkout's own .oracle/orly.json predates managed tracking, so a
// managed-based test would treat orly's own render as a stranger's file and
// push it aside on the next update.
function resolveLayout(model: RulesModel, targetRoot: string): { orlyFile: string; pointerHost?: string } {
function resolveLayout(model: RulesModel, targetRoot: string): Layout {
// The checkout that authors the rules holds them as sources, not as an
// installed copy — the same reason pack files inside the target are skipped
// rather than written. Its AGENTS.md IS the render, and the payload ships it
Expand All @@ -341,61 +337,6 @@ function resolveLayout(model: RulesModel, targetRoot: string): { orlyFile: strin
return { orlyFile: ORLY_AGENTS_FILENAME, pointerHost: AGENTS_FILENAME };
}

// The one edit orly makes to a file it does not own. Delimited so a re-run
// replaces exactly what the last run wrote and nothing either side of it, and
// so a user who wants orly out can delete the block by sight.
function pointerBlock(orlyFile: string): string {
return [
POINTER_OPEN,
`**Engineering harness:** read [\`${orlyFile}\`](${orlyFile}) as well — it carries the safety rules,`,
"the dispatch router that names which rule page to read before which edit, and the lifecycle",
"this repository gates on. Where the two disagree, this file wins.",
POINTER_CLOSE,
].join(NEWLINE);
}

// What a repository with no rules of its own starts with: the pointer, and a
// visible invitation to write here. Without the second line the stub reads as
// an orly artifact and nobody touches it — which is the trap the single-file
// layout had, moved one file over.
function stubHost(block: string): string {
return [
"# Repository rules",
"",
block,
"",
"Write this repository's own rules below. orly never edits this file",
"outside the block above, so nothing here is lost to an `orly update`.",
].join(NEWLINE);
}

async function upsertPointer(targetRoot: string, host: string, orlyFile: string): Promise<boolean> {
// Re-checked at the write, as every other destination is: the up-front
// refusal pass guards the plan, this guards the target that could have
// changed underneath the run.
assertWritableInside(targetRoot, host, POINTER_HOST_KIND);
const path = join(targetRoot, host);
const block = pointerBlock(orlyFile);
// No file, or one orly generated under an older layout where it owned this
// name. Neither holds a line the repository wrote, so both become the stub —
// which is the migration for anything installed before the split.
if (!existsSync(path) || (await Bun.file(path).text()).startsWith(GENERATED_BANNER)) {
await Bun.write(path, `${stubHost(block)}\n`);
return true;
}
const current = await Bun.file(path).text();
const open = current.indexOf(POINTER_OPEN);
const close = current.indexOf(POINTER_CLOSE);
if (open >= 0 && close > open) {
const replaced = `${current.slice(0, open)}${block}${current.slice(close + POINTER_CLOSE.length)}`;
if (replaced === current) return false;
await Bun.write(path, replaced);
return true;
}
await Bun.write(path, `${current.replace(/\s*$/, "")}\n\n${block}\n`);
return true;
}

function resolved(path: string): string {
try {
return realpathSync(path);
Expand Down
148 changes: 148 additions & 0 deletions src/loaders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { afterEach, describe, expect, test } from "bun:test";
import { existsSync, symlinkSync } from "node:fs";
import { join } from "node:path";

import { cleanupTemporaryDirectories, newRepository, ROOT } from "./gates_test_support";
import { install } from "./install";
import { readConfig } from "./config";
import { RulesModel } from "./model";

afterEach(cleanupTemporaryDirectories);

const VERSION = "0.9.0";

async function installInto(repo: string) {
const model = await RulesModel.load(ROOT);
return install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: VERSION });
}

describe("runtime loaders", () => {
// The gap these close: every runtime decides for itself what it loads, and a
// markdown link is a request an agent may not honour. Each assertion below is
// one runtime's deterministic way in.
test("a fresh repository gets a loader for every runtime that has one", async () => {
const repo = newRepository();

const result = await installInto(repo);

expect(result.ok).toBe(true);
expect(result.written).toContain("CLAUDE.md");
expect(result.written).toContain("opencode.json");
// Claude Code reads CLAUDE.md and nothing else, so the chain starts there
// and passes through the repository's own file on the way to orly's.
expect(await Bun.file(join(repo, "CLAUDE.md")).text()).toContain("\n@AGENTS.md\n");
expect(JSON.parse(await Bun.file(join(repo, "opencode.json")).text()).instructions)
.toEqual(["AGENTS.md", "AGENTS.orly.md"]);
});

// The line that does the work. Backticked it is decoration, which is what the
// pointer block shipped as before: a runtime resolves an import only when it
// stands alone and unquoted.
test("the pointer block carries a bare import line, not a fenced one", async () => {
const repo = newRepository();

await installInto(repo);

const host = await Bun.file(join(repo, "AGENTS.md")).text();
expect(host).toContain("\n@AGENTS.orly.md\n");
expect(host).not.toContain("`@AGENTS.orly.md`");
});

test("loaders are idempotent — a second install writes nothing", async () => {
const repo = newRepository();
await installInto(repo);

const second = await installInto(repo);

expect(second.written).toEqual([]);
expect(second.skipped).toContain("CLAUDE.md");
expect(second.skipped).toContain("opencode.json");
});

// The loaders are the repository's files, not orly's: they carry the same
// authorship rule as the pointer host and stay out of the managed list, so
// `orly doctor` never reports drift on a file its owner is free to edit.
test("loaders are never claimed as managed files", async () => {
const repo = newRepository();

await installInto(repo);

const managed = (await readConfig(repo))?.managed ?? [];
expect(managed).not.toContain("CLAUDE.md");
expect(managed).not.toContain("opencode.json");
expect(managed).not.toContain("AGENTS.md");
});

test("a repository's own CLAUDE.md is reported, never edited", async () => {
const repo = newRepository();
const theirs = "# Our own Claude instructions\n";
await Bun.write(join(repo, "CLAUDE.md"), theirs);

const result = await installInto(repo);

expect(result.ok).toBe(true);
expect(await Bun.file(join(repo, "CLAUDE.md")).text()).toBe(theirs);
expect(result.written).not.toContain("CLAUDE.md");
expect(result.skipped).toContain("CLAUDE.md");
});

// The other supported wiring, and the one a delimited edit would ruin: a
// write through the link lands in AGENTS.md and imports it into itself.
test("a CLAUDE.md symlinked at the host counts as delivery and is left alone", async () => {
const repo = newRepository();
await Bun.write(join(repo, "AGENTS.md"), "# Ours\n");
symlinkSync("AGENTS.md", join(repo, "CLAUDE.md"));

const result = await installInto(repo);

expect(result.ok).toBe(true);
expect(result.written).not.toContain("CLAUDE.md");
const host = await Bun.file(join(repo, "AGENTS.md")).text();
expect(host).toContain("# Ours");
// One block, reached through the link — not a second copy written via it.
expect(host.split("<!-- orly:begin -->").length - 1).toBe(1);
});

test("an existing opencode.json keeps its own keys and gains only what is missing", async () => {
const repo = newRepository();
await Bun.write(join(repo, "opencode.json"), JSON.stringify({ model: "anthropic/claude-fable-5", instructions: ["AGENTS.md"] }));

const result = await installInto(repo);

const config = JSON.parse(await Bun.file(join(repo, "opencode.json")).text());
expect(config.model).toBe("anthropic/claude-fable-5");
expect(config.instructions).toEqual(["AGENTS.md", "AGENTS.orly.md"]);
expect(result.written).toContain("opencode.json");
});

// Rewriting a file whose shape we do not understand is how an install eats
// someone's settings.
test("an unparseable opencode.json survives byte for byte", async () => {
const repo = newRepository();
const theirs = "{ not json at all\n";
await Bun.write(join(repo, "opencode.json"), theirs);

const result = await installInto(repo);

expect(result.ok).toBe(true);
expect(await Bun.file(join(repo, "opencode.json")).text()).toBe(theirs);
expect(result.skipped).toContain("opencode.json");
});

// Same guard the pointer host earned: a committed link out of the repository
// otherwise carries a write wherever it leads and reports it as a success.
test("refuses a loader symlinked outside the target repository", async () => {
const outside = newRepository();
const victim = join(outside, "victim.md");
await Bun.write(victim, "not orly's to write\n");
const repo = newRepository();
symlinkSync(victim, join(repo, "opencode.json"));

const result = await installInto(repo);

expect(result.ok).toBe(false);
expect(result.errors.some((error) => error.path === "opencode.json")).toBeTrue();
expect(await Bun.file(victim).text()).toBe("not orly's to write\n");
expect(existsSync(join(repo, "AGENTS.orly.md"))).toBe(false);
});
});
Loading
Loading