Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions claude-code-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Records Tasklet snapshots for [Tracybot](../README.md) when using Claude Code, t

## How it differs from `opencode-plugin`

Claude Code doesn't have OpenCode's Plan/Build mode distinction, so this doesn't produce the same `Tasklet` shape — each recorded unit is one `ClaudeTurn` (one `UserPromptSubmit` → `Stop` cycle that touched a file via `Edit`/`Write`/`MultiEdit`), tagged `"source": "claude-code"` so `buildHistory.ts` can tell the two shapes apart and parse each one correctly.
Claude Code doesn't have OpenCode's Plan/Build mode distinction, so this doesn't produce the same `Tasklet` shape — each recorded unit is one `ClaudeTurn` (one `UserPromptSubmit` → `Stop` cycle that changed the repo via `Edit`/`Write`/`MultiEdit` or a `Bash` command), tagged `"source": "claude-code"` so `buildHistory.ts` can tell the two shapes apart and parse each one correctly.

Prompt text is read from the turn's `transcript_path` rather than passed directly by a hook, and the response text comes from the `Stop` event's `last_assistant_message`.

Expand All @@ -15,7 +15,7 @@ bun install
bun run deploy
```

This builds the plugin and adds `PostToolUse` (matcher: `Edit|Write|MultiEdit`) and `Stop` hooks to `~/.claude/settings.json`, without touching any hooks you already have configured there.
This builds the plugin and adds `PostToolUse` (matcher: `Edit|Write|MultiEdit|Bash`) and `Stop` hooks to `~/.claude/settings.json`, without touching any hooks you already have configured there. If the hooks are already installed with an older matcher, the matcher is updated.

## Requirements

Expand Down
16 changes: 13 additions & 3 deletions claude-code-plugin/scripts/deploy.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,21 +51,31 @@ function hookCommand(event) {
return `${resolveBunPath()} ${DIST_PATH} ${event}`
}

const POST_TOOL_USE_MATCHER = 'Edit|Write|MultiEdit|Bash'

function findEntry(hookList, command) {
return (hookList ?? []).find(entry => (entry.hooks ?? []).some(h => h.command === command))
}

function hasCommand(hookList, command) {
return (hookList ?? []).some(entry => (entry.hooks ?? []).some(h => h.command === command))
return findEntry(hookList, command) !== undefined
}

function installHooks(settings) {
settings.hooks ??= {}

settings.hooks.PostToolUse ??= []
const postToolUseCommand = hookCommand('post-tool-use')
if (!hasCommand(settings.hooks.PostToolUse, postToolUseCommand)) {
const postToolUseEntry = findEntry(settings.hooks.PostToolUse, postToolUseCommand)
if (!postToolUseEntry) {
settings.hooks.PostToolUse.push({
matcher: 'Edit|Write|MultiEdit',
matcher: POST_TOOL_USE_MATCHER,
hooks: [{ type: 'command', command: postToolUseCommand }],
})
console.log(' Added PostToolUse hook')
} else if (postToolUseEntry.matcher !== POST_TOOL_USE_MATCHER) {
postToolUseEntry.matcher = POST_TOOL_USE_MATCHER
console.log(' Updated PostToolUse hook matcher')
} else {
console.log(' PostToolUse hook already present')
}
Expand Down
150 changes: 150 additions & 0 deletions claude-code-plugin/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { test, expect, afterEach } from "bun:test"
import path from "path"
import os from "os"
import fs from "fs"
import { readContextTurns, clearContextTurns } from "./state"

// Runs the real hook entry point against a temporary repo and the real
// tracking/tracy.py, the same way Claude Code invokes it.
const $ = Bun.$
const HOOK = path.join(import.meta.dir, "index.ts")
const TRACY_PY = path.resolve(import.meta.dir, "..", "..", "tracking", "tracy.py")

const tmpDirs: string[] = []
const sessionIds: string[] = []

afterEach(async () => {
for (const dir of tmpDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true })
}
for (const id of sessionIds.splice(0)) {
await clearContextTurns(id)
}
})

function sessionId(): string {
const id = `index-test-${Date.now()}-${Math.random()}`
sessionIds.push(id)
return id
}

async function makeRepo(): Promise<string> {
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "tracy-hook-test-")))
tmpDirs.push(dir)
await $`git init -q && git config user.email test@example.com && git config user.name Test`.cwd(dir).quiet()
fs.writeFileSync(path.join(dir, "app.js"), "var TOKEN_REGEXP = /x/;\n")
await $`git add app.js && git commit -q -m init`.cwd(dir).quiet()
return dir
}

function writeTranscript(dir: string, prompt: string): string {
const transcriptPath = path.join(dir, "transcript.jsonl")
fs.writeFileSync(transcriptPath, [
{ type: "user", message: { role: "user", content: prompt } },
{ type: "assistant", message: { role: "assistant", model: "claude-sonnet-5", content: [{ type: "text", text: "done" }] } },
].map(l => JSON.stringify(l)).join("\n"))
return transcriptPath
}

async function runHook(event: string, input: object): Promise<void> {
const proc = Bun.spawn([process.execPath, HOOK, event], {
stdin: new Blob([JSON.stringify(input)]),
env: { ...process.env, TRACY_SNAPSHOT_SCRIPT: TRACY_PY },
stdout: "ignore",
stderr: "ignore",
})
await proc.exited
}

async function latestSnapshotTurn(repo: string): Promise<any> {
const tracyId = (await $`git config --get tracy.current-id`.cwd(repo).quiet().nothrow()).stdout.toString().trim()
if (!tracyId) return undefined
return JSON.parse(await $`git log -1 --format=%b refs/tracy-local/${tracyId}`.cwd(repo).text())
}

test("a turn that changes files only through Bash is recorded with its own prompt", async () => {
const repo = await makeRepo()
const transcriptDir = fs.mkdtempSync(path.join(os.tmpdir(), "tracy-transcript-test-"))
tmpDirs.push(transcriptDir)
const id = sessionId()

// What `sed -i` run by Claude through the Bash tool does.
fs.writeFileSync(path.join(repo, "app.js"), "var SERVER_TIMING_NAME_REGEXP = /x/;\n")
await runHook("post-tool-use", { session_id: id, cwd: repo, tool_name: "Bash", tool_input: { command: "sed -i '' ... app.js" } })
await runHook("stop", { session_id: id, cwd: repo, transcript_path: writeTranscript(transcriptDir, "change the names of the variables"), last_assistant_message: "Renamed." })

const turn = await latestSnapshotTurn(repo)
expect(turn?.prompt).toBe("change the names of the variables")
expect(await readContextTurns(id, repo)).toEqual([])
})

test("a Bash-only turn that changes nothing is kept as context, not recorded", async () => {
const repo = await makeRepo()
const transcriptDir = fs.mkdtempSync(path.join(os.tmpdir(), "tracy-transcript-test-"))
tmpDirs.push(transcriptDir)
const id = sessionId()

await runHook("post-tool-use", { session_id: id, cwd: repo, tool_name: "Bash", tool_input: { command: "grep -n TOKEN app.js" } })
await runHook("stop", { session_id: id, cwd: repo, transcript_path: writeTranscript(transcriptDir, "what could we rename?"), last_assistant_message: "TOKEN_REGEXP is too generic." })

expect(await latestSnapshotTurn(repo)).toBeUndefined()
expect(await readContextTurns(id, repo)).toEqual([{ prompt: "what could we rename?", response: "TOKEN_REGEXP is too generic." }])
})

test("a discussion turn is attached to the next change in the same repo", async () => {
const repo = await makeRepo()
const transcriptDir = fs.mkdtempSync(path.join(os.tmpdir(), "tracy-transcript-test-"))
tmpDirs.push(transcriptDir)
const id = sessionId()

await runHook("stop", { session_id: id, cwd: repo, transcript_path: writeTranscript(transcriptDir, "what should we rename?"), last_assistant_message: "TOKEN_REGEXP." })

fs.writeFileSync(path.join(repo, "app.js"), "var SERVER_TIMING_NAME_REGEXP = /x/;\n")
await runHook("post-tool-use", { session_id: id, cwd: repo, tool_name: "Bash", tool_input: { command: "sed -i '' ... app.js" } })
await runHook("stop", { session_id: id, cwd: repo, transcript_path: writeTranscript(transcriptDir, "yes"), last_assistant_message: "Renamed." })

const turn = await latestSnapshotTurn(repo)
expect(turn?.prompt).toBe("yes")
expect(turn?.context).toEqual([{ prompt: "what should we rename?", response: "TOKEN_REGEXP." }])
})

test("a discussion turn in another repo is never attached to this repo's change", async () => {
const repo = await makeRepo()
const otherRepo = await makeRepo()
const transcriptDir = fs.mkdtempSync(path.join(os.tmpdir(), "tracy-transcript-test-"))
tmpDirs.push(transcriptDir)
const id = sessionId()

await runHook("stop", { session_id: id, cwd: otherRepo, transcript_path: writeTranscript(transcriptDir, "about the other project"), last_assistant_message: "Sure." })

fs.writeFileSync(path.join(repo, "app.js"), "var SERVER_TIMING_NAME_REGEXP = /x/;\n")
await runHook("post-tool-use", { session_id: id, cwd: repo, tool_name: "Bash", tool_input: { command: "sed -i '' ... app.js" } })
await runHook("stop", { session_id: id, cwd: repo, transcript_path: writeTranscript(transcriptDir, "rename it"), last_assistant_message: "Renamed." })

const turn = await latestSnapshotTurn(repo)
expect(turn?.prompt).toBe("rename it")
expect(turn?.context).toEqual([])
})

test("a change in one repo keeps another repo's discussion for that repo's next change", async () => {
const repoA = await makeRepo()
const repoB = await makeRepo()
const transcriptDir = fs.mkdtempSync(path.join(os.tmpdir(), "tracy-transcript-test-"))
tmpDirs.push(transcriptDir)
const id = sessionId()

await runHook("stop", { session_id: id, cwd: repoA, transcript_path: writeTranscript(transcriptDir, "what should we rename in A?"), last_assistant_message: "TOKEN_REGEXP." })

fs.writeFileSync(path.join(repoB, "app.js"), "var B_REGEXP = /x/;\n")
await runHook("post-tool-use", { session_id: id, cwd: repoB, tool_name: "Bash", tool_input: { command: "sed -i '' ... app.js" } })
await runHook("stop", { session_id: id, cwd: repoB, transcript_path: writeTranscript(transcriptDir, "fix B first"), last_assistant_message: "Fixed." })

fs.writeFileSync(path.join(repoA, "app.js"), "var SERVER_TIMING_NAME_REGEXP = /x/;\n")
await runHook("post-tool-use", { session_id: id, cwd: repoA, tool_name: "Bash", tool_input: { command: "sed -i '' ... app.js" } })
await runHook("stop", { session_id: id, cwd: repoA, transcript_path: writeTranscript(transcriptDir, "now do A"), last_assistant_message: "Renamed." })

expect((await latestSnapshotTurn(repoB))?.context).toEqual([])
const turnA = await latestSnapshotTurn(repoA)
expect(turnA?.prompt).toBe("now do A")
expect(turnA?.context).toEqual([{ prompt: "what should we rename in A?", response: "TOKEN_REGEXP." }])
})
60 changes: 44 additions & 16 deletions claude-code-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { markFileEdited, readPendingTurn, clearPendingTurn } from "./state"
import { markFileEdited, markBashRan, readPendingTurn, clearPendingTurn, appendContextTurn, readContextTurns, clearRepoContextTurns } from "./state"
import { extractTurnContext } from "./transcript"
import { getRepoRootForEditedFiles, resolveTracyPath, detectPythonCommand, runTracySnapshot } from "./tracy"
import type { ClaudeTurn } from "./types"
import { getRepoRootForEditedFiles, getFirstRepoRoot, getLatestSnapshot, resolveTracyPath, detectPythonCommand, runTracySnapshot } from "./tracy"
import type { ClaudeTurn, PendingTurnState } from "./types"

const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit"])

Expand All @@ -26,6 +26,10 @@ async function readStdinJson<T>(): Promise<T> {

async function handlePostToolUse(): Promise<void> {
const input = await readStdinJson<ToolEventInput>()
if (input.tool_name === "Bash") {
await markBashRan(input.session_id, input.cwd)
return
}
if (!input.tool_name || !EDIT_TOOLS.has(input.tool_name)) return
if (!input.tool_input?.file_path) return

Expand All @@ -36,24 +40,46 @@ async function handleStop(): Promise<void> {
const input = await readStdinJson<StopEventInput>()

const pending = await readPendingTurn(input.session_id)
if (!pending || pending.editedFiles.length === 0) return // nothing edited this turn — mirrors OpenCode's toolCount > 0 gate
await clearPendingTurn(input.session_id)

// Derived from the actual edited files, not input.cwd — see
// getRepoRootForEditedFiles for why the hook's own cwd can't be trusted.
const repoRoot = await getRepoRootForEditedFiles(pending.editedFiles)
if (!repoRoot) return
const { prompt, model } = await extractTurnContext(input.transcript_path)
const response = input.last_assistant_message ?? ""

const recordedRepoRoot = pending && await recordSnapshot(input.session_id, pending, prompt, model, response)
if (recordedRepoRoot) {
await clearRepoContextTurns(input.session_id, recordedRepoRoot)
} else if (prompt && !pending?.editedFiles.length) {
// Nothing changed this turn — mirrors OpenCode's toolCount > 0 gate,
// but keep the turn as context for the next one that does change
// something in the same repo. Where Bash ran is the better signal;
// the Stop event's cwd is the fallback for a turn with no tool use.
const repoRoot = await getFirstRepoRoot([...(pending?.bashCwds ?? []), input.cwd])
if (repoRoot) {
await appendContextTurn(input.session_id, repoRoot, { prompt, response })
}
}
}

// Returns the repo tracy.py recorded a snapshot in, or undefined if it
// didn't. It skips when the tree is unchanged, e.g. a turn whose Bash
// commands only read files.
async function recordSnapshot(sessionId: string, pending: PendingTurnState, prompt: string, model: string, response: string): Promise<string | undefined> {
// Derived from the edited files, or where Bash ran, not the Stop event's
// cwd — see getRepoRootForEditedFiles for why that can't be trusted.
const repoRoot = (await getRepoRootForEditedFiles(pending.editedFiles))
?? (await getFirstRepoRoot(pending.bashCwds ?? []))
if (!repoRoot) return undefined

const tracyPath = await resolveTracyPath(repoRoot)
if (!tracyPath) return // Tracybot not initialized in this repo
if (!tracyPath) return undefined // Tracybot not initialized in this repo

const pythonCmd = await detectPythonCommand()
if (!pythonCmd) return
if (!pythonCmd) return undefined

const now = Date.now()
const { prompt, model } = await extractTurnContext(input.transcript_path)
const turn: ClaudeTurn = {
id: `claude_${input.session_id}_${now}`,
sessionId: input.session_id,
id: `claude_${sessionId}_${now}`,
sessionId,
source: "claude-code",
// Claude Code's transcript only has the bare model id (e.g.
// "claude-sonnet-5"), not an "anthropic/..." formatted string the
Expand All @@ -63,13 +89,15 @@ async function handleStop(): Promise<void> {
// given Tasklet.
model: model ? `anthropic/${model}` : undefined,
prompt,
response: input.last_assistant_message ?? "",
response,
promptCreatedAt: now, // transcript doesn't reliably expose the original prompt timestamp — best available approximation
responseCompletedAt: now,
context: await readContextTurns(sessionId, repoRoot),
}

await runTracySnapshot(pythonCmd, tracyPath, repoRoot, JSON.stringify(turn), input.session_id)
await clearPendingTurn(input.session_id)
const before = await getLatestSnapshot(repoRoot)
await runTracySnapshot(pythonCmd, tracyPath, repoRoot, JSON.stringify(turn), sessionId)
return (await getLatestSnapshot(repoRoot)) !== before ? repoRoot : undefined
}

async function main(): Promise<void> {
Expand Down
96 changes: 96 additions & 0 deletions claude-code-plugin/src/state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { test, expect, afterEach } from "bun:test"
import path from "path"
import os from "os"
import fs from "fs"
import { appendContextTurn, readContextTurns, clearContextTurns, clearRepoContextTurns, MAX_CONTEXT_TURNS, markFileEdited, markBashRan, readPendingTurn, clearPendingTurn } from "./state"

const sessionIds: string[] = []

afterEach(async () => {
for (const id of sessionIds.splice(0)) {
await clearContextTurns(id)
await clearPendingTurn(id)
}
})

function sessionId(): string {
const id = `state-test-${Date.now()}-${Math.random()}`
sessionIds.push(id)
return id
}

test("readContextTurns returns an empty list when nothing was recorded", async () => {
expect(await readContextTurns(sessionId(), "/repo/a")).toEqual([])
})

test("appendContextTurn keeps turns oldest first", async () => {
const id = sessionId()
await appendContextTurn(id, "/repo/a", { prompt: "what could we add?", response: "1. Request IDs" })
await appendContextTurn(id, "/repo/a", { prompt: "tell me more about 1", response: "It would..." })

expect((await readContextTurns(id, "/repo/a")).map(t => t.prompt)).toEqual(["what could we add?", "tell me more about 1"])
})

test("appendContextTurn drops the oldest turns beyond MAX_CONTEXT_TURNS", async () => {
const id = sessionId()
for (let i = 0; i < MAX_CONTEXT_TURNS + 2; i++) {
await appendContextTurn(id, "/repo/a", { prompt: `p${i}`, response: "" })
}

const turns = await readContextTurns(id, "/repo/a")
expect(turns).toHaveLength(MAX_CONTEXT_TURNS)
expect(turns[0]?.prompt).toBe("p2")
})

test("context is kept per session", async () => {
const a = sessionId()
const b = sessionId()
await appendContextTurn(a, "/repo/a", { prompt: "only in a", response: "" })

expect(await readContextTurns(b, "/repo/a")).toEqual([])
})

test("readContextTurns only returns turns from the given repo, without the repo path", async () => {
const id = sessionId()
await appendContextTurn(id, "/repo/private", { prompt: "about the private repo", response: "" })
await appendContextTurn(id, "/repo/a", { prompt: "about a", response: "ok" })

expect(await readContextTurns(id, "/repo/a")).toEqual([{ prompt: "about a", response: "ok" }])
})

test("clearContextTurns removes the context so it isn't attached to a later edit", async () => {
const id = sessionId()
await appendContextTurn(id, "/repo/a", { prompt: "p", response: "r" })
await clearContextTurns(id)

expect(await readContextTurns(id, "/repo/a")).toEqual([])
})

test("clearRepoContextTurns removes only that repo's turns", async () => {
const id = sessionId()
await appendContextTurn(id, "/repo/a", { prompt: "about a", response: "" })
await appendContextTurn(id, "/repo/b", { prompt: "about b", response: "ok" })
await clearRepoContextTurns(id, "/repo/b")

expect(await readContextTurns(id, "/repo/b")).toEqual([])
expect(await readContextTurns(id, "/repo/a")).toEqual([{ prompt: "about a", response: "" }])
})

test("markBashRan records each working directory once, alongside edited files", async () => {
const id = sessionId()
await markFileEdited(id, "/repo/a/file.ts")
await markBashRan(id, "/repo/a")
await markBashRan(id, "/repo/a")

expect(await readPendingTurn(id)).toEqual({ editedFiles: ["/repo/a/file.ts"], bashCwds: ["/repo/a"] })
})

test.skipIf(process.platform === "win32")("state files are readable only by their owner", async () => {
const id = sessionId()
await markFileEdited(id, "/repo/a/file.ts")
await appendContextTurn(id, "/repo/a", { prompt: "p", response: "r" })

for (const name of [`tracybot-cc-turn-${id}.json`, `tracybot-cc-context-${id}.json`]) {
expect(fs.statSync(path.join(os.tmpdir(), name)).mode & 0o777).toBe(0o600)
}
})
Loading
Loading