@agentsfleet/orly 0.10.1 → 0.10.3
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 +54 -1
- package/src/config.ts +65 -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.3",
|
|
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,58 @@ 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 digest is algorithm-prefixed, and the unprefixed spelling still matches", async () => {
|
|
184
|
+
// Bare hex is indistinguishable from a credential to an entropy scanner:
|
|
185
|
+
// gitleaks refused the first commit that carried these, in every consuming
|
|
186
|
+
// repository at once. The prefix is what makes it pass without a scanner
|
|
187
|
+
// suppression — and 0.10.2's unprefixed records must not read as drift.
|
|
188
|
+
const bytes = new TextEncoder().encode("managed\n");
|
|
189
|
+
const digest = contentDigest(bytes);
|
|
190
|
+
expect(digest.startsWith("sha256:")).toBe(true);
|
|
191
|
+
|
|
192
|
+
const root = scratch();
|
|
193
|
+
writeFileSync(join(root, "managed.md"), "managed\n");
|
|
194
|
+
const legacy = { ...(await seedConfig(root)), managed: ["managed.md"], digests: { "managed.md": digest.slice("sha256:".length) } };
|
|
195
|
+
expect(managedDrift(root, legacy)).toEqual([]);
|
|
196
|
+
|
|
197
|
+
const prefixed = { ...legacy, digests: { "managed.md": digest } };
|
|
198
|
+
expect(managedDrift(root, prefixed)).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("a file with no recorded digest makes no claim", async () => {
|
|
202
|
+
// Two cases share this path: a config written before digests existed, and a
|
|
203
|
+
// hook orly manages but did not author. Neither may be reported as drift.
|
|
204
|
+
const root = scratch();
|
|
205
|
+
writeFileSync(join(root, "hook.sh"), "the repository's own\n");
|
|
206
|
+
const config = { ...(await seedConfig(root)), managed: ["hook.sh"], digests: {} };
|
|
207
|
+
expect(managedDrift(root, config)).toEqual([]);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
159
211
|
describe("managedDrift", () => {
|
|
160
212
|
test("every managed file present reports no drift", async () => {
|
|
161
213
|
const root = scratch();
|
|
@@ -223,6 +275,7 @@ describe("writeConfig", () => {
|
|
|
223
275
|
commands: { conform: [["make", "audit"]] },
|
|
224
276
|
surfaces: { user: ["src/"], docs: ["docs/"] },
|
|
225
277
|
managed: ["AGENTS.md"],
|
|
278
|
+
digests: {},
|
|
226
279
|
};
|
|
227
280
|
await writeConfig(root, mine);
|
|
228
281
|
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,43 @@ 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
|
+
///
|
|
134
|
+
/// Algorithm-prefixed, and not for elegance: a bare 64-character hex string is
|
|
135
|
+
/// indistinguishable from a credential to an entropy-based secret scanner, and
|
|
136
|
+
/// gitleaks refused the very first commit that carried these — in EVERY
|
|
137
|
+
/// consuming repository at once, since `orly update` writes one per managed
|
|
138
|
+
/// file. The prefix is what makes the value self-describing enough to pass,
|
|
139
|
+
/// and the alternative was a scanner suppression, which is how a real
|
|
140
|
+
/// credential eventually rides through.
|
|
141
|
+
export function contentDigest(bytes: Uint8Array): string {
|
|
142
|
+
return `${DIGEST_ALGORITHM}:${createHash(DIGEST_ALGORITHM).update(bytes).digest(DIGEST_ENCODING)}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Tolerates the unprefixed spelling 0.10.2 wrote for its one release, so a
|
|
146
|
+
// checkout installed on that version is not reported as wholly drifted before
|
|
147
|
+
// its next update — a format change is not an edit, and saying so would be the
|
|
148
|
+
// false alarm that teaches people to ignore the real ones.
|
|
149
|
+
function sameDigest(recorded: string, actual: string): boolean {
|
|
150
|
+
const bare = (digest: string) => digest.startsWith(`${DIGEST_ALGORITHM}:`) ? digest.slice(DIGEST_ALGORITHM.length + 1) : digest;
|
|
151
|
+
return bare(recorded) === bare(actual);
|
|
152
|
+
}
|
|
153
|
+
|
|
115
154
|
// What orly installed here, and whether the engine has moved since.
|
|
116
155
|
export function staleVersion(config: RepoConfig, installedVersion: string): string | undefined {
|
|
117
156
|
if (config.orly_version === installedVersion) return undefined;
|
|
@@ -152,9 +191,33 @@ export function managedDrift(targetRoot: string, config: RepoConfig): string[] {
|
|
|
152
191
|
...present
|
|
153
192
|
.filter((relativePath) => ignored.has(relativePath))
|
|
154
193
|
.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`),
|
|
194
|
+
...editedManaged(targetRoot, config, present),
|
|
155
195
|
].sort();
|
|
156
196
|
}
|
|
157
197
|
|
|
198
|
+
// A managed file whose CONTENT no longer matches what orly wrote.
|
|
199
|
+
//
|
|
200
|
+
// The half `managedDrift` was missing, and the gap was not theoretical: a
|
|
201
|
+
// consuming repository added two checks straight to a materialised gate script,
|
|
202
|
+
// the pack never learned them, and the next update deleted them — while doctor
|
|
203
|
+
// reported green throughout, because it verified that the file EXISTED and
|
|
204
|
+
// never that it still said what orly put there. A gate whose checks can be
|
|
205
|
+
// removed under a passing verifier is not a gate.
|
|
206
|
+
//
|
|
207
|
+
// The remedy for a hit is deliberately not `orly update`: the local content may
|
|
208
|
+
// be the better version, as it was in that case. Update overwrites it; the
|
|
209
|
+
// message says to move the change into the source instead, which is the only
|
|
210
|
+
// way it reaches every other consumer.
|
|
211
|
+
function editedManaged(targetRoot: string, config: RepoConfig, present: string[]): string[] {
|
|
212
|
+
return present.flatMap((relativePath) => {
|
|
213
|
+
const recorded = config.digests[relativePath];
|
|
214
|
+
if (!recorded) return [];
|
|
215
|
+
const actual = contentDigest(readFileSync(join(targetRoot, relativePath)));
|
|
216
|
+
if (sameDigest(recorded, actual)) return [];
|
|
217
|
+
return [`managed file was edited after orly wrote it: ${relativePath} — move the change into the pack source, or \`orly update\` to discard it`];
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
158
221
|
// Key order is fixed so a rewrite produces a reviewable diff: orly's own two
|
|
159
222
|
// fields sit at the ends, the repository's three in the middle, untouched.
|
|
160
223
|
export async function writeConfig(targetRoot: string, config: RepoConfig): Promise<string> {
|
|
@@ -168,6 +231,7 @@ export async function writeConfig(targetRoot: string, config: RepoConfig): Promi
|
|
|
168
231
|
commands: config.commands,
|
|
169
232
|
...(config.surfaces ? { surfaces: config.surfaces } : {}),
|
|
170
233
|
managed: [...config.managed].sort(),
|
|
234
|
+
digests: Object.fromEntries(Object.entries(config.digests).sort(([a], [b]) => a.localeCompare(b))),
|
|
171
235
|
};
|
|
172
236
|
await Bun.write(path, `${JSON.stringify(ordered, undefined, JSON_INDENT)}${NEWLINE}`);
|
|
173
237
|
return path;
|
|
@@ -177,7 +241,7 @@ export async function writeConfig(targetRoot: string, config: RepoConfig): Promi
|
|
|
177
241
|
// from what the repository already builds with; an empty set is honest rather
|
|
178
242
|
// than wrong, and says so in the gate's own failure message.
|
|
179
243
|
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: [] };
|
|
244
|
+
return { schema_version: CONFIG_SCHEMA_VERSION, orly_version: "", packs: [], commands: await sniffCommands(targetRoot), surfaces: undefined, managed: [], digests: {} };
|
|
181
245
|
}
|
|
182
246
|
|
|
183
247
|
// 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
|
|