@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/AGENTS.md +16 -11
- package/README.md +8 -1
- package/SOUL.md +4 -1
- package/audits/agents-md.md +3 -0
- package/audits/rust-error.sh +228 -0
- package/core/operating-model.md +13 -11
- package/dispatch/lib.sh +28 -3
- package/dispatch/lifecycle.md +27 -0
- package/dispatch/verify.md +45 -11
- package/dispatch/write_go.md +8 -0
- package/dispatch/write_rust.md +166 -0
- package/dispatch/write_rust.sh +38 -0
- package/docs/EXECUTE_DOC_READS.md +1 -1
- package/docs/RULE_ENFORCEMENT.md +2 -2
- package/docs/VERIFY_TIERS.md +35 -11
- package/docs/greptile-learnings/RULES.md +1 -0
- package/package.json +1 -1
- package/packs/language/rust/rules.md +9 -0
- package/registry.json +16 -0
- package/skills/orly-babysit-prs/SKILL.md +4 -8
- package/skills/orly-spec-new/SKILL.md +4 -6
- package/skills/orly-write-integration-test/SKILL.md +4 -6
- package/skills/orly-write-unit-test/SKILL.md +4 -6
- package/src/cli.ts +11 -1
- package/src/criteria.test.ts +17 -6
- package/src/criteria.ts +34 -9
- package/src/gates.test.ts +33 -9
- package/src/gates_spec.test.ts +1 -1
- package/src/install.ts +18 -72
- package/src/install_packs.test.ts +56 -0
- package/src/loaders.test.ts +148 -0
- package/src/loaders.ts +240 -0
- package/src/render.test.ts +59 -1
- package/src/verify.test.ts +72 -2
- package/src/verify.ts +44 -3
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
|
-
|
|
20
|
-
|
|
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 === "
|
|
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(["
|
|
33
|
-
expect(names(runGate(model, project, "verify"))).toEqual(["cmd.
|
|
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",
|
|
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, "
|
|
72
|
-
expect(runGate(model, project, "
|
|
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 () => {
|
package/src/gates_spec.test.ts
CHANGED
|
@@ -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(
|
|
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
|
|
103
|
-
//
|
|
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
|
-
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
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
|
|
117
|
-
|
|
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):
|
|
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
|
-
|
|
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
|
+
});
|