@agentsfleet/orly 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,6 +81,9 @@ your-repo/
81
81
  ├── AGENTS.md ─────────────── yours. untouched, except one delimited pointer block
82
82
  ├── AGENTS.orly.md ────────── the generated rules: safety, dispatch router, lifecycle
83
83
  │
84
+ ├── CLAUDE.md ─────────────── one import line; the only file Claude Code loads itself
85
+ ├── opencode.json ─────────── names both rule files; opencode loads nothing by default
86
+ │
84
87
  ├── dispatch/*.md ─────────── one rule page per kind of work
85
88
  ├── audits/*.sh ───────────── the deterministic gates
86
89
  ├── 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
191
194
  |---|---|---|
192
195
  | `AGENTS.md` | **you** | untouched, except one delimited pointer block |
193
196
  | `AGENTS.orly.md` | orly | rewritten |
197
+ | `CLAUDE.md` | **you** | written only when missing; never edited after |
198
+ | `opencode.json` | **you** | gains the two rule files in `instructions`; nothing else touched |
199
+
200
+ `AGENTS.md` stays yours — orly writes its rules beside it and points at them from one delimited block.
194
201
 
195
- 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.
202
+ 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.
196
203
 
197
204
  > [!WARNING]
198
205
  > 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.
@@ -335,6 +335,8 @@ siblings.
335
335
  | 26.5 | Does the `agentsfleet` profile map CONFORM to `make harness-verify` while VERIFY remains behavior proof? | YES |
336
336
  | 26.6 | Does `README.md` document initialization, explicit synchronization, status, and doctor commands for new repositories? | YES |
337
337
  | 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 |
338
+ | 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 |
339
+ | 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 |
338
340
 
339
341
  ### Scenario 27 — Rule-path residence & reachability
340
342
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentsfleet/orly",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Repository-scoped engineering harness: renders agent rules, materialises the gates that enforce them, and proves the PR boundary \u2014 for any coding agent.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -308,7 +308,11 @@ Install (the repository is the unit — no checkout of this package required):
308
308
  seeded .oracle/orly.json. A repository that
309
309
  already has an AGENTS.md keeps it: orly's
310
310
  rules land as AGENTS.orly.md, reached by a
311
- pointer block in the file you own.
311
+ pointer block in the file you own. One
312
+ import line lands per runtime that needs
313
+ one (CLAUDE.md, opencode.json), so the
314
+ rules load rather than wait to be noticed;
315
+ a loader you wrote yourself is left alone.
312
316
  orly update [--force] [--with <PACK>] [--dry-run] [--json]
313
317
  re-materialise at the installed engine version
314
318
 
package/src/install.ts CHANGED
@@ -4,16 +4,11 @@ import { dirname, extname, join, relative, resolve } from "node:path";
4
4
  import { UNSCOPED_ENVIRONMENT } from "./git_env";
5
5
  import { CONFIG_PATH, readConfig, seedConfig, selectPacks, writeConfig } from "./config";
6
6
  import { applyMode, assertWritableInside, hashContent, isBelow, isString, JsonObject, modeLabel, objectArray, objectValue, OrlyError, RulesModel, stringArray } from "./model";
7
+ import { AGENTS_FILENAME, GENERATED_BANNER, installLoaders, Layout, loaderTargets, ORLY_AGENTS_FILENAME } from "./loaders";
7
8
  import { referenceClosureErrors, renderProfileText } from "./references";
8
9
  import { Renderer } from "./render";
9
10
  import { HOOK_INVOCATION_ENV, HOOK_INVOCATION_VALUE } from "./telemetry";
10
11
 
11
- const AGENTS_FILENAME = "AGENTS.md";
12
- const ORLY_AGENTS_FILENAME = "AGENTS.orly.md";
13
- const GENERATED_BANNER = "> **Generated by `orly`.**";
14
- const NEWLINE = "\n";
15
- const POINTER_OPEN = "<!-- orly:begin -->";
16
- const POINTER_CLOSE = "<!-- orly:end -->";
17
12
  const HOOKS_DIRECTORY = ".githooks";
18
13
  const REGISTRY_PACKS_LABEL = "registry packs";
19
14
  const STAGE_PREFIX = "orly-install-";
@@ -29,7 +24,6 @@ const GIT_CONFIG_SUBCOMMAND = "config";
29
24
  const HOOKS_PATH_KEY = "core.hooksPath";
30
25
  const MANAGED_FILE_KIND = "managed file";
31
26
  const HOOK_KIND = "hook";
32
- const POINTER_HOST_KIND = "pointer host";
33
27
 
34
28
  export type InstallError = { path: string; message: string; suggestion: string };
35
29
 
@@ -99,13 +93,13 @@ export async function install(model: RulesModel, options: InstallOptions): Promi
99
93
  }
100
94
  const configEscape = escapesTarget(targetRoot, CONFIG_PATH, "config");
101
95
  if (configEscape) refusals.push(configEscape);
102
- // The pointer host is a write like any other, and the only one orly aims at
103
- // a file it does not own — so it needs the guard most, not least. A committed
96
+ // The loaders are writes like any other, and the only ones orly aims at files
97
+ // it does not own — so they need the guard most, not least. A committed
104
98
  // AGENTS.md symlink pointing outside the repository otherwise carried the
105
- // block wherever the link led, and reported it as an ordinary success.
106
- if (layout.pointerHost) {
107
- const hostEscape = escapesTarget(targetRoot, layout.pointerHost, POINTER_HOST_KIND);
108
- if (hostEscape) refusals.push(hostEscape);
99
+ // pointer block wherever the link led, and reported it as an ordinary success.
100
+ for (const [target, kind] of loaderTargets(layout)) {
101
+ const escape = escapesTarget(targetRoot, target, kind);
102
+ if (escape) refusals.push(escape);
109
103
  }
110
104
  if (refusals.length > 0) return { ok: false, packs, written: [], skipped, errors: refusals };
111
105
 
@@ -113,8 +107,10 @@ export async function install(model: RulesModel, options: InstallOptions): Promi
113
107
  if (closure.length > 0) return { ok: false, packs, written: [], skipped, errors: closure };
114
108
 
115
109
  // After the managed files land, so a repository whose install refused for any
116
- // other reason never carries a pointer to rules that were not written.
117
- if (layout.pointerHost && await upsertPointer(targetRoot, layout.pointerHost, layout.orlyFile)) written.push(layout.pointerHost);
110
+ // other reason never carries a loader pointing at rules that were not written.
111
+ const loaders = await installLoaders(targetRoot, layout);
112
+ written.push(...loaders.written);
113
+ skipped.push(...loaders.skipped);
118
114
  const hooks = options.installHooks ? await installHooks(targetRoot) : { written: [], all: [] };
119
115
  written.push(...hooks.written);
120
116
  skipped.push(...hooks.all.filter((path) => !hooks.written.includes(path)));
@@ -327,7 +323,7 @@ async function planFiles(model: RulesModel, packs: string[], commands: Record<st
327
323
  // checkout's own .oracle/orly.json predates managed tracking, so a
328
324
  // managed-based test would treat orly's own render as a stranger's file and
329
325
  // push it aside on the next update.
330
- function resolveLayout(model: RulesModel, targetRoot: string): { orlyFile: string; pointerHost?: string } {
326
+ function resolveLayout(model: RulesModel, targetRoot: string): Layout {
331
327
  // The checkout that authors the rules holds them as sources, not as an
332
328
  // installed copy — the same reason pack files inside the target are skipped
333
329
  // rather than written. Its AGENTS.md IS the render, and the payload ships it
@@ -341,61 +337,6 @@ function resolveLayout(model: RulesModel, targetRoot: string): { orlyFile: strin
341
337
  return { orlyFile: ORLY_AGENTS_FILENAME, pointerHost: AGENTS_FILENAME };
342
338
  }
343
339
 
344
- // The one edit orly makes to a file it does not own. Delimited so a re-run
345
- // replaces exactly what the last run wrote and nothing either side of it, and
346
- // so a user who wants orly out can delete the block by sight.
347
- function pointerBlock(orlyFile: string): string {
348
- return [
349
- POINTER_OPEN,
350
- `**Engineering harness:** read [\`${orlyFile}\`](${orlyFile}) as well — it carries the safety rules,`,
351
- "the dispatch router that names which rule page to read before which edit, and the lifecycle",
352
- "this repository gates on. Where the two disagree, this file wins.",
353
- POINTER_CLOSE,
354
- ].join(NEWLINE);
355
- }
356
-
357
- // What a repository with no rules of its own starts with: the pointer, and a
358
- // visible invitation to write here. Without the second line the stub reads as
359
- // an orly artifact and nobody touches it — which is the trap the single-file
360
- // layout had, moved one file over.
361
- function stubHost(block: string): string {
362
- return [
363
- "# Repository rules",
364
- "",
365
- block,
366
- "",
367
- "Write this repository's own rules below. orly never edits this file",
368
- "outside the block above, so nothing here is lost to an `orly update`.",
369
- ].join(NEWLINE);
370
- }
371
-
372
- async function upsertPointer(targetRoot: string, host: string, orlyFile: string): Promise<boolean> {
373
- // Re-checked at the write, as every other destination is: the up-front
374
- // refusal pass guards the plan, this guards the target that could have
375
- // changed underneath the run.
376
- assertWritableInside(targetRoot, host, POINTER_HOST_KIND);
377
- const path = join(targetRoot, host);
378
- const block = pointerBlock(orlyFile);
379
- // No file, or one orly generated under an older layout where it owned this
380
- // name. Neither holds a line the repository wrote, so both become the stub —
381
- // which is the migration for anything installed before the split.
382
- if (!existsSync(path) || (await Bun.file(path).text()).startsWith(GENERATED_BANNER)) {
383
- await Bun.write(path, `${stubHost(block)}\n`);
384
- return true;
385
- }
386
- const current = await Bun.file(path).text();
387
- const open = current.indexOf(POINTER_OPEN);
388
- const close = current.indexOf(POINTER_CLOSE);
389
- if (open >= 0 && close > open) {
390
- const replaced = `${current.slice(0, open)}${block}${current.slice(close + POINTER_CLOSE.length)}`;
391
- if (replaced === current) return false;
392
- await Bun.write(path, replaced);
393
- return true;
394
- }
395
- await Bun.write(path, `${current.replace(/\s*$/, "")}\n\n${block}\n`);
396
- return true;
397
- }
398
-
399
340
  function resolved(path: string): string {
400
341
  try {
401
342
  return realpathSync(path);
@@ -0,0 +1,196 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, lstatSync, symlinkSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ import { cleanupTemporaryDirectories, newRepository, ROOT } from "./gates_test_support";
6
+ import { install } from "./install";
7
+ import { readConfig } from "./config";
8
+ import { RulesModel } from "./model";
9
+
10
+ afterEach(cleanupTemporaryDirectories);
11
+
12
+ const VERSION = "0.9.0";
13
+
14
+ async function installInto(repo: string) {
15
+ const model = await RulesModel.load(ROOT);
16
+ return install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: VERSION });
17
+ }
18
+
19
+ describe("runtime loaders", () => {
20
+ // The gap these close: every runtime decides for itself what it loads, and a
21
+ // markdown link is a request an agent may not honour. Each assertion below is
22
+ // one runtime's deterministic way in.
23
+ test("a fresh repository gets a loader for every runtime that has one", async () => {
24
+ const repo = newRepository();
25
+
26
+ const result = await installInto(repo);
27
+
28
+ expect(result.ok).toBe(true);
29
+ expect(result.written).toContain("CLAUDE.md");
30
+ expect(result.written).toContain("opencode.json");
31
+ // Claude Code reads CLAUDE.md and nothing else, so the chain starts there
32
+ // and passes through the repository's own file on the way to orly's.
33
+ expect(await Bun.file(join(repo, "CLAUDE.md")).text()).toContain("\n@AGENTS.md\n");
34
+ expect(JSON.parse(await Bun.file(join(repo, "opencode.json")).text()).instructions)
35
+ .toEqual(["AGENTS.md", "AGENTS.orly.md"]);
36
+ });
37
+
38
+ // The line that does the work. Backticked it is decoration, which is what the
39
+ // pointer block shipped as before: a runtime resolves an import only when it
40
+ // stands alone and unquoted.
41
+ test("the pointer block carries a bare import line, not a fenced one", async () => {
42
+ const repo = newRepository();
43
+
44
+ await installInto(repo);
45
+
46
+ const host = await Bun.file(join(repo, "AGENTS.md")).text();
47
+ expect(host).toContain("\n@AGENTS.orly.md\n");
48
+ expect(host).not.toContain("`@AGENTS.orly.md`");
49
+ });
50
+
51
+ test("loaders are idempotent — a second install writes nothing", async () => {
52
+ const repo = newRepository();
53
+ await installInto(repo);
54
+
55
+ const second = await installInto(repo);
56
+
57
+ expect(second.written).toEqual([]);
58
+ expect(second.skipped).toContain("CLAUDE.md");
59
+ expect(second.skipped).toContain("opencode.json");
60
+ });
61
+
62
+ // The loaders are the repository's files, not orly's: they carry the same
63
+ // authorship rule as the pointer host and stay out of the managed list, so
64
+ // `orly doctor` never reports drift on a file its owner is free to edit.
65
+ test("loaders are never claimed as managed files", async () => {
66
+ const repo = newRepository();
67
+
68
+ await installInto(repo);
69
+
70
+ const managed = (await readConfig(repo))?.managed ?? [];
71
+ expect(managed).not.toContain("CLAUDE.md");
72
+ expect(managed).not.toContain("opencode.json");
73
+ expect(managed).not.toContain("AGENTS.md");
74
+ });
75
+
76
+ // The block is delimited so a re-run replaces exactly what the last run
77
+ // wrote. What surrounds it is the repository's prose and must survive an
78
+ // update that changes the block's own wording — as this release does.
79
+ test("a stale pointer block is replaced in place, and nothing either side of it", async () => {
80
+ const repo = newRepository();
81
+ await Bun.write(join(repo, "AGENTS.md"), "# Ours\n\nkeep me\n\n<!-- orly:begin -->\nstale pointer\n<!-- orly:end -->\n\ntrailing prose\n");
82
+
83
+ const result = await installInto(repo);
84
+
85
+ expect(result.written).toContain("AGENTS.md");
86
+ const host = await Bun.file(join(repo, "AGENTS.md")).text();
87
+ expect(host).toContain("keep me");
88
+ expect(host).toContain("trailing prose");
89
+ expect(host).not.toContain("stale pointer");
90
+ expect(host).toContain("\n@AGENTS.orly.md\n");
91
+ });
92
+
93
+ // The migration path for every repository installed before the loaders
94
+ // existed, and for every later change to the loader's own wording: the
95
+ // banner is the authorship evidence, so orly's own file is orly's to refresh.
96
+ test("a CLAUDE.md orly wrote itself is refreshed, not left stale", async () => {
97
+ const repo = newRepository();
98
+ await Bun.write(join(repo, "CLAUDE.md"), "> **Generated by `orly`.**\n> an older loader\n\n@ELSEWHERE.md\n");
99
+
100
+ const result = await installInto(repo);
101
+
102
+ expect(result.written).toContain("CLAUDE.md");
103
+ const loader = await Bun.file(join(repo, "CLAUDE.md")).text();
104
+ expect(loader).toContain("\n@AGENTS.md\n");
105
+ expect(loader).not.toContain("ELSEWHERE");
106
+ });
107
+
108
+ // A link orly cannot resolve is still someone's deliberate wiring. Writing
109
+ // through it would create the file it points at, which is a decision the
110
+ // repository never asked orly to make.
111
+ test("a dangling CLAUDE.md symlink is left alone, not written through", async () => {
112
+ const repo = newRepository();
113
+ symlinkSync("nowhere.md", join(repo, "CLAUDE.md"));
114
+
115
+ const result = await installInto(repo);
116
+
117
+ expect(result.ok).toBe(true);
118
+ expect(result.written).not.toContain("CLAUDE.md");
119
+ expect(result.skipped).toContain("CLAUDE.md");
120
+ expect(existsSync(join(repo, "nowhere.md"))).toBe(false);
121
+ expect(lstatSync(join(repo, "CLAUDE.md")).isSymbolicLink()).toBe(true);
122
+ });
123
+
124
+ test("a repository's own CLAUDE.md is reported, never edited", async () => {
125
+ const repo = newRepository();
126
+ const theirs = "# Our own Claude instructions\n";
127
+ await Bun.write(join(repo, "CLAUDE.md"), theirs);
128
+
129
+ const result = await installInto(repo);
130
+
131
+ expect(result.ok).toBe(true);
132
+ expect(await Bun.file(join(repo, "CLAUDE.md")).text()).toBe(theirs);
133
+ expect(result.written).not.toContain("CLAUDE.md");
134
+ expect(result.skipped).toContain("CLAUDE.md");
135
+ });
136
+
137
+ // The other supported wiring, and the one a delimited edit would ruin: a
138
+ // write through the link lands in AGENTS.md and imports it into itself.
139
+ test("a CLAUDE.md symlinked at the host counts as delivery and is left alone", async () => {
140
+ const repo = newRepository();
141
+ await Bun.write(join(repo, "AGENTS.md"), "# Ours\n");
142
+ symlinkSync("AGENTS.md", join(repo, "CLAUDE.md"));
143
+
144
+ const result = await installInto(repo);
145
+
146
+ expect(result.ok).toBe(true);
147
+ expect(result.written).not.toContain("CLAUDE.md");
148
+ const host = await Bun.file(join(repo, "AGENTS.md")).text();
149
+ expect(host).toContain("# Ours");
150
+ // One block, reached through the link — not a second copy written via it.
151
+ expect(host.split("<!-- orly:begin -->").length - 1).toBe(1);
152
+ });
153
+
154
+ test("an existing opencode.json keeps its own keys and gains only what is missing", async () => {
155
+ const repo = newRepository();
156
+ await Bun.write(join(repo, "opencode.json"), JSON.stringify({ model: "anthropic/claude-fable-5", instructions: ["AGENTS.md"] }));
157
+
158
+ const result = await installInto(repo);
159
+
160
+ const config = JSON.parse(await Bun.file(join(repo, "opencode.json")).text());
161
+ expect(config.model).toBe("anthropic/claude-fable-5");
162
+ expect(config.instructions).toEqual(["AGENTS.md", "AGENTS.orly.md"]);
163
+ expect(result.written).toContain("opencode.json");
164
+ });
165
+
166
+ // Rewriting a file whose shape we do not understand is how an install eats
167
+ // someone's settings.
168
+ test("an unparseable opencode.json survives byte for byte", async () => {
169
+ const repo = newRepository();
170
+ const theirs = "{ not json at all\n";
171
+ await Bun.write(join(repo, "opencode.json"), theirs);
172
+
173
+ const result = await installInto(repo);
174
+
175
+ expect(result.ok).toBe(true);
176
+ expect(await Bun.file(join(repo, "opencode.json")).text()).toBe(theirs);
177
+ expect(result.skipped).toContain("opencode.json");
178
+ });
179
+
180
+ // Same guard the pointer host earned: a committed link out of the repository
181
+ // otherwise carries a write wherever it leads and reports it as a success.
182
+ test("refuses a loader symlinked outside the target repository", async () => {
183
+ const outside = newRepository();
184
+ const victim = join(outside, "victim.md");
185
+ await Bun.write(victim, "not orly's to write\n");
186
+ const repo = newRepository();
187
+ symlinkSync(victim, join(repo, "opencode.json"));
188
+
189
+ const result = await installInto(repo);
190
+
191
+ expect(result.ok).toBe(false);
192
+ expect(result.errors.some((error) => error.path === "opencode.json")).toBeTrue();
193
+ expect(await Bun.file(victim).text()).toBe("not orly's to write\n");
194
+ expect(existsSync(join(repo, "AGENTS.orly.md"))).toBe(false);
195
+ });
196
+ });
package/src/loaders.ts ADDED
@@ -0,0 +1,240 @@
1
+ import { existsSync, lstatSync, realpathSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { assertWritableInside, isObject } from "./model";
5
+
6
+ // How orly's rules reach an agent's context, as opposed to how they reach the
7
+ // disk. Installing the file is not delivering it: every runtime decides for
8
+ // itself what it loads at session start, and only one of the four this
9
+ // repository grades against loads AGENTS.md unprompted. A pointer an agent has
10
+ // to notice and follow is a hope; an import a runtime resolves is a mechanism.
11
+ // So the rules land beside a small set of loaders, one per runtime that has a
12
+ // deterministic way in, and the runtimes with no way in are covered by the
13
+ // read-record gate instead.
14
+ //
15
+ // Every file here belongs to the repository, not to orly — they are the same
16
+ // class as the pointer host: written when absent, edited only inside a
17
+ // delimited block, never listed in `managed`, and never replaced wholesale
18
+ // once someone else has written one.
19
+
20
+ export const AGENTS_FILENAME = "AGENTS.md";
21
+ export const ORLY_AGENTS_FILENAME = "AGENTS.orly.md";
22
+ export const GENERATED_BANNER = "> **Generated by `orly`.**";
23
+ export const POINTER_HOST_KIND = "pointer host";
24
+
25
+ const CLAUDE_FILENAME = "CLAUDE.md";
26
+ const OPENCODE_FILENAME = "opencode.json";
27
+ const OPENCODE_SCHEMA = "https://opencode.ai/config.json";
28
+ const INSTRUCTIONS_KEY = "instructions";
29
+ const LOADER_KIND = "runtime loader";
30
+ const POINTER_OPEN = "<!-- orly:begin -->";
31
+ const POINTER_CLOSE = "<!-- orly:end -->";
32
+ const NEWLINE = "\n";
33
+ const JSON_INDENT = 2;
34
+
35
+ export type Layout = { orlyFile: string; pointerHost?: string };
36
+ export type LoaderResult = { written: string[]; skipped: string[] };
37
+
38
+ // What one loader destination came to. WRITTEN — orly created or refreshed it.
39
+ // CURRENT — already exactly right, by orly's hand or by someone else's wiring.
40
+ // THEIRS — the repository answered this question itself; leave it alone.
41
+ const WRITTEN = "written";
42
+ const CURRENT = "current";
43
+ const THEIRS = "theirs";
44
+ type Verdict = typeof WRITTEN | typeof CURRENT | typeof THEIRS;
45
+
46
+ // Every destination orly aims at a file it does not own, with the kind name
47
+ // the refusal will carry. Exported so the install can guard them in its
48
+ // up-front pass: a committed symlink escaping the repository must be refused
49
+ // before anything is staged, not discovered at the write.
50
+ export function loaderTargets(layout: Layout): Array<[string, string]> {
51
+ const targets: Array<[string, string]> = [[CLAUDE_FILENAME, LOADER_KIND], [OPENCODE_FILENAME, LOADER_KIND]];
52
+ if (layout.pointerHost) targets.unshift([layout.pointerHost, POINTER_HOST_KIND]);
53
+ return targets;
54
+ }
55
+
56
+ // Run after the managed files land, so a repository whose install refused for
57
+ // any other reason never carries a loader pointing at rules that were not
58
+ // written.
59
+ export async function installLoaders(targetRoot: string, layout: Layout): Promise<LoaderResult> {
60
+ const written: string[] = [];
61
+ const skipped: string[] = [];
62
+ const record = (path: string, verdict: Verdict) => (verdict === WRITTEN ? written : skipped).push(path);
63
+
64
+ // The host is what a loader imports: the repository's own file, which the
65
+ // pointer block chains onward to orly's. Importing orly's file directly
66
+ // would deliver the harness and drop the repository's own rules, which the
67
+ // block itself says outrank it.
68
+ const host = layout.pointerHost ?? layout.orlyFile;
69
+ if (layout.pointerHost) record(layout.pointerHost, await upsertPointer(targetRoot, layout.pointerHost, layout.orlyFile));
70
+ record(CLAUDE_FILENAME, await upsertClaudeLoader(targetRoot, host));
71
+ record(OPENCODE_FILENAME, await upsertOpencodeLoader(targetRoot, [...new Set([host, layout.orlyFile])]));
72
+ return { written, skipped };
73
+ }
74
+
75
+ // The one edit orly makes to a file it does not own. Delimited so a re-run
76
+ // replaces exactly what the last run wrote and nothing either side of it, and
77
+ // so a user who wants orly out can delete the block by sight.
78
+ //
79
+ // The bare `@<file>` line is the load-bearing half: Claude Code resolves it as
80
+ // an import, recursively, so the rules arrive with the file instead of waiting
81
+ // for an agent to follow a link. It must stay unbackticked and alone on its
82
+ // line — an import inside a code span stays literal text, which is exactly how
83
+ // this block spent its first release delivering nothing.
84
+ function pointerBlock(orlyFile: string): string {
85
+ return [
86
+ POINTER_OPEN,
87
+ `**Engineering harness:** read [\`${orlyFile}\`](${orlyFile}) as well — it carries the safety rules,`,
88
+ "the dispatch router that names which rule page to read before which edit, and the lifecycle",
89
+ "this repository gates on. Where the two disagree, this file wins.",
90
+ "",
91
+ "The line below is an import, not decoration: a runtime that resolves it loads those rules",
92
+ "with this file, and one that does not still has the link above.",
93
+ "",
94
+ `@${orlyFile}`,
95
+ POINTER_CLOSE,
96
+ ].join(NEWLINE);
97
+ }
98
+
99
+ // What a repository with no rules of its own starts with: the pointer, and a
100
+ // visible invitation to write here. Without the second line the stub reads as
101
+ // an orly artifact and nobody touches it — which is the trap the single-file
102
+ // layout had, moved one file over.
103
+ function stubHost(block: string): string {
104
+ return [
105
+ "# Repository rules",
106
+ "",
107
+ block,
108
+ "",
109
+ "Write this repository's own rules below. orly never edits this file",
110
+ "outside the block above, so nothing here is lost to an `orly update`.",
111
+ ].join(NEWLINE);
112
+ }
113
+
114
+ async function upsertPointer(targetRoot: string, host: string, orlyFile: string): Promise<Verdict> {
115
+ // Re-checked at the write, as every other destination is: the up-front
116
+ // refusal pass guards the plan, this guards the target that could have
117
+ // changed underneath the run.
118
+ assertWritableInside(targetRoot, host, POINTER_HOST_KIND);
119
+ const path = join(targetRoot, host);
120
+ const block = pointerBlock(orlyFile);
121
+ // No file, or one orly generated under an older layout where it owned this
122
+ // name. Neither holds a line the repository wrote, so both become the stub —
123
+ // which is the migration for anything installed before the split.
124
+ if (!existsSync(path) || (await Bun.file(path).text()).startsWith(GENERATED_BANNER)) {
125
+ await Bun.write(path, `${stubHost(block)}\n`);
126
+ return WRITTEN;
127
+ }
128
+ const current = await Bun.file(path).text();
129
+ const open = current.indexOf(POINTER_OPEN);
130
+ const close = current.indexOf(POINTER_CLOSE);
131
+ if (open >= 0 && close > open) {
132
+ const replaced = `${current.slice(0, open)}${block}${current.slice(close + POINTER_CLOSE.length)}`;
133
+ if (replaced === current) return CURRENT;
134
+ await Bun.write(path, replaced);
135
+ return WRITTEN;
136
+ }
137
+ await Bun.write(path, `${current.replace(/\s*$/, "")}\n\n${block}\n`);
138
+ return WRITTEN;
139
+ }
140
+
141
+ // Claude Code auto-loads CLAUDE.md and nothing else — an AGENTS.md reaches it
142
+ // only through an import or a symlink someone made by hand. Without this file
143
+ // the promise that a teammate's clone is governed with nothing installed is
144
+ // false in the runtime most of them are running.
145
+ //
146
+ // Written ONLY where the repository has none. A CLAUDE.md that already exists
147
+ // is an answer its author gave to this exact question, and the common form of
148
+ // that answer is a symlink to AGENTS.md — where a delimited edit would land in
149
+ // the host file and import it into itself. Recognising the link as delivery
150
+ // and leaving every other CLAUDE.md alone keeps one rule for both: orly writes
151
+ // this file or it does not touch it.
152
+ async function upsertClaudeLoader(targetRoot: string, host: string): Promise<Verdict> {
153
+ assertWritableInside(targetRoot, CLAUDE_FILENAME, LOADER_KIND);
154
+ const path = join(targetRoot, CLAUDE_FILENAME);
155
+ const link = linkVerdict(targetRoot, path, host);
156
+ if (link) return link;
157
+ const content = claudeLoader(host);
158
+ if (!existsSync(path)) {
159
+ await Bun.write(path, content);
160
+ return WRITTEN;
161
+ }
162
+ const current = await Bun.file(path).text();
163
+ if (current === content) return CURRENT;
164
+ // An older loader of orly's own making is orly's to refresh; anything else
165
+ // is the repository's file and is reported, not edited.
166
+ if (!current.startsWith(GENERATED_BANNER)) return THEIRS;
167
+ await Bun.write(path, content);
168
+ return WRITTEN;
169
+ }
170
+
171
+ function claudeLoader(host: string): string {
172
+ return [
173
+ GENERATED_BANNER,
174
+ "> Claude Code loads this file at session start and no other, so this is where",
175
+ `> this repository's rules get in. The import below pulls in \`${host}\`, and`,
176
+ "> whatever that file imports in turn — orly's rules among them.",
177
+ ">",
178
+ "> Delete this file to opt out; orly writes it only when it is missing, and",
179
+ "> never edits a CLAUDE.md it did not write.",
180
+ "",
181
+ `@${host}`,
182
+ "",
183
+ ].join(NEWLINE);
184
+ }
185
+
186
+ // A CLAUDE.md symlinked at the host is the other supported wiring, and it
187
+ // delivers the same bytes: the runtime reads the link and lands in the file
188
+ // the import chain starts from. Resolving both sides matters on macOS, where
189
+ // the repository itself routinely sits behind a link.
190
+ //
191
+ // Any other link is still someone's deliberate wiring, and is left alone for
192
+ // the reason the whole loader is: a write through a symlink lands in the file
193
+ // it points at, not the one orly named. Undefined = not a link, decide on
194
+ // content.
195
+ function linkVerdict(targetRoot: string, path: string, host: string): Verdict | undefined {
196
+ try {
197
+ if (!lstatSync(path).isSymbolicLink()) return undefined;
198
+ } catch {
199
+ return undefined;
200
+ }
201
+ try {
202
+ return realpathSync(path) === realpathSync(join(targetRoot, host)) ? CURRENT : THEIRS;
203
+ } catch {
204
+ return THEIRS;
205
+ }
206
+ }
207
+
208
+ // opencode loads nothing by default: instructions are the paths its config
209
+ // names, so a rules file it was never told about is a rules file it never
210
+ // reads. Both entries are listed because opencode has no auto-load to fall
211
+ // back on — naming only orly's half would deliver the harness in this runtime
212
+ // and the repository's own rules in every other one.
213
+ //
214
+ // An existing config is edited by adding what is missing and nothing else. A
215
+ // config orly cannot parse is left exactly as it is: rewriting a file whose
216
+ // shape we do not understand is how an install eats someone's settings.
217
+ async function upsertOpencodeLoader(targetRoot: string, entries: string[]): Promise<Verdict> {
218
+ assertWritableInside(targetRoot, OPENCODE_FILENAME, LOADER_KIND);
219
+ const path = join(targetRoot, OPENCODE_FILENAME);
220
+ if (!existsSync(path)) {
221
+ await Bun.write(path, serialise({ $schema: OPENCODE_SCHEMA, [INSTRUCTIONS_KEY]: entries }));
222
+ return WRITTEN;
223
+ }
224
+ let config: unknown;
225
+ try {
226
+ config = JSON.parse(await Bun.file(path).text());
227
+ } catch {
228
+ return THEIRS;
229
+ }
230
+ if (!isObject(config)) return THEIRS;
231
+ const listed = Array.isArray(config[INSTRUCTIONS_KEY]) ? config[INSTRUCTIONS_KEY].map(String) : [];
232
+ const missing = entries.filter((entry) => !listed.includes(entry));
233
+ if (missing.length === 0) return CURRENT;
234
+ await Bun.write(path, serialise({ ...config, [INSTRUCTIONS_KEY]: [...listed, ...missing] }));
235
+ return WRITTEN;
236
+ }
237
+
238
+ function serialise(config: object): string {
239
+ return `${JSON.stringify(config, undefined, JSON_INDENT)}\n`;
240
+ }
package/src/model.ts CHANGED
@@ -83,11 +83,21 @@ function isSymbolicLink(path: string): boolean {
83
83
 
84
84
  // A dangling link resolves nowhere; judge it by where it points, not where it
85
85
  // lands, so a link to a not-yet-created file outside the repository still fails.
86
+ //
87
+ // The directory holding it is resolved too, and has to be: the caller compares
88
+ // this against a realpath'd root, and a lexical answer put a link pointing
89
+ // squarely INSIDE the repository on the wrong side of that comparison wherever
90
+ // the repository itself sits behind a symlink — which is every macOS temporary
91
+ // directory, and plenty of real checkouts. The refusal even named the file it
92
+ // was about to protect, one path component at a time.
93
+ //
94
+ // Resolving the parent cannot fail here: this runs only where the destination
95
+ // itself lstat'd as a link, which a missing parent makes impossible.
86
96
  function safeRealpath(path: string): string | undefined {
87
97
  try {
88
98
  return realpathSync(path);
89
99
  } catch {
90
- return resolve(dirname(path), readlinkSync(path));
100
+ return resolve(realpathSync(dirname(path)), readlinkSync(path));
91
101
  }
92
102
  }
93
103