@agentsfleet/orly 0.5.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.
Files changed (112) hide show
  1. package/.githooks/pre-commit +42 -0
  2. package/.githooks/pre-push +67 -0
  3. package/AGENTS.md +348 -0
  4. package/LICENSE +21 -0
  5. package/README.md +249 -0
  6. package/SOUL.md +90 -0
  7. package/audits/agents-md.md +497 -0
  8. package/audits/agents-md.sh +353 -0
  9. package/audits/cross-tier-rates.sh +105 -0
  10. package/audits/data.sh +185 -0
  11. package/audits/deinit-pairs.sh +224 -0
  12. package/audits/design-tokens.sh +143 -0
  13. package/audits/doc-read.sh +183 -0
  14. package/audits/error-codes.sh +221 -0
  15. package/audits/fixtures/clean.diff +15 -0
  16. package/audits/fixtures/dirty.diff +16 -0
  17. package/audits/lifecycle-anchors.sh +22 -0
  18. package/audits/logging.sh +217 -0
  19. package/audits/msid-ui.sh +144 -0
  20. package/audits/parity-dispatch.sh +51 -0
  21. package/audits/rule-ledger-lib.sh +264 -0
  22. package/audits/rule-ledger.sh +225 -0
  23. package/audits/rule-paths.sh +108 -0
  24. package/audits/spec-template.sh +258 -0
  25. package/audits/sql-mod.sh +102 -0
  26. package/audits/ufs.sh +303 -0
  27. package/bin/orly +26 -0
  28. package/core/operating-model.md +256 -0
  29. package/dispatch/edit_rules.md +69 -0
  30. package/dispatch/lib.sh +213 -0
  31. package/dispatch/lifecycle.md +187 -0
  32. package/dispatch/name_architecture.md +93 -0
  33. package/dispatch/verify.md +111 -0
  34. package/dispatch/write_any.md +740 -0
  35. package/dispatch/write_any.sh +49 -0
  36. package/dispatch/write_auth.md +36 -0
  37. package/dispatch/write_changelog.md +64 -0
  38. package/dispatch/write_documentation.md +45 -0
  39. package/dispatch/write_go.md +24 -0
  40. package/dispatch/write_http.md +45 -0
  41. package/dispatch/write_mdx.md +7 -0
  42. package/dispatch/write_python.md +8 -0
  43. package/dispatch/write_rust.md +38 -0
  44. package/dispatch/write_shell.md +8 -0
  45. package/dispatch/write_spec.md +105 -0
  46. package/dispatch/write_sql.md +112 -0
  47. package/dispatch/write_sql.sh +36 -0
  48. package/dispatch/write_ts_adhere_bun.md +493 -0
  49. package/dispatch/write_ts_adhere_bun.sh +37 -0
  50. package/dispatch/write_zig.md +852 -0
  51. package/dispatch/write_zig.sh +40 -0
  52. package/docs/CHANGELOG_VOICE.md +18 -0
  53. package/docs/DISPATCH_ARCHITECTURE.md +664 -0
  54. package/docs/DISPATCH_REVIEW_DISPOSITION.md +65 -0
  55. package/docs/DOCUMENTATION_RULES.md +209 -0
  56. package/docs/EXECUTE_DOC_READS.md +29 -0
  57. package/docs/HARNESS_VERIFY_OUTPUT.md +40 -0
  58. package/docs/LIFECYCLE_PATTERNS.md +316 -0
  59. package/docs/LOGGING_STANDARD.md +397 -0
  60. package/docs/ORLY_ARCHITECTURE.md +129 -0
  61. package/docs/REST_API_DESIGN_GUIDELINES.md +783 -0
  62. package/docs/RULE_ENFORCEMENT.md +41 -0
  63. package/docs/SCHEMA_CONVENTIONS.md +93 -0
  64. package/docs/TEMPLATE.md +411 -0
  65. package/docs/VERIFY_TIERS.md +57 -0
  66. package/docs/greptile-learnings/README.md +51 -0
  67. package/docs/greptile-learnings/RULES.md +1045 -0
  68. package/fixtures/registry-invalid.json +1 -0
  69. package/fixtures/registry-valid.json +18 -0
  70. package/fixtures/unclosed-pack-block.md +8 -0
  71. package/package.json +63 -0
  72. package/packs/language/go/rules.md +24 -0
  73. package/packs/language/mdx/rules.md +7 -0
  74. package/packs/language/python/rules.md +8 -0
  75. package/packs/language/rust/rules.md +38 -0
  76. package/packs/language/shell/rules.md +8 -0
  77. package/registry.json +405 -0
  78. package/schemas/evidence.schema.json +43 -0
  79. package/schemas/profile.schema.json +12 -0
  80. package/schemas/registry.schema.json +12 -0
  81. package/skills/orly-babysit-prs/SKILL.md +326 -0
  82. package/skills/orly-spec-new/SKILL.md +157 -0
  83. package/skills/orly-write-integration-test/SKILL.md +470 -0
  84. package/skills/orly-write-unit-test/SKILL.md +520 -0
  85. package/src/cli.test.ts +70 -0
  86. package/src/cli.ts +287 -0
  87. package/src/config.test.ts +226 -0
  88. package/src/config.ts +288 -0
  89. package/src/criteria.test.ts +137 -0
  90. package/src/criteria.ts +166 -0
  91. package/src/criteria_spec.ts +127 -0
  92. package/src/criteria_support.ts +53 -0
  93. package/src/gates.test.ts +183 -0
  94. package/src/gates.ts +169 -0
  95. package/src/gates_spec.test.ts +98 -0
  96. package/src/gates_test_support.ts +194 -0
  97. package/src/git_env.ts +20 -0
  98. package/src/install.test.ts +514 -0
  99. package/src/install.ts +447 -0
  100. package/src/install_nested.test.ts +32 -0
  101. package/src/model.test.ts +31 -0
  102. package/src/model.ts +245 -0
  103. package/src/references.test.ts +47 -0
  104. package/src/references.ts +147 -0
  105. package/src/render.test.ts +60 -0
  106. package/src/render.ts +94 -0
  107. package/src/surfaces.test.ts +43 -0
  108. package/src/surfaces.ts +89 -0
  109. package/src/validation.test.ts +11 -0
  110. package/src/validation.ts +87 -0
  111. package/src/verify.test.ts +13 -0
  112. package/src/verify.ts +59 -0
package/src/install.ts ADDED
@@ -0,0 +1,447 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, renameSync, rmSync } from "node:fs";
2
+ import { dirname, extname, join, relative, resolve } from "node:path";
3
+
4
+ import { UNSCOPED_ENVIRONMENT } from "./git_env";
5
+ import { CONFIG_PATH, readConfig, seedConfig, selectPacks, writeConfig } from "./config";
6
+ import { applyMode, assertWritableInside, hashContent, isBelow, isString, JsonObject, modeLabel, objectArray, objectValue, OrlyError, RulesModel, stringArray } from "./model";
7
+ import { referenceClosureErrors, renderProfileText } from "./references";
8
+ import { Renderer } from "./render";
9
+
10
+ const AGENTS_FILENAME = "AGENTS.md";
11
+ const ORLY_AGENTS_FILENAME = "AGENTS.orly.md";
12
+ const GENERATED_BANNER = "> **Generated by `orly`.**";
13
+ const NEWLINE = "\n";
14
+ const POINTER_OPEN = "<!-- orly:begin -->";
15
+ const POINTER_CLOSE = "<!-- orly:end -->";
16
+ const HOOKS_DIRECTORY = ".githooks";
17
+ const REGISTRY_PACKS_LABEL = "registry packs";
18
+ const STAGE_PREFIX = "orly-install-";
19
+ const MARKDOWN_EXTENSION = ".md";
20
+ const SKILLS_SEGMENT = "/skills/";
21
+ const MODE_EXECUTABLE = "0755";
22
+ const MODE_REGULAR = "0644";
23
+ const GIT_COMMAND = "git";
24
+ const GIT_REPO_FLAG = "-C";
25
+ const PIPE_OUTPUT = "pipe";
26
+ const HOOK_GATES = [["pre-commit", "work"], ["pre-push", "verify"]] as const;
27
+ const GIT_CONFIG_SUBCOMMAND = "config";
28
+ const HOOKS_PATH_KEY = "core.hooksPath";
29
+ const MANAGED_FILE_KIND = "managed file";
30
+ const HOOK_KIND = "hook";
31
+ const POINTER_HOST_KIND = "pointer host";
32
+
33
+ export type InstallError = { path: string; message: string; suggestion: string };
34
+
35
+ export type InstallResult = {
36
+ ok: boolean;
37
+ packs: string[];
38
+ written: string[];
39
+ skipped: string[];
40
+ errors: InstallError[];
41
+ };
42
+
43
+ export type InstallOptions = {
44
+ targetRoot: string;
45
+ force: boolean;
46
+ installHooks: boolean;
47
+ orlyVersion: string;
48
+ };
49
+
50
+ type PlannedFile = { target: string; content: Uint8Array; mode: string };
51
+
52
+ // Materialise a profile into a repository: its rules, the gates that enforce
53
+ // them, the hooks that run the gates, and a lock recording exactly what landed.
54
+ // Everything is staged in a temporary directory first, so a run that refuses —
55
+ // or dies — leaves the target untouched rather than half-installed.
56
+ export async function install(model: RulesModel, options: InstallOptions): Promise<InstallResult> {
57
+ const targetRoot = resolve(options.targetRoot);
58
+ requireWorkTree(targetRoot);
59
+ const existing = await readConfig(targetRoot);
60
+ const config = existing ?? await seedConfig(targetRoot);
61
+ const packs = selectPacks(model, targetRoot, config.packs, new Set(config.managed));
62
+
63
+ const layout = resolveLayout(model, targetRoot);
64
+ const planned = await planFiles(model, packs, config.commands, targetRoot, layout.orlyFile);
65
+ const managed = new Set(config.managed);
66
+ const refusals: InstallError[] = [];
67
+ const written: string[] = [];
68
+ const skipped: string[] = [];
69
+
70
+ for (const file of planned) {
71
+ const escape = escapesTarget(targetRoot, file.target, MANAGED_FILE_KIND);
72
+ if (escape) { refusals.push(escape); continue; }
73
+ const path = join(targetRoot, file.target);
74
+ if (!existsSync(path)) { written.push(file.target); continue; }
75
+ // Already exactly what this run would write — leave it and say so.
76
+ if (hashContent(await Bun.file(path).bytes()) === hashContent(file.content) && modeLabel(path) === file.mode) { skipped.push(file.target); continue; }
77
+ // The lock is the record of authorship: a file orly wrote is orly's to
78
+ // replace, and git shows the replacement before it is committed. A file
79
+ // orly never wrote belongs to the repository and is never touched silently.
80
+ if (options.force || managed.has(file.target)) { written.push(file.target); continue; }
81
+ // The generated banner is authorship evidence in its own right, and the
82
+ // only evidence available for a repository installed before managed[]
83
+ // tracked anything — this checkout among them. Without it, orly's own
84
+ // render reads as a stranger's file and every update refuses.
85
+ if (file.target === layout.orlyFile && (await Bun.file(path).text()).startsWith(GENERATED_BANNER)) { written.push(file.target); continue; }
86
+ refusals.push(refusal(file.target, false));
87
+ }
88
+ if (options.installHooks) {
89
+ const claim = hooksClaimedByAnother(targetRoot);
90
+ if (claim && !options.force) refusals.push(claim);
91
+ for (const [name, gate] of HOOK_GATES) {
92
+ const target = `${HOOKS_DIRECTORY}/${name}`;
93
+ const escape = escapesTarget(targetRoot, target, HOOK_KIND);
94
+ if (escape) { refusals.push(escape); continue; }
95
+ const authorship = await hookAuthorship(targetRoot, target, gate, managed, options.force);
96
+ if (authorship) refusals.push(authorship);
97
+ }
98
+ }
99
+ const configEscape = escapesTarget(targetRoot, CONFIG_PATH, "config");
100
+ if (configEscape) refusals.push(configEscape);
101
+ // The pointer host is a write like any other, and the only one orly aims at
102
+ // a file it does not own — so it needs the guard most, not least. A committed
103
+ // AGENTS.md symlink pointing outside the repository otherwise carried the
104
+ // block wherever the link led, and reported it as an ordinary success.
105
+ if (layout.pointerHost) {
106
+ const hostEscape = escapesTarget(targetRoot, layout.pointerHost, POINTER_HOST_KIND);
107
+ if (hostEscape) refusals.push(hostEscape);
108
+ }
109
+ if (refusals.length > 0) return { ok: false, packs, written: [], skipped, errors: refusals };
110
+
111
+ const closure = await stageAndCommit(model, targetRoot, planned, written);
112
+ if (closure.length > 0) return { ok: false, packs, written: [], skipped, errors: closure };
113
+
114
+ // After the managed files land, so a repository whose install refused for any
115
+ // other reason never carries a pointer to rules that were not written.
116
+ if (layout.pointerHost && await upsertPointer(targetRoot, layout.pointerHost, layout.orlyFile)) written.push(layout.pointerHost);
117
+ const hooks = options.installHooks ? await installHooks(targetRoot) : { written: [], all: [] };
118
+ written.push(...hooks.written);
119
+ skipped.push(...hooks.all.filter((path) => !hooks.written.includes(path)));
120
+ // One file carries both halves: the repository's own fields pass through
121
+ // untouched, orly's two record what this run installed and wrote.
122
+ await writeConfig(targetRoot, { ...config, orly_version: options.orlyVersion, managed: [...planned.map((file) => file.target), ...hooks.all] });
123
+ if (!existing) written.push(CONFIG_PATH);
124
+ return { ok: true, packs, written: written.sort(), skipped: skipped.sort(), errors: [] };
125
+ }
126
+
127
+ // A committed symlink inside the target repository — planted by that
128
+ // repository's own author, not the person running init — otherwise lets a
129
+ // managed-file or hook write follow it and land anywhere the invoking user
130
+ // can write. Checked up front, before anything is staged, so it reports as
131
+ // a normal refusal rather than a mid-write failure; assertWritableInside
132
+ // runs again at the actual write as a last-line guard against a target
133
+ // that changed underneath the run.
134
+ function escapesTarget(targetRoot: string, target: string, kind: string): InstallError | undefined {
135
+ try {
136
+ assertWritableInside(targetRoot, target, kind);
137
+ return undefined;
138
+ } catch (error) {
139
+ return { path: target, message: error instanceof Error ? error.message : String(error), suggestion: "remove or fix the symlink in the target repository before installing" };
140
+ }
141
+ }
142
+
143
+ // A hook is a managed file that happens to be generated rather than copied, and
144
+ // it earns the same authorship rule: one orly wrote is orly's to replace, one
145
+ // the repository wrote is the repository's. Without this, installHooks wrote
146
+ // unconditionally — a repository whose own pre-commit ran its test suite lost
147
+ // it to `orly init` silently, counted as an ordinary write in the success line.
148
+ async function hookAuthorship(targetRoot: string, target: string, gate: string, managed: Set<string>, force: boolean): Promise<InstallError | undefined> {
149
+ if (force || managed.has(target)) return undefined;
150
+ const path = join(targetRoot, target);
151
+ if (!existsSync(path)) return undefined;
152
+ // Byte-identical to what this run would write: an install that predates hook
153
+ // tracking, not someone else's hook. Replacing it changes nothing.
154
+ if ((await Bun.file(path).text()) === hookScript(gate)) return undefined;
155
+ return { path: target, message: "a hook already exists here and orly did not write it", suggestion: "move it aside, re-run with --force to replace it, or use --no-hooks" };
156
+ }
157
+
158
+ function refusal(target: string, wasManaged: boolean): InstallError {
159
+ return wasManaged
160
+ ? { path: target, message: "managed file was edited in place since it was installed", suggestion: "revert the edit, or re-run with --force to overwrite it" }
161
+ : { path: target, message: "a file already exists here and orly did not write it", suggestion: "move it aside, or re-run with --force to replace it" };
162
+ }
163
+
164
+ // The payload is written to a scratch directory and only moved into the target
165
+ // once every file exists and every reference it names resolves. Reference
166
+ // closure runs against the staged tree, so a pack whose façade cites a document
167
+ // no selected pack provides is caught before anything lands. The scratch
168
+ // directory lives inside the target's own .oracle/ — not the OS tmp dir —
169
+ // because `rename(2)` cannot cross a filesystem boundary: an OS tmp dir and
170
+ // the target repository are routinely on different filesystems (a mounted
171
+ // external drive, a devcontainer's bind-mounted workspace over a tmpfs
172
+ // /tmp), and staging there turned every such install into a hard EXDEV
173
+ // crash instead of the atomic move this function exists to guarantee.
174
+ async function stageAndCommit(model: RulesModel, targetRoot: string, planned: PlannedFile[], written: string[]): Promise<InstallError[]> {
175
+ const stagingParent = join(targetRoot, dirname(CONFIG_PATH));
176
+ const stagingParentExisted = existsSync(stagingParent);
177
+ mkdirSync(stagingParent, { recursive: true });
178
+ const stage = mkdtempSync(join(stagingParent, STAGE_PREFIX));
179
+
180
+ // A refusal must leave the target exactly as it found it, and staging now
181
+ // lives inside it: remove .oracle/ too if this run is the one that created
182
+ // it and it is still empty on the failure path — a fresh empty directory
183
+ // left behind is as much a footprint as a written file. The success path
184
+ // must NOT run this: .oracle/ is still empty at this point (the caller
185
+ // writes orly.json into it right after stageAndCommit returns), so an
186
+ // unconditional check would delete a directory the next step needs.
187
+ const cleanupStagingParentIfEmpty = () => {
188
+ rmSync(stage, { recursive: true, force: true });
189
+ if (!stagingParentExisted && existsSync(stagingParent) && readdirSync(stagingParent).length === 0) rmSync(stagingParent, { recursive: true, force: true });
190
+ };
191
+
192
+ try {
193
+ for (const file of planned) {
194
+ const path = join(stage, file.target);
195
+ mkdirSync(dirname(path), { recursive: true });
196
+ await Bun.write(path, file.content);
197
+ applyMode(path, file.mode);
198
+ }
199
+ // Skills are playbooks copied verbatim, not rule pages: one names the
200
+ // façade for every language it might be invoked in. Grading those citations
201
+ // would force a Go repository to take the Zig and TypeScript packs to
202
+ // satisfy a menu it will never read — the same reason a `docs/TEMPLATE.md`
203
+ // comment is exempt.
204
+ const markdown = planned
205
+ .filter((file) => extname(file.target) === MARKDOWN_EXTENSION && !file.target.includes(SKILLS_SEGMENT))
206
+ .map((file) => join(stage, file.target));
207
+ const missing = await referenceClosureErrors(stage, markdown, targetRoot);
208
+ if (missing.length > 0) {
209
+ cleanupStagingParentIfEmpty();
210
+ return missing.map((message) => ({ path: AGENTS_FILENAME, message, suggestion: "select the pack that provides the cited file, or amend the citation" }));
211
+ }
212
+
213
+ const targets = new Set(written);
214
+ for (const file of planned.filter((candidate) => targets.has(candidate.target))) {
215
+ assertWritableInside(targetRoot, file.target, MANAGED_FILE_KIND);
216
+ const destination = join(targetRoot, file.target);
217
+ mkdirSync(dirname(destination), { recursive: true });
218
+ renameSync(join(stage, file.target), destination);
219
+ applyMode(destination, file.mode);
220
+ }
221
+ rmSync(stage, { recursive: true, force: true });
222
+ return [];
223
+ } catch (error) {
224
+ cleanupStagingParentIfEmpty();
225
+ throw error;
226
+ }
227
+ }
228
+
229
+ // A hooksPath already pointing somewhere other than what init installs is
230
+ // someone else's setup — retargeting it silently would disable whatever
231
+ // ran there before. init only overrides its own prior installs (the same
232
+ // directory name) or an unset config, matching the idempotent-rerun case.
233
+ function hooksClaimedByAnother(targetRoot: string): InstallError | undefined {
234
+ const result = Bun.spawnSync([GIT_COMMAND, GIT_REPO_FLAG, targetRoot, GIT_CONFIG_SUBCOMMAND, "--get", HOOKS_PATH_KEY], { stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT, env: UNSCOPED_ENVIRONMENT });
235
+ if (result.exitCode !== 0) return undefined;
236
+ const existing = result.stdout.toString().trim();
237
+ if (existing === "" || existing === HOOKS_DIRECTORY) return undefined;
238
+ return { path: HOOKS_PATH_KEY, message: `already set to '${existing}', not the directory this install manages`, suggestion: "run with --no-hooks to leave it alone, or --force to retarget it" };
239
+ }
240
+
241
+ // Hooks are generated, never copied: this repository's own hooks run `make
242
+ // audit` and `bin/orly verify`, neither of which exists in a consumer. What a
243
+ // consumer needs is the gate engine, reached through the binary that installed
244
+ // it — with a PATH fallback so a relocated checkout still resolves.
245
+ async function installHooks(targetRoot: string): Promise<{ written: string[]; all: string[] }> {
246
+ for (const [name] of HOOK_GATES) assertWritableInside(targetRoot, `${HOOKS_DIRECTORY}/${name}`, HOOK_KIND);
247
+ const directory = join(targetRoot, HOOKS_DIRECTORY);
248
+ mkdirSync(directory, { recursive: true });
249
+ const written: string[] = [];
250
+ const all: string[] = [];
251
+ for (const [name, gate] of HOOK_GATES) {
252
+ const path = join(directory, name);
253
+ const script = hookScript(gate);
254
+ all.push(`${HOOKS_DIRECTORY}/${name}`);
255
+ if (existsSync(path) && (await Bun.file(path).text()) === script && modeLabel(path) === MODE_EXECUTABLE) continue;
256
+ await Bun.write(path, script);
257
+ applyMode(path, MODE_EXECUTABLE);
258
+ written.push(`${HOOKS_DIRECTORY}/${name}`);
259
+ }
260
+ runGit(targetRoot, [GIT_CONFIG_SUBCOMMAND, HOOKS_PATH_KEY, HOOKS_DIRECTORY]);
261
+ return { written, all };
262
+ }
263
+
264
+ function hookScript(gate: string): string {
265
+ return [
266
+ "#!/usr/bin/env bash",
267
+ "# Generated by orly. Re-run `orly init` to refresh.",
268
+ "set -euo pipefail",
269
+ "",
270
+ "# git exports these to every hook; inherited by a spawned git they pin it to",
271
+ "# the hook's repository instead of the one being judged.",
272
+ "unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_COMMON_DIR GIT_PREFIX \\",
273
+ " GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES",
274
+ "",
275
+ 'if ! command -v orly >/dev/null 2>&1; then',
276
+ ' printf "orly: not on PATH — install it with `bun add -g @agentsfleet/orly`, or delete this hook\\n" >&2',
277
+ " exit 1",
278
+ "fi",
279
+ "",
280
+ `exec orly gate ${gate}`,
281
+ "",
282
+ ].join("\n");
283
+ }
284
+
285
+
286
+ // One entry per target, so two packs naming the same file collapse instead of
287
+ // racing. Two packs naming DIFFERENT sources for one target is a registry bug
288
+ // and stops the install rather than letting pack order decide the winner.
289
+ async function planFiles(model: RulesModel, packs: string[], commands: Record<string, string[][]>, targetRoot: string, orlyFile: string): Promise<PlannedFile[]> {
290
+ const registryPacks = objectValue(model.registry.packs, REGISTRY_PACKS_LABEL);
291
+ const known = new Set(Object.keys(registryPacks));
292
+ const planned = new Map<string, PlannedFile>();
293
+ const sources = new Map<string, string>();
294
+ for (const name of packs) {
295
+ const pack = objectValue(registryPacks[name], `pack ${name}`);
296
+ for (const entry of objectArray(pack.managed_files, `pack ${name} managed_files`)) {
297
+ if (!isString(entry.source) || !isString(entry.target)) throw new OrlyError(`pack ${name} managed file must carry string source and target`);
298
+ const claimed = sources.get(entry.target);
299
+ if (claimed && claimed !== entry.source) throw new OrlyError(`packs disagree on ${entry.target}: ${claimed} and ${entry.source}`);
300
+ sources.set(entry.target, entry.source);
301
+ const path = join(model.root, entry.source);
302
+ // Installing into the checkout that owns the sources. Copying is for
303
+ // consumers: here the repository already has the file, and writing it
304
+ // would either replace a pack source with its own pack-filtered
305
+ // rendering (same path) or plant a second copy that drifts from the
306
+ // original (skills/ into .claude/skills/). Skipping is what the retired
307
+ // sync verb used to buy, generalised past the same-path case.
308
+ if (resolved(model.root) === resolved(targetRoot)) continue;
309
+ planned.set(entry.target, { target: entry.target, content: await managedContent(path, entry.target, entry.source, packs, known, orlyFile), mode: modeLabel(path) });
310
+ }
311
+ }
312
+ const rendered = await new Renderer(model).renderText(packs, commands);
313
+ planned.set(orlyFile, { target: orlyFile, content: new TextEncoder().encode(rendered), mode: MODE_REGULAR });
314
+ return [...planned.values()].sort((left, right) => left.target.localeCompare(right.target));
315
+ }
316
+
317
+ // Where orly's rules land, and whether the repository keeps a file of its own.
318
+ //
319
+ // A repository that already wrote an AGENTS.md keeps it, under its own name,
320
+ // byte for byte — orly's rules go beside it as AGENTS.orly.md and are reached
321
+ // by a pointer block. Renaming their file would put orly's name on content it
322
+ // did not write and move a file its own docs already cite. A repository with
323
+ // no AGENTS.md gets orly's rules under that name directly, because nothing is
324
+ // displaced and agent runtimes auto-load it.
325
+ //
326
+ // The test is the generated banner, not the config's managed list: this
327
+ // checkout's own .oracle/orly.json predates managed tracking, so a
328
+ // managed-based test would treat orly's own render as a stranger's file and
329
+ // push it aside on the next update.
330
+ function resolveLayout(model: RulesModel, targetRoot: string): { orlyFile: string; pointerHost?: string } {
331
+ // The checkout that authors the rules holds them as sources, not as an
332
+ // installed copy — the same reason pack files inside the target are skipped
333
+ // rather than written. Its AGENTS.md IS the render, and the payload ships it
334
+ // as the reference copy.
335
+ if (resolved(model.root) === resolved(targetRoot)) return { orlyFile: AGENTS_FILENAME };
336
+ // Everywhere else orly is a guest. It always takes AGENTS.orly.md and always
337
+ // leaves AGENTS.md to the repository, whether or not the repository has
338
+ // written one yet. One layout, so `update` never has to ask which mode it is
339
+ // in, and a repository that starts with no rules of its own still has the
340
+ // file to put them in when it wants them.
341
+ return { orlyFile: ORLY_AGENTS_FILENAME, pointerHost: AGENTS_FILENAME };
342
+ }
343
+
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
+ function resolved(path: string): string {
400
+ try {
401
+ return realpathSync(path);
402
+ } catch {
403
+ return resolve(path);
404
+ }
405
+ }
406
+
407
+ // Managed markdown is pack-filtered on the way in, exactly as a rendered core
408
+ // document is. Copying it raw is how a Rust crate ends up holding a rule that
409
+ // 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> {
411
+ const bytes = await Bun.file(path).bytes();
412
+ if (extname(target) !== MARKDOWN_EXTENSION) return bytes;
413
+ const filtered = renderProfileText(new TextDecoder().decode(bytes), new Set(packs), known, source);
414
+ return new TextEncoder().encode(`${retargetRulesCitations(filtered, orlyFile)}\n`);
415
+ }
416
+
417
+ // A rule page citing `AGENTS.md` means orly's rules, which is that file's name
418
+ // only in the checkout that authors them. In a consumer they live in
419
+ // AGENTS.orly.md and AGENTS.md belongs to the repository, so a citation left
420
+ // alone would point every reader at the wrong half — and fail reference
421
+ // closure, since the repository's own file is not orly's to promise.
422
+ function retargetRulesCitations(text: string, orlyFile: string): string {
423
+ if (orlyFile === AGENTS_FILENAME) return text;
424
+ return text
425
+ .replaceAll(`](${AGENTS_FILENAME})`, `](${orlyFile})`)
426
+ .replaceAll(`](../${AGENTS_FILENAME})`, `](../${orlyFile})`)
427
+ .replaceAll(`\`${AGENTS_FILENAME}\``, `\`${orlyFile}\``);
428
+ }
429
+
430
+ // git canonicalises symlinks in --show-toplevel; targetRoot, as handed in by
431
+ // a caller, usually has not been (macOS puts $TMPDIR and often the caller's
432
+ // own working directory behind one). Comparing without resolving both sides
433
+ // made every install from a symlinked path — routine on macOS — refuse with
434
+ // "not the repository root" against its own root.
435
+ function requireWorkTree(targetRoot: string): void {
436
+ if (!existsSync(targetRoot)) throw new OrlyError(`target directory does not exist: ${targetRoot}`);
437
+ const result = Bun.spawnSync([GIT_COMMAND, GIT_REPO_FLAG, targetRoot, "rev-parse", "--show-toplevel"], { stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT, env: UNSCOPED_ENVIRONMENT });
438
+ if (result.exitCode !== 0) throw new OrlyError(`not a git repository: ${targetRoot} — run \`git init\` first`);
439
+ const top = resolve(result.stdout.toString().trim());
440
+ const resolvedTarget = realpathSync(targetRoot);
441
+ if (top !== resolvedTarget) throw new OrlyError(`install at the repository root, not a subdirectory: ${relative(top, resolvedTarget)}`);
442
+ }
443
+
444
+ function runGit(targetRoot: string, args: string[]): void {
445
+ const result = Bun.spawnSync([GIT_COMMAND, GIT_REPO_FLAG, targetRoot, ...args], { stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT, env: UNSCOPED_ENVIRONMENT });
446
+ if (result.exitCode !== 0) throw new OrlyError(`git ${args.join(" ")} failed: ${result.stderr.toString().trim()}`);
447
+ }
@@ -0,0 +1,32 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ import { cleanupTemporaryDirectories, newRepository } from "./gates_test_support";
6
+ import { install } from "./install";
7
+ import { RulesModel } from "./model";
8
+
9
+ afterEach(cleanupTemporaryDirectories);
10
+
11
+ describe("install package location", () => {
12
+ test("materialises managed files when the engine package is nested in a consumer repository", async () => {
13
+ const repo = newRepository();
14
+ const engineRoot = join(repo, "node_modules", "@agentsfleet", "orly");
15
+ mkdirSync(engineRoot, { recursive: true });
16
+ await Bun.write(join(engineRoot, "core.md"), "core\n");
17
+ await Bun.write(join(engineRoot, "rules.md"), "fixture rules\n");
18
+
19
+ const model = new RulesModel(engineRoot, {
20
+ schema_version: 1,
21
+ core_documents: ["core.md"],
22
+ packs: { base: { extensions: [], managed_files: [{ source: "rules.md", target: "rules.md" }] } },
23
+ rules: [],
24
+ });
25
+
26
+ const result = await install(model, { targetRoot: repo, force: false, installHooks: false, orlyVersion: "0.5.0" });
27
+
28
+ expect(result.ok).toBe(true);
29
+ expect(result.written).toContain("rules.md");
30
+ expect(await Bun.file(join(repo, "rules.md")).text()).toBe("fixture rules\n");
31
+ });
32
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolve } from "node:path";
3
+
4
+ import { readJsonObject, RulesModel } from "./model";
5
+
6
+ const ROOT = resolve(import.meta.dir, "..");
7
+
8
+ describe("RulesModel", () => {
9
+ test("validates the current registry", async () => {
10
+ const model = await RulesModel.load(ROOT);
11
+
12
+ expect(() => model.validate()).not.toThrow();
13
+ });
14
+
15
+ test("rejects the declared invalid registry fixture", async () => {
16
+ const registry = await readJsonObject(resolve(ROOT, "fixtures/registry-invalid.json"));
17
+ const model = new RulesModel(ROOT, registry);
18
+
19
+ expect(() => model.validate()).toThrow();
20
+ });
21
+
22
+ test("rejects missing mechanical rule fixtures", async () => {
23
+ const source = await RulesModel.load(ROOT);
24
+ const registry = structuredClone(source.registry);
25
+ if (!Array.isArray(registry.rules) || typeof registry.rules[0] !== "object" || registry.rules[0] === null) throw new Error("mechanical rule missing");
26
+ registry.rules[0] = { ...registry.rules[0], fixtures: { pass: ["fixtures/missing.json"], fail: ["fixtures/registry-invalid.json"] } };
27
+ const model = new RulesModel(source.root, registry);
28
+
29
+ expect(() => model.validate()).toThrow("fixture source is missing");
30
+ });
31
+ });