@dev.fast/whiteboard 0.0.0-stage → 0.2.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 (247) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/THIRD_PARTY_NOTICES.md +93 -0
  4. package/dist/account-alias-DOHN1RSH.js +973 -0
  5. package/dist/agent-cli-ChOq0ZuO.js +117 -0
  6. package/dist/agent-cli-DzWfZr6G.js +2 -0
  7. package/dist/agent-client-CPQo7iTI.js +305 -0
  8. package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
  9. package/dist/build-info.json +1 -0
  10. package/dist/cli-hVgHcdsv.js +47 -0
  11. package/dist/cli-runner-D8y2_luU.js +5095 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +128 -0
  14. package/dist/client-DFoh50W3.js +950 -0
  15. package/dist/desktop-discovery-DBO-PM2V.js +164 -0
  16. package/dist/error-message-OtiDonty.js +6 -0
  17. package/dist/fs-utils-BMPLt0cr.js +22 -0
  18. package/dist/fuzzy-match-BoAmcyak.js +30 -0
  19. package/dist/headless-host-BJxf4dUU.js +94 -0
  20. package/dist/input-error-OLgB_h31.js +11 -0
  21. package/dist/local-data-CxLk75rx.js +4491 -0
  22. package/dist/mcp-QeF8vkdf.js +140 -0
  23. package/dist/package-paths-B6-zxvIO.js +47 -0
  24. package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
  25. package/dist/profile-4ry2f3AM.js +76 -0
  26. package/dist/profile-Bh5FIrmf.js +2 -0
  27. package/dist/request-origin-D_4QIdoN.js +811 -0
  28. package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
  29. package/dist/review-home-paths-6zZH-1c9.js +2560 -0
  30. package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
  31. package/dist/runtime-CKiWhGrf.js +52 -0
  32. package/dist/runtime.d.ts +9 -0
  33. package/dist/runtime.js +2 -0
  34. package/dist/s3-SzLpeCG6.js +345 -0
  35. package/dist/s3-config-BgxkSoOy.js +2 -0
  36. package/dist/s3-config-CAxhO9u_.js +676 -0
  37. package/dist/server/desktop-host.d.ts +4 -0
  38. package/dist/server/desktop-host.js +1702 -0
  39. package/dist/server-discovery-Dbzn3w6Z.js +66 -0
  40. package/dist/sharing/index.d.ts +1977 -0
  41. package/dist/sharing/index.js +2 -0
  42. package/dist/src-CGP5ytbV.js +283 -0
  43. package/dist/src-CmdiBL20.js +3676 -0
  44. package/dist/src-DnwdaQ2r.js +865 -0
  45. package/dist/src-X9phtB2j.js +68 -0
  46. package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
  47. package/dist/tool-failure-C8zB73HV.js +98 -0
  48. package/dist/tutorial-trace-DrBvkaeL.js +43 -0
  49. package/instructions/authoring.md +36 -0
  50. package/instructions/file-lenses.md +13 -0
  51. package/instructions/scratchpad.md +23 -0
  52. package/instructions/trace-archaeology.md +113 -0
  53. package/onboarding.md +8 -0
  54. package/package.json +106 -3
  55. package/src/agent-selection.ts +100 -0
  56. package/src/agent-session-ref.ts +80 -0
  57. package/src/ask/agents.ts +351 -0
  58. package/src/ask/checkout-files.ts +47 -0
  59. package/src/ask/file-refs.ts +111 -0
  60. package/src/ask/pi-mcp.ts +154 -0
  61. package/src/ask/protocol.ts +244 -0
  62. package/src/ask/thread-state.ts +350 -0
  63. package/src/ask/thread.ts +1400 -0
  64. package/src/ask/threads.ts +219 -0
  65. package/src/ask/watch.ts +79 -0
  66. package/src/cli-install.ts +1041 -0
  67. package/src/cli-runner.ts +1461 -0
  68. package/src/cli-runtime-info.ts +35 -0
  69. package/src/cli.ts +222 -0
  70. package/src/connect-prompts.ts +248 -0
  71. package/src/cursor-deeplink.ts +11 -0
  72. package/src/desktop-discovery.ts +355 -0
  73. package/src/diff-selection-migration.ts +63 -0
  74. package/src/embedded-posthog-key.ts +6 -0
  75. package/src/error-telemetry.ts +247 -0
  76. package/src/evidence.ts +14 -0
  77. package/src/exception-telemetry.ts +126 -0
  78. package/src/fixtures/blocks/call_stack_diff.json +27 -0
  79. package/src/fixtures/blocks/callout.json +11 -0
  80. package/src/fixtures/blocks/code.json +9 -0
  81. package/src/fixtures/blocks/code_peek.json +8 -0
  82. package/src/fixtures/blocks/database_lens.json +51 -0
  83. package/src/fixtures/blocks/divider.json +1 -0
  84. package/src/fixtures/blocks/fixtures.ts +33 -0
  85. package/src/fixtures/blocks/flow_diagram.json +36 -0
  86. package/src/fixtures/blocks/ids.ts +8 -0
  87. package/src/fixtures/blocks/image.json +9 -0
  88. package/src/fixtures/blocks/markdown.json +7 -0
  89. package/src/fixtures/blocks/section.json +11 -0
  90. package/src/fixtures/blocks/sequence.json +31 -0
  91. package/src/fixtures/blocks/software_map.json +7 -0
  92. package/src/fixtures/blocks/trace_quote.json +9 -0
  93. package/src/fixtures/blocks/tutorial.json +51 -0
  94. package/src/fs-utils.ts +32 -0
  95. package/src/fuzzy-match.ts +94 -0
  96. package/src/install.ts +29 -0
  97. package/src/legacy-skills.ts +133 -0
  98. package/src/lens-selection.ts +230 -0
  99. package/src/markdown-latex-math.ts +230 -0
  100. package/src/markdown.ts +81 -0
  101. package/src/package-paths.ts +53 -0
  102. package/src/posthog-capture-client.ts +610 -0
  103. package/src/review-api/README.md +295 -0
  104. package/src/review-api/activity.ts +340 -0
  105. package/src/review-api/agent-cli.ts +219 -0
  106. package/src/review-api/agent-client.ts +188 -0
  107. package/src/review-api/anchor-quotes.ts +88 -0
  108. package/src/review-api/ask-history.ts +242 -0
  109. package/src/review-api/authoring-tools.ts +211 -0
  110. package/src/review-api/blocks/call_stack_diff.ts +75 -0
  111. package/src/review-api/blocks/callout.ts +24 -0
  112. package/src/review-api/blocks/code.ts +9 -0
  113. package/src/review-api/blocks/code_peek.ts +13 -0
  114. package/src/review-api/blocks/database_lens.ts +150 -0
  115. package/src/review-api/blocks/definition.ts +40 -0
  116. package/src/review-api/blocks/divider.ts +6 -0
  117. package/src/review-api/blocks/flow_diagram.ts +111 -0
  118. package/src/review-api/blocks/image.ts +10 -0
  119. package/src/review-api/blocks/index.ts +90 -0
  120. package/src/review-api/blocks/markdown.ts +17 -0
  121. package/src/review-api/blocks/section.ts +24 -0
  122. package/src/review-api/blocks/sequence.ts +56 -0
  123. package/src/review-api/blocks/software_map.ts +9 -0
  124. package/src/review-api/blocks/trace_quote.ts +10 -0
  125. package/src/review-api/blocks/tutorial.ts +39 -0
  126. package/src/review-api/checkout-fs.ts +14 -0
  127. package/src/review-api/client.ts +1 -0
  128. package/src/review-api/comparison-coverage.ts +304 -0
  129. package/src/review-api/component-reference.ts +30 -0
  130. package/src/review-api/diff-lenses.ts +175 -0
  131. package/src/review-api/document-headings.ts +51 -0
  132. package/src/review-api/document-text.ts +200 -0
  133. package/src/review-api/document.ts +906 -0
  134. package/src/review-api/file-lenses.ts +100 -0
  135. package/src/review-api/http.ts +1877 -0
  136. package/src/review-api/image-decode.ts +29 -0
  137. package/src/review-api/input-error.ts +9 -0
  138. package/src/review-api/instructions.ts +89 -0
  139. package/src/review-api/lens-alignment.ts +52 -0
  140. package/src/review-api/local-data.ts +1837 -0
  141. package/src/review-api/map-input.ts +154 -0
  142. package/src/review-api/mcp-client-agent.ts +32 -0
  143. package/src/review-api/mcp.ts +240 -0
  144. package/src/review-api/origin.ts +43 -0
  145. package/src/review-api/profile.ts +146 -0
  146. package/src/review-api/public-tools.ts +102 -0
  147. package/src/review-api/pull-request.ts +389 -0
  148. package/src/review-api/read-schemas.ts +85 -0
  149. package/src/review-api/recovery.ts +2 -0
  150. package/src/review-api/request-origin.ts +33 -0
  151. package/src/review-api/review-progress.ts +382 -0
  152. package/src/review-api/status-tool.ts +10 -0
  153. package/src/review-api/store-schema.ts +22 -0
  154. package/src/review-api/store.ts +1647 -0
  155. package/src/review-api/tool-failure.ts +64 -0
  156. package/src/review-api/trace-schema.ts +13 -0
  157. package/src/review-api/traces.ts +119 -0
  158. package/src/review-api/unsupported-files.integration.ts +91 -0
  159. package/src/review-api/workspaces.ts +692 -0
  160. package/src/review-api/worktree-source.ts +170 -0
  161. package/src/review-api/worktree-structural.integration.ts +320 -0
  162. package/src/review-app-launcher.ts +432 -0
  163. package/src/review-app-picker.ts +168 -0
  164. package/src/review-app.ts +134 -0
  165. package/src/review-bundled-tools.ts +249 -0
  166. package/src/review-checkout-paths.ts +37 -0
  167. package/src/review-diff-files.ts +89 -0
  168. package/src/review-head-checkout.ts +300 -0
  169. package/src/review-home-paths.ts +73 -0
  170. package/src/review-info.ts +59 -0
  171. package/src/review-instances.ts +113 -0
  172. package/src/review-logger.ts +183 -0
  173. package/src/review-preferences.ts +88 -0
  174. package/src/review-prepare.ts +291 -0
  175. package/src/review-stack.ts +112 -0
  176. package/src/review-telemetry.ts +1066 -0
  177. package/src/runtime.ts +74 -0
  178. package/src/server/account-alias.ts +30 -0
  179. package/src/server/bounded-stream.ts +34 -0
  180. package/src/server/bug-report.ts +301 -0
  181. package/src/server/client-error-budget.ts +45 -0
  182. package/src/server/crash-report.ts +273 -0
  183. package/src/server/desktop-host-shutdown.ts +64 -0
  184. package/src/server/desktop-host.ts +190 -0
  185. package/src/server/desktop-server.ts +703 -0
  186. package/src/server/diffr-config.ts +509 -0
  187. package/src/server/diffr-languages.ts +97 -0
  188. package/src/server/global-verb-relay.ts +190 -0
  189. package/src/server/headless-host.ts +149 -0
  190. package/src/server/hono-http.ts +162 -0
  191. package/src/server/http-json.ts +24 -0
  192. package/src/server/json-review-reporting.ts +174 -0
  193. package/src/server/process-error-telemetry.ts +174 -0
  194. package/src/server/review-api-parsers.ts +90 -0
  195. package/src/server/review-lifecycle-telemetry.ts +73 -0
  196. package/src/server/review-open-watchdog.ts +46 -0
  197. package/src/server/review-server-core.ts +290 -0
  198. package/src/server/structural-comparisons.ts +153 -0
  199. package/src/server/structural-diff.ts +186 -0
  200. package/src/server/tutorial-service.ts +241 -0
  201. package/src/server/ui-telemetry.ts +198 -0
  202. package/src/server-discovery.ts +95 -0
  203. package/src/session-markers.ts +132 -0
  204. package/src/sharing/auth.ts +41 -0
  205. package/src/sharing/cli.ts +83 -0
  206. package/src/sharing/client.ts +334 -0
  207. package/src/sharing/export.ts +195 -0
  208. package/src/sharing/host.ts +316 -0
  209. package/src/sharing/import.ts +787 -0
  210. package/src/sharing/index.ts +16 -0
  211. package/src/sharing/repository.ts +145 -0
  212. package/src/sharing/routes.ts +34 -0
  213. package/src/slug.ts +23 -0
  214. package/src/software-map-diff-counts.ts +517 -0
  215. package/src/software-map-model.ts +1147 -0
  216. package/src/software-map-topology-diff.ts +260 -0
  217. package/src/source.ts +92 -0
  218. package/src/startup-trace.ts +232 -0
  219. package/src/stored-document-migration.ts +151 -0
  220. package/src/telemetry-clean-text.ts +257 -0
  221. package/src/telemetry-config.ts +298 -0
  222. package/src/telemetry-debug-sink.ts +38 -0
  223. package/src/telemetry.ts +13 -0
  224. package/src/trace-cli.ts +156 -0
  225. package/src/trace-storage-cli.ts +509 -0
  226. package/src/tutorial-conversation.ts +18 -0
  227. package/src/ui-telemetry-events.ts +765 -0
  228. package/src/unified-diff.ts +71 -0
  229. package/src/viewed-coverage.ts +259 -0
  230. package/src/windows-cli.ts +125 -0
  231. package/tutorial/document.json +275 -0
  232. package/tutorial/runtime-manifest.json +12 -0
  233. package/tutorial/sample-service/package.json +9 -0
  234. package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
  235. package/tutorial/sample-service/src/app.ts +26 -0
  236. package/tutorial/sample-service/src/database/schema.ts +10 -0
  237. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
  238. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
  239. package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
  240. package/tutorial/sample-service/src/orders/order-service.ts +34 -0
  241. package/tutorial/sample-service/src/orders/order.ts +21 -0
  242. package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
  243. package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
  244. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
  245. package/tutorial/sample-service/tsconfig.json +12 -0
  246. package/tutorial/software-map.json +139 -0
  247. package/tutorial/trace.json +20 -0
@@ -0,0 +1,3866 @@
1
+ import { d as gitAt, u as git, v as resolveRepoContext } from "./src-CmdiBL20.js";
2
+ import { a as jsonNumber, c as jsonString, i as jsonBoolean, m as isStringValue, n as isJsonObject, o as jsonObject, r as jsonArray, t as isJsonArray, u as parseJsonText } from "./src-X9phtB2j.js";
3
+ import { a as TRACE_STORE_CLIENT_ID, b as commitShaSchema, c as deleteStoreResponseSchema, d as listUploadsResponseSchema, f as storeErrorCodeSchema, g as uploadManifestMismatch, h as traceObjectNameSchema, i as storeRoutes, l as listSessionsResponseSchema, m as storeResponseSchema, n as DEVICE_TOKEN_PATH, o as beginUploadResponseSchema, p as storeErrorEnvelopeSchema, r as SESSION_PATH, s as completeUploadResponseSchema, t as DEVICE_CODE_PATH, x as sessionIdSchema } from "./src-CGP5ytbV.js";
4
+ import { A as devReviewHome, E as writeTraceConfigFile, M as writeFileAtomic, P as writePrivateJsonAtomic, S as readTraceConfigFile, _ as emptyTraceConfig, c as resolveS3Setup, f as traceEnvValue$1, g as currentStore, h as TraceConfigurationError, i as isS3MockMode, j as normalizeStoreOrigin, l as s3CacheIdentity, m as DEFAULT_HOSTED_ORIGIN, n as clearTraceEnvCache$1, u as s3MockRoot, v as enabledOriginsOf, w as s3Store, x as hostedOrigin, y as hasHostedStore } from "./s3-config-CAxhO9u_.js";
5
+ import { t as errorMessage } from "./error-message-OtiDonty.js";
6
+ import { createHash, randomBytes } from "node:crypto";
7
+ import { z } from "zod";
8
+ import { closeSync, createReadStream, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
9
+ import { chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, stat } from "node:fs/promises";
10
+ import path from "node:path";
11
+ import os, { homedir, tmpdir } from "node:os";
12
+ import { spawn } from "node:child_process";
13
+ import { Readable, Transform } from "node:stream";
14
+ import { AsyncLocalStorage } from "node:async_hooks";
15
+ import { pipeline } from "node:stream/promises";
16
+ import { createGunzip, createGzip } from "node:zlib";
17
+ //#region ../trace-protocol/src/event-text.ts
18
+ function extractTraceEventText$1(event) {
19
+ if (!event) return "";
20
+ if (event.kind === "user") return event.text;
21
+ if (event.kind === "assistant") return event.markdown;
22
+ if (event.kind === "tool") return [
23
+ event.title,
24
+ event.command,
25
+ event.input,
26
+ event.output
27
+ ].filter(Boolean).join(" ");
28
+ if (event.kind === "separator") return event.label;
29
+ return "";
30
+ }
31
+ //#endregion
32
+ //#region ../trace-core/src/cli-output.ts
33
+ /** The stream a human report belongs on. */
34
+ function humanStream(output) {
35
+ return output.json ? output.stderr : output.stdout;
36
+ }
37
+ /** Writes one event line, and only under --json. */
38
+ function emitJsonEvent(output, event) {
39
+ if (output.json) output.stdout.write(`${JSON.stringify(event)}\n`);
40
+ }
41
+ /**
42
+ * Reports a failure on both channels and returns the exit code.
43
+ *
44
+ * The human message always reaches stderr. Under --json the same message also
45
+ * reaches stdout as a parseable error event, so an agent never has to read
46
+ * prose to learn why a command failed.
47
+ */
48
+ function failWithJsonError(output, stage, message) {
49
+ output.stderr.write(`${message}\n`);
50
+ emitJsonEvent(output, {
51
+ event: "error",
52
+ stage,
53
+ message
54
+ });
55
+ return 1;
56
+ }
57
+ const VALUE_FLAGS = /* @__PURE__ */ new Set([
58
+ "--review",
59
+ "--session",
60
+ "--agent-session"
61
+ ]);
62
+ /**
63
+ * Report whether the caller asked for JSON, by reading raw argv.
64
+ *
65
+ * A usage error happens during parsing, before commander resolves any option,
66
+ * so the error path cannot read the parsed value. Actions read the parsed
67
+ * option instead; this scan only has to answer for failures.
68
+ */
69
+ function jsonRequestedInArgv(argv) {
70
+ for (let index = 0; index < argv.length; index += 1) {
71
+ const token = argv[index];
72
+ if (token === "--") return false;
73
+ if (VALUE_FLAGS.has(token)) {
74
+ index += 1;
75
+ continue;
76
+ }
77
+ if (token === "--json" || token === "--json=true") return true;
78
+ }
79
+ return false;
80
+ }
81
+ //#endregion
82
+ //#region ../trace-core/src/store-authorization.ts
83
+ const context = new AsyncLocalStorage();
84
+ /** Only a foreground CLI installs this callback; hooks never prompt. */
85
+ function withStoreAuthorization(authorize, operation) {
86
+ return context.run({
87
+ authorize,
88
+ attempted: false
89
+ }, operation);
90
+ }
91
+ async function requestStoreAuthorization(origin) {
92
+ const scope = context.getStore();
93
+ if (!scope || scope.attempted) return void 0;
94
+ scope.attempted = true;
95
+ return scope.authorize(origin);
96
+ }
97
+ //#endregion
98
+ //#region ../trace-core/src/store-client.ts
99
+ const DEFAULT_TIMEOUT_MS = 3e4;
100
+ /** Largest metadata answer the client reads; object bytes never come this way. */
101
+ const MAX_STORE_RESPONSE_BYTES = 8388608;
102
+ /**
103
+ * The message for a store whose responses do not match this client's
104
+ * contract. A 0.1 store, for example, answers a begin call without an
105
+ * `uploadId`.
106
+ */
107
+ const STORE_UPGRADE_REQUIRED_MESSAGE = "The trace store does not support this version of Review. Update Review or use a matching store.";
108
+ /**
109
+ * The OAuth device-flow error shape used by the Better Auth device
110
+ * endpoints, e.g. `{"error":"invalid_grant","error_description":"..."}`.
111
+ * This is distinct from the store's own `storeErrorEnvelopeSchema`.
112
+ */
113
+ const oauthDeviceErrorSchema = z.object({
114
+ error: z.string(),
115
+ error_description: z.string().optional()
116
+ });
117
+ var StoreApiError = class extends Error {
118
+ code;
119
+ status;
120
+ constructor(code, status, message) {
121
+ super(message);
122
+ this.code = code;
123
+ this.status = status;
124
+ this.name = "StoreApiError";
125
+ }
126
+ };
127
+ var StoreClient = class {
128
+ origin;
129
+ token;
130
+ fetchImpl;
131
+ timeoutMs;
132
+ constructor(options) {
133
+ this.origin = options.origin;
134
+ this.token = options.token;
135
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
136
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
137
+ }
138
+ async listOwnUploads(repositoryId, query = {}) {
139
+ const params = new URLSearchParams();
140
+ if (query.session !== void 0) params.set("session", query.session);
141
+ if (query.cursor !== void 0) params.set("cursor", query.cursor);
142
+ if (query.limit !== void 0) params.set("limit", String(query.limit));
143
+ return this.get(storeRoutes.ownUploads(repositoryId), listUploadsResponseSchema, params);
144
+ }
145
+ async deviceCode(traces = true) {
146
+ return this.requestJson(DEVICE_CODE_PATH, {
147
+ client_id: TRACE_STORE_CLIENT_ID,
148
+ scope: traces ? "repo" : "identity"
149
+ }, void 0);
150
+ }
151
+ async deviceToken(deviceCode) {
152
+ const url = new URL(DEVICE_TOKEN_PATH, this.origin);
153
+ const response = await this.fetchImpl(url.toString(), {
154
+ method: "POST",
155
+ headers: this.jsonHeaders(),
156
+ body: JSON.stringify({
157
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
158
+ device_code: deviceCode,
159
+ client_id: TRACE_STORE_CLIENT_ID
160
+ }),
161
+ signal: AbortSignal.timeout(this.timeoutMs)
162
+ });
163
+ if (response.status === 400) {
164
+ const raw = await this.readJsonBody(response);
165
+ const oauthError = oauthDeviceErrorSchema.safeParse(raw);
166
+ if (oauthError.success && (oauthError.data.error === "authorization_pending" || oauthError.data.error === "slow_down")) return { pending: oauthError.data.error };
167
+ throw this.oauthOrEnvelopeError(raw, response.status);
168
+ }
169
+ if (!response.ok) throw await this.toStoreApiError(response);
170
+ return await response.json();
171
+ }
172
+ async session() {
173
+ return this.get(SESSION_PATH, z.object({ user: z.object({
174
+ id: z.string().min(1),
175
+ name: z.string()
176
+ }) }));
177
+ }
178
+ async createStore(body) {
179
+ return this.requestJson(storeRoutes.stores(), body, storeResponseSchema);
180
+ }
181
+ async findStore(query) {
182
+ try {
183
+ return await this.get(storeRoutes.stores(), storeResponseSchema, new URLSearchParams({
184
+ owner: query.owner,
185
+ name: query.name
186
+ }));
187
+ } catch (error) {
188
+ if (error instanceof StoreApiError && error.code === "not_found") return null;
189
+ throw error;
190
+ }
191
+ }
192
+ async beginUpload(repositoryId, sessionId, body) {
193
+ return this.requestJson(storeRoutes.uploads(repositoryId, sessionId), body, beginUploadResponseSchema);
194
+ }
195
+ async completeUpload(repositoryId, sessionId, uploadId, body) {
196
+ return this.requestJson(storeRoutes.uploadComplete(repositoryId, sessionId, uploadId), body, completeUploadResponseSchema);
197
+ }
198
+ /** Deletion is logical; the store answers 202 while an operator removes the objects. */
199
+ async deleteStore(repositoryId) {
200
+ return this.send("DELETE", storeRoutes.store(repositoryId), void 0, deleteStoreResponseSchema);
201
+ }
202
+ async listSessions(repositoryId, query) {
203
+ const params = new URLSearchParams();
204
+ if (query.commit) params.set("commit", query.commit);
205
+ if (query.session) params.set("session", query.session);
206
+ if (query.limit !== void 0) params.set("limit", String(query.limit));
207
+ if (query.cursor) params.set("cursor", query.cursor);
208
+ return this.get(storeRoutes.sessions(repositoryId), listSessionsResponseSchema, params);
209
+ }
210
+ jsonHeaders() {
211
+ return this.token ? {
212
+ "content-type": "application/json",
213
+ authorization: `Bearer ${this.token}`
214
+ } : { "content-type": "application/json" };
215
+ }
216
+ async get(path, schema, query) {
217
+ return this.send("GET", path, void 0, schema, query);
218
+ }
219
+ async requestJson(path, body, schema) {
220
+ return this.send("POST", path, body, schema);
221
+ }
222
+ async send(method, path, body, schema, query) {
223
+ const url = new URL(path, this.origin);
224
+ if (query) for (const [key, value] of query) url.searchParams.set(key, value);
225
+ const headers = {};
226
+ if (this.token) headers.authorization = `Bearer ${this.token}`;
227
+ if (body !== void 0) headers["content-type"] = "application/json";
228
+ const response = await this.fetchImpl(url.toString(), {
229
+ method,
230
+ headers,
231
+ body: body === void 0 ? void 0 : JSON.stringify(body),
232
+ signal: AbortSignal.timeout(this.timeoutMs)
233
+ });
234
+ if (!response.ok) {
235
+ const error = await this.toStoreApiError(response);
236
+ if (error.code === "repository_authorization_required") {
237
+ const token = await requestStoreAuthorization(this.origin);
238
+ if (token) {
239
+ this.token = token;
240
+ return this.send(method, path, body, schema, query);
241
+ }
242
+ }
243
+ throw error;
244
+ }
245
+ const contentLength = Number(response.headers.get("content-length") ?? "0");
246
+ if (contentLength > MAX_STORE_RESPONSE_BYTES) throw new StoreApiError("internal", response.status, `The trace store answered ${contentLength} bytes; the client accepts at most ${MAX_STORE_RESPONSE_BYTES}.`);
247
+ return this.parseJson(response, schema);
248
+ }
249
+ async parseJson(response, schema) {
250
+ const raw = await response.json();
251
+ if (!schema) return raw;
252
+ const result = schema.safeParse(raw);
253
+ if (!result.success) throw new StoreApiError("upgrade_required", response.status, STORE_UPGRADE_REQUIRED_MESSAGE);
254
+ return result.data;
255
+ }
256
+ async toStoreApiError(response) {
257
+ const raw = await this.readJsonBody(response);
258
+ return this.oauthOrEnvelopeError(raw, response.status);
259
+ }
260
+ /**
261
+ * Reads a response body once, as text, and parses it as JSON. Returns
262
+ * `undefined` if the body cannot be read or is not valid JSON.
263
+ */
264
+ async readJsonBody(response) {
265
+ let text;
266
+ try {
267
+ text = await response.text();
268
+ } catch {
269
+ return;
270
+ }
271
+ try {
272
+ return parseJsonText(text);
273
+ } catch {
274
+ return;
275
+ }
276
+ }
277
+ /**
278
+ * Builds a StoreApiError from an already-parsed body, which is either the
279
+ * store's own `{ error: { code, message } }` envelope or the OAuth
280
+ * device-flow error shape `{ error, error_description }` used by the
281
+ * Better Auth device endpoints.
282
+ */
283
+ oauthOrEnvelopeError(raw, status) {
284
+ if (raw === void 0) return new StoreApiError("internal", status, "The store returned an unreadable response.");
285
+ const oauthError = oauthDeviceErrorSchema.safeParse(raw);
286
+ if (oauthError.success) {
287
+ const knownCode = storeErrorCodeSchema.safeParse(oauthError.data.error);
288
+ return new StoreApiError(knownCode.success ? knownCode.data : "invalid_request", status, oauthError.data.error_description ?? oauthError.data.error);
289
+ }
290
+ const result = storeErrorEnvelopeSchema.safeParse(raw);
291
+ if (!result.success) return new StoreApiError("internal", status, "The store returned an error response that does not match the contract.");
292
+ return new StoreApiError(result.data.error.code, status, result.data.error.message);
293
+ }
294
+ };
295
+ //#endregion
296
+ //#region ../trace-core/src/store-auth.ts
297
+ const DEFAULT_STORE_ORIGIN = DEFAULT_HOSTED_ORIGIN;
298
+ const storeAuthSchema = z.object({
299
+ origin: z.string().min(1),
300
+ token: z.string().min(1),
301
+ login: z.string(),
302
+ savedAt: z.string()
303
+ });
304
+ /** Slow-down backoff added to the poll interval, per the OAuth device spec. */
305
+ const SLOW_DOWN_MS = 5e3;
306
+ function storeAuthPath(env = process.env) {
307
+ return path.join(devReviewHome(env), "auth.json");
308
+ }
309
+ async function readStoreAuth(env = process.env) {
310
+ let raw;
311
+ try {
312
+ raw = await readFile(storeAuthPath(env), "utf8");
313
+ } catch (error) {
314
+ if (error.code === "ENOENT") return null;
315
+ throw error;
316
+ }
317
+ let parsed;
318
+ try {
319
+ parsed = JSON.parse(raw);
320
+ } catch {
321
+ return null;
322
+ }
323
+ const result = storeAuthSchema.safeParse(parsed);
324
+ if (!result.success) return null;
325
+ try {
326
+ return {
327
+ ...result.data,
328
+ origin: normalizeStoreOrigin(result.data.origin)
329
+ };
330
+ } catch {
331
+ return null;
332
+ }
333
+ }
334
+ async function writeStoreAuth(auth, env = process.env) {
335
+ await writePrivateJsonAtomic(storeAuthPath(env), {
336
+ ...auth,
337
+ origin: normalizeStoreOrigin(auth.origin)
338
+ });
339
+ }
340
+ async function clearStoreAuth(env = process.env) {
341
+ await rm(storeAuthPath(env), { force: true });
342
+ }
343
+ /** A client bound to the saved login. Throws when no login is saved. */
344
+ async function requireStoreClient(env = process.env) {
345
+ const auth = await readStoreAuth(env);
346
+ if (!auth) throw new Error(`Run \`whiteboard login\` first.`);
347
+ return new StoreClient({
348
+ origin: auth.origin,
349
+ token: auth.token
350
+ });
351
+ }
352
+ async function defaultSleep(ms) {
353
+ await new Promise((resolve) => setTimeout(resolve, ms));
354
+ }
355
+ /** The command that opens a URL in the user's browser on this platform. */
356
+ function browserOpenCommand(platform = process.platform) {
357
+ return platform === "darwin" ? "open" : "xdg-open";
358
+ }
359
+ /** Opens a URL in the browser. Callers inject a stub in tests. */
360
+ async function openUrlInBrowser(url) {
361
+ const { spawn } = await import("node:child_process");
362
+ const child = spawn(browserOpenCommand(), [url], {
363
+ stdio: "ignore",
364
+ detached: true
365
+ });
366
+ child.on("error", () => {});
367
+ child.unref();
368
+ }
369
+ async function runStoreLogin(input) {
370
+ const output = {
371
+ json: input.json,
372
+ stdout: input.stdout,
373
+ stderr: input.stderr
374
+ };
375
+ let origin;
376
+ try {
377
+ origin = normalizeStoreOrigin(input.origin ?? DEFAULT_STORE_ORIGIN);
378
+ } catch (error) {
379
+ return failWithJsonError(output, "login", errorMessage(error));
380
+ }
381
+ const openUrl = input.openUrl ?? openUrlInBrowser;
382
+ const sleep = input.sleep ?? defaultSleep;
383
+ const client = new StoreClient({
384
+ origin,
385
+ fetch: input.fetch
386
+ });
387
+ const existing = input.traces ? await readStoreAuth(input.env) : null;
388
+ let expectedUser;
389
+ if (existing?.origin === origin) try {
390
+ expectedUser = (await new StoreClient({
391
+ origin,
392
+ token: existing.token,
393
+ fetch: input.fetch
394
+ }).session()).user.id;
395
+ } catch {}
396
+ let device;
397
+ try {
398
+ device = await client.deviceCode(input.traces ?? true);
399
+ } catch (error) {
400
+ return failWithJsonError(output, "login", error instanceof StoreApiError ? error.message : "Could not start the login.");
401
+ }
402
+ humanStream(output).write(`Open ${device.verification_uri_complete}\n`);
403
+ humanStream(output).write(`Code: ${device.user_code}\n`);
404
+ emitJsonEvent(output, {
405
+ event: "login",
406
+ status: "pending",
407
+ url: device.verification_uri_complete,
408
+ userCode: device.user_code
409
+ });
410
+ if (!input.noBrowser) await openUrl(device.verification_uri_complete);
411
+ let intervalMs = Math.max(1, device.interval) * 1e3;
412
+ const expiresAt = Date.now() + device.expires_in * 1e3;
413
+ let token;
414
+ while (Date.now() < expiresAt) {
415
+ await sleep(intervalMs);
416
+ let result;
417
+ try {
418
+ result = await client.deviceToken(device.device_code);
419
+ } catch (error) {
420
+ return failWithJsonError(output, "login", error instanceof StoreApiError ? error.message : "The login failed.");
421
+ }
422
+ if ("pending" in result) {
423
+ if (result.pending === "slow_down") intervalMs += SLOW_DOWN_MS;
424
+ continue;
425
+ }
426
+ token = result.access_token;
427
+ break;
428
+ }
429
+ if (!token) return failWithJsonError(output, "login", `The login expired. Run \`whiteboard login\` again.`);
430
+ const authedClient = new StoreClient({
431
+ origin,
432
+ token,
433
+ fetch: input.fetch
434
+ });
435
+ let login;
436
+ try {
437
+ const session = await authedClient.session();
438
+ if (expectedUser && session.user.id !== expectedUser) throw new Error("Authorize traces with the same GitHub account.");
439
+ login = session.user.name;
440
+ } catch (error) {
441
+ return failWithJsonError(output, "login", error instanceof StoreApiError ? error.message : "Could not read the login.");
442
+ }
443
+ await writeStoreAuth({
444
+ origin,
445
+ token,
446
+ login,
447
+ savedAt: (/* @__PURE__ */ new Date()).toISOString()
448
+ }, input.env);
449
+ emitJsonEvent(output, {
450
+ event: "login",
451
+ status: "ok",
452
+ login
453
+ });
454
+ humanStream(output).write(`You are logged in as ${login}.\n`);
455
+ return 0;
456
+ }
457
+ async function runStoreLogout(input) {
458
+ const existing = await readStoreAuth(input.env);
459
+ await clearStoreAuth(input.env);
460
+ input.stdout.write(existing ? "You are logged out.\n" : "You were not logged in.\n");
461
+ return 0;
462
+ }
463
+ async function runStoreWhoami(input) {
464
+ const output = {
465
+ json: input.json,
466
+ stdout: input.stdout,
467
+ stderr: input.stderr
468
+ };
469
+ const auth = await readStoreAuth(input.env);
470
+ if (!auth) return failWithJsonError(output, "whoami", `Run \`whiteboard login\` first.`);
471
+ const client = new StoreClient({
472
+ origin: auth.origin,
473
+ token: auth.token,
474
+ fetch: input.fetch
475
+ });
476
+ let login;
477
+ try {
478
+ login = (await client.session()).user.name;
479
+ } catch (error) {
480
+ return failWithJsonError(output, "whoami", error instanceof StoreApiError ? error.message : "Could not reach the hosted trace store.");
481
+ }
482
+ emitJsonEvent(output, {
483
+ event: "whoami",
484
+ login,
485
+ origin: auth.origin
486
+ });
487
+ humanStream(output).write(`Logged in to ${auth.origin} as ${login}.\n`);
488
+ return 0;
489
+ }
490
+ //#endregion
491
+ //#region ../trace-core/src/trace-consent.ts
492
+ async function readTraceUserConfig(devHome) {
493
+ return (await readTraceUserConfigFile(devHome)).consent;
494
+ }
495
+ /** The consent entries together with the file read they came from. */
496
+ async function readTraceUserConfigFile(devHome) {
497
+ const file = readTraceConfigFile({ devHome: devHome ?? devReviewHome() });
498
+ if (file.error) throw new TraceConfigurationError(file.error);
499
+ return {
500
+ file,
501
+ consent: {
502
+ version: 2,
503
+ repositories: (file.config?.repositories ?? []).map((entry) => ({
504
+ repositoryId: entry.repositoryId,
505
+ name: entry.name,
506
+ enabledOrigins: enabledOriginsOf(entry).flatMap((origin) => {
507
+ try {
508
+ return [normalizeStoreOrigin(origin)];
509
+ } catch {
510
+ return [];
511
+ }
512
+ }),
513
+ allowedAt: entry.allowedAt ?? null
514
+ }))
515
+ }
516
+ };
517
+ }
518
+ async function writeRepositories(file, repositories) {
519
+ await writeTraceConfigFile(file, {
520
+ ...file.config ?? emptyTraceConfig(),
521
+ repositories
522
+ });
523
+ }
524
+ function toConfigEntry(entry) {
525
+ const written = {
526
+ repositoryId: entry.repositoryId,
527
+ name: entry.name,
528
+ enabledOrigins: entry.enabledOrigins
529
+ };
530
+ if (entry.allowedAt) written.allowedAt = entry.allowedAt;
531
+ return written;
532
+ }
533
+ /**
534
+ * Allows one repository at one origin. A repository has one entry, keyed by
535
+ * its immutable id; allowing it at another origin appends to its list, and
536
+ * a rename updates the display name.
537
+ */
538
+ async function allowTraceRepository(entry, devHome = devReviewHome()) {
539
+ const origin = normalizeStoreOrigin(entry.origin);
540
+ if (!Number.isSafeInteger(entry.repositoryId) || entry.repositoryId < 1) throw new Error("The trace repository id must be a positive integer.");
541
+ const { file, consent: config } = await readTraceUserConfigFile(devHome);
542
+ const existing = config.repositories.find((candidate) => candidate.repositoryId === entry.repositoryId);
543
+ const merged = {
544
+ repositoryId: entry.repositoryId,
545
+ name: entry.name,
546
+ enabledOrigins: [.../* @__PURE__ */ new Set([...existing?.enabledOrigins ?? [], origin])],
547
+ allowedAt: existing?.allowedAt ?? (/* @__PURE__ */ new Date()).toISOString()
548
+ };
549
+ const repositories = [...config.repositories.filter((candidate) => candidate.repositoryId !== entry.repositoryId && candidate.name.toLowerCase() !== entry.name.toLowerCase()), merged];
550
+ await writeRepositories(file, repositories.map(toConfigEntry));
551
+ return {
552
+ version: 2,
553
+ repositories
554
+ };
555
+ }
556
+ /**
557
+ * Withdraws consent for a repository at every origin. The immutable id
558
+ * catches an entry whose display name moved; the name catches one whose id
559
+ * this machine never learned.
560
+ */
561
+ async function denyTraceRepository(repository, devHome = devReviewHome()) {
562
+ const { file, consent: config } = await readTraceUserConfigFile(devHome);
563
+ const repositories = config.repositories.filter((existing) => existing.name.toLowerCase() !== repository.name.toLowerCase() && (repository.repositoryId == null || existing.repositoryId !== repository.repositoryId));
564
+ const removed = repositories.length !== config.repositories.length;
565
+ if (removed) await writeRepositories(file, repositories.map(toConfigEntry));
566
+ return removed;
567
+ }
568
+ function findTraceRepository(config, name) {
569
+ return config.repositories.find((entry) => entry.name.toLowerCase() === name.toLowerCase()) ?? null;
570
+ }
571
+ //#endregion
572
+ //#region ../trace-core/src/trace-repo.ts
573
+ function parseRepo(value) {
574
+ const parts = value.split("/");
575
+ if (parts.length !== 2 || !parts[0] || !parts[1]) throw new Error("Repository must be OWNER/REPO.");
576
+ return {
577
+ owner: parts[0],
578
+ repo: parts[1]
579
+ };
580
+ }
581
+ function traceRepoName(repo) {
582
+ return `${repo.owner}/${repo.repo}`;
583
+ }
584
+ async function inferRepoFromGit(cwd) {
585
+ if (process.env.GITHUB_REPOSITORY) return parseRepo(process.env.GITHUB_REPOSITORY);
586
+ const slug = (await resolveRepoContext(cwd))?.githubSlug;
587
+ if (slug) return parseRepo(slug);
588
+ const result = await git(cwd, [
589
+ "remote",
590
+ "get-url",
591
+ "origin"
592
+ ], { allowFailure: true });
593
+ if (result.ok && result.stdout.trim()) {
594
+ const raw = result.stdout.trim();
595
+ const match = /[:/]([^/]+)\/([^/]+?)(?:\.git)?$/.exec(raw);
596
+ if (match && match[1] && match[2]) return parseRepo(`${match[1]}/${match[2]}`);
597
+ }
598
+ throw new Error("Could not infer GitHub repository from origin remote.");
599
+ }
600
+ //#endregion
601
+ //#region ../trace-core/src/trace-repository-target.ts
602
+ /**
603
+ * The identity of one target on this machine: a short digest of the origin
604
+ * and the repository id. Cache directories and provenance records use it,
605
+ * so a rename keeps its cache and a second origin never shares one.
606
+ */
607
+ function traceTargetKey(target) {
608
+ if (!Number.isSafeInteger(target.repositoryId) || target.repositoryId < 1) throw new Error("The trace repository id must be a positive integer.");
609
+ const origin = normalizeStoreOrigin(target.origin);
610
+ return `${createHash("sha256").update(origin).digest("hex").slice(0, 16)}/r${target.repositoryId}`;
611
+ }
612
+ const cachedTargetSchema = z.object({
613
+ origin: z.string(),
614
+ repositoryId: z.number().int().positive(),
615
+ storeId: z.string().regex(/^[0-9a-f]{32}$/),
616
+ name: z.string().min(1),
617
+ /** The checkout's own `owner/repo` at the time of the lookup. */
618
+ checkout: z.string().min(1),
619
+ savedAt: z.string()
620
+ });
621
+ /** The shared Git directory of a checkout. Sibling worktrees share it. */
622
+ async function gitCommonDirectory(cwd) {
623
+ return (await resolveRepoContext(cwd).catch(() => null))?.commonDir ?? null;
624
+ }
625
+ async function cachedTargetPath(cwd, origin, devHome) {
626
+ const commonDir = await gitCommonDirectory(cwd);
627
+ if (!commonDir) return null;
628
+ const key = createHash("sha256").update(`${origin}\n${commonDir}`).digest("hex");
629
+ return path.join(devHome, "trace", "targets", `${key}.json`);
630
+ }
631
+ /**
632
+ * Saves the target this checkout resolved to, so an offline read can reuse
633
+ * the identity the store confirmed. Tests call it to stand in for a lookup.
634
+ */
635
+ async function rememberTraceRepositoryTarget(input) {
636
+ const devHome = input.devHome ?? devReviewHome();
637
+ const origin = normalizeStoreOrigin(input.target.origin);
638
+ const filePath = await cachedTargetPath(input.cwd, origin, devHome);
639
+ if (!filePath) return;
640
+ await writePrivateJsonAtomic(filePath, {
641
+ ...input.target,
642
+ origin,
643
+ checkout: input.checkout ?? input.target.name,
644
+ savedAt: (/* @__PURE__ */ new Date()).toISOString()
645
+ });
646
+ }
647
+ /**
648
+ * The target this checkout resolved to on an earlier online lookup, or null
649
+ * when none was saved or the saved one names another origin or repository.
650
+ */
651
+ async function readCachedTraceRepositoryTarget(input) {
652
+ const devHome = input.devHome ?? devReviewHome();
653
+ const origin = normalizeStoreOrigin(input.origin);
654
+ const filePath = await cachedTargetPath(input.cwd, origin, devHome);
655
+ if (!filePath) return null;
656
+ let raw;
657
+ try {
658
+ raw = await readFile(filePath, "utf8");
659
+ } catch {
660
+ return null;
661
+ }
662
+ let parsed;
663
+ try {
664
+ const result = cachedTargetSchema.safeParse(JSON.parse(raw));
665
+ if (!result.success) return null;
666
+ parsed = result.data;
667
+ } catch {
668
+ return null;
669
+ }
670
+ if (parsed.origin !== origin) return null;
671
+ const checkout = await inferRepoFromGit(input.cwd).catch(() => null);
672
+ if (!checkout || traceRepoName(checkout).toLowerCase() !== parsed.checkout.toLowerCase()) return null;
673
+ return {
674
+ origin: parsed.origin,
675
+ repositoryId: parsed.repositoryId,
676
+ storeId: parsed.storeId,
677
+ name: parsed.name
678
+ };
679
+ }
680
+ /** Whether a store failure means the store could not answer at all. */
681
+ function isStoreUnreachable(error) {
682
+ return !(error instanceof StoreApiError) || error.status >= 500;
683
+ }
684
+ /**
685
+ * Resolves the checkout to its trace target through the store. A network
686
+ * failure on a read (`write === false`) falls back to the saved target of
687
+ * this checkout and reports `offline: true`. A write never falls back.
688
+ */
689
+ async function resolveTraceRepositoryTarget(input) {
690
+ const origin = normalizeStoreOrigin(input.origin);
691
+ const repo = await inferRepoFromGit(input.cwd);
692
+ const checkout = traceRepoName(repo);
693
+ let store;
694
+ try {
695
+ store = await input.client.findStore({
696
+ owner: repo.owner,
697
+ name: repo.repo
698
+ });
699
+ } catch (error) {
700
+ const cause = error instanceof Error ? error : new Error(String(error));
701
+ if (input.write || !isStoreUnreachable(cause) || cause instanceof StoreApiError && cause.code === "store_deleted") throw cause;
702
+ const cached = await readCachedTraceRepositoryTarget({
703
+ cwd: input.cwd,
704
+ origin,
705
+ devHome: input.devHome
706
+ });
707
+ if (!cached) throw cause;
708
+ return {
709
+ target: cached,
710
+ offline: true
711
+ };
712
+ }
713
+ if (!store) throw new StoreApiError("not_found", 404, `This repository has no active hosted trace store. Run \`whiteboard trace store create\`.`);
714
+ if (store.status !== "active") throw new StoreApiError("store_deleted", 410, "This repository's trace store was deleted.");
715
+ const target = {
716
+ origin,
717
+ repositoryId: store.repositoryId,
718
+ storeId: store.storeId,
719
+ name: store.displayName
720
+ };
721
+ await rememberTraceRepositoryTarget({
722
+ cwd: input.cwd,
723
+ target,
724
+ checkout,
725
+ devHome: input.devHome
726
+ }).catch(() => void 0);
727
+ return {
728
+ target,
729
+ offline: false
730
+ };
731
+ }
732
+ /**
733
+ * The consent entry that covers this target: the repository, allowed at the
734
+ * target's origin. An entry allowed only elsewhere names where.
735
+ */
736
+ async function requireTraceConsent(target, devHome) {
737
+ const entry = (await readTraceUserConfig(devHome)).repositories.find((candidate) => candidate.repositoryId === target.repositoryId);
738
+ if (entry?.enabledOrigins.includes(target.origin)) return entry;
739
+ if (entry) throw new Error(`${entry.name} is allowed to publish traces to ${entry.enabledOrigins.join(", ")}, not ${target.origin}. Run \`whiteboard trace allow .\` while logged in there.`);
740
+ throw new Error(`This repository is not allowed for trace publication. Run \`whiteboard trace allow .\`.`);
741
+ }
742
+ //#endregion
743
+ //#region ../trace-core/src/trace-session-provenance.ts
744
+ /** Records older than this are pruned when a new record is written. */
745
+ const PROVENANCE_RETENTION_MS = 7776e6;
746
+ const provenanceRecordSchema = z.object({
747
+ identity: z.string().min(1),
748
+ allowed: z.boolean(),
749
+ recordedAt: z.string()
750
+ });
751
+ function capturedSessionsDir(devHome = devReviewHome()) {
752
+ return path.join(devHome, "trace", "captured-sessions");
753
+ }
754
+ function sessionDir(sessionId, devHome) {
755
+ return path.join(capturedSessionsDir(devHome), sessionIdSchema.parse(sessionId));
756
+ }
757
+ /**
758
+ * The identity of one checkout for provenance. An allowed checkout is its
759
+ * target key, so sibling worktrees of one repository count as one place. An
760
+ * unallowed checkout is named by its repository, or by its Git directory
761
+ * when it has no GitHub remote.
762
+ */
763
+ function traceCaptureIdentity(input) {
764
+ if ("target" in input) return {
765
+ identity: traceTargetKey(input.target),
766
+ allowed: true
767
+ };
768
+ if ("repositoryName" in input) return {
769
+ identity: `unallowed:${input.repositoryName.toLowerCase()}`,
770
+ allowed: false
771
+ };
772
+ return {
773
+ identity: `unallowed:${input.gitDir}`,
774
+ allowed: false
775
+ };
776
+ }
777
+ /**
778
+ * Writes the record for one session and identity when it does not exist
779
+ * yet. Heartbeats call this often, so an existing record costs one stat.
780
+ */
781
+ async function recordTraceSessionProvenance(input) {
782
+ const devHome = input.devHome ?? devReviewHome();
783
+ const fileName = `${createHash("sha256").update(input.identity).digest("hex")}.json`;
784
+ const filePath = path.join(sessionDir(input.sessionId, devHome), fileName);
785
+ if (await stat(filePath).then(() => true, () => false)) return;
786
+ const record = {
787
+ identity: input.identity,
788
+ allowed: input.allowed,
789
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString()
790
+ };
791
+ await writePrivateJsonAtomic(filePath, record);
792
+ await pruneStaleProvenance(devHome).catch(() => void 0);
793
+ }
794
+ async function pruneStaleProvenance(devHome) {
795
+ const root = capturedSessionsDir(devHome);
796
+ const cutoff = Date.now() - PROVENANCE_RETENTION_MS;
797
+ for (const entry of await readdir(root, { withFileTypes: true })) {
798
+ if (!entry.isDirectory()) continue;
799
+ const dir = path.join(root, entry.name);
800
+ const info = await stat(dir).catch(() => null);
801
+ if (info && info.mtimeMs < cutoff) await rm(dir, {
802
+ recursive: true,
803
+ force: true
804
+ });
805
+ }
806
+ }
807
+ /** Every record the hooks wrote for one session. */
808
+ async function readTraceSessionProvenance(sessionId, devHome) {
809
+ const dir = sessionDir(sessionId, devHome ?? devReviewHome());
810
+ const files = await readdir(dir).catch(() => []);
811
+ const records = [];
812
+ for (const file of files.sort()) {
813
+ if (!file.endsWith(".json")) continue;
814
+ try {
815
+ const parsed = provenanceRecordSchema.safeParse(JSON.parse(await readFile(path.join(dir, file), "utf8")));
816
+ if (parsed.success) records.push(parsed.data);
817
+ } catch {}
818
+ }
819
+ return records;
820
+ }
821
+ var TraceProvenanceError = class extends Error {
822
+ reason;
823
+ constructor(reason, message) {
824
+ super(message);
825
+ this.reason = reason;
826
+ this.name = "TraceProvenanceError";
827
+ }
828
+ };
829
+ /** Require an allowed record and no records from another repository or origin. */
830
+ async function requireTraceSessionProvenance(sessionId, target, devHome) {
831
+ const records = await readTraceSessionProvenance(sessionId, devHome);
832
+ if (records.length === 0) throw new TraceProvenanceError("provenance_missing", `This session was not captured in ${target.name}. Start a new agent session there after \`whiteboard trace allow .\`; a commit trailer does not authorize publication.`);
833
+ const expected = traceTargetKey(target);
834
+ const samePlace = `unallowed:${target.name.toLowerCase()}`;
835
+ if (!(records.some((record) => record.allowed && record.identity === expected) && records.every((record) => record.identity === expected || record.identity === samePlace))) {
836
+ const mixed = records.some((record) => record.identity !== expected && record.identity !== samePlace);
837
+ throw new TraceProvenanceError(mixed ? "provenance_mixed" : "provenance_unapproved", mixed ? `This session also ran in a repository or destination outside ${target.name}. Start a new session in the allowed repository.` : `No hook recorded allowed capture for this session in ${target.name}. If publication is authorized, allow the repository and wait for the next hook before syncing, or start a new session.`);
838
+ }
839
+ }
840
+ //#endregion
841
+ //#region ../trace-core/src/trace-store-transport.ts
842
+ /** An object response failed. Its URL is deliberately not retained. */
843
+ var TraceObjectHttpError = class extends Error {
844
+ status;
845
+ constructor(status, message) {
846
+ super(message);
847
+ this.status = status;
848
+ }
849
+ };
850
+ const DEFAULT_LIMITS = {
851
+ timeoutMs: 3e5,
852
+ maxExpandedBytes: 1073741824
853
+ };
854
+ /**
855
+ * One private directory per process holds every staged upload. Its mode is
856
+ * 0700, so other local accounts cannot list or read staged transcripts. The
857
+ * process removes it on exit; a crash leaves it to the OS temp cleaner.
858
+ */
859
+ let stagingDirectory = null;
860
+ function stagingDir() {
861
+ stagingDirectory ??= (async () => {
862
+ const dir = await mkdtemp(path.join(os.tmpdir(), "review-trace-"));
863
+ await chmod(dir, 448);
864
+ process.once("exit", () => {
865
+ try {
866
+ rmSync(dir, {
867
+ recursive: true,
868
+ force: true
869
+ });
870
+ } catch {}
871
+ });
872
+ return dir;
873
+ })();
874
+ stagingDirectory.catch(() => {
875
+ stagingDirectory = null;
876
+ });
877
+ return stagingDirectory;
878
+ }
879
+ /**
880
+ * Compresses one file into the private staging directory. The size and the
881
+ * digest describe the gzipped bytes, which is what the store signs and checks.
882
+ */
883
+ async function gzipToTemp(sourcePath) {
884
+ const targetPath = path.join(await stagingDir(), `${randomBytes(8).toString("hex")}.jsonl.gz`);
885
+ const hash = createHash("sha256");
886
+ const digestTap = new Transform({ transform(chunk, _encoding, callback) {
887
+ hash.update(chunk);
888
+ callback(null, chunk);
889
+ } });
890
+ try {
891
+ await pipeline(createReadStream(sourcePath), createGzip(), digestTap, createWriteStream(targetPath, {
892
+ flags: "wx",
893
+ mode: 384
894
+ }));
895
+ } catch (cause) {
896
+ await rm(targetPath, { force: true });
897
+ throw cause;
898
+ }
899
+ return {
900
+ path: targetPath,
901
+ size: (await stat(targetPath)).size,
902
+ sha256: hash.digest("hex"),
903
+ cleanup: async () => {
904
+ await rm(targetPath, { force: true });
905
+ }
906
+ };
907
+ }
908
+ /** The transport used at run time. */
909
+ function createHttpTraceStoreTransport(client, fetchImpl = globalThis.fetch, options = {}) {
910
+ const limits = {
911
+ ...DEFAULT_LIMITS,
912
+ ...options.limits
913
+ };
914
+ return {
915
+ beginUpload: (repositoryId, sessionId, body) => client.beginUpload(repositoryId, sessionId, body),
916
+ completeUpload: (repositoryId, sessionId, uploadId, body) => client.completeUpload(repositoryId, sessionId, uploadId, body),
917
+ listSessions: (repositoryId, query) => client.listSessions(repositoryId, query),
918
+ async putObject(upload, filePath) {
919
+ const init = {
920
+ method: "PUT",
921
+ headers: upload.headers,
922
+ body: Readable.toWeb(createReadStream(filePath)),
923
+ duplex: "half",
924
+ signal: AbortSignal.timeout(limits.timeoutMs)
925
+ };
926
+ const response = await fetchImpl(upload.url, init);
927
+ if (response.status === 412) return;
928
+ if (!response.ok) throw new Error(await storageErrorMessage(response, `store ${upload.name}`));
929
+ },
930
+ async getObject(object, destinationPath) {
931
+ const signal = AbortSignal.timeout(limits.timeoutMs);
932
+ const response = await fetchImpl(object.url, {
933
+ method: "GET",
934
+ signal
935
+ });
936
+ if (!response.ok) throw new TraceObjectHttpError(response.status, await storageErrorMessage(response, "read the object"));
937
+ const contentLength = response.headers.get("content-length");
938
+ if (contentLength !== null && Number(contentLength) !== object.size) throw new Error(`The trace store announced ${contentLength} bytes for ${object.name}; the session declared ${object.size}.`);
939
+ if (!response.body) throw new Error(`The trace store sent no body for ${object.name} (HTTP ${response.status}).`);
940
+ await writeVerifiedObject(Readable.fromWeb(response.body, { signal }), object, destinationPath, limits);
941
+ }
942
+ };
943
+ }
944
+ /**
945
+ * Gunzips a compressed stream into `destinationPath` and only keeps the
946
+ * result when the compressed bytes match the declared size and digest. The
947
+ * output lands in a private temp file next to the destination first, so a
948
+ * failed or corrupt transfer never touches an existing file.
949
+ */
950
+ async function writeVerifiedObject(compressed, object, destinationPath, limits) {
951
+ await mkdir(path.dirname(destinationPath), { recursive: true });
952
+ const tempPath = `${destinationPath}.${randomBytes(6).toString("hex")}.tmp`;
953
+ const hash = createHash("sha256");
954
+ let compressedBytes = 0;
955
+ let expandedBytes = 0;
956
+ const compressedTap = new Transform({ transform(chunk, _encoding, callback) {
957
+ compressedBytes += chunk.byteLength;
958
+ if (compressedBytes > object.size) {
959
+ callback(/* @__PURE__ */ new Error(`The trace store sent more than the declared ${object.size} bytes for ${object.name}.`));
960
+ return;
961
+ }
962
+ hash.update(chunk);
963
+ callback(null, chunk);
964
+ } });
965
+ const expansionTap = new Transform({ transform(chunk, _encoding, callback) {
966
+ expandedBytes += chunk.byteLength;
967
+ if (expandedBytes > limits.maxExpandedBytes) {
968
+ callback(/* @__PURE__ */ new Error(`${object.name} expands past the ${limits.maxExpandedBytes} byte limit.`));
969
+ return;
970
+ }
971
+ callback(null, chunk);
972
+ } });
973
+ try {
974
+ await pipeline(compressed, compressedTap, createGunzip(), expansionTap, createWriteStream(tempPath, {
975
+ flags: "wx",
976
+ mode: 384
977
+ }));
978
+ if (compressedBytes !== object.size) throw new Error(`The trace store sent ${compressedBytes} bytes for ${object.name}; the session declared ${object.size}.`);
979
+ if (hash.digest("hex") !== object.sha256) throw new Error(`The bytes of ${object.name} do not match the declared checksum.`);
980
+ await rename(tempPath, destinationPath);
981
+ } catch (cause) {
982
+ await rm(tempPath, { force: true });
983
+ throw cause;
984
+ }
985
+ }
986
+ /**
987
+ * A presigned URL carries credentials, so no message shows it. S3 answers with
988
+ * an XML body whose `Code` names the fault.
989
+ */
990
+ async function storageErrorMessage(response, action) {
991
+ let code = null;
992
+ try {
993
+ code = /<Code>([^<]+)<\/Code>/.exec(await response.text())?.[1] ?? null;
994
+ } catch {
995
+ code = null;
996
+ }
997
+ return code ? `The trace store did not ${action} (HTTP ${response.status}: ${code}).` : `The trace store did not ${action} (HTTP ${response.status}).`;
998
+ }
999
+ //#endregion
1000
+ //#region ../trace-core/src/trace-sync-status.ts
1001
+ const MAX_ERROR_LENGTH = 300;
1002
+ const syncFailureSchema = z.object({
1003
+ session: z.string(),
1004
+ repository: z.string().nullable(),
1005
+ status: z.literal("failed"),
1006
+ error: z.string(),
1007
+ at: z.string(),
1008
+ retry: z.string().optional(),
1009
+ reason: z.enum([
1010
+ "provenance_missing",
1011
+ "provenance_unapproved",
1012
+ "provenance_mixed",
1013
+ "sync_failed"
1014
+ ]).optional()
1015
+ });
1016
+ function traceSyncStatusDir(devHome = devReviewHome()) {
1017
+ return path.join(devHome, "trace", "sync-status");
1018
+ }
1019
+ function statusPath(sessionId, devHome) {
1020
+ return path.join(traceSyncStatusDir(devHome), `${sessionIdSchema.parse(sessionId)}.json`);
1021
+ }
1022
+ /** Strips URLs and credentials from a message and caps its length. */
1023
+ function sanitizeTraceSyncError(message) {
1024
+ const cleaned = message.replace(/https?:\/\/\S+/g, "<url>").replace(/Bearer \S+/g, "Bearer <token>").replace(/X-Amz-\S+/g, "<signature>").replace(/\s+/g, " ").trim();
1025
+ return cleaned.length > MAX_ERROR_LENGTH ? `${cleaned.slice(0, 299)}…` : cleaned;
1026
+ }
1027
+ async function recordTraceSyncFailure(input) {
1028
+ if (!sessionIdSchema.safeParse(input.sessionId).success) return;
1029
+ const record = {
1030
+ session: input.sessionId,
1031
+ repository: input.repository,
1032
+ status: "failed",
1033
+ error: sanitizeTraceSyncError(input.error),
1034
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1035
+ reason: input.reason ?? "sync_failed",
1036
+ retry: input.reason ? void 0 : `whiteboard trace sync ${input.sessionId}`
1037
+ };
1038
+ await writePrivateJsonAtomic(statusPath(input.sessionId, input.devHome ?? devReviewHome()), record);
1039
+ }
1040
+ async function clearTraceSyncFailure(sessionId, devHome) {
1041
+ if (!sessionIdSchema.safeParse(sessionId).success) return;
1042
+ await rm(statusPath(sessionId, devHome ?? devReviewHome()), { force: true });
1043
+ }
1044
+ function describeTraceSyncFailure(failure) {
1045
+ const retry = failure.reason === "sync_failed" && failure.retry ? ` Retry with \`${failure.retry}\`.` : "";
1046
+ return `Failed background sync: session ${failure.session}${failure.repository ? ` of ${failure.repository}` : ""} at ${failure.at}: ${failure.error}${retry}\n`;
1047
+ }
1048
+ async function listTraceSyncFailures(devHome) {
1049
+ const dir = traceSyncStatusDir(devHome ?? devReviewHome());
1050
+ const files = await readdir(dir).catch(() => []);
1051
+ const failures = [];
1052
+ for (const file of files.sort()) {
1053
+ if (!file.endsWith(".json")) continue;
1054
+ try {
1055
+ const parsed = syncFailureSchema.safeParse(JSON.parse(await readFile(path.join(dir, file), "utf8")));
1056
+ if (parsed.success) failures.push(parsed.data);
1057
+ } catch {}
1058
+ }
1059
+ return failures;
1060
+ }
1061
+ //#endregion
1062
+ //#region ../trace-core/src/trace-storage/types.ts
1063
+ /** The store answered with a refusal (forbidden, deleted, bad login). Nothing is served. */
1064
+ var TraceStorageDeniedError = class extends Error {
1065
+ constructor(message) {
1066
+ super(message);
1067
+ this.name = "TraceStorageDeniedError";
1068
+ }
1069
+ };
1070
+ /** The store did not answer; a saved copy may be served, labeled offline. */
1071
+ var TraceStorageUnavailableError = class extends Error {
1072
+ constructor(message) {
1073
+ super(message);
1074
+ this.name = "TraceStorageUnavailableError";
1075
+ }
1076
+ };
1077
+ //#endregion
1078
+ //#region ../trace-core/src/trace-storage/hosted.ts
1079
+ function defaultWarning(message) {
1080
+ process.stderr.write(`${message}\n`);
1081
+ }
1082
+ /**
1083
+ * One line that names why a store read failed, or null when the answer
1084
+ * means the store simply holds nothing for this repository or session.
1085
+ */
1086
+ function storeReadWarning(error) {
1087
+ if (error instanceof StoreApiError) {
1088
+ if (error.code === "not_found") return null;
1089
+ if (error.code === "unauthorized") return `Trace store request failed: unauthorized. Run \`whiteboard login\`.`;
1090
+ if (error.code === "forbidden") return `Trace store request failed: forbidden. ${error.message}`;
1091
+ return `Trace store request failed: ${error.message}`;
1092
+ }
1093
+ return `Trace store request failed: ${error.message}`;
1094
+ }
1095
+ /** The object name the store uses for one trace of a session. */
1096
+ function traceObjectName(traceName) {
1097
+ if (traceName === "main") return "main.jsonl.gz";
1098
+ const base = path.basename(traceName);
1099
+ return `subagents/${base.endsWith(".jsonl") ? base : `${base}.jsonl`}.gz`;
1100
+ }
1101
+ /** The trace name behind one store object name. */
1102
+ function traceNameFromObject(name) {
1103
+ if (name === "main.jsonl.gz") return "main";
1104
+ return name.slice(10, -9);
1105
+ }
1106
+ function contentId(session, sha256) {
1107
+ return `sha256:${sha256}@${session.generation}`;
1108
+ }
1109
+ var HostedTraceStorage = class HostedTraceStorage {
1110
+ kind = "hosted";
1111
+ target;
1112
+ repositoryTarget;
1113
+ offline;
1114
+ transport;
1115
+ devHome;
1116
+ warn;
1117
+ /** One listing per session per instance; an instance lives one operation. */
1118
+ lookups = /* @__PURE__ */ new Map();
1119
+ constructor(parts) {
1120
+ this.repositoryTarget = parts.target;
1121
+ this.target = {
1122
+ kind: "hosted",
1123
+ ...parts.target
1124
+ };
1125
+ this.transport = parts.transport;
1126
+ this.offline = parts.offline ?? false;
1127
+ this.devHome = parts.devHome ?? devReviewHome();
1128
+ this.warn = parts.onWarning ?? defaultWarning;
1129
+ }
1130
+ /** A storage over an already resolved target; tests use a memory transport. */
1131
+ static fromParts(parts) {
1132
+ return new HostedTraceStorage(parts);
1133
+ }
1134
+ /**
1135
+ * The store for this checkout, or null when no store answers for it. A
1136
+ * reader needs no consent entry: a login plus GitHub admin access is enough
1137
+ * and the store enforces that. Without a login, or when the store does
1138
+ * not answer, the target this checkout resolved to earlier serves the
1139
+ * saved copies with `offline: true`. A write never falls back.
1140
+ */
1141
+ static async resolve(input) {
1142
+ const env = input.env ?? process.env;
1143
+ const devHome = devReviewHome(env, input.homeDir);
1144
+ const report = input.onWarning ?? defaultWarning;
1145
+ const origin = input.origin;
1146
+ if (input.write) {
1147
+ let client = input.client;
1148
+ if (!client) {
1149
+ const auth = await readStoreAuth(env);
1150
+ if (!auth || auth.origin !== origin) throw new Error(auth ? `You are logged in to ${auth.origin}, not the selected store ${origin}. Run \`whiteboard login --origin ${origin}\`.` : `The trace store login is missing. Run \`whiteboard login --origin ${origin}\`.`);
1151
+ client = new StoreClient({
1152
+ origin,
1153
+ token: auth.token
1154
+ });
1155
+ }
1156
+ const { target } = await resolveTraceRepositoryTarget({
1157
+ cwd: input.cwd,
1158
+ origin,
1159
+ client,
1160
+ write: true,
1161
+ devHome
1162
+ });
1163
+ return new HostedTraceStorage({
1164
+ target,
1165
+ transport: input.transport ?? createHttpTraceStoreTransport(client),
1166
+ devHome,
1167
+ onWarning: input.onWarning
1168
+ });
1169
+ }
1170
+ const auth = await readStoreAuth(env);
1171
+ const client = input.client ?? (auth && auth.origin === origin ? new StoreClient({
1172
+ origin,
1173
+ token: auth.token
1174
+ }) : void 0);
1175
+ if (!client) {
1176
+ if (auth && auth.origin !== origin) report(`You are logged in to ${auth.origin}, not the selected store ${origin}. Run \`whiteboard login --origin ${origin}\`; using saved copies until then.`);
1177
+ const cached = await readCachedTraceRepositoryTarget({
1178
+ cwd: input.cwd,
1179
+ origin,
1180
+ devHome
1181
+ }).catch(() => null);
1182
+ if (!cached) return null;
1183
+ return new HostedTraceStorage({
1184
+ target: cached,
1185
+ transport: input.transport ?? createHttpTraceStoreTransport(new StoreClient({ origin })),
1186
+ devHome,
1187
+ offline: true,
1188
+ onWarning: input.onWarning
1189
+ });
1190
+ }
1191
+ try {
1192
+ const { target, offline } = await resolveTraceRepositoryTarget({
1193
+ cwd: input.cwd,
1194
+ origin,
1195
+ client,
1196
+ write: false,
1197
+ devHome
1198
+ });
1199
+ if (offline) report("The trace store did not answer. Using the saved copies of this repository.");
1200
+ return new HostedTraceStorage({
1201
+ target,
1202
+ transport: input.transport ?? createHttpTraceStoreTransport(client),
1203
+ devHome,
1204
+ offline,
1205
+ onWarning: input.onWarning
1206
+ });
1207
+ } catch (error) {
1208
+ const cause = error instanceof Error ? error : new Error(String(error));
1209
+ if (cause instanceof StoreApiError && (cause.code === "unauthorized" || cause.code === "forbidden" || cause.code === "store_deleted")) throw new TraceStorageDeniedError(cause.message);
1210
+ const message = cause instanceof StoreApiError && cause.code === "not_found" ? cause.message : storeReadWarning(cause);
1211
+ if (message) report(message);
1212
+ return null;
1213
+ }
1214
+ }
1215
+ cacheIdentity() {
1216
+ return `hosted:${traceTargetKey(this.repositoryTarget)}:${this.repositoryTarget.storeId}`;
1217
+ }
1218
+ cacheScope() {
1219
+ const [originKey, repositoryKey] = traceTargetKey(this.repositoryTarget).split("/");
1220
+ return {
1221
+ owner: originKey,
1222
+ repo: repositoryKey
1223
+ };
1224
+ }
1225
+ async readiness() {
1226
+ if (this.offline) return {
1227
+ ready: false,
1228
+ reason: "The trace store did not answer. Saved copies are served offline."
1229
+ };
1230
+ return { ready: true };
1231
+ }
1232
+ async describeObject(sessionId, traceName) {
1233
+ const stored = await this.requireSession(sessionId);
1234
+ if (!stored) return null;
1235
+ const object = stored.objects.find((entry) => entry.name === traceObjectName(traceName));
1236
+ if (!object) return null;
1237
+ return {
1238
+ size: object.size,
1239
+ contentId: contentId(stored, object.sha256)
1240
+ };
1241
+ }
1242
+ async downloadObject(sessionId, traceName, destinationPath) {
1243
+ const stored = await this.requireSession(sessionId);
1244
+ if (!stored) return null;
1245
+ const object = stored.objects.find((entry) => entry.name === traceObjectName(traceName));
1246
+ if (!object) return null;
1247
+ let selected = object;
1248
+ let refreshed = false;
1249
+ const refresh = async () => {
1250
+ refreshed = true;
1251
+ this.lookups.delete(sessionId);
1252
+ const fresh = await this.requireSession(sessionId);
1253
+ const replacement = fresh?.objects.find((entry) => entry.name === object.name);
1254
+ if (!fresh || fresh.uploadId !== stored.uploadId || !replacement || replacement.sha256 !== object.sha256 || replacement.size !== object.size) throw new TraceStorageDeniedError("The trace changed while downloading. Run the read command again.");
1255
+ return replacement;
1256
+ };
1257
+ if (Date.parse(selected.expiresAt) <= Date.now() + 3e4) selected = await refresh();
1258
+ try {
1259
+ await this.transport.getObject(selected, destinationPath);
1260
+ } catch (error) {
1261
+ if (!(error instanceof TraceObjectHttpError) || error.status !== 403) throw error;
1262
+ if (refreshed) throw new TraceStorageDeniedError(error.message);
1263
+ selected = await refresh();
1264
+ try {
1265
+ await this.transport.getObject(selected, destinationPath);
1266
+ } catch (retryError) {
1267
+ if (retryError instanceof TraceObjectHttpError && retryError.status === 403) throw new TraceStorageDeniedError(retryError.message);
1268
+ throw retryError;
1269
+ }
1270
+ }
1271
+ return {
1272
+ size: object.size,
1273
+ contentId: contentId(stored, object.sha256)
1274
+ };
1275
+ }
1276
+ async listSubagents(sessionId) {
1277
+ const lookup = await this.lookupSession(sessionId);
1278
+ if (lookup.status !== "found") return [];
1279
+ return lookup.session.objects.filter((object) => object.name !== "main.jsonl.gz").map((object) => traceNameFromObject(object.name)).sort();
1280
+ }
1281
+ /**
1282
+ * The store's record of one session, in the shape the bucket's meta.json
1283
+ * has: repository, commits, branch, author, and last update. The store
1284
+ * keeps no pull request number, so that stays null.
1285
+ */
1286
+ async sessionMeta(sessionId) {
1287
+ const stored = await reachableSession(() => this.requireSession(sessionId));
1288
+ if (!stored) return null;
1289
+ return {
1290
+ session: stored.sessionId,
1291
+ repo: this.repositoryTarget.name,
1292
+ branch: stored.branch ?? null,
1293
+ pr: null,
1294
+ commits: [...stored.commits],
1295
+ author: stored.author ?? null,
1296
+ ts: stored.updatedAt
1297
+ };
1298
+ }
1299
+ async sessionsForCommit(commit) {
1300
+ if (this.offline) return null;
1301
+ if (!commitShaSchema.safeParse(commit).success) return null;
1302
+ try {
1303
+ const sessions = [];
1304
+ let cursor;
1305
+ for (let page = 0; page < MAX_COMMIT_LISTING_PAGES; page += 1) {
1306
+ const response = await this.transport.listSessions(this.repositoryTarget.repositoryId, cursor === void 0 ? { commit } : {
1307
+ commit,
1308
+ cursor
1309
+ });
1310
+ sessions.push(...response.sessions.map((session) => session.sessionId));
1311
+ if (!response.nextCursor) break;
1312
+ cursor = response.nextCursor;
1313
+ }
1314
+ return sessions.length > 0 ? {
1315
+ sessions,
1316
+ pr: null,
1317
+ branch: null
1318
+ } : null;
1319
+ } catch (error) {
1320
+ this.reportFailure(error instanceof Error ? error : new Error(String(error)));
1321
+ return null;
1322
+ }
1323
+ }
1324
+ /**
1325
+ * Publishes one stable session snapshot. Consent and provenance are
1326
+ * settled before any transcript is compressed, so nothing is sent for a
1327
+ * session that may not leave the machine.
1328
+ */
1329
+ async publish(input) {
1330
+ const target = this.repositoryTarget;
1331
+ const expected = traceRepoName(input.repo);
1332
+ if (expected.toLowerCase() !== target.name.toLowerCase()) throw new Error(`This checkout is ${target.name}, not ${expected}. A session is published only to its own repository's store.`);
1333
+ await requireTraceConsent(target, this.devHome);
1334
+ await requireTraceSessionProvenance(input.sessionId, target, this.devHome);
1335
+ const files = [];
1336
+ const omittedSubagents = [];
1337
+ for (const file of input.files) {
1338
+ const name = traceObjectName(file.name);
1339
+ if (file.name !== "main" && (files.length >= 64 || !traceObjectNameSchema.safeParse(name).success)) {
1340
+ omittedSubagents.push(file.name.replace(/\.jsonl$/, ""));
1341
+ continue;
1342
+ }
1343
+ files.push({
1344
+ name,
1345
+ path: file.path
1346
+ });
1347
+ }
1348
+ const compressed = [];
1349
+ try {
1350
+ for (const file of files) {
1351
+ const gzipped = await gzipToTemp(file.path);
1352
+ compressed.push({
1353
+ name: file.name,
1354
+ ...gzipped
1355
+ });
1356
+ }
1357
+ const manifest = compressed.map((object) => ({
1358
+ name: object.name,
1359
+ size: object.size,
1360
+ sha256: object.sha256
1361
+ }));
1362
+ const allCommits = [...new Set(input.commits?.filter((commit) => commitShaSchema.safeParse(commit).success) ?? await commitsForTraceSession(input.cwd, input.sessionId))];
1363
+ const commits = allCommits.slice(0, 200);
1364
+ const omitted = {
1365
+ subagents: omittedSubagents,
1366
+ commits: allCommits.length - commits.length
1367
+ };
1368
+ const labels = {
1369
+ branch: input.branch,
1370
+ author: input.author
1371
+ };
1372
+ this.lookups.delete(input.sessionId);
1373
+ const current = await this.lookupSessionLive(input.sessionId, true);
1374
+ if (current.status === "found" && sameObjects(manifest, current.session.objects)) {
1375
+ const completed = await this.completeUploadOnce(input.sessionId, current.session.uploadId, commits, labels);
1376
+ this.lookups.delete(input.sessionId);
1377
+ await clearTraceSyncFailure(input.sessionId, this.devHome).catch(() => void 0);
1378
+ return publishResult(compressed, "unchanged", target, completed, omitted);
1379
+ }
1380
+ const begun = await this.transport.beginUpload(target.repositoryId, input.sessionId, {
1381
+ harness: input.harness,
1382
+ objects: manifest
1383
+ });
1384
+ if (begun.storeId !== target.storeId) throw new Error(`The trace store changed while this session was being resolved. Run \`whiteboard trace allow .\` again.`);
1385
+ const mismatch = uploadManifestMismatch(manifest, begun.uploads);
1386
+ if (mismatch) throw new Error(mismatch);
1387
+ for (const upload of begun.uploads) {
1388
+ const object = compressed.find((entry) => entry.name === upload.name);
1389
+ if (!object) throw new Error(`The store offered ${upload.name} twice.`);
1390
+ await this.transport.putObject(upload, object.path);
1391
+ }
1392
+ const completed = await this.completeUploadOnce(input.sessionId, begun.uploadId, commits, labels);
1393
+ this.lookups.delete(input.sessionId);
1394
+ await clearTraceSyncFailure(input.sessionId, this.devHome).catch(() => void 0);
1395
+ return publishResult(compressed, "uploaded", target, completed, omitted);
1396
+ } finally {
1397
+ for (const object of compressed) await object.cleanup();
1398
+ }
1399
+ }
1400
+ /** Commits travel with the published snapshot; there is no separate index. */
1401
+ async associateCommits(_input) {
1402
+ return false;
1403
+ }
1404
+ async requireSession(sessionId) {
1405
+ const lookup = await this.lookupSession(sessionId);
1406
+ if (lookup.status === "unreachable") throw new TraceStorageUnavailableError(lookup.error?.message ?? "The trace store did not answer.");
1407
+ if (lookup.status === "denied") throw new TraceStorageDeniedError(lookup.error.message);
1408
+ return lookup.status === "found" ? lookup.session : null;
1409
+ }
1410
+ lookupSession(sessionId) {
1411
+ const pending = this.lookups.get(sessionId);
1412
+ if (pending) return pending;
1413
+ const lookup = this.lookupSessionLive(sessionId);
1414
+ this.lookups.set(sessionId, lookup);
1415
+ return lookup;
1416
+ }
1417
+ async lookupSessionLive(sessionId, publishing = false) {
1418
+ if (this.offline) return {
1419
+ status: "unreachable",
1420
+ error: null
1421
+ };
1422
+ try {
1423
+ const session = (await this.transport.listSessions(this.repositoryTarget.repositoryId, { session: sessionId })).sessions.find((candidate) => candidate.sessionId === sessionId);
1424
+ return session ? {
1425
+ status: "found",
1426
+ session
1427
+ } : { status: "absent" };
1428
+ } catch (error) {
1429
+ const cause = error instanceof Error ? error : new Error(String(error));
1430
+ if (!(publishing && cause instanceof StoreApiError && cause.code === "forbidden")) this.reportFailure(cause);
1431
+ if (isStoreUnreachable(cause)) return {
1432
+ status: "unreachable",
1433
+ error: cause
1434
+ };
1435
+ if (cause instanceof StoreApiError && cause.code === "not_found") return { status: "absent" };
1436
+ return {
1437
+ status: "denied",
1438
+ error: cause
1439
+ };
1440
+ }
1441
+ }
1442
+ /**
1443
+ * Completes the upload, and once more when the first answer was lost on
1444
+ * the network: completion is idempotent, so a repeat returns the same
1445
+ * receipt. A store answer is never retried; `stale_upload` means another
1446
+ * upload of this session published first.
1447
+ */
1448
+ async completeUploadOnce(sessionId, uploadId, commits, labels) {
1449
+ const complete = () => this.transport.completeUpload(this.repositoryTarget.repositoryId, sessionId, uploadId, {
1450
+ commits,
1451
+ branch: labels.branch,
1452
+ author: labels.author
1453
+ });
1454
+ try {
1455
+ return await complete();
1456
+ } catch (error) {
1457
+ if (error instanceof StoreApiError) throw syncStoreError(error, sessionId);
1458
+ try {
1459
+ return await complete();
1460
+ } catch (retryError) {
1461
+ throw retryError instanceof StoreApiError ? syncStoreError(retryError, sessionId) : retryError;
1462
+ }
1463
+ }
1464
+ }
1465
+ reportFailure(cause) {
1466
+ const message = storeReadWarning(cause);
1467
+ if (message) this.warn(message);
1468
+ }
1469
+ };
1470
+ /** A session lookup, or null when the store could not be reached or refused. */
1471
+ async function reachableSession(lookup) {
1472
+ try {
1473
+ return await lookup();
1474
+ } catch (error) {
1475
+ if (error instanceof TraceStorageUnavailableError || error instanceof TraceStorageDeniedError) return null;
1476
+ throw error;
1477
+ }
1478
+ }
1479
+ /** Most listing pages one commit lookup walks. */
1480
+ const MAX_COMMIT_LISTING_PAGES = 10;
1481
+ /** Whether the manifest names exactly the stored objects, byte for byte. */
1482
+ function sameObjects(manifest, stored) {
1483
+ if (manifest.length !== stored.length) return false;
1484
+ return manifest.every((object) => stored.some((candidate) => candidate.name === object.name && candidate.size === object.size && candidate.sha256 === object.sha256));
1485
+ }
1486
+ function publishResult(compressed, status, target, completed, omitted) {
1487
+ return {
1488
+ uploads: compressed.map((object) => ({
1489
+ blob: object.name === "main.jsonl.gz" ? "trace.jsonl" : `subagents/${traceNameFromObject(object.name)}.jsonl`,
1490
+ bytes_stored: object.size,
1491
+ status
1492
+ })),
1493
+ hosted: {
1494
+ repositoryId: target.repositoryId,
1495
+ storeId: target.storeId,
1496
+ uploadId: completed.uploadId,
1497
+ generation: completed.generation,
1498
+ complete: omitted.subagents.length === 0 && omitted.commits === 0,
1499
+ objects: completed.objects.map((object) => object.name),
1500
+ commits: completed.commits,
1501
+ omitted
1502
+ }
1503
+ };
1504
+ }
1505
+ function syncStoreError(error, sessionId) {
1506
+ if (error.code === "stale_upload") return /* @__PURE__ */ new Error(`Another upload of this session finished first. Run \`whiteboard trace sync ${sessionId}\` again to publish the newer transcript.`);
1507
+ return error;
1508
+ }
1509
+ const FIELD_SEPARATOR$1 = "";
1510
+ /** The commits whose Agent-Session trailers name this session. */
1511
+ async function commitsForTraceSession(cwd, sessionId) {
1512
+ const result = await git(cwd, [
1513
+ "log",
1514
+ "--all",
1515
+ "--no-show-signature",
1516
+ `--format=%H${FIELD_SEPARATOR$1}%(trailers:key=Agent-Session,valueonly,separator=${FIELD_SEPARATOR$1})`
1517
+ ], { allowFailure: true });
1518
+ if (!result.ok) return [];
1519
+ const commits = [];
1520
+ for (const line of result.stdout.split("\n")) {
1521
+ const [sha, ...trailers] = line.trim().split(FIELD_SEPARATOR$1);
1522
+ if (!commitShaSchema.safeParse(sha).success) continue;
1523
+ if (trailers.flatMap((value) => value.split("\n")).some((value) => value.trim() === sessionId) && !commits.includes(sha)) commits.push(sha);
1524
+ }
1525
+ return commits;
1526
+ }
1527
+ //#endregion
1528
+ //#region ../trace-core/src/trace-storage/resolve.ts
1529
+ function selectTraceStorage(scope = {}) {
1530
+ const env = scope.env ?? process.env;
1531
+ const config = readTraceConfigFile(scope);
1532
+ if (config.error) return {
1533
+ mode: "none",
1534
+ explicit: false,
1535
+ config,
1536
+ s3: null,
1537
+ hosted: null,
1538
+ error: config.error
1539
+ };
1540
+ let s3;
1541
+ try {
1542
+ s3 = resolveS3Setup(scope);
1543
+ } catch (error) {
1544
+ return {
1545
+ mode: "none",
1546
+ explicit: false,
1547
+ config,
1548
+ s3: null,
1549
+ hosted: null,
1550
+ error: errorMessage(error)
1551
+ };
1552
+ }
1553
+ const mock = isS3MockMode(env);
1554
+ const file = config.config;
1555
+ const pointer = currentStore(file);
1556
+ const hosted = hasHostedStore(file) ? { origin: hostedOrigin(file) } : null;
1557
+ const hasS3 = s3Store(file) !== null || s3.credentials !== null || mock;
1558
+ if (pointer === "hosted") return {
1559
+ mode: "hosted",
1560
+ explicit: true,
1561
+ config,
1562
+ s3,
1563
+ hosted: hosted ?? { origin: hostedOrigin(file) }
1564
+ };
1565
+ if (pointer === "s3") {
1566
+ if (!hasS3) return {
1567
+ mode: "s3",
1568
+ explicit: true,
1569
+ config,
1570
+ s3,
1571
+ hosted,
1572
+ error: `S3 trace storage is selected but no bucket credentials are configured. Run \`whiteboard trace storage use s3 --endpoint <url> --bucket <name> --key <id> --secret <secret>\`.`
1573
+ };
1574
+ return {
1575
+ mode: "s3",
1576
+ explicit: true,
1577
+ config,
1578
+ s3,
1579
+ hosted
1580
+ };
1581
+ }
1582
+ if (hasS3) return {
1583
+ mode: "s3",
1584
+ explicit: false,
1585
+ config,
1586
+ s3,
1587
+ hosted
1588
+ };
1589
+ if (hosted) return {
1590
+ mode: "hosted",
1591
+ explicit: false,
1592
+ config,
1593
+ s3,
1594
+ hosted
1595
+ };
1596
+ return {
1597
+ mode: "none",
1598
+ explicit: false,
1599
+ config,
1600
+ s3,
1601
+ hosted
1602
+ };
1603
+ }
1604
+ /**
1605
+ * The store to use, or null when no remote storage is configured. Throws a
1606
+ * TraceConfigurationError instead of silently selecting another destination
1607
+ * when the configuration is malformed or incomplete.
1608
+ */
1609
+ async function resolveTraceStorage(input = {}) {
1610
+ const selection = selectTraceStorage(input);
1611
+ if (selection.error) throw new TraceConfigurationError(selection.error);
1612
+ const mode = input.override ?? selection.mode;
1613
+ if (mode === "none") return null;
1614
+ if (mode === "s3") return s3Storage(selection, input);
1615
+ return hostedStorage(selection, input);
1616
+ }
1617
+ /**
1618
+ * Names the destination a capture attempt was started for, so a detached
1619
+ * trace sync can refuse to run once the selection changed.
1620
+ */
1621
+ function traceStorageExpectation(scope = {}) {
1622
+ const selection = selectTraceStorage(scope);
1623
+ if (selection.error || selection.mode === "none") return "none";
1624
+ if (selection.mode === "hosted") return `hosted:${selection.hosted?.origin ?? ""}`;
1625
+ const env = scope.env ?? process.env;
1626
+ if (isS3MockMode(env)) return `s3:${s3CacheIdentity(null, s3MockRoot(env))}`;
1627
+ return `s3:${s3CacheIdentity(requireS3Credentials(selection), null)}`;
1628
+ }
1629
+ function requireS3Credentials(selection) {
1630
+ const credentials = selection.s3?.credentials;
1631
+ if (!credentials) throw new TraceConfigurationError("S3 trace storage was requested but no bucket credentials are configured.");
1632
+ return credentials;
1633
+ }
1634
+ /** One human line for `trace status` and `trace storage use`. */
1635
+ function describeSelection(selection) {
1636
+ if (selection.error) return `error (${selection.error})`;
1637
+ if (selection.mode === "hosted") return `hosted (${selection.hosted?.origin ?? "unknown origin"})`;
1638
+ if (selection.mode === "none") return "none configured";
1639
+ const setup = selection.s3;
1640
+ const credentials = setup?.credentials;
1641
+ const where = credentials ? `bucket "${credentials.bucket}" at ${credentials.endpoint}` : "mock bucket";
1642
+ const source = setup?.source === "profile" ? "config.json profile" : setup?.source === "legacy-file" ? `legacy env file ${setup.envPath}` : setup?.source === "process-env" ? "process environment" : "test mode";
1643
+ const overrides = setup && setup.overrides.length > 0 && setup.source !== "process-env" ? `; environment overrides: ${setup.overrides.join(", ")}` : "";
1644
+ return `S3/R2 ${where} (${selection.explicit ? "selected" : "legacy configuration"}; credentials from ${source}${overrides})`;
1645
+ }
1646
+ /** The bucket store; its module loads only when a bucket is selected. */
1647
+ async function s3Storage(selection, scope) {
1648
+ const env = scope.env ?? process.env;
1649
+ const S3TraceStorage = await loadS3TraceStorage();
1650
+ if (isS3MockMode(env)) return S3TraceStorage.fromEnvironment(scope);
1651
+ return S3TraceStorage.fromCredentials(requireS3Credentials(selection), env);
1652
+ }
1653
+ async function hostedStorage(selection, input) {
1654
+ const origin = selection.hosted?.origin;
1655
+ if (!origin) throw new TraceConfigurationError(`Hosted trace storage is not configured. Run \`whiteboard trace allow .\` or \`whiteboard trace storage use hosted\`.`);
1656
+ const write = input.purpose === "write";
1657
+ const storage = await HostedTraceStorage.resolve({
1658
+ cwd: input.cwd ?? process.cwd(),
1659
+ origin,
1660
+ write,
1661
+ env: input.env,
1662
+ homeDir: input.homeDir,
1663
+ onWarning: input.onWarning
1664
+ });
1665
+ if (!storage) throw new TraceConfigurationError(`Hosted trace storage needs a login and a GitHub checkout with a trace store. Run \`whiteboard login\`, \`whiteboard trace store create\`, and \`whiteboard trace allow .\`.`);
1666
+ return storage;
1667
+ }
1668
+ /** Whether the selected store can be used; a configuration error counts as no. */
1669
+ function isTraceStorageConfigured(scope = {}) {
1670
+ const selection = selectTraceStorage(scope);
1671
+ return !selection.error && selection.mode !== "none";
1672
+ }
1673
+ /** Loads the direct-store implementation on demand, without opening a store. */
1674
+ async function loadS3TraceStorage() {
1675
+ return (await import("./s3-SzLpeCG6.js")).S3TraceStorage;
1676
+ }
1677
+ //#endregion
1678
+ //#region ../trace-core/src/agent-trace-parser.ts
1679
+ function extractTraceEventText(event) {
1680
+ return extractTraceEventText$1(event);
1681
+ }
1682
+ const ACTIVE_GAP_LIMIT_MS = 6e5;
1683
+ function computeActiveMs(events) {
1684
+ let total = 0;
1685
+ let previous = null;
1686
+ let sawTimestamp = false;
1687
+ for (const event of events) {
1688
+ const at = timestampOf(event);
1689
+ if (at === null) continue;
1690
+ sawTimestamp = true;
1691
+ if (previous !== null && at > previous) total += Math.min(at - previous, ACTIVE_GAP_LIMIT_MS);
1692
+ previous = at;
1693
+ }
1694
+ return sawTimestamp ? total : null;
1695
+ }
1696
+ const OUTPUT_LIMIT = 2e4;
1697
+ const INPUT_LIMIT = 4e3;
1698
+ const TITLE_LIMIT = 160;
1699
+ function parseAgentTraceJsonl(jsonl, options) {
1700
+ const records = [];
1701
+ for (const line of jsonl.split("\n")) {
1702
+ const trimmed = line.trim();
1703
+ if (!trimmed) continue;
1704
+ try {
1705
+ records.push(parseJsonText(trimmed));
1706
+ } catch {}
1707
+ }
1708
+ const firstType = jsonObject(records[0])?.type;
1709
+ const parsed = firstType === "session_meta" ? parseCodexRecords(records) : firstType === "session" ? parsePiRecords(records) : firstType === "opencode_session" ? parseOpenCodeRecords(records) : parseClaudeRecords(records, options);
1710
+ parsed.activeMs = computeActiveMs(parsed.events);
1711
+ return parsed;
1712
+ }
1713
+ function truncate(text, limit) {
1714
+ if (text.length <= limit) return text;
1715
+ const half = Math.floor(limit / 2);
1716
+ return `${text.slice(0, half)}\n… (+${text.length - limit} characters omitted) …\n${text.slice(-half)}`;
1717
+ }
1718
+ function compactLine(text, limit = TITLE_LIMIT) {
1719
+ const collapsed = text.replace(/\s+/g, " ").trim();
1720
+ if (collapsed.length <= limit) return collapsed;
1721
+ return `${collapsed.slice(0, limit - 1)}…`;
1722
+ }
1723
+ /** File titles keep their tail: the filename matters more than the prefix. */
1724
+ function compactFileLine(text, limit = TITLE_LIMIT) {
1725
+ const collapsed = text.replace(/\s+/g, " ").trim();
1726
+ if (collapsed.length <= limit) return collapsed;
1727
+ return `…${collapsed.slice(-(limit - 1))}`;
1728
+ }
1729
+ function relativizePath(filePath, cwd) {
1730
+ if (!cwd) return filePath;
1731
+ const relative = path.relative(cwd, filePath);
1732
+ if (!relative || relative.startsWith("..")) return filePath;
1733
+ return relative;
1734
+ }
1735
+ function timestampOf(event) {
1736
+ if (!event || event.kind === "separator" || !event.at) return null;
1737
+ const value = Date.parse(event.at);
1738
+ return Number.isFinite(value) ? value : null;
1739
+ }
1740
+ function claudeRecord(value) {
1741
+ const message = jsonObject(value.message);
1742
+ const toolUseResult = jsonObject(value.toolUseResult);
1743
+ return {
1744
+ type: jsonString(value.type),
1745
+ isSidechain: jsonBoolean(value.isSidechain),
1746
+ isMeta: jsonBoolean(value.isMeta),
1747
+ timestamp: jsonString(value.timestamp),
1748
+ cwd: jsonString(value.cwd),
1749
+ aiTitle: jsonString(value.aiTitle),
1750
+ customTitle: jsonString(value.customTitle),
1751
+ message: message && {
1752
+ role: jsonString(message.role),
1753
+ content: message.content
1754
+ },
1755
+ toolUseResult: toolUseResult && {
1756
+ filePath: jsonString(toolUseResult.filePath),
1757
+ structuredPatch: toolUseResult.structuredPatch
1758
+ }
1759
+ };
1760
+ }
1761
+ function claudeContentBlock(value) {
1762
+ if (!isJsonObject(value)) return void 0;
1763
+ return {
1764
+ type: jsonString(value.type),
1765
+ text: jsonString(value.text),
1766
+ thinking: jsonString(value.thinking),
1767
+ id: jsonString(value.id),
1768
+ name: jsonString(value.name),
1769
+ input: value.input,
1770
+ tool_use_id: jsonString(value.tool_use_id),
1771
+ content: value.content,
1772
+ is_error: jsonBoolean(value.is_error)
1773
+ };
1774
+ }
1775
+ function claudeContentBlocks(content) {
1776
+ const text = jsonString(content);
1777
+ if (text !== void 0) return [{
1778
+ type: "text",
1779
+ text
1780
+ }];
1781
+ return jsonArray(content)?.flatMap((block) => claudeContentBlock(block) ?? []) ?? [];
1782
+ }
1783
+ const CLAUDE_NOISE_PATTERNS = [
1784
+ /^\[SYSTEM NOTIFICATION/,
1785
+ /^\[Request interrupted/,
1786
+ /^This session is being continued/,
1787
+ /^<task-notification>/,
1788
+ /^<local-command-stdout>/
1789
+ ];
1790
+ function cleanClaudeUserText(text) {
1791
+ let cleaned = text;
1792
+ cleaned = cleaned.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "");
1793
+ cleaned = cleaned.replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/g, "");
1794
+ const commandName = /<command-name>([\s\S]*?)<\/command-name>/.exec(cleaned)?.[1];
1795
+ if (commandName) {
1796
+ const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(cleaned)?.[1] ?? "";
1797
+ return `${commandName.trim().startsWith("/") ? "" : "/"}${commandName.trim()} ${args.trim()}`.trim();
1798
+ }
1799
+ cleaned = cleaned.trim();
1800
+ if (!cleaned) return null;
1801
+ for (const pattern of CLAUDE_NOISE_PATTERNS) if (pattern.test(cleaned)) return null;
1802
+ return cleaned;
1803
+ }
1804
+ function claudeResultText(content) {
1805
+ const text = jsonString(content);
1806
+ if (text !== void 0) return text;
1807
+ const parts = [];
1808
+ for (const block of claudeContentBlocks(content)) if (block.type === "text" && block.text !== void 0) parts.push(block.text);
1809
+ else if (block.type === "image") parts.push("[image]");
1810
+ return parts.join("\n");
1811
+ }
1812
+ function patchCounts(patch) {
1813
+ const hunks = jsonArray(patch);
1814
+ if (!hunks) return null;
1815
+ let additions = 0;
1816
+ let deletions = 0;
1817
+ for (const hunk of hunks) for (const line of jsonArray(jsonObject(hunk)?.lines) ?? []) {
1818
+ const text = jsonString(line);
1819
+ if (text === void 0) continue;
1820
+ if (text.startsWith("+")) additions += 1;
1821
+ else if (text.startsWith("-")) deletions += 1;
1822
+ }
1823
+ return {
1824
+ additions,
1825
+ deletions
1826
+ };
1827
+ }
1828
+ function claudeToolEvent(block, at, cwd) {
1829
+ const name = block.name ?? "tool";
1830
+ const input = isJsonObject(block.input) ? block.input : {};
1831
+ const event = {
1832
+ kind: "tool",
1833
+ tool: name,
1834
+ verb: "Called",
1835
+ title: name,
1836
+ at
1837
+ };
1838
+ const inputText = (key) => jsonString(input[key]) ?? null;
1839
+ const filePath = inputText("file_path") ?? inputText("path") ?? inputText("notebook_path");
1840
+ switch (name) {
1841
+ case "Bash": {
1842
+ const command = inputText("command") ?? "";
1843
+ event.verb = "Ran";
1844
+ event.title = compactLine(command);
1845
+ event.command = truncate(command, INPUT_LIMIT);
1846
+ break;
1847
+ }
1848
+ case "Edit":
1849
+ case "MultiEdit":
1850
+ case "NotebookEdit":
1851
+ event.verb = "Edited";
1852
+ event.title = filePath ? relativizePath(filePath, cwd) : name;
1853
+ if (filePath) event.filePath = relativizePath(filePath, cwd);
1854
+ break;
1855
+ case "Write":
1856
+ event.verb = "Wrote";
1857
+ event.title = filePath ? relativizePath(filePath, cwd) : name;
1858
+ if (filePath) event.filePath = relativizePath(filePath, cwd);
1859
+ break;
1860
+ case "Read":
1861
+ event.verb = "Read";
1862
+ event.title = filePath ? relativizePath(filePath, cwd) : name;
1863
+ if (filePath) event.filePath = relativizePath(filePath, cwd);
1864
+ break;
1865
+ case "Grep":
1866
+ case "Glob": {
1867
+ const pattern = inputText("pattern") ?? "";
1868
+ event.verb = "Searched";
1869
+ event.title = compactLine(inputText("path") ? `${pattern} in ${relativizePath(inputText("path") ?? "", cwd)}` : pattern);
1870
+ break;
1871
+ }
1872
+ case "Task":
1873
+ case "Agent":
1874
+ event.verb = "Ran agent";
1875
+ event.title = compactLine(inputText("description") ?? inputText("prompt") ?? "subagent");
1876
+ break;
1877
+ case "WebFetch":
1878
+ event.verb = "Fetched";
1879
+ event.title = compactLine(inputText("url") ?? "");
1880
+ break;
1881
+ case "WebSearch":
1882
+ event.verb = "Searched web";
1883
+ event.title = compactLine(inputText("query") ?? "");
1884
+ break;
1885
+ case "TodoWrite":
1886
+ event.verb = "Updated";
1887
+ event.title = "task list";
1888
+ break;
1889
+ case "Skill":
1890
+ event.verb = "Loaded skill";
1891
+ event.title = compactLine(inputText("skill") ?? "");
1892
+ break;
1893
+ default: if (name.startsWith("mcp__")) {
1894
+ event.verb = "Called";
1895
+ event.title = name.replace(/^mcp__/, "").replace(/__/g, " · ");
1896
+ }
1897
+ }
1898
+ if (!event.command) {
1899
+ const pretty = JSON.stringify(input, null, 2);
1900
+ if (pretty && pretty !== "{}") event.input = truncate(pretty, INPUT_LIMIT);
1901
+ }
1902
+ return event;
1903
+ }
1904
+ function parseClaudeRecords(records, options) {
1905
+ const events = [];
1906
+ const pendingTools = /* @__PURE__ */ new Map();
1907
+ let title = null;
1908
+ let startedAt = null;
1909
+ let endedAt = null;
1910
+ let cwd = null;
1911
+ let userTurns = 0;
1912
+ let toolCalls = 0;
1913
+ for (const raw of records) {
1914
+ if (!isJsonObject(raw)) continue;
1915
+ const record = claudeRecord(raw);
1916
+ if (record.type === "ai-title" && record.aiTitle !== void 0) {
1917
+ title ??= record.aiTitle;
1918
+ continue;
1919
+ }
1920
+ if (record.type === "custom-title" && record.customTitle !== void 0) {
1921
+ title = record.customTitle;
1922
+ continue;
1923
+ }
1924
+ if (record.type !== "user" && record.type !== "assistant") continue;
1925
+ if (!options?.isSubagent && record.isSidechain) continue;
1926
+ cwd ??= record.cwd ?? null;
1927
+ const at = record.timestamp;
1928
+ if (at) {
1929
+ startedAt ??= at;
1930
+ endedAt = at;
1931
+ }
1932
+ const blocks = claudeContentBlocks(record.message?.content);
1933
+ if (record.type === "user") {
1934
+ let sawToolResult = false;
1935
+ for (const block of blocks) {
1936
+ if (block.type !== "tool_result") continue;
1937
+ sawToolResult = true;
1938
+ const pending = block.tool_use_id ? pendingTools.get(block.tool_use_id) : void 0;
1939
+ if (!pending) continue;
1940
+ const resultText = claudeResultText(block.content).trim();
1941
+ if (resultText) pending.output = truncate(resultText, OUTPUT_LIMIT);
1942
+ if (block.is_error) pending.error = true;
1943
+ const counts = patchCounts(record.toolUseResult?.structuredPatch);
1944
+ if (counts) {
1945
+ pending.additions = counts.additions;
1946
+ pending.deletions = counts.deletions;
1947
+ }
1948
+ if (record.toolUseResult?.filePath && !pending.filePath) pending.filePath = relativizePath(record.toolUseResult.filePath, cwd);
1949
+ if (block.tool_use_id) pendingTools.delete(block.tool_use_id);
1950
+ }
1951
+ if (sawToolResult || record.isMeta) continue;
1952
+ const cleaned = cleanClaudeUserText(blocks.flatMap((block) => block.type === "text" && block.text !== void 0 ? [block.text] : []).join("\n"));
1953
+ if (!cleaned) continue;
1954
+ userTurns += 1;
1955
+ events.push({
1956
+ kind: "user",
1957
+ text: cleaned,
1958
+ at
1959
+ });
1960
+ continue;
1961
+ }
1962
+ for (const block of blocks) if (block.type === "thinking" && block.thinking !== void 0) {
1963
+ const trimmed = block.thinking.trim();
1964
+ if (trimmed) events.push({
1965
+ kind: "assistant",
1966
+ markdown: trimmed,
1967
+ thinking: true,
1968
+ at
1969
+ });
1970
+ } else if (block.type === "text" && block.text !== void 0) {
1971
+ const trimmed = block.text.trim();
1972
+ if (trimmed) events.push({
1973
+ kind: "assistant",
1974
+ markdown: trimmed,
1975
+ at
1976
+ });
1977
+ } else if (block.type === "tool_use") {
1978
+ const event = claudeToolEvent(block, at, cwd);
1979
+ toolCalls += 1;
1980
+ events.push(event);
1981
+ if (block.id) pendingTools.set(block.id, event);
1982
+ }
1983
+ }
1984
+ return {
1985
+ harness: "claude-code",
1986
+ title,
1987
+ events,
1988
+ startedAt,
1989
+ endedAt,
1990
+ activeMs: null,
1991
+ userTurns,
1992
+ toolCalls
1993
+ };
1994
+ }
1995
+ function codexTextBlocks(value) {
1996
+ return jsonArray(value)?.flatMap((block) => {
1997
+ const record = jsonObject(block);
1998
+ return record ? [{
1999
+ type: jsonString(record.type),
2000
+ text: jsonString(record.text)
2001
+ }] : [];
2002
+ }) ?? [];
2003
+ }
2004
+ function codexPayload(value) {
2005
+ return {
2006
+ type: jsonString(value.type),
2007
+ role: jsonString(value.role),
2008
+ content: codexTextBlocks(value.content),
2009
+ name: jsonString(value.name),
2010
+ arguments: value.arguments,
2011
+ input: value.input,
2012
+ call_id: jsonString(value.call_id),
2013
+ output: value.output,
2014
+ cwd: jsonString(value.cwd),
2015
+ timestamp: jsonString(value.timestamp),
2016
+ message: jsonString(value.message),
2017
+ query: jsonString(value.query),
2018
+ stdout: jsonString(value.stdout),
2019
+ success: jsonBoolean(value.success),
2020
+ changes: jsonObject(value.changes),
2021
+ invocation: jsonObject(value.invocation),
2022
+ result: value.result,
2023
+ command: value.command,
2024
+ aggregated_output: jsonString(value.aggregated_output),
2025
+ exit_code: jsonNumber(value.exit_code)
2026
+ };
2027
+ }
2028
+ function codexRecord(value) {
2029
+ const payload = jsonObject(value.payload);
2030
+ return {
2031
+ type: jsonString(value.type),
2032
+ timestamp: jsonString(value.timestamp),
2033
+ payload: payload && codexPayload(payload)
2034
+ };
2035
+ }
2036
+ const CODEX_USER_NOISE_PREFIXES = [
2037
+ "<recommended_plugins",
2038
+ "<permissions",
2039
+ "<environment_context",
2040
+ "<environments_instructions",
2041
+ "<user_instructions",
2042
+ "<user_shell_command",
2043
+ "<turn_aborted",
2044
+ "<subagent_notification",
2045
+ "<codex_internal_context",
2046
+ "<goal_context",
2047
+ "<codex_delegation",
2048
+ "<external_",
2049
+ "<skills_instructions",
2050
+ "<skill>",
2051
+ "<apps_instructions",
2052
+ "<plugins_instructions",
2053
+ "<tools>",
2054
+ "<collaboration_mode",
2055
+ "<multi_agent_mode",
2056
+ "<multi_agent_role",
2057
+ "<realtime_conversation",
2058
+ "<context_window",
2059
+ "<turn_context",
2060
+ "<model_switch",
2061
+ "<personality_spec",
2062
+ "<token_budget",
2063
+ "<rollout_budget",
2064
+ "<git_attribution",
2065
+ "<ENVIRONMENT",
2066
+ "# AGENTS"
2067
+ ];
2068
+ function codexText(payload) {
2069
+ return (payload.content ?? []).flatMap((block) => (block.type === "input_text" || block.type === "output_text") && block.text !== void 0 ? [block.text] : []).join("\n").trim();
2070
+ }
2071
+ function codexPatchSummary(patch, cwd) {
2072
+ const files = [];
2073
+ let additions = 0;
2074
+ let deletions = 0;
2075
+ for (const line of patch.split("\n")) {
2076
+ const header = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/.exec(line);
2077
+ if (header) {
2078
+ files.push(relativizePath(header[1].trim(), cwd));
2079
+ continue;
2080
+ }
2081
+ if (line.startsWith("***")) continue;
2082
+ if (line.startsWith("+")) additions += 1;
2083
+ else if (line.startsWith("-")) deletions += 1;
2084
+ }
2085
+ return {
2086
+ filePath: files[0],
2087
+ additions,
2088
+ deletions,
2089
+ files
2090
+ };
2091
+ }
2092
+ const CODE_MODE_FILE_PATTERN = /\*\*\* (?:Add|Update|Delete) File: ([^\n\\"`]+)/g;
2093
+ function unifiedDiffCounts(diff) {
2094
+ let additions = 0;
2095
+ let deletions = 0;
2096
+ for (const line of diff.split("\n")) if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
2097
+ else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
2098
+ return {
2099
+ additions,
2100
+ deletions
2101
+ };
2102
+ }
2103
+ function codexValueText(value) {
2104
+ const text = jsonString(value);
2105
+ if (text !== void 0) return text;
2106
+ if (value === null || value === void 0) return "";
2107
+ return JSON.stringify(value);
2108
+ }
2109
+ function codexPatchEvent(payload, at, cwd) {
2110
+ const changes = payload.changes ?? {};
2111
+ const paths = Object.keys(changes);
2112
+ if (paths.length === 0) return null;
2113
+ let additions = 0;
2114
+ let deletions = 0;
2115
+ const relativePaths = paths.map((filePath) => relativizePath(filePath, cwd));
2116
+ for (const filePath of paths) {
2117
+ const change = jsonObject(changes[filePath]) ?? {};
2118
+ const content = jsonString(change.content);
2119
+ const unifiedDiff = jsonString(change.unified_diff);
2120
+ if (change.type === "add" && content !== void 0) additions += content.split("\n").length;
2121
+ else if (change.type === "delete" && content !== void 0) deletions += content.split("\n").length;
2122
+ else if (unifiedDiff !== void 0) {
2123
+ const counts = unifiedDiffCounts(unifiedDiff);
2124
+ additions += counts.additions;
2125
+ deletions += counts.deletions;
2126
+ }
2127
+ }
2128
+ const single = paths.length === 1 ? jsonObject(changes[paths[0]]) : null;
2129
+ const event = {
2130
+ kind: "tool",
2131
+ tool: "apply_patch",
2132
+ verb: single?.type === "add" ? "Added" : single?.type === "delete" ? "Deleted" : "Edited",
2133
+ title: compactFileLine(relativePaths.join(", ")),
2134
+ filePath: relativePaths[0],
2135
+ additions,
2136
+ deletions,
2137
+ at
2138
+ };
2139
+ const diffs = paths.flatMap((filePath) => jsonString(jsonObject(changes[filePath])?.unified_diff) ?? []);
2140
+ if (diffs.length > 0) event.input = truncate(diffs.join("\n"), INPUT_LIMIT);
2141
+ if (payload.stdout) event.output = truncate(payload.stdout, OUTPUT_LIMIT);
2142
+ if (payload.success === false) event.error = true;
2143
+ return event;
2144
+ }
2145
+ function codexMcpEvent(payload, at) {
2146
+ const invocation = payload.invocation ?? {};
2147
+ const server = jsonString(invocation.server) ?? "mcp";
2148
+ const tool = jsonString(invocation.tool) ?? "tool";
2149
+ const argumentsTitle = jsonString(jsonObject(invocation.arguments)?.title);
2150
+ const title = argumentsTitle === void 0 ? `${server} · ${tool}` : `${server} · ${tool} — ${argumentsTitle}`;
2151
+ const event = {
2152
+ kind: "tool",
2153
+ tool: `${server}.${tool}`,
2154
+ verb: "Called",
2155
+ title: compactLine(title),
2156
+ at
2157
+ };
2158
+ const input = codexValueText(invocation.arguments);
2159
+ if (input && input !== "{}") event.input = truncate(input, INPUT_LIMIT);
2160
+ const result = jsonObject(payload.result);
2161
+ const ok = jsonObject(result?.Ok);
2162
+ if (ok?.content) {
2163
+ const text = codexTextBlocks(ok.content).flatMap((block) => block.type === "text" && block.text !== void 0 ? [block.text] : []).join("\n").trim();
2164
+ if (text) event.output = truncate(text, OUTPUT_LIMIT);
2165
+ if (ok.isError) event.error = true;
2166
+ } else if (result && "Err" in result) {
2167
+ event.error = true;
2168
+ event.output = truncate(codexValueText(result.Err), OUTPUT_LIMIT);
2169
+ }
2170
+ return event;
2171
+ }
2172
+ function codexCodeModeEvent(event, source, cwd, skipPatches) {
2173
+ const cmdMatch = /"cmd"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(source);
2174
+ if (cmdMatch) {
2175
+ let command = cmdMatch[1];
2176
+ try {
2177
+ command = jsonString(parseJsonText(`"${cmdMatch[1]}"`)) ?? command;
2178
+ } catch {}
2179
+ event.verb = "Ran";
2180
+ event.title = compactLine(command);
2181
+ event.command = truncate(command, INPUT_LIMIT);
2182
+ return event;
2183
+ }
2184
+ const files = [];
2185
+ for (const match of source.matchAll(CODE_MODE_FILE_PATTERN)) {
2186
+ const file = relativizePath(match[1].trim(), cwd);
2187
+ if (!files.includes(file)) files.push(file);
2188
+ }
2189
+ if (files.length > 0) {
2190
+ if (skipPatches) return null;
2191
+ event.verb = "Edited";
2192
+ event.title = compactFileLine(files.join(", "));
2193
+ event.filePath = files[0];
2194
+ event.input = truncate(source, INPUT_LIMIT);
2195
+ return event;
2196
+ }
2197
+ const inner = /tools\.(\w+)\(/.exec(source);
2198
+ if (inner) {
2199
+ if (inner[1] === "wait") return null;
2200
+ if (inner[1].startsWith("mcp__")) return null;
2201
+ event.verb = "Called";
2202
+ event.title = inner[1].replace(/^mcp__/, "").replace(/__/g, " · ");
2203
+ event.input = truncate(source, INPUT_LIMIT);
2204
+ return event;
2205
+ }
2206
+ return null;
2207
+ }
2208
+ function codexToolEvent(payload, at, cwd, flags) {
2209
+ const name = payload.name ?? "tool";
2210
+ if (name === "wait") return null;
2211
+ const event = {
2212
+ kind: "tool",
2213
+ tool: name,
2214
+ verb: "Called",
2215
+ title: name,
2216
+ at
2217
+ };
2218
+ if (name === "exec" || name === "exec_command" || name === "shell" || name === "local_shell") {
2219
+ const source = codexValueText(payload.arguments ?? payload.input);
2220
+ if (source.includes("tools.")) return codexCodeModeEvent(event, source, cwd, flags.hasPatchEvents);
2221
+ let command = source;
2222
+ try {
2223
+ const parsed = jsonObject(parseJsonText(source || "{}"));
2224
+ command = jsonString(parsed?.cmd) ?? jsonArray(parsed?.command)?.join(" ") ?? jsonString(parsed?.command) ?? command;
2225
+ } catch {}
2226
+ event.verb = "Ran";
2227
+ event.title = compactLine(command);
2228
+ event.command = truncate(command, INPUT_LIMIT);
2229
+ return event;
2230
+ }
2231
+ if (name === "apply_patch") {
2232
+ if (flags.hasPatchEvents) return null;
2233
+ const patchInput = codexValueText(payload.input);
2234
+ const summary = codexPatchSummary(patchInput, cwd);
2235
+ event.verb = "Edited";
2236
+ event.title = compactFileLine(summary.files.join(", ") || "files");
2237
+ event.filePath = summary.filePath;
2238
+ event.additions = summary.additions;
2239
+ event.deletions = summary.deletions;
2240
+ event.input = truncate(patchInput, INPUT_LIMIT);
2241
+ return event;
2242
+ }
2243
+ const inputSource = codexValueText(payload.arguments ?? payload.input);
2244
+ if (inputSource) event.input = truncate(inputSource, INPUT_LIMIT);
2245
+ return event;
2246
+ }
2247
+ function codexOutputText(output) {
2248
+ const text = jsonString(output);
2249
+ if (text !== void 0) {
2250
+ try {
2251
+ const inner = jsonString(jsonObject(parseJsonText(text))?.output);
2252
+ if (inner !== void 0) return inner;
2253
+ } catch {}
2254
+ return text;
2255
+ }
2256
+ const inner = jsonString(jsonObject(output)?.output);
2257
+ if (inner !== void 0) return inner;
2258
+ return isJsonObject(output) || isJsonArray(output) ? codexValueText(output) : "";
2259
+ }
2260
+ function parseCodexRecords(records) {
2261
+ const events = [];
2262
+ const pendingTools = /* @__PURE__ */ new Map();
2263
+ let startedAt = null;
2264
+ let endedAt = null;
2265
+ let cwd = null;
2266
+ let userTurns = 0;
2267
+ let toolCalls = 0;
2268
+ let firstUserText = null;
2269
+ let hasUserMessageEvent = false;
2270
+ let hasAgentMessageEvent = false;
2271
+ let hasPatchEvents = false;
2272
+ let hasMcpEvents = false;
2273
+ const codexRecords = records.flatMap((raw) => isJsonObject(raw) ? [codexRecord(raw)] : []);
2274
+ for (const record of codexRecords) {
2275
+ if (record.type !== "event_msg") continue;
2276
+ const eventType = record.payload?.type;
2277
+ if (eventType === "user_message") hasUserMessageEvent = true;
2278
+ else if (eventType === "agent_message") hasAgentMessageEvent = true;
2279
+ else if (eventType === "patch_apply_end") hasPatchEvents = true;
2280
+ else if (eventType === "mcp_tool_call_end") hasMcpEvents = true;
2281
+ }
2282
+ const flags = {
2283
+ hasPatchEvents,
2284
+ hasMcpEvents
2285
+ };
2286
+ for (const record of codexRecords) {
2287
+ const payload = record.payload;
2288
+ if (record.type === "session_meta") {
2289
+ cwd = payload?.cwd ?? null;
2290
+ startedAt ??= payload?.timestamp ?? record.timestamp ?? null;
2291
+ continue;
2292
+ }
2293
+ const at = record.timestamp;
2294
+ if (record.type === "event_msg" && payload) {
2295
+ if (at) {
2296
+ startedAt ??= at;
2297
+ endedAt = at;
2298
+ }
2299
+ switch (payload.type) {
2300
+ case "user_message": {
2301
+ const text = (payload.message ?? "").trim();
2302
+ if (!text) break;
2303
+ const lowered = text.trimStart().toLowerCase();
2304
+ if (CODEX_USER_NOISE_PREFIXES.some((prefix) => lowered.startsWith(prefix.toLowerCase()))) break;
2305
+ userTurns += 1;
2306
+ firstUserText ??= text;
2307
+ events.push({
2308
+ kind: "user",
2309
+ text,
2310
+ at
2311
+ });
2312
+ break;
2313
+ }
2314
+ case "agent_message": {
2315
+ const text = (payload.message ?? "").trim();
2316
+ if (text) events.push({
2317
+ kind: "assistant",
2318
+ markdown: text,
2319
+ at
2320
+ });
2321
+ break;
2322
+ }
2323
+ case "patch_apply_end": {
2324
+ const event = codexPatchEvent(payload, at, cwd);
2325
+ if (event) {
2326
+ toolCalls += 1;
2327
+ events.push(event);
2328
+ }
2329
+ break;
2330
+ }
2331
+ case "mcp_tool_call_end":
2332
+ toolCalls += 1;
2333
+ events.push(codexMcpEvent(payload, at));
2334
+ break;
2335
+ case "web_search_end":
2336
+ toolCalls += 1;
2337
+ events.push({
2338
+ kind: "tool",
2339
+ tool: "web_search",
2340
+ verb: "Searched web",
2341
+ title: compactLine(payload.query ?? ""),
2342
+ at
2343
+ });
2344
+ break;
2345
+ case "exec_command_end": {
2346
+ const command = jsonArray(payload.command)?.join(" ") ?? codexValueText(payload.command);
2347
+ if (!command) break;
2348
+ toolCalls += 1;
2349
+ const event = {
2350
+ kind: "tool",
2351
+ tool: "exec",
2352
+ verb: "Ran",
2353
+ title: compactLine(command),
2354
+ command: truncate(command, INPUT_LIMIT),
2355
+ at
2356
+ };
2357
+ if (payload.aggregated_output) event.output = truncate(payload.aggregated_output, OUTPUT_LIMIT);
2358
+ if (payload.exit_code !== void 0 && payload.exit_code !== 0) event.error = true;
2359
+ events.push(event);
2360
+ break;
2361
+ }
2362
+ }
2363
+ continue;
2364
+ }
2365
+ if (record.type !== "response_item" || !payload) continue;
2366
+ if (at) {
2367
+ startedAt ??= at;
2368
+ endedAt = at;
2369
+ }
2370
+ switch (payload.type) {
2371
+ case "message": {
2372
+ const text = codexText(payload);
2373
+ if (!text) break;
2374
+ if (payload.role === "user") {
2375
+ if (hasUserMessageEvent) break;
2376
+ const lowered = text.trimStart().toLowerCase();
2377
+ if (CODEX_USER_NOISE_PREFIXES.some((prefix) => lowered.startsWith(prefix.toLowerCase()))) break;
2378
+ userTurns += 1;
2379
+ firstUserText ??= text;
2380
+ events.push({
2381
+ kind: "user",
2382
+ text,
2383
+ at
2384
+ });
2385
+ } else if (payload.role === "assistant") {
2386
+ if (hasAgentMessageEvent) break;
2387
+ events.push({
2388
+ kind: "assistant",
2389
+ markdown: text,
2390
+ at
2391
+ });
2392
+ }
2393
+ break;
2394
+ }
2395
+ case "function_call":
2396
+ case "custom_tool_call": {
2397
+ const event = codexToolEvent(payload, at, cwd, flags);
2398
+ if (!event) break;
2399
+ toolCalls += 1;
2400
+ events.push(event);
2401
+ if (payload.call_id) pendingTools.set(payload.call_id, event);
2402
+ break;
2403
+ }
2404
+ case "function_call_output":
2405
+ case "custom_tool_call_output": {
2406
+ const pending = payload.call_id ? pendingTools.get(payload.call_id) : void 0;
2407
+ if (!pending) break;
2408
+ const text = codexOutputText(payload.output).trim();
2409
+ if (text && !pending.output) pending.output = truncate(text, OUTPUT_LIMIT);
2410
+ if (payload.call_id) pendingTools.delete(payload.call_id);
2411
+ break;
2412
+ }
2413
+ }
2414
+ }
2415
+ return {
2416
+ harness: "codex",
2417
+ title: firstUserText ? compactLine(firstUserText) : null,
2418
+ events,
2419
+ startedAt,
2420
+ endedAt,
2421
+ activeMs: null,
2422
+ userTurns,
2423
+ toolCalls
2424
+ };
2425
+ }
2426
+ function piRecord(value) {
2427
+ const message = jsonObject(value.message);
2428
+ return {
2429
+ type: jsonString(value.type),
2430
+ timestamp: jsonString(value.timestamp),
2431
+ cwd: jsonString(value.cwd),
2432
+ message: message && {
2433
+ role: jsonString(message.role),
2434
+ toolCallId: jsonString(message.toolCallId),
2435
+ content: message.content
2436
+ }
2437
+ };
2438
+ }
2439
+ function piContentBlocks(content) {
2440
+ return jsonArray(content)?.flatMap((block) => {
2441
+ const record = jsonObject(block);
2442
+ return record ? [{
2443
+ type: jsonString(record.type),
2444
+ text: jsonString(record.text),
2445
+ thinking: jsonString(record.thinking),
2446
+ id: jsonString(record.id),
2447
+ name: jsonString(record.name),
2448
+ arguments: jsonObject(record.arguments)
2449
+ }] : [];
2450
+ }) ?? [];
2451
+ }
2452
+ function piText(content) {
2453
+ const text = jsonString(content);
2454
+ if (text !== void 0) return text;
2455
+ return piContentBlocks(content).flatMap((block) => block.type === "text" && block.text !== void 0 ? [block.text] : []).join("\n").trim();
2456
+ }
2457
+ function piToolEvent(block, at, cwd) {
2458
+ const name = block.name ?? "tool";
2459
+ const args = block.arguments ?? {};
2460
+ const argText = (key) => jsonString(args[key]) ?? null;
2461
+ const event = {
2462
+ kind: "tool",
2463
+ tool: name,
2464
+ verb: "Called",
2465
+ title: name,
2466
+ at
2467
+ };
2468
+ const pathValue = argText("path");
2469
+ switch (name) {
2470
+ case "bash": {
2471
+ const command = argText("command") ?? "";
2472
+ event.verb = "Ran";
2473
+ event.title = compactLine(command);
2474
+ event.command = truncate(command, INPUT_LIMIT);
2475
+ break;
2476
+ }
2477
+ case "read":
2478
+ event.verb = "Read";
2479
+ event.title = pathValue ? compactFileLine(relativizePath(pathValue, cwd)) : name;
2480
+ if (pathValue) event.filePath = relativizePath(pathValue, cwd);
2481
+ break;
2482
+ case "edit":
2483
+ event.verb = "Edited";
2484
+ event.title = pathValue ? compactFileLine(relativizePath(pathValue, cwd)) : name;
2485
+ if (pathValue) event.filePath = relativizePath(pathValue, cwd);
2486
+ break;
2487
+ case "write":
2488
+ event.verb = "Wrote";
2489
+ event.title = pathValue ? compactFileLine(relativizePath(pathValue, cwd)) : name;
2490
+ if (pathValue) event.filePath = relativizePath(pathValue, cwd);
2491
+ const content = argText("content");
2492
+ if (content !== null) event.additions = content.split("\n").length;
2493
+ break;
2494
+ case "subagent":
2495
+ event.verb = "Ran agent";
2496
+ event.title = compactLine(codexValueText(args.action));
2497
+ break;
2498
+ case "web_search":
2499
+ event.verb = "Searched web";
2500
+ event.title = compactLine(argText("query") ?? "");
2501
+ }
2502
+ if (!event.command) {
2503
+ const pretty = codexValueText(args);
2504
+ if (pretty && pretty !== "{}") event.input = truncate(pretty, INPUT_LIMIT);
2505
+ }
2506
+ return event;
2507
+ }
2508
+ function parsePiRecords(records) {
2509
+ const events = [];
2510
+ const pendingTools = /* @__PURE__ */ new Map();
2511
+ let startedAt = null;
2512
+ let endedAt = null;
2513
+ let cwd = null;
2514
+ let userTurns = 0;
2515
+ let toolCalls = 0;
2516
+ let firstUserText = null;
2517
+ for (const raw of records) {
2518
+ if (!isJsonObject(raw)) continue;
2519
+ const record = piRecord(raw);
2520
+ if (record.type === "session") {
2521
+ cwd = record.cwd ?? null;
2522
+ startedAt ??= record.timestamp ?? null;
2523
+ continue;
2524
+ }
2525
+ if (record.type !== "message" || !record.message) continue;
2526
+ const at = record.timestamp;
2527
+ if (at) {
2528
+ startedAt ??= at;
2529
+ endedAt = at;
2530
+ }
2531
+ const message = record.message;
2532
+ if (message.role === "user") {
2533
+ const text = piText(message.content);
2534
+ if (!text) continue;
2535
+ const lowered = text.trimStart().toLowerCase();
2536
+ if (CODEX_USER_NOISE_PREFIXES.some((prefix) => lowered.startsWith(prefix.toLowerCase()))) continue;
2537
+ userTurns += 1;
2538
+ firstUserText ??= text;
2539
+ events.push({
2540
+ kind: "user",
2541
+ text,
2542
+ at
2543
+ });
2544
+ continue;
2545
+ }
2546
+ if (message.role === "assistant") {
2547
+ const contentText = jsonString(message.content);
2548
+ if (contentText !== void 0) {
2549
+ const trimmed = contentText.trim();
2550
+ if (trimmed) events.push({
2551
+ kind: "assistant",
2552
+ markdown: trimmed,
2553
+ at
2554
+ });
2555
+ continue;
2556
+ }
2557
+ for (const block of piContentBlocks(message.content)) if (block.type === "thinking" && block.thinking !== void 0) {
2558
+ const trimmed = block.thinking.trim();
2559
+ if (trimmed) events.push({
2560
+ kind: "assistant",
2561
+ markdown: trimmed,
2562
+ thinking: true,
2563
+ at
2564
+ });
2565
+ } else if (block.type === "text" && block.text !== void 0) {
2566
+ const trimmed = block.text.trim();
2567
+ if (trimmed) events.push({
2568
+ kind: "assistant",
2569
+ markdown: trimmed,
2570
+ at
2571
+ });
2572
+ } else if (block.type === "toolCall") {
2573
+ if (block.name === "subagent_wait") continue;
2574
+ const event = piToolEvent(block, at, cwd);
2575
+ toolCalls += 1;
2576
+ events.push(event);
2577
+ if (block.id) pendingTools.set(block.id, event);
2578
+ }
2579
+ continue;
2580
+ }
2581
+ if (message.role === "toolResult") {
2582
+ const pending = message.toolCallId ? pendingTools.get(message.toolCallId) : void 0;
2583
+ if (!pending) continue;
2584
+ const text = piText(message.content);
2585
+ if (text && !pending.output) pending.output = truncate(text, OUTPUT_LIMIT);
2586
+ if (message.toolCallId) pendingTools.delete(message.toolCallId);
2587
+ }
2588
+ }
2589
+ return {
2590
+ harness: "pi",
2591
+ title: firstUserText ? compactLine(firstUserText) : null,
2592
+ events,
2593
+ startedAt,
2594
+ endedAt,
2595
+ activeMs: null,
2596
+ userTurns,
2597
+ toolCalls
2598
+ };
2599
+ }
2600
+ function openCodeIso(value) {
2601
+ const millis = jsonNumber(value);
2602
+ return millis === void 0 ? void 0 : new Date(millis).toISOString();
2603
+ }
2604
+ function openCodeRecord(value) {
2605
+ const info = jsonObject(value.info);
2606
+ const infoTime = jsonObject(info?.time);
2607
+ const time = jsonObject(value.time);
2608
+ return {
2609
+ type: jsonString(value.type),
2610
+ directory: jsonString(value.directory),
2611
+ title: jsonString(value.title),
2612
+ createdAt: openCodeIso(time?.created),
2613
+ role: jsonString(info?.role),
2614
+ messageCreatedAt: openCodeIso(infoTime?.created),
2615
+ completedAt: openCodeIso(infoTime?.completed),
2616
+ parts: (jsonArray(value.parts) ?? []).flatMap((part) => isJsonObject(part) ? [openCodePart(part)] : [])
2617
+ };
2618
+ }
2619
+ function openCodePart(value) {
2620
+ const state = jsonObject(value.state);
2621
+ return {
2622
+ type: jsonString(value.type),
2623
+ text: jsonString(value.text),
2624
+ ignored: jsonBoolean(value.ignored) === true,
2625
+ synthetic: jsonBoolean(value.synthetic) === true,
2626
+ startedAt: openCodeIso(jsonObject(value.time)?.start),
2627
+ tool: jsonString(value.tool),
2628
+ state: {
2629
+ status: jsonString(state?.status),
2630
+ input: jsonObject(state?.input) ?? {},
2631
+ output: jsonString(state?.output),
2632
+ error: jsonString(state?.error),
2633
+ title: jsonString(state?.title),
2634
+ startedAt: openCodeIso(jsonObject(state?.time)?.start)
2635
+ }
2636
+ };
2637
+ }
2638
+ function openCodeText(parts) {
2639
+ return parts.flatMap((part) => part.type === "text" && part.text !== void 0 && !part.ignored && !part.synthetic ? [part.text] : []).join("\n").trim();
2640
+ }
2641
+ function openCodeToolEvent(part, at, cwd) {
2642
+ const name = part.tool ?? "tool";
2643
+ const state = part.state;
2644
+ const args = state.input;
2645
+ const argText = (key) => jsonString(args[key]) ?? null;
2646
+ const lineCount = (key) => jsonString(args[key])?.split("\n").length;
2647
+ const event = {
2648
+ kind: "tool",
2649
+ tool: name,
2650
+ verb: "Called",
2651
+ title: state.title || name,
2652
+ at
2653
+ };
2654
+ const filePath = argText("filePath");
2655
+ const fileTitle = () => {
2656
+ if (!filePath) return;
2657
+ event.filePath = relativizePath(filePath, cwd);
2658
+ event.title = compactFileLine(event.filePath);
2659
+ };
2660
+ switch (name) {
2661
+ case "bash": {
2662
+ const command = argText("command") ?? "";
2663
+ event.verb = "Ran";
2664
+ event.title = compactLine(command);
2665
+ event.command = truncate(command, INPUT_LIMIT);
2666
+ break;
2667
+ }
2668
+ case "read":
2669
+ event.verb = "Read";
2670
+ fileTitle();
2671
+ break;
2672
+ case "edit": {
2673
+ event.verb = "Edited";
2674
+ fileTitle();
2675
+ const additions = lineCount("newString");
2676
+ const deletions = lineCount("oldString");
2677
+ if (additions !== void 0) event.additions = additions;
2678
+ if (deletions !== void 0) event.deletions = deletions;
2679
+ break;
2680
+ }
2681
+ case "write": {
2682
+ event.verb = "Wrote";
2683
+ fileTitle();
2684
+ const additions = lineCount("content");
2685
+ if (additions !== void 0) event.additions = additions;
2686
+ break;
2687
+ }
2688
+ case "apply_patch": {
2689
+ const summary = codexPatchSummary(argText("patchText") ?? "", cwd);
2690
+ event.verb = "Edited";
2691
+ event.title = compactFileLine(summary.files.join(", ") || name);
2692
+ if (summary.filePath) event.filePath = summary.filePath;
2693
+ event.additions = summary.additions;
2694
+ event.deletions = summary.deletions;
2695
+ break;
2696
+ }
2697
+ case "glob":
2698
+ case "grep":
2699
+ event.verb = "Searched";
2700
+ event.title = compactLine(argText("pattern") ?? name);
2701
+ break;
2702
+ case "list": {
2703
+ const listPath = argText("path");
2704
+ event.verb = "Listed";
2705
+ event.title = compactFileLine(listPath ? relativizePath(listPath, cwd) : name);
2706
+ break;
2707
+ }
2708
+ case "task":
2709
+ event.verb = "Ran agent";
2710
+ event.title = compactLine(argText("description") ?? name);
2711
+ break;
2712
+ case "webfetch":
2713
+ event.verb = "Fetched";
2714
+ event.title = compactLine(argText("url") ?? name);
2715
+ break;
2716
+ case "websearch":
2717
+ event.verb = "Searched web";
2718
+ event.title = compactLine(argText("query") ?? name);
2719
+ break;
2720
+ case "skill":
2721
+ event.verb = "Loaded skill";
2722
+ event.title = compactLine(argText("name") ?? name);
2723
+ }
2724
+ if (!event.command) {
2725
+ const pretty = codexValueText(args);
2726
+ if (pretty && pretty !== "{}") event.input = truncate(pretty, INPUT_LIMIT);
2727
+ }
2728
+ const output = state.status === "error" && state.error !== void 0 ? state.error : state.output ?? "";
2729
+ if (output.trim()) event.output = truncate(output, OUTPUT_LIMIT);
2730
+ return event;
2731
+ }
2732
+ function parseOpenCodeRecords(records) {
2733
+ const events = [];
2734
+ let title = null;
2735
+ let startedAt = null;
2736
+ let endedAt = null;
2737
+ let cwd = null;
2738
+ let userTurns = 0;
2739
+ let toolCalls = 0;
2740
+ let firstUserText = null;
2741
+ for (const raw of records) {
2742
+ if (!isJsonObject(raw)) continue;
2743
+ const record = openCodeRecord(raw);
2744
+ if (record.type === "opencode_session") {
2745
+ cwd = record.directory ?? null;
2746
+ title = record.title?.trim() || null;
2747
+ startedAt ??= record.createdAt ?? null;
2748
+ continue;
2749
+ }
2750
+ if (record.type !== "opencode_message") continue;
2751
+ const createdAt = record.messageCreatedAt;
2752
+ if (createdAt) {
2753
+ startedAt ??= createdAt;
2754
+ endedAt = record.completedAt ?? createdAt;
2755
+ }
2756
+ if (record.role === "user") {
2757
+ const text = openCodeText(record.parts);
2758
+ if (!text) continue;
2759
+ userTurns += 1;
2760
+ firstUserText ??= text;
2761
+ events.push({
2762
+ kind: "user",
2763
+ text,
2764
+ at: createdAt
2765
+ });
2766
+ continue;
2767
+ }
2768
+ if (record.role !== "assistant") continue;
2769
+ for (const part of record.parts) if (part.type === "reasoning" && part.text !== void 0) {
2770
+ const trimmed = part.text.trim();
2771
+ if (trimmed) events.push({
2772
+ kind: "assistant",
2773
+ markdown: trimmed,
2774
+ thinking: true,
2775
+ at: part.startedAt ?? createdAt
2776
+ });
2777
+ } else if (part.type === "text" && part.text !== void 0) {
2778
+ const trimmed = part.text.trim();
2779
+ if (trimmed) events.push({
2780
+ kind: "assistant",
2781
+ markdown: trimmed,
2782
+ at: createdAt
2783
+ });
2784
+ } else if (part.type === "tool") {
2785
+ toolCalls += 1;
2786
+ events.push(openCodeToolEvent(part, part.state.startedAt ?? createdAt, cwd));
2787
+ } else if (part.type === "compaction") events.push({
2788
+ kind: "separator",
2789
+ label: "Context compacted"
2790
+ });
2791
+ }
2792
+ return {
2793
+ harness: "opencode",
2794
+ title: title ?? (firstUserText ? compactLine(firstUserText) : null),
2795
+ events,
2796
+ startedAt,
2797
+ endedAt,
2798
+ activeMs: null,
2799
+ userTurns,
2800
+ toolCalls
2801
+ };
2802
+ }
2803
+ //#endregion
2804
+ //#region ../trace-core/src/trace-corpus.ts
2805
+ const RECORD_SEPARATOR = "";
2806
+ const FIELD_SEPARATOR = "";
2807
+ /** Atomically replaces a normalized JSONL copy without changing its version-1 record format. */
2808
+ function writeNormalizedTraceAtomic(targetPath, trace) {
2809
+ const content = [trace.metadata, ...trace.events].map((record) => JSON.stringify(record)).join("\n");
2810
+ writeFileAtomic(targetPath, `${content}\n`, "utf8");
2811
+ }
2812
+ /**
2813
+ * A saved copy, or null when the file is absent, written by another parser
2814
+ * version, or made for another store. A copy that names its store is served
2815
+ * only to that store; a copy from before the field is trusted only by s3
2816
+ * storage, which wrote every such file.
2817
+ */
2818
+ function readNormalizedTrace(filePath, storage) {
2819
+ try {
2820
+ const records = readFileSync(filePath, "utf8").split("\n").filter(Boolean).map((line) => parseJsonText(line));
2821
+ const metadata = records[0];
2822
+ if (!metadata || metadata.type !== "metadata" || metadata.version !== 1 || metadata.parserVersion !== "1" || !Number.isFinite(metadata.source?.bytes)) return null;
2823
+ if (storage) {
2824
+ const owner = metadata.source.storage;
2825
+ if (owner !== void 0 && owner !== storage.cacheIdentity()) return null;
2826
+ if (owner === void 0 && storage.kind !== "s3") return null;
2827
+ }
2828
+ const events = records.slice(1);
2829
+ if (events.some((record, index) => record.type !== "event" || record.index !== index || record.kind !== record.event?.kind || record.text !== extractTraceEventText(record.event))) return null;
2830
+ return {
2831
+ metadata,
2832
+ events
2833
+ };
2834
+ } catch {
2835
+ return null;
2836
+ }
2837
+ }
2838
+ /** Creates and returns the configured local search corpus directory; it performs no remote lookup. */
2839
+ function traceSearchCorpusDir() {
2840
+ const dir = process.env.REVIEW_TEST_TRACE_SEARCH_DIR ?? path.join(devReviewHome(), "trace-search");
2841
+ mkdirSync(dir, { recursive: true });
2842
+ return dir;
2843
+ }
2844
+ /** Parses a repository slug or returns the supplied owner/repo pair without consulting Git. */
2845
+ function normalizeRepo(repo) {
2846
+ return isRepoSlug(repo) ? parseRepo(repo) : repo;
2847
+ }
2848
+ /** Whether a repo input is the "owner/repo" slug form. */
2849
+ function isRepoSlug(repo) {
2850
+ return isStringValue(repo);
2851
+ }
2852
+ /** Builds a validated corpus path and ensures the corpus root exists; it does not write a trace. */
2853
+ function normalizedTracePath(repo, sessionId, traceName) {
2854
+ return path.join(traceSearchCorpusDir(), corpusPathSegment(repo.owner, "owner"), corpusPathSegment(repo.repo, "repository"), corpusPathSegment(sessionId, "session"), `${corpusPathSegment(traceName.replace(/\.jsonl$/, ""), "trace")}.jsonl`);
2855
+ }
2856
+ /** Finds a local copy compatible with the supplied store, restricting scoped stores to their own paths; it never downloads content. */
2857
+ function findNormalizedTraceFile(sessionId, traceName, storage) {
2858
+ const fileName = `${corpusPathSegment(traceName.replace(/\.jsonl$/, ""), "trace")}.jsonl`;
2859
+ const own = storage?.cacheScope(null);
2860
+ if (own) {
2861
+ const candidate = path.join(path.dirname(normalizedTracePath(own, sessionId, "main")), fileName);
2862
+ return isFile(candidate) ? candidate : null;
2863
+ }
2864
+ for (const sessionDir of findNormalizedSessionDirs(sessionId)) {
2865
+ const candidate = path.join(sessionDir, fileName);
2866
+ if (!isFile(candidate)) continue;
2867
+ if (!storage || readNormalizedTrace(candidate, storage) !== null) return candidate;
2868
+ }
2869
+ return null;
2870
+ }
2871
+ /** Returns sorted local corpus directories for a session, or an empty list on traversal failure. */
2872
+ function findNormalizedSessionDirs(sessionId) {
2873
+ const root = traceSearchCorpusDir();
2874
+ const session = corpusPathSegment(sessionId, "session");
2875
+ const results = [];
2876
+ try {
2877
+ for (const owner of readdirSync(root, { withFileTypes: true })) {
2878
+ if (!owner.isDirectory()) continue;
2879
+ const ownerDir = path.join(root, owner.name);
2880
+ for (const repo of readdirSync(ownerDir, { withFileTypes: true })) {
2881
+ if (!repo.isDirectory()) continue;
2882
+ const candidate = path.join(ownerDir, repo.name, session);
2883
+ if (isDirectory(candidate)) results.push(candidate);
2884
+ }
2885
+ }
2886
+ } catch {
2887
+ return [];
2888
+ }
2889
+ return results.sort();
2890
+ }
2891
+ function corpusPathSegment(value, label) {
2892
+ if (!/^[A-Za-z0-9._-]+$/.test(value)) throw new Error(`Invalid ${label} path segment: ${value}`);
2893
+ return value;
2894
+ }
2895
+ /** Returns the version-1 bucket object label for cache metadata without accessing storage. */
2896
+ function legacyObjectKey(sessionId, traceName) {
2897
+ if (traceName === "main") return `by-session/${sessionId}/trace.jsonl`;
2898
+ const base = path.basename(traceName);
2899
+ return `by-session/${sessionId}/subagents/${base.endsWith(".jsonl") ? base : `${base}.jsonl`}`;
2900
+ }
2901
+ /** Returns distinct strings in first-seen order without modifying the input. */
2902
+ function deduplicateStrings(items) {
2903
+ const seen = /* @__PURE__ */ new Set();
2904
+ const out = [];
2905
+ for (const item of items) if (!seen.has(item)) {
2906
+ seen.add(item);
2907
+ out.push(item);
2908
+ }
2909
+ return out;
2910
+ }
2911
+ /** Resolves a Git revision to its SHA, retaining the supplied revision when resolution fails. */
2912
+ async function resolveCommitSha(cwd, rev) {
2913
+ const result = await git(cwd, [
2914
+ "rev-parse",
2915
+ "--verify",
2916
+ "--end-of-options",
2917
+ rev
2918
+ ], { allowFailure: true });
2919
+ return result.ok && result.stdout.trim() ? result.stdout.trim() : rev;
2920
+ }
2921
+ /** Returns valid, unique Agent-Session trailers for a revision, or an empty list when Git fails. */
2922
+ async function readTrailerSessions(cwd, rev) {
2923
+ const result = await git(cwd, [
2924
+ "show",
2925
+ "-s",
2926
+ "--format=%(trailers:key=Agent-Session,valueonly)",
2927
+ "--end-of-options",
2928
+ rev
2929
+ ], { allowFailure: true });
2930
+ if (!result.ok) return [];
2931
+ const sessions = [];
2932
+ for (const line of result.stdout.split("\n")) {
2933
+ const value = line.trim();
2934
+ if (value && sessionIdSchema.safeParse(value).success && !sessions.includes(value)) sessions.push(value);
2935
+ }
2936
+ return sessions;
2937
+ }
2938
+ /** Reads valid, unique session trailers from all repository refs without consulting trace storage. */
2939
+ async function listRepositoryTraceSessionIds(cwd) {
2940
+ const result = await gitAt(cwd, [
2941
+ "log",
2942
+ "--all",
2943
+ "--no-show-signature",
2944
+ "--format=%(trailers:key=Agent-Session,valueonly,separator=%x1f)"
2945
+ ], { allowFailure: true });
2946
+ if (!result.ok) return [];
2947
+ return deduplicateStrings(result.stdout.split(/[\n\x1f]+/).map((value) => value.trim()).filter((value) => sessionIdSchema.safeParse(value).success));
2948
+ }
2949
+ /** Reads a revision subject and extracts its trailing pull request number, or returns null. */
2950
+ async function readSubjectPullNumber(cwd, rev) {
2951
+ const result = await git(cwd, [
2952
+ "show",
2953
+ "-s",
2954
+ "--format=%s",
2955
+ "--end-of-options",
2956
+ rev
2957
+ ], { allowFailure: true });
2958
+ if (!result.ok) return null;
2959
+ return subjectPullNumber(result.stdout.trim());
2960
+ }
2961
+ /** Extracts a trailing (#number) from a subject without consulting Git. */
2962
+ function subjectPullNumber(subject) {
2963
+ const match = /\(#(\d+)\)$/.exec(subject);
2964
+ return match ? Number(match[1]) : null;
2965
+ }
2966
+ /** Reads the repository author email and current branch, returning null fields outside a worktree. */
2967
+ async function readRepoMetaFields(cwd) {
2968
+ const insideResult = await git(cwd, ["rev-parse", "--is-inside-work-tree"], { allowFailure: true });
2969
+ if (!insideResult.ok || insideResult.stdout.trim() !== "true") return {
2970
+ author: null,
2971
+ branch: null
2972
+ };
2973
+ const branchResult = await git(cwd, ["branch", "--show-current"], { allowFailure: true });
2974
+ const branch = branchResult.ok ? branchResult.stdout.trim() : null;
2975
+ const authorResult = await git(cwd, ["config", "user.email"], { allowFailure: true });
2976
+ return {
2977
+ author: (authorResult.ok ? authorResult.stdout.trim() : null) || null,
2978
+ branch: branch || null
2979
+ };
2980
+ }
2981
+ /** Reads commit subjects and valid session trailers in the requested range without consulting trace storage. */
2982
+ async function commitsWithTrailers(input) {
2983
+ if (input.baseCommit === input.headCommit) return [];
2984
+ const format = [
2985
+ "%H",
2986
+ "%s",
2987
+ "%(trailers:key=Agent-Session,valueonly,separator=%x1f)"
2988
+ ].join("%x1f");
2989
+ const result = await git(input.rootPath, [
2990
+ "log",
2991
+ "--no-show-signature",
2992
+ `--format=${format}${RECORD_SEPARATOR}`,
2993
+ `${input.baseCommit}..${input.headCommit}`
2994
+ ], { allowFailure: true });
2995
+ if (!result.ok) return [];
2996
+ const commits = [];
2997
+ for (const chunk of result.stdout.split(RECORD_SEPARATOR)) {
2998
+ const record = chunk.replace(/^\s+/, "");
2999
+ if (!record) continue;
3000
+ const [sha, subject, ...trailerFields] = record.split(FIELD_SEPARATOR);
3001
+ if (!sha || !/^[0-9a-f]{40,64}$/.test(sha)) continue;
3002
+ const sessions = new Set(trailerFields.flatMap((field) => field.split("\n"))).values().map((value) => value.trim()).filter((value) => sessionIdSchema.safeParse(value).success).toArray();
3003
+ commits.push({
3004
+ sha,
3005
+ subject: subject ?? "",
3006
+ sessions
3007
+ });
3008
+ }
3009
+ return commits;
3010
+ }
3011
+ /** Fetches a pull request head and collects its unique session trailers, returning an empty list if Git cannot resolve the branch. */
3012
+ async function prScanTrailerSessions(cwd, commit, pr) {
3013
+ if (!(await gitAt(cwd, [
3014
+ "fetch",
3015
+ "--quiet",
3016
+ "origin",
3017
+ `refs/pull/${pr}/head`
3018
+ ], { allowFailure: true })).ok) return [];
3019
+ let revListRes = await gitAt(cwd, [
3020
+ "rev-list",
3021
+ "FETCH_HEAD",
3022
+ "--not",
3023
+ `${commit}^`
3024
+ ], { allowFailure: true });
3025
+ if (!revListRes.ok) revListRes = await gitAt(cwd, [
3026
+ "rev-list",
3027
+ "FETCH_HEAD",
3028
+ "--not",
3029
+ `${commit}~1`
3030
+ ], { allowFailure: true });
3031
+ if (!revListRes.ok) revListRes = await gitAt(cwd, ["rev-list", "FETCH_HEAD"], { allowFailure: true });
3032
+ if (!revListRes.ok) return [];
3033
+ const branchShas = revListRes.stdout.trim().split(/\s+/).filter(Boolean);
3034
+ const prSessions = [];
3035
+ for (const branchSha of branchShas) {
3036
+ const sessionsOnSha = await readTrailerSessions(cwd, branchSha);
3037
+ for (const s of sessionsOnSha) if (!prSessions.includes(s)) prSessions.push(s);
3038
+ }
3039
+ return prSessions;
3040
+ }
3041
+ /** Reports whether a path resolves to a regular file, returning false for filesystem errors. */
3042
+ function isFile(targetPath) {
3043
+ try {
3044
+ return statSync(targetPath).isFile();
3045
+ } catch {
3046
+ return false;
3047
+ }
3048
+ }
3049
+ function isDirectory(targetPath) {
3050
+ try {
3051
+ return statSync(targetPath).isDirectory();
3052
+ } catch {
3053
+ return false;
3054
+ }
3055
+ }
3056
+ /** Collects regular files recursively, retaining files found before a directory read fails. */
3057
+ function listFilesRecursive(dirPath) {
3058
+ const files = [];
3059
+ try {
3060
+ const entries = readdirSync(dirPath, { withFileTypes: true });
3061
+ for (const entry of entries) {
3062
+ const full = path.join(dirPath, entry.name);
3063
+ if (entry.isDirectory()) files.push(...listFilesRecursive(full));
3064
+ else if (entry.isFile()) files.push(full);
3065
+ }
3066
+ } catch {}
3067
+ return files;
3068
+ }
3069
+ //#endregion
3070
+ //#region ../trace-core/src/opencode-trace-export.ts
3071
+ const OPENCODE_SESSION_RECORD = "opencode_session";
3072
+ const OPENCODE_MESSAGE_RECORD = "opencode_message";
3073
+ /** OpenCode session ids are `ses_` followed by a sortable id. */
3074
+ function isOpenCodeSessionId(sessionId) {
3075
+ return sessionId.startsWith("ses_");
3076
+ }
3077
+ /**
3078
+ * OpenCode keeps sessions in its own database rather than a transcript file.
3079
+ * `opencode export` renders one as JSON; this writes that as the JSONL trace
3080
+ * the rest of the pipeline expects: one session header record, then one
3081
+ * record per message with its parts stripped of provider metadata and inline
3082
+ * file data. Returns null when OpenCode has no such session.
3083
+ */
3084
+ async function exportOpenCodeTrace(input) {
3085
+ mkdirSync(input.root, { recursive: true });
3086
+ const destination = path.join(input.root, `${input.sessionId}.jsonl`);
3087
+ const staging = `${destination}.tmp-${process.pid}`;
3088
+ const raw = await runOpenCodeExport(input.sessionId, `${staging}.json`);
3089
+ if (raw === null) return null;
3090
+ const exported = jsonObject(parseJsonText(raw));
3091
+ const info = jsonObject(exported?.info);
3092
+ if (!exported || !info) throw new Error(`opencode export ${input.sessionId} returned no session info.`);
3093
+ const messages = jsonArray(exported.messages);
3094
+ if (!messages) throw new Error(`opencode export ${input.sessionId} returned no message list.`);
3095
+ const header = {
3096
+ type: OPENCODE_SESSION_RECORD,
3097
+ ...pick(info, [
3098
+ "id",
3099
+ "parentID",
3100
+ "directory",
3101
+ "title",
3102
+ "version",
3103
+ "time"
3104
+ ])
3105
+ };
3106
+ const lines = [JSON.stringify(header), ...messages.map((message) => JSON.stringify(traceMessageRecord(message)))];
3107
+ writeFileAtomic(destination, `${lines.join("\n")}\n`, "utf8");
3108
+ return destination;
3109
+ }
3110
+ /**
3111
+ * Runs `opencode export` with stdout on a file. OpenCode exits before a pipe
3112
+ * drains, so a piped export is cut off at 128 KiB; a file descriptor is not.
3113
+ * Resolves to the exported JSON, or null when OpenCode has no such session.
3114
+ */
3115
+ function runOpenCodeExport(sessionId, stdoutPath) {
3116
+ const fd = openSync(stdoutPath, "w");
3117
+ return new Promise((resolve, reject) => {
3118
+ const child = spawn("opencode", [
3119
+ "export",
3120
+ sessionId,
3121
+ "--pure"
3122
+ ], {
3123
+ stdio: [
3124
+ "ignore",
3125
+ fd,
3126
+ "pipe"
3127
+ ],
3128
+ windowsHide: true
3129
+ });
3130
+ let stderr = "";
3131
+ const stderrStream = child.stderr;
3132
+ if (!stderrStream) throw new Error("opencode export did not open a stderr pipe.");
3133
+ stderrStream.setEncoding("utf8");
3134
+ stderrStream.on("data", (chunk) => {
3135
+ stderr += chunk;
3136
+ });
3137
+ child.once("error", (error) => {
3138
+ closeSync(fd);
3139
+ rmSync(stdoutPath, { force: true });
3140
+ reject(/* @__PURE__ */ new Error(`opencode export ${sessionId} failed: ${error.message}`));
3141
+ });
3142
+ child.once("exit", (code) => {
3143
+ closeSync(fd);
3144
+ try {
3145
+ if (code === 0) resolve(readFileSync(stdoutPath, "utf8"));
3146
+ else if (stderr.includes("Session not found")) resolve(null);
3147
+ else reject(/* @__PURE__ */ new Error(`opencode export ${sessionId} exited with ${code}: ${stderr.trim()}`));
3148
+ } finally {
3149
+ rmSync(stdoutPath, { force: true });
3150
+ }
3151
+ });
3152
+ });
3153
+ }
3154
+ function traceMessageRecord(message) {
3155
+ const record = jsonObject(message);
3156
+ const info = jsonObject(record?.info);
3157
+ if (!record || !info) throw new Error("opencode export returned a message without info.");
3158
+ const parts = jsonArray(record.parts) ?? [];
3159
+ return {
3160
+ type: OPENCODE_MESSAGE_RECORD,
3161
+ info: pick(info, [
3162
+ "id",
3163
+ "sessionID",
3164
+ "role",
3165
+ "time",
3166
+ "error",
3167
+ "agent",
3168
+ "modelID",
3169
+ "providerID"
3170
+ ]),
3171
+ parts: parts.flatMap((part) => {
3172
+ const object = jsonObject(part);
3173
+ return object ? [tracePart(object)] : [];
3174
+ })
3175
+ };
3176
+ }
3177
+ function tracePart(part) {
3178
+ const common = pick(part, ["id", "type"]);
3179
+ switch (part.type) {
3180
+ case "text": return {
3181
+ ...common,
3182
+ ...pick(part, [
3183
+ "text",
3184
+ "ignored",
3185
+ "synthetic"
3186
+ ])
3187
+ };
3188
+ case "reasoning": return {
3189
+ ...common,
3190
+ ...pick(part, ["text", "time"])
3191
+ };
3192
+ case "tool": {
3193
+ const state = jsonObject(part.state);
3194
+ return {
3195
+ ...common,
3196
+ ...pick(part, ["tool", "callID"]),
3197
+ state: state ? pick(state, [
3198
+ "status",
3199
+ "input",
3200
+ "output",
3201
+ "error",
3202
+ "title",
3203
+ "time"
3204
+ ]) : {}
3205
+ };
3206
+ }
3207
+ case "file": return {
3208
+ ...common,
3209
+ ...pick(part, ["mime", "filename"])
3210
+ };
3211
+ case "patch": return {
3212
+ ...common,
3213
+ ...pick(part, ["hash", "files"])
3214
+ };
3215
+ case "compaction": return {
3216
+ ...common,
3217
+ ...pick(part, ["auto"])
3218
+ };
3219
+ default: return common;
3220
+ }
3221
+ }
3222
+ function pick(value, keys) {
3223
+ const picked = {};
3224
+ for (const key of keys) {
3225
+ const entry = value[key];
3226
+ if (entry !== void 0) picked[key] = entry;
3227
+ }
3228
+ return picked;
3229
+ }
3230
+ //#endregion
3231
+ //#region ../trace-core/src/trace-local-sessions.ts
3232
+ /** Finds a local harness transcript and subagents, exporting OpenCode data when needed; it never publishes traces. */
3233
+ async function findLocalTrace(sessionId) {
3234
+ if (!sessionIdSchema.safeParse(sessionId).success) return null;
3235
+ const claudeRoot = traceEnvValue("TRACE_LOCAL_TRACE_ROOT") || path.join(homedir(), ".claude", "projects");
3236
+ const codexRoot = codexSessionsRoot();
3237
+ const piRoot = traceEnvValue("TRACE_PI_SESSIONS_ROOT") || path.join(homedir(), ".pi", "agent", "sessions");
3238
+ let harness = "claude";
3239
+ let tracePath = findClaudeTrace(claudeRoot, sessionId);
3240
+ if (!tracePath) {
3241
+ tracePath = findCodexTrace(codexRoot, sessionId);
3242
+ if (tracePath) harness = "codex";
3243
+ }
3244
+ if (!tracePath) {
3245
+ tracePath = findPiTrace(piRoot, sessionId);
3246
+ if (tracePath) harness = "pi";
3247
+ }
3248
+ if (!tracePath) {
3249
+ if (existsSync(claudeRoot) && isFile(path.join(claudeRoot, `${sessionId}.jsonl`))) {
3250
+ tracePath = path.join(claudeRoot, `${sessionId}.jsonl`);
3251
+ harness = "claude";
3252
+ }
3253
+ }
3254
+ if (!tracePath && isOpenCodeSessionId(sessionId)) {
3255
+ tracePath = await exportOpenCodeTrace({
3256
+ sessionId,
3257
+ root: process.env.TRACE_OPENCODE_TRACES_ROOT || path.join(devReviewHome(), "opencode-traces")
3258
+ });
3259
+ if (tracePath) harness = "opencode";
3260
+ }
3261
+ if (!tracePath) return null;
3262
+ const subagentPaths = findSubagentBlobs(tracePath);
3263
+ return {
3264
+ tracePath,
3265
+ harness,
3266
+ subagentPaths
3267
+ };
3268
+ }
3269
+ function findClaudeTrace(root, sessionId) {
3270
+ const fileName = `${sessionId}.jsonl`;
3271
+ if (!existsSync(root)) return null;
3272
+ const direct = path.join(root, fileName);
3273
+ if (isFile(direct)) return direct;
3274
+ try {
3275
+ const entries = readdirSync(root, { withFileTypes: true });
3276
+ for (const entry of entries) if (entry.isDirectory()) {
3277
+ const candidate = path.join(root, entry.name, fileName);
3278
+ if (isFile(candidate)) return candidate;
3279
+ }
3280
+ } catch {}
3281
+ return null;
3282
+ }
3283
+ function findCodexTrace(root, sessionId) {
3284
+ if (!existsSync(root)) return null;
3285
+ const suffix = `-${sessionId}.jsonl`;
3286
+ return listFilesRecursive(root).sort().find((entry) => {
3287
+ const name = path.basename(entry);
3288
+ return name.startsWith("rollout-") && name.endsWith(suffix);
3289
+ }) ?? null;
3290
+ }
3291
+ function findPiTrace(root, sessionId) {
3292
+ if (!existsSync(root)) return null;
3293
+ try {
3294
+ const entries = readdirSync(root, { withFileTypes: true });
3295
+ for (const entry of entries) if (entry.isDirectory()) {
3296
+ const subDir = path.join(root, entry.name);
3297
+ for (const file of readdirSync(subDir)) if (file.endsWith(`_${sessionId}.jsonl`) || file === `${sessionId}.jsonl`) {
3298
+ const candidate = path.join(subDir, file);
3299
+ if (isFile(candidate)) return candidate;
3300
+ }
3301
+ } else if (entry.isFile()) {
3302
+ if (entry.name.endsWith(`_${sessionId}.jsonl`) || entry.name === `${sessionId}.jsonl`) return path.join(root, entry.name);
3303
+ }
3304
+ } catch {}
3305
+ return null;
3306
+ }
3307
+ function findSubagentBlobs(tracePath) {
3308
+ const results = [];
3309
+ const stem = tracePath.endsWith(".jsonl") ? tracePath.slice(0, -6) : tracePath;
3310
+ const subagentsDir = path.join(stem, "subagents");
3311
+ if (existsSync(subagentsDir)) try {
3312
+ for (const name of readdirSync(subagentsDir)) if (name.endsWith(".jsonl")) results.push({
3313
+ name,
3314
+ path: path.join(subagentsDir, name)
3315
+ });
3316
+ } catch {}
3317
+ if (existsSync(stem)) try {
3318
+ for (const childEntry of readdirSync(stem, { withFileTypes: true })) if (childEntry.isDirectory() && childEntry.name !== "subagents") {
3319
+ const childDir = path.join(stem, childEntry.name);
3320
+ for (const runEntry of readdirSync(childDir, { withFileTypes: true })) if (runEntry.isDirectory() && runEntry.name.startsWith("run-")) {
3321
+ const runFile = path.join(childDir, runEntry.name, "session.jsonl");
3322
+ if (isFile(runFile)) {
3323
+ const shortChild = childEntry.name.slice(0, 8);
3324
+ results.push({
3325
+ name: `pi-${runEntry.name}-${shortChild}.jsonl`,
3326
+ path: runFile
3327
+ });
3328
+ }
3329
+ }
3330
+ }
3331
+ } catch {}
3332
+ return results.sort((a, b) => a.name.localeCompare(b.name));
3333
+ }
3334
+ /** Reads a trace setting through the existing environment and saved-file resolver without changing configuration. */
3335
+ function traceEnvValue(name) {
3336
+ return traceEnvValue$1(name);
3337
+ }
3338
+ /** Resolves the Codex session directory using the trace override or process CODEX_HOME, without reading CODEX_HOME from the trace env file. */
3339
+ function codexSessionsRoot() {
3340
+ const codexHome = process.env.CODEX_HOME;
3341
+ return traceEnvValue("TRACE_CODEX_SESSIONS_ROOT") || path.join(codexHome ? path.resolve(codexHome) : path.join(homedir(), ".codex"), "sessions");
3342
+ }
3343
+ //#endregion
3344
+ //#region ../trace-core/src/trace-read.ts
3345
+ const STORE_COMMIT_LOOKUP_LIMIT = 30;
3346
+ const REMOTE_HEAD_TTL_MS = 15e3;
3347
+ const TUTORIAL_TRACE_SESSION_ID = "review-tutorial-checkout";
3348
+ const lastCheckedTimes = /* @__PURE__ */ new Map();
3349
+ /**
3350
+ * The store to read from or publish to: an explicit override, or the
3351
+ * machine's selected store. Null means no remote storage is configured.
3352
+ */
3353
+ async function storageFor(storage, cwd) {
3354
+ return storage === void 0 ? resolveTraceStorage({ cwd }) : storage;
3355
+ }
3356
+ /**
3357
+ * A remote lookup, or null when the store could not be reached. A refusal
3358
+ * propagates: the caller must show nothing, not "nothing here".
3359
+ */
3360
+ async function reachable(lookup) {
3361
+ try {
3362
+ return await lookup();
3363
+ } catch (error) {
3364
+ if (error instanceof TraceStorageUnavailableError) return null;
3365
+ throw error;
3366
+ }
3367
+ }
3368
+ function freshnessKey(storage, sessionId, traceName) {
3369
+ return `${storage.cacheIdentity()}/${sessionId}/${traceName}`;
3370
+ }
3371
+ /** Describes sessions for a change range using trailers, then bounded store-index and pull-request fallbacks when none are found. */
3372
+ async function listReviewTraceSessions(input) {
3373
+ const storage = await storageFor(input.storage, input.rootPath);
3374
+ const sessions = /* @__PURE__ */ new Map();
3375
+ const commits = await commitsWithTrailers(input);
3376
+ for (const commit of commits) for (const sessionId of commit.sessions) {
3377
+ const existing = sessions.get(sessionId);
3378
+ if (existing) existing.commits.push({
3379
+ sha: commit.sha,
3380
+ subject: commit.subject
3381
+ });
3382
+ else sessions.set(sessionId, {
3383
+ sessionId,
3384
+ commits: [{
3385
+ sha: commit.sha,
3386
+ subject: commit.subject
3387
+ }]
3388
+ });
3389
+ }
3390
+ if (sessions.size === 0 && commits.length <= STORE_COMMIT_LOOKUP_LIMIT) await addSessionsFromStoreIndex(storage, commits, sessions);
3391
+ if (sessions.size === 0 && commits.length <= STORE_COMMIT_LOOKUP_LIMIT) await addSessionsFromPrScan(input.rootPath, commits, sessions);
3392
+ const descriptors = [];
3393
+ for (const ref of sessions.values()) {
3394
+ const desc = await describeTraceSession(ref, storage);
3395
+ descriptors.push(desc);
3396
+ }
3397
+ return descriptors;
3398
+ }
3399
+ /** Describes availability from compatible local copies and the selected store; store refusals propagate. */
3400
+ async function describeTraceSession(ref, storage) {
3401
+ const store = await storageFor(storage);
3402
+ const local = findNormalizedTraceFile(ref.sessionId, "main", store);
3403
+ const normalized = local ? readNormalizedTrace(local, store) : null;
3404
+ const remote = store ? await reachable(() => store.describeObject(ref.sessionId, "main")) : null;
3405
+ const available = normalized !== null || remote !== null && remote.size > 0;
3406
+ const harness = normalized?.metadata.harness ?? "unknown";
3407
+ const subagents = await listSessionSubagents(ref.sessionId, store);
3408
+ return {
3409
+ sessionId: ref.sessionId,
3410
+ harness,
3411
+ available,
3412
+ source: available ? "r2" : null,
3413
+ notSynced: !available,
3414
+ subagents,
3415
+ commits: ref.commits
3416
+ };
3417
+ }
3418
+ /** Loads the offline tutorial or a store-compatible normalized trace, preserving freshness and offline fallback status; store refusals propagate. */
3419
+ async function loadReviewAgentTrace(input) {
3420
+ const { sessionId, trace } = input;
3421
+ if (sessionId === "review-tutorial-checkout") {
3422
+ if (trace && trace !== "main") return null;
3423
+ const { loadTutorialTrace } = await import("./tutorial-trace-DrBvkaeL.js");
3424
+ return loadTutorialTrace();
3425
+ }
3426
+ if (!sessionIdSchema.safeParse(sessionId).success) return null;
3427
+ const traceName = trace ?? "main";
3428
+ const storage = await storageFor(input.storage, input.cwd);
3429
+ const requestedRepo = input.repo ? normalizeRepo(input.repo) : null;
3430
+ let scope = storage ? storage.cacheScope(requestedRepo) : requestedRepo;
3431
+ let normalizedPath = scope ? normalizedTracePath(scope, sessionId, traceName) : findNormalizedTraceFile(sessionId, traceName, storage);
3432
+ let normalized = normalizedPath ? readNormalizedTrace(normalizedPath, storage) : null;
3433
+ const now = Date.now();
3434
+ const checkKey = storage ? freshnessKey(storage, sessionId, traceName) : null;
3435
+ const lastChecked = checkKey ? lastCheckedTimes.get(checkKey) ?? 0 : 0;
3436
+ const canUseWithoutCheck = normalized && !input.refresh && now - lastChecked < REMOTE_HEAD_TTL_MS;
3437
+ if (storage && checkKey && !canUseWithoutCheck) {
3438
+ if (storage.kind === "hosted") lastCheckedTimes.delete(checkKey);
3439
+ let remote;
3440
+ try {
3441
+ remote = await storage.describeObject(sessionId, traceName);
3442
+ } catch (error) {
3443
+ if (error instanceof TraceStorageDeniedError) return null;
3444
+ if (!(error instanceof TraceStorageUnavailableError)) throw error;
3445
+ return normalized ? loadedNormalizedTrace(normalized, input.commits, "offline") : null;
3446
+ }
3447
+ if (storage.kind === "hosted" && remote === null) {
3448
+ if (normalized && normalizedPath) rmSync(normalizedPath, { force: true });
3449
+ lastCheckedTimes.delete(checkKey);
3450
+ return null;
3451
+ }
3452
+ if (storage.kind !== "hosted") lastCheckedTimes.set(checkKey, now);
3453
+ if (remote !== null && (!normalized || !cacheIsCurrent(storage, normalized, remote))) {
3454
+ if (!scope) {
3455
+ let repo = requestedRepo;
3456
+ if (!repo && input.cwd) repo = await inferRepoFromGit(input.cwd).catch(() => null);
3457
+ if (!repo) {
3458
+ const meta = await storage.sessionMeta(sessionId);
3459
+ if (meta?.repo) try {
3460
+ repo = parseRepo(meta.repo);
3461
+ } catch {
3462
+ repo = null;
3463
+ }
3464
+ }
3465
+ scope = storage.cacheScope(repo);
3466
+ }
3467
+ if (!scope) return normalized ? loadedNormalizedTrace(normalized, input.commits, "stale") : null;
3468
+ normalizedPath = normalizedTracePath(scope, sessionId, traceName);
3469
+ const fresh = await materializeNormalizedTrace({
3470
+ storage,
3471
+ sessionId,
3472
+ traceName,
3473
+ normalizedPath,
3474
+ repository: requestedRepo ? traceRepoName(requestedRepo) : normalized?.metadata.repository ?? traceRepoName(scope)
3475
+ });
3476
+ if (!fresh) return normalized ? loadedNormalizedTrace(normalized, input.commits, "stale") : null;
3477
+ normalized = fresh;
3478
+ }
3479
+ if (storage.kind === "hosted") lastCheckedTimes.set(checkKey, now);
3480
+ }
3481
+ if (!normalized) return null;
3482
+ return loadedNormalizedTrace(normalized, input.commits, storage?.kind === "hosted" && (await storage.readiness()).ready === false ? "offline" : "current");
3483
+ }
3484
+ /**
3485
+ * Whether a saved copy still matches the stored object. S3 storage can
3486
+ * only compare sizes and objects only grow; hosted storage names content
3487
+ * exactly, so an equal-size or smaller replacement is still seen.
3488
+ */
3489
+ function cacheIsCurrent(storage, normalized, remote) {
3490
+ if (storage.kind === "s3") return remote.size <= normalized.metadata.source.bytes;
3491
+ return normalized.metadata.source.contentId === remote.contentId;
3492
+ }
3493
+ function loadedNormalizedTrace(normalized, commits, cacheStatus) {
3494
+ const metadata = normalized.metadata;
3495
+ const descriptor = {
3496
+ sessionId: metadata.session,
3497
+ harness: metadata.harness,
3498
+ available: true,
3499
+ source: "r2",
3500
+ subagents: metadata.subagents,
3501
+ commits: commits ?? []
3502
+ };
3503
+ return {
3504
+ parserVersion: metadata.parserVersion,
3505
+ descriptor,
3506
+ trace: {
3507
+ harness: metadata.harness,
3508
+ title: metadata.title,
3509
+ events: normalized.events.map((record) => record.event),
3510
+ startedAt: metadata.startedAt,
3511
+ endedAt: metadata.endedAt,
3512
+ activeMs: metadata.activeMs,
3513
+ userTurns: metadata.userTurns,
3514
+ toolCalls: metadata.toolCalls
3515
+ },
3516
+ subagents: metadata.subagents,
3517
+ traceName: metadata.trace === "main" ? null : metadata.trace,
3518
+ cacheStatus
3519
+ };
3520
+ }
3521
+ async function materializeNormalizedTrace(input) {
3522
+ const rawTempPath = path.join(tmpdir(), `review-trace-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`);
3523
+ try {
3524
+ let downloaded;
3525
+ try {
3526
+ downloaded = await input.storage.downloadObject(input.sessionId, input.traceName, rawTempPath);
3527
+ } catch (error) {
3528
+ if (error instanceof TraceStorageDeniedError) throw error;
3529
+ if (error instanceof TraceStorageUnavailableError) return null;
3530
+ process.stderr.write(`Trace store download failed for ${input.traceName}: ${errorMessage(error)}\n`);
3531
+ return null;
3532
+ }
3533
+ if (!downloaded) return null;
3534
+ const parsed = parseAgentTraceJsonl(readFileSync(rawTempPath, "utf8"), { isSubagent: input.traceName !== "main" });
3535
+ const subagents = await listSessionSubagents(input.sessionId, input.storage);
3536
+ const normalized = {
3537
+ metadata: {
3538
+ type: "metadata",
3539
+ version: 1,
3540
+ parserVersion: "1",
3541
+ repository: input.repository,
3542
+ session: input.sessionId,
3543
+ trace: input.traceName,
3544
+ harness: parsed.harness,
3545
+ title: parsed.title,
3546
+ startedAt: parsed.startedAt,
3547
+ endedAt: parsed.endedAt,
3548
+ activeMs: parsed.activeMs,
3549
+ userTurns: parsed.userTurns,
3550
+ toolCalls: parsed.toolCalls,
3551
+ subagents,
3552
+ source: {
3553
+ r2Key: legacyObjectKey(input.sessionId, input.traceName),
3554
+ bytes: downloaded.size,
3555
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
3556
+ contentId: downloaded.contentId,
3557
+ storage: input.storage.cacheIdentity()
3558
+ }
3559
+ },
3560
+ events: parsed.events.map((event, index) => ({
3561
+ type: "event",
3562
+ index,
3563
+ kind: event.kind,
3564
+ text: extractTraceEventText(event),
3565
+ event
3566
+ }))
3567
+ };
3568
+ writeNormalizedTraceAtomic(input.normalizedPath, normalized);
3569
+ return normalized;
3570
+ } finally {
3571
+ rmSync(rawTempPath, { force: true });
3572
+ }
3573
+ }
3574
+ /** Refreshes requested sessions into the local corpus and reports available paths and unavailable sessions without publishing traces. */
3575
+ async function pullReviewTraceCorpus(input) {
3576
+ const repository = `${input.repo.owner}/${input.repo.repo}`;
3577
+ const corpusRoot = traceSearchCorpusDir();
3578
+ const storage = await storageFor(input.storage, input.cwd);
3579
+ const scope = (storage ? storage.cacheScope(input.repo) : null) ?? input.repo;
3580
+ const sessions = [];
3581
+ const unavailableSessions = [];
3582
+ const paths = [];
3583
+ for (const sessionRef of input.sessions) {
3584
+ const main = await loadReviewAgentTrace({
3585
+ sessionId: sessionRef.id,
3586
+ repo: input.repo,
3587
+ refresh: true,
3588
+ storage
3589
+ });
3590
+ if (!main) {
3591
+ unavailableSessions.push(sessionRef.id);
3592
+ continue;
3593
+ }
3594
+ paths.push(normalizedTracePath(scope, sessionRef.id, "main"));
3595
+ let traceCount = 1;
3596
+ let eventCount = main.trace.events.length;
3597
+ if (!input.mainOnly) for (const traceName of sessionRef.traces ?? main.subagents) {
3598
+ const subagent = await loadReviewAgentTrace({
3599
+ sessionId: sessionRef.id,
3600
+ trace: traceName,
3601
+ repo: input.repo,
3602
+ refresh: true,
3603
+ storage
3604
+ });
3605
+ if (subagent) {
3606
+ paths.push(normalizedTracePath(scope, sessionRef.id, traceName));
3607
+ traceCount += 1;
3608
+ eventCount += subagent.trace.events.length;
3609
+ }
3610
+ }
3611
+ sessions.push({
3612
+ session: sessionRef.id,
3613
+ traces: traceCount,
3614
+ events: eventCount,
3615
+ files: traceCount
3616
+ });
3617
+ }
3618
+ return {
3619
+ corpusRoot,
3620
+ repository,
3621
+ sessions,
3622
+ unavailableSessions,
3623
+ events: sessions.reduce((total, session) => total + session.events, 0),
3624
+ files: sessions.reduce((total, session) => total + session.files, 0),
3625
+ paths
3626
+ };
3627
+ }
3628
+ /** Resolves sessions by commit trailers, then the store index, then a pull-request scan, retaining the lookup source. */
3629
+ async function lookupReviewTraceCommit(input) {
3630
+ const storage = await storageFor(input.storage, input.cwd);
3631
+ const commit = await resolveCommitSha(input.cwd, input.sha);
3632
+ const trailerSessions = await readTrailerSessions(input.cwd, commit);
3633
+ const pr = await readSubjectPullNumber(input.cwd, commit);
3634
+ if (trailerSessions.length > 0) {
3635
+ const sessionMeta = await enrichSessionMeta(trailerSessions, storage);
3636
+ const result = {
3637
+ commit,
3638
+ sessions: trailerSessions,
3639
+ pr,
3640
+ branch: null,
3641
+ source: "trailer"
3642
+ };
3643
+ if (sessionMeta) result.session_meta = sessionMeta;
3644
+ return result;
3645
+ }
3646
+ const indexed = storage ? await storage.sessionsForCommit(commit) : null;
3647
+ if (indexed && indexed.sessions.length > 0) {
3648
+ const sessions = deduplicateStrings(indexed.sessions);
3649
+ const sessionMeta = await enrichSessionMeta(sessions, storage);
3650
+ const result = {
3651
+ commit,
3652
+ sessions,
3653
+ pr: indexed.pr ?? pr,
3654
+ branch: indexed.branch ?? null,
3655
+ source: "index"
3656
+ };
3657
+ if (sessionMeta) result.session_meta = sessionMeta;
3658
+ return result;
3659
+ }
3660
+ if (pr !== null) {
3661
+ const prSessions = await prScanTrailerSessions(input.cwd, commit, pr);
3662
+ if (prSessions.length > 0) {
3663
+ const sessionMeta = await enrichSessionMeta(prSessions, storage);
3664
+ const result = {
3665
+ commit,
3666
+ sessions: prSessions,
3667
+ pr,
3668
+ branch: null,
3669
+ source: "pr-scan"
3670
+ };
3671
+ if (sessionMeta) result.session_meta = sessionMeta;
3672
+ return result;
3673
+ }
3674
+ }
3675
+ return {
3676
+ commit,
3677
+ sessions: [],
3678
+ pr,
3679
+ branch: null,
3680
+ source: "none"
3681
+ };
3682
+ }
3683
+ async function enrichSessionMeta(sessions, storage) {
3684
+ if (!storage || sessions.length === 0) return void 0;
3685
+ const detail = {};
3686
+ for (const session of sessions) {
3687
+ const meta = await storage.sessionMeta(session);
3688
+ if (meta) detail[session] = {
3689
+ repo: meta.repo,
3690
+ branch: meta.branch,
3691
+ pr: meta.pr,
3692
+ author: meta.author
3693
+ };
3694
+ }
3695
+ return Object.keys(detail).length > 0 ? detail : void 0;
3696
+ }
3697
+ /** Resolves sessions for file blame or history commits, preserving their resolution order and lookup sources. */
3698
+ async function lookupReviewTraceBlame(input) {
3699
+ if (!input.file) throw new Error("File path is required.");
3700
+ if (input.lines) {
3701
+ const match = /^(\d+)(?:,(\d+))?$/.exec(input.lines.trim());
3702
+ if (!match) throw new Error(`Invalid line range "${input.lines}". Expected start,end or single line number.`);
3703
+ const start = parseInt(match[1], 10);
3704
+ const end = match[2] ? parseInt(match[2], 10) : start;
3705
+ if (start <= 0 || end < start) throw new Error(`Invalid line range "${input.lines}". Start must be >= 1 and end >= start.`);
3706
+ }
3707
+ let shas = [];
3708
+ if (input.history) {
3709
+ const spec = input.lines ? `${input.lines}:${input.file}` : `1,$:${input.file}`;
3710
+ const res = await gitAt(input.cwd, [
3711
+ "log",
3712
+ "-L",
3713
+ spec,
3714
+ "--format=%H",
3715
+ "-s"
3716
+ ], { allowFailure: true });
3717
+ if (!res.ok) throw new Error(res.stderr.trim() || `git log -L failed for ${input.file}`);
3718
+ shas = deduplicateStrings(res.stdout.trim().split(/\s+/).filter(Boolean));
3719
+ } else {
3720
+ const args = ["blame", "--line-porcelain"];
3721
+ if (input.lines) args.push("-L", input.lines);
3722
+ args.push("--", input.file);
3723
+ const res = await gitAt(input.cwd, args, { allowFailure: true });
3724
+ if (!res.ok) throw new Error(res.stderr.trim() || `git blame failed for ${input.file}`);
3725
+ const collected = [];
3726
+ for (const line of res.stdout.split("\n")) {
3727
+ const parts = line.trim().split(/\s+/);
3728
+ if (parts.length >= 3 && /^[0-9a-f]{40,64}$/i.test(parts[0])) {
3729
+ if (!collected.includes(parts[0])) collected.push(parts[0]);
3730
+ }
3731
+ }
3732
+ shas = collected;
3733
+ }
3734
+ const storage = await storageFor(input.storage, input.cwd);
3735
+ const resolutions = [];
3736
+ for (const sha of shas) resolutions.push(await lookupReviewTraceCommit({
3737
+ cwd: input.cwd,
3738
+ sha,
3739
+ storage
3740
+ }));
3741
+ return {
3742
+ file: input.file,
3743
+ range: input.lines ?? null,
3744
+ history: Boolean(input.history),
3745
+ resolutions
3746
+ };
3747
+ }
3748
+ /** Clears saved trace environment values and remote freshness timestamps without deleting normalized copies. */
3749
+ function clearTraceEnvCache() {
3750
+ clearTraceEnvCache$1();
3751
+ lastCheckedTimes.clear();
3752
+ }
3753
+ /** Subagent traces known locally or in the store, by name without ".jsonl". */
3754
+ async function listSessionSubagents(sessionId, storage) {
3755
+ const subagents = /* @__PURE__ */ new Set();
3756
+ for (const localSessionDir of findNormalizedSessionDirs(sessionId)) try {
3757
+ for (const entry of readdirSync(localSessionDir)) if (entry.endsWith(".jsonl") && entry !== "main.jsonl") subagents.add(entry.slice(0, -6));
3758
+ } catch {}
3759
+ const store = await storageFor(storage);
3760
+ if (store) {
3761
+ const names = await reachable(() => store.listSubagents(sessionId));
3762
+ for (const name of names ?? []) subagents.add(name);
3763
+ }
3764
+ return [...subagents].sort();
3765
+ }
3766
+ async function addSessionsFromPrScan(rootPath, commits, sessions) {
3767
+ const scannedPrs = /* @__PURE__ */ new Set();
3768
+ for (const commit of commits) {
3769
+ const pr = subjectPullNumber(commit.subject);
3770
+ if (pr === null || scannedPrs.has(pr)) continue;
3771
+ scannedPrs.add(pr);
3772
+ const prSessions = await prScanTrailerSessions(rootPath, commit.sha, pr);
3773
+ for (const sessionId of prSessions) {
3774
+ const existing = sessions.get(sessionId);
3775
+ if (existing) existing.commits.push({
3776
+ sha: commit.sha,
3777
+ subject: commit.subject
3778
+ });
3779
+ else sessions.set(sessionId, {
3780
+ sessionId,
3781
+ commits: [{
3782
+ sha: commit.sha,
3783
+ subject: commit.subject
3784
+ }]
3785
+ });
3786
+ }
3787
+ }
3788
+ }
3789
+ async function addSessionsFromStoreIndex(storage, commits, sessions) {
3790
+ if (!storage) return;
3791
+ for (const commit of commits) {
3792
+ const indexed = await storage.sessionsForCommit(commit.sha);
3793
+ if (!indexed) continue;
3794
+ for (const sessionId of indexed.sessions) {
3795
+ const existing = sessions.get(sessionId);
3796
+ if (existing) existing.commits.push({
3797
+ sha: commit.sha,
3798
+ subject: commit.subject
3799
+ });
3800
+ else sessions.set(sessionId, {
3801
+ sessionId,
3802
+ commits: [{
3803
+ sha: commit.sha,
3804
+ subject: commit.subject
3805
+ }]
3806
+ });
3807
+ }
3808
+ }
3809
+ }
3810
+ //#endregion
3811
+ //#region ../trace-core/src/trace-sync.ts
3812
+ /** Publishes the discovered local transcript and subagents through the selected store, preserving its consent and provenance checks. */
3813
+ async function syncReviewTrace(input) {
3814
+ const sessionId = input.sessionId.trim();
3815
+ if (!sessionIdSchema.safeParse(sessionId).success) throw new Error("Session id must be 8-128 characters of letters, digits, dots, dashes, or underscores.");
3816
+ const workDir = input.cwd ?? process.cwd();
3817
+ const storage = input.storage === void 0 ? await resolveTraceStorage({
3818
+ cwd: workDir,
3819
+ purpose: "write"
3820
+ }) : input.storage;
3821
+ if (!storage) throw new Error(`S3/R2 storage is not configured. Run \`whiteboard trace allow .\` to configure trace capture.`);
3822
+ const repo = input.repo ? parseRepo(input.repo) : await inferRepoFromGit(workDir);
3823
+ const local = await findLocalTrace(sessionId);
3824
+ if (!local) throw new Error(`No local trace found for session ${sessionId}.`);
3825
+ const { author, branch } = await readRepoMetaFields(workDir);
3826
+ const published = await storage.publish({
3827
+ sessionId,
3828
+ cwd: workDir,
3829
+ repo,
3830
+ harness: local.harness,
3831
+ files: [{
3832
+ name: "main",
3833
+ path: local.tracePath
3834
+ }, ...local.subagentPaths.map((sub) => ({
3835
+ name: sub.name,
3836
+ path: sub.path
3837
+ }))],
3838
+ commits: input.commits,
3839
+ branch,
3840
+ author
3841
+ });
3842
+ const result = {
3843
+ session: sessionId,
3844
+ repo: `${repo.owner}/${repo.repo}`,
3845
+ uploads: published.uploads
3846
+ };
3847
+ if (published.hosted) result.hosted = published.hosted;
3848
+ return result;
3849
+ }
3850
+ /** Validates a commit and associates its sessions through the selected store, failing when storage is absent. */
3851
+ async function writeReviewTraceCommitMapping(input) {
3852
+ const commit = commitShaSchema.parse(input.commit);
3853
+ const storage = await storageFor(input.storage, input.cwd);
3854
+ if (!storage) throw new Error(`Failed to write by-commit/${commit}.json.`);
3855
+ return storage.associateCommits({
3856
+ commit,
3857
+ sessions: input.sessions,
3858
+ branch: input.branch,
3859
+ resolve: async () => ({
3860
+ repo: await inferRepoFromGit(input.cwd),
3861
+ pr: await readSubjectPullNumber(input.cwd, commit)
3862
+ })
3863
+ });
3864
+ }
3865
+ //#endregion
3866
+ export { emitJsonEvent as $, traceCaptureIdentity as A, findTraceRepository as B, TraceStorageDeniedError as C, recordTraceSyncFailure as D, listTraceSyncFailures as E, inferRepoFromGit as F, readStoreAuth as G, DEFAULT_STORE_ORIGIN as H, parseRepo as I, runStoreLogout as J, requireStoreClient as K, traceRepoName as L, readCachedTraceRepositoryTarget as M, requireTraceConsent as N, TraceProvenanceError as O, resolveTraceRepositoryTarget as P, withStoreAuthorization as Q, allowTraceRepository as R, traceNameFromObject as S, describeTraceSyncFailure as T, clearStoreAuth as U, readTraceUserConfig as V, openUrlInBrowser as W, StoreApiError as X, runStoreWhoami as Y, StoreClient as Z, isTraceStorageConfigured as _, describeTraceSession as a, selectTraceStorage as b, lookupReviewTraceBlame as c, findLocalTrace as d, failWithJsonError as et, isOpenCodeSessionId as f, describeSelection as g, extractTraceEventText as h, clearTraceEnvCache as i, gitCommonDirectory as j, recordTraceSessionProvenance as k, lookupReviewTraceCommit as l, readTrailerSessions as m, writeReviewTraceCommitMapping as n, jsonRequestedInArgv as nt, listReviewTraceSessions as o, listRepositoryTraceSessionIds as p, runStoreLogin as q, TUTORIAL_TRACE_SESSION_ID as r, loadReviewAgentTrace as s, syncReviewTrace as t, humanStream as tt, pullReviewTraceCorpus as u, loadS3TraceStorage as v, clearTraceSyncFailure as w, traceStorageExpectation as x, resolveTraceStorage as y, denyTraceRepository as z };