@git-stunts/git-warp 10.1.1 → 10.3.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.
package/bin/warp-graph.js CHANGED
@@ -1,5 +1,6 @@
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';
@@ -10,12 +11,16 @@ import WarpGraph from '../src/domain/WarpGraph.js';
10
11
  import GitGraphAdapter from '../src/infrastructure/adapters/GitGraphAdapter.js';
11
12
  import HealthCheckService from '../src/domain/services/HealthCheckService.js';
12
13
  import ClockAdapter from '../src/infrastructure/adapters/ClockAdapter.js';
14
+ import NodeCryptoAdapter from '../src/infrastructure/adapters/NodeCryptoAdapter.js';
13
15
  import {
14
16
  REF_PREFIX,
15
17
  buildCheckpointRef,
16
18
  buildCoverageRef,
17
19
  buildWritersPrefix,
18
20
  parseWriterIdFromRef,
21
+ buildCursorActiveRef,
22
+ buildCursorSavedRef,
23
+ buildCursorSavedPrefix,
19
24
  } from '../src/domain/utils/RefLayout.js';
20
25
  import { HookInstaller, classifyExistingHook } from '../src/domain/services/HookInstaller.js';
21
26
  import { renderInfoView } from '../src/visualization/renderers/ascii/info.js';
@@ -23,6 +28,8 @@ import { renderCheckView } from '../src/visualization/renderers/ascii/check.js';
23
28
  import { renderHistoryView, summarizeOps } from '../src/visualization/renderers/ascii/history.js';
24
29
  import { renderPathView } from '../src/visualization/renderers/ascii/path.js';
25
30
  import { renderMaterializeView } from '../src/visualization/renderers/ascii/materialize.js';
31
+ import { parseCursorBlob } from '../src/domain/utils/parseCursorBlob.js';
32
+ import { renderSeekView } from '../src/visualization/renderers/ascii/seek.js';
26
33
  import { renderGraphView } from '../src/visualization/renderers/ascii/graph.js';
27
34
  import { renderSvg } from '../src/visualization/renderers/svg/index.js';
28
35
  import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../src/visualization/layouts/index.js';
@@ -44,6 +51,7 @@ Commands:
44
51
  history Show writer history
45
52
  check Report graph health/GC status
46
53
  materialize Materialize and checkpoint all graphs
54
+ seek Time-travel: step through graph history by Lamport tick
47
55
  view Interactive TUI graph browser (requires @git-stunts/git-warp-tui)
48
56
  install-hooks Install post-merge git hook
49
57
 
@@ -74,6 +82,14 @@ Path options:
74
82
 
75
83
  History options:
76
84
  --node <id> Filter patches touching node id
85
+
86
+ Seek options:
87
+ --tick <N|+N|-N> Jump to tick N, or step forward/backward
88
+ --latest Clear cursor, return to present
89
+ --save <name> Save current position as named cursor
90
+ --load <name> Restore a saved cursor
91
+ --list List all saved cursors
92
+ --drop <name> Delete a saved cursor
77
93
  `;
78
94
 
79
95
  /**
@@ -169,7 +185,7 @@ function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
169
185
  if (arg === '--view') {
170
186
  // Valid view modes: ascii, browser, svg:FILE, html:FILE
171
187
  // Don't consume known commands as modes
172
- const KNOWN_COMMANDS = ['info', 'query', 'path', 'history', 'check', 'materialize', 'install-hooks'];
188
+ const KNOWN_COMMANDS = ['info', 'query', 'path', 'history', 'check', 'materialize', 'seek', 'install-hooks'];
173
189
  const nextArg = argv[index + 1];
174
190
  const isViewMode = nextArg &&
175
191
  !nextArg.startsWith('-') &&
@@ -271,6 +287,17 @@ async function resolveGraphName(persistence, explicitGraph) {
271
287
  throw usageError('Multiple graphs found; specify --graph');
272
288
  }
273
289
 
290
+ /**
291
+ * Collects metadata about a single graph (writer count, refs, patches, checkpoint).
292
+ * @param {Object} persistence - GraphPersistencePort adapter
293
+ * @param {string} graphName - Name of the graph to inspect
294
+ * @param {Object} [options]
295
+ * @param {boolean} [options.includeWriterIds=false] - Include writer ID list
296
+ * @param {boolean} [options.includeRefs=false] - Include checkpoint/coverage refs
297
+ * @param {boolean} [options.includeWriterPatches=false] - Include per-writer patch counts
298
+ * @param {boolean} [options.includeCheckpointDate=false] - Include checkpoint date
299
+ * @returns {Promise<Object>} Graph info object
300
+ */
274
301
  async function getGraphInfo(persistence, graphName, {
275
302
  includeWriterIds = false,
276
303
  includeRefs = false,
@@ -322,6 +349,7 @@ async function getGraphInfo(persistence, graphName, {
322
349
  persistence,
323
350
  graphName,
324
351
  writerId: 'cli',
352
+ crypto: new NodeCryptoAdapter(),
325
353
  });
326
354
  const writerPatches = {};
327
355
  for (const writerId of writerIds) {
@@ -334,13 +362,29 @@ async function getGraphInfo(persistence, graphName, {
334
362
  return info;
335
363
  }
336
364
 
365
+ /**
366
+ * 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}>}
372
+ * @throws {CliError} If the specified graph is not found
373
+ */
337
374
  async function openGraph(options) {
338
375
  const { persistence } = await createPersistence(options.repo);
339
376
  const graphName = await resolveGraphName(persistence, options.graph);
377
+ if (options.graph) {
378
+ const graphNames = await listGraphNames(persistence);
379
+ if (!graphNames.includes(options.graph)) {
380
+ throw notFoundError(`Graph not found: ${options.graph}`);
381
+ }
382
+ }
340
383
  const graph = await WarpGraph.open({
341
384
  persistence,
342
385
  graphName,
343
386
  writerId: options.writer,
387
+ crypto: new NodeCryptoAdapter(),
344
388
  });
345
389
  return { graph, graphName, persistence };
346
390
  }
@@ -603,6 +647,9 @@ function renderInfo(payload) {
603
647
  if (graph.coverage?.sha) {
604
648
  lines.push(` coverage: ${graph.coverage.sha}`);
605
649
  }
650
+ if (graph.cursor?.active) {
651
+ lines.push(` cursor: tick ${graph.cursor.tick} (${graph.cursor.mode})`);
652
+ }
606
653
  }
607
654
  return `${lines.join('\n')}\n`;
608
655
  }
@@ -742,6 +789,26 @@ function renderError(payload) {
742
789
  return `Error: ${payload.error.message}\n`;
743
790
  }
744
791
 
792
+ /**
793
+ * Wraps SVG content in a minimal HTML document and writes it to disk.
794
+ * @param {string} filePath - Destination file path
795
+ * @param {string} svgContent - SVG markup to embed
796
+ */
797
+ function writeHtmlExport(filePath, svgContent) {
798
+ const html = `<!DOCTYPE html>\n<html><head><meta charset="utf-8"><title>git-warp</title></head><body>\n${svgContent}\n</body></html>`;
799
+ fs.writeFileSync(filePath, html);
800
+ }
801
+
802
+ /**
803
+ * Writes a command result to stdout/stderr in the appropriate format.
804
+ * Dispatches to JSON, SVG file, HTML file, ASCII view, or plain text
805
+ * 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')
811
+ */
745
812
  function emit(payload, { json, command, view }) {
746
813
  if (json) {
747
814
  process.stdout.write(`${stableStringify(payload)}\n`);
@@ -766,6 +833,14 @@ function emit(payload, { json, command, view }) {
766
833
  fs.writeFileSync(svgPath, payload._renderedSvg);
767
834
  process.stderr.write(`SVG written to ${svgPath}\n`);
768
835
  }
836
+ } else if (view && typeof view === 'string' && view.startsWith('html:')) {
837
+ const htmlPath = view.slice(5);
838
+ if (!payload._renderedSvg) {
839
+ process.stderr.write('No graph data — skipping HTML export.\n');
840
+ } else {
841
+ writeHtmlExport(htmlPath, payload._renderedSvg);
842
+ process.stderr.write(`HTML written to ${htmlPath}\n`);
843
+ }
769
844
  } else if (view) {
770
845
  process.stdout.write(`${payload._renderedAscii}\n`);
771
846
  } else {
@@ -783,6 +858,14 @@ function emit(payload, { json, command, view }) {
783
858
  fs.writeFileSync(svgPath, payload._renderedSvg);
784
859
  process.stderr.write(`SVG written to ${svgPath}\n`);
785
860
  }
861
+ } else if (view && typeof view === 'string' && view.startsWith('html:')) {
862
+ const htmlPath = view.slice(5);
863
+ if (!payload._renderedSvg) {
864
+ process.stderr.write('No path found — skipping HTML export.\n');
865
+ } else {
866
+ writeHtmlExport(htmlPath, payload._renderedSvg);
867
+ process.stderr.write(`HTML written to ${htmlPath}\n`);
868
+ }
786
869
  } else if (view) {
787
870
  process.stdout.write(renderPathView(payload));
788
871
  } else {
@@ -818,6 +901,15 @@ function emit(payload, { json, command, view }) {
818
901
  return;
819
902
  }
820
903
 
904
+ if (command === 'seek') {
905
+ if (view) {
906
+ process.stdout.write(renderSeekView(payload));
907
+ } else {
908
+ process.stdout.write(renderSeek(payload));
909
+ }
910
+ return;
911
+ }
912
+
821
913
  if (command === 'install-hooks') {
822
914
  process.stdout.write(renderInstallHooks(payload));
823
915
  return;
@@ -859,12 +951,19 @@ async function handleInfo({ options }) {
859
951
  const graphs = [];
860
952
  for (const name of graphNames) {
861
953
  const includeDetails = detailGraphs.has(name);
862
- graphs.push(await getGraphInfo(persistence, name, {
954
+ const info = await getGraphInfo(persistence, name, {
863
955
  includeWriterIds: includeDetails || isViewMode,
864
956
  includeRefs: includeDetails || isViewMode,
865
957
  includeWriterPatches: isViewMode,
866
958
  includeCheckpointDate: isViewMode,
867
- }));
959
+ });
960
+ const activeCursor = await readActiveCursor(persistence, name);
961
+ if (activeCursor) {
962
+ info.cursor = { active: true, tick: activeCursor.tick, mode: activeCursor.mode };
963
+ } else {
964
+ info.cursor = { active: false };
965
+ }
966
+ graphs.push(info);
868
967
  }
869
968
 
870
969
  return {
@@ -883,7 +982,9 @@ async function handleInfo({ options }) {
883
982
  */
884
983
  async function handleQuery({ options, args }) {
885
984
  const querySpec = parseQueryArgs(args);
886
- const { graph, graphName } = await openGraph(options);
985
+ const { graph, graphName, persistence } = await openGraph(options);
986
+ const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
987
+ emitCursorWarning(cursorInfo, null);
887
988
  let builder = graph.query();
888
989
 
889
990
  if (querySpec.match !== null) {
@@ -904,7 +1005,7 @@ async function handleQuery({ options, args }) {
904
1005
  const edges = await graph.getEdges();
905
1006
  const graphData = queryResultToGraphData(payload, edges);
906
1007
  const positioned = await layoutGraph(graphData, { type: 'query' });
907
- if (typeof options.view === 'string' && options.view.startsWith('svg:')) {
1008
+ if (typeof options.view === 'string' && (options.view.startsWith('svg:') || options.view.startsWith('html:'))) {
908
1009
  payload._renderedSvg = renderSvg(positioned, { title: `${graphName} query` });
909
1010
  } else {
910
1011
  payload._renderedAscii = renderGraphView(positioned, { title: `QUERY: ${graphName}` });
@@ -974,7 +1075,9 @@ function mapQueryError(error) {
974
1075
  */
975
1076
  async function handlePath({ options, args }) {
976
1077
  const pathOptions = parsePathArgs(args);
977
- const { graph, graphName } = await openGraph(options);
1078
+ const { graph, graphName, persistence } = await openGraph(options);
1079
+ const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
1080
+ emitCursorWarning(cursorInfo, null);
978
1081
 
979
1082
  try {
980
1083
  const result = await graph.traverse.shortestPath(
@@ -994,7 +1097,7 @@ async function handlePath({ options, args }) {
994
1097
  ...result,
995
1098
  };
996
1099
 
997
- if (options.view && result.found && typeof options.view === 'string' && options.view.startsWith('svg:')) {
1100
+ if (options.view && result.found && typeof options.view === 'string' && (options.view.startsWith('svg:') || options.view.startsWith('html:'))) {
998
1101
  const graphData = pathResultToGraphData(payload);
999
1102
  const positioned = await layoutGraph(graphData, { type: 'path' });
1000
1103
  payload._renderedSvg = renderSvg(positioned, { title: `${graphName} path` });
@@ -1020,6 +1123,8 @@ async function handlePath({ options, args }) {
1020
1123
  */
1021
1124
  async function handleCheck({ options }) {
1022
1125
  const { graph, graphName, persistence } = await openGraph(options);
1126
+ const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
1127
+ emitCursorWarning(cursorInfo, null);
1023
1128
  const health = await getHealth(persistence);
1024
1129
  const gcMetrics = await getGcMetrics(graph);
1025
1130
  const status = await graph.status();
@@ -1157,9 +1262,15 @@ function buildCheckPayload({
1157
1262
  */
1158
1263
  async function handleHistory({ options, args }) {
1159
1264
  const historyOptions = parseHistoryArgs(args);
1160
- const { graph, graphName } = await openGraph(options);
1265
+ const { graph, graphName, persistence } = await openGraph(options);
1266
+ const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
1267
+ emitCursorWarning(cursorInfo, null);
1268
+
1161
1269
  const writerId = options.writer;
1162
- const patches = await graph.getWriterPatches(writerId);
1270
+ let patches = await graph.getWriterPatches(writerId);
1271
+ if (cursorInfo.active) {
1272
+ patches = patches.filter(({ patch }) => patch.lamport <= cursorInfo.tick);
1273
+ }
1163
1274
  if (patches.length === 0) {
1164
1275
  throw notFoundError(`No patches found for writer: ${writerId}`);
1165
1276
  }
@@ -1184,12 +1295,23 @@ async function handleHistory({ options, args }) {
1184
1295
  return { payload, exitCode: EXIT_CODES.OK };
1185
1296
  }
1186
1297
 
1187
- async function materializeOneGraph({ persistence, graphName, writerId }) {
1188
- const graph = await WarpGraph.open({ persistence, graphName, writerId });
1189
- await graph.materialize();
1298
+ /**
1299
+ * Materializes a single graph, creates a checkpoint, and returns summary stats.
1300
+ * When a ceiling tick is provided (seek cursor active), the checkpoint step is
1301
+ * 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}>}
1308
+ */
1309
+ async function materializeOneGraph({ persistence, graphName, writerId, ceiling }) {
1310
+ const graph = await WarpGraph.open({ persistence, graphName, writerId, crypto: new NodeCryptoAdapter() });
1311
+ await graph.materialize(ceiling !== undefined ? { ceiling } : undefined);
1190
1312
  const nodes = await graph.getNodes();
1191
1313
  const edges = await graph.getEdges();
1192
- const checkpoint = await graph.createCheckpoint();
1314
+ const checkpoint = ceiling !== undefined ? null : await graph.createCheckpoint();
1193
1315
  const status = await graph.status();
1194
1316
 
1195
1317
  // Build per-writer patch counts for the view renderer
@@ -1241,12 +1363,20 @@ async function handleMaterialize({ options }) {
1241
1363
  }
1242
1364
 
1243
1365
  const results = [];
1366
+ let cursorWarningEmitted = false;
1244
1367
  for (const name of targets) {
1245
1368
  try {
1369
+ const cursor = await readActiveCursor(persistence, name);
1370
+ const ceiling = cursor ? cursor.tick : undefined;
1371
+ if (cursor && !cursorWarningEmitted) {
1372
+ emitCursorWarning({ active: true, tick: cursor.tick, maxTick: null }, null);
1373
+ cursorWarningEmitted = true;
1374
+ }
1246
1375
  const result = await materializeOneGraph({
1247
1376
  persistence,
1248
1377
  graphName: name,
1249
1378
  writerId: options.writer,
1379
+ ceiling,
1250
1380
  });
1251
1381
  results.push(result);
1252
1382
  } catch (error) {
@@ -1465,6 +1595,828 @@ function getHookStatusForCheck(repoPath) {
1465
1595
  }
1466
1596
  }
1467
1597
 
1598
+ // ============================================================================
1599
+ // Cursor I/O Helpers
1600
+ // ============================================================================
1601
+
1602
+ /**
1603
+ * Reads the active seek cursor for a graph from Git ref storage.
1604
+ *
1605
+ * @private
1606
+ * @param {Object} persistence - GraphPersistencePort adapter
1607
+ * @param {string} graphName - Name of the WARP graph
1608
+ * @returns {Promise<{tick: number, mode?: string}|null>} Cursor object, or null if no active cursor
1609
+ * @throws {Error} If the stored blob is corrupted or not valid JSON
1610
+ */
1611
+ async function readActiveCursor(persistence, graphName) {
1612
+ const ref = buildCursorActiveRef(graphName);
1613
+ const oid = await persistence.readRef(ref);
1614
+ if (!oid) {
1615
+ return null;
1616
+ }
1617
+ const buf = await persistence.readBlob(oid);
1618
+ return parseCursorBlob(buf, 'active cursor');
1619
+ }
1620
+
1621
+ /**
1622
+ * Writes (creates or overwrites) the active seek cursor for a graph.
1623
+ *
1624
+ * Serializes the cursor as JSON, stores it as a Git blob, and points
1625
+ * the active cursor ref at that blob.
1626
+ *
1627
+ * @private
1628
+ * @param {Object} persistence - GraphPersistencePort adapter
1629
+ * @param {string} graphName - Name of the WARP graph
1630
+ * @param {{tick: number, mode?: string}} cursor - Cursor state to persist
1631
+ * @returns {Promise<void>}
1632
+ */
1633
+ async function writeActiveCursor(persistence, graphName, cursor) {
1634
+ const ref = buildCursorActiveRef(graphName);
1635
+ const json = JSON.stringify(cursor);
1636
+ const oid = await persistence.writeBlob(Buffer.from(json, 'utf8'));
1637
+ await persistence.updateRef(ref, oid);
1638
+ }
1639
+
1640
+ /**
1641
+ * Removes the active seek cursor for a graph, returning to present state.
1642
+ *
1643
+ * No-op if no active cursor exists.
1644
+ *
1645
+ * @private
1646
+ * @param {Object} persistence - GraphPersistencePort adapter
1647
+ * @param {string} graphName - Name of the WARP graph
1648
+ * @returns {Promise<void>}
1649
+ */
1650
+ async function clearActiveCursor(persistence, graphName) {
1651
+ const ref = buildCursorActiveRef(graphName);
1652
+ const exists = await persistence.readRef(ref);
1653
+ if (exists) {
1654
+ await persistence.deleteRef(ref);
1655
+ }
1656
+ }
1657
+
1658
+ /**
1659
+ * Reads a named saved cursor from Git ref storage.
1660
+ *
1661
+ * @private
1662
+ * @param {Object} persistence - GraphPersistencePort adapter
1663
+ * @param {string} graphName - Name of the WARP graph
1664
+ * @param {string} name - Saved cursor name
1665
+ * @returns {Promise<{tick: number, mode?: string}|null>} Cursor object, or null if not found
1666
+ * @throws {Error} If the stored blob is corrupted or not valid JSON
1667
+ */
1668
+ async function readSavedCursor(persistence, graphName, name) {
1669
+ const ref = buildCursorSavedRef(graphName, name);
1670
+ const oid = await persistence.readRef(ref);
1671
+ if (!oid) {
1672
+ return null;
1673
+ }
1674
+ const buf = await persistence.readBlob(oid);
1675
+ return parseCursorBlob(buf, `saved cursor '${name}'`);
1676
+ }
1677
+
1678
+ /**
1679
+ * Persists a cursor under a named saved-cursor ref.
1680
+ *
1681
+ * Serializes the cursor as JSON, stores it as a Git blob, and points
1682
+ * the named saved-cursor ref at that blob.
1683
+ *
1684
+ * @private
1685
+ * @param {Object} persistence - GraphPersistencePort adapter
1686
+ * @param {string} graphName - Name of the WARP graph
1687
+ * @param {string} name - Saved cursor name
1688
+ * @param {{tick: number, mode?: string}} cursor - Cursor state to persist
1689
+ * @returns {Promise<void>}
1690
+ */
1691
+ async function writeSavedCursor(persistence, graphName, name, cursor) {
1692
+ const ref = buildCursorSavedRef(graphName, name);
1693
+ const json = JSON.stringify(cursor);
1694
+ const oid = await persistence.writeBlob(Buffer.from(json, 'utf8'));
1695
+ await persistence.updateRef(ref, oid);
1696
+ }
1697
+
1698
+ /**
1699
+ * Deletes a named saved cursor from Git ref storage.
1700
+ *
1701
+ * No-op if the named cursor does not exist.
1702
+ *
1703
+ * @private
1704
+ * @param {Object} persistence - GraphPersistencePort adapter
1705
+ * @param {string} graphName - Name of the WARP graph
1706
+ * @param {string} name - Saved cursor name to delete
1707
+ * @returns {Promise<void>}
1708
+ */
1709
+ async function deleteSavedCursor(persistence, graphName, name) {
1710
+ const ref = buildCursorSavedRef(graphName, name);
1711
+ const exists = await persistence.readRef(ref);
1712
+ if (exists) {
1713
+ await persistence.deleteRef(ref);
1714
+ }
1715
+ }
1716
+
1717
+ /**
1718
+ * Lists all saved cursors for a graph, reading each blob to include full cursor state.
1719
+ *
1720
+ * @private
1721
+ * @param {Object} persistence - GraphPersistencePort adapter
1722
+ * @param {string} graphName - Name of the WARP graph
1723
+ * @returns {Promise<Array<{name: string, tick: number, mode?: string}>>} Array of saved cursors with their names
1724
+ * @throws {Error} If any stored blob is corrupted or not valid JSON
1725
+ */
1726
+ async function listSavedCursors(persistence, graphName) {
1727
+ const prefix = buildCursorSavedPrefix(graphName);
1728
+ const refs = await persistence.listRefs(prefix);
1729
+ const cursors = [];
1730
+ for (const ref of refs) {
1731
+ const name = ref.slice(prefix.length);
1732
+ if (name) {
1733
+ const oid = await persistence.readRef(ref);
1734
+ if (oid) {
1735
+ const buf = await persistence.readBlob(oid);
1736
+ const cursor = parseCursorBlob(buf, `saved cursor '${name}'`);
1737
+ cursors.push({ name, ...cursor });
1738
+ }
1739
+ }
1740
+ }
1741
+ return cursors;
1742
+ }
1743
+
1744
+ // ============================================================================
1745
+ // Seek Arg Parser
1746
+ // ============================================================================
1747
+
1748
+ /**
1749
+ * 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
+ * @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
1759
+ */
1760
+ function parseSeekArgs(args) {
1761
+ const spec = {
1762
+ action: 'status', // status, tick, latest, save, load, list, drop
1763
+ tickValue: null,
1764
+ name: null,
1765
+ };
1766
+
1767
+ for (let i = 0; i < args.length; i++) {
1768
+ const arg = args[i];
1769
+
1770
+ if (arg === '--tick') {
1771
+ if (spec.action !== 'status') {
1772
+ throw usageError('--tick cannot be combined with other seek flags');
1773
+ }
1774
+ spec.action = 'tick';
1775
+ const val = args[i + 1];
1776
+ if (val === undefined) {
1777
+ throw usageError('Missing value for --tick');
1778
+ }
1779
+ spec.tickValue = val;
1780
+ i += 1;
1781
+ } else if (arg.startsWith('--tick=')) {
1782
+ if (spec.action !== 'status') {
1783
+ throw usageError('--tick cannot be combined with other seek flags');
1784
+ }
1785
+ spec.action = 'tick';
1786
+ spec.tickValue = arg.slice('--tick='.length);
1787
+ } else if (arg === '--latest') {
1788
+ if (spec.action !== 'status') {
1789
+ throw usageError('--latest cannot be combined with other seek flags');
1790
+ }
1791
+ 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
+ }
1832
+ } else if (arg === '--list') {
1833
+ if (spec.action !== 'status') {
1834
+ throw usageError('--list cannot be combined with other seek flags');
1835
+ }
1836
+ 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');
1856
+ }
1857
+ } else if (arg.startsWith('-')) {
1858
+ throw usageError(`Unknown seek option: ${arg}`);
1859
+ }
1860
+ }
1861
+
1862
+ return spec;
1863
+ }
1864
+
1865
+ /**
1866
+ * Resolves a tick value (absolute or relative +N/-N) against available ticks.
1867
+ *
1868
+ * For relative values, steps through the sorted tick array (with 0 prepended
1869
+ * as a virtual "empty state" position) by the given delta from the current
1870
+ * position. For absolute values, clamps to maxTick.
1871
+ *
1872
+ * @private
1873
+ * @param {string} tickValue - Raw tick string from CLI args (e.g. "5", "+1", "-2")
1874
+ * @param {number|null} currentTick - Current cursor tick, or null if no active cursor
1875
+ * @param {number[]} ticks - Sorted ascending array of available Lamport ticks
1876
+ * @param {number} maxTick - Maximum tick across all writers
1877
+ * @returns {number} Resolved tick value (clamped to valid range)
1878
+ * @throws {CliError} If tickValue is not a valid integer or relative delta
1879
+ */
1880
+ function resolveTickValue(tickValue, currentTick, ticks, maxTick) {
1881
+ // Relative: +N or -N
1882
+ if (tickValue.startsWith('+') || tickValue.startsWith('-')) {
1883
+ const delta = parseInt(tickValue, 10);
1884
+ if (!Number.isInteger(delta)) {
1885
+ throw usageError(`Invalid tick delta: ${tickValue}`);
1886
+ }
1887
+ const base = currentTick ?? 0;
1888
+
1889
+ // Find the current position in sorted ticks, then step by delta
1890
+ // Include tick 0 as a virtual "empty state" position (avoid duplicating if already present)
1891
+ const allPoints = (ticks.length > 0 && ticks[0] === 0) ? [...ticks] : [0, ...ticks];
1892
+ const currentIdx = allPoints.indexOf(base);
1893
+ const startIdx = currentIdx === -1 ? 0 : currentIdx;
1894
+ const targetIdx = Math.max(0, Math.min(allPoints.length - 1, startIdx + delta));
1895
+ return allPoints[targetIdx];
1896
+ }
1897
+
1898
+ // Absolute
1899
+ const n = parseInt(tickValue, 10);
1900
+ if (!Number.isInteger(n) || n < 0) {
1901
+ throw usageError(`Invalid tick value: ${tickValue}. Must be a non-negative integer, or +N/-N for relative.`);
1902
+ }
1903
+
1904
+ // Clamp to maxTick
1905
+ return Math.min(n, maxTick);
1906
+ }
1907
+
1908
+ // ============================================================================
1909
+ // Seek Handler
1910
+ // ============================================================================
1911
+
1912
+ /**
1913
+ * 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
1930
+ */
1931
+ async function handleSeek({ options, args }) {
1932
+ const seekSpec = parseSeekArgs(args);
1933
+ const { graph, graphName, persistence } = await openGraph(options);
1934
+ const activeCursor = await readActiveCursor(persistence, graphName);
1935
+ const { ticks, maxTick, perWriter } = await graph.discoverTicks();
1936
+ const frontierHash = computeFrontierHash(perWriter);
1937
+ if (seekSpec.action === 'list') {
1938
+ const saved = await listSavedCursors(persistence, graphName);
1939
+ return {
1940
+ payload: {
1941
+ graph: graphName,
1942
+ action: 'list',
1943
+ cursors: saved,
1944
+ activeTick: activeCursor ? activeCursor.tick : null,
1945
+ maxTick,
1946
+ },
1947
+ exitCode: EXIT_CODES.OK,
1948
+ };
1949
+ }
1950
+ if (seekSpec.action === 'drop') {
1951
+ const existing = await readSavedCursor(persistence, graphName, seekSpec.name);
1952
+ if (!existing) {
1953
+ throw notFoundError(`Saved cursor not found: ${seekSpec.name}`);
1954
+ }
1955
+ await deleteSavedCursor(persistence, graphName, seekSpec.name);
1956
+ return {
1957
+ payload: {
1958
+ graph: graphName,
1959
+ action: 'drop',
1960
+ name: seekSpec.name,
1961
+ tick: existing.tick,
1962
+ },
1963
+ exitCode: EXIT_CODES.OK,
1964
+ };
1965
+ }
1966
+ if (seekSpec.action === 'latest') {
1967
+ await clearActiveCursor(persistence, graphName);
1968
+ await graph.materialize();
1969
+ const nodes = await graph.getNodes();
1970
+ const edges = await graph.getEdges();
1971
+ const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
1972
+ const tickReceipt = await buildTickReceipt({ tick: maxTick, perWriter, graph });
1973
+ return {
1974
+ payload: {
1975
+ graph: graphName,
1976
+ action: 'latest',
1977
+ tick: maxTick,
1978
+ maxTick,
1979
+ ticks,
1980
+ nodes: nodes.length,
1981
+ edges: edges.length,
1982
+ perWriter: serializePerWriter(perWriter),
1983
+ patchCount: countPatchesAtTick(maxTick, perWriter),
1984
+ diff,
1985
+ tickReceipt,
1986
+ cursor: { active: false },
1987
+ },
1988
+ exitCode: EXIT_CODES.OK,
1989
+ };
1990
+ }
1991
+ if (seekSpec.action === 'save') {
1992
+ if (!activeCursor) {
1993
+ throw usageError('No active cursor to save. Use --tick first.');
1994
+ }
1995
+ await writeSavedCursor(persistence, graphName, seekSpec.name, activeCursor);
1996
+ return {
1997
+ payload: {
1998
+ graph: graphName,
1999
+ action: 'save',
2000
+ name: seekSpec.name,
2001
+ tick: activeCursor.tick,
2002
+ },
2003
+ exitCode: EXIT_CODES.OK,
2004
+ };
2005
+ }
2006
+ if (seekSpec.action === 'load') {
2007
+ const saved = await readSavedCursor(persistence, graphName, seekSpec.name);
2008
+ if (!saved) {
2009
+ throw notFoundError(`Saved cursor not found: ${seekSpec.name}`);
2010
+ }
2011
+ await graph.materialize({ ceiling: saved.tick });
2012
+ const nodes = await graph.getNodes();
2013
+ const edges = await graph.getEdges();
2014
+ await writeActiveCursor(persistence, graphName, { tick: saved.tick, mode: saved.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
2015
+ const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
2016
+ const tickReceipt = await buildTickReceipt({ tick: saved.tick, perWriter, graph });
2017
+ return {
2018
+ payload: {
2019
+ graph: graphName,
2020
+ action: 'load',
2021
+ name: seekSpec.name,
2022
+ tick: saved.tick,
2023
+ maxTick,
2024
+ ticks,
2025
+ nodes: nodes.length,
2026
+ edges: edges.length,
2027
+ perWriter: serializePerWriter(perWriter),
2028
+ patchCount: countPatchesAtTick(saved.tick, perWriter),
2029
+ diff,
2030
+ tickReceipt,
2031
+ cursor: { active: true, mode: saved.mode, tick: saved.tick, maxTick, name: seekSpec.name },
2032
+ },
2033
+ exitCode: EXIT_CODES.OK,
2034
+ };
2035
+ }
2036
+ if (seekSpec.action === 'tick') {
2037
+ const currentTick = activeCursor ? activeCursor.tick : null;
2038
+ const resolvedTick = resolveTickValue(seekSpec.tickValue, currentTick, ticks, maxTick);
2039
+ await graph.materialize({ ceiling: resolvedTick });
2040
+ const nodes = await graph.getNodes();
2041
+ const edges = await graph.getEdges();
2042
+ await writeActiveCursor(persistence, graphName, { tick: resolvedTick, mode: 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
2043
+ const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
2044
+ const tickReceipt = await buildTickReceipt({ tick: resolvedTick, perWriter, graph });
2045
+ return {
2046
+ payload: {
2047
+ graph: graphName,
2048
+ action: 'tick',
2049
+ tick: resolvedTick,
2050
+ maxTick,
2051
+ ticks,
2052
+ nodes: nodes.length,
2053
+ edges: edges.length,
2054
+ perWriter: serializePerWriter(perWriter),
2055
+ patchCount: countPatchesAtTick(resolvedTick, perWriter),
2056
+ diff,
2057
+ tickReceipt,
2058
+ cursor: { active: true, mode: 'lamport', tick: resolvedTick, maxTick, name: 'active' },
2059
+ },
2060
+ exitCode: EXIT_CODES.OK,
2061
+ };
2062
+ }
2063
+
2064
+ // status (bare seek)
2065
+ if (activeCursor) {
2066
+ await graph.materialize({ ceiling: activeCursor.tick });
2067
+ const nodes = await graph.getNodes();
2068
+ const edges = await graph.getEdges();
2069
+ const prevCounts = readSeekCounts(activeCursor);
2070
+ const prevFrontierHash = typeof activeCursor.frontierHash === 'string' ? activeCursor.frontierHash : null;
2071
+ if (prevCounts.nodes === null || prevCounts.edges === null || prevCounts.nodes !== nodes.length || prevCounts.edges !== edges.length || prevFrontierHash !== frontierHash) {
2072
+ await writeActiveCursor(persistence, graphName, { tick: activeCursor.tick, mode: activeCursor.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
2073
+ }
2074
+ const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
2075
+ const tickReceipt = await buildTickReceipt({ tick: activeCursor.tick, perWriter, graph });
2076
+ return {
2077
+ payload: {
2078
+ graph: graphName,
2079
+ action: 'status',
2080
+ tick: activeCursor.tick,
2081
+ maxTick,
2082
+ ticks,
2083
+ nodes: nodes.length,
2084
+ edges: edges.length,
2085
+ perWriter: serializePerWriter(perWriter),
2086
+ patchCount: countPatchesAtTick(activeCursor.tick, perWriter),
2087
+ diff,
2088
+ tickReceipt,
2089
+ cursor: { active: true, mode: activeCursor.mode, tick: activeCursor.tick, maxTick, name: 'active' },
2090
+ },
2091
+ exitCode: EXIT_CODES.OK,
2092
+ };
2093
+ }
2094
+ await graph.materialize();
2095
+ const nodes = await graph.getNodes();
2096
+ const edges = await graph.getEdges();
2097
+ const tickReceipt = await buildTickReceipt({ tick: maxTick, perWriter, graph });
2098
+ return {
2099
+ payload: {
2100
+ graph: graphName,
2101
+ action: 'status',
2102
+ tick: maxTick,
2103
+ maxTick,
2104
+ ticks,
2105
+ nodes: nodes.length,
2106
+ edges: edges.length,
2107
+ perWriter: serializePerWriter(perWriter),
2108
+ patchCount: countPatchesAtTick(maxTick, perWriter),
2109
+ diff: null,
2110
+ tickReceipt,
2111
+ cursor: { active: false },
2112
+ },
2113
+ exitCode: EXIT_CODES.OK,
2114
+ };
2115
+ }
2116
+
2117
+ /**
2118
+ * Converts the per-writer Map from discoverTicks() into a plain object for JSON output.
2119
+ *
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
2123
+ */
2124
+ function serializePerWriter(perWriter) {
2125
+ const result = {};
2126
+ for (const [writerId, info] of perWriter) {
2127
+ result[writerId] = { ticks: info.ticks, tipSha: info.tipSha, tickShas: info.tickShas };
2128
+ }
2129
+ return result;
2130
+ }
2131
+
2132
+ /**
2133
+ * Counts the total number of patches across all writers at or before the given tick.
2134
+ *
2135
+ * @private
2136
+ * @param {number} tick - Lamport tick ceiling (inclusive)
2137
+ * @param {Map<string, {ticks: number[], tipSha: string|null}>} perWriter - Per-writer tick data
2138
+ * @returns {number} Total patch count at or before the given tick
2139
+ */
2140
+ function countPatchesAtTick(tick, perWriter) {
2141
+ let count = 0;
2142
+ for (const [, info] of perWriter) {
2143
+ for (const t of info.ticks) {
2144
+ if (t <= tick) {
2145
+ count++;
2146
+ }
2147
+ }
2148
+ }
2149
+ return count;
2150
+ }
2151
+
2152
+ /**
2153
+ * Computes a stable fingerprint of the current graph frontier (writer tips).
2154
+ *
2155
+ * Used to suppress seek diffs when graph history may have changed since the
2156
+ * previous cursor snapshot (e.g. new writers/patches, rewritten refs).
2157
+ *
2158
+ * @private
2159
+ * @param {Map<string, {tipSha: string|null}>} perWriter - Per-writer metadata from discoverTicks()
2160
+ * @returns {string} Hex digest of the frontier fingerprint
2161
+ */
2162
+ function computeFrontierHash(perWriter) {
2163
+ const tips = {};
2164
+ for (const [writerId, info] of perWriter) {
2165
+ tips[writerId] = info?.tipSha || null;
2166
+ }
2167
+ return crypto.createHash('sha256').update(stableStringify(tips)).digest('hex');
2168
+ }
2169
+
2170
+ /**
2171
+ * Reads cached seek state counts from a cursor blob.
2172
+ *
2173
+ * Counts may be missing for older cursors (pre-diff support). In that case
2174
+ * callers should treat the counts as unknown and suppress diffs.
2175
+ *
2176
+ * @private
2177
+ * @param {Object|null} cursor - Parsed cursor blob object
2178
+ * @returns {{nodes: number|null, edges: number|null}} Parsed counts
2179
+ */
2180
+ function readSeekCounts(cursor) {
2181
+ if (!cursor || typeof cursor !== 'object') {
2182
+ return { nodes: null, edges: null };
2183
+ }
2184
+
2185
+ const nodes = typeof cursor.nodes === 'number' && Number.isFinite(cursor.nodes) ? cursor.nodes : null;
2186
+ const edges = typeof cursor.edges === 'number' && Number.isFinite(cursor.edges) ? cursor.edges : null;
2187
+ return { nodes, edges };
2188
+ }
2189
+
2190
+ /**
2191
+ * Computes node/edge deltas between the current seek position and the previous cursor.
2192
+ *
2193
+ * Returns null if the previous cursor is missing cached counts.
2194
+ *
2195
+ * @private
2196
+ * @param {Object|null} prevCursor - Cursor object read before updating the position
2197
+ * @param {{nodes: number, edges: number}} next - Current materialized counts
2198
+ * @param {string} frontierHash - Frontier fingerprint of the current graph
2199
+ * @returns {{nodes: number, edges: number}|null} Diff object or null when unknown
2200
+ */
2201
+ function computeSeekStateDiff(prevCursor, next, frontierHash) {
2202
+ const prev = readSeekCounts(prevCursor);
2203
+ if (prev.nodes === null || prev.edges === null) {
2204
+ return null;
2205
+ }
2206
+ const prevFrontierHash = typeof prevCursor?.frontierHash === 'string' ? prevCursor.frontierHash : null;
2207
+ if (!prevFrontierHash || prevFrontierHash !== frontierHash) {
2208
+ return null;
2209
+ }
2210
+ return {
2211
+ nodes: next.nodes - prev.nodes,
2212
+ edges: next.edges - prev.edges,
2213
+ };
2214
+ }
2215
+
2216
+ /**
2217
+ * Builds a per-writer operation summary for patches at an exact tick.
2218
+ *
2219
+ * Uses discoverTicks() tickShas mapping to locate patch SHAs, then loads and
2220
+ * summarizes patch ops. Typically only a handful of writers have a patch at any
2221
+ * single Lamport tick.
2222
+ *
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
2229
+ */
2230
+ async function buildTickReceipt({ tick, perWriter, graph }) {
2231
+ if (!Number.isInteger(tick) || tick <= 0) {
2232
+ return null;
2233
+ }
2234
+
2235
+ const receipt = {};
2236
+
2237
+ for (const [writerId, info] of perWriter) {
2238
+ const sha = info?.tickShas?.[tick];
2239
+ if (!sha) {
2240
+ continue;
2241
+ }
2242
+
2243
+ const patch = await graph.loadPatchBySha(sha);
2244
+ const ops = Array.isArray(patch?.ops) ? patch.ops : [];
2245
+ receipt[writerId] = { sha, opSummary: summarizeOps(ops) };
2246
+ }
2247
+
2248
+ return Object.keys(receipt).length > 0 ? receipt : null;
2249
+ }
2250
+
2251
+ /**
2252
+ * Renders a seek command payload as a human-readable string for terminal output.
2253
+ *
2254
+ * Handles all seek actions: list, drop, save, latest, load, tick, and status.
2255
+ *
2256
+ * @private
2257
+ * @param {Object} payload - Seek result payload from handleSeek
2258
+ * @returns {string} Formatted output string (includes trailing newline)
2259
+ */
2260
+ function renderSeek(payload) {
2261
+ const formatDelta = (n) => {
2262
+ if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) {
2263
+ return '';
2264
+ }
2265
+ const sign = n > 0 ? '+' : '';
2266
+ return ` (${sign}${n})`;
2267
+ };
2268
+
2269
+ const formatOpSummaryPlain = (summary) => {
2270
+ const order = [
2271
+ ['NodeAdd', '+', 'node'],
2272
+ ['EdgeAdd', '+', 'edge'],
2273
+ ['PropSet', '~', 'prop'],
2274
+ ['NodeTombstone', '-', 'node'],
2275
+ ['EdgeTombstone', '-', 'edge'],
2276
+ ['BlobValue', '+', 'blob'],
2277
+ ];
2278
+
2279
+ const parts = [];
2280
+ for (const [opType, symbol, label] of order) {
2281
+ const n = summary?.[opType];
2282
+ if (typeof n === 'number' && Number.isFinite(n) && n > 0) {
2283
+ parts.push(`${symbol}${n}${label}`);
2284
+ }
2285
+ }
2286
+ return parts.length > 0 ? parts.join(' ') : '(empty)';
2287
+ };
2288
+
2289
+ const appendReceiptSummary = (baseLine) => {
2290
+ const tickReceipt = payload?.tickReceipt;
2291
+ if (!tickReceipt || typeof tickReceipt !== 'object') {
2292
+ return `${baseLine}\n`;
2293
+ }
2294
+
2295
+ const entries = Object.entries(tickReceipt)
2296
+ .filter(([writerId, entry]) => writerId && entry && typeof entry === 'object')
2297
+ .sort(([a], [b]) => a.localeCompare(b));
2298
+
2299
+ if (entries.length === 0) {
2300
+ return `${baseLine}\n`;
2301
+ }
2302
+
2303
+ const maxWriterLen = Math.max(5, ...entries.map(([writerId]) => writerId.length));
2304
+ const receiptLines = [` Tick ${payload.tick}:`];
2305
+ for (const [writerId, entry] of entries) {
2306
+ const sha = typeof entry.sha === 'string' ? entry.sha.slice(0, 7) : '';
2307
+ const opSummary = entry.opSummary && typeof entry.opSummary === 'object' ? entry.opSummary : entry;
2308
+ receiptLines.push(` ${writerId.padEnd(maxWriterLen)} ${sha.padEnd(7)} ${formatOpSummaryPlain(opSummary)}`);
2309
+ }
2310
+
2311
+ return `${baseLine}\n${receiptLines.join('\n')}\n`;
2312
+ };
2313
+
2314
+ const buildStateStrings = () => {
2315
+ const nodeLabel = payload.nodes === 1 ? 'node' : 'nodes';
2316
+ const edgeLabel = payload.edges === 1 ? 'edge' : 'edges';
2317
+ const patchLabel = payload.patchCount === 1 ? 'patch' : 'patches';
2318
+ return {
2319
+ nodesStr: `${payload.nodes} ${nodeLabel}${formatDelta(payload.diff?.nodes)}`,
2320
+ edgesStr: `${payload.edges} ${edgeLabel}${formatDelta(payload.diff?.edges)}`,
2321
+ patchesStr: `${payload.patchCount} ${patchLabel}`,
2322
+ };
2323
+ };
2324
+
2325
+ if (payload.action === 'list') {
2326
+ if (payload.cursors.length === 0) {
2327
+ return 'No saved cursors.\n';
2328
+ }
2329
+ const lines = [];
2330
+ for (const c of payload.cursors) {
2331
+ const active = c.tick === payload.activeTick ? ' (active)' : '';
2332
+ lines.push(` ${c.name}: tick ${c.tick}${active}`);
2333
+ }
2334
+ return `${lines.join('\n')}\n`;
2335
+ }
2336
+
2337
+ if (payload.action === 'drop') {
2338
+ return `Dropped cursor "${payload.name}" (was at tick ${payload.tick}).\n`;
2339
+ }
2340
+
2341
+ if (payload.action === 'save') {
2342
+ return `Saved cursor "${payload.name}" at tick ${payload.tick}.\n`;
2343
+ }
2344
+
2345
+ if (payload.action === 'latest') {
2346
+ const { nodesStr, edgesStr } = buildStateStrings();
2347
+ return appendReceiptSummary(
2348
+ `${payload.graph}: returned to present (tick ${payload.maxTick}, ${nodesStr}, ${edgesStr})`,
2349
+ );
2350
+ }
2351
+
2352
+ if (payload.action === 'load') {
2353
+ const { nodesStr, edgesStr } = buildStateStrings();
2354
+ return appendReceiptSummary(
2355
+ `${payload.graph}: loaded cursor "${payload.name}" at tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr})`,
2356
+ );
2357
+ }
2358
+
2359
+ if (payload.action === 'tick') {
2360
+ const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
2361
+ return appendReceiptSummary(
2362
+ `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
2363
+ );
2364
+ }
2365
+
2366
+ // status
2367
+ if (payload.cursor && payload.cursor.active) {
2368
+ const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
2369
+ return appendReceiptSummary(
2370
+ `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
2371
+ );
2372
+ }
2373
+
2374
+ return `${payload.graph}: no cursor active, ${payload.ticks.length} ticks available\n`;
2375
+ }
2376
+
2377
+ /**
2378
+ * Reads the active cursor and sets `_seekCeiling` on the graph instance
2379
+ * so that subsequent materialize calls respect the time-travel boundary.
2380
+ *
2381
+ * Called by non-seek commands (query, path, check, etc.) that should
2382
+ * honour an active seek cursor.
2383
+ *
2384
+ * @private
2385
+ * @param {Object} graph - WarpGraph instance
2386
+ * @param {Object} persistence - GraphPersistencePort adapter
2387
+ * @param {string} graphName - Name of the WARP graph
2388
+ * @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
+ */
2390
+ async function applyCursorCeiling(graph, persistence, graphName) {
2391
+ const cursor = await readActiveCursor(persistence, graphName);
2392
+ if (cursor) {
2393
+ graph._seekCeiling = cursor.tick;
2394
+ return { active: true, tick: cursor.tick, maxTick: null };
2395
+ }
2396
+ return { active: false, tick: null, maxTick: null };
2397
+ }
2398
+
2399
+ /**
2400
+ * Prints a seek cursor warning banner to stderr when a cursor is active.
2401
+ *
2402
+ * No-op if the cursor is not active.
2403
+ *
2404
+ * Non-seek commands (query, path, check, history, materialize) pass null for
2405
+ * maxTick to avoid the cost of discoverTicks(); the banner then omits the
2406
+ * "of {maxTick}" suffix. Only the seek handler itself populates maxTick.
2407
+ *
2408
+ * @private
2409
+ * @param {{active: boolean, tick: number|null, maxTick: number|null}} cursorInfo - Result from applyCursorCeiling
2410
+ * @param {number|null} maxTick - Maximum Lamport tick (from discoverTicks), or null if unknown
2411
+ * @returns {void}
2412
+ */
2413
+ function emitCursorWarning(cursorInfo, maxTick) {
2414
+ if (cursorInfo.active) {
2415
+ const maxLabel = maxTick !== null && maxTick !== undefined ? ` of ${maxTick}` : '';
2416
+ process.stderr.write(`\u26A0 seek active (tick ${cursorInfo.tick}${maxLabel}) \u2014 run "git warp seek --latest" to return to present\n`);
2417
+ }
2418
+ }
2419
+
1468
2420
  async function handleView({ options, args }) {
1469
2421
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
1470
2422
  throw usageError('view command requires an interactive terminal (TTY)');
@@ -1500,6 +2452,7 @@ const COMMANDS = new Map([
1500
2452
  ['history', handleHistory],
1501
2453
  ['check', handleCheck],
1502
2454
  ['materialize', handleMaterialize],
2455
+ ['seek', handleSeek],
1503
2456
  ['view', handleView],
1504
2457
  ['install-hooks', handleInstallHooks],
1505
2458
  ]);
@@ -1534,7 +2487,7 @@ async function main() {
1534
2487
  throw usageError(`Unknown command: ${command}`);
1535
2488
  }
1536
2489
 
1537
- const VIEW_SUPPORTED_COMMANDS = ['info', 'check', 'history', 'path', 'materialize', 'query'];
2490
+ const VIEW_SUPPORTED_COMMANDS = ['info', 'check', 'history', 'path', 'materialize', 'query', 'seek'];
1538
2491
  if (options.view && !VIEW_SUPPORTED_COMMANDS.includes(command)) {
1539
2492
  throw usageError(`--view is not supported for '${command}'. Supported commands: ${VIEW_SUPPORTED_COMMANDS.join(', ')}`);
1540
2493
  }