@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,636 @@
1
+ /**
2
+ * Consolidated, append-only Regrade transition history. One file per
3
+ * transition at `.trails/regrade/history/<transition>.json`; each apply
4
+ * appends a run entry stamped with the plan content hash and the lock hash
5
+ * observed at that run.
6
+ */
7
+
8
+ import { InternalError, Result, ValidationError } from '@ontrails/core';
9
+ import type { Result as TrailsResult } from '@ontrails/core';
10
+ import { resolveRegradeHistoryReceipt } from '@ontrails/regrade';
11
+ import type {
12
+ RegradeFormJudgment,
13
+ RegradeReport,
14
+ ResolvedRegradeHistoryReceipt,
15
+ } from '@ontrails/regrade';
16
+ import {
17
+ getGovernedVocabularyTransition,
18
+ listGovernedVocabularyTransitions,
19
+ } from '@ontrails/warden';
20
+ import type { GovernedVocabularyHistoryProvenance } from '@ontrails/warden';
21
+ import { createHash, randomUUID } from 'node:crypto';
22
+ import {
23
+ existsSync,
24
+ mkdirSync,
25
+ readdirSync,
26
+ readFileSync,
27
+ renameSync,
28
+ rmSync,
29
+ writeFileSync,
30
+ } from 'node:fs';
31
+ import { dirname, isAbsolute, join } from 'node:path';
32
+
33
+ import {
34
+ regradePlanContentHash,
35
+ regradePlanDirectory,
36
+ regradePlanSlugForBody,
37
+ regradeSourceHash,
38
+ rootRelativePath,
39
+ } from './plan-artifact.js';
40
+ import type { RegradePlanArtifact, RegradePlanBody } from './plan-artifact.js';
41
+ import {
42
+ buildRegradeHistoryReceipt,
43
+ resolveRegradeSourceRevision,
44
+ serializeRegradeHistoryReceipt,
45
+ } from './receipt-history.js';
46
+ import type { RegradeChangedFileEvidence } from './receipt-history.js';
47
+
48
+ const resolvedReceipt = Symbol('resolvedReceipt');
49
+
50
+ export const writeRegradeHistoryFileAtomically = (params: {
51
+ readonly absolutePath: string;
52
+ readonly content: string;
53
+ readonly diagnosticPath: string;
54
+ readonly replace?: typeof renameSync | undefined;
55
+ }): TrailsResult<void, InternalError> => {
56
+ const temporaryPath = join(
57
+ dirname(params.absolutePath),
58
+ `.${randomUUID()}.regrade-history.tmp`
59
+ );
60
+ try {
61
+ mkdirSync(dirname(params.absolutePath), { recursive: true });
62
+ writeFileSync(temporaryPath, params.content);
63
+ (params.replace ?? renameSync)(temporaryPath, params.absolutePath);
64
+ return Result.ok();
65
+ } catch (error) {
66
+ try {
67
+ rmSync(temporaryPath, { force: true });
68
+ } catch {
69
+ // Preserve the primary persistence failure.
70
+ }
71
+ return Result.err(
72
+ new InternalError('Failed to atomically write Regrade history.', {
73
+ ...(error instanceof Error ? { cause: error } : {}),
74
+ context: { path: params.diagnosticPath },
75
+ })
76
+ );
77
+ }
78
+ };
79
+
80
+ export const consumeActiveRegradePlanAfterHistoryWrite = (params: {
81
+ readonly absoluteHistoryPath: string;
82
+ readonly absolutePlanPath: string;
83
+ readonly historyPath: string;
84
+ readonly planPath: string;
85
+ readonly priorHistoryBytes?: string | undefined;
86
+ readonly remove?: ((path: string) => void) | undefined;
87
+ readonly replace?: typeof renameSync | undefined;
88
+ }): TrailsResult<void, InternalError> => {
89
+ const remove =
90
+ params.remove ?? ((path: string) => rmSync(path, { force: true }));
91
+ const removeNewHistory = (): Error | undefined => {
92
+ try {
93
+ remove(params.absoluteHistoryPath);
94
+ return undefined;
95
+ } catch (error) {
96
+ return error instanceof Error ? error : new Error(String(error));
97
+ }
98
+ };
99
+ try {
100
+ remove(params.absolutePlanPath);
101
+ return Result.ok();
102
+ } catch (error) {
103
+ let rollbackError: Error | undefined;
104
+ if (params.priorHistoryBytes === undefined) {
105
+ rollbackError = removeNewHistory();
106
+ } else {
107
+ const restored = writeRegradeHistoryFileAtomically({
108
+ absolutePath: params.absoluteHistoryPath,
109
+ content: params.priorHistoryBytes,
110
+ diagnosticPath: params.historyPath,
111
+ replace: params.replace,
112
+ });
113
+ if (restored.isErr()) {
114
+ rollbackError = restored.error;
115
+ }
116
+ }
117
+ return Result.err(
118
+ new InternalError('Failed to remove active Regrade plan.', {
119
+ ...(error instanceof Error ? { cause: error } : {}),
120
+ context: {
121
+ history: params.historyPath,
122
+ plan: params.planPath,
123
+ ...(rollbackError === undefined
124
+ ? {}
125
+ : { historyRollback: rollbackError.message }),
126
+ },
127
+ })
128
+ );
129
+ }
130
+ };
131
+ interface RegradeHistoryRun {
132
+ readonly completionReport: RegradeReport;
133
+ readonly completionReportHash: string;
134
+ readonly lockHashAtRun: string;
135
+ readonly plan: RegradePlanArtifact;
136
+ readonly planContentHash: string;
137
+ readonly report: RegradeReport;
138
+ }
139
+
140
+ export interface RegradeHistoryArtifact {
141
+ readonly [resolvedReceipt]?: ResolvedRegradeHistoryReceipt;
142
+ readonly id: string;
143
+ readonly kind: 'regrade-history';
144
+ readonly path: string;
145
+ readonly runs: readonly RegradeHistoryRun[];
146
+ readonly schemaVersion: number;
147
+ }
148
+
149
+ const receiptDisposition = (
150
+ disposition: RegradeFormJudgment['disposition']
151
+ ) => {
152
+ switch (disposition) {
153
+ case 'mapped': {
154
+ return {
155
+ disposition: 'in-family-modified' as const,
156
+ verdict: 'applied' as const,
157
+ };
158
+ }
159
+ case 'out-of-family': {
160
+ return {
161
+ disposition: 'out-of-family' as const,
162
+ verdict: 'skipped' as const,
163
+ };
164
+ }
165
+ case 'preserved': {
166
+ return {
167
+ disposition: 'explicit-preserve' as const,
168
+ verdict: 'skipped' as const,
169
+ };
170
+ }
171
+ case 'unresolved': {
172
+ return {
173
+ disposition: 'in-family-unresolved' as const,
174
+ verdict: 'deferred' as const,
175
+ };
176
+ }
177
+ default: {
178
+ const exhaustive: never = disposition;
179
+ return exhaustive;
180
+ }
181
+ }
182
+ };
183
+
184
+ const reportForReceiptRun = (
185
+ run: ResolvedRegradeHistoryReceipt['runs'][number]
186
+ ): RegradeReport => {
187
+ const plan = run.plan as RegradePlanBody;
188
+ const { completion } = run.receipt;
189
+ const entries = run.receipt.evidence.changedFiles.map((file) => ({
190
+ outcome: 'rewrite' as const,
191
+ path: file.afterPath,
192
+ }));
193
+ const skipped = Object.values(completion.counts.skippedByReason).reduce(
194
+ (sum, count) => sum + count,
195
+ 0
196
+ );
197
+ const base: RegradeReport = {
198
+ apply: {
199
+ applied: completion.counts.rewritten,
200
+ filesChanged: completion.metrics.filesChanged,
201
+ review: completion.counts.review,
202
+ skipped,
203
+ unknown: completion.counts.unknown,
204
+ },
205
+ entries,
206
+ matched: completion.counts.matched,
207
+ review: completion.counts.review,
208
+ rewritten: completion.counts.rewritten,
209
+ root: '.',
210
+ scan: {
211
+ byDirectory: [],
212
+ byExtension: [],
213
+ files: {
214
+ matched: completion.counts.matched,
215
+ scanned: 0,
216
+ skipped,
217
+ },
218
+ skippedByReason: completion.counts.skippedByReason,
219
+ },
220
+ scanned: 0,
221
+ selectedClassIds:
222
+ plan.kind === 'class'
223
+ ? plan.classIds
224
+ : [plan.id ?? `vocabulary:${plan.from}->${plan.to}`],
225
+ skipped,
226
+ skipsByReason: completion.counts.skippedByReason,
227
+ unknownClassIds: [],
228
+ };
229
+ if (plan.kind !== 'vocabulary') {
230
+ return base;
231
+ }
232
+ const occurrences = run.classifiedState.forms.map((form, index) => {
233
+ const mapped = receiptDisposition(form.disposition);
234
+ return {
235
+ column: 1,
236
+ context: '',
237
+ ...mapped,
238
+ end: 0,
239
+ form: form.form,
240
+ line: form.representative?.line ?? 1,
241
+ path: form.representative?.path ?? run.receipt.transitionId,
242
+ reason: form.reason ?? form.disposition,
243
+ ...(form.target === undefined ? {} : { replacement: form.target }),
244
+ scopeTier: 'in-scope' as const,
245
+ start: index,
246
+ };
247
+ });
248
+ return {
249
+ ...base,
250
+ run: {
251
+ ledger: {
252
+ cycle: 1,
253
+ forms: Object.fromEntries(
254
+ occurrences.map((occurrence) => [occurrence.form, occurrence.verdict])
255
+ ),
256
+ occurrences,
257
+ },
258
+ plan,
259
+ report: {
260
+ applied: completion.counts.rewritten,
261
+ deferred: completion.counts.review,
262
+ dispositions: completion.counts.dispositions,
263
+ filesChanged: completion.metrics.filesChanged,
264
+ gate: {
265
+ ...completion.gate,
266
+ remainingByDisposition: {},
267
+ },
268
+ modified: completion.counts.rewritten,
269
+ open: completion.gate.remaining,
270
+ scopeTiers: { 'in-scope': occurrences.length, 'policy-classified': 0 },
271
+ skipped,
272
+ teachingSurfaces: { expected: [], missing: [], touched: [] },
273
+ },
274
+ },
275
+ };
276
+ };
277
+
278
+ const projectReceiptHistory = (
279
+ receipt: ResolvedRegradeHistoryReceipt
280
+ ): RegradeHistoryArtifact => ({
281
+ [resolvedReceipt]: receipt,
282
+ id: receipt.artifact.id,
283
+ kind: 'regrade-history',
284
+ path: receipt.artifact.path,
285
+ runs: receipt.runs.map((run) => ({
286
+ completionReport: reportForReceiptRun(run),
287
+ completionReportHash: run.receipt.evidence.sourceStateHash,
288
+ lockHashAtRun: run.receipt.evidence.lockStateHash,
289
+ plan: {
290
+ kind: 'regrade-plan',
291
+ path: `.trails/regrade/plans/${receipt.artifact.path.split('/').at(-1) ?? 'receipt'}`,
292
+ plan: run.plan as RegradePlanBody,
293
+ provenance: run.provenance,
294
+ schemaVersion: 1,
295
+ sourceHash: run.receipt.evidence.sourceStateHash,
296
+ transitionId: receipt.artifact.id,
297
+ },
298
+ planContentHash: run.receipt.intent.planContentHash,
299
+ report: reportForReceiptRun(run),
300
+ })),
301
+ schemaVersion: receipt.artifact.schemaVersion,
302
+ });
303
+
304
+ export interface RegradeHistorySummary {
305
+ readonly id: string;
306
+ readonly path: string;
307
+ readonly provenance?: GovernedVocabularyHistoryProvenance;
308
+ readonly schemaVersion: number;
309
+ readonly status: 'applied' | 'replay';
310
+ }
311
+
312
+ export const regradeHistoryPathForPlan = (
313
+ rootDir: string,
314
+ plan: RegradePlanBody
315
+ ): string =>
316
+ join(
317
+ regradePlanDirectory(rootDir),
318
+ 'history',
319
+ `${regradePlanSlugForBody(plan)}.json`
320
+ );
321
+
322
+ export const readRegradeHistoryArtifact = (
323
+ path: string
324
+ ): TrailsResult<RegradeHistoryArtifact, InternalError | ValidationError> => {
325
+ if (!existsSync(path)) {
326
+ return Result.err(
327
+ new ValidationError(`Regrade history "${path}" not found.`)
328
+ );
329
+ }
330
+ let parsedJson: unknown;
331
+ try {
332
+ parsedJson = JSON.parse(readFileSync(path, 'utf8'));
333
+ } catch (error) {
334
+ return Result.err(
335
+ new InternalError('Failed to read Regrade history artifact.', {
336
+ ...(error instanceof Error ? { cause: error } : {}),
337
+ context: { path },
338
+ })
339
+ );
340
+ }
341
+ const receipt = resolveRegradeHistoryReceipt(parsedJson);
342
+ if (receipt.isErr()) {
343
+ return receipt;
344
+ }
345
+ const observedPath = path.replaceAll('\\', '/');
346
+ const embeddedPath = receipt.value.artifact.path;
347
+ if (
348
+ observedPath !== embeddedPath &&
349
+ !observedPath.endsWith(`/${embeddedPath}`)
350
+ ) {
351
+ return Result.err(
352
+ new ValidationError(
353
+ 'Regrade history path does not match its observed file.',
354
+ {
355
+ context: { embeddedPath, observedPath },
356
+ }
357
+ )
358
+ );
359
+ }
360
+ return Result.ok(projectReceiptHistory(receipt.value));
361
+ };
362
+
363
+ /**
364
+ * Deterministic transition identity minted at the first recorded run and
365
+ * preserved for the life of the consolidated history file.
366
+ */
367
+ export const mintTransitionId = (
368
+ slug: string,
369
+ planContentHash: string,
370
+ lockHashAtRun: string
371
+ ): string =>
372
+ createHash('sha256')
373
+ .update(`${slug}\n${planContentHash}\n${lockHashAtRun}`)
374
+ .digest('hex')
375
+ .slice(0, 12);
376
+
377
+ const targetIncludesPlanTarget = (
378
+ transition: NonNullable<ReturnType<typeof getGovernedVocabularyTransition>>,
379
+ to: string
380
+ ): boolean =>
381
+ transition.target.kind === 'single'
382
+ ? transition.target.to === to
383
+ : transition.target.options.some((option) => option.to === to);
384
+
385
+ const governedTransitionForPlan = (
386
+ plan: Extract<RegradePlanBody, { kind: 'vocabulary' }>
387
+ ) => {
388
+ const idTransition =
389
+ plan.id === undefined
390
+ ? undefined
391
+ : getGovernedVocabularyTransition(plan.id);
392
+ return (
393
+ idTransition ??
394
+ listGovernedVocabularyTransitions().find(
395
+ (transition) => transition.from === plan.from
396
+ )
397
+ );
398
+ };
399
+
400
+ export const validateGovernedRegradePlan = (
401
+ artifact: RegradePlanArtifact
402
+ ): TrailsResult<void, ValidationError> => {
403
+ const { plan } = artifact;
404
+ if (plan.kind !== 'vocabulary') {
405
+ return Result.ok();
406
+ }
407
+ const transition = governedTransitionForPlan(plan);
408
+ if (transition === undefined) {
409
+ return Result.ok();
410
+ }
411
+ if (
412
+ transition.from === plan.from &&
413
+ targetIncludesPlanTarget(transition, plan.to)
414
+ ) {
415
+ return Result.ok();
416
+ }
417
+ return Result.err(
418
+ new ValidationError(
419
+ 'Governed Regrade plan does not match its registry transition.',
420
+ {
421
+ context: {
422
+ plan: { from: plan.from, id: plan.id, to: plan.to },
423
+ registry: { from: transition.from, id: transition.id },
424
+ },
425
+ }
426
+ )
427
+ );
428
+ };
429
+
430
+ /** Receipts are validated and hash-resolved during read. */
431
+ export const verifyRegradeHistoryRuns = (
432
+ artifact: RegradeHistoryArtifact
433
+ ): TrailsResult<{ readonly runs: number }, ValidationError> =>
434
+ Result.ok({ runs: artifact.runs.length });
435
+
436
+ const appendReceiptHistoryRun = (params: {
437
+ readonly absolutePath: string;
438
+ readonly artifact: RegradePlanArtifact;
439
+ readonly changedFiles: readonly RegradeChangedFileEvidence[];
440
+ readonly completedReport: RegradeReport;
441
+ readonly current: RegradeHistoryArtifact | undefined;
442
+ readonly lockHashAtRun: string;
443
+ readonly planContentHash: string;
444
+ readonly relativePath: string;
445
+ readonly report: RegradeReport;
446
+ readonly rootDir: string;
447
+ readonly sourceRevision: string;
448
+ }): TrailsResult<RegradeHistorySummary, Error> => {
449
+ if (
450
+ params.current !== undefined &&
451
+ params.artifact.transitionId !== undefined &&
452
+ params.artifact.transitionId !== params.current.id
453
+ ) {
454
+ return Result.err(
455
+ new ValidationError(
456
+ 'Regrade plan transition id mismatch — refusing to fork the consolidated history.',
457
+ {
458
+ context: {
459
+ history: params.current.id,
460
+ path: params.relativePath,
461
+ plan: params.artifact.transitionId,
462
+ },
463
+ }
464
+ )
465
+ );
466
+ }
467
+ const currentLastRun = params.current?.runs.at(-1);
468
+ if (
469
+ params.current !== undefined &&
470
+ params.artifact.transitionId === undefined &&
471
+ currentLastRun !== undefined &&
472
+ currentLastRun.plan.plan.id !== params.artifact.plan.id
473
+ ) {
474
+ return Result.err(
475
+ new ValidationError(
476
+ 'Regrade history already records a different plan identity under this transition name. Use `regrade adjust <transition>` to continue it, or pick a different plan name.',
477
+ {
478
+ context: {
479
+ history: currentLastRun.plan.plan.id,
480
+ path: params.relativePath,
481
+ plan: params.artifact.plan.id,
482
+ },
483
+ }
484
+ )
485
+ );
486
+ }
487
+ const transitionId =
488
+ params.current?.id ??
489
+ params.artifact.transitionId ??
490
+ mintTransitionId(
491
+ regradePlanSlugForBody(params.artifact.plan),
492
+ params.planContentHash,
493
+ params.lockHashAtRun
494
+ );
495
+ const receipt = buildRegradeHistoryReceipt({
496
+ artifact: params.artifact,
497
+ changedFiles: params.changedFiles,
498
+ completedReport: params.completedReport,
499
+ historyPath: params.relativePath,
500
+ ...(params.current?.[resolvedReceipt] === undefined
501
+ ? {}
502
+ : { prior: params.current[resolvedReceipt] }),
503
+ report: params.report,
504
+ rootDir: params.rootDir,
505
+ sourceRevision: params.sourceRevision,
506
+ transitionId,
507
+ });
508
+ if (receipt.isErr()) {
509
+ return receipt;
510
+ }
511
+ const serialized = serializeRegradeHistoryReceipt(receipt.value);
512
+ if (serialized.isErr()) {
513
+ return serialized;
514
+ }
515
+ const written = writeRegradeHistoryFileAtomically({
516
+ absolutePath: params.absolutePath,
517
+ content: serialized.value,
518
+ diagnosticPath: params.relativePath,
519
+ });
520
+ if (written.isErr()) {
521
+ return written;
522
+ }
523
+ const lastRun = receipt.value.runs.at(-1);
524
+ return Result.ok({
525
+ id: receipt.value.id,
526
+ path: receipt.value.path,
527
+ schemaVersion: receipt.value.schemaVersion,
528
+ status: lastRun?.runKind === 'proof' ? 'replay' : 'applied',
529
+ });
530
+ };
531
+
532
+ /**
533
+ * Append one applied run to the transition's consolidated history file. A
534
+ * Unchanged intent and classified state append a compact reference-only proof
535
+ * and surface `status: 'replay'`.
536
+ */
537
+ export const appendRegradeHistoryRun = (params: {
538
+ readonly artifact: RegradePlanArtifact;
539
+ readonly changedFiles?: readonly RegradeChangedFileEvidence[];
540
+ readonly completedReport?: RegradeReport;
541
+ readonly report: RegradeReport;
542
+ readonly rootDir: string;
543
+ readonly sourceRevision?: string;
544
+ }): TrailsResult<RegradeHistorySummary, Error> => {
545
+ const absolutePath = regradeHistoryPathForPlan(
546
+ params.rootDir,
547
+ params.artifact.plan
548
+ );
549
+ const relativePath = rootRelativePath(params.rootDir, absolutePath);
550
+ const planContentHash = regradePlanContentHash(params.artifact.plan);
551
+ const lockHashAtRun = regradeSourceHash(params.report);
552
+ const completionReport = params.completedReport ?? params.report;
553
+
554
+ let current: RegradeHistoryArtifact | undefined;
555
+ if (existsSync(absolutePath)) {
556
+ const existing = readRegradeHistoryArtifact(absolutePath);
557
+ if (existing.isErr()) {
558
+ return existing;
559
+ }
560
+ current = existing.value;
561
+ }
562
+ const sourceRevision =
563
+ params.sourceRevision === undefined
564
+ ? resolveRegradeSourceRevision(params.rootDir)
565
+ : Result.ok(params.sourceRevision);
566
+ if (sourceRevision.isErr()) {
567
+ return sourceRevision;
568
+ }
569
+ return appendReceiptHistoryRun({
570
+ absolutePath,
571
+ artifact: params.artifact,
572
+ changedFiles: params.changedFiles ?? [],
573
+ completedReport: completionReport,
574
+ current,
575
+ lockHashAtRun,
576
+ planContentHash,
577
+ relativePath,
578
+ report: params.report,
579
+ rootDir: params.rootDir,
580
+ sourceRevision: sourceRevision.value,
581
+ });
582
+ };
583
+
584
+ const hasPathSeparator = (value: string): boolean =>
585
+ value.includes('/') || value.includes('\\');
586
+
587
+ /**
588
+ * Resolve an opaque transition id to its validated consolidated history file.
589
+ * Path and filename references are rejected; the operator never selects the
590
+ * generator-owned receipt filename.
591
+ */
592
+ export const resolveRegradeHistoryPath = (
593
+ rootDir: string,
594
+ ref: string
595
+ ): TrailsResult<string, Error> => {
596
+ if (hasPathSeparator(ref) || isAbsolute(ref)) {
597
+ return Result.err(
598
+ new ValidationError(
599
+ `Regrade history reference "${ref}" must be an opaque transition id.`
600
+ )
601
+ );
602
+ }
603
+ const historyDir = join(regradePlanDirectory(rootDir), 'history');
604
+ if (!existsSync(historyDir)) {
605
+ return Result.err(
606
+ new ValidationError(`No Regrade history for transition "${ref}" found.`)
607
+ );
608
+ }
609
+ const matches: string[] = [];
610
+ for (const name of readdirSync(historyDir).toSorted()) {
611
+ if (!name.endsWith('.json')) {
612
+ continue;
613
+ }
614
+ const path = join(historyDir, name);
615
+ const history = readRegradeHistoryArtifact(path);
616
+ if (history.isErr()) {
617
+ return history;
618
+ }
619
+ if (history.value.id === ref) {
620
+ matches.push(path);
621
+ }
622
+ }
623
+ if (matches.length === 0) {
624
+ return Result.err(
625
+ new ValidationError(`No Regrade history for transition "${ref}" found.`)
626
+ );
627
+ }
628
+ if (matches.length > 1) {
629
+ return Result.err(
630
+ new ValidationError(
631
+ `Multiple Regrade histories claim transition "${ref}".`
632
+ )
633
+ );
634
+ }
635
+ return Result.ok(matches[0] as string);
636
+ };
@@ -0,0 +1,76 @@
1
+ import type { ProgressCallback, Result } from '@ontrails/core';
2
+ import { z } from 'zod';
3
+
4
+ export const regradeLifecyclePhaseSchema = z.object({
5
+ durationMs: z.number().int().nonnegative(),
6
+ name: z.string().min(1),
7
+ status: z.literal('completed'),
8
+ });
9
+
10
+ export const regradeLifecycleSchema = z.object({
11
+ durationMs: z.number().int().nonnegative(),
12
+ phases: z.array(regradeLifecyclePhaseSchema),
13
+ });
14
+
15
+ export type RegradeLifecycle = z.output<typeof regradeLifecycleSchema>;
16
+
17
+ interface RegradeLifecycleTrackerOptions {
18
+ readonly now?: (() => number) | undefined;
19
+ readonly progress?: ProgressCallback | undefined;
20
+ }
21
+
22
+ const elapsedMilliseconds = (startedAt: number, finishedAt: number): number =>
23
+ Math.max(0, Math.round(finishedAt - startedAt));
24
+
25
+ const phaseLabel = (name: string): string => name.replaceAll('-', ' ');
26
+
27
+ export class RegradeLifecycleTracker {
28
+ readonly #now: () => number;
29
+ readonly #phases: RegradeLifecycle['phases'][number][] = [];
30
+ readonly #progress: ProgressCallback | undefined;
31
+ readonly #startedAt: number;
32
+
33
+ constructor(options: RegradeLifecycleTrackerOptions = {}) {
34
+ this.#now = options.now ?? Date.now;
35
+ this.#progress = options.progress;
36
+ this.#startedAt = this.#now();
37
+ }
38
+
39
+ async run<T, E extends Error>(
40
+ name: string,
41
+ operation: () => Promise<Result<T, E>> | Result<T, E>
42
+ ): Promise<Result<T, E>> {
43
+ const startedAt = this.#now();
44
+ this.#progress?.({
45
+ message: `Regrade: ${phaseLabel(name)}`,
46
+ ts: new Date().toISOString(),
47
+ type: 'start',
48
+ });
49
+
50
+ const result = await operation();
51
+ const durationMs = elapsedMilliseconds(startedAt, this.#now());
52
+ if (result.isErr()) {
53
+ this.#progress?.({
54
+ message: `Regrade: ${phaseLabel(name)} failed (${durationMs} ms)`,
55
+ ts: new Date().toISOString(),
56
+ type: 'error',
57
+ });
58
+ return result;
59
+ }
60
+
61
+ this.#phases.push({ durationMs, name, status: 'completed' });
62
+ this.#progress?.({
63
+ message: `Regrade: ${phaseLabel(name)} complete (${durationMs} ms)`,
64
+ ts: new Date().toISOString(),
65
+ type: 'complete',
66
+ });
67
+ return result;
68
+ }
69
+
70
+ summary(): RegradeLifecycle {
71
+ return {
72
+ durationMs: elapsedMilliseconds(this.#startedAt, this.#now()),
73
+ phases: [...this.#phases],
74
+ };
75
+ }
76
+ }