From c191b043dfa68a309d280d97df399b41576cd4bd Mon Sep 17 00:00:00 2001 From: wasabeef Date: Mon, 13 Jul 2026 13:23:19 +0900 Subject: [PATCH 1/2] fix(init): self-heal git hooks when the init-time CLI shim breaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why The repo-local shim baked the node binary and CLI entry paths captured at init time. When the checkout that ran init was deleted or the node version changed, the shim kept existing and stayed executable, so the hook fallback chain short-circuited on it, swallowed the failure, and notes silently stopped being recorded and pushed while git kept succeeding. The shim now resolves the repository's current CLI install at runtime (node_modules, then PATH) and keeps the init-time paths only as the last resort for restricted hook environments such as GUI git clients. Hook scripts gate each resolver on success — the CLI exits zero even for internal no-ops and failures, so a non-zero exit precisely means the CLI could not start — and fall through instead of stopping. When no resolver can run the CLI, the hooks emit a one-line stderr warning while still exiting zero, and the PR report action emits a notice when a PR has commits but no tracked data. User impact Deleting the worktree that ran init, upgrading node, or pruning the npx cache no longer silently disables Agent Note in checkouts that have a local or global CLI install, and the remaining failure modes are visible instead of silent. Commits and pushes are never blocked. Existing repositories pick up the new hooks and shim by re-running agent-note init. Verification npm -w packages/cli run build npm -w packages/cli run typecheck npm -w packages/cli run lint npm -w packages/cli test (495 passed, 4 new hook fallback tests) npm -w packages/pr-report run build npm -w packages/pr-report test (51 passed) Release note: Git hooks now recover automatically when the repo-local CLI shim breaks, and failures emit a visible warning instead of silently skipping Agent Note recording. Agentnote-Session: 4a6d322d-5afe-45e3-a11b-cf15d0e740a9 --- CLAUDE.md | 1 + docs/architecture.md | 12 ++ packages/cli/dist/cli.js | 56 ++++-- packages/cli/src/commands/init.test.ts | 226 ++++++++++++++++++++++++- packages/cli/src/commands/init.ts | 61 +++++-- packages/pr-report/dist/index.js | 13 +- packages/pr-report/src/index.ts | 8 + 7 files changed, 335 insertions(+), 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b4363a0..eced146 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 132062b..818aaf2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/packages/cli/dist/cli.js b/packages/cli/dist/cli.js index a857a47..407a000 100755 --- a/packages/cli/dist/cli.js +++ b/packages/cli/dist/cli.js @@ -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 } @@ -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 = []; @@ -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 + exec "$REPO_ROOT/node_modules/.bin/agent-note" "$@" +fi +if command -v agent-note >/dev/null 2>&1; then + exec agent-note "$@" +fi exec ${shellSingleQuote(process.execPath)} ${shellSingleQuote(cliPath)} "$@" `; await mkdir6(shimDir, { recursive: true }); diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index e227314..3fc34bf 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -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( @@ -438,6 +452,214 @@ 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) => { + 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"); + } 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) => { + 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 { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 3d90b3e..6884846 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -185,24 +185,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 } @@ -217,23 +223,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 `; /** Install Agent Note git hooks, agent hooks, and optional GitHub workflows. */ @@ -466,7 +479,19 @@ async function installLocalCliShim(agentnoteDirPath: string): Promise { const shimDir = join(agentnoteDirPath, "bin"); const shimPath = join(shimDir, "agent-note"); const cliPath = resolve(process.argv[1]); + // The repository's current install is resolved first so hook behavior tracks + // CLI upgrades and survives deletion of the checkout that ran init. The + // init-time absolute paths stay as the last resort for restricted hook + // environments (e.g. GUI git clients without the user's PATH) and for + // checkouts without a local node_modules install. 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 + exec "$REPO_ROOT/node_modules/.bin/agent-note" "$@" +fi +if command -v agent-note >/dev/null 2>&1; then + exec agent-note "$@" +fi exec ${shellSingleQuote(process.execPath)} ${shellSingleQuote(cliPath)} "$@" `; diff --git a/packages/pr-report/dist/index.js b/packages/pr-report/dist/index.js index f59f38e..317344b 100644 --- a/packages/pr-report/dist/index.js +++ b/packages/pr-report/dist/index.js @@ -29018,7 +29018,7 @@ function utils_toCommandValue(input) { * @returns The command properties to send with the actual annotation command * See IssueCommandProperties: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionCommandManager.cs#L646 */ -function utils_toCommandProperties(annotationProperties) { +function toCommandProperties(annotationProperties) { if (!Object.keys(annotationProperties).length) { return {}; } @@ -31829,7 +31829,7 @@ function core_debug(message) { * @param properties optional properties to add to the annotation. */ function error(message, properties = {}) { - command_issueCommand('error', utils_toCommandProperties(properties), message instanceof Error ? message.toString() : message); + command_issueCommand('error', toCommandProperties(properties), message instanceof Error ? message.toString() : message); } /** * Adds a warning issue @@ -31837,7 +31837,7 @@ function error(message, properties = {}) { * @param properties optional properties to add to the annotation. */ function warning(message, properties = {}) { - command_issueCommand('warning', utils_toCommandProperties(properties), message instanceof Error ? message.toString() : message); + command_issueCommand('warning', toCommandProperties(properties), message instanceof Error ? message.toString() : message); } /** * Adds a notice issue @@ -31845,7 +31845,7 @@ function warning(message, properties = {}) { * @param properties optional properties to add to the annotation. */ function notice(message, properties = {}) { - issueCommand('notice', toCommandProperties(properties), message instanceof Error ? message.toString() : message); + command_issueCommand('notice', toCommandProperties(properties), message instanceof Error ? message.toString() : message); } /** * Writes info to log with console.log. @@ -38166,6 +38166,11 @@ async function run() { info("No agent-note data found for this PR."); return; } + if (shouldRetryNotesFetch(report)) { + // Detection line for silently broken git hooks; notice-level because + // human-only PRs legitimately have no tracked data. + notice("Agent Note found no tracked commits in this PR. If AI-assisted commits are expected, the refs/notes/agentnote ref may not have been pushed; re-run 'npx agent-note init' to repair the git hooks."); + } report.dashboard_preview_help_url = await inferDashboardPreviewHelpUrl(token, report.dashboard_url); const json = JSON.stringify(report, null, JSON_INDENT_SPACES); setOutput(ACTION_OUTPUT_NAMES.overallAiRatio, String(report.overall_ai_ratio ?? 0)); diff --git a/packages/pr-report/src/index.ts b/packages/pr-report/src/index.ts index 6b287f9..92748f3 100644 --- a/packages/pr-report/src/index.ts +++ b/packages/pr-report/src/index.ts @@ -258,6 +258,14 @@ async function run(): Promise { return; } + if (shouldRetryNotesFetch(report)) { + // Detection line for silently broken git hooks; notice-level because + // human-only PRs legitimately have no tracked data. + core.notice( + "Agent Note found no tracked commits in this PR. If AI-assisted commits are expected, the refs/notes/agentnote ref may not have been pushed; re-run 'npx agent-note init' to repair the git hooks.", + ); + } + report.dashboard_preview_help_url = await inferDashboardPreviewHelpUrl( token, report.dashboard_url, From 9febe290d58260991a169413109809ea195bed0f Mon Sep 17 00:00:00 2001 From: wasabeef Date: Mon, 13 Jul 2026 13:49:41 +0900 Subject: [PATCH 2/2] fix(init): let the shim fall through to init-time paths on runtime failures Run the shim's node_modules and PATH resolvers without exec so a CLI launch that fails at runtime (for example an incompatible node found on a restricted PATH) still reaches the init-time absolute paths instead of consuming the attempt. Also assert in tests that the pre-push warning fires only when notes really were not pushed, and extract the PR report repair notice into buildMissingNotesNotice with unit tests. Addresses self-review and CodeRabbit feedback on PR #89. Release note: skip Agentnote-Session: 4a6d322d-5afe-45e3-a11b-cf15d0e740a9 --- packages/cli/dist/cli.js | 4 ++-- packages/cli/src/commands/init.test.ts | 6 ++++++ packages/cli/src/commands/init.ts | 7 +++++-- packages/pr-report/dist/index.js | 21 +++++++++++++++++---- packages/pr-report/src/github.ts | 19 +++++++++++++++++++ packages/pr-report/src/index.test.ts | 18 ++++++++++++++++++ packages/pr-report/src/index.ts | 10 ++++------ 7 files changed, 71 insertions(+), 14 deletions(-) diff --git a/packages/cli/dist/cli.js b/packages/cli/dist/cli.js index 407a000..c5272ad 100755 --- a/packages/cli/dist/cli.js +++ b/packages/cli/dist/cli.js @@ -5700,10 +5700,10 @@ async function installLocalCliShim(agentnoteDirPath) { 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 - exec "$REPO_ROOT/node_modules/.bin/agent-note" "$@" + "$REPO_ROOT/node_modules/.bin/agent-note" "$@" && exit 0 fi if command -v agent-note >/dev/null 2>&1; then - exec agent-note "$@" + agent-note "$@" && exit 0 fi exec ${shellSingleQuote(process.execPath)} ${shellSingleQuote(cliPath)} "$@" `; diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index 3fc34bf..cd82256 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -591,6 +591,12 @@ describe("agentnote init", () => { 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 }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 6884846..6205bd5 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -484,13 +484,16 @@ async function installLocalCliShim(agentnoteDirPath: string): Promise { // init-time absolute paths stay as the last resort for restricted hook // environments (e.g. GUI git clients without the user's PATH) and for // checkouts without a local node_modules install. + // Resolvers run without exec so a launch that later fails at runtime (for + // example an incompatible node found on a restricted PATH) still falls + // through to the init-time paths instead of consuming the attempt. 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 - exec "$REPO_ROOT/node_modules/.bin/agent-note" "$@" + "$REPO_ROOT/node_modules/.bin/agent-note" "$@" && exit 0 fi if command -v agent-note >/dev/null 2>&1; then - exec agent-note "$@" + agent-note "$@" && exit 0 fi exec ${shellSingleQuote(process.execPath)} ${shellSingleQuote(cliPath)} "$@" `; diff --git a/packages/pr-report/dist/index.js b/packages/pr-report/dist/index.js index 317344b..fe921a4 100644 --- a/packages/pr-report/dist/index.js +++ b/packages/pr-report/dist/index.js @@ -36376,6 +36376,20 @@ function appendPrNumber(dashboardUrl, prNumber) { url.searchParams.set(PR_QUERY_PARAM, String(normalized)); return url.toString(); } +/** + * Build the workflow notice for a PR whose commits have no Agent Note data. + * + * This is the detection line for silently broken git hooks. It stays at + * notice level and returns null for tracked or empty PRs because human-only + * pull requests legitimately have no tracked data. + */ +function buildMissingNotesNotice(report) { + if (!shouldRetryNotesFetch(report)) + return null; + return ("Agent Note found no tracked commits in this PR. If AI-assisted commits are expected, " + + "the refs/notes/agentnote ref may not have been pushed; re-run 'npx agent-note init' " + + "to repair the git hooks."); +} /** * Detect whether the GitHub Pages environment restricts deploy branches. * @@ -38166,10 +38180,9 @@ async function run() { info("No agent-note data found for this PR."); return; } - if (shouldRetryNotesFetch(report)) { - // Detection line for silently broken git hooks; notice-level because - // human-only PRs legitimately have no tracked data. - notice("Agent Note found no tracked commits in this PR. If AI-assisted commits are expected, the refs/notes/agentnote ref may not have been pushed; re-run 'npx agent-note init' to repair the git hooks."); + const missingNotesNotice = buildMissingNotesNotice(report); + if (missingNotesNotice) { + notice(missingNotesNotice); } report.dashboard_preview_help_url = await inferDashboardPreviewHelpUrl(token, report.dashboard_url); const json = JSON.stringify(report, null, JSON_INDENT_SPACES); diff --git a/packages/pr-report/src/github.ts b/packages/pr-report/src/github.ts index 2f14d40..7586b53 100644 --- a/packages/pr-report/src/github.ts +++ b/packages/pr-report/src/github.ts @@ -147,6 +147,25 @@ function appendPrNumber( return url.toString(); } +/** + * Build the workflow notice for a PR whose commits have no Agent Note data. + * + * This is the detection line for silently broken git hooks. It stays at + * notice level and returns null for tracked or empty PRs because human-only + * pull requests legitimately have no tracked data. + */ +export function buildMissingNotesNotice(report: { + total_commits?: number; + tracked_commits?: number; +}): string | null { + if (!shouldRetryNotesFetch(report)) return null; + return ( + "Agent Note found no tracked commits in this PR. If AI-assisted commits are expected, " + + "the refs/notes/agentnote ref may not have been pushed; re-run 'npx agent-note init' " + + "to repair the git hooks." + ); +} + /** * Detect whether the GitHub Pages environment restricts deploy branches. * diff --git a/packages/pr-report/src/index.test.ts b/packages/pr-report/src/index.test.ts index 90c8cae..6935ce7 100644 --- a/packages/pr-report/src/index.test.ts +++ b/packages/pr-report/src/index.test.ts @@ -1,6 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { + buildMissingNotesNotice, COMMENT_MARKER, DESCRIPTION_BEGIN, DESCRIPTION_END, @@ -133,6 +134,23 @@ describe("shouldRetryNotesFetch", () => { }); }); +describe("buildMissingNotesNotice", () => { + it("builds the repair notice when commits exist but none are tracked", () => { + const notice = buildMissingNotesNotice({ total_commits: 3, tracked_commits: 0 }); + assert.ok(notice, "a PR with untracked commits should produce a notice"); + assert.match(notice, /refs\/notes\/agentnote/); + assert.match(notice, /npx agent-note init/); + }); + + it("returns null when at least one commit is tracked", () => { + assert.equal(buildMissingNotesNotice({ total_commits: 3, tracked_commits: 1 }), null); + }); + + it("returns null for pull requests with no commits in scope", () => { + assert.equal(buildMissingNotesNotice({ total_commits: 0, tracked_commits: 0 }), null); + }); +}); + describe("inferDashboardUrl", () => { it("infers the standard project Pages dashboard URL", () => { assert.equal( diff --git a/packages/pr-report/src/index.ts b/packages/pr-report/src/index.ts index 92748f3..2f8ee71 100644 --- a/packages/pr-report/src/index.ts +++ b/packages/pr-report/src/index.ts @@ -2,6 +2,7 @@ import * as core from "@actions/core"; import * as github from "@actions/github"; import { execSync } from "child_process"; import { + buildMissingNotesNotice, COMMENT_MARKER, hasDeploymentBranchProtection, PR_OUTPUT_MODES, @@ -258,12 +259,9 @@ async function run(): Promise { return; } - if (shouldRetryNotesFetch(report)) { - // Detection line for silently broken git hooks; notice-level because - // human-only PRs legitimately have no tracked data. - core.notice( - "Agent Note found no tracked commits in this PR. If AI-assisted commits are expected, the refs/notes/agentnote ref may not have been pushed; re-run 'npx agent-note init' to repair the git hooks.", - ); + const missingNotesNotice = buildMissingNotesNotice(report); + if (missingNotesNotice) { + core.notice(missingNotesNotice); } report.dashboard_preview_help_url = await inferDashboardPreviewHelpUrl(