@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,2560 @@
1
+ import { d as gitAt, u as git } from "./src-CmdiBL20.js";
2
+ import { c as jsonString, m as isStringValue, n as isJsonObject, o as jsonObject, r as jsonArray, t as isJsonArray, u as parseJsonText } from "./src-X9phtB2j.js";
3
+ import { u as listUploadsQuerySchema, x as sessionIdSchema } from "./src-CGP5ytbV.js";
4
+ import { $ as emitJsonEvent, A as traceCaptureIdentity, B as findTraceRepository, D as recordTraceSyncFailure, E as listTraceSyncFailures, F as inferRepoFromGit, G as readStoreAuth, I as parseRepo, K as requireStoreClient, L as traceRepoName, M as readCachedTraceRepositoryTarget, O as TraceProvenanceError, R as allowTraceRepository, S as traceNameFromObject, T as describeTraceSyncFailure, V as readTraceUserConfig, X as StoreApiError, Z as StoreClient, a as describeTraceSession, b as selectTraceStorage, c as lookupReviewTraceBlame, d as findLocalTrace, et as failWithJsonError, f as isOpenCodeSessionId, g as describeSelection, h as extractTraceEventText, i as clearTraceEnvCache, j as gitCommonDirectory, k as recordTraceSessionProvenance, l as lookupReviewTraceCommit, m as readTrailerSessions, n as writeReviewTraceCommitMapping, o as listReviewTraceSessions, p as listRepositoryTraceSessionIds, s as loadReviewAgentTrace, t as syncReviewTrace, tt as humanStream, u as pullReviewTraceCorpus, w as clearTraceSyncFailure, x as traceStorageExpectation, y as resolveTraceStorage, z as denyTraceRepository } from "./review-agent-traces-DNJFYzc8.js";
5
+ import { A as devReviewHome, E as writeTraceConfigFile, N as writeFileAtomicAsync, S as readTraceConfigFile, _ as emptyTraceConfig, b as hostedCaptureEnabled, c as resolveS3Setup, d as traceEnvPath, h as TraceConfigurationError, p as traceSettingsPath, x as hostedOrigin } from "./s3-config-CAxhO9u_.js";
6
+ import { t as errorMessage } from "./error-message-OtiDonty.js";
7
+ import crypto from "node:crypto";
8
+ import { z } from "zod";
9
+ import { accessSync, constants, existsSync } from "node:fs";
10
+ import { chmod, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
11
+ import path from "node:path";
12
+ import os from "node:os";
13
+ import { execFile, spawn } from "node:child_process";
14
+ import { promisify } from "node:util";
15
+ import { InvalidArgumentError, Option } from "commander";
16
+ //#region ../trace-core/src/trace-command.ts
17
+ /** Resolves the machine paths and environment shared by one CLI run. */
18
+ function traceScope(input = {}) {
19
+ const env = input.env ?? process.env;
20
+ const homeDir = input.homeDir ?? os.homedir();
21
+ return {
22
+ homeDir,
23
+ env,
24
+ devHome: devReviewHome(env, homeDir)
25
+ };
26
+ }
27
+ /** The executable name used in trace hooks. */
28
+ function traceCliName() {
29
+ return "whiteboard";
30
+ }
31
+ /** Returns the configured trace home, then the operating-system home. */
32
+ function traceHomeDir(env = process.env) {
33
+ return env.TRACE_HOME_DIR ?? os.homedir();
34
+ }
35
+ /**
36
+ * Resolves an explicit command, the environment, an installed command, or
37
+ * the configured CLI name on PATH, in that order.
38
+ */
39
+ function resolveTraceCommand(input = {}) {
40
+ if (isStringValue(input.explicit)) return { file: input.explicit };
41
+ if (input.explicit) return input.explicit;
42
+ const env = input.env ?? process.env;
43
+ if (env.REVIEW_TRACE_COMMAND) return { file: env.REVIEW_TRACE_COMMAND };
44
+ const installed = path.join(input.homeDir ?? traceHomeDir(env), ".local", "bin", "whiteboard");
45
+ if (existsSync(installed)) return { file: installed };
46
+ return { file: (env.PATH ?? "").split(path.delimiter).filter((directory) => path.isAbsolute(directory)).map((directory) => path.join(directory, "whiteboard")).find(isLiveTraceExecutable) ?? "whiteboard" };
47
+ }
48
+ /** Quotes one value for a POSIX shell command. */
49
+ function shellQuote(value) {
50
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
51
+ }
52
+ /** Renders the executable and each leading argument as quoted shell words. */
53
+ function renderTraceCommand(command) {
54
+ return [command.file, ...command.args ?? []].map(shellQuote).join(" ");
55
+ }
56
+ /** Only stable absolute commands can retain ownership across installations. */
57
+ function isLiveTraceExecutable(file) {
58
+ if (!file || !path.isAbsolute(file)) return false;
59
+ try {
60
+ accessSync(file, constants.X_OK);
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+ /** Keep an existing working installation; a missing executable can be repaired. */
67
+ function keepTraceExecutable(existing, wanted) {
68
+ return existing !== wanted && isLiveTraceExecutable(existing);
69
+ }
70
+ /** The first shell word from our rendered Git hook command. */
71
+ function traceCommandExecutable(command) {
72
+ if (!command) return void 0;
73
+ const quoted = /^'((?:[^']|'"'"')*)'(?:\s|$)/.exec(command);
74
+ const bare = /^([^\s']+)(?:\s|$)/.exec(command);
75
+ return quoted ? quoted[1].replaceAll(`'"'"'`, "'") : bare?.[1];
76
+ }
77
+ //#endregion
78
+ //#region ../trace-core/src/agent-trace-hooks.ts
79
+ /** Every harness whose trace hook this package writes, in report order. */
80
+ const AGENT_TRACE_HOOK_AGENTS = [
81
+ "claude",
82
+ "codex",
83
+ "opencode",
84
+ "pi"
85
+ ];
86
+ const PI_EXTENSION_MARKER = "Managed by Review Desktop trace setup";
87
+ const OPENCODE_TRACE_PLUGIN_MARKER = PI_EXTENSION_MARKER;
88
+ function claudeSettingsPath(homeDir) {
89
+ return path.join(homeDir, ".claude", "settings.json");
90
+ }
91
+ function codexConfigPath(homeDir) {
92
+ return path.join(homeDir, ".codex", "config.toml");
93
+ }
94
+ function piExtensionPath(homeDir) {
95
+ return path.join(homeDir, ".pi", "agent", "extensions", "review-trace.ts");
96
+ }
97
+ /** The configuration base OpenCode reads: `$XDG_CONFIG_HOME`, else `~/.config`. */
98
+ function configHome(homeDir, env = process.env) {
99
+ return env.XDG_CONFIG_HOME?.trim() || path.join(homeDir, ".config");
100
+ }
101
+ function openCodeDirectory(homeDir, env = process.env) {
102
+ return path.join(configHome(homeDir, env), "opencode");
103
+ }
104
+ function openCodePluginPath(homeDir, env = process.env) {
105
+ return path.join(openCodeDirectory(homeDir, env), "plugins", "review-trace.ts");
106
+ }
107
+ function executableOwner(file) {
108
+ const base = path.basename(file);
109
+ return base === "review" || base === "whiteboard" ? "review" : null;
110
+ }
111
+ /** The executable of a single lifecycle command, or undefined for a shell compound. */
112
+ function traceHookCommandFile(command) {
113
+ const text = jsonString(command);
114
+ if (text === void 0) return void 0;
115
+ const match = /^(.*) trace hook (SessionStart|UserPromptSubmit|SessionEnd)$/.exec(text);
116
+ if (!match) return void 0;
117
+ const prefix = match[1];
118
+ if (/^[a-zA-Z0-9_./-]+$/.test(prefix)) return prefix;
119
+ const decoded = prefix.slice(1, -1).replaceAll(`'"'"'`, "'");
120
+ return shellQuote(decoded) === prefix ? decoded : void 0;
121
+ }
122
+ /** Identifies a single executable lifecycle command; never accepts shell compounds. */
123
+ function traceHookCommandOwner(command) {
124
+ const file = traceHookCommandFile(command);
125
+ return file === void 0 ? null : executableOwner(file);
126
+ }
127
+ function extensionCommandFile(source) {
128
+ if (!source.trimStart().startsWith(`// ${PI_EXTENSION_MARKER}`)) return void 0;
129
+ const match = /spawn\(("(?:[^"\\]|\\.)*"), \["trace", "hook"/.exec(source);
130
+ if (!match) return void 0;
131
+ return jsonString(parseJsonText(match[1]));
132
+ }
133
+ function extensionOwner(source) {
134
+ const file = extensionCommandFile(source);
135
+ return file === void 0 ? null : executableOwner(file);
136
+ }
137
+ function piExtensionSource(reviewCommand) {
138
+ return `// ${PI_EXTENSION_MARKER}
139
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
140
+ import { spawn } from "node:child_process";
141
+
142
+ export default function (pi: ExtensionAPI) {
143
+ pi.on("session_start", async (_event, ctx) => {
144
+ const sessionId = ctx.sessionManager.getSessionId();
145
+ if (!sessionId) return;
146
+ runTraceHook("SessionStart", sessionId, ctx.cwd);
147
+ });
148
+
149
+ pi.on("turn_start", async (_event, ctx) => {
150
+ const sessionId = ctx.sessionManager.getSessionId();
151
+ if (!sessionId) return;
152
+ runTraceHook("TurnStart", sessionId, ctx.cwd);
153
+ });
154
+
155
+ pi.on("session_shutdown", async (_event, ctx) => {
156
+ const sessionId = ctx.sessionManager.getSessionId();
157
+ if (!sessionId) return;
158
+ runTraceHook("SessionEnd", sessionId, ctx.cwd);
159
+ });
160
+ }
161
+
162
+ function runTraceHook(eventName: string, sessionId: string, cwd: string) {
163
+ const payload = JSON.stringify({
164
+ hook_event_name: eventName,
165
+ session_id: sessionId,
166
+ });
167
+
168
+ const proc = spawn(${JSON.stringify(reviewCommand)}, ["trace", "hook", eventName], {
169
+ cwd,
170
+ stdio: ["pipe", "ignore", "ignore"],
171
+ });
172
+
173
+ // Missing binaries and closed pipes must never crash the harness.
174
+ proc.on("error", () => {});
175
+ proc.stdin.on("error", () => {});
176
+ proc.stdin.end(payload);
177
+ }
178
+ `;
179
+ }
180
+ function openCodeTracePluginSource(reviewCommand) {
181
+ return `// ${OPENCODE_TRACE_PLUGIN_MARKER}
182
+ import { spawn } from "node:child_process";
183
+
184
+ interface OpenCodeEvent {
185
+ type: string;
186
+ properties: Record<string, unknown>;
187
+ }
188
+
189
+ // OpenCode has no session-end event. A session goes idle after each turn,
190
+ // so every turn is one SessionStart/UserPromptSubmit .. SessionEnd cycle:
191
+ // the prompt registers the session for commit stamping and idle syncs the
192
+ // trace. Child sessions spawned by the task tool stay attached to their
193
+ // parent's turn and are never registered on their own.
194
+ export default async function reviewTracePlugin(input: { directory: string }) {
195
+ const directories = new Map<string, string>();
196
+ const childSessions = new Set<string>();
197
+
198
+ return {
199
+ event: async ({ event }: { event: OpenCodeEvent }) => {
200
+ const properties = event.properties;
201
+ if (event.type === "session.created") {
202
+ const info = record(properties.info);
203
+ const sessionId = text(info.id);
204
+ if (!sessionId) return;
205
+ if (typeof info.parentID === "string") {
206
+ childSessions.add(sessionId);
207
+ return;
208
+ }
209
+ const directory = text(info.directory) ?? input.directory;
210
+ directories.set(sessionId, directory);
211
+ runTraceHook("SessionStart", sessionId, directory);
212
+ return;
213
+ }
214
+ if (event.type === "message.updated") {
215
+ const info = record(properties.info);
216
+ if (info.role !== "user") return;
217
+ const sessionId = text(info.sessionID);
218
+ if (!sessionId || childSessions.has(sessionId)) return;
219
+ runTraceHook(
220
+ "UserPromptSubmit",
221
+ sessionId,
222
+ directories.get(sessionId) ?? input.directory,
223
+ );
224
+ return;
225
+ }
226
+ if (event.type === "session.idle") {
227
+ const sessionId = text(properties.sessionID);
228
+ if (!sessionId || childSessions.has(sessionId)) return;
229
+ runTraceHook(
230
+ "SessionEnd",
231
+ sessionId,
232
+ directories.get(sessionId) ?? input.directory,
233
+ );
234
+ }
235
+ },
236
+ };
237
+ }
238
+
239
+ function record(value: unknown): Record<string, unknown> {
240
+ return value && typeof value === "object"
241
+ ? (value as Record<string, unknown>)
242
+ : {};
243
+ }
244
+
245
+ function text(value: unknown): string | undefined {
246
+ return typeof value === "string" && value ? value : undefined;
247
+ }
248
+
249
+ function runTraceHook(eventName: string, sessionId: string, cwd: string) {
250
+ const payload = JSON.stringify({
251
+ hook_event_name: eventName,
252
+ session_id: sessionId,
253
+ });
254
+
255
+ const proc = spawn(${JSON.stringify(reviewCommand)}, ["trace", "hook", eventName], {
256
+ cwd,
257
+ stdio: ["pipe", "ignore", "ignore"],
258
+ });
259
+
260
+ // Missing binaries and closed pipes must never crash the harness.
261
+ proc.on("error", () => {});
262
+ proc.stdin.on("error", () => {});
263
+ proc.stdin.end(payload);
264
+ }
265
+ `;
266
+ }
267
+ /**
268
+ * Installs the session lifecycle hook of every harness this package owns.
269
+ *
270
+ * `harnessHooks: false` installs none, so one caller can keep the option its
271
+ * command registers. The result names each file that was written, in the
272
+ * order the installers ran.
273
+ */
274
+ async function installHarnessHooks(input) {
275
+ const installed = [];
276
+ const skipped = [];
277
+ if (input.harnessHooks === false) return {
278
+ installed,
279
+ skipped
280
+ };
281
+ const env = input.env ?? process.env;
282
+ for (const agent of AGENT_TRACE_HOOK_AGENTS) {
283
+ const present = existsSync(agentTraceHomeDirectory(agent, input.homeDir, env));
284
+ if (input.allHarnesses !== true && !present) {
285
+ skipped.push(agent);
286
+ continue;
287
+ }
288
+ const result = await HARNESS_HOOK_INSTALLERS[agent](input.homeDir, input.executable, env);
289
+ installed.push(result);
290
+ }
291
+ return {
292
+ installed,
293
+ skipped
294
+ };
295
+ }
296
+ /** The installer of one harness hook, keyed by the harness. */
297
+ const HARNESS_HOOK_INSTALLERS = {
298
+ claude: installClaudeTraceHook,
299
+ codex: installCodexTraceHook,
300
+ opencode: installOpenCodeTraceExtension,
301
+ pi: installPiTraceExtension
302
+ };
303
+ /** One line that names the harnesses an install left alone. */
304
+ function skippedHarnessesLine(skipped) {
305
+ return `Skipped the ${skipped.join(", ")} hook${skipped.length === 1 ? "" : "s"}: this machine has no such harness. Use --all-harnesses to write them anyway.\n`;
306
+ }
307
+ /**
308
+ * Idempotently configures Claude Code session lifecycle hooks in ~/.claude/settings.json.
309
+ */
310
+ async function installClaudeTraceHook(homeDir = os.homedir(), reviewCommand = traceCliName()) {
311
+ const settingsDir = path.join(homeDir, ".claude");
312
+ const settingsPath = claudeSettingsPath(homeDir);
313
+ let parsed = {};
314
+ if (existsSync(settingsPath)) try {
315
+ const content = parseJsonText(await readFile(settingsPath, "utf8"));
316
+ if (isJsonObject(content)) parsed = content;
317
+ } catch {
318
+ parsed = {};
319
+ }
320
+ const hooks = isJsonObject(parsed.hooks) ? parsed.hooks : {};
321
+ let modified = false;
322
+ let keptCommand;
323
+ const hookCommand = (eventName) => ({
324
+ type: "command",
325
+ command: `${shellCommand(reviewCommand)} trace hook ${eventName}`
326
+ });
327
+ for (const eventName of [
328
+ "SessionStart",
329
+ "UserPromptSubmit",
330
+ "SessionEnd"
331
+ ]) {
332
+ const existing = hooks[eventName];
333
+ const group = isJsonArray(existing) ? existing : [];
334
+ const wanted = hookCommand(eventName);
335
+ let found = false;
336
+ for (const entry of group) {
337
+ if (!isJsonObject(entry) || !isJsonArray(entry.hooks)) continue;
338
+ for (const hook of entry.hooks) {
339
+ if (!isJsonObject(hook)) continue;
340
+ const file = traceHookCommandFile(hook.command);
341
+ if (file === void 0 || executableOwner(file) === null) continue;
342
+ found = true;
343
+ if (keepTraceExecutable(file, reviewCommand)) {
344
+ keptCommand = file;
345
+ continue;
346
+ }
347
+ if (hook.command !== wanted.command) {
348
+ hook.command = wanted.command;
349
+ modified = true;
350
+ }
351
+ }
352
+ }
353
+ if (!found) {
354
+ group.push({ hooks: [wanted] });
355
+ modified = true;
356
+ }
357
+ hooks[eventName] = group;
358
+ }
359
+ if (modified || !existsSync(settingsPath)) {
360
+ parsed.hooks = hooks;
361
+ await mkdir(settingsDir, { recursive: true });
362
+ await writeFile(settingsPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
363
+ }
364
+ const result = {
365
+ agent: "claude",
366
+ path: settingsPath,
367
+ modified
368
+ };
369
+ if (keptCommand) result.keptCommand = keptCommand;
370
+ return result;
371
+ }
372
+ /**
373
+ * Idempotently configures Codex session lifecycle hooks in ~/.codex/config.toml.
374
+ */
375
+ async function installCodexTraceHook(homeDir = os.homedir(), reviewCommand = traceCliName()) {
376
+ const codexDir = path.join(homeDir, ".codex");
377
+ const configPath = codexConfigPath(homeDir);
378
+ let existing = "";
379
+ if (existsSync(configPath)) existing = await readFile(configPath, "utf8");
380
+ const hookEvents = [
381
+ "SessionStart",
382
+ "UserPromptSubmit",
383
+ "SessionEnd"
384
+ ];
385
+ const found = /* @__PURE__ */ new Set();
386
+ let keptCommand;
387
+ let next = transformCodexHooks(existing, (block, command, event) => {
388
+ const file = traceHookCommandFile(command);
389
+ if (file === void 0 || executableOwner(file) === null) return block;
390
+ found.add(event);
391
+ if (keepTraceExecutable(file, reviewCommand)) {
392
+ keptCommand = file;
393
+ return block;
394
+ }
395
+ return block.replace(/^command = .*$/m, () => `command = ${JSON.stringify(`${shellCommand(reviewCommand)} trace hook ${event}`)}`);
396
+ });
397
+ const missing = hookEvents.filter((event) => !found.has(event));
398
+ if (missing.length > 0) next = existing ? `${next.trimEnd()}\n\n${missing.map((event) => codexTraceHookToml(event, reviewCommand)).join("\n\n")}\n` : codexHookBlock(reviewCommand).trimStart();
399
+ const result = {
400
+ agent: "codex",
401
+ path: configPath,
402
+ modified: next !== existing
403
+ };
404
+ if (keptCommand) result.keptCommand = keptCommand;
405
+ if (!result.modified) return result;
406
+ await mkdir(codexDir, { recursive: true });
407
+ await writeFile(configPath, next, "utf8");
408
+ return result;
409
+ }
410
+ /**
411
+ * Idempotently writes the Pi session lifecycle extension into ~/.pi/agent/extensions/review-trace.ts.
412
+ */
413
+ async function installPiTraceExtension(homeDir = os.homedir(), reviewCommand = traceCliName()) {
414
+ const extensionsDir = path.join(homeDir, ".pi", "agent", "extensions");
415
+ const extensionPath = piExtensionPath(homeDir);
416
+ let existing = "";
417
+ if (existsSync(extensionPath)) existing = await readFile(extensionPath, "utf8");
418
+ const existingCommand = extensionCommandFile(existing);
419
+ if (keepTraceExecutable(existingCommand, reviewCommand)) return {
420
+ agent: "pi",
421
+ path: extensionPath,
422
+ modified: false,
423
+ keptCommand: existingCommand
424
+ };
425
+ const source = piExtensionSource(reviewCommand);
426
+ if (existing.trim() === source.trim()) return {
427
+ agent: "pi",
428
+ path: extensionPath,
429
+ modified: false
430
+ };
431
+ await mkdir(extensionsDir, { recursive: true });
432
+ await writeFile(extensionPath, source, "utf8");
433
+ return {
434
+ agent: "pi",
435
+ path: extensionPath,
436
+ modified: true
437
+ };
438
+ }
439
+ /**
440
+ * Idempotently writes the OpenCode trace plugin into ~/.config/opencode/plugins/review-trace.ts.
441
+ */
442
+ async function installOpenCodeTraceExtension(homeDir = os.homedir(), reviewCommand = traceCliName(), env = process.env) {
443
+ const pluginPath = openCodePluginPath(homeDir, env);
444
+ const pluginsDir = path.dirname(pluginPath);
445
+ let existing = "";
446
+ if (existsSync(pluginPath)) existing = await readFile(pluginPath, "utf8");
447
+ const existingCommand = extensionCommandFile(existing);
448
+ if (keepTraceExecutable(existingCommand, reviewCommand)) return {
449
+ agent: "opencode",
450
+ path: pluginPath,
451
+ modified: false,
452
+ keptCommand: existingCommand
453
+ };
454
+ const source = openCodeTracePluginSource(reviewCommand);
455
+ if (existing.trim() === source.trim()) return {
456
+ agent: "opencode",
457
+ path: pluginPath,
458
+ modified: false
459
+ };
460
+ await mkdir(pluginsDir, { recursive: true });
461
+ await writeFile(pluginPath, source, "utf8");
462
+ return {
463
+ agent: "opencode",
464
+ path: pluginPath,
465
+ modified: true
466
+ };
467
+ }
468
+ /** Removes Review hooks; a scoped removal preserves another live executable. */
469
+ async function removeAgentTraceHook(agent, homeDir = os.homedir(), env = process.env, expectedCommand) {
470
+ const removable = (file) => expectedCommand === void 0 || !keepTraceExecutable(file, expectedCommand);
471
+ if (agent === "claude") {
472
+ const settingsPath = claudeSettingsPath(homeDir);
473
+ if (!existsSync(settingsPath)) return false;
474
+ let parsed;
475
+ try {
476
+ parsed = parseJsonText(await readFile(settingsPath, "utf8"));
477
+ } catch {
478
+ return false;
479
+ }
480
+ if (!isJsonObject(parsed)) return false;
481
+ const hooks = parsed.hooks;
482
+ if (!isJsonObject(hooks)) return false;
483
+ let changed = false;
484
+ for (const eventName of [
485
+ "SessionStart",
486
+ "UserPromptSubmit",
487
+ "SessionEnd"
488
+ ]) {
489
+ const existing = hooks[eventName];
490
+ const keptGroups = (isJsonArray(existing) ? existing : []).flatMap((group) => {
491
+ if (!isJsonObject(group)) return [group];
492
+ if (!isJsonArray(group.hooks)) return [group];
493
+ const keptHooks = group.hooks.filter((hook) => {
494
+ const command = isJsonObject(hook) ? hook.command : void 0;
495
+ const owned = traceHookCommandOwner(command) === "review" && removable(traceHookCommandFile(command));
496
+ if (owned) changed = true;
497
+ return !owned;
498
+ });
499
+ return keptHooks.length > 0 ? [{
500
+ ...group,
501
+ hooks: keptHooks
502
+ }] : [];
503
+ });
504
+ if (keptGroups.length > 0) hooks[eventName] = keptGroups;
505
+ else delete hooks[eventName];
506
+ }
507
+ if (!changed) return false;
508
+ parsed.hooks = hooks;
509
+ await writeFile(settingsPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
510
+ return true;
511
+ }
512
+ if (agent === "codex") {
513
+ const configPath = codexConfigPath(homeDir);
514
+ if (!existsSync(configPath)) return false;
515
+ const existing = await readFile(configPath, "utf8");
516
+ const removed = transformCodexHooks(existing, (block, command) => traceHookCommandOwner(command) === "review" && removable(traceHookCommandFile(command)) ? "" : block).replace(/# review-trace-hooks:start\n\s*# review-trace-hooks:end\n?/, "");
517
+ if (removed === existing) return false;
518
+ const next = removed.replace(/\n{3,}/g, "\n\n");
519
+ await writeFile(configPath, next.trim() ? `${next.trimEnd()}\n` : "", "utf8");
520
+ return true;
521
+ }
522
+ const extensionPath = agent === "pi" ? piExtensionPath(homeDir) : openCodePluginPath(homeDir, env);
523
+ if (!existsSync(extensionPath)) return false;
524
+ const existing = await readFile(extensionPath, "utf8");
525
+ if (extensionOwner(existing) !== "review" || !removable(extensionCommandFile(existing))) return false;
526
+ await rm(extensionPath, { force: true });
527
+ return true;
528
+ }
529
+ function shellCommand(command) {
530
+ return command === traceCliName() ? command : shellQuote(command);
531
+ }
532
+ function codexTraceHookToml(eventName, reviewCommand) {
533
+ const status = eventName === "SessionStart" ? "\nstatusMessage = \"Recording agent session id for trace stamping\"" : "";
534
+ return `[[hooks.${eventName}]]
535
+ [[hooks.${eventName}.hooks]]
536
+ type = "command"
537
+ command = ${JSON.stringify(`${shellCommand(reviewCommand)} trace hook ${eventName}`)}${status}`;
538
+ }
539
+ /** The whole marked block, written when the Codex config is empty. */
540
+ function codexHookBlock(reviewCommand) {
541
+ return `\n# review-trace-hooks:start\n${[
542
+ "SessionStart",
543
+ "UserPromptSubmit",
544
+ "SessionEnd"
545
+ ].map((eventName) => codexTraceHookToml(eventName, reviewCommand)).join("\n\n")}\n# review-trace-hooks:end\n`;
546
+ }
547
+ function transformCodexHooks(content, transform) {
548
+ return content.replace(/^\[\[hooks\.(SessionStart|UserPromptSubmit|SessionEnd)\]\]\n\[\[hooks\.\1\.hooks\]\]\ntype = "command"\ncommand = ("(?:[^"\\\n]|\\.)*")(?:\n|$)(?:statusMessage = "[^"\n]*"(?:\n|$))?/gm, (block, event, encoded, offset) => {
549
+ let reachedOtherTable = false;
550
+ for (const line of content.slice(offset + block.length).split("\n")) {
551
+ const trimmed = line.trim();
552
+ if (!trimmed || trimmed.startsWith("#")) continue;
553
+ if (!trimmed.startsWith("[")) {
554
+ if (!reachedOtherTable) return block;
555
+ continue;
556
+ }
557
+ const header = /^\[{1,2}([A-Za-z0-9_.-]+)\]{1,2}(?:\s*#.*)?$/.exec(trimmed);
558
+ if (!header) return block;
559
+ if (trimmed === `[[hooks.${event}]]`) break;
560
+ if (header[1] === `hooks.${event}` || header[1].startsWith(`hooks.${event}.`)) return block;
561
+ reachedOtherTable = true;
562
+ }
563
+ try {
564
+ const command = jsonString(parseJsonText(encoded));
565
+ if (!command?.endsWith(` trace hook ${event}`)) return block;
566
+ return transform(block, command, event);
567
+ } catch {
568
+ return block;
569
+ }
570
+ });
571
+ }
572
+ async function readTextOrEmpty(filePath) {
573
+ return existsSync(filePath) ? readFile(filePath, "utf8") : "";
574
+ }
575
+ /**
576
+ * The directory one harness keeps its own configuration in. A machine without
577
+ * that directory does not run the harness, so an install writes it no hook.
578
+ */
579
+ function agentTraceHomeDirectory(agent, homeDir = os.homedir(), env = process.env) {
580
+ if (agent === "claude") return path.join(homeDir, ".claude");
581
+ if (agent === "codex") return path.join(homeDir, ".codex");
582
+ if (agent === "opencode") return openCodeDirectory(homeDir, env);
583
+ if (agent === "pi") return path.join(homeDir, ".pi");
584
+ throw new Error(`Unknown trace hook agent: ${String(agent)}`);
585
+ }
586
+ /** Reports recognized SessionStart owners and extension owners without changing files. */
587
+ async function describeTraceHookOwners(homeDir = os.homedir(), env = process.env) {
588
+ let claude = null;
589
+ try {
590
+ const parsed = parseJsonText(await readTextOrEmpty(claudeSettingsPath(homeDir)));
591
+ const hooks = isJsonObject(parsed) ? parsed.hooks : void 0;
592
+ const groups = isJsonObject(hooks) ? hooks.SessionStart : void 0;
593
+ for (const group of isJsonArray(groups) ? groups : []) {
594
+ if (!isJsonObject(group) || !isJsonArray(group.hooks)) continue;
595
+ for (const hook of group.hooks) {
596
+ const owner = isJsonObject(hook) ? traceHookCommandOwner(hook.command) : null;
597
+ if (owner) claude = owner;
598
+ }
599
+ }
600
+ } catch {
601
+ claude = null;
602
+ }
603
+ let codex = null;
604
+ transformCodexHooks(await readTextOrEmpty(codexConfigPath(homeDir)), (block, command, event) => {
605
+ if (event === "SessionStart") codex = traceHookCommandOwner(command) ?? codex;
606
+ return block;
607
+ });
608
+ return {
609
+ claude,
610
+ codex,
611
+ opencode: extensionOwner(await readTextOrEmpty(openCodePluginPath(homeDir, env))),
612
+ pi: extensionOwner(await readTextOrEmpty(piExtensionPath(homeDir)))
613
+ };
614
+ }
615
+ async function readActiveTraceSessions(filePath, now = Date.now()) {
616
+ const [content, fileStat] = await Promise.all([readFile(filePath, "utf8").catch(() => ""), stat(filePath).catch(() => null)]);
617
+ const legacyTimestamp = Math.floor(fileStat?.mtimeMs ?? now);
618
+ const sessions = /* @__PURE__ */ new Map();
619
+ for (const rawLine of content.split("\n")) {
620
+ const line = rawLine.trim();
621
+ if (!line) continue;
622
+ const fields = line.split(" ");
623
+ const parsedSessionId = sessionIdSchema.safeParse(fields[0]);
624
+ if (!parsedSessionId.success) continue;
625
+ const sessionId = parsedSessionId.data;
626
+ let lastActiveAt = legacyTimestamp;
627
+ if (fields.length > 1) {
628
+ if (fields.length !== 2 || !/^\d+$/.test(fields[1] ?? "")) continue;
629
+ lastActiveAt = Number(fields[1]);
630
+ if (!Number.isSafeInteger(lastActiveAt) || lastActiveAt <= 0) continue;
631
+ }
632
+ if (now - lastActiveAt <= 72e5) sessions.set(sessionId, lastActiveAt);
633
+ }
634
+ return sessions;
635
+ }
636
+ async function writeTraceSessions(filePath, sessions) {
637
+ if (sessions.size === 0) {
638
+ await rm(filePath, { force: true });
639
+ return;
640
+ }
641
+ const content = [...sessions].map(([sessionId, lastActiveAt]) => `${sessionId}\t${lastActiveAt}`).join("\n");
642
+ await writeFile(filePath, `${content}\n`, "utf8");
643
+ }
644
+ //#endregion
645
+ //#region ../trace-core/src/trace-capture-scope.ts
646
+ const HOSTED_CAPTURE_SCOPE_DESCRIPTION = "Hosted capture is limited to repositories allowed at this origin. Enable another repository only when the user explicitly requests publication.\n";
647
+ //#endregion
648
+ //#region ../trace-core/src/trace-repository-hooks.ts
649
+ const repositoryHookStateSchema = z.object({
650
+ version: z.literal(1),
651
+ root: z.string(),
652
+ managedHooksPath: z.string(),
653
+ previousHooksPath: z.string(),
654
+ previousHookDirectory: z.string(),
655
+ previousWasConfigured: z.boolean(),
656
+ command: z.string().optional()
657
+ });
658
+ async function enableTraceRepository(input) {
659
+ const resolved = await resolveRepository(input.cwd);
660
+ if (!resolved) return {
661
+ repository: false,
662
+ enabled: false,
663
+ message: "Not inside a Git repository."
664
+ };
665
+ const statePath = path.join(resolved.commonDir, "dev-fast", "trace-hooks", "state.json");
666
+ const hooksPath = path.join(resolved.commonDir, "dev-fast", "trace-hooks", "hooks");
667
+ const current = await configuredHooksPath(resolved.root);
668
+ const oldState = await readState(statePath);
669
+ const alreadyManaged = path.resolve(resolved.root, current.value || ".") === path.resolve(hooksPath);
670
+ const previousWasConfigured = alreadyManaged ? oldState?.previousWasConfigured ?? false : current.configured;
671
+ const previousHooksPath = alreadyManaged ? oldState?.previousHooksPath ?? path.join(resolved.commonDir, "hooks") : current.configured ? current.value : path.join(resolved.commonDir, "hooks");
672
+ const previousHookDirectory = alreadyManaged ? oldState?.previousHookDirectory ?? resolveHooksPath(resolved.root, previousHooksPath) : previousHooksPath;
673
+ const homeDir = input.scope.homeDir;
674
+ let reviewCommand = renderTraceCommand(resolveTraceCommand({
675
+ explicit: input.reviewCommand,
676
+ env: input.scope.env,
677
+ homeDir
678
+ }));
679
+ if (alreadyManaged && !input.replaceCommand && oldState?.command && keepTraceExecutable(traceCommandExecutable(oldState.command), traceCommandExecutable(reviewCommand) ?? "")) reviewCommand = oldState.command;
680
+ const state = {
681
+ version: 1,
682
+ root: resolved.root,
683
+ managedHooksPath: hooksPath,
684
+ previousHooksPath,
685
+ previousHookDirectory,
686
+ previousWasConfigured,
687
+ command: reviewCommand
688
+ };
689
+ await mkdir(hooksPath, { recursive: true });
690
+ await writeHook(path.join(hooksPath, "prepare-commit-msg"), prepareCommitMessageHook(previousHookDirectory, reviewCommand));
691
+ await writeHook(path.join(hooksPath, "pre-push"), prePushHook(previousHookDirectory, reviewCommand));
692
+ await writePrivateJson(statePath, state);
693
+ await gitAt(resolved.root, [
694
+ "config",
695
+ "--local",
696
+ "core.hooksPath",
697
+ hooksPath
698
+ ]);
699
+ await registerRepository(homeDir, resolved.root);
700
+ return {
701
+ repository: true,
702
+ enabled: true,
703
+ root: resolved.root,
704
+ managedHooksPath: hooksPath,
705
+ previousHooksPath,
706
+ command: reviewCommand,
707
+ message: "Review trace hooks are enabled for this repository."
708
+ };
709
+ }
710
+ async function repairTraceRepository(input) {
711
+ return enableTraceRepository(input);
712
+ }
713
+ async function disableTraceRepository(input) {
714
+ const resolved = await resolveRepository(input.cwd);
715
+ if (!resolved) return {
716
+ repository: false,
717
+ enabled: false,
718
+ message: "Not inside a Git repository."
719
+ };
720
+ const stateDir = path.join(resolved.commonDir, "dev-fast", "trace-hooks");
721
+ const state = await readState(path.join(stateDir, "state.json"));
722
+ if (!state) return {
723
+ repository: true,
724
+ enabled: false,
725
+ root: resolved.root,
726
+ message: "Review trace hooks are not enabled for this repository."
727
+ };
728
+ const current = await configuredHooksPath(resolved.root);
729
+ if (path.resolve(resolved.root, current.value || ".") === path.resolve(state.managedHooksPath)) {
730
+ if (state.previousWasConfigured) await gitAt(resolved.root, [
731
+ "config",
732
+ "--local",
733
+ "core.hooksPath",
734
+ state.previousHooksPath
735
+ ]);
736
+ else await gitAt(resolved.root, [
737
+ "config",
738
+ "--local",
739
+ "--unset",
740
+ "core.hooksPath"
741
+ ], { allowFailure: true });
742
+ }
743
+ await rm(stateDir, {
744
+ recursive: true,
745
+ force: true
746
+ });
747
+ await unregisterRepository(input.scope.homeDir, resolved.root);
748
+ return {
749
+ repository: true,
750
+ enabled: false,
751
+ root: resolved.root,
752
+ message: "Review trace hooks are disabled for this repository."
753
+ };
754
+ }
755
+ async function traceRepositoryStatus(cwd) {
756
+ const resolved = await resolveRepository(cwd);
757
+ if (!resolved) return {
758
+ repository: false,
759
+ enabled: false,
760
+ message: "Not inside a Git repository."
761
+ };
762
+ const state = await readState(path.join(resolved.commonDir, "dev-fast", "trace-hooks", "state.json"));
763
+ const current = await configuredHooksPath(resolved.root);
764
+ const enabled = Boolean(state && path.resolve(resolved.root, current.value || ".") === path.resolve(state.managedHooksPath));
765
+ const status = {
766
+ repository: true,
767
+ enabled,
768
+ root: resolved.root,
769
+ message: enabled ? "Review trace hooks are enabled for this repository." : "Review trace hooks are not enabled for this repository."
770
+ };
771
+ if (state) {
772
+ status.managedHooksPath = state.managedHooksPath;
773
+ status.previousHooksPath = state.previousHooksPath;
774
+ if (state.command !== void 0) status.command = state.command;
775
+ }
776
+ return status;
777
+ }
778
+ /** Disables registered hooks, preserving other live executables when scoped. */
779
+ async function disableAllTraceRepositories(scope, expectedCommand) {
780
+ const registry = await readRegistry(scope.homeDir);
781
+ const disabled = [];
782
+ const kept = [];
783
+ for (const root of registry) {
784
+ if (expectedCommand !== void 0) {
785
+ if (keepTraceExecutable(traceCommandExecutable((await traceRepositoryStatus(root).catch(() => null))?.command), expectedCommand)) {
786
+ kept.push(root);
787
+ continue;
788
+ }
789
+ }
790
+ if ((await disableTraceRepository({
791
+ cwd: root,
792
+ scope
793
+ }).catch(() => null))?.repository) disabled.push(root);
794
+ }
795
+ return {
796
+ disabled,
797
+ kept
798
+ };
799
+ }
800
+ async function resolveRepository(cwd) {
801
+ const rootResult = await gitAt(cwd, ["rev-parse", "--show-toplevel"], { allowFailure: true });
802
+ const commonResult = await gitAt(cwd, [
803
+ "rev-parse",
804
+ "--path-format=absolute",
805
+ "--git-common-dir"
806
+ ], { allowFailure: true });
807
+ if (!rootResult.ok || !commonResult.ok) return null;
808
+ return {
809
+ root: rootResult.stdout.trim(),
810
+ commonDir: commonResult.stdout.trim()
811
+ };
812
+ }
813
+ async function configuredHooksPath(root) {
814
+ const result = await gitAt(root, [
815
+ "config",
816
+ "--local",
817
+ "--get",
818
+ "core.hooksPath"
819
+ ], { allowFailure: true });
820
+ return {
821
+ configured: result.ok && Boolean(result.stdout.trim()),
822
+ value: result.stdout.trim()
823
+ };
824
+ }
825
+ function resolveHooksPath(root, hooksPath) {
826
+ return path.isAbsolute(hooksPath) ? hooksPath : path.resolve(root, hooksPath);
827
+ }
828
+ function previousHookSetup(pathValue, name) {
829
+ if (path.isAbsolute(pathValue)) return `previous=${shellQuote(path.join(pathValue, name))}`;
830
+ return ["root=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"", `previous="$root"/${shellQuote(path.join(pathValue, name))}`].join("\n");
831
+ }
832
+ function prepareCommitMessageHook(previousPath, reviewCommand) {
833
+ return `#!/bin/sh\n${previousHookSetup(previousPath, "prepare-commit-msg")}\nif [ -x "$previous" ]; then\n "$previous" "$@" || exit $?\nfi\n${reviewCommand} trace git-hook prepare-commit-msg "$@" || true\nexit 0\n`;
834
+ }
835
+ function prePushHook(previousPath, reviewCommand) {
836
+ return `#!/bin/sh\n${previousHookSetup(previousPath, "pre-push")}\ntmp="$(mktemp "\${TMPDIR:-/tmp}/review-pre-push.XXXXXX")" || exit 0\ntrap 'rm -f "$tmp"' EXIT HUP INT TERM\ncat > "$tmp"\nif [ -x "$previous" ]; then\n "$previous" "$@" < "$tmp" || exit $?\nfi\n${reviewCommand} trace git-hook pre-push "$@" < "$tmp" || true\nexit 0\n`;
837
+ }
838
+ async function writeHook(filePath, contents) {
839
+ await writeFile(filePath, contents, { mode: 493 });
840
+ await chmod(filePath, 493);
841
+ }
842
+ async function readState(filePath) {
843
+ try {
844
+ const parsed = repositoryHookStateSchema.safeParse(parseJsonText(await readFile(filePath, "utf8")));
845
+ return parsed.success ? parsed.data : null;
846
+ } catch {
847
+ return null;
848
+ }
849
+ }
850
+ async function writePrivateJson(filePath, value) {
851
+ await writeFileAtomicAsync(filePath, `${JSON.stringify(value, null, 2)}\n`, {
852
+ encoding: "utf8",
853
+ mode: 384
854
+ });
855
+ }
856
+ function registryPath(homeDir) {
857
+ return path.join(homeDir, ".config", "dev-trace", "repositories.json");
858
+ }
859
+ /** Returns the registered repository roots; never scans repositories or changes hooks. */
860
+ function listTraceRepositoryRoots(homeDir) {
861
+ return readRegistry(homeDir ?? traceHomeDir());
862
+ }
863
+ async function readRegistry(homeDir) {
864
+ try {
865
+ const value = parseJsonText(await readFile(registryPath(homeDir), "utf8"));
866
+ return (jsonArray(value) ?? []).map(jsonString).filter((item) => item !== void 0);
867
+ } catch {
868
+ return [];
869
+ }
870
+ }
871
+ async function registerRepository(homeDir, root) {
872
+ const current = await readRegistry(homeDir);
873
+ await writePrivateJson(registryPath(homeDir), [.../* @__PURE__ */ new Set([...current, root])]);
874
+ }
875
+ async function unregisterRepository(homeDir, root) {
876
+ await writePrivateJson(registryPath(homeDir), (await readRegistry(homeDir)).filter((entry) => entry !== root));
877
+ }
878
+ //#endregion
879
+ //#region ../trace-core/src/trace-upload-status.ts
880
+ /** A live, writer-authorized check of recorded uploads. */
881
+ async function writeOwnUploadStatus(input) {
882
+ const query = listUploadsQuerySchema.safeParse({
883
+ session: input.session,
884
+ cursor: input.cursor,
885
+ limit: input.limit ?? 10
886
+ });
887
+ if (!query.success) {
888
+ input.stdout.write("Upload status: invalid session, cursor, or limit.\n");
889
+ return 1;
890
+ }
891
+ if (!input.client) {
892
+ input.stdout.write("Upload status: not checked. Log in to the selected store.\n");
893
+ return 1;
894
+ }
895
+ const client = input.client;
896
+ try {
897
+ const store = await client.findStore({
898
+ owner: input.repo.owner,
899
+ name: input.repo.repo
900
+ });
901
+ if (!store) throw new StoreApiError("not_found", 404, "This repository has no hosted trace store.");
902
+ if (store.status !== "active") throw new StoreApiError("store_deleted", 410, "This trace store was deleted.");
903
+ if (store.bytesStored !== void 0) input.stdout.write(`Stored bytes: ${store.bytesStored}\n`);
904
+ const page = await client.listOwnUploads(store.repositoryId, query.data);
905
+ if (page.storeId !== store.storeId) throw new StoreApiError("store_deleted", 410, "The trace store changed. Check status again.");
906
+ input.stdout.write("Your uploads (checked with the store):\n");
907
+ if (page.uploads.length === 0) input.stdout.write("No upload found for this account.\n");
908
+ for (const upload of page.uploads) {
909
+ const label = upload.status === "pending" ? "Not completed" : upload.current ? "Uploaded" : "Uploaded, later replaced";
910
+ input.stdout.write(`${upload.sessionId}: ${label} at ${upload.completedAt ?? upload.createdAt} (upload ${upload.uploadId}).\n`);
911
+ }
912
+ if (page.nextCursor) input.stdout.write(`More uploads: run \`whiteboard trace status${input.session ? ` --agent-session ${input.session}` : ""} --limit ${query.data.limit} --cursor ${page.nextCursor}\`.\n`);
913
+ return 0;
914
+ } catch (error) {
915
+ const cause = error instanceof Error ? error : new Error(String(error));
916
+ input.stdout.write(`Upload status: not checked. ${cause.message}\n`);
917
+ return 1;
918
+ }
919
+ }
920
+ //#endregion
921
+ //#region ../trace-core/src/trace-hosted-cli.ts
922
+ /** A store failure as one sentence the user can act on. */
923
+ function describeStoreFailure(error, origin, repository) {
924
+ if (!(error instanceof StoreApiError)) return `Could not reach the trace store at ${origin}: ${error.message}`;
925
+ switch (error.code) {
926
+ case "unauthorized": return `The trace store at ${origin} rejected the login. Run \`whiteboard login --origin ${origin}\`.`;
927
+ case "forbidden": return `You cannot read the traces of ${repository}: ${error.message}`;
928
+ case "store_deleted": return `The trace store of ${repository} was deleted. Run \`whiteboard trace store create\` to create a new one.`;
929
+ case "not_found": return `${repository} has no trace store. Run \`whiteboard trace store create\` first.`;
930
+ default: return `The trace store at ${origin} answered ${error.code}: ${error.message}`;
931
+ }
932
+ }
933
+ /** A failure a hosted command reports on both channels, then exits 1. */
934
+ var HostedCommandFailure = class extends Error {};
935
+ /** Shares repository inference without changing each command's login policy. */
936
+ async function withHostedRepository(input, stage, fn) {
937
+ const fail = (message) => failWithJsonError(input, stage, message);
938
+ let name;
939
+ try {
940
+ name = await inferRepoFromGit(input.cwd);
941
+ } catch (error) {
942
+ return fail(errorMessage(error));
943
+ }
944
+ let origin = input.origin ?? "https://app.dev.fast";
945
+ let client = input.client;
946
+ if (stage === "store.create") try {
947
+ client ??= await requireStoreClient(input.scope.env);
948
+ } catch (error) {
949
+ return fail(errorMessage(error));
950
+ }
951
+ else if (stage === "allow" || !client) {
952
+ const auth = await readStoreAuth(input.scope.env);
953
+ if (!auth) return fail(stage === "allow" ? `Run \`whiteboard login\` first.` : `The trace store login is missing. Run \`whiteboard login --origin ${origin}\`.`);
954
+ origin = input.origin ?? auth.origin;
955
+ if (auth.origin !== origin) return fail(`You are logged in to ${auth.origin}, not the selected store ${origin}. Run \`whiteboard login --origin ${origin}\`.`);
956
+ client ??= new StoreClient({
957
+ origin,
958
+ token: auth.token
959
+ });
960
+ }
961
+ try {
962
+ return await fn({
963
+ name,
964
+ repository: traceRepoName(name),
965
+ origin,
966
+ client,
967
+ stage
968
+ });
969
+ } catch (error) {
970
+ if (error instanceof HostedCommandFailure) return fail(error.message);
971
+ throw error;
972
+ }
973
+ }
974
+ /** Requires an active store, preserving the command's store-error wording. */
975
+ async function requireActiveStore(ctx) {
976
+ let store;
977
+ try {
978
+ store = await ctx.client.findStore({
979
+ owner: ctx.name.owner,
980
+ name: ctx.name.repo
981
+ });
982
+ } catch (error) {
983
+ const failure = error instanceof Error ? error : new Error(String(error));
984
+ throw new HostedCommandFailure(ctx.stage === "sessions" ? describeStoreFailure(failure, ctx.origin, ctx.repository) : failure.message);
985
+ }
986
+ if (!store) throw new HostedCommandFailure(`${ctx.repository} has no trace store. Run \`whiteboard trace store create\` first.`);
987
+ if (store.status !== "active") throw new HostedCommandFailure(`The trace store of ${store.displayName} was deleted. Run \`whiteboard trace store create\` to create a new one.`);
988
+ return store;
989
+ }
990
+ /**
991
+ * Hosted consent commands: onboarding creates a repository store, allow
992
+ * records publication consent bound to the login's origin, deny withdraws
993
+ * it. None of them selects hosted storage; the trace storage use
994
+ * hosted` does that explicitly.
995
+ */
996
+ async function runTraceOnboard(input) {
997
+ return withHostedRepository(input, "store.create", async (ctx) => {
998
+ let store;
999
+ try {
1000
+ store = await ctx.client.createStore({
1001
+ owner: ctx.name.owner,
1002
+ name: ctx.name.repo
1003
+ });
1004
+ } catch (error) {
1005
+ if (error instanceof StoreApiError && error.code === "forbidden") throw new HostedCommandFailure(`You need push access to ${ctx.repository} to create its trace store.`);
1006
+ throw new HostedCommandFailure(errorMessage(error));
1007
+ }
1008
+ emitJsonEvent(input, {
1009
+ event: "trace.store.create",
1010
+ repositoryId: store.repositoryId,
1011
+ displayName: store.displayName,
1012
+ created: store.created === true
1013
+ });
1014
+ const stream = humanStream(input);
1015
+ stream.write(`Created the trace store of ${store.displayName} (id ${store.repositoryId}).\n`);
1016
+ stream.write(`Run \`whiteboard trace allow .\` to send traces from this repository.\n`);
1017
+ return 0;
1018
+ });
1019
+ }
1020
+ /** Reports the hosted store of one repository, or exits 1 without one. */
1021
+ async function runTraceStoreInfo(input) {
1022
+ return withHostedRepository(input, "store.info", async (ctx) => {
1023
+ let store;
1024
+ try {
1025
+ store = await ctx.client.findStore({
1026
+ owner: ctx.name.owner,
1027
+ name: ctx.name.repo
1028
+ });
1029
+ } catch (error) {
1030
+ throw new HostedCommandFailure(describeStoreFailure(error instanceof Error ? error : new Error(String(error)), ctx.origin, ctx.repository));
1031
+ }
1032
+ if (!store) throw new HostedCommandFailure(`${ctx.repository} has no trace store. Run \`whiteboard trace store create\` first.`);
1033
+ emitJsonEvent(input, {
1034
+ event: "trace.store",
1035
+ repository: store.displayName,
1036
+ repositoryId: store.repositoryId,
1037
+ storeId: store.storeId,
1038
+ status: store.status,
1039
+ bytesStored: store.bytesStored ?? null
1040
+ });
1041
+ const stream = humanStream(input);
1042
+ stream.write(`Repository: ${store.displayName} (id ${store.repositoryId})\n`);
1043
+ stream.write(`Store: ${store.storeId} (${store.status})\n`);
1044
+ if (store.bytesStored !== void 0) stream.write(`Stored bytes: ${store.bytesStored}\n`);
1045
+ return 0;
1046
+ });
1047
+ }
1048
+ /**
1049
+ * Deletes the hosted store of one repository. The deletion is logical: the
1050
+ * store stops every read and write at once, and an operator removes the
1051
+ * objects later. The consent of this machine stays; `deny` removes that.
1052
+ */
1053
+ async function runTraceStoreDelete(input) {
1054
+ return withHostedRepository(input, "store.delete", async (ctx) => {
1055
+ const store = await requireActiveStore(ctx);
1056
+ let deletion;
1057
+ try {
1058
+ deletion = await ctx.client.deleteStore(store.repositoryId);
1059
+ } catch (error) {
1060
+ if (error instanceof StoreApiError && error.code === "forbidden") throw new HostedCommandFailure(`Deleting the store of ${ctx.repository} needs admin access to the repository.`);
1061
+ throw new HostedCommandFailure(errorMessage(error));
1062
+ }
1063
+ emitJsonEvent(input, {
1064
+ event: "trace.store.delete",
1065
+ repository: store.displayName,
1066
+ repositoryId: deletion.repositoryId,
1067
+ storeId: deletion.storeId,
1068
+ status: deletion.status
1069
+ });
1070
+ humanStream(input).write(`Store deletion requested for ${store.displayName} (store ${deletion.storeId}). Uploaded objects are removed by a later operator cleanup.\n`);
1071
+ return 0;
1072
+ });
1073
+ }
1074
+ async function runTraceAllow(input) {
1075
+ return withHostedRepository(input, "allow", async (ctx) => {
1076
+ const selection = selectTraceStorage(input.scope);
1077
+ if (selection.error) return failWithJsonError(input, "allow", selection.error);
1078
+ if (selection.mode === "s3") return failWithJsonError(input, "allow", `This machine sends traces to a bucket. Run \`whiteboard trace storage use hosted\` first.`);
1079
+ const storeOrigin = ctx.origin;
1080
+ const store = await requireActiveStore(ctx);
1081
+ const { skipped } = await installHarnessHooks({
1082
+ homeDir: input.scope.homeDir,
1083
+ env: input.scope.env,
1084
+ executable: input.traceCommand?.file,
1085
+ harnessHooks: input.harnessHooks,
1086
+ allHarnesses: input.allHarnesses
1087
+ });
1088
+ if (skipped.length > 0) humanStream(input).write(skippedHarnessesLine(skipped));
1089
+ await enableTraceRepository({
1090
+ cwd: input.cwd,
1091
+ scope: input.scope,
1092
+ reviewCommand: input.traceCommand
1093
+ });
1094
+ await enableHostedCapture(input.scope.devHome, storeOrigin);
1095
+ await allowTraceRepository({
1096
+ repositoryId: store.repositoryId,
1097
+ name: store.displayName,
1098
+ origin: storeOrigin
1099
+ }, input.scope.devHome);
1100
+ emitJsonEvent(input, {
1101
+ event: "trace.allow",
1102
+ repositoryId: store.repositoryId,
1103
+ name: store.displayName,
1104
+ store: storeOrigin
1105
+ });
1106
+ const verifyCommand = input.verifyCommand ?? `whiteboard trace status`;
1107
+ humanStream(input).write(`Traces from ${store.displayName} may be published to ${storeOrigin}. Run \`${verifyCommand}\` to verify.\n`);
1108
+ return 0;
1109
+ });
1110
+ }
1111
+ /**
1112
+ * Switches hosted capture on. An uninstall leaves `capture.enabled: false`
1113
+ * behind; a later allow means the user wants capture back. A file with no
1114
+ * hosted entry gets one that names the login's origin.
1115
+ */
1116
+ async function enableHostedCapture(devHome, origin) {
1117
+ const file = readTraceConfigFile({ devHome });
1118
+ const current = file.config ?? emptyTraceConfig();
1119
+ const hosted = current.stores?.hosted;
1120
+ if (hosted?.capture?.enabled === true) return;
1121
+ await writeTraceConfigFile(file, {
1122
+ ...current,
1123
+ stores: {
1124
+ ...current.stores,
1125
+ hosted: hosted ? {
1126
+ ...hosted,
1127
+ capture: { enabled: true }
1128
+ } : {
1129
+ origin,
1130
+ capture: { enabled: true }
1131
+ }
1132
+ }
1133
+ });
1134
+ }
1135
+ async function runTraceDeny(input) {
1136
+ let name;
1137
+ try {
1138
+ name = traceRepoName(await inferRepoFromGit(input.cwd));
1139
+ } catch (error) {
1140
+ return failWithJsonError(input, "deny", errorMessage(error));
1141
+ }
1142
+ const devHome = input.scope.devHome;
1143
+ const cached = await readCachedTraceRepositoryTarget({
1144
+ cwd: input.cwd,
1145
+ origin: hostedOrigin(readTraceConfigFile({ devHome }).config),
1146
+ devHome
1147
+ }).catch(() => null);
1148
+ const removed = await denyTraceRepository({
1149
+ name,
1150
+ repositoryId: cached?.repositoryId ?? null
1151
+ }, devHome);
1152
+ emitJsonEvent(input, {
1153
+ event: "trace.deny",
1154
+ name,
1155
+ removed
1156
+ });
1157
+ humanStream(input).write(removed ? `${name} will no longer publish traces.\n` : `${name} was not allowed to publish traces.\n`);
1158
+ return 0;
1159
+ }
1160
+ function listedSession(session) {
1161
+ let bytes = 0;
1162
+ for (const object of session.objects) bytes += object.size;
1163
+ return {
1164
+ id: session.sessionId,
1165
+ harness: session.harness,
1166
+ updatedAt: session.updatedAt,
1167
+ branch: session.branch ?? null,
1168
+ author: session.author ?? null,
1169
+ generation: session.generation,
1170
+ commits: session.commits,
1171
+ traces: session.objects.map((object) => traceNameFromObject(object.name)),
1172
+ bytes
1173
+ };
1174
+ }
1175
+ /**
1176
+ * Lists every published session of this checkout's hosted store, one page
1177
+ * at a time. The command reads the store live: a missing login, a store
1178
+ * that does not answer, or a refusal is a failure, never a saved copy.
1179
+ * Reading needs no local publication consent; the store checks GitHub
1180
+ * access itself.
1181
+ */
1182
+ async function runTraceSessions(input) {
1183
+ const fail = (message) => failWithJsonError(input, "sessions", message);
1184
+ if (input.limit !== void 0 && (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 200)) return fail(`--limit must be a whole number from 1 to 200.`);
1185
+ if (input.cursor !== void 0 && !sessionIdSchema.safeParse(input.cursor).success) return fail("--cursor must be a session id from a previous page.");
1186
+ const selection = selectTraceStorage(input.scope);
1187
+ const mode = input.storage ?? selection.mode;
1188
+ if (mode === "s3") return fail(`\`whiteboard trace sessions\` lists the hosted store only. Run \`whiteboard trace storage use hosted\`, or pass \`--storage hosted\`.`);
1189
+ if (selection.error && (mode !== "hosted" || !selection.hosted)) return fail(selection.error);
1190
+ if (!selection.hosted) return fail(`Hosted trace storage is not configured. Run \`whiteboard trace allow .\` or \`whiteboard trace storage use hosted\`.`);
1191
+ return withHostedRepository({
1192
+ ...input,
1193
+ origin: selection.hosted.origin
1194
+ }, "sessions", async (ctx) => {
1195
+ const store = await requireActiveStore(ctx);
1196
+ const origin = ctx.origin;
1197
+ const query = input.cursor === void 0 ? { limit: input.limit ?? 50 } : {
1198
+ limit: input.limit ?? 50,
1199
+ cursor: input.cursor
1200
+ };
1201
+ let page;
1202
+ try {
1203
+ page = await ctx.client.listSessions(store.repositoryId, query);
1204
+ } catch (error) {
1205
+ if (error instanceof StoreApiError && error.code === "invalid_request") throw new HostedCommandFailure(`The trace store at ${origin} does not support listing every session yet. Update the store, or use \`whiteboard trace list --commit <sha>\`.`);
1206
+ throw new HostedCommandFailure(describeStoreFailure(error instanceof Error ? error : new Error(String(error)), origin, store.displayName));
1207
+ }
1208
+ const sessions = page.sessions.map(listedSession);
1209
+ emitJsonEvent(input, {
1210
+ event: "trace.sessions",
1211
+ repository: store.displayName,
1212
+ repositoryId: store.repositoryId,
1213
+ store: origin,
1214
+ sessions,
1215
+ nextCursor: page.nextCursor ?? null
1216
+ });
1217
+ const stream = humanStream(input);
1218
+ if (sessions.length === 0) {
1219
+ stream.write(`No published sessions in the trace store of ${store.displayName} at ${origin}.\n`);
1220
+ return 0;
1221
+ }
1222
+ for (const session of sessions) stream.write(`${session.id} ${session.harness} ${session.updatedAt} ${session.branch ?? "-"} ${session.bytes} bytes\n`);
1223
+ const nextPageFlags = (input.limit === void 0 ? "" : ` --limit ${input.limit}`) + (input.storage === void 0 ? "" : ` --storage ${input.storage}`);
1224
+ stream.write(page.nextCursor ? `Sessions are ordered by id. More follow: run \`whiteboard trace sessions${nextPageFlags} --cursor ${page.nextCursor}\`.\n` : "Sessions are ordered by id. This is the last page.\n");
1225
+ return 0;
1226
+ });
1227
+ }
1228
+ /** The hosted trace status lines: login, consent, and pending work. */
1229
+ async function writeHostedTraceStatus(input) {
1230
+ const stream = input.stdout;
1231
+ stream.write(HOSTED_CAPTURE_SCOPE_DESCRIPTION);
1232
+ const devHome = input.scope.devHome;
1233
+ const auth = await readStoreAuth(input.scope.env);
1234
+ const config = await readTraceUserConfig(devHome);
1235
+ stream.write(auth ? `Login: ${auth.login} at ${auth.origin}${auth.origin === input.origin ? "" : ` (selected store is ${input.origin}; run \`whiteboard login --origin ${input.origin}\`)`}\n` : `Login: none. Run \`whiteboard login --origin ${input.origin}\`.\n`);
1236
+ stream.write(`Capture switch: ${hostedCaptureEnabled(readTraceConfigFile({ devHome }).config) ? "on" : "off"}\n`);
1237
+ const owners = await describeTraceHookOwners(input.scope.homeDir, input.scope.env);
1238
+ stream.write(`Harness hooks: claude -> ${owners.claude ?? "none"}, codex -> ${owners.codex ?? "none"}, opencode -> ${owners.opencode ?? "none"}, pi -> ${owners.pi ?? "none"}\n`);
1239
+ const repositoryHooks = await traceRepositoryStatus(input.cwd);
1240
+ stream.write(`Git hooks: ${repositoryHooks.enabled ? repositoryHooks.command ?? "enabled" : "not enabled"}\n`);
1241
+ if (config.repositories.length === 0) stream.write(`Allowed repositories: none. Run \`whiteboard trace allow .\`.\n`);
1242
+ else for (const repository of config.repositories) stream.write(`Allowed repository: ${repository.name} (id ${repository.repositoryId}) -> ${repository.enabledOrigins.join(", ")}\n`);
1243
+ let repo = null;
1244
+ try {
1245
+ repo = await inferRepoFromGit(input.cwd);
1246
+ } catch {
1247
+ repo = null;
1248
+ }
1249
+ if (repo === null) stream.write("This directory has no GitHub remote to check.\n");
1250
+ else {
1251
+ const name = traceRepoName(repo);
1252
+ const entry = findTraceRepository(config, name);
1253
+ if (!entry) stream.write(`This repository (${name}) is not allowed. Run \`whiteboard trace allow .\`.\n`);
1254
+ else if (!entry.enabledOrigins.includes(input.origin)) stream.write(`This repository (${name}) is allowed at ${entry.enabledOrigins.join(", ")}, not the selected ${input.origin}. Run \`whiteboard trace allow .\` while logged in there.\n`);
1255
+ else stream.write(`This repository (${name}) is allowed to publish traces to ${input.origin}.\n`);
1256
+ }
1257
+ for (const sessionId of await pendingTraceSessions(input.cwd)) {
1258
+ if (input.session !== void 0 && input.session !== sessionId) continue;
1259
+ stream.write(`Pending agent session: ${sessionId}\n`);
1260
+ }
1261
+ for (const failure of await listTraceSyncFailures(devHome)) if (input.session === void 0 || input.session === failure.session) stream.write(describeTraceSyncFailure(failure));
1262
+ if (repo === null) return 1;
1263
+ return writeOwnUploadStatus({
1264
+ ...input,
1265
+ repo,
1266
+ client: input.client ?? (auth && auth.origin === input.origin ? new StoreClient({
1267
+ origin: auth.origin,
1268
+ token: auth.token
1269
+ }) : null)
1270
+ });
1271
+ }
1272
+ /** The agent sessions still marked active in this checkout. */
1273
+ async function pendingTraceSessions(cwd) {
1274
+ const gitPathResult = await git(cwd, [
1275
+ "rev-parse",
1276
+ "--git-path",
1277
+ "agent-session"
1278
+ ], { allowFailure: true });
1279
+ const sessionFilePath = gitPathResult.ok ? gitPathResult.stdout.trim() : "";
1280
+ if (!sessionFilePath) return [];
1281
+ return [...(await readActiveTraceSessions(sessionFilePath)).keys()];
1282
+ }
1283
+ //#endregion
1284
+ //#region ../trace-core/src/trace-machine-setup.ts
1285
+ const traceMachineSettingsSchema = z.object({
1286
+ version: z.literal(1),
1287
+ enabled: z.boolean(),
1288
+ autoActivateRepositories: z.literal(true),
1289
+ verifiedAt: z.string().optional(),
1290
+ error: z.string().optional()
1291
+ });
1292
+ /** The bucket credentials in the setup flow's field names. */
1293
+ async function readTraceCredentials(homeDir = os.homedir(), env = process.env) {
1294
+ const credentials = resolveS3Setup({
1295
+ homeDir,
1296
+ env
1297
+ }).credentials;
1298
+ if (!credentials) return null;
1299
+ return {
1300
+ endpoint: credentials.endpoint,
1301
+ bucket: credentials.bucket,
1302
+ key: credentials.accessKeyId,
1303
+ secret: credentials.secretAccessKey,
1304
+ region: credentials.region
1305
+ };
1306
+ }
1307
+ /**
1308
+ * The capture settings in effect: the profile's when the version-2 profile
1309
+ * carries them, otherwise the legacy settings file.
1310
+ */
1311
+ async function readCaptureSettings(setup, settingsPath) {
1312
+ if (setup.profile?.capture) return {
1313
+ settings: setup.profile.capture,
1314
+ source: "profile"
1315
+ };
1316
+ const legacy = await readSettings(settingsPath);
1317
+ return {
1318
+ settings: legacy ? {
1319
+ enabled: legacy.enabled,
1320
+ autoActivateRepositories: legacy.autoActivateRepositories,
1321
+ verifiedAt: legacy.verifiedAt,
1322
+ error: legacy.error
1323
+ } : null,
1324
+ source: "settings"
1325
+ };
1326
+ }
1327
+ async function traceMachineStatus(input = {}) {
1328
+ const homeDir = input.homeDir ?? os.homedir();
1329
+ const env = input.env ?? process.env;
1330
+ const envPath = traceEnvPath(homeDir, env);
1331
+ const settingsPath = traceSettingsPath(homeDir, env);
1332
+ const selection = selectTraceStorage({
1333
+ homeDir,
1334
+ env
1335
+ });
1336
+ if (!selection.s3) return {
1337
+ enabled: false,
1338
+ configured: false,
1339
+ autoActivateRepositories: false,
1340
+ envPath,
1341
+ settingsPath,
1342
+ configPath: selection.config.path,
1343
+ storageMode: selection.mode,
1344
+ error: selection.error
1345
+ };
1346
+ const setup = selection.s3;
1347
+ const credentials = setup.credentials;
1348
+ const { settings, source } = await readCaptureSettings(setup, settingsPath);
1349
+ const hostedSelected = selection.mode === "hosted";
1350
+ const hosted = hostedSelected && hostedCaptureEnabled(selection.config.config);
1351
+ const s3Enabled = !hostedSelected && settings?.enabled === true;
1352
+ const status = {
1353
+ enabled: hosted || s3Enabled,
1354
+ configured: hostedSelected || credentials !== null,
1355
+ autoActivateRepositories: hosted || s3Enabled && settings?.autoActivateRepositories === true,
1356
+ envPath,
1357
+ settingsPath,
1358
+ configPath: setup.configPath,
1359
+ storageMode: selection.mode,
1360
+ credentialsSource: setup.source,
1361
+ captureSource: source
1362
+ };
1363
+ if (credentials) {
1364
+ status.endpoint = credentials.endpoint;
1365
+ status.bucket = credentials.bucket;
1366
+ status.region = credentials.region;
1367
+ status.accessKeyIdPrefix = credentials.accessKeyId.slice(0, 6);
1368
+ }
1369
+ if (settings?.verifiedAt) status.verifiedAt = settings.verifiedAt;
1370
+ if (settings?.error) status.error = settings.error;
1371
+ if (selection.error) status.error = selection.error;
1372
+ return status;
1373
+ }
1374
+ /**
1375
+ * Whether this machine captures agent sessions at all. Every gate (session
1376
+ * hook, scaffold discovery, hook installation, repository enable/repair)
1377
+ * asks this instead of reading the settings file itself.
1378
+ */
1379
+ async function traceMachineEnabled(input = {}) {
1380
+ return (await traceMachineStatus(input)).enabled;
1381
+ }
1382
+ async function configureTraceMachine(input) {
1383
+ const homeDir = input.homeDir ?? os.homedir();
1384
+ const env = input.env ?? process.env;
1385
+ const existing = await readTraceCredentials(homeDir, env);
1386
+ const credentials = {
1387
+ endpoint: input.credentials?.endpoint ?? existing?.endpoint ?? "",
1388
+ bucket: input.credentials?.bucket ?? existing?.bucket ?? "",
1389
+ key: input.credentials?.key ?? existing?.key ?? "",
1390
+ secret: input.credentials?.secret ?? existing?.secret ?? ""
1391
+ };
1392
+ if (!Object.values(credentials).every(Boolean)) throw new Error("Trace setup needs an S3/R2 endpoint, bucket, access key ID, and secret access key.");
1393
+ const region = input.credentials?.region?.trim() || existing?.region || "auto";
1394
+ const configFile = readTraceConfigFile({
1395
+ homeDir,
1396
+ env
1397
+ });
1398
+ if (configFile.error) throw new TraceConfigurationError(configFile.error);
1399
+ const envPath = traceEnvPath(homeDir, env);
1400
+ const legacyPathsRequested = env.TRACE_ENV_FILE !== void 0 || env.TRACE_SETTINGS_FILE !== void 0;
1401
+ const target = configFile.config?.stores?.s3 || !existsSync(envPath) && !legacyPathsRequested ? "profile" : "legacy";
1402
+ if (target === "legacy") {
1403
+ await mkdir(path.dirname(envPath), { recursive: true });
1404
+ await writeFile(envPath, [
1405
+ `export TRACE_R2_ENDPOINT=${JSON.stringify(credentials.endpoint)}`,
1406
+ `export TRACE_R2_BUCKET=${JSON.stringify(credentials.bucket)}`,
1407
+ `export TRACE_R2_ACCESS_KEY_ID=${JSON.stringify(credentials.key)}`,
1408
+ `export TRACE_R2_SECRET_ACCESS_KEY=${JSON.stringify(credentials.secret)}`,
1409
+ `export TRACE_R2_REGION=${JSON.stringify(region)}`,
1410
+ ""
1411
+ ].join("\n"), { mode: 384 });
1412
+ await chmod(envPath, 384);
1413
+ clearTraceEnvCache();
1414
+ }
1415
+ let error;
1416
+ let verifiedAt;
1417
+ if (input.verify !== false) {
1418
+ if (env.TRACE_R2_MODE === "mock") verifiedAt = (/* @__PURE__ */ new Date()).toISOString();
1419
+ else {
1420
+ const { S3TraceStorage } = await import("./s3-SzLpeCG6.js");
1421
+ const readiness = await S3TraceStorage.fromCredentials({
1422
+ endpoint: credentials.endpoint,
1423
+ bucket: credentials.bucket,
1424
+ accessKeyId: credentials.key,
1425
+ secretAccessKey: credentials.secret,
1426
+ region
1427
+ }, env).readiness();
1428
+ if (readiness.ready) verifiedAt = (/* @__PURE__ */ new Date()).toISOString();
1429
+ else error = readiness.reason;
1430
+ }
1431
+ }
1432
+ if (target === "legacy") {
1433
+ const settings = {
1434
+ version: 1,
1435
+ enabled: true,
1436
+ autoActivateRepositories: true
1437
+ };
1438
+ if (verifiedAt) settings.verifiedAt = verifiedAt;
1439
+ if (error) settings.error = error;
1440
+ await writeSettings(traceSettingsPath(homeDir, env), settings);
1441
+ } else {
1442
+ const capture = {
1443
+ enabled: true,
1444
+ autoActivateRepositories: true
1445
+ };
1446
+ if (verifiedAt) capture.verifiedAt = verifiedAt;
1447
+ if (error) capture.error = error;
1448
+ await writeS3Profile(configFile, {
1449
+ endpoint: credentials.endpoint,
1450
+ bucket: credentials.bucket,
1451
+ accessKeyId: credentials.key,
1452
+ secretAccessKey: credentials.secret,
1453
+ region,
1454
+ capture
1455
+ });
1456
+ clearTraceEnvCache();
1457
+ }
1458
+ return traceMachineStatus({
1459
+ homeDir,
1460
+ env
1461
+ });
1462
+ }
1463
+ /**
1464
+ * Stores the profile and, when nothing is selected yet, selects direct
1465
+ * storage explicitly. An explicit hosted selection is left alone: setup
1466
+ * never silently redirects uploads.
1467
+ */
1468
+ async function writeS3Profile(configFile, profile) {
1469
+ const current = configFile.config ?? emptyTraceConfig();
1470
+ await writeTraceConfigFile(configFile, {
1471
+ ...current,
1472
+ "current-store": current["current-store"] ?? "s3",
1473
+ stores: {
1474
+ ...current.stores,
1475
+ s3: profile
1476
+ }
1477
+ });
1478
+ }
1479
+ async function disableTraceMachine(input = {}) {
1480
+ const homeDir = input.homeDir ?? os.homedir();
1481
+ const env = input.env ?? process.env;
1482
+ const configFile = readTraceConfigFile({
1483
+ homeDir,
1484
+ env
1485
+ });
1486
+ if (configFile.error) throw new TraceConfigurationError(configFile.error);
1487
+ if (selectTraceStorage({
1488
+ homeDir,
1489
+ env
1490
+ }).mode === "hosted") {
1491
+ const current = configFile.config ?? emptyTraceConfig();
1492
+ await writeTraceConfigFile(configFile, {
1493
+ ...current,
1494
+ stores: {
1495
+ ...current.stores,
1496
+ hosted: {
1497
+ ...current.stores?.hosted,
1498
+ capture: { enabled: false }
1499
+ }
1500
+ }
1501
+ });
1502
+ return;
1503
+ }
1504
+ const profile = configFile.config?.stores?.s3;
1505
+ if (profile?.capture) {
1506
+ await writeS3Profile(configFile, {
1507
+ ...profile,
1508
+ capture: {
1509
+ ...profile.capture,
1510
+ enabled: false
1511
+ }
1512
+ });
1513
+ return;
1514
+ }
1515
+ const settingsPath = traceSettingsPath(homeDir, env);
1516
+ if (input.removeSettings) {
1517
+ await rm(settingsPath, { force: true });
1518
+ return;
1519
+ }
1520
+ await writeSettings(settingsPath, {
1521
+ version: 1,
1522
+ enabled: false,
1523
+ autoActivateRepositories: true
1524
+ });
1525
+ }
1526
+ /** The legacy settings file alone, as `config migrate` reports it. */
1527
+ async function readLegacyCaptureSettings(filePath) {
1528
+ const legacy = await readSettings(filePath);
1529
+ if (!legacy) return null;
1530
+ const capture = {
1531
+ enabled: legacy.enabled,
1532
+ autoActivateRepositories: legacy.autoActivateRepositories
1533
+ };
1534
+ if (legacy.verifiedAt) capture.verifiedAt = legacy.verifiedAt;
1535
+ if (legacy.error) capture.error = legacy.error;
1536
+ return capture;
1537
+ }
1538
+ async function readSettings(filePath) {
1539
+ try {
1540
+ const parsed = traceMachineSettingsSchema.safeParse(parseJsonText(await readFile(filePath, "utf8")));
1541
+ return parsed.success ? parsed.data : null;
1542
+ } catch {
1543
+ return null;
1544
+ }
1545
+ }
1546
+ async function writeSettings(filePath, settings) {
1547
+ await writeFileAtomicAsync(filePath, `${JSON.stringify(settings, null, 2)}\n`, {
1548
+ encoding: "utf8",
1549
+ mode: 384
1550
+ });
1551
+ }
1552
+ //#endregion
1553
+ //#region ../trace-core/src/trace-hook-runner.ts
1554
+ const execFileAsync = promisify(execFile);
1555
+ const SESSION_ID_REGEX = /^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$/;
1556
+ /**
1557
+ * Starts `whiteboard trace sync` detached for one session. The attempt names
1558
+ * the destination it was started for; the sync rechecks the selection and
1559
+ * consent before any transfer. A missing CLI reports asynchronously and
1560
+ * never fails the caller.
1561
+ */
1562
+ function spawnDetachedTraceSync(input) {
1563
+ try {
1564
+ const command = resolveTraceCommand({
1565
+ explicit: input.command,
1566
+ env: input.scope.env,
1567
+ homeDir: input.scope.homeDir
1568
+ });
1569
+ const expectation = traceStorageExpectation(input.scope);
1570
+ const child = spawn(command.file, [
1571
+ ...command.args ?? [],
1572
+ "trace",
1573
+ "sync",
1574
+ input.sessionId,
1575
+ "--expect-storage",
1576
+ expectation
1577
+ ], {
1578
+ cwd: input.cwd,
1579
+ detached: true,
1580
+ stdio: "ignore"
1581
+ });
1582
+ child.on("error", () => {});
1583
+ child.unref();
1584
+ } catch {}
1585
+ }
1586
+ async function runTraceHook(input) {
1587
+ if (input.scope.env.TRACE_DISABLE === "1") return 0;
1588
+ const selection = selectTraceStorage(input.scope);
1589
+ if (selection.error || selection.mode === "none") return 0;
1590
+ if (!await traceMachineEnabled(input.scope)) return 0;
1591
+ let event = input.event;
1592
+ let sessionId = input.sessionId;
1593
+ if (input.stdin && !input.stdin.isTTY) try {
1594
+ const chunks = [];
1595
+ for await (const chunk of input.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1596
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
1597
+ if (raw) {
1598
+ const parsed = jsonObject(parseJsonText(raw));
1599
+ const hookEventName = jsonString(parsed?.hook_event_name);
1600
+ const parsedSessionId = jsonString(parsed?.session_id);
1601
+ if (hookEventName) event = hookEventName;
1602
+ if (parsedSessionId) sessionId = parsedSessionId;
1603
+ }
1604
+ } catch {}
1605
+ sessionId = (sessionId || input.scope.env.AGENT_SESSION_ID || "").trim();
1606
+ if (!sessionId || !SESSION_ID_REGEX.test(sessionId)) return 0;
1607
+ const normalizedEvent = event.toLowerCase().replaceAll(/[_-]/g, "");
1608
+ const isStart = normalizedEvent === "sessionstart";
1609
+ const isEnd = normalizedEvent === "sessionend";
1610
+ const isHeartbeat = normalizedEvent === "userpromptsubmit" || normalizedEvent === "turnstart";
1611
+ if (!isStart && !isEnd && !isHeartbeat) return 0;
1612
+ if (selection.mode === "hosted") {
1613
+ const origin = selection.hosted?.origin ?? "";
1614
+ const entry = await resolveAllowedTraceRepository(input.cwd, input.scope);
1615
+ await recordCaptureProvenance({
1616
+ cwd: input.cwd,
1617
+ sessionId,
1618
+ entry,
1619
+ origin,
1620
+ scope: input.scope
1621
+ }).catch(() => void 0);
1622
+ if (!entry || !entry.enabledOrigins.includes(origin)) return 0;
1623
+ }
1624
+ if (isStart) await enableTraceRepository({
1625
+ cwd: input.cwd,
1626
+ scope: input.scope,
1627
+ reviewCommand: input.traceCommand
1628
+ }).catch(() => void 0);
1629
+ const gitPathResult = await git(input.cwd, [
1630
+ "rev-parse",
1631
+ "--git-path",
1632
+ "agent-session"
1633
+ ], { allowFailure: true });
1634
+ let sessionFilePath = null;
1635
+ if (gitPathResult.ok && gitPathResult.stdout.trim()) sessionFilePath = gitPathResult.stdout.trim();
1636
+ const jjRootResult = await execFileAsync("jj", ["root"], { cwd: input.cwd }).catch(() => null);
1637
+ if (!sessionFilePath && jjRootResult?.stdout.trim()) sessionFilePath = path.join(jjRootResult.stdout.trim(), ".jj", "agent-session");
1638
+ let remainingSessions = [];
1639
+ if (sessionFilePath) {
1640
+ const now = Date.now();
1641
+ const currentSessions = await readActiveTraceSessions(sessionFilePath, now);
1642
+ if (isStart || isHeartbeat) {
1643
+ currentSessions.set(sessionId, now);
1644
+ remainingSessions = [...currentSessions.keys()];
1645
+ await writeTraceSessions(sessionFilePath, currentSessions).catch(() => void 0);
1646
+ } else if (isEnd) {
1647
+ currentSessions.delete(sessionId);
1648
+ remainingSessions = [...currentSessions.keys()];
1649
+ await writeTraceSessions(sessionFilePath, currentSessions).catch(() => void 0);
1650
+ }
1651
+ }
1652
+ if (jjRootResult?.stdout.trim()) {
1653
+ if ((isStart || isHeartbeat) && remainingSessions.length > 0) {
1654
+ const templateVal = jjCommitTrailersConfigValue(remainingSessions);
1655
+ await execFileAsync("jj", [
1656
+ "config",
1657
+ "set",
1658
+ "--repo",
1659
+ "templates.commit_trailers",
1660
+ templateVal
1661
+ ], { cwd: input.cwd }).catch(() => void 0);
1662
+ } else if (isEnd) {
1663
+ if (remainingSessions.length > 0) {
1664
+ const templateVal = jjCommitTrailersConfigValue(remainingSessions);
1665
+ await execFileAsync("jj", [
1666
+ "config",
1667
+ "set",
1668
+ "--repo",
1669
+ "templates.commit_trailers",
1670
+ templateVal
1671
+ ], { cwd: input.cwd }).catch(() => void 0);
1672
+ } else await execFileAsync("jj", [
1673
+ "config",
1674
+ "unset",
1675
+ "--repo",
1676
+ "templates.commit_trailers"
1677
+ ], { cwd: input.cwd }).catch(() => void 0);
1678
+ }
1679
+ }
1680
+ if (isEnd) spawnDetachedTraceSync({
1681
+ sessionId,
1682
+ cwd: input.cwd,
1683
+ scope: input.scope,
1684
+ command: input.traceCommand
1685
+ });
1686
+ return 0;
1687
+ }
1688
+ /**
1689
+ * The consent entry for this checkout's repository, or null when the
1690
+ * directory is not a GitHub repository or has no entry.
1691
+ */
1692
+ async function resolveAllowedTraceRepository(cwd, scope) {
1693
+ let name;
1694
+ try {
1695
+ name = traceRepoName(await inferRepoFromGit(cwd));
1696
+ } catch {
1697
+ return null;
1698
+ }
1699
+ const config = await readTraceUserConfig(scope.devHome);
1700
+ return findTraceRepository(config, name);
1701
+ }
1702
+ /**
1703
+ * Records where this session ran. The identity is the allowed target when
1704
+ * the entry names the selected store and the login matches it; otherwise
1705
+ * the repository, or the Git directory of a checkout without a remote.
1706
+ */
1707
+ async function recordCaptureProvenance(input) {
1708
+ const auth = await readStoreAuth(input.scope.env);
1709
+ let identity;
1710
+ if (input.entry && input.entry.enabledOrigins.includes(input.origin) && auth && auth.origin === input.origin) identity = traceCaptureIdentity({ target: {
1711
+ origin: input.origin,
1712
+ repositoryId: input.entry.repositoryId
1713
+ } });
1714
+ else {
1715
+ const repo = await inferRepoFromGit(input.cwd).catch(() => null);
1716
+ if (repo) identity = traceCaptureIdentity({ repositoryName: traceRepoName(repo) });
1717
+ else {
1718
+ const gitDir = await gitCommonDirectory(input.cwd);
1719
+ if (!gitDir) return;
1720
+ identity = traceCaptureIdentity({ gitDir });
1721
+ }
1722
+ }
1723
+ await recordTraceSessionProvenance({
1724
+ sessionId: input.sessionId,
1725
+ ...identity,
1726
+ devHome: input.scope.devHome
1727
+ });
1728
+ }
1729
+ function jjCommitTrailersConfigValue(sessionIds) {
1730
+ const trailers = `${sessionIds.map((id) => `Agent-Session: ${id}`).join("\n")}\n`;
1731
+ const templateExpression = JSON.stringify(trailers);
1732
+ return JSON.stringify(templateExpression);
1733
+ }
1734
+ //#endregion
1735
+ //#region ../trace-core/src/trace-git-hook-runner.ts
1736
+ const ZERO_OID = /^0+$/;
1737
+ async function runTraceGitHook(input) {
1738
+ if (input.scope.env.TRACE_DISABLE === "1") return 0;
1739
+ if (!await traceMachineEnabled(input.scope)) return 0;
1740
+ try {
1741
+ if (input.hook === "prepare-commit-msg") return runPrepareCommitMessage(input.cwd, input.args[0]);
1742
+ if (input.hook === "pre-push") {
1743
+ await runPrePush(input);
1744
+ return 0;
1745
+ }
1746
+ } catch (cause) {
1747
+ input.stderr.write(`trace-sync: warning: ${errorMessage(cause)}\n`);
1748
+ }
1749
+ return 0;
1750
+ }
1751
+ async function runPrepareCommitMessage(cwd, messagePath) {
1752
+ if (!messagePath) return 0;
1753
+ const sessions = await activeSessions(cwd);
1754
+ for (const session of sessions) await git(cwd, [
1755
+ "interpret-trailers",
1756
+ "--in-place",
1757
+ "--if-exists",
1758
+ "addIfDifferent",
1759
+ "--trailer",
1760
+ `Agent-Session: ${session}`,
1761
+ messagePath
1762
+ ], { allowFailure: true });
1763
+ return 0;
1764
+ }
1765
+ async function activeSessions(cwd) {
1766
+ const fromEnv = (process.env.AGENT_SESSION_ID ?? "").trim();
1767
+ if (sessionIdSchema.safeParse(fromEnv).success) return [fromEnv];
1768
+ const fileResult = await git(cwd, [
1769
+ "rev-parse",
1770
+ "--git-path",
1771
+ "agent-session"
1772
+ ], { allowFailure: true });
1773
+ if (!fileResult.ok) return [];
1774
+ const filePath = fileResult.stdout.trim();
1775
+ if (!filePath) return [];
1776
+ const sessions = await readActiveTraceSessions(filePath);
1777
+ await writeTraceSessions(filePath, sessions).catch(() => void 0);
1778
+ return [...sessions.keys()];
1779
+ }
1780
+ async function runPrePush(input) {
1781
+ const raw = await readStdin(input.stdin);
1782
+ const commits = /* @__PURE__ */ new Map();
1783
+ for (const line of raw.split("\n")) {
1784
+ const [localRef, localSha, remoteRef, remoteSha] = line.trim().split(/\s+/);
1785
+ if (!localRef || !localSha || !remoteRef || !remoteSha || ZERO_OID.test(localSha)) continue;
1786
+ const branch = remoteRef.startsWith("refs/heads/") ? remoteRef.slice(11) : null;
1787
+ const revisionArgs = await revisionRange(input.cwd, localSha, remoteSha);
1788
+ const listed = await git(input.cwd, [
1789
+ "rev-list",
1790
+ "--max-count=500",
1791
+ ...revisionArgs
1792
+ ], { allowFailure: true });
1793
+ if (!listed.ok) continue;
1794
+ for (const commit of listed.stdout.split("\n").map((value) => value.trim()).filter(Boolean)) {
1795
+ const sessions = await readTrailerSessions(input.cwd, commit);
1796
+ if (sessions.length > 0) commits.set(commit, {
1797
+ branch,
1798
+ sessions
1799
+ });
1800
+ }
1801
+ }
1802
+ if (commits.size === 0) return;
1803
+ const sessionCommits = /* @__PURE__ */ new Map();
1804
+ for (const [commit, value] of commits) {
1805
+ await writeReviewTraceCommitMapping({
1806
+ cwd: input.cwd,
1807
+ commit,
1808
+ sessions: value.sessions,
1809
+ branch: value.branch
1810
+ }).catch((cause) => warn(input.stderr, cause));
1811
+ for (const session of value.sessions) sessionCommits.set(session, [...sessionCommits.get(session) ?? [], commit]);
1812
+ }
1813
+ const selection = selectTraceStorage(input.scope);
1814
+ for (const [sessionId, values] of sessionCommits) {
1815
+ if (!isOpenCodeSessionId(sessionId)) try {
1816
+ if (!await findLocalTrace(sessionId)) {
1817
+ input.stderr.write(`trace-sync: skipping ${sessionId}: no local transcript.\n`);
1818
+ continue;
1819
+ }
1820
+ } catch (cause) {
1821
+ warn(input.stderr, cause);
1822
+ continue;
1823
+ }
1824
+ if (selection.mode === "hosted") {
1825
+ spawnDetachedTraceSync({
1826
+ sessionId,
1827
+ cwd: input.cwd,
1828
+ scope: input.scope,
1829
+ command: input.traceCommand
1830
+ });
1831
+ continue;
1832
+ }
1833
+ await syncReviewTrace({
1834
+ sessionId,
1835
+ cwd: input.cwd,
1836
+ commits: values
1837
+ }).catch((cause) => warn(input.stderr, cause));
1838
+ }
1839
+ }
1840
+ async function revisionRange(cwd, localSha, remoteSha) {
1841
+ if (ZERO_OID.test(remoteSha)) return [
1842
+ localSha,
1843
+ "--not",
1844
+ "--remotes"
1845
+ ];
1846
+ return (await git(cwd, [
1847
+ "cat-file",
1848
+ "-e",
1849
+ remoteSha
1850
+ ], { allowFailure: true })).ok ? [`${remoteSha}..${localSha}`] : [
1851
+ localSha,
1852
+ "--not",
1853
+ "--remotes"
1854
+ ];
1855
+ }
1856
+ async function readStdin(stdin) {
1857
+ if (!stdin) return "";
1858
+ const chunks = [];
1859
+ for await (const chunk of stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1860
+ return Buffer.concat(chunks).toString("utf8");
1861
+ }
1862
+ function warn(stderr, cause) {
1863
+ stderr.write(`trace-sync: warning: ${errorMessage(cause)}\n`);
1864
+ }
1865
+ //#endregion
1866
+ //#region ../trace-core/src/trace-capture-cli.ts
1867
+ async function runTraceStatus(input) {
1868
+ const machine = await traceMachineStatus(input.scope);
1869
+ const repository = await traceRepositoryStatus(input.cwd);
1870
+ const selection = selectTraceStorage(input.scope);
1871
+ input.stdout.write(`Trace capture: ${machine.enabled ? "enabled" : "disabled"}\n`);
1872
+ input.stdout.write(`Repository: ${repository.message}\n`);
1873
+ input.stdout.write(`Storage: ${describeSelection(selection)}\n`);
1874
+ input.stdout.write(`Config: ${selection.config.path} (${selection.config.source === "absent" ? "not present" : `version ${selection.config.source === "v1" ? "1, consent only" : "2"}`})\n`);
1875
+ if (selection.error) {
1876
+ input.stderr.write(`trace status: ${selection.error}\n`);
1877
+ return 1;
1878
+ }
1879
+ if (selection.mode === "hosted") return writeHostedTraceStatus({
1880
+ scope: input.scope,
1881
+ cwd: input.cwd,
1882
+ origin: selection.hosted?.origin ?? "",
1883
+ stdout: input.stdout,
1884
+ session: input.session,
1885
+ cursor: input.cursor,
1886
+ limit: input.limit
1887
+ });
1888
+ if (input.session !== void 0 || input.cursor !== void 0 || input.limit !== void 0) {
1889
+ input.stderr.write("Upload status filters require hosted storage.\n");
1890
+ return 1;
1891
+ }
1892
+ const [{ S3TraceStorage }, { describeS3Setup, noTraceConfigurationMessage }] = await Promise.all([import("./s3-SzLpeCG6.js"), import("./s3-config-BgxkSoOy.js")]);
1893
+ const setup = describeS3Setup(input.scope.env);
1894
+ input.stdout.write(`Checking trace configuration (${setup.envPath})…\n`);
1895
+ for (const failure of await listTraceSyncFailures(input.scope.devHome)) input.stdout.write(describeTraceSyncFailure(failure));
1896
+ if (!setup.config) {
1897
+ input.stderr.write(`trace status: ${setup.error ?? noTraceConfigurationMessage()}\n`);
1898
+ return 1;
1899
+ }
1900
+ input.stdout.write(` Endpoint: ${setup.config.endpoint}\n`);
1901
+ input.stdout.write(` Bucket: ${setup.config.bucket}\n`);
1902
+ input.stdout.write(` Key: ${setup.config.accessKeyId.slice(0, 6)}…\n`);
1903
+ const readiness = await S3TraceStorage.fromEnvironment(input.scope)?.readiness() ?? {
1904
+ ready: false,
1905
+ reason: "unknown error"
1906
+ };
1907
+ if (readiness.ready) {
1908
+ input.stdout.write(`✓ S3/R2 bucket "${setup.config.bucket}" is reachable.\n`);
1909
+ return 0;
1910
+ }
1911
+ input.stderr.write(`✗ Cannot reach S3/R2 bucket "${setup.config.bucket}": ${readiness.reason ?? "unknown error"}\n`);
1912
+ return 1;
1913
+ }
1914
+ async function runTraceEnable(input) {
1915
+ if (!(await traceMachineStatus(input.scope)).enabled) {
1916
+ input.stderr.write(`trace enable: Trace capture is not enabled. Run \`whiteboard trace allow .\`\n`);
1917
+ return 1;
1918
+ }
1919
+ const result = await enableTraceRepository({
1920
+ cwd: input.cwd,
1921
+ scope: input.scope,
1922
+ reviewCommand: input.traceCommand
1923
+ });
1924
+ (result.enabled ? input.stdout : input.stderr).write(`${result.message}\n`);
1925
+ return result.enabled ? 0 : 1;
1926
+ }
1927
+ /**
1928
+ * Installs the machine parts of trace capture: the harness hooks of every
1929
+ * agent, and the CLI itself when the CLI passes an installer. The command
1930
+ * touches no repository; `allow` keeps the consent and the Git hooks.
1931
+ *
1932
+ * The CLI install runs first, because it reports the command file the
1933
+ * harness hooks must call.
1934
+ */
1935
+ async function runTraceInstallMachine(input) {
1936
+ const output = {
1937
+ json: input.json,
1938
+ stdout: input.stdout,
1939
+ stderr: input.stderr
1940
+ };
1941
+ const traceCommand = input.traceCommand;
1942
+ const { installed, skipped } = await installHarnessHooks({
1943
+ homeDir: input.scope.homeDir,
1944
+ env: input.scope.env,
1945
+ executable: traceCommand?.file,
1946
+ harnessHooks: input.harnessHooks,
1947
+ allHarnesses: input.allHarnesses
1948
+ });
1949
+ emitJsonEvent(output, {
1950
+ event: "trace.install",
1951
+ hooks: installed,
1952
+ skipped
1953
+ });
1954
+ const stream = humanStream(output);
1955
+ if (input.harnessHooks === false) {
1956
+ stream.write("Harness hooks: skipped.\n");
1957
+ return 0;
1958
+ }
1959
+ for (const hook of installed) stream.write(`Harness hook: ${hook.agent} -> ${hook.path}\n`);
1960
+ if (skipped.length > 0) stream.write(skippedHarnessesLine(skipped));
1961
+ return 0;
1962
+ }
1963
+ async function runTraceDisable(input) {
1964
+ const result = await disableTraceRepository({
1965
+ cwd: input.cwd,
1966
+ scope: input.scope
1967
+ });
1968
+ input.stdout.write(`${result.message}\n`);
1969
+ return result.repository ? 0 : 1;
1970
+ }
1971
+ async function runTraceRepair(input) {
1972
+ if (!(await traceMachineStatus(input.scope)).enabled) {
1973
+ input.stderr.write(`trace repair: Trace capture is not enabled. Run \`whiteboard trace allow .\`\n`);
1974
+ return 1;
1975
+ }
1976
+ const result = await repairTraceRepository({
1977
+ cwd: input.cwd,
1978
+ scope: input.scope,
1979
+ reviewCommand: input.traceCommand
1980
+ });
1981
+ (result.enabled ? input.stdout : input.stderr).write(`${result.message}\n`);
1982
+ return result.enabled ? 0 : 1;
1983
+ }
1984
+ async function runTraceSync(input) {
1985
+ let result;
1986
+ try {
1987
+ if (input.expectStorage !== void 0) {
1988
+ const current = traceStorageExpectation(input.scope);
1989
+ if (current !== input.expectStorage) throw new Error(`The trace storage selection changed since this capture started (expected ${input.expectStorage}, now ${current}). Run \`whiteboard trace sync ${input.sessionId}\` to publish to the current selection.`);
1990
+ }
1991
+ result = await syncReviewTrace({
1992
+ sessionId: input.sessionId,
1993
+ cwd: input.cwd,
1994
+ repo: input.repo
1995
+ });
1996
+ } catch (error) {
1997
+ const message = errorMessage(error);
1998
+ await recordTraceSyncFailure({
1999
+ sessionId: input.sessionId.trim(),
2000
+ repository: await inferRepoFromGit(input.cwd).then((repo) => `${repo.owner}/${repo.repo}`).catch(() => null),
2001
+ error: message,
2002
+ reason: error instanceof TraceProvenanceError ? error.reason : void 0,
2003
+ devHome: input.scope.devHome
2004
+ }).catch(() => void 0);
2005
+ throw error;
2006
+ }
2007
+ await clearTraceSyncFailure(input.sessionId.trim(), input.scope.devHome).catch(() => void 0);
2008
+ if (input.json) {
2009
+ input.stdout.write(`${JSON.stringify(result)}\n`);
2010
+ return 0;
2011
+ }
2012
+ for (const upload of result.uploads) input.stdout.write(`${upload.blob} ${upload.bytes_stored} bytes ${upload.status}\n`);
2013
+ if (result.hosted) {
2014
+ for (const name of result.hosted.omitted.subagents) input.stdout.write(`${name} omitted (over the object limit or not a store name)\n`);
2015
+ if (result.hosted.omitted.commits > 0) input.stdout.write(`${result.hosted.omitted.commits} commit link(s) omitted (over the commit limit).\n`);
2016
+ input.stdout.write(result.hosted.complete ? `Published session ${result.session} of ${result.repo} to the trace store (generation ${result.hosted.generation}).\n` : `Published part of session ${result.session} of ${result.repo} to the trace store (generation ${result.hosted.generation}).\n`);
2017
+ return 0;
2018
+ }
2019
+ input.stdout.write(`Updated meta for session ${result.session} in ${result.repo}.\n`);
2020
+ return 0;
2021
+ }
2022
+ //#endregion
2023
+ //#region ../trace-core/src/trace-read-cli.ts
2024
+ /**
2025
+ * The store a read command uses: the explicit `--storage` override for this
2026
+ * one operation, or the machine's selection when none is given. An override
2027
+ * never changes the selection, capture settings, or consent.
2028
+ */
2029
+ async function resolveTraceReadStorage(override, cwd) {
2030
+ if (!override) return void 0;
2031
+ return resolveTraceStorage({
2032
+ cwd,
2033
+ override
2034
+ });
2035
+ }
2036
+ async function listSessionsForReviewScope(review, storage) {
2037
+ return listReviewTraceSessions({
2038
+ rootPath: review.repoRoot,
2039
+ baseCommit: review.baseCommit,
2040
+ headCommit: review.headCommit,
2041
+ storage
2042
+ });
2043
+ }
2044
+ async function runTraceList(input) {
2045
+ const storage = "resolvedStorage" in input ? input.resolvedStorage : await resolveTraceReadStorage(input.storage, input.cwd);
2046
+ let scope;
2047
+ let sessions;
2048
+ if ("commit" in input.scope) {
2049
+ const resolution = await lookupReviewTraceCommit({
2050
+ cwd: input.cwd,
2051
+ sha: input.scope.commit,
2052
+ storage
2053
+ });
2054
+ scope = { commit: resolution.commit };
2055
+ sessions = await Promise.all(resolution.sessions.map((sessionId) => describeTraceSession({
2056
+ sessionId,
2057
+ commits: [{
2058
+ sha: resolution.commit,
2059
+ subject: ""
2060
+ }]
2061
+ }, storage)));
2062
+ } else {
2063
+ scope = { review: input.scope.review.uuid };
2064
+ sessions = await listSessionsForReviewScope(input.scope.review, storage);
2065
+ }
2066
+ const publicSessions = sessions.map((session) => ({
2067
+ id: session.sessionId,
2068
+ harness: session.harness,
2069
+ available: session.available,
2070
+ traces: ["main", ...session.subagents ?? []],
2071
+ commits: session.commits
2072
+ }));
2073
+ if (input.json) {
2074
+ input.stdout.write(`${JSON.stringify({
2075
+ ...scope,
2076
+ sessions: publicSessions
2077
+ })}\n`);
2078
+ return 0;
2079
+ }
2080
+ if (sessions.length === 0) {
2081
+ const label = "review" in scope ? `review ${scope.review}` : `commit ${scope.commit}`;
2082
+ input.stdout.write(`No agent sessions recorded for ${label}.\n`);
2083
+ return 0;
2084
+ }
2085
+ for (const session of publicSessions) {
2086
+ input.stdout.write(`${session.id} (${session.harness}, ${session.available ? "S3/R2 synced" : "not synced"})\n`);
2087
+ for (const commit of session.commits) input.stdout.write(` commit ${commit.sha.slice(0, 9)} ${commit.subject}\n`);
2088
+ for (const name of session.traces.slice(1)) input.stdout.write(` trace ${name}\n`);
2089
+ }
2090
+ return 0;
2091
+ }
2092
+ async function runTraceShow(input) {
2093
+ const traceName = input.trace === "main" ? void 0 : input.trace;
2094
+ const loaded = await loadReviewAgentTrace({
2095
+ sessionId: input.sessionId,
2096
+ trace: traceName,
2097
+ cwd: input.cwd,
2098
+ storage: await resolveTraceReadStorage(input.storage, input.cwd)
2099
+ });
2100
+ if (!loaded) throw new Error(`No transcript is available for session ${input.sessionId}${traceName ? ` (trace ${traceName})` : ""}.`);
2101
+ const { trace } = loaded;
2102
+ if (input.eventIndex !== void 0) {
2103
+ const event = trace.events[input.eventIndex];
2104
+ if (!event) throw new Error(`Event ${input.eventIndex} is out of range. Session ${input.sessionId} has ${trace.events.length} events.`);
2105
+ const text = extractTraceEventText(event);
2106
+ if (input.json) {
2107
+ const traceQuoteProps = {
2108
+ sessionId: input.sessionId,
2109
+ event: input.eventIndex,
2110
+ trace: traceName || void 0
2111
+ };
2112
+ input.stdout.write(`${JSON.stringify({
2113
+ session: input.sessionId,
2114
+ trace: traceName ?? "main",
2115
+ event: input.eventIndex,
2116
+ kind: event.kind,
2117
+ text,
2118
+ trace_quote_props: traceQuoteProps
2119
+ })}\n`);
2120
+ return 0;
2121
+ }
2122
+ input.stdout.write(`${text}\n`);
2123
+ return 0;
2124
+ }
2125
+ const rows = trace.events.map((event, index) => ({
2126
+ event,
2127
+ index
2128
+ })).filter((row) => !input.kind || row.event.kind === input.kind);
2129
+ if (input.json) {
2130
+ input.stdout.write(`${JSON.stringify({
2131
+ session: input.sessionId,
2132
+ trace: traceName ?? "main",
2133
+ harness: trace.harness,
2134
+ title: trace.title,
2135
+ cache: loaded.cacheStatus,
2136
+ events: rows.map(({ event, index }) => ({
2137
+ event: index,
2138
+ kind: event.kind,
2139
+ summary: compactEventLine(event)
2140
+ }))
2141
+ })}\n`);
2142
+ return 0;
2143
+ }
2144
+ input.stdout.write(`# session ${input.sessionId}${traceName ? ` (trace ${traceName})` : ""} (${trace.harness}) — ${trace.title ?? "untitled"}\n# ${trace.events.length} events${input.kind ? ` (${rows.length} shown, kind=${input.kind})` : ""}${loaded.cacheStatus === "current" ? "" : ` (${loaded.cacheStatus} copy)`}\n`);
2145
+ for (const { event, index } of rows) input.stdout.write(`${String(index).padStart(4, " ")} ${compactEventLine(event)}\n`);
2146
+ return 0;
2147
+ }
2148
+ async function runTracePull(input) {
2149
+ try {
2150
+ const storage = "resolvedStorage" in input ? input.resolvedStorage : await resolveTraceReadStorage(input.storage, input.cwd);
2151
+ let scope;
2152
+ let sessions;
2153
+ let repoRoot = input.cwd;
2154
+ if ("review" in input.scope) {
2155
+ const review = input.scope.review;
2156
+ repoRoot = review.repoRoot;
2157
+ const refs = await listSessionsForReviewScope(review, storage);
2158
+ scope = { review: review.uuid };
2159
+ sessions = refs.map((ref) => ({
2160
+ id: ref.sessionId,
2161
+ traces: ref.subagents
2162
+ }));
2163
+ } else if ("commit" in input.scope) {
2164
+ const resolution = await lookupReviewTraceCommit({
2165
+ cwd: input.cwd,
2166
+ sha: input.scope.commit,
2167
+ storage
2168
+ });
2169
+ scope = { commit: resolution.commit };
2170
+ sessions = resolution.sessions.map((id) => ({ id }));
2171
+ } else if ("session" in input.scope) {
2172
+ scope = { session: input.scope.session };
2173
+ sessions = [{ id: input.scope.session }];
2174
+ } else {
2175
+ sessions = (await listRepositoryTraceSessionIds(input.cwd)).map((id) => ({ id }));
2176
+ scope = { repository: input.repo ?? "current" };
2177
+ }
2178
+ const repo = input.repo ? parseRepo(input.repo) : await inferRepoFromGit(repoRoot);
2179
+ const result = await pullReviewTraceCorpus({
2180
+ repo,
2181
+ sessions,
2182
+ mainOnly: input.mainOnly,
2183
+ cwd: repoRoot,
2184
+ storage
2185
+ });
2186
+ const output = {
2187
+ scope,
2188
+ corpus_root: result.corpusRoot,
2189
+ repository: result.repository,
2190
+ sessions: result.sessions,
2191
+ unavailable_sessions: result.unavailableSessions,
2192
+ events: result.events,
2193
+ files: result.files,
2194
+ paths: result.paths
2195
+ };
2196
+ if (input.json) input.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
2197
+ else {
2198
+ input.stdout.write(`Pulled ${result.sessions.length} session(s) into ${result.corpusRoot}.\n`);
2199
+ input.stdout.write(`Materialized ${result.files} normalized trace file(s) with ${result.events} event(s) for ${result.repository}.\n`);
2200
+ for (const filePath of result.paths) input.stdout.write(` ${filePath}\n`);
2201
+ if (result.unavailableSessions.length > 0) input.stderr.write(`Unavailable sessions: ${result.unavailableSessions.join(", ")}\n`);
2202
+ }
2203
+ return sessions.length > 0 && result.sessions.length === 0 ? 1 : 0;
2204
+ } catch (error) {
2205
+ input.stderr.write(`trace pull error: ${errorMessage(error)}\n`);
2206
+ return 1;
2207
+ }
2208
+ }
2209
+ async function runTraceBlame(input) {
2210
+ let result;
2211
+ try {
2212
+ result = await lookupReviewTraceBlame({
2213
+ cwd: input.cwd,
2214
+ file: input.file,
2215
+ lines: input.lines,
2216
+ history: input.history,
2217
+ storage: await resolveTraceReadStorage(input.storage, input.cwd)
2218
+ });
2219
+ } catch (err) {
2220
+ input.stderr.write(`trace blame error: ${errorMessage(err)}\n`);
2221
+ return 1;
2222
+ }
2223
+ if (input.json) {
2224
+ input.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
2225
+ return result.resolutions.some((r) => r.sessions.length > 0) ? 0 : 1;
2226
+ }
2227
+ if (result.resolutions.length === 0) {
2228
+ input.stderr.write(`no commits found for ${input.file}\n`);
2229
+ return 1;
2230
+ }
2231
+ for (const resolution of result.resolutions) printCommitResolution(resolution, input.stdout);
2232
+ return result.resolutions.some((r) => r.sessions.length > 0) ? 0 : 1;
2233
+ }
2234
+ function printCommitResolution(resolution, stdout) {
2235
+ const shortCommit = resolution.commit.slice(0, 12);
2236
+ if (resolution.sessions.length === 0) {
2237
+ stdout.write(`${shortCommit} no agent sessions found (source checked: trailer, index, pr-scan)\n`);
2238
+ return;
2239
+ }
2240
+ const prSuffix = resolution.pr !== null ? ` PR #${resolution.pr}` : "";
2241
+ stdout.write(`${shortCommit} → ${resolution.sessions.length} session(s) via ${resolution.source}${prSuffix}\n`);
2242
+ for (const session of resolution.sessions) {
2243
+ const meta = resolution.session_meta?.[session];
2244
+ const metaSuffix = meta ? ` (${meta.branch || "?"}, ${meta.author || "?"})` : "";
2245
+ stdout.write(` ${session}${metaSuffix}\n`);
2246
+ stdout.write(` trace: by-session/${session}/trace.jsonl\n`);
2247
+ stdout.write(` pull for FFF: whiteboard trace pull --agent-session ${session}\n`);
2248
+ }
2249
+ }
2250
+ function compactEventLine(event) {
2251
+ const oneLine = (text, limit) => {
2252
+ const collapsed = text.replace(/\s+/g, " ").trim();
2253
+ return collapsed.length > limit ? `${collapsed.slice(0, limit - 1)}…` : collapsed;
2254
+ };
2255
+ if (event.kind === "user") return `user ${oneLine(event.text, 160)}`;
2256
+ if (event.kind === "assistant") return `${event.thinking ? "thinking " : "assistant "} ${oneLine(event.markdown, 160)}`;
2257
+ if (event.kind === "separator") return `separator ${event.label}`;
2258
+ const counts = [event.additions ? `+${event.additions}` : null, event.deletions ? `−${event.deletions}` : null].filter(Boolean).join(" ");
2259
+ return `tool ${event.verb} ${oneLine(event.title, 120)}${counts ? ` ${counts}` : ""}${event.filePath ? ` [${event.filePath}]` : ""}`;
2260
+ }
2261
+ //#endregion
2262
+ //#region ../trace-core/src/trace-command-storage-option.ts
2263
+ /** Adds a read-only store override ; never changes persisted selection. */
2264
+ function addTraceStorageOption(command) {
2265
+ return command.addOption(new Option("--storage <mode>", "read from the s3 or hosted store instead of the selected one").choices(["s3", "hosted"]));
2266
+ }
2267
+ //#endregion
2268
+ //#region ../trace-core/src/trace-uninstall-hooks.ts
2269
+ /** Release Review hooks; keep its install, login, consent and data. */
2270
+ async function runTraceUninstallHooks(input) {
2271
+ const removed = [];
2272
+ for (const agent of AGENT_TRACE_HOOK_AGENTS) if (await removeAgentTraceHook(agent, input.scope.homeDir, input.scope.env)) removed.push(agent);
2273
+ const { disabled } = await disableAllTraceRepositories(input.scope);
2274
+ emitJsonEvent(input, {
2275
+ event: "trace.uninstall-hooks",
2276
+ owner: "review",
2277
+ removed,
2278
+ repositories: disabled
2279
+ });
2280
+ const output = humanStream(input);
2281
+ output.write(`Removed Whiteboard trace hooks. Kept the CLI, login, consent and captured traces.\n`);
2282
+ output.write("Run `whiteboard trace install` to restore trace hooks.\n");
2283
+ return 0;
2284
+ }
2285
+ //#endregion
2286
+ //#region ../trace-core/src/trace-capture-commands.ts
2287
+ /** Registers storage inspection and repository capture actions; never owns app commands. */
2288
+ function registerTraceCaptureCommands(trace, settings) {
2289
+ const { runtime, cwd, scope, traceCommand, configureOutput, configureJsonOutput } = settings;
2290
+ configureJsonOutput(trace.command("uninstall-hooks").description("Remove this CLI's agent and Git trace hooks; keep login, consent and traces")).action(async (options) => {
2291
+ settings.setExitCode(await runTraceUninstallHooks({
2292
+ scope,
2293
+ json: options.json,
2294
+ stdout: settings.stdout,
2295
+ stderr: settings.stderr
2296
+ }));
2297
+ });
2298
+ const withStorage = (command) => addTraceStorageOption(command);
2299
+ configureOutput(trace.command("status").description("Check trace storage and your hosted uploads").option("--agent-session <id>", "Check uploads of one session").option("--cursor <cursor>", "Continue an upload status page").option("--limit <count>", "Uploads per page", Number)).action(async (options) => {
2300
+ settings.setExitCode(await runtime.runTraceStatus({
2301
+ scope,
2302
+ cwd,
2303
+ session: options.agentSession,
2304
+ cursor: options.cursor,
2305
+ limit: options.limit,
2306
+ stdout: settings.stdout,
2307
+ stderr: settings.stderr
2308
+ }));
2309
+ });
2310
+ configureJsonOutput(withStorage(trace.command("sessions").description("List every published session of this repository's hosted trace store").option("--limit <n>", `sessions per page (1-200, default 50)`, (value) => {
2311
+ if (!/^\d+$/.test(value)) throw new InvalidArgumentError(`--limit must be a whole number from 1 to 200.`);
2312
+ return Number(value);
2313
+ }).option("--cursor <session-id>", "continue after this session id"))).action(async (options) => {
2314
+ settings.setExitCode(await runtime.runTraceSessions({
2315
+ scope,
2316
+ cwd,
2317
+ limit: options.limit,
2318
+ cursor: options.cursor,
2319
+ storage: options.storage,
2320
+ json: options.json,
2321
+ stdout: settings.stdout,
2322
+ stderr: settings.stderr
2323
+ }));
2324
+ });
2325
+ const repositoryCwd = (repoPath) => repoPath ? path.resolve(cwd, repoPath) : cwd;
2326
+ const createStore = async (repoPath, options) => {
2327
+ settings.setExitCode(await runtime.runTraceOnboard({
2328
+ scope,
2329
+ cwd: repositoryCwd(repoPath),
2330
+ json: options.json,
2331
+ stdout: settings.stdout,
2332
+ stderr: settings.stderr
2333
+ }));
2334
+ };
2335
+ const store = configureOutput(trace.command("store").description("Manage this repository's hosted trace store"));
2336
+ configureJsonOutput(store.command("create [path]").description("Create the hosted trace store for one repository (needs push access)")).action(createStore);
2337
+ configureJsonOutput(store.command("delete [path]").description("Delete the hosted trace store of one repository (admins only)")).action(async (repoPath, options) => {
2338
+ settings.setExitCode(await runtime.runTraceStoreDelete({
2339
+ scope,
2340
+ cwd: repositoryCwd(repoPath),
2341
+ json: options.json,
2342
+ stdout: settings.stdout,
2343
+ stderr: settings.stderr
2344
+ }));
2345
+ });
2346
+ configureJsonOutput(store.command("info [path]").description("Show the hosted trace store of one repository")).action(async (repoPath, options) => {
2347
+ settings.setExitCode(await runtime.runTraceStoreInfo({
2348
+ scope,
2349
+ cwd: repositoryCwd(repoPath),
2350
+ json: options.json,
2351
+ stdout: settings.stdout,
2352
+ stderr: settings.stderr
2353
+ }));
2354
+ });
2355
+ configureJsonOutput(trace.command("install").description("Install the agent hooks on this machine").option("--no-harness-hooks", "skip the Claude, Codex, OpenCode, and pi hook installers").option("--all-harnesses", "write every harness hook, even for a harness this machine lacks")).action(async (options) => {
2356
+ settings.setExitCode(await runtime.runTraceInstallMachine({
2357
+ scope,
2358
+ json: options.json,
2359
+ harnessHooks: options.harnessHooks,
2360
+ allHarnesses: options.allHarnesses,
2361
+ traceCommand,
2362
+ stdout: settings.stdout,
2363
+ stderr: settings.stderr
2364
+ }));
2365
+ });
2366
+ configureJsonOutput(trace.command("allow [path]").description("Allow one repository to publish traces to the hosted store").option("--no-harness-hooks", "skip the Claude, Codex, OpenCode, and pi hook installers").option("--all-harnesses", "write every harness hook, even for a harness this machine lacks")).action(async (repoPath, options) => {
2367
+ settings.setExitCode(await runtime.runTraceAllow({
2368
+ scope,
2369
+ cwd: repositoryCwd(repoPath),
2370
+ json: options.json,
2371
+ harnessHooks: options.harnessHooks,
2372
+ allHarnesses: options.allHarnesses,
2373
+ traceCommand,
2374
+ verifyCommand: settings.verifyCommand,
2375
+ stdout: settings.stdout,
2376
+ stderr: settings.stderr
2377
+ }));
2378
+ });
2379
+ configureJsonOutput(trace.command("deny [path]").description("Stop publishing traces from one repository to the hosted store")).action(async (repoPath, options) => {
2380
+ settings.setExitCode(await runtime.runTraceDeny({
2381
+ scope,
2382
+ cwd: repositoryCwd(repoPath),
2383
+ json: options.json,
2384
+ stdout: settings.stdout,
2385
+ stderr: settings.stderr
2386
+ }));
2387
+ });
2388
+ configureOutput(trace.command("enable [path]").description("Enable trace hooks for one Git repository")).action(async (repoPath) => {
2389
+ settings.setExitCode(await runtime.runTraceEnable({
2390
+ scope,
2391
+ cwd: repositoryCwd(repoPath),
2392
+ stdout: settings.stdout,
2393
+ stderr: settings.stderr,
2394
+ traceCommand
2395
+ }));
2396
+ });
2397
+ configureOutput(trace.command("disable [path]").description("Disable the trace hooks of one Git repository")).action(async (repoPath) => {
2398
+ settings.setExitCode(await runtime.runTraceDisable({
2399
+ scope,
2400
+ cwd: repositoryCwd(repoPath),
2401
+ stdout: settings.stdout
2402
+ }));
2403
+ });
2404
+ configureOutput(trace.command("repair [path]").description("Repair the trace hooks of one Git repository")).action(async (repoPath) => {
2405
+ settings.setExitCode(await runtime.runTraceRepair({
2406
+ scope,
2407
+ cwd: repositoryCwd(repoPath),
2408
+ stdout: settings.stdout,
2409
+ stderr: settings.stderr,
2410
+ traceCommand
2411
+ }));
2412
+ });
2413
+ }
2414
+ //#endregion
2415
+ //#region ../trace-core/src/trace-hook-commands.ts
2416
+ /** Registers upload and hidden hook actions; never resolves the machine scope. */
2417
+ function registerTraceHookCommands(trace, settings) {
2418
+ const { runtime, cwd, scope, traceCommand, configureOutput, configureJsonOutput } = settings;
2419
+ configureJsonOutput(trace.command("sync <session-id>").description("Upload a local session trace and its metadata").option("--repo <repo>", "GitHub owner/repo").addOption(new Option("--expect-storage <selection>", "abort when the storage selection changed since capture").hideHelp())).action(async (sessionId, options) => {
2420
+ settings.setExitCode(await runtime.runTraceSync({
2421
+ scope,
2422
+ cwd,
2423
+ sessionId,
2424
+ repo: options.repo,
2425
+ json: options.json,
2426
+ expectStorage: options.expectStorage,
2427
+ stdout: settings.stdout,
2428
+ stderr: settings.stderr
2429
+ }));
2430
+ });
2431
+ configureOutput(trace.command("hook <event>", { hidden: true }).description("Handle agent session lifecycle hooks").option("--session <id>", "Agent session ID")).action(async (event, options) => {
2432
+ settings.setExitCode(await runtime.runTraceHook({
2433
+ scope,
2434
+ cwd,
2435
+ event,
2436
+ sessionId: options.session,
2437
+ stdin: settings.stdin,
2438
+ traceCommand
2439
+ }));
2440
+ });
2441
+ configureOutput(trace.command("git-hook <hook> [args...]", { hidden: true }).description("Run a package-owned Git trace hook")).action(async (hook, args) => {
2442
+ settings.setExitCode(await runtime.runTraceGitHook({
2443
+ scope,
2444
+ cwd,
2445
+ hook,
2446
+ args,
2447
+ stdin: settings.stdin,
2448
+ stderr: settings.stderr,
2449
+ traceCommand
2450
+ }));
2451
+ });
2452
+ }
2453
+ //#endregion
2454
+ //#region ../trace-core/src/trace-read-commands.ts
2455
+ /** Registers Review trace reads; never resolves Review state. */
2456
+ function registerTraceReadCommands(trace, settings) {
2457
+ const { runtime, cwd, configureJsonOutput } = settings;
2458
+ const withStorage = (command) => addTraceStorageOption(command);
2459
+ const listOptions = (command) => command.option("--session <uuid>", "Whiteboard session UUID").option("--commit <sha>", "commit or revision");
2460
+ const pullOptions = (command) => command.option("--session <uuid>", "pull sessions for one Whiteboard");
2461
+ configureJsonOutput(withStorage(listOptions(trace.command("list").description("List agent sessions for a Whiteboard or commit")))).action(async (options) => {
2462
+ if (options.session && options.commit) throw new Error("Use either --session or --commit, not both.");
2463
+ settings.setExitCode(await runtime.runTraceList({
2464
+ cwd,
2465
+ reviewUuid: options.session,
2466
+ commitSha: options.commit,
2467
+ storage: options.storage,
2468
+ json: options.json,
2469
+ stdout: settings.stdout
2470
+ }));
2471
+ });
2472
+ configureJsonOutput(withStorage(trace.command("show <session-id>").description("Survey a trace or show an exact event").option("--trace <name>", "trace name; omit for the main trace").option("--event <index>", "print the complete text of one event", (value) => Number.parseInt(value, 10)).option("--kind <kind>", "only list user|assistant|tool|separator rows"))).action(async (sessionId, options) => {
2473
+ settings.setExitCode(await runtime.runTraceShow({
2474
+ cwd,
2475
+ sessionId,
2476
+ trace: options.trace,
2477
+ eventIndex: options.event,
2478
+ kind: options.kind,
2479
+ storage: options.storage,
2480
+ json: options.json,
2481
+ stdout: settings.stdout,
2482
+ stderr: settings.stderr
2483
+ }));
2484
+ });
2485
+ configureJsonOutput(withStorage(pullOptions(trace.command("pull").description("Pull traces into the local FFF search corpus").option("--repo <owner/repo>", "repository for the corpus path")).option("--commit <sha>", "pull sessions for one commit or revision").option("--agent-session <id>", "pull one session").option("--main-only", "exclude subagent traces"))).action(async (options) => {
2486
+ if ([
2487
+ options.session,
2488
+ options.commit,
2489
+ options.agentSession
2490
+ ].filter(Boolean).length > 1) throw new Error("Use only one of --session, --commit, or --agent-session.");
2491
+ settings.setExitCode(await runtime.runTracePull({
2492
+ cwd,
2493
+ repo: options.repo,
2494
+ reviewUuid: options.session,
2495
+ commitSha: options.commit,
2496
+ session: options.agentSession,
2497
+ mainOnly: options.mainOnly,
2498
+ storage: options.storage,
2499
+ json: options.json,
2500
+ stdout: settings.stdout,
2501
+ stderr: settings.stderr
2502
+ }));
2503
+ });
2504
+ configureJsonOutput(withStorage(trace.command("blame <file>").description("Blame lines in a file to agent sessions").option("-L, --lines <range>", "start,end line range").option("--history", "use git log -L to include every commit that shaped the lines"))).action(async (file, options) => {
2505
+ settings.setExitCode(await runtime.runTraceBlame({
2506
+ cwd,
2507
+ file,
2508
+ lines: options.lines,
2509
+ history: options.history,
2510
+ storage: options.storage,
2511
+ json: options.json,
2512
+ stdout: settings.stdout,
2513
+ stderr: settings.stderr
2514
+ }));
2515
+ });
2516
+ }
2517
+ //#endregion
2518
+ //#region ../trace-core/src/trace-commands.ts
2519
+ /** Registers the shared trace surface with explicit runtime and scope; never imports Review app state. */
2520
+ function registerTraceCommands(parent, options) {
2521
+ registerTraceCaptureCommands(parent, options);
2522
+ registerTraceReadCommands(parent, options);
2523
+ registerTraceHookCommands(parent, options);
2524
+ }
2525
+ //#endregion
2526
+ //#region src/review-home-paths.ts
2527
+ /** `<basename>-<12 hex>` of a resolved path, one safe storage segment. */
2528
+ function hashedSegment(target, fallback) {
2529
+ const resolved = path.resolve(target);
2530
+ return `${safeStorageSegment(path.basename(resolved) || fallback)}-${crypto.createHash("sha256").update(resolved).digest("hex").slice(0, 12)}`;
2531
+ }
2532
+ /** Encode a value as one filesystem-safe storage path segment. */
2533
+ function safeStorageSegment(value) {
2534
+ return value.replace(/[^A-Za-z0-9_.-]+/g, "__");
2535
+ }
2536
+ function reviewDesktopRoot(env = process.env) {
2537
+ return path.join(devReviewHome(env), "review-desktop");
2538
+ }
2539
+ /** Written by Desktops that predate instance selection. */
2540
+ function reviewLegacyDiscoveryPath(env = process.env) {
2541
+ return path.join(reviewDesktopRoot(env), "server.json");
2542
+ }
2543
+ function reviewInstancesDir(env = process.env) {
2544
+ return path.join(reviewDesktopRoot(env), "instances");
2545
+ }
2546
+ function reviewInstanceDiscoveryPath(key, env = process.env) {
2547
+ return path.join(reviewInstancesDir(env), `${key}.json`);
2548
+ }
2549
+ function reviewDefaultInstancePath(env = process.env) {
2550
+ return path.join(reviewDesktopRoot(env), "default-instance");
2551
+ }
2552
+ /** `dev-<name>-<hash>` for a source checkout, keyed like reviewRepoStorageRoot. */
2553
+ function reviewDevInstanceKey(checkout) {
2554
+ return `dev-${hashedSegment(checkout, "checkout")}`;
2555
+ }
2556
+ function reviewDesktopStateDir(env = process.env) {
2557
+ return path.join(reviewDesktopRoot(env), "state");
2558
+ }
2559
+ //#endregion
2560
+ export { runTraceSessions as A, installHarnessHooks as B, disableTraceMachine as C, runTraceAllow as D, traceMachineStatus as E, listTraceRepositoryRoots as F, traceScope as G, resolveTraceCommand as H, traceRepositoryStatus as I, HOSTED_CAPTURE_SCOPE_DESCRIPTION as L, runTraceStoreInfo as M, disableAllTraceRepositories as N, runTraceDeny as O, enableTraceRepository as P, AGENT_TRACE_HOOK_AGENTS as R, configureTraceMachine as S, traceMachineEnabled as T, shellQuote as U, removeAgentTraceHook as V, traceHomeDir as W, runTraceRepair as _, reviewInstancesDir as a, runTraceGitHook as b, registerTraceCommands as c, runTraceList as d, runTracePull as f, runTraceInstallMachine as g, runTraceEnable as h, reviewInstanceDiscoveryPath as i, runTraceStoreDelete as j, runTraceOnboard as k, resolveTraceReadStorage as l, runTraceDisable as m, reviewDesktopStateDir as n, reviewLegacyDiscoveryPath as o, runTraceShow as p, reviewDevInstanceKey as r, safeStorageSegment as s, reviewDefaultInstancePath as t, runTraceBlame as u, runTraceStatus as v, readLegacyCaptureSettings as w, runTraceHook as x, runTraceSync as y, describeTraceHookOwners as z };