@ontrails/trails 1.0.0-beta.17 → 1.0.0-beta.19

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 (45) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +7 -10
  3. package/package.json +13 -12
  4. package/src/app.ts +14 -4
  5. package/src/cli.ts +16 -0
  6. package/src/lifecycle-source-io.ts +33 -0
  7. package/src/project-writes.ts +62 -5
  8. package/src/retired-topo-command.ts +36 -0
  9. package/src/run-adapter-check.ts +76 -0
  10. package/src/run-collision.ts +1 -0
  11. package/src/trails/adapter-check.ts +244 -0
  12. package/src/trails/add-surface.ts +18 -18
  13. package/src/trails/add-trail.ts +3 -2
  14. package/src/trails/add-verify.ts +30 -6
  15. package/src/trails/{topo-compile.ts → compile.ts} +16 -8
  16. package/src/trails/completions-complete.ts +1 -1
  17. package/src/trails/create-adapter.ts +1084 -0
  18. package/src/trails/create-scaffold.ts +243 -29
  19. package/src/trails/create.ts +118 -17
  20. package/src/trails/deprecate.ts +59 -0
  21. package/src/trails/dev-clean.ts +2 -2
  22. package/src/trails/dev-reset.ts +2 -2
  23. package/src/trails/dev-stats.ts +1 -1
  24. package/src/trails/doctor.ts +56 -0
  25. package/src/trails/draft-promote.ts +1 -0
  26. package/src/trails/guide.ts +2 -2
  27. package/src/trails/revise.ts +53 -0
  28. package/src/trails/run-example.ts +12 -7
  29. package/src/trails/run-examples.ts +3 -3
  30. package/src/trails/run.ts +7 -4
  31. package/src/trails/survey.ts +332 -25
  32. package/src/trails/topo-history.ts +1 -1
  33. package/src/trails/topo-output-schemas.ts +30 -1
  34. package/src/trails/topo-pin.ts +3 -2
  35. package/src/trails/topo-read-support.ts +49 -8
  36. package/src/trails/topo-reports.ts +39 -22
  37. package/src/trails/topo-store-support.ts +62 -16
  38. package/src/trails/topo-support.ts +1 -1
  39. package/src/trails/topo-unpin.ts +2 -2
  40. package/src/trails/topo.ts +2 -2
  41. package/src/trails/{topo-verify.ts → validate.ts} +7 -7
  42. package/src/trails/version-lifecycle-support.ts +945 -0
  43. package/src/trails/warden-guide.ts +8 -0
  44. package/src/trails/warden.ts +18 -2
  45. package/src/versions.ts +4 -1
@@ -23,6 +23,7 @@ import type {
23
23
  import {
24
24
  ontrailsPackageRange,
25
25
  scaffoldDependencyVersions,
26
+ trailsPackageVersion,
26
27
  } from '../versions.js';
27
28
 
28
29
  // ---------------------------------------------------------------------------
@@ -39,6 +40,22 @@ interface ScaffoldResult {
39
40
  readonly plannedOperations: PlannedProjectOperation[];
40
41
  }
41
42
 
43
+ const frameworkCommandScripts = {
44
+ add: 'trails add',
45
+ compile: 'trails compile',
46
+ completions: 'trails completions',
47
+ deprecate: 'trails deprecate',
48
+ diff: 'trails diff',
49
+ doctor: 'trails doctor',
50
+ guide: 'trails guide',
51
+ revise: 'trails revise',
52
+ run: 'trails run',
53
+ survey: 'trails survey',
54
+ topo: 'trails topo',
55
+ validate: 'trails validate',
56
+ warden: 'trails warden',
57
+ } as const satisfies Record<string, string>;
58
+
42
59
  // ---------------------------------------------------------------------------
43
60
  // Content generators
44
61
  // ---------------------------------------------------------------------------
@@ -55,6 +72,7 @@ const generatePackageJson = (name: string): string => {
55
72
  ),
56
73
  devDependencies: Object.fromEntries(
57
74
  Object.entries({
75
+ '@ontrails/trails': ontrailsPackageRange,
58
76
  '@types/bun': scaffoldDependencyVersions.bunTypes,
59
77
  oxfmt: scaffoldDependencyVersions.oxfmt,
60
78
  oxlint: scaffoldDependencyVersions.oxlint,
@@ -63,14 +81,17 @@ const generatePackageJson = (name: string): string => {
63
81
  }).toSorted(([a], [b]) => a.localeCompare(b))
64
82
  ),
65
83
  name,
66
- scripts: {
67
- build: 'tsc -b',
68
- 'format:check': 'bunx ultracite check .',
69
- 'format:fix': 'bunx ultracite fix .',
70
- lint: 'oxlint ./src',
71
- test: 'bun test',
72
- typecheck: 'tsc --noEmit',
73
- },
84
+ scripts: Object.fromEntries(
85
+ Object.entries({
86
+ build: 'tsc -b',
87
+ 'format:check': 'bunx ultracite check .',
88
+ 'format:fix': 'bunx ultracite fix .',
89
+ lint: 'oxlint ./src',
90
+ test: 'bun test',
91
+ typecheck: 'tsc --noEmit',
92
+ ...frameworkCommandScripts,
93
+ }).toSorted(([a], [b]) => a.localeCompare(b))
94
+ ),
74
95
  type: 'module',
75
96
  version: '0.1.0',
76
97
  };
@@ -78,6 +99,18 @@ const generatePackageJson = (name: string): string => {
78
99
  return JSON.stringify(pkg, null, 2);
79
100
  };
80
101
 
102
+ const generateScaffoldProvenance = (starter: Starter): string =>
103
+ JSON.stringify(
104
+ {
105
+ generatedAt: new Date().toISOString(),
106
+ scaffoldVersion: trailsPackageVersion,
107
+ schemaVersion: 1,
108
+ template: starter,
109
+ },
110
+ null,
111
+ 2
112
+ );
113
+
81
114
  const TSCONFIG_CONTENT = JSON.stringify(
82
115
  {
83
116
  compilerOptions: {
@@ -98,6 +131,74 @@ const TSCONFIG_CONTENT = JSON.stringify(
98
131
  2
99
132
  );
100
133
 
134
+ const TSCONFIG_TESTS_CONTENT = JSON.stringify(
135
+ {
136
+ compilerOptions: {
137
+ noEmit: true,
138
+ rootDir: '.',
139
+ types: ['bun'],
140
+ },
141
+ exclude: [],
142
+ extends: './tsconfig.json',
143
+ include: ['src', '__tests__'],
144
+ },
145
+ null,
146
+ 2
147
+ );
148
+
149
+ const AGENTS_CONTENT = `# AGENTS.md
150
+
151
+ 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.
152
+
153
+ ## Commands
154
+
155
+ Use the project scripts first:
156
+
157
+ \`\`\`bash
158
+ bun install
159
+ bun run build
160
+ bun test
161
+ bun run typecheck
162
+ bun run lint
163
+ bun run format:check
164
+ bun run warden
165
+ bun run survey
166
+ bun run guide
167
+ \`\`\`
168
+
169
+ ## Lexicon
170
+
171
+ - \`trail\`, not action or handler
172
+ - \`blaze\`, not handler or impl
173
+ - \`topo\`, not registry or collection
174
+ - \`compose\`, not follow
175
+ - \`surface\`, not transport
176
+ - \`resource\`, not service or dependency
177
+ - \`layer\`, for compose-cutting trail wrapping
178
+
179
+ ## Trail Rules
180
+
181
+ - Blazes return \`Result\`; never throw from trail logic.
182
+ - Use \`Result.ok()\` and \`Result.err()\`; branch with \`isOk()\`, \`isErr()\`, or \`match()\`.
183
+ - Keep trail logic surface-agnostic. Do not import CLI, MCP, HTTP, request, or response types into blazes.
184
+ - Public MCP or HTTP trails declare an \`output\` schema.
185
+ - Trails that compose other trails declare \`composes: [...]\` and invoke them with \`ctx.compose(...)\`.
186
+ - Trails that use infrastructure declare \`resources: [...]\` and access them through the resource helpers.
187
+ - Use \`detours\` for recovery strategies instead of inline retry logic.
188
+ - Prefer examples for happy-path coverage, and add focused tests for edge cases.
189
+ `;
190
+
191
+ const CLAUDE_CONTENT = `# CLAUDE.md
192
+
193
+ ## Compatibility Shim
194
+
195
+ Keep shared project guidance in \`./AGENTS.md\`. Only Claude-specific bootstrap notes belong here.
196
+
197
+ ## Agent Instructions
198
+
199
+ @AGENTS.md
200
+ `;
201
+
101
202
  const GITIGNORE_CONTENT = `node_modules/
102
203
  dist/
103
204
  *.tsbuildinfo
@@ -112,6 +213,15 @@ import ultracite from 'ultracite/oxlint/core';
112
213
 
113
214
  export default defineConfig({
114
215
  extends: [ultracite],
216
+ rules: {
217
+ 'no-warning-comments': [
218
+ 'error',
219
+ {
220
+ location: 'start',
221
+ terms: ['todo:', 'fixme', 'xxx'],
222
+ },
223
+ ],
224
+ },
115
225
  });
116
226
  `;
117
227
 
@@ -153,9 +263,13 @@ export const hello = trail('hello', {
153
263
  `;
154
264
 
155
265
  const generateEntityTrails = (): string =>
156
- `import { Result, trail } from '@ontrails/core';
266
+ `import { randomUUID } from 'node:crypto';
267
+
268
+ import { NotFoundError, Result, trail } from '@ontrails/core';
157
269
  import { z } from 'zod';
158
270
 
271
+ import { entityStore } from '../store.js';
272
+
159
273
  const entitySchema = z.object({
160
274
  id: z.string(),
161
275
  name: z.string(),
@@ -170,28 +284,85 @@ export const show = trail('entity.show', {
170
284
  name: 'Show entity',
171
285
  },
172
286
  ],
173
- blaze: (input) => {
174
- return Result.ok({ id: input.id, name: 'Example' });
287
+ blaze: (input, ctx) => {
288
+ const store = entityStore.from(ctx);
289
+ const entity = store.get(input.id);
290
+ if (!entity) {
291
+ return Result.err(new NotFoundError(\`Entity "\${input.id}" not found\`));
292
+ }
293
+ return Result.ok(entity);
175
294
  },
176
295
  input: z.object({ id: z.string() }),
177
296
  output: entitySchema,
178
297
  intent: 'read',
298
+ resources: [entityStore],
179
299
  });
180
300
 
181
301
  export const add = trail('entity.add', {
182
302
  description: 'Add a new entity',
183
303
  examples: [
184
304
  {
185
- expected: { id: '1', name: 'New' },
305
+ expectedMatch: { name: 'New' },
186
306
  input: { name: 'New' },
187
307
  name: 'Add entity',
188
308
  },
189
309
  ],
190
- blaze: (input) => {
191
- return Result.ok({ id: '1', name: input.name });
310
+ blaze: (input, ctx) => {
311
+ const store = entityStore.from(ctx);
312
+ const entity = { id: randomUUID(), name: input.name };
313
+ store.add(entity);
314
+ return Result.ok(entity);
192
315
  },
193
316
  input: z.object({ name: z.string() }),
194
317
  output: entitySchema,
318
+ intent: 'write',
319
+ permit: { scopes: ['entity:write'] },
320
+ resources: [entityStore],
321
+ });
322
+
323
+ export const list = trail('entity.list', {
324
+ description: 'List entities',
325
+ examples: [
326
+ {
327
+ expected: { entities: [{ id: '1', name: 'Example' }] },
328
+ input: {},
329
+ name: 'List entities',
330
+ },
331
+ ],
332
+ blaze: (_input, ctx) => {
333
+ const store = entityStore.from(ctx);
334
+ return Result.ok({ entities: store.list() });
335
+ },
336
+ input: z.object({}),
337
+ output: z.object({
338
+ entities: z.array(entitySchema),
339
+ }),
340
+ intent: 'read',
341
+ resources: [entityStore],
342
+ });
343
+
344
+ export const remove = trail('entity.delete', {
345
+ description: 'Delete an entity by ID',
346
+ examples: [
347
+ {
348
+ expected: { deleted: true, id: '1' },
349
+ input: { id: '1' },
350
+ name: 'Delete entity',
351
+ },
352
+ ],
353
+ blaze: (input, ctx) => {
354
+ const store = entityStore.from(ctx);
355
+ const deleted = store.delete(input.id);
356
+ return Result.ok({ deleted, id: input.id });
357
+ },
358
+ input: z.object({ id: z.string() }),
359
+ output: z.object({
360
+ deleted: z.boolean(),
361
+ id: z.string(),
362
+ }),
363
+ intent: 'destroy',
364
+ permit: { scopes: ['entity:write'] },
365
+ resources: [entityStore],
195
366
  });
196
367
  `;
197
368
 
@@ -225,9 +396,9 @@ import { z } from 'zod';
225
396
 
226
397
  export const onboard = trail('entity.onboard', {
227
398
  description: 'Onboard a new entity end-to-end',
228
- crosses: ['entity.add'],
399
+ composes: ['entity.add'],
229
400
  blaze: async (input, ctx) => {
230
- const result = await ctx.cross('entity.add', { name: input.name });
401
+ const result = await ctx.compose('entity.add', { name: input.name });
231
402
  if (result.isErr()) {
232
403
  return result;
233
404
  }
@@ -235,6 +406,8 @@ export const onboard = trail('entity.onboard', {
235
406
  },
236
407
  input: z.object({ name: z.string() }),
237
408
  output: z.object({ onboarded: z.boolean() }),
409
+ intent: 'write',
410
+ permit: { scopes: ['entity:write'] },
238
411
  });
239
412
  `;
240
413
 
@@ -252,21 +425,49 @@ export const entityUpdated = signal('entity.updated', {
252
425
  `;
253
426
 
254
427
  const generateStore = (): string =>
255
- `/** In-memory store for entities. */
428
+ `import { Result, resource } from '@ontrails/core';
256
429
 
257
- interface Entity {
430
+ /** In-memory store for entities. */
431
+
432
+ export interface Entity {
258
433
  readonly id: string;
259
434
  readonly name: string;
260
435
  }
261
436
 
262
- const store = new Map<string, Entity>();
437
+ export interface EntityStore {
438
+ add(entity: Entity): void;
439
+ delete(id: string): boolean;
440
+ get(id: string): Entity | undefined;
441
+ list(): Entity[];
442
+ }
443
+
444
+ const defaultEntities: readonly Entity[] = [{ id: '1', name: 'Example' }];
263
445
 
264
- export const getEntity = (id: string): Entity | undefined => store.get(id);
265
- export const addEntity = (entity: Entity): void => {
266
- store.set(entity.id, entity);
446
+ export const createEntityStore = (
447
+ seed: readonly Entity[] = defaultEntities
448
+ ): EntityStore => {
449
+ const store = new Map(seed.map((entity) => [entity.id, entity] as const));
450
+ return {
451
+ add(entity) {
452
+ store.set(entity.id, entity);
453
+ },
454
+ delete(id) {
455
+ return store.delete(id);
456
+ },
457
+ get(id) {
458
+ return store.get(id);
459
+ },
460
+ list() {
461
+ return Array.from(store.values());
462
+ },
463
+ };
267
464
  };
268
- export const deleteEntity = (id: string): boolean => store.delete(id);
269
- export const listEntities = (): Entity[] => Array.from(store.values());
465
+
466
+ export const entityStore = resource('entity.store', {
467
+ description: 'In-memory entity store for the entity starter.',
468
+ create: () => Result.ok(createEntityStore()),
469
+ mock: createEntityStore,
470
+ });
270
471
  `;
271
472
 
272
473
  const starterImports: Record<
@@ -280,8 +481,9 @@ const starterImports: Record<
280
481
  "import * as search from './trails/search.js';",
281
482
  "import * as onboard from './trails/onboard.js';",
282
483
  "import * as entitySignals from './signals/entity-signals.js';",
484
+ "import * as store from './store.js';",
283
485
  ],
284
- modules: ['entity', 'search', 'onboard', 'entitySignals'],
486
+ modules: ['entity', 'search', 'onboard', 'entitySignals', 'store'],
285
487
  },
286
488
  hello: {
287
489
  imports: ["import * as hello from './trails/hello.js';"],
@@ -328,11 +530,15 @@ const collectScaffoldFiles = (
328
530
  ): Map<string, string> =>
329
531
  new Map([
330
532
  ['package.json', generatePackageJson(name)],
533
+ ['AGENTS.md', AGENTS_CONTENT],
534
+ ['CLAUDE.md', CLAUDE_CONTENT],
331
535
  ['tsconfig.json', TSCONFIG_CONTENT],
536
+ ['tsconfig.tests.json', TSCONFIG_TESTS_CONTENT],
332
537
  ['.gitignore', GITIGNORE_CONTENT],
333
538
  ['oxlint.config.ts', OXLINT_CONFIG_CONTENT],
334
539
  ['.oxfmtrc.jsonc', OXFMTRC_CONTENT],
335
540
  ['.trails/.gitignore', WORKSPACE_GITIGNORE_CONTENT],
541
+ ['.trails/scaffold.json', generateScaffoldProvenance(starter)],
336
542
  ['src/app.ts', generateAppTs(name, starter)],
337
543
  ...starterFileGenerators[starter](),
338
544
  ]);
@@ -354,7 +560,7 @@ export const createScaffold = trail('create.scaffold', {
354
560
  blaze: async (input) => {
355
561
  const projectDirResult = resolveProjectDir(input.dir ?? '.', input.name);
356
562
  if (projectDirResult.isErr()) {
357
- return Result.err(projectDirResult.error);
563
+ return projectDirResult;
358
564
  }
359
565
 
360
566
  const projectDir = projectDirResult.value;
@@ -363,13 +569,19 @@ export const createScaffold = trail('create.scaffold', {
363
569
  const fileMap = collectScaffoldFiles(input.name, starter);
364
570
  const operations = collectScaffoldOperations(fileMap);
365
571
  const plannedOperations = dryRun
366
- ? planProjectOperations(projectDir, operations)
367
- : await applyProjectOperations(projectDir, operations);
572
+ ? planProjectOperations(projectDir, operations, { existing: 'preserve' })
573
+ : await applyProjectOperations(projectDir, operations, {
574
+ existing: 'preserve',
575
+ });
368
576
  if (plannedOperations.isErr()) {
369
577
  return Result.err(plannedOperations.error);
370
578
  }
371
579
 
372
- const created = dryRun ? [] : [...fileMap.keys()];
580
+ const created = dryRun
581
+ ? []
582
+ : plannedOperations.value
583
+ .filter((operation) => operation.kind === 'write')
584
+ .map((operation) => operation.path);
373
585
 
374
586
  return Result.ok({
375
587
  created,
@@ -395,6 +607,7 @@ export const createScaffold = trail('create.scaffold', {
395
607
  .default('hello')
396
608
  .describe('Starter trail'),
397
609
  }),
610
+ intent: 'write',
398
611
  output: z.object({
399
612
  created: z
400
613
  .array(z.string())
@@ -414,5 +627,6 @@ export const createScaffold = trail('create.scaffold', {
414
627
  ])
415
628
  ),
416
629
  }),
630
+ permit: { scopes: ['project:write'] },
417
631
  visibility: 'internal',
418
632
  });
@@ -1,16 +1,19 @@
1
1
  /**
2
- * `create` route -- Create a new Trails project.
2
+ * `create` trail -- Create a new Trails project.
3
3
  *
4
4
  * Composes create.scaffold, add.surface, and add.verify sub-trails
5
- * via ctx.cross.
5
+ * via ctx.compose.
6
6
  */
7
7
 
8
8
  import { InternalError, Result, trail } from '@ontrails/core';
9
+ import type { TrailContext } from '@ontrails/core';
9
10
  import { z } from 'zod';
10
11
 
11
12
  import {
12
13
  PROJECT_NAME_MESSAGE,
13
14
  PROJECT_NAME_PATTERN,
15
+ projectPathExists,
16
+ writeProjectFile,
14
17
  } from '../project-writes.js';
15
18
 
16
19
  // ---------------------------------------------------------------------------
@@ -47,10 +50,17 @@ interface ScaffoldedProject {
47
50
  }
48
51
 
49
52
  interface SurfaceResult {
50
- readonly created: string;
53
+ readonly created: string | null;
51
54
  readonly dependency: string;
52
55
  }
53
56
 
57
+ type TrailContextWithCompose = TrailContext & {
58
+ readonly compose: NonNullable<TrailContext['compose']>;
59
+ };
60
+
61
+ const hasCompose = (ctx: TrailContext): ctx is TrailContextWithCompose =>
62
+ Boolean(ctx.compose);
63
+
54
64
  const buildScaffoldInput = (input: ScaffoldRequest) => ({
55
65
  ...(input.dir === undefined ? {} : { dir: input.dir }),
56
66
  name: input.name,
@@ -78,7 +88,9 @@ const collectSurfaceFiles = async (
78
88
  if (result.isErr()) {
79
89
  return Result.err(result.error);
80
90
  }
81
- created.push(result.value.created);
91
+ if (result.value.created !== null) {
92
+ created.push(result.value.created);
93
+ }
82
94
  }
83
95
 
84
96
  return Result.ok(created);
@@ -101,26 +113,104 @@ const collectVerifyFiles = async (
101
113
  const collectCreatedFiles = (
102
114
  scaffolded: readonly string[],
103
115
  surfaces: readonly string[],
104
- verify: readonly string[]
105
- ): string[] => [...scaffolded, ...surfaces, ...verify];
116
+ verify: readonly string[],
117
+ readme: string | null
118
+ ): string[] =>
119
+ readme === null
120
+ ? [...scaffolded, ...surfaces, ...verify]
121
+ : [...scaffolded, ...surfaces, ...verify, readme];
122
+
123
+ const surfaceReadmeLines = {
124
+ cli: '- `src/cli.ts` - CLI surface entry point',
125
+ http: '- `src/http.ts` - HTTP surface entry point',
126
+ mcp: '- `src/mcp.ts` - MCP surface entry point',
127
+ } satisfies Record<Surface, string>;
128
+
129
+ const starterReadmeLines = {
130
+ empty:
131
+ 'Starts with an empty `src/trails/` directory for authoring from scratch.',
132
+ entity:
133
+ 'Includes sample entity trails, a signal, and an in-memory store for exploration.',
134
+ hello: 'Includes a `hello` trail with examples for the first happy path.',
135
+ } satisfies Record<Starter, string>;
136
+
137
+ const generateReadme = (input: CreateInput): string => {
138
+ const surfaceLines = input.surfaces
139
+ .map((surface) => surfaceReadmeLines[surface])
140
+ .join('\n');
141
+ const verificationCommand = input.verify ? 'bun test\n' : '';
142
+ const verificationStructure = input.verify
143
+ ? '- `__tests__/examples.test.ts` - examples-as-tests harness\n'
144
+ : '- Verification files were not generated for this project\n';
145
+
146
+ return `# ${input.name}
147
+
148
+ 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.
149
+
150
+ ## Getting Started
151
+
152
+ \`\`\`bash
153
+ bun install
154
+ ${verificationCommand}bun run warden
155
+ bun run survey
156
+ bun run guide
157
+ \`\`\`
158
+
159
+ ## Project Structure
160
+
161
+ - \`src/app.ts\` - the topo that collects this project's trails
162
+ - \`src/trails/\` - trail definitions
163
+ ${surfaceLines}
164
+ ${verificationStructure}- \`AGENTS.md\` - project guidance for agents working in this app
165
+
166
+ ## Starter
167
+
168
+ ${starterReadmeLines[input.starter]}
169
+
170
+ ## Next Steps
171
+
172
+ - Add a trail with \`bun run add\`
173
+ - Run \`bun run warden\` before review
174
+ - Read \`AGENTS.md\` for Trails vocabulary and conventions
175
+ `;
176
+ };
177
+
178
+ const writeReadme = async (
179
+ input: CreateInput,
180
+ dir: string
181
+ ): Promise<Result<string | null, Error>> => {
182
+ const exists = projectPathExists(dir, 'README.md');
183
+ if (exists.isErr()) {
184
+ return Result.err(exists.error);
185
+ }
186
+ if (exists.value) {
187
+ return Result.ok(null);
188
+ }
189
+
190
+ const written = await writeProjectFile(
191
+ dir,
192
+ 'README.md',
193
+ generateReadme(input)
194
+ );
195
+ return written.isErr() ? Result.err(written.error) : Result.ok('README.md');
196
+ };
106
197
 
107
198
  // ---------------------------------------------------------------------------
108
- // Route definition
199
+ // Trail definition
109
200
  // ---------------------------------------------------------------------------
110
201
 
111
- export const createRoute = trail('create', {
202
+ export const createTrail = trail('create', {
112
203
  blaze: async (input: CreateInput, ctx) => {
113
- if (!ctx.cross) {
114
- return Result.err(new InternalError('create route requires ctx.cross'));
204
+ if (!hasCompose(ctx)) {
205
+ return Result.err(new InternalError('create trail requires ctx.compose'));
115
206
  }
116
- const { cross } = ctx;
117
207
 
118
- const scaffolded = await cross<ScaffoldedProject>(
208
+ const scaffolded = await ctx.compose<ScaffoldedProject>(
119
209
  'create.scaffold',
120
210
  buildScaffoldInput(input)
121
211
  );
122
212
  if (scaffolded.isErr()) {
123
- return Result.err(scaffolded.error);
213
+ return scaffolded;
124
214
  }
125
215
 
126
216
  const finishCreate = async (): Promise<
@@ -129,7 +219,7 @@ export const createRoute = trail('create', {
129
219
  const surfaceFiles = await collectSurfaceFiles(
130
220
  input.surfaces,
131
221
  (surface) =>
132
- cross<SurfaceResult>(
222
+ ctx.compose<SurfaceResult>(
133
223
  'add.surface',
134
224
  buildSurfaceInput(scaffolded.value.dir, surface)
135
225
  )
@@ -139,17 +229,26 @@ export const createRoute = trail('create', {
139
229
  }
140
230
 
141
231
  const verifyFiles = await collectVerifyFiles(input.verify, () =>
142
- cross<{ created: string[] }>('add.verify', buildVerifyInput(input))
232
+ ctx.compose<{ created: string[] }>(
233
+ 'add.verify',
234
+ buildVerifyInput(input)
235
+ )
143
236
  );
144
237
  if (verifyFiles.isErr()) {
145
238
  return Result.err(verifyFiles.error);
146
239
  }
147
240
 
241
+ const readmeFile = await writeReadme(input, scaffolded.value.dir);
242
+ if (readmeFile.isErr()) {
243
+ return Result.err(readmeFile.error);
244
+ }
245
+
148
246
  return Result.ok({
149
247
  created: collectCreatedFiles(
150
248
  scaffolded.value.created,
151
249
  surfaceFiles.value,
152
- verifyFiles.value
250
+ verifyFiles.value,
251
+ readmeFile.value
153
252
  ),
154
253
  dir: scaffolded.value.dir,
155
254
  name: input.name,
@@ -158,7 +257,7 @@ export const createRoute = trail('create', {
158
257
 
159
258
  return finishCreate();
160
259
  },
161
- crosses: ['create.scaffold', 'add.surface', 'add.verify'],
260
+ composes: ['create.scaffold', 'add.surface', 'add.verify'],
162
261
  description: 'Create a new Trails project',
163
262
  fields: {
164
263
  starter: {
@@ -204,6 +303,7 @@ export const createRoute = trail('create', {
204
303
  .describe('Starter trail'),
205
304
  surfaces: z
206
305
  .array(z.enum(['cli', 'http', 'mcp']))
306
+ .min(1)
207
307
  .default(['cli'])
208
308
  .describe('Surfaces'),
209
309
  verify: z.boolean().default(true).describe('Include testing + warden'),
@@ -213,4 +313,5 @@ export const createRoute = trail('create', {
213
313
  dir: z.string(),
214
314
  name: z.string(),
215
315
  }),
316
+ permit: { scopes: ['project:write'] },
216
317
  });