@zerotal/arch 1.7.5 → 1.8.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/CHANGELOG.md CHANGED
@@ -13,6 +13,58 @@ against. `mcp-surface.md` is diffed alongside it.
13
13
 
14
14
  ## [Unreleased]
15
15
 
16
+ ## [1.8.0] — 2026-08-24
17
+
18
+ ### Added
19
+
20
+ - **Agent skills — the depth the upfront block cannot afford.** The generated `AGENTS.md`
21
+ is short because every prompt it lands in pays for its whole length, so it points rather
22
+ than teaches. That constraint has a cost: an agent gets a map and no detail, and the
23
+ detail is where the expensive mistakes live. A skill is a file with a one-line
24
+ description that costs nothing until an agent decides it is relevant, so a procedure can
25
+ be written out in full. Two ship — `zerotal-schema-changes` (who owns the schema here,
26
+ the mixin columns nothing declares, and why an unguarded `ALTER TABLE` collides during a
27
+ release's `migrate`) and `zerotal-releases` (naming your own deploy steps, replacing the
28
+ asset directory rather than merging into it, `trustedProxies` behind a proxy, and the
29
+ pipe that hides a test suite's exit status). Both are shaped by the project like the
30
+ block is: the schema skill states this app's actual answer, and refuses to pick a side
31
+ when `synchronize` and migrations are both in play.
32
+
33
+ Written to `.agents/skills`, the cross-client path, plus `.claude/skills` when that agent
34
+ is detected. Frontmatter descriptions are JSON-quoted, because unquoted a colon makes the
35
+ rest a nested mapping and the file stops parsing — an inert skill looks exactly like an
36
+ installed one. Overriding a shipped skill is deleting its marker line: a `SKILL.md`
37
+ without the marker is yours and is never rewritten. Turn the whole feature off with
38
+ `ArchConfig({ skills: false })`.
39
+
40
+ - **`doctor` reports agent instructions that no longer describe this project.** The block
41
+ used to describe the framework, which moved when the framework did. It now also describes
42
+ the project, and every fact in it moves without anyone thinking about the file — add a
43
+ migrations directory, turn `synchronize` off, install a package. It still reads as current
44
+ while describing the app you used to have, and guidance that is confidently out of date
45
+ gets followed. Skills rot the same way and are easier to miss, because nothing reads one
46
+ until an agent decides it is relevant, by which point it is being acted on. The check
47
+ regenerates both in memory and compares, naming `arch:update` as the fix. A warning, never
48
+ a failure: a misleading instruction file does not stop an application working, and this
49
+ check earns the right to gate a deploy by failing only for what would. The server name is
50
+ read back from `.mcp.json` rather than assumed, so a project that renamed its server is
51
+ not reported as permanently stale.
52
+
53
+ ### Changed
54
+
55
+ - **The generated `AGENTS.md` block describes how this app is set up, not only what it
56
+ installed.** A package list answers "what is available here", which is not the question that
57
+ decides what to write: the framework's contracts are not uniform across projects, and the
58
+ places they differ are the places where guessing wrong compiles cleanly and fails at runtime.
59
+ `detectShape()` reads four facts off disk and the block states only the ones that change an
60
+ instruction — who owns the schema, whether route names are typed, whether
61
+ `exactOptionalPropertyTypes` or `noUncheckedIndexedAccess` are on, and whether there are
62
+ tests to run. Read from files rather than a booted app, because a project that will not boot
63
+ is often why the agent surface is being installed; `.env` is deliberately not among them,
64
+ since this output is committed and pasted into prompts. The `extends` chain is followed, so
65
+ an app inheriting a strict base is not read as unstrict. Additive — a caller that passes no
66
+ shape gets the block it got before.
67
+
16
68
  ## [1.7.5] — 2026-08-22
17
69
 
18
70
  ### Changed
package/api-surface.md CHANGED
@@ -94,6 +94,7 @@ interface ArchConfigShape = {
94
94
  mcpConfig: boolean
95
95
  mcpConfigPath: string
96
96
  serverName: string
97
+ skills: boolean
97
98
  }
98
99
 
99
100
  interface Detected = {
@@ -118,6 +119,7 @@ interface DoctorReport = {
118
119
  interface GuidelineOptions = {
119
120
  packages: string[]
120
121
  serverName: string
122
+ shape?: ProjectShape
121
123
  }
122
124
 
123
125
  interface InstalledPackage = {
package/docs/changelog.md CHANGED
@@ -27,6 +27,169 @@ the section for every version you cross and apply its migration notes, not only
27
27
  majors. [Releases and versioning](/docs/support-policy#releases-and-versioning) explains
28
28
  when that carve-out ends.
29
29
 
30
+ ## 1.8.0 — 2026-08-24
31
+
32
+ The first render mode, the codemod runner 2.0 depends on, and four failures that
33
+ each looked like something other than what they were.
34
+
35
+ ### Added
36
+
37
+ - **`static interactive = false` — the first rung of Flow's render modes.** Every component
38
+ until now was maximally interactive: rendered on the server, dehydrated into a snapshot,
39
+ tracked by the client, reachable over a socket. Right for a counter, wasteful for a nav rail.
40
+ A static component is rendered in full by its parent and nothing else — no `onDehydrate`, no
41
+ snapshot, no `<script type="application/json">`, no entry in the client's registry, and no
42
+ `data-flow-root`, which would freeze it at its first render since its only route to an update
43
+ is the parent re-rendering it. It takes no place in `_childIds` and does not shift its
44
+ interactive siblings' ids, so no sibling remounts and loses its state when a static one
45
+ appears above it. `lazy`, `defer` and `stream` throw rather than being ignored: each waits
46
+ for the client to ask for the real render, and a static child never registers to do the
47
+ asking.
48
+
49
+ Opt-in — nothing existing changes. `this.isInteractive` reports the mode from inside the
50
+ component, and being a new public member it takes that name away from applications; it is on
51
+ the documented reserved list. See [Static children](/docs/flow/layouts#static-children).
52
+
53
+ - **`zt upgrade` — the codemod runner.** The 2.0 ledger's rule is that every entry that can
54
+ have a codemod has one before 2.0 ships, and until now nothing had been built, which made
55
+ the ledger a list of changes nobody could afford to make.
56
+
57
+ **Dry by default**, which is backwards from most tools and deliberate: it rewrites source
58
+ across a whole project, and the first run should be something you can read and disagree with.
59
+ `--write` applies it. Nothing is written until the whole plan is known, so a run that fails
60
+ halfway leaves no half-upgraded tree, and `--dry` exercises the same code path as the real
61
+ thing rather than a parallel one that can drift from it. Codemods see each other's output,
62
+ because two of them touching one file across a version range is ordinary.
63
+
64
+ **What it could not do is the headline.** A codemod that walks past what it does not
65
+ understand is worse than none, since the changes it _did_ make imply the job is finished. So
66
+ every codemod returns two lists and the runner prints the second last and loudest, with file,
67
+ line and a reason. The first codemod covers the deprecated aliases — `BaseModel` → `Model`,
68
+ `routes:types` → `route:types`, `serve --dev` → `dev`. See [Commands](/docs/commands).
69
+
70
+ - **`--clean` on `assets:build` and `inertia:build`.** Pruning is conservative by default:
71
+ chunk-shaped filenames, plus whatever the last build on this machine recorded in `.zerotal/`.
72
+ That cannot recognise output some other naming produced. `--clean` needs no record — the
73
+ output directory belongs to the build, and what the build did not write does not belong in
74
+ it. It refuses `public/` and the project root, where deleting what was not rebuilt takes the
75
+ app's images and favicon with it, which is the one failure here that building again cannot
76
+ undo. Pruning stays the default; only you can say the directory holds nothing else.
77
+
78
+ - **Agent skills, from `@zerotal/arch`.** `AGENTS.md` is short because every prompt it lands
79
+ in pays for its whole length, so it points rather than teaches. That has a cost: an agent
80
+ gets a map and no detail, and the detail is where the expensive mistakes live. A skill is a
81
+ file with a one-line description that costs nothing until an agent decides it is relevant,
82
+ so a procedure can be written out in full. Two ship — one on changing the schema (who owns
83
+ it in your app, the mixin columns nothing declares, and why an unguarded `ALTER TABLE`
84
+ collides during a release's `migrate`) and one on shipping a release (naming your own deploy
85
+ steps, replacing the asset directory rather than merging into it, `trustedProxies` behind a
86
+ proxy, and the pipe that hides a test suite's exit status).
87
+
88
+ Written to `.agents/skills`, plus `.claude/skills` when that agent is detected. To replace
89
+ one this ships, edit it and delete its marker line — a `SKILL.md` without the marker is
90
+ yours and is never rewritten. `ArchConfig({ skills: false })` turns the feature off. Run
91
+ `zt arch:update` to install them.
92
+
93
+ - **`zt doctor` reports agent instructions that no longer describe your project.** Every fact
94
+ in the generated block moves without anyone thinking about the file: add a migrations
95
+ directory, turn `synchronize` off, install a package. It goes on reading as current while
96
+ describing the app you used to have, and guidance that is confidently out of date gets
97
+ followed rather than questioned. Skills rot the same way and are easier to miss, since
98
+ nothing reads one until an agent decides it is relevant — by which point it is being acted
99
+ on. The check regenerates both and compares, and names `zt arch:update` as the fix. A
100
+ warning, not a failure: it misleads a reader, it does not stop the app working.
101
+
102
+ ### Changed
103
+
104
+ - **A Flow page with nothing interactive on it opens no socket.** Every page connected at
105
+ boot, unconditionally — so a marketing page, a docs article or a rendered report held a
106
+ WebSocket per visitor, open on both ends for the life of the visit, to carry nothing. Both
107
+ paths that write to the socket take a `FlowComponent`, so with none registered there was not
108
+ a frame that _could_ be sent. The connection is made when something needs it now: after the
109
+ initial scan, after an SPA navigation, after a patch registers a child. `<Link navigate>`
110
+ fetches over HTTP, so a static page with links stays disconnected. A routed page honours the
111
+ same static, which is the half that matters — a page is a component, and one whose children
112
+ are static but which is interactive itself still connects.
113
+
114
+ - **The `@zerotal/arch` agent block describes how your app is set up, not only what it
115
+ installed.** A package list answers "what is available here", which is not the question that
116
+ decides what an agent should write: the framework's contracts are not uniform across
117
+ projects, and the places they differ are the places where guessing wrong compiles cleanly and
118
+ fails at runtime. `AGENTS.md` now states the four facts that change an instruction — who owns
119
+ the schema, whether route names are typed, whether `exactOptionalPropertyTypes` or
120
+ `noUncheckedIndexedAccess` are on, and whether there are tests to run. Read off disk rather
121
+ than from a booted app, because a project that will not boot is often why the agent surface
122
+ is being installed. `.env` is deliberately not among the files read: this output is committed
123
+ and pasted into prompts, and a detector that reads secrets is one refactor away from emitting
124
+ them. Re-run `zt arch:update` to pick it up.
125
+
126
+ ### Fixed
127
+
128
+ - **No mail could be sent over port 587.** A STARTTLS upgrade hands back a new socket and
129
+ leaves the old one attached, still firing its callbacks — and what that one delivers from
130
+ then on is the undecrypted TLS stream. Both sets of handlers appended to a single reply
131
+ buffer, so handshake records and ciphertext sat in the middle of the server's replies and no
132
+ line in the buffer matched a reply any more: the driver waited out its timeout without ever
133
+ parsing the `250`, and the server logged a connection lost after STARTTLS. Measured, 1,737
134
+ bytes of ciphertext went into the discarded socket's handler while the TLS handler received
135
+ the replies.
136
+
137
+ `close` and `error` were worse than `data`. The plaintext socket ending is a normal part of
138
+ handing over to TLS, and it marked the live connection closed — rejecting whatever was
139
+ waiting on the session that had just replaced it. Each set of callbacks now captures the
140
+ generation it was installed for, and an upgrade bumps it.
141
+
142
+ - **An Inertia `303` redirect left the browser doing nothing at all.** `X-Inertia: true` was
143
+ set inside the 302-to-303 conversion, so it only ever reached a redirect that arrived as a
144
+ 301 or 302 from a non-GET handler. A handler returning the 303 the protocol asks for skipped
145
+ the only line that marked its response — and `redirect(to, 303)` is what
146
+ [Authentication](/docs/authentication) tells people to write, in eight places. The form
147
+ submitted, the row was written, the mail went out, and the fields stayed filled in: a hang
148
+ from both ends, which is the worst shape a failure can take. Marking now happens for every
149
+ redirect status on an Inertia request, with the conversion a separate decision on top of it.
150
+ `307` and `308` are marked but left alone, since preserving the method is the whole reason to
151
+ choose them.
152
+
153
+ - **Answering the busy-port menu killed `serve --dev` on the spot.** The banner printed, then
154
+ `exited with code 1`, and nothing said why. Reading a prompt locks Bun's stdin stream, and
155
+ the lock is deliberately held for the life of the command so a second prompt can still read —
156
+ so the dev deck taking the terminal over threw `ReadableStream is locked`. It died inside the
157
+ alternate screen buffer, and restoring the terminal on the way out erased the error along
158
+ with everything else drawn there, which is why this was reported as "it just exits" rather
159
+ than as the error it was. The prompt hands stdin back where it took it; a deck that still
160
+ cannot have stdin degrades to streaming rather than dying, and a dev-mode failure stops the
161
+ deck before it reports.
162
+
163
+ - **Two builds sharing an output directory deleted each other's files.** Nothing forbids
164
+ `inertia:build` and `assets:build` writing to the same place, and the defaults invite it: one
165
+ writes to `public/assets`, `app.assets.outDir` often names the same directory, and the
166
+ default release pipeline runs them one after the other. The record of what to prune was one
167
+ flat list per directory, so each build read the other's files as its own previous build and
168
+ removed them. The release ended with whichever ran last and nothing reported a problem — the
169
+ build that lost still said "Build complete" on its way out, and the page it served then 404'd
170
+ its own script. The record is keyed by entry point now: a file another build claimed is not
171
+ this one's to remove, while chunks nobody claims are still swept.
172
+
173
+ - **`zt doctor` failed a schema configuration that works.** Sync on plus migrations present
174
+ read as "the schema needs exactly one source of truth", which misses the documented
175
+ arrangement where sync builds the schema from the models so a fresh clone runs without a
176
+ migration step, and `synchronize` is an expression that is false in production, where the
177
+ deploy runs `migrate`. The two never apply in the same environment. It fails in production
178
+ now, where the deploy really does run both, and warns elsewhere. A check that cries wolf
179
+ against a correct configuration is what stops `zt doctor` ever being trusted to gate a
180
+ deploy.
181
+
182
+ ### Documented
183
+
184
+ - **Replace a release directory, do not merge into it.** Chasing 195 orphaned chunks on a
185
+ server showed the build was never the problem — ten releases of a code-split app into one
186
+ directory hold steady at the build's own output, and every one of the reporting app's 68
187
+ chunks is referenced. What accumulates is the _release_: the archive is extracted over the
188
+ running directory, so every file in it is written and every file not in it is left alone, and
189
+ nothing on that machine ever runs a build. They stay publicly fetchable at their
190
+ content-hashed URLs, which is how copy that was taken down went on being served.
191
+ [Deployment](/docs/deployment) now gives the two spellings that replace the directory.
192
+
30
193
  ## 1.7.5 — 2026-08-23
31
194
 
32
195
  Two bugs that shipped to every deployed app, a package promoted to `stable`, and
package/docs/commands.md CHANGED
@@ -315,6 +315,38 @@ Packages register their own — see
315
315
  | `bun zt doctor` | Check the app for silent misconfigurations |
316
316
  | `bun zt key:generate` | Generate a new `APP_KEY` and write it to `.env` |
317
317
  | `bun zt lint:packages` | Check every workspace package against convention rules |
318
+ | `bun zt upgrade` | Apply the codemods for a version upgrade |
319
+
320
+ ### Upgrading between versions
321
+
322
+ `bun zt upgrade --to <version>` applies the codemods a version gap calls for —
323
+ mechanical rewrites the framework can make on your behalf when an API changes.
324
+
325
+ ```bash
326
+ bun zt upgrade --to 2.0.0 # print the plan, change nothing
327
+ bun zt upgrade --to 2.0.0 --write # apply it
328
+ ```
329
+
330
+ **It writes nothing unless you ask.** That is the opposite of most tools and
331
+ deliberate: it rewrites source across your whole project, so the first run should
332
+ be something you can read and disagree with. `--from` defaults to the `zerotal`
333
+ version in your `package.json`.
334
+
335
+ The half worth reading is the last one. A codemod that quietly walks past
336
+ something it does not understand is worse than none, because the changes it _did_
337
+ make suggest the job is finished — so anything it recognised and deliberately did
338
+ not touch is listed with a file, a line and a reason:
339
+
340
+ ```text
341
+ 1 place(s) need a decision this cannot make for you:
342
+ app/models/Post.ts:9 export function all<T extends BaseModel>(rows: T[]): T[] {
343
+ `BaseModel` in a type position. It resolves to the same class as `Model`, so
344
+ this compiles either way — renaming it is a readability call, not a
345
+ correctness one.
346
+ ```
347
+
348
+ Run it again after applying: a second run should report no changes. Codemods are
349
+ idempotent, and that is the cheapest way to confirm one did what it said.
318
350
 
319
351
  `doctor` runs every static sanity check against the booted app and prints each
320
352
  finding with its fix: APP_KEY strength, `database.synchronize` colliding with
@@ -71,6 +71,30 @@ Each entry is a `DeployTarget`:
71
71
  Omit the file entirely and you get `DEFAULT_DEPLOY_TARGETS`: `production` and
72
72
  `staging`, both with the default steps.
73
73
 
74
+ The defaults build and migrate; they do not check anything you wrote. A preflight command
75
+ of your own — `release:check`, a smoke test — has to be named in `steps` to run, and
76
+ nothing prompts you to add it, so a command written precisely to guard a release can sit
77
+ there never running. Name every step you want, in the order you want them:
78
+
79
+ ```ts
80
+ // config/deploy.ts
81
+ export default {
82
+ targets: {
83
+ production: {
84
+ url: "https://example.com",
85
+ steps: ["release:check", "assets:build", "inertia:build", "migrate"],
86
+ },
87
+ },
88
+ };
89
+ ```
90
+
91
+ Put a check first. A step that fails stops the release, and a check that runs after the
92
+ migration has already run has missed its moment.
93
+
94
+ Two things worth adding while you are there: `assets:build` and `inertia:build` accept
95
+ `--clean`, which removes anything in the output directory the build did not write — see
96
+ [Build assets](#build-assets).
97
+
74
98
  > **Note** — `deploy:<env>` runs **where the app runs**, with that environment's
75
99
  > variables. It does not reach another machine over SSH. Run it on the box, or in
76
100
  > the container build, as the step before the restart.
@@ -176,6 +200,56 @@ release that built its assets ahead of time and locked the tree down serves what
176
200
  shipped, and logs one line saying so. That is what lets the service run under a properly
177
201
  hardened unit; see [Hardening the service](#hardening-the-service).
178
202
 
203
+ ### Replace the asset directory on release, do not merge into it
204
+
205
+ Code splitting names every chunk after its content, so each build emits a new set and
206
+ abandons the last. Both build commands clean those up as they go, and they do it without
207
+ needing anything to have survived from the previous build — so the directory a build
208
+ writes holds that build's output and nothing else, on a developer's machine and a fresh CI
209
+ checkout alike.
210
+
211
+ What the build cannot clean is a directory it never sees. A release that is **unpacked over
212
+ the top** of the running one — `tar -xzf` into the app directory, `rsync` without
213
+ `--delete` — merges: every file in the archive is written, and every file that is not in
214
+ the archive is left exactly where it was. Nothing on that server ever ran a build, so
215
+ nothing ever removes last release's chunks, and they collect one release at a time.
216
+
217
+ That is not only clutter. They stay publicly fetchable at their content-hashed URLs, so a
218
+ page whose copy you withdrew is still readable by anyone holding the link — pricing you
219
+ took down, a policy you replaced, a feature you pulled.
220
+
221
+ Clear the directory as part of the release, before the new files land:
222
+
223
+ ```bash
224
+ # on the server, before extracting
225
+ rm -rf "$APP_DIR/public/assets"
226
+ tar -xzf release.tgz -C "$APP_DIR"
227
+
228
+ # or let rsync do it
229
+ rsync -a --delete public/assets/ "$HOST:$APP_DIR/public/assets/"
230
+ ```
231
+
232
+ Ordering matters if the old release is still serving traffic: clearing the directory takes
233
+ its bundles away, so do it as close to the swap as you can, or stage the release in a new
234
+ directory and move it into place.
235
+
236
+ ### `--clean` for a directory the build does not own outright
237
+
238
+ The cleanup above recognises the filenames `Bun.build()` produces. An app that sets its own
239
+ `naming`, or that writes a second bundle into the same directory by other means, can leave
240
+ output the build does not recognise as its own. `--clean` needs no recognition — whatever
241
+ this build did not write, goes:
242
+
243
+ ```bash
244
+ # in your project root
245
+ bun zt assets:build --clean
246
+ bun zt inertia:build --production --clean
247
+ ```
248
+
249
+ It refuses `public/` itself and the project root, where deleting what was not rebuilt would
250
+ take the app's images and favicon with it. Point the build at a directory of its own —
251
+ `public/assets` is the Inertia default.
252
+
179
253
  Bump your asset version (or hash the bundle) so clients reload onto the new build — see
180
254
  [Inertia › Asset versioning](/docs/inertia/middleware#asset-versioning).
181
255
 
@@ -253,6 +327,29 @@ Origins are compared exactly: no wildcards and no suffix matching, because
253
327
 
254
328
  > **Warning** — an app with the wrong origin configured renders every page correctly and refuses every action. There is no 500, nothing in the logs, and a status-code health check passes. The only symptom is that buttons do nothing.
255
329
 
330
+ ### Rate limiting counts the proxy, not the visitor
331
+
332
+ `ThrottleMiddleware` identifies a client by the address the request arrived from. Behind a
333
+ proxy that address is the proxy — `127.0.0.1` for every visitor on the site — so one bucket
334
+ is shared by everybody, and a single busy client can lock the whole site out.
335
+
336
+ Tell it how many proxies sit in front of the app:
337
+
338
+ ```ts fragment
339
+ // One proxy (Caddy, nginx, a load balancer) between the internet and the app.
340
+ ThrottleMiddleware.with({ maxAttempts: 60, trustedProxies: 1 });
341
+ ```
342
+
343
+ The count is how many entries to skip from the right of `X-Forwarded-For`. It is opt-in and
344
+ defaults to zero because the header is written by the client until something trusted
345
+ overwrites it: trusting it by default would let anyone forge an address and walk around
346
+ every limit you set. Zero is the safe default and the wrong answer once you deploy behind
347
+ something — which is easy to do and never revisit, because nothing about it fails loudly.
348
+ The symptom is a legitimate visitor getting a 429 they did not earn.
349
+
350
+ Count the proxies you actually run. Setting `trustedProxies: 3` with one proxy in front
351
+ reads an entry the client supplied.
352
+
256
353
  ### Never gate the transport path
257
354
 
258
355
  **Browsers do not attach basic-auth credentials to a WebSocket handshake.** An HTTP auth
@@ -195,7 +195,7 @@ The names in use:
195
195
  | Group | Names |
196
196
  | ----------------- | --------------------------------------------------------------------------------------------------------------------- |
197
197
  | Lifecycle | `onBoot` `onMount` `onHydrate` `onDehydrate` `onRendering` `onRendered` `onUpdate` `onUpdating` `onUpdated` `onError` |
198
- | Rendering | `render` `layout` `placeholder` `slot` `hasSlot` `child` |
198
+ | Rendering | `render` `layout` `placeholder` `slot` `hasSlot` `child` `isInteractive` |
199
199
  | Actions & state | `bind` `validate` `resetValidation` `errors` `addError` `refresh` `$refresh` `$set` `cancelled` `signal` |
200
200
  | Navigation | `redirect` `redirectRoute` `redirectIntended` `currentUrl` `navigateCurrent` |
201
201
  | Events & realtime | `dispatch` `dispatchSelf` `dispatchTo` `stream` `client` `$` |
@@ -365,6 +365,70 @@ export class CounterWidget extends Component {
365
365
 
366
366
  Props that need to survive WebSocket round-trips must be `@locked` so they are included in the snapshot. A `@locked` prop is set once at mount and stays fixed for the child's lifetime.
367
367
 
368
+ ### Static children
369
+
370
+ A child that will never receive an action does not need to be an island. Declare
371
+ `static interactive = false` and it is rendered as plain markup:
372
+
373
+ ```tsx fragment
374
+ export class SiteHeader extends Component {
375
+ static interactive = false;
376
+
377
+ override async render() {
378
+ return <header class="site-header">{/* … */}</header>;
379
+ }
380
+ }
381
+ ```
382
+
383
+ No snapshot, no state script, no entry in the client's component registry — and
384
+ nothing on the wire for it beyond the HTML itself. Use it for the parts of a page
385
+ that are structure rather than behaviour: headers, nav rails, footers, marketing
386
+ sections.
387
+
388
+ **A static child re-renders with its parent.** That is the difference that
389
+ matters. An interactive child is preserved across a parent update — its DOM and
390
+ state are left alone, which is the point of an island. A static child has no
391
+ state to preserve, so it is rendered fresh every time the parent renders, and
392
+ props passed to it are simply current.
393
+
394
+ It cannot be `lazy`, `defer` or `stream`: each of those waits for the client to
395
+ ask for the real render, and a static child never registers with the client to
396
+ do the asking. Passing one throws rather than leaving a placeholder that never
397
+ resolves.
398
+
399
+ Inside the component, `this.isInteractive` reports which mode it is in — useful
400
+ for leaving out something that only makes sense with a client attached.
401
+
402
+ ### A wholly static page opens no socket
403
+
404
+ `static interactive = false` works on a routed page as well as on a child, and the
405
+ page is the one that matters: a page is a component too, so a page whose children
406
+ are all static but which is interactive itself still registers with the client —
407
+ and still connects.
408
+
409
+ ```tsx fragment
410
+ export class ChangelogPage extends Component {
411
+ static interactive = false;
412
+
413
+ override async render() {
414
+ return <article>{/* … */}</article>;
415
+ }
416
+ }
417
+ ```
418
+
419
+ When nothing on the page registers, **no WebSocket is opened at all**. Not opened
420
+ and idle — never created. For a marketing page, a documentation article or a
421
+ rendered report, that is one connection per visitor that no longer exists on
422
+ either end.
423
+
424
+ The connection is made when it is needed rather than at boot, so a page that
425
+ becomes interactive later — an SPA navigation to one that is, a deferred child
426
+ arriving — connects at that moment. `<Link navigate>` needs nothing: it fetches
427
+ over HTTP.
428
+
429
+ A page with a single interactive component still connects, as before. One is
430
+ enough.
431
+
368
432
  ### Slots
369
433
 
370
434
  Where props pass **data** into a child, slots pass **markup**. A child component's plain children become its **default slot**; a `slots={{ … }}` prop supplies **named slots**. Inside the child, place each with `this.slot(name)` (or `this.slot()` for the default), and branch on `this.hasSlot(name)` to drop an optional wrapper entirely. This is the pattern for reusable shells — cards, modals, panels, page headers — where the container is fixed but the contents vary per use.
@@ -209,6 +209,32 @@ observers, global scopes, and state-machine callbacks, plus framework event
209
209
  subscriptions. `createTestApp()` and `testApp.close()` call it for you, so suites
210
210
  using those helpers don't need the explicit `afterEach`.
211
211
 
212
+ ## Running the suite from a script
213
+
214
+ A script that gates on the tests has to read the tests' exit status, and a pipe hides it:
215
+
216
+ ```bash
217
+ bun test 2>&1 | tail -3 # the status is tail's. Always 0, however the suite went.
218
+ ```
219
+
220
+ The suite is verbose enough that piping it somewhere is the natural thing to write, which
221
+ is what makes this worth saying: a deploy script written that way prints `1 fail` and
222
+ carries straight on to upload and restart. Nothing is wrong with the output — it is the
223
+ `$?` behind it that belongs to the last command in the pipe.
224
+
225
+ Either turn the pipe honest, or do not pipe:
226
+
227
+ ```bash
228
+ set -o pipefail # bash/zsh: the pipeline fails if any stage does
229
+ bun test 2>&1 | tail -3
230
+
231
+ # or keep the status and the output separately
232
+ bun test > test.log 2>&1 || { tail -20 test.log; exit 1; }
233
+ ```
234
+
235
+ `set -e` alone does not cover it — the pipeline succeeded, as far as the shell is
236
+ concerned.
237
+
212
238
  ## References
213
239
 
214
240
  The most-used members exported from `@zerotal/testing`. Each area's page documents
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/arch",
3
- "version": "1.7.5",
3
+ "version": "1.8.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -35,11 +35,11 @@
35
35
  "typecheck": "tsc --noEmit"
36
36
  },
37
37
  "dependencies": {
38
- "@zerotal/core": "1.7.5"
38
+ "@zerotal/core": "1.8.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/orm": "1.7.5"
42
+ "@zerotal/orm": "1.8.0"
43
43
  },
44
44
  "description": "The Zerotal agent surface — an MCP server that hands coding agents the framework's machine-readable truth: exact API signatures, live routes and schema, version-matched docs, and `zt doctor`.",
45
45
  "keywords": [
package/src/config.ts CHANGED
@@ -22,6 +22,13 @@ export interface ArchConfigShape {
22
22
  * `AGENTS.md` natively, and two files of guidance drift apart.
23
23
  */
24
24
  claudeFile: boolean;
25
+ /**
26
+ * Write the agent skill files.
27
+ *
28
+ * On by default: they cost nothing until an agent decides one is relevant,
29
+ * which is the entire reason they are files rather than more of `AGENTS.md`.
30
+ */
31
+ skills: boolean;
25
32
  /** Write the MCP client configuration. Default: `true`. */
26
33
  mcpConfig: boolean;
27
34
  /** Path of the MCP client config, relative to the project root. */
@@ -37,6 +44,7 @@ export interface ArchConfigShape {
37
44
  const defaults: ArchConfigShape = {
38
45
  agentsFile: true,
39
46
  claudeFile: true,
47
+ skills: true,
40
48
  mcpConfig: true,
41
49
  mcpConfigPath: ".mcp.json",
42
50
  serverName: "zerotal",
@@ -19,6 +19,9 @@ import { NoProjectRootError } from "../errors.ts";
19
19
  import { installedPackages } from "../probe/topics.ts";
20
20
  import { detectAgents } from "./detect.ts";
21
21
  import { agentsPreamble, buildGuidelines, claudeShim } from "./guidelines.ts";
22
+ import { detectShape } from "./shape.ts";
23
+ import { selectSkills, renderSkill, skillPaths, SKILL_MARKER } from "./skills.ts";
24
+ import type { ProjectShape } from "./shape.ts";
22
25
  import { applyBlock } from "./markers.ts";
23
26
  import { applyMcpConfig } from "./mcpConfig.ts";
24
27
 
@@ -52,11 +55,15 @@ export class ArchInstallCommand extends Command {
52
55
 
53
56
  const detected = await detectAgents(root);
54
57
  const packages = (await installedPackages(root)).map((pkg) => pkg.name);
58
+ // Read off disk rather than from a booted app: a project that will not boot is
59
+ // often exactly why someone is installing the agent surface.
60
+ const shape = await detectShape(root);
55
61
 
56
62
  const changes: Change[] = [
57
63
  ...(config.mcpConfig ? await this._mcpChanges(root, config, detected.targets) : []),
58
- ...(config.agentsFile ? [await this._agentsChange(root, config, packages)] : []),
64
+ ...(config.agentsFile ? [await this._agentsChange(root, config, packages, shape)] : []),
59
65
  ...(config.claudeFile ? [await this._claudeChange(root)] : []),
66
+ ...(config.skills ? await this._skillChanges(root, packages, shape, detected.agents) : []),
60
67
  ];
61
68
 
62
69
  this.section(dry ? "arch:install — dry run" : "arch:install");
@@ -77,7 +84,7 @@ export class ArchInstallCommand extends Command {
77
84
  continue;
78
85
  }
79
86
  if (change.status === "unchanged") {
80
- this.dim(` · ${change.path} — already up to date`);
87
+ this.dim(` · ${change.path} — ${change.detail ?? "already up to date"}`);
81
88
  continue;
82
89
  }
83
90
  if (!dry && change.text !== undefined) {
@@ -153,10 +160,11 @@ export class ArchInstallCommand extends Command {
153
160
  root: string,
154
161
  config: ArchConfigShape,
155
162
  packages: string[],
163
+ shape: ProjectShape,
156
164
  ): Promise<Change> {
157
165
  const outcome = applyBlock(
158
166
  await readIfPresent(join(root, "AGENTS.md")),
159
- buildGuidelines({ packages, serverName: config.serverName }),
167
+ buildGuidelines({ packages, serverName: config.serverName, shape }),
160
168
  agentsPreamble(),
161
169
  );
162
170
  return outcome.status === "conflict"
@@ -164,6 +172,41 @@ export class ArchInstallCommand extends Command {
164
172
  : { path: "AGENTS.md", status: outcome.status, text: outcome.text };
165
173
  }
166
174
 
175
+ /**
176
+ * The skill files, one per applicable skill per agent directory.
177
+ *
178
+ * A file without {@link SKILL_MARKER} is left exactly as it is. That is the
179
+ * whole override mechanism: to replace a skill this ships, edit it and delete
180
+ * the marker line. Anything cleverer would be a second config to keep in step
181
+ * with the thing it configures.
182
+ */
183
+ private async _skillChanges(
184
+ root: string,
185
+ packages: string[],
186
+ shape: ProjectShape,
187
+ agents: string[],
188
+ ): Promise<Change[]> {
189
+ const changes: Change[] = [];
190
+
191
+ for (const skill of selectSkills(packages, shape)) {
192
+ const text = renderSkill(skill, shape);
193
+ for (const path of skillPaths(skill.name, agents)) {
194
+ const existing = await readIfPresent(join(root, path));
195
+ if (existing !== undefined && !existing.includes(SKILL_MARKER)) {
196
+ changes.push({ path, status: "unchanged", detail: "yours — left alone" });
197
+ continue;
198
+ }
199
+ changes.push(
200
+ existing === text
201
+ ? { path, status: "unchanged" }
202
+ : { path, status: existing === undefined ? "created" : "updated", text },
203
+ );
204
+ }
205
+ }
206
+
207
+ return changes;
208
+ }
209
+
167
210
  private async _claudeChange(root: string): Promise<Change> {
168
211
  const outcome = applyBlock(await readIfPresent(join(root, "CLAUDE.md")), claudeShim());
169
212
  return outcome.status === "conflict"