@jaybeeuu/agent-cortex 1.41.0 → 1.41.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.41.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 3991199: Fix the PR gate bead created by `create-task`'s `create-chores.ts` carrying two conflicting
8
+ `implementation-type` labels. The gate declares `implementation-type:hitl` explicitly, but
9
+ `bd create --parent` also inherited the parent's `implementation-type:afk`, leaving the one
10
+ bead whose job is to stop automated merges labelled as autonomous work. The gate now passes
11
+ `--no-inherit-labels`; stage chores keep inheriting `:afk` intentionally. A regression test
12
+ drives the script against a stateful fake `bd` and asserts the gate's label set exactly.
13
+ - c37b50a: Add a command/query-separation rule to `style-code`: a function either returns a value or
14
+ changes state — never both. Commands mutate and return nothing; queries are pure and return a
15
+ value. Two carve-outs: a command that can fail may return a typed result (`Result<T, E>`) rather
16
+ than throw, and a fluent builder may return `this` to chain. Returning data about a mutation —
17
+ the saved row, a count, the previous value — is no longer permitted. The existing result-object
18
+ guidance is unchanged.
19
+ - c29fe79: Split the `style-tests` example catalogue into a new `EXAMPLES.md` and broaden it into
20
+ `❌ Instead` / `✅ Write` code pairs across eight categories: behaviour over implementation,
21
+ test data (vital visible / incidental hidden), mocking at boundaries, targeted assertions,
22
+ structure and naming, determinism and async, isolation, and content assertions. Red Flags gains
23
+ the recurring anti-patterns — assertion-free and tautological tests, expected values recomputed
24
+ with the implementation's own logic, `.only`/focus left in, sleeps and real clocks, unawaited
25
+ promises, and log-output assertions — and the verification checklist gains matching gates.
26
+ `SKILL.md` drops back to a lean 146 lines so the principles still load with the skill while the
27
+ deepened examples live behind progressive disclosure. The `createSomething` factory example is
28
+ corrected: it had a missing comma (a syntax error), asserted `prop: 0` while overriding with
29
+ `prop: 1`, and reached for `DeepPartial` where `Partial` reads better for a flat type.
30
+ - c29fe79: Add integration and e2e layer conventions to `style-tests`. A new `## Integration and e2e`
31
+ section in `EXAMPLES.md` covers six sub-topics with `❌ Instead` / `✅ Write` pairs: what each
32
+ layer proves, real collaborators over fakes, isolation at the integration layer, bounded async
33
+ deadlines over sleeps, determinism at the edges, and what e2e should prove. `SKILL.md`
34
+ generalises the AWS-specific "Integration and e2e are realistic" principle to real-service
35
+ guidance, defines what unit, integration, and e2e each prove in workflow step 1, and adds two
36
+ Red Flags rows — an "integration" test that mocks the collaborator it exists to integrate with,
37
+ and e2e re-testing business rules already covered by unit tests — plus two matching checklist
38
+ items.
39
+
3
40
  ## 1.41.0
4
41
 
5
42
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaybeeuu/agent-cortex",
3
- "version": "1.41.0",
3
+ "version": "1.41.1",
4
4
  "description": "Personal PI package with custom agents, skills, and extensions",
5
5
  "keywords": [
6
6
  "pi-package"
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-cortex",
3
3
  "description": "Personal Copilot CLI plugin with custom agents and skills",
4
- "version": "1.41.0",
4
+ "version": "1.41.1",
5
5
  "author": {
6
6
  "name": "jaybeeuu"
7
7
  },
@@ -24,7 +24,7 @@ One per stage in `pipeline.json` (currently `code`, `verify`, `review`, `documen
24
24
 
25
25
  - **Type**: `task`
26
26
  - **Title**: `[<parent-id>] PR Review and Merge`
27
- - **Labels**: `implementation-type:hitl`, `lifecycle:feature-pr`
27
+ - **Labels**: `implementation-type:hitl`, `lifecycle:feature-pr` — exactly one `implementation-type` label. The gate declares its own labels, so the script passes `--no-inherit-labels` and it never picks up the parent's `implementation-type:afk`. Stage chores do **not** opt out; they keep inheriting `:afk`.
28
28
  - **Dependencies**: `parent-child` to the parent bead; `blocks` on the final document stage chore (`document`, falling back to the last stage in `pipeline.json`).
29
29
  - **Purpose**: human review of the agent PR (agent branch → feature branch) before the feature closes.
30
30
 
@@ -105,5 +105,5 @@ The full bead-property contract (titles, labels, dependencies per chore and for
105
105
  - [ ] `implementation-type` label present on the parent bead (`bd label list <id>`) — resolved by the classifier or, on escalation, the rubric subagent.
106
106
  - [ ] HITL path: no pipeline chores created, caller told the classification. AFK path: script ran from the target project's cwd and its JSON contains every stage ID plus `featurePrReview`.
107
107
  - [ ] Chore titles/labels/dependencies match the contract (`[<parent-id>] <stage title>`, `stage:<id>`, `parent-child` to parent, `blocks` per `dependsOn`), verified against `REFERENCE.md`.
108
- - [ ] PR gate task exists with `implementation-type:hitl` + `lifecycle:feature-pr` labels and blocks the final document stage chore.
108
+ - [ ] PR gate task exists and blocks the final document stage chore, carrying **exactly one** `implementation-type` label — `implementation-type:hitl` (never an inherited `implementation-type:afk` alongside it) — plus `lifecycle:feature-pr`.
109
109
  - [ ] Report returned the parent bead ID, classification, and all child bead IDs.
@@ -106,6 +106,10 @@ const prGateBeadId = run([
106
106
  '--priority', priority,
107
107
  '--labels', 'implementation-type:hitl,lifecycle:feature-pr',
108
108
  '--parent', parentId,
109
+ // The gate declares its own labels; opt out of bd's create-time parent-label
110
+ // inheritance so its explicit :hitl is never joined by an inherited :afk.
111
+ // Stage chores deliberately keep inheriting :afk -- do not add this there.
112
+ '--no-inherit-labels',
109
113
  '--silent',
110
114
  ]);
111
115
  run(['bd', 'dep', 'add', prGateBeadId, documentStageBeadId, '--type', 'blocks']);
@@ -23,6 +23,7 @@ description: Defines this project's coding style, conventions, and architectural
23
23
  - **Clarity over cleverness.** Code is read many more times than it is written. Clever code optimises for the writing moment; clear code optimises for every subsequent reading.
24
24
  - **Validate at system edges, trust the type system within.** Inbound data from API ingress, deserialisation, or inter-service communication is untrusted until validated. Within application boundaries, trust the type system to enforce correct calls — don't re-validate at every internal module boundary.
25
25
  - **Prefer result objects for domain errors; reserve exceptions for emergencies.** Expected failures — validation errors, not-found, conflict — belong in a typed result the caller can handle. Thrown `Error` should signal something genuinely broken: a programming mistake, an invariant violation, or unrecoverable state.
26
+ - **A function either returns a value or changes state — never both.** This is command/query separation at function scale: a command mutates (writes, I/O, logging) and returns nothing — `void` or `Promise<void>`; a query is pure and returns a value. Returning data *about* a mutation — the saved row, a count, the previous value, a success boolean — makes one function two operations wearing one name. Two carve-outs: a command that can fail may return a typed result (`Result<T, E>`), which reports an outcome rather than data about the world and so composes with the result-object guidance above; and a fluent builder whose whole purpose is construction may return `this` to chain. Neither licenses a passthrough return — `arr.push()`-style returns stay banned.
26
27
  - **Deep modules beat shallow ones.** A tight public API with complexity hidden behind it is easier to use, test, and change than a flat sprawl of interdependent helpers.
27
28
  - **Be a class when you are stateful.** When a module must hold mutable state and expose methods that transform or manage it, model it as a class with public methods and real private fields (`#name`) — not a factory function returning a closure of methods. Native `#private` is enforced at runtime; TypeScript's `private` modifier is compile-time only and erases away. This is orthogonal to the functional guidance elsewhere: small components, explicit props, and hooks still govern UI and pure transforms, but a stateful object is a class, and pretending otherwise is a functional idiom wrapped around an object.
28
29
  - **Abstraction must earn its keep, but local helpers are free.** An abstraction extracted into a shared module before its shape is proven nearly always guesses wrong. But extracting a local helper to clarify domain logic is always worthwhile — even at first use. The goal is readable code, not zero duplication.
@@ -49,6 +50,7 @@ description: Defines this project's coding style, conventions, and architectural
49
50
  - **Broadening scope.** A change that fixes a bug and also renames three modules and extracts an abstraction is three changes. Split it.
50
51
  - **Skipping edge validation.** Inbound data at API ingress, deserialisation, or inter-service boundaries is untrusted until validated. Within the application, trust the type system. A missed edge validation silently poisons downstream callers.
51
52
  - **Unstructured errors.** A thrown string or `{ message }` object leaves the caller guessing what failed and why. Prefer typed result objects for expected domain failures — use thrown `Error` only when something has genuinely gone wrong (programming error, invariant violation, unrecoverable state).
53
+ - **A mutator that returns.** A function that changes state *and* returns something about that change — a count, the mutated object, the previous value — is two operations wearing one name. Split it into a command that mutates and a query that reads.
52
54
  - **Large modules with mixed concerns.** A file doing data loading, state orchestration, and rendering is three modules waiting to be extracted.
53
55
  - **Premature cross-module abstraction.** Extracting a shared utility into a common module before the pattern has proven itself across multiple callers nearly always guesses the wrong shape. Local helpers that clarify intent are fine at first use — keep them within the module boundary until the pattern repeats.
54
56
 
@@ -64,6 +66,7 @@ description: Defines this project's coding style, conventions, and architectural
64
66
  | "I'll throw an Error for this validation failure" | Expected domain failures should be typed results the caller can handle. Reserve thrown `Error` for things that are truly broken. |
65
67
  | "I'll extract this into a shared util — it might be useful later" | Extract locally first if it clarifies the code. Promote to shared only when at least a third real caller emerges. Guessing the shape before then wastes time and creates coupling. |
66
68
  | "A factory closure is more functional/pure" | State is state — a class names it. A closure over mutable state is an object with extra steps: the state is still there, the API is less discoverable, and nothing is gained. Use `#private` fields and public methods. |
69
+ | "It's convenient to return the saved row / new count from the write" | That return turns a command into a query mid-flight: the caller now depends on data the mutation happens to produce. Return a typed result for the outcome, and let a separate query read the state if the caller needs it. |
67
70
 
68
71
  ## Cross-skill references
69
72
 
@@ -93,5 +96,6 @@ scoping) and the stateful-object-vs-factory pair.
93
96
  - [ ] Modules are split by ownership, not just for reshuffling code
94
97
  - [ ] State transitions are explicit and encapsulated behind a clear API
95
98
  - [ ] Stateful modules with methods are classes using `#private` fields, not factory closures
99
+ - [ ] Functions are commands (mutate, return nothing) or queries (pure, return a value) — never both
96
100
  - [ ] Tests and docs updated where behaviour changed
97
101
  - [ ] Staged changes scanned with `review-security` before committing
@@ -0,0 +1,548 @@
1
+ # Examples
2
+
3
+ `❌ Instead` / `✅ Write` pairs for the rules in `SKILL.md`. Each pair is a pattern seen in
4
+ real test suites; the left column is what to replace, the right is what to replace it with.
5
+
6
+ ## Behaviour over implementation
7
+
8
+ Assert the observable outcome. If a refactor that preserves behaviour breaks the test, the test
9
+ was wrong.
10
+
11
+ ```ts
12
+ // ❌ Instead — spies on an internal collaborator; proves only that it was wired up
13
+ const spy = vi.spyOn(taxCalculator, "compute");
14
+ checkout(cart);
15
+ expect(spy).toHaveBeenCalledWith(cart);
16
+
17
+ // ✅ Write — asserts what the caller observes
18
+ expect(checkout(cart).total).toBe(90);
19
+ ```
20
+
21
+ ```ts
22
+ // ❌ Instead — recomputes the expected value with the implementation's own logic,
23
+ // so a bug in that logic passes the test
24
+ const expected = subtotal * (1 - discountRate);
25
+ expect(computeTotal(cart)).toBe(expected);
26
+
27
+ // ✅ Write — a known-good case with a hard-coded result
28
+ expect(computeTotal(cartOf100With10PercentDiscount)).toBe(90);
29
+ ```
30
+
31
+ ```ts
32
+ // ❌ Instead — exports an internal helper so the test can reach it
33
+ export const __testOnlyNormalise = (s: string) => s.trim().toLowerCase();
34
+
35
+ // ✅ Write — drive the behaviour through the public entry point that uses it
36
+ expect(parseEmail(" Ada@Example.COM ")).toStrictEqual({ address: "ada@example.com" });
37
+ ```
38
+
39
+ ## Test data: vital visible, incidental hidden
40
+
41
+ The values that drive the case belong in the test body. Everything else takes an obvious
42
+ default in a factory.
43
+
44
+ ```ts
45
+ // ❌ Instead — the value under test is hidden in a top-of-file constant.
46
+ // A reader cannot see which field makes this case "admin".
47
+ const ADMIN_USER: User = { id: "u1", name: "Ada", isAdmin: true, locale: "en-GB" };
48
+
49
+ it("grants admin access", () => {
50
+ expect(canEdit(ADMIN_USER)).toBe(true);
51
+ });
52
+
53
+ // ✅ Write — the driving value is on the assertion's line
54
+ it("grants admin access", () => {
55
+ expect(canEdit(createUser({ isAdmin: true }))).toBe(true);
56
+ });
57
+ ```
58
+
59
+ ```ts
60
+ // ❌ Instead — one module-scope object, mutated and reused across tests.
61
+ // These tests now depend on each other and on their order.
62
+ const user: User = { id: "u1", name: "Ada", isAdmin: false };
63
+
64
+ it("denies guest access", () => {
65
+ expect(canEdit(user)).toBe(false);
66
+ });
67
+
68
+ it("grants admin access", () => {
69
+ user.isAdmin = true;
70
+ expect(canEdit(user)).toBe(true);
71
+ }); // order-dependent: the first test fails if this one runs first
72
+
73
+ // ✅ Write — fresh data per test, produced by a factory
74
+ const createUser = (overrides: Partial<User> = {}): User => ({
75
+ id: "u1",
76
+ name: "Ada",
77
+ isAdmin: false,
78
+ ...overrides,
79
+ });
80
+
81
+ it("grants admin access", () => {
82
+ expect(canEdit(createUser({ isAdmin: true }))).toBe(true);
83
+ });
84
+
85
+ it("denies guest access", () => {
86
+ expect(canEdit(createUser())).toBe(false);
87
+ });
88
+ ```
89
+
90
+ ```ts
91
+ // ❌ Instead — the full literal is re-spelled in every test, obscuring the one field that changes
92
+ it("grants admin access", () => {
93
+ expect(canEdit({ id: "u1", name: "Ada", isAdmin: true, locale: "en-GB" })).toBe(true);
94
+ });
95
+
96
+ // ✅ Write — a factory defaults the incidental data, overrides carry the case
97
+ interface SomeThing {
98
+ prop: number;
99
+ otherProp: string;
100
+ }
101
+
102
+ // Colocated with the tests, named for what it builds. Partial is enough for a flat
103
+ // type; reach for a deep-partial only when the type nests.
104
+ const createSomething = (overrides: Partial<SomeThing> = {}): SomeThing => ({
105
+ prop: 0,
106
+ otherProp: "{other-prop}",
107
+ ...overrides,
108
+ });
109
+
110
+ it("overrides only the value under test", () => {
111
+ const fixture = createSomething({ prop: 1 });
112
+
113
+ expect(fixture).toStrictEqual({ prop: 1, otherProp: "{other-prop}" });
114
+ });
115
+ ```
116
+
117
+ ## Mocking: boundaries, not internals
118
+
119
+ Mock the genuine external edge — network, clock, third-party SDK. Do not mock a collaborator
120
+ inside the application; a real or in-memory one is usually cheaper and never lies.
121
+
122
+ ```ts
123
+ // ❌ Instead — the mock replaces an internal collaborator, so the test
124
+ // proves nothing about the real interaction
125
+ vi.mock("../repository/userRepository");
126
+ await saveUser(user);
127
+ expect(userRepository.save).toHaveBeenCalledWith(user);
128
+
129
+ // ✅ Write — a real in-memory implementation at the genuine boundary
130
+ const repository = new InMemoryUserRepository();
131
+ const service = new UserService(repository);
132
+
133
+ await service.save(user);
134
+
135
+ expect(await repository.get(user.id)).toStrictEqual(user);
136
+ ```
137
+
138
+ ```ts
139
+ // ❌ Instead — the test asserts the stub it just configured, not the mapping
140
+ vi.spyOn(http, "get").mockResolvedValue({ status: 200, body: apiUserResponse });
141
+ expect(await fetchUser("u1")).toStrictEqual(apiUserResponse);
142
+
143
+ // ✅ Write — stub the edge, assert the domain mapping the client performs
144
+ vi.spyOn(http, "get").mockResolvedValue({ status: 200, body: apiUserResponse });
145
+ expect(await fetchUser("u1")).toStrictEqual({ id: "u1", displayName: "Ada" });
146
+ ```
147
+
148
+ Mock as little as possible: if a real collaborator is fast, deterministic, and side-effect free,
149
+ use it.
150
+
151
+ ## Integration and e2e
152
+
153
+ These layers prove what units cannot: that your code speaks correctly to a real collaborator,
154
+ and that the assembled system works. They are the layers agents skip, and where flakiness is
155
+ born. See `SKILL.md` for the principles that govern them.
156
+
157
+ ### What each layer proves
158
+
159
+ A unit proves logic in isolation. An integration test proves your code against a real
160
+ collaborator across **one seam**. An e2e test proves the assembled system on a user-visible
161
+ journey. Pick the lowest layer that can prove the behaviour.
162
+
163
+ ```ts
164
+ // ❌ Instead — an "integration" test that mocks the collaborator it exists to integrate with
165
+ vi.mock("../db/pool");
166
+ await saveOrder(order);
167
+ expect(pool.query).toHaveBeenCalledWith(expect.stringContaining("INSERT INTO orders"));
168
+
169
+ // ✅ Write — exercises the real collaborator across the seam
170
+ await withPostgres(async (db) => {
171
+ const repository = new OrderRepository(db);
172
+ await repository.save(order);
173
+
174
+ expect(await repository.get(order.id)).toMatchObject({ id: order.id });
175
+ });
176
+ ```
177
+
178
+ ```ts
179
+ // ❌ Instead — a unit-tested rule re-proved at e2e: slow, and it proves nothing new
180
+ it("applies a 10% discount to a £100 order", async () => {
181
+ await addToCart(page, productPricedAt(100));
182
+ await openCart(page);
183
+
184
+ expect(await page.textContent(".total")).toBe("£90.00");
185
+ });
186
+
187
+ // ✅ Write — e2e proves the journey and the wiring; the rule stays a unit test
188
+ it("lets a signed-in customer complete checkout", async () => {
189
+ await signIn(page, customer);
190
+ await addToCart(page, product);
191
+ await checkout(page);
192
+
193
+ expect(await page.textContent(".confirmation")).toContain("Thank you");
194
+ });
195
+ ```
196
+
197
+ ### Real collaborators, not fakes
198
+
199
+ Run the genuine engine — Postgres, Redis, a queue, object storage — through TestContainers or
200
+ LocalStack. A fake drifts from the real contract and reintroduces exactly the bug the
201
+ integration test was written to catch. Fake only what you genuinely cannot run locally, and
202
+ use a contract double when you do.
203
+
204
+ ```ts
205
+ // ❌ Instead — a hand-rolled fake that does not share the real broker's contract
206
+ const fakeQueue = { send: vi.fn() };
207
+ await publishOrder(queueAdapter(fakeQueue));
208
+ expect(fakeQueue.send).toHaveBeenCalled();
209
+
210
+ // ✅ Write — the real broker in a container
211
+ const queue = await startQueueContainer();
212
+ await publishOrder(queueAdapter(queue));
213
+
214
+ const [received] = await consumeOne(queue, ORDER_TOPIC);
215
+ expect(received).toMatchObject({ id: order.id });
216
+ ```
217
+
218
+ ```ts
219
+ // ❌ Instead — an ad-hoc stub invented per test; nothing pins it to the provider's API
220
+ vi.spyOn(stripe, "charge").mockResolvedValue({ id: "ch_1" });
221
+
222
+ // ✅ Write — a contract double generated from the provider's schema
223
+ server.use(http.post("/v1/charges", () => HttpResponse.json(stripeChargeFixture)));
224
+
225
+ expect(await client.charge(customer, 100)).toMatchObject({ id: "ch_1" });
226
+ ```
227
+
228
+ ### Isolation at the integration layer
229
+
230
+ State is the enemy here. Give every test its own data and its own schema, namespace, or
231
+ transaction; never share a seeded row or a long-lived database between tests or parallel
232
+ workers. Starting a container is expensive and legitimately belongs in `beforeAll` — data
233
+ does not.
234
+
235
+ ```ts
236
+ // ❌ Instead — a shared seeded row mutated across tests; passes alone, fails in a parallel run
237
+ beforeAll(async () => {
238
+ db = await startPostgresContainer();
239
+ await db.exec(seedSql);
240
+ });
241
+
242
+ it("updates the account", async () => {
243
+ await db.update("accounts", { id: "acc-1" }, { balance: 50 });
244
+ });
245
+
246
+ it("reads the seeded balance", async () => {
247
+ expect(await db.get("accounts", "acc-1")).toMatchObject({ balance: 100 });
248
+ });
249
+
250
+ // ✅ Write — container per suite, data per test
251
+ beforeAll(async () => {
252
+ db = await startPostgresContainer();
253
+ });
254
+
255
+ it("updates the account", async () => {
256
+ const account = await createAccount(db, { balance: 100 });
257
+ await updateBalance(db, account.id, 50);
258
+
259
+ expect(await getAccount(db, account.id)).toMatchObject({ balance: 50 });
260
+ });
261
+
262
+ it("reads the inserted balance", async () => {
263
+ const account = await createAccount(db, { balance: 100 });
264
+
265
+ expect(await getAccount(db, account.id)).toMatchObject({ balance: 100 });
266
+ });
267
+ ```
268
+
269
+ ### Async and eventual consistency
270
+
271
+ Integration surfaces are asynchronous by nature. Await the observable signal with a bounded
272
+ deadline; a fixed sleep is both slow and a race it may still lose.
273
+
274
+ ```ts
275
+ // ❌ Instead — sleeps past the queue, then races it anyway
276
+ await publishOrder(queue, order);
277
+ await new Promise((resolve) => setTimeout(resolve, 500));
278
+
279
+ expect(await findProjection(db, order.id)).toBeDefined();
280
+
281
+ // ✅ Write — poll the observable signal with a bounded deadline
282
+ await publishOrder(queue, order);
283
+ await waitFor(async () => (await findProjection(db, order.id)) !== null, {
284
+ timeout: 5_000,
285
+ description: "order projection",
286
+ });
287
+ ```
288
+
289
+ ```ts
290
+ // ❌ Instead — asserts a step that has not run yet
291
+ it("emits an audit event on save", async () => {
292
+ await saveUser(user);
293
+
294
+ expect(await auditLog.events()).toHaveLength(1);
295
+ });
296
+
297
+ // ✅ Write — await the event, then assert it
298
+ it("emits an audit event on save", async () => {
299
+ await saveUser(user);
300
+ const event = await waitForEvent(auditLog, { type: "user.saved" });
301
+
302
+ expect(event).toMatchObject({ userId: user.id });
303
+ });
304
+ ```
305
+
306
+ ### Determinism at the edges
307
+
308
+ Pin the images, freeze the clock, and seed randomness. A test that pulls `latest` or reads the
309
+ wall clock fails eventually for reasons that have nothing to do with the code.
310
+
311
+ ```ts
312
+ // ❌ Instead — floating image tag and the real clock
313
+ const container = await new GenericContainer("postgres:latest").start();
314
+ const invoice = createInvoice({ issuedAt: new Date() });
315
+
316
+ // ✅ Write — pinned image and an injected clock
317
+ const container = await new GenericContainer("postgres:16.4-alpine").start();
318
+ const clock = new FixedClock("2024-01-01T00:00:00Z");
319
+ const invoice = createInvoice({ issuedAt: clock.now() });
320
+ ```
321
+
322
+ ### What e2e should prove
323
+
324
+ E2E is the most expensive layer, so spend it only on what nothing else can prove: that the
325
+ parts are wired together, migrations ran, configuration loaded, and a user can complete the
326
+ journey. Keep the count small.
327
+
328
+ ```ts
329
+ // ❌ Instead — reaches inside the system to assert call order
330
+ it("checks out", async () => {
331
+ await checkout(page);
332
+
333
+ expect(spyOn(inventoryService, "reserve")).toHaveBeenCalledBefore(
334
+ spyOn(paymentService, "charge"),
335
+ );
336
+ });
337
+
338
+ // ✅ Write — asserts what the journey produced
339
+ it("checks out", async () => {
340
+ await checkout(page);
341
+
342
+ expect(await page.textContent(".confirmation")).toContain("Thank you");
343
+ expect(await api.getOrder(orderRef)).toMatchObject({ state: "confirmed" });
344
+ });
345
+ ```
346
+
347
+ ## Assertions: targeted, never tautological
348
+
349
+ ```ts
350
+ // ❌ Instead — every field asserted; any unrelated field change breaks the test
351
+ expect(created).toStrictEqual({
352
+ id: "u1",
353
+ status: "active",
354
+ createdAt: "2024-01-01T00:00:00.000Z",
355
+ updatedAt: "2024-01-01T00:00:00.000Z",
356
+ version: 3,
357
+ });
358
+
359
+ // ✅ Write — assert the fields that carry the behaviour; use `expect.any` for the rest
360
+ expect(created).toMatchObject({ status: "active", id: expect.any(String) });
361
+ ```
362
+
363
+ ```ts
364
+ // ❌ Instead — the count is implied by the argument assertion
365
+ expect(mockSave).toHaveBeenCalledTimes(1);
366
+ expect(mockSave).toHaveBeenCalledWith(user);
367
+
368
+ // ✅ Write — the argument assertion already proves a single, correct call
369
+ expect(mockSave).toHaveBeenCalledWith(user);
370
+ ```
371
+
372
+ ```ts
373
+ // ❌ Instead — cannot fail; delete the implementation and this stays green
374
+ it("handles the request", async () => {
375
+ const result = await handler(request);
376
+ expect(result).toBeDefined();
377
+ });
378
+
379
+ // ✅ Write — names the observable contract
380
+ it("returns 200 for a valid request", async () => {
381
+ const response = await handler(request);
382
+
383
+ expect(response.status).toBe(200);
384
+ });
385
+ ```
386
+
387
+ ```ts
388
+ // ❌ Instead — no assertion at all; a smoke test that proves only "it did not throw"
389
+ it("processes an order", async () => {
390
+ await processOrder(order);
391
+ });
392
+
393
+ // ✅ Write — assert the effect the call was supposed to have
394
+ it("marks a processed order as fulfilled", async () => {
395
+ await processOrder(order);
396
+
397
+ expect(await orders.get(order.id)).toMatchObject({ state: "fulfilled" });
398
+ });
399
+ ```
400
+
401
+ ## Structure and naming
402
+
403
+ ```ts
404
+ // ❌ Instead — the name describes the implementation, and "works" says nothing
405
+ it("calls send once", ...);
406
+ it("works", ...);
407
+
408
+ // ✅ Write — the name states the behaviour the test proves
409
+ it("sends a confirmation email after a successful checkout", ...);
410
+ it("rejects a checkout with an expired card", ...);
411
+ ```
412
+
413
+ ```ts
414
+ // ❌ Instead — unrelated behaviours in one test: a failure does not localise
415
+ it("handles users", async () => {
416
+ expect(await createUser(valid)).toHaveProperty("id");
417
+ await expect(createUser(invalid)).rejects.toThrow();
418
+ expect(await listUsers()).toHaveLength(1);
419
+ });
420
+
421
+ // ✅ Write — one behaviour per test, so the failure name tells you what broke
422
+ it("assigns an id when creating a user", ...);
423
+ it("rejects a user with no email", ...);
424
+ it("lists every created user", ...);
425
+ ```
426
+
427
+ ```ts
428
+ // ❌ Instead — loops zero times and passes when there is nothing to check
429
+ for (const item of cart.items) {
430
+ expect(item.price).toBeGreaterThan(0);
431
+ }
432
+
433
+ // ✅ Write — assert the collection, so an empty cart fails
434
+ expect(cart.items.map((item) => item.price)).toStrictEqual([10, 20, 30]);
435
+ ```
436
+
437
+ ## Determinism and async
438
+
439
+ ```ts
440
+ // ❌ Instead — a sleep races the work and slows the suite
441
+ await new Promise((resolve) => setTimeout(resolve, 100));
442
+ expect(job.state).toBe("done");
443
+
444
+ // ✅ Write — await the observable completion signal
445
+ await job.completion;
446
+ expect(job.state).toBe("done");
447
+ ```
448
+
449
+ ```ts
450
+ // ❌ Instead — real wall clock and random ids make the expected value unknowable
451
+ const invoice = { id: crypto.randomUUID(), issuedAt: new Date() };
452
+
453
+ // ✅ Write — fixed values in the factory; override only what the case needs
454
+ const createInvoice = (overrides: Partial<Invoice> = {}): Invoice => ({
455
+ id: "invoice-1",
456
+ issuedAt: new Date("2024-01-01T00:00:00Z"),
457
+ ...overrides,
458
+ });
459
+ ```
460
+
461
+ ```ts
462
+ // ❌ Instead — the promise is never returned or awaited, so a rejection
463
+ // becomes an unhandled rejection and the test stays green
464
+ it("rejects an invalid payload", () => {
465
+ expect(handler(bad)).rejects.toThrow();
466
+ });
467
+
468
+ // ✅ Write — await the assertion
469
+ it("rejects an invalid payload", async () => {
470
+ await expect(handler(bad)).rejects.toThrow();
471
+ });
472
+ ```
473
+
474
+ ## Isolation
475
+
476
+ ```ts
477
+ // ❌ Instead — focus and skip markers committed; the suite silently runs less
478
+ it.only("saves the user", ...);
479
+ it.skip("handles a timeout", ...);
480
+
481
+ // ✅ Write — remove the marker; fix the failing test or delete it
482
+ it("saves the user", ...);
483
+ it("handles a timeout", ...);
484
+ ```
485
+
486
+ ```ts
487
+ // ❌ Instead — a shared row mutated by both tests; order now matters
488
+ let row: Row;
489
+
490
+ beforeEach(async () => {
491
+ row = await db.insert(makeRow());
492
+ });
493
+
494
+ it("updates the row", async () => {
495
+ await db.update(row.id, { name: "Ada" });
496
+ });
497
+
498
+ it("reads the row", async () => {
499
+ expect(await db.get(row.id)).toMatchObject({ name: "Original" });
500
+ });
501
+
502
+ // ✅ Write — each test owns its data; no cross-test coupling
503
+ it("updates the row", async () => {
504
+ const row = await db.insert(makeRow());
505
+ await db.update(row.id, { name: "Ada" });
506
+
507
+ expect(await db.get(row.id)).toMatchObject({ name: "Ada" });
508
+ });
509
+
510
+ it("reads the row as inserted", async () => {
511
+ const row = await db.insert(makeRow());
512
+
513
+ expect(await db.get(row.id)).toMatchObject({ name: "Original" });
514
+ });
515
+ ```
516
+
517
+ `before`/`after` hooks are for expensive, non-observable infrastructure — starting a server,
518
+ opening a connection — never for data a test asserts on.
519
+
520
+ ## Content assertions
521
+
522
+ Assert the behaviour the file participates in. A comparison against an expected file is a
523
+ content inventory; it breaks on a comment and proves nothing about the code.
524
+
525
+ ```ts
526
+ // ❌ Instead — catalogues the generated file
527
+ const config = await readFile("generated/nginx.conf", "utf8");
528
+ expect(config).toBe("server { listen 80; }");
529
+
530
+ // ✅ Write — asserts what the generator did with the input
531
+ const config = await generateNginxConfig({ port: 8080 });
532
+
533
+ expect(config).toContain("listen 8080;");
534
+ ```
535
+
536
+ ```ts
537
+ // ❌ Instead — snapshots static markdown and calls it a test
538
+ expect(await renderReadme(project)).toMatchSnapshot();
539
+
540
+ // ✅ Write — asserts the renderer produced the section it was asked for
541
+ const readme = await renderReadme({ ...project, title: "Agent Cortex" });
542
+
543
+ expect(readme).toContain("# Agent Cortex");
544
+ ```
545
+
546
+ A golden-output test is legitimate when the expected output is a **product of the behaviour
547
+ under test** — a parser's AST, a compiler's emitted artifact. It is illegitimate when the file
548
+ is static input the test simply re-reads.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: style-tests
3
- description: Defines this project's test-writing conventions — what to test, assertion strategy, mock discipline, the ban on static-content assertions, and how to construct test data. Use BEFORE writing or editing any test and when reviewing tests — consult it whenever you add or change test files rather than choosing an approach ad hoc. Also for a "test review", "mock strategy", "what should I test", or "is this test well-written".
3
+ description: Defines test-writing conventions — assertion strategy, mock discipline, the content-assertion ban, and test-data construction. Use before writing or reviewing any test, or for a "test review" or "what should I test".
4
4
  ---
5
5
 
6
6
  # Test Style
@@ -22,8 +22,9 @@ description: Defines this project's test-writing conventions — what to test, a
22
22
  - **Test behaviour, not implementation.** A test must survive a refactor that does not change observable behaviour. If it fails after such a refactor, the test was wrong.
23
23
  - **Mock last, at the edges.** Mock genuine external boundaries only. Never mock within the application/domain boundary; a real collaborator is almost always cheaper than a mock that lies.
24
24
  - **Unit tests at module boundaries.** Exercise the surface of a deep module through its public API, never its internals.
25
- - **Integration and e2e are realistic.** Exercise real service behaviour — AWS via LocalStack or TestContainers. AWS may not be the AUT, but it must still be exercised for certainty. Use a small number of smoke tests to prove the system hangs together.
25
+ - **Integration and e2e exercise real services.** Run the genuine collaborator — Postgres, Redis, a queue, object storage via TestContainers or LocalStack — never a fake that drifts from the real contract. Reserve e2e for a small number of smoke tests proving the system hangs together.
26
26
  - **No more tests than needed.** Every test earns its cost by pinpointing exactly where a failure lies. A test that cannot localise a fault adds maintenance weight without signal.
27
+ - **One behaviour per test.** A test should fail for exactly one reason; several unrelated assertions turn a failure into a debugging exercise.
27
28
  - **Test code is production code.** Linted, typechecked, properly typed — no `any`, no unsafe casts, no shortcuts in constructing data. Tolerate WETness where it leaves tests more descriptive.
28
29
  - **All setup and assertion data is visible inline.** Show exactly and only the values that explain the case; a helper with sensible defaults keeps it terse.
29
30
  - **Deterministic and performant.** A failing or flaky test is investigated now. Deferring is acceptable only against a top-priority bead.
@@ -50,14 +51,17 @@ is legitimate. `readFile(config).includes("x")` is not.
50
51
 
51
52
  ## Test-data construction
52
53
 
53
- - **Build data inline in the test** (or in a helper called from it) so the test body shows the shape of its own input.
54
+ - **Vital data is visible inline.** Any value that is asserted, derives the asserted state, or sets up the scenario under test appears in the test body — never hidden in a helper or a module-scope constant.
55
+ - **Incidental data is hidden.** Everything else takes an obvious default inside a factory, so the test body reads as only the values that matter.
54
56
  - **No shared mutable top-level fixture.** If data must be reused, produce it fresh per test via a function — a factory, not a constant. Module-scope constants are acceptable only when genuinely immutable and read-only.
55
- - **Prefer a small configurable helper** — a factory taking partial overrides with defaults for the rest — over repeated full literals. Build the helper only when reuse justifies it; a single test's data stays a plain inline object.
56
- - **Name and locate the helper conventionally** so the next agent finds it — colocated with the tests, named for what it builds.
57
+ - **Prefer a small configurable factory.** Optional partial overrides over sensible defaults beat repeated full literals. Build it when reuse justifies it; a single test's data stays a plain inline object.
58
+ - **Name and locate the factory conventionally.** Colocate it with the tests and name it for what it builds: `createNameOfType`.
59
+
60
+ Worked examples for each rule are in `EXAMPLES.md` under _Test data_.
57
61
 
58
62
  ## Workflow
59
63
 
60
- 1. **Determine the test level.** Can you test this behaviour through the module's public API or interface? Prefer integration-level tests (the trophy's higher coverage). Reserve unit tests for complex functional logic that genuinely benefits from tight isolation, always at the module boundary. Use a small number of e2e smoke tests — with real services via LocalStack/TestContainers — to prove the system hangs together.
64
+ 1. **Determine the test level.** A unit proves logic in isolation, integration proves your code against a real collaborator across one seam, and e2e proves the assembled system on a user-visible journey. Can you test this behaviour through the module's public API or interface? Prefer integration-level tests (the trophy's higher coverage). Reserve unit tests for complex functional logic that genuinely benefits from tight isolation, always at the module boundary. Use a small number of e2e smoke tests to prove the parts are wired together — not to re-prove rules already covered below.
61
65
 
62
66
  2. **Decide what to mock.** Mock as little as possible, and only at genuine external boundaries. A useful mental model: mock what is **above** the module under test in the dependency tree, not below it. At e2e, mock almost nothing.
63
67
 
@@ -75,11 +79,18 @@ is legitimate. `readFile(config).includes("x")` is not.
75
79
  |---|---|
76
80
  | Asserting static file content — an expected config/markdown/terraform string, or a snapshot of static content | Assert the behaviour the file participates in: data inserted into a template, a header compiled correctly, a deployed service ready to serve traffic |
77
81
  | Shared mutable top-level fixture reused across tests | Build fresh data per test via a factory; a module-scope constant only when genuinely immutable and read-only |
78
- | Repeated full literals for the same shape | A small configurable helper taking partial overrides with sensible defaults |
82
+ | Repeated full literals for the same shape | A small configurable factory taking partial overrides with sensible defaults |
83
+ | A test with no assertion, or one that cannot fail (`expect(result).toBeDefined()`) | Assert the observable contract — a specific value the code must produce |
84
+ | Computing the expected value with the implementation's own logic | Hard-code the expected value from a known-good case; the test must not share the implementation's bug |
79
85
  | Mocking implementation details or internal module structure | Mock the genuine external boundary and test through the public interface |
86
+ | An "integration" test that mocks the collaborator it is meant to integrate with | Run the real collaborator (TestContainers/LocalStack); a fake drifts from the real contract |
87
+ | E2E re-testing business rules already covered by unit tests | Spend e2e on wiring, migrations, and the journey — keep it to a few smoke tests |
80
88
  | Duplicating type or lint coverage | Delete the assertion — the compiler and linter already guarantee it |
81
- | Skipping flaky or failing tests | Fix or delete; a skipped test is a blind spot |
89
+ | Skipping flaky or failing tests, or leaving `.only`/focus in | Fix or delete; a skipped or focused test is a blind spot |
82
90
  | Sharing state via `before`/`after` hooks | Independent tests; hooks only for expensive, non-observable infrastructure |
91
+ | Sleeping or polling for async work to finish | Await the real completion signal; inject a clock for time-dependent logic |
92
+ | An unawaited promise in a test | `await` every async interaction so a rejection fails the test |
93
+ | Asserting log or console output | Assert the returned value or emitted event |
83
94
  | `any` or unsafe casts in test code | Proper types — test code is production code |
84
95
 
85
96
  ## Common Rationalizations
@@ -93,6 +104,7 @@ is legitimate. `readFile(config).includes("x")` is not.
93
104
  | "I'll skip this failing test and come back" | A skipped test is a blind spot. The failure is telling you something — listen to it. |
94
105
  | "The config file must match exactly this content" | You are cataloguing a file, not proving behaviour. Assert what the code does with it. |
95
106
  | "A shared fixture saves setup time" | It couples tests and hides which value drives the case. Build data fresh per test. |
107
+ | "Asserting every field is more thorough" | It makes the test break for reasons the case does not care about. Assert the fields that prove the behaviour. |
96
108
 
97
109
  ## Cross-skill references
98
110
 
@@ -105,45 +117,32 @@ is legitimate. `readFile(config).includes("x")` is not.
105
117
 
106
118
  ## Examples
107
119
 
108
- ### Behaviour over content
109
-
110
- | Instead of… | Write… |
111
- |---|---|
112
- | Reading a generated config and asserting it equals an expected string | Assert the value the code inserted into the template |
113
- | Snapshotting a markdown or terraform file's contents | Assert the behaviour the renderer/compiler produced |
114
- | Asserting a deploy artefact's static bytes | A smoke test that the deployed service is up and ready to serve traffic |
115
-
116
- ### Mock boundaries
117
-
118
- | Instead of… | Write… |
119
- |---|---|
120
- | Mocking the internal database driver to verify a query was called | Testing through the repository interface with a test double at that boundary |
121
- | Mocking `fetch` in a unit test for an API client | Testing the client with a real HTTP stub that returns controlled responses |
122
-
123
- ### Targeted assertions
120
+ The full `❌ Instead` / `✅ Write` catalogue lives in `EXAMPLES.md`: behaviour over
121
+ implementation, test data, mocking, integration and e2e, targeted assertions, structure and
122
+ naming, determinism and async, isolation, and content assertions. Headline pairs:
124
123
 
125
124
  | Instead of… | Write… |
126
125
  |---|---|
126
+ | Mocking the internal database driver to verify a query was called | Testing through the repository interface with a real in-memory implementation |
127
+ | Snapshotting a markdown or terraform file's contents | Asserting the behaviour the renderer/compiler produced |
127
128
  | `expect(result).toEqual(fullExpectedObject)` with every field asserted | `expect(result).toMatchObject({ status: "success", id: expect.any(String) })` |
128
- | `expect(mockSave).toHaveBeenCalledTimes(1)` and then `expect(mockSave).toHaveBeenCalledWith(data)` | Just the call-args assertion — count is redundant |
129
-
130
- ### Test data
131
-
132
- | Instead of… | Write… |
133
- |---|---|
134
129
  | `const user = sharedUser; user.isAdmin = true;` mutating a module-scope fixture | `const user = createUser({ isAdmin: true });` — fresh per test |
135
- | Spelling the full object literal in every test | `createUser({ isAdmin: true })` — a helper with defaults and explicit overrides |
136
130
 
137
131
  ## Verification checklist
138
132
 
139
133
  - [ ] Test verifies behaviour, not implementation structure
134
+ - [ ] Integration tests exercise the real collaborator — no mocking the engine you are integrating with
135
+ - [ ] E2E smoke tests prove wiring, migrations, and the journey — not business rules covered lower down
136
+ - [ ] Each test fails for exactly one reason, and its name states the behaviour
140
137
  - [ ] No mocking of internal module details — only public interfaces
141
138
  - [ ] No `before`/`after` hooks for shared test state (infrastructure setup only)
142
- - [ ] Assertions are targeted — no over-asserting or redundant checks
139
+ - [ ] Assertions are targeted — no over-asserting, no tautologies, no assertion-free tests
143
140
  - [ ] No test asserts static file content (config, markdown, terraform, static snapshots) — assertions prove behaviour, not inventory
144
141
  - [ ] Test data is built inline or via a fresh-per-test factory; no shared mutable top-level fixture
145
- - [ ] Reused test data uses a small configurable helper rather than repeated full literals
146
- - [ ] No skipped or commented-out tests
147
- - [ ] No flaky tests — all deterministic
142
+ - [ ] Vital data is visible inline; incidental data is defaulted in a factory
143
+ - [ ] Reused test data uses a small configurable factory rather than repeated full literals
144
+ - [ ] No skipped, focused, or commented-out tests
145
+ - [ ] No flaky tests — all deterministic, no sleeps or real clocks
146
+ - [ ] Every async interaction is awaited
148
147
  - [ ] No `any` or unsafe casts in test code
149
148
  - [ ] No duplication of type or linting coverage
@@ -0,0 +1,73 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { execFile } from "node:child_process";
4
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { fileURLToPath } from "node:url";
7
+ import { dirname, join } from "node:path";
8
+
9
+ import { makeFakeBd } from "./helpers/fake-bd.mjs";
10
+
11
+ const testDir = dirname(fileURLToPath(import.meta.url));
12
+ const CREATE_CHORES = join(testDir, "..", "skills", "planning", "create-task", "scripts", "create-chores.ts");
13
+
14
+ /** Run create-chores.ts against the fake `bd` with a parent that already
15
+ * carries implementation-type:afk — the label `bd create --parent` inherits.
16
+ * @param {string[]} parentLabels Labels seeded on the parent bead
17
+ * @returns {Promise<{ ids: Record<string, string>, beads: Array<{ id: string, title: string, labels: string[] }> }>} */
18
+ async function createChoresAgainstFakeBd(parentLabels) {
19
+ const dir = await mkdtemp(join(tmpdir(), "create-chores-"));
20
+ const { bin, state } = await makeFakeBd(dir, {
21
+ beads: [{ id: "parent-1", title: "Feature", labels: parentLabels }],
22
+ });
23
+
24
+ try {
25
+ const { exitCode, stdout, stderr } = await runScript(
26
+ ["--parent", "parent-1"],
27
+ { BD_PATH: bin, FAKE_BD_STATE: state },
28
+ );
29
+ assert.equal(exitCode, 0, stderr);
30
+
31
+ const ids = JSON.parse(stdout);
32
+ const { beads } = JSON.parse(await readFile(state, "utf8"));
33
+ return { ids, beads };
34
+ } finally {
35
+ await rm(dir, { recursive: true, force: true });
36
+ }
37
+ }
38
+
39
+ /** Run create-chores.ts with the given env overrides. */
40
+ function runScript(args, env) {
41
+ return new Promise((resolve) => {
42
+ execFile(
43
+ process.execPath,
44
+ [CREATE_CHORES, ...args],
45
+ { env: { ...process.env, ...env } },
46
+ (error, stdout, stderr) => {
47
+ resolve({ exitCode: error ? error.code ?? 1 : 0, stdout, stderr });
48
+ },
49
+ );
50
+ });
51
+ }
52
+
53
+ describe("create-chores", () => {
54
+ it("gives the PR gate exactly its declared labels, without the parent's inherited implementation-type:afk", async () => {
55
+ const { ids, beads } = await createChoresAgainstFakeBd(["implementation-type:afk"]);
56
+
57
+ const gate = beads.find((b) => b.id === ids.featurePrReview);
58
+ assert.deepEqual(
59
+ [...gate.labels].sort(),
60
+ ["implementation-type:hitl", "lifecycle:feature-pr"],
61
+ );
62
+ });
63
+
64
+ it("still lets stage chores inherit implementation-type:afk from the parent", async () => {
65
+ const { ids, beads } = await createChoresAgainstFakeBd(["implementation-type:afk"]);
66
+
67
+ const codeChore = beads.find((b) => b.id === ids.code);
68
+ assert.deepEqual(
69
+ [...codeChore.labels].sort(),
70
+ ["implementation-type:afk", "stage:code"],
71
+ );
72
+ });
73
+ });
@@ -0,0 +1,67 @@
1
+ // Shared test helper: a stateful fake `bd` binary for create-chores.ts.
2
+ // `bd` is a genuine external system (its real database must never be touched
3
+ // by tests), so the script is driven against this fake via the BD_PATH
4
+ // override. The fake reproduces the one real behaviour the test depends on —
5
+ // `bd create --parent` inherits the parent's labels unless
6
+ // `--no-inherit-labels` is passed — and records every created bead to a JSON
7
+ // state file the test can read back.
8
+ //
9
+ // State lives in `state.json` next to the binary; the test seeds it with the
10
+ // parent bead and reads the children back after the script runs. Written async
11
+ // throughout — the repo's check-no-sync lint scans test/ (including helpers).
12
+
13
+ import { chmod, writeFile } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+
16
+ const FAKE_BD_SCRIPT = [
17
+ "#!/usr/bin/env node",
18
+ "// Stateful fake for `bd create` (test/helpers/fake-bd.mjs).",
19
+ "const fs = require('node:fs/promises');",
20
+ "",
21
+ "const stateFile = process.env.FAKE_BD_STATE;",
22
+ "const VALUE_FLAGS = new Set(['--type', '--description', '--priority', '--labels', '--parent']);",
23
+ "const BOOL_FLAGS = new Set(['--silent', '--no-inherit-labels']);",
24
+ "",
25
+ "(async () => {",
26
+ " const argv = process.argv.slice(2);",
27
+ " if (argv[0] === 'dep') process.exit(0);",
28
+ " if (argv[0] !== 'create') process.exit(1);",
29
+ "",
30
+ " const title = argv[1];",
31
+ " const flags = {};",
32
+ " const bools = new Set();",
33
+ " for (let i = 2; i < argv.length; i++) {",
34
+ " const arg = argv[i];",
35
+ " if (BOOL_FLAGS.has(arg)) { bools.add(arg); continue; }",
36
+ " if (VALUE_FLAGS.has(arg)) { flags[arg] = argv[++i]; continue; }",
37
+ " }",
38
+ "",
39
+ " const state = JSON.parse(await fs.readFile(stateFile, 'utf8'));",
40
+ " const explicit = (flags['--labels'] || '').split(',').filter(Boolean);",
41
+ " const parent = state.beads.find((b) => b.id === flags['--parent']);",
42
+ " const inherited = parent && !bools.has('--no-inherit-labels') ? parent.labels : [];",
43
+ " const labels = [...new Set([...inherited, ...explicit])];",
44
+ "",
45
+ " const id = 'bd-' + state.nextId++;",
46
+ " state.beads.push({ id, title, labels, parent: flags['--parent'] ?? null });",
47
+ " await fs.writeFile(stateFile, JSON.stringify(state));",
48
+ " process.stdout.write(id + '\\n');",
49
+ "})().catch((err) => {",
50
+ " console.error(err);",
51
+ " process.exit(1);",
52
+ "});",
53
+ ].join("\n");
54
+
55
+ /** Create a stateful fake `bd` binary in `dir`.
56
+ * @param {string} dir Directory to hold the binary and its state.json
57
+ * @param {{ beads: Array<{ id: string, title: string, labels: string[] }> }} seed
58
+ * Pre-existing beads (the parent) the fake inherits labels from
59
+ * @returns {Promise<{ bin: string, state: string }>} */
60
+ export async function makeFakeBd(dir, { beads }) {
61
+ const bin = join(dir, "bd");
62
+ const state = join(dir, "state.json");
63
+ await writeFile(bin, FAKE_BD_SCRIPT + "\n");
64
+ await chmod(bin, 0o755);
65
+ await writeFile(state, JSON.stringify({ beads, nextId: 1 }));
66
+ return { bin, state };
67
+ }