diff --git a/AGENTS.md b/AGENTS.md index 0b896316..279b0018 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,7 @@ Gemini-specific event handling: `agent-note init` installs three git hooks alongside the agent's hook config: - **`prepare-commit-msg`**: Checks heartbeat freshness (< 1 hour) and file evidence (`changes.jsonl` or `pre_blobs.jsonl`) before injecting an `Agentnote-Session` trailer for plain git commits. Prompt-only sessions do not get plain git hook trailers. Agent `PreToolUse git commit` hooks may still inject trailers for prompt-only rescue because the commit command itself came from the agent. Skips amends. -- **`post-commit`**: Reads session ID from HEAD's trailer, calls `agent-note record ` to write git note. If `prepare-commit-msg` marked a long-running session as too stale for trailer injection, it calls `agent-note record --fallback-head`, which records only when a session post-edit blob matches a committed HEAD blob. If no trailer or stale marker exists but the current process exposes an adapter-supported session environment such as `CODEX_THREAD_ID`, it calls `agent-note record --fallback-env` to recover a fresh Codex transcript without trusting a stale active-session pointer. Env fallback prefers transcript rows tied to current commit files, ignores rows after HEAD, can recover work prepared just before the previous commit when no newer match exists, and uses commit-level attribution only for mutating shell-only work without exact `files_touched`. +- **`post-commit`**: Reads session ID from HEAD's trailer, calls `agent-note record ` to write git note. If `prepare-commit-msg` marked a long-running session as too stale for trailer injection, it calls `agent-note record --fallback-head`, which records only when a session post-edit blob matches a committed HEAD blob. If the current process exposes an adapter-supported session environment such as `CODEX_THREAD_ID`, it may also call `agent-note record --fallback-env` when HEAD still has no Agent Note after the trailer/head attempt. Env fallback prefers transcript rows tied to current commit files, ignores rows after HEAD, can recover work prepared just before the previous commit when no newer match exists, keeps bounded preceding decision-context prompts for display, and uses commit-level attribution only for mutating shell-only work without exact `files_touched`. - **`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. diff --git a/CLAUDE.md b/CLAUDE.md index c543aa8e..3fe4d690 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,7 +94,7 @@ Gemini-specific event handling: `agent-note init` installs three git hooks alongside the agent's hook config: - **`prepare-commit-msg`**: Checks heartbeat freshness (< 1 hour) and file evidence (`changes.jsonl` or `pre_blobs.jsonl`) before injecting an `Agentnote-Session` trailer for plain git commits. Prompt-only sessions do not get plain git hook trailers. Agent `PreToolUse git commit` hooks may still inject trailers for prompt-only rescue because the commit command itself came from the agent. Skips amends. -- **`post-commit`**: Reads session ID from HEAD's trailer, calls `agent-note record ` to write git note. If `prepare-commit-msg` marked a long-running session as too stale for trailer injection, it calls `agent-note record --fallback-head`, which records only when a session post-edit blob matches a committed HEAD blob. If no trailer or stale marker exists but the current process exposes an adapter-supported session environment such as `CODEX_THREAD_ID`, it calls `agent-note record --fallback-env` to recover a fresh Codex transcript without trusting a stale active-session pointer. Env fallback prefers transcript rows tied to current commit files, ignores rows after HEAD, can recover work prepared just before the previous commit when no newer match exists, and uses commit-level attribution only for mutating shell-only work without exact `files_touched`. +- **`post-commit`**: Reads session ID from HEAD's trailer, calls `agent-note record ` to write git note. If `prepare-commit-msg` marked a long-running session as too stale for trailer injection, it calls `agent-note record --fallback-head`, which records only when a session post-edit blob matches a committed HEAD blob. If the current process exposes an adapter-supported session environment such as `CODEX_THREAD_ID`, it may also call `agent-note record --fallback-env` when HEAD still has no Agent Note after the trailer/head attempt. Env fallback prefers transcript rows tied to current commit files, ignores rows after HEAD, can recover work prepared just before the previous commit when no newer match exists, keeps bounded preceding decision-context prompts for display, and uses commit-level attribution only for mutating shell-only work without exact `files_touched`. - **`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. diff --git a/docs/architecture.md b/docs/architecture.md index 75bb0a3a..b2733ed1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -388,14 +388,14 @@ Three git hooks handle commit integration and notes sharing: | Git hook | When | What it does | |---|---|---| | `prepare-commit-msg` | Before commit message editor opens | Checks session freshness and file evidence (`changes.jsonl` or `pre_blobs.jsonl`), then appends `Agentnote-Session` trailer. Prompt-only active sessions are skipped for plain git commits. Skips amend/reuse (`$2=commit`). | -| `post-commit` | After commit succeeds | Reads session ID from the finalized trailer on HEAD, calls `agent-note record ` to write git note. If `prepare-commit-msg` explicitly marked a stale-heartbeat fallback, calls `agent-note record --fallback-head`, which only records when a session post-edit blob matches a committed HEAD blob. If no trailer or marker exists but the current process exposes an adapter-supported session environment such as `CODEX_THREAD_ID`, calls `agent-note record --fallback-env`; fresh mutating transcript work can become commit-level attribution even when exact `files_touched` is unavailable. Idempotent — skips if note already exists. | +| `post-commit` | After commit succeeds | Reads session ID from the finalized trailer on HEAD, calls `agent-note record ` to write git note. If `prepare-commit-msg` explicitly marked a stale-heartbeat fallback, calls `agent-note record --fallback-head`, which only records when a session post-edit blob matches a committed HEAD blob. If the current process exposes an adapter-supported session environment such as `CODEX_THREAD_ID`, it may also call `agent-note record --fallback-env` when HEAD still has no Agent Note after the trailer/head attempt; fresh mutating transcript work can become commit-level attribution even when exact `files_touched` is unavailable. Direct file-matched env fallback rows may pull in bounded preceding decision-context prompts for display, but only the matched rows affect attribution. Idempotent — skips if note already exists. | | `pre-push` | Before push to remote | Pushes `refs/notes/agentnote` to the actual remote (`$1`) and waits for `push-notes` to finish. Recursion-guarded via `AGENTNOTE_PUSHING` env var. | Session freshness is verified via per-session heartbeat file (`sessions//heartbeat`). Heartbeat is refreshed by normalized hook events during long turns. `Stop` does NOT invalidate the heartbeat — it fires when the AI finishes responding, not when the session ends. Gemini `SessionEnd` is a real session termination and removes the heartbeat. Missing heartbeat in `prepare-commit-msg` skips trailer injection. Stale heartbeat writes a one-shot fallback marker for brand-new commits only; `post-commit` consumes that marker and records only if the active session has post-edit blob evidence that matches the committed HEAD blobs. Agent-hosted terminals may also expose the current session through adapter-specific environment variables. Today, Codex exposes `CODEX_THREAD_ID`, which lets `post-commit` recover a fresh Codex transcript even when `.git/agentnote/session` points at a stale or unrelated session. Plain git hook trailer injection also requires file evidence. File-change records or pre-edit blobs count as safe evidence because they can be matched back to committed files. Prompts alone are not enough for plain git hooks: a fresh prompt-only active session might belong to another agent or terminal workflow. Agent hook trailer injection can still preserve prompt-only work because the commit command itself was observed inside the agent. Transcript paths are supporting metadata, not recordable data by themselves. Heartbeat, `SessionStart`, and `transcript_path` metadata alone do not receive dangling `Agentnote-Session` trailers. -Environment fallback is narrower than trailer injection. It does not trust `.git/agentnote/session`; it trusts only an adapter-provided current process environment session id, validates the session id, discovers the agent transcript through the adapter, and requires a fresh heartbeat or fresh transcript mtime before recording. This helps terminals or agent hosts such as cmux, where the current Codex process may expose `CODEX_THREAD_ID` even if the repository active-session pointer was not updated. If the trusted transcript has direct file matches, Agent Note may ignore stale repository-local prompt logs and prefer the newest transcript rows after the parent commit that cover the commit files. If no newer matching row exists, it can still recover matching transcript work prepared just before the previous commit was finalized. Rows after the target commit are always ignored. If the trusted transcript has current mutating shell work but no exact per-prompt file touches, Agent Note records commit-level attribution by marking the commit files as AI-assisted while leaving `files_touched` empty. Read-only shell activity such as status checks is not enough for env fallback attribution. +Environment fallback is narrower than trailer injection. It does not trust `.git/agentnote/session`; it trusts only an adapter-provided current process environment session id, validates the session id, discovers the agent transcript through the adapter, and requires a fresh heartbeat or fresh transcript mtime before recording. This helps terminals or agent hosts such as cmux, where the current Codex process may expose `CODEX_THREAD_ID` even if the repository active-session pointer was not updated. It can also recover when a stale active-session pointer injected a trailer but that trailer produced no git note. If the trusted transcript has direct file matches, Agent Note may ignore stale repository-local prompt logs and prefer the newest transcript rows after the parent commit that cover the commit files. For display, it keeps a bounded amount of preceding transcript discussion so the PR Report and Dashboard still explain why the implementation happened; for attribution and line counts, only the direct file-matched rows are used. The display window stops at large time gaps or prior edits to other files so a long transcript backlog does not become the commit note. If no newer matching row exists, it can still recover matching transcript work prepared just before the previous commit was finalized. Rows after the target commit are always ignored. If the trusted transcript has current mutating shell work but no exact per-prompt file touches, Agent Note records commit-level attribution by marking the commit files as AI-assisted while leaving `files_touched` empty. Read-only shell activity such as status checks is not enough for env fallback attribution. ### Git hook installation diff --git a/docs/engineering.md b/docs/engineering.md index e6b0d401..b68bbfae 100644 --- a/docs/engineering.md +++ b/docs/engineering.md @@ -93,6 +93,10 @@ rmSync(path, { force: true }); - If you touch Dashboard workflow code, verify the relevant `packages/dashboard` test/build path. - If you touch PR Report rendering or Action inputs, verify the relevant `packages/pr-report` test/build path. - If you touch CLI core or an agent adapter, verify `packages/cli` build, typecheck, lint, and tests. +- Prefer characterization tests for user-visible contracts: CLI output, PR body updates, hidden reviewer context, Dashboard note persistence, and attribution fallback boundaries. +- Do not inflate coverage by repeating the same scenario. Use unique command inputs or generated scenario matrices, and assert uniqueness when a smoke test is meant to represent broad coverage. +- Dist CLI smoke tests must execute the built `packages/cli/dist/cli.js` in temporary repositories with isolated `HOME` / config paths. They should not depend on the developer's live repository state. +- For heuristic or fallback logic, cover both the rescue path and the false-positive path. A fallback that records missing data must also prove it does not attribute unrelated human or read-only work. ## Commit Messages And Release Notes diff --git a/packages/cli/dist/cli.js b/packages/cli/dist/cli.js index ea2710a4..cdfe9caf 100755 --- a/packages/cli/dist/cli.js +++ b/packages/cli/dist/cli.js @@ -3438,6 +3438,8 @@ var AGENTNOTE_IGNORE_MAX_WILDCARD_TOKENS = 10; var AGENTNOTE_IGNORE_OVERLAPPING_WILDCARD_RE = /\*{3,}|\*\.\*/; var TRANSCRIPT_COMMIT_FUTURE_TOLERANCE_MS = 30 * 1e3; var TRANSCRIPT_COMMIT_PAST_TOLERANCE_MS = 30 * 1e3; +var ENV_FALLBACK_CONTEXT_BEFORE_MATCH_LIMIT = 12; +var ENV_FALLBACK_CONTEXT_MAX_GAP_MS = 45 * 60 * 1e3; async function recordCommitEntry(opts) { const sessionDir = join6(opts.agentnoteDirPath, SESSIONS_DIR, opts.sessionId); const sessionAgent = await readSessionAgent(sessionDir); @@ -3734,12 +3736,11 @@ async function recordCommitEntry(opts) { }); consumedPromptEntries = promptWindowConsumedEntries.length > 0 ? promptWindowConsumedEntries : relevantPromptEntries; useSelectableTranscriptAttribution = true; - } else if (selectableTranscriptMatched.length > 0 && (promptEntries.length === 0 || transcriptPrimaryTurns.size > 0)) { - interactions = selectableTranscriptMatched.map( - (i) => toRecordedInteraction(i, commitFileSet, consumedPromptState) - ); - useSelectableTranscriptAttribution = true; } else if (opts.allowEnvironmentTranscriptFallback && transcriptMatched.length > 0) { + const envTranscriptSource = selectEnvironmentTranscriptSourceInteractions( + allInteractions, + parentCommitTimestampMs + ); const envTranscriptMatched = selectEnvironmentTranscriptSourceInteractions( transcriptMatched, parentCommitTimestampMs @@ -3749,11 +3750,22 @@ async function recordCommitEntry(opts) { commitFileSet, consumedPromptState ); - interactions = envMatched.map( + const envDisplay = selectEnvironmentTranscriptDisplayInteractions( + envTranscriptSource, + envMatched, + commitFileSet, + consumedPromptState + ); + interactions = envDisplay.map( (i) => toRecordedInteraction(i, commitFileSet, consumedPromptState) ); attributionTranscriptMatched = envMatched; useSelectableTranscriptAttribution = true; + } else if (selectableTranscriptMatched.length > 0 && (promptEntries.length === 0 || transcriptPrimaryTurns.size > 0)) { + interactions = selectableTranscriptMatched.map( + (i) => toRecordedInteraction(i, commitFileSet, consumedPromptState) + ); + useSelectableTranscriptAttribution = true; } else if (!crossTurnCommit && transcriptMatched.length === 0 && canUseUnmatchedTranscriptFallback(opts.allowEnvironmentTranscriptFallback, allInteractions)) { const fallbackSourceInteractions = opts.allowEnvironmentTranscriptFallback ? filterTranscriptInteractionsAfterParent(allInteractions, parentCommitTimestampMs) : allInteractions; interactions = selectTranscriptFallbackInteractions( @@ -3998,6 +4010,45 @@ function selectEnvironmentTranscriptMatchedInteractions(interactions, commitFile } return selected.reverse(); } +function selectEnvironmentTranscriptDisplayInteractions(sourceInteractions, matchedInteractions, commitFileSet, consumedPromptState) { + if (matchedInteractions.length === 0) return []; + const matchedSet = new Set(matchedInteractions); + const firstMatchIndex = sourceInteractions.findIndex( + (interaction) => matchedSet.has(interaction) + ); + if (firstMatchIndex < 0) return matchedInteractions; + const selected = new Set(matchedInteractions); + let includedBeforeMatch = 0; + let nextInteraction = sourceInteractions[firstMatchIndex]; + for (let index = firstMatchIndex - 1; index >= 0; index--) { + if (includedBeforeMatch >= ENV_FALLBACK_CONTEXT_BEFORE_MATCH_LIMIT) break; + const candidate = sourceInteractions[index]; + if (shouldStopEnvironmentTranscriptContext( + candidate, + nextInteraction, + commitFileSet, + consumedPromptState + )) { + break; + } + selected.add(candidate); + includedBeforeMatch++; + nextInteraction = candidate; + } + return sourceInteractions.filter((interaction) => selected.has(interaction)); +} +function shouldStopEnvironmentTranscriptContext(candidate, nextInteraction, commitFileSet, consumedPromptState) { + if (hasLargeTranscriptContextGap(candidate, nextInteraction)) return true; + const touched = candidate.files_touched ?? []; + if (touched.length === 0) return false; + return filterInteractionCommitFiles(candidate, commitFileSet, consumedPromptState).length === 0; +} +function hasLargeTranscriptContextGap(previous, next) { + const previousMs = parseTimestampMs(previous.timestamp); + const nextMs = parseTimestampMs(next.timestamp); + if (previousMs === null || nextMs === null) return false; + return nextMs - previousMs > ENV_FALLBACK_CONTEXT_MAX_GAP_MS; +} function collectCurrentUnattributedToolPromptIds(interactions, promptEntries, maxConsumedTurn, currentTurn) { const candidatePromptIds = /* @__PURE__ */ new Set(); for (const entry of promptEntries) { @@ -4934,10 +4985,12 @@ async function recordHeadFallback() { }); } async function recordEnvironmentFallback() { - if (await readHeadTrailerSessionId()) { - debugRecord("env fallback skipped: HEAD already has trailer"); + if (await hasHeadAgentNote()) { + debugRecord("env fallback skipped: HEAD already has an Agent Note"); return; } + if (await readHeadTrailerSessionId()) + debugRecord("env fallback continuing after empty trailer record"); const agentnoteDirPath = await agentnoteDir(); const sessionId = await resolveEnvironmentSessionId(agentnoteDirPath); if (!sessionId) { @@ -4958,6 +5011,10 @@ async function readActiveSessionId(agentnoteDirPath) { if (sessionId === "." || sessionId === "..") return null; return SESSION_ID_SEGMENT_RE.test(sessionId) ? sessionId : null; } +async function hasHeadAgentNote() { + const result = await gitSafe(["notes", `--ref=${NOTES_REF}`, "show", "HEAD"]); + return result.exitCode === 0 && result.stdout.trim() !== ""; +} async function resolveEnvironmentSessionId(agentnoteDirPath) { for (const agentName of listAgents()) { const candidate = await resolveAgentEnvironmentSession(agentnoteDirPath, agentName); @@ -5262,18 +5319,27 @@ if [ -z "$SESSION_ID" ]; then fi rm -f "$FALLBACK_FILE" 2>/dev/null || true 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 "$SESSION_ID" 2>/dev/null || true - exit 0 -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 "$SESSION_ID" 2>/dev/null || true -elif command -v agent-note >/dev/null 2>&1; then - agent-note record "$SESSION_ID" 2>/dev/null || true +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 + 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 + fi +} + +record_agentnote "$SESSION_ID" +if [ "$SESSION_ID" != "--fallback-env" ] && [ -n "${SHELL_CODEX_THREAD_ID}" ] && ! git notes --ref=${NOTES_REF} show HEAD >/dev/null 2>&1; then + record_agentnote "--fallback-env" fi `; var PRE_PUSH_SCRIPT = `#!/bin/sh diff --git a/packages/cli/src/agents/codex.test.ts b/packages/cli/src/agents/codex.test.ts index 96cfd06b..cb2eb7be 100644 --- a/packages/cli/src/agents/codex.test.ts +++ b/packages/cli/src/agents/codex.test.ts @@ -23,6 +23,21 @@ function buildRealSessionPatchTranscript(opts: { ); } +function buildShellCommandTranscript(command: string): string { + return ( + '{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Run the shell command"}]}}\n' + + '{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Running it now."}]}}\n' + + `${JSON.stringify({ + type: "response_item", + payload: { + type: "function_call", + name: "exec_command", + arguments: { cmd: command }, + }, + })}\n` + ); +} + describe("codex adapter", () => { let codexHome: string; let previousCodexHome: string | undefined; @@ -153,6 +168,63 @@ describe("codex adapter", () => { }); }); + it("marks mutating shell commands as mutation tools", async () => { + const transcriptDir = join(codexHome, "sessions", "shell-mutation"); + mkdirSync(transcriptDir, { recursive: true }); + const commands = [ + "apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: src/app.ts\n+ok\n*** End Patch\nPATCH", + "cat > src/app.ts", + "cp src/a.ts src/b.ts", + "mkdir -p src/generated", + "mv src/a.ts src/b.ts", + "npm install", + "npm audit fix", + "perl -pi -e 's/a/b/g' src/app.ts", + "pnpm add left-pad", + "rm src/app.ts", + "sed -i '' 's/a/b/g' src/app.ts", + "tee src/app.ts", + "touch src/app.ts", + "yarn upgrade", + "printf ok > src/app.ts", + ]; + + for (const [index, command] of commands.entries()) { + const transcriptPath = join(transcriptDir, `mutating-${index}.jsonl`); + writeFileSync(transcriptPath, buildShellCommandTranscript(command)); + + const interactions = await codex.extractInteractions(transcriptPath); + + assert.deepEqual(interactions[0].tools, ["exec_command"], command); + assert.deepEqual(interactions[0].mutation_tools, ["exec_command"], command); + } + }); + + it("does not mark read-only shell commands as mutation tools", async () => { + const transcriptDir = join(codexHome, "sessions", "shell-readonly"); + mkdirSync(transcriptDir, { recursive: true }); + const commands = [ + "git status --short", + "npm test", + "pnpm test", + "yarn test", + "cat src/app.ts", + "grep rm src/app.ts", + 'echo "rm src/app.ts"', + "printf '>'", + ]; + + for (const [index, command] of commands.entries()) { + const transcriptPath = join(transcriptDir, `readonly-${index}.jsonl`); + writeFileSync(transcriptPath, buildShellCommandTranscript(command)); + + const interactions = await codex.extractInteractions(transcriptPath); + + assert.deepEqual(interactions[0].tools, ["exec_command"], command); + assert.equal(interactions[0].mutation_tools, undefined, command); + } + }); + it("normalizes absolute patch paths and ignores nested patch markers inside added code", async () => { const transcriptDir = join(codexHome, "sessions", "normalized"); mkdirSync(transcriptDir, { recursive: true }); diff --git a/packages/cli/src/commands/e2e-smoke.test.ts b/packages/cli/src/commands/e2e-smoke.test.ts new file mode 100644 index 00000000..61922d11 --- /dev/null +++ b/packages/cli/src/commands/e2e-smoke.test.ts @@ -0,0 +1,327 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, describe, it } from "node:test"; +import type { AgentnoteEntry } from "../core/entry.js"; +import { git as runGit } from "../git.js"; + +type CliCase = { + args: string[]; + includes?: RegExp; + code?: number; +}; + +const CLI_ENV_PASSTHROUGH_KEYS = [ + "PATH", + "Path", + "NODE_ENV", + "TMPDIR", + "TEMP", + "TMP", + "LANG", + "LC_ALL", +] as const; +const AGENT_ENV_PREFIX_RE = /^(AGENTNOTE_|CODEX_|CLAUDE_|CURSOR_|GEMINI_)/; + +describe("agent-note dist CLI e2e smoke", () => { + const cliPath = join(process.cwd(), "dist", "cli.js"); + let testDir: string; + let homeDir: string; + let commandCount = 0; + let baseCommit = ""; + let featureCommit = ""; + let followupCommit = ""; + let scopedCommit = ""; + + before(async () => { + assert.equal( + existsSync(cliPath), + true, + "Missing dist/cli.js. Run `npm run build` in packages/cli before running this test.", + ); + testDir = mkdtempSync(join(tmpdir(), "agentnote-e2e-smoke-")); + homeDir = join(testDir, ".home"); + mkdirSync(homeDir, { recursive: true }); + await git(["init"]); + await git(["config", "user.email", "e2e@example.com"]); + await git(["config", "user.name", "Agent Note E2E"]); + + mkdirSync(join(testDir, "src"), { recursive: true }); + mkdirSync(join(testDir, "docs"), { recursive: true }); + writeFileSync(join(testDir, "README.md"), "# E2E\n\nSeed\n"); + await git(["add", "README.md"]); + await git(["commit", "-m", "chore: seed readme"]); + baseCommit = await gitOutput(["rev-parse", "HEAD"]); + + writeFileSync( + join(testDir, "src", "app.ts"), + "export const greeting = 'hello';\nexport const label = 'Agent Note';\n", + ); + await git(["add", "src/app.ts"]); + await git(["commit", "-m", "feat: add app label"]); + featureCommit = await gitOutput(["rev-parse", "HEAD"]); + await addNote(featureCommit, { + v: 1, + agent: "codex", + session_id: "11111111-2222-4333-8444-555555555555", + timestamp: "2026-05-12T00:00:00.000Z", + model: "gpt-5.4", + interactions: [ + { + prompt: "Add the Agent Note label to src/app.ts.", + response: "I added the label export in src/app.ts.", + files_touched: ["src/app.ts"], + }, + ], + files: [{ path: "src/app.ts", by_ai: true }], + attribution: { ai_ratio: 100, method: "file" }, + }); + + writeFileSync( + join(testDir, "src", "app.ts"), + "export const greeting = 'hello';\nexport const label = 'Agent Note';\nexport const ready = true;\n", + ); + writeFileSync(join(testDir, "docs", "space file.md"), "# Space File\n\nReady\n"); + await git(["add", "src/app.ts", "docs/space file.md"]); + await git(["commit", "-m", "fix: mark app ready"]); + followupCommit = await gitOutput(["rev-parse", "HEAD"]); + await addNote(followupCommit, { + v: 1, + agent: "claude", + session_id: "22222222-3333-4444-8555-666666666666", + timestamp: "2026-05-12T00:01:00.000Z", + model: "claude-opus-4-6", + interactions: [ + { + prompt: "Mark the app ready and document the spaced path.", + response: "I updated src/app.ts and added docs/space file.md.", + files_touched: ["src/app.ts", "docs/space file.md"], + }, + ], + files: [ + { path: "src/app.ts", by_ai: true }, + { path: "docs/space file.md", by_ai: true }, + ], + attribution: { ai_ratio: 100, method: "file" }, + }); + + mkdirSync(join(testDir, "@scope"), { recursive: true }); + writeFileSync(join(testDir, "@scope", "pkg.ts"), "export const scoped = true;\n"); + await git(["add", "@scope/pkg.ts"]); + await git(["commit", "-m", "feat: add scoped package file"]); + scopedCommit = await gitOutput(["rev-parse", "HEAD"]); + await addNote(scopedCommit, { + v: 1, + agent: "cursor", + session_id: "33333333-4444-4555-8666-777777777777", + timestamp: "2026-05-12T00:02:00.000Z", + model: "cursor", + interactions: [ + { + prompt: "Add a scoped package fixture.", + response: "I added @scope/pkg.ts.", + files_touched: ["@scope/pkg.ts"], + }, + ], + files: [{ path: "@scope/pkg.ts", by_ai: true }], + attribution: { ai_ratio: 100, method: "file" }, + }); + }); + + after(() => { + if (testDir) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it("runs many public commands against a note-rich repository", () => { + const githubBlobUrl = "https://github.com/wasabeef/AgentNote/blob/main/src/app.ts#L2"; + const fileUrl = `file://${join(testDir, "src", "app.ts")}#L2`; + const vscodeUrl = `vscode://file${join(testDir, "src", "app.ts")}:2:1`; + const cases: CliCase[] = [ + { args: ["version"], includes: /agent-note v/ }, + { args: ["--version"], includes: /agent-note v/ }, + { args: ["help"], includes: /agent-note why/ }, + { args: ["--help"], includes: /agent-note init/ }, + { args: [], includes: /usage:/ }, + { args: ["status"], includes: /Agent Note|tracking|configured|not configured/i }, + { args: ["show"], includes: /feat: add scoped package file|Add a scoped package fixture/ }, + { args: ["show", "HEAD"], includes: /feat: add scoped package file|@scope\/pkg\.ts/ }, + { args: ["show", scopedCommit], includes: /@scope\/pkg\.ts|cursor/ }, + { args: ["show", followupCommit], includes: /docs\/space file\.md|claude/ }, + { args: ["show", featureCommit], includes: /src\/app\.ts|codex/ }, + { args: ["show", baseCommit], includes: /session: none|no agent-note data/i }, + { args: ["log"], includes: /feat: add scoped package file|fix: mark app ready/ }, + { args: ["log", "1"], includes: /feat: add scoped package file/ }, + { args: ["log", "2"], includes: /fix: mark app ready|feat: add scoped/ }, + { args: ["log", "5"], includes: /chore: seed readme|feat: add app label/ }, + { + args: ["session", "11111111-2222-4333-8444-555555555555"], + includes: /feat: add app label|codex/, + }, + { + args: ["session", "22222222-3333-4444-8555-666666666666"], + includes: /fix: mark app ready|claude/, + }, + { + args: ["session", "33333333-4444-4555-8666-777777777777"], + includes: /feat: add scoped package file|cursor/, + }, + { + args: ["session", "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"], + includes: /no commits|not found/i, + }, + { args: ["pr", "HEAD~3"], includes: /Agent Note|feat: add app label|fix: mark app ready/ }, + { args: ["pr", "HEAD~3", "--json"], includes: /"commits"|"ai_ratio"/ }, + { args: ["pr", "HEAD~3", "--prompt-detail", "compact"], includes: /Agent Note/ }, + { args: ["pr", "HEAD~3", "--prompt-detail", "full"], includes: /Agent Note/ }, + { args: ["pr", "HEAD~3", "--output", "comment"], includes: /Agent Note/ }, + { args: ["pr", "HEAD~3", "--output", "description"], includes: /Agent Note/ }, + { + args: ["pr", "HEAD~2", "--head", "HEAD"], + includes: /fix: mark app ready|feat: add scoped/, + }, + { + args: ["pr", "HEAD~1", "--head", "HEAD", "--json"], + includes: /feat: add scoped package file/, + }, + { args: ["why", "src/app.ts:2"], includes: /target: src\/app\.ts:2|Agent Note|label/ }, + { args: ["why", "src/app.ts:2-3"], includes: /target: src\/app\.ts:2-3|Agent Note/ }, + { args: ["why", "src/app.ts:2:1"], includes: /target: src\/app\.ts:2|Agent Note/ }, + { args: ["why", "src/app.ts#L2"], includes: /target: src\/app\.ts:2|Agent Note/ }, + { args: ["why", "src/app.ts#L2-L3"], includes: /target: src\/app\.ts:2-3|Agent Note/ }, + { args: ["why", "@src/app.ts#L2"], includes: /target: src\/app\.ts:2|Agent Note/ }, + { + args: ["why", "docs/space file.md#L1"], + includes: /target: docs\/space file\.md:1|Agent Note/, + }, + { args: ["why", "@scope/pkg.ts:1"], includes: /target: @scope\/pkg\.ts:1|Agent Note/ }, + { args: ["why", "@@scope/pkg.ts:1"], includes: /target: @scope\/pkg\.ts:1|Agent Note/ }, + { args: ["why", githubBlobUrl], includes: /target: src\/app\.ts:2|Agent Note/ }, + { args: ["why", fileUrl], includes: /target: src\/app\.ts:2|Agent Note/ }, + { args: ["why", vscodeUrl], includes: /target: src\/app\.ts:2|Agent Note/ }, + { args: ["blame", "src/app.ts:3"], includes: /target: src\/app\.ts:3|Agent Note/ }, + { args: ["blame", "@scope/pkg.ts#L1"], includes: /target: @scope\/pkg\.ts:1|Agent Note/ }, + { + args: ["why", "README.md:1"], + includes: /agent note:\n {2}evidence: none|no Agent Note data/i, + }, + ]; + + assertUniqueCases("public", cases); + for (const testCase of cases) { + runCli(`public ${testCase.args.join(" ")}`, testCase); + } + + const invalidCases: CliCase[] = [ + { args: ["unknown"], code: 1, includes: /unknown command|usage/i }, + { args: ["show", "HEAD~1"], code: 1, includes: /commit must be HEAD|usage/i }, + { args: ["log", "0"], code: 1, includes: /positive|invalid|usage/i }, + { args: ["log", "abc"], code: 1, includes: /invalid|usage/i }, + { args: ["why"], code: 1, includes: /usage|target/i }, + { args: ["why", "src/app.ts"], code: 1, includes: /usage|target/i }, + { args: ["why", "src/app.ts:5-1"], code: 1, includes: /usage|line|target/i }, + { + args: ["pr", "HEAD~3", "--prompt-detail", "medium"], + code: 1, + includes: /prompt_detail|compact|full/i, + }, + { args: ["session"], code: 1, includes: /usage|session/i }, + { args: ["init", "--agent", "unknown"], code: 1, includes: /unknown|agent/i }, + ]; + assertUniqueCases("invalid", invalidCases); + for (const testCase of invalidCases) { + runCli(`invalid ${testCase.args.join(" ")}`, testCase); + } + + assert.ok(commandCount >= 50, `expected broad dist CLI coverage, got ${commandCount} commands`); + }); + + it("runs setup and cleanup commands through the built CLI", async () => { + for (const agent of ["claude", "codex", "cursor", "gemini"]) { + const dir = mkdtempSync(join(tmpdir(), `agentnote-e2e-init-${agent}-`)); + try { + await gitIn(dir, ["init"]); + await gitIn(dir, ["config", "user.email", "e2e@example.com"]); + await gitIn(dir, ["config", "user.name", "Agent Note E2E"]); + runCli(`init ${agent}`, { + args: ["init", "--agent", agent, "--no-action"], + cwd: dir, + includes: /agent-note init|hooks added/i, + }); + runCli(`status ${agent}`, { + args: ["status"], + cwd: dir, + includes: /Agent Note|tracking|configured|active|inactive/i, + }); + runCli(`deinit ${agent}`, { + args: ["deinit", "--agent", agent], + cwd: dir, + includes: /agent-note deinit|removed|not configured/i, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + }); + + function runCli(label: string, testCase: CliCase & { cwd?: string }) { + commandCount++; + const result = spawnSync(process.execPath, [cliPath, ...testCase.args], { + cwd: testCase.cwd ?? testDir, + env: buildCliEnv(), + encoding: "utf-8", + }); + const expectedCode = testCase.code ?? 0; + assert.equal( + result.status, + expectedCode, + `${label}: expected exit ${expectedCode}, got ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + if (testCase.includes) { + assert.match(result.stdout + result.stderr, testCase.includes, label); + } + } + + function assertUniqueCases(label: string, cases: CliCase[]) { + const keys = cases.map((testCase) => testCase.args.join("\0")); + assert.equal(new Set(keys).size, keys.length, `${label} CLI cases must be unique`); + } + + function buildCliEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const key of CLI_ENV_PASSTHROUGH_KEYS) { + const value = process.env[key]; + if (value !== undefined) env[key] = value; + } + env.HOME = homeDir; + env.XDG_CONFIG_HOME = join(testDir, ".xdg"); + for (const key of Object.keys(env)) { + if (AGENT_ENV_PREFIX_RE.test(key)) delete env[key]; + } + return env; + } + + function git(args: string[]): Promise { + return gitIn(testDir, args); + } + + function gitOutput(args: string[]): Promise { + return runGit(args, { cwd: testDir }); + } + + async function gitIn(cwd: string, args: string[]): Promise { + await runGit(args, { cwd }); + } + + async function addNote(commitSha: string, entry: AgentnoteEntry): Promise { + await runGit( + ["notes", "--ref=agentnote", "add", "-f", "-m", JSON.stringify(entry), commitSha], + { + cwd: testDir, + }, + ); + } +}); diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index 4e0b7991..f15e77f3 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -18,6 +18,7 @@ import { HEARTBEAT_FILE, NOTES_REF_FULL, PROMPTS_FILE, + SESSION_AGENT_FILE, SESSION_FILE, SESSIONS_DIR, TRAILER_KEY, @@ -39,11 +40,16 @@ function writeCodexTranscript( sessionId: string, cwd: string, filePath: string, + options: { + baseTimestampMs?: number; + contextPrompts?: string[]; + prompt?: string; + } = {}, ): string { const transcriptDir = join(codexHome, "sessions", "2026", "05", "12"); mkdirSync(transcriptDir, { recursive: true }); const transcriptPath = join(transcriptDir, `rollout-2026-05-12T12-00-00-${sessionId}.jsonl`); - const baseTimestampMs = Date.now(); + const baseTimestampMs = options.baseTimestampMs ?? Date.now(); const timestamp = (offsetMs: number) => new Date(baseTimestampMs + offsetMs).toISOString(); const patch = [ "*** Begin Patch", @@ -51,6 +57,32 @@ function writeCodexTranscript( "+export const cmuxEnvFallback = true;", "*** End Patch", ].join("\n"); + let offsetMs = 1000; + const contextRows = (options.contextPrompts ?? []).flatMap((prompt, index) => { + const rows = [ + JSON.stringify({ + type: "response_item", + timestamp: timestamp(offsetMs), + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: prompt }], + }, + }), + JSON.stringify({ + type: "response_item", + timestamp: timestamp(offsetMs + 1000), + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: `Context response ${index + 1}.` }], + }, + }), + ]; + offsetMs += 2000; + return rows; + }); + const prompt = options.prompt ?? "add cmux env fallback"; writeFileSync( transcriptPath, `${[ @@ -59,18 +91,19 @@ function writeCodexTranscript( timestamp: timestamp(0), payload: { id: sessionId, cwd }, }), + ...contextRows, JSON.stringify({ type: "response_item", - timestamp: timestamp(1000), + timestamp: timestamp(offsetMs), payload: { type: "message", role: "user", - content: [{ type: "input_text", text: "add cmux env fallback" }], + content: [{ type: "input_text", text: prompt }], }, }), JSON.stringify({ type: "response_item", - timestamp: timestamp(2000), + timestamp: timestamp(offsetMs + 1000), payload: { type: "message", role: "assistant", @@ -79,7 +112,7 @@ function writeCodexTranscript( }), JSON.stringify({ type: "response_item", - timestamp: timestamp(3000), + timestamp: timestamp(offsetMs + 2000), payload: { type: "function_call", name: "apply_patch", @@ -530,6 +563,116 @@ AGENTNOTE_PUSHING=1 git push "$REMOTE" refs/notes/agentnote 2>/dev/null & rmSync(dir, { recursive: true, force: true }); }); + it("post-commit environment fallback keeps bounded decision context prompts", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-codex-env-context-")); + 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 commit --allow-empty -m 'init'", { cwd: dir }); + + execSync(`node ${cliPath} init --agent codex --no-action`, { + cwd: dir, + encoding: "utf-8", + }); + + const codexSessionId = "019da962-23cc-7aa0-bbe3-a10f60fddada"; + const codexHome = join(dir, "codex-home"); + const filePath = "src/context-env.ts"; + const contextPrompts = [ + "why did the PR prompt output disappear?", + "is this related to cmux or the Codex session environment?", + "did the previous fallback fix miss this case?", + "v0.2 kept more prompt context; preserve that behavior safely", + ]; + writeCodexTranscript(codexHome, codexSessionId, dir, filePath, { contextPrompts }); + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(join(dir, filePath), "export const cmuxEnvFallback = true;\n"); + + execSync(`git add ${shellSingleQuote(filePath)}`, { cwd: dir }); + execSync("git commit -m 'fix: preserve env fallback context'", { + cwd: dir, + env: { ...process.env, CODEX_HOME: codexHome, CODEX_THREAD_ID: codexSessionId }, + }); + + const note = execSync("git notes --ref=agentnote show HEAD", { + cwd: dir, + encoding: "utf-8", + }); + const entry = JSON.parse(note); + const prompts = entry.interactions.map((interaction: { prompt: string }) => interaction.prompt); + assert.deepEqual(prompts, [...contextPrompts, "add cmux env fallback"]); + assert.deepEqual(entry.interactions[entry.interactions.length - 1].files_touched, [filePath]); + assert.equal(entry.interactions[0].files_touched, undefined); + assert.deepEqual(entry.files, [{ path: filePath, by_ai: true }]); + + rmSync(dir, { recursive: true, force: true }); + }); + + it("post-commit environment fallback retries when a stale trailer writes no note", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-codex-env-stale-trailer-")); + 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 commit --allow-empty -m 'init'", { cwd: dir }); + + execSync(`node ${cliPath} init --agent codex --no-action`, { + cwd: dir, + encoding: "utf-8", + }); + + const staleSessionId = "11111111-1111-4111-8111-111111111111"; + const staleSessionDir = join(dir, ".git", AGENTNOTE_DIR, SESSIONS_DIR, staleSessionId); + mkdirSync(staleSessionDir, { recursive: true }); + writeFileSync(join(dir, ".git", AGENTNOTE_DIR, SESSION_FILE), staleSessionId); + writeFileSync(join(staleSessionDir, SESSION_AGENT_FILE), "claude\n"); + writeFileSync(join(staleSessionDir, HEARTBEAT_FILE), String(Date.now())); + writeFileSync(join(staleSessionDir, TURN_FILE), "1"); + writeFileSync( + join(staleSessionDir, PROMPTS_FILE), + '{"event":"prompt","timestamp":"2026-05-12T10:00:00Z","prompt":"old unrelated work","turn":1}\n', + ); + writeFileSync(join(dir, "unrelated-claude.ts"), "export const unrelatedClaude = true;\n"); + const unrelatedBlob = execSync("git hash-object -w unrelated-claude.ts", { + cwd: dir, + encoding: "utf-8", + }).trim(); + writeFileSync( + join(staleSessionDir, CHANGES_FILE), + `{"event":"file_change","tool":"Write","file":"unrelated-claude.ts","blob":"${unrelatedBlob}","turn":1}\n`, + ); + + const codexSessionId = "019da962-23cc-7aa0-bbe3-a10f60fddada"; + const codexHome = join(dir, "codex-home"); + const filePath = "src/stale-trailer-env.ts"; + writeCodexTranscript(codexHome, codexSessionId, dir, filePath); + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(join(dir, filePath), "export const cmuxEnvFallback = true;\n"); + + execSync(`git add ${shellSingleQuote(filePath)}`, { cwd: dir }); + execSync("git commit -m 'feat: stale trailer env fallback'", { + cwd: dir, + env: { ...process.env, CODEX_HOME: codexHome, CODEX_THREAD_ID: codexSessionId }, + }); + + const message = execSync("git log -1 --format=%B", { cwd: dir, encoding: "utf-8" }); + assert.ok( + message.includes(`${TRAILER_KEY}: ${staleSessionId}`), + "the stale active session should still reproduce the wrong trailer shape", + ); + + const note = execSync("git notes --ref=agentnote show HEAD", { + cwd: dir, + encoding: "utf-8", + }); + const entry = JSON.parse(note); + assert.equal(entry.agent, "codex"); + assert.equal(entry.session_id, codexSessionId); + assert.equal(entry.interactions[0].prompt, "add cmux env fallback"); + assert.deepEqual(entry.interactions[0].files_touched, [filePath]); + + rmSync(dir, { recursive: true, force: true }); + }); + it("post-commit environment fallback ignores stale local prompts when the Codex transcript is fresh", () => { const dir = mkdtempSync(join(tmpdir(), "agentnote-codex-env-transcript-only-")); execSync("git init", { cwd: dir }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 09eb3d44..0abb1bc9 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -8,6 +8,7 @@ import { GIT_HOOK_NAMES, HEARTBEAT_TTL_SECONDS, NOTES_FETCH_REFSPEC, + NOTES_REF, NOTES_REF_FULL, POST_COMMIT_FALLBACK_FILE, POST_COMMIT_FALLBACK_HEAD, @@ -180,18 +181,27 @@ if [ -z "$SESSION_ID" ]; then fi rm -f "$FALLBACK_FILE" 2>/dev/null || true 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 "$SESSION_ID" 2>/dev/null || true - exit 0 -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 "$SESSION_ID" 2>/dev/null || true -elif command -v agent-note >/dev/null 2>&1; then - agent-note record "$SESSION_ID" 2>/dev/null || true +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 + 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 + fi +} + +record_agentnote "$SESSION_ID" +if [ "$SESSION_ID" != "--fallback-env" ] && [ -n "${SHELL_CODEX_THREAD_ID}" ] && ! git notes --ref=${NOTES_REF} show HEAD >/dev/null 2>&1; then + record_agentnote "--fallback-env" fi `; diff --git a/packages/cli/src/commands/record.ts b/packages/cli/src/commands/record.ts index f0960f50..80a28291 100644 --- a/packages/cli/src/commands/record.ts +++ b/packages/cli/src/commands/record.ts @@ -7,6 +7,7 @@ import { HEARTBEAT_FILE, HEARTBEAT_TTL_SECONDS, MILLISECONDS_PER_SECOND, + NOTES_REF, SESSION_FILE, SESSIONS_DIR, TEXT_ENCODING, @@ -20,7 +21,7 @@ import { writeSessionAgent, writeSessionTranscriptPath, } from "../core/session.js"; -import { git } from "../git.js"; +import { git, gitSafe } from "../git.js"; import { agentnoteDir } from "../paths.js"; const FALLBACK_HEAD_FLAG = "--fallback-head"; @@ -75,10 +76,12 @@ export async function recordHeadFallback(): Promise { /** Recover notes for agent-hosted terminals that expose the current session id. */ export async function recordEnvironmentFallback(): Promise { - if (await readHeadTrailerSessionId()) { - debugRecord("env fallback skipped: HEAD already has trailer"); + if (await hasHeadAgentNote()) { + debugRecord("env fallback skipped: HEAD already has an Agent Note"); return; } + if (await readHeadTrailerSessionId()) + debugRecord("env fallback continuing after empty trailer record"); const agentnoteDirPath = await agentnoteDir(); const sessionId = await resolveEnvironmentSessionId(agentnoteDirPath); @@ -103,6 +106,11 @@ async function readActiveSessionId(agentnoteDirPath: string): Promise { + const result = await gitSafe(["notes", `--ref=${NOTES_REF}`, "show", "HEAD"]); + return result.exitCode === 0 && result.stdout.trim() !== ""; +} + async function resolveEnvironmentSessionId(agentnoteDirPath: string): Promise { for (const agentName of listAgents()) { const candidate = await resolveAgentEnvironmentSession(agentnoteDirPath, agentName); diff --git a/packages/cli/src/core/record.test.ts b/packages/cli/src/core/record.test.ts index b84dc2ce..8df7b896 100644 --- a/packages/cli/src/core/record.test.ts +++ b/packages/cli/src/core/record.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, it } from "node:test"; +import type { TranscriptInteraction } from "../agents/types.js"; import { AGENTNOTE_DIR, CHANGES_FILE, @@ -15,8 +16,16 @@ import { SESSIONS_DIR, TURN_FILE, } from "./constants.js"; -import { analyzePromptSelection, toPersistedSelection } from "./prompt-window.js"; -import { hasSessionHeadBlobEvidence, recordCommitEntry } from "./record.js"; +import { + analyzePromptSelection, + type ConsumedPromptState, + toPersistedSelection, +} from "./prompt-window.js"; +import { + hasSessionHeadBlobEvidence, + recordCommitEntry, + selectEnvironmentTranscriptDisplayInteractions, +} from "./record.js"; import { readNote } from "./storage.js"; const SESSION_ID = "a0000000-0000-4000-8000-000000000001"; @@ -1009,6 +1018,95 @@ describe("post-commit fallback evidence simulation", () => { }); }); +describe("environment transcript display selection", () => { + it("keeps decision context while stopping at task boundaries across 50+ simulations", () => { + const commitFile = "src/target.ts"; + const consumed: ConsumedPromptState = { + legacyPromptIds: new Set(), + promptFilePairs: new Set(), + tailPromptIds: new Set(), + }; + const baseMs = Date.UTC(2026, 4, 13, 12, 0, 0); + const limitExpectedContext = (prompts: string[]) => prompts.slice(-12); + const modes = [ + "plain-context", + "old-other-file-boundary", + "middle-other-file-boundary", + "old-time-gap", + "same-file-context", + ] as const; + + let caseCount = 0; + for (let contextCount = 0; contextCount < 15; contextCount++) { + for (const mode of modes) { + const source: TranscriptInteraction[] = []; + let timestampMs = baseMs; + const pushInteraction = ( + prompt: string, + filesTouched: string[] = [], + gapMs = 60 * 1000, + ): TranscriptInteraction => { + timestampMs += gapMs; + const interaction: TranscriptInteraction = { + prompt, + response: `response for ${prompt}`, + timestamp: new Date(timestampMs).toISOString(), + files_touched: filesTouched.length > 0 ? filesTouched : undefined, + }; + source.push(interaction); + return interaction; + }; + let expectedContextPrompts = Array.from( + { length: contextCount }, + (_, index) => `${mode}: context ${index}`, + ); + + if (mode === "old-other-file-boundary") { + pushInteraction(`${mode}: previous task`, ["src/other.ts"]); + } + + if (mode === "old-time-gap") { + pushInteraction(`${mode}: previous task`); + } + + if (mode === "same-file-context") { + pushInteraction(`${mode}: same file setup`, [commitFile]); + expectedContextPrompts = [`${mode}: same file setup`, ...expectedContextPrompts]; + } + + for (let index = 0; index < contextCount; index++) { + if (mode === "middle-other-file-boundary" && index === Math.floor(contextCount / 2)) { + pushInteraction(`${mode}: previous task`, ["src/other.ts"]); + expectedContextPrompts = expectedContextPrompts.slice(index); + } + const gapMs = mode === "old-time-gap" && index === 0 ? 50 * 60 * 1000 : 60 * 1000; + pushInteraction(`${mode}: context ${index}`, [], gapMs); + } + + const matchGapMs = + mode === "old-time-gap" && contextCount === 0 ? 50 * 60 * 1000 : 60 * 1000; + const matched = pushInteraction(`${mode}: implement`, [commitFile], matchGapMs); + + const selected = selectEnvironmentTranscriptDisplayInteractions( + source, + [matched], + new Set([commitFile]), + consumed, + ); + const prompts = selected.map((interaction) => interaction.prompt); + assert.deepEqual( + prompts, + [...limitExpectedContext(expectedContextPrompts), `${mode}: implement`], + `${mode}/${contextCount}`, + ); + caseCount++; + } + } + + assert.ok(caseCount >= 50, "simulation should cover at least 50 context cases"); + }); +}); + function setupGitRepo(): { repoDir: string; agentnoteDirPath: string; sessionDir: string } { const repoDir = mkdtempSync(join(tmpdir(), "agentnote-record-")); execSync("git init", { cwd: repoDir }); diff --git a/packages/cli/src/core/record.ts b/packages/cli/src/core/record.ts index 580894c0..caae422a 100644 --- a/packages/cli/src/core/record.ts +++ b/packages/cli/src/core/record.ts @@ -63,6 +63,23 @@ const AGENTNOTE_IGNORE_OVERLAPPING_WILDCARD_RE = /\*{3,}|\*\.\*/; // Keep the window small enough to exclude post-commit debug prompts. const TRANSCRIPT_COMMIT_FUTURE_TOLERANCE_MS = 30 * 1000; const TRANSCRIPT_COMMIT_PAST_TOLERANCE_MS = 30 * 1000; +/** + * Maximum prior transcript rows kept for env fallback display context. + * + * Env fallback has no reliable hook turn window, so this is a final safety cap: + * large enough to preserve an investigation-to-implementation thread, but + * small enough to prevent a continuous transcript backlog from becoming the + * commit note. + */ +const ENV_FALLBACK_CONTEXT_BEFORE_MATCH_LIMIT = 12; + +/** + * Maximum time gap allowed between adjacent env fallback context rows. + * + * A larger gap usually means the user switched tasks or returned later, so the + * display window stops there even when the transcript file itself is fresh. + */ +const ENV_FALLBACK_CONTEXT_MAX_GAP_MS = 45 * 60 * 1000; /** Record an agentnote entry as a git note after a successful commit. */ export async function recordCommitEntry(opts: { @@ -487,19 +504,11 @@ export async function recordCommitEntry(opts: { ? promptWindowConsumedEntries : relevantPromptEntries; useSelectableTranscriptAttribution = true; - } else if ( - selectableTranscriptMatched.length > 0 && - (promptEntries.length === 0 || transcriptPrimaryTurns.size > 0) - ) { - // No session prompts at all — emit just the edit-linked transcript - // interactions (e.g. commit with no surviving prompts.jsonl entry). If - // session prompts exist but none were selected, do not revive old - // prompt_id-less transcript edits as a last-ditch fallback. - interactions = selectableTranscriptMatched.map((i) => - toRecordedInteraction(i, commitFileSet, consumedPromptState), - ); - useSelectableTranscriptAttribution = true; } else if (opts.allowEnvironmentTranscriptFallback && transcriptMatched.length > 0) { + const envTranscriptSource = selectEnvironmentTranscriptSourceInteractions( + allInteractions, + parentCommitTimestampMs, + ); const envTranscriptMatched = selectEnvironmentTranscriptSourceInteractions( transcriptMatched, parentCommitTimestampMs, @@ -509,11 +518,29 @@ export async function recordCommitEntry(opts: { commitFileSet, consumedPromptState, ); - interactions = envMatched.map((i) => + const envDisplay = selectEnvironmentTranscriptDisplayInteractions( + envTranscriptSource, + envMatched, + commitFileSet, + consumedPromptState, + ); + interactions = envDisplay.map((i) => toRecordedInteraction(i, commitFileSet, consumedPromptState), ); attributionTranscriptMatched = envMatched; useSelectableTranscriptAttribution = true; + } else if ( + selectableTranscriptMatched.length > 0 && + (promptEntries.length === 0 || transcriptPrimaryTurns.size > 0) + ) { + // No session prompts at all — emit just the edit-linked transcript + // interactions (e.g. commit with no surviving prompts.jsonl entry). If + // session prompts exist but none were selected, do not revive old + // prompt_id-less transcript edits as a last-ditch fallback. + interactions = selectableTranscriptMatched.map((i) => + toRecordedInteraction(i, commitFileSet, consumedPromptState), + ); + useSelectableTranscriptAttribution = true; } else if ( !crossTurnCommit && transcriptMatched.length === 0 && @@ -938,6 +965,87 @@ function selectEnvironmentTranscriptMatchedInteractions( return selected.reverse(); } +/** + * Expand env fallback display prompts without changing attribution evidence. + * + * Env fallback may only have transcript evidence, so the file-matched row is + * often the final "implement it" prompt. Keeping bounded prior transcript rows + * preserves the decision context while `matchedInteractions` remains the sole + * source for AI file attribution and line counts. + */ +export function selectEnvironmentTranscriptDisplayInteractions( + sourceInteractions: TranscriptInteraction[], + matchedInteractions: TranscriptInteraction[], + commitFileSet: Set, + consumedPromptState: ConsumedPromptState, +): TranscriptInteraction[] { + if (matchedInteractions.length === 0) return []; + + const matchedSet = new Set(matchedInteractions); + const firstMatchIndex = sourceInteractions.findIndex((interaction) => + matchedSet.has(interaction), + ); + if (firstMatchIndex < 0) return matchedInteractions; + + const selected = new Set(matchedInteractions); + let includedBeforeMatch = 0; + let nextInteraction = sourceInteractions[firstMatchIndex]; + + for (let index = firstMatchIndex - 1; index >= 0; index--) { + if (includedBeforeMatch >= ENV_FALLBACK_CONTEXT_BEFORE_MATCH_LIMIT) break; + + const candidate = sourceInteractions[index]; + if ( + shouldStopEnvironmentTranscriptContext( + candidate, + nextInteraction, + commitFileSet, + consumedPromptState, + ) + ) { + break; + } + + selected.add(candidate); + includedBeforeMatch++; + nextInteraction = candidate; + } + + return sourceInteractions.filter((interaction) => selected.has(interaction)); +} + +/** + * Decide whether a prior transcript row belongs to a different task. + * + * A prior row is safe display context when it has no file touch, or when its + * file touch still belongs to this commit. A different-file edit is treated as + * a hard boundary because it likely belongs to another commit/task. + */ +function shouldStopEnvironmentTranscriptContext( + candidate: TranscriptInteraction, + nextInteraction: TranscriptInteraction, + commitFileSet: Set, + consumedPromptState: ConsumedPromptState, +): boolean { + if (hasLargeTranscriptContextGap(candidate, nextInteraction)) return true; + + const touched = candidate.files_touched ?? []; + if (touched.length === 0) return false; + + return filterInteractionCommitFiles(candidate, commitFileSet, consumedPromptState).length === 0; +} + +/** Return true when two transcript rows are too far apart to be one task. */ +function hasLargeTranscriptContextGap( + previous: TranscriptInteraction, + next: TranscriptInteraction, +): boolean { + const previousMs = parseTimestampMs(previous.timestamp); + const nextMs = parseTimestampMs(next.timestamp); + if (previousMs === null || nextMs === null) return false; + return nextMs - previousMs > ENV_FALLBACK_CONTEXT_MAX_GAP_MS; +} + /** Current-window tool turns without per-file evidence. */ function collectCurrentUnattributedToolPromptIds( interactions: TranscriptInteraction[], diff --git a/packages/dashboard/workflow/sync-notes.test.mjs b/packages/dashboard/workflow/sync-notes.test.mjs index d2ffb8fe..650d092b 100644 --- a/packages/dashboard/workflow/sync-notes.test.mjs +++ b/packages/dashboard/workflow/sync-notes.test.mjs @@ -119,6 +119,31 @@ test("mergeDashboardNotes removes stale notes for the current PR when no replace } }); +test("mergeDashboardNotes preserves malformed unrelated notes without crashing", () => { + const tempDir = mkdtempSync(join(tmpdir(), "agentnote-dashboard-malformed-test-")); + const dashboardNotesDir = join(tempDir, "dashboard-notes"); + const snapshotDir = join(tempDir, "snapshot"); + + try { + mkdirSync(dashboardNotesDir, { recursive: true }); + mkdirSync(snapshotDir, { recursive: true }); + writeFileSync(join(dashboardNotesDir, "malformed.json"), "{not-json"); + writeNote(join(dashboardNotesDir, "old-pr47.json"), 47, "old-pr47"); + writeNote(join(snapshotDir, "new-pr47.json"), 47, "new-pr47"); + + mergeDashboardNotes(snapshotDir, dashboardNotesDir, { + eventName: "pull_request", + prNumber: 47, + }); + + assert.deepEqual(readdirSync(dashboardNotesDir).sort(), ["malformed.json", "new-pr47.json"]); + assert.equal(readFileSync(join(dashboardNotesDir, "malformed.json"), "utf-8"), "{not-json"); + assert.equal(readNoteShortSha(join(dashboardNotesDir, "new-pr47.json")), "new-pr47"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + test("pruneDashboardNotes keeps the newest persisted note files", () => { const tempDir = mkdtempSync(join(tmpdir(), "agentnote-dashboard-prune-test-")); const notesDir = join(tempDir, "notes"); diff --git a/packages/pr-report/src/index.test.ts b/packages/pr-report/src/index.test.ts index da1358b1..d0139894 100644 --- a/packages/pr-report/src/index.test.ts +++ b/packages/pr-report/src/index.test.ts @@ -68,6 +68,32 @@ describe("upsertDescription — replace when section already present", () => { assert.ok(result.includes("new content")); assert.ok(result.includes(DESCRIPTION_END)); }); + + it("preserves user-authored body comments while replacing the managed section", () => { + const existing = [ + "Intro text", + "", + DESCRIPTION_BEGIN, + "old Agent Note report", + DESCRIPTION_END, + "Manual checklist", + ].join("\n"); + const markdown = [ + "", + "new Agent Note report", + ].join("\n"); + + const result = upsertDescription(existing, markdown); + + assert.ok(result.includes("Intro text")); + assert.ok(result.includes("")); + assert.ok(result.includes("Hidden reviewer context")); + assert.ok(result.includes("new Agent Note report")); + assert.ok(result.includes("Manual checklist")); + assert.ok(!result.includes("old Agent Note report")); + }); }); describe("constants", () => { diff --git a/packages/pr-report/src/report.test.ts b/packages/pr-report/src/report.test.ts index b65bc47d..0dffa1cf 100644 --- a/packages/pr-report/src/report.test.ts +++ b/packages/pr-report/src/report.test.ts @@ -125,6 +125,35 @@ describe("renderMarkdown", () => { assert.ok(markdown.includes("| █████ 150% | 1 |")); }); + it("omits reviewer context when no commits have Agent Note data", () => { + const markdown = renderMarkdown( + baseReport({ + tracked_commits: 0, + total_prompts: 0, + commits: [ + { + sha: "def456789012", + short: "def4567", + message: "chore: human-only commit", + session_id: null, + model: null, + ai_ratio: null, + attribution_method: null, + prompts_count: 0, + files_total: 0, + files_ai: 0, + files: [{ path: "src/human.ts", by_ai: false }], + interactions: [], + attribution: null, + }, + ], + }), + ); + + assert.ok(!markdown.includes(REVIEWER_CONTEXT_BEGIN)); + assert.ok(markdown.includes("**Agent Note data:** No tracked commits")); + }); + it("renders hidden reviewer context before the commit table", () => { const base = baseReport(); const markdown = renderMarkdown( @@ -360,6 +389,22 @@ describe("renderMarkdown", () => { assert.equal(reviewerContext.slice(0, -REVIEWER_CONTEXT_END.length).includes("-->"), false); }); + it("keeps changed-area file paths from closing the hidden reviewer comment", () => { + const report = baseReport({ + commits: [ + { + ...baseReport().commits[0], + files: [{ path: "src/keep-->`path`.ts", by_ai: true }], + }, + ], + }); + + const reviewerContext = extractReviewerContext(renderMarkdown(report)); + + assert.ok(reviewerContext.includes("src/keep- ->\\`path\\`.ts")); + assert.equal(reviewerContext.slice(0, -REVIEWER_CONTEXT_END.length).includes("-->"), false); + }); + it("renders interaction context before the prompt", () => { const markdown = renderMarkdown(baseReport());