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..c5272ad 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 + "$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 }); diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index e227314..cd82256 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,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) => { + 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) => { + 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..6205bd5 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,22 @@ 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. + // 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 + "$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)} "$@" `; diff --git a/packages/pr-report/dist/index.js b/packages/pr-report/dist/index.js index f59f38e..fe921a4 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. @@ -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,6 +38180,10 @@ async function run() { info("No agent-note data found for this PR."); return; } + 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); setOutput(ACTION_OUTPUT_NAMES.overallAiRatio, String(report.overall_ai_ratio ?? 0)); 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 6b287f9..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,6 +259,11 @@ async function run(): Promise { return; } + const missingNotesNotice = buildMissingNotesNotice(report); + if (missingNotesNotice) { + core.notice(missingNotesNotice); + } + report.dashboard_preview_help_url = await inferDashboardPreviewHelpUrl( token, report.dashboard_url,