Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .oracle/orly.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"orly_version": "0.9.1",
"orly_version": "0.9.2",
"packs": [
"universal.authoring",
"language.zig",
Expand Down
7 changes: 5 additions & 2 deletions dispatch/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,11 @@ version-sync check passes where defined; branch contains `origin/main` HEAD
**`orly gate pr` follows the spec through the close.** A spec moved to `done/`
on this branch is still discovered — its `Branch:` header names the branch —
and every spec criterion runs against it; skip-pass is only for genuinely
spec-less branches. Mechanical criteria: `spec.moved` (`Status: DONE` ⇒ the
spec sits under `done/` and was moved on this branch) · `spec.baseline`
spec-less branches. A deliberately folded spec retains the exact branch header
and adds `**Folded-into:** \`M178_001\`` naming the owning spec. Discovery
validates that relation and gates the sole non-folded owner; two non-folded
specs on one branch remain a hard error. Mechanical criteria: `spec.moved`
(`Status: DONE` ⇒ the spec sits under `done/` and was moved on this branch) · `spec.baseline`
(`Test Baseline:` recorded) · `spec.ordering` (the branch's first commit
carries the spec — no code before CHORE(open)) · `spec.deferrals` (a deferral
claim needs the `> Indy (` ack quote in the spec). An Indy-acked deferral that
Expand Down
7 changes: 1 addition & 6 deletions docs/REST_API_DESIGN_GUIDELINES.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ Run this checklist as part of `CHORE(close)` (per `~/.claude/CLAUDE.md` lifecycl
- [ ] **Versioning** — added/renamed/removed surface listed in PR description; deprecation uses `Deprecation` + `Sunset` headers; new response fields declare `x-stability` (§9)
- [ ] **Tests** — happy path + one error per `hx.fail` + idempotency double-PATCH + `Idempotency-Key` replay (where applicable) + ETag mismatch (§10)
- [ ] **Logging** — sensitive ID values are DEBUG-only or carry `// log-id-allowed:` comment; secret-shaped fields are write-only or one-time-read (§11)
- [ ] **`make check-openapi` clean** — bundle in sync, redocly lint, error-schema + URL-shape checks pass (§6)
- [ ] **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)
- [ ] **No file over 350 lines** (§10)
- [ ] **`gitleaks detect` clean** (§10)
Expand Down Expand Up @@ -410,8 +409,6 @@ Don't invent other extensions without amending this doc.

## §6 — OpenAPI editing

**`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.

The source of truth lives under `public/openapi/`:

```
Expand All @@ -428,8 +425,7 @@ public/openapi/

1. Edit the relevant YAML under `public/openapi/paths/<tag>.yaml`.
2. Add / rename / remove the corresponding `match()` arm in `src/agentsfleetd/http/router.zig`.
3. Run `make check-openapi` — bundles YAML → JSON, runs Redocly lint, runs `check_openapi_errors.py`, runs `check_openapi_url_shape.py` (REST §1).
4. Commit YAML + bundled JSON + `router.zig` together. Splitting these across commits leaves CI red.
3. Commit YAML + bundled JSON + `router.zig` together. Splitting these across commits leaves CI red.

**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.

Expand Down Expand Up @@ -715,7 +711,6 @@ Before opening a PR touching any handler:
- [ ] POST endpoints accepting `Idempotency-Key` have a replay test: same key + same body → cached response; same key + different body → 4xx (§2)
- [ ] Mutable resources have an ETag/`If-Match` test: stale `If-Match` → 412 with current `etag` returned (§4)
- [ ] OpenAPI updated — endpoint definition, request/response schemas, error responses
- [ ] `make check-openapi` — bundle in sync, redocly lint, error-schema + URL-shape checks, router parity
- [ ] `gitleaks detect` clean
- [ ] No new file over 350 lines

Expand Down
4 changes: 2 additions & 2 deletions docs/RULE_ENFORCEMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ regenerates this file and fails on any difference. To update it, run
| Rule document | enforced by | judged | acknowledged | classified | trigger |
|---|---|--:|--:|--:|---|
| `docs/LOGGING_STANDARD.md` | `LOG` → `logging.sh` | 11 | 13 | 30/30 | mechanical |
| `docs/REST_API_DESIGN_GUIDELINES.md` | — | 0 | 0 | 0/74 | mechanical |
| `docs/REST_API_DESIGN_GUIDELINES.md` | — | 0 | 0 | 0/73 | mechanical |
| `docs/SCHEMA_CONVENTIONS.md` | — | 0 | 0 | 0/16 | mechanical |
| `docs/DOCUMENTATION_RULES.md` | — | 0 | 0 | 0/22 | latent |
| `docs/LIFECYCLE_PATTERNS.md` | — | 0 | 0 | 0/22 | mechanical |
| `docs/CHANGELOG_VOICE.md` | — | 0 | 0 | 0/3 | latent |
| `docs/VERIFY_TIERS.md` | — | 0 | 0 | 0/7 | uncited |
| `docs/greptile-learnings/RULES.md` | — | 0 | 0 | 0/114 | mechanical |
| **corpus** | | | | **30/288** | |
| **corpus** | | | | **30/287** | |

## How to read this

Expand Down
1 change: 1 addition & 0 deletions docs/TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ SPEC AUTHORING RULES (load-bearing — the one comment that survives):
**Categories:** {API | CLI | UI | SKILL | DOCS | OBS | INFRA — alphabetised, one or more}
**Batch:** B{1-4} — {parallel execution context}
**Branch:** {feat/mNN-name — added at CHORE(open)}
<!-- A deliberately folded workstream adds Folded-into metadata per dispatch/lifecycle.md. -->
**Test Baseline:** set at CHORE(open) — `unit=<N> integration=<M>` from the repository's declared `verify.*` commands (`.oracle/orly.json`)
**Depends on:** {M{N}_{NNN} (one-line reason), …}
**Provenance:** human-written | LLM-drafted ({model}, {date}) | agent-generated (pre-spec, {source doc})
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agentsfleet/orly",
"version": "0.9.1",
"version": "0.9.2",
"description": "Repository-scoped engineering harness: renders agent rules, materialises the gates that enforce them, and proves the PR boundary \u2014 for any coding agent.",
"license": "MIT",
"type": "module",
Expand Down
8 changes: 7 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ type CliResult = { exitCode: number; gate?: string; failedCriterion?: string };
const PASS_RESULT = "pass";
const NOT_REQUIRED_RESULT = "not-required";
const ACCEPT_DIRTY_FLAG = "--accept-dirty";
const HELP_FLAG = "--help";
const SHORT_HELP_FLAG = "-h";
const REASON_FLAG = "--reason";
const PIPE_OUTPUT = "pipe";
const PACKAGE_MANIFEST = "package.json";
Expand Down Expand Up @@ -59,7 +61,7 @@ async function execute(engineRoot: string, args: string[]): Promise<CliResult> {

async function run(model: RulesModel, args: string[]): Promise<CliResult> {
const [command, ...rest] = args;
if (!command || command === "--help" || command === "-h") {
if (!command || command === HELP_FLAG || command === SHORT_HELP_FLAG) {
printHelp();
return cliResult(command ? 0 : 1);
}
Expand All @@ -84,6 +86,10 @@ async function run(model: RulesModel, args: string[]): Promise<CliResult> {
// Read-only: run every gate in order (stop at the first red group), or one
// named gate. Nothing is ever written.
function gate(model: RulesModel, args: string[]): CliResult {
if (args.includes(HELP_FLAG) || args.includes(SHORT_HELP_FLAG)) {
printHelp();
return cliResult(0);
}
const acceptDirty = args.includes(ACCEPT_DIRTY_FLAG);
const named = args.find((argument) => !argument.startsWith("-"));
if (named !== undefined && !isGateName(named)) throw new OrlyError(`unknown gate: ${named} (work, verify, pr)`);
Expand Down
49 changes: 41 additions & 8 deletions src/gates.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";
import { basename, join, relative } from "node:path";

import { criteriaFor, CriterionContext, CriterionResult } from "./criteria";
import { UNSCOPED_ENVIRONMENT } from "./git_env";
Expand All @@ -14,14 +14,17 @@ const GIT = "git";
const DOCS_DIRECTORY = "docs";
const ACTIVE_DIRECTORY = "active";
const DONE_DIRECTORY = "done";
const BRANCH_HEADER = "Branch:";
const BRANCH_HEADER = "Branch";
const FOLDED_INTO_HEADER = "Folded-into";
const DEFAULT_BRANCHES = ["main", "master"];
const UTF8 = "utf8";
// Accept every real layout: docs/v1/, docs/v2/, docs/v0.9.2/ — cache-kit
// versions its spec tree by release, not by prototype integer.
const PROTOTYPE_PATTERN = /^v\d+(\.\d+)*$/;
const MARKDOWN_EXTENSION = ".md";
const NEWLINE = "\n";
const MARKDOWN_HEADER_TOKEN_PATTERN = /^\s*\*\*([^*]+):\*\*\s*`([^`]+)`/;
const SPEC_IDENTIFIER_PATTERN = /^(M\d+_\d+)(?:_|\.md$)/;
// Strict trailer shape: "Orly-Override: <criterion> (<reason>)". A trailer
// that does not parse is not an override — the gate stays red rather than
// guessing what a malformed waiver meant.
Expand All @@ -36,6 +39,12 @@ export type GateReport = {
};

export type Override = { criterion: string; reason: string };
type ClosedSpec = {
path: string;
identifier: string | undefined;
branch: string | undefined;
foldedInto: string | undefined;
};

// Run one gate: evaluate its criteria fresh from git + the working tree.
// A red criterion with a matching Orly-Override trailer on the branch is
Expand Down Expand Up @@ -110,10 +119,22 @@ export function activeSpecPath(root: string): string | undefined {
export function closedSpecPath(root: string): string | undefined {
const branch = gitOutput(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
if (!branch || DEFAULT_BRANCHES.includes(branch)) return undefined;
const specs = specPathsUnder(root, DONE_DIRECTORY).filter((path) => branchNamed(path, branch));
const specs = specPathsUnder(root, DONE_DIRECTORY)
.map(closedSpec)
.filter((spec): spec is ClosedSpec => spec !== undefined && spec.branch === branch);
if (specs.length === 0) return undefined;
if (specs.length > 1) throw new OrlyError(`more than one done/ spec names branch ${branch} — one stream per worktree:${NEWLINE}${specs.join(NEWLINE)}`);
return specs[0];
const owners = specs.filter((spec) => spec.foldedInto === undefined);
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)}`);
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)}`);
const owner = owners[0];
if (!owner) throw new OrlyError(`done/ spec ownership could not be resolved for branch ${branch}`);
const folded = specs.filter((spec) => spec.foldedInto !== undefined);
if (folded.length > 0 && !owner.identifier) throw new OrlyError(`the owning done/ spec for branch ${branch} has no milestone and workstream identifier: ${owner.path}`);
const selfFolds = folded.filter((spec) => spec.identifier === spec.foldedInto);
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)}`);
const invalidFolds = folded.filter((spec) => spec.foldedInto !== owner.identifier);
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)}`);
return owner.path;
}

// An in-flight (active/) spec wins; otherwise the branch's closed spec gates.
Expand All @@ -124,11 +145,23 @@ export function specPathFor(root: string): { path: string; closed: boolean } | u
return closed ? { path: closed, closed: true } : undefined;
}

function branchNamed(path: string, branch: string): boolean {
function closedSpec(path: string): ClosedSpec | undefined {
try {
return readFileSync(path, UTF8).split(/\r?\n/).some((line) => line.includes(BRANCH_HEADER) && line.includes(branch));
const metadata: ClosedSpec = {
path,
identifier: basename(path).match(SPEC_IDENTIFIER_PATTERN)?.[1],
branch: undefined,
foldedInto: undefined,
};
for (const line of readFileSync(path, UTF8).split(/\r?\n/)) {
if (line.startsWith("## ")) break;
const match = line.match(MARKDOWN_HEADER_TOKEN_PATTERN);
if (match?.[1] === BRANCH_HEADER) metadata.branch = match[2];
if (match?.[1] === FOLDED_INTO_HEADER) metadata.foldedInto = match[2];
}
return metadata;
} catch {
return false;
return undefined;
}
}

Expand Down
78 changes: 75 additions & 3 deletions src/gates_spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdirSync } from "node:fs";
import { join } from "node:path";

import { activeSpecPath, runGate } from "./gates";
import { activeSpecPath, closedSpecPath, runGate } from "./gates";
import {
cleanupTemporaryDirectories, closedSpecRepository, git, modelFor,
newRepository, newSpecRepository, SPEC_RELATIVE, specFixture,
cleanupTemporaryDirectories, closedSpecRepository, fixtureRegistry, git, modelFor,
newRepository, newSpecRepository, orly, SPEC_RELATIVE, specFixture,
} from "./gates_test_support";

afterEach(cleanupTemporaryDirectories);
Expand Down Expand Up @@ -39,6 +39,78 @@ describe("spec discovery", () => {
});

describe("closed-spec follow-through", () => {
test("two owning specs naming one branch remain a hard error", async () => {
const project = closedSpecRepository("feat/shared");
mkdirSync(join(project, "docs/v2/done"), { recursive: true });
await Bun.write(join(project, "docs/v2/done/M100_001_P2_CLI_SECOND.md"), specFixture("DONE", "feat/shared"));

expect(() => closedSpecPath(project)).toThrow("one stream per worktree");
});

test("a folded spec yields ownership to the spec it names", async () => {
const project = closedSpecRepository("feat/shared");
mkdirSync(join(project, "docs/v2/done"), { recursive: true });
await Bun.write(
join(project, "docs/v2/done/M100_001_P2_CLI_FOLDED.md"),
specFixture("DONE", "feat/shared", ["**Folded-into:** `M99_001`"]),
);

expect(closedSpecPath(project)).toEndWith("M99_001_P2_CLI_FIXTURE.md");
});

test("a folded spec must name the exact owner on its branch", async () => {
const project = closedSpecRepository("feat/shared");
mkdirSync(join(project, "docs/v2/done"), { recursive: true });
await Bun.write(
join(project, "docs/v2/done/M100_001_P2_CLI_FOLDED.md"),
specFixture("DONE", "feat/shared", ["**Folded-into:** `M404_001`"]),
);

expect(() => closedSpecPath(project)).toThrow("must name their owner M99_001");
});

test("a folded spec cannot name itself", async () => {
const project = closedSpecRepository("feat/shared");
mkdirSync(join(project, "docs/v2/done"), { recursive: true });
await Bun.write(
join(project, "docs/v2/done/M100_001_P2_CLI_FOLDED.md"),
specFixture("DONE", "feat/shared", ["**Folded-into:** `M100_001`"]),
);

expect(() => closedSpecPath(project)).toThrow("cannot fold into themselves");
});

test("a branch name is not a prefix match", async () => {
const project = newRepository();
git(project, "checkout", "-q", "-b", "feat/foo");
mkdirSync(join(project, "docs/v1/done"), { recursive: true });
await Bun.write(join(project, "docs/v1/done/M99_001_P2_CLI_OTHER.md"), specFixture("DONE", "feat/foo-2"));

expect(closedSpecPath(project)).toBeUndefined();
});

test("prose mentioning a branch does not declare ownership", async () => {
const project = newRepository();
git(project, "checkout", "-q", "-b", "feat/foo");
mkdirSync(join(project, "docs/v1/done"), { recursive: true });
await Bun.write(
join(project, "docs/v1/done/M99_001_P2_CLI_OTHER.md"),
specFixture("DONE", undefined, ["**Branch:** folded into `feat/foo` rather than taken as its own tree"]),
);

expect(closedSpecPath(project)).toBeUndefined();
});

test("gate help does not discover specs", async () => {
const project = closedSpecRepository("feat/shared");
mkdirSync(join(project, "docs/v2/done"), { recursive: true });
await Bun.write(join(project, "docs/v2/done/M100_001_P2_CLI_SECOND.md"), specFixture("DONE", "feat/shared"));

const result = orly(project, fixtureRegistry(project), "gate", "--help");
expect(result.code).toBe(0);
expect(result.output).toContain("orly gate");
});

test("a spec closed to done/ is discovered by its Branch: header and still gated", async () => {
const project = closedSpecRepository("feat/closed");
const model = await modelFor(project);
Expand Down
Loading