@zerotal/arch 1.7.5 → 1.8.1

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.
@@ -16,9 +16,12 @@ import type { Application } from "@zerotal/core";
16
16
  import { ArchConfig } from "../config.ts";
17
17
  import type { ArchConfigShape } from "../config.ts";
18
18
  import { NoProjectRootError } from "../errors.ts";
19
- import { installedPackages } from "../probe/topics.ts";
19
+ import { declaredPackages } 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
 
@@ -51,12 +54,18 @@ export class ArchInstallCommand extends Command {
51
54
  const config = this._config();
52
55
 
53
56
  const detected = await detectAgents(root);
54
- const packages = (await installedPackages(root)).map((pkg) => pkg.name);
57
+ // Declared, not installed: what an install layout hoists into node_modules
58
+ // differs between machines, and the guidance must not.
59
+ const packages = (await declaredPackages(root)).map((pkg) => pkg.name);
60
+ // Read off disk rather than from a booted app: a project that will not boot is
61
+ // often exactly why someone is installing the agent surface.
62
+ const shape = await detectShape(root);
55
63
 
56
64
  const changes: Change[] = [
57
65
  ...(config.mcpConfig ? await this._mcpChanges(root, config, detected.targets) : []),
58
- ...(config.agentsFile ? [await this._agentsChange(root, config, packages)] : []),
66
+ ...(config.agentsFile ? [await this._agentsChange(root, config, packages, shape)] : []),
59
67
  ...(config.claudeFile ? [await this._claudeChange(root)] : []),
68
+ ...(config.skills ? await this._skillChanges(root, packages, shape, detected.agents) : []),
60
69
  ];
61
70
 
62
71
  this.section(dry ? "arch:install — dry run" : "arch:install");
@@ -77,7 +86,7 @@ export class ArchInstallCommand extends Command {
77
86
  continue;
78
87
  }
79
88
  if (change.status === "unchanged") {
80
- this.dim(` · ${change.path} — already up to date`);
89
+ this.dim(` · ${change.path} — ${change.detail ?? "already up to date"}`);
81
90
  continue;
82
91
  }
83
92
  if (!dry && change.text !== undefined) {
@@ -153,10 +162,11 @@ export class ArchInstallCommand extends Command {
153
162
  root: string,
154
163
  config: ArchConfigShape,
155
164
  packages: string[],
165
+ shape: ProjectShape,
156
166
  ): Promise<Change> {
157
167
  const outcome = applyBlock(
158
168
  await readIfPresent(join(root, "AGENTS.md")),
159
- buildGuidelines({ packages, serverName: config.serverName }),
169
+ buildGuidelines({ packages, serverName: config.serverName, shape }),
160
170
  agentsPreamble(),
161
171
  );
162
172
  return outcome.status === "conflict"
@@ -164,6 +174,41 @@ export class ArchInstallCommand extends Command {
164
174
  : { path: "AGENTS.md", status: outcome.status, text: outcome.text };
165
175
  }
166
176
 
177
+ /**
178
+ * The skill files, one per applicable skill per agent directory.
179
+ *
180
+ * A file without {@link SKILL_MARKER} is left exactly as it is. That is the
181
+ * whole override mechanism: to replace a skill this ships, edit it and delete
182
+ * the marker line. Anything cleverer would be a second config to keep in step
183
+ * with the thing it configures.
184
+ */
185
+ private async _skillChanges(
186
+ root: string,
187
+ packages: string[],
188
+ shape: ProjectShape,
189
+ agents: string[],
190
+ ): Promise<Change[]> {
191
+ const changes: Change[] = [];
192
+
193
+ for (const skill of selectSkills(packages, shape)) {
194
+ const text = renderSkill(skill, shape);
195
+ for (const path of skillPaths(skill.name, agents)) {
196
+ const existing = await readIfPresent(join(root, path));
197
+ if (existing !== undefined && !existing.includes(SKILL_MARKER)) {
198
+ changes.push({ path, status: "unchanged", detail: "yours — left alone" });
199
+ continue;
200
+ }
201
+ changes.push(
202
+ existing === text
203
+ ? { path, status: "unchanged" }
204
+ : { path, status: existing === undefined ? "created" : "updated", text },
205
+ );
206
+ }
207
+ }
208
+
209
+ return changes;
210
+ }
211
+
167
212
  private async _claudeChange(root: string): Promise<Change> {
168
213
  const outcome = applyBlock(await readIfPresent(join(root, "CLAUDE.md")), claudeShim());
169
214
  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
  "",
@@ -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
+ }