@pikku/skills 0.12.27 → 0.12.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/skills",
3
- "version": "0.12.27",
3
+ "version": "0.12.28",
4
4
  "description": "The Pikku agent skills — the instruction set coding agents read to build, wire and deploy Pikku projects",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -141,7 +141,8 @@ And writing again replaces it rather than adding a second
141
141
  ```
142
142
  ````
143
143
 
144
- - **`status`** is `proposed` → `dispatched` → `built`. Nothing else. Every gate compares it literally.
144
+ - **`status`** is `designing` → `proposed` → `dispatched` → `built`. Nothing else. Every gate compares it literally. `designing` sits BEFORE `proposed`: the slice is written down but must not be built yet, because whoever is being shown its looks has not picked one. Only `proposed` is dispatchable, so the two cannot be one status without a slice being built out from under the person still choosing.
145
+ - **`statusAt:` and `attempts:` are bookkeeping, not content — never hand-edit them.** A loop driving this base writes both. `statusAt:` is stamped by whatever moved the status, and is what makes "how long has this been building?" answerable; the file's mtime is not the transition time, because a note is edited after dispatch for all sorts of reasons. `attempts:` is `seat@hash` entries recording which seat has already tried to move this note forward, against the content it was trying to move — it is the loop's only brake, and clearing it by hand hands back a budget that exists to stop a note nothing can satisfy being rewritten forever. Rewriting the note's real content refunds that budget on its own, which is the point: an answer that changes the note is what unsticks it.
145
146
  - **`entities`** lists what the slice touches, **at most three**. Past three it is not one buildable piece — split it.
146
147
  - **The scenario is a fenced `gherkin` block, in the third person.** `Given 'owner' has no entry` — never `Given I have no entry`. A quoted word _means a persona_, which is what lets a reader (and a test) tell who is acting. First person hides that, so it is rejected. The console draws the keywords as a column and each quoted persona as a chip, so a first-person scenario is visibly a block with no personas in it.
147
148
 
@@ -243,6 +244,32 @@ pikku knowledge index --check # report stale indexes without writing (CI gate)
243
244
 
244
245
  `index` rewrites only the block between `<!-- pikku:knowledge-index -->` markers, creating a scaffolded `index.md` for a section that has none. It is idempotent — running it twice changes nothing.
245
246
 
247
+ ### What to do next
248
+
249
+ ```bash
250
+ pikku knowledge next # the one thing to do next, derived from what is on disk
251
+ ```
252
+
253
+ `next` is a pure read: it looks at the notes and answers with exactly one action —
254
+ `repair-note`, `write-plan`, `ask-user`, `dispatch`, `hold`, or `idle`. Nothing has to
255
+ be armed by whoever noticed a transition, so calling it twice is free and a state
256
+ nobody anticipated is a missing answer rather than a run that quietly stops.
257
+
258
+ Two things about the output matter if you are driving it:
259
+
260
+ - **`reason` is machine wording.** It names the note, the frontmatter key and what the
261
+ gate wanted. Never repeat it to a person — they have not seen a note and it will read
262
+ as gibberish about files.
263
+ - **`ask-user` carries a `question` as well.** That IS the version for a person: a
264
+ `header`, the question in the language of their app, and `options` when the answer
265
+ comes from a closed vocabulary (which `status:` it is, which `surface:` it is).
266
+ `options` is empty when the answer is free text, and an empty list means offer free
267
+ text — never invent choices to fill it.
268
+
269
+ `hold` means a profile's own gate is holding the milestone and no seat this loop knows
270
+ about can clear it. It names the hold and the notes it is about; what to do then
271
+ belongs to that profile, not here.
272
+
246
273
  ### The milestone plan
247
274
 
248
275
  A milestone note says what the app must DO. Its **plan** — JSON beside the note, not prose — says what has to exist for it, and is what a finished build is measured against:
@@ -4,12 +4,13 @@ description: >-
4
4
  Use when WRITING KYSELY QUERIES (select/join/aggregate/insert/update/delete) inside a Pikku
5
5
  function body, or when setting up SQL database services with Kysely. Covers the query builder
6
6
  API (joins, aggregates + groupBy/having, returning, sql template, expression builder, $if,
7
- transactions, jsonArrayFrom relation helpers) AND @pikku/kysely service setup (channel stores,
7
+ transactions, jsonArrayFrom relation helpers), HOW MANY ROUND TRIPS a function body costs and how to
8
+ collapse sequential awaits into one statement, AND @pikku/kysely service setup (channel stores,
8
9
  workflow services, secret services, AI storage, deployment services). TRIGGER when: writing any
9
10
  non-trivial kysely query (a join, an aggregate/count/sum, groupBy, subquery, transaction, or
10
11
  conditional query), the injected `kysely` service is used in a function body, or code uses
11
- PikkuKysely, KyselyChannelStore, KyselyWorkflowService, KyselySecretService, or the user asks
12
- about SQL setup with Pikku. DO NOT TRIGGER when: user asks about MongoDB or Redis-backed
12
+ PikkuKysely, KyselyChannelStore, KyselyWorkflowService, KyselySecretService, a function body
13
+ awaits more than one query, or the user asks about SQL setup with Pikku. DO NOT TRIGGER when: user asks about MongoDB or Redis-backed
13
14
  services (use pikku-service-backends).
14
15
  installGroups: [core]
15
16
  ---
@@ -119,6 +120,109 @@ await kysely.transaction().execute(async (trx) => {
119
120
  })
120
121
  ```
121
122
 
123
+ ## One statement, not five
124
+
125
+ **Count the `await`s in the function body before you finish it.** In a deployed
126
+ stage the database is not in the process — every terminal (`.execute()`,
127
+ `.executeTakeFirst()`) is a network hop, and five in a row is five latencies the
128
+ caller waits through in series. This is the single most common thing wrong with a
129
+ generated function body, and it never shows up locally against a socket on the
130
+ same machine.
131
+
132
+ **Sequential is only correct when the second query needs the first one's
133
+ VALUES.** Everything else is one of these four:
134
+
135
+ **1. Independent reads → `Promise.all`.** Nothing about the SQL changes; they
136
+ just stop queuing behind each other.
137
+
138
+ ```typescript
139
+ // Three hops, in series
140
+ const item = await kysely.selectFrom('item').where('id','=',id).selectAll().executeTakeFirst()
141
+ const bins = await kysely.selectFrom('bin').where('warehouseId','=',wid).selectAll().execute()
142
+ const moves = await kysely.selectFrom('stockMove').where('itemId','=',id).selectAll().execute()
143
+
144
+ // One hop's worth of latency
145
+ const [item, bins, moves] = await Promise.all([
146
+ kysely.selectFrom('item').where('id','=',id).selectAll().executeTakeFirst(),
147
+ kysely.selectFrom('bin').where('warehouseId','=',wid).selectAll().execute(),
148
+ kysely.selectFrom('stockMove').where('itemId','=',id).selectAll().execute(),
149
+ ])
150
+ ```
151
+
152
+ **2. Parent, then its children → `jsonArrayFrom` / `jsonObjectFrom`.** A loop
153
+ containing an `await` is an N+1: one query per row, so the cost is the size of the
154
+ result set rather than the size of the code. **Never `await` inside a `for`/`map`
155
+ over rows you just fetched.** The relation helpers in the cookbook above collapse
156
+ it into one statement that returns the nested shape your output schema already
157
+ wants.
158
+
159
+ ```typescript
160
+ // N+1 — one extra hop per warehouse
161
+ const warehouses = await kysely.selectFrom('warehouse').selectAll().execute()
162
+ for (const w of warehouses) {
163
+ w.bins = await kysely.selectFrom('bin').where('warehouseId','=',w.id).selectAll().execute()
164
+ }
165
+ // One hop — see NESTED DATA above
166
+ ```
167
+
168
+ If the shape genuinely cannot be nested, fetch the children in **one** query with
169
+ `where('warehouseId', 'in', warehouses.map((w) => w.id))` and group them in JS.
170
+ Two hops beats N.
171
+
172
+ **3. Read, decide, write → one write that returns.** A `select` to check
173
+ existence followed by an `insert` is both two hops and a race — another request
174
+ can insert between them. `returning()` and `onConflict` do it in one statement,
175
+ and what comes back tells you which branch happened.
176
+
177
+ ```typescript
178
+ // Two hops and a race
179
+ const existing = await kysely.selectFrom('item').where('sku','=',sku).select('id').executeTakeFirst()
180
+ if (existing) throw new ConflictError()
181
+ await kysely.insertInto('item').values({ sku, name }).execute()
182
+
183
+ // One hop, and the database arbitrates
184
+ const created = await kysely
185
+ .insertInto('item')
186
+ .values({ sku, name })
187
+ .onConflict((oc) => oc.column('sku').doNothing())
188
+ .returning(['id', 'sku'])
189
+ .executeTakeFirst()
190
+ if (!created) throw new ConflictError()
191
+ ```
192
+
193
+ The same applies to fetch-then-update: `updateTable(...).where(...).returning(...)`
194
+ in one call, and `undefined` back means the row was not there — that is your
195
+ `NotFoundError`, not a reason for a preceding `select`. Many single-row inserts
196
+ are one `.values([...])` with an array.
197
+
198
+ **4. A read that only feeds the next query's `where` → a subquery or a CTE.**
199
+ If the first result never reaches the response and never reaches JS, it should
200
+ never have crossed the wire.
201
+
202
+ ```typescript
203
+ // Two hops — the ids are only ever used as a filter
204
+ const ids = await kysely.selectFrom('bin').where('warehouseId','=',wid).select('id').execute()
205
+ const stock = await kysely.selectFrom('stock').where('binId','in', ids.map((b) => b.id)).selectAll().execute()
206
+
207
+ // One hop
208
+ const stock = await kysely
209
+ .selectFrom('stock')
210
+ .where('binId', 'in', (eb) =>
211
+ eb.selectFrom('bin').select('bin.id').where('bin.warehouseId', '=', wid)
212
+ )
213
+ .selectAll()
214
+ .execute()
215
+ ```
216
+
217
+ `.with('name', (db) => ...)` builds a CTE when the same intermediate is needed
218
+ twice inside one statement. A total alongside a page is a window function —
219
+ `eb.fn.countAll<number>().over().as('total')` — not a second `count` query.
220
+
221
+ **A transaction does NOT reduce round trips.** It adds `BEGIN` and `COMMIT` around
222
+ whatever is inside it. Reach for it when several writes must land together or not
223
+ at all; never as a way to make sequential queries cheaper, and never wrapped round
224
+ reads that only needed `Promise.all`.
225
+
122
226
  Pikku provides SQL database services through six packages:
123
227
 
124
228
  - `@pikku/kysely` — Base service implementations (database-agnostic), the serialize plugins, `createAuditedKysely` and the `pikkuSchemas` helpers