@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.
package/dist/index.js ADDED
@@ -0,0 +1,384 @@
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 { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
27
+ import { z } from "zod";
28
+ import { mkdirSync } from "node:fs";
29
+ import { homedir, hostname } from "node:os";
30
+ import { join } from "node:path";
31
+ import { randomUUID } from "node:crypto";
32
+ import { Actor, Action, ArtifactRef, Change, Location, Method, EventInput, applyDefaultRoles, appendEvent, verifyProject, explainEvent, renderTimeline, renderWhyChain, describeEvent, buildExportBundle, verifyExportBundle, renderReportHtml, parseSigningKey, newShareId, buildLineage, renderLineageDot, renderLineageMermaid, renderLineageText, buildProjectStatus, renderProjectStatus, } from "@retrace-dev/core";
33
+ import { writeFileSync } from "node:fs";
34
+ import { ensureSigningKey } from "./keys.js";
35
+ import { SqliteStore } from "./sqlite-store.js";
36
+ import { RemoteStore } from "./remote-store.js";
37
+ import { isMainModule } from "./is-main.js";
38
+ const env = process.env;
39
+ const DEFAULT_PROJECT = env.RETRACE_PROJECT ?? "default";
40
+ /** Read at buildServer() time (not module load) so tests and embedders can configure it via env before building. */
41
+ const readDefaultActor = () => ({
42
+ type: "agent",
43
+ id: env.RETRACE_ACTOR ?? "mcp-agent",
44
+ model: env.RETRACE_ACTOR_MODEL,
45
+ on_behalf_of: env.RETRACE_ON_BEHALF_OF,
46
+ });
47
+ /** Location keys only the server may set. These are evidence ABOUT the writer — which session and machine produced
48
+ * the event, which client and IDE it came from, whether a human was at a keyboard — so a caller that could assert
49
+ * them could forge the very thing they exist to prove. Same reasoning that produced RETRACE_ACTOR_LOCK (security
50
+ * review 2026-08-21). The caller keeps `path`/`url`/`environment`, which it genuinely knows better than the server. */
51
+ const SERVER_ONLY = ["session", "device", "client", "ide", "workspace", "surface"];
52
+ /** WHERE enrichment for the MCP write path (backlog #15): fill each location field from `defaults` ONLY where the
53
+ * caller supplied nothing — a caller value is never overwritten, except for SERVER_ONLY keys, which are dropped
54
+ * rather than merged. Exported for unit tests. */
55
+ export function enrichLocation(caller, defaults) {
56
+ const merged = { ...defaults };
57
+ for (const [k, v] of Object.entries(caller ?? {}))
58
+ if (v !== undefined && !SERVER_ONLY.includes(k))
59
+ merged[k] = v;
60
+ return merged;
61
+ }
62
+ /** MCP client name (from the `initialize` handshake) → the `system` slug Retrace uses for it. An unmapped name is
63
+ * slugged rather than dropped: it is still better evidence than the hardcoded "claude-code" every client used to get. */
64
+ const CLIENT_SYSTEM = new Map([
65
+ ["claude-code", "claude-code"],
66
+ ["claude-ai", "claude-desktop"],
67
+ ["cursor-vscode", "cursor"],
68
+ ["Visual Studio Code", "vscode"],
69
+ ["grok-cli", "grok"],
70
+ ["grok", "grok"],
71
+ // Measured 2026-08-29 against Grok Build TUI 1.0.13: initialize.name is "grok-shell-retrace".
72
+ ["grok-shell-retrace", "grok"],
73
+ ["gemini-cli", "gemini-cli"],
74
+ ]);
75
+ export function clientSystem(name) {
76
+ // A Map, not an object literal: the client picks this name in the handshake, and an object would resolve
77
+ // "constructor"/"toString" through Object.prototype and return a function, which then fails Location's zod parse.
78
+ return CLIENT_SYSTEM.get(name) ?? (name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unknown");
79
+ }
80
+ /** IDE / agent-development environment hosting this agent, read from the environment that IDE injects into the pane it
81
+ * launches. Orca (onorca.dev) sets ORCA_PANE_KEY / ORCA_TAB_ID / ORCA_WORKTREE_ID / ORCA_TERMINAL_HANDLE on every
82
+ * agent pane; ORCA_WORKTREE_ID is the one that earns its place, because Orca's premise is N agents in N isolated
83
+ * worktrees and without it their event streams are indistinguishable. Nothing is guessed — an IDE that does not name
84
+ * itself in the environment gets no `ide`, and `location.client` already identifies VS Code / Cursor / Claude Desktop
85
+ * from the MCP handshake. Orca's bin directory being on PATH is NOT taken as evidence: that only means it is installed.
86
+ * WSL caveat: Orca sets these Windows-side and forwards only HISTFILE and the git-credential vars through WSLENV, so
87
+ * they do not reach a WSL pane unless WSLENV names them (see README). */
88
+ export function detectIde(env) {
89
+ const orca = env.ORCA_PANE_KEY || env.ORCA_WORKTREE_ID || env.ORCA_TERMINAL_HANDLE;
90
+ return {
91
+ ide: env.RETRACE_IDE ?? (orca ? "orca" : undefined),
92
+ workspace: env.RETRACE_WORKSPACE ?? env.ORCA_WORKTREE_ID,
93
+ };
94
+ }
95
+ /** Harness session id when one is exposed. MCP and the live git hook must read the same keys so a commit joins the
96
+ * events that produced it. No fallback: absence is what makes the key discriminating (a human `git commit` has none). */
97
+ export function harnessSession(env = process.env) {
98
+ return env.RETRACE_SESSION ?? env.CLAUDE_CODE_SESSION_ID ?? env.GROK_SESSION_ID;
99
+ }
100
+ export function makeStore() {
101
+ if (env.RETRACE_URL)
102
+ return new RemoteStore(env.RETRACE_URL, env.RETRACE_TOKEN);
103
+ const path = env.RETRACE_DB ?? join(homedir(), ".retrace", "retrace.db");
104
+ mkdirSync(join(path, ".."), { recursive: true });
105
+ return new SqliteStore(path);
106
+ }
107
+ export function buildServer(store = makeStore(), opts = {}) {
108
+ const server = new McpServer({ name: "retrace", version: "0.1.0" });
109
+ const remote = store instanceof RemoteStore ? store : null;
110
+ const pinned = opts.pinnedProject ?? env.RETRACE_PROJECT;
111
+ const lock = opts.lock ?? env.RETRACE_PROJECT_LOCK !== "0";
112
+ const commitLock = opts.commitLock ?? env.RETRACE_COMMIT_LOCK !== "0";
113
+ const actorLock = opts.actorLock ?? env.RETRACE_ACTOR_LOCK !== "0";
114
+ const defaultActor = readDefaultActor();
115
+ /** location.session: the harness's own session id when it exposes one, else a per-process run id (RETRACE_SESSION
116
+ * overrides both). Claude Code passes CLAUDE_CODE_SESSION_ID down to MCP subprocesses — verified 2026-08-27 against
117
+ * this server's own /proc/<pid>/environ under 2.1.250 — and Grok Build TUI exports GROK_SESSION_ID the same way.
118
+ * That shared env is what makes the id SHARED with the git hook: the same string lands on the agent's events and
119
+ * on the commits it drives, so `retrace_why` can walk between them. Two honest limits, both deliberate: a process
120
+ * environment is frozen at exec, so a session id re-minted mid-process is not seen until the server is respawned;
121
+ * and subagents inherit it, so this is a session key, not a per-run key. The random fallback stays for MCP clients
122
+ * that expose no session at all. */
123
+ const sessionId = harnessSession(env) ?? "run_" + randomUUID().replace(/-/g, "").slice(0, 12);
124
+ /** WHERE this server authoritatively knows (backlog #15). Evaluated per write rather than once, because the MCP
125
+ * client's identity only exists after the `initialize` handshake — which happens after buildServer() has returned.
126
+ * `url` is never stamped and no prod environment is synthesized — commit URLs and deploy environments belong to the
127
+ * git hook / Worker. `surface` is not stamped here either: an MCP subprocess is a harness child by construction, so
128
+ * the value would be the constant "agent" — hash bytes carrying no information. `system` follows the real client
129
+ * (a Cursor event used to be labelled "claude-code"); RETRACE_SYSTEM still overrides. */
130
+ const locationDefaults = () => {
131
+ const ci = server.server.getClientVersion();
132
+ return {
133
+ system: env.RETRACE_SYSTEM ?? (ci ? clientSystem(ci.name) : "claude-code"),
134
+ environment: env.RETRACE_ENVIRONMENT ?? env.RETRACE_ENV ?? "local",
135
+ path: process.cwd(),
136
+ device: env.RETRACE_DEVICE ?? hostname(),
137
+ session: sessionId,
138
+ client: ci ? `${ci.name}@${ci.version}` : undefined,
139
+ ...detectIde(env),
140
+ };
141
+ };
142
+ /** Resolve the project for a WRITE. If RETRACE_PROJECT is set (and lock on), any other explicit project is rejected
143
+ * so agents can't create stray projects by guessing a name. Read tools are not pinned. */
144
+ const writeProject = (requested) => {
145
+ const p = requested ?? pinned ?? DEFAULT_PROJECT;
146
+ if (lock && pinned && p !== pinned)
147
+ throw new Error(`project "${p}" is not allowed: this Retrace MCP server is pinned to project "${pinned}" (RETRACE_PROJECT). Omit project or pass "${pinned}". Set RETRACE_PROJECT_LOCK=0 to disable pinning.`);
148
+ return p;
149
+ };
150
+ /** "committed" events come only from the git post-commit hook — an agent logging one describes a commit it may not
151
+ * have made and misattributes it (this happened 2026-08-19). */
152
+ const guardAction = (action) => {
153
+ if (commitLock && action === "committed")
154
+ throw new Error(`action "committed" is reserved for the git hook, which already records every real commit with the correct actor. Log your work as "edited" or "decided" and reference the commit id in the artifact ids instead. Set RETRACE_COMMIT_LOCK=0 to override.`);
155
+ };
156
+ const ACTOR_LOCK_HINT = "Set RETRACE_ACTOR_LOCK=0 to override.";
157
+ /** Actor for a retrace_log WRITE (security review 2026-08-21, findings A1 + B4). With the lock on, this server only
158
+ * ever logs as its configured agent: human/system actors are refused outright, and id/model/on_behalf_of come from
159
+ * env — the caller may only decorate with display_name/version. Known limitation: cross-actor assertion (e.g. a
160
+ * "claude-cowork" event from a server configured as "claude-code") now needs the escape hatch; the credentialed
161
+ * per-actor version is backlog #6. */
162
+ const resolveActor = (callerActor) => {
163
+ if (!actorLock) {
164
+ return (callerActor?.type && callerActor.type !== "agent" ? callerActor : { ...defaultActor, ...(callerActor ?? {}) });
165
+ }
166
+ if (callerActor?.type === "human" || callerActor?.type === "system")
167
+ throw new Error(`actor.type "${callerActor.type}" is not allowed: this Retrace MCP server logs as its configured agent ("${defaultActor.id}"). Human instructions go through retrace_instruct; other human/system actors need the git hook or a credentialed context. ${ACTOR_LOCK_HINT}`);
168
+ return {
169
+ ...defaultActor,
170
+ // A configured model stays authoritative. When it is deliberately unpinned, accept the runtime model reported
171
+ // by the agent client; the credential and actor lock still control id/type/on_behalf_of. This lets clients such
172
+ // as Gemini CLI switch models without sealing stale attribution into the ledger.
173
+ ...(defaultActor.model === undefined && callerActor?.model !== undefined ? { model: callerActor.model } : {}),
174
+ ...(callerActor?.display_name !== undefined ? { display_name: callerActor.display_name } : {}),
175
+ ...(callerActor?.version !== undefined ? { version: callerActor.version } : {}),
176
+ };
177
+ };
178
+ /** Human actor for retrace_instruct. With the lock on, this server may only speak for its configured human. */
179
+ const resolveHuman = (humanId) => {
180
+ if (actorLock) {
181
+ const configured = env.RETRACE_ON_BEHALF_OF;
182
+ if (!configured)
183
+ throw new Error(`retrace_instruct cannot attribute an instruction to "${humanId}": RETRACE_ON_BEHALF_OF is not configured for this Retrace MCP server. Set it to the human this agent works for. ${ACTOR_LOCK_HINT}`);
184
+ if (humanId !== configured)
185
+ throw new Error(`human_id "${humanId}" is not allowed: this Retrace MCP server can only record instructions from its configured human ("${configured}", RETRACE_ON_BEHALF_OF), not an arbitrary one. ${ACTOR_LOCK_HINT}`);
186
+ }
187
+ return { type: "human", id: humanId };
188
+ };
189
+ server.registerTool("retrace_log", {
190
+ title: "Log a provenance event",
191
+ description: "Record WHO did WHAT to WHICH artifact(s), WHEN, WHERE, WHY and HOW. Call this after every meaningful action " +
192
+ "(create/edit/delete/execute/approve/send). Returns the event id — pass it as caused_by on follow-up actions " +
193
+ "so Retrace can reconstruct the causal chain back to the human instruction.",
194
+ inputSchema: {
195
+ project: z.string().optional().describe(`Project name (default: ${DEFAULT_PROJECT}). Omit it — the server pins writes to RETRACE_PROJECT and rejects other names.`),
196
+ action: Action.describe("Verb from the controlled vocabulary"),
197
+ action_detail: z.string().optional().describe("Required when action=other; free-text verb"),
198
+ artifacts: z.array(ArtifactRef).min(1).describe("Artifacts touched, e.g. {id:'repo:slcwitit/rpg#src/fight.ts', kind:'file', role:'both'}. role (PROV) = 'used' (input), " +
199
+ "'generated' (output) or 'both'. Omit it and the verb decides: read → used; created → generated; edited/moved/renamed → both; " +
200
+ "executed/sent/received/approved/rejected → used; deleted/other → unspecified. Always set role explicitly for OUTPUTS of an " +
201
+ "executed/sent action (a deployment, a report, a message) — the default treats those refs as inputs."),
202
+ intent: z.string().optional().describe("WHY: the reason for this action, in one sentence"),
203
+ caused_by: z.string().optional().describe("Event id of the instruction/action that caused this one"),
204
+ actor: Actor.partial().optional().describe("Override the default actor (defaults from env)"),
205
+ change: Change.optional().describe("WHAT changed: summary, diff, before/after hashes"),
206
+ location: Location.optional().describe("WHERE: path/url/environment/system. session/device/client/ide/workspace/surface are stamped by the server and ignored if you send them."),
207
+ method: Method.optional().describe("HOW: tool, instruction ref, params, tokens/cost"),
208
+ timestamp: z.string().optional().describe("ISO 8601; defaults to now"),
209
+ idempotency_key: z.string().optional(),
210
+ tags: z.array(z.string()).optional(),
211
+ },
212
+ }, async (args) => {
213
+ guardAction(args.action);
214
+ const actor = resolveActor(args.actor); // actor lock first — a rejected write must not get this far
215
+ // WHERE enrichment (backlog #15) and PROV role fill-absent: after the actor lock, before sealing (local appendEvent
216
+ // or the Worker's POST /events). A caller-supplied role is never overwritten; refs whose verb has no default stay absent.
217
+ const input = EventInput.parse({
218
+ ...args,
219
+ project: writeProject(args.project),
220
+ actor,
221
+ artifacts: applyDefaultRoles(args.action, args.artifacts),
222
+ location: enrichLocation(args.location, locationDefaults()),
223
+ });
224
+ const { event, deduped } = remote ? await remote.append(input) : await appendEvent(store, input);
225
+ return {
226
+ content: [{ type: "text", text: `${deduped ? "(deduped) " : ""}logged ${event.id} seq=${event.seq}\n${describeEvent(event)}` }],
227
+ structuredContent: { id: event.id, seq: event.seq, hash: event.hash, deduped },
228
+ };
229
+ });
230
+ server.registerTool("retrace_instruct", {
231
+ title: "Log a human instruction",
232
+ description: "Shortcut to record that a human gave an instruction (the root of a causal chain). Use at the start of a task " +
233
+ "with the user's request. Returns an event id to use as caused_by for the work that follows.",
234
+ inputSchema: {
235
+ project: z.string().optional(),
236
+ human_id: z.string().describe("Who gave the instruction (email or name)"),
237
+ instruction: z.string().describe("The instruction text (or a faithful summary)"),
238
+ artifacts: z.array(ArtifactRef).optional().describe("What the instruction is about; defaults to a task artifact (role generated). Supplied refs keep whatever role you give them — an instruction is about a file, it does not generate it."),
239
+ timestamp: z.string().optional().describe("ISO 8601; defaults to now"),
240
+ },
241
+ }, async (args) => {
242
+ const actor = resolveHuman(args.human_id); // actor lock first
243
+ const input = EventInput.parse({
244
+ project: writeProject(args.project),
245
+ actor,
246
+ action: "instructed",
247
+ // PROV role: the instruction brings its task into being (generated). Caller-supplied refs are stored as given —
248
+ // the instruction is ABOUT them, so no default is applied (absent = unspecified).
249
+ artifacts: args.artifacts ?? [{ id: `task:${args.instruction.slice(0, 60)}`, kind: "task", label: args.instruction.slice(0, 60), role: "generated" }],
250
+ intent: args.instruction,
251
+ timestamp: args.timestamp,
252
+ method: { tool: "chat", automated: false },
253
+ // WHERE enrichment (backlog #15): env-only — retrace_instruct deliberately has no caller-facing location param.
254
+ location: enrichLocation(undefined, locationDefaults()),
255
+ });
256
+ const { event } = remote ? await remote.append(input) : await appendEvent(store, input);
257
+ return { content: [{ type: "text", text: `instruction logged ${event.id}` }], structuredContent: { id: event.id } };
258
+ });
259
+ server.registerTool("retrace_history", {
260
+ title: "Retrace history",
261
+ description: "Timeline of events for a project, optionally filtered by artifact, actor, action, time range or text.",
262
+ inputSchema: {
263
+ project: z.string().optional(),
264
+ artifact_id: z.string().optional(),
265
+ actor_id: z.string().optional(),
266
+ actor_type: z.enum(["human", "agent", "system"]).optional(),
267
+ action: Action.optional(),
268
+ since: z.string().optional(),
269
+ until: z.string().optional(),
270
+ text: z.string().optional().describe("substring match across the event"),
271
+ limit: z.number().int().positive().max(1000).optional(),
272
+ },
273
+ }, async (args) => {
274
+ const events = await store.history({ ...args, project: args.project ?? DEFAULT_PROJECT });
275
+ return { content: [{ type: "text", text: renderTimeline(events) }], structuredContent: { count: events.length, events } };
276
+ });
277
+ server.registerTool("retrace_why", {
278
+ title: "Explain why an event happened",
279
+ description: "Follow caused_by links from an event back to the originating human instruction.",
280
+ inputSchema: { event_id: z.string() },
281
+ }, async ({ event_id }) => {
282
+ const chain = await explainEvent(store, event_id);
283
+ if (!chain.length)
284
+ return { content: [{ type: "text", text: `no event ${event_id}` }], isError: true };
285
+ return { content: [{ type: "text", text: renderWhyChain(chain) }], structuredContent: { chain } };
286
+ });
287
+ server.registerTool("retrace_status", {
288
+ title: "Project transparency status",
289
+ description: "One canonical view of chain integrity, causal coverage, capture gaps, actors, and integration freshness for humans and agents.",
290
+ inputSchema: { project: z.string().optional() },
291
+ }, async ({ project }) => {
292
+ const p = project ?? DEFAULT_PROJECT;
293
+ const status = remote ? await remote.status(p) : await buildProjectStatus(store, p);
294
+ return { content: [{ type: "text", text: renderProjectStatus(status) }], structuredContent: { status } };
295
+ });
296
+ server.registerTool("retrace_verify", {
297
+ title: "Verify chain integrity",
298
+ description: "Recompute the hash chain for a project and report whether history is intact.",
299
+ inputSchema: { project: z.string().optional() },
300
+ }, async ({ project }) => {
301
+ const p = project ?? DEFAULT_PROJECT;
302
+ const r = remote ? await remote.verify(p) : await verifyProject(store, p);
303
+ return {
304
+ content: [{ type: "text", text: r.ok ? `OK — ${r.checked} events verified for '${p}'` : `BROKEN at seq ${r.first_bad_seq}: ${r.reason}` }],
305
+ structuredContent: { ...r },
306
+ };
307
+ });
308
+ server.registerTool("retrace_export", {
309
+ title: "Signed provenance export",
310
+ description: "Build a signed (Ed25519) provenance bundle for a project or one artifact — the 'Prove' step. Optionally writes the JSON " +
311
+ "and a printable HTML report to disk. Anyone can verify the bundle offline with `retrace-export verify`.",
312
+ inputSchema: {
313
+ project: z.string().optional(),
314
+ artifact_id: z.string().optional().describe("Limit to one artifact (e.g. repo:rpg#src/fight.ts)"),
315
+ out_json: z.string().optional().describe("Path to write the signed JSON bundle"),
316
+ out_html: z.string().optional().describe("Path to write the printable HTML report (open → Print → Save as PDF)"),
317
+ },
318
+ }, async (args) => {
319
+ const project = args.project ?? DEFAULT_PROJECT;
320
+ const bundle = remote
321
+ ? await remote.export({ project, artifact_id: args.artifact_id })
322
+ : await buildExportBundle(store, { project, artifact_id: args.artifact_id }, { signingKey: parseSigningKey(env.RETRACE_SIGNING_KEY) ?? (await ensureSigningKey()).privateKey, issuerName: env.RETRACE_ISSUER });
323
+ const verdict = await verifyExportBundle(bundle);
324
+ if (args.out_json)
325
+ writeFileSync(args.out_json, JSON.stringify(bundle, null, 2));
326
+ if (args.out_html)
327
+ writeFileSync(args.out_html, renderReportHtml(bundle, verdict));
328
+ const summary = `${bundle.events.length} events · chain ${bundle.chain.ok ? "intact" : "BROKEN"} · signature ${verdict.signature}${bundle.issuer ? " (kid " + bundle.issuer.kid + ")" : ""}` +
329
+ (args.out_json ? `\njson → ${args.out_json}` : "") + (args.out_html ? `\nreport → ${args.out_html}` : "");
330
+ return { content: [{ type: "text", text: summary }], structuredContent: { events: bundle.events.length, chain_ok: bundle.chain.ok, signature: verdict.signature, kid: bundle.issuer?.kid, ...(args.out_json || args.out_html ? {} : { bundle }) } };
331
+ });
332
+ server.registerTool("retrace_share", {
333
+ title: "Create read-only share link",
334
+ description: "Create a public, read-only share link (timeline + verify + signed export + printable report) scoped to a project or one artifact.",
335
+ inputSchema: {
336
+ project: z.string().optional(),
337
+ artifact_id: z.string().optional(),
338
+ label: z.string().optional().describe("Shown as the report title, e.g. 'Jab counter — client review'"),
339
+ expires_in_days: z.number().int().positive().optional(),
340
+ },
341
+ }, async (args) => {
342
+ const project = args.project ?? DEFAULT_PROJECT;
343
+ if (remote) {
344
+ const r = await remote.share({ project, artifact_id: args.artifact_id, label: args.label, expires_in_days: args.expires_in_days });
345
+ return { content: [{ type: "text", text: `${r.url}\nreport: ${r.url}/report` }], structuredContent: { ...r } };
346
+ }
347
+ const id = newShareId();
348
+ const now = new Date();
349
+ const share = { id, project, artifact_id: args.artifact_id, label: args.label, created_at: now.toISOString(), expires_at: args.expires_in_days ? new Date(now.getTime() + args.expires_in_days * 86400000).toISOString() : undefined };
350
+ await store.createShare(share);
351
+ const base = env.RETRACE_PUBLIC_URL ?? `http://localhost:${env.RETRACE_PORT ?? 7777}`;
352
+ const url = `${base}/s/${id}`;
353
+ return { content: [{ type: "text", text: `${url}\nreport: ${url}/report\n(local links resolve while \`retrace-serve\` is running)` }], structuredContent: { share, url, report_url: `${url}/report` } };
354
+ });
355
+ server.registerTool("retrace_lineage", {
356
+ title: "Artifact lineage graph",
357
+ description: "Which artifacts came from which: explicit derived_from links plus causal flow (instruction → files touched → PR …). " +
358
+ "Returns text by default; format=dot (Graphviz) or mermaid for diagrams; format=json for nodes/edges.",
359
+ inputSchema: {
360
+ project: z.string().optional(),
361
+ artifact_id: z.string().optional().describe("Focus on one artifact (its events + causal ancestors)"),
362
+ format: z.enum(["text", "dot", "mermaid", "json"]).optional(),
363
+ include_actors: z.boolean().optional().describe("Add human/agent nodes with 'touched' edges"),
364
+ },
365
+ }, async (args) => {
366
+ const project = args.project ?? DEFAULT_PROJECT;
367
+ const events = args.artifact_id
368
+ ? (remote ? await remote.export({ project, artifact_id: args.artifact_id }) : await buildExportBundle(store, { project, artifact_id: args.artifact_id })).events
369
+ : await store.all(project);
370
+ const l = buildLineage(events, { includeActors: !!args.include_actors });
371
+ const fmt = args.format ?? "text";
372
+ const text = fmt === "dot" ? renderLineageDot(l) : fmt === "mermaid" ? renderLineageMermaid(l) : fmt === "json" ? JSON.stringify(l, null, 2) : renderLineageText(l);
373
+ return { content: [{ type: "text", text }], structuredContent: { nodes: l.nodes.length, edges: l.edges.length, ...(fmt === "json" ? { lineage: l } : {}) } };
374
+ });
375
+ server.registerTool("retrace_projects", { title: "List projects", description: "List projects that have events.", inputSchema: {} }, async () => {
376
+ const ps = await store.projects();
377
+ return { content: [{ type: "text", text: ps.join("\n") || "(none)" }], structuredContent: { projects: ps } };
378
+ });
379
+ return server;
380
+ }
381
+ if (isMainModule(import.meta.url)) {
382
+ const server = buildServer();
383
+ await server.connect(new StdioServerTransport());
384
+ }
@@ -0,0 +1,2 @@
1
+ /** True when an ES module is the process entry point, including npm bin symlinks. */
2
+ export declare function isMainModule(moduleUrl: string, entryPoint?: string): boolean;
@@ -0,0 +1,13 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ /** True when an ES module is the process entry point, including npm bin symlinks. */
4
+ export function isMainModule(moduleUrl, entryPoint = process.argv[1]) {
5
+ if (!entryPoint)
6
+ return false;
7
+ try {
8
+ return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(entryPoint);
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
package/dist/keys.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ export declare function keyPath(): string;
2
+ export declare function loadSigningKey(): JsonWebKey | null;
3
+ export declare function ensureSigningKey(): Promise<{
4
+ privateKey: JsonWebKey;
5
+ publicKey: JsonWebKey;
6
+ kid: string;
7
+ path: string;
8
+ created: boolean;
9
+ }>;
package/dist/keys.js ADDED
@@ -0,0 +1,28 @@
1
+ /** Local signing key management: ~/.retrace/signing-key.json (private JWK), auto-created on first use. */
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, dirname } from "node:path";
5
+ import { generateSigningKey, publicFromPrivate, keyId } from "@retrace-dev/core";
6
+ export function keyPath() {
7
+ return process.env.RETRACE_SIGNING_KEY_FILE ?? join(homedir(), ".retrace", "signing-key.json");
8
+ }
9
+ export function loadSigningKey() {
10
+ const p = keyPath();
11
+ if (!existsSync(p))
12
+ return null;
13
+ return JSON.parse(readFileSync(p, "utf8"));
14
+ }
15
+ export async function ensureSigningKey() {
16
+ const p = keyPath();
17
+ const existing = loadSigningKey();
18
+ if (existing)
19
+ return { privateKey: existing, publicKey: publicFromPrivate(existing), kid: await keyId(publicFromPrivate(existing)), path: p, created: false };
20
+ const kp = await generateSigningKey();
21
+ mkdirSync(dirname(p), { recursive: true });
22
+ writeFileSync(p, JSON.stringify(kp.privateKey, null, 2) + "\n");
23
+ try {
24
+ chmodSync(p, 0o600);
25
+ }
26
+ catch { }
27
+ return { ...kp, path: p, created: true };
28
+ }
@@ -0,0 +1,211 @@
1
+ /** Remote store: talks to the Retrace Cloudflare Worker over HTTP. Set RETRACE_URL (+ RETRACE_TOKEN). */
2
+ import { Event, EventStore, HistoryQuery, VerifyResult, EventInput, Share, ExportBundle, ProjectStatus } from "@retrace-dev/core";
3
+ /** Consistent headers for CLI-originated requests, including runtimes that require an explicit user agent. */
4
+ export declare function retraceHeaders(token?: string): Record<string, string>;
5
+ export declare class RemoteStore implements EventStore {
6
+ private baseUrl;
7
+ private token?;
8
+ constructor(baseUrl: string, token?: string | undefined);
9
+ private req;
10
+ /** Remote appends server-side (chain sealing must happen where the head lives). */
11
+ append(input: EventInput): Promise<{
12
+ event: Event;
13
+ deduped: boolean;
14
+ }>;
15
+ verify(project: string): Promise<VerifyResult>;
16
+ status(project: string): Promise<ProjectStatus>;
17
+ createShare(): Promise<void>;
18
+ getShare(id: string): Promise<Share | null>;
19
+ /** Server-side share creation; returns share + url. */
20
+ share(body: {
21
+ project: string;
22
+ artifact_id?: string;
23
+ label?: string;
24
+ expires_in_days?: number;
25
+ }): Promise<{
26
+ share: Share;
27
+ url: string;
28
+ }>;
29
+ export(scope: {
30
+ project: string;
31
+ artifact_id?: string;
32
+ }): Promise<ExportBundle>;
33
+ head(project: string): Promise<{
34
+ seq: number;
35
+ hash: string;
36
+ } | null>;
37
+ insert(): Promise<void>;
38
+ byIdempotencyKey(): Promise<Event | null>;
39
+ get(id: string): Promise<{
40
+ id: string;
41
+ project: string;
42
+ actor: {
43
+ type: "human" | "agent" | "system";
44
+ id: string;
45
+ display_name?: string | undefined;
46
+ model?: string | undefined;
47
+ version?: string | undefined;
48
+ on_behalf_of?: string | undefined;
49
+ };
50
+ action: "received" | "created" | "edited" | "deleted" | "read" | "executed" | "approved" | "rejected" | "sent" | "moved" | "renamed" | "instructed" | "committed" | "merged" | "other";
51
+ artifacts: {
52
+ id: string;
53
+ kind?: string | undefined;
54
+ label?: string | undefined;
55
+ derived_from?: string[] | undefined;
56
+ role?: "used" | "generated" | "both" | undefined;
57
+ }[];
58
+ timestamp: string;
59
+ seq: number;
60
+ prev_hash: string;
61
+ hash: string;
62
+ received_at: string;
63
+ action_detail?: string | undefined;
64
+ change?: {
65
+ before_hash?: string | undefined;
66
+ after_hash?: string | undefined;
67
+ diff?: string | undefined;
68
+ summary?: string | undefined;
69
+ } | undefined;
70
+ duration_ms?: number | undefined;
71
+ location?: {
72
+ system?: string | undefined;
73
+ path?: string | undefined;
74
+ url?: string | undefined;
75
+ environment?: string | undefined;
76
+ device?: string | undefined;
77
+ session?: string | undefined;
78
+ client?: string | undefined;
79
+ ide?: string | undefined;
80
+ workspace?: string | undefined;
81
+ surface?: "agent" | "tty" | undefined;
82
+ } | undefined;
83
+ intent?: string | undefined;
84
+ caused_by?: string | undefined;
85
+ method?: {
86
+ params?: Record<string, unknown> | undefined;
87
+ tool?: string | undefined;
88
+ instruction?: string | undefined;
89
+ automated?: boolean | undefined;
90
+ tokens?: number | undefined;
91
+ cost_usd?: number | undefined;
92
+ } | undefined;
93
+ idempotency_key?: string | undefined;
94
+ tags?: string[] | undefined;
95
+ } | null>;
96
+ all(project: string): Promise<{
97
+ id: string;
98
+ project: string;
99
+ actor: {
100
+ type: "human" | "agent" | "system";
101
+ id: string;
102
+ display_name?: string | undefined;
103
+ model?: string | undefined;
104
+ version?: string | undefined;
105
+ on_behalf_of?: string | undefined;
106
+ };
107
+ action: "received" | "created" | "edited" | "deleted" | "read" | "executed" | "approved" | "rejected" | "sent" | "moved" | "renamed" | "instructed" | "committed" | "merged" | "other";
108
+ artifacts: {
109
+ id: string;
110
+ kind?: string | undefined;
111
+ label?: string | undefined;
112
+ derived_from?: string[] | undefined;
113
+ role?: "used" | "generated" | "both" | undefined;
114
+ }[];
115
+ timestamp: string;
116
+ seq: number;
117
+ prev_hash: string;
118
+ hash: string;
119
+ received_at: string;
120
+ action_detail?: string | undefined;
121
+ change?: {
122
+ before_hash?: string | undefined;
123
+ after_hash?: string | undefined;
124
+ diff?: string | undefined;
125
+ summary?: string | undefined;
126
+ } | undefined;
127
+ duration_ms?: number | undefined;
128
+ location?: {
129
+ system?: string | undefined;
130
+ path?: string | undefined;
131
+ url?: string | undefined;
132
+ environment?: string | undefined;
133
+ device?: string | undefined;
134
+ session?: string | undefined;
135
+ client?: string | undefined;
136
+ ide?: string | undefined;
137
+ workspace?: string | undefined;
138
+ surface?: "agent" | "tty" | undefined;
139
+ } | undefined;
140
+ intent?: string | undefined;
141
+ caused_by?: string | undefined;
142
+ method?: {
143
+ params?: Record<string, unknown> | undefined;
144
+ tool?: string | undefined;
145
+ instruction?: string | undefined;
146
+ automated?: boolean | undefined;
147
+ tokens?: number | undefined;
148
+ cost_usd?: number | undefined;
149
+ } | undefined;
150
+ idempotency_key?: string | undefined;
151
+ tags?: string[] | undefined;
152
+ }[]>;
153
+ projects(): Promise<string[]>;
154
+ history(q: HistoryQuery): Promise<{
155
+ id: string;
156
+ project: string;
157
+ actor: {
158
+ type: "human" | "agent" | "system";
159
+ id: string;
160
+ display_name?: string | undefined;
161
+ model?: string | undefined;
162
+ version?: string | undefined;
163
+ on_behalf_of?: string | undefined;
164
+ };
165
+ action: "received" | "created" | "edited" | "deleted" | "read" | "executed" | "approved" | "rejected" | "sent" | "moved" | "renamed" | "instructed" | "committed" | "merged" | "other";
166
+ artifacts: {
167
+ id: string;
168
+ kind?: string | undefined;
169
+ label?: string | undefined;
170
+ derived_from?: string[] | undefined;
171
+ role?: "used" | "generated" | "both" | undefined;
172
+ }[];
173
+ timestamp: string;
174
+ seq: number;
175
+ prev_hash: string;
176
+ hash: string;
177
+ received_at: string;
178
+ action_detail?: string | undefined;
179
+ change?: {
180
+ before_hash?: string | undefined;
181
+ after_hash?: string | undefined;
182
+ diff?: string | undefined;
183
+ summary?: string | undefined;
184
+ } | undefined;
185
+ duration_ms?: number | undefined;
186
+ location?: {
187
+ system?: string | undefined;
188
+ path?: string | undefined;
189
+ url?: string | undefined;
190
+ environment?: string | undefined;
191
+ device?: string | undefined;
192
+ session?: string | undefined;
193
+ client?: string | undefined;
194
+ ide?: string | undefined;
195
+ workspace?: string | undefined;
196
+ surface?: "agent" | "tty" | undefined;
197
+ } | undefined;
198
+ intent?: string | undefined;
199
+ caused_by?: string | undefined;
200
+ method?: {
201
+ params?: Record<string, unknown> | undefined;
202
+ tool?: string | undefined;
203
+ instruction?: string | undefined;
204
+ automated?: boolean | undefined;
205
+ tokens?: number | undefined;
206
+ cost_usd?: number | undefined;
207
+ } | undefined;
208
+ idempotency_key?: string | undefined;
209
+ tags?: string[] | undefined;
210
+ }[]>;
211
+ }