@agentsfleet/orly 0.5.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.
Files changed (112) hide show
  1. package/.githooks/pre-commit +42 -0
  2. package/.githooks/pre-push +67 -0
  3. package/AGENTS.md +348 -0
  4. package/LICENSE +21 -0
  5. package/README.md +249 -0
  6. package/SOUL.md +90 -0
  7. package/audits/agents-md.md +497 -0
  8. package/audits/agents-md.sh +353 -0
  9. package/audits/cross-tier-rates.sh +105 -0
  10. package/audits/data.sh +185 -0
  11. package/audits/deinit-pairs.sh +224 -0
  12. package/audits/design-tokens.sh +143 -0
  13. package/audits/doc-read.sh +183 -0
  14. package/audits/error-codes.sh +221 -0
  15. package/audits/fixtures/clean.diff +15 -0
  16. package/audits/fixtures/dirty.diff +16 -0
  17. package/audits/lifecycle-anchors.sh +22 -0
  18. package/audits/logging.sh +217 -0
  19. package/audits/msid-ui.sh +144 -0
  20. package/audits/parity-dispatch.sh +51 -0
  21. package/audits/rule-ledger-lib.sh +264 -0
  22. package/audits/rule-ledger.sh +225 -0
  23. package/audits/rule-paths.sh +108 -0
  24. package/audits/spec-template.sh +258 -0
  25. package/audits/sql-mod.sh +102 -0
  26. package/audits/ufs.sh +303 -0
  27. package/bin/orly +26 -0
  28. package/core/operating-model.md +256 -0
  29. package/dispatch/edit_rules.md +69 -0
  30. package/dispatch/lib.sh +213 -0
  31. package/dispatch/lifecycle.md +187 -0
  32. package/dispatch/name_architecture.md +93 -0
  33. package/dispatch/verify.md +111 -0
  34. package/dispatch/write_any.md +740 -0
  35. package/dispatch/write_any.sh +49 -0
  36. package/dispatch/write_auth.md +36 -0
  37. package/dispatch/write_changelog.md +64 -0
  38. package/dispatch/write_documentation.md +45 -0
  39. package/dispatch/write_go.md +24 -0
  40. package/dispatch/write_http.md +45 -0
  41. package/dispatch/write_mdx.md +7 -0
  42. package/dispatch/write_python.md +8 -0
  43. package/dispatch/write_rust.md +38 -0
  44. package/dispatch/write_shell.md +8 -0
  45. package/dispatch/write_spec.md +105 -0
  46. package/dispatch/write_sql.md +112 -0
  47. package/dispatch/write_sql.sh +36 -0
  48. package/dispatch/write_ts_adhere_bun.md +493 -0
  49. package/dispatch/write_ts_adhere_bun.sh +37 -0
  50. package/dispatch/write_zig.md +852 -0
  51. package/dispatch/write_zig.sh +40 -0
  52. package/docs/CHANGELOG_VOICE.md +18 -0
  53. package/docs/DISPATCH_ARCHITECTURE.md +664 -0
  54. package/docs/DISPATCH_REVIEW_DISPOSITION.md +65 -0
  55. package/docs/DOCUMENTATION_RULES.md +209 -0
  56. package/docs/EXECUTE_DOC_READS.md +29 -0
  57. package/docs/HARNESS_VERIFY_OUTPUT.md +40 -0
  58. package/docs/LIFECYCLE_PATTERNS.md +316 -0
  59. package/docs/LOGGING_STANDARD.md +397 -0
  60. package/docs/ORLY_ARCHITECTURE.md +129 -0
  61. package/docs/REST_API_DESIGN_GUIDELINES.md +783 -0
  62. package/docs/RULE_ENFORCEMENT.md +41 -0
  63. package/docs/SCHEMA_CONVENTIONS.md +93 -0
  64. package/docs/TEMPLATE.md +411 -0
  65. package/docs/VERIFY_TIERS.md +57 -0
  66. package/docs/greptile-learnings/README.md +51 -0
  67. package/docs/greptile-learnings/RULES.md +1045 -0
  68. package/fixtures/registry-invalid.json +1 -0
  69. package/fixtures/registry-valid.json +18 -0
  70. package/fixtures/unclosed-pack-block.md +8 -0
  71. package/package.json +63 -0
  72. package/packs/language/go/rules.md +24 -0
  73. package/packs/language/mdx/rules.md +7 -0
  74. package/packs/language/python/rules.md +8 -0
  75. package/packs/language/rust/rules.md +38 -0
  76. package/packs/language/shell/rules.md +8 -0
  77. package/registry.json +405 -0
  78. package/schemas/evidence.schema.json +43 -0
  79. package/schemas/profile.schema.json +12 -0
  80. package/schemas/registry.schema.json +12 -0
  81. package/skills/orly-babysit-prs/SKILL.md +326 -0
  82. package/skills/orly-spec-new/SKILL.md +157 -0
  83. package/skills/orly-write-integration-test/SKILL.md +470 -0
  84. package/skills/orly-write-unit-test/SKILL.md +520 -0
  85. package/src/cli.test.ts +70 -0
  86. package/src/cli.ts +287 -0
  87. package/src/config.test.ts +226 -0
  88. package/src/config.ts +288 -0
  89. package/src/criteria.test.ts +137 -0
  90. package/src/criteria.ts +166 -0
  91. package/src/criteria_spec.ts +127 -0
  92. package/src/criteria_support.ts +53 -0
  93. package/src/gates.test.ts +183 -0
  94. package/src/gates.ts +169 -0
  95. package/src/gates_spec.test.ts +98 -0
  96. package/src/gates_test_support.ts +194 -0
  97. package/src/git_env.ts +20 -0
  98. package/src/install.test.ts +514 -0
  99. package/src/install.ts +447 -0
  100. package/src/install_nested.test.ts +32 -0
  101. package/src/model.test.ts +31 -0
  102. package/src/model.ts +245 -0
  103. package/src/references.test.ts +47 -0
  104. package/src/references.ts +147 -0
  105. package/src/render.test.ts +60 -0
  106. package/src/render.ts +94 -0
  107. package/src/surfaces.test.ts +43 -0
  108. package/src/surfaces.ts +89 -0
  109. package/src/validation.test.ts +11 -0
  110. package/src/validation.ts +87 -0
  111. package/src/verify.test.ts +13 -0
  112. package/src/verify.ts +59 -0
@@ -0,0 +1,127 @@
1
+ import { Criterion, CriterionContext, criterion, gitOutput, runCommand, Verdict } from "./criteria_support";
2
+ import { branchDiff, defaultMergeBase } from "./surfaces";
3
+
4
+ const OPEN_QUESTION = "[?]";
5
+ const PRODUCT_CLARITY_HEADING = "## Product Clarity";
6
+ const DIMENSION_PREFIX = "- **Dimension ";
7
+ const DONE_MARKER = "DONE";
8
+ const SPEC_GATE_SCRIPT = "audits/spec-template.sh";
9
+ const NO_SPEC_SKIP = "skipped — no active spec (quality gates still apply)";
10
+
11
+ const SPEC_GATE = "spec.gate";
12
+ const SPEC_OPEN_QUESTIONS = "spec.open-questions";
13
+ const SPEC_PRODUCT_CLARITY = "spec.product-clarity";
14
+ const SPEC_DIMENSIONS = "spec.dimensions";
15
+ const SPEC_MOVED = "spec.moved";
16
+ const SPEC_BASELINE = "spec.baseline";
17
+ const SPEC_ORDERING = "spec.ordering";
18
+ const SPEC_DEFERRALS = "spec.deferrals";
19
+ const STATUS_DONE = "Status: DONE";
20
+ const BASELINE_HEADER = "Test Baseline:";
21
+ const INDY_ACK = "> Indy (";
22
+ // deferred/deferral(s) only — never Zig's defer/errdefer keywords.
23
+ const DEFERRAL_CLAIM = /\bdeferr(ed|al|als)\b/i;
24
+ const SPEC_TREE_FILE = /^docs\/v[^/]+\/.+\.md$/;
25
+
26
+ // Wrap a spec-reading criterion: no active spec → skip-pass with the reason
27
+ // printed, so quality gates still run on spec-less (ad-hoc) branches.
28
+ function specCriterion(name: string, evaluate: (context: CriterionContext) => Verdict): Criterion {
29
+ return criterion(name, (context) => {
30
+ if (!context.specText || !context.specPath) return { ok: true, detail: NO_SPEC_SKIP };
31
+ return evaluate(context);
32
+ });
33
+ }
34
+
35
+ export function specGate(): Criterion {
36
+ return specCriterion(SPEC_GATE, (context) => runCommand(context.root, ["bash", SPEC_GATE_SCRIPT, "--file", context.specPath ?? ""]));
37
+ }
38
+
39
+ export function openQuestions(): Criterion {
40
+ return specCriterion(SPEC_OPEN_QUESTIONS, (context) => {
41
+ const hits = specLines(context).filter((line) => line.includes(OPEN_QUESTION));
42
+ return { ok: hits.length === 0, detail: hits.length === 0 ? "no open questions" : `${hits.length} line(s) still carry ${OPEN_QUESTION}` };
43
+ });
44
+ }
45
+
46
+ export function productClarity(): Criterion {
47
+ return specCriterion(SPEC_PRODUCT_CLARITY, (context) => {
48
+ const present = specLines(context).some((line) => line.startsWith(PRODUCT_CLARITY_HEADING));
49
+ return { ok: present, detail: present ? "section present" : `missing ${PRODUCT_CLARITY_HEADING}` };
50
+ });
51
+ }
52
+
53
+ export function specDimensions(): Criterion {
54
+ return specCriterion(SPEC_DIMENSIONS, (context) => {
55
+ const dimensions = specLines(context).filter((line) => line.trimStart().startsWith(DIMENSION_PREFIX));
56
+ const open = dimensions.filter((line) => !line.includes(DONE_MARKER));
57
+ return {
58
+ ok: open.length === 0,
59
+ detail: open.length === 0
60
+ ? `${dimensions.length} dimension(s) marked ${DONE_MARKER}`
61
+ : `${open.length} of ${dimensions.length} not ${DONE_MARKER}: ${dimensionLabels(open)}`,
62
+ };
63
+ });
64
+ }
65
+
66
+ // Closed-spec follow-through (the CHORE(close) blind spot): Status: DONE must
67
+ // mean the spec actually sits under done/ AND was moved there on this branch.
68
+ export function specMoved(): Criterion {
69
+ return specCriterion(SPEC_MOVED, (context) => {
70
+ if (!statusDone(context)) return { ok: true, detail: "Status not DONE — the move is not yet due" };
71
+ if (!context.specClosed) return { ok: false, detail: "Status: DONE but the spec still lives under active/ — CHORE(close) moves it to done/ before the PR" };
72
+ const moved = branchDiff(context.root).includes(context.specPath ?? "");
73
+ return moved
74
+ ? { ok: true, detail: "moved to done/ on this branch" }
75
+ : { ok: false, detail: "spec sits under done/ but this branch never moved it" };
76
+ });
77
+ }
78
+
79
+ export function specBaseline(): Criterion {
80
+ return specCriterion(SPEC_BASELINE, (context) => {
81
+ const present = specLines(context).some((line) => line.includes(BASELINE_HEADER));
82
+ return present
83
+ ? { ok: true, detail: "Test Baseline recorded" }
84
+ : { ok: false, detail: `no \`${BASELINE_HEADER}\` line in the spec header — CHORE(open) records it before any code` };
85
+ });
86
+ }
87
+
88
+ // "No code until the 4 steps committed": the branch's oldest commit must
89
+ // carry a spec file. A first commit of pure code means CHORE(open) never ran.
90
+ export function specOrdering(): Criterion {
91
+ return specCriterion(SPEC_ORDERING, (context) => {
92
+ const base = defaultMergeBase(context.root);
93
+ if (!base) return { ok: true, detail: "no merge base — ordering unknowable here" };
94
+ const first = gitOutput(context.root, ["log", "--reverse", "--format=%H", `${base}..HEAD`]).split(/\r?\n/)[0] ?? "";
95
+ if (!first) return { ok: true, detail: "no branch commits yet" };
96
+ const files = gitOutput(context.root, ["show", "--name-only", "--format=", first]).split(/\r?\n/).filter(Boolean);
97
+ const carriesSpec = files.some((file) => SPEC_TREE_FILE.test(file));
98
+ return carriesSpec
99
+ ? { ok: true, detail: `first branch commit ${first.slice(0, 8)} carries the spec` }
100
+ : { ok: false, detail: `first branch commit ${first.slice(0, 8)} carries no spec file — CHORE(open)'s 4 steps commit before any code` };
101
+ });
102
+ }
103
+
104
+ // A "deferred to follow-up" claim requires the Indy-acked verbatim quote in
105
+ // the spec; agent-unilateral deferral is incomplete scope, not deferral.
106
+ export function specDeferrals(): Criterion {
107
+ return specCriterion(SPEC_DEFERRALS, (context) => {
108
+ const claims = specLines(context).filter((line) => DEFERRAL_CLAIM.test(line) && !line.includes(INDY_ACK));
109
+ if (claims.length === 0) return { ok: true, detail: "no deferral claims" };
110
+ const acked = specLines(context).some((line) => line.includes(INDY_ACK));
111
+ return acked
112
+ ? { ok: true, detail: `${claims.length} deferral line(s), Indy ack quote present` }
113
+ : { ok: false, detail: `${claims.length} deferral line(s) with no "${INDY_ACK}" ack quote — agent-unilateral deferral is incomplete scope` };
114
+ });
115
+ }
116
+
117
+ function statusDone(context: CriterionContext): boolean {
118
+ return specLines(context).some((line) => line.replaceAll("*", "").replace(/\s+/g, " ").includes(STATUS_DONE));
119
+ }
120
+
121
+ function specLines(context: CriterionContext): string[] {
122
+ return (context.specText ?? "").split(/\r?\n/);
123
+ }
124
+
125
+ function dimensionLabels(lines: string[]): string {
126
+ return lines.map((line) => line.trim().slice(DIMENSION_PREFIX.length).split("*")[0]?.trim() ?? "?").join(", ");
127
+ }
@@ -0,0 +1,53 @@
1
+ import { UNSCOPED_ENVIRONMENT } from "./git_env";
2
+ import { RulesModel } from "./model";
3
+ import { SurfaceReport } from "./surfaces";
4
+
5
+ const PIPE_OUTPUT = "pipe";
6
+ const NO_OUTPUT = "no output";
7
+
8
+ export type Verdict = { ok: boolean; detail: string };
9
+ export type CriterionResult = Verdict & { name: string };
10
+
11
+ export type CriterionContext = {
12
+ root: string;
13
+ // Absent when the branch has no spec at all: spec criteria then skip-pass —
14
+ // an ad-hoc bug fix meets quality gates, never a demand to write a spec.
15
+ // A spec closed to done/ on this branch is still discovered (Branch: match)
16
+ // and gates with specClosed set — closing never skips the criteria.
17
+ specPath?: string;
18
+ specText?: string;
19
+ specClosed?: boolean;
20
+ model: RulesModel;
21
+ acceptDirty: boolean;
22
+ surfaces?: SurfaceReport;
23
+ };
24
+
25
+ export type Criterion = { name: string; evaluate: (context: CriterionContext) => CriterionResult };
26
+
27
+ // The name is written once and stamped onto the verdict, so a criterion can
28
+ // never report under a name that disagrees with the one it was registered as.
29
+ export function criterion(name: string, evaluate: (context: CriterionContext) => Verdict): Criterion {
30
+ return { name, evaluate: (context) => ({ name, ...evaluate(context) }) };
31
+ }
32
+
33
+ export function runCommand(root: string, command: string[]): Verdict {
34
+ // Profile command surfaces are repository-declared, so a named binary may
35
+ // simply not be installed here. Bun.spawnSync THROWS in that case; a gate
36
+ // must report every criterion as a verdict, never abort the run, so the
37
+ // missing binary becomes a red line naming the command.
38
+ let result;
39
+ try {
40
+ result = Bun.spawnSync(command, { cwd: root, env: UNSCOPED_ENVIRONMENT, stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT });
41
+ } catch (error) {
42
+ return { ok: false, detail: `${command[0] ?? "command"} could not be run: ${error instanceof Error ? error.message : String(error)}` };
43
+ }
44
+ if (result.exitCode === 0) return { ok: true, detail: "exit 0" };
45
+ const merged = `${result.stdout.toString()}\n${result.stderr.toString()}`;
46
+ const lines = merged.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
47
+ return { ok: false, detail: `exit ${result.exitCode}: ${lines[lines.length - 1] ?? NO_OUTPUT}` };
48
+ }
49
+
50
+ export function gitOutput(root: string, command: string[]): string {
51
+ const result = Bun.spawnSync(["git", ...command], { cwd: root, env: UNSCOPED_ENVIRONMENT, stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT });
52
+ return result.exitCode === 0 ? result.stdout.toString().trim() : "";
53
+ }
@@ -0,0 +1,183 @@
1
+ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test";
2
+ import { join } from "node:path";
3
+
4
+ import { branchOverrides, recordOverride, runGate, runGates } from "./gates";
5
+ import {
6
+ cleanupTemporaryDirectories, fixtureRegistry, git, gitOutput, modelFor,
7
+ names, newRepository, newSpecRepository, orly, temporaryDirectory,
8
+ } from "./gates_test_support";
9
+
10
+ const GIT_FIXTURE_TIMEOUT_MS = 30_000;
11
+
12
+ // Every case creates real Git repositories, so wall time follows local process load.
13
+ setDefaultTimeout(GIT_FIXTURE_TIMEOUT_MS);
14
+ afterEach(cleanupTemporaryDirectories);
15
+
16
+ describe("gate groups", () => {
17
+ test("run in order and stop at the first red group", async () => {
18
+ const project = newSpecRepository();
19
+ const model = await modelFor(project);
20
+ // Default branch: the work gate is red, so verify and pr never run.
21
+ const reports = runGates(model, project);
22
+
23
+ expect(reports.map((report) => report.gate)).toEqual(["work"]);
24
+ expect(reports[0]?.ok).toBeFalse();
25
+ expect(reports[0]?.results.find((result) => result.name === "git.branch")?.ok).toBeFalse();
26
+ });
27
+
28
+ test("each gate evaluates exactly its declared criteria", async () => {
29
+ const project = newSpecRepository();
30
+ const model = await modelFor(project);
31
+
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", "spec.dimensions"]);
34
+ 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",
37
+ ]);
38
+ });
39
+
40
+ test("gates never write: the tree is byte-identical after a run", async () => {
41
+ const project = newSpecRepository();
42
+ const model = await modelFor(project);
43
+ const before = gitOutput(project, "status", "--porcelain=v1", "-uall");
44
+
45
+ runGates(model, project);
46
+
47
+ expect(gitOutput(project, "status", "--porcelain=v1", "-uall")).toBe(before);
48
+ });
49
+ });
50
+
51
+ describe("repository resolution", () => {
52
+ test("a linked worktree resolves the config its checkout committed", async () => {
53
+ const project = newSpecRepository();
54
+ const model = await modelFor(project);
55
+ const worktree = join(temporaryDirectory(), "stream");
56
+ git(project, "worktree", "add", "-q", "-b", "feat/stream", worktree);
57
+
58
+ const config = runGate(model, worktree, "work").results.find((result) => result.name === "repo.config");
59
+
60
+ expect(config?.ok).toBeTrue();
61
+ expect(config?.detail).toBe("2 command(s) declared");
62
+ });
63
+
64
+ test("the declared commands run in the worktree, not in the main checkout", async () => {
65
+ const project = newSpecRepository();
66
+ const model = await modelFor(project, undefined, { conform: [["test", "-f", "stream-only.txt"]] });
67
+ const worktree = join(temporaryDirectory(), "stream");
68
+ git(project, "worktree", "add", "-q", "-b", "feat/stream", worktree);
69
+ await Bun.write(join(worktree, "stream-only.txt"), "present only in the worktree\n");
70
+
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();
73
+ });
74
+
75
+ test("a repository with no orly config is red, and names the install command", async () => {
76
+ const project = newSpecRepository();
77
+ const model = await modelFor(project);
78
+ const stranger = newRepository();
79
+
80
+ const config = runGate(model, stranger, "work").results.find((result) => result.name === "repo.config");
81
+
82
+ expect(config?.ok).toBeFalse();
83
+ expect(config?.detail).toContain("orly init");
84
+ });
85
+ });
86
+
87
+ describe("overrides", () => {
88
+ test("a trailer satisfies its criterion as overridden, never green", async () => {
89
+ const project = newSpecRepository();
90
+ const model = await modelFor(project, { user: ["src/"], docs: ["docs/pages/"] });
91
+ git(project, "checkout", "-q", "-b", "feat/fixture");
92
+ await Bun.write(join(project, "src/handler.ts"), "export const HANDLER = 1;\n");
93
+ git(project, "add", ".");
94
+ git(project, "commit", "-q", "-m", "feat: change a user surface");
95
+
96
+ expect(runGate(model, project, "pr").results.find((result) => result.name === "docs.updated")?.ok).toBeFalse();
97
+
98
+ recordOverride(project, "docs.updated", "internal rename, no page exists");
99
+ const overridden = runGate(model, project, "pr").results.find((result) => result.name === "docs.updated");
100
+ expect(overridden?.ok).toBeTrue();
101
+ expect(overridden?.detail).toStartWith("overridden (internal rename, no page exists)");
102
+ expect(branchOverrides(project)).toEqual([{ criterion: "docs.updated", reason: "internal rename, no page exists" }]);
103
+ });
104
+
105
+ test("an empty reason is refused and no commit is created", () => {
106
+ const project = newSpecRepository();
107
+ git(project, "checkout", "-q", "-b", "feat/fixture");
108
+ const head = gitOutput(project, "rev-parse", "HEAD");
109
+
110
+ expect(() => recordOverride(project, "docs.updated", " ")).toThrow("not a record");
111
+ expect(gitOutput(project, "rev-parse", "HEAD")).toBe(head);
112
+ });
113
+
114
+ test("a malformed hand-written trailer is not an override", async () => {
115
+ const project = newSpecRepository();
116
+ const model = await modelFor(project, { user: ["src/"], docs: ["docs/pages/"] });
117
+ git(project, "checkout", "-q", "-b", "feat/fixture");
118
+ await Bun.write(join(project, "src/handler.ts"), "export const HANDLER = 1;\n");
119
+ git(project, "add", ".");
120
+ git(project, "commit", "-q", "-m", "feat: surface change\n\nOrly-Override: docs.updated no parentheses here");
121
+
122
+ expect(branchOverrides(project)).toEqual([]);
123
+ expect(runGate(model, project, "pr").results.find((result) => result.name === "docs.updated")?.ok).toBeFalse();
124
+ });
125
+ });
126
+
127
+ describe("docs and tiered suites", () => {
128
+ test("docs.updated goes green when a docs page changes on the branch", async () => {
129
+ const project = newSpecRepository();
130
+ const model = await modelFor(project, { user: ["src/"], docs: ["docs/pages/"] });
131
+ git(project, "checkout", "-q", "-b", "feat/fixture");
132
+ await Bun.write(join(project, "src/handler.ts"), "export const HANDLER = 1;\n");
133
+ await Bun.write(join(project, "docs/pages/handler.md"), "# Handler\n");
134
+ git(project, "add", ".");
135
+ git(project, "commit", "-q", "-m", "feat: surface change with docs");
136
+
137
+ expect(runGate(model, project, "pr").results.find((result) => result.name === "docs.updated")?.ok).toBeTrue();
138
+ });
139
+
140
+ test("slow suites run on code branches and skip on prose-only branches", async () => {
141
+ const project = newSpecRepository();
142
+ const model = await modelFor(project, { user: [], docs: [] }, { "verify.integration": [["false"]] });
143
+ git(project, "checkout", "-q", "-b", "feat/fixture");
144
+ await Bun.write(join(project, "notes.md"), "prose only\n");
145
+ git(project, "add", ".");
146
+ git(project, "commit", "-q", "-m", "docs: prose only");
147
+
148
+ const skipped = runGate(model, project, "pr").results.find((result) => result.name === "cmd.verify.integration");
149
+ expect(skipped?.ok).toBeTrue();
150
+ expect(skipped?.detail).toContain("skipped — no code files");
151
+
152
+ await Bun.write(join(project, "src/thing.ts"), "export const THING = 1;\n");
153
+ git(project, "add", ".");
154
+ git(project, "commit", "-q", "-m", "feat: add code");
155
+ const ran = runGate(model, project, "pr").results.find((result) => result.name === "cmd.verify.integration");
156
+ expect(ran?.ok).toBeFalse();
157
+ expect(ran?.detail).toContain("false -> exit 1");
158
+ });
159
+ });
160
+
161
+ describe("end to end", () => {
162
+ test("gate walk through the real command-line entry", async () => {
163
+ const project = newSpecRepository();
164
+ const registry = fixtureRegistry(project);
165
+ const upstream = temporaryDirectory();
166
+ git(upstream, "init", "--bare", "-q");
167
+
168
+ // Red on the default branch, at the work gate.
169
+ let result = orly(project, registry, "gate");
170
+ expect(result.code).toBe(1);
171
+ expect(result.output).toContain("🔴 git.branch");
172
+
173
+ git(project, "checkout", "-q", "-b", "feat/fixture");
174
+ git(project, "remote", "add", "origin", upstream);
175
+ git(project, "push", "-q", "-u", "origin", "feat/fixture");
176
+
177
+ result = orly(project, registry, "gate");
178
+ expect(`${result.output}`).toContain("PR boundary open");
179
+ expect(result.code).toBe(0);
180
+ expect(orly(project, registry, "gate", "pr").code).toBe(0);
181
+ expect(orly(project, registry, "gate", "nope").code).not.toBe(0);
182
+ });
183
+ });
package/src/gates.ts ADDED
@@ -0,0 +1,169 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+
4
+ import { criteriaFor, CriterionContext, CriterionResult } from "./criteria";
5
+ import { UNSCOPED_ENVIRONMENT } from "./git_env";
6
+ import { OrlyError, RulesModel } from "./model";
7
+ import { defaultMergeBase } from "./surfaces";
8
+
9
+ export const GATE_ORDER = ["work", "verify", "pr"] as const;
10
+ export type GateName = (typeof GATE_ORDER)[number];
11
+
12
+ const PIPE_OUTPUT = "pipe";
13
+ const GIT = "git";
14
+ const DOCS_DIRECTORY = "docs";
15
+ const ACTIVE_DIRECTORY = "active";
16
+ const DONE_DIRECTORY = "done";
17
+ const BRANCH_HEADER = "Branch:";
18
+ const DEFAULT_BRANCHES = ["main", "master"];
19
+ const UTF8 = "utf8";
20
+ // Accept every real layout: docs/v1/, docs/v2/, docs/v0.9.2/ — cache-kit
21
+ // versions its spec tree by release, not by prototype integer.
22
+ const PROTOTYPE_PATTERN = /^v\d+(\.\d+)*$/;
23
+ const MARKDOWN_EXTENSION = ".md";
24
+ const NEWLINE = "\n";
25
+ // Strict trailer shape: "Orly-Override: <criterion> (<reason>)". A trailer
26
+ // that does not parse is not an override — the gate stays red rather than
27
+ // guessing what a malformed waiver meant.
28
+ const OVERRIDE_TRAILER = /^Orly-Override:\s*([a-z.]+[a-z])\s*\((.+)\)\s*$/;
29
+ const OVERRIDE_PREFIX = "Orly-Override:";
30
+ const OVERRIDDEN_DETAIL = "overridden";
31
+
32
+ export type GateReport = {
33
+ gate: GateName;
34
+ results: CriterionResult[];
35
+ ok: boolean;
36
+ };
37
+
38
+ export type Override = { criterion: string; reason: string };
39
+
40
+ // Run one gate: evaluate its criteria fresh from git + the working tree.
41
+ // A red criterion with a matching Orly-Override trailer on the branch is
42
+ // reported as overridden — satisfied, but never plain green.
43
+ export function runGate(model: RulesModel, root: string, gate: GateName, acceptDirty = false): GateReport {
44
+ const context = gateContext(model, root, acceptDirty);
45
+ const overrides = branchOverrides(root);
46
+ const results = criteriaFor(gate, context).map((criterion) => {
47
+ const result = criterion.evaluate(context);
48
+ if (result.ok) return result;
49
+ const override = overrides.find((entry) => entry.criterion === result.name);
50
+ if (!override) return result;
51
+ return { name: result.name, ok: true, detail: `${OVERRIDDEN_DETAIL} (${override.reason}) — ${result.detail}` };
52
+ });
53
+ return { gate, results, ok: results.every((result) => result.ok) };
54
+ }
55
+
56
+ // Run gates in order, stopping at the first red group: later gates are noise
57
+ // until the earlier boundary holds, and slow suites should not run on a
58
+ // branch that is not even clean.
59
+ export function runGates(model: RulesModel, root: string, acceptDirty = false): GateReport[] {
60
+ const reports: GateReport[] = [];
61
+ for (const gate of GATE_ORDER) {
62
+ const report = runGate(model, root, gate, acceptDirty);
63
+ reports.push(report);
64
+ if (!report.ok) break;
65
+ }
66
+ return reports;
67
+ }
68
+
69
+ export function isGateName(value: string): value is GateName {
70
+ return (GATE_ORDER as readonly string[]).includes(value);
71
+ }
72
+
73
+ // The override is an empty commit carrying a strict trailer: immutable once
74
+ // pushed, visible in the Pull Request, dead with the branch.
75
+ export function recordOverride(root: string, criterion: string, reason: string): string {
76
+ const trimmed = reason.trim();
77
+ if (!trimmed) throw new OrlyError("an override without a reason is not a record");
78
+ const message = `override: ${criterion}\n\n${OVERRIDE_PREFIX} ${criterion} (${trimmed.replaceAll(")", "]")})`;
79
+ const result = Bun.spawnSync([GIT, "commit", "--allow-empty", "-m", message], { cwd: root, env: UNSCOPED_ENVIRONMENT, stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT });
80
+ if (result.exitCode !== 0) throw new OrlyError(`could not record the override commit: ${result.stderr.toString().trim()}`);
81
+ return message;
82
+ }
83
+
84
+ // Overrides live in merge-base..HEAD commit bodies, so they are scoped to the
85
+ // branch and cannot leak past the merge.
86
+ export function branchOverrides(root: string): Override[] {
87
+ const base = defaultMergeBase(root);
88
+ if (!base) return [];
89
+ const overrides: Override[] = [];
90
+ for (const body of gitOutput(root, ["log", "--format=%B%x00", `${base}..HEAD`]).split("\0")) {
91
+ for (const line of body.split(/\r?\n/)) {
92
+ const match = line.trim().match(OVERRIDE_TRAILER);
93
+ if (match?.[1] && match[2]) overrides.push({ criterion: match[1], reason: match[2].trim() });
94
+ }
95
+ }
96
+ return overrides;
97
+ }
98
+
99
+ export function activeSpecPath(root: string): string | undefined {
100
+ const specs = specPathsUnder(root, ACTIVE_DIRECTORY);
101
+ if (specs.length === 0) return undefined;
102
+ if (specs.length > 1) throw new OrlyError(`more than one active spec — one stream per worktree:${NEWLINE}${specs.join(NEWLINE)}`);
103
+ return specs[0];
104
+ }
105
+
106
+ // Closed-spec follow-through: CHORE(close) moves the spec to done/, and the
107
+ // gate must keep proving it — a close that made the criteria skip-pass would
108
+ // be end-state theater. Discovery matches done/ specs whose Branch: header
109
+ // names the current branch; default branches never match a stream's spec.
110
+ export function closedSpecPath(root: string): string | undefined {
111
+ const branch = gitOutput(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
112
+ if (!branch || DEFAULT_BRANCHES.includes(branch)) return undefined;
113
+ const specs = specPathsUnder(root, DONE_DIRECTORY).filter((path) => branchNamed(path, branch));
114
+ if (specs.length === 0) return undefined;
115
+ if (specs.length > 1) throw new OrlyError(`more than one done/ spec names branch ${branch} — one stream per worktree:${NEWLINE}${specs.join(NEWLINE)}`);
116
+ return specs[0];
117
+ }
118
+
119
+ // An in-flight (active/) spec wins; otherwise the branch's closed spec gates.
120
+ export function specPathFor(root: string): { path: string; closed: boolean } | undefined {
121
+ const active = activeSpecPath(root);
122
+ if (active) return { path: active, closed: false };
123
+ const closed = closedSpecPath(root);
124
+ return closed ? { path: closed, closed: true } : undefined;
125
+ }
126
+
127
+ function branchNamed(path: string, branch: string): boolean {
128
+ try {
129
+ return readFileSync(path, UTF8).split(/\r?\n/).some((line) => line.includes(BRANCH_HEADER) && line.includes(branch));
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ function gateContext(model: RulesModel, root: string, acceptDirty: boolean): CriterionContext {
136
+ const spec = specPathFor(root);
137
+ const context: CriterionContext = { root, model, acceptDirty };
138
+ if (spec) {
139
+ context.specPath = relative(root, spec.path);
140
+ context.specText = specTextSync(spec.path);
141
+ context.specClosed = spec.closed;
142
+ }
143
+ return context;
144
+ }
145
+
146
+ function specTextSync(path: string): string {
147
+ try {
148
+ return readFileSync(path, UTF8);
149
+ } catch {
150
+ throw new OrlyError(`cannot read the active spec: ${path}`);
151
+ }
152
+ }
153
+
154
+ function specPathsUnder(root: string, stage: string): string[] {
155
+ const docs = join(root, DOCS_DIRECTORY);
156
+ if (!existsSync(docs)) return [];
157
+ const found: string[] = [];
158
+ for (const prototype of readdirSync(docs).filter((name) => PROTOTYPE_PATTERN.test(name)).sort()) {
159
+ const directory = join(docs, prototype, stage);
160
+ if (!existsSync(directory)) continue;
161
+ for (const file of readdirSync(directory).filter((name) => name.endsWith(MARKDOWN_EXTENSION)).sort()) found.push(join(directory, file));
162
+ }
163
+ return found;
164
+ }
165
+
166
+ function gitOutput(root: string, command: string[]): string {
167
+ const result = Bun.spawnSync([GIT, ...command], { cwd: root, env: UNSCOPED_ENVIRONMENT, stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT });
168
+ return result.exitCode === 0 ? result.stdout.toString().trim() : "";
169
+ }
@@ -0,0 +1,98 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ import { activeSpecPath, runGate } from "./gates";
6
+ import {
7
+ cleanupTemporaryDirectories, closedSpecRepository, git, modelFor,
8
+ newRepository, newSpecRepository, SPEC_RELATIVE, specFixture,
9
+ } from "./gates_test_support";
10
+
11
+ afterEach(cleanupTemporaryDirectories);
12
+
13
+ describe("spec discovery", () => {
14
+ test("no spec tree: spec criteria skip and quality gates still run", async () => {
15
+ const project = newRepository();
16
+ const model = await modelFor(project);
17
+ git(project, "checkout", "-q", "-b", "fix/adhoc");
18
+
19
+ const verify = runGate(model, project, "verify");
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();
22
+ });
23
+
24
+ test("cache-kit style docs/v0.9.2/ layout is discovered", () => {
25
+ const project = newRepository();
26
+ mkdirSync(join(project, "docs/v0.9.2/active"), { recursive: true });
27
+ Bun.write(join(project, "docs/v0.9.2/active/M05_001_P2_CLI_X.md"), specFixture());
28
+
29
+ expect(activeSpecPath(project)).toContain("docs/v0.9.2/active");
30
+ });
31
+
32
+ test("two active specs are a hard error — one stream per worktree", () => {
33
+ const project = newSpecRepository();
34
+ mkdirSync(join(project, "docs/v2/active"), { recursive: true });
35
+ Bun.write(join(project, "docs/v2/active/M02_001_P2_CLI_Y.md"), specFixture());
36
+
37
+ expect(() => activeSpecPath(project)).toThrow("one stream per worktree");
38
+ });
39
+ });
40
+
41
+ describe("closed-spec follow-through", () => {
42
+ test("a spec closed to done/ is discovered by its Branch: header and still gated", async () => {
43
+ const project = closedSpecRepository("feat/closed");
44
+ const model = await modelFor(project);
45
+
46
+ const pr = runGate(model, project, "pr");
47
+ expect(pr.results.find((result) => result.name === "spec.dimensions")?.detail).not.toContain("no active spec");
48
+ expect(pr.results.find((result) => result.name === "spec.moved")?.ok).toBeTrue();
49
+ expect(pr.results.find((result) => result.name === "spec.ordering")?.ok).toBeTrue();
50
+ expect(pr.results.find((result) => result.name === "spec.baseline")?.ok).toBeTrue();
51
+ });
52
+
53
+ test("Status: DONE while the spec still lives under active/ is red on spec.moved", async () => {
54
+ const project = newRepository();
55
+ const model = await modelFor(project);
56
+ git(project, "checkout", "-q", "-b", "feat/undone");
57
+ mkdirSync(join(project, "docs/v1/active"), { recursive: true });
58
+ await Bun.write(join(project, SPEC_RELATIVE), specFixture("DONE", "feat/undone"));
59
+ git(project, "add", ".");
60
+ git(project, "commit", "-q", "-m", "chore: spec says done but never moved");
61
+
62
+ const moved = runGate(model, project, "pr").results.find((result) => result.name === "spec.moved");
63
+ expect(moved?.ok).toBeFalse();
64
+ expect(moved?.detail).toContain("still lives under active/");
65
+ });
66
+
67
+ test("code committed before the spec is red on spec.ordering", async () => {
68
+ const project = newRepository();
69
+ const model = await modelFor(project);
70
+ git(project, "checkout", "-q", "-b", "feat/rush");
71
+ await Bun.write(join(project, "src/rushed.ts"), "export const RUSHED = 1;\n");
72
+ git(project, "add", ".");
73
+ git(project, "commit", "-q", "-m", "feat: code before any spec");
74
+ mkdirSync(join(project, "docs/v1/active"), { recursive: true });
75
+ await Bun.write(join(project, SPEC_RELATIVE), specFixture("IN_PROGRESS", "feat/rush"));
76
+ git(project, "add", ".");
77
+ git(project, "commit", "-q", "-m", "chore: spec arrives late");
78
+
79
+ const ordering = runGate(model, project, "pr").results.find((result) => result.name === "spec.ordering");
80
+ expect(ordering?.ok).toBeFalse();
81
+ expect(ordering?.detail).toContain("carries no spec file");
82
+ });
83
+
84
+ test("a deferral claim needs the Indy ack quote", async () => {
85
+ const bare = closedSpecRepository("feat/defer", ["- Dimension 1.2 was deferred to follow-up"]);
86
+ const bareModel = await modelFor(bare);
87
+ const red = runGate(bareModel, bare, "pr").results.find((result) => result.name === "spec.deferrals");
88
+ expect(red?.ok).toBeFalse();
89
+ expect(red?.detail).toContain("agent-unilateral");
90
+
91
+ const acked = closedSpecRepository("feat/defer", [
92
+ "- Dimension 1.2 was deferred to follow-up",
93
+ '> Indy (2026-08-11 09:00): "defer 1.2, ship the rest" — context: fixture',
94
+ ]);
95
+ const ackedModel = await modelFor(acked);
96
+ expect(runGate(ackedModel, acked, "pr").results.find((result) => result.name === "spec.deferrals")?.ok).toBeTrue();
97
+ });
98
+ });