@zerotal/arch 1.7.4 → 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.
Files changed (126) hide show
  1. package/CHANGELOG.md +86 -2
  2. package/README.md +4 -2
  3. package/api-surface.md +2 -0
  4. package/docs/about.md +16 -16
  5. package/docs/admin/actions.md +9 -9
  6. package/docs/admin/auth.md +2 -2
  7. package/docs/admin/dashboard.md +5 -5
  8. package/docs/admin/extending-ui.md +12 -12
  9. package/docs/admin/extending.md +16 -7
  10. package/docs/admin/forms.md +4 -4
  11. package/docs/admin/index.md +2 -2
  12. package/docs/admin/operations.md +9 -9
  13. package/docs/admin/resources.md +3 -3
  14. package/docs/admin/structure.md +6 -6
  15. package/docs/admin/tables.md +8 -8
  16. package/docs/admin/testing.md +4 -4
  17. package/docs/ai.md +118 -11
  18. package/docs/application.md +12 -12
  19. package/docs/arch.md +25 -3
  20. package/docs/assets.md +11 -11
  21. package/docs/audit.md +13 -13
  22. package/docs/authentication.md +39 -39
  23. package/docs/authorization.md +13 -13
  24. package/docs/broadcasting/channels.md +2 -2
  25. package/docs/broadcasting/client.md +2 -2
  26. package/docs/broadcasting/events.md +9 -10
  27. package/docs/broadcasting/index.md +1 -1
  28. package/docs/broadcasting/testing.md +5 -5
  29. package/docs/cache.md +15 -13
  30. package/docs/carbon.md +35 -35
  31. package/docs/changelog.md +268 -1
  32. package/docs/client/index.md +13 -13
  33. package/docs/commands.md +36 -4
  34. package/docs/components.md +116 -115
  35. package/docs/config-system.md +4 -4
  36. package/docs/container.md +27 -27
  37. package/docs/context.md +27 -27
  38. package/docs/contributing.md +28 -0
  39. package/docs/controllers.md +10 -10
  40. package/docs/conventions.md +23 -15
  41. package/docs/cookies.md +3 -3
  42. package/docs/csrf.md +6 -6
  43. package/docs/database.md +22 -17
  44. package/docs/deployment.md +99 -2
  45. package/docs/devtools.md +16 -16
  46. package/docs/email-verification.md +8 -8
  47. package/docs/encryption.md +7 -7
  48. package/docs/errors.md +6 -6
  49. package/docs/events.md +12 -11
  50. package/docs/flow/components.md +32 -32
  51. package/docs/flow/decorators.md +18 -18
  52. package/docs/flow/events.md +16 -16
  53. package/docs/flow/forms.md +17 -17
  54. package/docs/flow/icons.md +5 -5
  55. package/docs/flow/index.md +21 -21
  56. package/docs/flow/layouts.md +99 -35
  57. package/docs/flow/lifecycle.md +16 -16
  58. package/docs/flow/models.md +9 -9
  59. package/docs/flow/pagination.md +8 -8
  60. package/docs/flow/performance.md +7 -7
  61. package/docs/flow/references.md +3 -3
  62. package/docs/flow/routing.md +26 -26
  63. package/docs/flow/testing.md +20 -20
  64. package/docs/getting-started.md +18 -13
  65. package/docs/health.md +17 -8
  66. package/docs/helpers.md +17 -17
  67. package/docs/i18n.md +10 -10
  68. package/docs/inertia/devtools.md +4 -4
  69. package/docs/inertia/index.md +6 -6
  70. package/docs/inertia/props.md +21 -21
  71. package/docs/inertia/rendering.md +9 -9
  72. package/docs/inertia/ssr.md +2 -2
  73. package/docs/lifecycle.md +2 -2
  74. package/docs/lock.md +11 -11
  75. package/docs/logger.md +12 -12
  76. package/docs/media.md +22 -22
  77. package/docs/middleware.md +17 -17
  78. package/docs/migrations.md +13 -13
  79. package/docs/monitor.md +6 -6
  80. package/docs/notifications.md +24 -24
  81. package/docs/orm/casts.md +14 -14
  82. package/docs/orm/factories.md +8 -8
  83. package/docs/orm/index.md +22 -20
  84. package/docs/orm/lifecycle.md +13 -13
  85. package/docs/orm/queries.md +28 -28
  86. package/docs/orm/relationships.md +16 -16
  87. package/docs/orm/serialization.md +11 -11
  88. package/docs/package-development.md +10 -10
  89. package/docs/pagination.md +14 -14
  90. package/docs/password-reset.md +6 -6
  91. package/docs/providers.md +11 -11
  92. package/docs/query-builder.md +18 -18
  93. package/docs/queue.md +14 -12
  94. package/docs/rate-limiting.md +6 -6
  95. package/docs/responses.md +14 -14
  96. package/docs/roles-and-2fa.md +9 -9
  97. package/docs/routing.md +44 -44
  98. package/docs/scaffolding.md +1 -1
  99. package/docs/scheduler.md +10 -10
  100. package/docs/seeding.md +6 -6
  101. package/docs/session.md +16 -14
  102. package/docs/social.md +14 -14
  103. package/docs/storage.md +25 -13
  104. package/docs/structure.md +2 -2
  105. package/docs/support-policy.md +23 -10
  106. package/docs/telemetry.md +12 -12
  107. package/docs/tenancy.md +34 -40
  108. package/docs/testing/browser.md +6 -6
  109. package/docs/testing/console.md +3 -3
  110. package/docs/testing/database.md +10 -10
  111. package/docs/testing/flow-browser.md +6 -6
  112. package/docs/testing/http.md +12 -12
  113. package/docs/testing/index.md +29 -3
  114. package/docs/testing/mocking.md +8 -8
  115. package/docs/upgrade.md +1 -1
  116. package/docs/validator.md +19 -19
  117. package/docs/view.md +14 -14
  118. package/package.json +4 -4
  119. package/src/config.ts +8 -0
  120. package/src/index.ts +25 -1
  121. package/src/install/ArchInstallCommand.ts +46 -3
  122. package/src/install/guidelines.ts +84 -0
  123. package/src/install/shape.ts +177 -0
  124. package/src/install/skills.ts +271 -0
  125. package/src/provider/ArchProvider.ts +148 -0
  126. package/src/tools/searchDocs.ts +7 -0
package/docs/validator.md CHANGED
@@ -89,7 +89,7 @@ export class StorePostRequest extends FormRequest {
89
89
  Call `validate()` as a static method on the class. It reads `HttpContext` from
90
90
  `AsyncLocalStorage` — no arguments needed.
91
91
 
92
- ```typescript
92
+ ```typescript fragment
93
93
  // in a controller
94
94
  const data = await StorePostRequest.validate();
95
95
 
@@ -116,7 +116,7 @@ never need to catch them yourself.
116
116
  `authorize()` runs **before** validation. Returning `false` throws a
117
117
  `ForbiddenError` (403) without touching the request body.
118
118
 
119
- ```typescript
119
+ ```typescript fragment
120
120
  // in a FormRequest subclass
121
121
 
122
122
  // Check the current user's role:
@@ -141,7 +141,7 @@ async authorize(): Promise<boolean> {
141
141
  Because `rules()` is an instance method, you have full access to `this.context`
142
142
  for rules that depend on the current user, route params, or session state:
143
143
 
144
- ```typescript
144
+ ```typescript fragment
145
145
  // in a FormRequest subclass
146
146
  rules(r: RuleBuilder) {
147
147
  const userId = this.context.user?.id;
@@ -157,7 +157,7 @@ rules(r: RuleBuilder) {
157
157
 
158
158
  For simple one-off validation without a dedicated class:
159
159
 
160
- ```typescript
160
+ ```typescript fragment
161
161
  // in a controller
162
162
  import { validate } from "@zerotal/validator";
163
163
 
@@ -180,7 +180,7 @@ For CLI commands, services, or background jobs — where there is no request to
180
180
  redirect and no response to throw — use the `Validator` facade. It returns a
181
181
  `{ success, data, errors }` outcome and never throws.
182
182
 
183
- ```typescript
183
+ ```typescript fragment
184
184
  // in a command or service
185
185
  import { Validator } from "@zerotal/validator";
186
186
 
@@ -200,7 +200,7 @@ if (!result.success) {
200
200
 
201
201
  Start every rule chain with a type method on `RuleBuilder`:
202
202
 
203
- ```typescript
203
+ ```typescript fragment
204
204
  // in a rules() method or factory
205
205
  const r = new RuleBuilder(); // or the `r` param in rules()
206
206
 
@@ -247,7 +247,7 @@ These are available on **every** rule type:
247
247
 
248
248
  ### Custom validator
249
249
 
250
- ```typescript
250
+ ```typescript fragment
251
251
  // in a rules() method or factory
252
252
  username: r.string().custom(async (value) => {
253
253
  const taken = await User.findBy("username", value as string);
@@ -257,7 +257,7 @@ username: r.string().custom(async (value) => {
257
257
 
258
258
  ## String rules
259
259
 
260
- ```typescript
260
+ ```typescript fragment
261
261
  // in a rules() method or factory
262
262
  r.string()
263
263
  .min(3) // minimum character length
@@ -292,7 +292,7 @@ r.string()
292
292
 
293
293
  ## Number rules
294
294
 
295
- ```typescript
295
+ ```typescript fragment
296
296
  // in a rules() method or factory
297
297
  r.number()
298
298
  .min(0) // minimum value (inclusive)
@@ -305,7 +305,7 @@ r.number()
305
305
 
306
306
  ## Boolean rules
307
307
 
308
- ```typescript
308
+ ```typescript fragment
309
309
  // in a rules() method or factory
310
310
  r.boolean()
311
311
  .accepted() // truthy — for "agree to terms" checkboxes
@@ -314,7 +314,7 @@ r.boolean()
314
314
 
315
315
  ## Date rules
316
316
 
317
- ```typescript
317
+ ```typescript fragment
318
318
  // in a rules() method or factory
319
319
  r.date()
320
320
  .after("2026-01-01") // strictly after date
@@ -325,7 +325,7 @@ r.date()
325
325
 
326
326
  ## Array rules
327
327
 
328
- ```typescript
328
+ ```typescript fragment
329
329
  // in a rules() method or factory
330
330
  r.array(r.string()) // array of strings
331
331
  .min(1) // minimum item count
@@ -340,7 +340,7 @@ r.array(r.object({ name: r.string(), age: r.number() }));
340
340
 
341
341
  ## Object rules
342
342
 
343
- ```typescript
343
+ ```typescript fragment
344
344
  // in a rules() method or factory
345
345
  r.object({
346
346
  street: r.string(),
@@ -357,7 +357,7 @@ r.object({
357
357
 
358
358
  Validates `File` objects from `multipart/form-data` uploads:
359
359
 
360
- ```typescript
360
+ ```typescript fragment
361
361
  // in a rules() method or factory
362
362
  avatar: r.file()
363
363
  .mimes(["jpg", "jpeg", "png", "webp"]) // allowed extensions
@@ -368,7 +368,7 @@ avatar: r.file()
368
368
 
369
369
  ## Password rules
370
370
 
371
- ```typescript
371
+ ```typescript fragment
372
372
  // in a rules() method or factory
373
373
  r.password()
374
374
  .min(8) // minimum length (default: 8)
@@ -390,7 +390,7 @@ The third argument ignores a record on update — pass the current record's ID,
390
390
  a `UniqueOptions` object (`{ ignoreId }`) for clarity. A fourth argument
391
391
  overrides the error message.
392
392
 
393
- ```typescript
393
+ ```typescript fragment
394
394
  // in a rules() method or factory
395
395
  email: r.string().email().unique("users", "email");
396
396
 
@@ -403,7 +403,7 @@ email: r.string().email().unique("users", "email", { ignoreId: userId });
403
403
 
404
404
  ### exists — value must exist in the DB
405
405
 
406
- ```typescript
406
+ ```typescript fragment
407
407
  // in a rules() method or factory
408
408
  userId: r.number().exists("users", "id");
409
409
  roleSlug: r.string().exists("roles", "slug");
@@ -450,7 +450,7 @@ back to the previous page.
450
450
  Errors and old input are stored in the [session](/docs/session) under the keys
451
451
  `'errors'` and `'old'`. Read them on the next request:
452
452
 
453
- ```typescript
453
+ ```typescript fragment
454
454
  // in a controller
455
455
  const errors = ctx.flashed<Record<string, string[]>>("errors");
456
456
  const old = ctx.flashed<Record<string, unknown>>("old");
@@ -500,7 +500,7 @@ would not.
500
500
  **A `FormRequest` fails the HTTP request**, so test it through the route it
501
501
  guards. The status tells you which failure mode you got:
502
502
 
503
- ```typescript
503
+ ```typescript fragment
504
504
  // tests/http/register.test.ts
505
505
  const res = await app.post("/register", { email: "not-an-email" });
506
506
 
package/docs/view.md CHANGED
@@ -67,7 +67,7 @@ use the [`safe()` helper or `Raw` component](#embedding-raw-html).
67
67
 
68
68
  The runtime renders attributes from props with a few rules:
69
69
 
70
- ```tsx
70
+ ```tsx fragment
71
71
  // in a component
72
72
  <input type="text" value={name} disabled={isLocked} required={false} data-id={42} />
73
73
  // → <input type="text" value="…" disabled data-id="42">
@@ -97,7 +97,7 @@ Children are rendered by type, which is the engine's security boundary:
97
97
 
98
98
  Two consequences worth remembering:
99
99
 
100
- ```tsx
100
+ ```tsx fragment
101
101
  // in a component
102
102
  {
103
103
  user && <Welcome name={user.name} />;
@@ -123,7 +123,7 @@ Call `view()` from a controller action to set the response. It accepts any value
123
123
  whose `.toString()` returns HTML — a `SafeHtml` instance from JSX is the normal
124
124
  case:
125
125
 
126
- ```tsx
126
+ ```tsx fragment
127
127
  // app/controllers/PostController.tsx
128
128
  import { view } from "zerotal";
129
129
  import type { HttpContext } from "zerotal";
@@ -159,7 +159,7 @@ export default function Welcome(ctx: HttpContext, { title }: { title: string })
159
159
  }
160
160
  ```
161
161
 
162
- ```ts
162
+ ```ts fragment
163
163
  // routes/index.ts — the HttpContext is injected; you only pass the extra props
164
164
  import { Router } from "zerotal";
165
165
  import Welcome from "../resources/views/Welcome.tsx";
@@ -213,7 +213,7 @@ export function AppLayout({ title, children }: AppLayoutProps) {
213
213
  }
214
214
  ```
215
215
 
216
- ```tsx
216
+ ```tsx fragment
217
217
  // resources/views/About.tsx
218
218
  import { defineLayout } from "zerotal/view";
219
219
  import { AppLayout } from "./layouts/AppLayout.tsx";
@@ -229,7 +229,7 @@ export const AboutPage = wrap<{ heading: string }>(({ heading }) => (
229
229
  ));
230
230
  ```
231
231
 
232
- ```ts
232
+ ```ts fragment
233
233
  // routes/index.ts
234
234
  import { Router } from "zerotal";
235
235
  import { AboutPage } from "../resources/views/About.tsx";
@@ -246,7 +246,7 @@ directly with `Router.view()`. The third argument is the props — a **static
246
246
  object**, or a **per-request factory** that receives the `HttpContext` and may be
247
247
  async:
248
248
 
249
- ```ts
249
+ ```ts fragment
250
250
  // routes/index.ts
251
251
  // Static props — evaluated once at registration (marketing / info pages):
252
252
  Router.view("/about", AboutPage, { title: "About Us" });
@@ -263,7 +263,7 @@ Router.view("/privacy", PrivacyPage);
263
263
 
264
264
  The returned registration is chainable:
265
265
 
266
- ```ts
266
+ ```ts fragment
267
267
  // routes/index.ts
268
268
  Router.view("/terms", TermsPage)
269
269
  .name("terms") // name the route for url() generation
@@ -276,7 +276,7 @@ Under [file-based routing](/docs/routing), a page file's **default export**
276
276
  becomes a `GET` route automatically. The page is a function of `(http, params)`
277
277
  and may be async:
278
278
 
279
- ```tsx
279
+ ```tsx fragment
280
280
  // app/views/posts/[slug].tsx → GET /posts/:slug
281
281
  import type { HttpContext } from "zerotal";
282
282
  import { Post } from "../../models/Post.ts";
@@ -311,7 +311,7 @@ export default definePage((http, params) => `<h1>Hello ${params.name}</h1>`);
311
311
  When you have pre-rendered markup (e.g. from a Markdown renderer) use `safe()` or
312
312
  `<Raw>` to bypass escaping:
313
313
 
314
- ```tsx
314
+ ```tsx fragment
315
315
  // in a component
316
316
  import { safe, Raw } from "zerotal/view";
317
317
 
@@ -335,7 +335,7 @@ import { safe, Raw } from "zerotal/view";
335
335
  `esc()` escapes a value for use in raw string templates, where automatic JSX
336
336
  escaping isn't available:
337
337
 
338
- ```ts
338
+ ```ts fragment
339
339
  // in a helper
340
340
  import { esc } from "zerotal/view";
341
341
 
@@ -367,7 +367,7 @@ function returning a node that stringifies, so most of it tests without a server
367
367
  **Render the component and assert on the string.** No request, no router, no
368
368
  browser:
369
369
 
370
- ```typescript
370
+ ```typescript fragment
371
371
  // tests/views/PostCard.test.ts
372
372
  import { test, expect } from "bun:test";
373
373
  import { PostCard } from "../../resources/views/PostCard.tsx";
@@ -383,7 +383,7 @@ test("renders the title and author", () => {
383
383
  **Escaping is the test that matters.** Every view that renders user input has one
384
384
  job beyond looking right, and it is the job that becomes a security incident:
385
385
 
386
- ```typescript
386
+ ```typescript fragment
387
387
  // tests/views/PostCard.test.ts
388
388
  test("escapes markup in user-supplied text", () => {
389
389
  const html = String(PostCard({ post: { title: "<script>alert(1)</script>" } }));
@@ -400,7 +400,7 @@ fails if someone later routes user input into it.
400
400
  **Through a route, assert the rendered text** rather than the markup around it.
401
401
  `assertSee` survives a class rename; a full-HTML comparison does not:
402
402
 
403
- ```typescript
403
+ ```typescript fragment
404
404
  // tests/http/posts.test.ts
405
405
  const res = await app.get("/posts");
406
406
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@zerotal/arch",
3
- "version": "1.7.4",
3
+ "version": "1.8.0",
4
4
  "license": "MIT",
5
- "maturity": "beta",
5
+ "maturity": "stable",
6
6
  "private": false,
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
@@ -35,11 +35,11 @@
35
35
  "typecheck": "tsc --noEmit"
36
36
  },
37
37
  "dependencies": {
38
- "@zerotal/core": "1.7.4"
38
+ "@zerotal/core": "1.8.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/orm": "1.7.4"
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",
package/src/index.ts CHANGED
@@ -27,7 +27,15 @@ export * from "./errors.ts";
27
27
  // serve a subset, or add its own alongside them.
28
28
  export { archTools, vendoredDocsDir } from "./tools/index.ts";
29
29
  export type { ToolContext } from "./tools/index.ts";
30
+ // Subprocess plumbing, not API. `_probe.ts` carries the underscore this repo uses
31
+ // for a module nobody outside its package should reach for, nothing in the tree
32
+ // imports these from here, and the export block above says why the tools and the
33
+ // probe topics are public without ever saying why these are. Marked before the
34
+ // stable promise attaches, because withdrawing them afterwards is a breaking
35
+ // change made on behalf of a caller that does not exist.
36
+ /** @internal */
30
37
  export { findApp, spawnProbe } from "./tools/_probe.ts";
38
+ /** @internal */
31
39
  export type { ProbeResult, ProbeRunner, SpawnProbeOptions } from "./tools/_probe.ts";
32
40
 
33
41
  // The in-app reads, for anyone wanting them without the subprocess.
@@ -45,12 +53,28 @@ export type {
45
53
  SchemaReport,
46
54
  } from "./probe/topics.ts";
47
55
 
48
- // The install writers, so a project can generate the same files its own way.
56
+ // The writers behind `arch:install`. Exported for the command and its tests to
57
+ // reach, not as API.
58
+ //
59
+ // They were public on the reasoning that "a project can generate the same files
60
+ // its own way" — a use nobody has, and a promise about the shape of `.mcp.json`
61
+ // writing, agent detection and marker fencing that would hold for the rest of
62
+ // the 1.x line. `ArchInstallCommand` is the only caller in the tree. Under `beta`
63
+ // that was a cheap bet; the stable label is what makes it expensive, so they are
64
+ // marked now rather than withdrawn later on behalf of a caller who never arrived.
65
+ /** @internal */
49
66
  export { detectAgents } from "./install/detect.ts";
67
+ /** @internal */
50
68
  export type { Detected, McpTarget } from "./install/detect.ts";
69
+ /** @internal */
51
70
  export { applyMcpConfig, serverEntry, SERVER_ENTRY_PATH } from "./install/mcpConfig.ts";
71
+ /** @internal */
52
72
  export type { ConfigOutcome } from "./install/mcpConfig.ts";
73
+ /** @internal */
53
74
  export { applyBlock, fence, BLOCK_END, BLOCK_START } from "./install/markers.ts";
75
+ /** @internal */
54
76
  export type { BlockOutcome } from "./install/markers.ts";
77
+ /** @internal */
55
78
  export { agentsPreamble, buildGuidelines, claudeShim } from "./install/guidelines.ts";
79
+ /** @internal */
56
80
  export type { GuidelineOptions } from "./install/guidelines.ts";
@@ -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"
@@ -11,6 +11,8 @@
11
11
  * would summarise are the ones those tools return in full.
12
12
  */
13
13
 
14
+ import type { ProjectShape } from "./shape.ts";
15
+
14
16
  /** A block contributed by one installed package. */
15
17
  interface PackageBlock {
16
18
  /** The package that triggers it. */
@@ -144,6 +146,11 @@ export interface GuidelineOptions {
144
146
  packages: string[];
145
147
  /** The key the MCP server is registered under, so the text names it correctly. */
146
148
  serverName: string;
149
+ /**
150
+ * How this project is configured, from {@link detectShape}. Omitted, the block
151
+ * is what it always was — a function of the package list.
152
+ */
153
+ shape?: ProjectShape;
147
154
  }
148
155
 
149
156
  /**
@@ -191,6 +198,9 @@ export function buildGuidelines(options: GuidelineOptions): string {
191
198
  );
192
199
  }
193
200
 
201
+ const shape = options.shape ? shapeSection(options.shape) : "";
202
+ if (shape) sections.push("", shape);
203
+
194
204
  sections.push(rulesSection());
195
205
 
196
206
  return sections
@@ -258,6 +268,80 @@ function commandSection(): string {
258
268
  ].join("\n");
259
269
  }
260
270
 
271
+ /**
272
+ * What this project is configured to be, where that changes what to write.
273
+ *
274
+ * The rest of this file describes the framework; this describes *this app*. The
275
+ * distinction matters because the framework's contracts are not uniform, and the
276
+ * places they differ are places where guessing wrong compiles cleanly and fails
277
+ * at runtime. An `EmailVerification` mixin needs a migration in one app and not
278
+ * in another, and the app that needed one lost 419 tests to a column that was
279
+ * never created.
280
+ *
281
+ * Only facts that change an instruction are emitted. "This app uses SQLite" is
282
+ * trivia; "migrations own the schema here, so a new column needs one" is a
283
+ * decision the agent would otherwise get wrong half the time.
284
+ */
285
+ function shapeSection(shape: ProjectShape): string {
286
+ const lines: string[] = [];
287
+
288
+ if (shape.schemaSource === "migrations") {
289
+ lines.push(
290
+ "- **Migrations own the schema.** A new or changed column needs a migration — including " +
291
+ "columns a mixin registers imperatively, which are added to tables that already exist " +
292
+ "but never create one. Generate with `bun zt make:migration`, guard an added column " +
293
+ "with `Schema.hasColumn`, and never hand-edit a migration that has run.",
294
+ );
295
+ } else if (shape.schemaSource === "models") {
296
+ lines.push(
297
+ "- **The models own the schema** (`database.synchronize`). The table is built from what " +
298
+ "the models declare, so a `@column` is the whole change and there is no migration to " +
299
+ "write. Check `schema` after adding one to confirm it landed.",
300
+ );
301
+ } else if (shape.schemaSource === "both") {
302
+ lines.push(
303
+ "- **Both `database.synchronize` and migrations are present**, which is deliberate in " +
304
+ "some apps (sync locally, migrations in production) and a mistake in others. Run " +
305
+ "`doctor` and read what it says about the source of truth before adding a column — " +
306
+ "the answer decides whether a migration is required or would collide.",
307
+ );
308
+ }
309
+
310
+ if (shape.routeTypes) {
311
+ lines.push(
312
+ "- **Route names are typed.** `types/routes.generated.ts` exists, so `route()` is checked " +
313
+ "against it and a stale file turns a working call into a type error. Run " +
314
+ "`bun zt route:types` after any route change.",
315
+ );
316
+ }
317
+
318
+ if (shape.strict.exactOptionalPropertyTypes) {
319
+ lines.push(
320
+ "- **`exactOptionalPropertyTypes` is on.** `{ x: undefined }` is not assignable to " +
321
+ "`{ x?: T }`. Build optional properties conditionally — `...(v ? { x: v } : {})` — " +
322
+ "rather than assigning `undefined` and expecting the key to be treated as absent.",
323
+ );
324
+ }
325
+
326
+ if (shape.strict.noUncheckedIndexedAccess) {
327
+ lines.push(
328
+ "- **`noUncheckedIndexedAccess` is on.** Every index read is `T | undefined`. Narrow it; " +
329
+ "do not reach for `!` to silence it.",
330
+ );
331
+ }
332
+
333
+ if (shape.hasTests) {
334
+ lines.push(
335
+ "- **This app has tests.** Run `bun zt test` before calling a task done — `doctor` checks " +
336
+ "configuration, not behaviour, and only one of the two notices that a change broke " +
337
+ "something.",
338
+ );
339
+ }
340
+
341
+ if (lines.length === 0) return "";
342
+ return ["### How this app is set up", "", ...lines].join("\n");
343
+ }
344
+
261
345
  function rulesSection(): string {
262
346
  return [
263
347
  "",