Skip to content
Open
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: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ do not import whole files or unrelated workflows merely to preserve source shape
and journals deterministic. Workflow, harness adaptations, clusters, reviews,
and presentation belong to the maintained Markdown skill and private agent code.
Replay mocks never execute customer tools or change routing.
- Work hooks (`understudy hooks`) are in scope: on a coding tool's hook they
report only the session id, repository identity, branch, and HEAD commit
when those change (`docs/hooks-commands.md`). They never send code, prompts,
paths, or remote credentials, print nothing, and never fail the agent.
- Monitoring, Desktop, local model serving, and training job execution remain
deferred.
- Until automated tracked-and-packed-content checks exist, manual inspection is
Expand Down
40 changes: 40 additions & 0 deletions docs/hooks-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Work hooks

`understudy hooks install` adds command hooks to Claude Code's settings
(`--settings <file>`, else `$CLAUDE_CONFIG_DIR/settings.json`, else
`~/.claude/settings.json`). Claude Code runs them on each prompt and after each
shell command (`Bash` or `PowerShell`), including failed ones. Understudy uses what they report to show what each pull request
cost: a request belongs to the pull request holding the next commit its
session made.

`understudy hooks uninstall` removes only these hooks. Reinstalling replaces
them, so it is safe to run after moving or updating the CLI.

## What leaves the machine

Only when a session's git state changes, one request to
`POST /admin/v1/orgs/{org}/team/work-events` carrying:

- the Claude Code session id;
- the repository as `host/owner/name`, from `origin`, without credentials;
- the branch name, or nothing on a detached HEAD;
- the HEAD commit and its committer time;
- when the hook ran, and when this session's previous state was last seen.

No code, diffs, prompts, file paths, or remote credentials are sent. Outside a
git repository the hook sends nothing.

## Credentials and failures

The hook reports with `UNDERSTUDY_API_KEY`, else Claude Code's
`ANTHROPIC_API_KEY` when it is an Understudy key (never a provider key), else
the key stored by `understudy setup`. The organization comes from
`UNDERSTUDY_ORG_ID` or the stored credentials. The API origin is `--api-url`
at install, else `UNDERSTUDY_API_URL`, else production; plain http is accepted
only for localhost.

The hook prints nothing and always exits 0, because Claude Code adds a hook's
output to the model's context. A failed report leaves the local state
unchanged, so the next hook sends it again. Local state is one owner-only
file per session in `~/.understudy/work-hooks/`, holding its last reported
state; files untouched for 14 days are removed.
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { addModelsCommand } from "./commands/models.js";
import { addBillingCommands } from "./commands/billing.js";
import { addCapturesCommands } from "./commands/captures.js";
import { addContextCommands } from "./commands/context.js";
import { addHooksCommands } from "./commands/hooks.js";
import { addKeysCommands } from "./commands/keys.js";
import { addMigrationCommands } from "./commands/migrations.js";
import { addProjectsCommands } from "./commands/projects.js";
Expand Down Expand Up @@ -45,6 +46,7 @@ export function createCli(
addBillingCommands(program);
addCapturesCommands(program);
addContextCommands(program);
addHooksCommands(program);
addKeysCommands(program);
addModelsCommand(program);
addMigrationCommands(program);
Expand Down
176 changes: 176 additions & 0 deletions src/commands/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { lstat, mkdir, readdir, rmdir, unlink } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";
import { homedir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { Command } from "commander";

import { createCredentialStore } from "../auth/credentials.js";
import { CliError } from "../errors.js";
import {
reportGitState,
sessionStateSchema,
resolveApiUrl,
resolveHookCredential,
type GitStateDependencies,
} from "../hooks/git-state.js";
import {
cliEntry,
gitStateCommand,
readSettings,
resolveClaudeSettingsPath,
withGitStateHooks,
withoutGitStateHooks,
writeSettings,
} from "../hooks/settings.js";
import { createInferenceCredentialStore } from "../inference/credentials.js";
import { formatOutput } from "../output.js";
import { createPrivateJsonStore, ensurePrivateDirectory } from "../storage/private-json.js";

const execute = promisify(execFile);
const hookInputMaxBytes = 256 * 1024;

export function addHooksCommands(
program: Command,
gitStateDependencies: () => GitStateDependencies = createGitStateDependencies,
): void {
const hooks = program.command("hooks")
.description("Install Claude Code hooks that tie its spend to the pull requests it produces.");

hooks.command("install")
.description("Add the hooks to Claude Code settings, replacing an earlier install.")
.option("--settings <file>", "Claude Code settings file; defaults to $CLAUDE_CONFIG_DIR or ~/.claude settings.json.")
.option("--api-url <origin>", "Understudy API origin, for local development.")
.addHelpText("after", `
On each prompt and after each shell command, Claude Code runs this CLI, which
reports the session's repository, branch, and commit to Understudy when they
change. It sends no code, prompts, paths, or remote credentials. Understudy
uses it to show what each pull request cost. The key is UNDERSTUDY_API_KEY,
an Understudy ANTHROPIC_API_KEY, or the one stored by understudy setup.
`)
.action(async function (this: Command, options: { settings?: string; apiUrl?: string }) {
if (options.apiUrl !== undefined && resolveApiUrl(options.apiUrl, {}) === null) {
throw new CliError("--api-url must be an https origin, or http on localhost.");
}
const file = resolveClaudeSettingsPath(options.settings);
const command = gitStateCommand(cliEntry(), options.apiUrl);
await writeSettings(file, withGitStateHooks(await readSettings(file), command));
process.stdout.write(`${formatOutput(this, { installed: true, settings: file, command }, [
`Installed the work hooks in ${file}.`,
"New Claude Code sessions report their git state; restart running ones.",
])}\n`);
});

hooks.command("uninstall")
.description("Remove only these hooks from Claude Code settings.")
.option("--settings <file>", "Claude Code settings file; defaults to $CLAUDE_CONFIG_DIR or ~/.claude settings.json.")
.action(async function (this: Command, options: { settings?: string }) {
const file = resolveClaudeSettingsPath(options.settings);
await writeSettings(file, withoutGitStateHooks(await readSettings(file)));
process.stdout.write(`${formatOutput(this, { installed: false, settings: file }, [
`Removed the work hooks from ${file}.`,
])}\n`);
});

// Claude Code runs this; nothing reaches stdout or stderr and it always
// exits 0, since a hook's output joins the model's context.
hooks.command("git-state", { hidden: true })
.option("--harness <harness>", "The coding tool running the hook.", "claude_code")
.option("--api-url <origin>")
.action(async (options: { harness: string; apiUrl?: string }) => {
const apiUrl = resolveApiUrl(options.apiUrl, process.env);
if (options.harness !== "claude_code" || apiUrl === null) return;
await reportGitState({ harness: "claude_code", apiUrl }, gitStateDependencies());
});
}

export function createGitStateDependencies(): GitStateDependencies {
const directory = path.join(homedir(), ".understudy", "work-hooks");
const inferenceStore = createInferenceCredentialStore();
const authStore = createCredentialStore();
return {
now: () => Date.now(),
readInput: () => readStdin(hookInputMaxBytes, 1_000),
git: async (cwd, args) => {
// Without inherited GIT_* overrides, like the migration commands.
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")));
try {
const { stdout } = await execute("git", ["-C", cwd, ...args], { env, timeout: 1_500, maxBuffer: 64 * 1024 });
return stdout.trim();
} catch {
return null;
}
},
sessionState: (sessionId) =>
createPrivateJsonStore({
directory,
fileName: `${sessionFile(sessionId)}.json`,
label: "work hook state",
schema: sessionStateSchema,
}),
// mkdir is atomic: whoever creates the directory holds the lock. A lock
// older than a hook's timeout belongs to a process that died.
lockSession: async (sessionId, operation) => {
await ensurePrivateDirectory(directory);
const lock = path.join(directory, `${sessionFile(sessionId)}.lock`);
for (let attempt = 0; attempt < 40; attempt += 1) {
try {
await mkdir(lock, { mode: 0o700 });
} catch {
const entry = await lstat(lock).catch(() => null);
if (entry && Date.now() - entry.mtimeMs > 10_000) await rmdir(lock).catch(() => undefined);
await sleep(25);
continue;
}
try {
return await operation();
} finally {
await rmdir(lock).catch(() => undefined);
}
}
return undefined;
},
pruneSessionStates: async (olderThanMs) => {
for (const name of await readdir(directory).catch(() => [])) {
if (!name.endsWith(".json")) continue;
const file = path.join(directory, name);
const entry = await lstat(file).catch(() => null);
if (entry?.isFile() && entry.mtimeMs < olderThanMs) await unlink(file).catch(() => undefined);
}
},
credential: async () => {
const inference = await inferenceStore.read().catch(() => null);
const auth = await authStore.read().catch(() => null);
return resolveHookCredential(process.env, [
inference ?? {},
auth?.method === "organization_key" ? auth : {},
]);
},
};
}

function sessionFile(sessionId: string): string {
return createHash("sha256").update(sessionId).digest("hex").slice(0, 32);
}

function readStdin(maxBytes: number, timeoutMs: number): Promise<string> {
return new Promise((resolve) => {
const chunks: Buffer[] = [];
let size = 0;
const finish = () => {
clearTimeout(timer);
process.stdin.pause();
resolve(size > maxBytes ? "" : Buffer.concat(chunks).toString("utf8"));
};
const timer = setTimeout(finish, timeoutMs);
process.stdin.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size <= maxBytes) chunks.push(chunk);
else finish();
});
process.stdin.on("end", finish);
process.stdin.on("error", finish);
});
}
Loading
Loading