@retrace-dev/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import { EventInput } from "@retrace-dev/core";
3
+ export type Cfg = {
4
+ project?: string;
5
+ db?: string;
6
+ url?: string;
7
+ token?: string;
8
+ credential?: string;
9
+ environment?: string;
10
+ repoName?: string;
11
+ /** Resolved from --allow-remote / RETRACE_ALLOW_REMOTE, not from .retrace.json — a repo that HAS the file is already
12
+ * permitted, so setting it there would be a no-op. See guardRemoteWrite. */
13
+ allowRemote?: boolean;
14
+ };
15
+ /**
16
+ * Which bearer token the hook sends. `file` is the repo's .retrace.json. Precedence: RETRACE_HOOK_TOKEN (explicit
17
+ * override) > the credential entry named by `credential` (matched on actor.id in the credentials file) > RETRACE_TOKEN >
18
+ * .retrace.json `token`. Without a `credential` field this is exactly the old behaviour, so repos that still run on the
19
+ * owner token (boxing-rpg) are untouched. Naming a credential that cannot be found throws rather than quietly falling
20
+ * back to the owner token — the point of the field is that the owner token stops being what this repo uses.
21
+ */
22
+ export declare function resolveHookToken(file: Pick<Cfg, "token" | "credential">, env?: NodeJS.ProcessEnv, credentialsFile?: string): string | undefined;
23
+ /** One line per failed hook run, appended to <git-dir>/retrace-hook.log. Never throws (the hook is non-fatal by design); the
24
+ * messages that reach it (HTTP status + body, config errors) carry no token. */
25
+ export declare function appendHookLog(gitDir: string, line: string): void;
26
+ /**
27
+ * Trailers from EVERY trailing trailer-only paragraph of a commit message, walking back from the last paragraph
28
+ * and stopping at the first paragraph with prose (the subject never counts). Git's own `%(trailers)` reads only
29
+ * the LAST paragraph, so 68c343f's `Retrace-*` block + separate `Co-Authored-By` block lost the Retrace-* lines and
30
+ * the hook minted actor "claude-fable-5" (backlog #12, dogfood log 2026-08-20). A `Key: value` line inside prose is
31
+ * NOT a trailer. Keys are lowercased; continuation lines (leading whitespace) are unfolded into the previous value.
32
+ * `trailerText` = the collected paragraphs' lines (CRLF → LF), so the caller can strip exactly those from the body.
33
+ * Line endings are normalised first and the value capture avoids `.`/`$` (both stop at \r and U+2028): a CRLF
34
+ * message (kept verbatim by `--cleanup=verbatim`, `git commit-tree`, API-made commits) otherwise had its lines
35
+ * classified as trailers yet none extracted — the Retrace-* block vanished from the ledger (review of the #12 fix).
36
+ */
37
+ export declare function parseTrailers(message: string): {
38
+ trailers: Record<string, string[]>;
39
+ trailerText: string[];
40
+ };
41
+ export declare function commitToEvent(repo: string, sha: string, cfg: Cfg, live?: boolean): EventInput;
42
+ /** Did this hook run under a controlling terminal? "tty" = a human typed `git commit`; "agent" = a harness ran it.
43
+ * Read from /proc/self/stat field 7 (tty_nr), NOT from tty.isatty(): the installed post-commit script redirects its
44
+ * own stdout AND stderr to /dev/null (see hookScript), which destroys every file-descriptor signal while leaving the
45
+ * controlling terminal itself intact. Measured 2026-08-27: agent-spawned tty_nr=0, real pty tty_nr=34819.
46
+ * Linux-only by construction — with no /proc the field is simply absent, which is a legal permanent state
47
+ * ("absence is information", schema.ts). It is EVIDENCE only and never decides WHO: authorship does that. */
48
+ export declare function ttySurface(procStat?: string): "tty" | "agent" | undefined;
49
+ /** A repo that was never wired to Retrace must not write to a REMOTE ledger just because the shell happens to export
50
+ * RETRACE_URL. `.retrace.json` is the committed marker that says "this repo logs to a ledger" — `install` writes it —
51
+ * so its absence, combined with an ambient RETRACE_URL, means a scratch repo has picked up someone else's production
52
+ * credentials. That is not hypothetical: on 2026-08-28 six events in four junk projects (bf, p, demo, reprotest)
53
+ * reached the live Worker exactly this way, from temp repos under /tmp, and had to be deleted project-by-project
54
+ * because the ledger is append-only. The repo's own test harness already strips RETRACE_* for this reason
55
+ * (git-hook.test.ts baseEnv, after the 2026-08-19 dogfood incident); this is the same defence for anyone driving the
56
+ * CLI by hand.
57
+ * Local writes are deliberately NOT gated — a stray row in a SQLite file is cheap to discard, a sealed event in a
58
+ * shared append-only ledger is not. The real hook never trips this: `install` writes .retrace.json before the hook
59
+ * can ever run. Escape hatch for env-only setups (CI backfilling a repo that does not carry the file):
60
+ * `--allow-remote`, or RETRACE_ALLOW_REMOTE=1. */
61
+ export declare function guardRemoteWrite(repo: string, cfg: Cfg): void;
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * retrace-git — Git adapter. Turns commits into Retrace events.
4
+ *
5
+ * retrace-git install [--project <name>] [--repo <path>] write .git/hooks/post-commit + .retrace.json
6
+ * retrace-git commit [--repo <path>] [<sha>] log one commit (default HEAD) — what the hook runs
7
+ * retrace-git backfill [--repo <path>] [--since <ref>] [--max <n>] log history oldest→newest (idempotent by sha)
8
+ * retrace-git uninstall [--repo <path>]
9
+ *
10
+ * Config precedence: CLI flags > env (RETRACE_PROJECT/RETRACE_DB/RETRACE_URL/RETRACE_TOKEN) > .retrace.json in repo root.
11
+ * Remote-write guard: writing to a REMOTE ledger requires a .retrace.json in the repo root — see guardRemoteWrite.
12
+ * Token precedence (resolveHookToken): env RETRACE_HOOK_TOKEN > the credential named by .retrace.json "credential"
13
+ * (looked up by actor.id in RETRACE_CREDENTIALS_FILE, default ~/.retrace/worker-credentials.json — a scoped assert
14
+ * credential, so the hook need not carry the owner token) > env RETRACE_TOKEN > .retrace.json "token". No "credential"
15
+ * field = the owner-token behaviour, unchanged. A named-but-missing credential is an error, never a silent fallback.
16
+ * Failures of `commit` (the hook path) are appended to <git-dir>/retrace-hook.log: the post-commit script discards
17
+ * stdout/stderr, and with a fail-closed assert credential a 401/403 would otherwise be an invisible drop (owner-token
18
+ * migration 2026-08-23). Re-log a dropped commit with `retrace-git commit <sha>` or `backfill`.
19
+ *
20
+ * Mapping a commit → event
21
+ * WHO author (human) — or an AGENT if the commit has a trailer `Retrace-Actor: <id>` (optionally
22
+ * `Retrace-Model: <model>`), or a `Co-Authored-By:` naming Claude/Copilot/Codex/Grok/… or a "[bot]" author;
23
+ * in that case the human author becomes `on_behalf_of`. Trailers are read from ALL trailing trailer-only
24
+ * paragraphs (not just git's last one — backlog #12, dogfood log 2026-08-20: a `Retrace-*` paragraph
25
+ * followed by a separate `Co-Authored-By` paragraph lost the Retrace-* lines); a Co-Authored-By agent gets
26
+ * id = family ("claude", "copilot", …) and model = slug of the full name ("Claude Fable 5" → "claude-fable-5").
27
+ * WHAT action=committed (or merged for merge commits); artifacts = commit:<sha> + repo:<name>#<path> per file, all role=generated
28
+ * WHEN author date
29
+ * WHERE system=git, path=repo root, environment=local (override RETRACE_ENV), device=hostname (override
30
+ * RETRACE_DEVICE), session=CLAUDE_CODE_SESSION_ID or GROK_SESSION_ID when an agent's shell drove the commit (absent for a
31
+ * human's own `git commit` — see below), ide/workspace when an IDE names itself (Orca), surface=tty|agent
32
+ * WHY intent = commit subject (+ body); caused_by = trailer `Retrace-Caused-By: evt_…`, else env RETRACE_CAUSED_BY,
33
+ * else contents of .git/retrace-caused-by (a scratch file agents/MCP can write)
34
+ * HOW tool=git, params { branch, parents, files, insertions, deletions }, automated = agent commit
35
+ */
36
+ import { execFileSync } from "node:child_process";
37
+ import { existsSync, readFileSync, writeFileSync, appendFileSync, chmodSync, unlinkSync, mkdirSync } from "node:fs";
38
+ import { homedir, hostname } from "node:os";
39
+ import { basename, join, resolve } from "node:path";
40
+ import { appendEvent, describeEvent } from "@retrace-dev/core";
41
+ import { makeStore, detectIde, harnessSession } from "./index.js";
42
+ import { RemoteStore } from "./remote-store.js";
43
+ import { isMainModule } from "./is-main.js";
44
+ /** The operator's local mirror of the Worker's RETRACE_CREDENTIALS (JSON array of {token, actor, …}); only token + actor.id are read. */
45
+ const DEFAULT_CREDENTIALS_FILE = join(homedir(), ".retrace", "worker-credentials.json");
46
+ /**
47
+ * Which bearer token the hook sends. `file` is the repo's .retrace.json. Precedence: RETRACE_HOOK_TOKEN (explicit
48
+ * override) > the credential entry named by `credential` (matched on actor.id in the credentials file) > RETRACE_TOKEN >
49
+ * .retrace.json `token`. Without a `credential` field this is exactly the old behaviour, so repos that still run on the
50
+ * owner token (boxing-rpg) are untouched. Naming a credential that cannot be found throws rather than quietly falling
51
+ * back to the owner token — the point of the field is that the owner token stops being what this repo uses.
52
+ */
53
+ export function resolveHookToken(file, env = process.env, credentialsFile = env.RETRACE_CREDENTIALS_FILE ?? DEFAULT_CREDENTIALS_FILE) {
54
+ if (env.RETRACE_HOOK_TOKEN)
55
+ return env.RETRACE_HOOK_TOKEN;
56
+ if (file.credential) {
57
+ if (!existsSync(credentialsFile))
58
+ throw new Error(`.retrace.json names credential "${file.credential}" but ${credentialsFile} does not exist (set RETRACE_CREDENTIALS_FILE, or remove "credential" to fall back to RETRACE_TOKEN)`);
59
+ const entries = JSON.parse(readFileSync(credentialsFile, "utf8"));
60
+ const hit = Array.isArray(entries) ? entries.find((c) => c?.actor?.id === file.credential) : undefined;
61
+ if (typeof hit?.token !== "string" || !hit.token)
62
+ throw new Error(`credential "${file.credential}" not found in ${credentialsFile} (matched on actor.id)`);
63
+ return hit.token;
64
+ }
65
+ return env.RETRACE_TOKEN ?? file.token;
66
+ }
67
+ /** One line per failed hook run, appended to <git-dir>/retrace-hook.log. Never throws (the hook is non-fatal by design); the
68
+ * messages that reach it (HTTP status + body, config errors) carry no token. */
69
+ export function appendHookLog(gitDir, line) {
70
+ try {
71
+ appendFileSync(join(gitDir, "retrace-hook.log"), `${new Date().toISOString()} ${line}\n`);
72
+ }
73
+ catch { }
74
+ }
75
+ function git(repo, args) {
76
+ return execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
77
+ }
78
+ function parseArgs(argv) {
79
+ const flags = {};
80
+ const pos = [];
81
+ for (let i = 0; i < argv.length; i++) {
82
+ const a = argv[i];
83
+ if (a.startsWith("--")) {
84
+ const k = a.slice(2);
85
+ const nxt = argv[i + 1];
86
+ if (nxt && !nxt.startsWith("--")) {
87
+ flags[k] = nxt;
88
+ i++;
89
+ }
90
+ else
91
+ flags[k] = true;
92
+ }
93
+ else
94
+ pos.push(a);
95
+ }
96
+ return { flags, pos };
97
+ }
98
+ function loadCfg(repo, flags) {
99
+ let file = {};
100
+ const p = join(repo, ".retrace.json");
101
+ if (existsSync(p))
102
+ file = JSON.parse(readFileSync(p, "utf8"));
103
+ const cfg = {
104
+ project: flags.project ?? process.env.RETRACE_PROJECT ?? file.project ?? basename(repo),
105
+ db: process.env.RETRACE_DB ?? file.db,
106
+ url: process.env.RETRACE_URL ?? file.url,
107
+ credential: file.credential,
108
+ token: resolveHookToken(file),
109
+ environment: process.env.RETRACE_ENV ?? file.environment ?? "local",
110
+ repoName: file.repoName,
111
+ allowRemote: flags["allow-remote"] !== undefined || process.env.RETRACE_ALLOW_REMOTE === "1",
112
+ };
113
+ // The local store is built by makeStore (reads env) — propagate the file's db path when unset. The remote store is built
114
+ // from cfg directly (logCommit) so the resolved token, not whatever RETRACE_TOKEN the shell exports, is what is sent.
115
+ if (cfg.db && !process.env.RETRACE_DB)
116
+ process.env.RETRACE_DB = cfg.db;
117
+ return cfg;
118
+ }
119
+ const AGENT_COAUTHOR = /claude|copilot|codex|cursor|devin|aider|gpt|gemini|grok|\[bot\]/i;
120
+ /** Agent families a Co-Authored-By name is mapped onto (first match wins) — the actor id (backlog #12). */
121
+ const AGENT_FAMILIES = ["claude", "copilot", "codex", "cursor", "devin", "aider", "gemini", "grok", "gpt"];
122
+ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
123
+ /** A trailer line, per git: token, colon, whitespace, non-blank value. */
124
+ const TRAILER_LINE = /^[A-Za-z][\w-]*:\s+\S/;
125
+ /**
126
+ * Trailers from EVERY trailing trailer-only paragraph of a commit message, walking back from the last paragraph
127
+ * and stopping at the first paragraph with prose (the subject never counts). Git's own `%(trailers)` reads only
128
+ * the LAST paragraph, so 68c343f's `Retrace-*` block + separate `Co-Authored-By` block lost the Retrace-* lines and
129
+ * the hook minted actor "claude-fable-5" (backlog #12, dogfood log 2026-08-20). A `Key: value` line inside prose is
130
+ * NOT a trailer. Keys are lowercased; continuation lines (leading whitespace) are unfolded into the previous value.
131
+ * `trailerText` = the collected paragraphs' lines (CRLF → LF), so the caller can strip exactly those from the body.
132
+ * Line endings are normalised first and the value capture avoids `.`/`$` (both stop at \r and U+2028): a CRLF
133
+ * message (kept verbatim by `--cleanup=verbatim`, `git commit-tree`, API-made commits) otherwise had its lines
134
+ * classified as trailers yet none extracted — the Retrace-* block vanished from the ledger (review of the #12 fix).
135
+ */
136
+ export function parseTrailers(message) {
137
+ const paras = message.replace(/\r\n?/g, "\n").trim().split(/\n\s*\n/).map((p) => p.split("\n"));
138
+ const isTrailerPara = (p) => TRAILER_LINE.test(p[0]) && p.every((l) => TRAILER_LINE.test(l) || /^\s/.test(l));
139
+ let k = paras.length;
140
+ while (k > 1 && isTrailerPara(paras[k - 1]))
141
+ k--;
142
+ const trailerText = paras.slice(k).flat();
143
+ const trailers = {};
144
+ let last;
145
+ for (const line of trailerText) {
146
+ const m = line.match(/^([A-Za-z][\w-]*):\s+([\s\S]*)$/);
147
+ if (m)
148
+ (last = trailers[m[1].toLowerCase()] ??= []).push(m[2].trim());
149
+ else if (last)
150
+ last[last.length - 1] += " " + line.trim();
151
+ }
152
+ return { trailers, trailerText };
153
+ }
154
+ /** Body minus its last `n` non-blank lines (the trailer paragraphs, which are always a suffix of the body). CRLF → LF
155
+ * as in parseTrailers, so `intent` never carries a stray \r next to git's already CR-free `%s` subject. */
156
+ function stripTrailers(body, n) {
157
+ const lines = body.replace(/\r\n?/g, "\n").split("\n");
158
+ let i = lines.length;
159
+ while (n > 0 && i > 0)
160
+ if (lines[--i].trim())
161
+ n--;
162
+ return lines.slice(0, i).join("\n").trim().replace(/\n{3,}/g, "\n\n");
163
+ }
164
+ /** Co-Authored-By agent → { id: family, model: slug of the full name when it says more than the family, display_name:
165
+ * name as written }. Keeps "Claude Fable 5" from minting actor id "claude-fable-5" (backlog #12). */
166
+ function coauthorActor(coauthor, ae) {
167
+ const name = coauthor.replace(/<.*>/, "").trim();
168
+ const family = AGENT_FAMILIES.find((f) => name.toLowerCase().includes(f));
169
+ const full = slug(name);
170
+ return { type: "agent", id: family ?? full, model: family && full !== family ? full : undefined, on_behalf_of: ae, display_name: name };
171
+ }
172
+ export function commitToEvent(repo, sha, cfg, live = false) {
173
+ const fmt = ["%H", "%P", "%an", "%ae", "%aI", "%s", "%b", "%B"].join("%x1f");
174
+ const raw = git(repo, ["show", "-s", `--format=${fmt}`, sha]);
175
+ const [fullSha, parents, an, ae, aI, subject, body, message] = raw.split("\x1f");
176
+ const parentList = parents ? parents.split(" ") : [];
177
+ const { trailers, trailerText } = parseTrailers(message);
178
+ const numstat = git(repo, ["show", "--numstat", "--format=", sha]).split("\n").filter(Boolean);
179
+ let ins = 0, del = 0;
180
+ const files = numstat.map((l) => {
181
+ const [a, d, path] = l.split("\t");
182
+ if (a !== "-")
183
+ ins += Number(a);
184
+ if (d !== "-")
185
+ del += Number(d);
186
+ return path;
187
+ });
188
+ let branch = "";
189
+ try {
190
+ branch = git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]);
191
+ }
192
+ catch { }
193
+ const repoName = cfg.repoName ?? remoteName(repo) ?? basename(repo);
194
+ const coauthors = trailers["co-authored-by"] ?? [];
195
+ const agentId = trailers["retrace-actor"]?.[0];
196
+ const agentCo = coauthors.find((c) => AGENT_COAUTHOR.test(c));
197
+ const isBot = /\[bot\]/i.test(an);
198
+ let actor;
199
+ if (agentId)
200
+ actor = { type: "agent", id: agentId, model: trailers["retrace-model"]?.[0], on_behalf_of: ae };
201
+ else if (agentCo)
202
+ actor = coauthorActor(agentCo, ae);
203
+ else if (isBot)
204
+ actor = { type: "system", id: ae || an, display_name: an };
205
+ else
206
+ actor = { type: "human", id: ae || an, display_name: an };
207
+ let causedBy = trailers["retrace-caused-by"]?.[0] ?? process.env.RETRACE_CAUSED_BY;
208
+ if (!causedBy) {
209
+ const f = join(git(repo, ["rev-parse", "--git-dir"]), "retrace-caused-by");
210
+ const fp = resolve(repo, f);
211
+ if (existsSync(fp))
212
+ causedBy = readFileSync(fp, "utf8").trim() || undefined;
213
+ }
214
+ const isMerge = parentList.length > 1;
215
+ const cleanBody = stripTrailers(body, trailerText.length); // prose "Key: value" lines survive (backlog #12)
216
+ return {
217
+ project: cfg.project ?? basename(repo),
218
+ actor,
219
+ action: isMerge ? "merged" : "committed",
220
+ // PROV role: a commit generates the commit object and the new state of every changed file (a deletion included —
221
+ // the commit's diff generates that state; invalidation is not a role). Parents are inputs via derived_from, not refs.
222
+ artifacts: [
223
+ { id: `commit:${repoName}@${fullSha.slice(0, 12)}`, kind: "commit", label: `${repoName}@${fullSha.slice(0, 7)}`, derived_from: parentList.length ? parentList.map((p) => `commit:${repoName}@${p.slice(0, 12)}`) : undefined, role: "generated" },
224
+ ...files.map((f) => ({ id: `repo:${repoName}#${f}`, kind: "file", label: f, role: "generated" })),
225
+ ],
226
+ change: { before_hash: parentList[0], after_hash: fullSha, summary: `${files.length} file${files.length === 1 ? "" : "s"}, +${ins} −${del}` },
227
+ timestamp: new Date(aI).toISOString(),
228
+ location: {
229
+ system: "git", path: repo, environment: cfg.environment, device: process.env.RETRACE_DEVICE ?? hostname(),
230
+ // `live` = the post-commit hook, the ONLY caller whose own process context is the commit's context. backfill and
231
+ // `commit <sha>` replay commits this process did not produce, so stamping them would seal fabricated evidence.
232
+ ...(live ? { session: harnessSession(process.env), ...detectIde(process.env), surface: ttySurface() } : {}),
233
+ },
234
+ intent: cleanBody ? `${subject}\n\n${cleanBody}` : subject,
235
+ caused_by: causedBy,
236
+ method: { tool: "git", automated: actor.type !== "human", params: { branch, parents: parentList, files: files.length, insertions: ins, deletions: del, sha: fullSha } },
237
+ idempotency_key: `git:${fullSha}`,
238
+ tags: ["git", ...(isMerge ? ["merge"] : [])],
239
+ };
240
+ }
241
+ /** Did this hook run under a controlling terminal? "tty" = a human typed `git commit`; "agent" = a harness ran it.
242
+ * Read from /proc/self/stat field 7 (tty_nr), NOT from tty.isatty(): the installed post-commit script redirects its
243
+ * own stdout AND stderr to /dev/null (see hookScript), which destroys every file-descriptor signal while leaving the
244
+ * controlling terminal itself intact. Measured 2026-08-27: agent-spawned tty_nr=0, real pty tty_nr=34819.
245
+ * Linux-only by construction — with no /proc the field is simply absent, which is a legal permanent state
246
+ * ("absence is information", schema.ts). It is EVIDENCE only and never decides WHO: authorship does that. */
247
+ export function ttySurface(procStat = "/proc/self/stat") {
248
+ try {
249
+ const stat = readFileSync(procStat, "utf8");
250
+ // Parse after the last ")": field 2 (comm) is parenthesised and may itself contain spaces and parens.
251
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" "); // [state, ppid, pgrp, session, tty_nr, ...]
252
+ const ttyNr = Number(fields[4]);
253
+ return Number.isFinite(ttyNr) && fields.length > 4 ? (ttyNr === 0 ? "agent" : "tty") : undefined;
254
+ }
255
+ catch {
256
+ return undefined;
257
+ }
258
+ }
259
+ function remoteName(repo) {
260
+ try {
261
+ const url = git(repo, ["remote", "get-url", "origin"]);
262
+ const m = url.match(/[:/]([^/:]+\/[^/]+?)(\.git)?$/);
263
+ return m?.[1];
264
+ }
265
+ catch {
266
+ return undefined;
267
+ }
268
+ }
269
+ /** A repo that was never wired to Retrace must not write to a REMOTE ledger just because the shell happens to export
270
+ * RETRACE_URL. `.retrace.json` is the committed marker that says "this repo logs to a ledger" — `install` writes it —
271
+ * so its absence, combined with an ambient RETRACE_URL, means a scratch repo has picked up someone else's production
272
+ * credentials. That is not hypothetical: on 2026-08-28 six events in four junk projects (bf, p, demo, reprotest)
273
+ * reached the live Worker exactly this way, from temp repos under /tmp, and had to be deleted project-by-project
274
+ * because the ledger is append-only. The repo's own test harness already strips RETRACE_* for this reason
275
+ * (git-hook.test.ts baseEnv, after the 2026-08-19 dogfood incident); this is the same defence for anyone driving the
276
+ * CLI by hand.
277
+ * Local writes are deliberately NOT gated — a stray row in a SQLite file is cheap to discard, a sealed event in a
278
+ * shared append-only ledger is not. The real hook never trips this: `install` writes .retrace.json before the hook
279
+ * can ever run. Escape hatch for env-only setups (CI backfilling a repo that does not carry the file):
280
+ * `--allow-remote`, or RETRACE_ALLOW_REMOTE=1. */
281
+ export function guardRemoteWrite(repo, cfg) {
282
+ if (!cfg.url || cfg.allowRemote || existsSync(join(repo, ".retrace.json")))
283
+ return;
284
+ throw new Error(`refusing to log to the remote ledger ${cfg.url} from ${repo}: this repo has no .retrace.json, so RETRACE_URL came ` +
285
+ `from the environment rather than from the repo, and project "${cfg.project}" would be created there. ` +
286
+ `If this repo really should log to that ledger, run \`retrace-git install --project <name>\` (writes .retrace.json). ` +
287
+ `If it is a scratch or test repo, write locally with RETRACE_DB=<path>, or unset RETRACE_URL. ` +
288
+ `To override for one run: --allow-remote (or RETRACE_ALLOW_REMOTE=1).`);
289
+ }
290
+ async function logCommit(repo, sha, cfg, live = false) {
291
+ // The single choke point for every write path (hook, `commit <sha>`, backfill) — so a new caller cannot forget it.
292
+ guardRemoteWrite(repo, cfg);
293
+ const input = commitToEvent(repo, sha, cfg, live);
294
+ const store = cfg.url ? new RemoteStore(cfg.url, cfg.token) : makeStore();
295
+ return store instanceof RemoteStore ? store.append(input) : appendEvent(store, input);
296
+ }
297
+ const HOOK_MARK = "# retrace-git hook";
298
+ function hookScript() {
299
+ const self = new URL(import.meta.url).pathname;
300
+ return `#!/bin/sh\n${HOOK_MARK}\nnode "${self}" commit --hook --repo "$(git rev-parse --show-toplevel)" >/dev/null 2>&1 || echo "retrace: failed to log commit (non-fatal; reason appended to $(git rev-parse --git-dir)/retrace-hook.log)" >&2\n`;
301
+ }
302
+ async function main() {
303
+ const { flags, pos } = parseArgs(process.argv.slice(2));
304
+ const cmd = pos[0] ?? "help";
305
+ const repo = resolve(flags.repo ?? git(process.cwd(), ["rev-parse", "--show-toplevel"]));
306
+ const gitDir = resolve(repo, git(repo, ["rev-parse", "--git-dir"]));
307
+ if (cmd === "commit") {
308
+ // The hook path. Config errors (a credential that can't be resolved) and server rejections (401/403 from a
309
+ // fail-closed assert credential, 5xx) both land in retrace-hook.log, because the hook script discards our output.
310
+ const sha = pos[1] ?? "HEAD";
311
+ try {
312
+ const r = await logCommit(repo, sha, loadCfg(repo, flags), flags.hook === true);
313
+ console.log(`${r.deduped ? "(already logged) " : "logged "}${r.event.id}\n${describeEvent(r.event)}`);
314
+ }
315
+ catch (e) {
316
+ let id = sha;
317
+ try {
318
+ id = git(repo, ["rev-parse", "--short=12", sha]);
319
+ }
320
+ catch { }
321
+ appendHookLog(gitDir, `commit ${id} in ${repo} NOT logged: ${e?.message ?? e}`);
322
+ throw e;
323
+ }
324
+ return;
325
+ }
326
+ const cfg = loadCfg(repo, flags);
327
+ if (cmd === "install") {
328
+ mkdirSync(join(gitDir, "hooks"), { recursive: true });
329
+ const hookPath = join(gitDir, "hooks", "post-commit");
330
+ if (existsSync(hookPath) && !readFileSync(hookPath, "utf8").includes(HOOK_MARK)) {
331
+ console.error(`A post-commit hook already exists at ${hookPath}. Append this line to it manually:\n ${hookScript().split("\n")[2]}`);
332
+ process.exit(1);
333
+ }
334
+ writeFileSync(hookPath, hookScript());
335
+ chmodSync(hookPath, 0o755);
336
+ const cfgPath = join(repo, ".retrace.json");
337
+ if (!existsSync(cfgPath))
338
+ writeFileSync(cfgPath, JSON.stringify({ project: cfg.project, environment: cfg.environment }, null, 2) + "\n");
339
+ console.log(`installed post-commit hook → ${hookPath}\nproject: ${cfg.project}\nconfig: ${cfgPath} (commit it; add db/url/token there or via env)`);
340
+ return;
341
+ }
342
+ if (cmd === "uninstall") {
343
+ const hookPath = join(gitDir, "hooks", "post-commit");
344
+ if (existsSync(hookPath) && readFileSync(hookPath, "utf8").includes(HOOK_MARK)) {
345
+ unlinkSync(hookPath);
346
+ console.log("removed hook");
347
+ }
348
+ return;
349
+ }
350
+ if (cmd === "backfill") {
351
+ const range = flags.since ? `${flags.since}..HEAD` : "HEAD";
352
+ const max = flags.max ? ["-n", String(flags.max)] : [];
353
+ const shas = git(repo, ["rev-list", "--reverse", ...max, range]).split("\n").filter(Boolean);
354
+ let n = 0, d = 0;
355
+ for (const sha of shas) {
356
+ const r = await logCommit(repo, sha, cfg);
357
+ r.deduped ? d++ : n++;
358
+ }
359
+ console.log(`backfill: ${n} logged, ${d} already present, project '${cfg.project}'`);
360
+ return;
361
+ }
362
+ console.log(`retrace-git <install|uninstall|commit [sha]|backfill [--since ref] [--max n]> [--repo path] [--project name] [--allow-remote]`);
363
+ }
364
+ if (isMainModule(import.meta.url))
365
+ main().catch((e) => { console.error("retrace-git:", e.message ?? e); process.exit(1); });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * retrace-github — GitHub PR adapter helpers.
4
+ * retrace-github setup <owner/repo> --url https://retrace-api.you.workers.dev [--project name]
5
+ * prints the exact webhook settings to paste into GitHub (or a `gh api` one-liner)
6
+ * retrace-github backfill <owner/repo> [--project name] [--state all|open|closed] [--max 100] [--token $GITHUB_TOKEN]
7
+ * pulls PRs + reviews via the REST API and logs them (idempotent) into the configured store
8
+ * retrace-github replay <payload.json> --event pull_request [--project name]
9
+ * maps a saved webhook payload and logs it (handy for testing without a public URL)
10
+ * Store config as everywhere: RETRACE_DB (local) or RETRACE_URL(+RETRACE_TOKEN).
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+ import { mapGithubPullRest, mapGithubWebhook, appendEvent, describeEvent } from "@retrace-dev/core";
14
+ import { makeStore } from "./index.js";
15
+ import { RemoteStore } from "./remote-store.js";
16
+ import { isMainModule } from "./is-main.js";
17
+ function parseArgs(argv) {
18
+ const flags = {};
19
+ const pos = [];
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const a = argv[i];
22
+ if (a.startsWith("--")) {
23
+ const n = argv[i + 1];
24
+ if (n && !n.startsWith("--")) {
25
+ flags[a.slice(2)] = n;
26
+ i++;
27
+ }
28
+ else
29
+ flags[a.slice(2)] = true;
30
+ }
31
+ else
32
+ pos.push(a);
33
+ }
34
+ return { flags, pos };
35
+ }
36
+ async function gh(path, token) {
37
+ const res = await fetch(`https://api.github.com${path}`, { headers: { accept: "application/vnd.github+json", "user-agent": "retrace-github", ...(token ? { authorization: `Bearer ${token}` } : {}) } });
38
+ if (!res.ok)
39
+ throw new Error(`GitHub ${path} → ${res.status} ${await res.text()}`);
40
+ return res.json();
41
+ }
42
+ async function logAll(inputs) {
43
+ const store = makeStore();
44
+ let n = 0, d = 0;
45
+ for (const input of inputs) {
46
+ const r = store instanceof RemoteStore ? await store.append(input) : await appendEvent(store, input);
47
+ r.deduped ? d++ : n++;
48
+ if (!r.deduped)
49
+ console.log(describeEvent(r.event));
50
+ }
51
+ return { logged: n, deduped: d };
52
+ }
53
+ async function main() {
54
+ const { flags, pos } = parseArgs(process.argv.slice(2));
55
+ const cmd = pos[0];
56
+ if (cmd === "setup") {
57
+ const repo = pos[1];
58
+ const url = flags.url ?? process.env.RETRACE_URL ?? "https://<your-worker>.workers.dev";
59
+ const project = flags.project ?? process.env.RETRACE_PROJECT ?? repo;
60
+ const hook = `${url.replace(/\/$/, "")}/hooks/github?project=${encodeURIComponent(project)}`;
61
+ console.log(`GitHub → repo ${repo} → Settings → Webhooks → Add webhook
62
+ Payload URL: ${hook}
63
+ Content type: application/json
64
+ Secret: <the value you set with: wrangler secret put RETRACE_GITHUB_SECRET>
65
+ Events: Pull requests, Pull request reviews, Issue comments, Workflow runs${flags.push ? ", Pushes" : ""}
66
+
67
+ or with the GitHub CLI:
68
+ gh api repos/${repo}/hooks -f name=web -F active=true \\
69
+ -f "config[url]=${hook}" -f "config[content_type]=json" -f "config[secret]=$RETRACE_GITHUB_SECRET" \\
70
+ -f "events[]=pull_request" -f "events[]=pull_request_review" -f "events[]=issue_comment" -f "events[]=workflow_run"`);
71
+ return;
72
+ }
73
+ if (cmd === "backfill") {
74
+ const repo = pos[1];
75
+ if (!repo)
76
+ throw new Error("usage: retrace-github backfill <owner/repo>");
77
+ const token = flags.token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
78
+ const project = flags.project ?? process.env.RETRACE_PROJECT ?? repo;
79
+ const state = flags.state ?? "all";
80
+ const max = Number(flags.max ?? 100);
81
+ const inputs = [];
82
+ for (let page = 1; inputs.length < max * 3; page++) {
83
+ const prs = await gh(`/repos/${repo}/pulls?state=${state}&sort=created&direction=asc&per_page=50&page=${page}`, token);
84
+ if (!prs.length)
85
+ break;
86
+ for (const pr of prs.slice(0, max)) {
87
+ const reviews = await gh(`/repos/${repo}/pulls/${pr.number}/reviews`, token).catch(() => []);
88
+ inputs.push(...mapGithubPullRest(repo, pr, reviews, project));
89
+ }
90
+ if (prs.length < 50)
91
+ break;
92
+ }
93
+ inputs.sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)));
94
+ const r = await logAll(inputs);
95
+ console.log(`backfill ${repo}: ${r.logged} logged, ${r.deduped} already present → project '${project}'`);
96
+ return;
97
+ }
98
+ if (cmd === "replay") {
99
+ const file = pos[1];
100
+ const ev = flags.event;
101
+ if (!file || !ev)
102
+ throw new Error("usage: retrace-github replay <payload.json> --event <x-github-event>");
103
+ const inputs = mapGithubWebhook(ev, JSON.parse(readFileSync(file, "utf8")), { project: flags.project ?? process.env.RETRACE_PROJECT, includePush: !!flags.push });
104
+ const r = await logAll(inputs);
105
+ console.log(`replay: ${r.logged} logged, ${r.deduped} deduped`);
106
+ return;
107
+ }
108
+ console.log("retrace-github <setup <owner/repo> --url U | backfill <owner/repo> [--token T] [--state all] [--max n] | replay <payload.json> --event E> [--project name]");
109
+ }
110
+ if (isMainModule(import.meta.url))
111
+ main().catch((e) => { console.error("retrace-github:", e.message ?? e); process.exit(1); });
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Retrace MCP server — lets any MCP-capable agent (Claude Code, Claude Desktop, Cursor…)
4
+ * log provenance events and retrace history.
5
+ *
6
+ * Config (env):
7
+ * RETRACE_DB path to local SQLite file (default ~/.retrace/retrace.db)
8
+ * RETRACE_URL if set, use the remote Worker instead of local SQLite
9
+ * RETRACE_TOKEN bearer token for the remote Worker
10
+ * RETRACE_PROJECT default project name; when set, WRITE tools (retrace_log/retrace_instruct) are pinned to it —
11
+ * a different explicit project is rejected. Set RETRACE_PROJECT_LOCK=0 to allow any project.
12
+ * RETRACE_COMMIT_LOCK action "committed" is reserved for the git hook; retrace_log rejects it. Set 0 to allow.
13
+ * RETRACE_ACTOR_LOCK actor identity is authoritative from env: retrace_log rejects human/system actors and ignores
14
+ * caller-supplied id/model/on_behalf_of; retrace_instruct only attributes to RETRACE_ON_BEHALF_OF.
15
+ * Set 0 to allow caller overrides (backfill / trusted contexts only).
16
+ * RETRACE_ACTOR default actor id for this agent (e.g. "claude-code")
17
+ * RETRACE_ACTOR_MODEL default model string
18
+ * RETRACE_ON_BEHALF_OF the human this agent works for (e.g. jordan@...)
19
+ * RETRACE_SESSION override location.session (default: CLAUDE_CODE_SESSION_ID or GROK_SESSION_ID, else a run id)
20
+ * RETRACE_DEVICE override location.device (default: os.hostname() — an opt-out, since a hostname is sealed into
21
+ * hash-covered bodies that share links serve pre-auth and no later redaction is possible)
22
+ * RETRACE_IDE / RETRACE_WORKSPACE override location.ide / location.workspace (default: detected from the IDE's own
23
+ * environment — Orca's ORCA_PANE_KEY / ORCA_WORKTREE_ID; nothing is guessed)
24
+ */
25
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
26
+ import { Location } from "@retrace-dev/core";
27
+ import { SqliteStore } from "./sqlite-store.js";
28
+ import { RemoteStore } from "./remote-store.js";
29
+ /** WHERE enrichment for the MCP write path (backlog #15): fill each location field from `defaults` ONLY where the
30
+ * caller supplied nothing — a caller value is never overwritten, except for SERVER_ONLY keys, which are dropped
31
+ * rather than merged. Exported for unit tests. */
32
+ export declare function enrichLocation(caller: Location | undefined, defaults: Location): Location;
33
+ export declare function clientSystem(name: string): string;
34
+ /** IDE / agent-development environment hosting this agent, read from the environment that IDE injects into the pane it
35
+ * launches. Orca (onorca.dev) sets ORCA_PANE_KEY / ORCA_TAB_ID / ORCA_WORKTREE_ID / ORCA_TERMINAL_HANDLE on every
36
+ * agent pane; ORCA_WORKTREE_ID is the one that earns its place, because Orca's premise is N agents in N isolated
37
+ * worktrees and without it their event streams are indistinguishable. Nothing is guessed — an IDE that does not name
38
+ * itself in the environment gets no `ide`, and `location.client` already identifies VS Code / Cursor / Claude Desktop
39
+ * from the MCP handshake. Orca's bin directory being on PATH is NOT taken as evidence: that only means it is installed.
40
+ * WSL caveat: Orca sets these Windows-side and forwards only HISTFILE and the git-credential vars through WSLENV, so
41
+ * they do not reach a WSL pane unless WSLENV names them (see README). */
42
+ export declare function detectIde(env: NodeJS.ProcessEnv): Pick<Location, "ide" | "workspace">;
43
+ /** Harness session id when one is exposed. MCP and the live git hook must read the same keys so a commit joins the
44
+ * events that produced it. No fallback: absence is what makes the key discriminating (a human `git commit` has none). */
45
+ export declare function harnessSession(env?: NodeJS.ProcessEnv): string | undefined;
46
+ export declare function makeStore(): SqliteStore | RemoteStore;
47
+ export declare function buildServer(store?: SqliteStore | RemoteStore, opts?: {
48
+ pinnedProject?: string;
49
+ lock?: boolean;
50
+ commitLock?: boolean;
51
+ actorLock?: boolean;
52
+ }): McpServer;