From a53151df67d76d5767735fff40a95c41db2c4889 Mon Sep 17 00:00:00 2001 From: Kishore Kumar Date: Thu, 27 Aug 2026 22:44:29 +0530 Subject: [PATCH 1/2] feat(install): deliver the rules, don't just install them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.orly.md was reachable only through a prose pointer an agent had to notice and follow. Nothing loaded it: no import, no symlink, no hook. The same held one file up — Claude Code loads CLAUDE.md and nothing else, so a repository with only an AGENTS.md handed its agent no rules at all. This checkout was itself an example. So the install now writes one loader per runtime that has a deterministic way in: a bare `@AGENTS.orly.md` import inside the pointer block, a CLAUDE.md importing the host where the repository has none, and both rule files in opencode.json's `instructions`. Codex and Amp auto-load AGENTS.md and reach orly's rules through the block already there. The loaders are the repository's files, not orly's: absent from `managed`, written only where nothing was written before, and never rewritten. A CLAUDE.md symlinked at the host counts as delivery and is left alone — editing through it would import AGENTS.md into itself. Delivery logic moves to src/loaders.ts, which is the concern's own unit and takes install.ts from 452 lines to 393. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DTMFyrbXJo3UrzQygaNtcn --- .oracle/orly.json | 2 +- CLAUDE.md | 9 ++ README.md | 9 +- audits/agents-md.md | 2 + opencode.json | 6 ++ package.json | 2 +- src/cli.ts | 6 +- src/install.ts | 83 +++------------- src/loaders.test.ts | 148 ++++++++++++++++++++++++++++ src/loaders.ts | 235 ++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 427 insertions(+), 75 deletions(-) create mode 100644 CLAUDE.md create mode 100644 opencode.json create mode 100644 src/loaders.test.ts create mode 100644 src/loaders.ts diff --git a/.oracle/orly.json b/.oracle/orly.json index 132c450..dd8d4c6 100644 --- a/.oracle/orly.json +++ b/.oracle/orly.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "orly_version": "0.8.0", + "orly_version": "0.9.0", "packs": [ "universal.authoring", "language.zig", diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9a71acf --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index d88d108..a5b9f59 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/audits/agents-md.md b/audits/agents-md.md index 89c8fc4..7f66ea7 100644 --- a/audits/agents-md.md +++ b/audits/agents-md.md @@ -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 diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..333df1e --- /dev/null +++ b/opencode.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://opencode.ai/config.json", + "instructions": [ + "AGENTS.md" + ] +} diff --git a/package.json b/package.json index f31c83b..6d4a56b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli.ts b/src/cli.ts index 1343bae..0f036a7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 ] [--dry-run] [--json] re-materialise at the installed engine version diff --git a/src/install.ts b/src/install.ts index 343229a..5f20ff4 100644 --- a/src/install.ts +++ b/src/install.ts @@ -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 = ""; -const POINTER_CLOSE = ""; const HOOKS_DIRECTORY = ".githooks"; const REGISTRY_PACKS_LABEL = "registry packs"; const STAGE_PREFIX = "orly-install-"; @@ -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 }; @@ -99,13 +93,13 @@ 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 }; @@ -113,8 +107,10 @@ export async function install(model: RulesModel, options: InstallOptions): Promi 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))); @@ -327,7 +323,7 @@ async function planFiles(model: RulesModel, packs: string[], commands: Record { - // 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); diff --git a/src/loaders.test.ts b/src/loaders.test.ts new file mode 100644 index 0000000..2ffc783 --- /dev/null +++ b/src/loaders.test.ts @@ -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("").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); + }); +}); diff --git a/src/loaders.ts b/src/loaders.ts new file mode 100644 index 0000000..3583f8e --- /dev/null +++ b/src/loaders.ts @@ -0,0 +1,235 @@ +import { existsSync, lstatSync, realpathSync } from "node:fs"; +import { join } from "node:path"; + +import { assertWritableInside, isObject } from "./model"; + +// How orly's rules reach an agent's context, as opposed to how they reach the +// disk. Installing the file is not delivering it: every runtime decides for +// itself what it loads at session start, and only one of the four this +// repository grades against loads AGENTS.md unprompted. A pointer an agent has +// to notice and follow is a hope; an import a runtime resolves is a mechanism. +// So the rules land beside a small set of loaders, one per runtime that has a +// deterministic way in, and the runtimes with no way in are covered by the +// read-record gate instead. +// +// Every file here belongs to the repository, not to orly — they are the same +// class as the pointer host: written when absent, edited only inside a +// delimited block, never listed in `managed`, and never replaced wholesale +// once someone else has written one. + +export const AGENTS_FILENAME = "AGENTS.md"; +export const ORLY_AGENTS_FILENAME = "AGENTS.orly.md"; +export const GENERATED_BANNER = "> **Generated by `orly`.**"; +export const POINTER_HOST_KIND = "pointer host"; + +const CLAUDE_FILENAME = "CLAUDE.md"; +const OPENCODE_FILENAME = "opencode.json"; +const OPENCODE_SCHEMA = "https://opencode.ai/config.json"; +const INSTRUCTIONS_KEY = "instructions"; +const LOADER_KIND = "runtime loader"; +const POINTER_OPEN = ""; +const POINTER_CLOSE = ""; +const NEWLINE = "\n"; +const JSON_INDENT = 2; + +export type Layout = { orlyFile: string; pointerHost?: string }; +export type LoaderResult = { written: string[]; skipped: string[] }; +// written — orly created or refreshed it. current — already exactly right. +// theirs — the repository answered this question itself; leave it alone. +type Verdict = "written" | "current" | "theirs"; + +// Every destination orly aims at a file it does not own, with the kind name +// the refusal will carry. Exported so the install can guard them in its +// up-front pass: a committed symlink escaping the repository must be refused +// before anything is staged, not discovered at the write. +export function loaderTargets(layout: Layout): Array<[string, string]> { + const targets: Array<[string, string]> = [[CLAUDE_FILENAME, LOADER_KIND], [OPENCODE_FILENAME, LOADER_KIND]]; + if (layout.pointerHost) targets.unshift([layout.pointerHost, POINTER_HOST_KIND]); + return targets; +} + +// Run after the managed files land, so a repository whose install refused for +// any other reason never carries a loader pointing at rules that were not +// written. +export async function installLoaders(targetRoot: string, layout: Layout): Promise { + const written: string[] = []; + const skipped: string[] = []; + const record = (path: string, verdict: Verdict) => (verdict === "written" ? written : skipped).push(path); + + // The host is what a loader imports: the repository's own file, which the + // pointer block chains onward to orly's. Importing orly's file directly + // would deliver the harness and drop the repository's own rules, which the + // block itself says outrank it. + const host = layout.pointerHost ?? layout.orlyFile; + if (layout.pointerHost) record(layout.pointerHost, await upsertPointer(targetRoot, layout.pointerHost, layout.orlyFile)); + record(CLAUDE_FILENAME, await upsertClaudeLoader(targetRoot, host)); + record(OPENCODE_FILENAME, await upsertOpencodeLoader(targetRoot, [...new Set([host, layout.orlyFile])])); + return { written, skipped }; +} + +// 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. +// +// The bare `@` line is the load-bearing half: Claude Code resolves it as +// an import, recursively, so the rules arrive with the file instead of waiting +// for an agent to follow a link. It must stay unbackticked and alone on its +// line — an import inside a code span stays literal text, which is exactly how +// this block spent its first release delivering nothing. +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.", + "", + "The line below is an import, not decoration: a runtime that resolves it loads those rules", + "with this file, and one that does not still has the link above.", + "", + `@${orlyFile}`, + 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 { + // 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 "written"; + } + 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 "current"; + await Bun.write(path, replaced); + return "written"; + } + await Bun.write(path, `${current.replace(/\s*$/, "")}\n\n${block}\n`); + return "written"; +} + +// Claude Code auto-loads CLAUDE.md and nothing else — an AGENTS.md reaches it +// only through an import or a symlink someone made by hand. Without this file +// the promise that a teammate's clone is governed with nothing installed is +// false in the runtime most of them are running. +// +// Written ONLY where the repository has none. A CLAUDE.md that already exists +// is an answer its author gave to this exact question, and the common form of +// that answer is a symlink to AGENTS.md — where a delimited edit would land in +// the host file and import it into itself. Recognising the link as delivery +// and leaving every other CLAUDE.md alone keeps one rule for both: orly writes +// this file or it does not touch it. +async function upsertClaudeLoader(targetRoot: string, host: string): Promise { + assertWritableInside(targetRoot, CLAUDE_FILENAME, LOADER_KIND); + const path = join(targetRoot, CLAUDE_FILENAME); + const link = linkVerdict(targetRoot, path, host); + if (link) return link; + const content = claudeLoader(host); + if (!existsSync(path)) { + await Bun.write(path, content); + return "written"; + } + const current = await Bun.file(path).text(); + if (current === content) return "current"; + // An older loader of orly's own making is orly's to refresh; anything else + // is the repository's file and is reported, not edited. + if (!current.startsWith(GENERATED_BANNER)) return "theirs"; + await Bun.write(path, content); + return "written"; +} + +function claudeLoader(host: string): string { + return [ + GENERATED_BANNER, + "> 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 \`${host}\`, 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.", + "", + `@${host}`, + "", + ].join(NEWLINE); +} + +// A CLAUDE.md symlinked at the host is the other supported wiring, and it +// delivers the same bytes: the runtime reads the link and lands in the file +// the import chain starts from. Resolving both sides matters on macOS, where +// the repository itself routinely sits behind a link. +// +// Any other link is still someone's deliberate wiring, and is left alone for +// the reason the whole loader is: a write through a symlink lands in the file +// it points at, not the one orly named. Undefined = not a link, decide on +// content. +function linkVerdict(targetRoot: string, path: string, host: string): Verdict | undefined { + try { + if (!lstatSync(path).isSymbolicLink()) return undefined; + } catch { + return undefined; + } + try { + return realpathSync(path) === realpathSync(join(targetRoot, host)) ? "current" : "theirs"; + } catch { + return "theirs"; + } +} + +// opencode loads nothing by default: instructions are the paths its config +// names, so a rules file it was never told about is a rules file it never +// reads. Both entries are listed because opencode has no auto-load to fall +// back on — naming only orly's half would deliver the harness in this runtime +// and the repository's own rules in every other one. +// +// An existing config is edited by adding what is missing and nothing else. A +// config orly cannot parse is left exactly as it is: rewriting a file whose +// shape we do not understand is how an install eats someone's settings. +async function upsertOpencodeLoader(targetRoot: string, entries: string[]): Promise { + assertWritableInside(targetRoot, OPENCODE_FILENAME, LOADER_KIND); + const path = join(targetRoot, OPENCODE_FILENAME); + if (!existsSync(path)) { + await Bun.write(path, serialise({ $schema: OPENCODE_SCHEMA, [INSTRUCTIONS_KEY]: entries })); + return "written"; + } + let config: unknown; + try { + config = JSON.parse(await Bun.file(path).text()); + } catch { + return "theirs"; + } + if (!isObject(config)) return "theirs"; + const listed = Array.isArray(config[INSTRUCTIONS_KEY]) ? config[INSTRUCTIONS_KEY].map(String) : []; + const missing = entries.filter((entry) => !listed.includes(entry)); + if (missing.length === 0) return "current"; + await Bun.write(path, serialise({ ...config, [INSTRUCTIONS_KEY]: [...listed, ...missing] })); + return "written"; +} + +function serialise(config: object): string { + return `${JSON.stringify(config, undefined, JSON_INDENT)}\n`; +} From 272b31134e23fb88bb51d1ca0576d70060b9dca5 Mon Sep 17 00:00:00 2001 From: Kishore Kumar Date: Thu, 27 Aug 2026 22:45:59 +0530 Subject: [PATCH 2/2] fix(loaders): name the verdict literals per RULE UFS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three-way Verdict union repeated its own strings at every return. The push gate saw them the moment the file became tracked, which the working tree could not — ufs.sh scans git ls-files. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DTMFyrbXJo3UrzQygaNtcn --- src/loaders.ts | 43 ++++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/loaders.ts b/src/loaders.ts index 3583f8e..a94c673 100644 --- a/src/loaders.ts +++ b/src/loaders.ts @@ -34,9 +34,14 @@ const JSON_INDENT = 2; export type Layout = { orlyFile: string; pointerHost?: string }; export type LoaderResult = { written: string[]; skipped: string[] }; -// written — orly created or refreshed it. current — already exactly right. -// theirs — the repository answered this question itself; leave it alone. -type Verdict = "written" | "current" | "theirs"; + +// What one loader destination came to. WRITTEN — orly created or refreshed it. +// CURRENT — already exactly right, by orly's hand or by someone else's wiring. +// THEIRS — the repository answered this question itself; leave it alone. +const WRITTEN = "written"; +const CURRENT = "current"; +const THEIRS = "theirs"; +type Verdict = typeof WRITTEN | typeof CURRENT | typeof THEIRS; // Every destination orly aims at a file it does not own, with the kind name // the refusal will carry. Exported so the install can guard them in its @@ -54,7 +59,7 @@ export function loaderTargets(layout: Layout): Array<[string, string]> { export async function installLoaders(targetRoot: string, layout: Layout): Promise { const written: string[] = []; const skipped: string[] = []; - const record = (path: string, verdict: Verdict) => (verdict === "written" ? written : skipped).push(path); + const record = (path: string, verdict: Verdict) => (verdict === WRITTEN ? written : skipped).push(path); // The host is what a loader imports: the repository's own file, which the // pointer block chains onward to orly's. Importing orly's file directly @@ -118,19 +123,19 @@ async function upsertPointer(targetRoot: string, host: string, orlyFile: string) // 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 "written"; + return WRITTEN; } 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 "current"; + if (replaced === current) return CURRENT; await Bun.write(path, replaced); - return "written"; + return WRITTEN; } await Bun.write(path, `${current.replace(/\s*$/, "")}\n\n${block}\n`); - return "written"; + return WRITTEN; } // Claude Code auto-loads CLAUDE.md and nothing else — an AGENTS.md reaches it @@ -152,15 +157,15 @@ async function upsertClaudeLoader(targetRoot: string, host: string): Promise !listed.includes(entry)); - if (missing.length === 0) return "current"; + if (missing.length === 0) return CURRENT; await Bun.write(path, serialise({ ...config, [INSTRUCTIONS_KEY]: [...listed, ...missing] })); - return "written"; + return WRITTEN; } function serialise(config: object): string {