@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
@@ -0,0 +1,177 @@
1
+ /**
2
+ * What this project is configured to be, as distinct from what it has installed.
3
+ *
4
+ * The guidance in `guidelines.ts` was a function of the package list alone, and
5
+ * a package list cannot answer the questions that actually decide what an agent
6
+ * should write. The framework's contracts are not uniform: the same mixin needs a
7
+ * migration in one app and not in another, `route()` is checked against generated
8
+ * names in one and unchecked in another, and an optional property is written one
9
+ * way under `exactOptionalPropertyTypes` and another way without it.
10
+ *
11
+ * An agent that guesses wrong there does not get a type error. It gets a working
12
+ * build and a runtime failure — `no such column: email_verified_at`, hundreds of
13
+ * tests down at once, in an app whose schema is owned by migrations and whose
14
+ * `AGENTS.md` never said so.
15
+ *
16
+ * ## Read from files, not from a booted app
17
+ *
18
+ * `arch:install` runs in a project that may not boot — that is often *why*
19
+ * someone is installing the agent surface. Everything here comes off disk.
20
+ *
21
+ * ## What is deliberately not read
22
+ *
23
+ * `.env`, and anything else holding credentials. This output is written into a
24
+ * file that is committed and pasted into prompts; a detector that reads secrets
25
+ * is one refactor away from emitting them. Where a fact is available from both a
26
+ * config file and the environment, the config file is the only source used.
27
+ */
28
+ import { join } from "node:path";
29
+
30
+ /** How the database schema comes to exist, which decides who must write a column. */
31
+ export type SchemaSource = "migrations" | "models" | "both" | "unknown";
32
+
33
+ export interface ProjectShape {
34
+ schemaSource: SchemaSource;
35
+ /** `types/routes.generated.ts` exists, so `route()` names are checked. */
36
+ routeTypes: boolean;
37
+ /** tsconfig flags that change how correct code is written, not just how it is checked. */
38
+ strict: {
39
+ exactOptionalPropertyTypes: boolean;
40
+ noUncheckedIndexedAccess: boolean;
41
+ strict: boolean;
42
+ };
43
+ /** A `tests/` directory with something in it. */
44
+ hasTests: boolean;
45
+ }
46
+
47
+ /** Read `root`'s shape. Every probe fails soft: an unknown fact is simply not stated. */
48
+ export async function detectShape(root: string): Promise<ProjectShape> {
49
+ const [schemaSource, routeTypes, strict, hasTests] = await Promise.all([
50
+ _schemaSource(root),
51
+ _exists(join(root, "types", "routes.generated.ts")),
52
+ _strictness(root),
53
+ _hasFiles(join(root, "tests")),
54
+ ]);
55
+ return { schemaSource, routeTypes, strict, hasTests };
56
+ }
57
+
58
+ /**
59
+ * Migrations, models, or both.
60
+ *
61
+ * Mirrors `zt doctor`'s `synchronize-vs-migrations` check, which decides the same
62
+ * question from the same two inputs — but reports it as a *problem* when both are
63
+ * on. Here both is a legitimate answer worth stating plainly, because an agent
64
+ * needs to know it is looking at an app where either route might be the intended
65
+ * one and it should ask rather than assume.
66
+ */
67
+ async function _schemaSource(root: string): Promise<SchemaSource> {
68
+ const migrations = await _hasFiles(join(root, "database", "migrations"));
69
+ const synchronize = await _synchronizeOn(root);
70
+
71
+ if (migrations && synchronize) return "both";
72
+ if (migrations) return "migrations";
73
+ if (synchronize) return "models";
74
+ return "unknown";
75
+ }
76
+
77
+ /**
78
+ * Whether `database.synchronize` is on.
79
+ *
80
+ * Read as text rather than by importing the config, which would need the app's
81
+ * environment and its whole provider graph. The value is commonly an expression —
82
+ * `env("APP_ENV") !== "production"` — so a literal `false` is the only confident
83
+ * "off"; anything else is treated as "may be on", which errs toward telling the
84
+ * agent the arrangement is ambiguous rather than asserting the wrong half.
85
+ */
86
+ async function _synchronizeOn(root: string): Promise<boolean> {
87
+ const source = await _read(join(root, "config", "database.ts"));
88
+ if (!source) return false;
89
+ const match = /\bsynchronize\s*:\s*([^,\n}]+)/.exec(source);
90
+ if (!match) return false;
91
+ const value = (match[1] ?? "").trim();
92
+ return value !== "false";
93
+ }
94
+
95
+ /**
96
+ * Read the strictness flags through the whole `extends` chain.
97
+ *
98
+ * Reading only the project's own `tsconfig.json` gets this wrong for most real
99
+ * apps, and wrong in the quiet direction: a workspace app that extends a strict
100
+ * base has none of these flags in its own file, so every one reads as off and the
101
+ * guidance says nothing. The app the framework itself ships did exactly that —
102
+ * detected as unstrict while compiling under a base that turns all three on.
103
+ *
104
+ * Nearest wins, as tsc resolves it: a flag set in the extending file overrides
105
+ * the one it inherits, including turning an inherited flag off.
106
+ */
107
+ async function _strictness(root: string): Promise<ProjectShape["strict"]> {
108
+ const chain = await _tsconfigChain(join(root, "tsconfig.json"));
109
+
110
+ const on = (flag: string): boolean => {
111
+ for (const source of chain) {
112
+ const match = new RegExp(`"${flag}"\\s*:\\s*(true|false)`).exec(source);
113
+ if (match) return match[1] === "true";
114
+ }
115
+ return false;
116
+ };
117
+
118
+ // The two that matter here are not implied by `strict` — each has to be asked
119
+ // for, which is why they are worth reporting separately from it.
120
+ return {
121
+ strict: on("strict"),
122
+ exactOptionalPropertyTypes: on("exactOptionalPropertyTypes"),
123
+ noUncheckedIndexedAccess: on("noUncheckedIndexedAccess"),
124
+ };
125
+ }
126
+
127
+ /** A tsconfig and everything it extends, nearest first. */
128
+ async function _tsconfigChain(path: string, depth = 0): Promise<string[]> {
129
+ // Bounded rather than cycle-tracked: a chain this long is already pathological,
130
+ // and the cost of being wrong here is a missing line of guidance.
131
+ if (depth > 8) return [];
132
+
133
+ const source = await _read(path);
134
+ if (source === undefined) return [];
135
+
136
+ const extended = /"extends"\s*:\s*"([^"]+)"/.exec(source)?.[1];
137
+ if (!extended) return [source];
138
+
139
+ // Only a relative path is resolvable from here. A package reference
140
+ // (`@tsconfig/bun`) lives in node_modules under a layout this does not chase.
141
+ if (!extended.startsWith(".")) return [source];
142
+
143
+ const { dirname, resolve: resolvePath } = await import("node:path");
144
+ const next = resolvePath(
145
+ dirname(path),
146
+ extended.endsWith(".json") ? extended : `${extended}.json`,
147
+ );
148
+ return [source, ...(await _tsconfigChain(next, depth + 1))];
149
+ }
150
+
151
+ async function _read(path: string): Promise<string | undefined> {
152
+ try {
153
+ const file = Bun.file(path);
154
+ return (await file.exists()) ? await file.text() : undefined;
155
+ } catch {
156
+ return undefined;
157
+ }
158
+ }
159
+
160
+ async function _exists(path: string): Promise<boolean> {
161
+ try {
162
+ return await Bun.file(path).exists();
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+
168
+ /** A directory that exists and holds at least one file. */
169
+ async function _hasFiles(dir: string): Promise<boolean> {
170
+ try {
171
+ const { readdir } = await import("node:fs/promises");
172
+ const entries = await readdir(dir);
173
+ return entries.some((entry) => !entry.startsWith("."));
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Depth that costs nothing until it is needed.
3
+ *
4
+ * `guidelines.ts` opens by saying that every prompt its output lands in pays for
5
+ * its whole length, and that is why the block there is short and points rather
6
+ * than teaches. The cost is real, and so is what it buys: an agent gets a map and
7
+ * no detail, and the detail is where the expensive mistakes live. Knowing that
8
+ * migrations exist is not the same as knowing that a mixin's column needs one
9
+ * here and not in the app next door.
10
+ *
11
+ * A skill is the other half. It sits on disk with a one-line description, costs
12
+ * nothing while it is not relevant, and is loaded whole when it is — so the depth
13
+ * that could never be afforded upfront can be written out properly.
14
+ *
15
+ * ## What belongs here rather than in the block
16
+ *
17
+ * Anything procedural. The block says what exists and who owns it; a skill says
18
+ * how to do one thing correctly, including the parts that look optional and are
19
+ * not. If a line would only ever matter to someone already doing the task, it is
20
+ * a skill.
21
+ *
22
+ * ## What does not belong here at all
23
+ *
24
+ * Anything the tools answer better. `api_surface` gives exact signatures for the
25
+ * installed version and `search_docs` gives the pages; a skill that paraphrases
26
+ * either is a copy that starts wrong on the next release. These describe
27
+ * *sequences and traps* — what the documentation is worst at and what costs the
28
+ * most to learn by hitting it.
29
+ */
30
+ import type { ProjectShape } from "./shape.ts";
31
+
32
+ /**
33
+ * Marks a file as this tool's to rewrite.
34
+ *
35
+ * Placed as the first body line, after the frontmatter, because frontmatter has
36
+ * to open the file. A `SKILL.md` without it is somebody's own and is never
37
+ * touched — overriding a shipped skill should be as simple as editing it.
38
+ */
39
+ export const SKILL_MARKER = "<!-- zerotal:arch:generated -->";
40
+
41
+ export interface Skill {
42
+ /** Directory name, and the name in the frontmatter. */
43
+ name: string;
44
+ /** The one line an agent reads to decide whether to load the rest. */
45
+ description: string;
46
+ /** Packages that must be installed for this skill to apply. */
47
+ requires?: string[];
48
+ /** Body, given the project's shape. Empty means "not applicable here". */
49
+ body: (shape: ProjectShape | undefined) => string;
50
+ }
51
+
52
+ const SCHEMA_SKILL: Skill = {
53
+ name: "zerotal-schema-changes",
54
+ description:
55
+ "Add or change a database column in a Zerotal app — deciding whether a migration is " +
56
+ "required, writing one that is safe to re-run, and the mixin columns that need one even " +
57
+ "though nothing declares them.",
58
+ requires: ["@zerotal/orm"],
59
+ body: (shape) => {
60
+ const source = shape?.schemaSource ?? "unknown";
61
+
62
+ // Emphasis is written `_like this_`, not `*like this*`. These files land in
63
+ // somebody's repository and are checked by their formatter, and a generator
64
+ // whose output fails the project's own format check is one nobody can run in
65
+ // CI.
66
+ const owner =
67
+ source === "migrations"
68
+ ? "**Migrations own the schema in this app.**"
69
+ : source === "models"
70
+ ? "**The models own the schema in this app** (`database.synchronize` is on)."
71
+ : source === "both"
72
+ ? "**Both are in play here** — `database.synchronize` is on _and_ migrations exist. " +
73
+ "Run `doctor` and read the source-of-truth line before you touch anything."
74
+ : "**Which one owns the schema here is not obvious from the config.** Run `doctor` " +
75
+ "and read the source-of-truth line first.";
76
+
77
+ return `
78
+ # Changing the schema
79
+
80
+ ${owner}
81
+
82
+ ## Decide who owns the schema before writing anything
83
+
84
+ Two arrangements, and they need different work for the same change:
85
+
86
+ - **Models own it** (\`database.synchronize\`). The table is built from what the models
87
+ declare. A \`@column\` is the whole change; there is no migration to write.
88
+ - **Migrations own it.** The table is built from what a migration says. A \`@column\` alone
89
+ changes nothing on disk, and every query touching it fails at runtime.
90
+
91
+ \`bun zt doctor\` reports which. Ask it rather than guessing — the failure mode for guessing
92
+ wrong is a clean type-check and a runtime error under load.
93
+
94
+ ## The columns nothing declares
95
+
96
+ Some mixins register a column imperatively rather than with \`@column\` — \`EmailVerification\`
97
+ adds \`email_verified_at\`, \`Authenticatable\` adds \`remember_token\`. A boot-time concern
98
+ adds those to their table **if the table already exists**.
99
+
100
+ It never creates a table, and it never revisits one. So where migrations own the schema, a
101
+ \`create users\` migration that does not mention \`email_verified_at\` produces a table
102
+ without it, permanently:
103
+
104
+ \`\`\`
105
+ SQLiteError: no such column: email_verified_at
106
+ \`\`\`
107
+
108
+ Composing such a mixin in a migrations-owned app means writing the migration too.
109
+
110
+ ## Write the migration so it can meet a database that already has the column
111
+
112
+ The concern above may already have added it — on any database that has booted the app since
113
+ the mixin was composed. An unguarded \`ALTER TABLE\` then fails with \`duplicate column name\`,
114
+ during the release's \`migrate\` step:
115
+
116
+ \`\`\`ts
117
+ import { Schema } from "@zerotal/orm";
118
+
119
+ export default class extends Migration {
120
+ async up(): Promise<void> {
121
+ if (!(await Schema.hasColumn("users", "email_verified_at"))) {
122
+ await Schema.table("users", (table) => {
123
+ table.dateTime("email_verified_at").nullable();
124
+ });
125
+ }
126
+ }
127
+ }
128
+ \`\`\`
129
+
130
+ ## Sequence
131
+
132
+ 1. \`bun zt doctor\` — who owns the schema.
133
+ 2. \`bun zt make:migration\` if migrations do. Never hand-edit one that has run: the runner
134
+ records it as applied and will not run it again, so the edit reaches no database that
135
+ already migrated.
136
+ 3. Call the \`schema\` tool afterwards to confirm the column is really there. It reads the
137
+ database, not the models, which is the difference that matters here.
138
+ 4. \`bun zt test\`.
139
+ `.trim();
140
+ },
141
+ };
142
+
143
+ const RELEASE_SKILL: Skill = {
144
+ name: "zerotal-releases",
145
+ description:
146
+ "Ship a Zerotal app — ordering the release steps, replacing built assets rather than " +
147
+ "merging into them, and the proxy and shell settings that fail quietly in production.",
148
+ body: () =>
149
+ `
150
+ # Shipping a release
151
+
152
+ ## Name your own steps
153
+
154
+ \`deploy:<env>\` runs the steps named in \`config/deploy.ts\`, defaulting to build-and-migrate.
155
+ A preflight command of your own runs only if you name it, and nothing prompts you to:
156
+
157
+ \`\`\`ts
158
+ // config/deploy.ts
159
+ export default {
160
+ targets: {
161
+ production: {
162
+ url: "https://example.com",
163
+ steps: ["release:check", "assets:build", "inertia:build", "migrate"],
164
+ },
165
+ },
166
+ };
167
+ \`\`\`
168
+
169
+ Put the check first. A step that fails stops the release, and a check that runs after the
170
+ migration has missed its moment.
171
+
172
+ \`deploy:<env>\` runs **where the app runs**, with that environment's variables. It does not
173
+ reach another machine.
174
+
175
+ ## Replace the asset directory, do not merge into it
176
+
177
+ Each build emits a fresh set of content-hashed chunks and cleans up the set it replaced. It
178
+ can only clean a directory it is run in. A release unpacked over the top of the running one
179
+ — \`tar -xzf\` into the app directory, \`rsync\` without \`--delete\` — merges: files in the
180
+ archive are written, files not in it are left exactly where they were. Nothing on that
181
+ machine ever runs a build, so last release's bundles stay, and they stay **publicly
182
+ fetchable at their hashed URLs**. Copy you withdrew is still readable by anyone with the
183
+ link.
184
+
185
+ \`\`\`bash
186
+ rm -rf "$APP_DIR/public/assets" # before extracting
187
+ tar -xzf release.tgz -C "$APP_DIR"
188
+
189
+ # or
190
+ rsync -a --delete public/assets/ "$HOST:$APP_DIR/public/assets/"
191
+ \`\`\`
192
+
193
+ Clearing takes the running release's bundles away, so do it close to the swap, or stage into
194
+ a new directory and move it into place.
195
+
196
+ \`--clean\` on \`assets:build\` / \`inertia:build\` removes anything in the output directory the
197
+ build did not write. It is for output some other naming produced; it does not help a
198
+ directory nothing runs in, and it refuses \`public/\` itself.
199
+
200
+ ## Rate limiting counts the proxy, not the visitor
201
+
202
+ Behind a proxy every request arrives from the same address, so one bucket is shared by
203
+ everybody and a single client can lock the site out. Say how many proxies are in front:
204
+
205
+ \`\`\`ts
206
+ ThrottleMiddleware.with({ maxAttempts: 60, trustedProxies: 1 });
207
+ \`\`\`
208
+
209
+ It defaults to zero because \`X-Forwarded-For\` is client-written until something trusted
210
+ overwrites it. Count the proxies you actually run — too many reads an entry the client
211
+ supplied.
212
+
213
+ ## A pipe hides the exit status
214
+
215
+ \`\`\`bash
216
+ bun test 2>&1 | tail -3 # the status is tail's. Always 0, however the suite went.
217
+ \`\`\`
218
+
219
+ A deploy script gated that way prints \`1 fail\` and carries on to upload and restart. Use
220
+ \`set -o pipefail\`, or capture the status. \`set -e\` alone does not cover it — the pipeline
221
+ succeeded, as far as the shell is concerned.
222
+ `.trim(),
223
+ };
224
+
225
+ /** Everything that could be installed, before the project narrows it. */
226
+ export const SKILLS: Skill[] = [SCHEMA_SKILL, RELEASE_SKILL];
227
+
228
+ /** The skills that apply to a project with these packages and this shape. */
229
+ export function selectSkills(
230
+ packages: readonly string[],
231
+ shape: ProjectShape | undefined,
232
+ all: readonly Skill[] = SKILLS,
233
+ ): Skill[] {
234
+ const installed = new Set(packages);
235
+ return all.filter(
236
+ (skill) =>
237
+ (skill.requires ?? []).every((pkg) => installed.has(pkg)) &&
238
+ skill.body(shape).trim().length > 0,
239
+ );
240
+ }
241
+
242
+ /** One `SKILL.md`: frontmatter, the marker, then the body. */
243
+ export function renderSkill(skill: Skill, shape: ProjectShape | undefined): string {
244
+ return [
245
+ "---",
246
+ `name: ${skill.name}`,
247
+ // Quoted: descriptions contain commas and colons, which unquoted YAML reads
248
+ // as structure.
249
+ `description: ${JSON.stringify(skill.description)}`,
250
+ "---",
251
+ "",
252
+ SKILL_MARKER,
253
+ "",
254
+ skill.body(shape),
255
+ "",
256
+ ].join("\n");
257
+ }
258
+
259
+ /**
260
+ * Where a skill directory goes.
261
+ *
262
+ * `.agents/skills` is the cross-client path; `.claude/skills` is read by Claude
263
+ * Code, and some clients scan it too. Written to both when that agent is present,
264
+ * because a skill in the wrong directory is not a degraded skill — it is an
265
+ * inert file that looks installed.
266
+ */
267
+ export function skillPaths(name: string, agents: readonly string[]): string[] {
268
+ const paths = [`.agents/skills/${name}/SKILL.md`];
269
+ if (agents.includes("Claude Code")) paths.push(`.claude/skills/${name}/SKILL.md`);
270
+ return paths;
271
+ }
@@ -1,5 +1,6 @@
1
1
  import { ServiceProvider } from "@zerotal/core";
2
2
  import type { AppEnvironment, DoctorCheck } from "@zerotal/core";
3
+ import type { ProjectShape } from "../install/shape.ts";
3
4
  import { SERVER_ENTRY_PATH } from "../install/mcpConfig.ts";
4
5
 
5
6
  /**
@@ -96,6 +97,153 @@ export class ArchProvider extends ServiceProvider {
96
97
  }
97
98
  },
98
99
  },
100
+ {
101
+ id: "arch-agents-current",
102
+ label: "Agent instructions",
103
+ run: async () => agentsFileCheck(process.cwd()),
104
+ },
99
105
  ];
100
106
  }
101
107
  }
108
+
109
+ /**
110
+ * Is the generated block still true?
111
+ *
112
+ * It was a description of the framework, which changed about as often as the
113
+ * framework did. It is now also a description of *this project* — which packages
114
+ * are installed, who owns the schema, which strictness flags are on — and every
115
+ * one of those moves without anyone thinking about `AGENTS.md`. Add a migration
116
+ * directory, turn `synchronize` off, install a package, upgrade the framework:
117
+ * the file still reads as current and is quietly describing the app you had.
118
+ *
119
+ * That is worse than having no file. Guidance nobody wrote is obviously absent;
120
+ * guidance that is confidently out of date gets followed.
121
+ *
122
+ * A warning rather than a failure: a stale instruction file misleads a person or
123
+ * an agent, and does not stop the application working. `doctor` earns the right
124
+ * to gate a deploy by failing only for things that would break one.
125
+ *
126
+ * Takes its root rather than reading `process.cwd()`, so it is testable without
127
+ * moving the process into a fixture.
128
+ *
129
+ * @internal
130
+ */
131
+ export async function agentsFileCheck(
132
+ root: string,
133
+ ): Promise<{ status: "ok" | "warn"; message: string; fix?: string }> {
134
+ const file = Bun.file(`${root}/AGENTS.md`);
135
+ if (!(await file.exists())) {
136
+ return {
137
+ status: "warn",
138
+ message:
139
+ "No AGENTS.md, so an agent working here has no instructions and none of the " +
140
+ "project-specific facts the MCP tools cannot infer.",
141
+ fix: "bun zt arch:install",
142
+ };
143
+ }
144
+
145
+ const [{ buildGuidelines }, { detectShape }, { installedPackages }, markers] = await Promise.all([
146
+ import("../install/guidelines.ts"),
147
+ import("../install/shape.ts"),
148
+ import("../probe/topics.ts"),
149
+ import("../install/markers.ts"),
150
+ ]);
151
+
152
+ const current = _blockOf(await file.text(), markers.BLOCK_START, markers.BLOCK_END);
153
+ if (current === undefined) {
154
+ return {
155
+ status: "warn",
156
+ message:
157
+ "AGENTS.md has no generated block, so nothing here describes the framework or how " +
158
+ "this app is set up.",
159
+ fix: "bun zt arch:install",
160
+ };
161
+ }
162
+
163
+ const packages = (await installedPackages(root)).map((pkg) => pkg.name);
164
+ const shape = await detectShape(root);
165
+ const expected = buildGuidelines({
166
+ packages,
167
+ serverName: await _serverName(root),
168
+ shape,
169
+ });
170
+
171
+ if (current.trim() !== expected.trim()) {
172
+ return {
173
+ status: "warn",
174
+ message:
175
+ "AGENTS.md describes a different project than this one — its packages, its setup or " +
176
+ "its framework version have moved since it was written.",
177
+ fix: "bun zt arch:update",
178
+ };
179
+ }
180
+
181
+ // Skills rot the same way and are easier to miss: nothing reads them until an
182
+ // agent decides one is relevant, and by then it is being followed.
183
+ const stale = await _staleSkills(root, packages, shape);
184
+ if (stale.length > 0) {
185
+ return {
186
+ status: "warn",
187
+ message: `AGENTS.md is current, but ${stale.length} skill file(s) are not: ${stale.join(", ")}.`,
188
+ fix: "bun zt arch:update",
189
+ };
190
+ }
191
+
192
+ return { status: "ok", message: "AGENTS.md and skills match this project" };
193
+ }
194
+
195
+ /** Skill files that are missing, or generated and no longer what they would be. */
196
+ async function _staleSkills(
197
+ root: string,
198
+ packages: string[],
199
+ shape: ProjectShape,
200
+ ): Promise<string[]> {
201
+ const { selectSkills, renderSkill, skillPaths, SKILL_MARKER } =
202
+ await import("../install/skills.ts");
203
+ const { detectAgents } = await import("../install/detect.ts");
204
+ const { agents } = await detectAgents(root);
205
+
206
+ const stale: string[] = [];
207
+ for (const skill of selectSkills(packages, shape)) {
208
+ for (const path of skillPaths(skill.name, agents)) {
209
+ const file = Bun.file(`${root}/${path}`);
210
+ if (!(await file.exists())) {
211
+ stale.push(path);
212
+ continue;
213
+ }
214
+ const text = await file.text();
215
+ // A file somebody took ownership of is theirs, current or not.
216
+ if (!text.includes(SKILL_MARKER)) continue;
217
+ if (text !== renderSkill(skill, shape)) stale.push(path);
218
+ }
219
+ }
220
+ return stale;
221
+ }
222
+
223
+ /** The generated body, unfenced, or `undefined` when there is no block. */
224
+ function _blockOf(text: string, start: string, end: string): string | undefined {
225
+ const from = text.indexOf(start);
226
+ const to = text.indexOf(end);
227
+ if (from === -1 || to === -1 || to < from) return undefined;
228
+ return text.slice(from + start.length, to);
229
+ }
230
+
231
+ /**
232
+ * The key the server is registered under, read back from `.mcp.json`.
233
+ *
234
+ * The block names it, so comparing against a guessed name would report every
235
+ * project that renamed its server as permanently stale. Read rather than
236
+ * `require`d, which would cache the first answer and miss a rename.
237
+ */
238
+ async function _serverName(root: string): Promise<string> {
239
+ try {
240
+ const document = (await Bun.file(`${root}/.mcp.json`).json()) as {
241
+ mcpServers?: Record<string, unknown>;
242
+ servers?: Record<string, unknown>;
243
+ };
244
+ const names = Object.keys(document.mcpServers ?? document.servers ?? {});
245
+ return names[0] ?? "zerotal";
246
+ } catch {
247
+ return "zerotal";
248
+ }
249
+ }
@@ -181,6 +181,13 @@ export function parsePage(path: string, raw: string): DocPage {
181
181
  };
182
182
 
183
183
  for (const line of body.split("\n")) {
184
+ // A fence delimiter is markup, and its info string is metadata about the
185
+ // block rather than anything the page says. Indexing them put the language
186
+ // name into every page that shows code — a search for "typescript" matched
187
+ // most of the corpus on the strength of its fences — and, once documentation
188
+ // examples grew a `fragment` marker, that word too.
189
+ if (/^\s*`{3,}/.test(line)) continue;
190
+
184
191
  const match = /^(#{1,3})\s+(.*)$/.exec(line);
185
192
  if (match) {
186
193
  flush();