@agentsfleet/orly 0.7.0 → 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/registry.json CHANGED
@@ -36,6 +36,14 @@
36
36
  "source": "dispatch/write_pr_description.md",
37
37
  "target": "dispatch/write_pr_description.md"
38
38
  },
39
+ {
40
+ "source": "audits/doc-read.sh",
41
+ "target": "audits/doc-read.sh"
42
+ },
43
+ {
44
+ "source": "audits/rule-ledger-lib.sh",
45
+ "target": "audits/rule-ledger-lib.sh"
46
+ },
39
47
  {
40
48
  "source": "audits/ufs.sh",
41
49
  "target": "audits/ufs.sh"
@@ -118,6 +126,14 @@
118
126
  ".rs"
119
127
  ],
120
128
  "managed_files": [
129
+ {
130
+ "source": "dispatch/write_rust.sh",
131
+ "target": "dispatch/write_rust.sh"
132
+ },
133
+ {
134
+ "source": "audits/rust-error.sh",
135
+ "target": "audits/rust-error.sh"
136
+ },
121
137
  {
122
138
  "source": "packs/language/rust/rules.md",
123
139
  "target": "dispatch/write_rust.md"
@@ -1,14 +1,10 @@
1
1
  ---
2
2
  name: orly-babysit-prs
3
3
  description: |
4
- Post-push follow-up for PRs/MRs: backoff-cadence polls over CI check runs
5
- (fix every failure your diff caused), every greptile inline thread, and the
6
- PR-level summary; classifies via gstack greptile-triage, suppresses via
7
- greptile-history, stops after two consecutive empty polls with CI green.
8
- Forge-aware (gh PR / glab MR). Use after `gh pr create` / `glab mr create`,
9
- after every push to a branch with an open PR/MR, or on "babysit", "watch
10
- greptile", "poll the PR/MR", "watch reviews", "follow up on review
11
- feedback". Cross-agent: Claude, Codex, OpenCode, Amp.
4
+ Post-push follow-up for a Pull Request or Merge Request: poll Continuous
5
+ Integration (CI) checks, greptile threads, and the summary; fix what your diff
6
+ broke; stop after two empty polls with CI green. Forge-aware (gh, glab). Use
7
+ after creating or pushing to a PR/MR, or on "babysit", "watch reviews".
12
8
  ---
13
9
 
14
10
  # orly-babysit-prs
@@ -1,12 +1,10 @@
1
1
  ---
2
2
  name: orly-spec-new
3
3
  description: |
4
- Author a milestone/workstream spec the executing agent (Orly) ships into a
5
- deterministic, review-clean, reported Pull Request — intent capture and
6
- review-readiness first, file mechanics last. Use on "create a spec", "new
7
- milestone", "start M{N}_{NNN}", "spec out X", or any TODO.md attempt
8
- (forbidden — every non-trivial intent becomes a spec). Cross-agent,
9
- self-contained markdown.
4
+ Author a milestone or workstream spec that ships as a deterministic,
5
+ review-clean Pull Request — intent capture first, file mechanics last. Use on
6
+ "create a spec", "new milestone", "spec out X", "start M{N}_{NNN}", or any
7
+ TODO.md attempt (forbidden — every non-trivial intent becomes a spec).
10
8
  ---
11
9
 
12
10
  # orly-spec-new
@@ -1,12 +1,10 @@
1
1
  ---
2
2
  name: orly-write-integration-test
3
3
  description: >
4
- Service-layer integration tests against real dependencies (Postgres, Redis,
5
- full HTTP router + middleware) with deterministic failure injection,
6
- per-test isolation, drain + leak audits, and 100-or-more-connection parallelism
7
- proofs. Sister of orly-write-unit-test. Use for handlers, repos, services, or
8
- any real-I/O module-boundary crossing. Not for browser E2E (gstack /qa) or
9
- pure logic (orly-write-unit-test).
4
+ Service-layer integration tests against real dependencies — Postgres, Redis,
5
+ full HTTP router and middleware — with failure injection, per-test isolation,
6
+ and leak audits. Use for handlers, repositories, services, or any real
7
+ input/output module boundary; not for browser end-to-end or pure logic.
10
8
  ---
11
9
 
12
10
  # Write Integration Test
@@ -1,12 +1,10 @@
1
1
  ---
2
2
  name: orly-write-unit-test
3
3
  description: >
4
- Risk-weighted, failure-injecting test generation across 8 stacks (Python,
5
- Python SDK, OpenAPI, JS/TS CLI, React/TS, Zig, Rust, Shell) enforcing behaviour +
6
- failure + invariant + integration + regression coverage with a
7
- Definition-of-Done gate; deterministic production-safety proofs (Zig
8
- zero-leak, 100-or-more-connection concurrency, complexity/latency budgets) live
9
- in the skill body. Use during implementation, not after.
4
+ Risk-weighted, failure-injecting unit tests across Python, TypeScript, React,
5
+ Zig, Rust, and shell: behaviour, failure, invariant, and regression coverage
6
+ behind a Definition-of-Done gate. Production-safety proofs live in the body.
7
+ Use during implementation, not after.
10
8
  ---
11
9
 
12
10
  # Write Unit Test
package/src/cli.ts CHANGED
@@ -293,6 +293,12 @@ function printHelp(): void {
293
293
  Gates (read-only; no PR without every criterion green or a recorded override):
294
294
  orly gate [--accept-dirty] run work → verify → pr; stop at first red
295
295
  orly gate <work|verify|pr> run one gate
296
+ work does this commit conform? the declared conform command; no git
297
+ state, so a commit hook's own dirty tree never blocks it
298
+ verify does the work hold up? spec dimensions, docs language, and the
299
+ fast verify.* set
300
+ pr can this ship? branch, tree, pushed, every spec criterion, and
301
+ the slow verify.* suites
296
302
  orly override <CRITERION> --reason <REASON>
297
303
  empty commit with an Orly-Override trailer
298
304
 
@@ -24,9 +24,12 @@ describe("criteriaFor", () => {
24
24
  return criteriaFor(gate, context).map((criterion) => criterion.name).sort();
25
25
  }
26
26
 
27
- test("work evaluates the branch, the tree and the profile", async () => {
27
+ // work is the commit-time gate, and a commit hook's tree is dirty by
28
+ // construction. It asks the repository's own conform command and nothing
29
+ // about git state.
30
+ test("work evaluates the profile and the conform tier", async () => {
28
31
  const project = newSpecRepository();
29
- expect(named(WORK, await contextFor(project))).toEqual(["git.branch", "git.tree", REPO_CONFIG]);
32
+ expect(named(WORK, await contextFor(project))).toEqual(["cmd.conform", REPO_CONFIG]);
30
33
  });
31
34
 
32
35
  test("verify pairs the spec dimensions with the fast command tier only", async () => {
@@ -34,7 +37,9 @@ describe("criteriaFor", () => {
34
37
  const names = named(VERIFY, await contextFor(project));
35
38
 
36
39
  expect(names).toContain("spec.dimensions");
37
- expect(names).toContain("cmd.conform");
40
+ expect(names).toContain("cmd.verify.unit");
41
+ // conform is the work gate's tier now, so the chain runs it once.
42
+ expect(names).not.toContain("cmd.conform");
38
43
  // The slow tier belongs to the pr gate; leaking it here would make every
39
44
  // verify run pay for integration and memory suites.
40
45
  expect(names).not.toContain("cmd.verify.integration");
@@ -47,9 +52,15 @@ describe("criteriaFor", () => {
47
52
  for (const required of ["spec.moved", "spec.baseline", "spec.ordering", "spec.deferrals", "docs.updated"]) {
48
53
  expect(names).toContain(required);
49
54
  }
50
- // git.branch is a work-gate concern: by pr time the branch is established,
51
- // and re-asserting it would make an already-merged branch un-gateable.
52
- expect(names).not.toContain("git.branch");
55
+ // git.branch belongs here and only here. It was a work-gate criterion until
56
+ // the generated pre-commit hook was found refusing every commit made on the
57
+ // default branch — including the commit that installs orly, and the
58
+ // spec-on-main commit the operating model itself prescribes. The question
59
+ // "is this the default branch" is only ever decisive where a Pull Request
60
+ // would open from it, so it is asked there. Running `orly gate pr` on a
61
+ // merged branch reds, which is the honest answer: no PR opens from main.
62
+ expect(names).toContain("git.branch");
63
+ expect(names).toContain("git.tree");
53
64
  });
54
65
 
55
66
  test("an unknown gate name yields no criteria rather than throwing", async () => {
package/src/criteria.ts CHANGED
@@ -36,6 +36,12 @@ const DOCS_LANGUAGE = "docs.language";
36
36
  // line is a summary, not a linter dump.
37
37
  const NAMED_FINDINGS = 3;
38
38
 
39
+ // CONFORM is its own lifecycle stage — EXECUTE → CONFORM → VERIFY — and it is
40
+ // its own tier here for the same reason. Lumped into verify, the deterministic
41
+ // rule audit could only run beside the lint and unit suites, so a repository
42
+ // paid minutes to learn something its seconds-long conform command already
43
+ // knew, and `orly gate` ran it twice on the way to `pr`.
44
+ const CONFORM_TIER = "conform";
39
45
  const FAST_TIER = "fast";
40
46
  const SLOW_TIER = "slow";
41
47
  // The slow tier is a fixed name set, not a prefix rule: lint and version
@@ -45,14 +51,31 @@ const SLOW_COMMANDS = ["verify.integration", "verify.memory"];
45
51
  // Every criterion is mechanical: it reads an exit code or a file, never a
46
52
  // judgment. The anchor invariant promises the machine can PROVE the PR
47
53
  // boundary, so anything unprovable stays prose and never lands here.
48
- // work = is this branch workable · verify = does the work hold up ·
49
- // pr = can this ship (whole-branch checks + the slow suites).
54
+ //
55
+ // One gate per cadence, and each question asked at the only moment it can be
56
+ // answered honestly:
57
+ //
58
+ // work — does this commit conform? Config plus the declared `conform`
59
+ // command. Nothing about branch or tree: inside a commit hook the
60
+ // tree is dirty BY CONSTRUCTION (that is what is being committed),
61
+ // and the operating model itself says a new spec is committed on the
62
+ // default branch. Judging either here made the generated pre-commit
63
+ // hook refuse every commit — including the one installing orly —
64
+ // with `--no-verify`, which Hard Safety forbids, as the only way out.
65
+ // verify — does the work hold up? Spec dimensions, documentation language,
66
+ // and the fast `verify.*` set. Runs before the work leaves the
67
+ // machine, not after every edit.
68
+ // pr — can this ship? The whole-branch facts (branch shape, clean tree,
69
+ // pushed) plus every spec criterion and the slow suites. A tree is
70
+ // only required clean where a reviewer is about to read it, and a
71
+ // branch is only wrong for being `main` where a PR would open from
72
+ // it.
50
73
  export function criteriaFor(gate: string, context: CriterionContext): Criterion[] {
51
- if (gate === "work") return [gitBranch(), gitTree(), repositoryConfig()];
74
+ if (gate === "work") return [repositoryConfig(), ...commandCriteria(context, CONFORM_TIER)];
52
75
  if (gate === "verify") return [specDimensions(), docsLanguage(), ...commandCriteria(context, FAST_TIER)];
53
76
  if (gate === "pr") {
54
77
  return [
55
- gitTree(), gitPushed(), specGate(), openQuestions(), productClarity(), specDimensions(),
78
+ gitBranch(), gitTree(), gitPushed(), specGate(), openQuestions(), productClarity(), specDimensions(),
56
79
  specMoved(), specBaseline(), specOrdering(), specDeferrals(),
57
80
  docsUpdated(), ...commandCriteria(context, SLOW_TIER),
58
81
  ];
@@ -107,10 +130,11 @@ function repositoryConfig(): Criterion {
107
130
  }
108
131
 
109
132
  // Model C: the repository's declared command surface. Orly owns policy and
110
- // invokes these; the repository owns what they actually do. Fast tier =
111
- // conform + verify.unit. Slow tier = every other verify.* (integration,
112
- // memleak); those auto-pass with a printed skip when the branch carries no
113
- // code, so a prose-only branch never pays for the slow suites.
133
+ // invokes these; the repository owns what they actually do. Conform tier = the
134
+ // `conform` command alone. Fast tier = every verify.* that is not slow. Slow
135
+ // tier = verify.integration and verify.memory; those auto-pass with a printed
136
+ // skip when the branch carries no code, so a prose-only branch never pays for
137
+ // the slow suites.
114
138
  function commandCriteria(context: CriterionContext, tier: string): Criterion[] {
115
139
  const config = resolvedConfig(context);
116
140
  if (!config) return tier === FAST_TIER ? [criterion(REPO_CONFIG, () => ({ ok: false, detail: UNINSTALLED }))] : [];
@@ -125,8 +149,9 @@ function commandCriteria(context: CriterionContext, tier: string): Criterion[] {
125
149
  }
126
150
 
127
151
  function tierOf(key: string): string {
152
+ if (key === CONFORM_COMMAND) return CONFORM_TIER;
128
153
  if (SLOW_COMMANDS.includes(key)) return SLOW_TIER;
129
- if (key === CONFORM_COMMAND || key.startsWith(VERIFY_PREFIX)) return FAST_TIER;
154
+ if (key.startsWith(VERIFY_PREFIX)) return FAST_TIER;
130
155
  return "";
131
156
  }
132
157
 
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
- const model = await modelFor(project);
20
- // Default branch: the work gate is red, so verify and pr never run.
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 === "git.branch")?.ok).toBeFalse();
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(["git.branch", "git.tree", "repo.config"]);
33
- expect(names(runGate(model, project, "verify"))).toEqual(["cmd.conform", "cmd.verify.unit", "docs.language", "spec.dimensions"]);
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", "spec.gate",
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, "verify").results.find((result) => result.name === "cmd.conform")?.ok).toBeTrue();
72
- expect(runGate(model, project, "verify").results.find((result) => result.name === "cmd.conform")?.ok).toBeFalse();
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 () => {
@@ -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(verify.results.find((result) => result.name === "cmd.conform")?.ok).toBeTrue();
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
- async function managedContent(path: string, target: string, source: string, packs: string[], known: Set<string>, orlyFile: string): Promise<Uint8Array> {
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
+ });
@@ -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", "audit"]], "verify.unit": [["bun", "test", "src"]] };
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
+ });
@@ -1,13 +1,83 @@
1
1
  import { expect, test } from "bun:test";
2
- import { resolve } from "node:path";
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 { objectValue, RulesModel } from "./model";
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,