@git-stunts/git-warp 10.1.2 → 10.4.2

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 (106) hide show
  1. package/README.md +31 -4
  2. package/bin/warp-graph.js +1242 -59
  3. package/index.d.ts +31 -0
  4. package/index.js +4 -0
  5. package/package.json +13 -3
  6. package/src/domain/WarpGraph.js +487 -140
  7. package/src/domain/crdt/LWW.js +1 -1
  8. package/src/domain/crdt/ORSet.js +10 -6
  9. package/src/domain/crdt/VersionVector.js +5 -1
  10. package/src/domain/errors/EmptyMessageError.js +2 -4
  11. package/src/domain/errors/ForkError.js +4 -0
  12. package/src/domain/errors/IndexError.js +4 -0
  13. package/src/domain/errors/OperationAbortedError.js +4 -0
  14. package/src/domain/errors/QueryError.js +4 -0
  15. package/src/domain/errors/SchemaUnsupportedError.js +4 -0
  16. package/src/domain/errors/ShardCorruptionError.js +2 -6
  17. package/src/domain/errors/ShardLoadError.js +2 -6
  18. package/src/domain/errors/ShardValidationError.js +2 -7
  19. package/src/domain/errors/StorageError.js +2 -6
  20. package/src/domain/errors/SyncError.js +4 -0
  21. package/src/domain/errors/TraversalError.js +4 -0
  22. package/src/domain/errors/WarpError.js +2 -4
  23. package/src/domain/errors/WormholeError.js +4 -0
  24. package/src/domain/services/AnchorMessageCodec.js +1 -4
  25. package/src/domain/services/BitmapIndexBuilder.js +10 -6
  26. package/src/domain/services/BitmapIndexReader.js +27 -21
  27. package/src/domain/services/BoundaryTransitionRecord.js +22 -15
  28. package/src/domain/services/CheckpointMessageCodec.js +1 -7
  29. package/src/domain/services/CheckpointSerializerV5.js +20 -19
  30. package/src/domain/services/CheckpointService.js +18 -18
  31. package/src/domain/services/CommitDagTraversalService.js +13 -1
  32. package/src/domain/services/DagPathFinding.js +40 -18
  33. package/src/domain/services/DagTopology.js +7 -6
  34. package/src/domain/services/DagTraversal.js +5 -3
  35. package/src/domain/services/Frontier.js +7 -6
  36. package/src/domain/services/HealthCheckService.js +15 -14
  37. package/src/domain/services/HookInstaller.js +64 -13
  38. package/src/domain/services/HttpSyncServer.js +15 -14
  39. package/src/domain/services/IndexRebuildService.js +12 -12
  40. package/src/domain/services/IndexStalenessChecker.js +13 -6
  41. package/src/domain/services/JoinReducer.js +28 -27
  42. package/src/domain/services/LogicalTraversal.js +7 -6
  43. package/src/domain/services/MessageCodecInternal.js +2 -0
  44. package/src/domain/services/ObserverView.js +6 -6
  45. package/src/domain/services/PatchBuilderV2.js +9 -9
  46. package/src/domain/services/PatchMessageCodec.js +1 -7
  47. package/src/domain/services/ProvenanceIndex.js +6 -8
  48. package/src/domain/services/ProvenancePayload.js +1 -2
  49. package/src/domain/services/QueryBuilder.js +29 -23
  50. package/src/domain/services/StateDiff.js +7 -7
  51. package/src/domain/services/StateSerializerV5.js +8 -6
  52. package/src/domain/services/StreamingBitmapIndexBuilder.js +29 -23
  53. package/src/domain/services/SyncProtocol.js +23 -26
  54. package/src/domain/services/TemporalQuery.js +4 -3
  55. package/src/domain/services/TranslationCost.js +4 -4
  56. package/src/domain/services/WormholeService.js +19 -15
  57. package/src/domain/types/TickReceipt.js +10 -6
  58. package/src/domain/types/WarpTypesV2.js +2 -3
  59. package/src/domain/utils/CachedValue.js +1 -1
  60. package/src/domain/utils/LRUCache.js +3 -3
  61. package/src/domain/utils/MinHeap.js +2 -2
  62. package/src/domain/utils/RefLayout.js +106 -15
  63. package/src/domain/utils/WriterId.js +2 -2
  64. package/src/domain/utils/defaultCodec.js +9 -2
  65. package/src/domain/utils/defaultCrypto.js +36 -0
  66. package/src/domain/utils/parseCursorBlob.js +51 -0
  67. package/src/domain/utils/roaring.js +5 -5
  68. package/src/domain/utils/seekCacheKey.js +32 -0
  69. package/src/domain/warp/PatchSession.js +3 -3
  70. package/src/domain/warp/Writer.js +2 -2
  71. package/src/infrastructure/adapters/BunHttpAdapter.js +21 -8
  72. package/src/infrastructure/adapters/CasSeekCacheAdapter.js +311 -0
  73. package/src/infrastructure/adapters/ClockAdapter.js +2 -2
  74. package/src/infrastructure/adapters/DenoHttpAdapter.js +22 -9
  75. package/src/infrastructure/adapters/GitGraphAdapter.js +16 -27
  76. package/src/infrastructure/adapters/NodeCryptoAdapter.js +16 -3
  77. package/src/infrastructure/adapters/NodeHttpAdapter.js +33 -11
  78. package/src/infrastructure/adapters/WebCryptoAdapter.js +21 -11
  79. package/src/infrastructure/codecs/CborCodec.js +16 -8
  80. package/src/ports/BlobPort.js +2 -2
  81. package/src/ports/CodecPort.js +2 -2
  82. package/src/ports/CommitPort.js +8 -21
  83. package/src/ports/ConfigPort.js +3 -3
  84. package/src/ports/CryptoPort.js +7 -7
  85. package/src/ports/GraphPersistencePort.js +12 -14
  86. package/src/ports/HttpServerPort.js +1 -5
  87. package/src/ports/IndexStoragePort.js +1 -0
  88. package/src/ports/LoggerPort.js +9 -9
  89. package/src/ports/RefPort.js +5 -5
  90. package/src/ports/SeekCachePort.js +73 -0
  91. package/src/ports/TreePort.js +3 -3
  92. package/src/visualization/layouts/converters.js +14 -7
  93. package/src/visualization/layouts/elkAdapter.js +24 -11
  94. package/src/visualization/layouts/elkLayout.js +23 -7
  95. package/src/visualization/layouts/index.js +3 -3
  96. package/src/visualization/renderers/ascii/check.js +30 -17
  97. package/src/visualization/renderers/ascii/graph.js +122 -16
  98. package/src/visualization/renderers/ascii/history.js +29 -90
  99. package/src/visualization/renderers/ascii/index.js +1 -1
  100. package/src/visualization/renderers/ascii/info.js +9 -7
  101. package/src/visualization/renderers/ascii/materialize.js +20 -16
  102. package/src/visualization/renderers/ascii/opSummary.js +81 -0
  103. package/src/visualization/renderers/ascii/path.js +1 -1
  104. package/src/visualization/renderers/ascii/seek.js +344 -0
  105. package/src/visualization/renderers/ascii/table.js +1 -1
  106. package/src/visualization/renderers/svg/index.js +5 -1
package/bin/warp-graph.js CHANGED
@@ -1,32 +1,120 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import crypto from 'node:crypto';
3
4
  import fs from 'node:fs';
4
5
  import path from 'node:path';
5
6
  import process from 'node:process';
6
7
  import readline from 'node:readline';
7
8
  import { execFileSync } from 'node:child_process';
9
+ // @ts-expect-error — no type declarations for @git-stunts/plumbing
8
10
  import GitPlumbing, { ShellRunnerFactory } from '@git-stunts/plumbing';
9
11
  import WarpGraph from '../src/domain/WarpGraph.js';
10
12
  import GitGraphAdapter from '../src/infrastructure/adapters/GitGraphAdapter.js';
11
13
  import HealthCheckService from '../src/domain/services/HealthCheckService.js';
12
14
  import ClockAdapter from '../src/infrastructure/adapters/ClockAdapter.js';
15
+ import NodeCryptoAdapter from '../src/infrastructure/adapters/NodeCryptoAdapter.js';
13
16
  import {
14
17
  REF_PREFIX,
15
18
  buildCheckpointRef,
16
19
  buildCoverageRef,
17
20
  buildWritersPrefix,
18
21
  parseWriterIdFromRef,
22
+ buildCursorActiveRef,
23
+ buildCursorSavedRef,
24
+ buildCursorSavedPrefix,
19
25
  } from '../src/domain/utils/RefLayout.js';
26
+ import CasSeekCacheAdapter from '../src/infrastructure/adapters/CasSeekCacheAdapter.js';
20
27
  import { HookInstaller, classifyExistingHook } from '../src/domain/services/HookInstaller.js';
21
28
  import { renderInfoView } from '../src/visualization/renderers/ascii/info.js';
22
29
  import { renderCheckView } from '../src/visualization/renderers/ascii/check.js';
23
30
  import { renderHistoryView, summarizeOps } from '../src/visualization/renderers/ascii/history.js';
24
31
  import { renderPathView } from '../src/visualization/renderers/ascii/path.js';
25
32
  import { renderMaterializeView } from '../src/visualization/renderers/ascii/materialize.js';
33
+ import { parseCursorBlob } from '../src/domain/utils/parseCursorBlob.js';
34
+ import { renderSeekView } from '../src/visualization/renderers/ascii/seek.js';
26
35
  import { renderGraphView } from '../src/visualization/renderers/ascii/graph.js';
27
36
  import { renderSvg } from '../src/visualization/renderers/svg/index.js';
28
37
  import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../src/visualization/layouts/index.js';
29
38
 
39
+ /**
40
+ * @typedef {Object} Persistence
41
+ * @property {(prefix: string) => Promise<string[]>} listRefs
42
+ * @property {(ref: string) => Promise<string|null>} readRef
43
+ * @property {(ref: string, oid: string) => Promise<void>} updateRef
44
+ * @property {(ref: string) => Promise<void>} deleteRef
45
+ * @property {(oid: string) => Promise<Buffer>} readBlob
46
+ * @property {(buf: Buffer) => Promise<string>} writeBlob
47
+ * @property {(sha: string) => Promise<{date?: string|null}>} getNodeInfo
48
+ * @property {(sha: string, coverageSha: string) => Promise<boolean>} isAncestor
49
+ * @property {() => Promise<{ok: boolean}>} ping
50
+ * @property {*} plumbing
51
+ */
52
+
53
+ /**
54
+ * @typedef {Object} WarpGraphInstance
55
+ * @property {(opts?: {ceiling?: number}) => Promise<void>} materialize
56
+ * @property {() => Promise<Array<{id: string}>>} getNodes
57
+ * @property {() => Promise<Array<{from: string, to: string, label?: string}>>} getEdges
58
+ * @property {() => Promise<string|null>} createCheckpoint
59
+ * @property {() => *} query
60
+ * @property {{ shortestPath: Function }} traverse
61
+ * @property {(writerId: string) => Promise<Array<{patch: any, sha: string}>>} getWriterPatches
62
+ * @property {() => Promise<{frontier: Record<string, any>}>} status
63
+ * @property {() => Promise<Map<string, any>>} getFrontier
64
+ * @property {() => {totalTombstones: number, tombstoneRatio: number}} getGCMetrics
65
+ * @property {() => Promise<number>} getPropertyCount
66
+ * @property {() => Promise<{ticks: number[], maxTick: number, perWriter: Map<string, WriterTickInfo>}>} discoverTicks
67
+ * @property {(sha: string) => Promise<{ops?: any[]}>} loadPatchBySha
68
+ * @property {(cache: any) => void} setSeekCache
69
+ * @property {*} seekCache
70
+ * @property {number} [_seekCeiling]
71
+ * @property {boolean} [_provenanceDegraded]
72
+ */
73
+
74
+ /**
75
+ * @typedef {Object} WriterTickInfo
76
+ * @property {number[]} ticks
77
+ * @property {string|null} tipSha
78
+ * @property {Record<number, string>} [tickShas]
79
+ */
80
+
81
+ /**
82
+ * @typedef {Object} CursorBlob
83
+ * @property {number} tick
84
+ * @property {string} [mode]
85
+ * @property {number} [nodes]
86
+ * @property {number} [edges]
87
+ * @property {string} [frontierHash]
88
+ */
89
+
90
+ /**
91
+ * @typedef {Object} CliOptions
92
+ * @property {string} repo
93
+ * @property {boolean} json
94
+ * @property {string|null} view
95
+ * @property {string|null} graph
96
+ * @property {string} writer
97
+ * @property {boolean} help
98
+ */
99
+
100
+ /**
101
+ * @typedef {Object} GraphInfoResult
102
+ * @property {string} name
103
+ * @property {{count: number, ids?: string[]}} writers
104
+ * @property {{ref: string, sha: string|null, date?: string|null}} [checkpoint]
105
+ * @property {{ref: string, sha: string|null}} [coverage]
106
+ * @property {Record<string, number>} [writerPatches]
107
+ * @property {{active: boolean, tick?: number, mode?: string}} [cursor]
108
+ */
109
+
110
+ /**
111
+ * @typedef {Object} SeekSpec
112
+ * @property {string} action
113
+ * @property {string|null} tickValue
114
+ * @property {string|null} name
115
+ * @property {boolean} noPersistentCache
116
+ */
117
+
30
118
  const EXIT_CODES = {
31
119
  OK: 0,
32
120
  USAGE: 1,
@@ -44,6 +132,7 @@ Commands:
44
132
  history Show writer history
45
133
  check Report graph health/GC status
46
134
  materialize Materialize and checkpoint all graphs
135
+ seek Time-travel: step through graph history by Lamport tick
47
136
  view Interactive TUI graph browser (requires @git-stunts/git-warp-tui)
48
137
  install-hooks Install post-merge git hook
49
138
 
@@ -74,6 +163,14 @@ Path options:
74
163
 
75
164
  History options:
76
165
  --node <id> Filter patches touching node id
166
+
167
+ Seek options:
168
+ --tick <N|+N|-N> Jump to tick N, or step forward/backward
169
+ --latest Clear cursor, return to present
170
+ --save <name> Save current position as named cursor
171
+ --load <name> Restore a saved cursor
172
+ --list List all saved cursors
173
+ --drop <name> Delete a saved cursor
77
174
  `;
78
175
 
79
176
  /**
@@ -95,20 +192,25 @@ class CliError extends Error {
95
192
  }
96
193
  }
97
194
 
195
+ /** @param {string} message */
98
196
  function usageError(message) {
99
197
  return new CliError(message, { code: 'E_USAGE', exitCode: EXIT_CODES.USAGE });
100
198
  }
101
199
 
200
+ /** @param {string} message */
102
201
  function notFoundError(message) {
103
202
  return new CliError(message, { code: 'E_NOT_FOUND', exitCode: EXIT_CODES.NOT_FOUND });
104
203
  }
105
204
 
205
+ /** @param {*} value */
106
206
  function stableStringify(value) {
207
+ /** @param {*} input @returns {*} */
107
208
  const normalize = (input) => {
108
209
  if (Array.isArray(input)) {
109
210
  return input.map(normalize);
110
211
  }
111
212
  if (input && typeof input === 'object') {
213
+ /** @type {Record<string, *>} */
112
214
  const sorted = {};
113
215
  for (const key of Object.keys(input).sort()) {
114
216
  sorted[key] = normalize(input[key]);
@@ -121,8 +223,10 @@ function stableStringify(value) {
121
223
  return JSON.stringify(normalize(value), null, 2);
122
224
  }
123
225
 
226
+ /** @param {string[]} argv */
124
227
  function parseArgs(argv) {
125
228
  const options = createDefaultOptions();
229
+ /** @type {string[]} */
126
230
  const positionals = [];
127
231
  const optionDefs = [
128
232
  { flag: '--repo', shortFlag: '-r', key: 'repo' },
@@ -153,6 +257,14 @@ function createDefaultOptions() {
153
257
  };
154
258
  }
155
259
 
260
+ /**
261
+ * @param {Object} params
262
+ * @param {string[]} params.argv
263
+ * @param {number} params.index
264
+ * @param {Record<string, *>} params.options
265
+ * @param {Array<{flag: string, shortFlag?: string, key: string}>} params.optionDefs
266
+ * @param {string[]} params.positionals
267
+ */
156
268
  function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
157
269
  const arg = argv[index];
158
270
 
@@ -169,7 +281,7 @@ function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
169
281
  if (arg === '--view') {
170
282
  // Valid view modes: ascii, browser, svg:FILE, html:FILE
171
283
  // Don't consume known commands as modes
172
- const KNOWN_COMMANDS = ['info', 'query', 'path', 'history', 'check', 'materialize', 'install-hooks'];
284
+ const KNOWN_COMMANDS = ['info', 'query', 'path', 'history', 'check', 'materialize', 'seek', 'install-hooks'];
173
285
  const nextArg = argv[index + 1];
174
286
  const isViewMode = nextArg &&
175
287
  !nextArg.startsWith('-') &&
@@ -204,8 +316,10 @@ function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
204
316
  shortFlag: matched.shortFlag,
205
317
  allowEmpty: false,
206
318
  });
207
- options[matched.key] = result.value;
208
- return { consumed: result.consumed };
319
+ if (result) {
320
+ options[matched.key] = result.value;
321
+ return { consumed: result.consumed };
322
+ }
209
323
  }
210
324
 
211
325
  if (arg.startsWith('-')) {
@@ -216,6 +330,10 @@ function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
216
330
  return { consumed: argv.length - index - 1, done: true };
217
331
  }
218
332
 
333
+ /**
334
+ * @param {string} arg
335
+ * @param {Array<{flag: string, shortFlag?: string, key: string}>} optionDefs
336
+ */
219
337
  function matchOptionDef(arg, optionDefs) {
220
338
  return optionDefs.find((def) =>
221
339
  arg === def.flag ||
@@ -224,6 +342,7 @@ function matchOptionDef(arg, optionDefs) {
224
342
  );
225
343
  }
226
344
 
345
+ /** @param {string} repoPath @returns {Promise<{persistence: Persistence}>} */
227
346
  async function createPersistence(repoPath) {
228
347
  const runner = ShellRunnerFactory.create();
229
348
  const plumbing = new GitPlumbing({ cwd: repoPath, runner });
@@ -235,6 +354,7 @@ async function createPersistence(repoPath) {
235
354
  return { persistence };
236
355
  }
237
356
 
357
+ /** @param {Persistence} persistence @returns {Promise<string[]>} */
238
358
  async function listGraphNames(persistence) {
239
359
  if (typeof persistence.listRefs !== 'function') {
240
360
  return [];
@@ -257,6 +377,11 @@ async function listGraphNames(persistence) {
257
377
  return [...names].sort();
258
378
  }
259
379
 
380
+ /**
381
+ * @param {Persistence} persistence
382
+ * @param {string|null} explicitGraph
383
+ * @returns {Promise<string>}
384
+ */
260
385
  async function resolveGraphName(persistence, explicitGraph) {
261
386
  if (explicitGraph) {
262
387
  return explicitGraph;
@@ -271,6 +396,17 @@ async function resolveGraphName(persistence, explicitGraph) {
271
396
  throw usageError('Multiple graphs found; specify --graph');
272
397
  }
273
398
 
399
+ /**
400
+ * Collects metadata about a single graph (writer count, refs, patches, checkpoint).
401
+ * @param {Persistence} persistence - GraphPersistencePort adapter
402
+ * @param {string} graphName - Name of the graph to inspect
403
+ * @param {Object} [options]
404
+ * @param {boolean} [options.includeWriterIds=false] - Include writer ID list
405
+ * @param {boolean} [options.includeRefs=false] - Include checkpoint/coverage refs
406
+ * @param {boolean} [options.includeWriterPatches=false] - Include per-writer patch counts
407
+ * @param {boolean} [options.includeCheckpointDate=false] - Include checkpoint date
408
+ * @returns {Promise<GraphInfoResult>} Graph info object
409
+ */
274
410
  async function getGraphInfo(persistence, graphName, {
275
411
  includeWriterIds = false,
276
412
  includeRefs = false,
@@ -281,11 +417,12 @@ async function getGraphInfo(persistence, graphName, {
281
417
  const writerRefs = typeof persistence.listRefs === 'function'
282
418
  ? await persistence.listRefs(writersPrefix)
283
419
  : [];
284
- const writerIds = writerRefs
420
+ const writerIds = /** @type {string[]} */ (writerRefs
285
421
  .map((ref) => parseWriterIdFromRef(ref))
286
422
  .filter(Boolean)
287
- .sort();
423
+ .sort());
288
424
 
425
+ /** @type {GraphInfoResult} */
289
426
  const info = {
290
427
  name: graphName,
291
428
  writers: {
@@ -301,6 +438,7 @@ async function getGraphInfo(persistence, graphName, {
301
438
  const checkpointRef = buildCheckpointRef(graphName);
302
439
  const checkpointSha = await persistence.readRef(checkpointRef);
303
440
 
441
+ /** @type {{ref: string, sha: string|null, date?: string|null}} */
304
442
  const checkpoint = { ref: checkpointRef, sha: checkpointSha || null };
305
443
 
306
444
  if (includeCheckpointDate && checkpointSha) {
@@ -322,11 +460,13 @@ async function getGraphInfo(persistence, graphName, {
322
460
  persistence,
323
461
  graphName,
324
462
  writerId: 'cli',
463
+ crypto: new NodeCryptoAdapter(),
325
464
  });
465
+ /** @type {Record<string, number>} */
326
466
  const writerPatches = {};
327
467
  for (const writerId of writerIds) {
328
468
  const patches = await graph.getWriterPatches(writerId);
329
- writerPatches[writerId] = patches.length;
469
+ writerPatches[/** @type {string} */ (writerId)] = patches.length;
330
470
  }
331
471
  info.writerPatches = writerPatches;
332
472
  }
@@ -334,17 +474,31 @@ async function getGraphInfo(persistence, graphName, {
334
474
  return info;
335
475
  }
336
476
 
477
+ /**
478
+ * Opens a WarpGraph for the given CLI options.
479
+ * @param {CliOptions} options - Parsed CLI options
480
+ * @returns {Promise<{graph: WarpGraphInstance, graphName: string, persistence: Persistence}>}
481
+ * @throws {CliError} If the specified graph is not found
482
+ */
337
483
  async function openGraph(options) {
338
484
  const { persistence } = await createPersistence(options.repo);
339
485
  const graphName = await resolveGraphName(persistence, options.graph);
340
- const graph = await WarpGraph.open({
486
+ if (options.graph) {
487
+ const graphNames = await listGraphNames(persistence);
488
+ if (!graphNames.includes(options.graph)) {
489
+ throw notFoundError(`Graph not found: ${options.graph}`);
490
+ }
491
+ }
492
+ const graph = /** @type {WarpGraphInstance} */ (/** @type {*} */ (await WarpGraph.open({ // TODO(ts-cleanup): narrow port type
341
493
  persistence,
342
494
  graphName,
343
495
  writerId: options.writer,
344
- });
496
+ crypto: new NodeCryptoAdapter(),
497
+ })));
345
498
  return { graph, graphName, persistence };
346
499
  }
347
500
 
501
+ /** @param {string[]} args */
348
502
  function parseQueryArgs(args) {
349
503
  const spec = {
350
504
  match: null,
@@ -363,6 +517,11 @@ function parseQueryArgs(args) {
363
517
  return spec;
364
518
  }
365
519
 
520
+ /**
521
+ * @param {string[]} args
522
+ * @param {number} index
523
+ * @param {{match: string|null, select: string[]|null, steps: Array<{type: string, label?: string, key?: string, value?: string}>}} spec
524
+ */
366
525
  function consumeQueryArg(args, index, spec) {
367
526
  const stepResult = readTraversalStep(args, index);
368
527
  if (stepResult) {
@@ -406,6 +565,7 @@ function consumeQueryArg(args, index, spec) {
406
565
  return null;
407
566
  }
408
567
 
568
+ /** @param {string} value */
409
569
  function parseWhereProp(value) {
410
570
  const [key, ...rest] = value.split('=');
411
571
  if (!key || rest.length === 0) {
@@ -414,6 +574,7 @@ function parseWhereProp(value) {
414
574
  return { type: 'where-prop', key, value: rest.join('=') };
415
575
  }
416
576
 
577
+ /** @param {string} value */
417
578
  function parseSelectFields(value) {
418
579
  if (value === '') {
419
580
  return [];
@@ -421,6 +582,10 @@ function parseSelectFields(value) {
421
582
  return value.split(',').map((field) => field.trim()).filter(Boolean);
422
583
  }
423
584
 
585
+ /**
586
+ * @param {string[]} args
587
+ * @param {number} index
588
+ */
424
589
  function readTraversalStep(args, index) {
425
590
  const arg = args[index];
426
591
  if (arg !== '--outgoing' && arg !== '--incoming') {
@@ -432,6 +597,9 @@ function readTraversalStep(args, index) {
432
597
  return { step: { type: arg.slice(2), label }, consumed };
433
598
  }
434
599
 
600
+ /**
601
+ * @param {{args: string[], index: number, flag: string, shortFlag?: string, allowEmpty?: boolean}} params
602
+ */
435
603
  function readOptionValue({ args, index, flag, shortFlag, allowEmpty = false }) {
436
604
  const arg = args[index];
437
605
  if (matchesOptionFlag(arg, flag, shortFlag)) {
@@ -445,10 +613,16 @@ function readOptionValue({ args, index, flag, shortFlag, allowEmpty = false }) {
445
613
  return null;
446
614
  }
447
615
 
616
+ /**
617
+ * @param {string} arg
618
+ * @param {string} flag
619
+ * @param {string} [shortFlag]
620
+ */
448
621
  function matchesOptionFlag(arg, flag, shortFlag) {
449
622
  return arg === flag || (shortFlag && arg === shortFlag);
450
623
  }
451
624
 
625
+ /** @param {{args: string[], index: number, flag: string, allowEmpty?: boolean}} params */
452
626
  function readNextOptionValue({ args, index, flag, allowEmpty }) {
453
627
  const value = args[index + 1];
454
628
  if (value === undefined || (!allowEmpty && value === '')) {
@@ -457,6 +631,7 @@ function readNextOptionValue({ args, index, flag, allowEmpty }) {
457
631
  return { value, consumed: 1 };
458
632
  }
459
633
 
634
+ /** @param {{arg: string, flag: string, allowEmpty?: boolean}} params */
460
635
  function readInlineOptionValue({ arg, flag, allowEmpty }) {
461
636
  const value = arg.slice(flag.length + 1);
462
637
  if (!allowEmpty && value === '') {
@@ -465,9 +640,12 @@ function readInlineOptionValue({ arg, flag, allowEmpty }) {
465
640
  return { value, consumed: 0 };
466
641
  }
467
642
 
643
+ /** @param {string[]} args */
468
644
  function parsePathArgs(args) {
469
645
  const options = createPathOptions();
646
+ /** @type {string[]} */
470
647
  const labels = [];
648
+ /** @type {string[]} */
471
649
  const positionals = [];
472
650
 
473
651
  for (let i = 0; i < args.length; i += 1) {
@@ -479,6 +657,7 @@ function parsePathArgs(args) {
479
657
  return options;
480
658
  }
481
659
 
660
+ /** @returns {{from: string|null, to: string|null, dir: string|undefined, labelFilter: string|string[]|undefined, maxDepth: number|undefined}} */
482
661
  function createPathOptions() {
483
662
  return {
484
663
  from: null,
@@ -489,8 +668,12 @@ function createPathOptions() {
489
668
  };
490
669
  }
491
670
 
671
+ /**
672
+ * @param {{args: string[], index: number, options: ReturnType<typeof createPathOptions>, labels: string[], positionals: string[]}} params
673
+ */
492
674
  function consumePathArg({ args, index, options, labels, positionals }) {
493
675
  const arg = args[index];
676
+ /** @type {Array<{flag: string, apply: (value: string) => void}>} */
494
677
  const handlers = [
495
678
  { flag: '--from', apply: (value) => { options.from = value; } },
496
679
  { flag: '--to', apply: (value) => { options.to = value; } },
@@ -515,6 +698,11 @@ function consumePathArg({ args, index, options, labels, positionals }) {
515
698
  return { consumed: 0 };
516
699
  }
517
700
 
701
+ /**
702
+ * @param {ReturnType<typeof createPathOptions>} options
703
+ * @param {string[]} labels
704
+ * @param {string[]} positionals
705
+ */
518
706
  function finalizePathOptions(options, labels, positionals) {
519
707
  if (!options.from) {
520
708
  options.from = positionals[0] || null;
@@ -535,10 +723,12 @@ function finalizePathOptions(options, labels, positionals) {
535
723
  }
536
724
  }
537
725
 
726
+ /** @param {string} value */
538
727
  function parseLabels(value) {
539
728
  return value.split(',').map((label) => label.trim()).filter(Boolean);
540
729
  }
541
730
 
731
+ /** @param {string} value */
542
732
  function parseMaxDepth(value) {
543
733
  const parsed = Number.parseInt(value, 10);
544
734
  if (Number.isNaN(parsed)) {
@@ -547,7 +737,9 @@ function parseMaxDepth(value) {
547
737
  return parsed;
548
738
  }
549
739
 
740
+ /** @param {string[]} args */
550
741
  function parseHistoryArgs(args) {
742
+ /** @type {{node: string|null}} */
551
743
  const options = { node: null };
552
744
 
553
745
  for (let i = 0; i < args.length; i += 1) {
@@ -578,6 +770,10 @@ function parseHistoryArgs(args) {
578
770
  return options;
579
771
  }
580
772
 
773
+ /**
774
+ * @param {*} patch
775
+ * @param {string} nodeId
776
+ */
581
777
  function patchTouchesNode(patch, nodeId) {
582
778
  const ops = Array.isArray(patch?.ops) ? patch.ops : [];
583
779
  for (const op of ops) {
@@ -591,6 +787,7 @@ function patchTouchesNode(patch, nodeId) {
591
787
  return false;
592
788
  }
593
789
 
790
+ /** @param {*} payload */
594
791
  function renderInfo(payload) {
595
792
  const lines = [`Repo: ${payload.repo}`];
596
793
  lines.push(`Graphs: ${payload.graphs.length}`);
@@ -603,10 +800,14 @@ function renderInfo(payload) {
603
800
  if (graph.coverage?.sha) {
604
801
  lines.push(` coverage: ${graph.coverage.sha}`);
605
802
  }
803
+ if (graph.cursor?.active) {
804
+ lines.push(` cursor: tick ${graph.cursor.tick} (${graph.cursor.mode})`);
805
+ }
606
806
  }
607
807
  return `${lines.join('\n')}\n`;
608
808
  }
609
809
 
810
+ /** @param {*} payload */
610
811
  function renderQuery(payload) {
611
812
  const lines = [
612
813
  `Graph: ${payload.graph}`,
@@ -625,6 +826,7 @@ function renderQuery(payload) {
625
826
  return `${lines.join('\n')}\n`;
626
827
  }
627
828
 
829
+ /** @param {*} payload */
628
830
  function renderPath(payload) {
629
831
  const lines = [
630
832
  `Graph: ${payload.graph}`,
@@ -647,6 +849,7 @@ const ANSI_RED = '\x1b[31m';
647
849
  const ANSI_DIM = '\x1b[2m';
648
850
  const ANSI_RESET = '\x1b[0m';
649
851
 
852
+ /** @param {string} state */
650
853
  function colorCachedState(state) {
651
854
  if (state === 'fresh') {
652
855
  return `${ANSI_GREEN}${state}${ANSI_RESET}`;
@@ -657,6 +860,7 @@ function colorCachedState(state) {
657
860
  return `${ANSI_RED}${ANSI_DIM}${state}${ANSI_RESET}`;
658
861
  }
659
862
 
863
+ /** @param {*} payload */
660
864
  function renderCheck(payload) {
661
865
  const lines = [
662
866
  `Graph: ${payload.graph}`,
@@ -707,6 +911,7 @@ function renderCheck(payload) {
707
911
  return `${lines.join('\n')}\n`;
708
912
  }
709
913
 
914
+ /** @param {*} hook */
710
915
  function formatHookStatusLine(hook) {
711
916
  if (!hook.installed && hook.foreign) {
712
917
  return "Hook: foreign hook present — run 'git warp install-hooks'";
@@ -720,6 +925,7 @@ function formatHookStatusLine(hook) {
720
925
  return `Hook: installed (v${hook.version}) — upgrade available, run 'git warp install-hooks'`;
721
926
  }
722
927
 
928
+ /** @param {*} payload */
723
929
  function renderHistory(payload) {
724
930
  const lines = [
725
931
  `Graph: ${payload.graph}`,
@@ -738,10 +944,28 @@ function renderHistory(payload) {
738
944
  return `${lines.join('\n')}\n`;
739
945
  }
740
946
 
947
+ /** @param {*} payload */
741
948
  function renderError(payload) {
742
949
  return `Error: ${payload.error.message}\n`;
743
950
  }
744
951
 
952
+ /**
953
+ * Wraps SVG content in a minimal HTML document and writes it to disk.
954
+ * @param {string} filePath - Destination file path
955
+ * @param {string} svgContent - SVG markup to embed
956
+ */
957
+ function writeHtmlExport(filePath, svgContent) {
958
+ const html = `<!DOCTYPE html>\n<html><head><meta charset="utf-8"><title>git-warp</title></head><body>\n${svgContent}\n</body></html>`;
959
+ fs.writeFileSync(filePath, html);
960
+ }
961
+
962
+ /**
963
+ * Writes a command result to stdout/stderr in the appropriate format.
964
+ * Dispatches to JSON, SVG file, HTML file, ASCII view, or plain text
965
+ * based on the combination of flags.
966
+ * @param {*} payload - Command result payload
967
+ * @param {{json: boolean, command: string, view: string|null}} options
968
+ */
745
969
  function emit(payload, { json, command, view }) {
746
970
  if (json) {
747
971
  process.stdout.write(`${stableStringify(payload)}\n`);
@@ -766,6 +990,14 @@ function emit(payload, { json, command, view }) {
766
990
  fs.writeFileSync(svgPath, payload._renderedSvg);
767
991
  process.stderr.write(`SVG written to ${svgPath}\n`);
768
992
  }
993
+ } else if (view && typeof view === 'string' && view.startsWith('html:')) {
994
+ const htmlPath = view.slice(5);
995
+ if (!payload._renderedSvg) {
996
+ process.stderr.write('No graph data — skipping HTML export.\n');
997
+ } else {
998
+ writeHtmlExport(htmlPath, payload._renderedSvg);
999
+ process.stderr.write(`HTML written to ${htmlPath}\n`);
1000
+ }
769
1001
  } else if (view) {
770
1002
  process.stdout.write(`${payload._renderedAscii}\n`);
771
1003
  } else {
@@ -783,6 +1015,14 @@ function emit(payload, { json, command, view }) {
783
1015
  fs.writeFileSync(svgPath, payload._renderedSvg);
784
1016
  process.stderr.write(`SVG written to ${svgPath}\n`);
785
1017
  }
1018
+ } else if (view && typeof view === 'string' && view.startsWith('html:')) {
1019
+ const htmlPath = view.slice(5);
1020
+ if (!payload._renderedSvg) {
1021
+ process.stderr.write('No path found — skipping HTML export.\n');
1022
+ } else {
1023
+ writeHtmlExport(htmlPath, payload._renderedSvg);
1024
+ process.stderr.write(`HTML written to ${htmlPath}\n`);
1025
+ }
786
1026
  } else if (view) {
787
1027
  process.stdout.write(renderPathView(payload));
788
1028
  } else {
@@ -818,6 +1058,15 @@ function emit(payload, { json, command, view }) {
818
1058
  return;
819
1059
  }
820
1060
 
1061
+ if (command === 'seek') {
1062
+ if (view) {
1063
+ process.stdout.write(renderSeekView(payload));
1064
+ } else {
1065
+ process.stdout.write(renderSeek(payload));
1066
+ }
1067
+ return;
1068
+ }
1069
+
821
1070
  if (command === 'install-hooks') {
822
1071
  process.stdout.write(renderInstallHooks(payload));
823
1072
  return;
@@ -833,9 +1082,8 @@ function emit(payload, { json, command, view }) {
833
1082
 
834
1083
  /**
835
1084
  * Handles the `info` command: summarizes graphs in the repository.
836
- * @param {Object} params
837
- * @param {Object} params.options - Parsed CLI options
838
- * @returns {Promise<{repo: string, graphs: Object[]}>} Info payload
1085
+ * @param {{options: CliOptions}} params
1086
+ * @returns {Promise<{repo: string, graphs: GraphInfoResult[]}>} Info payload
839
1087
  * @throws {CliError} If the specified graph is not found
840
1088
  */
841
1089
  async function handleInfo({ options }) {
@@ -859,12 +1107,19 @@ async function handleInfo({ options }) {
859
1107
  const graphs = [];
860
1108
  for (const name of graphNames) {
861
1109
  const includeDetails = detailGraphs.has(name);
862
- graphs.push(await getGraphInfo(persistence, name, {
1110
+ const info = await getGraphInfo(persistence, name, {
863
1111
  includeWriterIds: includeDetails || isViewMode,
864
1112
  includeRefs: includeDetails || isViewMode,
865
1113
  includeWriterPatches: isViewMode,
866
1114
  includeCheckpointDate: isViewMode,
867
- }));
1115
+ });
1116
+ const activeCursor = await readActiveCursor(persistence, name);
1117
+ if (activeCursor) {
1118
+ info.cursor = { active: true, tick: activeCursor.tick, mode: activeCursor.mode };
1119
+ } else {
1120
+ info.cursor = { active: false };
1121
+ }
1122
+ graphs.push(info);
868
1123
  }
869
1124
 
870
1125
  return {
@@ -875,15 +1130,15 @@ async function handleInfo({ options }) {
875
1130
 
876
1131
  /**
877
1132
  * Handles the `query` command: runs a logical graph query.
878
- * @param {Object} params
879
- * @param {Object} params.options - Parsed CLI options
880
- * @param {string[]} params.args - Remaining positional arguments (query spec)
881
- * @returns {Promise<{payload: Object, exitCode: number}>} Query result payload
1133
+ * @param {{options: CliOptions, args: string[]}} params
1134
+ * @returns {Promise<{payload: *, exitCode: number}>} Query result payload
882
1135
  * @throws {CliError} On invalid query options or query execution errors
883
1136
  */
884
1137
  async function handleQuery({ options, args }) {
885
1138
  const querySpec = parseQueryArgs(args);
886
- const { graph, graphName } = await openGraph(options);
1139
+ const { graph, graphName, persistence } = await openGraph(options);
1140
+ const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
1141
+ emitCursorWarning(cursorInfo, null);
887
1142
  let builder = graph.query();
888
1143
 
889
1144
  if (querySpec.match !== null) {
@@ -904,7 +1159,7 @@ async function handleQuery({ options, args }) {
904
1159
  const edges = await graph.getEdges();
905
1160
  const graphData = queryResultToGraphData(payload, edges);
906
1161
  const positioned = await layoutGraph(graphData, { type: 'query' });
907
- if (typeof options.view === 'string' && options.view.startsWith('svg:')) {
1162
+ if (typeof options.view === 'string' && (options.view.startsWith('svg:') || options.view.startsWith('html:'))) {
908
1163
  payload._renderedSvg = renderSvg(positioned, { title: `${graphName} query` });
909
1164
  } else {
910
1165
  payload._renderedAscii = renderGraphView(positioned, { title: `QUERY: ${graphName}` });
@@ -920,6 +1175,10 @@ async function handleQuery({ options, args }) {
920
1175
  }
921
1176
  }
922
1177
 
1178
+ /**
1179
+ * @param {*} builder
1180
+ * @param {Array<{type: string, label?: string, key?: string, value?: string}>} steps
1181
+ */
923
1182
  function applyQuerySteps(builder, steps) {
924
1183
  let current = builder;
925
1184
  for (const step of steps) {
@@ -928,6 +1187,10 @@ function applyQuerySteps(builder, steps) {
928
1187
  return current;
929
1188
  }
930
1189
 
1190
+ /**
1191
+ * @param {*} builder
1192
+ * @param {{type: string, label?: string, key?: string, value?: string}} step
1193
+ */
931
1194
  function applyQueryStep(builder, step) {
932
1195
  if (step.type === 'outgoing') {
933
1196
  return builder.outgoing(step.label);
@@ -936,11 +1199,16 @@ function applyQueryStep(builder, step) {
936
1199
  return builder.incoming(step.label);
937
1200
  }
938
1201
  if (step.type === 'where-prop') {
939
- return builder.where((node) => matchesPropFilter(node, step.key, step.value));
1202
+ return builder.where((/** @type {*} */ node) => matchesPropFilter(node, /** @type {string} */ (step.key), /** @type {string} */ (step.value))); // TODO(ts-cleanup): type CLI payload
940
1203
  }
941
1204
  return builder;
942
1205
  }
943
1206
 
1207
+ /**
1208
+ * @param {*} node
1209
+ * @param {string} key
1210
+ * @param {string} value
1211
+ */
944
1212
  function matchesPropFilter(node, key, value) {
945
1213
  const props = node.props || {};
946
1214
  if (!Object.prototype.hasOwnProperty.call(props, key)) {
@@ -949,6 +1217,11 @@ function matchesPropFilter(node, key, value) {
949
1217
  return String(props[key]) === value;
950
1218
  }
951
1219
 
1220
+ /**
1221
+ * @param {string} graphName
1222
+ * @param {*} result
1223
+ * @returns {{graph: string, stateHash: *, nodes: *, _renderedSvg?: string, _renderedAscii?: string}}
1224
+ */
952
1225
  function buildQueryPayload(graphName, result) {
953
1226
  return {
954
1227
  graph: graphName,
@@ -957,6 +1230,7 @@ function buildQueryPayload(graphName, result) {
957
1230
  };
958
1231
  }
959
1232
 
1233
+ /** @param {*} error */
960
1234
  function mapQueryError(error) {
961
1235
  if (error && error.code && String(error.code).startsWith('E_QUERY')) {
962
1236
  throw usageError(error.message);
@@ -966,15 +1240,15 @@ function mapQueryError(error) {
966
1240
 
967
1241
  /**
968
1242
  * Handles the `path` command: finds a shortest path between two nodes.
969
- * @param {Object} params
970
- * @param {Object} params.options - Parsed CLI options
971
- * @param {string[]} params.args - Remaining positional arguments (path spec)
972
- * @returns {Promise<{payload: Object, exitCode: number}>} Path result payload
1243
+ * @param {{options: CliOptions, args: string[]}} params
1244
+ * @returns {Promise<{payload: *, exitCode: number}>} Path result payload
973
1245
  * @throws {CliError} If --from/--to are missing or a node is not found
974
1246
  */
975
1247
  async function handlePath({ options, args }) {
976
1248
  const pathOptions = parsePathArgs(args);
977
- const { graph, graphName } = await openGraph(options);
1249
+ const { graph, graphName, persistence } = await openGraph(options);
1250
+ const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
1251
+ emitCursorWarning(cursorInfo, null);
978
1252
 
979
1253
  try {
980
1254
  const result = await graph.traverse.shortestPath(
@@ -994,7 +1268,7 @@ async function handlePath({ options, args }) {
994
1268
  ...result,
995
1269
  };
996
1270
 
997
- if (options.view && result.found && typeof options.view === 'string' && options.view.startsWith('svg:')) {
1271
+ if (options.view && result.found && typeof options.view === 'string' && (options.view.startsWith('svg:') || options.view.startsWith('html:'))) {
998
1272
  const graphData = pathResultToGraphData(payload);
999
1273
  const positioned = await layoutGraph(graphData, { type: 'path' });
1000
1274
  payload._renderedSvg = renderSvg(positioned, { title: `${graphName} path` });
@@ -1004,7 +1278,7 @@ async function handlePath({ options, args }) {
1004
1278
  payload,
1005
1279
  exitCode: result.found ? EXIT_CODES.OK : EXIT_CODES.NOT_FOUND,
1006
1280
  };
1007
- } catch (error) {
1281
+ } catch (/** @type {*} */ error) { // TODO(ts-cleanup): type error
1008
1282
  if (error && error.code === 'NODE_NOT_FOUND') {
1009
1283
  throw notFoundError(error.message);
1010
1284
  }
@@ -1014,12 +1288,13 @@ async function handlePath({ options, args }) {
1014
1288
 
1015
1289
  /**
1016
1290
  * Handles the `check` command: reports graph health, GC, and hook status.
1017
- * @param {Object} params
1018
- * @param {Object} params.options - Parsed CLI options
1019
- * @returns {Promise<{payload: Object, exitCode: number}>} Health check payload
1291
+ * @param {{options: CliOptions}} params
1292
+ * @returns {Promise<{payload: *, exitCode: number}>} Health check payload
1020
1293
  */
1021
1294
  async function handleCheck({ options }) {
1022
1295
  const { graph, graphName, persistence } = await openGraph(options);
1296
+ const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
1297
+ emitCursorWarning(cursorInfo, null);
1023
1298
  const health = await getHealth(persistence);
1024
1299
  const gcMetrics = await getGcMetrics(graph);
1025
1300
  const status = await graph.status();
@@ -1044,17 +1319,20 @@ async function handleCheck({ options }) {
1044
1319
  };
1045
1320
  }
1046
1321
 
1322
+ /** @param {Persistence} persistence */
1047
1323
  async function getHealth(persistence) {
1048
1324
  const clock = ClockAdapter.node();
1049
- const healthService = new HealthCheckService({ persistence, clock });
1325
+ const healthService = new HealthCheckService({ persistence: /** @type {*} */ (persistence), clock }); // TODO(ts-cleanup): narrow port type
1050
1326
  return await healthService.getHealth();
1051
1327
  }
1052
1328
 
1329
+ /** @param {WarpGraphInstance} graph */
1053
1330
  async function getGcMetrics(graph) {
1054
1331
  await graph.materialize();
1055
1332
  return graph.getGCMetrics();
1056
1333
  }
1057
1334
 
1335
+ /** @param {WarpGraphInstance} graph */
1058
1336
  async function collectWriterHeads(graph) {
1059
1337
  const frontier = await graph.getFrontier();
1060
1338
  return [...frontier.entries()]
@@ -1062,6 +1340,10 @@ async function collectWriterHeads(graph) {
1062
1340
  .map(([writerId, sha]) => ({ writerId, sha }));
1063
1341
  }
1064
1342
 
1343
+ /**
1344
+ * @param {Persistence} persistence
1345
+ * @param {string} graphName
1346
+ */
1065
1347
  async function loadCheckpointInfo(persistence, graphName) {
1066
1348
  const checkpointRef = buildCheckpointRef(graphName);
1067
1349
  const checkpointSha = await persistence.readRef(checkpointRef);
@@ -1076,6 +1358,10 @@ async function loadCheckpointInfo(persistence, graphName) {
1076
1358
  };
1077
1359
  }
1078
1360
 
1361
+ /**
1362
+ * @param {Persistence} persistence
1363
+ * @param {string|null} checkpointSha
1364
+ */
1079
1365
  async function readCheckpointDate(persistence, checkpointSha) {
1080
1366
  if (!checkpointSha) {
1081
1367
  return null;
@@ -1084,6 +1370,7 @@ async function readCheckpointDate(persistence, checkpointSha) {
1084
1370
  return info.date || null;
1085
1371
  }
1086
1372
 
1373
+ /** @param {string|null} checkpointDate */
1087
1374
  function computeAgeSeconds(checkpointDate) {
1088
1375
  if (!checkpointDate) {
1089
1376
  return null;
@@ -1095,6 +1382,11 @@ function computeAgeSeconds(checkpointDate) {
1095
1382
  return Math.max(0, Math.floor((Date.now() - parsed) / 1000));
1096
1383
  }
1097
1384
 
1385
+ /**
1386
+ * @param {Persistence} persistence
1387
+ * @param {string} graphName
1388
+ * @param {Array<{writerId: string, sha: string}>} writerHeads
1389
+ */
1098
1390
  async function loadCoverageInfo(persistence, graphName, writerHeads) {
1099
1391
  const coverageRef = buildCoverageRef(graphName);
1100
1392
  const coverageSha = await persistence.readRef(coverageRef);
@@ -1109,6 +1401,11 @@ async function loadCoverageInfo(persistence, graphName, writerHeads) {
1109
1401
  };
1110
1402
  }
1111
1403
 
1404
+ /**
1405
+ * @param {Persistence} persistence
1406
+ * @param {Array<{writerId: string, sha: string}>} writerHeads
1407
+ * @param {string} coverageSha
1408
+ */
1112
1409
  async function findMissingWriters(persistence, writerHeads, coverageSha) {
1113
1410
  const missing = [];
1114
1411
  for (const head of writerHeads) {
@@ -1120,6 +1417,9 @@ async function findMissingWriters(persistence, writerHeads, coverageSha) {
1120
1417
  return missing;
1121
1418
  }
1122
1419
 
1420
+ /**
1421
+ * @param {{repo: string, graphName: string, health: *, checkpoint: *, writerHeads: Array<{writerId: string, sha: string}>, coverage: *, gcMetrics: *, hook: *|null, status: *|null}} params
1422
+ */
1123
1423
  function buildCheckPayload({
1124
1424
  repo,
1125
1425
  graphName,
@@ -1149,24 +1449,28 @@ function buildCheckPayload({
1149
1449
 
1150
1450
  /**
1151
1451
  * Handles the `history` command: shows patch history for a writer.
1152
- * @param {Object} params
1153
- * @param {Object} params.options - Parsed CLI options
1154
- * @param {string[]} params.args - Remaining positional arguments (history options)
1155
- * @returns {Promise<{payload: Object, exitCode: number}>} History payload
1452
+ * @param {{options: CliOptions, args: string[]}} params
1453
+ * @returns {Promise<{payload: *, exitCode: number}>} History payload
1156
1454
  * @throws {CliError} If no patches are found for the writer
1157
1455
  */
1158
1456
  async function handleHistory({ options, args }) {
1159
1457
  const historyOptions = parseHistoryArgs(args);
1160
- const { graph, graphName } = await openGraph(options);
1458
+ const { graph, graphName, persistence } = await openGraph(options);
1459
+ const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
1460
+ emitCursorWarning(cursorInfo, null);
1461
+
1161
1462
  const writerId = options.writer;
1162
- const patches = await graph.getWriterPatches(writerId);
1463
+ let patches = await graph.getWriterPatches(writerId);
1464
+ if (cursorInfo.active) {
1465
+ patches = patches.filter((/** @type {*} */ { patch }) => patch.lamport <= /** @type {number} */ (cursorInfo.tick)); // TODO(ts-cleanup): type CLI payload
1466
+ }
1163
1467
  if (patches.length === 0) {
1164
1468
  throw notFoundError(`No patches found for writer: ${writerId}`);
1165
1469
  }
1166
1470
 
1167
1471
  const entries = patches
1168
- .filter(({ patch }) => !historyOptions.node || patchTouchesNode(patch, historyOptions.node))
1169
- .map(({ patch, sha }) => ({
1472
+ .filter((/** @type {*} */ { patch }) => !historyOptions.node || patchTouchesNode(patch, historyOptions.node)) // TODO(ts-cleanup): type CLI payload
1473
+ .map((/** @type {*} */ { patch, sha }) => ({ // TODO(ts-cleanup): type CLI payload
1170
1474
  sha,
1171
1475
  schema: patch.schema,
1172
1476
  lamport: patch.lamport,
@@ -1184,15 +1488,23 @@ async function handleHistory({ options, args }) {
1184
1488
  return { payload, exitCode: EXIT_CODES.OK };
1185
1489
  }
1186
1490
 
1187
- async function materializeOneGraph({ persistence, graphName, writerId }) {
1188
- const graph = await WarpGraph.open({ persistence, graphName, writerId });
1189
- await graph.materialize();
1491
+ /**
1492
+ * Materializes a single graph, creates a checkpoint, and returns summary stats.
1493
+ * When a ceiling tick is provided (seek cursor active), the checkpoint step is
1494
+ * skipped because the user is exploring historical state, not persisting it.
1495
+ * @param {{persistence: Persistence, graphName: string, writerId: string, ceiling?: number}} params
1496
+ * @returns {Promise<{graph: string, nodes: number, edges: number, properties: number, checkpoint: string|null, writers: Record<string, number>, patchCount: number}>}
1497
+ */
1498
+ async function materializeOneGraph({ persistence, graphName, writerId, ceiling }) {
1499
+ const graph = await WarpGraph.open({ persistence, graphName, writerId, crypto: new NodeCryptoAdapter() });
1500
+ await graph.materialize(ceiling !== undefined ? { ceiling } : undefined);
1190
1501
  const nodes = await graph.getNodes();
1191
1502
  const edges = await graph.getEdges();
1192
- const checkpoint = await graph.createCheckpoint();
1503
+ const checkpoint = ceiling !== undefined ? null : await graph.createCheckpoint();
1193
1504
  const status = await graph.status();
1194
1505
 
1195
1506
  // Build per-writer patch counts for the view renderer
1507
+ /** @type {Record<string, number>} */
1196
1508
  const writers = {};
1197
1509
  let totalPatchCount = 0;
1198
1510
  for (const wId of Object.keys(status.frontier)) {
@@ -1216,9 +1528,8 @@ async function materializeOneGraph({ persistence, graphName, writerId }) {
1216
1528
 
1217
1529
  /**
1218
1530
  * Handles the `materialize` command: materializes and checkpoints all graphs.
1219
- * @param {Object} params
1220
- * @param {Object} params.options - Parsed CLI options
1221
- * @returns {Promise<{payload: Object, exitCode: number}>} Materialize result payload
1531
+ * @param {{options: CliOptions}} params
1532
+ * @returns {Promise<{payload: *, exitCode: number}>} Materialize result payload
1222
1533
  * @throws {CliError} If the specified graph is not found
1223
1534
  */
1224
1535
  async function handleMaterialize({ options }) {
@@ -1241,12 +1552,20 @@ async function handleMaterialize({ options }) {
1241
1552
  }
1242
1553
 
1243
1554
  const results = [];
1555
+ let cursorWarningEmitted = false;
1244
1556
  for (const name of targets) {
1245
1557
  try {
1558
+ const cursor = await readActiveCursor(persistence, name);
1559
+ const ceiling = cursor ? cursor.tick : undefined;
1560
+ if (cursor && !cursorWarningEmitted) {
1561
+ emitCursorWarning({ active: true, tick: cursor.tick, maxTick: null }, null);
1562
+ cursorWarningEmitted = true;
1563
+ }
1246
1564
  const result = await materializeOneGraph({
1247
1565
  persistence,
1248
1566
  graphName: name,
1249
1567
  writerId: options.writer,
1568
+ ceiling,
1250
1569
  });
1251
1570
  results.push(result);
1252
1571
  } catch (error) {
@@ -1257,13 +1576,14 @@ async function handleMaterialize({ options }) {
1257
1576
  }
1258
1577
  }
1259
1578
 
1260
- const allFailed = results.every((r) => r.error);
1579
+ const allFailed = results.every((r) => /** @type {*} */ (r).error); // TODO(ts-cleanup): type CLI payload
1261
1580
  return {
1262
1581
  payload: { graphs: results },
1263
1582
  exitCode: allFailed ? EXIT_CODES.INTERNAL : EXIT_CODES.OK,
1264
1583
  };
1265
1584
  }
1266
1585
 
1586
+ /** @param {*} payload */
1267
1587
  function renderMaterialize(payload) {
1268
1588
  if (payload.graphs.length === 0) {
1269
1589
  return 'No graphs found in repo.\n';
@@ -1280,6 +1600,7 @@ function renderMaterialize(payload) {
1280
1600
  return `${lines.join('\n')}\n`;
1281
1601
  }
1282
1602
 
1603
+ /** @param {*} payload */
1283
1604
  function renderInstallHooks(payload) {
1284
1605
  if (payload.action === 'up-to-date') {
1285
1606
  return `Hook: already up to date (v${payload.version}) at ${payload.hookPath}\n`;
@@ -1300,7 +1621,7 @@ function createHookInstaller() {
1300
1621
  const templateDir = path.resolve(__dirname, '..', 'hooks');
1301
1622
  const { version } = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8'));
1302
1623
  return new HookInstaller({
1303
- fs,
1624
+ fs: /** @type {*} */ (fs), // TODO(ts-cleanup): narrow port type
1304
1625
  execGitConfig: execGitConfigValue,
1305
1626
  version,
1306
1627
  templateDir,
@@ -1308,6 +1629,11 @@ function createHookInstaller() {
1308
1629
  });
1309
1630
  }
1310
1631
 
1632
+ /**
1633
+ * @param {string} repoPath
1634
+ * @param {string} key
1635
+ * @returns {string|null}
1636
+ */
1311
1637
  function execGitConfigValue(repoPath, key) {
1312
1638
  try {
1313
1639
  if (key === '--git-dir') {
@@ -1327,6 +1653,7 @@ function isInteractive() {
1327
1653
  return Boolean(process.stderr.isTTY);
1328
1654
  }
1329
1655
 
1656
+ /** @param {string} question @returns {Promise<string>} */
1330
1657
  function promptUser(question) {
1331
1658
  const rl = readline.createInterface({
1332
1659
  input: process.stdin,
@@ -1340,6 +1667,7 @@ function promptUser(question) {
1340
1667
  });
1341
1668
  }
1342
1669
 
1670
+ /** @param {string[]} args */
1343
1671
  function parseInstallHooksArgs(args) {
1344
1672
  const options = { force: false };
1345
1673
  for (const arg of args) {
@@ -1352,6 +1680,10 @@ function parseInstallHooksArgs(args) {
1352
1680
  return options;
1353
1681
  }
1354
1682
 
1683
+ /**
1684
+ * @param {*} classification
1685
+ * @param {{force: boolean}} hookOptions
1686
+ */
1355
1687
  async function resolveStrategy(classification, hookOptions) {
1356
1688
  if (hookOptions.force) {
1357
1689
  return 'replace';
@@ -1368,6 +1700,7 @@ async function resolveStrategy(classification, hookOptions) {
1368
1700
  return await promptForForeignStrategy();
1369
1701
  }
1370
1702
 
1703
+ /** @param {*} classification */
1371
1704
  async function promptForOursStrategy(classification) {
1372
1705
  const installer = createHookInstaller();
1373
1706
  if (classification.version === installer._version) {
@@ -1409,10 +1742,8 @@ async function promptForForeignStrategy() {
1409
1742
 
1410
1743
  /**
1411
1744
  * Handles the `install-hooks` command: installs or upgrades the post-merge git hook.
1412
- * @param {Object} params
1413
- * @param {Object} params.options - Parsed CLI options
1414
- * @param {string[]} params.args - Remaining positional arguments (install-hooks options)
1415
- * @returns {Promise<{payload: Object, exitCode: number}>} Install result payload
1745
+ * @param {{options: CliOptions, args: string[]}} params
1746
+ * @returns {Promise<{payload: *, exitCode: number}>} Install result payload
1416
1747
  * @throws {CliError} If an existing hook is found and the session is not interactive
1417
1748
  */
1418
1749
  async function handleInstallHooks({ options, args }) {
@@ -1448,6 +1779,7 @@ async function handleInstallHooks({ options, args }) {
1448
1779
  };
1449
1780
  }
1450
1781
 
1782
+ /** @param {string} hookPath */
1451
1783
  function readHookContent(hookPath) {
1452
1784
  try {
1453
1785
  return fs.readFileSync(hookPath, 'utf8');
@@ -1456,6 +1788,7 @@ function readHookContent(hookPath) {
1456
1788
  }
1457
1789
  }
1458
1790
 
1791
+ /** @param {string} repoPath */
1459
1792
  function getHookStatusForCheck(repoPath) {
1460
1793
  try {
1461
1794
  const installer = createHookInstaller();
@@ -1465,6 +1798,850 @@ function getHookStatusForCheck(repoPath) {
1465
1798
  }
1466
1799
  }
1467
1800
 
1801
+ // ============================================================================
1802
+ // Cursor I/O Helpers
1803
+ // ============================================================================
1804
+
1805
+ /**
1806
+ * Reads the active seek cursor for a graph from Git ref storage.
1807
+ *
1808
+ * @param {Persistence} persistence - GraphPersistencePort adapter
1809
+ * @param {string} graphName - Name of the WARP graph
1810
+ * @returns {Promise<CursorBlob|null>} Cursor object, or null if no active cursor
1811
+ * @throws {Error} If the stored blob is corrupted or not valid JSON
1812
+ */
1813
+ async function readActiveCursor(persistence, graphName) {
1814
+ const ref = buildCursorActiveRef(graphName);
1815
+ const oid = await persistence.readRef(ref);
1816
+ if (!oid) {
1817
+ return null;
1818
+ }
1819
+ const buf = await persistence.readBlob(oid);
1820
+ return parseCursorBlob(buf, 'active cursor');
1821
+ }
1822
+
1823
+ /**
1824
+ * Writes (creates or overwrites) the active seek cursor for a graph.
1825
+ *
1826
+ * Serializes the cursor as JSON, stores it as a Git blob, and points
1827
+ * the active cursor ref at that blob.
1828
+ *
1829
+ * @param {Persistence} persistence - GraphPersistencePort adapter
1830
+ * @param {string} graphName - Name of the WARP graph
1831
+ * @param {CursorBlob} cursor - Cursor state to persist
1832
+ * @returns {Promise<void>}
1833
+ */
1834
+ async function writeActiveCursor(persistence, graphName, cursor) {
1835
+ const ref = buildCursorActiveRef(graphName);
1836
+ const json = JSON.stringify(cursor);
1837
+ const oid = await persistence.writeBlob(Buffer.from(json, 'utf8'));
1838
+ await persistence.updateRef(ref, oid);
1839
+ }
1840
+
1841
+ /**
1842
+ * Removes the active seek cursor for a graph, returning to present state.
1843
+ *
1844
+ * No-op if no active cursor exists.
1845
+ *
1846
+ * @param {Persistence} persistence - GraphPersistencePort adapter
1847
+ * @param {string} graphName - Name of the WARP graph
1848
+ * @returns {Promise<void>}
1849
+ */
1850
+ async function clearActiveCursor(persistence, graphName) {
1851
+ const ref = buildCursorActiveRef(graphName);
1852
+ const exists = await persistence.readRef(ref);
1853
+ if (exists) {
1854
+ await persistence.deleteRef(ref);
1855
+ }
1856
+ }
1857
+
1858
+ /**
1859
+ * Reads a named saved cursor from Git ref storage.
1860
+ *
1861
+ * @param {Persistence} persistence - GraphPersistencePort adapter
1862
+ * @param {string} graphName - Name of the WARP graph
1863
+ * @param {string} name - Saved cursor name
1864
+ * @returns {Promise<CursorBlob|null>} Cursor object, or null if not found
1865
+ * @throws {Error} If the stored blob is corrupted or not valid JSON
1866
+ */
1867
+ async function readSavedCursor(persistence, graphName, name) {
1868
+ const ref = buildCursorSavedRef(graphName, name);
1869
+ const oid = await persistence.readRef(ref);
1870
+ if (!oid) {
1871
+ return null;
1872
+ }
1873
+ const buf = await persistence.readBlob(oid);
1874
+ return parseCursorBlob(buf, `saved cursor '${name}'`);
1875
+ }
1876
+
1877
+ /**
1878
+ * Persists a cursor under a named saved-cursor ref.
1879
+ *
1880
+ * Serializes the cursor as JSON, stores it as a Git blob, and points
1881
+ * the named saved-cursor ref at that blob.
1882
+ *
1883
+ * @param {Persistence} persistence - GraphPersistencePort adapter
1884
+ * @param {string} graphName - Name of the WARP graph
1885
+ * @param {string} name - Saved cursor name
1886
+ * @param {CursorBlob} cursor - Cursor state to persist
1887
+ * @returns {Promise<void>}
1888
+ */
1889
+ async function writeSavedCursor(persistence, graphName, name, cursor) {
1890
+ const ref = buildCursorSavedRef(graphName, name);
1891
+ const json = JSON.stringify(cursor);
1892
+ const oid = await persistence.writeBlob(Buffer.from(json, 'utf8'));
1893
+ await persistence.updateRef(ref, oid);
1894
+ }
1895
+
1896
+ /**
1897
+ * Deletes a named saved cursor from Git ref storage.
1898
+ *
1899
+ * No-op if the named cursor does not exist.
1900
+ *
1901
+ * @param {Persistence} persistence - GraphPersistencePort adapter
1902
+ * @param {string} graphName - Name of the WARP graph
1903
+ * @param {string} name - Saved cursor name to delete
1904
+ * @returns {Promise<void>}
1905
+ */
1906
+ async function deleteSavedCursor(persistence, graphName, name) {
1907
+ const ref = buildCursorSavedRef(graphName, name);
1908
+ const exists = await persistence.readRef(ref);
1909
+ if (exists) {
1910
+ await persistence.deleteRef(ref);
1911
+ }
1912
+ }
1913
+
1914
+ /**
1915
+ * Lists all saved cursors for a graph, reading each blob to include full cursor state.
1916
+ *
1917
+ * @param {Persistence} persistence - GraphPersistencePort adapter
1918
+ * @param {string} graphName - Name of the WARP graph
1919
+ * @returns {Promise<Array<{name: string, tick: number, mode?: string}>>} Array of saved cursors with their names
1920
+ * @throws {Error} If any stored blob is corrupted or not valid JSON
1921
+ */
1922
+ async function listSavedCursors(persistence, graphName) {
1923
+ const prefix = buildCursorSavedPrefix(graphName);
1924
+ const refs = await persistence.listRefs(prefix);
1925
+ const cursors = [];
1926
+ for (const ref of refs) {
1927
+ const name = ref.slice(prefix.length);
1928
+ if (name) {
1929
+ const oid = await persistence.readRef(ref);
1930
+ if (oid) {
1931
+ const buf = await persistence.readBlob(oid);
1932
+ const cursor = parseCursorBlob(buf, `saved cursor '${name}'`);
1933
+ cursors.push({ name, ...cursor });
1934
+ }
1935
+ }
1936
+ }
1937
+ return cursors;
1938
+ }
1939
+
1940
+ // ============================================================================
1941
+ // Seek Arg Parser
1942
+ // ============================================================================
1943
+
1944
+ /**
1945
+ * @param {string} arg
1946
+ * @param {SeekSpec} spec
1947
+ */
1948
+ function handleSeekBooleanFlag(arg, spec) {
1949
+ if (arg === '--clear-cache') {
1950
+ if (spec.action !== 'status') {
1951
+ throw usageError('--clear-cache cannot be combined with other seek flags');
1952
+ }
1953
+ spec.action = 'clear-cache';
1954
+ } else if (arg === '--no-persistent-cache') {
1955
+ spec.noPersistentCache = true;
1956
+ }
1957
+ }
1958
+
1959
+ /**
1960
+ * Parses CLI arguments for the `seek` command into a structured spec.
1961
+ * @param {string[]} args - Raw CLI arguments following the `seek` subcommand
1962
+ * @returns {SeekSpec} Parsed spec
1963
+ */
1964
+ function parseSeekArgs(args) {
1965
+ /** @type {SeekSpec} */
1966
+ const spec = {
1967
+ action: 'status', // status, tick, latest, save, load, list, drop, clear-cache
1968
+ tickValue: null,
1969
+ name: null,
1970
+ noPersistentCache: false,
1971
+ };
1972
+
1973
+ for (let i = 0; i < args.length; i++) {
1974
+ const arg = args[i];
1975
+
1976
+ if (arg === '--tick') {
1977
+ if (spec.action !== 'status') {
1978
+ throw usageError('--tick cannot be combined with other seek flags');
1979
+ }
1980
+ spec.action = 'tick';
1981
+ const val = args[i + 1];
1982
+ if (val === undefined) {
1983
+ throw usageError('Missing value for --tick');
1984
+ }
1985
+ spec.tickValue = val;
1986
+ i += 1;
1987
+ } else if (arg.startsWith('--tick=')) {
1988
+ if (spec.action !== 'status') {
1989
+ throw usageError('--tick cannot be combined with other seek flags');
1990
+ }
1991
+ spec.action = 'tick';
1992
+ spec.tickValue = arg.slice('--tick='.length);
1993
+ } else if (arg === '--latest') {
1994
+ if (spec.action !== 'status') {
1995
+ throw usageError('--latest cannot be combined with other seek flags');
1996
+ }
1997
+ spec.action = 'latest';
1998
+ } else if (arg === '--save') {
1999
+ if (spec.action !== 'status') {
2000
+ throw usageError('--save cannot be combined with other seek flags');
2001
+ }
2002
+ spec.action = 'save';
2003
+ const val = args[i + 1];
2004
+ if (val === undefined || val.startsWith('-')) {
2005
+ throw usageError('Missing name for --save');
2006
+ }
2007
+ spec.name = val;
2008
+ i += 1;
2009
+ } else if (arg.startsWith('--save=')) {
2010
+ if (spec.action !== 'status') {
2011
+ throw usageError('--save cannot be combined with other seek flags');
2012
+ }
2013
+ spec.action = 'save';
2014
+ spec.name = arg.slice('--save='.length);
2015
+ if (!spec.name) {
2016
+ throw usageError('Missing name for --save');
2017
+ }
2018
+ } else if (arg === '--load') {
2019
+ if (spec.action !== 'status') {
2020
+ throw usageError('--load cannot be combined with other seek flags');
2021
+ }
2022
+ spec.action = 'load';
2023
+ const val = args[i + 1];
2024
+ if (val === undefined || val.startsWith('-')) {
2025
+ throw usageError('Missing name for --load');
2026
+ }
2027
+ spec.name = val;
2028
+ i += 1;
2029
+ } else if (arg.startsWith('--load=')) {
2030
+ if (spec.action !== 'status') {
2031
+ throw usageError('--load cannot be combined with other seek flags');
2032
+ }
2033
+ spec.action = 'load';
2034
+ spec.name = arg.slice('--load='.length);
2035
+ if (!spec.name) {
2036
+ throw usageError('Missing name for --load');
2037
+ }
2038
+ } else if (arg === '--list') {
2039
+ if (spec.action !== 'status') {
2040
+ throw usageError('--list cannot be combined with other seek flags');
2041
+ }
2042
+ spec.action = 'list';
2043
+ } else if (arg === '--drop') {
2044
+ if (spec.action !== 'status') {
2045
+ throw usageError('--drop cannot be combined with other seek flags');
2046
+ }
2047
+ spec.action = 'drop';
2048
+ const val = args[i + 1];
2049
+ if (val === undefined || val.startsWith('-')) {
2050
+ throw usageError('Missing name for --drop');
2051
+ }
2052
+ spec.name = val;
2053
+ i += 1;
2054
+ } else if (arg.startsWith('--drop=')) {
2055
+ if (spec.action !== 'status') {
2056
+ throw usageError('--drop cannot be combined with other seek flags');
2057
+ }
2058
+ spec.action = 'drop';
2059
+ spec.name = arg.slice('--drop='.length);
2060
+ if (!spec.name) {
2061
+ throw usageError('Missing name for --drop');
2062
+ }
2063
+ } else if (arg === '--clear-cache' || arg === '--no-persistent-cache') {
2064
+ handleSeekBooleanFlag(arg, spec);
2065
+ } else if (arg.startsWith('-')) {
2066
+ throw usageError(`Unknown seek option: ${arg}`);
2067
+ }
2068
+ }
2069
+
2070
+ return spec;
2071
+ }
2072
+
2073
+ /**
2074
+ * Resolves a tick value (absolute or relative +N/-N) against available ticks.
2075
+ *
2076
+ * For relative values, steps through the sorted tick array (with 0 prepended
2077
+ * as a virtual "empty state" position) by the given delta from the current
2078
+ * position. For absolute values, clamps to maxTick.
2079
+ *
2080
+ * @private
2081
+ * @param {string} tickValue - Raw tick string from CLI args (e.g. "5", "+1", "-2")
2082
+ * @param {number|null} currentTick - Current cursor tick, or null if no active cursor
2083
+ * @param {number[]} ticks - Sorted ascending array of available Lamport ticks
2084
+ * @param {number} maxTick - Maximum tick across all writers
2085
+ * @returns {number} Resolved tick value (clamped to valid range)
2086
+ * @throws {CliError} If tickValue is not a valid integer or relative delta
2087
+ */
2088
+ function resolveTickValue(tickValue, currentTick, ticks, maxTick) {
2089
+ // Relative: +N or -N
2090
+ if (tickValue.startsWith('+') || tickValue.startsWith('-')) {
2091
+ const delta = parseInt(tickValue, 10);
2092
+ if (!Number.isInteger(delta)) {
2093
+ throw usageError(`Invalid tick delta: ${tickValue}`);
2094
+ }
2095
+ const base = currentTick ?? 0;
2096
+
2097
+ // Find the current position in sorted ticks, then step by delta
2098
+ // Include tick 0 as a virtual "empty state" position (avoid duplicating if already present)
2099
+ const allPoints = (ticks.length > 0 && ticks[0] === 0) ? [...ticks] : [0, ...ticks];
2100
+ const currentIdx = allPoints.indexOf(base);
2101
+ const startIdx = currentIdx === -1 ? 0 : currentIdx;
2102
+ const targetIdx = Math.max(0, Math.min(allPoints.length - 1, startIdx + delta));
2103
+ return allPoints[targetIdx];
2104
+ }
2105
+
2106
+ // Absolute
2107
+ const n = parseInt(tickValue, 10);
2108
+ if (!Number.isInteger(n) || n < 0) {
2109
+ throw usageError(`Invalid tick value: ${tickValue}. Must be a non-negative integer, or +N/-N for relative.`);
2110
+ }
2111
+
2112
+ // Clamp to maxTick
2113
+ return Math.min(n, maxTick);
2114
+ }
2115
+
2116
+ // ============================================================================
2117
+ // Seek Handler
2118
+ // ============================================================================
2119
+
2120
+ /**
2121
+ * @param {WarpGraphInstance} graph
2122
+ * @param {Persistence} persistence
2123
+ * @param {string} graphName
2124
+ * @param {SeekSpec} seekSpec
2125
+ */
2126
+ function wireSeekCache(graph, persistence, graphName, seekSpec) {
2127
+ if (seekSpec.noPersistentCache) {
2128
+ return;
2129
+ }
2130
+ graph.setSeekCache(new CasSeekCacheAdapter({
2131
+ persistence,
2132
+ plumbing: persistence.plumbing,
2133
+ graphName,
2134
+ }));
2135
+ }
2136
+
2137
+ /**
2138
+ * Handles the `git warp seek` command across all sub-actions.
2139
+ * @param {{options: CliOptions, args: string[]}} params
2140
+ * @returns {Promise<{payload: *, exitCode: number}>}
2141
+ */
2142
+ async function handleSeek({ options, args }) {
2143
+ const seekSpec = parseSeekArgs(args);
2144
+ const { graph, graphName, persistence } = await openGraph(options);
2145
+ void wireSeekCache(graph, persistence, graphName, seekSpec);
2146
+
2147
+ // Handle --clear-cache before discovering ticks (no materialization needed)
2148
+ if (seekSpec.action === 'clear-cache') {
2149
+ if (graph.seekCache) {
2150
+ await graph.seekCache.clear();
2151
+ }
2152
+ return {
2153
+ payload: { graph: graphName, action: 'clear-cache', message: 'Seek cache cleared.' },
2154
+ exitCode: EXIT_CODES.OK,
2155
+ };
2156
+ }
2157
+
2158
+ const activeCursor = await readActiveCursor(persistence, graphName);
2159
+ const { ticks, maxTick, perWriter } = await graph.discoverTicks();
2160
+ const frontierHash = computeFrontierHash(perWriter);
2161
+ if (seekSpec.action === 'list') {
2162
+ const saved = await listSavedCursors(persistence, graphName);
2163
+ return {
2164
+ payload: {
2165
+ graph: graphName,
2166
+ action: 'list',
2167
+ cursors: saved,
2168
+ activeTick: activeCursor ? activeCursor.tick : null,
2169
+ maxTick,
2170
+ },
2171
+ exitCode: EXIT_CODES.OK,
2172
+ };
2173
+ }
2174
+ if (seekSpec.action === 'drop') {
2175
+ const dropName = /** @type {string} */ (seekSpec.name);
2176
+ const existing = await readSavedCursor(persistence, graphName, dropName);
2177
+ if (!existing) {
2178
+ throw notFoundError(`Saved cursor not found: ${dropName}`);
2179
+ }
2180
+ await deleteSavedCursor(persistence, graphName, dropName);
2181
+ return {
2182
+ payload: {
2183
+ graph: graphName,
2184
+ action: 'drop',
2185
+ name: seekSpec.name,
2186
+ tick: existing.tick,
2187
+ },
2188
+ exitCode: EXIT_CODES.OK,
2189
+ };
2190
+ }
2191
+ if (seekSpec.action === 'latest') {
2192
+ await clearActiveCursor(persistence, graphName);
2193
+ await graph.materialize();
2194
+ const nodes = await graph.getNodes();
2195
+ const edges = await graph.getEdges();
2196
+ const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
2197
+ const tickReceipt = await buildTickReceipt({ tick: maxTick, perWriter, graph });
2198
+ return {
2199
+ payload: {
2200
+ graph: graphName,
2201
+ action: 'latest',
2202
+ tick: maxTick,
2203
+ maxTick,
2204
+ ticks,
2205
+ nodes: nodes.length,
2206
+ edges: edges.length,
2207
+ perWriter: serializePerWriter(perWriter),
2208
+ patchCount: countPatchesAtTick(maxTick, perWriter),
2209
+ diff,
2210
+ tickReceipt,
2211
+ cursor: { active: false },
2212
+ },
2213
+ exitCode: EXIT_CODES.OK,
2214
+ };
2215
+ }
2216
+ if (seekSpec.action === 'save') {
2217
+ if (!activeCursor) {
2218
+ throw usageError('No active cursor to save. Use --tick first.');
2219
+ }
2220
+ await writeSavedCursor(persistence, graphName, /** @type {string} */ (seekSpec.name), activeCursor);
2221
+ return {
2222
+ payload: {
2223
+ graph: graphName,
2224
+ action: 'save',
2225
+ name: seekSpec.name,
2226
+ tick: activeCursor.tick,
2227
+ },
2228
+ exitCode: EXIT_CODES.OK,
2229
+ };
2230
+ }
2231
+ if (seekSpec.action === 'load') {
2232
+ const loadName = /** @type {string} */ (seekSpec.name);
2233
+ const saved = await readSavedCursor(persistence, graphName, loadName);
2234
+ if (!saved) {
2235
+ throw notFoundError(`Saved cursor not found: ${loadName}`);
2236
+ }
2237
+ await graph.materialize({ ceiling: saved.tick });
2238
+ const nodes = await graph.getNodes();
2239
+ const edges = await graph.getEdges();
2240
+ await writeActiveCursor(persistence, graphName, { tick: saved.tick, mode: saved.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
2241
+ const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
2242
+ const tickReceipt = await buildTickReceipt({ tick: saved.tick, perWriter, graph });
2243
+ return {
2244
+ payload: {
2245
+ graph: graphName,
2246
+ action: 'load',
2247
+ name: seekSpec.name,
2248
+ tick: saved.tick,
2249
+ maxTick,
2250
+ ticks,
2251
+ nodes: nodes.length,
2252
+ edges: edges.length,
2253
+ perWriter: serializePerWriter(perWriter),
2254
+ patchCount: countPatchesAtTick(saved.tick, perWriter),
2255
+ diff,
2256
+ tickReceipt,
2257
+ cursor: { active: true, mode: saved.mode, tick: saved.tick, maxTick, name: seekSpec.name },
2258
+ },
2259
+ exitCode: EXIT_CODES.OK,
2260
+ };
2261
+ }
2262
+ if (seekSpec.action === 'tick') {
2263
+ const currentTick = activeCursor ? activeCursor.tick : null;
2264
+ const resolvedTick = resolveTickValue(/** @type {string} */ (seekSpec.tickValue), currentTick, ticks, maxTick);
2265
+ await graph.materialize({ ceiling: resolvedTick });
2266
+ const nodes = await graph.getNodes();
2267
+ const edges = await graph.getEdges();
2268
+ await writeActiveCursor(persistence, graphName, { tick: resolvedTick, mode: 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
2269
+ const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
2270
+ const tickReceipt = await buildTickReceipt({ tick: resolvedTick, perWriter, graph });
2271
+ return {
2272
+ payload: {
2273
+ graph: graphName,
2274
+ action: 'tick',
2275
+ tick: resolvedTick,
2276
+ maxTick,
2277
+ ticks,
2278
+ nodes: nodes.length,
2279
+ edges: edges.length,
2280
+ perWriter: serializePerWriter(perWriter),
2281
+ patchCount: countPatchesAtTick(resolvedTick, perWriter),
2282
+ diff,
2283
+ tickReceipt,
2284
+ cursor: { active: true, mode: 'lamport', tick: resolvedTick, maxTick, name: 'active' },
2285
+ },
2286
+ exitCode: EXIT_CODES.OK,
2287
+ };
2288
+ }
2289
+
2290
+ // status (bare seek)
2291
+ if (activeCursor) {
2292
+ await graph.materialize({ ceiling: activeCursor.tick });
2293
+ const nodes = await graph.getNodes();
2294
+ const edges = await graph.getEdges();
2295
+ const prevCounts = readSeekCounts(activeCursor);
2296
+ const prevFrontierHash = typeof activeCursor.frontierHash === 'string' ? activeCursor.frontierHash : null;
2297
+ if (prevCounts.nodes === null || prevCounts.edges === null || prevCounts.nodes !== nodes.length || prevCounts.edges !== edges.length || prevFrontierHash !== frontierHash) {
2298
+ await writeActiveCursor(persistence, graphName, { tick: activeCursor.tick, mode: activeCursor.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
2299
+ }
2300
+ const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
2301
+ const tickReceipt = await buildTickReceipt({ tick: activeCursor.tick, perWriter, graph });
2302
+ return {
2303
+ payload: {
2304
+ graph: graphName,
2305
+ action: 'status',
2306
+ tick: activeCursor.tick,
2307
+ maxTick,
2308
+ ticks,
2309
+ nodes: nodes.length,
2310
+ edges: edges.length,
2311
+ perWriter: serializePerWriter(perWriter),
2312
+ patchCount: countPatchesAtTick(activeCursor.tick, perWriter),
2313
+ diff,
2314
+ tickReceipt,
2315
+ cursor: { active: true, mode: activeCursor.mode, tick: activeCursor.tick, maxTick, name: 'active' },
2316
+ },
2317
+ exitCode: EXIT_CODES.OK,
2318
+ };
2319
+ }
2320
+ await graph.materialize();
2321
+ const nodes = await graph.getNodes();
2322
+ const edges = await graph.getEdges();
2323
+ const tickReceipt = await buildTickReceipt({ tick: maxTick, perWriter, graph });
2324
+ return {
2325
+ payload: {
2326
+ graph: graphName,
2327
+ action: 'status',
2328
+ tick: maxTick,
2329
+ maxTick,
2330
+ ticks,
2331
+ nodes: nodes.length,
2332
+ edges: edges.length,
2333
+ perWriter: serializePerWriter(perWriter),
2334
+ patchCount: countPatchesAtTick(maxTick, perWriter),
2335
+ diff: null,
2336
+ tickReceipt,
2337
+ cursor: { active: false },
2338
+ },
2339
+ exitCode: EXIT_CODES.OK,
2340
+ };
2341
+ }
2342
+
2343
+ /**
2344
+ * Converts the per-writer Map from discoverTicks() into a plain object for JSON output.
2345
+ *
2346
+ * @param {Map<string, WriterTickInfo>} perWriter - Per-writer tick data
2347
+ * @returns {Record<string, WriterTickInfo>} Plain object keyed by writer ID
2348
+ */
2349
+ function serializePerWriter(perWriter) {
2350
+ /** @type {Record<string, WriterTickInfo>} */
2351
+ const result = {};
2352
+ for (const [writerId, info] of perWriter) {
2353
+ result[writerId] = { ticks: info.ticks, tipSha: info.tipSha, tickShas: info.tickShas };
2354
+ }
2355
+ return result;
2356
+ }
2357
+
2358
+ /**
2359
+ * Counts the total number of patches across all writers at or before the given tick.
2360
+ *
2361
+ * @param {number} tick - Lamport tick ceiling (inclusive)
2362
+ * @param {Map<string, WriterTickInfo>} perWriter - Per-writer tick data
2363
+ * @returns {number} Total patch count at or before the given tick
2364
+ */
2365
+ function countPatchesAtTick(tick, perWriter) {
2366
+ let count = 0;
2367
+ for (const [, info] of perWriter) {
2368
+ for (const t of info.ticks) {
2369
+ if (t <= tick) {
2370
+ count++;
2371
+ }
2372
+ }
2373
+ }
2374
+ return count;
2375
+ }
2376
+
2377
+ /**
2378
+ * Computes a stable fingerprint of the current graph frontier (writer tips).
2379
+ *
2380
+ * Used to suppress seek diffs when graph history may have changed since the
2381
+ * previous cursor snapshot (e.g. new writers/patches, rewritten refs).
2382
+ *
2383
+ * @param {Map<string, WriterTickInfo>} perWriter - Per-writer metadata from discoverTicks()
2384
+ * @returns {string} Hex digest of the frontier fingerprint
2385
+ */
2386
+ function computeFrontierHash(perWriter) {
2387
+ /** @type {Record<string, string|null>} */
2388
+ const tips = {};
2389
+ for (const [writerId, info] of perWriter) {
2390
+ tips[writerId] = info?.tipSha || null;
2391
+ }
2392
+ return crypto.createHash('sha256').update(stableStringify(tips)).digest('hex');
2393
+ }
2394
+
2395
+ /**
2396
+ * Reads cached seek state counts from a cursor blob.
2397
+ *
2398
+ * Counts may be missing for older cursors (pre-diff support). In that case
2399
+ * callers should treat the counts as unknown and suppress diffs.
2400
+ *
2401
+ * @param {CursorBlob|null} cursor - Parsed cursor blob object
2402
+ * @returns {{nodes: number|null, edges: number|null}} Parsed counts
2403
+ */
2404
+ function readSeekCounts(cursor) {
2405
+ if (!cursor || typeof cursor !== 'object') {
2406
+ return { nodes: null, edges: null };
2407
+ }
2408
+
2409
+ const nodes = typeof cursor.nodes === 'number' && Number.isFinite(cursor.nodes) ? cursor.nodes : null;
2410
+ const edges = typeof cursor.edges === 'number' && Number.isFinite(cursor.edges) ? cursor.edges : null;
2411
+ return { nodes, edges };
2412
+ }
2413
+
2414
+ /**
2415
+ * Computes node/edge deltas between the current seek position and the previous cursor.
2416
+ *
2417
+ * Returns null if the previous cursor is missing cached counts.
2418
+ *
2419
+ * @param {CursorBlob|null} prevCursor - Cursor object read before updating the position
2420
+ * @param {{nodes: number, edges: number}} next - Current materialized counts
2421
+ * @param {string} frontierHash - Frontier fingerprint of the current graph
2422
+ * @returns {{nodes: number, edges: number}|null} Diff object or null when unknown
2423
+ */
2424
+ function computeSeekStateDiff(prevCursor, next, frontierHash) {
2425
+ const prev = readSeekCounts(prevCursor);
2426
+ if (prev.nodes === null || prev.edges === null) {
2427
+ return null;
2428
+ }
2429
+ const prevFrontierHash = typeof prevCursor?.frontierHash === 'string' ? prevCursor.frontierHash : null;
2430
+ if (!prevFrontierHash || prevFrontierHash !== frontierHash) {
2431
+ return null;
2432
+ }
2433
+ return {
2434
+ nodes: next.nodes - prev.nodes,
2435
+ edges: next.edges - prev.edges,
2436
+ };
2437
+ }
2438
+
2439
+ /**
2440
+ * Builds a per-writer operation summary for patches at an exact tick.
2441
+ *
2442
+ * Uses discoverTicks() tickShas mapping to locate patch SHAs, then loads and
2443
+ * summarizes patch ops. Typically only a handful of writers have a patch at any
2444
+ * single Lamport tick.
2445
+ *
2446
+ * @param {{tick: number, perWriter: Map<string, WriterTickInfo>, graph: WarpGraphInstance}} params
2447
+ * @returns {Promise<Record<string, {sha: string, opSummary: *}>|null>} Map of writerId to { sha, opSummary }, or null if empty
2448
+ */
2449
+ async function buildTickReceipt({ tick, perWriter, graph }) {
2450
+ if (!Number.isInteger(tick) || tick <= 0) {
2451
+ return null;
2452
+ }
2453
+
2454
+ /** @type {Record<string, {sha: string, opSummary: *}>} */
2455
+ const receipt = {};
2456
+
2457
+ for (const [writerId, info] of perWriter) {
2458
+ const sha = /** @type {*} */ (info?.tickShas)?.[tick]; // TODO(ts-cleanup): type CLI payload
2459
+ if (!sha) {
2460
+ continue;
2461
+ }
2462
+
2463
+ const patch = await graph.loadPatchBySha(sha);
2464
+ const ops = Array.isArray(patch?.ops) ? patch.ops : [];
2465
+ receipt[writerId] = { sha, opSummary: summarizeOps(ops) };
2466
+ }
2467
+
2468
+ return Object.keys(receipt).length > 0 ? receipt : null;
2469
+ }
2470
+
2471
+ /**
2472
+ * Renders a seek command payload as a human-readable string for terminal output.
2473
+ *
2474
+ * Handles all seek actions: list, drop, save, latest, load, tick, and status.
2475
+ *
2476
+ * @param {*} payload - Seek result payload from handleSeek
2477
+ * @returns {string} Formatted output string (includes trailing newline)
2478
+ */
2479
+ function renderSeek(payload) {
2480
+ const formatDelta = (/** @type {*} */ n) => { // TODO(ts-cleanup): type CLI payload
2481
+ if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) {
2482
+ return '';
2483
+ }
2484
+ const sign = n > 0 ? '+' : '';
2485
+ return ` (${sign}${n})`;
2486
+ };
2487
+
2488
+ const formatOpSummaryPlain = (/** @type {*} */ summary) => { // TODO(ts-cleanup): type CLI payload
2489
+ const order = [
2490
+ ['NodeAdd', '+', 'node'],
2491
+ ['EdgeAdd', '+', 'edge'],
2492
+ ['PropSet', '~', 'prop'],
2493
+ ['NodeTombstone', '-', 'node'],
2494
+ ['EdgeTombstone', '-', 'edge'],
2495
+ ['BlobValue', '+', 'blob'],
2496
+ ];
2497
+
2498
+ const parts = [];
2499
+ for (const [opType, symbol, label] of order) {
2500
+ const n = summary?.[opType];
2501
+ if (typeof n === 'number' && Number.isFinite(n) && n > 0) {
2502
+ parts.push(`${symbol}${n}${label}`);
2503
+ }
2504
+ }
2505
+ return parts.length > 0 ? parts.join(' ') : '(empty)';
2506
+ };
2507
+
2508
+ const appendReceiptSummary = (/** @type {string} */ baseLine) => {
2509
+ const tickReceipt = payload?.tickReceipt;
2510
+ if (!tickReceipt || typeof tickReceipt !== 'object') {
2511
+ return `${baseLine}\n`;
2512
+ }
2513
+
2514
+ const entries = Object.entries(tickReceipt)
2515
+ .filter(([writerId, entry]) => writerId && entry && typeof entry === 'object')
2516
+ .sort(([a], [b]) => a.localeCompare(b));
2517
+
2518
+ if (entries.length === 0) {
2519
+ return `${baseLine}\n`;
2520
+ }
2521
+
2522
+ const maxWriterLen = Math.max(5, ...entries.map(([writerId]) => writerId.length));
2523
+ const receiptLines = [` Tick ${payload.tick}:`];
2524
+ for (const [writerId, entry] of entries) {
2525
+ const sha = typeof entry.sha === 'string' ? entry.sha.slice(0, 7) : '';
2526
+ const opSummary = entry.opSummary && typeof entry.opSummary === 'object' ? entry.opSummary : entry;
2527
+ receiptLines.push(` ${writerId.padEnd(maxWriterLen)} ${sha.padEnd(7)} ${formatOpSummaryPlain(opSummary)}`);
2528
+ }
2529
+
2530
+ return `${baseLine}\n${receiptLines.join('\n')}\n`;
2531
+ };
2532
+
2533
+ const buildStateStrings = () => {
2534
+ const nodeLabel = payload.nodes === 1 ? 'node' : 'nodes';
2535
+ const edgeLabel = payload.edges === 1 ? 'edge' : 'edges';
2536
+ const patchLabel = payload.patchCount === 1 ? 'patch' : 'patches';
2537
+ return {
2538
+ nodesStr: `${payload.nodes} ${nodeLabel}${formatDelta(payload.diff?.nodes)}`,
2539
+ edgesStr: `${payload.edges} ${edgeLabel}${formatDelta(payload.diff?.edges)}`,
2540
+ patchesStr: `${payload.patchCount} ${patchLabel}`,
2541
+ };
2542
+ };
2543
+
2544
+ if (payload.action === 'clear-cache') {
2545
+ return `${payload.message}\n`;
2546
+ }
2547
+
2548
+ if (payload.action === 'list') {
2549
+ if (payload.cursors.length === 0) {
2550
+ return 'No saved cursors.\n';
2551
+ }
2552
+ const lines = [];
2553
+ for (const c of payload.cursors) {
2554
+ const active = c.tick === payload.activeTick ? ' (active)' : '';
2555
+ lines.push(` ${c.name}: tick ${c.tick}${active}`);
2556
+ }
2557
+ return `${lines.join('\n')}\n`;
2558
+ }
2559
+
2560
+ if (payload.action === 'drop') {
2561
+ return `Dropped cursor "${payload.name}" (was at tick ${payload.tick}).\n`;
2562
+ }
2563
+
2564
+ if (payload.action === 'save') {
2565
+ return `Saved cursor "${payload.name}" at tick ${payload.tick}.\n`;
2566
+ }
2567
+
2568
+ if (payload.action === 'latest') {
2569
+ const { nodesStr, edgesStr } = buildStateStrings();
2570
+ return appendReceiptSummary(
2571
+ `${payload.graph}: returned to present (tick ${payload.maxTick}, ${nodesStr}, ${edgesStr})`,
2572
+ );
2573
+ }
2574
+
2575
+ if (payload.action === 'load') {
2576
+ const { nodesStr, edgesStr } = buildStateStrings();
2577
+ return appendReceiptSummary(
2578
+ `${payload.graph}: loaded cursor "${payload.name}" at tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr})`,
2579
+ );
2580
+ }
2581
+
2582
+ if (payload.action === 'tick') {
2583
+ const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
2584
+ return appendReceiptSummary(
2585
+ `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
2586
+ );
2587
+ }
2588
+
2589
+ // status
2590
+ if (payload.cursor && payload.cursor.active) {
2591
+ const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
2592
+ return appendReceiptSummary(
2593
+ `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
2594
+ );
2595
+ }
2596
+
2597
+ return `${payload.graph}: no cursor active, ${payload.ticks.length} ticks available\n`;
2598
+ }
2599
+
2600
+ /**
2601
+ * Reads the active cursor and sets `_seekCeiling` on the graph instance
2602
+ * so that subsequent materialize calls respect the time-travel boundary.
2603
+ *
2604
+ * Called by non-seek commands (query, path, check, etc.) that should
2605
+ * honour an active seek cursor.
2606
+ *
2607
+ * @param {WarpGraphInstance} graph - WarpGraph instance
2608
+ * @param {Persistence} persistence - GraphPersistencePort adapter
2609
+ * @param {string} graphName - Name of the WARP graph
2610
+ * @returns {Promise<{active: boolean, tick: number|null, maxTick: number|null}>} Cursor info — maxTick is always null; non-seek commands intentionally skip discoverTicks() for performance
2611
+ */
2612
+ async function applyCursorCeiling(graph, persistence, graphName) {
2613
+ const cursor = await readActiveCursor(persistence, graphName);
2614
+ if (cursor) {
2615
+ graph._seekCeiling = cursor.tick;
2616
+ return { active: true, tick: cursor.tick, maxTick: null };
2617
+ }
2618
+ return { active: false, tick: null, maxTick: null };
2619
+ }
2620
+
2621
+ /**
2622
+ * Prints a seek cursor warning banner to stderr when a cursor is active.
2623
+ *
2624
+ * No-op if the cursor is not active.
2625
+ *
2626
+ * Non-seek commands (query, path, check, history, materialize) pass null for
2627
+ * maxTick to avoid the cost of discoverTicks(); the banner then omits the
2628
+ * "of {maxTick}" suffix. Only the seek handler itself populates maxTick.
2629
+ *
2630
+ * @param {{active: boolean, tick: number|null, maxTick: number|null}} cursorInfo - Result from applyCursorCeiling
2631
+ * @param {number|null} maxTick - Maximum Lamport tick (from discoverTicks), or null if unknown
2632
+ * @returns {void}
2633
+ */
2634
+ function emitCursorWarning(cursorInfo, maxTick) {
2635
+ if (cursorInfo.active) {
2636
+ const maxLabel = maxTick !== null && maxTick !== undefined ? ` of ${maxTick}` : '';
2637
+ process.stderr.write(`\u26A0 seek active (tick ${cursorInfo.tick}${maxLabel}) \u2014 run "git warp seek --latest" to return to present\n`);
2638
+ }
2639
+ }
2640
+
2641
+ /**
2642
+ * @param {{options: CliOptions, args: string[]}} params
2643
+ * @returns {Promise<{payload: *, exitCode: number}>}
2644
+ */
1468
2645
  async function handleView({ options, args }) {
1469
2646
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
1470
2647
  throw usageError('view command requires an interactive terminal (TTY)');
@@ -1475,13 +2652,14 @@ async function handleView({ options, args }) {
1475
2652
  : 'list';
1476
2653
 
1477
2654
  try {
2655
+ // @ts-expect-error — optional peer dependency, may not be installed
1478
2656
  const { startTui } = await import('@git-stunts/git-warp-tui');
1479
2657
  await startTui({
1480
2658
  repo: options.repo || '.',
1481
2659
  graph: options.graph || 'default',
1482
2660
  mode: viewMode,
1483
2661
  });
1484
- } catch (err) {
2662
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error
1485
2663
  if (err.code === 'ERR_MODULE_NOT_FOUND' || (err.message && err.message.includes('Cannot find module'))) {
1486
2664
  throw usageError(
1487
2665
  'Interactive TUI requires @git-stunts/git-warp-tui.\n' +
@@ -1493,16 +2671,18 @@ async function handleView({ options, args }) {
1493
2671
  return { payload: undefined, exitCode: 0 };
1494
2672
  }
1495
2673
 
1496
- const COMMANDS = new Map([
2674
+ /** @type {Map<string, Function>} */
2675
+ const COMMANDS = new Map(/** @type {[string, Function][]} */ ([
1497
2676
  ['info', handleInfo],
1498
2677
  ['query', handleQuery],
1499
2678
  ['path', handlePath],
1500
2679
  ['history', handleHistory],
1501
2680
  ['check', handleCheck],
1502
2681
  ['materialize', handleMaterialize],
2682
+ ['seek', handleSeek],
1503
2683
  ['view', handleView],
1504
2684
  ['install-hooks', handleInstallHooks],
1505
- ]);
2685
+ ]));
1506
2686
 
1507
2687
  /**
1508
2688
  * CLI entry point. Parses arguments, dispatches to the appropriate command handler,
@@ -1534,17 +2714,18 @@ async function main() {
1534
2714
  throw usageError(`Unknown command: ${command}`);
1535
2715
  }
1536
2716
 
1537
- const VIEW_SUPPORTED_COMMANDS = ['info', 'check', 'history', 'path', 'materialize', 'query'];
2717
+ const VIEW_SUPPORTED_COMMANDS = ['info', 'check', 'history', 'path', 'materialize', 'query', 'seek'];
1538
2718
  if (options.view && !VIEW_SUPPORTED_COMMANDS.includes(command)) {
1539
2719
  throw usageError(`--view is not supported for '${command}'. Supported commands: ${VIEW_SUPPORTED_COMMANDS.join(', ')}`);
1540
2720
  }
1541
2721
 
1542
- const result = await handler({
2722
+ const result = await /** @type {Function} */ (handler)({
1543
2723
  command,
1544
2724
  args: positionals.slice(1),
1545
2725
  options,
1546
2726
  });
1547
2727
 
2728
+ /** @type {{payload: *, exitCode: number}} */
1548
2729
  const normalized = result && typeof result === 'object' && 'payload' in result
1549
2730
  ? result
1550
2731
  : { payload: result, exitCode: EXIT_CODES.OK };
@@ -1552,13 +2733,15 @@ async function main() {
1552
2733
  if (normalized.payload !== undefined) {
1553
2734
  emit(normalized.payload, { json: options.json, command, view: options.view });
1554
2735
  }
1555
- process.exitCode = normalized.exitCode ?? EXIT_CODES.OK;
2736
+ // Use process.exit() to avoid waiting for fire-and-forget I/O (e.g. seek cache writes).
2737
+ process.exit(normalized.exitCode ?? EXIT_CODES.OK);
1556
2738
  }
1557
2739
 
1558
2740
  main().catch((error) => {
1559
2741
  const exitCode = error instanceof CliError ? error.exitCode : EXIT_CODES.INTERNAL;
1560
2742
  const code = error instanceof CliError ? error.code : 'E_INTERNAL';
1561
2743
  const message = error instanceof Error ? error.message : 'Unknown error';
2744
+ /** @type {{error: {code: string, message: string, cause?: *}}} */
1562
2745
  const payload = { error: { code, message } };
1563
2746
 
1564
2747
  if (error && error.cause) {
@@ -1570,5 +2753,5 @@ main().catch((error) => {
1570
2753
  } else {
1571
2754
  process.stderr.write(renderError(payload));
1572
2755
  }
1573
- process.exitCode = exitCode;
2756
+ process.exit(exitCode);
1574
2757
  });