@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.
- package/dispatch/lifecycle.md +5 -2
- package/docs/REST_API_DESIGN_GUIDELINES.md +1 -6
- package/docs/RULE_ENFORCEMENT.md +2 -2
- package/docs/TEMPLATE.md +1 -0
- package/package.json +1 -1
- package/src/cli.ts +7 -1
- package/src/gates.ts +41 -8
- package/src/gates_spec.test.ts +75 -3
package/dispatch/lifecycle.md
CHANGED
|
@@ -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.
|
|
203
|
-
|
|
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.
|
|
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
|
|
package/docs/RULE_ENFORCEMENT.md
CHANGED
|
@@ -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/
|
|
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/
|
|
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.
|
|
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 ===
|
|
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)
|
|
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
|
-
|
|
116
|
-
|
|
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
|
|
148
|
+
function closedSpec(path: string): ClosedSpec | undefined {
|
|
128
149
|
try {
|
|
129
|
-
|
|
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
|
|
164
|
+
return undefined;
|
|
132
165
|
}
|
|
133
166
|
}
|
|
134
167
|
|
package/src/gates_spec.test.ts
CHANGED
|
@@ -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);
|