@agentsfleet/orly 0.7.1 → 0.8.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/AGENTS.md +16 -11
- package/SOUL.md +4 -1
- package/audits/agents-md.md +1 -0
- package/audits/rust-error.sh +228 -0
- package/core/operating-model.md +13 -11
- package/dispatch/lib.sh +28 -3
- package/dispatch/lifecycle.md +27 -0
- package/dispatch/verify.md +45 -11
- package/dispatch/write_go.md +8 -0
- package/dispatch/write_rust.md +166 -0
- package/dispatch/write_rust.sh +38 -0
- package/docs/EXECUTE_DOC_READS.md +1 -1
- package/docs/RULE_ENFORCEMENT.md +2 -2
- package/docs/VERIFY_TIERS.md +35 -11
- package/docs/greptile-learnings/RULES.md +1 -0
- package/package.json +1 -1
- package/packs/language/rust/rules.md +9 -0
- package/registry.json +16 -0
- package/skills/orly-babysit-prs/SKILL.md +4 -8
- package/skills/orly-spec-new/SKILL.md +4 -6
- package/skills/orly-write-integration-test/SKILL.md +4 -6
- package/skills/orly-write-unit-test/SKILL.md +4 -6
- package/src/cli.ts +6 -0
- package/src/criteria.test.ts +17 -6
- package/src/criteria.ts +34 -9
- package/src/gates.test.ts +33 -9
- package/src/gates_spec.test.ts +1 -1
- package/src/install.ts +6 -1
- package/src/install_packs.test.ts +56 -0
- package/src/render.test.ts +59 -1
- package/src/verify.test.ts +72 -2
- package/src/verify.ts +44 -3
package/src/gates.test.ts
CHANGED
|
@@ -16,27 +16,51 @@ afterEach(cleanupTemporaryDirectories);
|
|
|
16
16
|
describe("gate groups", () => {
|
|
17
17
|
test("run in order and stop at the first red group", async () => {
|
|
18
18
|
const project = newSpecRepository();
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// A failing conform reds the work gate, so verify and pr never run.
|
|
20
|
+
const model = await modelFor(project, undefined, { conform: [["false"]] });
|
|
21
21
|
const reports = runGates(model, project);
|
|
22
22
|
|
|
23
23
|
expect(reports.map((report) => report.gate)).toEqual(["work"]);
|
|
24
24
|
expect(reports[0]?.ok).toBeFalse();
|
|
25
|
-
expect(reports[0]?.results.find((result) => result.name === "
|
|
25
|
+
expect(reports[0]?.results.find((result) => result.name === "cmd.conform")?.ok).toBeFalse();
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
// One gate per cadence: work asks whether the commit conforms, verify whether
|
|
29
|
+
// the work holds up, pr whether it can ship. Branch shape and a clean tree are
|
|
30
|
+
// pr's questions alone — asked at commit time they refuse the commit being
|
|
31
|
+
// made, and asked at push time they refuse a scratch file that is not being
|
|
32
|
+
// pushed.
|
|
28
33
|
test("each gate evaluates exactly its declared criteria", async () => {
|
|
29
34
|
const project = newSpecRepository();
|
|
30
35
|
const model = await modelFor(project);
|
|
31
36
|
|
|
32
|
-
expect(names(runGate(model, project, "work"))).toEqual(["
|
|
33
|
-
expect(names(runGate(model, project, "verify"))).toEqual(["cmd.
|
|
37
|
+
expect(names(runGate(model, project, "work"))).toEqual(["cmd.conform", "repo.config"]);
|
|
38
|
+
expect(names(runGate(model, project, "verify"))).toEqual(["cmd.verify.unit", "docs.language", "spec.dimensions"]);
|
|
34
39
|
expect(names(runGate(model, project, "pr"))).toEqual([
|
|
35
|
-
"docs.updated", "git.pushed", "git.tree", "spec.baseline", "spec.deferrals", "spec.dimensions",
|
|
36
|
-
"spec.moved", "spec.open-questions", "spec.ordering", "spec.product-clarity",
|
|
40
|
+
"docs.updated", "git.branch", "git.pushed", "git.tree", "spec.baseline", "spec.deferrals", "spec.dimensions",
|
|
41
|
+
"spec.gate", "spec.moved", "spec.open-questions", "spec.ordering", "spec.product-clarity",
|
|
37
42
|
]);
|
|
38
43
|
});
|
|
39
44
|
|
|
45
|
+
// The bug this composition exists to kill: the hook orly generates runs
|
|
46
|
+
// `orly gate work`, and a commit hook's tree is dirty by construction — that
|
|
47
|
+
// is what is being committed. On the default branch, which the operating
|
|
48
|
+
// model itself says a new spec is committed on, it failed twice over. A fresh
|
|
49
|
+
// install could not commit itself without `--no-verify`, which Hard Safety
|
|
50
|
+
// forbids outright.
|
|
51
|
+
test("the work gate passes on the default branch with a staged edit", async () => {
|
|
52
|
+
const project = newSpecRepository();
|
|
53
|
+
const model = await modelFor(project);
|
|
54
|
+
await Bun.write(join(project, "src/staged.txt"), "an edit on its way into a commit\n");
|
|
55
|
+
git(project, "add", "src/staged.txt");
|
|
56
|
+
|
|
57
|
+
const report = runGate(model, project, "work");
|
|
58
|
+
|
|
59
|
+
expect(report.ok).toBeTrue();
|
|
60
|
+
expect(names(report)).not.toContain("git.tree");
|
|
61
|
+
expect(names(report)).not.toContain("git.branch");
|
|
62
|
+
});
|
|
63
|
+
|
|
40
64
|
test("gates never write: the tree is byte-identical after a run", async () => {
|
|
41
65
|
const project = newSpecRepository();
|
|
42
66
|
const model = await modelFor(project);
|
|
@@ -68,8 +92,8 @@ describe("repository resolution", () => {
|
|
|
68
92
|
git(project, "worktree", "add", "-q", "-b", "feat/stream", worktree);
|
|
69
93
|
await Bun.write(join(worktree, "stream-only.txt"), "present only in the worktree\n");
|
|
70
94
|
|
|
71
|
-
expect(runGate(model, worktree, "
|
|
72
|
-
expect(runGate(model, project, "
|
|
95
|
+
expect(runGate(model, worktree, "work").results.find((result) => result.name === "cmd.conform")?.ok).toBeTrue();
|
|
96
|
+
expect(runGate(model, project, "work").results.find((result) => result.name === "cmd.conform")?.ok).toBeFalse();
|
|
73
97
|
});
|
|
74
98
|
|
|
75
99
|
test("a repository with no orly config is red, and names the install command", async () => {
|
package/src/gates_spec.test.ts
CHANGED
|
@@ -18,7 +18,7 @@ describe("spec discovery", () => {
|
|
|
18
18
|
|
|
19
19
|
const verify = runGate(model, project, "verify");
|
|
20
20
|
expect(verify.results.find((result) => result.name === "spec.dimensions")?.detail).toContain("no active spec");
|
|
21
|
-
expect(
|
|
21
|
+
expect(runGate(model, project, "work").results.find((result) => result.name === "cmd.conform")?.ok).toBeTrue();
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
test("cache-kit style docs/v0.9.2/ layout is discovered", () => {
|
package/src/install.ts
CHANGED
|
@@ -407,7 +407,12 @@ function resolved(path: string): string {
|
|
|
407
407
|
// Managed markdown is pack-filtered on the way in, exactly as a rendered core
|
|
408
408
|
// document is. Copying it raw is how a Rust crate ends up holding a rule that
|
|
409
409
|
// points at a Zig façade it will never receive.
|
|
410
|
-
|
|
410
|
+
//
|
|
411
|
+
// Exported because it is the definition of what bytes belong at a managed
|
|
412
|
+
// target, and `orly verify` asks exactly that question of the checkout that
|
|
413
|
+
// authors the sources — where no install ever writes, so nothing else would
|
|
414
|
+
// catch a hand-edited copy drifting from the source every consumer receives.
|
|
415
|
+
export async function managedContent(path: string, target: string, source: string, packs: string[], known: Set<string>, orlyFile: string): Promise<Uint8Array> {
|
|
411
416
|
const bytes = await Bun.file(path).bytes();
|
|
412
417
|
if (extname(target) !== MARKDOWN_EXTENSION) return bytes;
|
|
413
418
|
const filtered = renderProfileText(new TextDecoder().decode(bytes), new Set(packs), known, source);
|
|
@@ -7,6 +7,9 @@ import { install } from "./install";
|
|
|
7
7
|
import { RulesModel } from "./model";
|
|
8
8
|
|
|
9
9
|
const SOURCE_EXTENSIONS = ["rs", "ts", "tsx", "js", "jsx", "py", "sh", "sql", "zig", "mdx"];
|
|
10
|
+
const DESCRIPTION_BUDGET = 320;
|
|
11
|
+
const RECORDER = "audits/doc-read.sh";
|
|
12
|
+
const RECORDER_LIBRARY = "audits/rule-ledger-lib.sh";
|
|
10
13
|
// Read from package.json rather than restated here: a hand-synced copy goes
|
|
11
14
|
// stale at the next release and the test then proves an install at a version
|
|
12
15
|
// that no longer ships.
|
|
@@ -36,3 +39,56 @@ describe("opt-in pack selection", () => {
|
|
|
36
39
|
expect(existsSync(join(repo, "dispatch/edit_rules.md"))).toBe(false);
|
|
37
40
|
});
|
|
38
41
|
});
|
|
42
|
+
|
|
43
|
+
// Every host renders skill metadata into a fixed context budget and truncates
|
|
44
|
+
// the set when it overflows — Codex says so out loud ("descriptions were
|
|
45
|
+
// shortened to fit the skills context budget"). A truncated description is a
|
|
46
|
+
// skill the model can no longer route to correctly, and the failure is silent
|
|
47
|
+
// at the point it matters. The bound is checked here so a growing description
|
|
48
|
+
// fails a test rather than degrading discovery in the field.
|
|
49
|
+
describe("packaged skill metadata", () => {
|
|
50
|
+
test("every description stays inside the host budget", async () => {
|
|
51
|
+
const skills = new Set<string>();
|
|
52
|
+
for (const entry of (await Bun.file(join(ROOT, "registry.json")).json()).packs["workflow.skills"].managed_files) skills.add(entry.source);
|
|
53
|
+
expect(skills.size).toBeGreaterThan(0);
|
|
54
|
+
|
|
55
|
+
for (const relative of [...skills].sort()) {
|
|
56
|
+
const frontmatter = (await Bun.file(join(ROOT, relative)).text()).split("---")[1] ?? "";
|
|
57
|
+
const description = (frontmatter.split(/^description:/m)[1] ?? "").split(/^[a-z_]+:/m)[0] ?? "";
|
|
58
|
+
const rendered = description.replace(/^[>|]/, "").replace(/\s+/g, " ").trim();
|
|
59
|
+
|
|
60
|
+
expect(rendered.length).toBeGreaterThan(0);
|
|
61
|
+
expect({ skill: relative, length: rendered.length }).toEqual({ skill: relative, length: Math.min(rendered.length, DESCRIPTION_BUDGET) });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// The operating model tells every agent to record a triggered read with
|
|
67
|
+
// `bash audits/doc-read.sh log <path>`, and pre-commit compares that record to
|
|
68
|
+
// the staged diff. The rule shipped through universal.authoring; the script did
|
|
69
|
+
// not, so in a consumer the command the rule names was simply absent — the
|
|
70
|
+
// runtime's read hook called nothing and the check had nothing to compare. A
|
|
71
|
+
// rule that names a command ships that command.
|
|
72
|
+
describe("the DOC READ recorder", () => {
|
|
73
|
+
test("installs with the authoring pack and runs where it lands", async () => {
|
|
74
|
+
const model = await RulesModel.load(ROOT);
|
|
75
|
+
const repo = newRepository();
|
|
76
|
+
await Bun.write(join(repo, ".oracle/orly.json"), JSON.stringify({ schema_version: 1, orly_version: "", packs: [], commands: {}, managed: [] }));
|
|
77
|
+
|
|
78
|
+
const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: ENGINE_VERSION });
|
|
79
|
+
|
|
80
|
+
expect(result.errors).toEqual([]);
|
|
81
|
+
expect(result.packs).toContain("universal.authoring");
|
|
82
|
+
expect(existsSync(join(repo, RECORDER))).toBe(true);
|
|
83
|
+
expect(existsSync(join(repo, RECORDER_LIBRARY))).toBe(true);
|
|
84
|
+
|
|
85
|
+
// Sourcing its library and resolving façade scope are the two ways this
|
|
86
|
+
// pair can be shipped incomplete, and both fail at run time rather than at
|
|
87
|
+
// copy time — so the proof is a real run in the repository it landed in.
|
|
88
|
+
const logged = Bun.spawnSync(["bash", RECORDER, "log", "README.md"], { cwd: repo, stdout: "pipe", stderr: "pipe" });
|
|
89
|
+
expect(logged.exitCode).toBe(0);
|
|
90
|
+
const checked = Bun.spawnSync(["bash", RECORDER, "check"], { cwd: repo, stdout: "pipe", stderr: "pipe" });
|
|
91
|
+
expect(checked.exitCode).toBe(0);
|
|
92
|
+
expect(checked.stdout.toString()).toContain("DOC READ");
|
|
93
|
+
});
|
|
94
|
+
});
|
package/src/render.test.ts
CHANGED
|
@@ -15,7 +15,7 @@ const KERNEL_PACKS = ["universal.authoring", "language.zig", "language.rust", "d
|
|
|
15
15
|
// This repository's own selection, as `.oracle/orly.json` declares it — the
|
|
16
16
|
// root render must stay current against exactly what `orly update` writes.
|
|
17
17
|
const DOTFILES_PACKS = ["universal.authoring", "language.zig", "language.typescript", "language.javascript", "language.rust", "language.go", "language.python", "language.shell", "language.mdx", "domain.sql", "domain.http", "domain.auth", "domain.documentation", "domain.changelog", "workflow.specifications", "workflow.governance", "product.agentsfleet", "persona.indy", "workflow.skills"];
|
|
18
|
-
const DOTFILES_COMMANDS = { conform: [["make", "
|
|
18
|
+
const DOTFILES_COMMANDS = { conform: [["make", "conform"]], "verify.unit": [["bun", "test", "src"]] };
|
|
19
19
|
|
|
20
20
|
describe("Renderer", () => {
|
|
21
21
|
test("renders byte-stable text for a pack set", async () => {
|
|
@@ -58,3 +58,61 @@ describe("Renderer", () => {
|
|
|
58
58
|
expect(new Renderer(model).renderText(KERNEL_PACKS)).rejects.toThrow("unclosed orly pack block");
|
|
59
59
|
});
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
// Claims the rendered rules make about the machine. Each one was false when
|
|
63
|
+
// this suite was written: the prose said "always" where the gate runs once,
|
|
64
|
+
// named a chain where one gate is the boundary, made a conditional skill
|
|
65
|
+
// mandatory, and credited a reporting-only criterion with enforcement. Prose
|
|
66
|
+
// drifting back is exactly as expensive as the first time, and none of it is
|
|
67
|
+
// visible to a render-determinism check.
|
|
68
|
+
describe("the rendered rules match the machine", () => {
|
|
69
|
+
test("VERIFY states two cadences rather than one 'always'", async () => {
|
|
70
|
+
const text = await new Renderer(await RulesModel.load(ROOT)).renderText(DOTFILES_PACKS, DOTFILES_COMMANDS);
|
|
71
|
+
|
|
72
|
+
expect(text).toContain("Two cadences, one boundary");
|
|
73
|
+
expect(text).toContain("runs ONCE, at the milestone boundary");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("CHORE(close) names the pr gate, not the whole chain", async () => {
|
|
77
|
+
const text = await new Renderer(await RulesModel.load(ROOT)).renderText(DOTFILES_PACKS, DOTFILES_COMMANDS);
|
|
78
|
+
|
|
79
|
+
expect(text).toContain("`orly gate pr` is the command CHORE(close) runs");
|
|
80
|
+
// Sound without re-running the fast tier, and the reason is stated where
|
|
81
|
+
// the claim is: git.pushed pins HEAD to what pre-push already graded.
|
|
82
|
+
expect(text).toContain("`git.pushed` proves HEAD is exactly what the pre-push");
|
|
83
|
+
expect(text).toContain("One gate per cadence, each tier run once");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("the integration skill is conditional on a real input/output boundary", async () => {
|
|
87
|
+
const text = await new Renderer(await RulesModel.load(ROOT)).renderText(DOTFILES_PACKS, DOTFILES_COMMANDS);
|
|
88
|
+
|
|
89
|
+
expect(text).toContain("when the diff crosses a module boundary with real input/output");
|
|
90
|
+
expect(text).not.toContain("both before the PR, never skipped");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// persona.indy is opt-in, and five engineering clauses were hiding inside it:
|
|
94
|
+
// a repository dropping the voice silently dropped symlink-edit routing,
|
|
95
|
+
// dotfiles backup, vault naming, sibling-repo precedent, and the
|
|
96
|
+
// read-the-reference mandate with it. A pack named for voice must cost only
|
|
97
|
+
// voice when it is left out.
|
|
98
|
+
test("the engineering clauses survive without the persona pack", async () => {
|
|
99
|
+
const renderer = new Renderer(await RulesModel.load(ROOT));
|
|
100
|
+
const withoutPersona = await renderer.renderText(DOTFILES_PACKS.filter((pack) => pack !== "persona.indy"), DOTFILES_COMMANDS);
|
|
101
|
+
|
|
102
|
+
expect(withoutPersona).toContain("Symlinked edits land in the repository that owns the file");
|
|
103
|
+
expect(withoutPersona).toContain("timestamped backup");
|
|
104
|
+
expect(withoutPersona).toContain("Vault names come from the environment");
|
|
105
|
+
expect(withoutPersona).toContain("Check a sibling repository");
|
|
106
|
+
expect(withoutPersona).toContain("Read the reference implementation before designing");
|
|
107
|
+
// What persona is actually for stays behind with it.
|
|
108
|
+
expect(withoutPersona).not.toContain("Indy's reference checkouts");
|
|
109
|
+
expect(withoutPersona).not.toContain("🤠 Indy");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("SOUL does not claim the language criterion enforces anything", async () => {
|
|
113
|
+
const soul = await Bun.file(resolve(ROOT, "SOUL.md")).text();
|
|
114
|
+
|
|
115
|
+
expect(soul).toContain("REPORTS it");
|
|
116
|
+
expect(soul).not.toContain("`orly gate verify` enforces it");
|
|
117
|
+
});
|
|
118
|
+
});
|
package/src/verify.test.ts
CHANGED
|
@@ -1,13 +1,83 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
3
5
|
|
|
4
6
|
import { RulesModel } from "./model";
|
|
5
|
-
import { verifyRenders } from "./verify";
|
|
7
|
+
import { packSourceErrors, verifyRenders } from "./verify";
|
|
6
8
|
|
|
7
9
|
const ROOT = resolve(import.meta.dir, "..");
|
|
10
|
+
const PACK = "language.demo";
|
|
11
|
+
const SOURCE = "packs/demo/rules.md";
|
|
12
|
+
const TARGET = "dispatch/write_demo.md";
|
|
13
|
+
const BODY = "# Demo\n\nOne rule, stated once.\n";
|
|
14
|
+
|
|
15
|
+
// A checkout carrying one pack whose source and target are different paths —
|
|
16
|
+
// the shape `planFiles` never writes into, and therefore the only shape that
|
|
17
|
+
// can drift.
|
|
18
|
+
function sandbox(target?: string): { model: RulesModel; root: string } {
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), "orly-packs-"));
|
|
20
|
+
mkdirSync(join(root, "packs/demo"), { recursive: true });
|
|
21
|
+
writeFileSync(join(root, SOURCE), BODY);
|
|
22
|
+
if (target !== undefined) {
|
|
23
|
+
mkdirSync(join(root, "dispatch"), { recursive: true });
|
|
24
|
+
writeFileSync(join(root, TARGET), target);
|
|
25
|
+
}
|
|
26
|
+
const registry = { packs: { [PACK]: { extensions: [], managed_files: [{ source: SOURCE, target: TARGET }] } } };
|
|
27
|
+
return { model: new RulesModel(root, registry), root };
|
|
28
|
+
}
|
|
8
29
|
|
|
9
30
|
test("every profile renders deterministically", async () => {
|
|
10
31
|
const checks = await verifyRenders(await RulesModel.load(ROOT));
|
|
11
32
|
|
|
12
33
|
expect(checks.filter((check) => check.name.endsWith(".idempotent")).every((check) => check.result === "pass")).toBeTrue();
|
|
13
34
|
});
|
|
35
|
+
|
|
36
|
+
test("pack targets match their sources", async () => {
|
|
37
|
+
const model = await RulesModel.load(ROOT);
|
|
38
|
+
const local = Object.keys(model.registry.packs as Record<string, unknown>).sort();
|
|
39
|
+
|
|
40
|
+
expect(await packSourceErrors(model, local)).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("pack target drift names the pack and both paths", async () => {
|
|
44
|
+
const { model, root } = sandbox(`${BODY}and a second rule the source never gained.\n`);
|
|
45
|
+
try {
|
|
46
|
+
const findings = await packSourceErrors(model, [PACK]);
|
|
47
|
+
|
|
48
|
+
expect(findings).toHaveLength(1);
|
|
49
|
+
expect(findings[0]).toContain(TARGET);
|
|
50
|
+
expect(findings[0]).toContain(SOURCE);
|
|
51
|
+
expect(findings[0]).toContain(PACK);
|
|
52
|
+
} finally {
|
|
53
|
+
rmSync(root, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a pack target the checkout does not carry is not drift", async () => {
|
|
58
|
+
const { model, root } = sandbox();
|
|
59
|
+
try {
|
|
60
|
+
expect(await packSourceErrors(model, [PACK])).toEqual([]);
|
|
61
|
+
} finally {
|
|
62
|
+
rmSync(root, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// The trailing newline managedContent appends is part of what a consumer
|
|
67
|
+
// receives, so a target missing it is drift like any other byte.
|
|
68
|
+
test("a matched pack target reports clean", async () => {
|
|
69
|
+
const { model, root } = sandbox(BODY);
|
|
70
|
+
try {
|
|
71
|
+
expect(await packSourceErrors(model, [PACK])).toEqual([]);
|
|
72
|
+
} finally {
|
|
73
|
+
rmSync(root, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("verify reports pack source parity beside the render proofs", async () => {
|
|
78
|
+
const checks = await verifyRenders(await RulesModel.load(ROOT));
|
|
79
|
+
const parity = checks.find((check) => check.name === "packs.sources.current");
|
|
80
|
+
|
|
81
|
+
expect(parity).toBeDefined();
|
|
82
|
+
expect(parity?.result).toBe("pass");
|
|
83
|
+
});
|
package/src/verify.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import { mkdirSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { localSelection } from "./config";
|
|
5
|
-
import {
|
|
5
|
+
import { managedContent } from "./install";
|
|
6
|
+
import { hashContent, isString, objectArray, objectValue, RulesModel } from "./model";
|
|
6
7
|
import { Renderer } from "./render";
|
|
7
8
|
|
|
8
9
|
const PASS_RESULT = "pass";
|
|
9
10
|
const FAIL_RESULT = "fail";
|
|
11
|
+
const REGISTRY_PACKS_LABEL = "registry packs";
|
|
12
|
+
const AGENTS_FILENAME = "AGENTS.md";
|
|
13
|
+
const DETAIL_SEPARATOR = "; ";
|
|
10
14
|
|
|
11
15
|
export type VerificationCheck = {
|
|
12
16
|
name: string;
|
|
@@ -32,10 +36,47 @@ export async function verifyRenders(model: RulesModel): Promise<VerificationChec
|
|
|
32
36
|
});
|
|
33
37
|
}
|
|
34
38
|
const errors = await renderer.rootErrors(local.packs, local.commands);
|
|
35
|
-
checks.push({ name: "generated.root.current", result: errors.length === 0 ? PASS_RESULT : FAIL_RESULT, detail: errors.join(
|
|
39
|
+
checks.push({ name: "generated.root.current", result: errors.length === 0 ? PASS_RESULT : FAIL_RESULT, detail: errors.join(DETAIL_SEPARATOR) });
|
|
40
|
+
const drift = await packSourceErrors(model, local.packs);
|
|
41
|
+
checks.push({ name: "packs.sources.current", result: drift.length === 0 ? PASS_RESULT : FAIL_RESULT, detail: drift.join(DETAIL_SEPARATOR) });
|
|
36
42
|
return checks;
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
// Every pack file this checkout carries, against the bytes its pack would ship.
|
|
46
|
+
//
|
|
47
|
+
// `planFiles` deliberately skips writing a managed file into the checkout that
|
|
48
|
+
// owns its source: writing there would either replace a source with its own
|
|
49
|
+
// pack-filtered rendering or plant a second copy that drifts. The consequence is
|
|
50
|
+
// that the copy living here is maintained BY HAND and no install ever corrects
|
|
51
|
+
// it — so a source can gain three sections while the target beside it keeps the
|
|
52
|
+
// old two, and every consumer receives rules this repository's own agents, its
|
|
53
|
+
// dispatch-coverage audit, and its rule ledger never see. Both halves of that
|
|
54
|
+
// had already happened when this check was written.
|
|
55
|
+
//
|
|
56
|
+
// A target the checkout does not carry is silence, not drift: a consumer-only
|
|
57
|
+
// path (a skill copied into `.claude/skills/`) is absent here by design.
|
|
58
|
+
export async function packSourceErrors(model: RulesModel, packs: string[]): Promise<string[]> {
|
|
59
|
+
const registryPacks = objectValue(model.registry.packs, REGISTRY_PACKS_LABEL);
|
|
60
|
+
const known = new Set(Object.keys(registryPacks));
|
|
61
|
+
const findings = new Set<string>();
|
|
62
|
+
for (const name of packs) {
|
|
63
|
+
const pack = objectValue(registryPacks[name], `pack ${name}`);
|
|
64
|
+
for (const entry of objectArray(pack.managed_files, `pack ${name} managed_files`)) {
|
|
65
|
+
if (!isString(entry.source) || !isString(entry.target)) continue;
|
|
66
|
+
// Same path on both sides is the source itself — nothing to compare it to.
|
|
67
|
+
if (entry.source === entry.target) continue;
|
|
68
|
+
const targetPath = join(model.root, entry.target);
|
|
69
|
+
if (!existsSync(targetPath)) continue;
|
|
70
|
+
// The engine checkout renders its own rules under their own name, so the
|
|
71
|
+
// citation retarget managedContent applies for a guest is identity here.
|
|
72
|
+
const expected = await managedContent(join(model.root, entry.source), entry.target, entry.source, packs, known, AGENTS_FILENAME);
|
|
73
|
+
if (hashContent(expected) === hashContent(await Bun.file(targetPath).bytes())) continue;
|
|
74
|
+
findings.add(`${entry.target} drifted from ${entry.source} (pack ${name}) — copy the source over it`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return [...findings].sort();
|
|
78
|
+
}
|
|
79
|
+
|
|
39
80
|
export async function writeEvidence(
|
|
40
81
|
model: RulesModel,
|
|
41
82
|
profile: string,
|