@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/AGENTS.md +16 -11
- package/SOUL.md +4 -1
- package/audits/agents-md.md +1 -0
- package/audits/logging.sh +90 -16
- 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_any.md +5 -4
- 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/LOGGING_STANDARD.md +14 -4
- package/docs/RULE_ENFORCEMENT.md +3 -3
- 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/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
|
|
5
|
-
(
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
5
|
-
full HTTP router
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
package/src/criteria.test.ts
CHANGED
|
@@ -24,9 +24,12 @@ describe("criteriaFor", () => {
|
|
|
24
24
|
return criteriaFor(gate, context).map((criterion) => criterion.name).sort();
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
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(["
|
|
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.
|
|
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
|
|
51
|
-
//
|
|
52
|
-
|
|
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
|
-
//
|
|
49
|
-
//
|
|
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 [
|
|
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.
|
|
111
|
-
// conform
|
|
112
|
-
//
|
|
113
|
-
// code, so a prose-only branch never pays for
|
|
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
|
|
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
|
-
|
|
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,
|