@visual-z/xeno 0.7.2 → 0.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.
Files changed (108) hide show
  1. package/CHANGELOG.md +43 -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 +38 -6
  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.ts +200 -0
  35. package/src/core/history.ts +1 -11
  36. package/src/core/json-event.ts +0 -6
  37. package/src/core/outcome.ts +0 -1
  38. package/src/core/program-status.ts +11 -2
  39. package/src/core/progress.ts +14 -8
  40. package/src/core/project.ts +6 -16
  41. package/src/core/read-bounds.ts +11 -2
  42. package/src/core/read-observation.ts +24 -0
  43. package/src/core/request-budget.ts +13 -3
  44. package/src/core/request-state.ts +178 -19
  45. package/src/core/response-recovery.ts +2 -1
  46. package/src/core/session-entry.ts +8 -3
  47. package/src/core/session-exit.ts +19 -3
  48. package/src/core/session-format.ts +169 -0
  49. package/src/core/settings.ts +7 -27
  50. package/src/core/stall.ts +30 -7
  51. package/src/core/stop-report.ts +4 -3
  52. package/src/core/transcript.ts +22 -2
  53. package/src/runtime/blind-check.ts +36 -0
  54. package/src/runtime/counter-check.ts +74 -0
  55. package/src/runtime/exec-sessions.ts +11 -0
  56. package/src/runtime/foundation.ts +46 -8
  57. package/src/runtime/loop.ts +239 -430
  58. package/src/runtime/oauth.ts +1 -3
  59. package/src/runtime/provider-request.ts +2 -1
  60. package/src/runtime/runtime-target.ts +2 -2
  61. package/src/runtime/session-control.ts +4 -4
  62. package/src/runtime/tools.ts +44 -42
  63. package/src/runtime/version.ts +12 -0
  64. package/src/runtime/workspace-lifecycle.ts +31 -0
  65. package/src/store/blind-checks.ts +32 -0
  66. package/src/store/checkpoint-history.ts +1 -12
  67. package/src/store/file-index.ts +88 -7
  68. package/src/store/log.ts +25 -27
  69. package/src/store/recovery.ts +1 -3
  70. package/src/store/replay.ts +2 -2
  71. package/src/store/restore.ts +10 -202
  72. package/src/store/search.ts +7 -5
  73. package/src/store/session-archive.ts +0 -1
  74. package/src/store/sessions.ts +31 -33
  75. package/src/store/workspace-clone.ts +42 -0
  76. package/src/store/workspace.ts +10 -157
  77. package/src/tui/ui/status.ts +6 -2
  78. package/src/core/benchmark.ts +0 -195
  79. package/src/core/evaluation-coverage.ts +0 -74
  80. package/src/core/experiment.ts +0 -173
  81. package/src/core/harness-comparison.ts +0 -90
  82. package/src/core/harness-diagnostics.ts +0 -206
  83. package/src/core/native-eval.ts +0 -25
  84. package/src/core/policy-evaluation.ts +0 -55
  85. package/src/core/query-eval-contract.ts +0 -14
  86. package/src/core/recall-analysis.ts +0 -90
  87. package/src/core/recall-trial.ts +0 -153
  88. package/src/core/recovery-corpus.ts +0 -17
  89. package/src/core/recovery-evaluation.ts +0 -60
  90. package/src/core/tool-mode-protocol.ts +0 -25
  91. package/src/core/trial-lifecycle.ts +0 -14
  92. package/src/core/window-trial.ts +0 -65
  93. package/src/index.ts +0 -10
  94. package/src/runtime/benchmark.ts +0 -187
  95. package/src/runtime/harness-comparison.ts +0 -376
  96. package/src/runtime/harness-eval.ts +0 -462
  97. package/src/runtime/native-eval-fixtures.ts +0 -33
  98. package/src/runtime/native-eval.ts +0 -146
  99. package/src/runtime/query-eval-verifier.ts +0 -28
  100. package/src/runtime/recall-trial.ts +0 -226
  101. package/src/runtime/recovery-coverage.ts +0 -71
  102. package/src/runtime/recovery-evidence.ts +0 -76
  103. package/src/runtime/recovery-fixtures.ts +0 -116
  104. package/src/runtime/tool-mode-eval.ts +0 -146
  105. package/src/runtime/trial-control.ts +0 -45
  106. package/src/runtime/window-trial.ts +0 -141
  107. package/src/store/git-branch.ts +0 -35
  108. package/src/tui/ui/testing.ts +0 -5
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);
@@ -22,7 +22,7 @@
22
22
  * and silently truncate a log.
23
23
  */
24
24
 
25
- import { cp, lstat, mkdir, mkdtemp, open, readdir, rename, rm, stat } from "node:fs/promises";
25
+ import { cp, lstat, mkdir, mkdtemp, open, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
26
26
  import type { Dirent } from "node:fs";
27
27
  import { tmpdir } from "node:os";
28
28
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
@@ -112,10 +112,6 @@ export function sessionsDir(workspace: string, override?: string): string {
112
112
  return join(workspace, ".xeno", "sessions");
113
113
  }
114
114
 
115
- export function legacyLogDir(workspace: string): string {
116
- return join(workspace, ".xeno", "log");
117
- }
118
-
119
115
  /**
120
116
  * `<base36 ms>-<4 digit base36 random>`.
121
117
  *
@@ -141,6 +137,8 @@ export async function createSession(
141
137
  ): Promise<{ id: string; path: string }> {
142
138
  const dir = sessionsDir(workspace, sessionDir);
143
139
  await mkdir(dir, { recursive: true });
140
+ // The first session makes the state directory exist; keep it out of git from that moment.
141
+ await ignoreStateDir(dirname(dir));
144
142
  for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt++) {
145
143
  const id = formatSessionId(clock.nowMs(), clock.random());
146
144
  const path = join(dir, id);
@@ -193,21 +191,6 @@ export async function listSessions(workspace: string, sessionDir?: string, limit
193
191
  return { sessions, truncated };
194
192
  }
195
193
 
196
- /**
197
- * A leftover `.xeno/log` is not migrated. Moving someone else's data cannot
198
- * be a startup side effect; the person keeps using it by pointing `--log`
199
- * at it. Once `.xeno/sessions/` exists they are already in the new layout
200
- * and the leftover is just leftover, so the notice stays quiet.
201
- */
202
- export async function legacyLogNotice(workspace: string): Promise<string | null> {
203
- if (!(await isDirectory(legacyLogDir(workspace)))) return null;
204
- if (await isDirectory(sessionsDir(workspace))) return null;
205
- return (
206
- "This workspace still has a log at .xeno/log from before named sessions existed. " +
207
- "It has not been moved. Continue it with --log .xeno/log"
208
- );
209
- }
210
-
211
194
  export type SessionPrefixMatch =
212
195
  | { ok: true; session: Extract<ListedSession, { ok: true }> }
213
196
  | { ok: false; reason: string; candidates: string[] };
@@ -352,46 +335,44 @@ export interface ResolveLogOpts extends SessionEntryFlags {
352
335
  export async function resolveLogDir(
353
336
  workspace: string,
354
337
  opts: ResolveLogOpts = {},
355
- ): Promise<{ logDir: string; created: boolean; legacyNotice: string | null }> {
338
+ ): Promise<{ logDir: string; created: boolean }> {
356
339
  const entry = planSessionEntry(opts);
357
- // Computed first: createSession will mkdir `.xeno/sessions/`, after which
358
- // the leftover-log condition is no longer true.
359
- const legacyNotice = await legacyLogNotice(workspace);
360
340
  switch (entry.kind) {
361
341
  case "named": {
362
342
  const root = sessionsDir(workspace, opts.sessionDir), logDir = join(root, entry.id);
363
343
  await mkdir(root, { recursive: true });
364
- try { await mkdir(logDir); return { logDir, created: true, legacyNotice }; }
344
+ await ignoreStateDir(dirname(root));
345
+ try { await mkdir(logDir); return { logDir, created: true }; }
365
346
  catch (error) {
366
347
  if ((error as { code?: string }).code !== "EEXIST" || !(await lstat(logDir)).isDirectory()) throw error;
367
- return { logDir, created: false, legacyNotice };
348
+ return { logDir, created: false };
368
349
  }
369
350
  }
370
351
  case "ephemeral": {
371
352
  const logDir = await mkdtemp(join(tmpdir(), "xeno-nosession-"));
372
- return { logDir, created: true, legacyNotice };
353
+ return { logDir, created: true };
373
354
  }
374
355
  case "open": {
375
- if (entry.explicit) return { logDir: entry.target, created: false, legacyNotice };
356
+ if (entry.explicit) return { logDir: entry.target, created: false };
376
357
  const archive = resolve(workspace, entry.target);
377
358
  if (await stat(archive).then(info => info.isFile(), () => false)) {
378
- return { logDir: await importSessionArchive(archive, sessionsDir(workspace, opts.sessionDir), workspace), created: true, legacyNotice };
359
+ return { logDir: await importSessionArchive(archive, sessionsDir(workspace, opts.sessionDir), workspace), created: true };
379
360
  }
380
361
  const found = await findSessionByPrefix(workspace, entry.target, opts.sessionDir);
381
362
  if (!found.ok) throw new Error(found.reason);
382
- return { logDir: found.session.path, created: false, legacyNotice };
363
+ return { logDir: found.session.path, created: false };
383
364
  }
384
365
  case "fork": {
385
366
  const found = await findSessionByPrefix(workspace, entry.target, opts.sessionDir);
386
367
  if (!found.ok) throw new Error(found.reason);
387
368
  const created = await createSession(workspace, systemIdClock, opts.sessionDir);
388
369
  await copyAncestryTo(found.session.path, created.path, new Date().toISOString());
389
- return { logDir: created.path, created: true, legacyNotice };
370
+ return { logDir: created.path, created: true };
390
371
  }
391
372
  case "continue": {
392
373
  const { sessions } = await listSessions(workspace, opts.sessionDir, Infinity);
393
374
  const newest = sessions.find((s) => s.ok);
394
- if (newest !== undefined) return { logDir: newest.path, created: false, legacyNotice };
375
+ if (newest !== undefined) return { logDir: newest.path, created: false };
395
376
  break;
396
377
  }
397
378
  case "select":
@@ -400,7 +381,7 @@ export async function resolveLogDir(
400
381
  default: return assertNever(entry, "resolveLogDir");
401
382
  }
402
383
  const created = await createSession(workspace, systemIdClock, opts.sessionDir);
403
- return { logDir: created.path, created: true, legacyNotice };
384
+ return { logDir: created.path, created: true };
404
385
  }
405
386
 
406
387
  async function existingDir(workspace: string, prefix: string): Promise<string | null> {
@@ -482,3 +463,20 @@ async function isDirectory(path: string): Promise<boolean> {
482
463
  throw err;
483
464
  }
484
465
  }
466
+
467
+ /**
468
+ * Keeps a state directory out of `git status` the way pytest, ruff and mypy
469
+ * keep their caches out: a `.gitignore` holding `*` inside the directory
470
+ * itself. Checks people and models write ("only plan.md changed") read git,
471
+ * and a visible session directory made such a check fail forever
472
+ * (fix-state-dir-self-ignore). The user's own ignore files are never edited,
473
+ * and .git/info/exclude is avoided: worktrees do not read it, and a sandboxed
474
+ * agent could use it to hide files. An existing file is left alone; failure
475
+ * to write one never blocks a session.
476
+ */
477
+ export async function ignoreStateDir(stateRoot: string): Promise<void> {
478
+ try {
479
+ await mkdir(stateRoot, { recursive: true });
480
+ await writeFile(join(stateRoot, ".gitignore"), "# Created by xeno: session state is not project source.\n*\n", { flag: "wx" });
481
+ } catch { /* present already, or not writable: either way nothing to do */ }
482
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * A disposable copy of a workspace, for running a check somewhere it cannot
3
+ * touch the real files (feat-counter-check).
4
+ *
5
+ * Copy-on-write where the filesystem offers it -- APFS clonefile through
6
+ * `cp -c`, reflinks through `cp --reflink=auto` -- so a workspace with large
7
+ * dependency trees costs metadata, not bytes. Where it does not, cp falls back
8
+ * to a real copy, bounded by the deadline below.
9
+ */
10
+ import { mkdtemp, rm } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ /**
15
+ * Assumed, not measured: a clone is metadata-only on APFS and reflink
16
+ * filesystems; a full copy that takes longer than this is better reported as
17
+ * unavailable than waited on.
18
+ */
19
+ export const CLONE_TIMEOUT_MS = 60_000;
20
+
21
+ export type WorkspaceClone = { ok: true; path: string; dispose: () => Promise<void> } | { ok: false; reason: string };
22
+
23
+ export async function cloneWorkspace(root: string, signal?: AbortSignal): Promise<WorkspaceClone> {
24
+ const parent = await mkdtemp(join(tmpdir(), "xeno-counter-check-")), path = join(parent, "workspace");
25
+ const dispose = () => rm(parent, { recursive: true, force: true });
26
+ const attempts = process.platform === "darwin" ? [["cp", "-cR", root, path], ["cp", "-R", root, path]] : [["cp", "-a", "--reflink=auto", root, path]];
27
+ let reason = "the workspace could not be copied";
28
+ for (const argv of attempts) {
29
+ if (signal?.aborted) { await dispose(); return { ok: false, reason: "cancelled" }; }
30
+ await rm(path, { recursive: true, force: true });
31
+ const child = Bun.spawn(argv, { stdout: "ignore", stderr: "pipe" });
32
+ const timer = setTimeout(() => child.kill(), CLONE_TIMEOUT_MS);
33
+ const abort = () => child.kill();
34
+ signal?.addEventListener("abort", abort, { once: true });
35
+ const [exit, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
36
+ clearTimeout(timer); signal?.removeEventListener("abort", abort);
37
+ if (exit === 0) return { ok: true, path, dispose };
38
+ reason = child.signalCode ? `copying the workspace exceeded ${CLONE_TIMEOUT_MS / 1000}s` : `the workspace could not be copied: ${stderr.trim().split("\n").at(-1) ?? `exit ${exit}`}`;
39
+ }
40
+ await dispose();
41
+ return { ok: false, reason };
42
+ }