@git-stunts/git-warp 10.3.2 → 10.7.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 (108) hide show
  1. package/README.md +6 -3
  2. package/SECURITY.md +89 -1
  3. package/bin/warp-graph.js +574 -208
  4. package/index.d.ts +55 -0
  5. package/index.js +4 -0
  6. package/package.json +8 -4
  7. package/src/domain/WarpGraph.js +334 -161
  8. package/src/domain/crdt/LWW.js +1 -1
  9. package/src/domain/crdt/ORSet.js +10 -6
  10. package/src/domain/crdt/VersionVector.js +5 -1
  11. package/src/domain/errors/EmptyMessageError.js +2 -4
  12. package/src/domain/errors/ForkError.js +4 -0
  13. package/src/domain/errors/IndexError.js +4 -0
  14. package/src/domain/errors/OperationAbortedError.js +4 -0
  15. package/src/domain/errors/QueryError.js +4 -0
  16. package/src/domain/errors/SchemaUnsupportedError.js +4 -0
  17. package/src/domain/errors/ShardCorruptionError.js +2 -6
  18. package/src/domain/errors/ShardLoadError.js +2 -6
  19. package/src/domain/errors/ShardValidationError.js +2 -7
  20. package/src/domain/errors/StorageError.js +2 -6
  21. package/src/domain/errors/SyncError.js +4 -0
  22. package/src/domain/errors/TraversalError.js +4 -0
  23. package/src/domain/errors/WarpError.js +2 -4
  24. package/src/domain/errors/WormholeError.js +4 -0
  25. package/src/domain/services/AnchorMessageCodec.js +1 -4
  26. package/src/domain/services/BitmapIndexBuilder.js +10 -6
  27. package/src/domain/services/BitmapIndexReader.js +27 -21
  28. package/src/domain/services/BoundaryTransitionRecord.js +22 -15
  29. package/src/domain/services/CheckpointMessageCodec.js +1 -7
  30. package/src/domain/services/CheckpointSerializerV5.js +20 -19
  31. package/src/domain/services/CheckpointService.js +18 -18
  32. package/src/domain/services/CommitDagTraversalService.js +13 -1
  33. package/src/domain/services/DagPathFinding.js +40 -18
  34. package/src/domain/services/DagTopology.js +7 -6
  35. package/src/domain/services/DagTraversal.js +5 -3
  36. package/src/domain/services/Frontier.js +7 -6
  37. package/src/domain/services/HealthCheckService.js +15 -14
  38. package/src/domain/services/HookInstaller.js +64 -13
  39. package/src/domain/services/HttpSyncServer.js +88 -19
  40. package/src/domain/services/IndexRebuildService.js +12 -12
  41. package/src/domain/services/IndexStalenessChecker.js +13 -6
  42. package/src/domain/services/JoinReducer.js +28 -27
  43. package/src/domain/services/LogicalTraversal.js +7 -6
  44. package/src/domain/services/MessageCodecInternal.js +2 -0
  45. package/src/domain/services/ObserverView.js +6 -6
  46. package/src/domain/services/PatchBuilderV2.js +9 -9
  47. package/src/domain/services/PatchMessageCodec.js +1 -7
  48. package/src/domain/services/ProvenanceIndex.js +6 -8
  49. package/src/domain/services/ProvenancePayload.js +1 -2
  50. package/src/domain/services/QueryBuilder.js +29 -23
  51. package/src/domain/services/StateDiff.js +7 -7
  52. package/src/domain/services/StateSerializerV5.js +8 -6
  53. package/src/domain/services/StreamingBitmapIndexBuilder.js +29 -23
  54. package/src/domain/services/SyncAuthService.js +396 -0
  55. package/src/domain/services/SyncProtocol.js +23 -26
  56. package/src/domain/services/TemporalQuery.js +4 -3
  57. package/src/domain/services/TranslationCost.js +4 -4
  58. package/src/domain/services/WormholeService.js +19 -15
  59. package/src/domain/types/TickReceipt.js +10 -6
  60. package/src/domain/types/WarpTypesV2.js +2 -3
  61. package/src/domain/utils/CachedValue.js +1 -1
  62. package/src/domain/utils/LRUCache.js +3 -3
  63. package/src/domain/utils/MinHeap.js +2 -2
  64. package/src/domain/utils/RefLayout.js +19 -0
  65. package/src/domain/utils/WriterId.js +2 -2
  66. package/src/domain/utils/defaultCodec.js +9 -2
  67. package/src/domain/utils/defaultCrypto.js +36 -0
  68. package/src/domain/utils/roaring.js +5 -5
  69. package/src/domain/utils/seekCacheKey.js +32 -0
  70. package/src/domain/warp/PatchSession.js +3 -3
  71. package/src/domain/warp/Writer.js +2 -2
  72. package/src/infrastructure/adapters/BunHttpAdapter.js +21 -8
  73. package/src/infrastructure/adapters/CasSeekCacheAdapter.js +311 -0
  74. package/src/infrastructure/adapters/ClockAdapter.js +2 -2
  75. package/src/infrastructure/adapters/DenoHttpAdapter.js +22 -9
  76. package/src/infrastructure/adapters/GitGraphAdapter.js +25 -83
  77. package/src/infrastructure/adapters/InMemoryGraphAdapter.js +488 -0
  78. package/src/infrastructure/adapters/NodeCryptoAdapter.js +16 -3
  79. package/src/infrastructure/adapters/NodeHttpAdapter.js +33 -11
  80. package/src/infrastructure/adapters/WebCryptoAdapter.js +21 -11
  81. package/src/infrastructure/adapters/adapterValidation.js +90 -0
  82. package/src/infrastructure/codecs/CborCodec.js +16 -8
  83. package/src/ports/BlobPort.js +2 -2
  84. package/src/ports/CodecPort.js +2 -2
  85. package/src/ports/CommitPort.js +8 -21
  86. package/src/ports/ConfigPort.js +3 -3
  87. package/src/ports/CryptoPort.js +7 -7
  88. package/src/ports/GraphPersistencePort.js +12 -14
  89. package/src/ports/HttpServerPort.js +1 -5
  90. package/src/ports/IndexStoragePort.js +1 -0
  91. package/src/ports/LoggerPort.js +9 -9
  92. package/src/ports/RefPort.js +5 -5
  93. package/src/ports/SeekCachePort.js +73 -0
  94. package/src/ports/TreePort.js +3 -3
  95. package/src/visualization/layouts/converters.js +14 -7
  96. package/src/visualization/layouts/elkAdapter.js +17 -4
  97. package/src/visualization/layouts/elkLayout.js +23 -7
  98. package/src/visualization/layouts/index.js +3 -3
  99. package/src/visualization/renderers/ascii/check.js +30 -17
  100. package/src/visualization/renderers/ascii/graph.js +92 -1
  101. package/src/visualization/renderers/ascii/history.js +28 -26
  102. package/src/visualization/renderers/ascii/info.js +9 -7
  103. package/src/visualization/renderers/ascii/materialize.js +20 -16
  104. package/src/visualization/renderers/ascii/opSummary.js +15 -7
  105. package/src/visualization/renderers/ascii/path.js +1 -1
  106. package/src/visualization/renderers/ascii/seek.js +187 -23
  107. package/src/visualization/renderers/ascii/table.js +1 -1
  108. package/src/visualization/renderers/svg/index.js +5 -1
package/bin/warp-graph.js CHANGED
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import process from 'node:process';
7
7
  import readline from 'node:readline';
8
8
  import { execFileSync } from 'node:child_process';
9
+ // @ts-expect-error — no type declarations for @git-stunts/plumbing
9
10
  import GitPlumbing, { ShellRunnerFactory } from '@git-stunts/plumbing';
10
11
  import WarpGraph from '../src/domain/WarpGraph.js';
11
12
  import GitGraphAdapter from '../src/infrastructure/adapters/GitGraphAdapter.js';
@@ -22,6 +23,7 @@ import {
22
23
  buildCursorSavedRef,
23
24
  buildCursorSavedPrefix,
24
25
  } from '../src/domain/utils/RefLayout.js';
26
+ import CasSeekCacheAdapter from '../src/infrastructure/adapters/CasSeekCacheAdapter.js';
25
27
  import { HookInstaller, classifyExistingHook } from '../src/domain/services/HookInstaller.js';
26
28
  import { renderInfoView } from '../src/visualization/renderers/ascii/info.js';
27
29
  import { renderCheckView } from '../src/visualization/renderers/ascii/check.js';
@@ -29,11 +31,94 @@ import { renderHistoryView, summarizeOps } from '../src/visualization/renderers/
29
31
  import { renderPathView } from '../src/visualization/renderers/ascii/path.js';
30
32
  import { renderMaterializeView } from '../src/visualization/renderers/ascii/materialize.js';
31
33
  import { parseCursorBlob } from '../src/domain/utils/parseCursorBlob.js';
32
- import { renderSeekView } from '../src/visualization/renderers/ascii/seek.js';
34
+ import { diffStates } from '../src/domain/services/StateDiff.js';
35
+ import { renderSeekView, formatStructuralDiff } from '../src/visualization/renderers/ascii/seek.js';
33
36
  import { renderGraphView } from '../src/visualization/renderers/ascii/graph.js';
34
37
  import { renderSvg } from '../src/visualization/renderers/svg/index.js';
35
38
  import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../src/visualization/layouts/index.js';
36
39
 
40
+ /**
41
+ * @typedef {Object} Persistence
42
+ * @property {(prefix: string) => Promise<string[]>} listRefs
43
+ * @property {(ref: string) => Promise<string|null>} readRef
44
+ * @property {(ref: string, oid: string) => Promise<void>} updateRef
45
+ * @property {(ref: string) => Promise<void>} deleteRef
46
+ * @property {(oid: string) => Promise<Buffer>} readBlob
47
+ * @property {(buf: Buffer) => Promise<string>} writeBlob
48
+ * @property {(sha: string) => Promise<{date?: string|null}>} getNodeInfo
49
+ * @property {(sha: string, coverageSha: string) => Promise<boolean>} isAncestor
50
+ * @property {() => Promise<{ok: boolean}>} ping
51
+ * @property {*} plumbing
52
+ */
53
+
54
+ /**
55
+ * @typedef {Object} WarpGraphInstance
56
+ * @property {(opts?: {ceiling?: number}) => Promise<void>} materialize
57
+ * @property {() => Promise<Array<{id: string}>>} getNodes
58
+ * @property {() => Promise<Array<{from: string, to: string, label?: string}>>} getEdges
59
+ * @property {() => Promise<string|null>} createCheckpoint
60
+ * @property {() => *} query
61
+ * @property {{ shortestPath: Function }} traverse
62
+ * @property {(writerId: string) => Promise<Array<{patch: any, sha: string}>>} getWriterPatches
63
+ * @property {() => Promise<{frontier: Record<string, any>}>} status
64
+ * @property {() => Promise<Map<string, any>>} getFrontier
65
+ * @property {() => {totalTombstones: number, tombstoneRatio: number}} getGCMetrics
66
+ * @property {() => Promise<number>} getPropertyCount
67
+ * @property {() => Promise<import('../src/domain/services/JoinReducer.js').WarpStateV5 | null>} getStateSnapshot
68
+ * @property {() => Promise<{ticks: number[], maxTick: number, perWriter: Map<string, WriterTickInfo>}>} discoverTicks
69
+ * @property {(sha: string) => Promise<{ops?: any[]}>} loadPatchBySha
70
+ * @property {(cache: any) => void} setSeekCache
71
+ * @property {*} seekCache
72
+ * @property {number} [_seekCeiling]
73
+ * @property {boolean} [_provenanceDegraded]
74
+ */
75
+
76
+ /**
77
+ * @typedef {Object} WriterTickInfo
78
+ * @property {number[]} ticks
79
+ * @property {string|null} tipSha
80
+ * @property {Record<number, string>} [tickShas]
81
+ */
82
+
83
+ /**
84
+ * @typedef {Object} CursorBlob
85
+ * @property {number} tick
86
+ * @property {string} [mode]
87
+ * @property {number} [nodes]
88
+ * @property {number} [edges]
89
+ * @property {string} [frontierHash]
90
+ */
91
+
92
+ /**
93
+ * @typedef {Object} CliOptions
94
+ * @property {string} repo
95
+ * @property {boolean} json
96
+ * @property {string|null} view
97
+ * @property {string|null} graph
98
+ * @property {string} writer
99
+ * @property {boolean} help
100
+ */
101
+
102
+ /**
103
+ * @typedef {Object} GraphInfoResult
104
+ * @property {string} name
105
+ * @property {{count: number, ids?: string[]}} writers
106
+ * @property {{ref: string, sha: string|null, date?: string|null}} [checkpoint]
107
+ * @property {{ref: string, sha: string|null}} [coverage]
108
+ * @property {Record<string, number>} [writerPatches]
109
+ * @property {{active: boolean, tick?: number, mode?: string}} [cursor]
110
+ */
111
+
112
+ /**
113
+ * @typedef {Object} SeekSpec
114
+ * @property {string} action
115
+ * @property {string|null} tickValue
116
+ * @property {string|null} name
117
+ * @property {boolean} noPersistentCache
118
+ * @property {boolean} diff
119
+ * @property {number} diffLimit
120
+ */
121
+
37
122
  const EXIT_CODES = {
38
123
  OK: 0,
39
124
  USAGE: 1,
@@ -90,6 +175,8 @@ Seek options:
90
175
  --load <name> Restore a saved cursor
91
176
  --list List all saved cursors
92
177
  --drop <name> Delete a saved cursor
178
+ --diff Show structural diff (added/removed nodes, edges, props)
179
+ --diff-limit <N> Max diff entries (default 2000)
93
180
  `;
94
181
 
95
182
  /**
@@ -111,20 +198,25 @@ class CliError extends Error {
111
198
  }
112
199
  }
113
200
 
201
+ /** @param {string} message */
114
202
  function usageError(message) {
115
203
  return new CliError(message, { code: 'E_USAGE', exitCode: EXIT_CODES.USAGE });
116
204
  }
117
205
 
206
+ /** @param {string} message */
118
207
  function notFoundError(message) {
119
208
  return new CliError(message, { code: 'E_NOT_FOUND', exitCode: EXIT_CODES.NOT_FOUND });
120
209
  }
121
210
 
211
+ /** @param {*} value */
122
212
  function stableStringify(value) {
213
+ /** @param {*} input @returns {*} */
123
214
  const normalize = (input) => {
124
215
  if (Array.isArray(input)) {
125
216
  return input.map(normalize);
126
217
  }
127
218
  if (input && typeof input === 'object') {
219
+ /** @type {Record<string, *>} */
128
220
  const sorted = {};
129
221
  for (const key of Object.keys(input).sort()) {
130
222
  sorted[key] = normalize(input[key]);
@@ -137,8 +229,10 @@ function stableStringify(value) {
137
229
  return JSON.stringify(normalize(value), null, 2);
138
230
  }
139
231
 
232
+ /** @param {string[]} argv */
140
233
  function parseArgs(argv) {
141
234
  const options = createDefaultOptions();
235
+ /** @type {string[]} */
142
236
  const positionals = [];
143
237
  const optionDefs = [
144
238
  { flag: '--repo', shortFlag: '-r', key: 'repo' },
@@ -169,6 +263,14 @@ function createDefaultOptions() {
169
263
  };
170
264
  }
171
265
 
266
+ /**
267
+ * @param {Object} params
268
+ * @param {string[]} params.argv
269
+ * @param {number} params.index
270
+ * @param {Record<string, *>} params.options
271
+ * @param {Array<{flag: string, shortFlag?: string, key: string}>} params.optionDefs
272
+ * @param {string[]} params.positionals
273
+ */
172
274
  function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
173
275
  const arg = argv[index];
174
276
 
@@ -220,8 +322,10 @@ function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
220
322
  shortFlag: matched.shortFlag,
221
323
  allowEmpty: false,
222
324
  });
223
- options[matched.key] = result.value;
224
- return { consumed: result.consumed };
325
+ if (result) {
326
+ options[matched.key] = result.value;
327
+ return { consumed: result.consumed };
328
+ }
225
329
  }
226
330
 
227
331
  if (arg.startsWith('-')) {
@@ -232,6 +336,10 @@ function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
232
336
  return { consumed: argv.length - index - 1, done: true };
233
337
  }
234
338
 
339
+ /**
340
+ * @param {string} arg
341
+ * @param {Array<{flag: string, shortFlag?: string, key: string}>} optionDefs
342
+ */
235
343
  function matchOptionDef(arg, optionDefs) {
236
344
  return optionDefs.find((def) =>
237
345
  arg === def.flag ||
@@ -240,6 +348,7 @@ function matchOptionDef(arg, optionDefs) {
240
348
  );
241
349
  }
242
350
 
351
+ /** @param {string} repoPath @returns {Promise<{persistence: Persistence}>} */
243
352
  async function createPersistence(repoPath) {
244
353
  const runner = ShellRunnerFactory.create();
245
354
  const plumbing = new GitPlumbing({ cwd: repoPath, runner });
@@ -251,6 +360,7 @@ async function createPersistence(repoPath) {
251
360
  return { persistence };
252
361
  }
253
362
 
363
+ /** @param {Persistence} persistence @returns {Promise<string[]>} */
254
364
  async function listGraphNames(persistence) {
255
365
  if (typeof persistence.listRefs !== 'function') {
256
366
  return [];
@@ -273,6 +383,11 @@ async function listGraphNames(persistence) {
273
383
  return [...names].sort();
274
384
  }
275
385
 
386
+ /**
387
+ * @param {Persistence} persistence
388
+ * @param {string|null} explicitGraph
389
+ * @returns {Promise<string>}
390
+ */
276
391
  async function resolveGraphName(persistence, explicitGraph) {
277
392
  if (explicitGraph) {
278
393
  return explicitGraph;
@@ -289,14 +404,14 @@ async function resolveGraphName(persistence, explicitGraph) {
289
404
 
290
405
  /**
291
406
  * Collects metadata about a single graph (writer count, refs, patches, checkpoint).
292
- * @param {Object} persistence - GraphPersistencePort adapter
407
+ * @param {Persistence} persistence - GraphPersistencePort adapter
293
408
  * @param {string} graphName - Name of the graph to inspect
294
409
  * @param {Object} [options]
295
410
  * @param {boolean} [options.includeWriterIds=false] - Include writer ID list
296
411
  * @param {boolean} [options.includeRefs=false] - Include checkpoint/coverage refs
297
412
  * @param {boolean} [options.includeWriterPatches=false] - Include per-writer patch counts
298
413
  * @param {boolean} [options.includeCheckpointDate=false] - Include checkpoint date
299
- * @returns {Promise<Object>} Graph info object
414
+ * @returns {Promise<GraphInfoResult>} Graph info object
300
415
  */
301
416
  async function getGraphInfo(persistence, graphName, {
302
417
  includeWriterIds = false,
@@ -308,11 +423,12 @@ async function getGraphInfo(persistence, graphName, {
308
423
  const writerRefs = typeof persistence.listRefs === 'function'
309
424
  ? await persistence.listRefs(writersPrefix)
310
425
  : [];
311
- const writerIds = writerRefs
426
+ const writerIds = /** @type {string[]} */ (writerRefs
312
427
  .map((ref) => parseWriterIdFromRef(ref))
313
428
  .filter(Boolean)
314
- .sort();
429
+ .sort());
315
430
 
431
+ /** @type {GraphInfoResult} */
316
432
  const info = {
317
433
  name: graphName,
318
434
  writers: {
@@ -328,6 +444,7 @@ async function getGraphInfo(persistence, graphName, {
328
444
  const checkpointRef = buildCheckpointRef(graphName);
329
445
  const checkpointSha = await persistence.readRef(checkpointRef);
330
446
 
447
+ /** @type {{ref: string, sha: string|null, date?: string|null}} */
331
448
  const checkpoint = { ref: checkpointRef, sha: checkpointSha || null };
332
449
 
333
450
  if (includeCheckpointDate && checkpointSha) {
@@ -351,10 +468,11 @@ async function getGraphInfo(persistence, graphName, {
351
468
  writerId: 'cli',
352
469
  crypto: new NodeCryptoAdapter(),
353
470
  });
471
+ /** @type {Record<string, number>} */
354
472
  const writerPatches = {};
355
473
  for (const writerId of writerIds) {
356
474
  const patches = await graph.getWriterPatches(writerId);
357
- writerPatches[writerId] = patches.length;
475
+ writerPatches[/** @type {string} */ (writerId)] = patches.length;
358
476
  }
359
477
  info.writerPatches = writerPatches;
360
478
  }
@@ -364,11 +482,8 @@ async function getGraphInfo(persistence, graphName, {
364
482
 
365
483
  /**
366
484
  * Opens a WarpGraph for the given CLI options.
367
- * @param {Object} options - Parsed CLI options
368
- * @param {string} [options.repo] - Repository path
369
- * @param {string} [options.graph] - Explicit graph name
370
- * @param {string} [options.writer] - Writer ID
371
- * @returns {Promise<{graph: Object, graphName: string, persistence: Object}>}
485
+ * @param {CliOptions} options - Parsed CLI options
486
+ * @returns {Promise<{graph: WarpGraphInstance, graphName: string, persistence: Persistence}>}
372
487
  * @throws {CliError} If the specified graph is not found
373
488
  */
374
489
  async function openGraph(options) {
@@ -380,15 +495,16 @@ async function openGraph(options) {
380
495
  throw notFoundError(`Graph not found: ${options.graph}`);
381
496
  }
382
497
  }
383
- const graph = await WarpGraph.open({
498
+ const graph = /** @type {WarpGraphInstance} */ (/** @type {*} */ (await WarpGraph.open({ // TODO(ts-cleanup): narrow port type
384
499
  persistence,
385
500
  graphName,
386
501
  writerId: options.writer,
387
502
  crypto: new NodeCryptoAdapter(),
388
- });
503
+ })));
389
504
  return { graph, graphName, persistence };
390
505
  }
391
506
 
507
+ /** @param {string[]} args */
392
508
  function parseQueryArgs(args) {
393
509
  const spec = {
394
510
  match: null,
@@ -407,6 +523,11 @@ function parseQueryArgs(args) {
407
523
  return spec;
408
524
  }
409
525
 
526
+ /**
527
+ * @param {string[]} args
528
+ * @param {number} index
529
+ * @param {{match: string|null, select: string[]|null, steps: Array<{type: string, label?: string, key?: string, value?: string}>}} spec
530
+ */
410
531
  function consumeQueryArg(args, index, spec) {
411
532
  const stepResult = readTraversalStep(args, index);
412
533
  if (stepResult) {
@@ -450,6 +571,7 @@ function consumeQueryArg(args, index, spec) {
450
571
  return null;
451
572
  }
452
573
 
574
+ /** @param {string} value */
453
575
  function parseWhereProp(value) {
454
576
  const [key, ...rest] = value.split('=');
455
577
  if (!key || rest.length === 0) {
@@ -458,6 +580,7 @@ function parseWhereProp(value) {
458
580
  return { type: 'where-prop', key, value: rest.join('=') };
459
581
  }
460
582
 
583
+ /** @param {string} value */
461
584
  function parseSelectFields(value) {
462
585
  if (value === '') {
463
586
  return [];
@@ -465,6 +588,10 @@ function parseSelectFields(value) {
465
588
  return value.split(',').map((field) => field.trim()).filter(Boolean);
466
589
  }
467
590
 
591
+ /**
592
+ * @param {string[]} args
593
+ * @param {number} index
594
+ */
468
595
  function readTraversalStep(args, index) {
469
596
  const arg = args[index];
470
597
  if (arg !== '--outgoing' && arg !== '--incoming') {
@@ -476,6 +603,9 @@ function readTraversalStep(args, index) {
476
603
  return { step: { type: arg.slice(2), label }, consumed };
477
604
  }
478
605
 
606
+ /**
607
+ * @param {{args: string[], index: number, flag: string, shortFlag?: string, allowEmpty?: boolean}} params
608
+ */
479
609
  function readOptionValue({ args, index, flag, shortFlag, allowEmpty = false }) {
480
610
  const arg = args[index];
481
611
  if (matchesOptionFlag(arg, flag, shortFlag)) {
@@ -489,10 +619,16 @@ function readOptionValue({ args, index, flag, shortFlag, allowEmpty = false }) {
489
619
  return null;
490
620
  }
491
621
 
622
+ /**
623
+ * @param {string} arg
624
+ * @param {string} flag
625
+ * @param {string} [shortFlag]
626
+ */
492
627
  function matchesOptionFlag(arg, flag, shortFlag) {
493
628
  return arg === flag || (shortFlag && arg === shortFlag);
494
629
  }
495
630
 
631
+ /** @param {{args: string[], index: number, flag: string, allowEmpty?: boolean}} params */
496
632
  function readNextOptionValue({ args, index, flag, allowEmpty }) {
497
633
  const value = args[index + 1];
498
634
  if (value === undefined || (!allowEmpty && value === '')) {
@@ -501,6 +637,7 @@ function readNextOptionValue({ args, index, flag, allowEmpty }) {
501
637
  return { value, consumed: 1 };
502
638
  }
503
639
 
640
+ /** @param {{arg: string, flag: string, allowEmpty?: boolean}} params */
504
641
  function readInlineOptionValue({ arg, flag, allowEmpty }) {
505
642
  const value = arg.slice(flag.length + 1);
506
643
  if (!allowEmpty && value === '') {
@@ -509,9 +646,12 @@ function readInlineOptionValue({ arg, flag, allowEmpty }) {
509
646
  return { value, consumed: 0 };
510
647
  }
511
648
 
649
+ /** @param {string[]} args */
512
650
  function parsePathArgs(args) {
513
651
  const options = createPathOptions();
652
+ /** @type {string[]} */
514
653
  const labels = [];
654
+ /** @type {string[]} */
515
655
  const positionals = [];
516
656
 
517
657
  for (let i = 0; i < args.length; i += 1) {
@@ -523,6 +663,7 @@ function parsePathArgs(args) {
523
663
  return options;
524
664
  }
525
665
 
666
+ /** @returns {{from: string|null, to: string|null, dir: string|undefined, labelFilter: string|string[]|undefined, maxDepth: number|undefined}} */
526
667
  function createPathOptions() {
527
668
  return {
528
669
  from: null,
@@ -533,8 +674,12 @@ function createPathOptions() {
533
674
  };
534
675
  }
535
676
 
677
+ /**
678
+ * @param {{args: string[], index: number, options: ReturnType<typeof createPathOptions>, labels: string[], positionals: string[]}} params
679
+ */
536
680
  function consumePathArg({ args, index, options, labels, positionals }) {
537
681
  const arg = args[index];
682
+ /** @type {Array<{flag: string, apply: (value: string) => void}>} */
538
683
  const handlers = [
539
684
  { flag: '--from', apply: (value) => { options.from = value; } },
540
685
  { flag: '--to', apply: (value) => { options.to = value; } },
@@ -559,6 +704,11 @@ function consumePathArg({ args, index, options, labels, positionals }) {
559
704
  return { consumed: 0 };
560
705
  }
561
706
 
707
+ /**
708
+ * @param {ReturnType<typeof createPathOptions>} options
709
+ * @param {string[]} labels
710
+ * @param {string[]} positionals
711
+ */
562
712
  function finalizePathOptions(options, labels, positionals) {
563
713
  if (!options.from) {
564
714
  options.from = positionals[0] || null;
@@ -579,10 +729,12 @@ function finalizePathOptions(options, labels, positionals) {
579
729
  }
580
730
  }
581
731
 
732
+ /** @param {string} value */
582
733
  function parseLabels(value) {
583
734
  return value.split(',').map((label) => label.trim()).filter(Boolean);
584
735
  }
585
736
 
737
+ /** @param {string} value */
586
738
  function parseMaxDepth(value) {
587
739
  const parsed = Number.parseInt(value, 10);
588
740
  if (Number.isNaN(parsed)) {
@@ -591,7 +743,9 @@ function parseMaxDepth(value) {
591
743
  return parsed;
592
744
  }
593
745
 
746
+ /** @param {string[]} args */
594
747
  function parseHistoryArgs(args) {
748
+ /** @type {{node: string|null}} */
595
749
  const options = { node: null };
596
750
 
597
751
  for (let i = 0; i < args.length; i += 1) {
@@ -622,6 +776,10 @@ function parseHistoryArgs(args) {
622
776
  return options;
623
777
  }
624
778
 
779
+ /**
780
+ * @param {*} patch
781
+ * @param {string} nodeId
782
+ */
625
783
  function patchTouchesNode(patch, nodeId) {
626
784
  const ops = Array.isArray(patch?.ops) ? patch.ops : [];
627
785
  for (const op of ops) {
@@ -635,6 +793,7 @@ function patchTouchesNode(patch, nodeId) {
635
793
  return false;
636
794
  }
637
795
 
796
+ /** @param {*} payload */
638
797
  function renderInfo(payload) {
639
798
  const lines = [`Repo: ${payload.repo}`];
640
799
  lines.push(`Graphs: ${payload.graphs.length}`);
@@ -654,6 +813,7 @@ function renderInfo(payload) {
654
813
  return `${lines.join('\n')}\n`;
655
814
  }
656
815
 
816
+ /** @param {*} payload */
657
817
  function renderQuery(payload) {
658
818
  const lines = [
659
819
  `Graph: ${payload.graph}`,
@@ -672,6 +832,7 @@ function renderQuery(payload) {
672
832
  return `${lines.join('\n')}\n`;
673
833
  }
674
834
 
835
+ /** @param {*} payload */
675
836
  function renderPath(payload) {
676
837
  const lines = [
677
838
  `Graph: ${payload.graph}`,
@@ -694,6 +855,7 @@ const ANSI_RED = '\x1b[31m';
694
855
  const ANSI_DIM = '\x1b[2m';
695
856
  const ANSI_RESET = '\x1b[0m';
696
857
 
858
+ /** @param {string} state */
697
859
  function colorCachedState(state) {
698
860
  if (state === 'fresh') {
699
861
  return `${ANSI_GREEN}${state}${ANSI_RESET}`;
@@ -704,6 +866,7 @@ function colorCachedState(state) {
704
866
  return `${ANSI_RED}${ANSI_DIM}${state}${ANSI_RESET}`;
705
867
  }
706
868
 
869
+ /** @param {*} payload */
707
870
  function renderCheck(payload) {
708
871
  const lines = [
709
872
  `Graph: ${payload.graph}`,
@@ -754,6 +917,7 @@ function renderCheck(payload) {
754
917
  return `${lines.join('\n')}\n`;
755
918
  }
756
919
 
920
+ /** @param {*} hook */
757
921
  function formatHookStatusLine(hook) {
758
922
  if (!hook.installed && hook.foreign) {
759
923
  return "Hook: foreign hook present — run 'git warp install-hooks'";
@@ -767,6 +931,7 @@ function formatHookStatusLine(hook) {
767
931
  return `Hook: installed (v${hook.version}) — upgrade available, run 'git warp install-hooks'`;
768
932
  }
769
933
 
934
+ /** @param {*} payload */
770
935
  function renderHistory(payload) {
771
936
  const lines = [
772
937
  `Graph: ${payload.graph}`,
@@ -785,6 +950,7 @@ function renderHistory(payload) {
785
950
  return `${lines.join('\n')}\n`;
786
951
  }
787
952
 
953
+ /** @param {*} payload */
788
954
  function renderError(payload) {
789
955
  return `Error: ${payload.error.message}\n`;
790
956
  }
@@ -803,11 +969,8 @@ function writeHtmlExport(filePath, svgContent) {
803
969
  * Writes a command result to stdout/stderr in the appropriate format.
804
970
  * Dispatches to JSON, SVG file, HTML file, ASCII view, or plain text
805
971
  * based on the combination of flags.
806
- * @param {Object} payload - Command result payload
807
- * @param {Object} options
808
- * @param {boolean} options.json - Emit JSON to stdout
809
- * @param {string} options.command - Command name (info, query, path, etc.)
810
- * @param {string|boolean} options.view - View mode (true for ascii, 'svg:PATH', 'html:PATH', 'browser')
972
+ * @param {*} payload - Command result payload
973
+ * @param {{json: boolean, command: string, view: string|null}} options
811
974
  */
812
975
  function emit(payload, { json, command, view }) {
813
976
  if (json) {
@@ -925,9 +1088,8 @@ function emit(payload, { json, command, view }) {
925
1088
 
926
1089
  /**
927
1090
  * Handles the `info` command: summarizes graphs in the repository.
928
- * @param {Object} params
929
- * @param {Object} params.options - Parsed CLI options
930
- * @returns {Promise<{repo: string, graphs: Object[]}>} Info payload
1091
+ * @param {{options: CliOptions}} params
1092
+ * @returns {Promise<{repo: string, graphs: GraphInfoResult[]}>} Info payload
931
1093
  * @throws {CliError} If the specified graph is not found
932
1094
  */
933
1095
  async function handleInfo({ options }) {
@@ -974,10 +1136,8 @@ async function handleInfo({ options }) {
974
1136
 
975
1137
  /**
976
1138
  * Handles the `query` command: runs a logical graph query.
977
- * @param {Object} params
978
- * @param {Object} params.options - Parsed CLI options
979
- * @param {string[]} params.args - Remaining positional arguments (query spec)
980
- * @returns {Promise<{payload: Object, exitCode: number}>} Query result payload
1139
+ * @param {{options: CliOptions, args: string[]}} params
1140
+ * @returns {Promise<{payload: *, exitCode: number}>} Query result payload
981
1141
  * @throws {CliError} On invalid query options or query execution errors
982
1142
  */
983
1143
  async function handleQuery({ options, args }) {
@@ -1021,6 +1181,10 @@ async function handleQuery({ options, args }) {
1021
1181
  }
1022
1182
  }
1023
1183
 
1184
+ /**
1185
+ * @param {*} builder
1186
+ * @param {Array<{type: string, label?: string, key?: string, value?: string}>} steps
1187
+ */
1024
1188
  function applyQuerySteps(builder, steps) {
1025
1189
  let current = builder;
1026
1190
  for (const step of steps) {
@@ -1029,6 +1193,10 @@ function applyQuerySteps(builder, steps) {
1029
1193
  return current;
1030
1194
  }
1031
1195
 
1196
+ /**
1197
+ * @param {*} builder
1198
+ * @param {{type: string, label?: string, key?: string, value?: string}} step
1199
+ */
1032
1200
  function applyQueryStep(builder, step) {
1033
1201
  if (step.type === 'outgoing') {
1034
1202
  return builder.outgoing(step.label);
@@ -1037,11 +1205,16 @@ function applyQueryStep(builder, step) {
1037
1205
  return builder.incoming(step.label);
1038
1206
  }
1039
1207
  if (step.type === 'where-prop') {
1040
- return builder.where((node) => matchesPropFilter(node, step.key, step.value));
1208
+ return builder.where((/** @type {*} */ node) => matchesPropFilter(node, /** @type {string} */ (step.key), /** @type {string} */ (step.value))); // TODO(ts-cleanup): type CLI payload
1041
1209
  }
1042
1210
  return builder;
1043
1211
  }
1044
1212
 
1213
+ /**
1214
+ * @param {*} node
1215
+ * @param {string} key
1216
+ * @param {string} value
1217
+ */
1045
1218
  function matchesPropFilter(node, key, value) {
1046
1219
  const props = node.props || {};
1047
1220
  if (!Object.prototype.hasOwnProperty.call(props, key)) {
@@ -1050,6 +1223,11 @@ function matchesPropFilter(node, key, value) {
1050
1223
  return String(props[key]) === value;
1051
1224
  }
1052
1225
 
1226
+ /**
1227
+ * @param {string} graphName
1228
+ * @param {*} result
1229
+ * @returns {{graph: string, stateHash: *, nodes: *, _renderedSvg?: string, _renderedAscii?: string}}
1230
+ */
1053
1231
  function buildQueryPayload(graphName, result) {
1054
1232
  return {
1055
1233
  graph: graphName,
@@ -1058,6 +1236,7 @@ function buildQueryPayload(graphName, result) {
1058
1236
  };
1059
1237
  }
1060
1238
 
1239
+ /** @param {*} error */
1061
1240
  function mapQueryError(error) {
1062
1241
  if (error && error.code && String(error.code).startsWith('E_QUERY')) {
1063
1242
  throw usageError(error.message);
@@ -1067,10 +1246,8 @@ function mapQueryError(error) {
1067
1246
 
1068
1247
  /**
1069
1248
  * Handles the `path` command: finds a shortest path between two nodes.
1070
- * @param {Object} params
1071
- * @param {Object} params.options - Parsed CLI options
1072
- * @param {string[]} params.args - Remaining positional arguments (path spec)
1073
- * @returns {Promise<{payload: Object, exitCode: number}>} Path result payload
1249
+ * @param {{options: CliOptions, args: string[]}} params
1250
+ * @returns {Promise<{payload: *, exitCode: number}>} Path result payload
1074
1251
  * @throws {CliError} If --from/--to are missing or a node is not found
1075
1252
  */
1076
1253
  async function handlePath({ options, args }) {
@@ -1107,7 +1284,7 @@ async function handlePath({ options, args }) {
1107
1284
  payload,
1108
1285
  exitCode: result.found ? EXIT_CODES.OK : EXIT_CODES.NOT_FOUND,
1109
1286
  };
1110
- } catch (error) {
1287
+ } catch (/** @type {*} */ error) { // TODO(ts-cleanup): type error
1111
1288
  if (error && error.code === 'NODE_NOT_FOUND') {
1112
1289
  throw notFoundError(error.message);
1113
1290
  }
@@ -1117,9 +1294,8 @@ async function handlePath({ options, args }) {
1117
1294
 
1118
1295
  /**
1119
1296
  * Handles the `check` command: reports graph health, GC, and hook status.
1120
- * @param {Object} params
1121
- * @param {Object} params.options - Parsed CLI options
1122
- * @returns {Promise<{payload: Object, exitCode: number}>} Health check payload
1297
+ * @param {{options: CliOptions}} params
1298
+ * @returns {Promise<{payload: *, exitCode: number}>} Health check payload
1123
1299
  */
1124
1300
  async function handleCheck({ options }) {
1125
1301
  const { graph, graphName, persistence } = await openGraph(options);
@@ -1149,17 +1325,20 @@ async function handleCheck({ options }) {
1149
1325
  };
1150
1326
  }
1151
1327
 
1328
+ /** @param {Persistence} persistence */
1152
1329
  async function getHealth(persistence) {
1153
1330
  const clock = ClockAdapter.node();
1154
- const healthService = new HealthCheckService({ persistence, clock });
1331
+ const healthService = new HealthCheckService({ persistence: /** @type {*} */ (persistence), clock }); // TODO(ts-cleanup): narrow port type
1155
1332
  return await healthService.getHealth();
1156
1333
  }
1157
1334
 
1335
+ /** @param {WarpGraphInstance} graph */
1158
1336
  async function getGcMetrics(graph) {
1159
1337
  await graph.materialize();
1160
1338
  return graph.getGCMetrics();
1161
1339
  }
1162
1340
 
1341
+ /** @param {WarpGraphInstance} graph */
1163
1342
  async function collectWriterHeads(graph) {
1164
1343
  const frontier = await graph.getFrontier();
1165
1344
  return [...frontier.entries()]
@@ -1167,6 +1346,10 @@ async function collectWriterHeads(graph) {
1167
1346
  .map(([writerId, sha]) => ({ writerId, sha }));
1168
1347
  }
1169
1348
 
1349
+ /**
1350
+ * @param {Persistence} persistence
1351
+ * @param {string} graphName
1352
+ */
1170
1353
  async function loadCheckpointInfo(persistence, graphName) {
1171
1354
  const checkpointRef = buildCheckpointRef(graphName);
1172
1355
  const checkpointSha = await persistence.readRef(checkpointRef);
@@ -1181,6 +1364,10 @@ async function loadCheckpointInfo(persistence, graphName) {
1181
1364
  };
1182
1365
  }
1183
1366
 
1367
+ /**
1368
+ * @param {Persistence} persistence
1369
+ * @param {string|null} checkpointSha
1370
+ */
1184
1371
  async function readCheckpointDate(persistence, checkpointSha) {
1185
1372
  if (!checkpointSha) {
1186
1373
  return null;
@@ -1189,6 +1376,7 @@ async function readCheckpointDate(persistence, checkpointSha) {
1189
1376
  return info.date || null;
1190
1377
  }
1191
1378
 
1379
+ /** @param {string|null} checkpointDate */
1192
1380
  function computeAgeSeconds(checkpointDate) {
1193
1381
  if (!checkpointDate) {
1194
1382
  return null;
@@ -1200,6 +1388,11 @@ function computeAgeSeconds(checkpointDate) {
1200
1388
  return Math.max(0, Math.floor((Date.now() - parsed) / 1000));
1201
1389
  }
1202
1390
 
1391
+ /**
1392
+ * @param {Persistence} persistence
1393
+ * @param {string} graphName
1394
+ * @param {Array<{writerId: string, sha: string}>} writerHeads
1395
+ */
1203
1396
  async function loadCoverageInfo(persistence, graphName, writerHeads) {
1204
1397
  const coverageRef = buildCoverageRef(graphName);
1205
1398
  const coverageSha = await persistence.readRef(coverageRef);
@@ -1214,6 +1407,11 @@ async function loadCoverageInfo(persistence, graphName, writerHeads) {
1214
1407
  };
1215
1408
  }
1216
1409
 
1410
+ /**
1411
+ * @param {Persistence} persistence
1412
+ * @param {Array<{writerId: string, sha: string}>} writerHeads
1413
+ * @param {string} coverageSha
1414
+ */
1217
1415
  async function findMissingWriters(persistence, writerHeads, coverageSha) {
1218
1416
  const missing = [];
1219
1417
  for (const head of writerHeads) {
@@ -1225,6 +1423,9 @@ async function findMissingWriters(persistence, writerHeads, coverageSha) {
1225
1423
  return missing;
1226
1424
  }
1227
1425
 
1426
+ /**
1427
+ * @param {{repo: string, graphName: string, health: *, checkpoint: *, writerHeads: Array<{writerId: string, sha: string}>, coverage: *, gcMetrics: *, hook: *|null, status: *|null}} params
1428
+ */
1228
1429
  function buildCheckPayload({
1229
1430
  repo,
1230
1431
  graphName,
@@ -1254,10 +1455,8 @@ function buildCheckPayload({
1254
1455
 
1255
1456
  /**
1256
1457
  * Handles the `history` command: shows patch history for a writer.
1257
- * @param {Object} params
1258
- * @param {Object} params.options - Parsed CLI options
1259
- * @param {string[]} params.args - Remaining positional arguments (history options)
1260
- * @returns {Promise<{payload: Object, exitCode: number}>} History payload
1458
+ * @param {{options: CliOptions, args: string[]}} params
1459
+ * @returns {Promise<{payload: *, exitCode: number}>} History payload
1261
1460
  * @throws {CliError} If no patches are found for the writer
1262
1461
  */
1263
1462
  async function handleHistory({ options, args }) {
@@ -1269,15 +1468,15 @@ async function handleHistory({ options, args }) {
1269
1468
  const writerId = options.writer;
1270
1469
  let patches = await graph.getWriterPatches(writerId);
1271
1470
  if (cursorInfo.active) {
1272
- patches = patches.filter(({ patch }) => patch.lamport <= cursorInfo.tick);
1471
+ patches = patches.filter((/** @type {*} */ { patch }) => patch.lamport <= /** @type {number} */ (cursorInfo.tick)); // TODO(ts-cleanup): type CLI payload
1273
1472
  }
1274
1473
  if (patches.length === 0) {
1275
1474
  throw notFoundError(`No patches found for writer: ${writerId}`);
1276
1475
  }
1277
1476
 
1278
1477
  const entries = patches
1279
- .filter(({ patch }) => !historyOptions.node || patchTouchesNode(patch, historyOptions.node))
1280
- .map(({ patch, sha }) => ({
1478
+ .filter((/** @type {*} */ { patch }) => !historyOptions.node || patchTouchesNode(patch, historyOptions.node)) // TODO(ts-cleanup): type CLI payload
1479
+ .map((/** @type {*} */ { patch, sha }) => ({ // TODO(ts-cleanup): type CLI payload
1281
1480
  sha,
1282
1481
  schema: patch.schema,
1283
1482
  lamport: patch.lamport,
@@ -1299,12 +1498,8 @@ async function handleHistory({ options, args }) {
1299
1498
  * Materializes a single graph, creates a checkpoint, and returns summary stats.
1300
1499
  * When a ceiling tick is provided (seek cursor active), the checkpoint step is
1301
1500
  * skipped because the user is exploring historical state, not persisting it.
1302
- * @param {Object} params
1303
- * @param {Object} params.persistence - GraphPersistencePort adapter
1304
- * @param {string} params.graphName - Name of the graph to materialize
1305
- * @param {string} params.writerId - Writer ID for the CLI session
1306
- * @param {number} [params.ceiling] - Optional seek ceiling tick
1307
- * @returns {Promise<{graph: string, nodes: number, edges: number, properties: number, checkpoint: string|null, writers: Object, patchCount: number}>}
1501
+ * @param {{persistence: Persistence, graphName: string, writerId: string, ceiling?: number}} params
1502
+ * @returns {Promise<{graph: string, nodes: number, edges: number, properties: number, checkpoint: string|null, writers: Record<string, number>, patchCount: number}>}
1308
1503
  */
1309
1504
  async function materializeOneGraph({ persistence, graphName, writerId, ceiling }) {
1310
1505
  const graph = await WarpGraph.open({ persistence, graphName, writerId, crypto: new NodeCryptoAdapter() });
@@ -1315,6 +1510,7 @@ async function materializeOneGraph({ persistence, graphName, writerId, ceiling }
1315
1510
  const status = await graph.status();
1316
1511
 
1317
1512
  // Build per-writer patch counts for the view renderer
1513
+ /** @type {Record<string, number>} */
1318
1514
  const writers = {};
1319
1515
  let totalPatchCount = 0;
1320
1516
  for (const wId of Object.keys(status.frontier)) {
@@ -1338,9 +1534,8 @@ async function materializeOneGraph({ persistence, graphName, writerId, ceiling }
1338
1534
 
1339
1535
  /**
1340
1536
  * Handles the `materialize` command: materializes and checkpoints all graphs.
1341
- * @param {Object} params
1342
- * @param {Object} params.options - Parsed CLI options
1343
- * @returns {Promise<{payload: Object, exitCode: number}>} Materialize result payload
1537
+ * @param {{options: CliOptions}} params
1538
+ * @returns {Promise<{payload: *, exitCode: number}>} Materialize result payload
1344
1539
  * @throws {CliError} If the specified graph is not found
1345
1540
  */
1346
1541
  async function handleMaterialize({ options }) {
@@ -1387,13 +1582,14 @@ async function handleMaterialize({ options }) {
1387
1582
  }
1388
1583
  }
1389
1584
 
1390
- const allFailed = results.every((r) => r.error);
1585
+ const allFailed = results.every((r) => /** @type {*} */ (r).error); // TODO(ts-cleanup): type CLI payload
1391
1586
  return {
1392
1587
  payload: { graphs: results },
1393
1588
  exitCode: allFailed ? EXIT_CODES.INTERNAL : EXIT_CODES.OK,
1394
1589
  };
1395
1590
  }
1396
1591
 
1592
+ /** @param {*} payload */
1397
1593
  function renderMaterialize(payload) {
1398
1594
  if (payload.graphs.length === 0) {
1399
1595
  return 'No graphs found in repo.\n';
@@ -1410,6 +1606,7 @@ function renderMaterialize(payload) {
1410
1606
  return `${lines.join('\n')}\n`;
1411
1607
  }
1412
1608
 
1609
+ /** @param {*} payload */
1413
1610
  function renderInstallHooks(payload) {
1414
1611
  if (payload.action === 'up-to-date') {
1415
1612
  return `Hook: already up to date (v${payload.version}) at ${payload.hookPath}\n`;
@@ -1430,7 +1627,7 @@ function createHookInstaller() {
1430
1627
  const templateDir = path.resolve(__dirname, '..', 'hooks');
1431
1628
  const { version } = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8'));
1432
1629
  return new HookInstaller({
1433
- fs,
1630
+ fs: /** @type {*} */ (fs), // TODO(ts-cleanup): narrow port type
1434
1631
  execGitConfig: execGitConfigValue,
1435
1632
  version,
1436
1633
  templateDir,
@@ -1438,6 +1635,11 @@ function createHookInstaller() {
1438
1635
  });
1439
1636
  }
1440
1637
 
1638
+ /**
1639
+ * @param {string} repoPath
1640
+ * @param {string} key
1641
+ * @returns {string|null}
1642
+ */
1441
1643
  function execGitConfigValue(repoPath, key) {
1442
1644
  try {
1443
1645
  if (key === '--git-dir') {
@@ -1457,6 +1659,7 @@ function isInteractive() {
1457
1659
  return Boolean(process.stderr.isTTY);
1458
1660
  }
1459
1661
 
1662
+ /** @param {string} question @returns {Promise<string>} */
1460
1663
  function promptUser(question) {
1461
1664
  const rl = readline.createInterface({
1462
1665
  input: process.stdin,
@@ -1470,6 +1673,7 @@ function promptUser(question) {
1470
1673
  });
1471
1674
  }
1472
1675
 
1676
+ /** @param {string[]} args */
1473
1677
  function parseInstallHooksArgs(args) {
1474
1678
  const options = { force: false };
1475
1679
  for (const arg of args) {
@@ -1482,6 +1686,10 @@ function parseInstallHooksArgs(args) {
1482
1686
  return options;
1483
1687
  }
1484
1688
 
1689
+ /**
1690
+ * @param {*} classification
1691
+ * @param {{force: boolean}} hookOptions
1692
+ */
1485
1693
  async function resolveStrategy(classification, hookOptions) {
1486
1694
  if (hookOptions.force) {
1487
1695
  return 'replace';
@@ -1498,6 +1706,7 @@ async function resolveStrategy(classification, hookOptions) {
1498
1706
  return await promptForForeignStrategy();
1499
1707
  }
1500
1708
 
1709
+ /** @param {*} classification */
1501
1710
  async function promptForOursStrategy(classification) {
1502
1711
  const installer = createHookInstaller();
1503
1712
  if (classification.version === installer._version) {
@@ -1539,10 +1748,8 @@ async function promptForForeignStrategy() {
1539
1748
 
1540
1749
  /**
1541
1750
  * Handles the `install-hooks` command: installs or upgrades the post-merge git hook.
1542
- * @param {Object} params
1543
- * @param {Object} params.options - Parsed CLI options
1544
- * @param {string[]} params.args - Remaining positional arguments (install-hooks options)
1545
- * @returns {Promise<{payload: Object, exitCode: number}>} Install result payload
1751
+ * @param {{options: CliOptions, args: string[]}} params
1752
+ * @returns {Promise<{payload: *, exitCode: number}>} Install result payload
1546
1753
  * @throws {CliError} If an existing hook is found and the session is not interactive
1547
1754
  */
1548
1755
  async function handleInstallHooks({ options, args }) {
@@ -1578,6 +1785,7 @@ async function handleInstallHooks({ options, args }) {
1578
1785
  };
1579
1786
  }
1580
1787
 
1788
+ /** @param {string} hookPath */
1581
1789
  function readHookContent(hookPath) {
1582
1790
  try {
1583
1791
  return fs.readFileSync(hookPath, 'utf8');
@@ -1586,6 +1794,7 @@ function readHookContent(hookPath) {
1586
1794
  }
1587
1795
  }
1588
1796
 
1797
+ /** @param {string} repoPath */
1589
1798
  function getHookStatusForCheck(repoPath) {
1590
1799
  try {
1591
1800
  const installer = createHookInstaller();
@@ -1602,10 +1811,9 @@ function getHookStatusForCheck(repoPath) {
1602
1811
  /**
1603
1812
  * Reads the active seek cursor for a graph from Git ref storage.
1604
1813
  *
1605
- * @private
1606
- * @param {Object} persistence - GraphPersistencePort adapter
1814
+ * @param {Persistence} persistence - GraphPersistencePort adapter
1607
1815
  * @param {string} graphName - Name of the WARP graph
1608
- * @returns {Promise<{tick: number, mode?: string}|null>} Cursor object, or null if no active cursor
1816
+ * @returns {Promise<CursorBlob|null>} Cursor object, or null if no active cursor
1609
1817
  * @throws {Error} If the stored blob is corrupted or not valid JSON
1610
1818
  */
1611
1819
  async function readActiveCursor(persistence, graphName) {
@@ -1624,10 +1832,9 @@ async function readActiveCursor(persistence, graphName) {
1624
1832
  * Serializes the cursor as JSON, stores it as a Git blob, and points
1625
1833
  * the active cursor ref at that blob.
1626
1834
  *
1627
- * @private
1628
- * @param {Object} persistence - GraphPersistencePort adapter
1835
+ * @param {Persistence} persistence - GraphPersistencePort adapter
1629
1836
  * @param {string} graphName - Name of the WARP graph
1630
- * @param {{tick: number, mode?: string}} cursor - Cursor state to persist
1837
+ * @param {CursorBlob} cursor - Cursor state to persist
1631
1838
  * @returns {Promise<void>}
1632
1839
  */
1633
1840
  async function writeActiveCursor(persistence, graphName, cursor) {
@@ -1642,8 +1849,7 @@ async function writeActiveCursor(persistence, graphName, cursor) {
1642
1849
  *
1643
1850
  * No-op if no active cursor exists.
1644
1851
  *
1645
- * @private
1646
- * @param {Object} persistence - GraphPersistencePort adapter
1852
+ * @param {Persistence} persistence - GraphPersistencePort adapter
1647
1853
  * @param {string} graphName - Name of the WARP graph
1648
1854
  * @returns {Promise<void>}
1649
1855
  */
@@ -1658,11 +1864,10 @@ async function clearActiveCursor(persistence, graphName) {
1658
1864
  /**
1659
1865
  * Reads a named saved cursor from Git ref storage.
1660
1866
  *
1661
- * @private
1662
- * @param {Object} persistence - GraphPersistencePort adapter
1867
+ * @param {Persistence} persistence - GraphPersistencePort adapter
1663
1868
  * @param {string} graphName - Name of the WARP graph
1664
1869
  * @param {string} name - Saved cursor name
1665
- * @returns {Promise<{tick: number, mode?: string}|null>} Cursor object, or null if not found
1870
+ * @returns {Promise<CursorBlob|null>} Cursor object, or null if not found
1666
1871
  * @throws {Error} If the stored blob is corrupted or not valid JSON
1667
1872
  */
1668
1873
  async function readSavedCursor(persistence, graphName, name) {
@@ -1681,11 +1886,10 @@ async function readSavedCursor(persistence, graphName, name) {
1681
1886
  * Serializes the cursor as JSON, stores it as a Git blob, and points
1682
1887
  * the named saved-cursor ref at that blob.
1683
1888
  *
1684
- * @private
1685
- * @param {Object} persistence - GraphPersistencePort adapter
1889
+ * @param {Persistence} persistence - GraphPersistencePort adapter
1686
1890
  * @param {string} graphName - Name of the WARP graph
1687
1891
  * @param {string} name - Saved cursor name
1688
- * @param {{tick: number, mode?: string}} cursor - Cursor state to persist
1892
+ * @param {CursorBlob} cursor - Cursor state to persist
1689
1893
  * @returns {Promise<void>}
1690
1894
  */
1691
1895
  async function writeSavedCursor(persistence, graphName, name, cursor) {
@@ -1700,8 +1904,7 @@ async function writeSavedCursor(persistence, graphName, name, cursor) {
1700
1904
  *
1701
1905
  * No-op if the named cursor does not exist.
1702
1906
  *
1703
- * @private
1704
- * @param {Object} persistence - GraphPersistencePort adapter
1907
+ * @param {Persistence} persistence - GraphPersistencePort adapter
1705
1908
  * @param {string} graphName - Name of the WARP graph
1706
1909
  * @param {string} name - Saved cursor name to delete
1707
1910
  * @returns {Promise<void>}
@@ -1717,8 +1920,7 @@ async function deleteSavedCursor(persistence, graphName, name) {
1717
1920
  /**
1718
1921
  * Lists all saved cursors for a graph, reading each blob to include full cursor state.
1719
1922
  *
1720
- * @private
1721
- * @param {Object} persistence - GraphPersistencePort adapter
1923
+ * @param {Persistence} persistence - GraphPersistencePort adapter
1722
1924
  * @param {string} graphName - Name of the WARP graph
1723
1925
  * @returns {Promise<Array<{name: string, tick: number, mode?: string}>>} Array of saved cursors with their names
1724
1926
  * @throws {Error} If any stored blob is corrupted or not valid JSON
@@ -1745,24 +1947,92 @@ async function listSavedCursors(persistence, graphName) {
1745
1947
  // Seek Arg Parser
1746
1948
  // ============================================================================
1747
1949
 
1950
+ /**
1951
+ * @param {string} arg
1952
+ * @param {SeekSpec} spec
1953
+ */
1954
+ function handleSeekBooleanFlag(arg, spec) {
1955
+ if (arg === '--clear-cache') {
1956
+ if (spec.action !== 'status') {
1957
+ throw usageError('--clear-cache cannot be combined with other seek flags');
1958
+ }
1959
+ spec.action = 'clear-cache';
1960
+ } else if (arg === '--no-persistent-cache') {
1961
+ spec.noPersistentCache = true;
1962
+ } else if (arg === '--diff') {
1963
+ spec.diff = true;
1964
+ }
1965
+ }
1966
+
1967
+ /**
1968
+ * Parses --diff-limit / --diff-limit=N into the seek spec.
1969
+ * @param {string} arg
1970
+ * @param {string[]} args
1971
+ * @param {number} i
1972
+ * @param {SeekSpec} spec
1973
+ */
1974
+ function handleDiffLimitFlag(arg, args, i, spec) {
1975
+ let raw;
1976
+ if (arg.startsWith('--diff-limit=')) {
1977
+ raw = arg.slice('--diff-limit='.length);
1978
+ } else {
1979
+ raw = args[i + 1];
1980
+ if (raw === undefined) {
1981
+ throw usageError('Missing value for --diff-limit');
1982
+ }
1983
+ }
1984
+ const n = Number(raw);
1985
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
1986
+ throw usageError(`Invalid --diff-limit value: ${raw}. Must be a positive integer.`);
1987
+ }
1988
+ spec.diffLimit = n;
1989
+ }
1990
+
1991
+ /**
1992
+ * Parses a named action flag (--save, --load, --drop) with its value.
1993
+ * @param {string} flagName - e.g. 'save'
1994
+ * @param {string} arg - Current arg token
1995
+ * @param {string[]} args - All args
1996
+ * @param {number} i - Current index
1997
+ * @param {SeekSpec} spec
1998
+ * @returns {number} Number of extra args consumed (0 or 1)
1999
+ */
2000
+ function parseSeekNamedAction(flagName, arg, args, i, spec) {
2001
+ if (spec.action !== 'status') {
2002
+ throw usageError(`--${flagName} cannot be combined with other seek flags`);
2003
+ }
2004
+ spec.action = flagName;
2005
+ if (arg === `--${flagName}`) {
2006
+ const val = args[i + 1];
2007
+ if (val === undefined || val.startsWith('-')) {
2008
+ throw usageError(`Missing name for --${flagName}`);
2009
+ }
2010
+ spec.name = val;
2011
+ return 1;
2012
+ }
2013
+ spec.name = arg.slice(`--${flagName}=`.length);
2014
+ if (!spec.name) {
2015
+ throw usageError(`Missing name for --${flagName}`);
2016
+ }
2017
+ return 0;
2018
+ }
2019
+
1748
2020
  /**
1749
2021
  * Parses CLI arguments for the `seek` command into a structured spec.
1750
- *
1751
- * Supports mutually exclusive actions: `--tick <value>`, `--latest`,
1752
- * `--save <name>`, `--load <name>`, `--list`, `--drop <name>`.
1753
- * Defaults to `status` when no flags are provided.
1754
- *
1755
- * @private
1756
2022
  * @param {string[]} args - Raw CLI arguments following the `seek` subcommand
1757
- * @returns {{action: string, tickValue: string|null, name: string|null}} Parsed spec
1758
- * @throws {CliError} If arguments are invalid or flags are combined
2023
+ * @returns {SeekSpec} Parsed spec
1759
2024
  */
1760
2025
  function parseSeekArgs(args) {
2026
+ /** @type {SeekSpec} */
1761
2027
  const spec = {
1762
- action: 'status', // status, tick, latest, save, load, list, drop
2028
+ action: 'status', // status, tick, latest, save, load, list, drop, clear-cache
1763
2029
  tickValue: null,
1764
2030
  name: null,
2031
+ noPersistentCache: false,
2032
+ diff: false,
2033
+ diffLimit: 2000,
1765
2034
  };
2035
+ let diffLimitProvided = false;
1766
2036
 
1767
2037
  for (let i = 0; i < args.length; i++) {
1768
2038
  const arg = args[i];
@@ -1789,76 +2059,39 @@ function parseSeekArgs(args) {
1789
2059
  throw usageError('--latest cannot be combined with other seek flags');
1790
2060
  }
1791
2061
  spec.action = 'latest';
1792
- } else if (arg === '--save') {
1793
- if (spec.action !== 'status') {
1794
- throw usageError('--save cannot be combined with other seek flags');
1795
- }
1796
- spec.action = 'save';
1797
- const val = args[i + 1];
1798
- if (val === undefined || val.startsWith('-')) {
1799
- throw usageError('Missing name for --save');
1800
- }
1801
- spec.name = val;
1802
- i += 1;
1803
- } else if (arg.startsWith('--save=')) {
1804
- if (spec.action !== 'status') {
1805
- throw usageError('--save cannot be combined with other seek flags');
1806
- }
1807
- spec.action = 'save';
1808
- spec.name = arg.slice('--save='.length);
1809
- if (!spec.name) {
1810
- throw usageError('Missing name for --save');
1811
- }
1812
- } else if (arg === '--load') {
1813
- if (spec.action !== 'status') {
1814
- throw usageError('--load cannot be combined with other seek flags');
1815
- }
1816
- spec.action = 'load';
1817
- const val = args[i + 1];
1818
- if (val === undefined || val.startsWith('-')) {
1819
- throw usageError('Missing name for --load');
1820
- }
1821
- spec.name = val;
1822
- i += 1;
1823
- } else if (arg.startsWith('--load=')) {
1824
- if (spec.action !== 'status') {
1825
- throw usageError('--load cannot be combined with other seek flags');
1826
- }
1827
- spec.action = 'load';
1828
- spec.name = arg.slice('--load='.length);
1829
- if (!spec.name) {
1830
- throw usageError('Missing name for --load');
1831
- }
2062
+ } else if (arg === '--save' || arg.startsWith('--save=')) {
2063
+ i += parseSeekNamedAction('save', arg, args, i, spec);
2064
+ } else if (arg === '--load' || arg.startsWith('--load=')) {
2065
+ i += parseSeekNamedAction('load', arg, args, i, spec);
1832
2066
  } else if (arg === '--list') {
1833
2067
  if (spec.action !== 'status') {
1834
2068
  throw usageError('--list cannot be combined with other seek flags');
1835
2069
  }
1836
2070
  spec.action = 'list';
1837
- } else if (arg === '--drop') {
1838
- if (spec.action !== 'status') {
1839
- throw usageError('--drop cannot be combined with other seek flags');
1840
- }
1841
- spec.action = 'drop';
1842
- const val = args[i + 1];
1843
- if (val === undefined || val.startsWith('-')) {
1844
- throw usageError('Missing name for --drop');
1845
- }
1846
- spec.name = val;
1847
- i += 1;
1848
- } else if (arg.startsWith('--drop=')) {
1849
- if (spec.action !== 'status') {
1850
- throw usageError('--drop cannot be combined with other seek flags');
1851
- }
1852
- spec.action = 'drop';
1853
- spec.name = arg.slice('--drop='.length);
1854
- if (!spec.name) {
1855
- throw usageError('Missing name for --drop');
2071
+ } else if (arg === '--drop' || arg.startsWith('--drop=')) {
2072
+ i += parseSeekNamedAction('drop', arg, args, i, spec);
2073
+ } else if (arg === '--clear-cache' || arg === '--no-persistent-cache' || arg === '--diff') {
2074
+ handleSeekBooleanFlag(arg, spec);
2075
+ } else if (arg === '--diff-limit' || arg.startsWith('--diff-limit=')) {
2076
+ handleDiffLimitFlag(arg, args, i, spec);
2077
+ diffLimitProvided = true;
2078
+ if (arg === '--diff-limit') {
2079
+ i += 1;
1856
2080
  }
1857
2081
  } else if (arg.startsWith('-')) {
1858
2082
  throw usageError(`Unknown seek option: ${arg}`);
1859
2083
  }
1860
2084
  }
1861
2085
 
2086
+ // --diff is only meaningful for actions that navigate to a tick
2087
+ const DIFF_ACTIONS = new Set(['tick', 'latest', 'load']);
2088
+ if (spec.diff && !DIFF_ACTIONS.has(spec.action)) {
2089
+ throw usageError(`--diff cannot be used with --${spec.action}`);
2090
+ }
2091
+ if (diffLimitProvided && !spec.diff) {
2092
+ throw usageError('--diff-limit requires --diff');
2093
+ }
2094
+
1862
2095
  return spec;
1863
2096
  }
1864
2097
 
@@ -1909,28 +2142,44 @@ function resolveTickValue(tickValue, currentTick, ticks, maxTick) {
1909
2142
  // Seek Handler
1910
2143
  // ============================================================================
1911
2144
 
2145
+ /**
2146
+ * @param {WarpGraphInstance} graph
2147
+ * @param {Persistence} persistence
2148
+ * @param {string} graphName
2149
+ * @param {SeekSpec} seekSpec
2150
+ */
2151
+ function wireSeekCache(graph, persistence, graphName, seekSpec) {
2152
+ if (seekSpec.noPersistentCache) {
2153
+ return;
2154
+ }
2155
+ graph.setSeekCache(new CasSeekCacheAdapter({
2156
+ persistence,
2157
+ plumbing: persistence.plumbing,
2158
+ graphName,
2159
+ }));
2160
+ }
2161
+
1912
2162
  /**
1913
2163
  * Handles the `git warp seek` command across all sub-actions.
1914
- *
1915
- * Dispatches to the appropriate logic based on the parsed action:
1916
- * - `status`: show current cursor position or "no cursor" state
1917
- * - `tick`: set the cursor to an absolute or relative Lamport tick
1918
- * - `latest`: clear the cursor, returning to present state
1919
- * - `save`: persist the active cursor under a name
1920
- * - `load`: restore a named cursor as the active cursor
1921
- * - `list`: enumerate all saved cursors
1922
- * - `drop`: delete a named saved cursor
1923
- *
1924
- * @private
1925
- * @param {Object} params - Command parameters
1926
- * @param {Object} params.options - CLI options (repo, graph, writer, json)
1927
- * @param {string[]} params.args - Raw CLI arguments following the `seek` subcommand
1928
- * @returns {Promise<{payload: Object, exitCode: number}>} Command result with payload and exit code
1929
- * @throws {CliError} On invalid arguments or missing cursors
2164
+ * @param {{options: CliOptions, args: string[]}} params
2165
+ * @returns {Promise<{payload: *, exitCode: number}>}
1930
2166
  */
1931
2167
  async function handleSeek({ options, args }) {
1932
2168
  const seekSpec = parseSeekArgs(args);
1933
2169
  const { graph, graphName, persistence } = await openGraph(options);
2170
+ void wireSeekCache(graph, persistence, graphName, seekSpec);
2171
+
2172
+ // Handle --clear-cache before discovering ticks (no materialization needed)
2173
+ if (seekSpec.action === 'clear-cache') {
2174
+ if (graph.seekCache) {
2175
+ await graph.seekCache.clear();
2176
+ }
2177
+ return {
2178
+ payload: { graph: graphName, action: 'clear-cache', message: 'Seek cache cleared.' },
2179
+ exitCode: EXIT_CODES.OK,
2180
+ };
2181
+ }
2182
+
1934
2183
  const activeCursor = await readActiveCursor(persistence, graphName);
1935
2184
  const { ticks, maxTick, perWriter } = await graph.discoverTicks();
1936
2185
  const frontierHash = computeFrontierHash(perWriter);
@@ -1948,11 +2197,12 @@ async function handleSeek({ options, args }) {
1948
2197
  };
1949
2198
  }
1950
2199
  if (seekSpec.action === 'drop') {
1951
- const existing = await readSavedCursor(persistence, graphName, seekSpec.name);
2200
+ const dropName = /** @type {string} */ (seekSpec.name);
2201
+ const existing = await readSavedCursor(persistence, graphName, dropName);
1952
2202
  if (!existing) {
1953
- throw notFoundError(`Saved cursor not found: ${seekSpec.name}`);
2203
+ throw notFoundError(`Saved cursor not found: ${dropName}`);
1954
2204
  }
1955
- await deleteSavedCursor(persistence, graphName, seekSpec.name);
2205
+ await deleteSavedCursor(persistence, graphName, dropName);
1956
2206
  return {
1957
2207
  payload: {
1958
2208
  graph: graphName,
@@ -1964,8 +2214,16 @@ async function handleSeek({ options, args }) {
1964
2214
  };
1965
2215
  }
1966
2216
  if (seekSpec.action === 'latest') {
2217
+ const prevTick = activeCursor ? activeCursor.tick : null;
2218
+ let sdResult = null;
2219
+ if (seekSpec.diff) {
2220
+ sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: maxTick, diffLimit: seekSpec.diffLimit });
2221
+ }
1967
2222
  await clearActiveCursor(persistence, graphName);
1968
- await graph.materialize();
2223
+ // When --diff already materialized at maxTick, skip redundant re-materialize
2224
+ if (!sdResult) {
2225
+ await graph.materialize({ ceiling: maxTick });
2226
+ }
1969
2227
  const nodes = await graph.getNodes();
1970
2228
  const edges = await graph.getEdges();
1971
2229
  const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
@@ -1984,6 +2242,7 @@ async function handleSeek({ options, args }) {
1984
2242
  diff,
1985
2243
  tickReceipt,
1986
2244
  cursor: { active: false },
2245
+ ...sdResult,
1987
2246
  },
1988
2247
  exitCode: EXIT_CODES.OK,
1989
2248
  };
@@ -1992,7 +2251,7 @@ async function handleSeek({ options, args }) {
1992
2251
  if (!activeCursor) {
1993
2252
  throw usageError('No active cursor to save. Use --tick first.');
1994
2253
  }
1995
- await writeSavedCursor(persistence, graphName, seekSpec.name, activeCursor);
2254
+ await writeSavedCursor(persistence, graphName, /** @type {string} */ (seekSpec.name), activeCursor);
1996
2255
  return {
1997
2256
  payload: {
1998
2257
  graph: graphName,
@@ -2004,11 +2263,20 @@ async function handleSeek({ options, args }) {
2004
2263
  };
2005
2264
  }
2006
2265
  if (seekSpec.action === 'load') {
2007
- const saved = await readSavedCursor(persistence, graphName, seekSpec.name);
2266
+ const loadName = /** @type {string} */ (seekSpec.name);
2267
+ const saved = await readSavedCursor(persistence, graphName, loadName);
2008
2268
  if (!saved) {
2009
- throw notFoundError(`Saved cursor not found: ${seekSpec.name}`);
2269
+ throw notFoundError(`Saved cursor not found: ${loadName}`);
2270
+ }
2271
+ const prevTick = activeCursor ? activeCursor.tick : null;
2272
+ let sdResult = null;
2273
+ if (seekSpec.diff) {
2274
+ sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: saved.tick, diffLimit: seekSpec.diffLimit });
2275
+ }
2276
+ // When --diff already materialized at saved.tick, skip redundant call
2277
+ if (!sdResult) {
2278
+ await graph.materialize({ ceiling: saved.tick });
2010
2279
  }
2011
- await graph.materialize({ ceiling: saved.tick });
2012
2280
  const nodes = await graph.getNodes();
2013
2281
  const edges = await graph.getEdges();
2014
2282
  await writeActiveCursor(persistence, graphName, { tick: saved.tick, mode: saved.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
@@ -2029,14 +2297,22 @@ async function handleSeek({ options, args }) {
2029
2297
  diff,
2030
2298
  tickReceipt,
2031
2299
  cursor: { active: true, mode: saved.mode, tick: saved.tick, maxTick, name: seekSpec.name },
2300
+ ...sdResult,
2032
2301
  },
2033
2302
  exitCode: EXIT_CODES.OK,
2034
2303
  };
2035
2304
  }
2036
2305
  if (seekSpec.action === 'tick') {
2037
2306
  const currentTick = activeCursor ? activeCursor.tick : null;
2038
- const resolvedTick = resolveTickValue(seekSpec.tickValue, currentTick, ticks, maxTick);
2039
- await graph.materialize({ ceiling: resolvedTick });
2307
+ const resolvedTick = resolveTickValue(/** @type {string} */ (seekSpec.tickValue), currentTick, ticks, maxTick);
2308
+ let sdResult = null;
2309
+ if (seekSpec.diff) {
2310
+ sdResult = await computeStructuralDiff({ graph, prevTick: currentTick, currentTick: resolvedTick, diffLimit: seekSpec.diffLimit });
2311
+ }
2312
+ // When --diff already materialized at resolvedTick, skip redundant call
2313
+ if (!sdResult) {
2314
+ await graph.materialize({ ceiling: resolvedTick });
2315
+ }
2040
2316
  const nodes = await graph.getNodes();
2041
2317
  const edges = await graph.getEdges();
2042
2318
  await writeActiveCursor(persistence, graphName, { tick: resolvedTick, mode: 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
@@ -2056,12 +2332,22 @@ async function handleSeek({ options, args }) {
2056
2332
  diff,
2057
2333
  tickReceipt,
2058
2334
  cursor: { active: true, mode: 'lamport', tick: resolvedTick, maxTick, name: 'active' },
2335
+ ...sdResult,
2059
2336
  },
2060
2337
  exitCode: EXIT_CODES.OK,
2061
2338
  };
2062
2339
  }
2063
2340
 
2064
2341
  // status (bare seek)
2342
+ return await handleSeekStatus({ graph, graphName, persistence, activeCursor, ticks, maxTick, perWriter, frontierHash });
2343
+ }
2344
+
2345
+ /**
2346
+ * Handles the `status` sub-action of `seek` (bare seek with no action flag).
2347
+ * @param {{graph: WarpGraphInstance, graphName: string, persistence: Persistence, activeCursor: CursorBlob|null, ticks: number[], maxTick: number, perWriter: Map<string, WriterTickInfo>, frontierHash: string}} params
2348
+ * @returns {Promise<{payload: *, exitCode: number}>}
2349
+ */
2350
+ async function handleSeekStatus({ graph, graphName, persistence, activeCursor, ticks, maxTick, perWriter, frontierHash }) {
2065
2351
  if (activeCursor) {
2066
2352
  await graph.materialize({ ceiling: activeCursor.tick });
2067
2353
  const nodes = await graph.getNodes();
@@ -2117,11 +2403,11 @@ async function handleSeek({ options, args }) {
2117
2403
  /**
2118
2404
  * Converts the per-writer Map from discoverTicks() into a plain object for JSON output.
2119
2405
  *
2120
- * @private
2121
- * @param {Map<string, {ticks: number[], tipSha: string|null}>} perWriter - Per-writer tick data
2122
- * @returns {Object<string, {ticks: number[], tipSha: string|null}>} Plain object keyed by writer ID
2406
+ * @param {Map<string, WriterTickInfo>} perWriter - Per-writer tick data
2407
+ * @returns {Record<string, WriterTickInfo>} Plain object keyed by writer ID
2123
2408
  */
2124
2409
  function serializePerWriter(perWriter) {
2410
+ /** @type {Record<string, WriterTickInfo>} */
2125
2411
  const result = {};
2126
2412
  for (const [writerId, info] of perWriter) {
2127
2413
  result[writerId] = { ticks: info.ticks, tipSha: info.tipSha, tickShas: info.tickShas };
@@ -2132,9 +2418,8 @@ function serializePerWriter(perWriter) {
2132
2418
  /**
2133
2419
  * Counts the total number of patches across all writers at or before the given tick.
2134
2420
  *
2135
- * @private
2136
2421
  * @param {number} tick - Lamport tick ceiling (inclusive)
2137
- * @param {Map<string, {ticks: number[], tipSha: string|null}>} perWriter - Per-writer tick data
2422
+ * @param {Map<string, WriterTickInfo>} perWriter - Per-writer tick data
2138
2423
  * @returns {number} Total patch count at or before the given tick
2139
2424
  */
2140
2425
  function countPatchesAtTick(tick, perWriter) {
@@ -2155,11 +2440,11 @@ function countPatchesAtTick(tick, perWriter) {
2155
2440
  * Used to suppress seek diffs when graph history may have changed since the
2156
2441
  * previous cursor snapshot (e.g. new writers/patches, rewritten refs).
2157
2442
  *
2158
- * @private
2159
- * @param {Map<string, {tipSha: string|null}>} perWriter - Per-writer metadata from discoverTicks()
2443
+ * @param {Map<string, WriterTickInfo>} perWriter - Per-writer metadata from discoverTicks()
2160
2444
  * @returns {string} Hex digest of the frontier fingerprint
2161
2445
  */
2162
2446
  function computeFrontierHash(perWriter) {
2447
+ /** @type {Record<string, string|null>} */
2163
2448
  const tips = {};
2164
2449
  for (const [writerId, info] of perWriter) {
2165
2450
  tips[writerId] = info?.tipSha || null;
@@ -2173,8 +2458,7 @@ function computeFrontierHash(perWriter) {
2173
2458
  * Counts may be missing for older cursors (pre-diff support). In that case
2174
2459
  * callers should treat the counts as unknown and suppress diffs.
2175
2460
  *
2176
- * @private
2177
- * @param {Object|null} cursor - Parsed cursor blob object
2461
+ * @param {CursorBlob|null} cursor - Parsed cursor blob object
2178
2462
  * @returns {{nodes: number|null, edges: number|null}} Parsed counts
2179
2463
  */
2180
2464
  function readSeekCounts(cursor) {
@@ -2192,8 +2476,7 @@ function readSeekCounts(cursor) {
2192
2476
  *
2193
2477
  * Returns null if the previous cursor is missing cached counts.
2194
2478
  *
2195
- * @private
2196
- * @param {Object|null} prevCursor - Cursor object read before updating the position
2479
+ * @param {CursorBlob|null} prevCursor - Cursor object read before updating the position
2197
2480
  * @param {{nodes: number, edges: number}} next - Current materialized counts
2198
2481
  * @param {string} frontierHash - Frontier fingerprint of the current graph
2199
2482
  * @returns {{nodes: number, edges: number}|null} Diff object or null when unknown
@@ -2220,22 +2503,19 @@ function computeSeekStateDiff(prevCursor, next, frontierHash) {
2220
2503
  * summarizes patch ops. Typically only a handful of writers have a patch at any
2221
2504
  * single Lamport tick.
2222
2505
  *
2223
- * @private
2224
- * @param {Object} params
2225
- * @param {number} params.tick - Lamport tick to summarize
2226
- * @param {Map<string, {tickShas?: Object}>} params.perWriter - Per-writer tick metadata from discoverTicks()
2227
- * @param {Object} params.graph - WarpGraph instance
2228
- * @returns {Promise<Object<string, Object>|null>} Map of writerId → { sha, opSummary }, or null if empty
2506
+ * @param {{tick: number, perWriter: Map<string, WriterTickInfo>, graph: WarpGraphInstance}} params
2507
+ * @returns {Promise<Record<string, {sha: string, opSummary: *}>|null>} Map of writerId to { sha, opSummary }, or null if empty
2229
2508
  */
2230
2509
  async function buildTickReceipt({ tick, perWriter, graph }) {
2231
2510
  if (!Number.isInteger(tick) || tick <= 0) {
2232
2511
  return null;
2233
2512
  }
2234
2513
 
2514
+ /** @type {Record<string, {sha: string, opSummary: *}>} */
2235
2515
  const receipt = {};
2236
2516
 
2237
2517
  for (const [writerId, info] of perWriter) {
2238
- const sha = info?.tickShas?.[tick];
2518
+ const sha = /** @type {*} */ (info?.tickShas)?.[tick]; // TODO(ts-cleanup): type CLI payload
2239
2519
  if (!sha) {
2240
2520
  continue;
2241
2521
  }
@@ -2248,17 +2528,89 @@ async function buildTickReceipt({ tick, perWriter, graph }) {
2248
2528
  return Object.keys(receipt).length > 0 ? receipt : null;
2249
2529
  }
2250
2530
 
2531
+ /**
2532
+ * Computes a structural diff between the state at a previous tick and
2533
+ * the state at the current tick.
2534
+ *
2535
+ * Materializes the baseline tick first, snapshots the state, then
2536
+ * materializes the target tick and calls diffStates() between the two.
2537
+ * Applies diffLimit truncation when the total change count exceeds the cap.
2538
+ *
2539
+ * @param {{graph: WarpGraphInstance, prevTick: number|null, currentTick: number, diffLimit: number}} params
2540
+ * @returns {Promise<{structuralDiff: *, diffBaseline: string, baselineTick: number|null, truncated: boolean, totalChanges: number, shownChanges: number}>}
2541
+ */
2542
+ async function computeStructuralDiff({ graph, prevTick, currentTick, diffLimit }) {
2543
+ let beforeState = null;
2544
+ let diffBaseline = 'empty';
2545
+ let baselineTick = null;
2546
+
2547
+ // Short-circuit: same tick produces an empty diff
2548
+ if (prevTick !== null && prevTick === currentTick) {
2549
+ const empty = { nodes: { added: [], removed: [] }, edges: { added: [], removed: [] }, props: { set: [], removed: [] } };
2550
+ return { structuralDiff: empty, diffBaseline: 'tick', baselineTick: prevTick, truncated: false, totalChanges: 0, shownChanges: 0 };
2551
+ }
2552
+
2553
+ if (prevTick !== null && prevTick > 0) {
2554
+ await graph.materialize({ ceiling: prevTick });
2555
+ beforeState = await graph.getStateSnapshot();
2556
+ diffBaseline = 'tick';
2557
+ baselineTick = prevTick;
2558
+ }
2559
+
2560
+ await graph.materialize({ ceiling: currentTick });
2561
+ const afterState = /** @type {*} */ (await graph.getStateSnapshot()); // TODO(ts-cleanup): narrow WarpStateV5
2562
+ const diff = diffStates(beforeState, afterState);
2563
+
2564
+ return applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit);
2565
+ }
2566
+
2567
+ /**
2568
+ * Applies truncation limits to a structural diff result.
2569
+ *
2570
+ * @param {*} diff
2571
+ * @param {string} diffBaseline
2572
+ * @param {number|null} baselineTick
2573
+ * @param {number} diffLimit
2574
+ * @returns {{structuralDiff: *, diffBaseline: string, baselineTick: number|null, truncated: boolean, totalChanges: number, shownChanges: number}}
2575
+ */
2576
+ function applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit) {
2577
+ const totalChanges =
2578
+ diff.nodes.added.length + diff.nodes.removed.length +
2579
+ diff.edges.added.length + diff.edges.removed.length +
2580
+ diff.props.set.length + diff.props.removed.length;
2581
+
2582
+ if (totalChanges <= diffLimit) {
2583
+ return { structuralDiff: diff, diffBaseline, baselineTick, truncated: false, totalChanges, shownChanges: totalChanges };
2584
+ }
2585
+
2586
+ // Truncate sequentially (nodes → edges → props), keeping sort order within each category
2587
+ let remaining = diffLimit;
2588
+ const cap = (/** @type {any[]} */ arr) => {
2589
+ const take = Math.min(arr.length, remaining);
2590
+ remaining -= take;
2591
+ return arr.slice(0, take);
2592
+ };
2593
+
2594
+ const capped = {
2595
+ nodes: { added: cap(diff.nodes.added), removed: cap(diff.nodes.removed) },
2596
+ edges: { added: cap(diff.edges.added), removed: cap(diff.edges.removed) },
2597
+ props: { set: cap(diff.props.set), removed: cap(diff.props.removed) },
2598
+ };
2599
+
2600
+ const shownChanges = diffLimit - remaining;
2601
+ return { structuralDiff: capped, diffBaseline, baselineTick, truncated: true, totalChanges, shownChanges };
2602
+ }
2603
+
2251
2604
  /**
2252
2605
  * Renders a seek command payload as a human-readable string for terminal output.
2253
2606
  *
2254
2607
  * Handles all seek actions: list, drop, save, latest, load, tick, and status.
2255
2608
  *
2256
- * @private
2257
- * @param {Object} payload - Seek result payload from handleSeek
2609
+ * @param {*} payload - Seek result payload from handleSeek
2258
2610
  * @returns {string} Formatted output string (includes trailing newline)
2259
2611
  */
2260
2612
  function renderSeek(payload) {
2261
- const formatDelta = (n) => {
2613
+ const formatDelta = (/** @type {*} */ n) => { // TODO(ts-cleanup): type CLI payload
2262
2614
  if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) {
2263
2615
  return '';
2264
2616
  }
@@ -2266,7 +2618,7 @@ function renderSeek(payload) {
2266
2618
  return ` (${sign}${n})`;
2267
2619
  };
2268
2620
 
2269
- const formatOpSummaryPlain = (summary) => {
2621
+ const formatOpSummaryPlain = (/** @type {*} */ summary) => { // TODO(ts-cleanup): type CLI payload
2270
2622
  const order = [
2271
2623
  ['NodeAdd', '+', 'node'],
2272
2624
  ['EdgeAdd', '+', 'edge'],
@@ -2286,7 +2638,7 @@ function renderSeek(payload) {
2286
2638
  return parts.length > 0 ? parts.join(' ') : '(empty)';
2287
2639
  };
2288
2640
 
2289
- const appendReceiptSummary = (baseLine) => {
2641
+ const appendReceiptSummary = (/** @type {string} */ baseLine) => {
2290
2642
  const tickReceipt = payload?.tickReceipt;
2291
2643
  if (!tickReceipt || typeof tickReceipt !== 'object') {
2292
2644
  return `${baseLine}\n`;
@@ -2322,6 +2674,10 @@ function renderSeek(payload) {
2322
2674
  };
2323
2675
  };
2324
2676
 
2677
+ if (payload.action === 'clear-cache') {
2678
+ return `${payload.message}\n`;
2679
+ }
2680
+
2325
2681
  if (payload.action === 'list') {
2326
2682
  if (payload.cursors.length === 0) {
2327
2683
  return 'No saved cursors.\n';
@@ -2344,26 +2700,29 @@ function renderSeek(payload) {
2344
2700
 
2345
2701
  if (payload.action === 'latest') {
2346
2702
  const { nodesStr, edgesStr } = buildStateStrings();
2347
- return appendReceiptSummary(
2703
+ const base = appendReceiptSummary(
2348
2704
  `${payload.graph}: returned to present (tick ${payload.maxTick}, ${nodesStr}, ${edgesStr})`,
2349
2705
  );
2706
+ return base + formatStructuralDiff(payload);
2350
2707
  }
2351
2708
 
2352
2709
  if (payload.action === 'load') {
2353
2710
  const { nodesStr, edgesStr } = buildStateStrings();
2354
- return appendReceiptSummary(
2711
+ const base = appendReceiptSummary(
2355
2712
  `${payload.graph}: loaded cursor "${payload.name}" at tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr})`,
2356
2713
  );
2714
+ return base + formatStructuralDiff(payload);
2357
2715
  }
2358
2716
 
2359
2717
  if (payload.action === 'tick') {
2360
2718
  const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
2361
- return appendReceiptSummary(
2719
+ const base = appendReceiptSummary(
2362
2720
  `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
2363
2721
  );
2722
+ return base + formatStructuralDiff(payload);
2364
2723
  }
2365
2724
 
2366
- // status
2725
+ // status (structuralDiff is never populated here; no formatStructuralDiff call)
2367
2726
  if (payload.cursor && payload.cursor.active) {
2368
2727
  const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
2369
2728
  return appendReceiptSummary(
@@ -2381,9 +2740,8 @@ function renderSeek(payload) {
2381
2740
  * Called by non-seek commands (query, path, check, etc.) that should
2382
2741
  * honour an active seek cursor.
2383
2742
  *
2384
- * @private
2385
- * @param {Object} graph - WarpGraph instance
2386
- * @param {Object} persistence - GraphPersistencePort adapter
2743
+ * @param {WarpGraphInstance} graph - WarpGraph instance
2744
+ * @param {Persistence} persistence - GraphPersistencePort adapter
2387
2745
  * @param {string} graphName - Name of the WARP graph
2388
2746
  * @returns {Promise<{active: boolean, tick: number|null, maxTick: number|null}>} Cursor info — maxTick is always null; non-seek commands intentionally skip discoverTicks() for performance
2389
2747
  */
@@ -2405,7 +2763,6 @@ async function applyCursorCeiling(graph, persistence, graphName) {
2405
2763
  * maxTick to avoid the cost of discoverTicks(); the banner then omits the
2406
2764
  * "of {maxTick}" suffix. Only the seek handler itself populates maxTick.
2407
2765
  *
2408
- * @private
2409
2766
  * @param {{active: boolean, tick: number|null, maxTick: number|null}} cursorInfo - Result from applyCursorCeiling
2410
2767
  * @param {number|null} maxTick - Maximum Lamport tick (from discoverTicks), or null if unknown
2411
2768
  * @returns {void}
@@ -2417,6 +2774,10 @@ function emitCursorWarning(cursorInfo, maxTick) {
2417
2774
  }
2418
2775
  }
2419
2776
 
2777
+ /**
2778
+ * @param {{options: CliOptions, args: string[]}} params
2779
+ * @returns {Promise<{payload: *, exitCode: number}>}
2780
+ */
2420
2781
  async function handleView({ options, args }) {
2421
2782
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
2422
2783
  throw usageError('view command requires an interactive terminal (TTY)');
@@ -2427,13 +2788,14 @@ async function handleView({ options, args }) {
2427
2788
  : 'list';
2428
2789
 
2429
2790
  try {
2791
+ // @ts-expect-error — optional peer dependency, may not be installed
2430
2792
  const { startTui } = await import('@git-stunts/git-warp-tui');
2431
2793
  await startTui({
2432
2794
  repo: options.repo || '.',
2433
2795
  graph: options.graph || 'default',
2434
2796
  mode: viewMode,
2435
2797
  });
2436
- } catch (err) {
2798
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error
2437
2799
  if (err.code === 'ERR_MODULE_NOT_FOUND' || (err.message && err.message.includes('Cannot find module'))) {
2438
2800
  throw usageError(
2439
2801
  'Interactive TUI requires @git-stunts/git-warp-tui.\n' +
@@ -2445,7 +2807,8 @@ async function handleView({ options, args }) {
2445
2807
  return { payload: undefined, exitCode: 0 };
2446
2808
  }
2447
2809
 
2448
- const COMMANDS = new Map([
2810
+ /** @type {Map<string, Function>} */
2811
+ const COMMANDS = new Map(/** @type {[string, Function][]} */ ([
2449
2812
  ['info', handleInfo],
2450
2813
  ['query', handleQuery],
2451
2814
  ['path', handlePath],
@@ -2455,7 +2818,7 @@ const COMMANDS = new Map([
2455
2818
  ['seek', handleSeek],
2456
2819
  ['view', handleView],
2457
2820
  ['install-hooks', handleInstallHooks],
2458
- ]);
2821
+ ]));
2459
2822
 
2460
2823
  /**
2461
2824
  * CLI entry point. Parses arguments, dispatches to the appropriate command handler,
@@ -2492,12 +2855,13 @@ async function main() {
2492
2855
  throw usageError(`--view is not supported for '${command}'. Supported commands: ${VIEW_SUPPORTED_COMMANDS.join(', ')}`);
2493
2856
  }
2494
2857
 
2495
- const result = await handler({
2858
+ const result = await /** @type {Function} */ (handler)({
2496
2859
  command,
2497
2860
  args: positionals.slice(1),
2498
2861
  options,
2499
2862
  });
2500
2863
 
2864
+ /** @type {{payload: *, exitCode: number}} */
2501
2865
  const normalized = result && typeof result === 'object' && 'payload' in result
2502
2866
  ? result
2503
2867
  : { payload: result, exitCode: EXIT_CODES.OK };
@@ -2505,13 +2869,15 @@ async function main() {
2505
2869
  if (normalized.payload !== undefined) {
2506
2870
  emit(normalized.payload, { json: options.json, command, view: options.view });
2507
2871
  }
2508
- process.exitCode = normalized.exitCode ?? EXIT_CODES.OK;
2872
+ // Use process.exit() to avoid waiting for fire-and-forget I/O (e.g. seek cache writes).
2873
+ process.exit(normalized.exitCode ?? EXIT_CODES.OK);
2509
2874
  }
2510
2875
 
2511
2876
  main().catch((error) => {
2512
2877
  const exitCode = error instanceof CliError ? error.exitCode : EXIT_CODES.INTERNAL;
2513
2878
  const code = error instanceof CliError ? error.code : 'E_INTERNAL';
2514
2879
  const message = error instanceof Error ? error.message : 'Unknown error';
2880
+ /** @type {{error: {code: string, message: string, cause?: *}}} */
2515
2881
  const payload = { error: { code, message } };
2516
2882
 
2517
2883
  if (error && error.cause) {
@@ -2523,5 +2889,5 @@ main().catch((error) => {
2523
2889
  } else {
2524
2890
  process.stderr.write(renderError(payload));
2525
2891
  }
2526
- process.exitCode = exitCode;
2892
+ process.exit(exitCode);
2527
2893
  });