@ontrails/trails 0.2.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 (121) hide show
  1. package/CHANGELOG.md +1906 -0
  2. package/README.md +48 -0
  3. package/bin/trails.ts +3 -0
  4. package/package.json +57 -0
  5. package/src/app.ts +167 -0
  6. package/src/clack.ts +111 -0
  7. package/src/cli.ts +308 -0
  8. package/src/completions.ts +431 -0
  9. package/src/lifecycle-source-io.ts +33 -0
  10. package/src/load-app-mirror.ts +202 -0
  11. package/src/local-state-io.ts +129 -0
  12. package/src/mcp-app.ts +42 -0
  13. package/src/mcp-options.ts +92 -0
  14. package/src/mcp.ts +8 -0
  15. package/src/project-writes.ts +377 -0
  16. package/src/regrade/audit.ts +571 -0
  17. package/src/regrade/config.ts +152 -0
  18. package/src/regrade/history.ts +636 -0
  19. package/src/regrade/lifecycle.ts +76 -0
  20. package/src/regrade/live-api-preserve.ts +123 -0
  21. package/src/regrade/plan-artifact.ts +515 -0
  22. package/src/regrade/plan-derivation.ts +301 -0
  23. package/src/regrade/prepared-run.ts +259 -0
  24. package/src/regrade/receipt-history.ts +446 -0
  25. package/src/regrade/source-transaction.ts +185 -0
  26. package/src/release/bindings.ts +58 -0
  27. package/src/release/changeset-packages.ts +99 -0
  28. package/src/release/check.ts +1191 -0
  29. package/src/release/cli-bundle.ts +575 -0
  30. package/src/release/config.ts +73 -0
  31. package/src/release/contract-facts.ts +425 -0
  32. package/src/release/homebrew.ts +221 -0
  33. package/src/release/index.ts +180 -0
  34. package/src/release/lock-roundtrip-smoke.ts +255 -0
  35. package/src/release/lock-roundtrip-workspace.ts +107 -0
  36. package/src/release/native-bun-publish.ts +964 -0
  37. package/src/release/native-bun-registry.ts +848 -0
  38. package/src/release/notes-cli.ts +171 -0
  39. package/src/release/notes.ts +390 -0
  40. package/src/release/pack-coherence.ts +455 -0
  41. package/src/release/package-route-facts.ts +146 -0
  42. package/src/release/packed-artifacts-smoke.ts +236 -0
  43. package/src/release/policy.ts +1780 -0
  44. package/src/release/semver.ts +104 -0
  45. package/src/release/smoke.ts +56 -0
  46. package/src/release/stable-version-release.ts +80 -0
  47. package/src/release/wayfinder-dogfood-smoke.ts +762 -0
  48. package/src/release/zero-line-transition.ts +68 -0
  49. package/src/retired-topo-command.ts +36 -0
  50. package/src/run-adapter-check.ts +76 -0
  51. package/src/run-argv.ts +133 -0
  52. package/src/run-collision.ts +126 -0
  53. package/src/run-completions-install.ts +179 -0
  54. package/src/run-example.ts +149 -0
  55. package/src/run-examples.ts +148 -0
  56. package/src/run-quiet.ts +75 -0
  57. package/src/run-regrade-progress.ts +47 -0
  58. package/src/run-release-check.ts +74 -0
  59. package/src/run-schema.ts +74 -0
  60. package/src/run-trace.ts +273 -0
  61. package/src/run-warden.ts +39 -0
  62. package/src/run-watch-project.ts +52 -0
  63. package/src/run-watch.ts +381 -0
  64. package/src/run-wayfind-outline.ts +170 -0
  65. package/src/scaffold-version-sync.ts +183 -0
  66. package/src/scaffold-versions.generated.ts +12 -0
  67. package/src/trails/adapter-check.ts +244 -0
  68. package/src/trails/add-surface.ts +816 -0
  69. package/src/trails/add-trail.ts +141 -0
  70. package/src/trails/add-verify.ts +252 -0
  71. package/src/trails/compile.ts +118 -0
  72. package/src/trails/completions-complete.ts +236 -0
  73. package/src/trails/completions.ts +47 -0
  74. package/src/trails/config-explain.ts +43 -0
  75. package/src/trails/create-adapter.ts +785 -0
  76. package/src/trails/create-scaffold.ts +1215 -0
  77. package/src/trails/create-versions.ts +62 -0
  78. package/src/trails/create.ts +652 -0
  79. package/src/trails/deprecate.ts +59 -0
  80. package/src/trails/dev-clean.ts +80 -0
  81. package/src/trails/dev-reset.ts +48 -0
  82. package/src/trails/dev-stats.ts +71 -0
  83. package/src/trails/dev-support.ts +360 -0
  84. package/src/trails/doctor.ts +77 -0
  85. package/src/trails/draft-promote.ts +949 -0
  86. package/src/trails/guide.ts +106 -0
  87. package/src/trails/load-app.ts +1145 -0
  88. package/src/trails/operator-context.ts +66 -0
  89. package/src/trails/project-context-output.ts +304 -0
  90. package/src/trails/project-context.ts +613 -0
  91. package/src/trails/project.ts +65 -0
  92. package/src/trails/regrade.ts +4951 -0
  93. package/src/trails/release-check.ts +113 -0
  94. package/src/trails/release-smoke.ts +49 -0
  95. package/src/trails/revise.ts +53 -0
  96. package/src/trails/root-dir.ts +21 -0
  97. package/src/trails/run-example.ts +592 -0
  98. package/src/trails/run-examples.ts +149 -0
  99. package/src/trails/run.ts +496 -0
  100. package/src/trails/scaffold-json.ts +60 -0
  101. package/src/trails/scaffold-topo-identity.ts +479 -0
  102. package/src/trails/survey.ts +990 -0
  103. package/src/trails/topo-activation.ts +14 -0
  104. package/src/trails/topo-constants.ts +2 -0
  105. package/src/trails/topo-history.ts +47 -0
  106. package/src/trails/topo-output-schemas.ts +259 -0
  107. package/src/trails/topo-pin.ts +38 -0
  108. package/src/trails/topo-read-support.ts +368 -0
  109. package/src/trails/topo-reports.ts +809 -0
  110. package/src/trails/topo-store-support.ts +323 -0
  111. package/src/trails/topo-support.ts +247 -0
  112. package/src/trails/topo-unpin.ts +61 -0
  113. package/src/trails/topo.ts +92 -0
  114. package/src/trails/validate.ts +348 -0
  115. package/src/trails/version-lifecycle-support.ts +936 -0
  116. package/src/trails/warden-guide.ts +134 -0
  117. package/src/trails/warden.ts +598 -0
  118. package/src/trails/wayfind-diff.ts +716 -0
  119. package/src/trails/wayfind-outline.ts +876 -0
  120. package/src/trails/wayfind.ts +1319 -0
  121. package/src/versions.ts +31 -0
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `create.versions` trail -- Sync generated scaffold dependency versions.
3
+ *
4
+ * Derives `apps/trails/src/scaffold-versions.generated.ts` from the root
5
+ * `package.json` catalog and devDependencies. Graduated from
6
+ * `scripts/sync-scaffold-versions.ts`.
7
+ */
8
+
9
+ import { Result, trail, ValidationError } from '@ontrails/core';
10
+ import { z } from 'zod';
11
+
12
+ import { syncScaffoldVersions } from '../scaffold-version-sync.js';
13
+ import { resolveTrailRootDir } from './root-dir.js';
14
+
15
+ const createVersionsInputSchema = z.object({
16
+ check: z
17
+ .boolean()
18
+ .default(false)
19
+ .describe('Verify the generated file is current instead of writing'),
20
+ rootDir: z.string().optional().describe('Workspace root directory'),
21
+ });
22
+
23
+ const createVersionsOutputSchema = z.object({
24
+ generatedPath: z.string(),
25
+ mode: z.enum(['check', 'write']),
26
+ written: z.boolean(),
27
+ });
28
+
29
+ export const createVersionsTrail = trail('create.versions', {
30
+ description: 'Sync generated scaffold dependency versions',
31
+ examples: [
32
+ {
33
+ input: { check: true },
34
+ name: 'Verify generated scaffold versions are current',
35
+ },
36
+ ],
37
+ implementation: async (input, ctx) => {
38
+ const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
39
+ if (rootDirResult.isErr()) {
40
+ return rootDirResult;
41
+ }
42
+
43
+ try {
44
+ return Result.ok(
45
+ await syncScaffoldVersions({
46
+ check: input.check,
47
+ rootDir: rootDirResult.value,
48
+ })
49
+ );
50
+ } catch (error) {
51
+ return Result.err(
52
+ new ValidationError(
53
+ error instanceof Error ? error.message : String(error)
54
+ )
55
+ );
56
+ }
57
+ },
58
+ input: createVersionsInputSchema,
59
+ intent: 'write',
60
+ output: createVersionsOutputSchema,
61
+ permit: { scopes: ['project:write'] },
62
+ });
@@ -0,0 +1,652 @@
1
+ /**
2
+ * `create` trail -- Create a new Trails project.
3
+ *
4
+ * Composes create.scaffold, add.surface, and add.verify sub-trails
5
+ * via ctx.compose.
6
+ */
7
+
8
+ import { realpathSync } from 'node:fs';
9
+ import { basename, dirname, join, relative } from 'node:path';
10
+
11
+ import { InternalError, Result, trail } from '@ontrails/core';
12
+ import type { TrailContext } from '@ontrails/core';
13
+ import { z } from 'zod';
14
+
15
+ import {
16
+ PROJECT_NAME_MESSAGE,
17
+ PROJECT_NAME_PATTERN,
18
+ projectPathExists,
19
+ writeProjectFile,
20
+ } from '../project-writes.js';
21
+ import type { PlannedProjectOperation } from '../project-writes.js';
22
+ import { resolveSurfaceEntryFile } from './add-surface.js';
23
+ import { resolveVerifyHookDir } from './add-verify.js';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ type Starter = 'empty' | 'entity' | 'hello';
30
+ type Surface = 'cli' | 'http' | 'mcp';
31
+
32
+ interface CreateInput {
33
+ readonly dir?: string | undefined;
34
+ readonly name: string;
35
+ readonly starter: Starter;
36
+ readonly surfaces: readonly Surface[];
37
+ readonly verify: boolean;
38
+ readonly workspace: boolean;
39
+ }
40
+
41
+ interface ScaffoldRequest {
42
+ readonly dir?: string | undefined;
43
+ readonly dryRun: boolean;
44
+ readonly name: string;
45
+ readonly starter: Starter;
46
+ readonly workspace: boolean;
47
+ }
48
+
49
+ interface VerifyRequest {
50
+ readonly dir?: string | undefined;
51
+ readonly name: string;
52
+ readonly verify: boolean;
53
+ }
54
+
55
+ interface ScaffoldedProject {
56
+ readonly appDir: string;
57
+ readonly appRoot: string;
58
+ readonly created: string[];
59
+ readonly dir: string;
60
+ readonly dryRun: boolean;
61
+ readonly layout: 'standalone' | 'workspace';
62
+ readonly name: string;
63
+ readonly plannedOperations: PlannedProjectOperation[];
64
+ }
65
+
66
+ interface CreateResult {
67
+ readonly appDir: string;
68
+ readonly created: string[];
69
+ readonly dir: string;
70
+ readonly dryRun: boolean;
71
+ readonly guidance: string[];
72
+ readonly layout: 'standalone' | 'workspace';
73
+ readonly name: string;
74
+ readonly plannedOperations: PlannedProjectOperation[];
75
+ }
76
+
77
+ interface SurfaceResult {
78
+ readonly created: string | null;
79
+ readonly dependency: string;
80
+ }
81
+
82
+ type TrailContextWithCompose = TrailContext & {
83
+ readonly compose: NonNullable<TrailContext['compose']>;
84
+ };
85
+
86
+ const hasCompose = (ctx: TrailContext): ctx is TrailContextWithCompose =>
87
+ Boolean(ctx.compose);
88
+
89
+ const buildScaffoldInput = (input: ScaffoldRequest) => ({
90
+ ...(input.dir === undefined ? {} : { dir: input.dir }),
91
+ dryRun: input.dryRun,
92
+ name: input.name,
93
+ starter: input.starter,
94
+ workspace: input.workspace,
95
+ });
96
+
97
+ const buildSurfaceInput = (dir: string, surface: string) => ({
98
+ dir,
99
+ surface,
100
+ });
101
+
102
+ const buildVerifyInput = (input: VerifyRequest) => ({
103
+ ...(input.dir === undefined ? {} : { dir: input.dir }),
104
+ name: input.name,
105
+ });
106
+
107
+ const collectSurfaceFiles = async (
108
+ surfaces: readonly string[],
109
+ addSurface: (surface: string) => Promise<Result<SurfaceResult, Error>>
110
+ ): Promise<Result<string[], Error>> => {
111
+ const created: string[] = [];
112
+
113
+ for (const surface of surfaces) {
114
+ const result = await addSurface(surface);
115
+ if (result.isErr()) {
116
+ return result;
117
+ }
118
+ if (result.value.created !== null) {
119
+ created.push(result.value.created);
120
+ }
121
+ }
122
+
123
+ return Result.ok(created);
124
+ };
125
+
126
+ const collectVerifyFiles = async (
127
+ shouldVerify: boolean,
128
+ addVerify: () => Promise<Result<{ created: string[] }, Error>>
129
+ ): Promise<Result<string[], Error>> => {
130
+ if (!shouldVerify) {
131
+ return Result.ok([]);
132
+ }
133
+
134
+ const result = await addVerify();
135
+ return result.isErr()
136
+ ? Result.err(result.error)
137
+ : Result.ok(result.value.created);
138
+ };
139
+
140
+ const collectCreatedFiles = (
141
+ scaffolded: readonly string[],
142
+ surfaces: readonly string[],
143
+ verify: readonly string[],
144
+ readme: string | null
145
+ ): string[] =>
146
+ readme === null
147
+ ? [...scaffolded, ...surfaces, ...verify]
148
+ : [...scaffolded, ...surfaces, ...verify, readme];
149
+
150
+ const projectRelativeAppPath = (appRoot: string, path: string): string =>
151
+ appRoot === '.' ? path : `${appRoot}/${path}`;
152
+
153
+ const projectRelativeVerifyPath = (
154
+ scaffolded: ScaffoldedProject,
155
+ hookDir: string,
156
+ path: string
157
+ ): string => {
158
+ if (path !== 'lefthook.yml') {
159
+ return projectRelativeAppPath(scaffolded.appRoot, path);
160
+ }
161
+ // Keep reports relative when canonical hook ownership spans a filesystem
162
+ // alias such as macOS's /var -> /private/var mapping.
163
+ try {
164
+ return (
165
+ relative(
166
+ realpathSync(scaffolded.dir),
167
+ join(realpathSync(hookDir), path)
168
+ ) || path
169
+ );
170
+ } catch {
171
+ return relative(scaffolded.dir, join(hookDir, path)) || path;
172
+ }
173
+ };
174
+
175
+ type ResolvedSurfaceEntryFiles = ReadonlyMap<Surface, string>;
176
+
177
+ const resolveSurfaceEntryFiles = (
178
+ appDir: string,
179
+ surfaces: readonly Surface[],
180
+ projectedLocalTsconfig: boolean
181
+ ): Result<ResolvedSurfaceEntryFiles, Error> => {
182
+ const entries = new Map<Surface, string>();
183
+ for (const surface of surfaces) {
184
+ const entryFile = resolveSurfaceEntryFile(appDir, surface, {
185
+ projectedLocalTsconfig,
186
+ projectedPackageTypeModule: true,
187
+ });
188
+ if (entryFile.isErr()) {
189
+ return entryFile;
190
+ }
191
+ entries.set(surface, entryFile.value);
192
+ }
193
+ return Result.ok(entries);
194
+ };
195
+
196
+ const plansLocalTsconfig = (scaffolded: ScaffoldedProject): boolean =>
197
+ scaffolded.plannedOperations.some(
198
+ (operation) =>
199
+ operation.kind === 'write' &&
200
+ operation.path ===
201
+ projectRelativeAppPath(scaffolded.appRoot, 'tsconfig.json')
202
+ );
203
+
204
+ const collectCreateOperations = (
205
+ scaffolded: ScaffoldedProject,
206
+ input: CreateInput,
207
+ surfaceEntryFiles: ResolvedSurfaceEntryFiles,
208
+ hookDir: string
209
+ ): Result<PlannedProjectOperation[], Error> => {
210
+ const surfacePaths = [...surfaceEntryFiles.values()].map((entryFile) =>
211
+ projectRelativeAppPath(scaffolded.appRoot, entryFile)
212
+ );
213
+
214
+ const preserveExistingPaths = [
215
+ ...surfacePaths,
216
+ ...(input.verify
217
+ ? [
218
+ projectRelativeAppPath(
219
+ scaffolded.appRoot,
220
+ '__tests__/examples.test.ts'
221
+ ),
222
+ ]
223
+ : []),
224
+ 'README.md',
225
+ ];
226
+ const additions: PlannedProjectOperation[] = [];
227
+ for (const path of preserveExistingPaths) {
228
+ const exists = projectPathExists(scaffolded.dir, path);
229
+ if (exists.isErr()) {
230
+ return exists;
231
+ }
232
+ if (!exists.value) {
233
+ additions.push({ kind: 'write', path });
234
+ }
235
+ }
236
+ if (input.verify) {
237
+ const hookExists = projectPathExists(hookDir, 'lefthook.yml');
238
+ if (hookExists.isErr()) {
239
+ return hookExists;
240
+ }
241
+ if (!hookExists.value) {
242
+ additions.push({
243
+ kind: 'write',
244
+ path: projectRelativeVerifyPath(scaffolded, hookDir, 'lefthook.yml'),
245
+ });
246
+ }
247
+ }
248
+ additions.push(
249
+ ...(input.surfaces.length === 0
250
+ ? []
251
+ : [
252
+ {
253
+ kind: 'write' as const,
254
+ path: projectRelativeAppPath(scaffolded.appRoot, 'package.json'),
255
+ },
256
+ ]),
257
+ ...(input.verify
258
+ ? [
259
+ {
260
+ kind: 'write' as const,
261
+ path: projectRelativeAppPath(scaffolded.appRoot, 'package.json'),
262
+ },
263
+ ]
264
+ : [])
265
+ );
266
+ const unique = new Map<string, PlannedProjectOperation>();
267
+ for (const operation of [...scaffolded.plannedOperations, ...additions]) {
268
+ const key =
269
+ operation.kind === 'rename'
270
+ ? `${operation.kind}:${operation.from}:${operation.to}`
271
+ : `${operation.kind}:${operation.path}`;
272
+ unique.set(key, operation);
273
+ }
274
+ return Result.ok([...unique.values()]);
275
+ };
276
+
277
+ const createGuidance = (input: CreateInput): string[] =>
278
+ input.workspace
279
+ ? [
280
+ `Install dependencies, then run \`bunx trails compile --app ${input.name} --permit '{"id":"local-dev","scopes":["topo:write"]}'\` from the workspace root to derive apps/${input.name}/trails.lock.`,
281
+ 'The workspace view derives from the literal workspace.apps catalog and app-owned locks; no root aggregate lock is created.',
282
+ 'Disposable cache and observed state stay in the global per-user Trails cache and state homes.',
283
+ ]
284
+ : [
285
+ 'Install dependencies, then run `bun run compile --permit \'{"id":"local-dev","scopes":["topo:write"]}\'` from the app root to derive trails.lock.',
286
+ 'Disposable cache and observed state stay in the global per-user Trails cache and state homes.',
287
+ ];
288
+
289
+ const surfaceReadmeDescriptions = {
290
+ cli: 'CLI surface entry point',
291
+ http: 'HTTP surface entry point',
292
+ mcp: 'MCP surface entry point',
293
+ } satisfies Record<Surface, string>;
294
+
295
+ const starterReadmeLines = {
296
+ empty:
297
+ 'Starts with an empty `src/trails/` directory for authoring from scratch.',
298
+ entity:
299
+ 'Includes sample entity trails, a signal, and an in-memory store for exploration.',
300
+ hello: 'Includes a `hello` trail with examples for the first happy path.',
301
+ } satisfies Record<Starter, string>;
302
+
303
+ const starterOwnedFiles = {
304
+ empty: [],
305
+ entity: [
306
+ 'src/app.ts',
307
+ 'src/signals/entity-signals.ts',
308
+ 'src/store.ts',
309
+ 'src/trails/entity.ts',
310
+ 'src/trails/onboard.ts',
311
+ 'src/trails/search.ts',
312
+ ],
313
+ hello: ['src/app.ts', 'src/trails/hello.ts'],
314
+ } as const satisfies Record<Starter, readonly string[]>;
315
+
316
+ const createdStarterContract = (
317
+ starter: Starter,
318
+ appPrefix: string,
319
+ createdScaffoldFiles: ReadonlySet<string>
320
+ ): boolean =>
321
+ starter !== 'empty' &&
322
+ starterOwnedFiles[starter].every((path) =>
323
+ createdScaffoldFiles.has(`${appPrefix}${path}`)
324
+ );
325
+
326
+ const generateReadme = (
327
+ input: CreateInput,
328
+ surfaceEntryFiles: ResolvedSurfaceEntryFiles,
329
+ createdSurfaceFiles: ReadonlySet<string>,
330
+ createdScaffoldFiles: ReadonlySet<string>
331
+ ): string => {
332
+ const appPrefix = input.workspace ? `apps/${input.name}/` : '';
333
+ const surfaceLines = [...surfaceEntryFiles]
334
+ .map(
335
+ ([surface, entryFile]) =>
336
+ `- \`${appPrefix}${entryFile}\` - ${surfaceReadmeDescriptions[surface]}`
337
+ )
338
+ .join('\n');
339
+ const verificationCommand = input.verify ? 'bun test\n' : '';
340
+ const verificationStructure = input.verify
341
+ ? '- `__tests__/examples.test.ts` - examples-as-tests harness\n'
342
+ : '- Verification files were not generated for this project\n';
343
+ const ownsStarterContract = createdStarterContract(
344
+ input.starter,
345
+ appPrefix,
346
+ createdScaffoldFiles
347
+ );
348
+ const workspaceRunCommand =
349
+ !input.workspace || !ownsStarterContract
350
+ ? ''
351
+ : `bunx trails run ${input.starter === 'hello' ? 'hello' : 'entity.list'} --app ${input.name} --permit '{"id":"local-dev","scopes":["trails:run"]}'\n`;
352
+ const cliEntryFile = surfaceEntryFiles.get('cli');
353
+ const localPermitGuidance =
354
+ input.starter === 'entity' &&
355
+ ownsStarterContract &&
356
+ cliEntryFile !== undefined &&
357
+ createdSurfaceFiles.has(cliEntryFile)
358
+ ? `## Local Permits
359
+
360
+ Protected starter writes require an explicit scoped permit. For local exploration, run the generated CLI with the narrow starter scope:
361
+
362
+ \`\`\`bash
363
+ bun ${appPrefix}${cliEntryFile} entity add --name New --permit '{"id":"local-dev","scopes":["entity:write"]}'
364
+ \`\`\`
365
+
366
+ `
367
+ : '';
368
+
369
+ const compileCommands = input.workspace
370
+ ? `bunx trails compile --app ${input.name} --permit '{"id":"local-dev","scopes":["topo:write"]}'
371
+ bunx trails validate --app ${input.name}
372
+ ${workspaceRunCommand}bunx trails warden --app ${input.name}
373
+ bunx trails wayfind --overview --app ${input.name}`
374
+ : `bun run compile --permit '{"id":"local-dev","scopes":["topo:write"]}'
375
+ bun run validate
376
+ bun run warden
377
+ bun run survey
378
+ bun run guide`;
379
+ const layoutDescription = input.workspace
380
+ ? `This configured workspace names the \`${input.name}\` app in root \`workspace.apps\`. The app owns \`apps/${input.name}/trails.lock\`; the workspace derives its cross-app view and never creates a root aggregate lock.`
381
+ : 'This standalone app owns its root `trails.lock`.';
382
+
383
+ return `# ${input.name}
384
+
385
+ 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.
386
+
387
+ ${layoutDescription}
388
+
389
+ ## Getting Started
390
+
391
+ \`\`\`bash
392
+ bun install
393
+ ${compileCommands}
394
+ ${verificationCommand}
395
+ \`\`\`
396
+
397
+ ## Project Structure
398
+
399
+ - \`${appPrefix}src/app.ts\` - the side-effect-free topo entry
400
+ - \`${appPrefix}src/trails/\` - trail definitions
401
+ ${surfaceLines}
402
+ ${verificationStructure.replaceAll('`__tests__/', `\`${appPrefix}__tests__/`)}- \`${appPrefix}AGENTS.md\` - project guidance for agents working in this app
403
+
404
+ The generated app module authors deterministic scaffold provenance in the \`scaffold\` overlay. The normal compile path validates and embeds it in the app-owned lock. Disposable cache and observed state live in the global per-user cache and state homes, never in this project.
405
+
406
+ ## Starter
407
+
408
+ ${starterReadmeLines[input.starter]}
409
+
410
+ ${localPermitGuidance}## Next Steps
411
+
412
+ - Add a trail with ${input.workspace ? `\`cd apps/${input.name} && bun run add\`` : '`bun run add`'}
413
+ - Run ${input.workspace ? `\`bunx trails warden --app ${input.name}\`` : '`bun run warden`'} before review
414
+ - Read \`${appPrefix}AGENTS.md\` for Trails vocabulary and conventions
415
+ `;
416
+ };
417
+
418
+ const writeReadme = async (
419
+ input: CreateInput,
420
+ dir: string,
421
+ surfaceEntryFiles: ResolvedSurfaceEntryFiles,
422
+ createdSurfaceFiles: ReadonlySet<string>,
423
+ createdScaffoldFiles: ReadonlySet<string>
424
+ ): Promise<Result<string | null, Error>> => {
425
+ const exists = projectPathExists(dir, 'README.md');
426
+ if (exists.isErr()) {
427
+ return exists;
428
+ }
429
+ if (exists.value) {
430
+ return Result.ok(null);
431
+ }
432
+
433
+ const written = await writeProjectFile(
434
+ dir,
435
+ 'README.md',
436
+ generateReadme(
437
+ input,
438
+ surfaceEntryFiles,
439
+ createdSurfaceFiles,
440
+ createdScaffoldFiles
441
+ )
442
+ );
443
+ return written.isErr() ? Result.err(written.error) : Result.ok('README.md');
444
+ };
445
+
446
+ // ---------------------------------------------------------------------------
447
+ // Trail definition
448
+ // ---------------------------------------------------------------------------
449
+
450
+ export const createTrail = trail('create', {
451
+ composes: ['create.scaffold', 'add.surface', 'add.verify'],
452
+ description: 'Create a new Trails project',
453
+ dryRun: true,
454
+ fields: {
455
+ starter: {
456
+ options: [
457
+ {
458
+ hint: 'One trail, one example',
459
+ label: 'Hello world',
460
+ value: 'hello',
461
+ },
462
+ {
463
+ hint: '4 trails, signal, store',
464
+ label: 'Entity CRUD',
465
+ value: 'entity',
466
+ },
467
+ { hint: 'Just the structure', label: 'Empty', value: 'empty' },
468
+ ],
469
+ },
470
+ surfaces: {
471
+ options: [
472
+ { hint: 'Commander-based command line', label: 'CLI', value: 'cli' },
473
+ {
474
+ hint: 'Model Context Protocol for agents',
475
+ label: 'MCP',
476
+ value: 'mcp',
477
+ },
478
+ {
479
+ hint: 'Hono-powered HTTP endpoints',
480
+ label: 'HTTP',
481
+ value: 'http',
482
+ },
483
+ ],
484
+ },
485
+ },
486
+ implementation: async (input: CreateInput, ctx) => {
487
+ if (!hasCompose(ctx)) {
488
+ return Result.err(new InternalError('create trail requires ctx.compose'));
489
+ }
490
+
491
+ const scaffoldPlan = await ctx.compose<ScaffoldedProject>(
492
+ 'create.scaffold',
493
+ buildScaffoldInput({ ...input, dryRun: true })
494
+ );
495
+ if (scaffoldPlan.isErr()) {
496
+ return scaffoldPlan;
497
+ }
498
+
499
+ let hookDir: Result<string, Error> = Result.ok(scaffoldPlan.value.appDir);
500
+ if (input.verify) {
501
+ hookDir = input.workspace
502
+ ? Result.ok(scaffoldPlan.value.dir)
503
+ : await resolveVerifyHookDir(scaffoldPlan.value.appDir);
504
+ }
505
+ if (hookDir.isErr()) {
506
+ return hookDir;
507
+ }
508
+
509
+ const surfaceEntryFiles = resolveSurfaceEntryFiles(
510
+ scaffoldPlan.value.appDir,
511
+ input.surfaces,
512
+ plansLocalTsconfig(scaffoldPlan.value)
513
+ );
514
+ if (surfaceEntryFiles.isErr()) {
515
+ return surfaceEntryFiles;
516
+ }
517
+
518
+ const plannedOperations = collectCreateOperations(
519
+ scaffoldPlan.value,
520
+ input,
521
+ surfaceEntryFiles.value,
522
+ hookDir.value
523
+ );
524
+ if (plannedOperations.isErr()) {
525
+ return plannedOperations;
526
+ }
527
+
528
+ if (ctx.dryRun === true) {
529
+ return Result.ok({
530
+ appDir: scaffoldPlan.value.appDir,
531
+ created: [],
532
+ dir: scaffoldPlan.value.dir,
533
+ dryRun: true,
534
+ guidance: createGuidance(input),
535
+ layout: scaffoldPlan.value.layout,
536
+ name: input.name,
537
+ plannedOperations: plannedOperations.value,
538
+ });
539
+ }
540
+
541
+ const scaffolded = await ctx.compose<ScaffoldedProject>(
542
+ 'create.scaffold',
543
+ buildScaffoldInput({ ...input, dryRun: false })
544
+ );
545
+ if (scaffolded.isErr()) {
546
+ return scaffolded;
547
+ }
548
+
549
+ const finishCreate = async (): Promise<Result<CreateResult, Error>> => {
550
+ const surfaceFiles = await collectSurfaceFiles(
551
+ input.surfaces,
552
+ (surface) =>
553
+ ctx.compose<SurfaceResult>(
554
+ 'add.surface',
555
+ buildSurfaceInput(scaffolded.value.appDir, surface)
556
+ )
557
+ );
558
+ if (surfaceFiles.isErr()) {
559
+ return surfaceFiles;
560
+ }
561
+
562
+ const verifyFiles = await collectVerifyFiles(input.verify, () =>
563
+ ctx.compose<{ created: string[] }>(
564
+ 'add.verify',
565
+ buildVerifyInput({
566
+ dir: dirname(scaffolded.value.appDir),
567
+ name: basename(scaffolded.value.appDir),
568
+ verify: input.verify,
569
+ })
570
+ )
571
+ );
572
+ if (verifyFiles.isErr()) {
573
+ return verifyFiles;
574
+ }
575
+
576
+ const readmeFile = await writeReadme(
577
+ input,
578
+ scaffolded.value.dir,
579
+ surfaceEntryFiles.value,
580
+ new Set(surfaceFiles.value),
581
+ new Set(scaffolded.value.created)
582
+ );
583
+ if (readmeFile.isErr()) {
584
+ return readmeFile;
585
+ }
586
+
587
+ return Result.ok({
588
+ appDir: scaffolded.value.appDir,
589
+ created: collectCreatedFiles(
590
+ scaffolded.value.created,
591
+ surfaceFiles.value.map((path) =>
592
+ projectRelativeAppPath(scaffolded.value.appRoot, path)
593
+ ),
594
+ verifyFiles.value.map((path) =>
595
+ projectRelativeVerifyPath(scaffolded.value, hookDir.value, path)
596
+ ),
597
+ readmeFile.value
598
+ ),
599
+ dir: scaffolded.value.dir,
600
+ dryRun: false,
601
+ guidance: createGuidance(input),
602
+ layout: scaffolded.value.layout,
603
+ name: input.name,
604
+ plannedOperations: plannedOperations.value,
605
+ });
606
+ };
607
+
608
+ return finishCreate();
609
+ },
610
+ input: z.object({
611
+ dir: z.string().optional().describe('Parent directory'),
612
+ name: z
613
+ .string()
614
+ .regex(PROJECT_NAME_PATTERN, PROJECT_NAME_MESSAGE)
615
+ .describe('Project name'),
616
+ starter: z
617
+ .enum(['hello', 'entity', 'empty'])
618
+ .default('hello')
619
+ .describe('Starter trail'),
620
+ surfaces: z
621
+ .array(z.enum(['cli', 'http', 'mcp']))
622
+ .min(1)
623
+ .default(['cli'])
624
+ .describe('Surfaces'),
625
+ verify: z.boolean().default(true).describe('Include testing + warden'),
626
+ workspace: z
627
+ .boolean()
628
+ .default(false)
629
+ .describe('Create a configured workspace with one app'),
630
+ }),
631
+ output: z.object({
632
+ appDir: z.string(),
633
+ created: z.array(z.string()),
634
+ dir: z.string(),
635
+ dryRun: z.boolean(),
636
+ guidance: z.array(z.string()),
637
+ layout: z.enum(['standalone', 'workspace']),
638
+ name: z.string(),
639
+ plannedOperations: z.array(
640
+ z.discriminatedUnion('kind', [
641
+ z.object({ kind: z.literal('mkdir'), path: z.string() }),
642
+ z.object({
643
+ from: z.string(),
644
+ kind: z.literal('rename'),
645
+ to: z.string(),
646
+ }),
647
+ z.object({ kind: z.literal('write'), path: z.string() }),
648
+ ])
649
+ ),
650
+ }),
651
+ permit: { scopes: ['project:write'] },
652
+ });