@ultimat3/cli 1.1.0 → 2.0.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 (138) hide show
  1. package/CLAUDE.md +724 -0
  2. package/README.md +41 -9
  3. package/package.json +25 -23
  4. package/src/api-routes.ts +16 -0
  5. package/src/app-auth.ts +32 -0
  6. package/src/app-entities.ts +18 -0
  7. package/src/app-env.ts +103 -0
  8. package/src/app-load.ts +20 -3
  9. package/src/bin.ts +4 -3
  10. package/src/budgets.ts +114 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +215 -0
  13. package/src/cmd-db.ts +332 -155
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +87 -17
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +64 -9
  18. package/src/cmd-env.ts +95 -0
  19. package/src/cmd-errors.ts +33 -13
  20. package/src/cmd-fix.ts +5 -1
  21. package/src/cmd-generate.ts +146 -111
  22. package/src/cmd-help.ts +16 -5
  23. package/src/cmd-i18n.ts +2 -0
  24. package/src/cmd-jobs.ts +47 -33
  25. package/src/cmd-mcp.ts +11 -2
  26. package/src/cmd-new.ts +13 -7
  27. package/src/cmd-planned.ts +55 -10
  28. package/src/cmd-policy.ts +1 -0
  29. package/src/cmd-registries.ts +3 -0
  30. package/src/cmd-secrets.ts +368 -0
  31. package/src/cmd-tasks.ts +1 -0
  32. package/src/cmd-test.ts +17 -23
  33. package/src/cmd-verify.ts +177 -23
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +251 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +112 -0
  39. package/src/db-snapshot.ts +24 -0
  40. package/src/dev-assets.ts +86 -20
  41. package/src/dev-cache.ts +122 -0
  42. package/src/dev-dashboard.ts +19 -4
  43. package/src/dev-hooks.ts +27 -2
  44. package/src/dev-n-plus-one.ts +191 -0
  45. package/src/dev-queue.ts +105 -19
  46. package/src/dev-render.ts +158 -26
  47. package/src/dev-roles-fixture.ts +67 -0
  48. package/src/dev-roles.ts +186 -78
  49. package/src/dev-runtime.ts +117 -40
  50. package/src/dev-services.ts +15 -0
  51. package/src/dev-storage.ts +245 -0
  52. package/src/dev-sync.ts +107 -0
  53. package/src/dev-traces.ts +11 -3
  54. package/src/dispatch.ts +4 -2
  55. package/src/document-styles.ts +54 -0
  56. package/src/drift.ts +37 -9
  57. package/src/error-catalog.ts +7 -18
  58. package/src/error-codes.ts +186 -0
  59. package/src/error-contract.ts +29 -7
  60. package/src/error-fixes.ts +114 -0
  61. package/src/errors.ts +205 -140
  62. package/src/fix-command.ts +268 -0
  63. package/src/flag-number.ts +56 -0
  64. package/src/framework-scope.ts +49 -0
  65. package/src/generate-kinds.ts +97 -0
  66. package/src/guards.ts +186 -0
  67. package/src/index.ts +87 -14
  68. package/src/island-bundle.ts +166 -0
  69. package/src/island-routes.ts +50 -0
  70. package/src/jobs-driver.ts +33 -0
  71. package/src/jobs-json.ts +24 -0
  72. package/src/jobs-report.ts +17 -4
  73. package/src/mcp-db-target.ts +52 -27
  74. package/src/mcp-errors.ts +120 -19
  75. package/src/mcp-host.ts +44 -25
  76. package/src/messages.ts +81 -2
  77. package/src/metrics-endpoint.ts +73 -0
  78. package/src/migrations.ts +37 -4
  79. package/src/otlp-export.ts +64 -0
  80. package/src/output.ts +46 -16
  81. package/src/parse.ts +41 -3
  82. package/src/policy-facts.ts +38 -6
  83. package/src/policy-fixture.ts +14 -7
  84. package/src/prerender.ts +111 -2
  85. package/src/registry.ts +21 -3
  86. package/src/runtime-overrides.ts +66 -0
  87. package/src/safe-url-label.ts +24 -0
  88. package/src/scaffold-fixture.ts +10 -0
  89. package/src/scaffold-typecheck.ts +16 -38
  90. package/src/serve.ts +202 -18
  91. package/src/source-files.ts +4 -0
  92. package/src/statement-loop.ts +74 -0
  93. package/src/style-csp.ts +18 -0
  94. package/src/sync-authenticator.ts +59 -0
  95. package/src/templates/action.ts +15 -30
  96. package/src/templates/admin-page.ts +103 -0
  97. package/src/templates/admin.ts +11 -7
  98. package/src/templates/backfill.ts +212 -0
  99. package/src/templates/entity.ts +72 -31
  100. package/src/templates/guard.ts +143 -0
  101. package/src/templates/index.ts +12 -1
  102. package/src/templates/island.ts +67 -0
  103. package/src/templates/job.ts +53 -13
  104. package/src/templates/naming.ts +17 -1
  105. package/src/templates/policy.ts +35 -28
  106. package/src/templates/query.ts +24 -5
  107. package/src/templates/resource.ts +19 -11
  108. package/src/templates/route.ts +90 -15
  109. package/src/templates/scaffold-app.ts +142 -45
  110. package/src/templates/scaffold-claude-agents.ts +149 -0
  111. package/src/templates/scaffold-claude-commands.ts +221 -0
  112. package/src/templates/scaffold-claude.ts +134 -0
  113. package/src/templates/scaffold-container.ts +46 -2
  114. package/src/templates/scaffold-db-package.ts +91 -0
  115. package/src/templates/scaffold-docs.ts +24 -5
  116. package/src/templates/scaffold-domain-package.ts +90 -0
  117. package/src/templates/scaffold-env.ts +87 -0
  118. package/src/templates/scaffold-i18n.ts +4 -1
  119. package/src/templates/scaffold-mcp-package.ts +49 -0
  120. package/src/templates/scaffold-package-shape.ts +25 -4
  121. package/src/templates/scaffold-repo.ts +116 -257
  122. package/src/templates/scaffold-roles.ts +68 -0
  123. package/src/templates/scaffold-ui-package.ts +56 -0
  124. package/src/templates/slice-foundation.ts +88 -0
  125. package/src/templates/wrap.ts +95 -0
  126. package/src/test-counts.ts +35 -0
  127. package/src/test-select.ts +30 -15
  128. package/src/test-shards.ts +21 -3
  129. package/src/test-workers.ts +47 -0
  130. package/src/ts-scan.ts +271 -13
  131. package/src/tsconfig-references.ts +78 -0
  132. package/src/verify-floor.ts +133 -0
  133. package/src/verify-step.ts +19 -0
  134. package/src/verify-test-run.ts +72 -0
  135. package/src/verify-tests.ts +160 -71
  136. package/src/version-loader.ts +20 -3
  137. package/src/workspace-checks.ts +87 -16
  138. package/src/write-line.ts +34 -0
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { GeneratedFile, NameSet } from './naming';
6
6
  import { icon } from './scaffold-icon';
7
+ import { rolesFiles } from './scaffold-roles';
7
8
 
8
9
  const webPackage = (app: NameSet): string => `{
9
10
  "name": "@${app.kebab}/web",
@@ -66,29 +67,37 @@ const siteStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
66
67
 
67
68
  .hero {
68
69
  display: grid;
69
- gap: tokens.$space-4;
70
- padding: tokens.$space-8;
71
- background: tokens.$surface-base;
72
- color: tokens.$text-primary;
70
+ gap: tokens.space(4);
71
+ padding: tokens.space(8);
72
+ background: tokens.role('bg');
73
+ color: tokens.role('fg');
73
74
  }
74
75
 
75
76
  .cta {
76
77
  justify-self: start;
77
- padding: tokens.$space-2 tokens.$space-4;
78
- border-radius: tokens.$radius-md;
79
- background: tokens.$accent-solid;
80
- color: tokens.$accent-on-solid;
78
+ padding: tokens.space(2) tokens.space(4);
79
+ border-radius: tokens.radius('md');
80
+ background: tokens.role('accent');
81
+ color: tokens.role('accent-fg');
81
82
  }
82
83
  `;
83
84
 
84
- const sitePageTest = (): string => `import { expect, unitTest } from '@ultimat3/testing';
85
+ const sitePageTest =
86
+ (): string => `// The landing page ships zero JS and declares its metadata. Both are promises the file makes in
87
+ // its config, and both are the kind that rot silently when someone adds one import.
88
+ import { metaContextFor, routeDataFor } from '@ultimat3/render';
89
+ import { expect, unitTest } from '@ultimat3/testing';
85
90
  import { config } from './page';
86
91
 
92
+ // The same two objects a render builds: \`routeDataFor\` resolves the route's data once, and
93
+ // \`metaContextFor\` wraps it the way every render mode wraps it before calling \`meta\`.
94
+ const ctx = { params: {}, url: 'https://example.test/' };
95
+
87
96
  unitTest('the landing page ships zero JS and declares metadata', async () => {
88
97
  expect(config.render).toBe('static');
89
98
  expect(config.hydrate).toBe('never');
90
99
  expect(config.budget.js).toBe('0kb');
91
- const meta = await config.meta({});
100
+ const meta = await config.meta(metaContextFor(ctx, await routeDataFor(config, ctx)));
92
101
  expect(meta.title ?? '').not.toBe('');
93
102
  });
94
103
  `;
@@ -102,7 +111,12 @@ import { defineRoute } from '@ultimat3/render';
102
111
  import styles from './page.module.scss';
103
112
 
104
113
  export const config = defineRoute({
105
- render: 'stream',
114
+ // 'ssr', not 'stream', and this is not a downgrade: 'stream' needs a boundary to stream into,
115
+ // and the framework has no hole marker yet. Solid's <Suspense> is not it — it throws outside a
116
+ // Solid renderer, and the server JSX factory is inert on purpose. A scaffolded 'stream' route
117
+ // therefore failed x routes with X_ROUTE_MODE_INVALID on the first run, printing a fix nobody
118
+ // could follow. Ship the mode that works. Async data needs no boundary: await it in the page.
119
+ render: 'ssr',
106
120
  hydrate: 'visible',
107
121
  offline: 'runtime',
108
122
  // Auth is a policy, never a route-local flag: one authz system, evaluated everywhere.
@@ -123,17 +137,20 @@ export function DashboardPage() {
123
137
  const dashboardStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
124
138
 
125
139
  .panel {
126
- padding: tokens.$space-6;
127
- background: tokens.$surface-raised;
128
- color: tokens.$text-primary;
140
+ padding: tokens.space(6);
141
+ background: tokens.role('surface-raised');
142
+ color: tokens.role('fg');
129
143
  }
130
144
  `;
131
145
 
132
- const dashboardTest = (): string => `import { expect, unitTest } from '@ultimat3/testing';
146
+ const dashboardTest =
147
+ (): string => `// The dashboard renders per request, is gated by a policy, and has an offline strategy. Losing
148
+ // the policy is the interesting regression: the page still renders, to anyone.
149
+ import { expect, unitTest } from '@ultimat3/testing';
133
150
  import { config } from './page';
134
151
 
135
- unitTest('the dashboard streams, requires a permission and has an offline strategy', () => {
136
- expect(config.render).toBe('stream');
152
+ unitTest('the dashboard renders on the server, is gated, and has an offline strategy', () => {
153
+ expect(config.render).toBe('ssr');
137
154
  expect(config.policy?.permission).toBe('dashboard:read');
138
155
  expect(config.offline).toBe('runtime');
139
156
  });
@@ -160,10 +177,10 @@ const offlineStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
160
177
 
161
178
  .offline {
162
179
  display: grid;
163
- gap: tokens.$space-3;
164
- padding: tokens.$space-8;
165
- background: tokens.$surface-base;
166
- color: tokens.$text-secondary;
180
+ gap: tokens.space(3);
181
+ padding: tokens.space(8);
182
+ background: tokens.role('bg');
183
+ color: tokens.role('fg-muted');
167
184
  }
168
185
  `;
169
186
 
@@ -187,7 +204,10 @@ export const health = action({
187
204
  });
188
205
  `;
189
206
 
190
- const apiTest = (): string => `import { contractTest, expect } from '@ultimat3/testing';
207
+ const apiTest =
208
+ (): string => `// The health action's contract, run as the framework generates it: garbage input refused, the
209
+ // operation in the OpenAPI document. The declaration is the source; this only runs it.
210
+ import { contractTest, expect } from '@ultimat3/testing';
191
211
  import { health } from './health';
192
212
 
193
213
  // Named here because every projection needs a stable name and this file does not boot the app.
@@ -207,39 +227,84 @@ contractTest('health projects one MCP tool and one OpenAPI operation', () => {
207
227
  `;
208
228
 
209
229
  const sharedTokens =
210
- (): string => `// Semantic tokens for this app, layered on @ultimat3/ui. Components reference these names; a raw
211
- // hex anywhere in the app is a lint failure, because dark theme is not a later project.
212
- @use '@ultimat3/ui/tokens' as base;
213
-
214
- $surface-base: base.$surface-base;
215
- $surface-raised: base.$surface-raised;
216
- $text-primary: base.$text-primary;
217
- $text-secondary: base.$text-secondary;
218
- $accent-solid: base.$accent-solid;
219
- $accent-on-solid: base.$accent-on-solid;
230
+ (): string => `// This app's authoring layer for stylesheets: \`@use '../../shared/tokens' as t;\` in a
231
+ // \`*.module.scss\` and reach for \`t.role(…)\`. Forwards @ultimat3/ui's token layer verbatim and is
232
+ // where this app's own functions and mixins go.
233
+ //
234
+ // Emits no CSS, and must not: every module is its own Sass compilation, so a \`:root\` block in here
235
+ // would be inlined once per stylesheet that uses it. The custom properties those functions REFER to
236
+ // are defined exactly once, by \`shared/global.scss\`.
237
+ //
238
+ // A raw hex anywhere in the app is a lint failure, because dark theme is not a later project.
239
+ //
240
+ // The API is functions, not variables: \`role('accent')\`, \`space(4)\`, \`radius('md')\`,
241
+ // \`text('lg')\`, \`shadow('sm')\`, plus mixins like \`@include focus-ring\` and \`@include surface\`.
242
+ // Colours are stored as space-separated RGB CHANNELS, so \`role('accent', 0.12)\` gives you a tint
243
+ // without inventing a second token.
244
+ @forward '@ultimat3/ui/tokens';
245
+ `;
246
+
247
+ const sharedGlobalStyle =
248
+ (): string => `// The app document's global layer, and the only stylesheet in this app that emits top-level CSS:
249
+ // @ultimat3/ui's custom properties (\`:root{--color-*;--space-*;…}\`) and then its reset. Every rule
250
+ // a component emits reads those properties through \`var(--…)\`, so without this file the browser
251
+ // drops every one of those declarations and the app renders unstyled.
252
+ //
253
+ // Exactly one file, imported for its side effect by \`global.ts\` — never \`@use\`d from a
254
+ // \`*.module.scss\`. Each module is a separate Sass compilation, so a \`@use\` that emits would
255
+ // duplicate the whole \`:root\` block once per module.
256
+ //
257
+ // This app's own global rules go below the @use, never inside a component module.
258
+ @use '@ultimat3/ui/global.scss';
259
+ `;
260
+
261
+ const sharedGlobalModule =
262
+ (): string => `// The one edge that puts the global stylesheet in this app's module graph. \`shared/\` is loaded by
263
+ // both surfaces and by the framework's own boot scan, so the tokens reach every document without a
264
+ // page having to remember to import them — and \`x verify\` fails with X_STYLES_GLOBAL_MISSING if
265
+ // this edge is ever cut.
266
+
267
+ import './global.scss';
220
268
  `;
221
269
 
222
270
  const sharedActor =
223
271
  (): string => `// The actor type both surfaces agree on. Policies read this and nothing else, so authz cannot
224
272
  // disagree between HTTP, live queries, jobs and MCP.
273
+ import { expandRoles, grantMatches } from '@ultimat3/policy';
274
+ import { roles } from './roles';
275
+
225
276
  export interface Actor {
226
277
  readonly id: string;
227
278
  readonly orgId: string;
228
279
  readonly roles: readonly string[];
229
280
  }
230
281
 
231
- export const isMember = (actor: Actor | null): boolean =>
232
- actor !== null && (actor.roles.includes('member') || actor.roles.includes('owner'));
282
+ /**
283
+ * What this actor may DO, answered from the declared role map. Never \`actor.roles.includes('admin')\`:
284
+ * a role-name comparison is a second authz rule, and it goes stale the moment a role is renamed or
285
+ * a grant moves to another role. \`grantMatches\` is what reads a \`post:*\` wildcard as one.
286
+ */
287
+ export const holds = (actor: Actor | null, permission: string): boolean =>
288
+ actor !== null &&
289
+ expandRoles(actor.roles, roles).some((grant) => grantMatches(grant, permission));
233
290
  `;
234
291
 
235
- const sharedActorTest = (): string => `import { expect } from 'bun:test';
236
- import { unitTest } from '@ultimat3/testing';
237
- import { isMember } from './actor';
238
-
239
- unitTest('isMember rejects anonymous and viewer actors', () => {
240
- expect(isMember(null)).toBe(false);
241
- expect(isMember({ id: 'a', orgId: 'o', roles: ['viewer'] })).toBe(false);
242
- expect(isMember({ id: 'a', orgId: 'o', roles: ['owner'] })).toBe(true);
292
+ const sharedActorTest =
293
+ (): string => `// \`holds\` answers from the declared role map, never from a role NAME. An undeclared role must
294
+ // expand to no grants — the branch that turns a typo into an actor who can do everything.
295
+ import { expect, unitTest } from '@ultimat3/testing';
296
+ import type { Actor } from './actor';
297
+ import { holds } from './actor';
298
+
299
+ const actor = (...names: readonly string[]): Actor => ({ id: 'a', orgId: 'o', roles: names });
300
+
301
+ unitTest('holds answers from the role map, and an anonymous actor holds nothing', () => {
302
+ expect(holds(null, 'dashboard:read')).toBe(false);
303
+ // A role no defineRoles() call declares expands to no grants — never to every grant.
304
+ expect(holds(actor('visitor'), 'dashboard:read')).toBe(false);
305
+ expect(holds(actor('member'), 'dashboard:read')).toBe(true);
306
+ expect(holds(actor('admin'), 'dashboard:read')).toBe(true);
307
+ expect(holds(actor('member'), 'admin:read')).toBe(false);
243
308
  });
244
309
  `;
245
310
 
@@ -293,6 +358,19 @@ const server =
293
358
  import { join } from 'node:path';
294
359
  import { runRole } from '@ultimat3/cli';
295
360
 
361
+ // MORE THAN ONE REPLICA? Add these two lines, above \`runRole\`:
362
+ //
363
+ // import { configureIdempotency } from '@ultimat3/action';
364
+ // configureIdempotency({ scope: 'shared' });
365
+ //
366
+ // \`idempotent: true\` on an action promises that a retry does not repeat the work. Under the
367
+ // process-scoped default that promise holds inside ONE process — a client retrying
368
+ // \`POST /api/payments/charge\` after a timeout lands on another replica, which has never seen the
369
+ // key, and charges the card twice, silently, with \`x verify\` green. Declaring \`'shared'\` is what
370
+ // makes that a boot error (\`X_IDEMPOTENCY_NOT_SHARED\`) unless a shared store is installed.
371
+ // \`runRole\` installs the Postgres one for you, on the connection it resolved from \`DATABASE_URL\`,
372
+ // so the declaration is all this app owes. It must run before \`runRole\` imports the actions.
373
+
296
374
  /**
297
375
  * Where the app is. From this file normally — the image's WORKDIR is not the app root's business.
298
376
  * A \`--compile\` binary is the exception: its \`import.meta.dir\` is Bun's virtual filesystem, which
@@ -315,6 +393,11 @@ const prerender =
315
393
  (): string => `// The static entry. \`x build --target static\` runs this with \`--out <dir>\` and it writes one HTML
316
394
  // file per \`render: 'static'\` route — a CDN or an object store then serves site/ with no process
317
395
  // behind it. Every other render mode needs a running app and is reported as skipped, never emitted.
396
+ //
397
+ // Skipped is not unweighed: a route that declares a \`budget:\` is rendered in memory and measured
398
+ // whatever its mode, so \`x verify\`'s \`budgets\` step has a number for it. \`unmeasured\` is the list
399
+ // this build could not render — each one is an X_BUDGET_UNMEASURED at the gate, and this is where
400
+ // the reason is.
318
401
 
319
402
  import { join } from 'node:path';
320
403
  import { prerenderSite } from '@ultimat3/cli';
@@ -323,12 +406,15 @@ const root = join(import.meta.dir, '..', '..');
323
406
  const flag = Bun.argv.indexOf('--out');
324
407
  const out = (flag === -1 ? undefined : Bun.argv[flag + 1]) ?? join(root, '.x', 'static');
325
408
  // SITE_ORIGIN is what canonical and og:url are built from; the default is only ever a local build.
326
- const origin = Bun.env['SITE_ORIGIN'];
409
+ // Property access, not \`Bun.env['SITE_ORIGIN']\`: the scaffolded tsconfig does not set
410
+ // \`noPropertyAccessFromIndexSignature\`, so the bracket form is the one biome's useLiteralKeys
411
+ // reports — a diagnostic in an app's first lint run over a file the app never wrote.
412
+ const origin = Bun.env.SITE_ORIGIN;
327
413
 
328
414
  if (import.meta.main) {
329
415
  const report = await prerenderSite({ root, out, ...(origin === undefined ? {} : { origin }) });
330
416
  await Bun.stdout.write(
331
- \`\${JSON.stringify({ ok: true, out: report.out, pages: report.pages.length, skipped: report.skipped })}\\n\`,
417
+ \`\${JSON.stringify({ ok: true, out: report.out, pages: report.pages.length, skipped: report.skipped, unmeasured: report.unmeasured })}\\n\`,
332
418
  );
333
419
  }
334
420
  `;
@@ -364,11 +450,22 @@ export function appFiles(app: NameSet): readonly GeneratedFile[] {
364
450
  { path: 'apps/web/api/health.ts', contents: apiAction() },
365
451
  { path: 'apps/web/api/health.test.ts', contents: apiTest() },
366
452
  { path: 'apps/web/shared/tokens.scss', contents: sharedTokens() },
453
+ { path: 'apps/web/shared/global.scss', contents: sharedGlobalStyle() },
454
+ { path: 'apps/web/shared/global.ts', contents: sharedGlobalModule() },
367
455
  { path: 'apps/web/shared/actor.ts', contents: sharedActor() },
368
456
  { path: 'apps/web/shared/actor.test.ts', contents: sharedActorTest() },
457
+ // The app's role map, beside the actor that reads it. `shared/` and not a feature folder:
458
+ // `defineRoles()` merges, so a per-feature call is legal and is how an app ends up with no
459
+ // answer to "which roles exist?" — see `scaffold-roles.ts`.
460
+ ...rolesFiles(),
369
461
  { path: 'apps/admin/package.json', contents: adminPackage(app) },
370
462
  { path: 'apps/admin/tsconfig.json', contents: tsconfig() },
371
- { path: 'apps/admin/app/page.tsx', contents: adminPage() },
463
+ // `apps/admin/app/admin/page.tsx`, not `apps/admin/app/page.tsx`: the directory IS the URL,
464
+ // relative to the surface root, so the shallower path resolves to `/` and collides with
465
+ // `apps/web/site/page.tsx` — `x dev` loads both surfaces into one route table and the
466
+ // scaffolded app failed its own `x routes` with X_ROUTE_DUPLICATE. `/admin` also matches
467
+ // @ultimat3/admin's own `basePath` default, so the two agree instead of merely not clashing.
468
+ { path: 'apps/admin/app/admin/page.tsx', contents: adminPage() },
372
469
  { path: 'apps/mobile/README.md', contents: placeholder('mobile', app) },
373
470
  { path: 'apps/desktop/README.md', contents: placeholder('desktop', app) },
374
471
  ];
@@ -0,0 +1,149 @@
1
+ // The subagents `x new` writes into `.claude/agents/`. Scoped by BOUNDARY, never by role: a
2
+ // researcher/coder/reviewer trio has no file set, so it cannot be told what it may not touch. The
3
+ // app's real boundaries are the eight primitives and the surfaces — one agent per boundary, plus
4
+ // `shape`, the read-only one that decides whether there is anything to build at all.
5
+
6
+ import type { GeneratedFile, NameSet } from './naming';
7
+
8
+ const shape = (app: NameSet): string => `---
9
+ name: shape
10
+ description: Use BEFORE writing any code, at the idea stage — turns a product request into the one decision that governs everything after it: which of the eight primitives each piece is, which slice and surface it lives in, and the exact \`x g\` invocations. Read-only; it decides, it never builds. Also use when a request seems to need something the framework does not have.
11
+ tools: Read, Glob, Grep, Bash
12
+ ---
13
+
14
+ You decide **whether and what to build** in ${app.kebab}. You write no code, and you do not edit
15
+ files. Your answer is a plan a builder can execute without re-deciding anything.
16
+
17
+ ## The eight, and there is no ninth
18
+
19
+ \`entity\` · \`policy\` · \`action\` · \`mutator\` · \`query\` · \`job\` · \`route\` · \`task\`
20
+
21
+ | Question | Primitive |
22
+ |---|---|
23
+ | what is stored, and what is always true of it | \`entity\` |
24
+ | who may do it | \`policy\` |
25
+ | a server-authoritative operation with an input and an output | \`action\` |
26
+ | an \`action\` that writes exactly one entity | \`mutator\` |
27
+ | a read, cached and optionally live | \`query\` |
28
+ | durable background work, retried, idempotent | \`job\` |
29
+ | a URL a human visits | \`route\` |
30
+ | work on a schedule | \`task\` |
31
+
32
+ **A request that fits none is not one feature.** Split it until every piece is one of the eight,
33
+ and name the pieces. A capability that genuinely has no home arrives as a **function that returns**
34
+ one of these — never as a ninth kind of thing. If you cannot express it that way, say so plainly and
35
+ stop; that answer is worth more than a plan built on a shape the app cannot hold.
36
+
37
+ ## Then place it
38
+
39
+ | Where | What belongs there |
40
+ |---|---|
41
+ | \`apps/web/site/<path>/page.tsx\` | public, SEO-critical, 0kb JS |
42
+ | \`apps/web/app/<slice>/\` | the feature slice: entity, repo, policy, actions, queries, jobs, UI |
43
+ | \`apps/web/api/<name>/route.ts\` | HTTP surface for actions |
44
+ | \`apps/web/shared/\` | tokens, primitives, the actor type — a leaf, imports nothing of yours |
45
+ | \`packages/db/\` | schema, migrations, seed |
46
+ | \`packages/ui/\` | components with no feature knowledge |
47
+
48
+ Read the tree before you place anything. \`x routes\`, \`x actions\`, \`x queries\`, \`x entities\`,
49
+ \`x jobs\`, \`x tasks\` and \`x policy list\` are the registries — check whether the thing already
50
+ exists before proposing it, and add \`--json\` to any of them.
51
+
52
+ ## Report
53
+
54
+ \`\`\`
55
+ Build: yes | no — <one line>
56
+ Pieces: <primitive> <name> → <dir> (one line each)
57
+ Generate: x g <kind> <name> --feature <slice> (one line each, in dependency order)
58
+ Existing: <what already covers part of this>
59
+ Refused: <anything that fits no primitive, and why>
60
+ \`\`\`
61
+ `;
62
+
63
+ const data = (app: NameSet): string => `---
64
+ name: data
65
+ description: Use for anything about what is stored — entity definitions, invariants, indexes, repositories, migrations and seed data in ${app.kebab}. Owns \`packages/db/\` and every \`entity.ts\` and \`repo.ts\`. Not for actions, queries or UI.
66
+ tools: Read, Write, Edit, Glob, Grep, Bash
67
+ ---
68
+
69
+ You own the data boundary of ${app.kebab}: \`packages/db/\` plus every \`entity.ts\` and \`repo.ts\` in a
70
+ feature slice. Nothing else. A change outside that set is a collision — report it, do not make it.
71
+
72
+ | Rule | Detail |
73
+ |---|---|
74
+ | The entity is the schema | columns, invariants, indexes and tenancy are declared there, never in SQL |
75
+ | Migrations are generated | edit \`entity.ts\`, then \`x db gen "what changed"\`. Never hand-write a file into \`packages/db/migrations/\` |
76
+ | Destructive is declared | a migration that drops, truncates or retypes carries \`-- destructive: true\`, or the gate refuses it |
77
+ | \`repo.ts\` is the only door | every read and write goes through it; a raw statement anywhere else is authz bypassed |
78
+ | Branch before you break things | \`x db branch create <name>\`, never the shared dev database |
79
+ | Money | \`{ minor, currency }\` — integer minor units and an ISO code, both, always. Never a float |
80
+ | Time | store UTC; a formatted date always names an explicit IANA time zone |
81
+
82
+ Inspect before you change: \`x entities list\`, \`x entities describe <name>\`, both with \`--json\`.
83
+
84
+ Checks: \`bun test <path>/entity.test.ts\`, \`bunx biome check --write <paths>\`, and \`bun run typecheck\`
85
+ once when you are otherwise done. Never \`x verify\` — that belongs to whoever coordinates you.
86
+ `;
87
+
88
+ const server = (app: NameSet): string => `---
89
+ name: server
90
+ description: Use for server-authoritative behaviour in ${app.kebab} — actions, mutators, queries, jobs, tasks and policies, plus the HTTP surface under \`apps/web/api/\`. Not for entities, migrations or anything that renders.
91
+ tools: Read, Write, Edit, Glob, Grep, Bash
92
+ ---
93
+
94
+ You own the server boundary of ${app.kebab}: \`policy.ts\`, actions, mutators, queries, jobs and tasks
95
+ inside a feature slice, plus \`apps/web/api/\`. Not \`entity.ts\`, not \`repo.ts\`, not anything that
96
+ renders. A change outside that set is a collision — report it, do not make it.
97
+
98
+ | Rule | Detail |
99
+ |---|---|
100
+ | Generate, then edit | \`x g <kind> <name> --feature <slice>\` for every one of them — a hand-written primitive is missing from every registry that projects it |
101
+ | One authz object | the policy decides; a route or a UI that re-checks is a second answer that will drift |
102
+ | Data through the repo | an action calls \`repo.ts\`, never the database |
103
+ | Errors are instructions | never \`throw new Error\` — subclass \`UltimateError\` with a stable \`X_SCREAMING_SNAKE\` code, a cause and a \`fix:\` a caller can actually run |
104
+ | Jobs run at least once | a handler must be idempotent — an upsert or a statement whose second run changes nothing, never \`count + 1\` |
105
+ | Tasks name a time zone | a cron schedule with an ambient zone is a different time twice a year |
106
+ | No \`any\` | \`unknown\` plus a schema parse |
107
+
108
+ Inspect before you change: \`x actions describe <name>\`, \`x queries describe <name>\`, \`x jobs ls\`,
109
+ \`x tasks list\`, \`x policy explain <subject>\` — every one takes \`--json\`.
110
+
111
+ Checks: \`bun test <path>/<file>.test.ts\`, \`bunx biome check --write <paths>\`, and \`bun run typecheck\`
112
+ once when you are otherwise done. Never \`x verify\` — that belongs to whoever coordinates you.
113
+ `;
114
+
115
+ const web = (app: NameSet): string => `---
116
+ name: web
117
+ description: Use for anything rendered in ${app.kebab} — pages under \`apps/web/site/\` and \`apps/web/app/\`, islands, components in \`packages/ui/\`, styles, tokens and i18n catalogs. Not for actions, queries, entities or migrations.
118
+ tools: Read, Write, Edit, Glob, Grep, Bash
119
+ ---
120
+
121
+ You own the rendering boundary of ${app.kebab}: \`apps/web/site/\`, the pages and UI of
122
+ \`apps/web/app/\`, \`apps/web/shared/\`, \`packages/ui/\` and the i18n catalogs. Not the primitives a page
123
+ calls. A change outside that set is a collision — report it, do not make it.
124
+
125
+ | Rule | Detail |
126
+ |---|---|
127
+ | \`site/\` is 0kb JS | and may not import from \`app/\`. One interactive control on a static page is an island: \`x g island <name> --at <dir>\` |
128
+ | \`shared/\` is a leaf | tokens, primitives, the actor type. It imports nothing of yours |
129
+ | The directory is the URL | \`page.tsx\` under \`site/\`/\`app/\`, \`route.ts\` under \`api/\`. The filename is never the path |
130
+ | Every string through \`t()\` | a literal in a component is a string no locale can ever translate |
131
+ | Semantic tokens only | never a raw hex, in a component or a stylesheet |
132
+ | Dates name a zone | explicit IANA time zone at every call site, no ambient default |
133
+ | A page calls primitives | queries and actions, never a repo and never the database |
134
+
135
+ Inspect before you change: \`x routes --json\`, and \`x i18n check\` for catalog gaps.
136
+
137
+ Checks: \`bun test <path>/page.test.ts\`, \`bunx biome check --write <paths>\`, and \`bun run typecheck\`
138
+ once when you are otherwise done. Never \`x verify\` — that belongs to whoever coordinates you.
139
+ `;
140
+
141
+ /** One agent per boundary, plus the read-only one that runs before there is a boundary to hold. */
142
+ export function claudeAgentFiles(app: NameSet): readonly GeneratedFile[] {
143
+ return [
144
+ { path: '.claude/agents/shape.md', contents: shape(app) },
145
+ { path: '.claude/agents/data.md', contents: data(app) },
146
+ { path: '.claude/agents/server.md', contents: server(app) },
147
+ { path: '.claude/agents/web.md', contents: web(app) },
148
+ ];
149
+ }