@agentsfleet/orly 0.10.14 → 0.12.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-push +2 -1
- package/AGENTS.md +21 -31
- package/README.md +94 -28
- package/SOUL.md +13 -23
- package/audits/agents-md.md +67 -57
- package/audits/deinit-pairs.sh +13 -18
- package/audits/design-tokens.sh +9 -23
- package/audits/doc-read.sh +10 -33
- package/audits/doc-reads.ts +41 -0
- package/audits/error-codes.sh +18 -25
- package/audits/logging-rust.awk +145 -0
- package/audits/logging.sh +10 -166
- package/audits/msid-ui.sh +45 -18
- package/audits/rule-ledger-lib.sh +9 -6
- package/audits/rust-error.sh +20 -15
- package/audits/scope.sh +88 -0
- package/audits/spec-template.sh +2 -2
- package/audits/spec-template.ts +1 -1
- package/audits/sql-mod.sh +11 -14
- package/audits/ufs-strings.awk +164 -0
- package/audits/ufs.sh +10 -188
- package/core/operating-model.md +8 -8
- package/dispatch/edit_rules.md +8 -6
- package/dispatch/lib.sh +41 -26
- package/dispatch/lifecycle.md +12 -5
- package/dispatch/name_architecture.md +8 -3
- package/dispatch/verify.md +1 -1
- package/dispatch/write_any.md +10 -3
- package/dispatch/write_changelog.md +7 -4
- package/dispatch/write_go.md +1 -1
- package/dispatch/write_http.md +5 -7
- package/dispatch/write_python.md +1 -1
- package/dispatch/write_rust.md +17 -1
- package/dispatch/write_shell.md +1 -1
- package/dispatch/write_spec.md +3 -1
- package/dispatch/write_sql.md +28 -6
- package/dispatch/write_ts_adhere_bun.md +11 -0
- package/dispatch/write_zig.md +53 -21
- package/docs/CHANGELOG.md +10 -0
- package/docs/CHANGELOG_VOICE.md +11 -2
- package/docs/EXECUTE_DOC_READS.md +10 -10
- package/docs/HARNESS_VERIFY_OUTPUT.md +6 -3
- package/docs/JUDGMENTS.md +156 -0
- package/docs/LIFECYCLE_PATTERNS.md +26 -16
- package/docs/LOGGING_STANDARD.md +39 -19
- package/docs/ORLY_ARCHITECTURE.md +77 -6
- package/docs/RELEASE_TEMPLATE.md +21 -5
- package/docs/REST_API_DESIGN_GUIDELINES.md +86 -21
- package/docs/RULE_ENFORCEMENT.md +6 -6
- package/docs/SCHEMA_CONVENTIONS.md +24 -3
- package/docs/TEMPLATE.md +4 -4
- package/docs/VERIFY_TIERS.md +2 -2
- package/docs/greptile-learnings/RULES.md +81 -26
- package/llms.txt +46 -34
- package/package.json +6 -1
- package/packs/language/go/rules.md +1 -1
- package/packs/language/python/rules.md +1 -1
- package/packs/language/rust/rules.md +17 -1
- package/packs/language/shell/rules.md +1 -1
- package/registry.json +22 -7
- package/schemas/profile.schema.json +48 -5
- package/skills/orly-babysit-prs/SKILL.md +87 -34
- package/skills/orly-spec-new/SKILL.md +19 -6
- package/skills/orly-write-integration-test/SKILL.md +4 -4
- package/skills/orly-write-unit-test/SKILL.md +10 -1
- package/src/cli.test.ts +2 -2
- package/src/cli.ts +19 -75
- package/src/cli_help.ts +69 -0
- package/src/command_limits.ts +20 -0
- package/src/command_process.test.ts +109 -0
- package/src/command_process.ts +88 -0
- package/src/command_runner.test.ts +88 -0
- package/src/command_runner.ts +33 -0
- package/src/comprehension_cache.test.ts +123 -0
- package/src/config.test.ts +5 -4
- package/src/config.ts +26 -164
- package/src/config_discovery.ts +159 -0
- package/src/config_validation.test.ts +56 -0
- package/src/config_validation.ts +25 -0
- package/src/criteria.test.ts +1 -1
- package/src/criteria.ts +10 -26
- package/src/criteria_spec.ts +12 -20
- package/src/criteria_support.ts +2 -19
- package/src/doc_reads.test.ts +42 -0
- package/src/doc_rules.ts +2 -2
- package/src/evaluation_resources.test.ts +64 -0
- package/src/execution/config.ts +22 -0
- package/src/execution/plan.test.ts +65 -0
- package/src/execution/plan.ts +51 -0
- package/src/gates.ts +9 -6
- package/src/gates_spec.test.ts +52 -6
- package/src/gates_test_support.ts +9 -7
- package/src/install.test.ts +38 -222
- package/src/install.ts +117 -308
- package/src/install_loaders.test.ts +203 -0
- package/src/install_nested.test.ts +2 -2
- package/src/install_packs.test.ts +66 -6
- package/src/installation/hooks.ts +63 -0
- package/src/installation/lock.test.ts +97 -0
- package/src/installation/lock.ts +56 -0
- package/src/installation/migration.test.ts +212 -0
- package/src/installation/migration.ts +65 -0
- package/src/installation/plan.ts +96 -0
- package/src/installation/transaction.ts +126 -0
- package/src/installation_preservation.test.ts +48 -0
- package/src/judgments/advice.test.ts +38 -0
- package/src/judgments/advice.ts +32 -0
- package/src/judgments/command.integration.test.ts +47 -0
- package/src/judgments/command.ts +90 -0
- package/src/judgments/constants.ts +55 -0
- package/src/judgments/evidence.test.ts +48 -0
- package/src/judgments/evidence.ts +34 -0
- package/src/judgments/files.test.ts +62 -0
- package/src/judgments/files.ts +73 -0
- package/src/judgments/questions.test.ts +19 -0
- package/src/judgments/questions.ts +85 -0
- package/src/judgments/replay.test.ts +43 -0
- package/src/judgments/replay.ts +66 -0
- package/src/judgments/runner.integration.test.ts +165 -0
- package/src/judgments/runner.ts +97 -0
- package/src/judgments/scan.test.ts +19 -0
- package/src/judgments/scan.ts +40 -0
- package/src/judgments/syntax.test.ts +115 -0
- package/src/judgments/syntax.ts +71 -0
- package/src/judgments/syntax_worker.ts +70 -0
- package/src/judgments/test_support.ts +60 -0
- package/src/judgments/transport.integration.test.ts +63 -0
- package/src/judgments/transport.ts +35 -0
- package/src/judgments/types.test.ts +43 -0
- package/src/judgments/types.ts +66 -0
- package/src/judgments/wire.test.ts +48 -0
- package/src/judgments/wire.ts +40 -0
- package/src/loaders.test.ts +13 -16
- package/src/loaders.ts +28 -10
- package/src/model.test.ts +20 -3
- package/src/model.ts +7 -18
- package/src/pack_hygiene.test.ts +42 -1
- package/src/pack_hygiene.ts +18 -8
- package/src/references.ts +15 -7
- package/src/release_inventory.test.ts +80 -0
- package/src/render.test.ts +1 -1
- package/src/render.ts +3 -8
- package/src/required_scanner.test.ts +36 -0
- package/src/review_poll.test.ts +158 -0
- package/src/scanner_scope.test.ts +202 -0
- package/src/setup.test.ts +9 -6
- package/src/spec_baseline.test.ts +1 -1
- package/src/spec_completion.test.ts +74 -0
- package/src/spec_obligations.ts +64 -0
- package/src/spec_rehearsal.test.ts +182 -0
- package/src/spec_template_io.test.ts +11 -9
- package/src/surfaces.test.ts +2 -0
- package/src/surfaces.ts +1 -1
- package/src/validation.ts +7 -4
- package/src/verify.test.ts +18 -2
- package/src/verify.ts +7 -3
package/src/install.ts
CHANGED
|
@@ -1,182 +1,115 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, mkdtempSync,
|
|
2
|
-
import { dirname, extname, join,
|
|
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,
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
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
|
|
51
|
-
requireWorkTree(
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
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
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const
|
|
308
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
340
|
-
|
|
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
|
}
|