@agentsfleet/orly 0.9.1 → 0.9.2

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.
@@ -199,8 +199,11 @@ version-sync check passes where defined; branch contains `origin/main` HEAD
199
199
  **`orly gate pr` follows the spec through the close.** A spec moved to `done/`
200
200
  on this branch is still discovered — its `Branch:` header names the branch —
201
201
  and every spec criterion runs against it; skip-pass is only for genuinely
202
- spec-less branches. Mechanical criteria: `spec.moved` (`Status: DONE` ⇒ the
203
- spec sits under `done/` and was moved on this branch) · `spec.baseline`
202
+ spec-less branches. A deliberately folded spec retains the exact branch header
203
+ and adds `**Folded-into:** \`M178_001\`` naming the owning spec. Discovery
204
+ validates that relation and gates the sole non-folded owner; two non-folded
205
+ specs on one branch remain a hard error. Mechanical criteria: `spec.moved`
206
+ (`Status: DONE` ⇒ the spec sits under `done/` and was moved on this branch) · `spec.baseline`
204
207
  (`Test Baseline:` recorded) · `spec.ordering` (the branch's first commit
205
208
  carries the spec — no code before CHORE(open)) · `spec.deferrals` (a deferral
206
209
  claim needs the `> Indy (` ack quote in the spec). An Indy-acked deferral that
@@ -44,7 +44,6 @@ Run this checklist as part of `CHORE(close)` (per `~/.claude/CLAUDE.md` lifecycl
44
44
  - [ ] **Versioning** — added/renamed/removed surface listed in PR description; deprecation uses `Deprecation` + `Sunset` headers; new response fields declare `x-stability` (§9)
45
45
  - [ ] **Tests** — happy path + one error per `hx.fail` + idempotency double-PATCH + `Idempotency-Key` replay (where applicable) + ETag mismatch (§10)
46
46
  - [ ] **Logging** — sensitive ID values are DEBUG-only or carry `// log-id-allowed:` comment; secret-shaped fields are write-only or one-time-read (§11)
47
- - [ ] **`make check-openapi` clean** — bundle in sync, redocly lint, error-schema + URL-shape checks pass (§6)
48
47
  - [ ] **The repository's declared `verify.unit` command is clean** — the route-scope table's exhaustive match and its tests cover the auth gate matrix (§10)
49
48
  - [ ] **No file over 350 lines** (§10)
50
49
  - [ ] **`gitleaks detect` clean** (§10)
@@ -410,8 +409,6 @@ Don't invent other extensions without amending this doc.
410
409
 
411
410
  ## §6 — OpenAPI editing
412
411
 
413
- **`public/openapi.json` is a build artifact.** Never edit it directly. Edits get wiped on `make check-openapi` and CI's bundle-in-sync gate fails the PR.
414
-
415
412
  The source of truth lives under `public/openapi/`:
416
413
 
417
414
  ```
@@ -428,8 +425,7 @@ public/openapi/
428
425
 
429
426
  1. Edit the relevant YAML under `public/openapi/paths/<tag>.yaml`.
430
427
  2. Add / rename / remove the corresponding `match()` arm in `src/agentsfleetd/http/router.zig`.
431
- 3. Run `make check-openapi` — bundles YAML → JSON, runs Redocly lint, runs `check_openapi_errors.py`, runs `check_openapi_url_shape.py` (REST §1).
432
- 4. Commit YAML + bundled JSON + `router.zig` together. Splitting these across commits leaves CI red.
428
+ 3. Commit YAML + bundled JSON + `router.zig` together. Splitting these across commits leaves CI red.
433
429
 
434
430
  **Router ↔ openapi.json parity is reviewer-enforced.** There is no mechanical gate cross-checking that every `router.match()` arm has a documented openapi path or vice versa. When you add, rename, or remove a route, both surfaces must move in the same diff and the reviewer must verify it. The previous Python parity gate (`audits/check_openapi_sync.py`) and its data file (`route_manifest.zig`, deleted) were retired in M61_002.
435
431
 
@@ -715,7 +711,6 @@ Before opening a PR touching any handler:
715
711
  - [ ] POST endpoints accepting `Idempotency-Key` have a replay test: same key + same body → cached response; same key + different body → 4xx (§2)
716
712
  - [ ] Mutable resources have an ETag/`If-Match` test: stale `If-Match` → 412 with current `etag` returned (§4)
717
713
  - [ ] OpenAPI updated — endpoint definition, request/response schemas, error responses
718
- - [ ] `make check-openapi` — bundle in sync, redocly lint, error-schema + URL-shape checks, router parity
719
714
  - [ ] `gitleaks detect` clean
720
715
  - [ ] No new file over 350 lines
721
716
 
@@ -7,14 +7,14 @@ regenerates this file and fails on any difference. To update it, run
7
7
  | Rule document | enforced by | judged | acknowledged | classified | trigger |
8
8
  |---|---|--:|--:|--:|---|
9
9
  | `docs/LOGGING_STANDARD.md` | `LOG` → `logging.sh` | 11 | 13 | 30/30 | mechanical |
10
- | `docs/REST_API_DESIGN_GUIDELINES.md` | — | 0 | 0 | 0/74 | mechanical |
10
+ | `docs/REST_API_DESIGN_GUIDELINES.md` | — | 0 | 0 | 0/73 | mechanical |
11
11
  | `docs/SCHEMA_CONVENTIONS.md` | — | 0 | 0 | 0/16 | mechanical |
12
12
  | `docs/DOCUMENTATION_RULES.md` | — | 0 | 0 | 0/22 | latent |
13
13
  | `docs/LIFECYCLE_PATTERNS.md` | — | 0 | 0 | 0/22 | mechanical |
14
14
  | `docs/CHANGELOG_VOICE.md` | — | 0 | 0 | 0/3 | latent |
15
15
  | `docs/VERIFY_TIERS.md` | — | 0 | 0 | 0/7 | uncited |
16
16
  | `docs/greptile-learnings/RULES.md` | — | 0 | 0 | 0/114 | mechanical |
17
- | **corpus** | | | | **30/288** | |
17
+ | **corpus** | | | | **30/287** | |
18
18
 
19
19
  ## How to read this
20
20
 
package/docs/TEMPLATE.md CHANGED
@@ -100,6 +100,7 @@ SPEC AUTHORING RULES (load-bearing — the one comment that survives):
100
100
  **Categories:** {API | CLI | UI | SKILL | DOCS | OBS | INFRA — alphabetised, one or more}
101
101
  **Batch:** B{1-4} — {parallel execution context}
102
102
  **Branch:** {feat/mNN-name — added at CHORE(open)}
103
+ <!-- A deliberately folded workstream adds Folded-into metadata per dispatch/lifecycle.md. -->
103
104
  **Test Baseline:** set at CHORE(open) — `unit=<N> integration=<M>` from the repository's declared `verify.*` commands (`.oracle/orly.json`)
104
105
  **Depends on:** {M{N}_{NNN} (one-line reason), …}
105
106
  **Provenance:** human-written | LLM-drafted ({model}, {date}) | agent-generated (pre-spec, {source doc})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentsfleet/orly",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Repository-scoped engineering harness: renders agent rules, materialises the gates that enforce them, and proves the PR boundary \u2014 for any coding agent.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -15,6 +15,8 @@ type CliResult = { exitCode: number; gate?: string; failedCriterion?: string };
15
15
  const PASS_RESULT = "pass";
16
16
  const NOT_REQUIRED_RESULT = "not-required";
17
17
  const ACCEPT_DIRTY_FLAG = "--accept-dirty";
18
+ const HELP_FLAG = "--help";
19
+ const SHORT_HELP_FLAG = "-h";
18
20
  const REASON_FLAG = "--reason";
19
21
  const PIPE_OUTPUT = "pipe";
20
22
  const PACKAGE_MANIFEST = "package.json";
@@ -59,7 +61,7 @@ async function execute(engineRoot: string, args: string[]): Promise<CliResult> {
59
61
 
60
62
  async function run(model: RulesModel, args: string[]): Promise<CliResult> {
61
63
  const [command, ...rest] = args;
62
- if (!command || command === "--help" || command === "-h") {
64
+ if (!command || command === HELP_FLAG || command === SHORT_HELP_FLAG) {
63
65
  printHelp();
64
66
  return cliResult(command ? 0 : 1);
65
67
  }
@@ -84,6 +86,10 @@ async function run(model: RulesModel, args: string[]): Promise<CliResult> {
84
86
  // Read-only: run every gate in order (stop at the first red group), or one
85
87
  // named gate. Nothing is ever written.
86
88
  function gate(model: RulesModel, args: string[]): CliResult {
89
+ if (args.includes(HELP_FLAG) || args.includes(SHORT_HELP_FLAG)) {
90
+ printHelp();
91
+ return cliResult(0);
92
+ }
87
93
  const acceptDirty = args.includes(ACCEPT_DIRTY_FLAG);
88
94
  const named = args.find((argument) => !argument.startsWith("-"));
89
95
  if (named !== undefined && !isGateName(named)) throw new OrlyError(`unknown gate: ${named} (work, verify, pr)`);
package/src/gates.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
- import { join, relative } from "node:path";
2
+ import { basename, join, relative } from "node:path";
3
3
 
4
4
  import { criteriaFor, CriterionContext, CriterionResult } from "./criteria";
5
5
  import { UNSCOPED_ENVIRONMENT } from "./git_env";
@@ -14,7 +14,8 @@ const GIT = "git";
14
14
  const DOCS_DIRECTORY = "docs";
15
15
  const ACTIVE_DIRECTORY = "active";
16
16
  const DONE_DIRECTORY = "done";
17
- const BRANCH_HEADER = "Branch:";
17
+ const BRANCH_HEADER = "Branch";
18
+ const FOLDED_INTO_HEADER = "Folded-into";
18
19
  const DEFAULT_BRANCHES = ["main", "master"];
19
20
  const UTF8 = "utf8";
20
21
  // Accept every real layout: docs/v1/, docs/v2/, docs/v0.9.2/ — cache-kit
@@ -22,6 +23,8 @@ const UTF8 = "utf8";
22
23
  const PROTOTYPE_PATTERN = /^v\d+(\.\d+)*$/;
23
24
  const MARKDOWN_EXTENSION = ".md";
24
25
  const NEWLINE = "\n";
26
+ const MARKDOWN_HEADER_TOKEN_PATTERN = /^\s*\*\*([^*]+):\*\*\s*`([^`]+)`/;
27
+ const SPEC_IDENTIFIER_PATTERN = /^(M\d+_\d+)(?:_|\.md$)/;
25
28
  // Strict trailer shape: "Orly-Override: <criterion> (<reason>)". A trailer
26
29
  // that does not parse is not an override — the gate stays red rather than
27
30
  // guessing what a malformed waiver meant.
@@ -36,6 +39,12 @@ export type GateReport = {
36
39
  };
37
40
 
38
41
  export type Override = { criterion: string; reason: string };
42
+ type ClosedSpec = {
43
+ path: string;
44
+ identifier: string | undefined;
45
+ branch: string | undefined;
46
+ foldedInto: string | undefined;
47
+ };
39
48
 
40
49
  // Run one gate: evaluate its criteria fresh from git + the working tree.
41
50
  // A red criterion with a matching Orly-Override trailer on the branch is
@@ -110,10 +119,22 @@ export function activeSpecPath(root: string): string | undefined {
110
119
  export function closedSpecPath(root: string): string | undefined {
111
120
  const branch = gitOutput(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
112
121
  if (!branch || DEFAULT_BRANCHES.includes(branch)) return undefined;
113
- const specs = specPathsUnder(root, DONE_DIRECTORY).filter((path) => branchNamed(path, branch));
122
+ const specs = specPathsUnder(root, DONE_DIRECTORY)
123
+ .map(closedSpec)
124
+ .filter((spec): spec is ClosedSpec => spec !== undefined && spec.branch === branch);
114
125
  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];
126
+ const owners = specs.filter((spec) => spec.foldedInto === undefined);
127
+ if (owners.length === 0) throw new OrlyError(`every done/ spec naming branch ${branch} is folded — one owning stream is required:${NEWLINE}${specs.map((spec) => spec.path).join(NEWLINE)}`);
128
+ if (owners.length > 1) throw new OrlyError(`more than one done/ spec names branch ${branch} — one stream per worktree:${NEWLINE}${owners.map((spec) => spec.path).join(NEWLINE)}`);
129
+ const owner = owners[0];
130
+ if (!owner) throw new OrlyError(`done/ spec ownership could not be resolved for branch ${branch}`);
131
+ const folded = specs.filter((spec) => spec.foldedInto !== undefined);
132
+ if (folded.length > 0 && !owner.identifier) throw new OrlyError(`the owning done/ spec for branch ${branch} has no milestone and workstream identifier: ${owner.path}`);
133
+ const selfFolds = folded.filter((spec) => spec.identifier === spec.foldedInto);
134
+ if (selfFolds.length > 0) throw new OrlyError(`folded done/ specs naming branch ${branch} cannot fold into themselves:${NEWLINE}${selfFolds.map((spec) => spec.path).join(NEWLINE)}`);
135
+ const invalidFolds = folded.filter((spec) => spec.foldedInto !== owner.identifier);
136
+ if (invalidFolds.length > 0) throw new OrlyError(`folded done/ specs naming branch ${branch} must name their owner ${owner.identifier}:${NEWLINE}${invalidFolds.map((spec) => spec.path).join(NEWLINE)}`);
137
+ return owner.path;
117
138
  }
118
139
 
119
140
  // An in-flight (active/) spec wins; otherwise the branch's closed spec gates.
@@ -124,11 +145,23 @@ export function specPathFor(root: string): { path: string; closed: boolean } | u
124
145
  return closed ? { path: closed, closed: true } : undefined;
125
146
  }
126
147
 
127
- function branchNamed(path: string, branch: string): boolean {
148
+ function closedSpec(path: string): ClosedSpec | undefined {
128
149
  try {
129
- return readFileSync(path, UTF8).split(/\r?\n/).some((line) => line.includes(BRANCH_HEADER) && line.includes(branch));
150
+ const metadata: ClosedSpec = {
151
+ path,
152
+ identifier: basename(path).match(SPEC_IDENTIFIER_PATTERN)?.[1],
153
+ branch: undefined,
154
+ foldedInto: undefined,
155
+ };
156
+ for (const line of readFileSync(path, UTF8).split(/\r?\n/)) {
157
+ if (line.startsWith("## ")) break;
158
+ const match = line.match(MARKDOWN_HEADER_TOKEN_PATTERN);
159
+ if (match?.[1] === BRANCH_HEADER) metadata.branch = match[2];
160
+ if (match?.[1] === FOLDED_INTO_HEADER) metadata.foldedInto = match[2];
161
+ }
162
+ return metadata;
130
163
  } catch {
131
- return false;
164
+ return undefined;
132
165
  }
133
166
  }
134
167
 
@@ -2,10 +2,10 @@ import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { mkdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
- import { activeSpecPath, runGate } from "./gates";
5
+ import { activeSpecPath, closedSpecPath, runGate } from "./gates";
6
6
  import {
7
- cleanupTemporaryDirectories, closedSpecRepository, git, modelFor,
8
- newRepository, newSpecRepository, SPEC_RELATIVE, specFixture,
7
+ cleanupTemporaryDirectories, closedSpecRepository, fixtureRegistry, git, modelFor,
8
+ newRepository, newSpecRepository, orly, SPEC_RELATIVE, specFixture,
9
9
  } from "./gates_test_support";
10
10
 
11
11
  afterEach(cleanupTemporaryDirectories);
@@ -39,6 +39,78 @@ describe("spec discovery", () => {
39
39
  });
40
40
 
41
41
  describe("closed-spec follow-through", () => {
42
+ test("two owning specs naming one branch remain a hard error", async () => {
43
+ const project = closedSpecRepository("feat/shared");
44
+ mkdirSync(join(project, "docs/v2/done"), { recursive: true });
45
+ await Bun.write(join(project, "docs/v2/done/M100_001_P2_CLI_SECOND.md"), specFixture("DONE", "feat/shared"));
46
+
47
+ expect(() => closedSpecPath(project)).toThrow("one stream per worktree");
48
+ });
49
+
50
+ test("a folded spec yields ownership to the spec it names", async () => {
51
+ const project = closedSpecRepository("feat/shared");
52
+ mkdirSync(join(project, "docs/v2/done"), { recursive: true });
53
+ await Bun.write(
54
+ join(project, "docs/v2/done/M100_001_P2_CLI_FOLDED.md"),
55
+ specFixture("DONE", "feat/shared", ["**Folded-into:** `M99_001`"]),
56
+ );
57
+
58
+ expect(closedSpecPath(project)).toEndWith("M99_001_P2_CLI_FIXTURE.md");
59
+ });
60
+
61
+ test("a folded spec must name the exact owner on its branch", async () => {
62
+ const project = closedSpecRepository("feat/shared");
63
+ mkdirSync(join(project, "docs/v2/done"), { recursive: true });
64
+ await Bun.write(
65
+ join(project, "docs/v2/done/M100_001_P2_CLI_FOLDED.md"),
66
+ specFixture("DONE", "feat/shared", ["**Folded-into:** `M404_001`"]),
67
+ );
68
+
69
+ expect(() => closedSpecPath(project)).toThrow("must name their owner M99_001");
70
+ });
71
+
72
+ test("a folded spec cannot name itself", async () => {
73
+ const project = closedSpecRepository("feat/shared");
74
+ mkdirSync(join(project, "docs/v2/done"), { recursive: true });
75
+ await Bun.write(
76
+ join(project, "docs/v2/done/M100_001_P2_CLI_FOLDED.md"),
77
+ specFixture("DONE", "feat/shared", ["**Folded-into:** `M100_001`"]),
78
+ );
79
+
80
+ expect(() => closedSpecPath(project)).toThrow("cannot fold into themselves");
81
+ });
82
+
83
+ test("a branch name is not a prefix match", async () => {
84
+ const project = newRepository();
85
+ git(project, "checkout", "-q", "-b", "feat/foo");
86
+ mkdirSync(join(project, "docs/v1/done"), { recursive: true });
87
+ await Bun.write(join(project, "docs/v1/done/M99_001_P2_CLI_OTHER.md"), specFixture("DONE", "feat/foo-2"));
88
+
89
+ expect(closedSpecPath(project)).toBeUndefined();
90
+ });
91
+
92
+ test("prose mentioning a branch does not declare ownership", async () => {
93
+ const project = newRepository();
94
+ git(project, "checkout", "-q", "-b", "feat/foo");
95
+ mkdirSync(join(project, "docs/v1/done"), { recursive: true });
96
+ await Bun.write(
97
+ join(project, "docs/v1/done/M99_001_P2_CLI_OTHER.md"),
98
+ specFixture("DONE", undefined, ["**Branch:** folded into `feat/foo` rather than taken as its own tree"]),
99
+ );
100
+
101
+ expect(closedSpecPath(project)).toBeUndefined();
102
+ });
103
+
104
+ test("gate help does not discover specs", async () => {
105
+ const project = closedSpecRepository("feat/shared");
106
+ mkdirSync(join(project, "docs/v2/done"), { recursive: true });
107
+ await Bun.write(join(project, "docs/v2/done/M100_001_P2_CLI_SECOND.md"), specFixture("DONE", "feat/shared"));
108
+
109
+ const result = orly(project, fixtureRegistry(project), "gate", "--help");
110
+ expect(result.code).toBe(0);
111
+ expect(result.output).toContain("orly gate");
112
+ });
113
+
42
114
  test("a spec closed to done/ is discovered by its Branch: header and still gated", async () => {
43
115
  const project = closedSpecRepository("feat/closed");
44
116
  const model = await modelFor(project);