@akanjs/cli 3.0.0-alpha.99 → 3.0.0-beta.0
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/.build-stamp +1 -1
- package/{abstractCompactor-byytxs22.js → abstractCompactor-mjh5ppsq.js} +1 -1
- package/{agent.command-v1a4746v.js → agent.command-d2gfxfck.js} +5 -5
- package/{application.command-j05fr5jc.js → application.command-pj2m7584.js} +5 -5
- package/{applicationBuildRunner-s4gsk728.js → applicationBuildRunner-kxwbssgn.js} +2 -2
- package/{applicationReleasePackager-28v9vbzk.js → applicationReleasePackager-4yasxwv9.js} +1 -1
- package/buildBatch.proc.js +2 -2
- package/{capacitorApp-8d6d4zkm.js → capacitorApp-36rbr858.js} +2 -2
- package/{cloud.command-r6wvq2rp.js → cloud.command-jshwdq22.js} +9 -9
- package/{context.command-4vdt1aqq.js → context.command-ez8a0zyz.js} +13 -13
- package/{devTui-tnj2hh4f.js → devTui-40ggmd99.js} +85 -84
- package/{devTuiView-xzed2pd6.js → devTuiView-c08ccbat.js} +1 -1
- package/{guideline.command-p92dq6ts.js → guideline.command-xmqwx8zm.js} +11 -7
- package/guidelines/agentRule/agentRule.instruction.md +92 -28
- package/guidelines/conventions/conventions.instruction.md +97 -39
- package/guidelines/mcpRule/mcpRule.generate.json +11 -1
- package/guidelines/mcpRule/mcpRule.instruction.md +296 -53
- package/guidelines/queryRule/queryRule.instruction.md +17 -9
- package/guidelines/ssrRule/ssrRule.instruction.md +15 -13
- package/guidelines/workspaceOnboarding/workspaceOnboarding.instruction.md +10 -5
- package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +68 -3
- package/incrementalBuilder.proc.js +1 -1
- package/{index-gq44bqjt.js → index-259dsnz3.js} +2 -2
- package/{index-b9rkcrx8.js → index-2hq52jq3.js} +9 -7
- package/{index-qsq3q8fy.js → index-2tv3fdx4.js} +21 -10
- package/{index-qkk27bk6.js → index-4edzf1gr.js} +4 -4
- package/{index-yza7zkpt.js → index-600zhnve.js} +10 -10
- package/{index-srdssqj7.js → index-7737gxcp.js} +3 -3
- package/{index-zmjppp25.js → index-8pm30m73.js} +5 -5
- package/{index-p5neav9b.js → index-a64esmtb.js} +54 -9
- package/{index-0amdxycf.js → index-fz5q3gd7.js} +3 -3
- package/{index-fc1bh0a7.js → index-gyd220p2.js} +1 -1
- package/{index-5svjba73.js → index-j9yhg404.js} +1 -1
- package/{index-c69j78kk.js → index-kn1h7p1x.js} +1 -1
- package/{index-gtmz74tj.js → index-ktb5njgy.js} +2 -2
- package/{index-znynzt5w.js → index-mak3cd28.js} +1 -1
- package/{index-29sxqfaw.js → index-ne2qy0nk.js} +3 -3
- package/{index-hyywke0f.js → index-pnqbkf5p.js} +1 -1
- package/{index-8p8p0cf8.js → index-rwg2aa4f.js} +3 -3
- package/{index-5wd728df.js → index-v0qh106m.js} +215 -12
- package/{index-7d6vny25.js → index-ws5e0j51.js} +2 -2
- package/{index-1yxtbmbj.js → index-wt7qrtd4.js} +1 -1
- package/{index-gkkdx9kp.js → index-zry5nz81.js} +6 -6
- package/index.js +19 -19
- package/{library.command-s4fszngs.js → library.command-nbsky672.js} +3 -3
- package/{localRegistry.command-fq2w7cgz.js → localRegistry.command-yeq57wjr.js} +8 -8
- package/{module.command-6n7dwvav.js → module.command-29vqzrh8.js} +6 -6
- package/{package.command-gr82ypnr.js → package.command-axv0abmn.js} +3 -3
- package/package.json +2 -2
- package/{page.command-7gvgb3jb.js → page.command-vs9s3zrh.js} +3 -3
- package/{primitive.command-tk5ckw38.js → primitive.command-cbxmqbtc.js} +7 -7
- package/{quality.command-38v81baf.js → quality.command-gmbs7nws.js} +2 -2
- package/{repair.command-5xbwvsxp.js → repair.command-bqsjptsj.js} +5 -5
- package/{routeSourceValidator-ze6x894c.js → routeSourceValidator-apyqw7wk.js} +90 -17
- package/{scalar.command-0epwcr5k.js → scalar.command-6k1vhe9s.js} +5 -5
- package/{subspace.command-e2t36xb2.js → subspace.command-fwjfcts7.js} +7 -5
- package/templates/app/page/_index.tsx +3 -3
- package/templates/app/page/_layout.tsx +31 -32
- package/templates/appSample/page/task/[taskId]/_index.tsx +22 -27
- package/templates/appSample/page/task/[taskId]/edit.tsx +25 -30
- package/templates/appSample/page/task/_index.tsx +3 -7
- package/templates/appSample/page/task/_layout.tsx +4 -15
- package/templates/appSample/page/task/new.tsx +4 -10
- package/templates/crudPages/[__model__Id]/edit/page.tsx +26 -28
- package/templates/crudPages/[__model__Id]/page.tsx +20 -25
- package/templates/crudPages/new/page.tsx +24 -23
- package/templates/crudPages/page.tsx +25 -24
- package/templates/crudSinglePage/page.tsx +25 -24
- package/templates/lib/sig.ts +4 -0
- package/{workflow.command-kphbr7eh.js → workflow.command-nwpcdc8p.js} +10 -10
- package/{workspace.command-ag5hy887.js → workspace.command-8s050m0h.js} +20 -20
|
@@ -93,14 +93,24 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
|
|
|
93
93
|
model never saw is one it answers about from the filename. An adaptor declares `accepts: { image, document }` and
|
|
94
94
|
`AgentService.readable` degrades the rest, so a text-only provider needs no attachment code at all — DeepSeek
|
|
95
95
|
declares none, which is why an image against the default provider is refused out loud while an extracted PDF
|
|
96
|
-
works, `text` being readable by every model there is.
|
|
97
|
-
reaches the chat as an attachment rather than the literal `[image]` it used to become. Persisting keeps each
|
|
96
|
+
works, `text` being readable by every model there is. Persisting keeps each
|
|
98
97
|
attachment's name and drops its content: web storage is a few megabytes, one screenshot fills a chunk of it, and
|
|
99
98
|
a save that fails is silent — so keeping the bytes would quietly stop keeping the transcript.
|
|
100
99
|
**The ceilings are the message's, not the file's**: 4 MB per file, 8 MB and five files per message, and the same
|
|
101
100
|
file twice is refused by name. The bytes ride inside one turn's JSON, so what a provider refuses is the sum —
|
|
102
101
|
and a request that cannot be sent is one the user has to empty the composer to escape, which is why the refusal
|
|
103
|
-
happens at the paperclip and names the file it dropped.
|
|
102
|
+
happens at the paperclip and names the file it dropped. All three are defaults rather than the law —
|
|
103
|
+
`<Agent.Chat attachLimits={{ perFileBytes, perMessageBytes, perMessageCount }} />` — because what one request can
|
|
104
|
+
carry belongs to the configured provider and not to the framework. The per-file one is measured on **what the
|
|
105
|
+
reader produced**, not on the file that was picked: a reader that uploads and answers a `url` has already paid
|
|
106
|
+
what the ceiling exists for, so refusing its 6 MB photo would refuse it for a cost it does not incur. Only the
|
|
107
|
+
built-in path checks the source size, and there the file itself is the carrier.
|
|
108
|
+
- **`MessageAttachment.ref` is the host's own handle on the file — carried, never read.** A file id, a storage key,
|
|
109
|
+
whatever turns the attachment back into something a tool can be handed. The framework only moves it: the model is
|
|
110
|
+
never shown one, and a tool the app publishes (`useFileFieldTool` below) is what spends it. Without it a host that
|
|
111
|
+
stores its uploads keeps a map beside the transcript keyed on name and size — the same guess `Attachment.same`
|
|
112
|
+
has to make, and the one that is wrong for two crops of one export. So when both sides carry a `ref`, it *is* the
|
|
113
|
+
answer to "the same file twice", and the heuristic is what is left for when they do not.
|
|
104
114
|
- **Speech is one engine contract and the framework's own policy.** `<Agent.Chat voice={engine} />` takes a
|
|
105
115
|
`VoiceEngine` — `listen(handlers)` and `speak(sentence)`, both cancellable — and the chat decides everything
|
|
106
116
|
else: a press-to-talk microphone whose transcript lands in the composer to be corrected, one utterance per
|
|
@@ -131,6 +141,11 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
|
|
|
131
141
|
`data-agent-zone` container; guides follow the layout cascade (ancestors and own, never a sibling's). Zone
|
|
132
142
|
membership is positional — there is no per-declaration zone key, so a lib component joins whatever zone the app
|
|
133
143
|
mounts it in.
|
|
144
|
+
- **Two agents' turns run in parallel; their tool calls take turns.** Tool execution is serialized across the
|
|
145
|
+
whole page, because `AgentAbort` and `AgentProgress` reach the running call through a module slot rather than a
|
|
146
|
+
parameter — two calls in flight restore each other's slot and the second one's progress goes nowhere. Only the
|
|
147
|
+
execution queues: a zone parked on an approval card holds nothing up, since waiting for the user sits outside
|
|
148
|
+
the queue, and a call that is aborted releases it.
|
|
134
149
|
- **Everything a zone publishes is named `<id>.<name>`,** and that name is what instructions must use. A bare
|
|
135
150
|
`createVideoProject` inside a zone naming its tool `videoProjectDraft.createVideoProject` is a tool that does
|
|
136
151
|
not exist: the model calls it, the surface answers `Unknown tool`, and a turn is gone. Build the name from the
|
|
@@ -200,7 +215,12 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
|
|
|
200
215
|
globally and a component cannot build one per render: pass the list it has — a slice's sort keys, the options a
|
|
201
216
|
prop carried — and it is published and enforced the same way. Neither reaches a set that fills in *after* the
|
|
202
217
|
first render, since a declaration is mount-static; put that in the tool's `guard`, which is re-read per call and
|
|
203
|
-
can name the current values in its refusal. **
|
|
218
|
+
can name the current values in its refusal. **One array level of either is describable too** —
|
|
219
|
+
`.arg("bodies", [String])`, `.opt("modes", [TaskStatus])` — and `oneOf` then narrows the elements rather than the
|
|
220
|
+
list. It is the schema `fill<Model>Form` already builds for an array field, so the same shape reaching an agent
|
|
221
|
+
through a form and not through a component tool was an oversight: a tool that could not say "a list of these"
|
|
222
|
+
said it in prose, and the app then owned a parser for the format it invented. A second level is not describable.
|
|
223
|
+
**An argument type nothing can describe — a model class, `Any`, a
|
|
204
224
|
`Map` — withdraws the whole tool and says so on the console**, naming the tool, the argument and the type; the
|
|
205
225
|
callable still drives the click a person makes. It does not throw: a tool schema is built during render, and an
|
|
206
226
|
agent-tooling mistake that aborted the render would cost the route its server rendering. `st.useState`'s `set`
|
|
@@ -315,6 +335,29 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
|
|
|
315
335
|
for a person too: the options arrive when the dropdown opens, and an agent never opens it. `Field.ParentId` /
|
|
316
336
|
`Field.ChildrenId` need none of that — the id *is* the value, so the ordinary setter describes it. All four still
|
|
317
337
|
require the setter **by reference**, and a `disabled` picker publishes nothing.
|
|
338
|
+
- **An upload control reaches an agent the same way, through `useFileFieldTool`.** The clause after "picked **or
|
|
339
|
+
uploaded**" has its own hook: the control hands `read` and `label` and gets `load<Field>OptionsOn<Model>` — the
|
|
340
|
+
same name the relation picker publishes, because a form draws both side by side and a model that learned the
|
|
341
|
+
name on one field spells it the same on the next — plus the field's own `set<Field>On<Model>` taking
|
|
342
|
+
`<field>Id` / `<field>Ids`, with `add`/`sub` besides on an array field. **`read` omitted publishes nothing**: a
|
|
343
|
+
shared `Field.Img` forwards an optional prop, and a call site handing it none has no candidates and must not
|
|
344
|
+
grow a tool that refuses every id. `read` is the control's rather than the framework's because the ids are the
|
|
345
|
+
host's — a conversation carries `MessageAttachment.ref`s, and only whoever set them can turn one back into the
|
|
346
|
+
model a form field holds. akanjs draws no upload control itself, so this is the hook a lib's `Field.Img` /
|
|
347
|
+
`Field.Imgs` calls, exactly as `Field.Relation` calls the relation one.
|
|
348
|
+
- **`add`/`sub` on a file field are the only way in, not the weaker way.** For every other list of ids they are a
|
|
349
|
+
convenience the framework declines to pay a tool for — `useFieldTool` publishes them for embedded rows only, and
|
|
350
|
+
the relation picker not at all, because a picker's `read` is the whole resolvable set so rewriting the array is
|
|
351
|
+
always expressible. An upload control's `read` is what *this conversation* brought, and the field may already
|
|
352
|
+
hold files that predate it: naming the array whole drops those, since the ids that would keep them are ids the
|
|
353
|
+
guard has never heard of. **`max` / `min` are passed, not derived** — `ConstantField` carries `arrDepth`, which
|
|
354
|
+
says the value is a list and not how long a legal one is — and they reach the tool's *description* as well as
|
|
355
|
+
its guard, because a limit an agent can only learn by tripping the server's `Err` is the round trip the listing
|
|
356
|
+
tool exists to spare it. Two call sites rendering one field with different caps describe one name two ways, and
|
|
357
|
+
the surface warns, which is the right answer: a field has one cap. For the same reason the unknown-id refusal
|
|
358
|
+
**names the boundary rather than the absence** on a list: an id already on the field is real, on screen, and in
|
|
359
|
+
the form the agent just read, so "no such file" reads as the form being wrong instead of the verb — it says the
|
|
360
|
+
id is not among what is *offered*, and points at `sub<Field>On<Model>`, which does cover it.
|
|
318
361
|
- **An array of embedded rows also publishes `add<Field>On<Model>` and `sub<Field>On<Model>`** — append, and
|
|
319
362
|
remove-by-position — beside the whole-array setter. Not new authority: the setter can already produce any array
|
|
320
363
|
those two can, so they are strictly weaker. What they add is that neither can touch a row it was not given, and
|
|
@@ -367,18 +410,11 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
|
|
|
367
410
|
useless to a model — that is cheaper than every tool learning to avoid it.
|
|
368
411
|
|
|
369
412
|
## Slash Commands And The Transcript
|
|
370
|
-
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
is the
|
|
375
|
-
- **The chat answers six slash commands of its own**, listed in the same `/` menu ahead of the prompts:
|
|
376
|
-
`/new` (`/clear`), `/retry`, `/compact`, `/copy`, `/help` and `/tools`. An app writes none of them and cannot add
|
|
377
|
-
one — the extension point for a product's own command is a `prompt()` endpoint, which is guarded and server-side.
|
|
378
|
-
**A built-in wins a name collision with a prompt of the same name**, the mirror image of the tool rule: a
|
|
379
|
-
component's `st.tool` shadows a built-in it means to replace, but no library's prompt may take `/new` away from
|
|
380
|
-
the user who typed it — so a shadowed prompt is dropped from the menu rather than listed twice. `/new` and
|
|
381
|
-
`/copy` are also dispatched *before* the is-a-turn-running check **and before the question card takes the
|
|
413
|
+
- **The `/` menu is the chat's own.** An app's prompts are `page().prompt()` declarations served over MCP to
|
|
414
|
+
external agents; a browser chat lists none of them, so the menu holds the six built-in commands and nothing else.
|
|
415
|
+
- **The chat answers six slash commands of its own**: `/new` (`/clear`), `/retry`, `/compact`, `/copy`, `/help` and
|
|
416
|
+
`/tools`. An app writes none of them and cannot add one — a workflow a person invokes by name belongs to MCP, as
|
|
417
|
+
a `page().prompt()`, where it is guarded by the page it opens. `/new` and `/copy` are dispatched *before* the is-a-turn-running check **and before the question card takes the
|
|
382
418
|
composer**, because mid-turn is exactly when they are reached for and a question the agent asked is the middle
|
|
383
419
|
of a turn like any other — answered as text, `/new` would have reached the model as the user's decision.
|
|
384
420
|
`/new` therefore aborts the turn it is clearing and waits for it to wind down, since the loop clears its own
|
|
@@ -438,21 +474,24 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
|
|
|
438
474
|
row, Tab completing its name, and Escape closing the menu and then, pressed again, the panel.
|
|
439
475
|
|
|
440
476
|
## Built-In Tools
|
|
441
|
-
- The framework publishes five built-ins on every store surface: `navigate` (internal paths only, the same
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
is worth waiting for — publish an `st.tool` beside the
|
|
477
|
+
- The framework publishes five built-ins on every store surface: `navigate` (internal paths only, the same router
|
|
478
|
+
`Link` rides), `goBack` (this session's history — global, because history is not a control a page owns and a page
|
|
479
|
+
that draws no back link is not one you may not leave — a screen that must not be left withholds them with `builtins`,
|
|
480
|
+
not with prose), `readScreen` (the rendered DOM as compact text — headings, links, control values, and `(disabled)`
|
|
481
|
+
on a control or button that has it; the chat's own UI is skipped via `data-agent-ui`, and a password value is never
|
|
482
|
+
read), `readState(key)` (one masked store key — a key that is unknown or that this screen does not read is refused
|
|
483
|
+
*with the keys it can* read, the way `readScreen` names its sections), and `highlight(target)`. Declaring a hook tool
|
|
484
|
+
under one of those names shadows the built-in, so reuse them only to mean that. **There is no general-purpose wait**:
|
|
485
|
+
a built-in one was reachable on every screen and a model spent it on whatever key it liked, parking turns nobody
|
|
486
|
+
asked to park. Waiting belongs to the screen that knows what is worth waiting for — publish an `st.tool` beside the
|
|
487
|
+
control that starts the work, and let it await the work.
|
|
451
488
|
- **A tool that changes the screen waits for the screen before it answers.** `router.push` returns while the RSC
|
|
452
489
|
payload is still in flight and a store action that fires `void fetch.*` commits a tick later, so `navigate`
|
|
453
490
|
awaits `ScreenSettle.wait()` — DOM quiescence, bounded, because the client router hands its promise to nobody —
|
|
454
|
-
and the session awaits it after every
|
|
455
|
-
describes the moment before the change landed and the `readScreen` that follows reads the
|
|
491
|
+
and the session awaits it after every tool that did not declare `settle: false` before taking the change report.
|
|
492
|
+
Without it the report describes the moment before the change landed and the `readScreen` that follows reads the
|
|
493
|
+
page the user left. The reading built-ins (`readScreen`, `readState`, `highlight`) declare it, because a wait
|
|
494
|
+
costs 120ms of quiet at the very least and they change nothing a resource holds.
|
|
456
495
|
New tools and state from a fresh route are still only listed from the next turn: the catalogue is snapshotted
|
|
457
496
|
when the turn starts.
|
|
458
497
|
- **A tool that waits for its own work costs no model turns; one that returns early costs one round trip per
|
|
@@ -483,6 +522,13 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
|
|
|
483
522
|
one costs nothing, while two buttons reading "Save" are not the same control. **Nothing hidden ever resolves** —
|
|
484
523
|
a ring nobody can see reads as a broken tool, not as a miss. A section named by a heading is read to the next
|
|
485
524
|
heading of its level or higher.
|
|
525
|
+
- **An image is always named, and its address rides only when asked for.** `readScreen` prints `[image: <alt>]`,
|
|
526
|
+
and `[image]` for one with no `alt` — dropping the unlabelled one left a card that renders a picture reading
|
|
527
|
+
exactly like a card that renders nothing, and "this page shows no image" is the one answer the screen cannot
|
|
528
|
+
support. `readScreen({ images: true })` appends `(<src>)` to each, for handing one to a tool that takes a
|
|
529
|
+
picture; it is off by default because a gallery is one long URL per thumbnail, which is the read's whole budget
|
|
530
|
+
spent on the part of the screen it can say the least about. A `data:` URL is never printed — that is the bytes
|
|
531
|
+
themselves rather than somewhere to fetch them.
|
|
486
532
|
- **A screen is only aimable if its names are printed.** `readScreen` writes `(#anchor)` beside a heading that
|
|
487
533
|
opens an id'd or scoped container, and a truncated read ends with the headings below the cut — otherwise
|
|
488
534
|
everything past the 8000-character limit is unreachable, because nothing names it, and an agent asked to point
|
|
@@ -519,6 +565,24 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
|
|
|
519
565
|
a no-op when nobody is rendering it. The chat shows it on that call's row until the row resolves. It is the
|
|
520
566
|
browser twin of `McpProgress.report`. Import it and `AgentAbort` from `akanjs/store`: an app may not reach
|
|
521
567
|
`use-agentic` directly (`no-import-external-library`), and those two are the channels a long tool body needs.
|
|
568
|
+
- **One turn carries every call the model made in it, and they run in order.** An assistant turn may hold any
|
|
569
|
+
number of tool calls; the session executes them one at a time, in the order they arrived, and posts them back as
|
|
570
|
+
one `tool` message. A batch therefore costs **one** model round trip, where the same calls chained one per turn
|
|
571
|
+
cost a round trip *and* a resend of the whole transcript each — and then meet the turn cap halfway through. The
|
|
572
|
+
framework asks for the batch in its own half of the system prompt (`AgentService.preamble`); what an app adds is
|
|
573
|
+
a tool that does the whole job in one call, so there are fewer calls to batch in the first place. Two agents'
|
|
574
|
+
calls still take turns page-wide — see Zones.
|
|
575
|
+
- **A chain of ten calls is usually a model checking its own work, not ten things to do.** Measured against the
|
|
576
|
+
provider, the batch itself lands in one turn; what follows it is `readScreen → readState → readScreen`, one read
|
|
577
|
+
per turn, confirming a write whose change report already said the same thing. The preamble tells the model not
|
|
578
|
+
to, which is worth a turn and a half on a plain "approve what is pending". So a tool that returns *what
|
|
579
|
+
changed* pays for itself twice — once as the answer, once as the reads it stops. The read a fresh route needs
|
|
580
|
+
after `navigate` is acquisition rather than confirmation and is unaffected.
|
|
581
|
+
- **A batch reports each changed resource once, at the end.** Every call takes its own before-and-after, so eight
|
|
582
|
+
approvals would each attach the whole list they approved from — and by the time the model reads the message,
|
|
583
|
+
only the last copy is still true. The superseded ones are dropped (`ToolOutput.deduped`), so a resource several
|
|
584
|
+
calls touched appears once, as it finally stands, on the last result that touched it. What each call did is
|
|
585
|
+
still its own `result`; do not compensate by returning the collection from every write.
|
|
522
586
|
- **The turn cap is a question, not a dead end.** At `maxTurns` the session asks whether to keep going through the
|
|
523
587
|
same card `askUser` uses, and the answer rides as the user's own turn — so a steer typed instead of the
|
|
524
588
|
keep-going choice reaches the model as guidance. A host that renders no `pendingQuestion` passes no
|
|
@@ -403,7 +403,9 @@ framework has no hard delete for a model table, and `delete` is deliberately lef
|
|
|
403
403
|
The facade spells `Many`/`One` out on its writes (`updateOne` / `updateMany` / `removeOne` / `removeMany`); only the
|
|
404
404
|
count was shortened to `count(query)`, with `countDocuments` kept as `@deprecated`. `updateById(id, update)` and
|
|
405
405
|
`removeById(id)` are those same query-level writes narrowed to one id, **not** the document path: they fire no hooks,
|
|
406
|
-
so a model whose removal cascades or carries a `_postRemove` still goes through `remove<Model>(id)`.
|
|
406
|
+
so a model whose removal cascades or carries a `_postRemove` still goes through `remove<Model>(id)`. The
|
|
407
|
+
write-through pair is `pickAndWrite(id, data)` / `pickOneAndWrite(query, data)` — pick the document, write into it
|
|
408
|
+
and save it, so the save hooks do run; reach for those when the write has to be seen by them.
|
|
407
409
|
|
|
408
410
|
**`<model>.service.ts`** — keep methods to a few lines: load → chain → `return await ….save()`. Write `return await`
|
|
409
411
|
explicitly in tail position; do not "optimize" it away. Side effects belong in `override async _preUpdate` /
|
|
@@ -470,7 +472,7 @@ Full contract — credential handshake, room revalidation, socket cleanup scopin
|
|
|
470
472
|
- **The guards are also the MCP exposure decision** — see MCP Exposure. An endpoint that names none is not published to agents at all, and a mutation whose only guard is `Public` is refused, so a missing `guards` array now costs visibility as well as authorization.
|
|
471
473
|
- Resource guards are `Can<Verb><Model>` classes in `srvkit/guards.ts` that `implements Guard` with an `async canPass(context)`. They **fail closed**: no resource named ⇒ `false`; a load that throws ⇒ `logger.warn` then `false`. Admin bypass goes first.
|
|
472
474
|
- Keep `static name = "User";` on guard classes. `fetch` serializes guard names and the API explorer filters on them; it looks like dead code, and deleting it breaks the UI. Comment it so the next reader knows.
|
|
473
|
-
- **Every guard class also declares `static scope: GuardScope`, and it is required with no default.** `"account"` means the verdict reads the caller and nothing about the call, so it can be evaluated with no arguments — which is what lets an MCP listing hide what this caller certainly cannot use. `"resource"` means it needs the call's arguments (`context.getArg()`) and fails closed without them, so it is never evaluated for a listing: the entry stays visible and is stopped at call time. Getting it wrong is not a type error, so the marker is mandatory rather than defaulted — `SignedIn` / `Admin` / role checks are `"account"`, and every `Can<Verb><Model>` is `"resource"`.
|
|
475
|
+
- **Every guard class also declares `static scope: GuardScope`, and it is required with no default.** `"account"` means the verdict reads the caller and nothing about the call, so it can be evaluated with no arguments — which is what lets an MCP listing hide what this caller certainly cannot use. `"resource"` means it needs the call's arguments (`context.getArg()`) and fails closed without them, so it is never evaluated for a listing: the entry stays visible and is stopped at call time. Getting it wrong is not a type error, so the marker is mandatory rather than defaulted — `SignedIn` / `Admin` / role checks are `"account"`, and every `Can<Verb><Model>` is `"resource"`. An `"account"` guard that throws at listing time reached for arguments it does not have: the entry is hidden from every caller and the guard is named once in the log as mismarked. A guard that admits no model at all — `Person` — adds `static agents = false`; the MCP catalogue then refuses every endpoint it guards outright, so the person-only act is absent from the document rather than hidden per caller.
|
|
474
476
|
- The acting user arrives via `.with(Self)` / `.with(CurrentUserId)` / `.with(Me)`. Never trust a client-supplied id.
|
|
475
477
|
- Guards ship with the library that owns the model and are imported by its own signals through the package path, so a mounting app inherits authorization and cannot forget it.
|
|
476
478
|
- Services re-check ownership even when a guard already gated the call — two independent gates.
|
|
@@ -515,7 +517,12 @@ Full contract — filter-arg `ref` pickers, `getQueryMeta` summary counters, `la
|
|
|
515
517
|
`[object Object]`. It is an admin API: `root:` is always `Admin`.
|
|
516
518
|
- A slice's `exec` returns a `QueryOf` (an opaque query descriptor); you **cannot** chain `.sort()`/`.limit()` on
|
|
517
519
|
it. Apply ordering/paging via the store `init` fetch option instead: `initX(..., { sort, page, limit })`. For a
|
|
518
|
-
chainable builder use the model facade's `
|
|
520
|
+
chainable builder use the model facade's `find`/`findOne`.
|
|
521
|
+
- **A projection is bare on the facade and nested under `select` on a filter accessor.** `pickById(id, { secret:
|
|
522
|
+
true })` and `find`/`findOne`/`findById`/`pickOne` take the projection as their second argument; a generated
|
|
523
|
+
`findBy<Filter>(...args, { select: { secret: true } })` takes it inside the option object. The shapes do not swap
|
|
524
|
+
— `{ select: … }` handed to the facade projects a field named `select`, which no model has, and the read comes
|
|
525
|
+
back empty with no error. This is the only way to read a `field.secret(...)` value.
|
|
519
526
|
- **A slice list is either one window or an accumulated one.** `setPageOf<Model>` swaps the window;
|
|
520
527
|
`loadMoreOf<Model>()` appends the rows after the ones loaded and takes **no page number** — it skips by
|
|
521
528
|
`<model>List.length`, so the offset cannot drift from what is on screen when a live insertion moves the server's
|
|
@@ -598,6 +605,7 @@ Conventions that hold for both shapes:
|
|
|
598
605
|
- Best-effort code returns a sentinel (`null`, `undefined`, `[0, 0]`, `{}`). There are no Result/Either wrappers.
|
|
599
606
|
- `try/catch` is rare and always converts an exception into a decision, never swallows one. Guards catch → `logger.warn` → `return false`; adapters catch → `logger.error` → `return null`; UI uses `try/finally` to reset a spinner. A bodyless `catch {}` is acceptable only with a one-line reason.
|
|
600
607
|
- Store actions do not `try/catch` — let the framework toast the `Err`. Client-side validation failure is `msg.error("<key>")` plus an early return, never a throw.
|
|
608
|
+
- **A failure another process reported travels as itself.** A server-to-server `fetch.<endpoint>(..., { origin })` restores the remote `Err` — the same class, key and `data` — so a hop that rethrows it answers its own caller with that error and the dictionary translates it. Never wrap the catch in a `new Error`, and never re-key it as a `new Err` of your own: both discard what the endpoint chose to say, and a bare `Error` is generalized to `Internal Server Error` on the way out.
|
|
601
609
|
|
|
602
610
|
### MCP Exposure
|
|
603
611
|
|
|
@@ -616,16 +624,41 @@ it.
|
|
|
616
624
|
slice and generated CRUD only, never a named slice or a custom endpoint. It is an opt-**out**, and it is
|
|
617
625
|
curation, not authorization: HTTP serves the endpoint exactly as before.
|
|
618
626
|
- **Narrow by cost, and read the boot log first.** MCP forbids a `$ref` across entries, so every entry inlines the
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
627
|
+
schema of every model it mentions and the listing is re-sent whole to every agent that connects. By default a
|
|
628
|
+
response schema names a nested model instead of inlining it (`outputSchema: "shallow"`; `"full"` restores the
|
|
629
|
+
closure, `"none"` drops the schema and keeps the text block) and a request schema asks for a relation's id, which
|
|
630
|
+
is what the wire carries. `MCP catalogue: … · listing 214KB` plus a per-signal `MCP catalogue cost:` line says
|
|
631
|
+
where the rest went; `mcp: { cru: false }` on a model is the next lever. `outputSchema` moves wire bytes and client
|
|
632
|
+
memory only — the model receives names, descriptions and input schemas (measured: 219 tools ≈ 41k tokens either
|
|
633
|
+
way) — so tool count and the input model behind `create*` / `update*` are what cost context. In one line:
|
|
634
|
+
`Person` for an act reserved for a person, `mcp: { cru: false }` for the tools whose input models cost the model
|
|
635
|
+
tokens, `outputSchema` for nothing that reaches the model.
|
|
622
636
|
- Settings live in the app's `lib/option.ts` — `option.setMcp({ enabled, readOnly, path, version, instructions,
|
|
623
|
-
allowedOrigins, pageSize, language, auth })`,
|
|
624
|
-
`AKAN_MCP_READONLY=true` is the read-only-deployment
|
|
625
|
-
whole surface off.
|
|
637
|
+
allowedOrigins, pageSize, language, outputSchema, rateLimit, auth })`, or `setMcp((env) => …)` for one derived from the server env, **not
|
|
638
|
+
`main.ts`**. Each field has an `AKAN_MCP_*` env spelling. `AKAN_MCP_READONLY=true` is the read-only-deployment
|
|
639
|
+
valve, not the exposure switch; `AKAN_MCP=false` takes the whole surface off. Every call that executes an
|
|
640
|
+
endpoint is rate-limited per caller (120 a minute, 8 in flight, per process; 429 + `Retry-After`) — `rateLimit`
|
|
641
|
+
/ `AKAN_MCP_RATE_LIMIT` tunes or disables it.
|
|
642
|
+
- **Authentication is the same server.** An app mounting `libs/shared` serves the OAuth 2.1 authorization server
|
|
643
|
+
beside `/mcp` — `/.well-known/oauth-authorization-server`, `/oauth/authorize`, `/oauth/token`, `/oauth/register`
|
|
644
|
+
(RFC 7591; Cursor's `cursor://` redirect admitted) plus the consent page `libs/shared/page/oauth/consent` — and
|
|
645
|
+
names itself as the issuer, so `/mcp` demands a bearer token, verifies its signature, and refuses one carrying no
|
|
646
|
+
`aud`. A token is the app's own access JWT plus `aud`/`iss`/`client_id`, so `AccountMiddleware` and the guards
|
|
647
|
+
judge it unchanged; there is no scope. Per-app knobs live in `env.server.*` under `oauth` (`consentPath` and
|
|
648
|
+
`signinPath` with the basePath, `clients`, `dynamicRegistration`, `allowedRedirectSchemes`, `clientIdMetadata`,
|
|
649
|
+
`enabled`). `/mcp` reads the `Authorization` header only — a cookie is dropped at the door — and `JWT_SECRET` is
|
|
650
|
+
mandatory outside `local`, because the derived fallback is forgeable from three public names. A grant is revoked
|
|
651
|
+
whole — `POST /oauth/revoke` (RFC 7009) by the client, `fetch.revokeOAuthConnection(sessionId)` by the account's
|
|
652
|
+
owner, `fetch.listOAuthConnections()` to see them — and a revoked access token is refused at its next call.
|
|
653
|
+
- **A person may, a model may not.** An agent's token names its `client_id` and `aud`; a browser session has
|
|
654
|
+
neither, and `context.origin` is `"mcp"` for a call through the MCP endpoint. `guards: [Every, Person]` refuses a
|
|
655
|
+
model and, because `Person` declares `static agents = false`, takes the entry out of the MCP catalogue document
|
|
656
|
+
itself (named in the boot log like `mcp: false`); `.with(AgentCall)` hands an endpoint the same verdict as a
|
|
657
|
+
boolean to narrow what the call sets in motion; `isAgentCall(context)` is the function behind both
|
|
658
|
+
(`@libs/shared/srvkit`). Never sniff `aud` through a cast.
|
|
626
659
|
- **The refusals are fail-closed**: a declared `mcp: false`, an endpoint with no `guards`, a mutation with no real
|
|
627
660
|
guard, `pubsub` and `message`, an `Any` or `Upload` return, a file upload, a required `Any` argument, and the
|
|
628
|
-
generated `light<Model>` read.
|
|
661
|
+
generated `light<Model>` read. An `enumOf` argument is checked against its values on every path. **Every refusal
|
|
629
662
|
is named in the boot log**, along with every published entry missing a dictionary `.desc()` — an agent picks a
|
|
630
663
|
tool by its description.
|
|
631
664
|
- A refused endpoint answers the *same* "unknown tool" as one that does not exist, and a guard's refusal is
|
|
@@ -633,10 +666,14 @@ it.
|
|
|
633
666
|
- **A `field.visual` field is stripped from every MCP result** and from the readable schema so the two agree.
|
|
634
667
|
- **A structured result ships twice by default** — as `structuredContent` and as the same JSON in the text block.
|
|
635
668
|
`option.setMcp({ legacyTextBlock: false })` leaves a pointer there instead.
|
|
636
|
-
-
|
|
637
|
-
`
|
|
638
|
-
|
|
639
|
-
|
|
669
|
+
- **A prompt is a screen: `page().prompt(name, description)`.** There is no `prompt()` on `endpoint()`.
|
|
670
|
+
`prompts/get` runs the page's body in the RSC worker under the caller's token — nothing is rendered — and answers
|
|
671
|
+
the description, one `resource` per `fetch.*` the page made (masked by the endpoint's return model, addressed by
|
|
672
|
+
its `akan://` uri, one document once whatever shapes read it) and the published tools of the modules it fetched
|
|
673
|
+
from, minus what is already attached; lists — only lists — are cut to `promptBudget`. A guard refusing a query
|
|
674
|
+
inside the body reads as the screen refusing the account, the same way a redirect does. A
|
|
675
|
+
required argument left out answers a pointer to the `<model>List…` tool, never a guess. The description is the
|
|
676
|
+
only instruction, in English — `<Agent.Guide>` never reaches MCP, and the in-page chat lists no page prompts.
|
|
640
677
|
|
|
641
678
|
Full contract — configuration, wire behaviour, resource URIs, OAuth metadata, protocol revisions, and
|
|
642
679
|
`McpProgress.report`: `get_guideline` with `mcpRule`, or `akan guideline show mcpRule`.
|
|
@@ -745,10 +782,11 @@ shape, so `cascade` never means "related" — it means one of exactly these:
|
|
|
745
782
|
forms: a relation, an id with `ref`, or a polymorphic id with `refPath`. An array, a Map, `ref` together with
|
|
746
783
|
`refPath`, and a field naming no owner each fail the class build.
|
|
747
784
|
- **A `refPath` must name an `enumOf` field** — a free-form owner type is unknowable at build time. The one
|
|
748
|
-
exception is opt-in and priced: `
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
785
|
+
exception is opt-in and priced: `cascade: "removeWithAny"` takes a free-form `String` type field holding the
|
|
786
|
+
owner's refName, and sweeps for children on **every** model's removal. The sweep is one indexed probe, but a
|
|
787
|
+
single wildcard edge turns every cascade in the app back to one document at a time; the boot log names the
|
|
788
|
+
edges in one `info` line. The action carries its own `refPath` in the option type, so the widening cannot be
|
|
789
|
+
declared apart from the direction it widens.
|
|
752
790
|
- **A cascade goes through the target's service, never its model** — that path is what runs the target's
|
|
753
791
|
`_postRemove`, where a module puts the side effect the removal has to carry.
|
|
754
792
|
- **Nothing checks whether another document still references the same target.** `File` in particular is deduped by
|
|
@@ -767,39 +805,50 @@ shape, so `cascade` never means "related" — it means one of exactly these:
|
|
|
767
805
|
- Reserved `_*.tsx` route filenames are limited to `_index.tsx`, `_layout.tsx`, and `_overrides.tsx`; do not add files like `_Component.tsx` or `_helper.tsx`.
|
|
768
806
|
- Page filenames must not start with an uppercase letter. Move helper components like `Component.tsx` to app `ui`, `common`, or `lib` instead.
|
|
769
807
|
- Dynamic segments use `[id]`; route groups use directories like `(user)`, `(public)`, `(tab)`, or `(detail)`.
|
|
770
|
-
-
|
|
808
|
+
- **A route file exports one thing**: `export default page()…render(…)` for a page, `layout()…render(…)` for a
|
|
809
|
+
`_layout.tsx`, `rootLayout()…render(…)` for the app's (or a basePath's) root `_layout.tsx`. Every route setting
|
|
810
|
+
is a stage of the chain — `.param()`, `.search()`, `.config()`, `.head()` / `.metadata()`, `.loading()`;
|
|
811
|
+
`.notFound()` / `.error()` on a layout; `.fonts()` / `.theme()` / `.manifest()` / `.reconnect()` / `.wsConnect()`
|
|
812
|
+
/ `.layoutStyle()` / `.gaTrackingId()` on the root layout; `.prompt()` on a page. A named export beside the chain,
|
|
813
|
+
or `page()` in a `_layout.tsx`, fails the build.
|
|
814
|
+
- **A page names every `[x]` segment of its path with `.param("x", Type)`** and reads a query key only through
|
|
815
|
+
`.search("k", Type)`. Values arrive typed — `ID`/`String` → string, `Int`/`Float` → number, `Boolean`, `Date` →
|
|
816
|
+
Dayjs, an `enumOf` class → its union, `[T]` → array — a path value the type refuses answers not-found, and a
|
|
817
|
+
search value that fails is dropped. A layout may declare a subset. Names are string literals: `akan sync` reads
|
|
818
|
+
them off the source and refuses a `[projectId]` folder whose page declares no `.param("projectId")`. `lang` is
|
|
819
|
+
never declared: every route sits under `/:lang`, and the locale segment reaches every stage as `lang`.
|
|
771
820
|
- `_overrides.tsx` is a logic-free UI-override manifest: imports plus a single `export default override({ Slot: AppComponent })` (from `akanjs/ui`), no `"use client"`. It re-skins framework `akanjs/ui` components for its route subtree; nested manifests merge over ancestors slot-by-slot (closest wins). See the UI Customization reference for the slot list.
|
|
772
|
-
-
|
|
821
|
+
- The legacy shape — `export default async function Page` beside `pageConfig` / `head` / `Loading` exports — still
|
|
822
|
+
loads and logs one deprecation warning per file at boot. Migrate with the `workspaceRecipes` recipe "Migrating
|
|
823
|
+
`page/` to the route chain"; new files are never written in it.
|
|
773
824
|
- `libs/<lib>/page` follows the same rules and ships routes to apps that opt in with `syncPageLibs` in `akan.config.ts`: `true` takes every lib dep that has a `page` folder, an array takes the libs listed, `false` (the default) syncs nothing.
|
|
774
825
|
- `akan sync` links those routes into `apps/<app>/page/(libs)/(<lib>)` — once per basePath when the app declares subRoutes. The folder is generated and gitignored; edit the lib source, never the link.
|
|
775
826
|
- Both path segments are route groups, so a lib route mounts at its own path (`libs/<lib>/page/login/_index.tsx` serves `/login`). Two synced routes that resolve to the same pattern are a sync-time error.
|
|
776
|
-
-
|
|
827
|
+
- `.config({ devOnly: true })` keeps a route out of `akan build` while it keeps serving under `akan start` and keeps being typechecked. On a `_layout.tsx` it excludes every route under that directory too. Write it as a literal `true`/`false` — the build reads it off the source without evaluating the module.
|
|
777
828
|
- Before changing route behavior, check `pkgs/akanjs/server/routeTreeBuilder.ts` and nearby routes for the expected pattern.
|
|
778
829
|
|
|
779
830
|
### Page Body Shape
|
|
780
831
|
|
|
781
832
|
```tsx
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
return <Task.Zone.Card init={taskInitInOrg} prefix={`/org/${orgId}`} />;
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
export const pageConfig = { transition: "stack" } satisfies PageConfig;
|
|
833
|
+
export default page()
|
|
834
|
+
.param("orgId", ID)
|
|
835
|
+
.config({ transition: "stack" })
|
|
836
|
+
.render(async ({ orgId }) => {
|
|
837
|
+
const { l } = usePage();
|
|
838
|
+
getSelf({ unauthorize: "/signin" });
|
|
839
|
+
const [{ org }, { taskInitInOrg }] = await Promise.all([fetch.viewOrg(orgId), fetch.initTaskInOrg(orgId)]);
|
|
840
|
+
return <Task.Zone.Card init={taskInitInOrg} prefix={`/org/${orgId}`} />;
|
|
841
|
+
});
|
|
795
842
|
```
|
|
796
843
|
|
|
797
|
-
- There is no `loader=` / `render=` page prop
|
|
798
|
-
|
|
799
|
-
- Body order
|
|
844
|
+
- There is no `loader=` / `render=` page prop and no `PageProps` interface: the chain declares the arguments and
|
|
845
|
+
`.render()` receives them flat, typed, with `lang` on every route and `children` added for a layout.
|
|
846
|
+
- Body order inside `.render()`: `usePage()`, auth, fetch, return.
|
|
800
847
|
- Run independent fetches through `Promise.all`, even when there is only one.
|
|
801
848
|
- Gate auth at `_layout.tsx`; repeating `getSelf({ unauthorize: "/signin" })` in the page is fine and common.
|
|
802
|
-
-
|
|
849
|
+
- `async` on the render callback only when the body awaits; the callback is not a React component, so it takes no
|
|
850
|
+
name and no return type.
|
|
851
|
+
- Publish a screen to agents with `.prompt(name, description)` — see MCP Exposure.
|
|
803
852
|
- No `useState`, no `useEffect`, and no comments in page files.
|
|
804
853
|
|
|
805
854
|
## Akan Sync Conventions (`apps/**`, `libs/**`)
|
|
@@ -872,9 +921,18 @@ when two shapes disagree.
|
|
|
872
921
|
one — `host` for the dev host, gateway and RSC worker, plus a row per replica the gateway forwards — and the
|
|
873
922
|
selected one's log beside it. `Tab` walks every row and `1`-`9` jump to that app (the rail shows each
|
|
874
923
|
app's digit), `↑↓` scroll (shift for a page, `G` back to following),
|
|
875
|
-
`/` greps, `e` shows stderr only, `c` clears, `
|
|
924
|
+
`/` greps, `e` shows stderr only, `c` clears, `y` copies, `Y` copies the log path, `o` opens the browser,
|
|
925
|
+
`r` restarts that app, `q` quits.
|
|
876
926
|
**`--plain` prints prefixed interleaved lines instead**, and a pipe, a redirect or an unsized terminal
|
|
877
927
|
downgrades to that on its own.
|
|
928
|
+
- **A supervised session writes `local/apps/<app>/runtime/dev.log`, and that path is how a log gets handed over.**
|
|
929
|
+
Ink repaints one frame in place, so the full-screen view leaves a bordered screenshot in the scrollback and
|
|
930
|
+
takes the rest of the session with it — a drag-selection is cleared by the next repaint, and what survives
|
|
931
|
+
carries borders and lines truncated to the pane. The file is the same output with no ANSI, nothing truncated,
|
|
932
|
+
and every process of the app in arrival order, including the dev host's own build output, which reaches no
|
|
933
|
+
other file. The previous session is kept beside it as `dev.prev.log`. Hand somebody — or an agent — the
|
|
934
|
+
path rather than a paste: it costs one line instead of a transcript, and it can be re-read after a fix.
|
|
935
|
+
In the view, `y` copies the lines the filters have already narrowed and `Y` copies the path.
|
|
878
936
|
- **How many apps boot at once is the machine's answer, not a constant.** A cold boot build is the builder's
|
|
879
937
|
RSS peak (~900MB per app), so the default wave is what memory and cores allow — half the memory budget
|
|
880
938
|
divided by that peak, and one app per four cores — which boots a laptop's apps together and still staggers
|
|
@@ -14,7 +14,17 @@
|
|
|
14
14
|
],
|
|
15
15
|
"update": {
|
|
16
16
|
"filePath": "./mcpRule.instruction.md",
|
|
17
|
-
"contents": [
|
|
17
|
+
"contents": [
|
|
18
|
+
"exposure rule",
|
|
19
|
+
"configuration",
|
|
20
|
+
"declaring endpoints",
|
|
21
|
+
"refusals and wire behaviour",
|
|
22
|
+
"prompts",
|
|
23
|
+
"rolling out to an app",
|
|
24
|
+
"choosing what to open",
|
|
25
|
+
"writing instructions",
|
|
26
|
+
"verifying an app"
|
|
27
|
+
],
|
|
18
28
|
"rules": [
|
|
19
29
|
"Use current Akan APIs only",
|
|
20
30
|
"State the rule before the reason, and keep the reason to one clause",
|