@agentsfleet/orly 0.7.1 → 0.9.0

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/src/gates.test.ts CHANGED
@@ -16,27 +16,51 @@ afterEach(cleanupTemporaryDirectories);
16
16
  describe("gate groups", () => {
17
17
  test("run in order and stop at the first red group", async () => {
18
18
  const project = newSpecRepository();
19
- const model = await modelFor(project);
20
- // Default branch: the work gate is red, so verify and pr never run.
19
+ // A failing conform reds the work gate, so verify and pr never run.
20
+ const model = await modelFor(project, undefined, { conform: [["false"]] });
21
21
  const reports = runGates(model, project);
22
22
 
23
23
  expect(reports.map((report) => report.gate)).toEqual(["work"]);
24
24
  expect(reports[0]?.ok).toBeFalse();
25
- expect(reports[0]?.results.find((result) => result.name === "git.branch")?.ok).toBeFalse();
25
+ expect(reports[0]?.results.find((result) => result.name === "cmd.conform")?.ok).toBeFalse();
26
26
  });
27
27
 
28
+ // One gate per cadence: work asks whether the commit conforms, verify whether
29
+ // the work holds up, pr whether it can ship. Branch shape and a clean tree are
30
+ // pr's questions alone — asked at commit time they refuse the commit being
31
+ // made, and asked at push time they refuse a scratch file that is not being
32
+ // pushed.
28
33
  test("each gate evaluates exactly its declared criteria", async () => {
29
34
  const project = newSpecRepository();
30
35
  const model = await modelFor(project);
31
36
 
32
- expect(names(runGate(model, project, "work"))).toEqual(["git.branch", "git.tree", "repo.config"]);
33
- expect(names(runGate(model, project, "verify"))).toEqual(["cmd.conform", "cmd.verify.unit", "docs.language", "spec.dimensions"]);
37
+ expect(names(runGate(model, project, "work"))).toEqual(["cmd.conform", "repo.config"]);
38
+ expect(names(runGate(model, project, "verify"))).toEqual(["cmd.verify.unit", "docs.language", "spec.dimensions"]);
34
39
  expect(names(runGate(model, project, "pr"))).toEqual([
35
- "docs.updated", "git.pushed", "git.tree", "spec.baseline", "spec.deferrals", "spec.dimensions", "spec.gate",
36
- "spec.moved", "spec.open-questions", "spec.ordering", "spec.product-clarity",
40
+ "docs.updated", "git.branch", "git.pushed", "git.tree", "spec.baseline", "spec.deferrals", "spec.dimensions",
41
+ "spec.gate", "spec.moved", "spec.open-questions", "spec.ordering", "spec.product-clarity",
37
42
  ]);
38
43
  });
39
44
 
45
+ // The bug this composition exists to kill: the hook orly generates runs
46
+ // `orly gate work`, and a commit hook's tree is dirty by construction — that
47
+ // is what is being committed. On the default branch, which the operating
48
+ // model itself says a new spec is committed on, it failed twice over. A fresh
49
+ // install could not commit itself without `--no-verify`, which Hard Safety
50
+ // forbids outright.
51
+ test("the work gate passes on the default branch with a staged edit", async () => {
52
+ const project = newSpecRepository();
53
+ const model = await modelFor(project);
54
+ await Bun.write(join(project, "src/staged.txt"), "an edit on its way into a commit\n");
55
+ git(project, "add", "src/staged.txt");
56
+
57
+ const report = runGate(model, project, "work");
58
+
59
+ expect(report.ok).toBeTrue();
60
+ expect(names(report)).not.toContain("git.tree");
61
+ expect(names(report)).not.toContain("git.branch");
62
+ });
63
+
40
64
  test("gates never write: the tree is byte-identical after a run", async () => {
41
65
  const project = newSpecRepository();
42
66
  const model = await modelFor(project);
@@ -68,8 +92,8 @@ describe("repository resolution", () => {
68
92
  git(project, "worktree", "add", "-q", "-b", "feat/stream", worktree);
69
93
  await Bun.write(join(worktree, "stream-only.txt"), "present only in the worktree\n");
70
94
 
71
- expect(runGate(model, worktree, "verify").results.find((result) => result.name === "cmd.conform")?.ok).toBeTrue();
72
- expect(runGate(model, project, "verify").results.find((result) => result.name === "cmd.conform")?.ok).toBeFalse();
95
+ expect(runGate(model, worktree, "work").results.find((result) => result.name === "cmd.conform")?.ok).toBeTrue();
96
+ expect(runGate(model, project, "work").results.find((result) => result.name === "cmd.conform")?.ok).toBeFalse();
73
97
  });
74
98
 
75
99
  test("a repository with no orly config is red, and names the install command", async () => {
@@ -18,7 +18,7 @@ describe("spec discovery", () => {
18
18
 
19
19
  const verify = runGate(model, project, "verify");
20
20
  expect(verify.results.find((result) => result.name === "spec.dimensions")?.detail).toContain("no active spec");
21
- expect(verify.results.find((result) => result.name === "cmd.conform")?.ok).toBeTrue();
21
+ expect(runGate(model, project, "work").results.find((result) => result.name === "cmd.conform")?.ok).toBeTrue();
22
22
  });
23
23
 
24
24
  test("cache-kit style docs/v0.9.2/ layout is discovered", () => {
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);
@@ -407,7 +348,12 @@ function resolved(path: string): string {
407
348
  // Managed markdown is pack-filtered on the way in, exactly as a rendered core
408
349
  // document is. Copying it raw is how a Rust crate ends up holding a rule that
409
350
  // points at a Zig façade it will never receive.
410
- async function managedContent(path: string, target: string, source: string, packs: string[], known: Set<string>, orlyFile: string): Promise<Uint8Array> {
351
+ //
352
+ // Exported because it is the definition of what bytes belong at a managed
353
+ // target, and `orly verify` asks exactly that question of the checkout that
354
+ // authors the sources — where no install ever writes, so nothing else would
355
+ // catch a hand-edited copy drifting from the source every consumer receives.
356
+ export async function managedContent(path: string, target: string, source: string, packs: string[], known: Set<string>, orlyFile: string): Promise<Uint8Array> {
411
357
  const bytes = await Bun.file(path).bytes();
412
358
  if (extname(target) !== MARKDOWN_EXTENSION) return bytes;
413
359
  const filtered = renderProfileText(new TextDecoder().decode(bytes), new Set(packs), known, source);
@@ -7,6 +7,9 @@ import { install } from "./install";
7
7
  import { RulesModel } from "./model";
8
8
 
9
9
  const SOURCE_EXTENSIONS = ["rs", "ts", "tsx", "js", "jsx", "py", "sh", "sql", "zig", "mdx"];
10
+ const DESCRIPTION_BUDGET = 320;
11
+ const RECORDER = "audits/doc-read.sh";
12
+ const RECORDER_LIBRARY = "audits/rule-ledger-lib.sh";
10
13
  // Read from package.json rather than restated here: a hand-synced copy goes
11
14
  // stale at the next release and the test then proves an install at a version
12
15
  // that no longer ships.
@@ -36,3 +39,56 @@ describe("opt-in pack selection", () => {
36
39
  expect(existsSync(join(repo, "dispatch/edit_rules.md"))).toBe(false);
37
40
  });
38
41
  });
42
+
43
+ // Every host renders skill metadata into a fixed context budget and truncates
44
+ // the set when it overflows — Codex says so out loud ("descriptions were
45
+ // shortened to fit the skills context budget"). A truncated description is a
46
+ // skill the model can no longer route to correctly, and the failure is silent
47
+ // at the point it matters. The bound is checked here so a growing description
48
+ // fails a test rather than degrading discovery in the field.
49
+ describe("packaged skill metadata", () => {
50
+ test("every description stays inside the host budget", async () => {
51
+ const skills = new Set<string>();
52
+ for (const entry of (await Bun.file(join(ROOT, "registry.json")).json()).packs["workflow.skills"].managed_files) skills.add(entry.source);
53
+ expect(skills.size).toBeGreaterThan(0);
54
+
55
+ for (const relative of [...skills].sort()) {
56
+ const frontmatter = (await Bun.file(join(ROOT, relative)).text()).split("---")[1] ?? "";
57
+ const description = (frontmatter.split(/^description:/m)[1] ?? "").split(/^[a-z_]+:/m)[0] ?? "";
58
+ const rendered = description.replace(/^[>|]/, "").replace(/\s+/g, " ").trim();
59
+
60
+ expect(rendered.length).toBeGreaterThan(0);
61
+ expect({ skill: relative, length: rendered.length }).toEqual({ skill: relative, length: Math.min(rendered.length, DESCRIPTION_BUDGET) });
62
+ }
63
+ });
64
+ });
65
+
66
+ // The operating model tells every agent to record a triggered read with
67
+ // `bash audits/doc-read.sh log <path>`, and pre-commit compares that record to
68
+ // the staged diff. The rule shipped through universal.authoring; the script did
69
+ // not, so in a consumer the command the rule names was simply absent — the
70
+ // runtime's read hook called nothing and the check had nothing to compare. A
71
+ // rule that names a command ships that command.
72
+ describe("the DOC READ recorder", () => {
73
+ test("installs with the authoring pack and runs where it lands", async () => {
74
+ const model = await RulesModel.load(ROOT);
75
+ const repo = newRepository();
76
+ await Bun.write(join(repo, ".oracle/orly.json"), JSON.stringify({ schema_version: 1, orly_version: "", packs: [], commands: {}, managed: [] }));
77
+
78
+ const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: ENGINE_VERSION });
79
+
80
+ expect(result.errors).toEqual([]);
81
+ expect(result.packs).toContain("universal.authoring");
82
+ expect(existsSync(join(repo, RECORDER))).toBe(true);
83
+ expect(existsSync(join(repo, RECORDER_LIBRARY))).toBe(true);
84
+
85
+ // Sourcing its library and resolving façade scope are the two ways this
86
+ // pair can be shipped incomplete, and both fail at run time rather than at
87
+ // copy time — so the proof is a real run in the repository it landed in.
88
+ const logged = Bun.spawnSync(["bash", RECORDER, "log", "README.md"], { cwd: repo, stdout: "pipe", stderr: "pipe" });
89
+ expect(logged.exitCode).toBe(0);
90
+ const checked = Bun.spawnSync(["bash", RECORDER, "check"], { cwd: repo, stdout: "pipe", stderr: "pipe" });
91
+ expect(checked.exitCode).toBe(0);
92
+ expect(checked.stdout.toString()).toContain("DOC READ");
93
+ });
94
+ });
@@ -0,0 +1,148 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, 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
+ test("a repository's own CLAUDE.md is reported, never edited", async () => {
77
+ const repo = newRepository();
78
+ const theirs = "# Our own Claude instructions\n";
79
+ await Bun.write(join(repo, "CLAUDE.md"), theirs);
80
+
81
+ const result = await installInto(repo);
82
+
83
+ expect(result.ok).toBe(true);
84
+ expect(await Bun.file(join(repo, "CLAUDE.md")).text()).toBe(theirs);
85
+ expect(result.written).not.toContain("CLAUDE.md");
86
+ expect(result.skipped).toContain("CLAUDE.md");
87
+ });
88
+
89
+ // The other supported wiring, and the one a delimited edit would ruin: a
90
+ // write through the link lands in AGENTS.md and imports it into itself.
91
+ test("a CLAUDE.md symlinked at the host counts as delivery and is left alone", async () => {
92
+ const repo = newRepository();
93
+ await Bun.write(join(repo, "AGENTS.md"), "# Ours\n");
94
+ symlinkSync("AGENTS.md", join(repo, "CLAUDE.md"));
95
+
96
+ const result = await installInto(repo);
97
+
98
+ expect(result.ok).toBe(true);
99
+ expect(result.written).not.toContain("CLAUDE.md");
100
+ const host = await Bun.file(join(repo, "AGENTS.md")).text();
101
+ expect(host).toContain("# Ours");
102
+ // One block, reached through the link — not a second copy written via it.
103
+ expect(host.split("<!-- orly:begin -->").length - 1).toBe(1);
104
+ });
105
+
106
+ test("an existing opencode.json keeps its own keys and gains only what is missing", async () => {
107
+ const repo = newRepository();
108
+ await Bun.write(join(repo, "opencode.json"), JSON.stringify({ model: "anthropic/claude-fable-5", instructions: ["AGENTS.md"] }));
109
+
110
+ const result = await installInto(repo);
111
+
112
+ const config = JSON.parse(await Bun.file(join(repo, "opencode.json")).text());
113
+ expect(config.model).toBe("anthropic/claude-fable-5");
114
+ expect(config.instructions).toEqual(["AGENTS.md", "AGENTS.orly.md"]);
115
+ expect(result.written).toContain("opencode.json");
116
+ });
117
+
118
+ // Rewriting a file whose shape we do not understand is how an install eats
119
+ // someone's settings.
120
+ test("an unparseable opencode.json survives byte for byte", async () => {
121
+ const repo = newRepository();
122
+ const theirs = "{ not json at all\n";
123
+ await Bun.write(join(repo, "opencode.json"), theirs);
124
+
125
+ const result = await installInto(repo);
126
+
127
+ expect(result.ok).toBe(true);
128
+ expect(await Bun.file(join(repo, "opencode.json")).text()).toBe(theirs);
129
+ expect(result.skipped).toContain("opencode.json");
130
+ });
131
+
132
+ // Same guard the pointer host earned: a committed link out of the repository
133
+ // otherwise carries a write wherever it leads and reports it as a success.
134
+ test("refuses a loader symlinked outside the target repository", async () => {
135
+ const outside = newRepository();
136
+ const victim = join(outside, "victim.md");
137
+ await Bun.write(victim, "not orly's to write\n");
138
+ const repo = newRepository();
139
+ symlinkSync(victim, join(repo, "opencode.json"));
140
+
141
+ const result = await installInto(repo);
142
+
143
+ expect(result.ok).toBe(false);
144
+ expect(result.errors.some((error) => error.path === "opencode.json")).toBeTrue();
145
+ expect(await Bun.file(victim).text()).toBe("not orly's to write\n");
146
+ expect(existsSync(join(repo, "AGENTS.orly.md"))).toBe(false);
147
+ });
148
+ });