@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.
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/THIRD_PARTY_NOTICES.md +93 -0
- package/dist/account-alias-DOHN1RSH.js +973 -0
- package/dist/agent-cli-ChOq0ZuO.js +117 -0
- package/dist/agent-cli-DzWfZr6G.js +2 -0
- package/dist/agent-client-CPQo7iTI.js +305 -0
- package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
- package/dist/build-info.json +1 -0
- package/dist/cli-hVgHcdsv.js +47 -0
- package/dist/cli-runner-D8y2_luU.js +5095 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +128 -0
- package/dist/client-DFoh50W3.js +950 -0
- package/dist/desktop-discovery-DBO-PM2V.js +164 -0
- package/dist/error-message-OtiDonty.js +6 -0
- package/dist/fs-utils-BMPLt0cr.js +22 -0
- package/dist/fuzzy-match-BoAmcyak.js +30 -0
- package/dist/headless-host-BJxf4dUU.js +94 -0
- package/dist/input-error-OLgB_h31.js +11 -0
- package/dist/local-data-CxLk75rx.js +4491 -0
- package/dist/mcp-QeF8vkdf.js +140 -0
- package/dist/package-paths-B6-zxvIO.js +47 -0
- package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
- package/dist/profile-4ry2f3AM.js +76 -0
- package/dist/profile-Bh5FIrmf.js +2 -0
- package/dist/request-origin-D_4QIdoN.js +811 -0
- package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
- package/dist/review-home-paths-6zZH-1c9.js +2560 -0
- package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
- package/dist/runtime-CKiWhGrf.js +52 -0
- package/dist/runtime.d.ts +9 -0
- package/dist/runtime.js +2 -0
- package/dist/s3-SzLpeCG6.js +345 -0
- package/dist/s3-config-BgxkSoOy.js +2 -0
- package/dist/s3-config-CAxhO9u_.js +676 -0
- package/dist/server/desktop-host.d.ts +4 -0
- package/dist/server/desktop-host.js +1702 -0
- package/dist/server-discovery-Dbzn3w6Z.js +66 -0
- package/dist/sharing/index.d.ts +1977 -0
- package/dist/sharing/index.js +2 -0
- package/dist/src-CGP5ytbV.js +283 -0
- package/dist/src-CmdiBL20.js +3676 -0
- package/dist/src-DnwdaQ2r.js +865 -0
- package/dist/src-X9phtB2j.js +68 -0
- package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
- package/dist/tool-failure-C8zB73HV.js +98 -0
- package/dist/tutorial-trace-DrBvkaeL.js +43 -0
- package/instructions/authoring.md +36 -0
- package/instructions/file-lenses.md +13 -0
- package/instructions/scratchpad.md +23 -0
- package/instructions/trace-archaeology.md +113 -0
- package/onboarding.md +8 -0
- package/package.json +106 -3
- package/src/agent-selection.ts +100 -0
- package/src/agent-session-ref.ts +80 -0
- package/src/ask/agents.ts +351 -0
- package/src/ask/checkout-files.ts +47 -0
- package/src/ask/file-refs.ts +111 -0
- package/src/ask/pi-mcp.ts +154 -0
- package/src/ask/protocol.ts +244 -0
- package/src/ask/thread-state.ts +350 -0
- package/src/ask/thread.ts +1400 -0
- package/src/ask/threads.ts +219 -0
- package/src/ask/watch.ts +79 -0
- package/src/cli-install.ts +1041 -0
- package/src/cli-runner.ts +1461 -0
- package/src/cli-runtime-info.ts +35 -0
- package/src/cli.ts +222 -0
- package/src/connect-prompts.ts +248 -0
- package/src/cursor-deeplink.ts +11 -0
- package/src/desktop-discovery.ts +355 -0
- package/src/diff-selection-migration.ts +63 -0
- package/src/embedded-posthog-key.ts +6 -0
- package/src/error-telemetry.ts +247 -0
- package/src/evidence.ts +14 -0
- package/src/exception-telemetry.ts +126 -0
- package/src/fixtures/blocks/call_stack_diff.json +27 -0
- package/src/fixtures/blocks/callout.json +11 -0
- package/src/fixtures/blocks/code.json +9 -0
- package/src/fixtures/blocks/code_peek.json +8 -0
- package/src/fixtures/blocks/database_lens.json +51 -0
- package/src/fixtures/blocks/divider.json +1 -0
- package/src/fixtures/blocks/fixtures.ts +33 -0
- package/src/fixtures/blocks/flow_diagram.json +36 -0
- package/src/fixtures/blocks/ids.ts +8 -0
- package/src/fixtures/blocks/image.json +9 -0
- package/src/fixtures/blocks/markdown.json +7 -0
- package/src/fixtures/blocks/section.json +11 -0
- package/src/fixtures/blocks/sequence.json +31 -0
- package/src/fixtures/blocks/software_map.json +7 -0
- package/src/fixtures/blocks/trace_quote.json +9 -0
- package/src/fixtures/blocks/tutorial.json +51 -0
- package/src/fs-utils.ts +32 -0
- package/src/fuzzy-match.ts +94 -0
- package/src/install.ts +29 -0
- package/src/legacy-skills.ts +133 -0
- package/src/lens-selection.ts +230 -0
- package/src/markdown-latex-math.ts +230 -0
- package/src/markdown.ts +81 -0
- package/src/package-paths.ts +53 -0
- package/src/posthog-capture-client.ts +610 -0
- package/src/review-api/README.md +295 -0
- package/src/review-api/activity.ts +340 -0
- package/src/review-api/agent-cli.ts +219 -0
- package/src/review-api/agent-client.ts +188 -0
- package/src/review-api/anchor-quotes.ts +88 -0
- package/src/review-api/ask-history.ts +242 -0
- package/src/review-api/authoring-tools.ts +211 -0
- package/src/review-api/blocks/call_stack_diff.ts +75 -0
- package/src/review-api/blocks/callout.ts +24 -0
- package/src/review-api/blocks/code.ts +9 -0
- package/src/review-api/blocks/code_peek.ts +13 -0
- package/src/review-api/blocks/database_lens.ts +150 -0
- package/src/review-api/blocks/definition.ts +40 -0
- package/src/review-api/blocks/divider.ts +6 -0
- package/src/review-api/blocks/flow_diagram.ts +111 -0
- package/src/review-api/blocks/image.ts +10 -0
- package/src/review-api/blocks/index.ts +90 -0
- package/src/review-api/blocks/markdown.ts +17 -0
- package/src/review-api/blocks/section.ts +24 -0
- package/src/review-api/blocks/sequence.ts +56 -0
- package/src/review-api/blocks/software_map.ts +9 -0
- package/src/review-api/blocks/trace_quote.ts +10 -0
- package/src/review-api/blocks/tutorial.ts +39 -0
- package/src/review-api/checkout-fs.ts +14 -0
- package/src/review-api/client.ts +1 -0
- package/src/review-api/comparison-coverage.ts +304 -0
- package/src/review-api/component-reference.ts +30 -0
- package/src/review-api/diff-lenses.ts +175 -0
- package/src/review-api/document-headings.ts +51 -0
- package/src/review-api/document-text.ts +200 -0
- package/src/review-api/document.ts +906 -0
- package/src/review-api/file-lenses.ts +100 -0
- package/src/review-api/http.ts +1877 -0
- package/src/review-api/image-decode.ts +29 -0
- package/src/review-api/input-error.ts +9 -0
- package/src/review-api/instructions.ts +89 -0
- package/src/review-api/lens-alignment.ts +52 -0
- package/src/review-api/local-data.ts +1837 -0
- package/src/review-api/map-input.ts +154 -0
- package/src/review-api/mcp-client-agent.ts +32 -0
- package/src/review-api/mcp.ts +240 -0
- package/src/review-api/origin.ts +43 -0
- package/src/review-api/profile.ts +146 -0
- package/src/review-api/public-tools.ts +102 -0
- package/src/review-api/pull-request.ts +389 -0
- package/src/review-api/read-schemas.ts +85 -0
- package/src/review-api/recovery.ts +2 -0
- package/src/review-api/request-origin.ts +33 -0
- package/src/review-api/review-progress.ts +382 -0
- package/src/review-api/status-tool.ts +10 -0
- package/src/review-api/store-schema.ts +22 -0
- package/src/review-api/store.ts +1647 -0
- package/src/review-api/tool-failure.ts +64 -0
- package/src/review-api/trace-schema.ts +13 -0
- package/src/review-api/traces.ts +119 -0
- package/src/review-api/unsupported-files.integration.ts +91 -0
- package/src/review-api/workspaces.ts +692 -0
- package/src/review-api/worktree-source.ts +170 -0
- package/src/review-api/worktree-structural.integration.ts +320 -0
- package/src/review-app-launcher.ts +432 -0
- package/src/review-app-picker.ts +168 -0
- package/src/review-app.ts +134 -0
- package/src/review-bundled-tools.ts +249 -0
- package/src/review-checkout-paths.ts +37 -0
- package/src/review-diff-files.ts +89 -0
- package/src/review-head-checkout.ts +300 -0
- package/src/review-home-paths.ts +73 -0
- package/src/review-info.ts +59 -0
- package/src/review-instances.ts +113 -0
- package/src/review-logger.ts +183 -0
- package/src/review-preferences.ts +88 -0
- package/src/review-prepare.ts +291 -0
- package/src/review-stack.ts +112 -0
- package/src/review-telemetry.ts +1066 -0
- package/src/runtime.ts +74 -0
- package/src/server/account-alias.ts +30 -0
- package/src/server/bounded-stream.ts +34 -0
- package/src/server/bug-report.ts +301 -0
- package/src/server/client-error-budget.ts +45 -0
- package/src/server/crash-report.ts +273 -0
- package/src/server/desktop-host-shutdown.ts +64 -0
- package/src/server/desktop-host.ts +190 -0
- package/src/server/desktop-server.ts +703 -0
- package/src/server/diffr-config.ts +509 -0
- package/src/server/diffr-languages.ts +97 -0
- package/src/server/global-verb-relay.ts +190 -0
- package/src/server/headless-host.ts +149 -0
- package/src/server/hono-http.ts +162 -0
- package/src/server/http-json.ts +24 -0
- package/src/server/json-review-reporting.ts +174 -0
- package/src/server/process-error-telemetry.ts +174 -0
- package/src/server/review-api-parsers.ts +90 -0
- package/src/server/review-lifecycle-telemetry.ts +73 -0
- package/src/server/review-open-watchdog.ts +46 -0
- package/src/server/review-server-core.ts +290 -0
- package/src/server/structural-comparisons.ts +153 -0
- package/src/server/structural-diff.ts +186 -0
- package/src/server/tutorial-service.ts +241 -0
- package/src/server/ui-telemetry.ts +198 -0
- package/src/server-discovery.ts +95 -0
- package/src/session-markers.ts +132 -0
- package/src/sharing/auth.ts +41 -0
- package/src/sharing/cli.ts +83 -0
- package/src/sharing/client.ts +334 -0
- package/src/sharing/export.ts +195 -0
- package/src/sharing/host.ts +316 -0
- package/src/sharing/import.ts +787 -0
- package/src/sharing/index.ts +16 -0
- package/src/sharing/repository.ts +145 -0
- package/src/sharing/routes.ts +34 -0
- package/src/slug.ts +23 -0
- package/src/software-map-diff-counts.ts +517 -0
- package/src/software-map-model.ts +1147 -0
- package/src/software-map-topology-diff.ts +260 -0
- package/src/source.ts +92 -0
- package/src/startup-trace.ts +232 -0
- package/src/stored-document-migration.ts +151 -0
- package/src/telemetry-clean-text.ts +257 -0
- package/src/telemetry-config.ts +298 -0
- package/src/telemetry-debug-sink.ts +38 -0
- package/src/telemetry.ts +13 -0
- package/src/trace-cli.ts +156 -0
- package/src/trace-storage-cli.ts +509 -0
- package/src/tutorial-conversation.ts +18 -0
- package/src/ui-telemetry-events.ts +765 -0
- package/src/unified-diff.ts +71 -0
- package/src/viewed-coverage.ts +259 -0
- package/src/windows-cli.ts +125 -0
- package/tutorial/document.json +275 -0
- package/tutorial/runtime-manifest.json +12 -0
- package/tutorial/sample-service/package.json +9 -0
- package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
- package/tutorial/sample-service/src/app.ts +26 -0
- package/tutorial/sample-service/src/database/schema.ts +10 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
- package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
- package/tutorial/sample-service/src/orders/order-service.ts +34 -0
- package/tutorial/sample-service/src/orders/order.ts +21 -0
- package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
- package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
- package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
- package/tutorial/sample-service/tsconfig.json +12 -0
- package/tutorial/software-map.json +139 -0
- 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 };
|