diff --git a/src/core/event-log.ts b/src/core/event-log.ts index a2833fe..3254f97 100644 --- a/src/core/event-log.ts +++ b/src/core/event-log.ts @@ -64,6 +64,13 @@ export interface HookEvent { * Optional — absent when no workflow is active. */ workflow_id?: string; + /** + * Loom spine link — id of the Loom task this event belongs to, when the + * session was launched by Loom (LOOM_TASK_ID set). Lets Loom attribute token + * savings to a task exactly. Optional — absent outside Loom, so standalone + * token-pilot events stay byte-identical to before. + */ + task_id?: string; event: | "denied" | "allowed" @@ -235,8 +242,14 @@ export async function appendEvent( event.workflow_id ?? process.env.TOKEN_PILOT_WORKFLOW_ID ?? process.env.CLAUDE_CODE_WORKFLOW_ID ?? + process.env.LOOM_WORKFLOW_ID ?? undefined; - const tagged = wf ? { ...event, workflow_id: wf } : event; + // Loom spine: tag the task id when the session was launched by Loom. + // Env-driven so call sites stay unchanged; absent outside Loom → no field. + const taskId = event.task_id ?? process.env.LOOM_TASK_ID ?? undefined; + let tagged = event; + if (wf) tagged = { ...tagged, workflow_id: wf }; + if (taskId) tagged = { ...tagged, task_id: taskId }; await ensureLogDir(projectRoot); await rotateIfNeeded(projectRoot); const line = JSON.stringify(tagged) + "\n"; diff --git a/tests/core/event-log.test.ts b/tests/core/event-log.test.ts index 3c5969a..fae0aa1 100644 --- a/tests/core/event-log.test.ts +++ b/tests/core/event-log.test.ts @@ -222,6 +222,59 @@ describe("appendEvent / loadEvents", () => { }); }); +// ─── appendEvent — Loom spine tagging (task_id / workflow_id from env) ───────── + +describe("appendEvent — Loom spine tagging", () => { + const base: HookEvent = { + ts: 1_700_000_000_000, + session_id: "s1", + agent_type: null, + agent_id: null, + event: "denied", + file: "src/big.ts", + lines: 500, + estTokens: 2000, + summaryTokens: 400, + savedTokens: 1600, + }; + + it("tags task_id from LOOM_TASK_ID when set", async () => { + const project = await makeTmp(); + tmpDirs.push(project); + process.env.LOOM_TASK_ID = "tj-xyz"; + try { + await appendEvent(project, { ...base }); + } finally { + delete process.env.LOOM_TASK_ID; + } + const events = await loadEvents(project); + expect(events[0].task_id).toBe("tj-xyz"); + }); + + it("tags workflow_id from LOOM_WORKFLOW_ID when set", async () => { + const project = await makeTmp(); + tmpDirs.push(project); + process.env.LOOM_WORKFLOW_ID = "wf-1"; + try { + await appendEvent(project, { ...base }); + } finally { + delete process.env.LOOM_WORKFLOW_ID; + } + const events = await loadEvents(project); + expect(events[0].workflow_id).toBe("wf-1"); + }); + + it("adds no task_id field when LOOM_TASK_ID is unset (back-compat)", async () => { + const project = await makeTmp(); + tmpDirs.push(project); + delete process.env.LOOM_TASK_ID; + await appendEvent(project, { ...base }); + const events = await loadEvents(project); + expect(events[0].task_id).toBeUndefined(); + expect("task_id" in events[0]).toBe(false); + }); +}); + // ─── applyRetention ────────────────────────────────────────────────────────── describe("applyRetention", () => {