@agentsfleet/orly 0.10.1 → 0.10.2
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/package.json +1 -1
- package/src/config.test.ts +36 -1
- package/src/config.ts +48 -1
- package/src/install.ts +16 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentsfleet/orly",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"description": "Repository-scoped engineering harness: renders agent rules, materialises the gates that enforce them, and proves the PR boundary \u2014 for any coding agent.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/config.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
|
|
6
|
-
import { CONFIG_PATH, managedDrift, readConfig, readConfigSync, seedConfig, selectPacks, staleVersion, writeConfig } from "./config";
|
|
6
|
+
import { CONFIG_PATH, contentDigest, managedDrift, readConfig, readConfigSync, seedConfig, selectPacks, staleVersion, writeConfig } from "./config";
|
|
7
7
|
import { RulesModel } from "./model";
|
|
8
8
|
|
|
9
9
|
const ROOT = resolve(import.meta.dir, "..");
|
|
@@ -156,6 +156,40 @@ describe("staleVersion", () => {
|
|
|
156
156
|
});
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
+
describe("managedDrift — content", () => {
|
|
160
|
+
test("a managed file edited after orly wrote it is drift", async () => {
|
|
161
|
+
// The gap this closes was not hypothetical: a consuming repository added a
|
|
162
|
+
// gradient ban and a font-ownership check straight to a materialised gate
|
|
163
|
+
// script, the pack never learned them, and the next update deleted them —
|
|
164
|
+
// with doctor green the whole way, because it asked only whether the file
|
|
165
|
+
// EXISTED. A gate whose checks can be removed under a passing verifier is
|
|
166
|
+
// not a gate.
|
|
167
|
+
const root = scratch();
|
|
168
|
+
const wrote = "check_one\ncheck_two\n";
|
|
169
|
+
writeFileSync(join(root, "audit.sh"), wrote);
|
|
170
|
+
const config = { ...(await seedConfig(root)), managed: ["audit.sh"], digests: { "audit.sh": contentDigest(new TextEncoder().encode(wrote)) } };
|
|
171
|
+
expect(managedDrift(root, config)).toEqual([]);
|
|
172
|
+
|
|
173
|
+
writeFileSync(join(root, "audit.sh"), "check_one\n");
|
|
174
|
+
const drift = managedDrift(root, config);
|
|
175
|
+
expect(drift).toHaveLength(1);
|
|
176
|
+
expect(drift[0]).toContain("audit.sh");
|
|
177
|
+
expect(drift[0]).toContain("edited after orly wrote it");
|
|
178
|
+
// Deliberately NOT "run orly update": the local content may be the better
|
|
179
|
+
// version, and update discards it. The source is where it has to go.
|
|
180
|
+
expect(drift[0]).toContain("move the change into the pack source");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a file with no recorded digest makes no claim", async () => {
|
|
184
|
+
// Two cases share this path: a config written before digests existed, and a
|
|
185
|
+
// hook orly manages but did not author. Neither may be reported as drift.
|
|
186
|
+
const root = scratch();
|
|
187
|
+
writeFileSync(join(root, "hook.sh"), "the repository's own\n");
|
|
188
|
+
const config = { ...(await seedConfig(root)), managed: ["hook.sh"], digests: {} };
|
|
189
|
+
expect(managedDrift(root, config)).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
159
193
|
describe("managedDrift", () => {
|
|
160
194
|
test("every managed file present reports no drift", async () => {
|
|
161
195
|
const root = scratch();
|
|
@@ -223,6 +257,7 @@ describe("writeConfig", () => {
|
|
|
223
257
|
commands: { conform: [["make", "audit"]] },
|
|
224
258
|
surfaces: { user: ["src/"], docs: ["docs/"] },
|
|
225
259
|
managed: ["AGENTS.md"],
|
|
260
|
+
digests: {},
|
|
226
261
|
};
|
|
227
262
|
await writeConfig(root, mine);
|
|
228
263
|
await writeConfig(root, { ...(await readConfig(root))!, orly_version: "0.4.0", managed: ["AGENTS.md", "dispatch/write_rust.md"] });
|
package/src/config.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
2
3
|
import { dirname, extname, join, relative } from "node:path";
|
|
3
4
|
|
|
@@ -16,6 +17,9 @@ const EXTENSIONS_FIELD = "extensions";
|
|
|
16
17
|
const PACKS_FIELD = "packs";
|
|
17
18
|
const VERSION_FIELD = "orly_version";
|
|
18
19
|
const MANAGED_FIELD = "managed";
|
|
20
|
+
const DIGESTS_FIELD = "digests";
|
|
21
|
+
const DIGEST_ALGORITHM = "sha256";
|
|
22
|
+
const DIGEST_ENCODING = "hex";
|
|
19
23
|
const COMMANDS_FIELD = "commands";
|
|
20
24
|
const CONFORM_COMMAND = "conform";
|
|
21
25
|
const MAKE_COMMAND = "make";
|
|
@@ -65,6 +69,7 @@ export type RepoConfig = {
|
|
|
65
69
|
commands: Record<string, string[][]>;
|
|
66
70
|
surfaces: JsonObject | undefined;
|
|
67
71
|
managed: string[];
|
|
72
|
+
digests: Record<string, string>;
|
|
68
73
|
};
|
|
69
74
|
|
|
70
75
|
export function configPath(targetRoot: string): string {
|
|
@@ -109,9 +114,26 @@ function parseConfig(value: JsonObject): RepoConfig {
|
|
|
109
114
|
commands: readCommands(value[COMMANDS_FIELD]),
|
|
110
115
|
surfaces: isObject(value.surfaces) ? value.surfaces : undefined,
|
|
111
116
|
managed: stringArray(value[MANAGED_FIELD] ?? [], `${CONFIG_PATH} ${MANAGED_FIELD}`),
|
|
117
|
+
digests: readDigests(value[DIGESTS_FIELD]),
|
|
112
118
|
};
|
|
113
119
|
}
|
|
114
120
|
|
|
121
|
+
// Absent in a config written before digests existed, and absent per-file for
|
|
122
|
+
// anything orly did not author (a hook it declined to overwrite). Both cases
|
|
123
|
+
// read as "no claim", which is what keeps an older checkout green until its
|
|
124
|
+
// next update rather than failing it for a record it never had.
|
|
125
|
+
function readDigests(value: unknown): Record<string, string> {
|
|
126
|
+
if (!isObject(value)) return {};
|
|
127
|
+
const digests: Record<string, string> = {};
|
|
128
|
+
for (const [path, digest] of Object.entries(value)) if (isString(digest)) digests[path] = digest;
|
|
129
|
+
return digests;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// The digest orly records for content it wrote, and recomputes to check it.
|
|
133
|
+
export function contentDigest(bytes: Uint8Array): string {
|
|
134
|
+
return createHash(DIGEST_ALGORITHM).update(bytes).digest(DIGEST_ENCODING);
|
|
135
|
+
}
|
|
136
|
+
|
|
115
137
|
// What orly installed here, and whether the engine has moved since.
|
|
116
138
|
export function staleVersion(config: RepoConfig, installedVersion: string): string | undefined {
|
|
117
139
|
if (config.orly_version === installedVersion) return undefined;
|
|
@@ -152,9 +174,33 @@ export function managedDrift(targetRoot: string, config: RepoConfig): string[] {
|
|
|
152
174
|
...present
|
|
153
175
|
.filter((relativePath) => ignored.has(relativePath))
|
|
154
176
|
.map((relativePath) => `managed file is ignored by git: ${relativePath} — orly wrote it and a clone will not receive it; scope the .gitignore rule that excludes it`),
|
|
177
|
+
...editedManaged(targetRoot, config, present),
|
|
155
178
|
].sort();
|
|
156
179
|
}
|
|
157
180
|
|
|
181
|
+
// A managed file whose CONTENT no longer matches what orly wrote.
|
|
182
|
+
//
|
|
183
|
+
// The half `managedDrift` was missing, and the gap was not theoretical: a
|
|
184
|
+
// consuming repository added two checks straight to a materialised gate script,
|
|
185
|
+
// the pack never learned them, and the next update deleted them — while doctor
|
|
186
|
+
// reported green throughout, because it verified that the file EXISTED and
|
|
187
|
+
// never that it still said what orly put there. A gate whose checks can be
|
|
188
|
+
// removed under a passing verifier is not a gate.
|
|
189
|
+
//
|
|
190
|
+
// The remedy for a hit is deliberately not `orly update`: the local content may
|
|
191
|
+
// be the better version, as it was in that case. Update overwrites it; the
|
|
192
|
+
// message says to move the change into the source instead, which is the only
|
|
193
|
+
// way it reaches every other consumer.
|
|
194
|
+
function editedManaged(targetRoot: string, config: RepoConfig, present: string[]): string[] {
|
|
195
|
+
return present.flatMap((relativePath) => {
|
|
196
|
+
const recorded = config.digests[relativePath];
|
|
197
|
+
if (!recorded) return [];
|
|
198
|
+
const actual = contentDigest(readFileSync(join(targetRoot, relativePath)));
|
|
199
|
+
if (actual === recorded) return [];
|
|
200
|
+
return [`managed file was edited after orly wrote it: ${relativePath} — move the change into the pack source, or \`orly update\` to discard it`];
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
158
204
|
// Key order is fixed so a rewrite produces a reviewable diff: orly's own two
|
|
159
205
|
// fields sit at the ends, the repository's three in the middle, untouched.
|
|
160
206
|
export async function writeConfig(targetRoot: string, config: RepoConfig): Promise<string> {
|
|
@@ -168,6 +214,7 @@ export async function writeConfig(targetRoot: string, config: RepoConfig): Promi
|
|
|
168
214
|
commands: config.commands,
|
|
169
215
|
...(config.surfaces ? { surfaces: config.surfaces } : {}),
|
|
170
216
|
managed: [...config.managed].sort(),
|
|
217
|
+
digests: Object.fromEntries(Object.entries(config.digests).sort(([a], [b]) => a.localeCompare(b))),
|
|
171
218
|
};
|
|
172
219
|
await Bun.write(path, `${JSON.stringify(ordered, undefined, JSON_INDENT)}${NEWLINE}`);
|
|
173
220
|
return path;
|
|
@@ -177,7 +224,7 @@ export async function writeConfig(targetRoot: string, config: RepoConfig): Promi
|
|
|
177
224
|
// from what the repository already builds with; an empty set is honest rather
|
|
178
225
|
// than wrong, and says so in the gate's own failure message.
|
|
179
226
|
export async function seedConfig(targetRoot: string): Promise<RepoConfig> {
|
|
180
|
-
return { schema_version: CONFIG_SCHEMA_VERSION, orly_version: "", packs: [], commands: await sniffCommands(targetRoot), surfaces: undefined, managed: [] };
|
|
227
|
+
return { schema_version: CONFIG_SCHEMA_VERSION, orly_version: "", packs: [], commands: await sniffCommands(targetRoot), surfaces: undefined, managed: [], digests: {} };
|
|
181
228
|
}
|
|
182
229
|
|
|
183
230
|
// What this repository installed and runs with, read from its own `.oracle/`.
|
package/src/install.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, renameSy
|
|
|
2
2
|
import { dirname, extname, join, relative, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { UNSCOPED_ENVIRONMENT } from "./git_env";
|
|
5
|
-
import { CONFIG_PATH, readConfig, seedConfig, selectPacks, writeConfig } from "./config";
|
|
5
|
+
import { CONFIG_PATH, contentDigest, readConfig, seedConfig, selectPacks, writeConfig } from "./config";
|
|
6
6
|
import { applyMode, assertWritableInside, hashContent, isBelow, isString, JsonObject, modeLabel, objectArray, objectValue, OrlyError, RulesModel, stringArray } from "./model";
|
|
7
7
|
import { AGENTS_FILENAME, GENERATED_BANNER, installLoaders, Layout, loaderTargets, ORLY_AGENTS_FILENAME } from "./loaders";
|
|
8
8
|
import { referenceClosureErrors, renderProfileText } from "./references";
|
|
@@ -116,7 +116,16 @@ export async function install(model: RulesModel, options: InstallOptions): Promi
|
|
|
116
116
|
skipped.push(...hooks.all.filter((path) => !hooks.written.includes(path)));
|
|
117
117
|
// One file carries both halves: the repository's own fields pass through
|
|
118
118
|
// untouched, orly's two record what this run installed and wrote.
|
|
119
|
-
|
|
119
|
+
// Digests cover `planned` only. Hooks are managed — orly restores a missing
|
|
120
|
+
// one — but it does not always AUTHOR them: it refuses to overwrite a hook it
|
|
121
|
+
// did not write. Recording a digest for a file whose content belongs to the
|
|
122
|
+
// repository would report the repository's own edits as drift.
|
|
123
|
+
await writeConfig(targetRoot, {
|
|
124
|
+
...config,
|
|
125
|
+
orly_version: options.orlyVersion,
|
|
126
|
+
managed: [...planned.map((file) => file.target), ...hooks.all],
|
|
127
|
+
digests: Object.fromEntries(planned.map((file) => [file.target, contentDigest(file.content)])),
|
|
128
|
+
});
|
|
120
129
|
if (!existing) written.push(CONFIG_PATH);
|
|
121
130
|
return { ok: true, packs, written: written.sort(), skipped: skipped.sort(), errors: [] };
|
|
122
131
|
}
|
|
@@ -370,6 +379,11 @@ function retargetRulesCitations(text: string, orlyFile: string): string {
|
|
|
370
379
|
return text
|
|
371
380
|
.replaceAll(`](${AGENTS_FILENAME})`, `](${orlyFile})`)
|
|
372
381
|
.replaceAll(`](../${AGENTS_FILENAME})`, `](../${orlyFile})`)
|
|
382
|
+
// The relative spelling first: `../AGENTS.md` contains `AGENTS.md`, so the
|
|
383
|
+
// bare rule below would rewrite its tail and leave the `../` stranded.
|
|
384
|
+
// Missing it is what rendered `[`../AGENTS.md`](../AGENTS.orly.md)` into
|
|
385
|
+
// four managed pages — a label naming one file over a link to another.
|
|
386
|
+
.replaceAll(`\`../${AGENTS_FILENAME}\``, `\`../${orlyFile}\``)
|
|
373
387
|
.replaceAll(`\`${AGENTS_FILENAME}\``, `\`${orlyFile}\``);
|
|
374
388
|
}
|
|
375
389
|
|