@agentsfleet/orly 0.10.13 → 0.10.14
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 +6 -1
- package/package.json +1 -1
- package/src/gates.ts +32 -20
- package/src/gates_spec.test.ts +60 -0
package/dispatch/lifecycle.md
CHANGED
|
@@ -258,7 +258,12 @@ and every spec criterion runs against it; skip-pass is only for genuinely
|
|
|
258
258
|
spec-less branches. A deliberately folded spec retains the exact branch header
|
|
259
259
|
and adds `**Folded-into:** \`M178_001\`` naming the owning spec. Discovery
|
|
260
260
|
validates that relation and gates the sole non-folded owner; two non-folded
|
|
261
|
-
specs on one branch remain a hard error.
|
|
261
|
+
specs on one branch remain a hard error. **The same relation holds in
|
|
262
|
+
`active/`, from the moment the fold is decided.** A second workstream may sit
|
|
263
|
+
beside an open stream while it names that stream through `Folded-into`, and the
|
|
264
|
+
gates run against the owner — a fold is not a second stream, and it was decided
|
|
265
|
+
when the scope was, not when the spec moved. Extra `active/` specs without the
|
|
266
|
+
header are still the hard error they were: one stream per worktree. Mechanical criteria: `spec.moved`
|
|
262
267
|
(`Status: DONE` ⇒ the spec sits under `done/` and was moved on this branch) · `spec.baseline`
|
|
263
268
|
(`Test Baseline:` recorded) · `spec.ordering` (the branch's first commit
|
|
264
269
|
carries the spec — no code before CHORE(open)) · `spec.deferrals` (a deferral
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentsfleet/orly",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.14",
|
|
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/gates.ts
CHANGED
|
@@ -39,7 +39,7 @@ export type GateReport = {
|
|
|
39
39
|
};
|
|
40
40
|
|
|
41
41
|
export type Override = { criterion: string; reason: string };
|
|
42
|
-
type
|
|
42
|
+
type SpecMetadata = {
|
|
43
43
|
path: string;
|
|
44
44
|
identifier: string | undefined;
|
|
45
45
|
branch: string | undefined;
|
|
@@ -105,11 +105,34 @@ export function branchOverrides(root: string): Override[] {
|
|
|
105
105
|
return overrides;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// One stream per worktree, and a fold is not a second stream. Extra specs in
|
|
109
|
+
// active/ are allowed only when each names the one non-folded owner through
|
|
110
|
+
// Folded-into — the same relation closedSpecPath enforces in done/. Without
|
|
111
|
+
// this, folding a workstream into an open stream was impossible during the
|
|
112
|
+
// work and legal only after the close, which is the wrong way round: the fold
|
|
113
|
+
// is decided when the scope is, not when the spec moves.
|
|
108
114
|
export function activeSpecPath(root: string): string | undefined {
|
|
109
|
-
const specs = specPathsUnder(root, ACTIVE_DIRECTORY)
|
|
115
|
+
const specs = specPathsUnder(root, ACTIVE_DIRECTORY).map(specMetadata)
|
|
116
|
+
.filter((spec): spec is SpecMetadata => spec !== undefined);
|
|
110
117
|
if (specs.length === 0) return undefined;
|
|
111
|
-
|
|
112
|
-
|
|
118
|
+
return owningSpec(specs, "active spec", "active specs").path;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The fold relation, shared by both discovery paths: exactly one non-folded
|
|
122
|
+
// owner, every other spec naming it, and no spec naming itself.
|
|
123
|
+
function owningSpec(specs: SpecMetadata[], one: string, many: string): SpecMetadata {
|
|
124
|
+
const owners = specs.filter((spec) => spec.foldedInto === undefined);
|
|
125
|
+
if (owners.length === 0) throw new OrlyError(`every ${one} is folded — one owning stream is required:${NEWLINE}${specs.map((spec) => spec.path).join(NEWLINE)}`);
|
|
126
|
+
if (owners.length > 1) throw new OrlyError(`more than one ${one} — one stream per worktree:${NEWLINE}${owners.map((spec) => spec.path).join(NEWLINE)}`);
|
|
127
|
+
const owner = owners[0];
|
|
128
|
+
if (!owner) throw new OrlyError(`${one} ownership could not be resolved`);
|
|
129
|
+
const folded = specs.filter((spec) => spec.foldedInto !== undefined);
|
|
130
|
+
if (folded.length > 0 && !owner.identifier) throw new OrlyError(`the owning ${one} has no milestone and workstream identifier: ${owner.path}`);
|
|
131
|
+
const selfFolds = folded.filter((spec) => spec.identifier === spec.foldedInto);
|
|
132
|
+
if (selfFolds.length > 0) throw new OrlyError(`folded ${many} cannot fold into themselves:${NEWLINE}${selfFolds.map((spec) => spec.path).join(NEWLINE)}`);
|
|
133
|
+
const invalidFolds = folded.filter((spec) => spec.foldedInto !== owner.identifier);
|
|
134
|
+
if (invalidFolds.length > 0) throw new OrlyError(`folded ${many} must name their owner ${owner.identifier}:${NEWLINE}${invalidFolds.map((spec) => spec.path).join(NEWLINE)}`);
|
|
135
|
+
return owner;
|
|
113
136
|
}
|
|
114
137
|
|
|
115
138
|
// Closed-spec follow-through: CHORE(close) moves the spec to done/, and the
|
|
@@ -120,21 +143,10 @@ export function closedSpecPath(root: string): string | undefined {
|
|
|
120
143
|
const branch = gitOutput(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
121
144
|
if (!branch || DEFAULT_BRANCHES.includes(branch)) return undefined;
|
|
122
145
|
const specs = specPathsUnder(root, DONE_DIRECTORY)
|
|
123
|
-
.map(
|
|
124
|
-
.filter((spec): spec is
|
|
146
|
+
.map(specMetadata)
|
|
147
|
+
.filter((spec): spec is SpecMetadata => spec !== undefined && spec.branch === branch);
|
|
125
148
|
if (specs.length === 0) return undefined;
|
|
126
|
-
|
|
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;
|
|
149
|
+
return owningSpec(specs, `done/ spec naming branch ${branch}`, `done/ specs naming branch ${branch}`).path;
|
|
138
150
|
}
|
|
139
151
|
|
|
140
152
|
// An in-flight (active/) spec wins; otherwise the branch's closed spec gates.
|
|
@@ -145,9 +157,9 @@ export function specPathFor(root: string): { path: string; closed: boolean } | u
|
|
|
145
157
|
return closed ? { path: closed, closed: true } : undefined;
|
|
146
158
|
}
|
|
147
159
|
|
|
148
|
-
function
|
|
160
|
+
function specMetadata(path: string): SpecMetadata | undefined {
|
|
149
161
|
try {
|
|
150
|
-
const metadata:
|
|
162
|
+
const metadata: SpecMetadata = {
|
|
151
163
|
path,
|
|
152
164
|
identifier: basename(path).match(SPEC_IDENTIFIER_PATTERN)?.[1],
|
|
153
165
|
branch: undefined,
|
package/src/gates_spec.test.ts
CHANGED
|
@@ -38,6 +38,66 @@ describe("spec discovery", () => {
|
|
|
38
38
|
|
|
39
39
|
expect(() => activeSpecPath(project)).toThrow("one stream per worktree");
|
|
40
40
|
});
|
|
41
|
+
|
|
42
|
+
test("a folded active spec yields ownership to the spec it names", async () => {
|
|
43
|
+
const project = newSpecRepository();
|
|
44
|
+
mkdirSync(join(project, "docs/v2/active"), { recursive: true });
|
|
45
|
+
await Bun.write(
|
|
46
|
+
join(project, "docs/v2/active/M100_001_P2_CLI_FOLDED.md"),
|
|
47
|
+
specFixture(undefined, undefined, ["**Folded-into:** `M99_001`"]),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(activeSpecPath(project)).toEndWith("M99_001_P2_CLI_FIXTURE.md");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a folded active spec must name the exact owner", async () => {
|
|
54
|
+
const project = newSpecRepository();
|
|
55
|
+
mkdirSync(join(project, "docs/v2/active"), { recursive: true });
|
|
56
|
+
await Bun.write(
|
|
57
|
+
join(project, "docs/v2/active/M100_001_P2_CLI_FOLDED.md"),
|
|
58
|
+
specFixture(undefined, undefined, ["**Folded-into:** `M404_001`"]),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(() => activeSpecPath(project)).toThrow("must name their owner M99_001");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a folded active spec cannot name itself", async () => {
|
|
65
|
+
const project = newSpecRepository();
|
|
66
|
+
mkdirSync(join(project, "docs/v2/active"), { recursive: true });
|
|
67
|
+
await Bun.write(
|
|
68
|
+
join(project, "docs/v2/active/M100_001_P2_CLI_FOLDED.md"),
|
|
69
|
+
specFixture(undefined, undefined, ["**Folded-into:** `M100_001`"]),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(() => activeSpecPath(project)).toThrow("cannot fold into themselves");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("every active spec folded leaves no owning stream", async () => {
|
|
76
|
+
const project = newRepository();
|
|
77
|
+
mkdirSync(join(project, "docs/v2/active"), { recursive: true });
|
|
78
|
+
await Bun.write(
|
|
79
|
+
join(project, "docs/v2/active/M100_001_P2_CLI_FOLDED.md"),
|
|
80
|
+
specFixture(undefined, undefined, ["**Folded-into:** `M99_001`"]),
|
|
81
|
+
);
|
|
82
|
+
await Bun.write(
|
|
83
|
+
join(project, "docs/v2/active/M101_001_P2_CLI_ALSO_FOLDED.md"),
|
|
84
|
+
specFixture(undefined, undefined, ["**Folded-into:** `M99_001`"]),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(() => activeSpecPath(project)).toThrow("one owning stream is required");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a folded active spec still gates through its owner", async () => {
|
|
91
|
+
const project = newSpecRepository();
|
|
92
|
+
const model = await modelFor(project);
|
|
93
|
+
mkdirSync(join(project, "docs/v2/active"), { recursive: true });
|
|
94
|
+
await Bun.write(
|
|
95
|
+
join(project, "docs/v2/active/M100_001_P2_CLI_FOLDED.md"),
|
|
96
|
+
specFixture(undefined, undefined, ["**Folded-into:** `M99_001`"]),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
expect(runGate(model, project, "work").results.find((result) => result.name === "cmd.conform")?.ok).toBeTrue();
|
|
100
|
+
});
|
|
41
101
|
});
|
|
42
102
|
|
|
43
103
|
describe("closed-spec follow-through", () => {
|