@visual-z/xeno 0.7.2 → 0.8.1

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 (117) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +36 -9
  3. package/docs/foundations.md +1 -1
  4. package/package.json +3 -1
  5. package/src/apps/cli.ts +4 -3
  6. package/src/apps/commands.ts +8 -0
  7. package/src/apps/goal-flow.ts +70 -0
  8. package/src/apps/render.ts +0 -2
  9. package/src/apps/session-hub.ts +6 -5
  10. package/src/apps/session-lifecycle.ts +64 -0
  11. package/src/apps/shell.ts +89 -34
  12. package/src/apps/startup.ts +32 -31
  13. package/src/apps/ui/app.ts +105 -13
  14. package/src/apps/ui/control-model.ts +18 -1
  15. package/src/apps/ui/surface.ts +2 -0
  16. package/src/apps/update-check.ts +1 -1
  17. package/src/core/acceptance.ts +41 -9
  18. package/src/core/blind-checks.ts +72 -0
  19. package/src/core/branch-receipt.ts +3 -1
  20. package/src/core/checkpoints.ts +21 -0
  21. package/src/core/churn.ts +2 -2
  22. package/src/core/completion-review.ts +18 -11
  23. package/src/core/continuation.ts +11 -10
  24. package/src/core/control-view.ts +5 -8
  25. package/src/core/deadline.ts +2 -1
  26. package/src/core/derive.ts +0 -7
  27. package/src/core/environment.ts +2 -2
  28. package/src/core/event-validation.ts +19 -6
  29. package/src/core/events.ts +41 -32
  30. package/src/core/evidence-ladder.ts +62 -0
  31. package/src/core/evidence-links.ts +0 -10
  32. package/src/core/evidence.ts +1 -2
  33. package/src/core/export.ts +6 -11
  34. package/src/core/goal-view.ts +64 -0
  35. package/src/core/goal.ts +200 -0
  36. package/src/core/history.ts +1 -11
  37. package/src/core/json-event.ts +0 -6
  38. package/src/core/outcome.ts +0 -1
  39. package/src/core/program-status.ts +11 -2
  40. package/src/core/progress.ts +14 -8
  41. package/src/core/project.ts +6 -16
  42. package/src/core/read-bounds.ts +11 -2
  43. package/src/core/read-observation.ts +24 -0
  44. package/src/core/request-budget.ts +13 -3
  45. package/src/core/request-state.ts +178 -19
  46. package/src/core/response-recovery.ts +15 -2
  47. package/src/core/session-entry.ts +8 -3
  48. package/src/core/session-exit.ts +19 -3
  49. package/src/core/session-format.ts +169 -0
  50. package/src/core/settings.ts +7 -27
  51. package/src/core/stall.ts +30 -7
  52. package/src/core/stop-report.ts +4 -3
  53. package/src/core/transcript.ts +22 -2
  54. package/src/runtime/blind-check.ts +36 -0
  55. package/src/runtime/counter-check.ts +74 -0
  56. package/src/runtime/exec-sessions.ts +11 -0
  57. package/src/runtime/foundation.ts +46 -8
  58. package/src/runtime/loop.ts +239 -430
  59. package/src/runtime/oauth.ts +1 -3
  60. package/src/runtime/openai.ts +2 -2
  61. package/src/runtime/pi-provider.ts +3 -3
  62. package/src/runtime/provider-request.ts +2 -1
  63. package/src/runtime/provider.ts +33 -3
  64. package/src/runtime/retry.ts +9 -2
  65. package/src/runtime/runtime-target.ts +2 -2
  66. package/src/runtime/session-control.ts +4 -4
  67. package/src/runtime/tools.ts +44 -42
  68. package/src/runtime/usage.ts +28 -0
  69. package/src/runtime/version.ts +12 -0
  70. package/src/runtime/workspace-lifecycle.ts +31 -0
  71. package/src/store/blind-checks.ts +32 -0
  72. package/src/store/checkpoint-history.ts +1 -12
  73. package/src/store/file-index.ts +88 -7
  74. package/src/store/log.ts +25 -27
  75. package/src/store/recovery.ts +1 -3
  76. package/src/store/replay.ts +2 -2
  77. package/src/store/restore.ts +10 -202
  78. package/src/store/search.ts +7 -5
  79. package/src/store/session-archive.ts +0 -1
  80. package/src/store/sessions.ts +31 -33
  81. package/src/store/workspace-clone.ts +42 -0
  82. package/src/store/workspace.ts +10 -157
  83. package/src/tui/ui/duration.ts +16 -0
  84. package/src/tui/ui/layout.ts +4 -4
  85. package/src/tui/ui/screen.ts +13 -7
  86. package/src/tui/ui/status.ts +99 -17
  87. package/src/core/benchmark.ts +0 -195
  88. package/src/core/evaluation-coverage.ts +0 -74
  89. package/src/core/experiment.ts +0 -173
  90. package/src/core/harness-comparison.ts +0 -90
  91. package/src/core/harness-diagnostics.ts +0 -206
  92. package/src/core/native-eval.ts +0 -25
  93. package/src/core/policy-evaluation.ts +0 -55
  94. package/src/core/query-eval-contract.ts +0 -14
  95. package/src/core/recall-analysis.ts +0 -90
  96. package/src/core/recall-trial.ts +0 -153
  97. package/src/core/recovery-corpus.ts +0 -17
  98. package/src/core/recovery-evaluation.ts +0 -60
  99. package/src/core/tool-mode-protocol.ts +0 -25
  100. package/src/core/trial-lifecycle.ts +0 -14
  101. package/src/core/window-trial.ts +0 -65
  102. package/src/index.ts +0 -10
  103. package/src/runtime/benchmark.ts +0 -187
  104. package/src/runtime/harness-comparison.ts +0 -376
  105. package/src/runtime/harness-eval.ts +0 -462
  106. package/src/runtime/native-eval-fixtures.ts +0 -33
  107. package/src/runtime/native-eval.ts +0 -146
  108. package/src/runtime/query-eval-verifier.ts +0 -28
  109. package/src/runtime/recall-trial.ts +0 -226
  110. package/src/runtime/recovery-coverage.ts +0 -71
  111. package/src/runtime/recovery-evidence.ts +0 -76
  112. package/src/runtime/recovery-fixtures.ts +0 -116
  113. package/src/runtime/tool-mode-eval.ts +0 -146
  114. package/src/runtime/trial-control.ts +0 -45
  115. package/src/runtime/window-trial.ts +0 -141
  116. package/src/store/git-branch.ts +0 -35
  117. package/src/tui/ui/testing.ts +0 -5
@@ -1,22 +1,31 @@
1
1
  /**
2
- * Candidate paths for @-file expansion.
2
+ * Candidate paths for @-file expansion and the walk search engine.
3
3
  *
4
4
  * A glob on every keystroke is a stall in a large repo (the card's rejected
5
5
  * alternative). Scan once, cache for the session. The snapshot scanner is a
6
6
  * different job -- it hashes contents and refuses to truncate -- so this walk
7
7
  * is its own function rather than a mode flag on scanWorkspace.
8
+ *
9
+ * Inside a Git work tree the candidates are Git's own listing: tracked plus
10
+ * untracked files that are not ignored (S.6 item 4). Claude Code's Grep,
11
+ * OpenCode (`rg --files`) and Pi (`fd`) follow .gitignore the same way; a
12
+ * walk that ignores it let an ignored worktree's build output fill the whole
13
+ * budget before the product's directories were reached
14
+ * (research-file-index-truncation).
8
15
  */
9
16
 
10
- import { readdir } from "node:fs/promises";
17
+ import { lstat, readdir } from "node:fs/promises";
11
18
  import type { Dirent } from "node:fs";
12
- import { join, relative, sep } from "node:path";
19
+ import { dirname, join, relative, sep } from "node:path";
13
20
  import { DEFAULT_IGNORE } from "./workspace.ts";
21
+ import { gitBytes } from "./workspace-observation.ts";
14
22
 
15
23
  /**
16
- * Cap on indexed paths. Assumed, not measured: enough to fuzzy-search a
17
- * large repo; past this the picker is hiding a missing filter, not serving
18
- * one. Hitting it truncates and says so -- a silent cut would make @ miss
19
- * files that exist.
24
+ * Cap on paths from an unfiltered directory walk, outside Git. Assumed, not
25
+ * measured: enough to fuzzy-search a large tree; past this the walk is hiding
26
+ * a missing filter, not serving one. Hitting it truncates and says so -- a
27
+ * silent cut would make @ miss files that exist. A Git listing is already the
28
+ * filtered candidate set and is not capped unless the caller asks.
20
29
  */
21
30
  export const MAX_INDEX_ENTRIES = 20_000;
22
31
 
@@ -38,7 +47,79 @@ function isIgnored(rel: string, ignore: readonly string[]): boolean {
38
47
  return ignore.some((i) => parts.includes(i));
39
48
  }
40
49
 
50
+ /** Git judges its own ignore rules; an error or a path outside any work tree is not ignored. */
51
+ export async function gitIgnored(path: string, signal?: AbortSignal): Promise<boolean> {
52
+ try {
53
+ const info = await lstat(path);
54
+ await gitBytes(info.isDirectory() ? path : dirname(path), ["check-ignore", "-q", "--", path], undefined, signal);
55
+ return true;
56
+ } catch { return false; }
57
+ }
58
+
59
+ /** Git's listing under `dir`, relative to it, or null when `dir` is not a work tree that Git lists. */
60
+ async function gitListing(dir: string, signal?: AbortSignal): Promise<string[] | null> {
61
+ try {
62
+ if (new TextDecoder().decode(await gitBytes(dir, ["rev-parse", "--is-inside-work-tree"], undefined, signal)).trim() !== "true") return null;
63
+ // Naming an ignored directory is an explicit request: walk it instead.
64
+ if (await gitIgnored(dir, signal)) return null;
65
+ const listed = new TextDecoder().decode(await gitBytes(dir, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], undefined, signal));
66
+ // An untracked nested repository is listed once, as "name/".
67
+ return [...new Set(listed.split("\0").filter(Boolean).map(path => path.replace(/\/$/, "")))].sort();
68
+ } catch { return null; }
69
+ }
70
+
71
+ /** Assumed: enough concurrent lstat calls to hide latency without exhausting descriptors. */
72
+ const LSTAT_BATCH = 256;
73
+
74
+ async function indexGitListing(root: string, listed: readonly string[], opts: IndexOptions): Promise<FileIndex> {
75
+ const ignore = opts.ignore ?? DEFAULT_IGNORE;
76
+ const max = opts.maxEntries ?? Infinity;
77
+ const paths: string[] = [], issues: { code: string; path?: string }[] = [];
78
+ // The index can name a path under a directory since replaced by a symlink;
79
+ // following it would read outside the tree, so every ancestor is checked once.
80
+ const directories = new Map<string, Promise<boolean>>([["", Promise.resolve(true)]]);
81
+ const plainDirectory = (rel: string): Promise<boolean> => {
82
+ let known = directories.get(rel);
83
+ if (known === undefined) {
84
+ const parent = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
85
+ known = plainDirectory(parent).then(async ok => ok && await lstat(join(root, rel)).then(info => info.isDirectory() && !info.isSymbolicLink(), () => false));
86
+ directories.set(rel, known);
87
+ }
88
+ return known;
89
+ };
90
+ for (let offset = 0; offset < listed.length; offset += LSTAT_BATCH) {
91
+ if (opts.signal?.aborted || opts.deadline !== undefined && performance.now() >= opts.deadline) {
92
+ issues.push({ code: opts.signal?.aborted ? "cancelled" : "timeout" }); return { paths, truncated: true, issues };
93
+ }
94
+ const batch = listed.slice(offset, offset + LSTAT_BATCH).filter(rel => !isIgnored(rel.split("/").join(sep), ignore));
95
+ const kinds = await Promise.all(batch.map(async rel => {
96
+ const parent = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
97
+ if (!await plainDirectory(parent)) return "skip";
98
+ // A tracked path deleted from the work tree is not a candidate.
99
+ const info = await lstat(join(root, rel)).catch(() => null);
100
+ return info === null || info.isSymbolicLink() ? "skip" : info.isDirectory() ? "nested" : info.isFile() ? "file" : "skip";
101
+ }));
102
+ for (let i = 0; i < batch.length; i++) {
103
+ const rel = batch[i]!;
104
+ if (kinds[i] === "nested") {
105
+ // A submodule or nested repository: its own listing, or a walk if it has none.
106
+ const nested = await buildFileIndex(join(root, rel), { ...opts, ...(max === Infinity ? {} : { maxEntries: max - paths.length }) });
107
+ paths.push(...nested.paths.map(path => `${rel}/${path}`));
108
+ issues.push(...(nested.issues ?? []).map(issue => issue.path === undefined ? issue : { ...issue, path: `${rel}/${issue.path}` }));
109
+ if (nested.truncated) return { paths, truncated: true, issues };
110
+ continue;
111
+ }
112
+ if (kinds[i] !== "file") continue;
113
+ if (paths.length >= max) { issues.push({ code: "file_index_limit" }); return { paths, truncated: true, issues }; }
114
+ paths.push(rel);
115
+ }
116
+ }
117
+ return { paths, truncated: false, ...(issues.length ? { issues } : {}) };
118
+ }
119
+
41
120
  export async function buildFileIndex(root: string, opts: IndexOptions = {}): Promise<FileIndex> {
121
+ const listed = await gitListing(root, opts.signal);
122
+ if (listed !== null) return indexGitListing(root, listed, opts);
42
123
  const ignore = opts.ignore ?? DEFAULT_IGNORE;
43
124
  const max = opts.maxEntries ?? MAX_INDEX_ENTRIES;
44
125
  const paths: string[] = [];
package/src/store/log.ts CHANGED
@@ -26,7 +26,7 @@
26
26
  * fine and, unlike a single rollout file, nothing grows without bound.
27
27
  */
28
28
 
29
- import { mkdir, readdir, readFile, open } from "node:fs/promises";
29
+ import { access, mkdir, readdir, readFile, open } from "node:fs/promises";
30
30
  import { createHash } from "node:crypto";
31
31
  import { join } from "node:path";
32
32
  import type { LogEvent, NodeRef, IsoTime, EventId } from "../core/events.ts";
@@ -37,6 +37,7 @@ import { emitHandoff } from "../core/handoff.ts";
37
37
  import { atomicWrite, durableAppend } from "./atomic.ts";
38
38
  import { serialized } from "./serial.ts";
39
39
  import { stateAtPosition } from "../core/session-position.ts";
40
+ import { SESSION_FORMAT, SessionFormatError } from "../core/session-format.ts";
40
41
 
41
42
  /** Allocation number. Not an ordering over the tree. */
42
43
  export type NodeId = number;
@@ -502,17 +503,8 @@ export async function writeHandoff(
502
503
  return text;
503
504
  }
504
505
 
505
- async function readLegacyManifest(dir: string): Promise<Manifest> {
506
- try {
507
- return JSON.parse(await readFile(manifestPath(dir), "utf8")) as Manifest;
508
- } catch (err) {
509
- if ((err as { code?: string }).code === "ENOENT") return { version: 1, nodes: [] };
510
- throw err;
511
- }
512
- }
513
-
514
506
  type MetadataRecord =
515
- | { kind: "seed"; manifest: Manifest }
507
+ | { kind: "format"; version: number }
516
508
  | { kind: "relocate"; workspace: string }
517
509
  | { kind: "cursor"; cursor: Cursor }
518
510
  | { kind: "parent_session"; path: string }
@@ -521,31 +513,40 @@ type MetadataRecord =
521
513
 
522
514
  function metadataPath(dir: string): string { return join(dir, "metadata.jsonl"); }
523
515
 
516
+ /**
517
+ * S.3: the journal's first record declares the session format. A directory in
518
+ * any other format -- a journal opening with something else, or a manifest
519
+ * with no journal at all -- is refused before anything reads or writes it.
520
+ * Null means a directory no session has written yet.
521
+ */
524
522
  async function readMetadata(dir: string): Promise<MetadataRecord[] | null> {
525
523
  let text: string;
526
524
  try { text = await readFile(metadataPath(dir), "utf8"); }
527
525
  catch (error) {
528
- if ((error as { code?: string }).code === "ENOENT") return null;
529
- throw error;
526
+ if ((error as { code?: string }).code !== "ENOENT") throw error;
527
+ if (await access(manifestPath(dir)).then(() => true, () => false)) throw new SessionFormatError(dir, "undeclared");
528
+ return null;
530
529
  }
531
530
  if (text.length > 0 && !text.endsWith("\n")) throw new Error("INTEGRITY: incomplete metadata journal tail; no cursor is guessed");
532
- return text.split("\n").filter(Boolean).map((line) => {
531
+ const records = text.split("\n").filter(Boolean).map((line) => {
533
532
  const envelope = JSON.parse(line) as { record: MetadataRecord; sha256: string };
534
533
  const digest = createHash("sha256").update(JSON.stringify(envelope.record)).digest("hex");
535
534
  if (envelope.sha256 !== digest) throw new Error("INTEGRITY: metadata journal digest mismatch");
536
535
  return envelope.record;
537
536
  });
537
+ const first = records[0];
538
+ if (first?.kind !== "format") throw new SessionFormatError(dir, "undeclared");
539
+ if (first.version !== SESSION_FORMAT) throw new SessionFormatError(dir, first.version);
540
+ return records;
538
541
  }
539
542
 
540
543
  /** The metadata journal already owns configuration history; no second state log is needed. */
541
544
  export async function readCursorAt(dir: string, node: number, atEvent?: string): Promise<Cursor | null> {
542
545
  const records = await readMetadata(dir);
543
- const legacy = records === null ? await readLegacyManifest(dir) : undefined;
544
- const states: Cursor[] = legacy?.cursor ? [legacy.cursor] : [];
545
- let relocated = legacy?.relocated === true;
546
+ const states: Cursor[] = [];
547
+ let relocated = false;
546
548
  for (const record of records ?? []) {
547
549
  if (record.kind === "cursor") states.push({ ...record.cursor });
548
- else if (record.kind === "seed") { if (record.manifest.cursor) states.push({ ...record.manifest.cursor }); relocated ||= record.manifest.relocated === true; }
549
550
  else if (record.kind === "relocate") { relocated = true; for (const state of states) state.workspace = record.workspace; }
550
551
  }
551
552
  if (relocated) for (const state of states) state.blob_dir = join(dir, "blobs");
@@ -560,11 +561,10 @@ export async function readManifest(dir: string): Promise<Manifest> {
560
561
 
561
562
  async function readManifestUnlocked(dir: string): Promise<Manifest> {
562
563
  const records = await readMetadata(dir);
563
- if (records === null) return readLegacyManifest(dir);
564
- let manifest: Manifest = { version: 1, nodes: [] };
565
- for (const event of records) {
564
+ const manifest: Manifest = { version: 1, nodes: [] };
565
+ for (const event of records ?? []) {
566
566
  switch (event.kind) {
567
- case "seed": manifest = structuredClone(event.manifest); break;
567
+ case "format": break;
568
568
  case "cursor": manifest.cursor = event.cursor; break;
569
569
  case "relocate":
570
570
  manifest.relocated = true;
@@ -603,11 +603,9 @@ async function appendMetadata(dir: string, event: MetadataRecord): Promise<void>
603
603
  const encode = (record: MetadataRecord) => JSON.stringify({
604
604
  record, sha256: createHash("sha256").update(JSON.stringify(record)).digest("hex"),
605
605
  }) + "\n";
606
- if (await readMetadata(dir) === null) {
607
- // Preserve old manifests once, then make every subsequent move append-only.
608
- const seed: MetadataRecord = { kind: "seed", manifest: await readLegacyManifest(dir) };
609
- await durableAppend(metadataPath(dir), encode(seed));
610
- }
606
+ // The first write declares the format; readMetadata has already refused a
607
+ // directory in any other format, so nothing here appends to an old log.
608
+ if (await readMetadata(dir) === null) await durableAppend(metadataPath(dir), encode({ kind: "format", version: SESSION_FORMAT }));
611
609
  await durableAppend(metadataPath(dir), encode(event));
612
610
  }
613
611
 
@@ -33,10 +33,8 @@ export async function recoverCut(dir: string, cursor: Cursor): Promise<Cursor> {
33
33
  next_node: Math.max(cursor.next_node, cut.next_node + 1, ...nodes.map((n) => n + 1)),
34
34
  model: cut.next_model ?? cursor.model, updated_at: cut.at,
35
35
  };
36
- // Old cut records did not identify providers. Do not carry the source's
37
- // provider onto a different legacy model and manufacture an identity.
36
+ // Cuts of this session format always record the successor's provider (S.3).
38
37
  if (cut.next_provider !== undefined) next.provider = cut.next_provider;
39
- else if (next.model !== cursor.model) delete next.provider;
40
38
  if (cut.next_thinking !== undefined) next.thinking = cut.next_thinking;
41
39
  if (cut.next_context_ref !== undefined) next.context_ref = cut.next_context_ref;
42
40
  delete next.at_event;
@@ -7,7 +7,7 @@
7
7
  import { deriveHandoff } from "../core/derive.ts";
8
8
  import type { Handoff } from "../core/handoff.ts";
9
9
  import type { CutEvent, LogEvent } from "../core/events.ts";
10
- import { boundHandoff, boundWorkingHandoff, boundRecoveryHandoff } from "../core/history.ts";
10
+ import { boundHandoff, boundRecoveryHandoff } from "../core/history.ts";
11
11
  import { retainedContext } from "../core/context-window.ts";
12
12
  import { digestNode, nodePath, readAncestry, readManifest, type Manifest } from "./log.ts";
13
13
  import { readNodeHistory } from "./restore.ts";
@@ -32,7 +32,7 @@ export async function carriedHandoff(dir: string, node: number, full: Handoff,
32
32
  if (!history.ok) throw new Error(history.error);
33
33
  return boundRecoveryHandoff(full, history.nodes, policy.handoff_tokens, policy.guidance_tools);
34
34
  }
35
- return (policy.working_set_version === 1 ? boundWorkingHandoff : boundHandoff)(full, policy.handoff_tokens);
35
+ return boundHandoff(full, policy.handoff_tokens);
36
36
  }
37
37
 
38
38
  export async function replayContext(
@@ -1,46 +1,24 @@
1
1
  /**
2
- * Restoring the workspace to a node.
3
- *
4
- * This is where appendix H's argument becomes executable: reverting is not
5
- * overwriting the present with the past, it is moving a cursor to another
6
- * node. The nodes are untouched, so the cursor can move again -- which is
7
- * why a restore here cannot produce the failure that made Zed's checkpoint
8
- * restore an unanswered S1, where reverting destroyed both the files and the
9
- * thread with no way back.
2
+ * Walking a node's ancestry for history: every ancestor's events, bounded by
3
+ * the fork point below each one.
10
4
  *
11
5
  * WHY THIS DOES NOT REUSE readAncestry. That walk stops at a cut, because a
12
6
  * handoff basis means the parent's events do not replay -- correct for
13
- * context, catastrophic for files. A node whose ancestry crosses a cut would
14
- * see only the snapshots after it, and applyRestore deletes whatever is not
15
- * in its target, so restoring past a boundary would silently remove every
16
- * file written before it. File history therefore always walks to the root,
17
- * whatever each node declared about context.
7
+ * context, wrong for files and turns. File history (checkpoint-history.ts)
8
+ * therefore walks to the root whatever each node declared about context; the
9
+ * "context" scope stops at an explicit new root instead.
18
10
  */
19
11
 
20
12
  import { readNode, readManifest, digestNode, MAX_ANCESTRY_DEPTH, type Manifest } from "./log.ts";
21
13
  import type { LogEvent } from "../core/events.ts";
22
- import {
23
- planRestore, applyRestore, scanWorkspace, DEFAULT_IGNORE,
24
- type ScanDelta, type FileSet, type RestoreResult,
25
- } from "./workspace.ts";
26
14
 
27
15
  /**
28
- * Where to move the workspace to.
29
- *
30
- * A bare node means the end of that node. `atEvent` names a snapshot inside
31
- * it, which is what makes an ordinary undo possible: a node accumulates many
32
- * turns, so without it the earliest reachable state is the end of the node
33
- * and nothing in the node being worked in can be taken back.
16
+ * Where to move the workspace to. A bare node means the end of that node;
17
+ * `atEvent` names a point inside it, which is what makes an ordinary undo
18
+ * possible within the node being worked in.
34
19
  */
35
20
  export type RestoreTarget = number | { node: number; atEvent?: string };
36
21
 
37
- /** One snapshot, kept separate from its neighbours so a turn can be addressed. */
38
- export type FileDelta = ScanDelta & { node: number; event: string };
39
-
40
- export type FileHistoryResult =
41
- | { ok: true; deltas: FileDelta[] }
42
- | { ok: false; error: string };
43
-
44
22
  /** One ancestor's contribution: its events, bounded by any fork point below it. */
45
23
  export interface NodeEvents {
46
24
  node: number;
@@ -51,28 +29,6 @@ export type NodeHistoryResult =
51
29
  | { ok: true; nodes: NodeEvents[] }
52
30
  | { ok: false; error: string };
53
31
 
54
- export type PlanResult =
55
- | { ok: true; files: FileSet; willWrite: string[]; willDelete: string[] }
56
- | { ok: false; error: string };
57
-
58
- export type RestoreToNodeResult =
59
- | ({ ok: true } & RestoreResult)
60
- | { ok: false; error: string };
61
-
62
- /**
63
- * Every snapshot delta from the root to `node`, in replay order.
64
- *
65
- * Two fields on the way up, treated oppositely, because they answer different
66
- * questions:
67
- *
68
- * `basis` says what the MODEL should see, and is ignored here. A cut
69
- * truncates the context walk; the files it wrote are still on disk.
70
- *
71
- * `at_event` says where this node's history BEGINS in its parent, which is
72
- * a question about history, so it is obeyed. Ignoring it makes a rewind
73
- * restore the very file it was asked to undo -- the parent's later turns
74
- * would flow into a node that forked before them.
75
- */
76
32
  /**
77
33
  * Every ancestor's events, root-to-leaf, bounded by the fork points below.
78
34
  *
@@ -125,9 +81,8 @@ export async function readNodeHistory(
125
81
  };
126
82
  }
127
83
 
128
- // The fork point is an event id in the node's whole stream, not a
129
- // snapshot index: a turn that changed nothing writes no snapshot, so
130
- // counting snapshots would land on the wrong turn.
84
+ // The fork point is an event id in the node's whole stream, never a
85
+ // count of checkpoints: a turn that changed nothing writes none.
131
86
  let contributed = events;
132
87
  if (cutoff !== undefined) {
133
88
  const at = events.findIndex((e) => e.id === cutoff);
@@ -153,150 +108,3 @@ export async function readNodeHistory(
153
108
  chain.reverse();
154
109
  return { ok: true, nodes: chain };
155
110
  }
156
-
157
- /**
158
- * Every snapshot delta from the root to `node`, in replay order.
159
- *
160
- * Two fields on the way up, treated oppositely, because they answer different
161
- * questions:
162
- *
163
- * `basis` says what the MODEL should see, and is ignored here. A cut
164
- * truncates the context walk; the files it wrote are still on disk.
165
- *
166
- * `at_event` says where this node's history BEGINS in its parent, which is
167
- * a question about history, so it is obeyed. Ignoring it makes a rewind
168
- * restore the very file it was asked to undo -- the parent's later turns
169
- * would flow into a node that forked before them.
170
- *
171
- * Kept one entry per snapshot rather than merged per node: merging is lossy
172
- * in exactly the direction that matters, since the turns inside a node are
173
- * the ones a person is most likely to want back.
174
- */
175
- export async function readFileHistory(
176
- dir: string,
177
- node: number,
178
- manifest?: Manifest,
179
- ): Promise<FileHistoryResult> {
180
- const history = await readNodeHistory(dir, node, manifest);
181
- if (!history.ok) return { ok: false, error: history.error };
182
-
183
- const deltas: FileDelta[] = [];
184
- for (const n of history.nodes) {
185
- for (const e of n.events) {
186
- if (e.t !== "snapshot") continue;
187
- deltas.push({
188
- node: n.node, event: e.id,
189
- added: { ...e.added }, changed: { ...e.changed }, removed: [...e.removed],
190
- });
191
- }
192
- }
193
- return { ok: true, deltas };
194
- }
195
-
196
- /**
197
- * What a restore would do, without doing it.
198
- *
199
- * Separate from the restore because applyRestore deletes whatever is not in
200
- * the target -- necessary, or the workspace would end up a mixture of two
201
- * states, but destructive enough that a caller has to be able to look first.
202
- */
203
- export async function planRestoreToNode(
204
- dir: string,
205
- target: RestoreTarget,
206
- manifest?: Manifest,
207
- root?: string,
208
- ignore: readonly string[] = DEFAULT_IGNORE,
209
- ): Promise<PlanResult> {
210
- const { node, atEvent } = typeof target === "number" ? { node: target, atEvent: undefined } : target;
211
- const history = await readNodeHistory(dir, node, manifest);
212
- if (!history.ok) return { ok: false, error: history.error };
213
-
214
- const deltas: FileDelta[] = [];
215
- for (const n of history.nodes) {
216
- // The cutoff is an id in the EVENT stream, not in the snapshot stream: a
217
- // fork point is where a conversation was whole, which is the end of an
218
- // exchange and usually not a snapshot at all. Cutting the events first
219
- // and collecting snapshots after is what keeps the files and the
220
- // conversation landing on the same point.
221
- let events = n.events;
222
- if (atEvent !== undefined && n.node === node) {
223
- const at = events.findIndex((e) => e.id === atEvent);
224
- if (at === -1) {
225
- return { ok: false, error: `NO_SUCH_EVENT: node ${node} has no event ${atEvent}` };
226
- }
227
- events = events.slice(0, at + 1);
228
- }
229
- for (const e of events) {
230
- if (e.t !== "snapshot") continue;
231
- deltas.push({
232
- node: n.node, event: e.id,
233
- added: { ...e.added }, changed: { ...e.changed }, removed: [...e.removed],
234
- });
235
- }
236
- }
237
-
238
- const files = planRestore(deltas, node);
239
- const willWrite = Object.keys(files).sort();
240
- if (!root) return { ok: true, files, willWrite, willDelete: [] };
241
-
242
- const current = await scanWorkspace(root, ignore);
243
- if (!current.ok) return { ok: false, error: current.error };
244
- const willDelete = Object.keys(current.files).filter((p) => !(p in files)).sort();
245
- return { ok: true, files, willWrite, willDelete };
246
- }
247
-
248
- /**
249
- * Moves the workspace to the state it had at `node`.
250
- *
251
- * Writes nothing to the log. The log is the truth and the workspace is a
252
- * projection of it, so there is nothing to record: the nodes did not change,
253
- * only which one is being projected. That is also what keeps this safe
254
- * despite being irreversible in itself -- the previous cursor position is
255
- * still a node, so the move can be made again in the other direction.
256
- */
257
- export async function restoreToNode(
258
- dir: string,
259
- blobDir: string,
260
- root: string,
261
- target: RestoreTarget,
262
- manifest?: Manifest,
263
- ignore: readonly string[] = DEFAULT_IGNORE,
264
- ): Promise<RestoreToNodeResult> {
265
- const plan = await planRestoreToNode(dir, target, manifest ?? (await readManifest(dir)), root, ignore);
266
- if (!plan.ok) return { ok: false, error: plan.error };
267
- return { ok: true, ...(await applyRestorePlan(root, blobDir, plan.files, ignore, describeTarget(target))) };
268
- }
269
-
270
- /**
271
- * Applies a plan that has already been computed.
272
- *
273
- * Split out so a caller holding a plan does not have to compute it twice.
274
- * That is not only wasted work: a caller that re-planned inside the apply
275
- * could never see the apply fail on its own, which turns its own "did the
276
- * files move?" guard into unreachable code.
277
- */
278
- export async function applyRestorePlan(
279
- root: string,
280
- blobDir: string,
281
- files: FileSet,
282
- ignore: readonly string[],
283
- label: string,
284
- ): Promise<RestoreResult> {
285
- const result = await applyRestore(root, blobDir, files, ignore);
286
- if (result.unrecoverable.length > 0) return result;
287
- return {
288
- ...result,
289
- note:
290
- `Workspace moved to ${label}. ` +
291
- result.note +
292
- " Other logged snapshots remain available. File versions never " +
293
- "snapshotted cannot be reconstructed from the log.",
294
- };
295
- }
296
-
297
- function describeTarget(target: RestoreTarget): string {
298
- if (typeof target === "number") return `node ${target}`;
299
- return target.atEvent === undefined
300
- ? `node ${target.node}`
301
- : `node ${target.node} as of snapshot ${target.atEvent}`;
302
- }
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Workspace search: grep, find, ls.
3
3
  *
4
- * The walk reuses buildFileIndex so ignore rules, the 20000 cap, and the
5
- * no-symlink rule stay one implementation. Ripgrep / fd are used only when
4
+ * The walk reuses buildFileIndex so ignore rules (.gitignore inside a Git
5
+ * work tree, S.6 item 4), the walk cap, and the no-symlink rule stay one
6
+ * implementation. Ripgrep / fd follow the same .gitignore rule, and search an
7
+ * ignored target named explicitly, like the walk. They are used only when
6
8
  * they are already on PATH -- never downloaded -- because fetching a binary
7
9
  * at runtime is a supply-chain path the Isolation section of README.md
8
10
  * told the user they would not be taking.
@@ -16,7 +18,7 @@ import { lstat, readdir, readFile } from "node:fs/promises";
16
18
  import { isAbsolute, join, relative, sep } from "node:path";
17
19
  import { Worker } from "node:worker_threads";
18
20
  import { assertNever } from "../core/exhaustive.ts";
19
- import { buildFileIndex } from "./file-index.ts";
21
+ import { buildFileIndex, gitIgnored } from "./file-index.ts";
20
22
  import { DEFAULT_IGNORE } from "./workspace.ts";
21
23
  import { blobAddress } from "./blobs.ts";
22
24
  import { previewSearchObservation, type SearchObservation } from "../core/search-observation.ts";
@@ -464,7 +466,7 @@ async function grepRipgrep(opts: GrepOpts, abs: string, limit: number): Promise<
464
466
  "--color=never",
465
467
  "--hidden",
466
468
  "--no-follow",
467
- "--no-ignore",
469
+ ...(await gitIgnored(abs, opts.signal) ? ["--no-ignore"] : []),
468
470
  "--max-filesize",
469
471
  String(MAX_FILE_BYTES),
470
472
  ...rgIgnoreArgs(),
@@ -606,7 +608,7 @@ async function findFd(opts: FindOpts, abs: string, limit: number): Promise<Searc
606
608
  "--glob",
607
609
  "--color=never",
608
610
  "--hidden",
609
- "--no-ignore",
611
+ ...(await gitIgnored(abs, opts.signal) ? ["--no-ignore"] : []),
610
612
  "--absolute-path",
611
613
  "--print0",
612
614
  ...fdExcludeArgs(),
@@ -39,7 +39,6 @@ async function referencedBlobs(dir: string, fallback?: string): Promise<Map<stri
39
39
  const roots = [...new Set([historical?.blob_dir, cursor?.blob_dir, fallback, join(dir, "blobs")].filter((p): p is string => !!p))];
40
40
  const add = (sha: string) => { refs.set(sha, [...new Set([...(refs.get(sha) ?? []), ...roots])]); };
41
41
  for (const event of read.events) {
42
- if (event.t === "snapshot") for (const sha of [...Object.values(event.added), ...Object.values(event.changed)]) add(sha);
43
42
  if (event.t === "checkpoint") for (const change of Object.values(event.changes)) {
44
43
  if (change.before) add(change.before.sha256);
45
44
  if (change.after) add(change.after.sha256);