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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ Gemini-specific event handling:
- **`pre-push`**: Auto-pushes `refs/notes/agentnote` to remote. Uses `AGENTNOTE_PUSHING` recursion guard.

Existing hooks are backed up and chained. Compatible with husky/lefthook.
The repo-local CLI shim resolves the repository's current install at runtime (`node_modules/.bin` → PATH → init-time paths as last resort), and hook scripts treat a non-zero shim exit as "CLI could not start" — they fall through to the next resolver and emit a one-line stderr warning when nothing can run the CLI, while always exiting zero so commits and pushes are never blocked.
Git worktrees are supported by keeping session buffers in each worktree's own git dir while sharing the repo-local CLI shim from the common git dir. This must work for bare and non-bare repositories, arbitrary worktree directory layouts, and agent-managed worktree commits after init from either the main checkout or a linked worktree. Claude Agent View is one example, but the worktree behavior must stay agent-agnostic for Codex, Cursor, Gemini, and future adapters.

### Core modules
Expand Down
12 changes: 12 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,18 @@ common git-dir shim shared by all worktrees. This lets `agent-note init` run
from either the main checkout or a linked worktree while still supporting
commits made from any related worktree.

The shim resolves the repository's current CLI install at runtime — the
repository `node_modules/.bin/agent-note` first, then a PATH-installed CLI,
and finally the node binary and CLI path captured at init time. The captured
paths remain as the last resort for restricted hook environments such as GUI
git clients that run hooks without the user's PATH. Because the CLI exits
zero even when its work is a no-op or fails internally, a non-zero exit from
a shim means the CLI could not start at all (for example, the checkout that
ran `init` was deleted). Hook scripts treat that as "try the next resolver"
instead of stopping, and when no resolver can start the CLI they emit a
one-line stderr warning while still exiting zero, so commits and pushes are
never blocked and the degradation is visible instead of silent.

When an existing hook file is found, agent-note chains to it — the original hook runs first, then agent-note's logic runs. This avoids overwriting user or tool-managed hooks.

## CLI commands
Expand Down
56 changes: 38 additions & 18 deletions packages/cli/dist/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -5449,24 +5449,30 @@ fi
record_agentnote() {
RECORD_SESSION_ID="$1"
if [ -z "$RECORD_SESSION_ID" ]; then return; fi
# Prefer the repo-local shim created at init time so post-commit uses the
# exact CLI version that generated these hooks.
if [ -x "$GIT_DIR/agentnote/bin/agent-note" ]; then
"$GIT_DIR/agentnote/bin/agent-note" record "$RECORD_SESSION_ID" 2>/dev/null || true
# Prefer the repo-local shim so worktrees share one CLI resolution. The CLI
# exits zero even when recording is a no-op, so a non-zero exit means the
# CLI could not start (stale shim paths); fall through to the next resolver
# instead of consuming the attempt.
if [ -x "$GIT_DIR/agentnote/bin/agent-note" ] && "$GIT_DIR/agentnote/bin/agent-note" record "$RECORD_SESSION_ID" 2>/dev/null; then
return
fi
# Git worktrees use their own $GIT_DIR but share hooks through the common git
# dir. Fall back to the common shim when init ran from another worktree.
if [ -n "$COMMON_GIT_DIR" ] && [ "$COMMON_GIT_DIR" != "$GIT_DIR" ] && [ -x "$COMMON_GIT_DIR/agentnote/bin/agent-note" ]; then
"$COMMON_GIT_DIR/agentnote/bin/agent-note" record "$RECORD_SESSION_ID" 2>/dev/null || true
if [ -n "$COMMON_GIT_DIR" ] && [ "$COMMON_GIT_DIR" != "$GIT_DIR" ] && [ -x "$COMMON_GIT_DIR/agentnote/bin/agent-note" ] && "$COMMON_GIT_DIR/agentnote/bin/agent-note" record "$RECORD_SESSION_ID" 2>/dev/null; then
return
fi
# Fall back to stable local/global binaries only.
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
if [ -f "$REPO_ROOT/node_modules/.bin/agent-note" ]; then
"$REPO_ROOT/node_modules/.bin/agent-note" record "$RECORD_SESSION_ID" 2>/dev/null || true
elif command -v agent-note >/dev/null 2>&1; then
agent-note record "$RECORD_SESSION_ID" 2>/dev/null || true
if [ -f "$REPO_ROOT/node_modules/.bin/agent-note" ] && "$REPO_ROOT/node_modules/.bin/agent-note" record "$RECORD_SESSION_ID" 2>/dev/null; then
return
fi
if command -v agent-note >/dev/null 2>&1 && agent-note record "$RECORD_SESSION_ID" 2>/dev/null; then
return
fi
# Reaching here means session evidence exists but no resolver could start the CLI.
if [ -z "$AGENTNOTE_RECORD_WARNED" ]; then
AGENTNOTE_RECORD_WARNED=1
echo "agent-note: could not record the commit note; run 'npx agent-note init' to repair the hooks" >&2
fi
}

Expand All @@ -5480,23 +5486,30 @@ ${AGENTNOTE_HOOK_MARKER}
# Push agentnote notes alongside code via the repo-local shim so hook behavior
# tracks the current CLI implementation after upgrades. Wait for completion so
# PR workflows can fetch the latest notes ref, but never block the main push on failure.
# The CLI exits zero even when the notes push itself fails, so a non-zero exit
# means the CLI could not start (stale shim paths); fall through to the next
# resolver instead of consuming the attempt.
if [ -n "$AGENTNOTE_PUSHING" ]; then exit 0; fi
GIT_DIR="$(git rev-parse --git-dir 2>/dev/null)"
COMMON_GIT_DIR="$(git rev-parse --git-common-dir 2>/dev/null)"
if [ -x "$GIT_DIR/agentnote/bin/agent-note" ]; then
"$GIT_DIR/agentnote/bin/agent-note" push-notes "$1" 2>/dev/null || true
if [ -x "$GIT_DIR/agentnote/bin/agent-note" ] && "$GIT_DIR/agentnote/bin/agent-note" push-notes "$1" 2>/dev/null; then
exit 0
fi
if [ -n "$COMMON_GIT_DIR" ] && [ "$COMMON_GIT_DIR" != "$GIT_DIR" ] && [ -x "$COMMON_GIT_DIR/agentnote/bin/agent-note" ]; then
"$COMMON_GIT_DIR/agentnote/bin/agent-note" push-notes "$1" 2>/dev/null || true
if [ -n "$COMMON_GIT_DIR" ] && [ "$COMMON_GIT_DIR" != "$GIT_DIR" ] && [ -x "$COMMON_GIT_DIR/agentnote/bin/agent-note" ] && "$COMMON_GIT_DIR/agentnote/bin/agent-note" push-notes "$1" 2>/dev/null; then
exit 0
fi
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
if [ -f "$REPO_ROOT/node_modules/.bin/agent-note" ]; then
"$REPO_ROOT/node_modules/.bin/agent-note" push-notes "$1" 2>/dev/null || true
elif command -v agent-note >/dev/null 2>&1; then
agent-note push-notes "$1" 2>/dev/null || true
if [ -f "$REPO_ROOT/node_modules/.bin/agent-note" ] && "$REPO_ROOT/node_modules/.bin/agent-note" push-notes "$1" 2>/dev/null; then
exit 0
fi
if command -v agent-note >/dev/null 2>&1 && agent-note push-notes "$1" 2>/dev/null; then
exit 0
fi
# Warn only when local notes exist, so checkouts that never use Agent Note stay quiet.
if git rev-parse --verify --quiet refs/notes/${NOTES_REF} >/dev/null 2>&1; then
echo "agent-note: could not push git notes; run 'npx agent-note init' to repair the hooks" >&2
fi
exit 0
`;
async function init(args2) {
let agents = [];
Expand Down Expand Up @@ -5685,6 +5698,13 @@ async function installLocalCliShim(agentnoteDirPath) {
const shimPath = join10(shimDir, "agent-note");
const cliPath = resolve6(process.argv[1]);
const shim = `#!/bin/sh
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
if [ -n "$REPO_ROOT" ] && [ -f "$REPO_ROOT/node_modules/.bin/agent-note" ] && command -v node >/dev/null 2>&1; then
"$REPO_ROOT/node_modules/.bin/agent-note" "$@" && exit 0
fi
if command -v agent-note >/dev/null 2>&1; then
agent-note "$@" && exit 0
fi
exec ${shellSingleQuote(process.execPath)} ${shellSingleQuote(cliPath)} "$@"
`;
await mkdir6(shimDir, { recursive: true });
Expand Down
232 changes: 230 additions & 2 deletions packages/cli/src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,8 +412,22 @@ describe("agentnote init", () => {

const shim = readFileSync(shimPath, "utf-8");
assert.ok(shim.startsWith("#!/bin/sh"), "shim should be executable shell script");
assert.ok(shim.includes(process.execPath), "shim should pin the current node binary");
assert.ok(shim.includes("dist/cli.js"), "shim should pin the current CLI path");
assert.ok(
shim.includes('"$REPO_ROOT/node_modules/.bin/agent-note"'),
"shim should prefer the repository's current CLI install",
);
assert.ok(
shim.includes("command -v agent-note"),
"shim should fall back to a PATH-installed CLI",
);
assert.ok(
shim.includes(process.execPath),
"shim should keep the init-time node binary as the last resort",
);
assert.ok(
shim.includes("dist/cli.js"),
"shim should keep the init-time CLI path as the last resort",
);

const postCommitHook = readFileSync(join(testDir, ".git", "hooks", "post-commit"), "utf-8");
assert.ok(
Expand All @@ -438,6 +452,220 @@ describe("agentnote init", () => {
);
});

it("records commits through the fallback chain when the shim is broken", () => {
const dir = mkdtempSync(join(tmpdir(), "agentnote-broken-shim-record-"));
try {
execSync("git init", { cwd: dir });
execSync("git config user.email test@test.com", { cwd: dir });
execSync("git config user.name Test", { cwd: dir });
execSync(`node ${cliPath} init --agent claude --no-action`, { cwd: dir });

// Reproduce the incident: the shim file survives but its baked init-time
// paths are gone, so it is executable yet always fails.
const shimPath = join(dir, ".git", AGENTNOTE_DIR, "bin", "agent-note");
writeFileSync(shimPath, "#!/bin/sh\nexit 127\n", { mode: 0o755 });

const localBinDir = join(dir, "node_modules", ".bin");
mkdirSync(localBinDir, { recursive: true });
writeFileSync(
join(localBinDir, "agent-note"),
`#!/bin/sh\nexec ${shellSingleQuote(process.execPath)} ${shellSingleQuote(cliPath)} "$@"\n`,
{ mode: 0o755 },
);

const sessionId = "aaaaaaaa-bbbb-4ccc-8ddd-ffffffffffff";
const runClaudeHook = (payload: Record<string, unknown>) => {
execFileSync(process.execPath, [cliPath, "hook", "--agent", "claude"], {
cwd: dir,
input: JSON.stringify(payload),
encoding: "utf-8",
env: withoutCodexThreadEnv(),
});
};

runClaudeHook({ hook_event_name: "SessionStart", session_id: sessionId });
runClaudeHook({
hook_event_name: "UserPromptSubmit",
session_id: sessionId,
prompt: "Survive a broken shim.",
});
const filePath = join(dir, "broken-shim.txt");
runClaudeHook({
hook_event_name: "PreToolUse",
session_id: sessionId,
tool_name: "Write",
tool_use_id: "tool-broken-shim",
tool_input: { file_path: filePath },
});
writeFileSync(filePath, "self-healing hooks\n");
runClaudeHook({
hook_event_name: "PostToolUse",
session_id: sessionId,
tool_name: "Write",
tool_use_id: "tool-broken-shim",
tool_input: { file_path: filePath },
});

execSync("git add broken-shim.txt", { cwd: dir });
execSync("git commit -m 'feat: broken shim fallback'", {
cwd: dir,
encoding: "utf-8",
env: withoutCodexThreadEnv(),
});

const note = JSON.parse(
execSync("git notes --ref=agentnote show HEAD", { cwd: dir, encoding: "utf-8" }),
);
assert.equal(note.session_id, sessionId);
assert.deepEqual(note.interactions[0].files_touched, ["broken-shim.txt"]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("pushes notes through the fallback chain when the shim is broken", () => {
const dir = mkdtempSync(join(tmpdir(), "agentnote-broken-shim-push-"));
const remoteDir = mkdtempSync(join(tmpdir(), "agentnote-broken-shim-remote-"));
try {
execSync("git init --bare", { cwd: remoteDir });
execSync("git init", { cwd: dir });
execSync("git config user.email test@test.com", { cwd: dir });
execSync("git config user.name Test", { cwd: dir });
execSync(`git remote add origin ${remoteDir}`, { cwd: dir });
execSync("git commit --allow-empty -m 'init'", { cwd: dir });
execSync(`node ${cliPath} init --agent claude --no-action`, { cwd: dir });

const shimPath = join(dir, ".git", AGENTNOTE_DIR, "bin", "agent-note");
writeFileSync(shimPath, "#!/bin/sh\nexit 127\n", { mode: 0o755 });

const localBinDir = join(dir, "node_modules", ".bin");
mkdirSync(localBinDir, { recursive: true });
writeFileSync(
join(localBinDir, "agent-note"),
`#!/bin/sh\nexec ${shellSingleQuote(process.execPath)} ${shellSingleQuote(cliPath)} "$@"\n`,
{ mode: 0o755 },
);

execSync("git notes --ref=agentnote add -m '{\"v\":1}' HEAD", { cwd: dir });
execSync("git push -u origin HEAD", { cwd: dir, encoding: "utf-8" });

const remoteNotesRef = execSync("git rev-parse --verify refs/notes/agentnote", {
cwd: remoteDir,
encoding: "utf-8",
}).trim();
assert.ok(remoteNotesRef.length > 0, "notes should push through the fallback chain");
} finally {
rmSync(dir, { recursive: true, force: true });
rmSync(remoteDir, { recursive: true, force: true });
}
});

it("warns on push when no resolver can run the CLI and notes exist", () => {
const dir = mkdtempSync(join(tmpdir(), "agentnote-broken-shim-warn-push-"));
const remoteDir = mkdtempSync(join(tmpdir(), "agentnote-warn-push-remote-"));
try {
execSync("git init --bare", { cwd: remoteDir });
execSync("git init", { cwd: dir });
execSync("git config user.email test@test.com", { cwd: dir });
execSync("git config user.name Test", { cwd: dir });
execSync(`git remote add origin ${remoteDir}`, { cwd: dir });
execSync("git commit --allow-empty -m 'init'", { cwd: dir });
execSync(`node ${cliPath} init --agent claude --no-action`, { cwd: dir });

const shimPath = join(dir, ".git", AGENTNOTE_DIR, "bin", "agent-note");
writeFileSync(shimPath, "#!/bin/sh\nexit 127\n", { mode: 0o755 });
execSync("git notes --ref=agentnote add -m '{\"v\":1}' HEAD", { cwd: dir });

// A PATH without node_modules/.bin or a global CLI leaves no working resolver.
const stderrFile = join(dir, "push-stderr.txt");
execSync(`git push -u origin HEAD 2>${shellSingleQuote(stderrFile)}`, {
cwd: dir,
encoding: "utf-8",
env: { ...withoutCodexThreadEnv(), PATH: "/usr/bin:/bin" },
});

const stderr = readFileSync(stderrFile, "utf-8");
assert.match(stderr, /agent-note: could not push git notes/);
const remoteHead = execSync("git rev-parse --verify HEAD", {
cwd: remoteDir,
encoding: "utf-8",
}).trim();
assert.ok(remoteHead.length > 0, "the main push must still succeed");
assert.throws(() => {
execSync("git rev-parse --verify refs/notes/agentnote", {
cwd: remoteDir,
stdio: "pipe",
});
}, "the warning must reflect notes that really were not pushed");
} finally {
rmSync(dir, { recursive: true, force: true });
rmSync(remoteDir, { recursive: true, force: true });
}
});

it("warns on commit when no resolver can run the CLI for a tracked session", () => {
const dir = mkdtempSync(join(tmpdir(), "agentnote-broken-shim-warn-commit-"));
try {
execSync("git init", { cwd: dir });
execSync("git config user.email test@test.com", { cwd: dir });
execSync("git config user.name Test", { cwd: dir });
execSync(`node ${cliPath} init --agent claude --no-action`, { cwd: dir });

const shimPath = join(dir, ".git", AGENTNOTE_DIR, "bin", "agent-note");
writeFileSync(shimPath, "#!/bin/sh\nexit 127\n", { mode: 0o755 });

const sessionId = "aaaaaaaa-bbbb-4ccc-8ddd-abcdefabcdef";
const runClaudeHook = (payload: Record<string, unknown>) => {
execFileSync(process.execPath, [cliPath, "hook", "--agent", "claude"], {
cwd: dir,
input: JSON.stringify(payload),
encoding: "utf-8",
env: withoutCodexThreadEnv(),
});
};
runClaudeHook({ hook_event_name: "SessionStart", session_id: sessionId });
runClaudeHook({
hook_event_name: "UserPromptSubmit",
session_id: sessionId,
prompt: "Warn when the CLI is unreachable.",
});
const filePath = join(dir, "warn-commit.txt");
runClaudeHook({
hook_event_name: "PreToolUse",
session_id: sessionId,
tool_name: "Write",
tool_use_id: "tool-warn-commit",
tool_input: { file_path: filePath },
});
writeFileSync(filePath, "visible degradation\n");
runClaudeHook({
hook_event_name: "PostToolUse",
session_id: sessionId,
tool_name: "Write",
tool_use_id: "tool-warn-commit",
tool_input: { file_path: filePath },
});

execSync("git add warn-commit.txt", { cwd: dir });
const stderrFile = join(dir, "commit-stderr.txt");
execSync(`git commit -m 'feat: warn commit' 2>${shellSingleQuote(stderrFile)}`, {
cwd: dir,
encoding: "utf-8",
env: { ...withoutCodexThreadEnv(), PATH: "/usr/bin:/bin" },
});

const stderr = readFileSync(stderrFile, "utf-8");
assert.match(stderr, /agent-note: could not record the commit note/);
const warningCount = stderr.split("could not record the commit note").length - 1;
assert.equal(warningCount, 1, "the warning should be emitted once per commit");
assert.throws(() => {
execSync("git notes --ref=agentnote show HEAD", { cwd: dir, stdio: "pipe" });
}, "no note should exist because no resolver could run the CLI");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("records plain commits made from a git worktree using the common shim", () => {
const dir = mkdtempSync(join(tmpdir(), "agentnote-worktree-"));
try {
Expand Down
Loading
Loading