@git-stunts/git-warp 10.4.2 → 10.8.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.
package/bin/warp-graph.js CHANGED
@@ -25,16 +25,15 @@ import {
25
25
  } from '../src/domain/utils/RefLayout.js';
26
26
  import CasSeekCacheAdapter from '../src/infrastructure/adapters/CasSeekCacheAdapter.js';
27
27
  import { HookInstaller, classifyExistingHook } from '../src/domain/services/HookInstaller.js';
28
- import { renderInfoView } from '../src/visualization/renderers/ascii/info.js';
29
- import { renderCheckView } from '../src/visualization/renderers/ascii/check.js';
30
- import { renderHistoryView, summarizeOps } from '../src/visualization/renderers/ascii/history.js';
31
- import { renderPathView } from '../src/visualization/renderers/ascii/path.js';
32
- import { renderMaterializeView } from '../src/visualization/renderers/ascii/materialize.js';
28
+ import { summarizeOps } from '../src/visualization/renderers/ascii/history.js';
33
29
  import { parseCursorBlob } from '../src/domain/utils/parseCursorBlob.js';
34
- import { renderSeekView } from '../src/visualization/renderers/ascii/seek.js';
30
+ import { diffStates } from '../src/domain/services/StateDiff.js';
35
31
  import { renderGraphView } from '../src/visualization/renderers/ascii/graph.js';
36
32
  import { renderSvg } from '../src/visualization/renderers/svg/index.js';
37
33
  import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../src/visualization/layouts/index.js';
34
+ import { present } from './presenters/index.js';
35
+ import { stableStringify, compactStringify } from './presenters/json.js';
36
+ import { renderError } from './presenters/text.js';
38
37
 
39
38
  /**
40
39
  * @typedef {Object} Persistence
@@ -63,6 +62,7 @@ import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../s
63
62
  * @property {() => Promise<Map<string, any>>} getFrontier
64
63
  * @property {() => {totalTombstones: number, tombstoneRatio: number}} getGCMetrics
65
64
  * @property {() => Promise<number>} getPropertyCount
65
+ * @property {() => Promise<import('../src/domain/services/JoinReducer.js').WarpStateV5 | null>} getStateSnapshot
66
66
  * @property {() => Promise<{ticks: number[], maxTick: number, perWriter: Map<string, WriterTickInfo>}>} discoverTicks
67
67
  * @property {(sha: string) => Promise<{ops?: any[]}>} loadPatchBySha
68
68
  * @property {(cache: any) => void} setSeekCache
@@ -91,6 +91,7 @@ import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../s
91
91
  * @typedef {Object} CliOptions
92
92
  * @property {string} repo
93
93
  * @property {boolean} json
94
+ * @property {boolean} ndjson
94
95
  * @property {string|null} view
95
96
  * @property {string|null} graph
96
97
  * @property {string} writer
@@ -113,6 +114,8 @@ import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../s
113
114
  * @property {string|null} tickValue
114
115
  * @property {string|null} name
115
116
  * @property {boolean} noPersistentCache
117
+ * @property {boolean} diff
118
+ * @property {number} diffLimit
116
119
  */
117
120
 
118
121
  const EXIT_CODES = {
@@ -138,7 +141,8 @@ Commands:
138
141
 
139
142
  Options:
140
143
  --repo <path> Path to git repo (default: cwd)
141
- --json Emit JSON output
144
+ --json Emit JSON output (pretty-printed, sorted keys)
145
+ --ndjson Emit compact single-line JSON (for piping/scripting)
142
146
  --view [mode] Visual output (ascii, browser, svg:FILE, html:FILE)
143
147
  --graph <name> Graph name (required if repo has multiple graphs)
144
148
  --writer <id> Writer id (default: cli)
@@ -171,6 +175,8 @@ Seek options:
171
175
  --load <name> Restore a saved cursor
172
176
  --list List all saved cursors
173
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)
174
180
  `;
175
181
 
176
182
  /**
@@ -202,27 +208,6 @@ function notFoundError(message) {
202
208
  return new CliError(message, { code: 'E_NOT_FOUND', exitCode: EXIT_CODES.NOT_FOUND });
203
209
  }
204
210
 
205
- /** @param {*} value */
206
- function stableStringify(value) {
207
- /** @param {*} input @returns {*} */
208
- const normalize = (input) => {
209
- if (Array.isArray(input)) {
210
- return input.map(normalize);
211
- }
212
- if (input && typeof input === 'object') {
213
- /** @type {Record<string, *>} */
214
- const sorted = {};
215
- for (const key of Object.keys(input).sort()) {
216
- sorted[key] = normalize(input[key]);
217
- }
218
- return sorted;
219
- }
220
- return input;
221
- };
222
-
223
- return JSON.stringify(normalize(value), null, 2);
224
- }
225
-
226
211
  /** @param {string[]} argv */
227
212
  function parseArgs(argv) {
228
213
  const options = createDefaultOptions();
@@ -250,6 +235,7 @@ function createDefaultOptions() {
250
235
  return {
251
236
  repo: process.cwd(),
252
237
  json: false,
238
+ ndjson: false,
253
239
  view: null,
254
240
  graph: null,
255
241
  writer: 'cli',
@@ -278,6 +264,11 @@ function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
278
264
  return { consumed: 0 };
279
265
  }
280
266
 
267
+ if (arg === '--ndjson') {
268
+ options.ndjson = true;
269
+ return { consumed: 0 };
270
+ }
271
+
281
272
  if (arg === '--view') {
282
273
  // Valid view modes: ascii, browser, svg:FILE, html:FILE
283
274
  // Don't consume known commands as modes
@@ -787,299 +778,6 @@ function patchTouchesNode(patch, nodeId) {
787
778
  return false;
788
779
  }
789
780
 
790
- /** @param {*} payload */
791
- function renderInfo(payload) {
792
- const lines = [`Repo: ${payload.repo}`];
793
- lines.push(`Graphs: ${payload.graphs.length}`);
794
- for (const graph of payload.graphs) {
795
- const writers = graph.writers ? ` writers=${graph.writers.count}` : '';
796
- lines.push(`- ${graph.name}${writers}`);
797
- if (graph.checkpoint?.sha) {
798
- lines.push(` checkpoint: ${graph.checkpoint.sha}`);
799
- }
800
- if (graph.coverage?.sha) {
801
- lines.push(` coverage: ${graph.coverage.sha}`);
802
- }
803
- if (graph.cursor?.active) {
804
- lines.push(` cursor: tick ${graph.cursor.tick} (${graph.cursor.mode})`);
805
- }
806
- }
807
- return `${lines.join('\n')}\n`;
808
- }
809
-
810
- /** @param {*} payload */
811
- function renderQuery(payload) {
812
- const lines = [
813
- `Graph: ${payload.graph}`,
814
- `State: ${payload.stateHash}`,
815
- `Nodes: ${payload.nodes.length}`,
816
- ];
817
-
818
- for (const node of payload.nodes) {
819
- const id = node.id ?? '(unknown)';
820
- lines.push(`- ${id}`);
821
- if (node.props && Object.keys(node.props).length > 0) {
822
- lines.push(` props: ${JSON.stringify(node.props)}`);
823
- }
824
- }
825
-
826
- return `${lines.join('\n')}\n`;
827
- }
828
-
829
- /** @param {*} payload */
830
- function renderPath(payload) {
831
- const lines = [
832
- `Graph: ${payload.graph}`,
833
- `From: ${payload.from}`,
834
- `To: ${payload.to}`,
835
- `Found: ${payload.found ? 'yes' : 'no'}`,
836
- `Length: ${payload.length}`,
837
- ];
838
-
839
- if (payload.path && payload.path.length > 0) {
840
- lines.push(`Path: ${payload.path.join(' -> ')}`);
841
- }
842
-
843
- return `${lines.join('\n')}\n`;
844
- }
845
-
846
- const ANSI_GREEN = '\x1b[32m';
847
- const ANSI_YELLOW = '\x1b[33m';
848
- const ANSI_RED = '\x1b[31m';
849
- const ANSI_DIM = '\x1b[2m';
850
- const ANSI_RESET = '\x1b[0m';
851
-
852
- /** @param {string} state */
853
- function colorCachedState(state) {
854
- if (state === 'fresh') {
855
- return `${ANSI_GREEN}${state}${ANSI_RESET}`;
856
- }
857
- if (state === 'stale') {
858
- return `${ANSI_YELLOW}${state}${ANSI_RESET}`;
859
- }
860
- return `${ANSI_RED}${ANSI_DIM}${state}${ANSI_RESET}`;
861
- }
862
-
863
- /** @param {*} payload */
864
- function renderCheck(payload) {
865
- const lines = [
866
- `Graph: ${payload.graph}`,
867
- `Health: ${payload.health.status}`,
868
- ];
869
-
870
- if (payload.status) {
871
- lines.push(`Cached State: ${colorCachedState(payload.status.cachedState)}`);
872
- lines.push(`Patches Since Checkpoint: ${payload.status.patchesSinceCheckpoint}`);
873
- lines.push(`Tombstone Ratio: ${payload.status.tombstoneRatio.toFixed(2)}`);
874
- lines.push(`Writers: ${payload.status.writers}`);
875
- }
876
-
877
- if (payload.checkpoint?.sha) {
878
- lines.push(`Checkpoint: ${payload.checkpoint.sha}`);
879
- if (payload.checkpoint.ageSeconds !== null) {
880
- lines.push(`Checkpoint Age: ${payload.checkpoint.ageSeconds}s`);
881
- }
882
- } else {
883
- lines.push('Checkpoint: none');
884
- }
885
-
886
- if (!payload.status) {
887
- lines.push(`Writers: ${payload.writers.count}`);
888
- }
889
- for (const head of payload.writers.heads) {
890
- lines.push(`- ${head.writerId}: ${head.sha}`);
891
- }
892
-
893
- if (payload.coverage?.sha) {
894
- lines.push(`Coverage: ${payload.coverage.sha}`);
895
- lines.push(`Coverage Missing: ${payload.coverage.missingWriters.length}`);
896
- } else {
897
- lines.push('Coverage: none');
898
- }
899
-
900
- if (payload.gc) {
901
- lines.push(`Tombstones: ${payload.gc.totalTombstones}`);
902
- if (!payload.status) {
903
- lines.push(`Tombstone Ratio: ${payload.gc.tombstoneRatio}`);
904
- }
905
- }
906
-
907
- if (payload.hook) {
908
- lines.push(formatHookStatusLine(payload.hook));
909
- }
910
-
911
- return `${lines.join('\n')}\n`;
912
- }
913
-
914
- /** @param {*} hook */
915
- function formatHookStatusLine(hook) {
916
- if (!hook.installed && hook.foreign) {
917
- return "Hook: foreign hook present — run 'git warp install-hooks'";
918
- }
919
- if (!hook.installed) {
920
- return "Hook: not installed — run 'git warp install-hooks'";
921
- }
922
- if (hook.current) {
923
- return `Hook: installed (v${hook.version}) — up to date`;
924
- }
925
- return `Hook: installed (v${hook.version}) — upgrade available, run 'git warp install-hooks'`;
926
- }
927
-
928
- /** @param {*} payload */
929
- function renderHistory(payload) {
930
- const lines = [
931
- `Graph: ${payload.graph}`,
932
- `Writer: ${payload.writer}`,
933
- `Entries: ${payload.entries.length}`,
934
- ];
935
-
936
- if (payload.nodeFilter) {
937
- lines.push(`Node Filter: ${payload.nodeFilter}`);
938
- }
939
-
940
- for (const entry of payload.entries) {
941
- lines.push(`- ${entry.sha} (lamport: ${entry.lamport}, ops: ${entry.opCount})`);
942
- }
943
-
944
- return `${lines.join('\n')}\n`;
945
- }
946
-
947
- /** @param {*} payload */
948
- function renderError(payload) {
949
- return `Error: ${payload.error.message}\n`;
950
- }
951
-
952
- /**
953
- * Wraps SVG content in a minimal HTML document and writes it to disk.
954
- * @param {string} filePath - Destination file path
955
- * @param {string} svgContent - SVG markup to embed
956
- */
957
- function writeHtmlExport(filePath, svgContent) {
958
- const html = `<!DOCTYPE html>\n<html><head><meta charset="utf-8"><title>git-warp</title></head><body>\n${svgContent}\n</body></html>`;
959
- fs.writeFileSync(filePath, html);
960
- }
961
-
962
- /**
963
- * Writes a command result to stdout/stderr in the appropriate format.
964
- * Dispatches to JSON, SVG file, HTML file, ASCII view, or plain text
965
- * based on the combination of flags.
966
- * @param {*} payload - Command result payload
967
- * @param {{json: boolean, command: string, view: string|null}} options
968
- */
969
- function emit(payload, { json, command, view }) {
970
- if (json) {
971
- process.stdout.write(`${stableStringify(payload)}\n`);
972
- return;
973
- }
974
-
975
- if (command === 'info') {
976
- if (view) {
977
- process.stdout.write(renderInfoView(payload));
978
- } else {
979
- process.stdout.write(renderInfo(payload));
980
- }
981
- return;
982
- }
983
-
984
- if (command === 'query') {
985
- if (view && typeof view === 'string' && view.startsWith('svg:')) {
986
- const svgPath = view.slice(4);
987
- if (!payload._renderedSvg) {
988
- process.stderr.write('No graph data — skipping SVG export.\n');
989
- } else {
990
- fs.writeFileSync(svgPath, payload._renderedSvg);
991
- process.stderr.write(`SVG written to ${svgPath}\n`);
992
- }
993
- } else if (view && typeof view === 'string' && view.startsWith('html:')) {
994
- const htmlPath = view.slice(5);
995
- if (!payload._renderedSvg) {
996
- process.stderr.write('No graph data — skipping HTML export.\n');
997
- } else {
998
- writeHtmlExport(htmlPath, payload._renderedSvg);
999
- process.stderr.write(`HTML written to ${htmlPath}\n`);
1000
- }
1001
- } else if (view) {
1002
- process.stdout.write(`${payload._renderedAscii}\n`);
1003
- } else {
1004
- process.stdout.write(renderQuery(payload));
1005
- }
1006
- return;
1007
- }
1008
-
1009
- if (command === 'path') {
1010
- if (view && typeof view === 'string' && view.startsWith('svg:')) {
1011
- const svgPath = view.slice(4);
1012
- if (!payload._renderedSvg) {
1013
- process.stderr.write('No path found — skipping SVG export.\n');
1014
- } else {
1015
- fs.writeFileSync(svgPath, payload._renderedSvg);
1016
- process.stderr.write(`SVG written to ${svgPath}\n`);
1017
- }
1018
- } else if (view && typeof view === 'string' && view.startsWith('html:')) {
1019
- const htmlPath = view.slice(5);
1020
- if (!payload._renderedSvg) {
1021
- process.stderr.write('No path found — skipping HTML export.\n');
1022
- } else {
1023
- writeHtmlExport(htmlPath, payload._renderedSvg);
1024
- process.stderr.write(`HTML written to ${htmlPath}\n`);
1025
- }
1026
- } else if (view) {
1027
- process.stdout.write(renderPathView(payload));
1028
- } else {
1029
- process.stdout.write(renderPath(payload));
1030
- }
1031
- return;
1032
- }
1033
-
1034
- if (command === 'check') {
1035
- if (view) {
1036
- process.stdout.write(renderCheckView(payload));
1037
- } else {
1038
- process.stdout.write(renderCheck(payload));
1039
- }
1040
- return;
1041
- }
1042
-
1043
- if (command === 'history') {
1044
- if (view) {
1045
- process.stdout.write(renderHistoryView(payload));
1046
- } else {
1047
- process.stdout.write(renderHistory(payload));
1048
- }
1049
- return;
1050
- }
1051
-
1052
- if (command === 'materialize') {
1053
- if (view) {
1054
- process.stdout.write(renderMaterializeView(payload));
1055
- } else {
1056
- process.stdout.write(renderMaterialize(payload));
1057
- }
1058
- return;
1059
- }
1060
-
1061
- if (command === 'seek') {
1062
- if (view) {
1063
- process.stdout.write(renderSeekView(payload));
1064
- } else {
1065
- process.stdout.write(renderSeek(payload));
1066
- }
1067
- return;
1068
- }
1069
-
1070
- if (command === 'install-hooks') {
1071
- process.stdout.write(renderInstallHooks(payload));
1072
- return;
1073
- }
1074
-
1075
- if (payload?.error) {
1076
- process.stderr.write(renderError(payload));
1077
- return;
1078
- }
1079
-
1080
- process.stdout.write(`${stableStringify(payload)}\n`);
1081
- }
1082
-
1083
781
  /**
1084
782
  * Handles the `info` command: summarizes graphs in the repository.
1085
783
  * @param {{options: CliOptions}} params
@@ -1583,38 +1281,6 @@ async function handleMaterialize({ options }) {
1583
1281
  };
1584
1282
  }
1585
1283
 
1586
- /** @param {*} payload */
1587
- function renderMaterialize(payload) {
1588
- if (payload.graphs.length === 0) {
1589
- return 'No graphs found in repo.\n';
1590
- }
1591
-
1592
- const lines = [];
1593
- for (const entry of payload.graphs) {
1594
- if (entry.error) {
1595
- lines.push(`${entry.graph}: error — ${entry.error}`);
1596
- } else {
1597
- lines.push(`${entry.graph}: ${entry.nodes} nodes, ${entry.edges} edges, checkpoint ${entry.checkpoint}`);
1598
- }
1599
- }
1600
- return `${lines.join('\n')}\n`;
1601
- }
1602
-
1603
- /** @param {*} payload */
1604
- function renderInstallHooks(payload) {
1605
- if (payload.action === 'up-to-date') {
1606
- return `Hook: already up to date (v${payload.version}) at ${payload.hookPath}\n`;
1607
- }
1608
- if (payload.action === 'skipped') {
1609
- return 'Hook: installation skipped\n';
1610
- }
1611
- const lines = [`Hook: ${payload.action} (v${payload.version})`, `Path: ${payload.hookPath}`];
1612
- if (payload.backupPath) {
1613
- lines.push(`Backup: ${payload.backupPath}`);
1614
- }
1615
- return `${lines.join('\n')}\n`;
1616
- }
1617
-
1618
1284
  function createHookInstaller() {
1619
1285
  const __filename = new URL(import.meta.url).pathname;
1620
1286
  const __dirname = path.dirname(__filename);
@@ -1953,9 +1619,64 @@ function handleSeekBooleanFlag(arg, spec) {
1953
1619
  spec.action = 'clear-cache';
1954
1620
  } else if (arg === '--no-persistent-cache') {
1955
1621
  spec.noPersistentCache = true;
1622
+ } else if (arg === '--diff') {
1623
+ spec.diff = true;
1956
1624
  }
1957
1625
  }
1958
1626
 
1627
+ /**
1628
+ * Parses --diff-limit / --diff-limit=N into the seek spec.
1629
+ * @param {string} arg
1630
+ * @param {string[]} args
1631
+ * @param {number} i
1632
+ * @param {SeekSpec} spec
1633
+ */
1634
+ function handleDiffLimitFlag(arg, args, i, spec) {
1635
+ let raw;
1636
+ if (arg.startsWith('--diff-limit=')) {
1637
+ raw = arg.slice('--diff-limit='.length);
1638
+ } else {
1639
+ raw = args[i + 1];
1640
+ if (raw === undefined) {
1641
+ throw usageError('Missing value for --diff-limit');
1642
+ }
1643
+ }
1644
+ const n = Number(raw);
1645
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
1646
+ throw usageError(`Invalid --diff-limit value: ${raw}. Must be a positive integer.`);
1647
+ }
1648
+ spec.diffLimit = n;
1649
+ }
1650
+
1651
+ /**
1652
+ * Parses a named action flag (--save, --load, --drop) with its value.
1653
+ * @param {string} flagName - e.g. 'save'
1654
+ * @param {string} arg - Current arg token
1655
+ * @param {string[]} args - All args
1656
+ * @param {number} i - Current index
1657
+ * @param {SeekSpec} spec
1658
+ * @returns {number} Number of extra args consumed (0 or 1)
1659
+ */
1660
+ function parseSeekNamedAction(flagName, arg, args, i, spec) {
1661
+ if (spec.action !== 'status') {
1662
+ throw usageError(`--${flagName} cannot be combined with other seek flags`);
1663
+ }
1664
+ spec.action = flagName;
1665
+ if (arg === `--${flagName}`) {
1666
+ const val = args[i + 1];
1667
+ if (val === undefined || val.startsWith('-')) {
1668
+ throw usageError(`Missing name for --${flagName}`);
1669
+ }
1670
+ spec.name = val;
1671
+ return 1;
1672
+ }
1673
+ spec.name = arg.slice(`--${flagName}=`.length);
1674
+ if (!spec.name) {
1675
+ throw usageError(`Missing name for --${flagName}`);
1676
+ }
1677
+ return 0;
1678
+ }
1679
+
1959
1680
  /**
1960
1681
  * Parses CLI arguments for the `seek` command into a structured spec.
1961
1682
  * @param {string[]} args - Raw CLI arguments following the `seek` subcommand
@@ -1968,7 +1689,10 @@ function parseSeekArgs(args) {
1968
1689
  tickValue: null,
1969
1690
  name: null,
1970
1691
  noPersistentCache: false,
1692
+ diff: false,
1693
+ diffLimit: 2000,
1971
1694
  };
1695
+ let diffLimitProvided = false;
1972
1696
 
1973
1697
  for (let i = 0; i < args.length; i++) {
1974
1698
  const arg = args[i];
@@ -1995,78 +1719,39 @@ function parseSeekArgs(args) {
1995
1719
  throw usageError('--latest cannot be combined with other seek flags');
1996
1720
  }
1997
1721
  spec.action = 'latest';
1998
- } else if (arg === '--save') {
1999
- if (spec.action !== 'status') {
2000
- throw usageError('--save cannot be combined with other seek flags');
2001
- }
2002
- spec.action = 'save';
2003
- const val = args[i + 1];
2004
- if (val === undefined || val.startsWith('-')) {
2005
- throw usageError('Missing name for --save');
2006
- }
2007
- spec.name = val;
2008
- i += 1;
2009
- } else if (arg.startsWith('--save=')) {
2010
- if (spec.action !== 'status') {
2011
- throw usageError('--save cannot be combined with other seek flags');
2012
- }
2013
- spec.action = 'save';
2014
- spec.name = arg.slice('--save='.length);
2015
- if (!spec.name) {
2016
- throw usageError('Missing name for --save');
2017
- }
2018
- } else if (arg === '--load') {
2019
- if (spec.action !== 'status') {
2020
- throw usageError('--load cannot be combined with other seek flags');
2021
- }
2022
- spec.action = 'load';
2023
- const val = args[i + 1];
2024
- if (val === undefined || val.startsWith('-')) {
2025
- throw usageError('Missing name for --load');
2026
- }
2027
- spec.name = val;
2028
- i += 1;
2029
- } else if (arg.startsWith('--load=')) {
2030
- if (spec.action !== 'status') {
2031
- throw usageError('--load cannot be combined with other seek flags');
2032
- }
2033
- spec.action = 'load';
2034
- spec.name = arg.slice('--load='.length);
2035
- if (!spec.name) {
2036
- throw usageError('Missing name for --load');
2037
- }
1722
+ } else if (arg === '--save' || arg.startsWith('--save=')) {
1723
+ i += parseSeekNamedAction('save', arg, args, i, spec);
1724
+ } else if (arg === '--load' || arg.startsWith('--load=')) {
1725
+ i += parseSeekNamedAction('load', arg, args, i, spec);
2038
1726
  } else if (arg === '--list') {
2039
1727
  if (spec.action !== 'status') {
2040
1728
  throw usageError('--list cannot be combined with other seek flags');
2041
1729
  }
2042
1730
  spec.action = 'list';
2043
- } else if (arg === '--drop') {
2044
- if (spec.action !== 'status') {
2045
- throw usageError('--drop cannot be combined with other seek flags');
2046
- }
2047
- spec.action = 'drop';
2048
- const val = args[i + 1];
2049
- if (val === undefined || val.startsWith('-')) {
2050
- throw usageError('Missing name for --drop');
2051
- }
2052
- spec.name = val;
2053
- i += 1;
2054
- } else if (arg.startsWith('--drop=')) {
2055
- if (spec.action !== 'status') {
2056
- throw usageError('--drop cannot be combined with other seek flags');
2057
- }
2058
- spec.action = 'drop';
2059
- spec.name = arg.slice('--drop='.length);
2060
- if (!spec.name) {
2061
- throw usageError('Missing name for --drop');
2062
- }
2063
- } else if (arg === '--clear-cache' || arg === '--no-persistent-cache') {
1731
+ } else if (arg === '--drop' || arg.startsWith('--drop=')) {
1732
+ i += parseSeekNamedAction('drop', arg, args, i, spec);
1733
+ } else if (arg === '--clear-cache' || arg === '--no-persistent-cache' || arg === '--diff') {
2064
1734
  handleSeekBooleanFlag(arg, spec);
1735
+ } else if (arg === '--diff-limit' || arg.startsWith('--diff-limit=')) {
1736
+ handleDiffLimitFlag(arg, args, i, spec);
1737
+ diffLimitProvided = true;
1738
+ if (arg === '--diff-limit') {
1739
+ i += 1;
1740
+ }
2065
1741
  } else if (arg.startsWith('-')) {
2066
1742
  throw usageError(`Unknown seek option: ${arg}`);
2067
1743
  }
2068
1744
  }
2069
1745
 
1746
+ // --diff is only meaningful for actions that navigate to a tick
1747
+ const DIFF_ACTIONS = new Set(['tick', 'latest', 'load']);
1748
+ if (spec.diff && !DIFF_ACTIONS.has(spec.action)) {
1749
+ throw usageError(`--diff cannot be used with --${spec.action}`);
1750
+ }
1751
+ if (diffLimitProvided && !spec.diff) {
1752
+ throw usageError('--diff-limit requires --diff');
1753
+ }
1754
+
2070
1755
  return spec;
2071
1756
  }
2072
1757
 
@@ -2189,8 +1874,16 @@ async function handleSeek({ options, args }) {
2189
1874
  };
2190
1875
  }
2191
1876
  if (seekSpec.action === 'latest') {
1877
+ const prevTick = activeCursor ? activeCursor.tick : null;
1878
+ let sdResult = null;
1879
+ if (seekSpec.diff) {
1880
+ sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: maxTick, diffLimit: seekSpec.diffLimit });
1881
+ }
2192
1882
  await clearActiveCursor(persistence, graphName);
2193
- await graph.materialize();
1883
+ // When --diff already materialized at maxTick, skip redundant re-materialize
1884
+ if (!sdResult) {
1885
+ await graph.materialize({ ceiling: maxTick });
1886
+ }
2194
1887
  const nodes = await graph.getNodes();
2195
1888
  const edges = await graph.getEdges();
2196
1889
  const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
@@ -2209,6 +1902,7 @@ async function handleSeek({ options, args }) {
2209
1902
  diff,
2210
1903
  tickReceipt,
2211
1904
  cursor: { active: false },
1905
+ ...sdResult,
2212
1906
  },
2213
1907
  exitCode: EXIT_CODES.OK,
2214
1908
  };
@@ -2234,7 +1928,15 @@ async function handleSeek({ options, args }) {
2234
1928
  if (!saved) {
2235
1929
  throw notFoundError(`Saved cursor not found: ${loadName}`);
2236
1930
  }
2237
- await graph.materialize({ ceiling: saved.tick });
1931
+ const prevTick = activeCursor ? activeCursor.tick : null;
1932
+ let sdResult = null;
1933
+ if (seekSpec.diff) {
1934
+ sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: saved.tick, diffLimit: seekSpec.diffLimit });
1935
+ }
1936
+ // When --diff already materialized at saved.tick, skip redundant call
1937
+ if (!sdResult) {
1938
+ await graph.materialize({ ceiling: saved.tick });
1939
+ }
2238
1940
  const nodes = await graph.getNodes();
2239
1941
  const edges = await graph.getEdges();
2240
1942
  await writeActiveCursor(persistence, graphName, { tick: saved.tick, mode: saved.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
@@ -2255,6 +1957,7 @@ async function handleSeek({ options, args }) {
2255
1957
  diff,
2256
1958
  tickReceipt,
2257
1959
  cursor: { active: true, mode: saved.mode, tick: saved.tick, maxTick, name: seekSpec.name },
1960
+ ...sdResult,
2258
1961
  },
2259
1962
  exitCode: EXIT_CODES.OK,
2260
1963
  };
@@ -2262,7 +1965,14 @@ async function handleSeek({ options, args }) {
2262
1965
  if (seekSpec.action === 'tick') {
2263
1966
  const currentTick = activeCursor ? activeCursor.tick : null;
2264
1967
  const resolvedTick = resolveTickValue(/** @type {string} */ (seekSpec.tickValue), currentTick, ticks, maxTick);
2265
- await graph.materialize({ ceiling: resolvedTick });
1968
+ let sdResult = null;
1969
+ if (seekSpec.diff) {
1970
+ sdResult = await computeStructuralDiff({ graph, prevTick: currentTick, currentTick: resolvedTick, diffLimit: seekSpec.diffLimit });
1971
+ }
1972
+ // When --diff already materialized at resolvedTick, skip redundant call
1973
+ if (!sdResult) {
1974
+ await graph.materialize({ ceiling: resolvedTick });
1975
+ }
2266
1976
  const nodes = await graph.getNodes();
2267
1977
  const edges = await graph.getEdges();
2268
1978
  await writeActiveCursor(persistence, graphName, { tick: resolvedTick, mode: 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
@@ -2282,12 +1992,22 @@ async function handleSeek({ options, args }) {
2282
1992
  diff,
2283
1993
  tickReceipt,
2284
1994
  cursor: { active: true, mode: 'lamport', tick: resolvedTick, maxTick, name: 'active' },
1995
+ ...sdResult,
2285
1996
  },
2286
1997
  exitCode: EXIT_CODES.OK,
2287
1998
  };
2288
1999
  }
2289
2000
 
2290
2001
  // status (bare seek)
2002
+ return await handleSeekStatus({ graph, graphName, persistence, activeCursor, ticks, maxTick, perWriter, frontierHash });
2003
+ }
2004
+
2005
+ /**
2006
+ * Handles the `status` sub-action of `seek` (bare seek with no action flag).
2007
+ * @param {{graph: WarpGraphInstance, graphName: string, persistence: Persistence, activeCursor: CursorBlob|null, ticks: number[], maxTick: number, perWriter: Map<string, WriterTickInfo>, frontierHash: string}} params
2008
+ * @returns {Promise<{payload: *, exitCode: number}>}
2009
+ */
2010
+ async function handleSeekStatus({ graph, graphName, persistence, activeCursor, ticks, maxTick, perWriter, frontierHash }) {
2291
2011
  if (activeCursor) {
2292
2012
  await graph.materialize({ ceiling: activeCursor.tick });
2293
2013
  const nodes = await graph.getNodes();
@@ -2469,132 +2189,76 @@ async function buildTickReceipt({ tick, perWriter, graph }) {
2469
2189
  }
2470
2190
 
2471
2191
  /**
2472
- * Renders a seek command payload as a human-readable string for terminal output.
2192
+ * Computes a structural diff between the state at a previous tick and
2193
+ * the state at the current tick.
2473
2194
  *
2474
- * Handles all seek actions: list, drop, save, latest, load, tick, and status.
2195
+ * Materializes the baseline tick first, snapshots the state, then
2196
+ * materializes the target tick and calls diffStates() between the two.
2197
+ * Applies diffLimit truncation when the total change count exceeds the cap.
2475
2198
  *
2476
- * @param {*} payload - Seek result payload from handleSeek
2477
- * @returns {string} Formatted output string (includes trailing newline)
2199
+ * @param {{graph: WarpGraphInstance, prevTick: number|null, currentTick: number, diffLimit: number}} params
2200
+ * @returns {Promise<{structuralDiff: *, diffBaseline: string, baselineTick: number|null, truncated: boolean, totalChanges: number, shownChanges: number}>}
2478
2201
  */
2479
- function renderSeek(payload) {
2480
- const formatDelta = (/** @type {*} */ n) => { // TODO(ts-cleanup): type CLI payload
2481
- if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) {
2482
- return '';
2483
- }
2484
- const sign = n > 0 ? '+' : '';
2485
- return ` (${sign}${n})`;
2486
- };
2487
-
2488
- const formatOpSummaryPlain = (/** @type {*} */ summary) => { // TODO(ts-cleanup): type CLI payload
2489
- const order = [
2490
- ['NodeAdd', '+', 'node'],
2491
- ['EdgeAdd', '+', 'edge'],
2492
- ['PropSet', '~', 'prop'],
2493
- ['NodeTombstone', '-', 'node'],
2494
- ['EdgeTombstone', '-', 'edge'],
2495
- ['BlobValue', '+', 'blob'],
2496
- ];
2497
-
2498
- const parts = [];
2499
- for (const [opType, symbol, label] of order) {
2500
- const n = summary?.[opType];
2501
- if (typeof n === 'number' && Number.isFinite(n) && n > 0) {
2502
- parts.push(`${symbol}${n}${label}`);
2503
- }
2504
- }
2505
- return parts.length > 0 ? parts.join(' ') : '(empty)';
2506
- };
2507
-
2508
- const appendReceiptSummary = (/** @type {string} */ baseLine) => {
2509
- const tickReceipt = payload?.tickReceipt;
2510
- if (!tickReceipt || typeof tickReceipt !== 'object') {
2511
- return `${baseLine}\n`;
2512
- }
2513
-
2514
- const entries = Object.entries(tickReceipt)
2515
- .filter(([writerId, entry]) => writerId && entry && typeof entry === 'object')
2516
- .sort(([a], [b]) => a.localeCompare(b));
2517
-
2518
- if (entries.length === 0) {
2519
- return `${baseLine}\n`;
2520
- }
2521
-
2522
- const maxWriterLen = Math.max(5, ...entries.map(([writerId]) => writerId.length));
2523
- const receiptLines = [` Tick ${payload.tick}:`];
2524
- for (const [writerId, entry] of entries) {
2525
- const sha = typeof entry.sha === 'string' ? entry.sha.slice(0, 7) : '';
2526
- const opSummary = entry.opSummary && typeof entry.opSummary === 'object' ? entry.opSummary : entry;
2527
- receiptLines.push(` ${writerId.padEnd(maxWriterLen)} ${sha.padEnd(7)} ${formatOpSummaryPlain(opSummary)}`);
2528
- }
2529
-
2530
- return `${baseLine}\n${receiptLines.join('\n')}\n`;
2531
- };
2532
-
2533
- const buildStateStrings = () => {
2534
- const nodeLabel = payload.nodes === 1 ? 'node' : 'nodes';
2535
- const edgeLabel = payload.edges === 1 ? 'edge' : 'edges';
2536
- const patchLabel = payload.patchCount === 1 ? 'patch' : 'patches';
2537
- return {
2538
- nodesStr: `${payload.nodes} ${nodeLabel}${formatDelta(payload.diff?.nodes)}`,
2539
- edgesStr: `${payload.edges} ${edgeLabel}${formatDelta(payload.diff?.edges)}`,
2540
- patchesStr: `${payload.patchCount} ${patchLabel}`,
2541
- };
2542
- };
2543
-
2544
- if (payload.action === 'clear-cache') {
2545
- return `${payload.message}\n`;
2546
- }
2547
-
2548
- if (payload.action === 'list') {
2549
- if (payload.cursors.length === 0) {
2550
- return 'No saved cursors.\n';
2551
- }
2552
- const lines = [];
2553
- for (const c of payload.cursors) {
2554
- const active = c.tick === payload.activeTick ? ' (active)' : '';
2555
- lines.push(` ${c.name}: tick ${c.tick}${active}`);
2556
- }
2557
- return `${lines.join('\n')}\n`;
2558
- }
2202
+ async function computeStructuralDiff({ graph, prevTick, currentTick, diffLimit }) {
2203
+ let beforeState = null;
2204
+ let diffBaseline = 'empty';
2205
+ let baselineTick = null;
2559
2206
 
2560
- if (payload.action === 'drop') {
2561
- return `Dropped cursor "${payload.name}" (was at tick ${payload.tick}).\n`;
2207
+ // Short-circuit: same tick produces an empty diff
2208
+ if (prevTick !== null && prevTick === currentTick) {
2209
+ const empty = { nodes: { added: [], removed: [] }, edges: { added: [], removed: [] }, props: { set: [], removed: [] } };
2210
+ return { structuralDiff: empty, diffBaseline: 'tick', baselineTick: prevTick, truncated: false, totalChanges: 0, shownChanges: 0 };
2562
2211
  }
2563
2212
 
2564
- if (payload.action === 'save') {
2565
- return `Saved cursor "${payload.name}" at tick ${payload.tick}.\n`;
2213
+ if (prevTick !== null && prevTick > 0) {
2214
+ await graph.materialize({ ceiling: prevTick });
2215
+ beforeState = await graph.getStateSnapshot();
2216
+ diffBaseline = 'tick';
2217
+ baselineTick = prevTick;
2566
2218
  }
2567
2219
 
2568
- if (payload.action === 'latest') {
2569
- const { nodesStr, edgesStr } = buildStateStrings();
2570
- return appendReceiptSummary(
2571
- `${payload.graph}: returned to present (tick ${payload.maxTick}, ${nodesStr}, ${edgesStr})`,
2572
- );
2573
- }
2220
+ await graph.materialize({ ceiling: currentTick });
2221
+ const afterState = /** @type {*} */ (await graph.getStateSnapshot()); // TODO(ts-cleanup): narrow WarpStateV5
2222
+ const diff = diffStates(beforeState, afterState);
2574
2223
 
2575
- if (payload.action === 'load') {
2576
- const { nodesStr, edgesStr } = buildStateStrings();
2577
- return appendReceiptSummary(
2578
- `${payload.graph}: loaded cursor "${payload.name}" at tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr})`,
2579
- );
2580
- }
2224
+ return applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit);
2225
+ }
2581
2226
 
2582
- if (payload.action === 'tick') {
2583
- const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
2584
- return appendReceiptSummary(
2585
- `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
2586
- );
2587
- }
2227
+ /**
2228
+ * Applies truncation limits to a structural diff result.
2229
+ *
2230
+ * @param {*} diff
2231
+ * @param {string} diffBaseline
2232
+ * @param {number|null} baselineTick
2233
+ * @param {number} diffLimit
2234
+ * @returns {{structuralDiff: *, diffBaseline: string, baselineTick: number|null, truncated: boolean, totalChanges: number, shownChanges: number}}
2235
+ */
2236
+ function applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit) {
2237
+ const totalChanges =
2238
+ diff.nodes.added.length + diff.nodes.removed.length +
2239
+ diff.edges.added.length + diff.edges.removed.length +
2240
+ diff.props.set.length + diff.props.removed.length;
2241
+
2242
+ if (totalChanges <= diffLimit) {
2243
+ return { structuralDiff: diff, diffBaseline, baselineTick, truncated: false, totalChanges, shownChanges: totalChanges };
2244
+ }
2245
+
2246
+ // Truncate sequentially (nodes → edges → props), keeping sort order within each category
2247
+ let remaining = diffLimit;
2248
+ const cap = (/** @type {any[]} */ arr) => {
2249
+ const take = Math.min(arr.length, remaining);
2250
+ remaining -= take;
2251
+ return arr.slice(0, take);
2252
+ };
2588
2253
 
2589
- // status
2590
- if (payload.cursor && payload.cursor.active) {
2591
- const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
2592
- return appendReceiptSummary(
2593
- `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
2594
- );
2595
- }
2254
+ const capped = {
2255
+ nodes: { added: cap(diff.nodes.added), removed: cap(diff.nodes.removed) },
2256
+ edges: { added: cap(diff.edges.added), removed: cap(diff.edges.removed) },
2257
+ props: { set: cap(diff.props.set), removed: cap(diff.props.removed) },
2258
+ };
2596
2259
 
2597
- return `${payload.graph}: no cursor active, ${payload.ticks.length} ticks available\n`;
2260
+ const shownChanges = diffLimit - remaining;
2261
+ return { structuralDiff: capped, diffBaseline, baselineTick, truncated: true, totalChanges, shownChanges };
2598
2262
  }
2599
2263
 
2600
2264
  /**
@@ -2701,6 +2365,12 @@ async function main() {
2701
2365
  if (options.json && options.view) {
2702
2366
  throw usageError('--json and --view are mutually exclusive');
2703
2367
  }
2368
+ if (options.ndjson && options.view) {
2369
+ throw usageError('--ndjson and --view are mutually exclusive');
2370
+ }
2371
+ if (options.json && options.ndjson) {
2372
+ throw usageError('--json and --ndjson are mutually exclusive');
2373
+ }
2704
2374
 
2705
2375
  const command = positionals[0];
2706
2376
  if (!command) {
@@ -2731,7 +2401,8 @@ async function main() {
2731
2401
  : { payload: result, exitCode: EXIT_CODES.OK };
2732
2402
 
2733
2403
  if (normalized.payload !== undefined) {
2734
- emit(normalized.payload, { json: options.json, command, view: options.view });
2404
+ const format = options.ndjson ? 'ndjson' : options.json ? 'json' : 'text';
2405
+ present(normalized.payload, { format, command, view: options.view });
2735
2406
  }
2736
2407
  // Use process.exit() to avoid waiting for fire-and-forget I/O (e.g. seek cache writes).
2737
2408
  process.exit(normalized.exitCode ?? EXIT_CODES.OK);
@@ -2748,8 +2419,9 @@ main().catch((error) => {
2748
2419
  payload.error.cause = error.cause instanceof Error ? error.cause.message : error.cause;
2749
2420
  }
2750
2421
 
2751
- if (process.argv.includes('--json')) {
2752
- process.stdout.write(`${stableStringify(payload)}\n`);
2422
+ if (process.argv.includes('--json') || process.argv.includes('--ndjson')) {
2423
+ const stringify = process.argv.includes('--ndjson') ? compactStringify : stableStringify;
2424
+ process.stdout.write(`${stringify(payload)}\n`);
2753
2425
  } else {
2754
2426
  process.stderr.write(renderError(payload));
2755
2427
  }