@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/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
+ }
@@ -15,7 +15,7 @@ const KERNEL_PACKS = ["universal.authoring", "language.zig", "language.rust", "d
15
15
  // This repository's own selection, as `.oracle/orly.json` declares it — the
16
16
  // root render must stay current against exactly what `orly update` writes.
17
17
  const DOTFILES_PACKS = ["universal.authoring", "language.zig", "language.typescript", "language.javascript", "language.rust", "language.go", "language.python", "language.shell", "language.mdx", "domain.sql", "domain.http", "domain.auth", "domain.documentation", "domain.changelog", "workflow.specifications", "workflow.governance", "product.agentsfleet", "persona.indy", "workflow.skills"];
18
- const DOTFILES_COMMANDS = { conform: [["make", "audit"]], "verify.unit": [["bun", "test", "src"]] };
18
+ const DOTFILES_COMMANDS = { conform: [["make", "conform"]], "verify.unit": [["bun", "test", "src"]] };
19
19
 
20
20
  describe("Renderer", () => {
21
21
  test("renders byte-stable text for a pack set", async () => {
@@ -58,3 +58,61 @@ describe("Renderer", () => {
58
58
  expect(new Renderer(model).renderText(KERNEL_PACKS)).rejects.toThrow("unclosed orly pack block");
59
59
  });
60
60
  });
61
+
62
+ // Claims the rendered rules make about the machine. Each one was false when
63
+ // this suite was written: the prose said "always" where the gate runs once,
64
+ // named a chain where one gate is the boundary, made a conditional skill
65
+ // mandatory, and credited a reporting-only criterion with enforcement. Prose
66
+ // drifting back is exactly as expensive as the first time, and none of it is
67
+ // visible to a render-determinism check.
68
+ describe("the rendered rules match the machine", () => {
69
+ test("VERIFY states two cadences rather than one 'always'", async () => {
70
+ const text = await new Renderer(await RulesModel.load(ROOT)).renderText(DOTFILES_PACKS, DOTFILES_COMMANDS);
71
+
72
+ expect(text).toContain("Two cadences, one boundary");
73
+ expect(text).toContain("runs ONCE, at the milestone boundary");
74
+ });
75
+
76
+ test("CHORE(close) names the pr gate, not the whole chain", async () => {
77
+ const text = await new Renderer(await RulesModel.load(ROOT)).renderText(DOTFILES_PACKS, DOTFILES_COMMANDS);
78
+
79
+ expect(text).toContain("`orly gate pr` is the command CHORE(close) runs");
80
+ // Sound without re-running the fast tier, and the reason is stated where
81
+ // the claim is: git.pushed pins HEAD to what pre-push already graded.
82
+ expect(text).toContain("`git.pushed` proves HEAD is exactly what the pre-push");
83
+ expect(text).toContain("One gate per cadence, each tier run once");
84
+ });
85
+
86
+ test("the integration skill is conditional on a real input/output boundary", async () => {
87
+ const text = await new Renderer(await RulesModel.load(ROOT)).renderText(DOTFILES_PACKS, DOTFILES_COMMANDS);
88
+
89
+ expect(text).toContain("when the diff crosses a module boundary with real input/output");
90
+ expect(text).not.toContain("both before the PR, never skipped");
91
+ });
92
+
93
+ // persona.indy is opt-in, and five engineering clauses were hiding inside it:
94
+ // a repository dropping the voice silently dropped symlink-edit routing,
95
+ // dotfiles backup, vault naming, sibling-repo precedent, and the
96
+ // read-the-reference mandate with it. A pack named for voice must cost only
97
+ // voice when it is left out.
98
+ test("the engineering clauses survive without the persona pack", async () => {
99
+ const renderer = new Renderer(await RulesModel.load(ROOT));
100
+ const withoutPersona = await renderer.renderText(DOTFILES_PACKS.filter((pack) => pack !== "persona.indy"), DOTFILES_COMMANDS);
101
+
102
+ expect(withoutPersona).toContain("Symlinked edits land in the repository that owns the file");
103
+ expect(withoutPersona).toContain("timestamped backup");
104
+ expect(withoutPersona).toContain("Vault names come from the environment");
105
+ expect(withoutPersona).toContain("Check a sibling repository");
106
+ expect(withoutPersona).toContain("Read the reference implementation before designing");
107
+ // What persona is actually for stays behind with it.
108
+ expect(withoutPersona).not.toContain("Indy's reference checkouts");
109
+ expect(withoutPersona).not.toContain("🤠 Indy");
110
+ });
111
+
112
+ test("SOUL does not claim the language criterion enforces anything", async () => {
113
+ const soul = await Bun.file(resolve(ROOT, "SOUL.md")).text();
114
+
115
+ expect(soul).toContain("REPORTS it");
116
+ expect(soul).not.toContain("`orly gate verify` enforces it");
117
+ });
118
+ });
@@ -1,13 +1,83 @@
1
1
  import { expect, test } from "bun:test";
2
- import { resolve } from "node:path";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
3
5
 
4
6
  import { RulesModel } from "./model";
5
- import { verifyRenders } from "./verify";
7
+ import { packSourceErrors, verifyRenders } from "./verify";
6
8
 
7
9
  const ROOT = resolve(import.meta.dir, "..");
10
+ const PACK = "language.demo";
11
+ const SOURCE = "packs/demo/rules.md";
12
+ const TARGET = "dispatch/write_demo.md";
13
+ const BODY = "# Demo\n\nOne rule, stated once.\n";
14
+
15
+ // A checkout carrying one pack whose source and target are different paths —
16
+ // the shape `planFiles` never writes into, and therefore the only shape that
17
+ // can drift.
18
+ function sandbox(target?: string): { model: RulesModel; root: string } {
19
+ const root = mkdtempSync(join(tmpdir(), "orly-packs-"));
20
+ mkdirSync(join(root, "packs/demo"), { recursive: true });
21
+ writeFileSync(join(root, SOURCE), BODY);
22
+ if (target !== undefined) {
23
+ mkdirSync(join(root, "dispatch"), { recursive: true });
24
+ writeFileSync(join(root, TARGET), target);
25
+ }
26
+ const registry = { packs: { [PACK]: { extensions: [], managed_files: [{ source: SOURCE, target: TARGET }] } } };
27
+ return { model: new RulesModel(root, registry), root };
28
+ }
8
29
 
9
30
  test("every profile renders deterministically", async () => {
10
31
  const checks = await verifyRenders(await RulesModel.load(ROOT));
11
32
 
12
33
  expect(checks.filter((check) => check.name.endsWith(".idempotent")).every((check) => check.result === "pass")).toBeTrue();
13
34
  });
35
+
36
+ test("pack targets match their sources", async () => {
37
+ const model = await RulesModel.load(ROOT);
38
+ const local = Object.keys(model.registry.packs as Record<string, unknown>).sort();
39
+
40
+ expect(await packSourceErrors(model, local)).toEqual([]);
41
+ });
42
+
43
+ test("pack target drift names the pack and both paths", async () => {
44
+ const { model, root } = sandbox(`${BODY}and a second rule the source never gained.\n`);
45
+ try {
46
+ const findings = await packSourceErrors(model, [PACK]);
47
+
48
+ expect(findings).toHaveLength(1);
49
+ expect(findings[0]).toContain(TARGET);
50
+ expect(findings[0]).toContain(SOURCE);
51
+ expect(findings[0]).toContain(PACK);
52
+ } finally {
53
+ rmSync(root, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ test("a pack target the checkout does not carry is not drift", async () => {
58
+ const { model, root } = sandbox();
59
+ try {
60
+ expect(await packSourceErrors(model, [PACK])).toEqual([]);
61
+ } finally {
62
+ rmSync(root, { recursive: true, force: true });
63
+ }
64
+ });
65
+
66
+ // The trailing newline managedContent appends is part of what a consumer
67
+ // receives, so a target missing it is drift like any other byte.
68
+ test("a matched pack target reports clean", async () => {
69
+ const { model, root } = sandbox(BODY);
70
+ try {
71
+ expect(await packSourceErrors(model, [PACK])).toEqual([]);
72
+ } finally {
73
+ rmSync(root, { recursive: true, force: true });
74
+ }
75
+ });
76
+
77
+ test("verify reports pack source parity beside the render proofs", async () => {
78
+ const checks = await verifyRenders(await RulesModel.load(ROOT));
79
+ const parity = checks.find((check) => check.name === "packs.sources.current");
80
+
81
+ expect(parity).toBeDefined();
82
+ expect(parity?.result).toBe("pass");
83
+ });
package/src/verify.ts CHANGED
@@ -1,12 +1,16 @@
1
- import { mkdirSync } from "node:fs";
1
+ import { existsSync, mkdirSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
 
4
4
  import { localSelection } from "./config";
5
- import { objectValue, RulesModel } from "./model";
5
+ import { managedContent } from "./install";
6
+ import { hashContent, isString, objectArray, objectValue, RulesModel } from "./model";
6
7
  import { Renderer } from "./render";
7
8
 
8
9
  const PASS_RESULT = "pass";
9
10
  const FAIL_RESULT = "fail";
11
+ const REGISTRY_PACKS_LABEL = "registry packs";
12
+ const AGENTS_FILENAME = "AGENTS.md";
13
+ const DETAIL_SEPARATOR = "; ";
10
14
 
11
15
  export type VerificationCheck = {
12
16
  name: string;
@@ -32,10 +36,47 @@ export async function verifyRenders(model: RulesModel): Promise<VerificationChec
32
36
  });
33
37
  }
34
38
  const errors = await renderer.rootErrors(local.packs, local.commands);
35
- checks.push({ name: "generated.root.current", result: errors.length === 0 ? PASS_RESULT : FAIL_RESULT, detail: errors.join("; ") });
39
+ checks.push({ name: "generated.root.current", result: errors.length === 0 ? PASS_RESULT : FAIL_RESULT, detail: errors.join(DETAIL_SEPARATOR) });
40
+ const drift = await packSourceErrors(model, local.packs);
41
+ checks.push({ name: "packs.sources.current", result: drift.length === 0 ? PASS_RESULT : FAIL_RESULT, detail: drift.join(DETAIL_SEPARATOR) });
36
42
  return checks;
37
43
  }
38
44
 
45
+ // Every pack file this checkout carries, against the bytes its pack would ship.
46
+ //
47
+ // `planFiles` deliberately skips writing a managed file into the checkout that
48
+ // owns its source: writing there would either replace a source with its own
49
+ // pack-filtered rendering or plant a second copy that drifts. The consequence is
50
+ // that the copy living here is maintained BY HAND and no install ever corrects
51
+ // it — so a source can gain three sections while the target beside it keeps the
52
+ // old two, and every consumer receives rules this repository's own agents, its
53
+ // dispatch-coverage audit, and its rule ledger never see. Both halves of that
54
+ // had already happened when this check was written.
55
+ //
56
+ // A target the checkout does not carry is silence, not drift: a consumer-only
57
+ // path (a skill copied into `.claude/skills/`) is absent here by design.
58
+ export async function packSourceErrors(model: RulesModel, packs: string[]): Promise<string[]> {
59
+ const registryPacks = objectValue(model.registry.packs, REGISTRY_PACKS_LABEL);
60
+ const known = new Set(Object.keys(registryPacks));
61
+ const findings = new Set<string>();
62
+ for (const name of packs) {
63
+ const pack = objectValue(registryPacks[name], `pack ${name}`);
64
+ for (const entry of objectArray(pack.managed_files, `pack ${name} managed_files`)) {
65
+ if (!isString(entry.source) || !isString(entry.target)) continue;
66
+ // Same path on both sides is the source itself — nothing to compare it to.
67
+ if (entry.source === entry.target) continue;
68
+ const targetPath = join(model.root, entry.target);
69
+ if (!existsSync(targetPath)) continue;
70
+ // The engine checkout renders its own rules under their own name, so the
71
+ // citation retarget managedContent applies for a guest is identity here.
72
+ const expected = await managedContent(join(model.root, entry.source), entry.target, entry.source, packs, known, AGENTS_FILENAME);
73
+ if (hashContent(expected) === hashContent(await Bun.file(targetPath).bytes())) continue;
74
+ findings.add(`${entry.target} drifted from ${entry.source} (pack ${name}) — copy the source over it`);
75
+ }
76
+ }
77
+ return [...findings].sort();
78
+ }
79
+
39
80
  export async function writeEvidence(
40
81
  model: RulesModel,
41
82
  profile: string,