@agentsfleet/orly 0.10.14 → 0.11.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 (155) hide show
  1. package/.githooks/pre-push +2 -1
  2. package/AGENTS.md +21 -31
  3. package/README.md +65 -29
  4. package/SOUL.md +13 -23
  5. package/audits/agents-md.md +67 -57
  6. package/audits/deinit-pairs.sh +13 -18
  7. package/audits/design-tokens.sh +9 -23
  8. package/audits/doc-read.sh +10 -33
  9. package/audits/doc-reads.ts +41 -0
  10. package/audits/error-codes.sh +18 -25
  11. package/audits/logging-rust.awk +145 -0
  12. package/audits/logging.sh +10 -166
  13. package/audits/msid-ui.sh +45 -18
  14. package/audits/rule-ledger-lib.sh +9 -6
  15. package/audits/rust-error.sh +20 -15
  16. package/audits/scope.sh +88 -0
  17. package/audits/spec-template.sh +2 -2
  18. package/audits/spec-template.ts +1 -1
  19. package/audits/sql-mod.sh +11 -14
  20. package/audits/ufs-strings.awk +164 -0
  21. package/audits/ufs.sh +10 -188
  22. package/core/operating-model.md +8 -8
  23. package/dispatch/edit_rules.md +8 -6
  24. package/dispatch/lib.sh +41 -26
  25. package/dispatch/lifecycle.md +12 -5
  26. package/dispatch/name_architecture.md +8 -3
  27. package/dispatch/verify.md +1 -1
  28. package/dispatch/write_any.md +10 -3
  29. package/dispatch/write_changelog.md +7 -4
  30. package/dispatch/write_go.md +1 -1
  31. package/dispatch/write_http.md +5 -7
  32. package/dispatch/write_python.md +1 -1
  33. package/dispatch/write_rust.md +17 -1
  34. package/dispatch/write_shell.md +1 -1
  35. package/dispatch/write_spec.md +3 -1
  36. package/dispatch/write_sql.md +28 -6
  37. package/dispatch/write_ts_adhere_bun.md +11 -0
  38. package/dispatch/write_zig.md +53 -21
  39. package/docs/CHANGELOG_VOICE.md +11 -2
  40. package/docs/EXECUTE_DOC_READS.md +10 -10
  41. package/docs/HARNESS_VERIFY_OUTPUT.md +6 -3
  42. package/docs/JUDGMENTS.md +153 -0
  43. package/docs/LIFECYCLE_PATTERNS.md +26 -16
  44. package/docs/LOGGING_STANDARD.md +39 -19
  45. package/docs/ORLY_ARCHITECTURE.md +77 -6
  46. package/docs/RELEASE_TEMPLATE.md +21 -5
  47. package/docs/REST_API_DESIGN_GUIDELINES.md +86 -21
  48. package/docs/RULE_ENFORCEMENT.md +6 -6
  49. package/docs/SCHEMA_CONVENTIONS.md +24 -3
  50. package/docs/TEMPLATE.md +4 -4
  51. package/docs/VERIFY_TIERS.md +2 -2
  52. package/docs/greptile-learnings/RULES.md +81 -26
  53. package/llms.txt +37 -34
  54. package/package.json +6 -1
  55. package/packs/language/go/rules.md +1 -1
  56. package/packs/language/python/rules.md +1 -1
  57. package/packs/language/rust/rules.md +17 -1
  58. package/packs/language/shell/rules.md +1 -1
  59. package/registry.json +22 -7
  60. package/schemas/profile.schema.json +48 -5
  61. package/skills/orly-babysit-prs/SKILL.md +87 -34
  62. package/skills/orly-spec-new/SKILL.md +19 -6
  63. package/skills/orly-write-integration-test/SKILL.md +4 -4
  64. package/skills/orly-write-unit-test/SKILL.md +10 -1
  65. package/src/cli.test.ts +2 -2
  66. package/src/cli.ts +19 -75
  67. package/src/cli_help.ts +69 -0
  68. package/src/command_limits.ts +20 -0
  69. package/src/command_process.test.ts +109 -0
  70. package/src/command_process.ts +88 -0
  71. package/src/command_runner.test.ts +88 -0
  72. package/src/command_runner.ts +33 -0
  73. package/src/comprehension_cache.test.ts +123 -0
  74. package/src/config.test.ts +5 -4
  75. package/src/config.ts +26 -164
  76. package/src/config_discovery.ts +159 -0
  77. package/src/config_validation.test.ts +56 -0
  78. package/src/config_validation.ts +25 -0
  79. package/src/criteria.test.ts +1 -1
  80. package/src/criteria.ts +10 -26
  81. package/src/criteria_spec.ts +12 -20
  82. package/src/criteria_support.ts +2 -19
  83. package/src/doc_reads.test.ts +42 -0
  84. package/src/doc_rules.ts +2 -2
  85. package/src/evaluation_resources.test.ts +64 -0
  86. package/src/execution/config.ts +22 -0
  87. package/src/execution/plan.test.ts +65 -0
  88. package/src/execution/plan.ts +51 -0
  89. package/src/gates.ts +9 -6
  90. package/src/gates_spec.test.ts +52 -6
  91. package/src/gates_test_support.ts +9 -7
  92. package/src/install.test.ts +38 -222
  93. package/src/install.ts +117 -308
  94. package/src/install_loaders.test.ts +203 -0
  95. package/src/install_nested.test.ts +2 -2
  96. package/src/install_packs.test.ts +66 -6
  97. package/src/installation/hooks.ts +63 -0
  98. package/src/installation/lock.test.ts +97 -0
  99. package/src/installation/lock.ts +56 -0
  100. package/src/installation/migration.test.ts +212 -0
  101. package/src/installation/migration.ts +65 -0
  102. package/src/installation/plan.ts +96 -0
  103. package/src/installation/transaction.ts +126 -0
  104. package/src/installation_preservation.test.ts +48 -0
  105. package/src/judgments/advice.test.ts +38 -0
  106. package/src/judgments/advice.ts +32 -0
  107. package/src/judgments/command.integration.test.ts +47 -0
  108. package/src/judgments/command.ts +90 -0
  109. package/src/judgments/constants.ts +55 -0
  110. package/src/judgments/evidence.test.ts +48 -0
  111. package/src/judgments/evidence.ts +34 -0
  112. package/src/judgments/files.test.ts +62 -0
  113. package/src/judgments/files.ts +73 -0
  114. package/src/judgments/questions.test.ts +19 -0
  115. package/src/judgments/questions.ts +85 -0
  116. package/src/judgments/replay.test.ts +43 -0
  117. package/src/judgments/replay.ts +66 -0
  118. package/src/judgments/runner.integration.test.ts +165 -0
  119. package/src/judgments/runner.ts +97 -0
  120. package/src/judgments/scan.test.ts +19 -0
  121. package/src/judgments/scan.ts +40 -0
  122. package/src/judgments/syntax.test.ts +115 -0
  123. package/src/judgments/syntax.ts +71 -0
  124. package/src/judgments/syntax_worker.ts +70 -0
  125. package/src/judgments/test_support.ts +60 -0
  126. package/src/judgments/transport.integration.test.ts +63 -0
  127. package/src/judgments/transport.ts +35 -0
  128. package/src/judgments/types.test.ts +43 -0
  129. package/src/judgments/types.ts +66 -0
  130. package/src/judgments/wire.test.ts +48 -0
  131. package/src/judgments/wire.ts +40 -0
  132. package/src/loaders.test.ts +13 -16
  133. package/src/loaders.ts +28 -10
  134. package/src/model.test.ts +20 -3
  135. package/src/model.ts +7 -18
  136. package/src/pack_hygiene.test.ts +42 -1
  137. package/src/pack_hygiene.ts +18 -8
  138. package/src/references.ts +15 -7
  139. package/src/release_inventory.test.ts +80 -0
  140. package/src/render.test.ts +1 -1
  141. package/src/render.ts +3 -8
  142. package/src/required_scanner.test.ts +36 -0
  143. package/src/review_poll.test.ts +158 -0
  144. package/src/scanner_scope.test.ts +202 -0
  145. package/src/setup.test.ts +9 -6
  146. package/src/spec_baseline.test.ts +1 -1
  147. package/src/spec_completion.test.ts +74 -0
  148. package/src/spec_obligations.ts +64 -0
  149. package/src/spec_rehearsal.test.ts +182 -0
  150. package/src/spec_template_io.test.ts +11 -9
  151. package/src/surfaces.test.ts +2 -0
  152. package/src/surfaces.ts +1 -1
  153. package/src/validation.ts +7 -4
  154. package/src/verify.test.ts +18 -2
  155. package/src/verify.ts +7 -3
package/src/install.ts CHANGED
@@ -1,182 +1,115 @@
1
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, renameSync, rmSync } from "node:fs";
2
- import { dirname, extname, join, relative, resolve } from "node:path";
3
-
1
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, copyFileSync, lstatSync, readlinkSync, symlinkSync, realpathSync, readdirSync, rmdirSync } from "node:fs";
2
+ import { dirname, extname, join, resolve } from "node:path";
4
3
  import { UNSCOPED_ENVIRONMENT } from "./git_env";
5
- import { CONFIG_PATH, contentDigest, readConfig, seedConfig, selectPacks, writeConfig } from "./config";
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";
8
- import { referenceClosureErrors, renderProfileText } from "./references";
9
- import { Renderer } from "./render";
10
- import { HOOK_INVOCATION_ENV, HOOK_INVOCATION_VALUE } from "./telemetry";
11
-
12
- const HOOKS_DIRECTORY = ".githooks";
13
- const REGISTRY_PACKS_LABEL = "registry packs";
14
- const STAGE_PREFIX = "orly-install-";
15
- const MARKDOWN_EXTENSION = ".md";
16
- const SKILLS_SEGMENT = "/skills/";
17
- const MODE_EXECUTABLE = "0755";
18
- const MODE_REGULAR = "0644";
19
- const GIT_COMMAND = "git";
20
- const GIT_REPO_FLAG = "-C";
4
+ import { CONFIG_PATH, contentDigest, sameDigest, seedConfig, serialiseConfig, type RepoConfig } from "./config";
5
+ import { selectPacks } from "./config_discovery";
6
+ import { assertWritableInside, applyMode, modeLabel, OrlyError, MODE_EXECUTABLE, MODE_REGULAR, type RulesModel } from "./model";
7
+ import { installLoaders, loaderTargets, type Layout } from "./loaders";
8
+ import { referenceClosureErrors } from "./references";
9
+ import { planFiles, resolveLayout, type PlannedFile } from "./installation/plan";
10
+ import { readInstallConfig, migrationDeletes, PREVIOUS_CONFIG } from "./installation/migration";
11
+ import { plannedHooks, checkHooks, configureHooks, hooksPath, HOOKS_DIRECTORY } from "./installation/hooks";
12
+ import { diskDigest, InstallTransaction, type FileWrite, type InstallCheckpoint } from "./installation/transaction";
13
+ import { installationLock } from "./installation/lock";
14
+
15
+ const INSTALL_DIRECTORY = ".orly";
16
+ const MANAGED_KIND = "managed file";
21
17
  const PIPE_OUTPUT = "pipe";
22
- const HOOK_GATES = [["pre-commit", "work"], ["pre-push", "verify"]] as const;
23
- const GIT_CONFIG_SUBCOMMAND = "config";
24
18
  const HOOKS_PATH_KEY = "core.hooksPath";
25
- const MANAGED_FILE_KIND = "managed file";
26
- const HOOK_KIND = "hook";
27
-
28
19
  export type InstallError = { path: string; message: string; suggestion: string };
20
+ export type InstallResult = { ok: boolean; packs: string[]; written: string[]; skipped: string[]; errors: InstallError[] };
21
+ export type InstallOptions = { targetRoot: string; force: boolean; installHooks: boolean; orlyVersion: string; requestedPacks?: string[]; checkpoint?: InstallCheckpoint };
29
22
 
30
- export type InstallResult = {
31
- ok: boolean;
32
- packs: string[];
33
- written: string[];
34
- skipped: string[];
35
- errors: InstallError[];
36
- };
37
-
38
- export type InstallOptions = {
39
- targetRoot: string;
40
- force: boolean;
41
- installHooks: boolean;
42
- orlyVersion: string;
43
- };
44
-
45
- type PlannedFile = { target: string; content: Uint8Array; mode: string };
46
-
47
- // Validate the planned files before writing them. Later filesystem failures
48
- // can still leave a partial installation; inspect the diff before retrying.
49
23
  export async function install(model: RulesModel, options: InstallOptions): Promise<InstallResult> {
50
- const targetRoot = resolve(options.targetRoot);
51
- requireWorkTree(targetRoot);
52
- const existing = await readConfig(targetRoot);
53
- const config = existing ?? await seedConfig(targetRoot);
54
- const packs = selectPacks(model, targetRoot, config.packs, new Set(config.managed));
55
-
56
- const layout = resolveLayout(model, targetRoot);
57
- const planned = await planFiles(model, packs, config.commands, targetRoot, layout.orlyFile);
58
- const managed = new Set(config.managed);
59
- const refusals: InstallError[] = [];
60
- const written: string[] = [];
61
- const skipped: string[] = [];
62
-
63
- for (const file of planned) {
64
- const escape = escapesTarget(targetRoot, file.target, MANAGED_FILE_KIND);
65
- if (escape) { refusals.push(escape); continue; }
66
- const path = join(targetRoot, file.target);
67
- if (!existsSync(path)) { written.push(file.target); continue; }
68
- // Already exactly what this run would write — leave it and say so.
69
- if (hashContent(await Bun.file(path).bytes()) === hashContent(file.content) && modeLabel(path) === file.mode) { skipped.push(file.target); continue; }
70
- // The lock is the record of authorship: a file orly wrote is orly's to
71
- // replace, and git shows the replacement before it is committed. A file
72
- // orly never wrote belongs to the repository and is never touched silently.
73
- if (options.force || managed.has(file.target)) { written.push(file.target); continue; }
74
- // The generated banner is authorship evidence in its own right, and the
75
- // only evidence available for a repository installed before managed[]
76
- // tracked anything — this checkout among them. Without it, orly's own
77
- // render reads as a stranger's file and every update refuses.
78
- if (file.target === layout.orlyFile && (await Bun.file(path).text()).startsWith(GENERATED_BANNER)) { written.push(file.target); continue; }
79
- refusals.push(refusal(file.target));
80
- }
81
- if (options.installHooks) {
82
- const claim = hooksClaimedByAnother(targetRoot);
83
- if (claim && !options.force) refusals.push(claim);
84
- for (const [name, gate] of HOOK_GATES) {
85
- const target = `${HOOKS_DIRECTORY}/${name}`;
86
- const escape = escapesTarget(targetRoot, target, HOOK_KIND);
87
- if (escape) { refusals.push(escape); continue; }
88
- const authorship = await hookAuthorship(targetRoot, target, gate, managed, options.force);
89
- if (authorship) refusals.push(authorship);
90
- }
91
- }
92
- const configEscape = escapesTarget(targetRoot, CONFIG_PATH, "config");
93
- if (configEscape) refusals.push(configEscape);
94
- // The loaders are writes like any other, and the only ones orly aims at files
95
- // it does not own — so they need the guard most, not least. A committed
96
- // AGENTS.md symlink pointing outside the repository otherwise carried the
97
- // pointer block wherever the link led, and reported it as an ordinary success.
98
- for (const [target, kind] of loaderTargets(layout)) {
99
- const escape = escapesTarget(targetRoot, target, kind);
100
- if (escape) refusals.push(escape);
24
+ const root = resolve(options.targetRoot);
25
+ requireWorkTree(root);
26
+ const directoryExisted = existsSync(join(root, INSTALL_DIRECTORY));
27
+ let release;
28
+ try { release = installationLock(root); }
29
+ catch (error) { return failure([], INSTALL_DIRECTORY, error); }
30
+ try { return await installLocked(model, options, root); }
31
+ finally {
32
+ release();
33
+ if (!directoryExisted && readdirSync(join(root, INSTALL_DIRECTORY)).length === 0) rmdirSync(join(root, INSTALL_DIRECTORY));
101
34
  }
102
- if (refusals.length > 0) return { ok: false, packs, written: [], skipped, errors: refusals };
103
-
104
- const closure = await stageAndCommit(model, targetRoot, planned, written);
105
- if (closure.length > 0) return { ok: false, packs, written: [], skipped, errors: closure };
106
-
107
- // After the managed files land, so a repository whose install refused for any
108
- // other reason never carries a loader pointing at rules that were not written.
109
- const loaders = await installLoaders(targetRoot, layout);
110
- written.push(...loaders.written);
111
- skipped.push(...loaders.skipped);
112
- const hooks = options.installHooks ? await installHooks(targetRoot) : { written: [], all: [] };
113
- written.push(...hooks.written);
114
- skipped.push(...hooks.all.filter((path) => !hooks.written.includes(path)));
115
- // One file carries both halves: the repository's own fields pass through
116
- // untouched, orly's two record what this run installed and wrote.
117
- // Digests cover `planned` only. Hooks are managed — orly restores a missing
118
- // one — but it does not always AUTHOR them: it refuses to overwrite a hook it
119
- // did not write. Recording a digest for a file whose content belongs to the
120
- // repository would report the repository's own edits as drift.
121
- await writeConfig(targetRoot, {
122
- ...config,
123
- orly_version: options.orlyVersion,
124
- managed: [...planned.map((file) => file.target), ...hooks.all],
125
- digests: Object.fromEntries(planned.map((file) => [file.target, contentDigest(file.content)])),
126
- });
127
- if (!existing) written.push(CONFIG_PATH);
128
- return { ok: true, packs, written: written.sort(), skipped: skipped.sort(), errors: [] };
129
35
  }
130
36
 
131
- // A committed symlink inside the target repository — planted by that
132
- // repository's own author, not the person running init — otherwise lets a
133
- // managed-file or hook write follow it and land anywhere the invoking user
134
- // can write. Checked up front, before anything is staged, so it reports as
135
- // a normal refusal rather than a mid-write failure; assertWritableInside
136
- // runs again at the actual write as a last-line guard against a target
137
- // that changed underneath the run.
138
- function escapesTarget(targetRoot: string, target: string, kind: string): InstallError | undefined {
37
+ async function installLocked(model: RulesModel, options: InstallOptions, root: string): Promise<InstallResult> {
38
+ const transaction = new InstallTransaction(root, options.orlyVersion, options.checkpoint);
39
+ transaction.recover(() => configureHooks(root));
40
+ const expected = new Map([[CONFIG_PATH, diskDigest(root, CONFIG_PATH)]]);
41
+ if (expected.get(CONFIG_PATH) === null) expected.set(PREVIOUS_CONFIG, diskDigest(root, PREVIOUS_CONFIG));
42
+ const config = await readInstallConfig(root) ?? await seedConfig(root);
43
+ config.packs = [...new Set([...config.packs, ...(options.requestedPacks ?? [])])].sort();
44
+ const packs = selectPacks(model, root, config.packs, new Set(config.managed));
45
+ const layout = resolveLayout(model, root);
46
+ const planned = await planFiles(model, packs, config.commands, root, layout.orlyFile);
139
47
  try {
140
- assertWritableInside(targetRoot, target, kind);
141
- return undefined;
48
+ for (const [path, before] of expected) if (diskDigest(root, path) !== before) throw new OrlyError(`installation conflict: ${path} changed after planning`);
49
+ if (options.installHooks) {
50
+ checkHooks(root, config, options.force);
51
+ planned.push(...plannedHooks(options.orlyVersion));
52
+ }
53
+ return await materialise(root, layout, planned, packs, config, options, transaction, expected);
142
54
  } catch (error) {
143
- return { path: target, message: error instanceof Error ? error.message : String(error), suggestion: "remove or fix the symlink in the target repository before installing" };
55
+ // A persisted transaction must remain available for recovery. Return the
56
+ // conflict without deleting its receipts or the owner's files.
57
+ return failure(packs, String(error).includes(HOOKS_PATH_KEY) ? HOOKS_PATH_KEY : INSTALL_DIRECTORY, error);
144
58
  }
145
59
  }
146
60
 
147
- // A hook is a managed file that happens to be generated rather than copied, and
148
- // it earns the same authorship rule: one orly wrote is orly's to replace, one
149
- // the repository wrote is the repository's. Without this, installHooks wrote
150
- // unconditionally — a repository whose own pre-commit ran its test suite lost
151
- // it to `orly init` silently, counted as an ordinary write in the success line.
152
- async function hookAuthorship(targetRoot: string, target: string, gate: string, managed: Set<string>, force: boolean): Promise<InstallError | undefined> {
153
- if (force || managed.has(target)) return undefined;
154
- const path = join(targetRoot, target);
155
- if (!existsSync(path)) return undefined;
156
- // Byte-identical to what this run would write: an install that predates hook
157
- // tracking, not someone else's hook. Replacing it changes nothing.
158
- if ((await Bun.file(path).text()) === hookScript(gate)) return undefined;
159
- 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" };
61
+ async function materialise(root: string, layout: Layout, planned: PlannedFile[], packs: string[], config: RepoConfig, options: InstallOptions, transaction: InstallTransaction, expected: Map<string, string | null>): Promise<InstallResult> {
62
+ for (const [path, kind] of loaderTargets(layout)) {
63
+ try { assertWritableInside(root, path, kind); }
64
+ catch (error) { return failure(packs, path, error); }
65
+ expected.set(path, existsSync(join(root, path)) && lstatSync(join(root, path)).isSymbolicLink() ? null : diskDigest(root, path));
66
+ }
67
+ assertWritableInside(root, CONFIG_PATH, "configuration");
68
+ const hooks = options.installHooks ? { before: hooksPath(root), after: HOOKS_DIRECTORY } : null;
69
+ const errors = await preflight(root, planned, config, options.force, expected);
70
+ if (errors.length) return { ok: false, packs, written: [], skipped: [], errors };
71
+ const preservedHooks = options.installHooks ? [] : config.managed.filter((path) => /^(?:\.githooks|\.orly\/hooks)\//.test(path));
72
+ const destinations = new Set([...planned.map((file) => file.target), ...preservedHooks]);
73
+ const deletes = migrationDeletes(root, config, destinations);
74
+ for (const file of deletes) if (file.path === PREVIOUS_CONFIG) file.before = expected.get(file.path)!;
75
+ const { files: staged, skipped: loaderSkipped } = await stagePlan(root, layout, planned, deletes.map((file) => file.path));
76
+ const next = { ...config, orly_version: options.orlyVersion, managed: [...destinations], digests: { ...Object.fromEntries(preservedHooks.filter((path) => config.digests[path]).map((path) => [path, config.digests[path]!])), ...Object.fromEntries(planned.map((file) => [file.target, contentDigest(file.content)])) } };
77
+ staged.push({ target: CONFIG_PATH, content: Buffer.from(serialiseConfig(next)), mode: MODE_REGULAR });
78
+ const written: string[] = [];
79
+ const skipped: string[] = [...loaderSkipped];
80
+ const writes: FileWrite[] = [];
81
+ for (const file of staged) {
82
+ const before = expected.get(file.target) ?? null;
83
+ const current = diskDigest(root, file.target);
84
+ if (current !== before && current !== contentDigest(file.content)) throw new OrlyError(`installation conflict: ${file.target} changed after planning`);
85
+ if (before === contentDigest(file.content) && modeLabel(join(root, file.target)) === file.mode) skipped.push(file.target);
86
+ else written.push(file.target);
87
+ writes.push({ path: file.target, before, content: Buffer.from(file.content).toString("base64"), mode: file.mode === MODE_EXECUTABLE ? MODE_EXECUTABLE : MODE_REGULAR });
88
+ }
89
+ if (options.installHooks) checkHooks(root, config, options.force);
90
+ if (written.length || deletes.length) transaction.commit(writes, deletes, hooks, () => configureHooks(root));
91
+ else if (options.installHooks) configureHooks(root);
92
+ return { ok: true, packs, written: written.sort(), skipped: skipped.sort(), errors: [] };
160
93
  }
161
94
 
162
- function refusal(target: string): InstallError {
163
- return { 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" };
95
+ async function preflight(root: string, planned: PlannedFile[], config: RepoConfig, force: boolean, expected: Map<string, string | null>): Promise<InstallError[]> {
96
+ const errors: InstallError[] = [];
97
+ for (const file of planned) {
98
+ assertWritableInside(root, file.target, MANAGED_KIND);
99
+ const current = diskDigest(root, file.target);
100
+ expected.set(file.target, current);
101
+ if (current === null || current === contentDigest(file.content)) continue;
102
+ const recorded = config.digests[file.target];
103
+ if (force || (config.managed.includes(file.target) && recorded && sameDigest(recorded, current))) continue;
104
+ errors.push({ path: file.target, message: "existing content is unowned or edited after installation", suggestion: "preserve or reconcile your change; --force explicitly replaces ordinary update conflicts" });
105
+ }
106
+ return errors;
164
107
  }
165
108
 
166
- // Check references in the staged payload. Stage inside the repository because
167
- // rename cannot move files across filesystem boundaries.
168
- async function stageAndCommit(model: RulesModel, targetRoot: string, planned: PlannedFile[], written: string[]): Promise<InstallError[]> {
169
- const stagingParent = join(targetRoot, dirname(CONFIG_PATH));
170
- const stagingParentExisted = existsSync(stagingParent);
171
- mkdirSync(stagingParent, { recursive: true });
172
- const stage = mkdtempSync(join(stagingParent, STAGE_PREFIX));
173
-
174
- // On failure, remove an empty .oracle/ only if this call created it.
175
- const cleanupStagingParentIfEmpty = () => {
176
- rmSync(stage, { recursive: true, force: true });
177
- if (!stagingParentExisted && existsSync(stagingParent) && readdirSync(stagingParent).length === 0) rmSync(stagingParent, { recursive: true, force: true });
178
- };
179
-
109
+ async function stagePlan(root: string, layout: Layout, planned: PlannedFile[], obsolete: string[]): Promise<{ files: PlannedFile[]; skipped: string[] }> {
110
+ assertWritableInside(root, INSTALL_DIRECTORY, "staging directory");
111
+ mkdirSync(join(root, INSTALL_DIRECTORY), { recursive: true });
112
+ const stage = mkdtempSync(join(root, INSTALL_DIRECTORY, "install-stage-"));
180
113
  try {
181
114
  for (const file of planned) {
182
115
  const path = join(stage, file.target);
@@ -184,159 +117,35 @@ async function stageAndCommit(model: RulesModel, targetRoot: string, planned: Pl
184
117
  await Bun.write(path, file.content);
185
118
  applyMode(path, file.mode);
186
119
  }
187
- // Skills may name optional language guides that this repository does not use.
188
- const markdown = planned
189
- .filter((file) => extname(file.target) === MARKDOWN_EXTENSION && !file.target.includes(SKILLS_SEGMENT))
190
- .map((file) => join(stage, file.target));
191
- const missing = await referenceClosureErrors(stage, markdown, targetRoot);
192
- if (missing.length > 0) {
193
- cleanupStagingParentIfEmpty();
194
- return missing.map((message) => ({ path: AGENTS_FILENAME, message, suggestion: "select the pack that provides the cited file, or amend the citation" }));
195
- }
196
-
197
- const targets = new Set(written);
198
- for (const file of planned.filter((candidate) => targets.has(candidate.target))) {
199
- assertWritableInside(targetRoot, file.target, MANAGED_FILE_KIND);
200
- const destination = join(targetRoot, file.target);
201
- mkdirSync(dirname(destination), { recursive: true });
202
- renameSync(join(stage, file.target), destination);
203
- applyMode(destination, file.mode);
204
- }
120
+ const markdown = planned.filter((file) => extname(file.target) === ".md" && !file.target.includes("/skills/")).map((file) => join(stage, file.target));
121
+ const missing = await referenceClosureErrors(stage, markdown, root);
122
+ if (missing.length) throw new OrlyError(missing.join("\n"));
123
+ for (const [target] of loaderTargets(layout)) copyLoader(root, stage, target);
124
+ const loaders = await installLoaders(stage, layout, obsolete);
125
+ const result = [...planned];
126
+ for (const target of loaders.written) result.push({ target, content: await Bun.file(join(stage, target)).bytes(), mode: MODE_REGULAR });
127
+ return { files: result, skipped: loaders.skipped };
128
+ } finally {
205
129
  rmSync(stage, { recursive: true, force: true });
206
- return [];
207
- } catch (error) {
208
- cleanupStagingParentIfEmpty();
209
- throw error;
210
- }
211
- }
212
-
213
- // Retargeting another tool's hooks would silently disable its checks.
214
- function hooksClaimedByAnother(targetRoot: string): InstallError | undefined {
215
- 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 });
216
- if (result.exitCode !== 0) return undefined;
217
- const existing = result.stdout.toString().trim();
218
- if (existing === "" || existing === HOOKS_DIRECTORY) return undefined;
219
- 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" };
220
- }
221
-
222
- // Consumer hooks use the installed executable, independent of this checkout.
223
- async function installHooks(targetRoot: string): Promise<{ written: string[]; all: string[] }> {
224
- for (const [name] of HOOK_GATES) assertWritableInside(targetRoot, `${HOOKS_DIRECTORY}/${name}`, HOOK_KIND);
225
- const directory = join(targetRoot, HOOKS_DIRECTORY);
226
- mkdirSync(directory, { recursive: true });
227
- const written: string[] = [];
228
- const all: string[] = [];
229
- for (const [name, gate] of HOOK_GATES) {
230
- const path = join(directory, name);
231
- const script = hookScript(gate);
232
- all.push(`${HOOKS_DIRECTORY}/${name}`);
233
- if (existsSync(path) && (await Bun.file(path).text()) === script && modeLabel(path) === MODE_EXECUTABLE) continue;
234
- await Bun.write(path, script);
235
- applyMode(path, MODE_EXECUTABLE);
236
- written.push(`${HOOKS_DIRECTORY}/${name}`);
237
- }
238
- runGit(targetRoot, [GIT_CONFIG_SUBCOMMAND, HOOKS_PATH_KEY, HOOKS_DIRECTORY]);
239
- return { written, all };
240
- }
241
-
242
- function hookScript(gate: string): string {
243
- return [
244
- "#!/usr/bin/env bash",
245
- "# Generated by orly. Re-run `orly init` to refresh.",
246
- "set -euo pipefail",
247
- "",
248
- "# git exports these to every hook; inherited by a spawned git they pin it to",
249
- "# the hook's repository instead of the one being judged.",
250
- "unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_COMMON_DIR GIT_PREFIX \\",
251
- " GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES",
252
- "",
253
- 'if ! command -v orly >/dev/null 2>&1; then',
254
- " printf '%s\\n' 'orly: not on PATH — install it with `bun add -g @agentsfleet/orly`' >&2",
255
- " exit 1",
256
- "fi",
257
- "",
258
- `export ${HOOK_INVOCATION_ENV}=${HOOK_INVOCATION_VALUE}`,
259
- `exec orly gate ${gate}`,
260
- "",
261
- ].join("\n");
262
- }
263
- // One entry per target, so two packs naming the same file collapse instead of
264
- // racing. Two packs naming DIFFERENT sources for one target is a registry bug
265
- // and stops the install rather than letting pack order decide the winner.
266
- async function planFiles(model: RulesModel, packs: string[], commands: Record<string, string[][]>, targetRoot: string, orlyFile: string): Promise<PlannedFile[]> {
267
- const registryPacks = objectValue(model.registry.packs, REGISTRY_PACKS_LABEL);
268
- const known = new Set(Object.keys(registryPacks));
269
- const planned = new Map<string, PlannedFile>();
270
- const sources = new Map<string, string>();
271
- for (const name of packs) {
272
- const pack = objectValue(registryPacks[name], `pack ${name}`);
273
- for (const entry of objectArray(pack.managed_files, `pack ${name} managed_files`)) {
274
- if (!isString(entry.source) || !isString(entry.target)) throw new OrlyError(`pack ${name} managed file must carry string source and target`);
275
- const claimed = sources.get(entry.target);
276
- if (claimed && claimed !== entry.source) throw new OrlyError(`packs disagree on ${entry.target}: ${claimed} and ${entry.source}`);
277
- sources.set(entry.target, entry.source);
278
- const path = join(model.root, entry.source);
279
- // Never overwrite the engine's pack sources with filtered consumer copies.
280
- if (resolved(model.root) === resolved(targetRoot)) continue;
281
- planned.set(entry.target, { target: entry.target, content: await managedContent(path, entry.target, entry.source, packs, known, orlyFile), mode: modeLabel(path) });
282
- }
283
- }
284
- const rendered = await new Renderer(model).renderText(packs, commands);
285
- planned.set(orlyFile, { target: orlyFile, content: new TextEncoder().encode(rendered), mode: MODE_REGULAR });
286
- return [...planned.values()].sort((left, right) => left.target.localeCompare(right.target));
287
- }
288
-
289
- function resolveLayout(model: RulesModel, targetRoot: string): Layout {
290
- // The engine owns its render; consumers retain their own AGENTS.md.
291
- if (resolved(model.root) === resolved(targetRoot)) return { orlyFile: AGENTS_FILENAME };
292
- return { orlyFile: ORLY_AGENTS_FILENAME, pointerHost: AGENTS_FILENAME };
293
- }
294
-
295
- function resolved(path: string): string {
296
- try {
297
- return realpathSync(path);
298
- } catch {
299
- return resolve(path);
300
130
  }
301
131
  }
302
132
 
303
- // Installation and verification use the same filtered bytes for each pack.
304
- export async function managedContent(path: string, target: string, source: string, packs: string[], known: Set<string>, orlyFile: string): Promise<Uint8Array> {
305
- const bytes = await Bun.file(path).bytes();
306
- if (extname(target) !== MARKDOWN_EXTENSION) return bytes;
307
- const filtered = renderProfileText(new TextDecoder().decode(bytes), new Set(packs), known, source);
308
- return new TextEncoder().encode(`${retargetRulesCitations(filtered, orlyFile)}\n`);
309
- }
310
-
311
- // Managed rule citations must point to Orly's file, not the consumer's own rules.
312
- function retargetRulesCitations(text: string, orlyFile: string): string {
313
- if (orlyFile === AGENTS_FILENAME) return text;
314
- return text
315
- .replaceAll(`](${AGENTS_FILENAME})`, `](${orlyFile})`)
316
- .replaceAll(`](../${AGENTS_FILENAME})`, `](../${orlyFile})`)
317
- // The relative spelling first: `../AGENTS.md` contains `AGENTS.md`, so the
318
- // bare rule below would rewrite its tail and leave the `../` stranded.
319
- // Missing it is what rendered `[`../AGENTS.md`](../AGENTS.orly.md)` into
320
- // four managed pages — a label naming one file over a link to another.
321
- .replaceAll(`\`../${AGENTS_FILENAME}\``, `\`../${orlyFile}\``)
322
- .replaceAll(`\`${AGENTS_FILENAME}\``, `\`${orlyFile}\``);
133
+ function copyLoader(root: string, stage: string, target: string): void {
134
+ const source = join(root, target);
135
+ let stat;
136
+ try { stat = lstatSync(source); } catch { return; }
137
+ const destination = join(stage, target);
138
+ if (stat.isSymbolicLink()) symlinkSync(readlinkSync(source), destination);
139
+ else copyFileSync(source, destination);
323
140
  }
324
141
 
325
- // git canonicalises symlinks in --show-toplevel; targetRoot, as handed in by
326
- // a caller, usually has not been (macOS puts $TMPDIR and often the caller's
327
- // own working directory behind one). Comparing without resolving both sides
328
- // made every install from a symlinked path — routine on macOS — refuse with
329
- // "not the repository root" against its own root.
330
- function requireWorkTree(targetRoot: string): void {
331
- if (!existsSync(targetRoot)) throw new OrlyError(`target directory does not exist: ${targetRoot}`);
332
- const result = Bun.spawnSync([GIT_COMMAND, GIT_REPO_FLAG, targetRoot, "rev-parse", "--show-toplevel"], { stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT, env: UNSCOPED_ENVIRONMENT });
333
- if (result.exitCode !== 0) throw new OrlyError(`not a git repository: ${targetRoot} — run \`git init\` first`);
334
- const top = resolve(result.stdout.toString().trim());
335
- const resolvedTarget = realpathSync(targetRoot);
336
- if (top !== resolvedTarget) throw new OrlyError(`install at the repository root, not a subdirectory: ${relative(top, resolvedTarget)}`);
142
+ function requireWorkTree(root: string): void {
143
+ if (!existsSync(root)) throw new OrlyError(`target directory does not exist: ${root}`);
144
+ const result = Bun.spawnSync(["git", "-C", root, "rev-parse", "--show-toplevel"], { stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT, env: UNSCOPED_ENVIRONMENT });
145
+ if (result.exitCode !== 0) throw new OrlyError(`not a git repository: ${root} — run git init first`);
146
+ if (realpathSync(root) !== realpathSync(result.stdout.toString().trim())) throw new OrlyError("install at the repository root, not a subdirectory");
337
147
  }
338
148
 
339
- function runGit(targetRoot: string, args: string[]): void {
340
- const result = Bun.spawnSync([GIT_COMMAND, GIT_REPO_FLAG, targetRoot, ...args], { stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT, env: UNSCOPED_ENVIRONMENT });
341
- if (result.exitCode !== 0) throw new OrlyError(`git ${args.join(" ")} failed: ${result.stderr.toString().trim()}`);
149
+ function failure(packs: string[], path: string, error: unknown): InstallResult {
150
+ return { ok: false, packs, written: [], skipped: [], errors: [{ path, message: error instanceof Error ? error.message : String(error), suggestion: "reconcile the named conflict and repeat the same version's update" }] };
342
151
  }