@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.
@@ -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. Mechanical criteria: `spec.moved`
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.13",
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 ClosedSpec = {
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
- if (specs.length > 1) throw new OrlyError(`more than one active spec — one stream per worktree:${NEWLINE}${specs.join(NEWLINE)}`);
112
- return specs[0];
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(closedSpec)
124
- .filter((spec): spec is ClosedSpec => spec !== undefined && spec.branch === branch);
146
+ .map(specMetadata)
147
+ .filter((spec): spec is SpecMetadata => spec !== undefined && spec.branch === branch);
125
148
  if (specs.length === 0) return undefined;
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;
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 closedSpec(path: string): ClosedSpec | undefined {
160
+ function specMetadata(path: string): SpecMetadata | undefined {
149
161
  try {
150
- const metadata: ClosedSpec = {
162
+ const metadata: SpecMetadata = {
151
163
  path,
152
164
  identifier: basename(path).match(SPEC_IDENTIFIER_PATTERN)?.[1],
153
165
  branch: undefined,
@@ -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", () => {