Skip to content

Add Claude Code work hooks that report git state changes - #2

Open
aamir wants to merge 3 commits into
mainfrom
aamir/hooks-git-state
Open

aamir wants to merge 3 commits into
mainfrom
aamir/hooks-git-state

Conversation

@aamir

@aamir aamir commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds understudy hooks, so Understudy can show what each pull request cost. Claude Code sends its session id on every request but not its git state. These hooks report that state when it changes.

  • understudy hooks install [--settings <file>] [--api-url <origin>] merges two command hooks into Claude Code's settings ($CLAUDE_CONFIG_DIR or ~/.claude):
    • UserPromptSubmit;
    • PostToolUse on Bash, since shell commands are where git state changes.
    • The write is atomic, keeps every other key and hook, and uses absolute paths. Reinstalling replaces the hooks.
  • understudy hooks uninstall removes only these hooks.
  • The hidden understudy hooks git-state runs as the hook:
    1. Reads session_id and cwd from the hook input.
    2. Runs git for the branch, HEAD, its committer time, and the normalized origin.
    3. When anything changed since this session's last report, posts it to POST /admin/v1/orgs/{org}/team/work-events.
    4. Prints nothing and always exits 0. A failed report is retried on the next hook.
  • What leaves the machine:
    • Sent: session id, host/owner/name, branch, HEAD, commit time, and when the hook ran.
    • Never sent: code, diffs, prompts, paths, or remote credentials.
  • Key: UNDERSTUDY_API_KEY, else Claude Code's ANTHROPIC_API_KEY when it is an Understudy key (never a provider key), else the stored credential.
  • Origin: --api-url or UNDERSTUDY_API_URL, with http allowed only on localhost; production otherwise.
  • docs/hooks-commands.md documents it. AGENTS.md puts work hooks in scope with that boundary.

Evidence

  • npm run check: 697 tests pass, and the public boundary check passes.
  • New tests/hooks.test.mjs covers:
    • the settings merge: keeps other hooks, idempotent, uninstall, invalid JSON left untouched, file mode kept;
    • the runner: first report, unchanged, change with prev_seen_at_ms, detached HEAD, failures kept for retry, provider keys refused, origin rules;
    • a real git repo and HTTP server, with the hook printing nothing.
  • Against a local Understudy stack, Claude Code 2.1.308 with these hooks reported each branch switch and commit in a three-prompt session. Each request was attributed to the pull request whose commit it led to.

Not run: a release build (npm run build:binary).

Merge Danger

Door: two-way. Nothing runs until someone installs the hooks.

Blast Radius: Low. The hook never blocks or fails Claude Code. The service side is off unless the organization has work attribution enabled; without it the hook gets a 404 and records nothing.

🤖 Generated with Claude Code

understudy hooks install adds UserPromptSubmit and PostToolUse (Bash) command
hooks to Claude Code's settings. The hidden hooks git-state command they run
reports a session's repository, branch and HEAD to Understudy only when they
change, so Understudy can show what each pull request cost. It sends no code,
prompts, paths or remote credentials, prints nothing, and always exits 0.
understudy hooks uninstall removes only these hooks.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-04T14:27:33.255718Z 3373343 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 614b8503ed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/hooks/settings.ts Outdated

/** How this process was started: the compiled binary, or node with the bundled script. */
export function cliEntry(execPath = process.execPath, script = process.argv[1]): string[] {
return script && /\.(?:c|m)?js$/.test(script) ? [execPath, path.resolve(script)] : [execPath];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve extensionless Node bin script paths

When this package is installed through its declared bin, package managers expose an extensionless symlink such as /usr/local/bin/understudy, and Node retains that symlink in process.argv[1]. This suffix check therefore returns only process.execPath, installing a command like node hooks git-state ...; Node then tries to load a script named hooks, so every installed hook exits before reporting. I reproduced this with an extensionless bin symlink under Node 24.15; retain or resolve the script path when the runtime is Node rather than inferring its presence from the filename extension.

AGENTS.md reference: AGENTS.md:L71-L74

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3373343: the script is kept whenever it exists on disk, so an extensionless bin link stays in the command. Bun's compiled binary reports a script path that exists only inside it, so it gets the binary alone. New test.

Comment thread src/hooks/git-state.ts Outdated
z.object({ recorded: z.literal(true) }),
);
// Only a recorded change is remembered, so a failed one is sent again.
await dependencies.state.write(pruned(stored, input.sessionId, current, observedAt));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize hook-state updates across async processes

When two sessions fire hooks concurrently, both processes can read the same snapshot before either request completes and then this unconditional write lets the last finisher erase the other session—or even regress one session to an older state. The installed handlers are explicitly asynchronous, and Claude's hook reference confirms that each async execution creates a separate background process, so overlapping read/request/write cycles are expected; subsequent hooks then resend unchanged states with missing or incorrect prev_seen_at_ms. Serialize the transaction or merge against the latest state before replacing the shared file.

AGENTS.md reference: AGENTS.md:L71-L74

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3373343: each session has its own owner-only state file under ~/.understudy/work-hooks/, so concurrent sessions can't erase each other. A write never replaces a newer look. New test.

Installed through a package manager's bin link, Node reports the script
without an extension, which dropped it from the hook command. Keep the
script whenever it exists on disk; Bun's compiled binary reports a path
that exists only inside it.

Async hooks of different sessions shared one state file and could erase
each other's entries. Each session now has its own owner-only file, an
older look never replaces a newer one, and files untouched for 14 days are
removed when a new session starts.
@aamir

aamir commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3373343bd3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/hooks/git-state.ts Outdated
Comment on lines +64 to +65
const latest = await store.read();
if (!latest || latest.lastSeenMs <= observedAt) await store.write(current);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the state freshness check atomic

When two async hooks for the same session reach remember concurrently, both can read the same latest value and pass the check, after which the older observation can finish its write last and regress the file. Claude's hook documentation confirms that every async execution uses a separate background process, and the private store's atomic rename does not make this read/check/write sequence atomic; later hooks can consequently resend stale state with an incorrect prev_seen_at_ms. Fresh evidence after the stated fix is this remaining TOCTOU window, so serialize the transaction per session or use a real compare-and-swap.

AGENTS.md reference: AGENTS.md:L71-L74

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9176123: each session's read-check-write runs under a per-session lock (an atomic mkdir, with stale-lock recovery after 10s). A hook that can't get the lock skips remembering and the next hook sends again; a duplicate report is harmless on the service side.

Comment thread src/hooks/settings.ts Outdated
hooks: {
...hooks,
UserPromptSubmit: [...(hooks?.UserPromptSubmit ?? []), { hooks: [handler] }],
PostToolUse: [...(hooks?.PostToolUse ?? []), { matcher: "Bash", hooks: [handler] }],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report state after failed shell calls

When a Bash invocation changes HEAD but exits nonzero, such as git commit ...; false, Claude fires PostToolUseFailure rather than PostToolUse; the official hook lifecycle distinguishes the success and failure events. Because only the success handler is installed here, the state is not reported before Claude's next model request and can be lost entirely if the session then ends, so install the same handler for failed Bash calls.

AGENTS.md reference: AGENTS.md:L71-L74

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9176123: the same handler is installed for PostToolUseFailure.

Comment thread src/hooks/settings.ts Outdated
hooks: {
...hooks,
UserPromptSubmit: [...(hooks?.UserPromptSubmit ?? []), { hooks: [handler] }],
PostToolUse: [...(hooks?.PostToolUse ?? []), { matcher: "Bash", hooks: [handler] }],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match PowerShell shell calls too

On native Windows sessions where Claude executes the PowerShell tool, this exact Bash matcher never runs, so commits and branch changes made by those shell calls are omitted despite the hook input parser explicitly accepting Windows paths. Claude's hook documentation treats Bash and PowerShell as separate tool names and uses Bash|PowerShell to cover both; include both tools to uphold the documented after-each-shell-command behavior.

AGENTS.md reference: AGENTS.md:L71-L74

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9176123: both shell hooks match Bash|PowerShell.

…commands

Two async hooks of one session could both pass the freshness check and
the older write land last. Each session's read-check-write now runs under
an mkdir lock; a hook that can't get it skips remembering, and the next
hook sends again. The shell hook also runs on PostToolUseFailure, since a
command can commit and then fail, and matches PowerShell for Windows
sessions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant