@visual-z/xeno 0.1.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 (180) hide show
  1. package/README.md +498 -0
  2. package/docs/install.md +130 -0
  3. package/package.json +43 -0
  4. package/src/apps/approve.ts +49 -0
  5. package/src/apps/cli.ts +37 -0
  6. package/src/apps/commands.ts +226 -0
  7. package/src/apps/completions.ts +28 -0
  8. package/src/apps/input.ts +297 -0
  9. package/src/apps/render.ts +207 -0
  10. package/src/apps/shell.ts +2353 -0
  11. package/src/apps/startup.ts +1302 -0
  12. package/src/core/benchmark.ts +195 -0
  13. package/src/core/boundary.ts +174 -0
  14. package/src/core/churn.ts +59 -0
  15. package/src/core/completion-review.ts +66 -0
  16. package/src/core/context-policy.ts +60 -0
  17. package/src/core/context-window.ts +54 -0
  18. package/src/core/continuation.ts +114 -0
  19. package/src/core/cycle.ts +17 -0
  20. package/src/core/data-query.ts +201 -0
  21. package/src/core/deadline.ts +39 -0
  22. package/src/core/derive.ts +489 -0
  23. package/src/core/edit-plan.ts +200 -0
  24. package/src/core/environment.ts +29 -0
  25. package/src/core/event-validation.ts +99 -0
  26. package/src/core/events.ts +486 -0
  27. package/src/core/evidence-links.ts +71 -0
  28. package/src/core/evidence.ts +89 -0
  29. package/src/core/execution.ts +12 -0
  30. package/src/core/exhaustive.ts +18 -0
  31. package/src/core/experiment.ts +40 -0
  32. package/src/core/export.ts +169 -0
  33. package/src/core/file-references.ts +35 -0
  34. package/src/core/frontmatter.ts +213 -0
  35. package/src/core/handoff.ts +397 -0
  36. package/src/core/harness-comparison.ts +90 -0
  37. package/src/core/history-query.ts +103 -0
  38. package/src/core/history.ts +100 -0
  39. package/src/core/json-event.ts +135 -0
  40. package/src/core/json-pointer.ts +46 -0
  41. package/src/core/keymap.ts +241 -0
  42. package/src/core/observation-view.ts +87 -0
  43. package/src/core/outcome.ts +45 -0
  44. package/src/core/overflow.ts +59 -0
  45. package/src/core/permissions.ts +182 -0
  46. package/src/core/policy-evaluation.ts +55 -0
  47. package/src/core/progress.ts +101 -0
  48. package/src/core/project.ts +372 -0
  49. package/src/core/prompts.ts +83 -0
  50. package/src/core/queue.ts +60 -0
  51. package/src/core/read-bounds.ts +15 -0
  52. package/src/core/recall-analysis.ts +90 -0
  53. package/src/core/recall-trial.ts +153 -0
  54. package/src/core/response-recovery.ts +46 -0
  55. package/src/core/scheduling.ts +8 -0
  56. package/src/core/settings.ts +174 -0
  57. package/src/core/skills.ts +121 -0
  58. package/src/core/stall.ts +48 -0
  59. package/src/core/text-normalize.ts +81 -0
  60. package/src/core/theme.ts +179 -0
  61. package/src/core/tokens.ts +149 -0
  62. package/src/core/tool-contract.ts +102 -0
  63. package/src/core/tool-input.ts +38 -0
  64. package/src/core/transcript.ts +60 -0
  65. package/src/core/window-trial.ts +65 -0
  66. package/src/core/work-state.ts +103 -0
  67. package/src/core/yaml.ts +282 -0
  68. package/src/index.ts +10 -0
  69. package/src/runtime/auth.ts +255 -0
  70. package/src/runtime/benchmark.ts +187 -0
  71. package/src/runtime/catalog.ts +365 -0
  72. package/src/runtime/evidence-notes.ts +33 -0
  73. package/src/runtime/exec-sessions.ts +214 -0
  74. package/src/runtime/execution-control.ts +39 -0
  75. package/src/runtime/harness-comparison.ts +356 -0
  76. package/src/runtime/history-tools.ts +108 -0
  77. package/src/runtime/loop.ts +1983 -0
  78. package/src/runtime/model.ts +614 -0
  79. package/src/runtime/models-fetch.ts +155 -0
  80. package/src/runtime/oauth-device.ts +86 -0
  81. package/src/runtime/oauth.ts +759 -0
  82. package/src/runtime/openai-responses.ts +286 -0
  83. package/src/runtime/openai.ts +675 -0
  84. package/src/runtime/provider.ts +498 -0
  85. package/src/runtime/recall-trial.ts +226 -0
  86. package/src/runtime/resume.ts +66 -0
  87. package/src/runtime/retry.ts +297 -0
  88. package/src/runtime/tools.ts +1193 -0
  89. package/src/runtime/usage.ts +225 -0
  90. package/src/runtime/window-trial.ts +140 -0
  91. package/src/runtime/working-notes.ts +30 -0
  92. package/src/store/atomic.ts +31 -0
  93. package/src/store/auth-store.ts +50 -0
  94. package/src/store/blobs.ts +142 -0
  95. package/src/store/command.ts +75 -0
  96. package/src/store/config.ts +69 -0
  97. package/src/store/context-files.ts +356 -0
  98. package/src/store/file-index.ts +81 -0
  99. package/src/store/git-branch.ts +35 -0
  100. package/src/store/history-search.ts +19 -0
  101. package/src/store/log.ts +642 -0
  102. package/src/store/models.ts +41 -0
  103. package/src/store/observations.ts +181 -0
  104. package/src/store/query-source.ts +44 -0
  105. package/src/store/recovery.ts +39 -0
  106. package/src/store/replay.ts +63 -0
  107. package/src/store/resources.ts +361 -0
  108. package/src/store/restore.ts +302 -0
  109. package/src/store/search.ts +644 -0
  110. package/src/store/serial.ts +13 -0
  111. package/src/store/sessions.ts +463 -0
  112. package/src/store/trust.ts +69 -0
  113. package/src/store/workspace.ts +256 -0
  114. package/src/store/worktree.ts +233 -0
  115. package/src/tui/ansi.ts +67 -0
  116. package/src/tui/autocomplete.ts +29 -0
  117. package/src/tui/complete.ts +87 -0
  118. package/src/tui/diff.ts +160 -0
  119. package/src/tui/editor.ts +893 -0
  120. package/src/tui/exhaustive.ts +14 -0
  121. package/src/tui/fuzzy.ts +75 -0
  122. package/src/tui/host.ts +223 -0
  123. package/src/tui/input.ts +69 -0
  124. package/src/tui/keys.ts +378 -0
  125. package/src/tui/markdown.ts +17 -0
  126. package/src/tui/render.ts +153 -0
  127. package/src/tui/select.ts +240 -0
  128. package/src/tui/settings.ts +40 -0
  129. package/src/tui/statusline.ts +162 -0
  130. package/src/tui/style.ts +205 -0
  131. package/src/tui/terminal.ts +30 -0
  132. package/src/tui/text.ts +52 -0
  133. package/src/tui/toolview.ts +386 -0
  134. package/src/tui/transcript.ts +71 -0
  135. package/src/tui/tree.ts +405 -0
  136. package/src/tui/width.ts +567 -0
  137. package/src/vendor/pi-tui/LICENSE.upstream +21 -0
  138. package/src/vendor/pi-tui/VENDOR.md +23 -0
  139. package/src/vendor/pi-tui/alt-screen-search.ts +327 -0
  140. package/src/vendor/pi-tui/autocomplete.ts +835 -0
  141. package/src/vendor/pi-tui/components/alt-screen-flash.ts +51 -0
  142. package/src/vendor/pi-tui/components/box.ts +167 -0
  143. package/src/vendor/pi-tui/components/cancellable-loader.ts +40 -0
  144. package/src/vendor/pi-tui/components/editor.ts +2470 -0
  145. package/src/vendor/pi-tui/components/h-stack.ts +44 -0
  146. package/src/vendor/pi-tui/components/image.ts +127 -0
  147. package/src/vendor/pi-tui/components/input.ts +494 -0
  148. package/src/vendor/pi-tui/components/loader.ts +101 -0
  149. package/src/vendor/pi-tui/components/markdown.ts +1015 -0
  150. package/src/vendor/pi-tui/components/mouse-region.ts +33 -0
  151. package/src/vendor/pi-tui/components/scroll-view.ts +224 -0
  152. package/src/vendor/pi-tui/components/select-list.ts +273 -0
  153. package/src/vendor/pi-tui/components/settings-list.ts +328 -0
  154. package/src/vendor/pi-tui/components/spacer.ts +28 -0
  155. package/src/vendor/pi-tui/components/stack.ts +154 -0
  156. package/src/vendor/pi-tui/components/text.ts +107 -0
  157. package/src/vendor/pi-tui/components/truncated-text.ts +65 -0
  158. package/src/vendor/pi-tui/components/v-stack.ts +33 -0
  159. package/src/vendor/pi-tui/editor-component.ts +74 -0
  160. package/src/vendor/pi-tui/fuzzy.ts +138 -0
  161. package/src/vendor/pi-tui/index.ts +156 -0
  162. package/src/vendor/pi-tui/keybindings.ts +320 -0
  163. package/src/vendor/pi-tui/keys.ts +1401 -0
  164. package/src/vendor/pi-tui/kill-ring.ts +46 -0
  165. package/src/vendor/pi-tui/latex.ts +1506 -0
  166. package/src/vendor/pi-tui/layout-node.ts +51 -0
  167. package/src/vendor/pi-tui/layout.ts +449 -0
  168. package/src/vendor/pi-tui/native-modifiers.ts +13 -0
  169. package/src/vendor/pi-tui/native-module-path.ts +31 -0
  170. package/src/vendor/pi-tui/native-platform.ts +63 -0
  171. package/src/vendor/pi-tui/stdin-buffer.ts +444 -0
  172. package/src/vendor/pi-tui/terminal-colors.ts +73 -0
  173. package/src/vendor/pi-tui/terminal-image.ts +696 -0
  174. package/src/vendor/pi-tui/terminal.ts +547 -0
  175. package/src/vendor/pi-tui/tui-alt-screen.ts +1742 -0
  176. package/src/vendor/pi-tui/tui-main-screen.ts +655 -0
  177. package/src/vendor/pi-tui/tui.ts +1456 -0
  178. package/src/vendor/pi-tui/undo-stack.ts +28 -0
  179. package/src/vendor/pi-tui/utils.ts +1345 -0
  180. package/src/vendor/pi-tui/word-navigation.ts +117 -0
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Handoff: the structured projection that carries a segment's durable state into
3
+ * the next one. Generated deterministically from the log with zero LLM calls
4
+ * (decisions D2, D3).
5
+ *
6
+ * The field set borrows names from the closed MCP SEP #2683 proposal
7
+ * (handoff_version, completed, decisions, pitfalls) for future interop. That
8
+ * proposal states no types, no required fields and no invariants, so everything
9
+ * below is this project's own -- in particular the invariants in
10
+ * validateHandoff(), which are the part that actually does work.
11
+ *
12
+ * The contract is enforced here rather than in the prompt because the harness is
13
+ * the only layer that sees prompt assembly, tool mediation and lifecycle events
14
+ * at once -- the placement argument from ClawVM (arXiv:2604.10352).
15
+ */
16
+
17
+ import type { LineRange, Sha256, NodeRef, IsoTime, EvidenceQuery, EventId } from "./events.ts";
18
+ import type { ProgressEvidence } from "./progress.ts";
19
+ import { isExecutionOutcome, type ExecutionOutcome } from "./outcome.ts";
20
+ import type { WorkItem } from "./work-state.ts";
21
+ import type { EvidenceLink } from "./evidence-links.ts";
22
+ import { emitYaml } from "./yaml.ts";
23
+
24
+ export const HANDOFF_VERSION = 1 as const;
25
+
26
+ /** Resolved proof that a completion claim is real: a log event that exited zero. */
27
+ export type Evidence = {
28
+ /** Configured gate name, when the backing command had one. */
29
+ gate?: string;
30
+ command: string;
31
+ exit: number;
32
+ at: IsoTime;
33
+ /** Id of the backing tool_result event, so the claim is traceable to the log. */
34
+ event_id: string;
35
+ /** Exact observed workspace after the command; absence means legacy/unknown. */
36
+ workspace_sha256?: string;
37
+ }
38
+
39
+ export type CompletedItem = {
40
+ what: string;
41
+ work_id?: string;
42
+ /** The proof as it stood when the claim was made. */
43
+ evidence: Evidence;
44
+ evidence_stale?: true;
45
+ /**
46
+ * Set when the gate this item cited is failing as of the latest run.
47
+ *
48
+ * The item stays in `completed` because it really did pass when claimed, and
49
+ * deleting that record would throw away something true. But a reader scanning
50
+ * `completed` would otherwise have to cross-reference `verification` to notice
51
+ * the suite is now red, so the signal is repeated inline where it is read.
52
+ */
53
+ evidence_superseded?: { gate: string; status: "fail"; exit?: number; at?: IsoTime };
54
+ }
55
+
56
+ /**
57
+ * A claim whose evidence did not resolve. Kept visible on purpose: a skipped or
58
+ * failed check must never read as a pass, and silently dropping the claim would
59
+ * hide exactly the information a reviewer needs.
60
+ */
61
+ export type UnverifiedClaim = {
62
+ what: string;
63
+ claimed: EvidenceQuery;
64
+ reason: "no_matching_event" | "nonzero_exit" | "stale_workspace";
65
+ /** Present when a matching event existed but failed. */
66
+ exit?: number;
67
+ }
68
+
69
+ export type InFlightItem = {
70
+ work_id?: string;
71
+ what: string;
72
+ next_action: string;
73
+ }
74
+
75
+ export type BlockedItem = {
76
+ work_id?: string;
77
+ what: string;
78
+ reason: string;
79
+ }
80
+
81
+ export type DecisionItem = {
82
+ chose: string;
83
+ because: string;
84
+ rejected?: readonly string[];
85
+ }
86
+
87
+ export type PitfallItem = {
88
+ tried: string;
89
+ failed_because: string;
90
+ }
91
+
92
+ export type FileAnchor = {
93
+ path: string;
94
+ /** Checked against subsequent workspace snapshots; true marks a mismatch. */
95
+ sha256: Sha256;
96
+ stale?: true;
97
+ read_ranges?: readonly LineRange[];
98
+ touched: readonly ("read" | "edit")[];
99
+ }
100
+
101
+ /**
102
+ * A branch this node opened, and what became of it.
103
+ *
104
+ * Recorded in the handoff rather than only in the log because the handoff is
105
+ * the only thing that crosses a boundary: a branch absent from it is a branch
106
+ * the next context does not know exists.
107
+ */
108
+ export type BranchRecord = {
109
+ node: number;
110
+ /** Event id in this node where the branch was spawned. */
111
+ spawned_at: EventId;
112
+ purpose: string;
113
+ /** null means unresolved, which D16 requires be visible rather than quiet. */
114
+ resolution: "merged" | "discarded" | null;
115
+ /** Present once the branch joined; a path, never the handoff's contents. */
116
+ handoff_ref?: string;
117
+ summary?: string;
118
+ outcome?: ExecutionOutcome;
119
+ };
120
+
121
+ export type VerificationStatus = "pass" | "fail" | "not_run";
122
+
123
+ export type VerificationItem = {
124
+ gate: string;
125
+ status: VerificationStatus;
126
+ at?: IsoTime;
127
+ exit?: number;
128
+ workspace_sha256?: string;
129
+ stale?: true;
130
+ }
131
+
132
+ export type UserTurnRecord = {
133
+ at: IsoTime;
134
+ text: string;
135
+ /**
136
+ * Set when a later interrupt event cancelled this turn. Omitted rather
137
+ * than false: absence means the turn was not abandoned, which is the
138
+ * common case, and deleting the turn would rewrite history.
139
+ */
140
+ abandoned?: true;
141
+ };
142
+
143
+ export type Handoff = {
144
+ handoff_version: typeof HANDOFF_VERSION | 2;
145
+ /** V2 arrays are carried suffixes; the immutable source rebuilds every page. */
146
+ history?: {
147
+ source: NodeRef;
148
+ sections: Record<string, { total: number; offset: number }>;
149
+ };
150
+ /**
151
+ * The node this state was derived FROM.
152
+ *
153
+ * There is deliberately no field naming the node it is FOR: a fork makes one
154
+ * handoff the basis for several children at once, so that question has no
155
+ * single answer. Identity lives here and nowhere else.
156
+ */
157
+ predecessor: NodeRef | null;
158
+
159
+ /**
160
+ * Verbatim from the first user turn of segment 0 that was not later
161
+ * cancelled. Never rewritten. If every user turn in that segment was
162
+ * abandoned, this is the last of them (EMPTY_OBJECTIVE forbids blanking it);
163
+ * the abandoned mark lives on `user_turns`.
164
+ */
165
+ objective: string;
166
+ /**
167
+ * Every user turn, verbatim, accumulated across segments. Which sentence is a
168
+ * hard constraint is not derivable, and user turns are both the smallest and
169
+ * highest-value content in a session -- so all of them are kept rather than
170
+ * asking the model to label some of them.
171
+ */
172
+ user_turns: readonly UserTurnRecord[];
173
+ /** Set only when the cap dropped turns; names the log refs that still hold them. */
174
+ user_turns_truncated?: { dropped: number; recover_from: readonly string[] };
175
+
176
+ state: {
177
+ /** O.2: canonical identified work; legacy lists below are compatible projections. */
178
+ work_items?: readonly WorkItem[];
179
+ completed: readonly CompletedItem[];
180
+ unverified_claims: readonly UnverifiedClaim[];
181
+ in_flight: readonly InFlightItem[];
182
+ blocked: readonly BlockedItem[];
183
+ /** Derived from the log, so always present even when the model annotated nothing. */
184
+ last_activity: string | null;
185
+ };
186
+
187
+ decisions: readonly DecisionItem[];
188
+ pitfalls: readonly PitfallItem[];
189
+ file_anchors: readonly FileAnchor[];
190
+ verification: readonly VerificationItem[];
191
+ /** Branches opened from this node, resolved or not. */
192
+ branches: readonly BranchRecord[];
193
+ open_questions: readonly string[];
194
+ /** D9 exact novelty ledger. Absence is legacy; rebuild it from original events. */
195
+ progress_evidence?: readonly ProgressEvidence[];
196
+ /** O.6: original evidence references and replacement relationships, never inline history. */
197
+ evidence_links?: readonly EvidenceLink[];
198
+
199
+ /**
200
+ * True only when work remains AND some in-flight item has a concrete next
201
+ * action. Blocked-with-no-next-action is therefore invalid by construction,
202
+ * which is the correct reading: a human has to look at it.
203
+ */
204
+ continuation_valid: boolean;
205
+
206
+ budget: {
207
+ /** Length of the path from the root to this node, inclusive. */
208
+ nodes_on_path: number;
209
+ /** Consecutive nodes along this path with no verifiable progress (D9). */
210
+ nodes_without_progress: number;
211
+ };
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // YAML field order: schema-defined, not alphabetical, so the file reads top-down
216
+ // the way a person would want to read it (decision D10).
217
+ // ---------------------------------------------------------------------------
218
+
219
+ const ORDER: Record<string, readonly string[]> = {
220
+ "": [
221
+ "handoff_version", "predecessor", "history",
222
+ "objective", "user_turns", "user_turns_truncated",
223
+ "state", "decisions", "pitfalls", "file_anchors",
224
+ "verification", "branches", "open_questions", "progress_evidence", "evidence_links",
225
+ "continuation_valid", "budget",
226
+ ],
227
+ predecessor: ["node", "log_ref", "sha256"],
228
+ user_turns: ["at", "text", "abandoned"],
229
+ user_turns_truncated: ["dropped", "recover_from"],
230
+ state: ["completed", "unverified_claims", "in_flight", "blocked", "last_activity", "work_items"],
231
+ "state.completed": ["work_id", "what", "evidence", "evidence_superseded"],
232
+ "state.completed.evidence_superseded": ["gate", "status", "exit", "at"],
233
+ "state.completed.evidence": ["gate", "command", "exit", "at", "event_id"],
234
+ "state.unverified_claims": ["what", "claimed", "reason", "exit"],
235
+ "state.in_flight": ["work_id", "what", "next_action"],
236
+ "state.blocked": ["work_id", "what", "reason"],
237
+ "state.work_items": ["id", "what", "status", "verification", "opened_at", "updated_at", "next_action", "reason", "evidence", "previous_evidence"],
238
+ decisions: ["chose", "because", "rejected"],
239
+ pitfalls: ["tried", "failed_because"],
240
+ file_anchors: ["path", "sha256", "read_ranges", "touched"],
241
+ verification: ["gate", "status", "at", "exit"],
242
+ branches: ["node", "spawned_at", "purpose", "resolution", "handoff_ref", "summary", "outcome"],
243
+ "branches.outcome": ["status", "reason"],
244
+ evidence_links: ["id", "work", "what", "event", "resource", "version", "freshness", "superseded_by"],
245
+ budget: ["nodes_on_path", "nodes_without_progress"],
246
+ };
247
+
248
+ /** Key-order resolver for emitYaml(). Array indices are transparent to the path. */
249
+ export function handoffKeyOrder(path: readonly string[]): readonly string[] | undefined {
250
+ return ORDER[path.join(".")];
251
+ }
252
+
253
+ /**
254
+ * The one way to serialize a handoff.
255
+ *
256
+ * Callers cannot forget the key order and silently get alphabetical output. The
257
+ * types above are declared as type aliases rather than interfaces so they carry
258
+ * an implicit index signature and satisfy YamlValue directly -- no cast.
259
+ */
260
+ export function emitHandoff(h: Handoff): string {
261
+ return emitYaml(h, { keyOrder: handoffKeyOrder });
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Invariants
266
+ // ---------------------------------------------------------------------------
267
+
268
+ export type Violation = {
269
+ code:
270
+ | "COMPLETED_WITHOUT_PASSING_EVIDENCE"
271
+ | "PASS_WITHOUT_ZERO_EXIT"
272
+ | "ANCHOR_WITHOUT_SHA"
273
+ | "EMPTY_OBJECTIVE"
274
+ | "PREDECESSOR_MISSING"
275
+ | "PREDECESSOR_WITHOUT_SHA"
276
+ | "EMPTY_PATH"
277
+ | "CONTINUATION_WITHOUT_NEXT_ACTION"
278
+ | "INVALID_HISTORY"
279
+ | "INVALID_EXECUTION_OUTCOME"
280
+ | "INVALID_WORK_STATE"
281
+ | "INVALID_EVIDENCE_LINK"
282
+ | "BAD_VERSION";
283
+ detail: string;
284
+ }
285
+
286
+ /**
287
+ * The enforceable contract. Every rule here exists because some real harness got
288
+ * it wrong: "migration applied successfully" with a nonzero exit code nobody
289
+ * read; a skipped check reported as a pass; a stale file snapshot presented as
290
+ * current state.
291
+ */
292
+ export function validateHandoff(h: Handoff): Violation[] {
293
+ const v: Violation[] = [];
294
+ for (const link of h.evidence_links ?? []) if (!link.id || !link.work || !link.event ||
295
+ !["current", "stale", "unknown"].includes(link.freshness) || (link.freshness !== "unknown" && !link.version)) {
296
+ v.push({ code: "INVALID_EVIDENCE_LINK", detail: `invalid evidence reference ${link.id}` });
297
+ }
298
+ const workIds = new Set<string>();
299
+ for (const item of h.state.work_items ?? []) {
300
+ const proven = item.verification === "verified" || item.verification === "stale";
301
+ if (!item.id.trim() || workIds.has(item.id) || !item.what.trim() || !item.opened_at || !item.updated_at ||
302
+ !["active", "blocked", "completed", "cancelled"].includes(item.status) ||
303
+ !["not_run", "verified", "unverified", "stale"].includes(item.verification) ||
304
+ (item.status === "active" && !item.next_action?.trim()) ||
305
+ (item.status === "completed" && !proven) ||
306
+ (proven && (!item.evidence || item.evidence.exit !== 0)) ||
307
+ (!proven && item.evidence !== undefined)) {
308
+ v.push({ code: "INVALID_WORK_STATE", detail: `invalid identity, lifecycle or evidence for work ${item.id}` });
309
+ }
310
+ workIds.add(item.id);
311
+ }
312
+ for (const branch of h.branches) {
313
+ if (branch.outcome !== undefined && !isExecutionOutcome(branch.outcome)) {
314
+ v.push({ code: "INVALID_EXECUTION_OUTCOME", detail: `branch ${branch.node} has an invalid execution outcome` });
315
+ }
316
+ }
317
+
318
+ if (h.handoff_version !== HANDOFF_VERSION && h.handoff_version !== 2) {
319
+ v.push({ code: "BAD_VERSION", detail: `expected ${HANDOFF_VERSION}, got ${h.handoff_version}` });
320
+ }
321
+ if (h.handoff_version === 2) {
322
+ const history = h.history;
323
+ if (!history || !h.predecessor || history.source.node !== h.predecessor.node ||
324
+ history.source.sha256 !== h.predecessor.sha256) {
325
+ v.push({ code: "INVALID_HISTORY", detail: "paged handoff must identify its immutable predecessor" });
326
+ } else for (const [section, range] of Object.entries(history.sections)) {
327
+ const values = section.startsWith("state.") ? h.state[section.slice(6) as keyof Handoff["state"]]
328
+ : h[section as keyof Handoff];
329
+ if (!Array.isArray(values) || !Number.isSafeInteger(range.total) || !Number.isSafeInteger(range.offset) ||
330
+ range.offset < 0 || range.total < range.offset || range.total - range.offset !== values.length) {
331
+ v.push({ code: "INVALID_HISTORY", detail: `invalid carried range for ${section}` });
332
+ }
333
+ }
334
+ }
335
+
336
+ for (const c of h.state.completed) {
337
+ if (c.evidence.exit !== 0) {
338
+ v.push({
339
+ code: "COMPLETED_WITHOUT_PASSING_EVIDENCE",
340
+ detail: `"${c.what}" cites ${c.evidence.command} which exited ${c.evidence.exit}`,
341
+ });
342
+ }
343
+ }
344
+
345
+ for (const item of h.verification) {
346
+ if (item.status === "pass" && item.exit !== 0) {
347
+ v.push({
348
+ code: "PASS_WITHOUT_ZERO_EXIT",
349
+ detail: `gate "${item.gate}" is pass but exit is ${item.exit ?? "absent"}`,
350
+ });
351
+ }
352
+ }
353
+
354
+ for (const a of h.file_anchors) {
355
+ if (!a.sha256) {
356
+ v.push({ code: "ANCHOR_WITHOUT_SHA", detail: `anchor for ${a.path} has no sha256` });
357
+ }
358
+ }
359
+
360
+ if (h.objective.trim().length === 0) {
361
+ v.push({ code: "EMPTY_OBJECTIVE", detail: "objective is empty" });
362
+ }
363
+
364
+ // Every handoff is derived from some node, so a null predecessor means the
365
+ // state has no provenance -- there is nothing to grep back to.
366
+ if (h.predecessor === null) {
367
+ v.push({ code: "PREDECESSOR_MISSING", detail: "handoff has no source node" });
368
+ } else if (!h.predecessor.sha256) {
369
+ // Same rule as ANCHOR_WITHOUT_SHA: a reference you cannot check is a
370
+ // promise, not a pointer (D8). predecessor is the chain's most important
371
+ // anchor; filling it without a digest is how the empty-string default
372
+ // survived every call site. node 0 with predecessor === null is a
373
+ // different case and is already covered above.
374
+ v.push({
375
+ code: "PREDECESSOR_WITHOUT_SHA",
376
+ detail: `predecessor node ${h.predecessor.node} has no sha256`,
377
+ });
378
+ }
379
+
380
+ // A path always contains at least the node itself.
381
+ if (h.budget.nodes_on_path < 1) {
382
+ v.push({
383
+ code: "EMPTY_PATH",
384
+ detail: `nodes_on_path is ${h.budget.nodes_on_path}, but a path includes its own node`,
385
+ });
386
+ }
387
+
388
+ if (h.continuation_valid && !h.state.in_flight.some((i) => i.next_action.trim().length > 0) &&
389
+ !(h.handoff_version === 2 && (h.history?.sections["state.in_flight"]?.offset ?? 0) > 0)) {
390
+ v.push({
391
+ code: "CONTINUATION_WITHOUT_NEXT_ACTION",
392
+ detail: "continuation_valid is true but no in_flight item has a next_action",
393
+ });
394
+ }
395
+
396
+ return v;
397
+ }
@@ -0,0 +1,90 @@
1
+ /** Shared evaluation protocol, not a production context policy (research N.10). */
2
+ import { shuffled } from "./experiment.ts";
3
+ import { canReserve, type BenchmarkUsage } from "./benchmark.ts";
4
+
5
+ export const COMPARISON_ARMS = ["xeno-windowed", "xeno-continuous", "pi"] as const;
6
+ export type ComparisonArm = typeof COMPARISON_ARMS[number];
7
+ export interface ComparisonJob { id: string; arm: ComparisonArm }
8
+
9
+ /** Null means an explicit provider-balance authorization, never zero allowance. */
10
+ export function comparisonCanReserve(charged: number, reservation: number, budget: number | null): boolean {
11
+ if (![charged, reservation, charged + reservation].every(Number.isFinite) || charged < 0 || reservation <= 0) return false;
12
+ return budget === null || canReserve(charged, reservation, budget);
13
+ }
14
+
15
+ /** DeepSeek documents 402 as insufficient balance; other errors need their own diagnosis. */
16
+ export function comparisonFailureKind(status: number | null): "balance" | "provider" {
17
+ return status === 402 ? "balance" : "provider";
18
+ }
19
+
20
+ export function comparisonSchedule(ids: readonly string[], seed: number): ComparisonJob[] {
21
+ if (ids.length === 0 || new Set(ids).size !== ids.length) throw new Error("empty or duplicate comparison tasks");
22
+ const arms = shuffled(COMPARISON_ARMS, seed);
23
+ return [...ids].sort().flatMap((id, i) => arms.map((_, position) => ({ id, arm: arms[(i + position) % arms.length]! })));
24
+ }
25
+
26
+ export function jsonObject(value: unknown): Record<string, unknown> {
27
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected JSON object");
28
+ return value as Record<string, unknown>;
29
+ }
30
+
31
+ /** Missing cache usage is unknown, never a fabricated zero or a discounted bill. */
32
+ export function comparisonUsage(body: unknown): BenchmarkUsage | null {
33
+ try {
34
+ const usage = jsonObject(jsonObject(body)["usage"]);
35
+ const prompt = usage["prompt_tokens"], output = usage["completion_tokens"];
36
+ const details = usage["prompt_tokens_details"];
37
+ const vendorCached = usage["prompt_cache_hit_tokens"];
38
+ const standardCached = details ? jsonObject(details)["cached_tokens"] : undefined;
39
+ if (vendorCached !== undefined && standardCached !== undefined && vendorCached !== standardCached) return null;
40
+ const cached = vendorCached ?? standardCached;
41
+ if (![prompt, output, cached].every((n) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0)) return null;
42
+ const p = prompt as number, o = output as number, c = cached as number;
43
+ if (p === 0 || o === 0 || c > p) return null;
44
+ if (usage["prompt_cache_miss_tokens"] !== undefined && usage["prompt_cache_miss_tokens"] !== p - c) return null;
45
+ return { input_tokens: p - c, output_tokens: o, cache_read_input_tokens: c, cache_creation_input_tokens: 0 };
46
+ } catch { return null; }
47
+ }
48
+
49
+ /** Buffer upstream responses for one billing boundary; do not report this as TTFT. */
50
+ export function completionSse(value: unknown): string {
51
+ const body = jsonObject(value);
52
+ const choices = body["choices"];
53
+ if (!Array.isArray(choices) || choices.length !== 1) throw new Error("expected one completion");
54
+ const choice = jsonObject(choices[0]), message = jsonObject(choice["message"]);
55
+ const calls = message["tool_calls"];
56
+ const finish = choice["finish_reason"];
57
+ if (typeof finish !== "string" || !["stop", "tool_calls", "length", "content_filter"].includes(finish)) throw new Error("incomplete completion");
58
+ if (calls !== undefined && !Array.isArray(calls)) throw new Error("invalid tool calls");
59
+ const delta = { ...message, ...(Array.isArray(calls) ? { tool_calls: calls.map((call: unknown, index: number) => ({ ...jsonObject(call), index })) } : {}) };
60
+ const common = { id: body["id"], object: "chat.completion.chunk", created: body["created"], model: body["model"] };
61
+ return [
62
+ { ...common, choices: [{ index: 0, delta, finish_reason: null }] },
63
+ { ...common, choices: [{ index: 0, delta: {}, finish_reason: finish }] },
64
+ { ...common, choices: [], usage: body["usage"] },
65
+ ].map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + "data: [DONE]\n\n";
66
+ }
67
+
68
+ /** JSONL permits U+2028 in strings. Only LF frames an event, including Pi's output. */
69
+ export function comparisonLines(buffer: string, chunk: string): { buffer: string; values: unknown[] } {
70
+ const lines = (buffer + chunk).split("\n");
71
+ const remainder = lines.pop()!;
72
+ return { buffer: remainder, values: lines.filter((line) => line.trim() !== "").map((line) => JSON.parse(line) as unknown) };
73
+ }
74
+
75
+ export function comparisonSummary(plan: readonly ComparisonJob[], results: readonly (ComparisonJob & { status: string; correct: boolean })[]) {
76
+ const keys = new Set(plan.map((job) => `${job.id}/${job.arm}`)), seen = new Set<string>();
77
+ if (keys.size !== plan.length) throw new Error("duplicate planned comparison job");
78
+ for (const result of results) {
79
+ const key = `${result.id}/${result.arm}`;
80
+ if (!keys.has(key) || seen.has(key) || (result.correct && result.status !== "answered")) throw new Error("invalid comparison outcome");
81
+ seen.add(key);
82
+ }
83
+ return COMPARISON_ARMS.map((arm) => {
84
+ const planned = plan.filter((job) => job.arm === arm).length, rows = results.filter((row) => row.arm === arm);
85
+ const correct = rows.filter((row) => row.correct).length;
86
+ return { arm, planned, attempted: rows.length, answered: rows.filter((row) => row.status === "answered").length,
87
+ correct, missing: planned - rows.length, accuracy: rows.length ? correct / rows.length : null,
88
+ complete: rows.length === planned };
89
+ }).filter((row) => row.planned > 0);
90
+ }
@@ -0,0 +1,103 @@
1
+ /** N.2: searchable views retain provenance; they can always be rebuilt from the log. */
2
+ import type { LogEvent, ToolResultEvent } from "./events.ts";
3
+ import { canonicalValue } from "./continuation.ts";
4
+ import { deriveEvidenceLinks, observationResources, resourceKey, type EvidenceLink } from "./evidence-links.ts";
5
+
6
+ export interface HistoryEntry extends Pick<ToolResultEvent, "effect" | "outcome" | "data" | "diagnostic"> {
7
+ node: number;
8
+ event: string;
9
+ kind: string;
10
+ text: string;
11
+ tool?: string;
12
+ input?: unknown;
13
+ ok?: boolean;
14
+ artifact?: string;
15
+ resources?: readonly string[];
16
+ work_ids?: readonly string[];
17
+ evidence?: readonly EvidenceLink[];
18
+ source_available?: boolean;
19
+ }
20
+
21
+ export function historyEntries(nodes: readonly { node: number; events: readonly LogEvent[] }[]): HistoryEntry[] {
22
+ const entries: HistoryEntry[] = [];
23
+ const calls = new Map<string, { name: string; input: unknown }>();
24
+ for (const { node, events } of nodes) for (const e of events) {
25
+ if (e.t === "assistant") {
26
+ for (const block of e.blocks) if (block.type === "tool_use") calls.set(block.call_id, { name: block.name, input: block.input });
27
+ const text = e.blocks.filter((b) => b.type === "text").map((b) => b.text).join("\n");
28
+ if (text) entries.push({ node, event: e.id, kind: e.t, text });
29
+ } else if (e.t === "tool_result") {
30
+ const call = calls.get(e.call_id);
31
+ // Indexing query results recursively would make the index amplify itself.
32
+ if (call?.name === "history" || call?.name === "note") continue;
33
+ entries.push({ node, event: e.id, kind: e.t, text: e.content, ok: e.ok,
34
+ ...(call ? { tool: call.name, input: call.input } : {}),
35
+ resources: observationResources(e, call?.input),
36
+ ...(e.effect ? { effect: e.effect } : {}), ...(e.outcome ? { outcome: e.outcome } : {}),
37
+ ...(e.data !== undefined ? { data: e.data } : {}), ...(e.diagnostic ? { diagnostic: e.diagnostic } : {}),
38
+ ...(e.content_ref ? { artifact: e.content_ref.sha256 } : {}) });
39
+ } else if (["user", "next_action", "decision", "pitfall", "blocked", "question", "claim", "work_status", "evidence_link"].includes(e.t)) {
40
+ entries.push({ node, event: e.id, kind: e.t, text: e.t === "user" ? e.text : canonicalValue(e),
41
+ ...("work_id" in e && e.work_id ? { work_ids: [e.work_id] } : {}),
42
+ ...(e.t === "evidence_link" ? { work_ids: [e.work], ...(e.resource ? { resources: [resourceKey(e.resource)] } : {}) } : {}) });
43
+ }
44
+ }
45
+ const lookup = new Map(entries.map(entry => [entry.event, entry]));
46
+ for (const link of deriveEvidenceLinks([], nodes.flatMap(node => [...node.events]))) {
47
+ const source = lookup.get(link.event);
48
+ const annotation = lookup.get(link.id);
49
+ if (annotation) { annotation.source_available = source !== undefined; annotation.evidence = [link]; }
50
+ if (source) {
51
+ source.work_ids = [...new Set([...(source.work_ids ?? []), link.work])];
52
+ source.evidence = [...(source.evidence ?? []), link];
53
+ }
54
+ }
55
+ return entries;
56
+ }
57
+
58
+ export function historyIndex(entries: readonly HistoryEntry[]): {
59
+ byEvent: ReadonlyMap<string, HistoryEntry>; byResource: ReadonlyMap<string, readonly HistoryEntry[]>; byWork: ReadonlyMap<string, readonly HistoryEntry[]>;
60
+ } {
61
+ const byResource = new Map<string, HistoryEntry[]>(), byWork = new Map<string, HistoryEntry[]>();
62
+ const add = (index: Map<string, HistoryEntry[]>, key: string, entry: HistoryEntry): void => {
63
+ const items = index.get(key);
64
+ if (items) items.push(entry); else index.set(key, [entry]);
65
+ };
66
+ for (const entry of entries) {
67
+ for (const resource of entry.resources ?? []) add(byResource, resource, entry);
68
+ for (const work of entry.work_ids ?? []) add(byWork, work, entry);
69
+ }
70
+ return { byEvent: new Map(entries.map(entry => [entry.event, entry])), byResource, byWork };
71
+ }
72
+
73
+ /** Literal AND search, newest first. No regex execution, hidden ranking, or LLM. */
74
+ export function queryHistory(entries: readonly HistoryEntry[], query: string, tool?: string): HistoryEntry[] {
75
+ const terms = query.toLowerCase().trim().split(/\s+/).filter(Boolean);
76
+ return entries.filter((entry) => {
77
+ if (tool !== undefined && entry.tool !== tool) return false;
78
+ const text = `${entry.text}\n${canonicalValue(entry.input)}`.toLowerCase();
79
+ return terms.every((term) => text.includes(term));
80
+ }).toReversed();
81
+ }
82
+
83
+ /**
84
+ * Lines of an entry that contain a query term, so a caller can see the match
85
+ * without reading back the whole observation. Literal and case-insensitive,
86
+ * like queryHistory; an index view, never replacement evidence.
87
+ */
88
+ export function historyHits(text: string, terms: readonly string[], limit = 5, width = 300): { line: number; text: string }[] {
89
+ const needles = terms.map((term) => term.toLowerCase()).filter(Boolean);
90
+ if (needles.length === 0 || limit < 1) return [];
91
+ const hits: { line: number; text: string }[] = [];
92
+ const lines = text.split("\n");
93
+ for (let i = 0; i < lines.length && hits.length < limit; i++) {
94
+ const lower = lines[i]!.toLowerCase();
95
+ if (!needles.some((needle) => lower.includes(needle))) continue;
96
+ const line = lines[i]!;
97
+ // Center a long line on its first match so the term survives the cut.
98
+ const at = Math.min(...needles.map((needle) => lower.indexOf(needle)).filter((index) => index >= 0));
99
+ const start = line.length <= width ? 0 : Math.max(0, Math.min(at - Math.floor(width / 3), line.length - width));
100
+ hits.push({ line: i + 1, text: line.slice(start, start + width) });
101
+ }
102
+ return hits;
103
+ }