diff --git a/.oracle/orly.json b/.oracle/orly.json index daa5aaa..45d7b70 100644 --- a/.oracle/orly.json +++ b/.oracle/orly.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "orly_version": "0.9.1", + "orly_version": "0.9.2", "packs": [ "universal.authoring", "language.zig", diff --git a/dispatch/lifecycle.md b/dispatch/lifecycle.md index cbec2d9..4cb4f51 100644 --- a/dispatch/lifecycle.md +++ b/dispatch/lifecycle.md @@ -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 diff --git a/docs/REST_API_DESIGN_GUIDELINES.md b/docs/REST_API_DESIGN_GUIDELINES.md index 954bba6..8cfa321 100644 --- a/docs/REST_API_DESIGN_GUIDELINES.md +++ b/docs/REST_API_DESIGN_GUIDELINES.md @@ -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) @@ -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/`: ``` @@ -428,8 +425,7 @@ public/openapi/ 1. Edit the relevant YAML under `public/openapi/paths/.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. @@ -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 diff --git a/docs/RULE_ENFORCEMENT.md b/docs/RULE_ENFORCEMENT.md index 173bc95..7a7c778 100644 --- a/docs/RULE_ENFORCEMENT.md +++ b/docs/RULE_ENFORCEMENT.md @@ -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 diff --git a/docs/TEMPLATE.md b/docs/TEMPLATE.md index b78a2a4..575259d 100644 --- a/docs/TEMPLATE.md +++ b/docs/TEMPLATE.md @@ -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)} + **Test Baseline:** set at CHORE(open) — `unit= integration=` 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}) diff --git a/package.json b/package.json index b530bac..b844991 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli.ts b/src/cli.ts index 0f036a7..a970feb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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"; @@ -59,7 +61,7 @@ async function execute(engineRoot: string, args: string[]): Promise { async function run(model: RulesModel, args: string[]): Promise { 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); } @@ -84,6 +86,10 @@ async function run(model: RulesModel, args: string[]): Promise { // 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)`); diff --git a/src/gates.ts b/src/gates.ts index 2f12884..5bf138a 100644 --- a/src/gates.ts +++ b/src/gates.ts @@ -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"; @@ -14,7 +14,8 @@ 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 @@ -22,6 +23,8 @@ const UTF8 = "utf8"; 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: ()". A trailer // that does not parse is not an override — the gate stays red rather than // guessing what a malformed waiver meant. @@ -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 @@ -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. @@ -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; } } diff --git a/src/gates_spec.test.ts b/src/gates_spec.test.ts index 2581595..a868cbc 100644 --- a/src/gates_spec.test.ts +++ b/src/gates_spec.test.ts @@ -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); @@ -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);