@pikku/skills 0.12.27 → 0.12.29

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.29",
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",
@@ -2,8 +2,8 @@
2
2
  name: pikku-addon
3
3
  description: >-
4
4
  Use when creating or consuming reusable function packages (addons) in Pikku. Covers wireAddon,
5
- ref(), pikkuAddonServices, pikkuAddonWireServices, addon package structure, and cross-project
6
- function sharing. TRIGGER when: code uses wireAddon/ref()/pikkuAddonServices, user asks about
5
+ ref(), pikkuAddonServices, pikkuAddonWireServices, addon package structure, addons that ship
6
+ database tables (pikku db export), and cross-project function sharing. TRIGGER when: code uses wireAddon/ref()/pikkuAddonServices, user asks about
7
7
  addons, reusable function packages, cross-project sharing, or addon package structure. DO NOT
8
8
  TRIGGER when: user asks about internal function composition (use pikku-wiring) or general function
9
9
  definitions (use pikku-concepts).
@@ -264,6 +264,49 @@ addon` above is the exception — it runs before the addon, and its CLI, exist.
264
264
  addon installs fine and fails to typecheck in every app that depends on it —
265
265
  which is what `pikku validate` is there to catch before you publish.
266
266
 
267
+ ### Database tables
268
+
269
+ An addon may **ship** tables. It must never **create** them: it runs inside the
270
+ consumer, against the consumer's database, so a boot-time `CREATE TABLE` puts a
271
+ second authority on a schema the consumer's migrations own. It declares instead,
272
+ and the consumer's migration history absorbs the declaration.
273
+
274
+ Author the DDL per dialect:
275
+
276
+ ```text
277
+ db/sqlite/0001-labels.sql
278
+ db/postgres/0001-labels.sql
279
+ ```
280
+
281
+ `pikku all` publishes `<outDir>/db/pikku-db-meta.gen.json` on every build — per
282
+ dialect, the SQL verbatim plus a table/column map — and writes it **empty** when
283
+ the addon has no tables, because a consumer reads an absent file as a package
284
+ that cannot say. (`pikku db export` writes the same file on demand.) The
285
+ consumer resolves it **through the package name**, so it must be exported and
286
+ packed, or it never arrives:
287
+
288
+ ```json
289
+ {
290
+ "exports": {
291
+ "./.pikku/db/pikku-db-meta.gen.json": "./dist/.pikku/addon/db/pikku-db-meta.gen.json"
292
+ },
293
+ "files": ["dist"]
294
+ }
295
+ ```
296
+
297
+ **An unresolvable artifact stops `db generate`.** Because the file is
298
+ unconditional, absence means the package cannot say whether it ships tables —
299
+ either it was built with an older CLI, or `exports`/`files` do not carry it. The
300
+ error names both causes. An addon with genuinely no tables is *not* this case:
301
+ it publishes `{}` and is waved through.
302
+
303
+ Two more loud ones: a malformed artifact (missing the SQL for a dialect it
304
+ claims) is rejected rather than half-applied, and an addon publishing only a
305
+ dialect the consumer does not use gets an error naming what it does support.
306
+
307
+ `verifiers/db-schema` runs this end to end on both dialects — copy its addon
308
+ `package.json` when wiring a new one.
309
+
267
310
  ## Consuming an Addon
268
311
 
269
312
  ### Install & Register
@@ -281,6 +324,14 @@ wireAddon({ name: 'todos', package: '@my-org/addon-todos' })
281
324
 
282
325
  After registration, run `yarn pikku all` to generate types for the addon's functions.
283
326
 
327
+ If the addon ships tables, `pikku db generate` then writes one migration per
328
+ addon — named after the package, carrying the addon's own SQL — after Better
329
+ Auth's and the runtime's, so an addon table may reference `user` or a runtime
330
+ table. `pikku db migrate` applies it; re-running `generate` writes nothing once
331
+ covered, and writes only the delta after an addon upgrade. An addon wired with
332
+ `wireRemoteAddon` contributes no schema at all: its tables belong to the
333
+ deployment that runs its functions.
334
+
284
335
  ### Call via RPC
285
336
 
286
337
  ```typescript
@@ -57,7 +57,8 @@ package that declared them, and the extra segment is what stops a linked addon's
57
57
  "./.pikku/pikku-metadata.gen.json": "./dist/.pikku/addon/pikku-metadata.gen.json",
58
58
  "./.pikku/rpc/pikku-rpc-wirings-map.internal.gen.js": {
59
59
  "types": "./dist/.pikku/addon/rpc/pikku-rpc-wirings-map.internal.gen.d.ts"
60
- }
60
+ },
61
+ "./.pikku/db/pikku-db-meta.gen.json": "./dist/.pikku/addon/db/pikku-db-meta.gen.json"
61
62
  },
62
63
  "files": ["dist"],
63
64
  "peerDependencies": {
@@ -23,7 +23,7 @@ than what they look like.
23
23
 
24
24
  | You are… | Read |
25
25
  | --- | --- |
26
- | Defining or invoking an agent — tools, memory, streaming, approval, threads | `references/agents.md` |
26
+ | Defining or invoking an agent — tools, memory, streaming, approval, threads, images | `references/agents.md` |
27
27
  | Wiring the runner, or pointing model strings at a provider or gateway | `references/runner-vercel.md` |
28
28
  | Adding speech in or out of an agent | `references/voice.md` |
29
29
 
@@ -44,7 +44,9 @@ session, the credentials and the RPC depth for you.
44
44
  key. `role`, `personality` and `goal` are concatenated in that order and
45
45
  nothing validates which text lands where, so the split buys legibility only.
46
46
  - **`output` is honoured only when the agent has no tools.** A structured-output
47
- schema on a tool-calling agent is silently inert.
47
+ schema on a tool-calling agent is silently inert. The pairing that wants it is
48
+ reading an image or a document into typed data — one tool-free agent, an
49
+ `output` schema, and the picture passed as an `attachments` entry.
48
50
  - **`auth` defaults to `false`**, because agents are normally invoked from an
49
51
  already-authenticated `pikkuFunc`. `scopes` and `permissions` are enforced
50
52
  either way — see `pikku-auth`.
@@ -201,6 +201,36 @@ export const structuredAgent = pikkuAgent({
201
201
  })
202
202
  ```
203
203
 
204
+ ### Images and files
205
+
206
+ `attachments` on the agent input is how a picture, a scan or a PDF reaches the
207
+ model. Each entry carries **either** `data` (base64, no `data:` prefix) **or**
208
+ `url`, plus a `mediaType`:
209
+
210
+ ```typescript
211
+ const { object } = await rpc.agent.run('read-receipt', {
212
+ message: 'List every line item on this receipt.',
213
+ threadId, resourceId,
214
+ attachments: [{ type: 'image', data: base64Jpeg, mediaType: 'image/jpeg' }],
215
+ })
216
+ ```
217
+
218
+ - **`url` is downloaded server-side**, by the runner, before the model sees it —
219
+ so a caller-supplied URL is an SSRF surface. `VercelAgentRunner`'s third
220
+ constructor argument is a host allowlist; set it whenever an HTTP wiring
221
+ accepts attachment URLs, or take `data` and never accept a URL at all.
222
+ - **The model has to be a vision model.** The provider prefix does not decide
223
+ this — `openai/gpt-5-mini` reads images, a text-only id in the same family
224
+ answers as though the attachment were not there rather than erroring.
225
+ - **Reading an image into data is the tool-free case**, so it can use `output`:
226
+ an agent with an `output` schema and no tools resolves `result.object` as the
227
+ typed extraction. Add one tool and you get prose back instead — see
228
+ **Structured output** above.
229
+ - **Base64 is the request body.** A phone photo is measured in megabytes and
230
+ goes through your function's input schema, the RPC payload and the model's
231
+ context. Downscale on the client before uploading — the model does not read
232
+ the pixels you paid to send.
233
+
204
234
  ### Narrowing tools per step
205
235
 
206
236
  `prepareStep` runs before each step with the live tool array for that step, so
@@ -33,9 +33,11 @@ plus more effort" — it is App plus a deliberate surface checklist, so read the
33
33
  base first and follow it in full rather than blending the two into one plan.
34
34
 
35
35
  The supporting references belong to whichever mode sends you to them:
36
- `references/multi-app.md` (a second frontend), `references/theming.md`
37
- (authoring the theme), `references/ship.md` (deploying, and the Fabric-readiness
38
- contract).
36
+ `references/multi-app.md` (a second frontend), `references/design.md` (committing
37
+ to a design direction and judging whether the screens realise it — read before
38
+ the first screen is built, not after the last), `references/theming.md`
39
+ (authoring the theme),
40
+ `references/ship.md` (deploying, and the Fabric-readiness contract).
39
41
 
40
42
  ## Bootstrap before anything else
41
43
 
@@ -418,6 +418,12 @@ over, and an uncovered function is a half-milestone whether or not the note says
418
418
  file. Compose the kit from `@/components/<Name>` rather than hand-rolling.
419
419
  Register the screen in `useNavItems()` — that one file feeds both the desktop
420
420
  sidebar and the phone navigation.
421
+ **Read `references/design.md` before you write the first screen.** You commit
422
+ to a design direction there and are then accountable to it — it hands you no
423
+ layouts, because the design is yours to make. Screenshot each screen at 390
424
+ and 1440 with the seed in place at the END of every milestone, and look at the
425
+ images — not once at §8, where the only affordable fix is a repaint of eight
426
+ screens.
421
427
  6. **Scenario** (§7), then `status: built`.
422
428
 
423
429
  Rules that are not optional:
@@ -635,17 +641,29 @@ loud is a number nobody acts on.
635
641
 
636
642
  ## 8. Make it look like someone designed it
637
643
 
638
- Two separate jobs, and conflating them is why open-source builds come out looking
639
- like the template:
640
-
641
- - **8a. Direction** — deciding what it should look like. **No open-source tool
642
- does this.** Fabric has `fabric-theme`; you have §1's answer and this section.
643
- - **8b. Critique** — judging how well the built screens execute that direction.
644
+ **This section numbers 8, but half of it has already happened.** Read
645
+ `references/design.md` before the first screen is built — a design pass run on
646
+ eight milestones of scaffolded screens is a repaint, and it shows. What is left
647
+ here at §8 is the theme you may have deferred and the critique you cannot run
648
+ until there are screens to critique.
649
+
650
+ Three separate jobs, and conflating them is why open-source builds come out
651
+ looking like the template:
652
+
653
+ - **Direction** — deciding what it should look like. **No open-source tool does
654
+ this.** Fabric has `fabric-theme`; you have §1's answer and 8a below.
655
+ - **Execution** — whether the screens actually realise that direction, or
656
+ default to whatever component was nearest. No theme does this, and it is where
657
+ "works but looks like nobody decided anything" comes from.
658
+ `references/design.md` carries the process for it, and it belongs at §6, per
659
+ screen.
660
+ - **Critique** — judging how well the built screens execute the direction.
644
661
  `impeccable` does this well, and it is free.
645
662
 
646
663
  Impeccable audits the design you chose. It will never tell you the app should
647
664
  have looked like something else — it will happily award a clean bill of health to
648
- a perfectly-executed default. Skip 8a and you ship Neutral with good spacing.
665
+ a perfectly-executed default. Skip the first two and you ship Neutral with good
666
+ spacing.
649
667
 
650
668
  ### 8a. Author the theme — the step nothing does for you
651
669
 
@@ -674,6 +692,13 @@ theme is; only the note records why.
674
692
 
675
693
  ### 8b. Compose real components, then critique
676
694
 
695
+ **`references/design.md` is where design actually lives** — committing to a
696
+ direction before the first screen, judging the result from screenshots rather
697
+ than from source, the two screens that get skipped, and why you reset the dev
698
+ database before judging anything. It prescribes no layouts on purpose: two apps
699
+ built from this skill should not look like each other. What follows here is only
700
+ the component inventory.
701
+
677
702
  **Compose with Mantine's rich components — not tables and text everywhere:**
678
703
 
679
704
  - **`@mantine/charts`** (Recharts underneath) for overviews — `AreaChart`,
@@ -710,8 +735,17 @@ a modal taller than the viewport. Mantine gives you the tools (responsive `Grid`
710
735
  them. The template already mounts a phone navigation per `AGENTS.md` — pick
711
736
  `MobileTabBar` or `MobileNavDrawer` deliberately per app, never both.
712
737
 
713
- The gate: **no P0 findings left on any screen, in any app, at either width.**
714
- Don't silence a finding by deleting the feature it is about.
738
+ The gate: **no P0 findings left on any screen, in any app, at either width**,
739
+ and every screen honestly answers the questions in `references/design.md` —
740
+ including whether it looks like the direction you committed to or like the
741
+ components you had. Don't silence a finding by deleting the feature it is
742
+ about.
743
+
744
+ **Critique the data too, not just the layout.** Scenario runs write run-tagged
745
+ rows into the dev database, so by §8 the app is full of `Ripe peaches d193e2aa`
746
+ seven copies deep. Run `pikku db reset` and restart the dev server before you
747
+ screenshot anything — a screen judged against that data gets designed for a
748
+ problem it does not have.
715
749
 
716
750
  ## 9. Ship it, and stay Fabric-ready
717
751
 
@@ -735,6 +769,8 @@ cheaper to honour than to retrofit:
735
769
 
736
770
  - `references/multi-app.md` — adding a second frontend (§4), at the milestone
737
771
  that needs it
772
+ - `references/design.md` — committing to a design direction, and how to tell
773
+ whether the screens realise it. Read BEFORE the first screen (§6), not at §8
738
774
  - `references/theming.md` — authoring the theme (§8a)
739
775
  - `references/ship.md` — deploying, and the Fabric-readiness contract (§9)
740
776
  - Sibling skills: `pikku-knowledge` (§2), `pikku-auth` (§3),
@@ -0,0 +1,128 @@
1
+ # Design
2
+
3
+ Your job here is to design something worth the product — not to apply a house
4
+ style. **There is no house style, and this file is not one.** Two apps built from
5
+ this skill should not look like each other; if they do, something has gone wrong
6
+ that no amount of spacing will fix.
7
+
8
+ So: no prescribed layouts, no component rules, no ratios. What follows is the
9
+ process that makes freedom accountable, the handful of facts that are not
10
+ matters of taste, and the symptoms of a screen nobody actually designed.
11
+
12
+ ## Commit to a direction before the first screen
13
+
14
+ An agent given "make it look good" and nothing else defaults — to the component
15
+ nearest to hand, on every screen, in every app. Not because it lacks taste, but
16
+ because there is nothing to be wrong against. A direction fixes that: state one,
17
+ in words, before any screen exists.
18
+
19
+ Say what this product *is* — its register, its reference points, what it feels
20
+ like to use, what it is deliberately not. "A warm, food-forward thing you use
21
+ standing at an open fridge door, closer to a recipe card than to a dashboard, and
22
+ never clinical" is a direction. "Clean and modern" is not — it rules nothing out,
23
+ so it cannot be departed from.
24
+
25
+ Write it to `knowledge/decisions/design/`, then build the theme from it
26
+ (`references/theming.md`). **From that point you are accountable to your own
27
+ direction, not to this file.** That is the whole mechanism: the freedom is real,
28
+ and so is the commitment.
29
+
30
+ Be ambitious with it. A direction that could describe any SaaS app has not been
31
+ chosen — it has been defaulted to in words instead of in components.
32
+
33
+ ## One screen, designed properly, before the rest exist
34
+
35
+ Design the first real screen as if it were the only one, and take it further than
36
+ feels necessary. Every screen after it inherits its register — its density, its
37
+ rhythm, what a row of data looks like, how state is signalled. That inheritance
38
+ happens whether you plan it or not, so make the first one worth inheriting.
39
+
40
+ This is also the cheapest design work in the project. The eighth screen is a
41
+ repaint of eight; the first is a decision.
42
+
43
+ ## Judge it — and don't grade your own homework
44
+
45
+ Models rate their own output generously, and "does this look good?" answered by
46
+ the thing that made it is always yes. Use evidence.
47
+
48
+ - **Screenshot every screen and look at the image**, at ~390px and at ~1440px.
49
+ Judging your own UI from source is guessing, and the failures that matter —
50
+ proportion, hierarchy, a wall of identical boxes — are invisible in JSX.
51
+ - **Run `impeccable`** (`npx impeccable install`, Node 22.18+) and feed it the
52
+ screenshots. It is external, it does not flatter, and it scores execution
53
+ against interaction heuristics. But it audits how well you executed the design
54
+ you chose — it will award a clean bill of health to a perfectly executed
55
+ default. It checks step 2; it never replaces it.
56
+ - **Look at every milestone, not once at the end.** A screen that was fine at
57
+ three rows is a different screen at sixty, and the milestone that added the
58
+ sixty is the cheapest place to notice.
59
+
60
+ Questions worth answering honestly, per screen. The answers are yours; only the
61
+ questions are fixed:
62
+
63
+ 1. What question does someone open this screen to ask, and how fast do they get
64
+ the answer?
65
+ 2. What can be understood before reading a word?
66
+ 3. What is here that is not earning its space?
67
+ 4. What does it look like at real volume, and at 390px?
68
+ 5. Does it look like the direction you committed to — or like the components you
69
+ had?
70
+ 6. Would you show it to the user without apologising for it?
71
+
72
+ ## Facts, not taste
73
+
74
+ These are not design opinions and are not open to a different answer.
75
+
76
+ - **Reset the dev database before judging anything.** Scenario runs deliberately
77
+ tag rows with a run id so assertions do not collide, so after a few runs the
78
+ app is full of `Ripe peaches d193e2aa`, seven copies deep. Nobody's data looks
79
+ like that, and a screen designed against it is designed for a problem it does
80
+ not have. `pikku db reset` replays the migrations and the seed — and the dev
81
+ server holds an open handle to the file, so **restart it after**, or every call
82
+ fails with `disk I/O error` and the app looks broken for reasons that are not
83
+ the app.
84
+ - **Seed generously and realistically.** A seed with a deliberate spread designs
85
+ the hard cases for you. Three identical rows teach you nothing.
86
+ - **Never render a sign-in method that is not configured.** A "Continue with
87
+ Google" button on an app with no Google credentials is a dead control on the
88
+ first screen anyone sees. Render social buttons from what `socialProviders`
89
+ actually declares — and when it declares none, the divider goes too.
90
+ - **Empty, loading and error are states that exist.** The empty state is what a
91
+ new user meets first and the one most often skipped entirely.
92
+ - **Contrast and tap targets are measured, not judged.** `pikku-a11y` covers it.
93
+ One trap worth knowing: a Mantine `light` variant paints its label at the
94
+ generated ramp's stop, which lands just under AA on its own tint — name the
95
+ darker ink once and reuse it.
96
+
97
+ ## Two screens that get skipped
98
+
99
+ Not rules about how they should look — only that they are yours to design.
100
+
101
+ **The login page is the entry point.** It is the first thing anyone sees, where
102
+ every demo starts, and routinely the least designed screen in the app: a default
103
+ card with a wordmark, saying "scaffold" before the product has said anything. It
104
+ gets the same direction as everything else.
105
+
106
+ **The navigation is on every screen**, which makes it the highest-leverage
107
+ surface you have. The scaffold mounts one that works; working is not the same as
108
+ considered. Decide the destinations and their number deliberately, and style it
109
+ through the theme rather than leaving the component defaults — the same look on
110
+ every app is the tell.
111
+
112
+ ## Symptoms of a screen nobody designed
113
+
114
+ If a screen shows these, you defaulted. **The fix is yours to choose** — these
115
+ are a diagnosis, not a prescription, and the interesting answer is rarely the
116
+ first one.
117
+
118
+ - The top of the screen is a form, and the content it acts on starts below the
119
+ fold.
120
+ - Two adjacent surfaces do the same job because both were added separately.
121
+ - Everything is the same size, weight and colour, so nothing can be found
122
+ without reading it.
123
+ - The same fact is stated twice in one line, in two different components.
124
+ - A section named for an exception holds half the data.
125
+ - Every row carries the same buttons, and the buttons outweigh the content.
126
+ - The palette's meaningful colours are also used decoratively, so they have
127
+ stopped meaning anything.
128
+ - It looks like the last app you built.
@@ -285,6 +285,39 @@ script, run `pikku dev` from the **project root** (it resolves `srcDirectories`
285
285
  relative to the config, so a nested cwd yields a doubled watch path and no hot
286
286
  reload).
287
287
 
288
+ ## Reaching a model
289
+
290
+ An agent needs an `agentRunner` in singleton services, and locally you do not
291
+ write one: `pikku dev` builds it from env when it finds a **matching pair** —
292
+ `OPENAI_BASE_URL` + `OPENAI_API_KEY`, or `LITELLM_PROXY_URL` + `LITELLM_API_KEY`
293
+ — and registers it under `'*'`, so every `provider/model` prefix resolves
294
+ through it. With neither pair complete it builds nothing and every agent call
295
+ fails with `AIProviderNotConfiguredError` (a 503) — which reads like a broken
296
+ agent rather than a missing key, so check `.env` first. Mixing halves is worse
297
+ than missing them: a URL from one source with a key from the other 401s on every
298
+ call, so the pairs are taken whole, OpenAI first.
299
+
300
+ Two ways to fill them in:
301
+
302
+ **The Fabric AI gateway.** One key, and every model the gateway fronts —
303
+ OpenAI, Anthropic, Google, the OpenRouter catalogue — is reachable by id, billed
304
+ through your Fabric account rather than per-vendor:
305
+
306
+ ```bash
307
+ pikku fabric login
308
+ pikku fabric llm key --env >> .env # writes both pairs; --shell and --json also exist
309
+ ```
310
+
311
+ It mints or reuses a developer-scoped key against your Fabric login. `link` is
312
+ not required — the key is yours, not the project's.
313
+
314
+ **Your own vendor key.** `OPENAI_BASE_URL=https://api.openai.com/v1` with your
315
+ `OPENAI_API_KEY`, and model ids are then only the ones that vendor serves.
316
+
317
+ A deployed stage takes the same names through `pikku fabric secrets set` /
318
+ `variables set` — the agent units get their runner wired by the bundler, from
319
+ those values.
320
+
288
321
  ## Deploy
289
322
 
290
323
  ```bash
@@ -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