@ontrails/trails 1.0.0-beta.24 → 1.0.0-beta.29

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 (48) hide show
  1. package/CHANGELOG.md +189 -0
  2. package/README.md +6 -5
  3. package/package.json +16 -14
  4. package/src/app.ts +42 -3
  5. package/src/cli.ts +17 -3
  6. package/src/local-state-io.ts +20 -0
  7. package/src/mcp-app.ts +10 -0
  8. package/src/mcp-options.ts +2 -3
  9. package/src/project-writes.ts +11 -11
  10. package/src/release/check.ts +41 -25
  11. package/src/release/index.ts +40 -0
  12. package/src/release/native-bun-registry.ts +282 -19
  13. package/src/release/pack-coherence.ts +457 -0
  14. package/src/release/policy.ts +1684 -0
  15. package/src/release/semver.ts +104 -0
  16. package/src/release/wayfinder-dogfood-smoke.ts +600 -66
  17. package/src/run-completions-install.ts +2 -2
  18. package/src/run-schema.ts +41 -0
  19. package/src/run-wayfind-outline.ts +166 -0
  20. package/src/trails/adapter-check.ts +1 -1
  21. package/src/trails/add-surface.ts +1 -1
  22. package/src/trails/add-verify.ts +3 -3
  23. package/src/trails/compile.ts +17 -25
  24. package/src/trails/create-scaffold.ts +14 -14
  25. package/src/trails/create.ts +5 -5
  26. package/src/trails/dev-support.ts +44 -24
  27. package/src/trails/doctor.ts +23 -2
  28. package/src/trails/draft-promote.ts +7 -7
  29. package/src/trails/guide.ts +15 -19
  30. package/src/trails/load-app.ts +58 -9
  31. package/src/trails/operator-context.ts +66 -0
  32. package/src/trails/regrade.ts +72 -0
  33. package/src/trails/release-check.ts +1 -0
  34. package/src/trails/run-example.ts +21 -37
  35. package/src/trails/run-examples.ts +16 -32
  36. package/src/trails/run.ts +31 -44
  37. package/src/trails/survey.ts +76 -62
  38. package/src/trails/topo-output-schemas.ts +11 -0
  39. package/src/trails/topo-pin.ts +6 -20
  40. package/src/trails/topo-read-support.ts +91 -41
  41. package/src/trails/topo-reports.ts +4 -2
  42. package/src/trails/topo-store-support.ts +172 -39
  43. package/src/trails/topo-support.ts +1 -2
  44. package/src/trails/topo.ts +5 -19
  45. package/src/trails/validate.ts +9 -20
  46. package/src/trails/version-lifecycle-support.ts +10 -20
  47. package/src/trails/warden.ts +18 -0
  48. package/src/trails/wayfind.ts +1001 -0
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { basename, extname, join } from 'node:path';
9
9
 
10
- import type { Topo } from '@ontrails/core';
10
+ import type { CliCommandAliasInput, Topo } from '@ontrails/core';
11
11
  import {
12
12
  deriveSafePath,
13
13
  NotFoundError,
@@ -28,8 +28,7 @@ import { z } from 'zod';
28
28
 
29
29
  import { writeIsolatedExampleJsonFile } from '../local-state-io.js';
30
30
 
31
- import { tryLoadFreshAppLease } from './load-app.js';
32
- import { resolveTrailRootDir } from './root-dir.js';
31
+ import { withFreshAppLease, withOperatorRootDir } from './operator-context.js';
33
32
  import {
34
33
  buildCurrentTopoBrief,
35
34
  buildCurrentTopoList,
@@ -435,7 +434,7 @@ const readPathTopoGraph = async (
435
434
  const describeAgainstPathTarget = (against: string): string =>
436
435
  basename(against) === 'topo.lock' || extname(against) === '.json'
437
436
  ? 'workspace-relative TopoGraph file'
438
- : 'workspace-relative directory containing topo.lock';
437
+ : 'workspace-relative directory containing trails.lock or topo.lock';
439
438
 
440
439
  const topoGraphNotFound = (against: string): NotFoundError =>
441
440
  new NotFoundError(
@@ -449,7 +448,9 @@ const readAgainstTopoGraph = async (
449
448
  against?: string | undefined
450
449
  ): Promise<Result<{ against: string; map: TopoGraph }, Error>> => {
451
450
  if (against === undefined || against === 'saved') {
452
- const map = await readTopoGraph({ dir: join(rootDir, '.trails') });
451
+ const map =
452
+ (await readTopoGraph({ dir: rootDir })) ??
453
+ (await readTopoGraph({ dir: join(rootDir, '.trails') }));
453
454
  return map === null
454
455
  ? Result.err(
455
456
  new NotFoundError(
@@ -505,9 +506,13 @@ const buildSurveyLookup = (
505
506
  app: Topo,
506
507
  entityId: string,
507
508
  rootDir: string,
509
+ cliAliases:
510
+ | Readonly<Record<string, readonly CliCommandAliasInput[]>>
511
+ | undefined,
508
512
  surfaceLayerNames?: Partial<SurfaceLayerNames> | undefined
509
513
  ): Result<object, Error> => {
510
514
  const matches = buildCurrentTopoMatches(app, entityId, {
515
+ cliAliases,
511
516
  rootDir,
512
517
  surfaceLayerNames,
513
518
  });
@@ -518,9 +523,13 @@ const buildSurveyTrailDetail = (
518
523
  app: Topo,
519
524
  id: string,
520
525
  rootDir: string,
526
+ cliAliases:
527
+ | Readonly<Record<string, readonly CliCommandAliasInput[]>>
528
+ | undefined,
521
529
  surfaceLayerNames?: Partial<SurfaceLayerNames> | undefined
522
530
  ): Result<object, Error> => {
523
531
  const detail = buildCurrentTrailDetail(app, id, {
532
+ cliAliases,
524
533
  rootDir,
525
534
  surfaceLayerNames,
526
535
  });
@@ -572,15 +581,24 @@ type SurveyHandler = (
572
581
  app: Topo,
573
582
  input: SurveyInput,
574
583
  rootDir: string,
584
+ cliAliases:
585
+ | Readonly<Record<string, readonly CliCommandAliasInput[]>>
586
+ | undefined,
575
587
  surfaceLayerNames?: Partial<SurfaceLayerNames> | undefined
576
588
  ) => Result<object, Error> | Promise<Result<object, Error>>;
577
589
 
578
590
  /** Handlers keyed by survey mode. */
579
591
  const surveyHandlers: Record<SurveyMode, SurveyHandler> = {
580
- lookup: (app, input, rootDir, surfaceLayerNames) =>
592
+ lookup: (app, input, rootDir, cliAliases, surfaceLayerNames) =>
581
593
  input.id === undefined || input.id === ''
582
594
  ? Result.err(new ValidationError('Survey lookup requires an id'))
583
- : buildSurveyLookup(app, input.id, rootDir, surfaceLayerNames),
595
+ : buildSurveyLookup(
596
+ app,
597
+ input.id,
598
+ rootDir,
599
+ cliAliases,
600
+ surfaceLayerNames
601
+ ),
584
602
  overview: (app, _input, rootDir) =>
585
603
  Result.ok(buildCurrentTopoList(app, { rootDir })),
586
604
  };
@@ -595,11 +613,20 @@ const dispatchSurvey = async (
595
613
  app: Topo,
596
614
  input: SurveyInput,
597
615
  rootDir: string,
616
+ cliAliases:
617
+ | Readonly<Record<string, readonly CliCommandAliasInput[]>>
618
+ | undefined,
598
619
  surfaceLayerNames?: Partial<SurfaceLayerNames> | undefined
599
620
  ): Promise<Result<SurveyEnvelope, Error>> => {
600
621
  const mode = deriveSurveyMode(input);
601
622
  const handler = surveyHandlers[mode];
602
- const result = await handler(app, input, rootDir, surfaceLayerNames);
623
+ const result = await handler(
624
+ app,
625
+ input,
626
+ rootDir,
627
+ cliAliases,
628
+ surfaceLayerNames
629
+ );
603
630
  if (result.isErr()) {
604
631
  return result;
605
632
  }
@@ -615,19 +642,16 @@ const detailInputSchema = z.object({
615
642
  const withFreshSurveyApp = async <T>(
616
643
  input: { readonly module?: string | undefined },
617
644
  rootDir: string,
618
- consume: (app: Topo) => Promise<Result<T, Error>> | Result<T, Error>
619
- ): Promise<Result<T, Error>> => {
620
- const leaseResult = await tryLoadFreshAppLease(input.module, rootDir);
621
- if (leaseResult.isErr()) {
622
- return Result.err(leaseResult.error);
623
- }
624
- const lease = leaseResult.value;
625
- try {
626
- return await consume(lease.app);
627
- } finally {
628
- lease.release();
629
- }
630
- };
645
+ consume: (
646
+ app: Topo,
647
+ cliAliases:
648
+ | Readonly<Record<string, readonly CliCommandAliasInput[]>>
649
+ | undefined
650
+ ) => Promise<Result<T, Error>> | Result<T, Error>
651
+ ): Promise<Result<T, Error>> =>
652
+ withFreshAppLease(input.module, rootDir, (lease) =>
653
+ consume(lease.app, lease.cliAliases)
654
+ );
631
655
 
632
656
  const withResolvedSurveyApp = async <T>(
633
657
  input: {
@@ -637,16 +661,17 @@ const withResolvedSurveyApp = async <T>(
637
661
  cwd: string | undefined,
638
662
  consume: (
639
663
  app: Topo,
640
- rootDir: string
664
+ rootDir: string,
665
+ cliAliases:
666
+ | Readonly<Record<string, readonly CliCommandAliasInput[]>>
667
+ | undefined
641
668
  ) => Promise<Result<T, Error>> | Result<T, Error>
642
- ): Promise<Result<T, Error>> => {
643
- const rootDirResult = resolveTrailRootDir(input.rootDir, cwd);
644
- if (rootDirResult.isErr()) {
645
- return Result.err(rootDirResult.error);
646
- }
647
- const rootDir = rootDirResult.value;
648
- return withFreshSurveyApp(input, rootDir, (app) => consume(app, rootDir));
649
- };
669
+ ): Promise<Result<T, Error>> =>
670
+ withOperatorRootDir(input, { cwd }, (rootDir) =>
671
+ withFreshSurveyApp(input, rootDir, (app, cliAliases) =>
672
+ consume(app, rootDir, cliAliases)
673
+ )
674
+ );
650
675
 
651
676
  const moduleInputSchema = z.object({
652
677
  module: z.string().optional().describe('Path to the app module'),
@@ -718,8 +743,14 @@ const surveyMatchOutput = z.discriminatedUnion('kind', [
718
743
  export const surveyTrail = trail('survey', {
719
744
  args: ['id'],
720
745
  blaze: async (input, ctx) =>
721
- withResolvedSurveyApp(input, ctx.cwd, (app, rootDir) =>
722
- dispatchSurvey(app, input, rootDir, readSurfaceLayerNamesFromContext(ctx))
746
+ withResolvedSurveyApp(input, ctx.cwd, (app, rootDir, cliAliases) =>
747
+ dispatchSurvey(
748
+ app,
749
+ input,
750
+ rootDir,
751
+ cliAliases,
752
+ readSurfaceLayerNamesFromContext(ctx)
753
+ )
723
754
  ),
724
755
  description: 'Full topo introspection',
725
756
  examples: [
@@ -827,6 +858,7 @@ export const surveySurfacesTrail = trail('survey.surfaces', {
827
858
  });
828
859
 
829
860
  export const surveyDiffTrail = trail('survey.diff', {
861
+ args: ['target'],
830
862
  blaze: async (input, ctx) =>
831
863
  withResolvedSurveyApp(input, ctx.cwd, (app, rootDir) =>
832
864
  buildSurveyDiff(app, rootDir, input)
@@ -838,6 +870,16 @@ export const surveyDiffTrail = trail('survey.diff', {
838
870
  input: createDiffExampleInput(),
839
871
  name: 'Diff against baseline',
840
872
  },
873
+ {
874
+ description: 'Show only breaking contract drift',
875
+ input: { ...createDiffExampleInput(), breaks: true },
876
+ name: 'Breaking changes',
877
+ },
878
+ {
879
+ description: 'Show graph-only force audit events',
880
+ input: { ...createDiffExampleInput(), forces: true },
881
+ name: 'Force audit events',
882
+ },
841
883
  {
842
884
  description: 'Reject an empty saved map target',
843
885
  error: 'ValidationError',
@@ -859,43 +901,15 @@ export const surveyDiffTrail = trail('survey.diff', {
859
901
  output: diffOutput,
860
902
  });
861
903
 
862
- export const diffTrail = trail('diff', {
863
- args: ['target'],
864
- blaze: async (input, ctx) =>
865
- withResolvedSurveyApp(input, ctx.cwd, (app, rootDir) =>
866
- buildSurveyDiff(app, rootDir, input)
867
- ),
868
- description: 'Diff the current topo against a saved TopoGraph',
869
- examples: [
870
- {
871
- description: 'Compare current topo to a saved TopoGraph directory',
872
- input: createDiffExampleInput(),
873
- name: 'Diff against baseline',
874
- },
875
- {
876
- description: 'Show only breaking contract drift',
877
- input: { ...createDiffExampleInput(), breaks: true },
878
- name: 'Breaking changes',
879
- },
880
- {
881
- description: 'Show graph-only force audit events',
882
- input: { ...createDiffExampleInput(), forces: true },
883
- name: 'Force audit events',
884
- },
885
- ],
886
- input: diffInputSchema,
887
- intent: 'read',
888
- output: diffOutput,
889
- });
890
-
891
904
  export const surveyTrailDetailTrail = trail('survey.trail', {
892
905
  args: ['id'],
893
906
  blaze: async (input, ctx) =>
894
- withResolvedSurveyApp(input, ctx.cwd, (app, rootDir) =>
907
+ withResolvedSurveyApp(input, ctx.cwd, (app, rootDir, cliAliases) =>
895
908
  buildSurveyTrailDetail(
896
909
  app,
897
910
  input.id,
898
911
  rootDir,
912
+ cliAliases,
899
913
  readSurfaceLayerNamesFromContext(ctx)
900
914
  )
901
915
  ),
@@ -178,6 +178,17 @@ export const trailDetailOutput = z.object({
178
178
  cli: z
179
179
  .object({
180
180
  path: z.array(z.string()).readonly(),
181
+ routes: z
182
+ .array(
183
+ z.object({
184
+ kind: z.enum(['alias', 'canonical']),
185
+ path: z.array(z.string()).readonly(),
186
+ source: z.enum(['derived', 'surface', 'trail']),
187
+ target: z.string(),
188
+ })
189
+ )
190
+ .readonly()
191
+ .optional(),
181
192
  })
182
193
  .nullable(),
183
194
  composedLayers: z.object({
@@ -1,8 +1,7 @@
1
1
  import { Result, trail } from '@ontrails/core';
2
2
  import { z } from 'zod';
3
3
 
4
- import { tryLoadFreshAppLease } from './load-app.js';
5
- import { resolveTrailRootDir } from './root-dir.js';
4
+ import { withFreshOperatorApp } from './operator-context.js';
6
5
  import {
7
6
  createIsolatedExampleInput,
8
7
  pinCurrentTopoSnapshot,
@@ -10,25 +9,12 @@ import {
10
9
  } from './topo-support.js';
11
10
 
12
11
  export const topoPinTrail = trail('topo.pin', {
13
- blaze: async (input, ctx) => {
14
- const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
15
- if (rootDirResult.isErr()) {
16
- return rootDirResult;
17
- }
18
- const rootDir = rootDirResult.value;
19
- const leaseResult = await tryLoadFreshAppLease(input.module, rootDir);
20
- if (leaseResult.isErr()) {
21
- return leaseResult;
22
- }
23
- const lease = leaseResult.value;
24
- try {
25
- return Result.ok(
12
+ blaze: async (input, ctx) =>
13
+ withFreshOperatorApp(input, ctx, ({ lease, rootDir }) =>
14
+ Result.ok(
26
15
  pinCurrentTopoSnapshot(lease.app, { name: input.name, rootDir })
27
- );
28
- } finally {
29
- lease.release();
30
- }
31
- },
16
+ )
17
+ ),
32
18
  description: 'Pin the current topo under a durable name',
33
19
  examples: [
34
20
  {
@@ -8,7 +8,7 @@
8
8
  import { existsSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
 
11
- import type { Topo, TrailContext } from '@ontrails/core';
11
+ import type { CliCommandAliasInput, Topo, TrailContext } from '@ontrails/core';
12
12
  import {
13
13
  ConflictError,
14
14
  deriveTrailsDbPath,
@@ -24,9 +24,10 @@ import {
24
24
  deriveTopoGraphHash,
25
25
  readLockManifest,
26
26
  readTopoGraph,
27
+ readTrailsLock,
27
28
  stripTopoGraphForces,
28
29
  } from '@ontrails/topographer';
29
- import type { TopoGraph } from '@ontrails/topographer';
30
+ import type { LockManifest, TopoGraph } from '@ontrails/topographer';
30
31
 
31
32
  import type {
32
33
  BriefReport,
@@ -71,6 +72,9 @@ export interface CurrentTopoMatch {
71
72
  }
72
73
 
73
74
  export interface CurrentTopoReadOptions {
75
+ readonly cliAliases?:
76
+ | Readonly<Record<string, readonly CliCommandAliasInput[]>>
77
+ | undefined;
74
78
  readonly rootDir?: string | undefined;
75
79
  readonly surfaceLayerNames?: Partial<SurfaceLayerNames> | undefined;
76
80
  }
@@ -96,6 +100,46 @@ export const readSurfaceLayerNamesFromContext = (
96
100
  const hasCommittedLock = (trailsDir: string): boolean =>
97
101
  existsSync(join(trailsDir, 'trails.lock'));
98
102
 
103
+ const readCommittedLock = async (
104
+ rootDir: string
105
+ ): Promise<{
106
+ readonly lockManifest: LockManifest;
107
+ readonly topoGraph: TopoGraph;
108
+ } | null> => {
109
+ const trailsLock = await readTrailsLock({ dir: rootDir });
110
+ if (trailsLock !== null) {
111
+ return {
112
+ lockManifest: {
113
+ artifacts: [
114
+ {
115
+ path: 'topo.lock',
116
+ role: 'topo',
117
+ sha256: trailsLock.topoGraphHash,
118
+ },
119
+ ],
120
+ scope: trailsLock.scope,
121
+ summary: trailsLock.summary,
122
+ version: 3,
123
+ },
124
+ topoGraph: trailsLock.topoGraph as TopoGraph,
125
+ };
126
+ }
127
+
128
+ const legacyDir = deriveTrailsDir({ rootDir });
129
+ const legacyManifest = await readLockManifest({ dir: legacyDir });
130
+ if (legacyManifest === null) {
131
+ return null;
132
+ }
133
+ const legacyTopoGraph = await readTopoGraph({ dir: legacyDir });
134
+ if (legacyTopoGraph === null) {
135
+ return null;
136
+ }
137
+ return {
138
+ lockManifest: legacyManifest,
139
+ topoGraph: legacyTopoGraph,
140
+ };
141
+ };
142
+
99
143
  // ---------------------------------------------------------------------------
100
144
  // Public read-only consumers
101
145
  // ---------------------------------------------------------------------------
@@ -105,12 +149,13 @@ export const buildTopoSummary = (
105
149
  options?: { readonly rootDir?: string }
106
150
  ): TopoSummaryReport => {
107
151
  const rootDir = deriveRootDir(options?.rootDir);
108
- const trailsDir = deriveTrailsDir({ rootDir });
109
152
  return {
110
153
  app: deriveBriefReport(app),
111
154
  dbPath: deriveTrailsDbPath({ rootDir }),
112
155
  list: deriveSurveyList(app),
113
- lockExists: hasCommittedLock(trailsDir),
156
+ lockExists:
157
+ hasCommittedLock(rootDir) ||
158
+ hasCommittedLock(deriveTrailsDir({ rootDir })),
114
159
  lockPath: LOCK_PATH,
115
160
  };
116
161
  };
@@ -154,6 +199,7 @@ export const buildCurrentTrailDetail = (
154
199
  ? undefined
155
200
  : deriveTrailDetail(trail, app, undefined, {
156
201
  surfaceLayerNames: options?.surfaceLayerNames,
202
+ topoGraph: deriveTopoGraph(app, { cliAliases: options?.cliAliases }),
157
203
  });
158
204
  };
159
205
 
@@ -192,7 +238,7 @@ export const buildCurrentTopoMatches = (
192
238
  (activationGraph ??= deriveActivationGraph(app));
193
239
  let topoGraph: ReturnType<typeof deriveTopoGraph> | undefined;
194
240
  const getTopoGraph = (): ReturnType<typeof deriveTopoGraph> =>
195
- (topoGraph ??= deriveTopoGraph(app));
241
+ (topoGraph ??= deriveTopoGraph(app, { cliAliases: options?.cliAliases }));
196
242
 
197
243
  const trail = app.get(id);
198
244
  if (trail !== undefined) {
@@ -220,14 +266,17 @@ export const buildCurrentTopoMatches = (
220
266
 
221
267
  export const validateCurrentTopo = async (
222
268
  app: Topo,
223
- options?: { readonly rootDir?: string }
269
+ options?: {
270
+ readonly cliAliases?:
271
+ | Readonly<Record<string, readonly CliCommandAliasInput[]>>
272
+ | undefined;
273
+ readonly rootDir?: string;
274
+ }
224
275
  ): Promise<Result<TopoValidateReport, Error>> => {
225
276
  const rootDir = deriveRootDir(options?.rootDir);
226
- let lockManifest: Awaited<ReturnType<typeof readLockManifest>>;
277
+ let committedLock: Awaited<ReturnType<typeof readCommittedLock>>;
227
278
  try {
228
- lockManifest = await readLockManifest({
229
- dir: deriveTrailsDir({ rootDir }),
230
- });
279
+ committedLock = await readCommittedLock(rootDir);
231
280
  } catch (error) {
232
281
  const message =
233
282
  error instanceof Error
@@ -240,7 +289,7 @@ export const validateCurrentTopo = async (
240
289
  );
241
290
  }
242
291
 
243
- if (lockManifest === null) {
292
+ if (committedLock === null) {
244
293
  return Result.err(
245
294
  new NotFoundError(
246
295
  'No committed trails.lock found. Run `trails compile` first.'
@@ -248,7 +297,10 @@ export const validateCurrentTopo = async (
248
297
  );
249
298
  }
250
299
 
251
- const currentExport = deriveCurrentTopoExport(app, { rootDir });
300
+ const currentExport = deriveCurrentTopoExport(app, {
301
+ cliAliases: options?.cliAliases,
302
+ rootDir,
303
+ });
252
304
  if (currentExport.isErr()) {
253
305
  return currentExport;
254
306
  }
@@ -256,7 +308,7 @@ export const validateCurrentTopo = async (
256
308
  currentExport.value.topoGraphJson
257
309
  ) as TopoGraph;
258
310
  const currentHash = currentExport.value.topoGraphHash;
259
- const topoArtifact = lockManifest.artifacts.find(
311
+ const topoArtifact = committedLock.lockManifest.artifacts.find(
260
312
  (artifact) => artifact.role === 'topo' && artifact.path === 'topo.lock'
261
313
  );
262
314
  if (topoArtifact === undefined) {
@@ -267,36 +319,34 @@ export const validateCurrentTopo = async (
267
319
  );
268
320
  }
269
321
 
322
+ const committedTopo = committedLock.topoGraph;
323
+ const committedHash = deriveTopoGraphHash(committedTopo);
324
+ if (committedHash !== topoArtifact.sha256) {
325
+ return Result.err(
326
+ new ValidationError(
327
+ 'trails.lock graph hash does not match its embedded TopoGraph. Run `trails compile` to refresh it.'
328
+ )
329
+ );
330
+ }
331
+
270
332
  if (topoArtifact.sha256 !== currentHash) {
271
- const committedTopo = await readTopoGraph({
272
- dir: deriveTrailsDir({ rootDir }),
273
- });
274
- if (committedTopo !== null) {
275
- const committedHash = deriveTopoGraphHash(committedTopo);
276
- const forceStrippedHash = deriveTopoGraphHash(
277
- stripTopoGraphForces(committedTopo)
278
- );
279
- if (
280
- committedHash === topoArtifact.sha256 &&
281
- forceStrippedHash === currentHash
282
- ) {
283
- return Result.ok({
284
- committedHash: topoArtifact.sha256,
285
- currentHash,
286
- lockPath: LOCK_PATH,
287
- stale: false,
288
- });
289
- }
333
+ const forceStrippedHash = deriveTopoGraphHash(
334
+ stripTopoGraphForces(committedTopo)
335
+ );
336
+ if (forceStrippedHash === currentHash) {
337
+ return Result.ok({
338
+ committedHash: topoArtifact.sha256,
339
+ currentHash,
340
+ lockPath: LOCK_PATH,
341
+ stale: false,
342
+ });
290
343
  }
291
- const breakingSummary =
292
- committedTopo === null
293
- ? ''
294
- : (() => {
295
- const diff = deriveTopoGraphDiff(committedTopo, currentTopo);
296
- return diff.breaking.length > 0
297
- ? ` Breaking changes detected: ${diff.breaking.length}.`
298
- : '';
299
- })();
344
+ const breakingSummary = (() => {
345
+ const diff = deriveTopoGraphDiff(committedTopo, currentTopo);
346
+ return diff.breaking.length > 0
347
+ ? ` Breaking changes detected: ${diff.breaking.length}.`
348
+ : '';
349
+ })();
300
350
  return Result.err(
301
351
  new ConflictError(
302
352
  `trails.lock is stale. Run \`trails compile\` to refresh it.${breakingSummary}`
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  DETOUR_MAX_ATTEMPTS_CAP,
3
- deriveCliPath,
3
+ deriveTrailCliCommandProjection,
4
4
  filterSurfaceTrails,
5
5
  isArchivedTrailVersionEntry,
6
6
  zodToJsonSchema,
@@ -176,6 +176,7 @@ export interface TrailDetailReport {
176
176
  readonly activationSources: readonly ActivationSourceReport[];
177
177
  readonly cli: {
178
178
  readonly path: readonly string[];
179
+ readonly routes?: NonNullable<TopoGraphEntry['cli']>['routes'];
179
180
  } | null;
180
181
  /**
181
182
  * Composed layer names visible at the survey boundary.
@@ -560,7 +561,8 @@ export const deriveShippedSurfaceProjectionsForTrail = (
560
561
  trail.id,
561
562
  'trail'
562
563
  );
563
- const commandPath = deriveCliPath(trail.id);
564
+ const commandPath =
565
+ entry?.cli?.path ?? deriveTrailCliCommandProjection(trail).path;
564
566
  const httpMethod = deriveHttpMethod(trail.intent);
565
567
  const httpPath = deriveHttpPath(trail.id);
566
568
  const mcpToolName = deriveToolName(app.name, trail.id);