@ontrails/trails 1.0.0-beta.2 → 1.0.0-beta.22

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 (150) hide show
  1. package/CHANGELOG.md +647 -0
  2. package/README.md +26 -0
  3. package/package.json +28 -7
  4. package/src/app.ts +86 -2
  5. package/src/clack.ts +22 -0
  6. package/src/cli.ts +330 -11
  7. package/src/completions.ts +240 -0
  8. package/src/lifecycle-source-io.ts +33 -0
  9. package/src/load-app-mirror.ts +202 -0
  10. package/src/local-state-io.ts +153 -0
  11. package/src/mcp-app.ts +30 -0
  12. package/src/mcp-options.ts +77 -0
  13. package/src/mcp.ts +8 -0
  14. package/src/project-writes.ts +377 -0
  15. package/src/release/bindings.ts +39 -0
  16. package/src/release/check.ts +818 -0
  17. package/src/release/config.ts +63 -0
  18. package/src/release/contract-facts.ts +425 -0
  19. package/src/release/index.ts +85 -0
  20. package/src/release/native-bun-publish.ts +651 -0
  21. package/src/release/native-bun-registry.ts +350 -0
  22. package/src/release/packed-artifacts-smoke.ts +236 -0
  23. package/src/release/smoke.ts +46 -0
  24. package/src/release/wayfinder-dogfood-smoke.ts +226 -0
  25. package/src/retired-topo-command.ts +36 -0
  26. package/src/run-adapter-check.ts +76 -0
  27. package/src/run-collision.ts +126 -0
  28. package/src/run-completions-install.ts +179 -0
  29. package/src/run-example.ts +149 -0
  30. package/src/run-examples.ts +148 -0
  31. package/src/run-quiet.ts +75 -0
  32. package/src/run-release-check.ts +74 -0
  33. package/src/run-trace.ts +273 -0
  34. package/src/run-warden.ts +39 -0
  35. package/src/run-watch.ts +432 -0
  36. package/src/scaffold-version-sync.ts +183 -0
  37. package/src/scaffold-versions.generated.ts +12 -0
  38. package/src/trails/adapter-check.ts +244 -0
  39. package/src/trails/add-surface.ts +94 -40
  40. package/src/trails/add-trail.ts +79 -41
  41. package/src/trails/add-verify.ts +95 -25
  42. package/src/trails/compile.ts +67 -0
  43. package/src/trails/completions-complete.ts +165 -0
  44. package/src/trails/completions.ts +47 -0
  45. package/src/trails/create-adapter.ts +1084 -0
  46. package/src/trails/create-scaffold.ts +399 -104
  47. package/src/trails/create-versions.ts +62 -0
  48. package/src/trails/create.ts +185 -71
  49. package/src/trails/deprecate.ts +59 -0
  50. package/src/trails/dev-clean.ts +82 -0
  51. package/src/trails/dev-reset.ts +50 -0
  52. package/src/trails/dev-stats.ts +72 -0
  53. package/src/trails/dev-support.ts +340 -0
  54. package/src/trails/doctor.ts +56 -0
  55. package/src/trails/draft-promote.ts +949 -0
  56. package/src/trails/guide.ts +74 -68
  57. package/src/trails/load-app.ts +1143 -15
  58. package/src/trails/project.ts +17 -3
  59. package/src/trails/release-check.ts +104 -0
  60. package/src/trails/release-smoke.ts +48 -0
  61. package/src/trails/revise.ts +53 -0
  62. package/src/trails/root-dir.ts +21 -0
  63. package/src/trails/run-example.ts +491 -0
  64. package/src/trails/run-examples.ts +145 -0
  65. package/src/trails/run.ts +410 -0
  66. package/src/trails/scaffold-json.ts +58 -0
  67. package/src/trails/survey.ts +881 -226
  68. package/src/trails/topo-activation.ts +385 -0
  69. package/src/trails/topo-constants.ts +2 -0
  70. package/src/trails/topo-history.ts +47 -0
  71. package/src/trails/topo-output-schemas.ts +248 -0
  72. package/src/trails/topo-pin.ts +52 -0
  73. package/src/trails/topo-read-support.ts +313 -0
  74. package/src/trails/topo-reports.ts +807 -0
  75. package/src/trails/topo-store-support.ts +174 -0
  76. package/src/trails/topo-support.ts +220 -0
  77. package/src/trails/topo-unpin.ts +61 -0
  78. package/src/trails/topo.ts +106 -0
  79. package/src/trails/validate.ts +38 -0
  80. package/src/trails/version-lifecycle-support.ts +945 -0
  81. package/src/trails/warden-guide.ts +129 -0
  82. package/src/trails/warden.ts +165 -58
  83. package/src/versions.ts +31 -0
  84. package/.turbo/turbo-build.log +0 -1
  85. package/.turbo/turbo-lint.log +0 -3
  86. package/.turbo/turbo-typecheck.log +0 -1
  87. package/__tests__/examples.test.ts +0 -6
  88. package/dist/bin/trails.d.ts +0 -3
  89. package/dist/bin/trails.d.ts.map +0 -1
  90. package/dist/bin/trails.js +0 -4
  91. package/dist/bin/trails.js.map +0 -1
  92. package/dist/src/app.d.ts +0 -2
  93. package/dist/src/app.d.ts.map +0 -1
  94. package/dist/src/app.js +0 -11
  95. package/dist/src/app.js.map +0 -1
  96. package/dist/src/clack.d.ts +0 -9
  97. package/dist/src/clack.d.ts.map +0 -1
  98. package/dist/src/clack.js +0 -62
  99. package/dist/src/clack.js.map +0 -1
  100. package/dist/src/cli.d.ts +0 -2
  101. package/dist/src/cli.d.ts.map +0 -1
  102. package/dist/src/cli.js +0 -13
  103. package/dist/src/cli.js.map +0 -1
  104. package/dist/src/trails/add-surface.d.ts +0 -13
  105. package/dist/src/trails/add-surface.d.ts.map +0 -1
  106. package/dist/src/trails/add-surface.js +0 -88
  107. package/dist/src/trails/add-surface.js.map +0 -1
  108. package/dist/src/trails/add-trail.d.ts +0 -11
  109. package/dist/src/trails/add-trail.d.ts.map +0 -1
  110. package/dist/src/trails/add-trail.js +0 -85
  111. package/dist/src/trails/add-trail.js.map +0 -1
  112. package/dist/src/trails/add-verify.d.ts +0 -10
  113. package/dist/src/trails/add-verify.d.ts.map +0 -1
  114. package/dist/src/trails/add-verify.js +0 -67
  115. package/dist/src/trails/add-verify.js.map +0 -1
  116. package/dist/src/trails/create-scaffold.d.ts +0 -15
  117. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  118. package/dist/src/trails/create-scaffold.js +0 -288
  119. package/dist/src/trails/create-scaffold.js.map +0 -1
  120. package/dist/src/trails/create.d.ts +0 -22
  121. package/dist/src/trails/create.d.ts.map +0 -1
  122. package/dist/src/trails/create.js +0 -121
  123. package/dist/src/trails/create.js.map +0 -1
  124. package/dist/src/trails/guide.d.ts +0 -11
  125. package/dist/src/trails/guide.d.ts.map +0 -1
  126. package/dist/src/trails/guide.js +0 -80
  127. package/dist/src/trails/guide.js.map +0 -1
  128. package/dist/src/trails/load-app.d.ts +0 -4
  129. package/dist/src/trails/load-app.d.ts.map +0 -1
  130. package/dist/src/trails/load-app.js +0 -24
  131. package/dist/src/trails/load-app.js.map +0 -1
  132. package/dist/src/trails/project.d.ts +0 -8
  133. package/dist/src/trails/project.d.ts.map +0 -1
  134. package/dist/src/trails/project.js +0 -43
  135. package/dist/src/trails/project.js.map +0 -1
  136. package/dist/src/trails/survey.d.ts +0 -33
  137. package/dist/src/trails/survey.d.ts.map +0 -1
  138. package/dist/src/trails/survey.js +0 -225
  139. package/dist/src/trails/survey.js.map +0 -1
  140. package/dist/src/trails/warden.d.ts +0 -19
  141. package/dist/src/trails/warden.d.ts.map +0 -1
  142. package/dist/src/trails/warden.js +0 -88
  143. package/dist/src/trails/warden.js.map +0 -1
  144. package/dist/tsconfig.tsbuildinfo +0 -1
  145. package/src/__tests__/create.test.ts +0 -349
  146. package/src/__tests__/guide.test.ts +0 -91
  147. package/src/__tests__/load-app.test.ts +0 -15
  148. package/src/__tests__/survey.test.ts +0 -161
  149. package/src/__tests__/warden.test.ts +0 -74
  150. package/tsconfig.json +0 -9
@@ -4,12 +4,32 @@
4
4
  * Generates package.json, tsconfig, app.ts, starter trails, and .trails/ directory.
5
5
  */
6
6
 
7
- import { mkdirSync } from 'node:fs';
8
- import { dirname, join, resolve } from 'node:path';
7
+ import { resolve } from 'node:path';
9
8
 
10
- import { Result, trail } from '@ontrails/core';
9
+ import { Result, trail, WORKSPACE_GITIGNORE_CONTENT } from '@ontrails/core';
11
10
  import { z } from 'zod';
12
11
 
12
+ import {
13
+ applyProjectOperations,
14
+ planProjectOperations,
15
+ PROJECT_NAME_MESSAGE,
16
+ PROJECT_NAME_PATTERN,
17
+ resolveProjectDir,
18
+ } from '../project-writes.js';
19
+ import type {
20
+ PlannedProjectOperation,
21
+ ProjectWriteOperation,
22
+ } from '../project-writes.js';
23
+ import {
24
+ ontrailsPackageRange,
25
+ scaffoldDependencyVersions,
26
+ trailsPackageVersion,
27
+ } from '../versions.js';
28
+ import {
29
+ stringifyScaffoldJson,
30
+ stringifyScaffoldPackageJson,
31
+ } from './scaffold-json.js';
32
+
13
33
  // ---------------------------------------------------------------------------
14
34
  // Types
15
35
  // ---------------------------------------------------------------------------
@@ -19,73 +39,197 @@ type Starter = 'empty' | 'entity' | 'hello';
19
39
  interface ScaffoldResult {
20
40
  readonly created: string[];
21
41
  readonly dir: string;
42
+ readonly dryRun: boolean;
22
43
  readonly name: string;
44
+ readonly plannedOperations: PlannedProjectOperation[];
23
45
  }
24
46
 
47
+ const frameworkCommandScripts = {
48
+ add: 'trails add',
49
+ compile: 'trails compile',
50
+ completions: 'trails completions',
51
+ deprecate: 'trails deprecate',
52
+ diff: 'trails diff',
53
+ doctor: 'trails doctor',
54
+ guide: 'trails guide',
55
+ revise: 'trails revise',
56
+ run: 'trails run',
57
+ survey: 'trails survey',
58
+ topo: 'trails topo',
59
+ validate: 'trails validate',
60
+ warden: 'trails warden',
61
+ } as const satisfies Record<string, string>;
62
+
25
63
  // ---------------------------------------------------------------------------
26
64
  // Content generators
27
65
  // ---------------------------------------------------------------------------
28
66
 
29
67
  const generatePackageJson = (name: string): string => {
30
68
  const deps: Record<string, string> = {
31
- '@ontrails/core': 'workspace:*',
32
- zod: '^4.0.0',
69
+ '@ontrails/core': ontrailsPackageRange,
70
+ zod: scaffoldDependencyVersions.zod,
33
71
  };
34
72
 
35
73
  const pkg: Record<string, unknown> = {
36
74
  dependencies: Object.fromEntries(
37
75
  Object.entries(deps).toSorted(([a], [b]) => a.localeCompare(b))
38
76
  ),
77
+ devDependencies: Object.fromEntries(
78
+ Object.entries({
79
+ '@ontrails/trails': ontrailsPackageRange,
80
+ '@types/bun': scaffoldDependencyVersions.bunTypes,
81
+ oxfmt: scaffoldDependencyVersions.oxfmt,
82
+ oxlint: scaffoldDependencyVersions.oxlint,
83
+ typescript: scaffoldDependencyVersions.typescript,
84
+ ultracite: scaffoldDependencyVersions.ultracite,
85
+ }).toSorted(([a], [b]) => a.localeCompare(b))
86
+ ),
39
87
  name,
40
- scripts: {
41
- build: 'tsc -b',
42
- lint: 'oxlint ./src',
43
- test: 'bun test',
44
- typecheck: 'tsc --noEmit',
45
- },
88
+ scripts: Object.fromEntries(
89
+ Object.entries({
90
+ build: 'tsc -b',
91
+ 'format:check': 'bunx ultracite check .',
92
+ 'format:fix': 'bunx ultracite fix .',
93
+ lint: 'oxlint ./src',
94
+ test: 'bun test',
95
+ typecheck: 'tsc --noEmit',
96
+ ...frameworkCommandScripts,
97
+ }).toSorted(([a], [b]) => a.localeCompare(b))
98
+ ),
46
99
  type: 'module',
47
100
  version: '0.1.0',
48
101
  };
49
102
 
50
- return JSON.stringify(pkg, null, 2);
103
+ return stringifyScaffoldPackageJson(pkg);
51
104
  };
52
105
 
53
- const TSCONFIG_CONTENT = JSON.stringify(
54
- {
55
- compilerOptions: {
56
- declaration: true,
57
- module: 'ESNext',
58
- moduleResolution: 'bundler',
59
- noUncheckedIndexedAccess: true,
60
- outDir: 'dist',
61
- rootDir: 'src',
62
- skipLibCheck: true,
63
- strict: true,
64
- target: 'ESNext',
65
- verbatimModuleSyntax: true,
66
- },
67
- include: ['src'],
106
+ const generateScaffoldProvenance = (starter: Starter): string =>
107
+ stringifyScaffoldJson({
108
+ generatedAt: new Date().toISOString(),
109
+ scaffoldVersion: trailsPackageVersion,
110
+ schemaVersion: 1,
111
+ template: starter,
112
+ });
113
+
114
+ const TSCONFIG_CONTENT = `{
115
+ "compilerOptions": {
116
+ "declaration": true,
117
+ "module": "ESNext",
118
+ "moduleResolution": "bundler",
119
+ "noUncheckedIndexedAccess": true,
120
+ "outDir": "dist",
121
+ "rootDir": "src",
122
+ "skipLibCheck": true,
123
+ "strict": true,
124
+ "target": "ESNext",
125
+ "verbatimModuleSyntax": true
68
126
  },
69
- null,
70
- 2
71
- );
127
+ "include": ["src"]
128
+ }
129
+ `;
130
+
131
+ const TSCONFIG_TESTS_CONTENT = `{
132
+ "compilerOptions": {
133
+ "noEmit": true,
134
+ "rootDir": ".",
135
+ "types": ["bun"]
136
+ },
137
+ "exclude": [],
138
+ "extends": "./tsconfig.json",
139
+ "include": ["src", "__tests__"]
140
+ }
141
+ `;
142
+
143
+ const AGENTS_CONTENT = `# AGENTS.md
144
+
145
+ This is a Trails project. Trails is an agent-native, contract-first TypeScript framework: author a trail once with typed input, Result output, examples, intent, and meta; surface it through CLI, MCP, HTTP, or future WebSocket without rewriting the contract.
146
+
147
+ ## Commands
148
+
149
+ Use the project scripts first:
150
+
151
+ \`\`\`bash
152
+ bun install
153
+ bun run build
154
+ bun test
155
+ bun run typecheck
156
+ bun run lint
157
+ bun run format:check
158
+ bun run warden
159
+ bun run survey
160
+ bun run guide
161
+ \`\`\`
162
+
163
+ ## Lexicon
164
+
165
+ - \`trail\`, not action or handler
166
+ - \`blaze\`, not handler or impl
167
+ - \`topo\`, not registry or collection
168
+ - \`compose\`, not follow
169
+ - \`surface\`, not transport
170
+ - \`resource\`, not service or dependency
171
+ - \`layer\`, for compose-cutting trail wrapping
172
+
173
+ ## Trail Rules
174
+
175
+ - Blazes return \`Result\`; never throw from trail logic.
176
+ - Use \`Result.ok()\` and \`Result.err()\`; branch with \`isOk()\`, \`isErr()\`, or \`match()\`.
177
+ - Keep trail logic surface-agnostic. Do not import CLI, MCP, HTTP, request, or response types into blazes.
178
+ - Public MCP or HTTP trails declare an \`output\` schema.
179
+ - Trails that compose other trails declare \`composes: [...]\` and invoke them with \`ctx.compose(...)\`.
180
+ - Trails that use infrastructure declare \`resources: [...]\` and access them through the resource helpers.
181
+ - Use \`detours\` for recovery strategies instead of inline retry logic.
182
+ - Prefer examples for happy-path coverage, and add focused tests for edge cases.
183
+ `;
184
+
185
+ const CLAUDE_CONTENT = `# CLAUDE.md
186
+
187
+ ## Compatibility Shim
188
+
189
+ Keep shared project guidance in \`./AGENTS.md\`. Only Claude-specific bootstrap notes belong here.
190
+
191
+ ## Agent Instructions
192
+
193
+ @AGENTS.md
194
+ `;
72
195
 
73
196
  const GITIGNORE_CONTENT = `node_modules/
74
197
  dist/
75
198
  *.tsbuildinfo
76
- .trails/_surface.json
199
+ .trails/cache/
200
+ .trails/state/
201
+ .trails/config.local.js
202
+ .trails/config.local.ts
77
203
  `;
78
204
 
79
- const OXLINTRC_CONTENT = JSON.stringify(
80
- {
81
- extends: ['ultracite'],
205
+ const OXLINT_CONFIG_CONTENT = `import { defineConfig } from 'oxlint';
206
+ import ultracite from 'ultracite/oxlint/core';
207
+
208
+ export default defineConfig({
209
+ extends: [ultracite],
210
+ rules: {
211
+ 'no-warning-comments': [
212
+ 'error',
213
+ {
214
+ location: 'start',
215
+ terms: ['todo:', 'fixme', 'xxx'],
216
+ },
217
+ ],
82
218
  },
83
- null,
84
- 2
85
- );
219
+ });
220
+ `;
86
221
 
87
222
  const OXFMTRC_CONTENT = `{
88
- // ultracite defaults
223
+ "$schema": "./node_modules/oxfmt/configuration_schema.json",
224
+ "tabWidth": 2,
225
+ "useTabs": false,
226
+ "semi": true,
227
+ "singleQuote": true,
228
+ "trailingComma": "es5",
229
+ "bracketSpacing": true,
230
+ "arrowParens": "always",
231
+ "proseWrap": "never",
232
+ "printWidth": 80,
89
233
  }
90
234
  `;
91
235
 
@@ -94,6 +238,10 @@ const generateHelloTrail = (): string =>
94
238
  import { z } from 'zod';
95
239
 
96
240
  export const hello = trail('hello', {
241
+ blaze: (input) => {
242
+ const name = input.name ?? 'world';
243
+ return Result.ok({ message: \`Hello, \${name}!\` });
244
+ },
97
245
  description: 'Say hello',
98
246
  examples: [
99
247
  {
@@ -107,30 +255,38 @@ export const hello = trail('hello', {
107
255
  name: 'Named greeting',
108
256
  },
109
257
  ],
110
- implementation: (input) => {
111
- const name = input.name ?? 'world';
112
- return Result.ok({ message: \`Hello, \${name}!\` });
113
- },
114
258
  input: z.object({
115
259
  name: z.string().optional(),
116
260
  }),
261
+ intent: 'read',
117
262
  output: z.object({
118
263
  message: z.string(),
119
264
  }),
120
- readOnly: true,
121
265
  });
122
266
  `;
123
267
 
124
268
  const generateEntityTrails = (): string =>
125
- `import { Result, trail } from '@ontrails/core';
269
+ `import { randomUUID } from 'node:crypto';
270
+
271
+ import { NotFoundError, Result, trail } from '@ontrails/core';
126
272
  import { z } from 'zod';
127
273
 
274
+ import { entityStore } from '../store.js';
275
+
128
276
  const entitySchema = z.object({
129
277
  id: z.string(),
130
278
  name: z.string(),
131
279
  });
132
280
 
133
281
  export const show = trail('entity.show', {
282
+ blaze: (input, ctx) => {
283
+ const store = entityStore.from(ctx);
284
+ const entity = store.get(input.id);
285
+ if (!entity) {
286
+ return Result.err(new NotFoundError(\`Entity "\${input.id}" not found\`));
287
+ }
288
+ return Result.ok(entity);
289
+ },
134
290
  description: 'Show an entity by ID',
135
291
  examples: [
136
292
  {
@@ -139,28 +295,77 @@ export const show = trail('entity.show', {
139
295
  name: 'Show entity',
140
296
  },
141
297
  ],
142
- implementation: (input) => {
143
- return Result.ok({ id: input.id, name: 'Example' });
144
- },
145
298
  input: z.object({ id: z.string() }),
299
+ intent: 'read',
146
300
  output: entitySchema,
147
- readOnly: true,
301
+ resources: [entityStore],
148
302
  });
149
303
 
150
304
  export const add = trail('entity.add', {
305
+ blaze: (input, ctx) => {
306
+ const store = entityStore.from(ctx);
307
+ const entity = { id: randomUUID(), name: input.name };
308
+ store.add(entity);
309
+ return Result.ok(entity);
310
+ },
151
311
  description: 'Add a new entity',
152
312
  examples: [
153
313
  {
154
- expected: { id: '1', name: 'New' },
314
+ expectedMatch: { name: 'New' },
155
315
  input: { name: 'New' },
156
316
  name: 'Add entity',
157
317
  },
158
318
  ],
159
- implementation: (input) => {
160
- return Result.ok({ id: '1', name: input.name });
161
- },
162
319
  input: z.object({ name: z.string() }),
320
+ intent: 'write',
163
321
  output: entitySchema,
322
+ permit: { scopes: ['entity:write'] },
323
+ resources: [entityStore],
324
+ });
325
+
326
+ export const list = trail('entity.list', {
327
+ blaze: (_input, ctx) => {
328
+ const store = entityStore.from(ctx);
329
+ return Result.ok({ entities: store.list() });
330
+ },
331
+ description: 'List entities',
332
+ examples: [
333
+ {
334
+ expected: { entities: [{ id: '1', name: 'Example' }] },
335
+ input: {},
336
+ name: 'List entities',
337
+ },
338
+ ],
339
+ input: z.object({}),
340
+ intent: 'read',
341
+ output: z.object({
342
+ entities: z.array(entitySchema),
343
+ }),
344
+ resources: [entityStore],
345
+ });
346
+
347
+ export const remove = trail('entity.delete', {
348
+ blaze: (input, ctx) => {
349
+ const store = entityStore.from(ctx);
350
+ const deleted = store.delete(input.id);
351
+ return Result.ok({ deleted, id: input.id });
352
+ },
353
+ description: 'Delete an entity by ID',
354
+ examples: [
355
+ {
356
+ expected: { deleted: true, id: '1' },
357
+ input: { id: '1' },
358
+ name: 'Delete entity',
359
+ },
360
+ ],
361
+ input: z.object({ id: z.string() }),
362
+ intent: 'destroy',
363
+ output: z.object({
364
+ deleted: z.boolean(),
365
+ id: z.string(),
366
+ }),
367
+ permit: { scopes: ['entity:write'] },
368
+ resources: [entityStore],
164
369
  });
165
370
  `;
166
371
 
@@ -169,6 +374,9 @@ const generateSearchTrail = (): string =>
169
374
  import { z } from 'zod';
170
375
 
171
376
  export const search = trail('search', {
377
+ blaze: () => {
378
+ return Result.ok({ results: [] });
379
+ },
172
380
  description: 'Search entities by query',
173
381
  examples: [
174
382
  {
@@ -177,41 +385,40 @@ export const search = trail('search', {
177
385
  name: 'Search entities',
178
386
  },
179
387
  ],
180
- implementation: () => {
181
- return Result.ok({ results: [] });
182
- },
183
388
  input: z.object({ query: z.string() }),
389
+ intent: 'read',
184
390
  output: z.object({
185
391
  results: z.array(z.object({ id: z.string(), name: z.string() })),
186
392
  }),
187
- readOnly: true,
188
393
  });
189
394
  `;
190
395
 
191
- const generateOnboardHike = (): string =>
192
- `import { Result, hike } from '@ontrails/core';
396
+ const generateOnboardTrail = (): string =>
397
+ `import { Result, trail } from '@ontrails/core';
193
398
  import { z } from 'zod';
194
399
 
195
- export const onboard = hike('entity.onboard', {
196
- description: 'Onboard a new entity end-to-end',
197
- follows: ['entity.add'],
198
- implementation: async (input, ctx) => {
199
- const result = await ctx.follow('entity.add', { name: input.name });
400
+ export const onboard = trail('entity.onboard', {
401
+ blaze: async (input, ctx) => {
402
+ const result = await ctx.compose('entity.add', { name: input.name });
200
403
  if (result.isErr()) {
201
404
  return result;
202
405
  }
203
406
  return Result.ok({ onboarded: true });
204
407
  },
408
+ composes: ['entity.add'],
409
+ description: 'Onboard a new entity end-to-end',
205
410
  input: z.object({ name: z.string() }),
411
+ intent: 'write',
206
412
  output: z.object({ onboarded: z.boolean() }),
413
+ permit: { scopes: ['entity:write'] },
207
414
  });
208
415
  `;
209
416
 
210
- const generateEntityEvents = (): string =>
211
- `import { event } from '@ontrails/core';
417
+ const generateEntitySignals = (): string =>
418
+ `import { signal } from '@ontrails/core';
212
419
  import { z } from 'zod';
213
420
 
214
- export const entityUpdated = event('entity.updated', {
421
+ export const entityUpdated = signal('entity.updated', {
215
422
  description: 'Fired when an entity is updated',
216
423
  payload: z.object({
217
424
  entityId: z.string(),
@@ -221,21 +428,49 @@ export const entityUpdated = event('entity.updated', {
221
428
  `;
222
429
 
223
430
  const generateStore = (): string =>
224
- `/** In-memory store for entities. */
431
+ `import { Result, resource } from '@ontrails/core';
432
+
433
+ /** In-memory store for entities. */
225
434
 
226
- interface Entity {
435
+ export interface Entity {
227
436
  readonly id: string;
228
437
  readonly name: string;
229
438
  }
230
439
 
231
- const store = new Map<string, Entity>();
440
+ export interface EntityStore {
441
+ add(entity: Entity): void;
442
+ delete(id: string): boolean;
443
+ get(id: string): Entity | undefined;
444
+ list(): Entity[];
445
+ }
446
+
447
+ const defaultEntities: readonly Entity[] = [{ id: '1', name: 'Example' }];
232
448
 
233
- export const getEntity = (id: string): Entity | undefined => store.get(id);
234
- export const addEntity = (entity: Entity): void => {
235
- store.set(entity.id, entity);
449
+ export const createEntityStore = (
450
+ seed: readonly Entity[] = defaultEntities
451
+ ): EntityStore => {
452
+ const store = new Map(seed.map((entity) => [entity.id, entity] as const));
453
+ return {
454
+ add(entity) {
455
+ store.set(entity.id, entity);
456
+ },
457
+ delete(id) {
458
+ return store.delete(id);
459
+ },
460
+ get(id) {
461
+ return store.get(id);
462
+ },
463
+ list() {
464
+ return [...store.values()];
465
+ },
466
+ };
236
467
  };
237
- export const deleteEntity = (id: string): boolean => store.delete(id);
238
- export const listEntities = (): Entity[] => Array.from(store.values());
468
+
469
+ export const entityStore = resource('entity.store', {
470
+ create: () => Result.ok(createEntityStore()),
471
+ description: 'In-memory entity store for the entity starter.',
472
+ mock: createEntityStore,
473
+ });
239
474
  `;
240
475
 
241
476
  const starterImports: Record<
@@ -248,9 +483,10 @@ const starterImports: Record<
248
483
  "import * as entity from './trails/entity.js';",
249
484
  "import * as search from './trails/search.js';",
250
485
  "import * as onboard from './trails/onboard.js';",
251
- "import * as entityEvents from './events/entity-events.js';",
486
+ "import * as entitySignals from './signals/entity-signals.js';",
487
+ "import * as store from './store.js';",
252
488
  ],
253
- modules: ['entity', 'search', 'onboard', 'entityEvents'],
489
+ modules: ['entity', 'search', 'onboard', 'entitySignals', 'store'],
254
490
  },
255
491
  hello: {
256
492
  imports: ["import * as hello from './trails/hello.js';"],
@@ -258,16 +494,31 @@ const starterImports: Record<
258
494
  },
259
495
  };
260
496
 
497
+ const renderTopoExpression = (
498
+ appNameLiteral: string,
499
+ modules: readonly string[]
500
+ ): string => {
501
+ if (modules.length === 0) {
502
+ return `topo(${appNameLiteral})`;
503
+ }
504
+
505
+ if (modules.length === 1) {
506
+ return `topo(${appNameLiteral}, ${modules[0]})`;
507
+ }
508
+
509
+ return `topo(\n ${[appNameLiteral, ...modules].join(',\n ')}\n)`;
510
+ };
511
+
261
512
  const generateAppTs = (name: string, starter: Starter): string => {
262
513
  const { imports, modules } = starterImports[starter];
263
- const topoArgs =
264
- modules.length > 0 ? `'${name}', ${modules.join(', ')}` : `'${name}'`;
514
+ const appNameLiteral = `'${name}'`;
515
+ const topoExpression = renderTopoExpression(appNameLiteral, modules);
265
516
 
266
517
  return [
267
518
  "import { topo } from '@ontrails/core';",
268
519
  ...imports,
269
520
  '',
270
- `export const app = topo(${topoArgs});`,
521
+ `export const app = ${topoExpression};`,
271
522
  '',
272
523
  ].join('\n');
273
524
  };
@@ -281,8 +532,8 @@ const starterFileGenerators: Record<Starter, () => [string, string][]> = {
281
532
  entity: () => [
282
533
  ['src/trails/entity.ts', generateEntityTrails()],
283
534
  ['src/trails/search.ts', generateSearchTrail()],
284
- ['src/trails/onboard.ts', generateOnboardHike()],
285
- ['src/events/entity-events.ts', generateEntityEvents()],
535
+ ['src/trails/onboard.ts', generateOnboardTrail()],
536
+ ['src/signals/entity-signals.ts', generateEntitySignals()],
286
537
  ['src/store.ts', generateStore()],
287
538
  ],
288
539
  hello: () => [['src/trails/hello.ts', generateHelloTrail()]],
@@ -294,59 +545,103 @@ const collectScaffoldFiles = (
294
545
  ): Map<string, string> =>
295
546
  new Map([
296
547
  ['package.json', generatePackageJson(name)],
548
+ ['AGENTS.md', AGENTS_CONTENT],
549
+ ['CLAUDE.md', CLAUDE_CONTENT],
297
550
  ['tsconfig.json', TSCONFIG_CONTENT],
551
+ ['tsconfig.tests.json', TSCONFIG_TESTS_CONTENT],
298
552
  ['.gitignore', GITIGNORE_CONTENT],
299
- ['.oxlintrc.json', OXLINTRC_CONTENT],
553
+ ['oxlint.config.ts', OXLINT_CONFIG_CONTENT],
300
554
  ['.oxfmtrc.jsonc', OXFMTRC_CONTENT],
555
+ ['.trails/.gitignore', WORKSPACE_GITIGNORE_CONTENT],
556
+ ['.trails/scaffold.json', generateScaffoldProvenance(starter)],
301
557
  ['src/app.ts', generateAppTs(name, starter)],
302
558
  ...starterFileGenerators[starter](),
303
559
  ]);
304
560
 
305
- const writeScaffoldFiles = async (
306
- projectDir: string,
561
+ const collectScaffoldOperations = (
307
562
  fileMap: Map<string, string>
308
- ): Promise<string[]> => {
309
- const files: string[] = [];
310
- for (const [relativePath, content] of fileMap) {
311
- const fullPath = join(projectDir, relativePath);
312
- mkdirSync(dirname(fullPath), { recursive: true });
313
- await Bun.write(fullPath, content);
314
- files.push(relativePath);
315
- }
316
- return files;
317
- };
563
+ ): ProjectWriteOperation[] =>
564
+ [...fileMap].map(([path, content]) => ({
565
+ content,
566
+ kind: 'write' as const,
567
+ path,
568
+ }));
318
569
 
319
570
  // ---------------------------------------------------------------------------
320
571
  // Trail definition
321
572
  // ---------------------------------------------------------------------------
322
573
 
323
574
  export const createScaffold = trail('create.scaffold', {
324
- description: 'Scaffold a new Trails project',
325
- implementation: async (input) => {
326
- const projectDir = resolve(input.dir ?? '.', input.name);
575
+ blaze: async (input) => {
576
+ const projectDirResult = resolveProjectDir(input.dir ?? '.', input.name);
577
+ if (projectDirResult.isErr()) {
578
+ return projectDirResult;
579
+ }
580
+
581
+ const projectDir = projectDirResult.value;
327
582
  const starter = (input.starter ?? 'hello') as Starter;
583
+ const dryRun = input.dryRun === true;
328
584
  const fileMap = collectScaffoldFiles(input.name, starter);
329
- const files = await writeScaffoldFiles(projectDir, fileMap);
330
- mkdirSync(join(projectDir, '.trails'), { recursive: true });
585
+ const operations = collectScaffoldOperations(fileMap);
586
+ const plannedOperations = dryRun
587
+ ? planProjectOperations(projectDir, operations, { existing: 'preserve' })
588
+ : await applyProjectOperations(projectDir, operations, {
589
+ existing: 'preserve',
590
+ });
591
+ if (plannedOperations.isErr()) {
592
+ return Result.err(plannedOperations.error);
593
+ }
594
+
595
+ const created = dryRun
596
+ ? []
597
+ : plannedOperations.value
598
+ .filter((operation) => operation.kind === 'write')
599
+ .map((operation) => operation.path);
331
600
 
332
601
  return Result.ok({
333
- created: files,
334
- dir: projectDir,
602
+ created,
603
+ dir: resolve(projectDir),
604
+ dryRun,
335
605
  name: input.name,
606
+ plannedOperations: plannedOperations.value,
336
607
  } satisfies ScaffoldResult);
337
608
  },
609
+ description: 'Scaffold a new Trails project',
338
610
  input: z.object({
339
611
  dir: z.string().optional().describe('Parent directory'),
340
- name: z.string().describe('Project name'),
612
+ dryRun: z
613
+ .boolean()
614
+ .default(false)
615
+ .describe('Plan scaffold writes without touching the project directory'),
616
+ name: z
617
+ .string()
618
+ .regex(PROJECT_NAME_PATTERN, PROJECT_NAME_MESSAGE)
619
+ .describe('Project name'),
341
620
  starter: z
342
621
  .enum(['hello', 'entity', 'empty'])
343
622
  .default('hello')
344
623
  .describe('Starter trail'),
345
624
  }),
346
- markers: { internal: true },
625
+ intent: 'write',
347
626
  output: z.object({
348
- created: z.array(z.string()),
627
+ created: z
628
+ .array(z.string())
629
+ .describe('Project-relative paths of files written (empty in dry-run)'),
349
630
  dir: z.string(),
631
+ dryRun: z.boolean(),
350
632
  name: z.string(),
633
+ plannedOperations: z.array(
634
+ z.discriminatedUnion('kind', [
635
+ z.object({ kind: z.literal('mkdir'), path: z.string() }),
636
+ z.object({
637
+ from: z.string(),
638
+ kind: z.literal('rename'),
639
+ to: z.string(),
640
+ }),
641
+ z.object({ kind: z.literal('write'), path: z.string() }),
642
+ ])
643
+ ),
351
644
  }),
645
+ permit: { scopes: ['project:write'] },
646
+ visibility: 'internal',
352
647
  });