@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.
- package/.githooks/pre-commit +42 -0
- package/.githooks/pre-push +67 -0
- package/AGENTS.md +348 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/SOUL.md +90 -0
- package/audits/agents-md.md +497 -0
- package/audits/agents-md.sh +353 -0
- package/audits/cross-tier-rates.sh +105 -0
- package/audits/data.sh +185 -0
- package/audits/deinit-pairs.sh +224 -0
- package/audits/design-tokens.sh +143 -0
- package/audits/doc-read.sh +183 -0
- package/audits/error-codes.sh +221 -0
- package/audits/fixtures/clean.diff +15 -0
- package/audits/fixtures/dirty.diff +16 -0
- package/audits/lifecycle-anchors.sh +22 -0
- package/audits/logging.sh +217 -0
- package/audits/msid-ui.sh +144 -0
- package/audits/parity-dispatch.sh +51 -0
- package/audits/rule-ledger-lib.sh +264 -0
- package/audits/rule-ledger.sh +225 -0
- package/audits/rule-paths.sh +108 -0
- package/audits/spec-template.sh +258 -0
- package/audits/sql-mod.sh +102 -0
- package/audits/ufs.sh +303 -0
- package/bin/orly +26 -0
- package/core/operating-model.md +256 -0
- package/dispatch/edit_rules.md +69 -0
- package/dispatch/lib.sh +213 -0
- package/dispatch/lifecycle.md +187 -0
- package/dispatch/name_architecture.md +93 -0
- package/dispatch/verify.md +111 -0
- package/dispatch/write_any.md +740 -0
- package/dispatch/write_any.sh +49 -0
- package/dispatch/write_auth.md +36 -0
- package/dispatch/write_changelog.md +64 -0
- package/dispatch/write_documentation.md +45 -0
- package/dispatch/write_go.md +24 -0
- package/dispatch/write_http.md +45 -0
- package/dispatch/write_mdx.md +7 -0
- package/dispatch/write_python.md +8 -0
- package/dispatch/write_rust.md +38 -0
- package/dispatch/write_shell.md +8 -0
- package/dispatch/write_spec.md +105 -0
- package/dispatch/write_sql.md +112 -0
- package/dispatch/write_sql.sh +36 -0
- package/dispatch/write_ts_adhere_bun.md +493 -0
- package/dispatch/write_ts_adhere_bun.sh +37 -0
- package/dispatch/write_zig.md +852 -0
- package/dispatch/write_zig.sh +40 -0
- package/docs/CHANGELOG_VOICE.md +18 -0
- package/docs/DISPATCH_ARCHITECTURE.md +664 -0
- package/docs/DISPATCH_REVIEW_DISPOSITION.md +65 -0
- package/docs/DOCUMENTATION_RULES.md +209 -0
- package/docs/EXECUTE_DOC_READS.md +29 -0
- package/docs/HARNESS_VERIFY_OUTPUT.md +40 -0
- package/docs/LIFECYCLE_PATTERNS.md +316 -0
- package/docs/LOGGING_STANDARD.md +397 -0
- package/docs/ORLY_ARCHITECTURE.md +129 -0
- package/docs/REST_API_DESIGN_GUIDELINES.md +783 -0
- package/docs/RULE_ENFORCEMENT.md +41 -0
- package/docs/SCHEMA_CONVENTIONS.md +93 -0
- package/docs/TEMPLATE.md +411 -0
- package/docs/VERIFY_TIERS.md +57 -0
- package/docs/greptile-learnings/README.md +51 -0
- package/docs/greptile-learnings/RULES.md +1045 -0
- package/fixtures/registry-invalid.json +1 -0
- package/fixtures/registry-valid.json +18 -0
- package/fixtures/unclosed-pack-block.md +8 -0
- package/package.json +63 -0
- package/packs/language/go/rules.md +24 -0
- package/packs/language/mdx/rules.md +7 -0
- package/packs/language/python/rules.md +8 -0
- package/packs/language/rust/rules.md +38 -0
- package/packs/language/shell/rules.md +8 -0
- package/registry.json +405 -0
- package/schemas/evidence.schema.json +43 -0
- package/schemas/profile.schema.json +12 -0
- package/schemas/registry.schema.json +12 -0
- package/skills/orly-babysit-prs/SKILL.md +326 -0
- package/skills/orly-spec-new/SKILL.md +157 -0
- package/skills/orly-write-integration-test/SKILL.md +470 -0
- package/skills/orly-write-unit-test/SKILL.md +520 -0
- package/src/cli.test.ts +70 -0
- package/src/cli.ts +287 -0
- package/src/config.test.ts +226 -0
- package/src/config.ts +288 -0
- package/src/criteria.test.ts +137 -0
- package/src/criteria.ts +166 -0
- package/src/criteria_spec.ts +127 -0
- package/src/criteria_support.ts +53 -0
- package/src/gates.test.ts +183 -0
- package/src/gates.ts +169 -0
- package/src/gates_spec.test.ts +98 -0
- package/src/gates_test_support.ts +194 -0
- package/src/git_env.ts +20 -0
- package/src/install.test.ts +514 -0
- package/src/install.ts +447 -0
- package/src/install_nested.test.ts +32 -0
- package/src/model.test.ts +31 -0
- package/src/model.ts +245 -0
- package/src/references.test.ts +47 -0
- package/src/references.ts +147 -0
- package/src/render.test.ts +60 -0
- package/src/render.ts +94 -0
- package/src/surfaces.test.ts +43 -0
- package/src/surfaces.ts +89 -0
- package/src/validation.test.ts +11 -0
- package/src/validation.ts +87 -0
- package/src/verify.test.ts +13 -0
- 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
|
+
});
|