From 6efa40c81ec4dfef6a2111901a275f2ad0a86e1c Mon Sep 17 00:00:00 2001 From: wasabeef Date: Tue, 12 May 2026 11:22:46 +0900 Subject: [PATCH 01/11] fix(hooks): recover Codex env sessions --- AGENTS.md | 2 +- CLAUDE.md | 2 +- docs/architecture.md | 6 +- docs/knowledge/investigations.md | 4 +- packages/cli/dist/cli.js | 82 +++++++++++-- packages/cli/src/commands/commit.ts | 3 +- packages/cli/src/commands/init.test.ts | 161 ++++++++++++++++++++++++- packages/cli/src/commands/init.ts | 3 + packages/cli/src/commands/record.ts | 90 +++++++++++++- 9 files changed, 332 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 91c9e604..d94b15f2 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. +- **`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 `CODEX_THREAD_ID`, it calls `agent-note record --fallback-env` to recover a fresh Codex transcript without trusting a stale active-session pointer. - **`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 bcd2370d..1fb3185a 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. +- **`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 `CODEX_THREAD_ID`, it calls `agent-note record --fallback-env` to recover a fresh Codex transcript without trusting a stale active-session pointer. - **`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 7d660787..f5680bda 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -388,13 +388,15 @@ 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. 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 no trailer or marker exists but the current process exposes an agent session environment such as `CODEX_THREAD_ID`, calls `agent-note record --fallback-env` and records only when that environment session has fresh evidence. Idempotent — skips if note already exists. | | `pre-push` | Before push to remote | Auto-pushes `refs/notes/agentnote` to the actual remote (`$1`) in background. 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. +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 environment variables. Today, `CODEX_THREAD_ID` 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 the current process environment, 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. + ### Git hook installation `agent-note init` installs git hooks respecting the repository's hook directory: diff --git a/docs/knowledge/investigations.md b/docs/knowledge/investigations.md index 34b6772a..6da7245e 100644 --- a/docs/knowledge/investigations.md +++ b/docs/knowledge/investigations.md @@ -47,6 +47,7 @@ - 観測結果: PR `#71` の直近 commit 群は長時間作業ではなく短時間でも欠落しました。`17c2d1d` も `Agentnote-Session` trailer と git note を持たないため、PR Report では `—` になります。 - 原因: `.git/agentnote/session` が現在の Codex 作業 session を正しく表しておらず、fresh な active session pointer が `prompts.jsonl` だけを持つ状態でした。plain `git commit` の `prepare-commit-msg` は commit command が Agent 内で観測されたかを判断できないため、prompt-only session に trailer を付けると別 session hijack の危険があります。 - 修正: plain `git commit` 経路(`prepare-commit-msg`)は、fresh heartbeat に加えて `changes.jsonl` または `pre_blobs.jsonl` の file evidence がある session だけに trailer を付けます。Agent の `PreToolUse git commit` 経路は、commit command 自体が Agent 内で観測されているため prompt-only rescue を維持します。`agent-note commit` も wrapper 内で session を確認できるため、`prompts.jsonl` / `changes.jsonl` / `pre_blobs.jsonl` のいずれかを recordable data として扱います。 +- Follow-up 修正: cmux などの Agent host 上では、`.git/agentnote/session` が更新されなくても process environment に現在の Agent session が残る場合があります。`CODEX_THREAD_ID` がある場合、`post-commit` は `--fallback-env` で fresh な Codex transcript を探し、transcript が現在 commit file に接続できる場合だけ note を作ります。古い transcript mtime は拒否するため、stale な Codex session は救済しません。 #### Safe fallback @@ -54,6 +55,7 @@ - `post-commit` は trailer がなく、かつ marker がある場合だけ `agent-note record --fallback-head` を呼びます。 - fallback は `.git/agentnote/session` を無条件に信じません。active session に recordable data があり、かつ `changes.jsonl` の post-edit `blob` が HEAD の committed blob と一致する場合だけ `recordCommitEntry()` に進みます。 - prompt-only / metadata-only / unrelated file evidence / same-path different-blob evidence は救済しません。 +- environment fallback は `.git/agentnote/session` を使わず、現在 process の `CODEX_THREAD_ID` だけを候補にします。Codex transcript は adapter の transcript discovery で探し、heartbeat または transcript mtime が fresh な場合だけ `recordCommitEntry()` に進みます。これは cmux のような host が Codex process environment を維持しているケースの救済であり、古い active pointer を再び信用するものではありません。 - HEAD blob 読み取りは `git diff-tree -z --raw` を使います。NUL 区切りで読むことで、Git の `core.quotePath=true` による path quote を避け、`src/日本語 file.ts` のような path でも post-edit blob evidence を正しく照合します。 #### Display behavior @@ -64,7 +66,7 @@ #### Regression coverage - `packages/cli/src/commands/hook.test.ts`: `PreToolUse` の `git commit` hook が stale heartbeat を更新すること、metadata-only session では trailer を注入しないことを確認します。 -- `packages/cli/src/commands/init.test.ts`: 生成された `prepare-commit-msg` hook が metadata-only session と fresh prompt-only session を skip し、file evidence がある session だけに trailer を入れることを確認します。同じ test file で、stale heartbeat のため trailer がない commit でも post-edit blob が HEAD blob と一致すれば post-commit fallback が note を作成し、stale prompt-only session、same-path different-blob session、amend commit は note を作らないこと、root commit と quoted raw diff path でも fallback が動くことを確認します。 +- `packages/cli/src/commands/init.test.ts`: 生成された `prepare-commit-msg` hook が metadata-only session と fresh prompt-only session を skip し、file evidence がある session だけに trailer を入れることを確認します。同じ test file で、stale heartbeat のため trailer がない commit でも post-edit blob が HEAD blob と一致すれば post-commit fallback が note を作成し、stale prompt-only session、same-path different-blob session、amend commit は note を作らないこと、root commit と quoted raw diff path でも fallback が動くことを確認します。さらに、`.git/agentnote/session` が unrelated prompt-only session を指していても、fresh な `CODEX_THREAD_ID` transcript が commit file に接続できる場合だけ environment fallback が note を作り、stale transcript は拒否することを確認します。 - `packages/cli/src/core/record.test.ts`: 180 case の fallback evidence simulation を追加し、`Claude` / `Codex` / `Cursor` / `Gemini`、current / rotated `changes` / `pre_blobs`、matching / unrelated / empty evidence、prompt-only noise を組み合わせて fallback predicate を検証します。 - `packages/cli/src/commands/commit.test.ts`: manual `agent-note commit` も同じ条件を使うことを確認します。 - `packages/pr-report/src/report.test.ts`: note missing commit は `Total AI Ratio: —`、true 0% attribution commit は従来通り `░░░░░░░░ 0%` と表示されることを確認します。 diff --git a/packages/cli/dist/cli.js b/packages/cli/dist/cli.js index c022b58f..fbd4b468 100755 --- a/packages/cli/dist/cli.js +++ b/packages/cli/dist/cli.js @@ -3395,7 +3395,15 @@ async function recordCommitEntry(opts) { if (existingNote) return { promptCount: 0, aiRatio: 0 }; let commitFiles = []; try { - const raw = await git(["diff-tree", "-z", "--root", "--no-commit-id", "--name-only", "-r", "HEAD"]); + const raw = await git([ + "diff-tree", + "-z", + "--root", + "--no-commit-id", + "--name-only", + "-r", + "HEAD" + ]); commitFiles = raw.split("\0").filter(Boolean); } catch { } @@ -4744,9 +4752,11 @@ async function sessionFile() { // src/commands/record.ts import { existsSync as existsSync8 } from "node:fs"; -import { readFile as readFile8 } from "node:fs/promises"; +import { mkdir as mkdir5, readFile as readFile8, stat as stat2 } from "node:fs/promises"; import { join as join8 } from "node:path"; var FALLBACK_HEAD_FLAG = "--fallback-head"; +var FALLBACK_ENV_FLAG = "--fallback-env"; +var ENV_CODEX_THREAD_ID = "CODEX_THREAD_ID"; var SESSION_ID_SEGMENT_RE = /^[A-Za-z0-9._-]+$/; var RAW_DIFF_STATUS_RE = /^:\d+ \d+ [0-9a-f]+ ([0-9a-f]+) ([A-Z][0-9]*)$/; var RAW_DIFF_RENAME_OR_COPY_PREFIXES = ["R", "C"]; @@ -4756,6 +4766,10 @@ async function record(args2) { await recordHeadFallback(); return; } + if (args2[0] === FALLBACK_ENV_FLAG) { + await recordEnvironmentFallback(); + return; + } const sessionId = args2[0]; if (!sessionId) return; await recordCommitEntry({ agentnoteDirPath: await agentnoteDir(), sessionId }); @@ -4777,6 +4791,13 @@ async function recordHeadFallback() { requireAiFileEvidence: true }); } +async function recordEnvironmentFallback() { + if (await readHeadTrailerSessionId()) return; + const agentnoteDirPath = await agentnoteDir(); + const sessionId = await resolveEnvironmentSessionId(agentnoteDirPath); + if (!sessionId) return; + await recordCommitEntry({ agentnoteDirPath, sessionId }); +} async function readActiveSessionId(agentnoteDirPath) { const activeSessionPath = join8(agentnoteDirPath, SESSION_FILE); if (!existsSync8(activeSessionPath)) return null; @@ -4784,6 +4805,41 @@ async function readActiveSessionId(agentnoteDirPath) { if (sessionId === "." || sessionId === "..") return null; return SESSION_ID_SEGMENT_RE.test(sessionId) ? sessionId : null; } +async function resolveEnvironmentSessionId(agentnoteDirPath) { + const codexSessionId = sanitizeSessionId(process.env[ENV_CODEX_THREAD_ID]); + if (!codexSessionId) return null; + const sessionDir = join8(agentnoteDirPath, SESSIONS_DIR, codexSessionId); + await mkdir5(sessionDir, { recursive: true }); + const existingAgent = await readSessionAgent(sessionDir); + if (existingAgent && existingAgent !== AGENT_NAMES.codex) return null; + if (!existingAgent) await writeSessionAgent(sessionDir, AGENT_NAMES.codex); + const transcriptPath = await readSessionTranscriptPath(sessionDir) ?? getAgent(AGENT_NAMES.codex).findTranscript(codexSessionId); + if (transcriptPath) await writeSessionTranscriptPath(sessionDir, transcriptPath); + if (!await hasFreshEnvironmentEvidence(sessionDir, transcriptPath)) return null; + return codexSessionId; +} +function sanitizeSessionId(value) { + const sessionId = value?.trim(); + if (!sessionId || sessionId === "." || sessionId === "..") return null; + return SESSION_ID_SEGMENT_RE.test(sessionId) ? sessionId : null; +} +async function hasFreshEnvironmentEvidence(sessionDir, transcriptPath) { + if (await hasRecordableSessionData(sessionDir) && await isFreshFile(join8(sessionDir, HEARTBEAT_FILE))) { + return true; + } + if (transcriptPath && await isFreshFile(transcriptPath)) return true; + return false; +} +async function isFreshFile(filePath) { + try { + const stats = await stat2(filePath); + if (!stats.isFile()) return false; + const ageMs = Date.now() - stats.mtimeMs; + return ageMs >= 0 && ageMs <= HEARTBEAT_TTL_SECONDS * MILLISECONDS_PER_SECOND; + } catch { + return false; + } +} async function readHeadCommittedBlobs() { const raw = await git(["diff-tree", "-z", "--raw", "--root", "--no-commit-id", "-r", "HEAD"]); return parseCommittedBlobs(raw); @@ -4873,6 +4929,7 @@ async function commit(args2) { } else if (!skipAgentNoteRecording) { try { await recordHeadFallback(); + await recordEnvironmentFallback(); } catch (err) { console.error(`agent-note: warning: fallback recording failed: ${err.message}`); } @@ -4886,12 +4943,13 @@ import { join as join11 } from "node:path"; // src/commands/init.ts import { existsSync as existsSync10 } from "node:fs"; -import { chmod, mkdir as mkdir5, readFile as readFile10, writeFile as writeFile7 } from "node:fs/promises"; +import { chmod, mkdir as mkdir6, readFile as readFile10, writeFile as writeFile7 } from "node:fs/promises"; import { isAbsolute as isAbsolute2, join as join10, resolve as resolve5 } from "node:path"; var PR_REPORT_WORKFLOW_FILENAME = "agentnote-pr-report.yml"; var DASHBOARD_WORKFLOW_FILENAME = "agentnote-dashboard.yml"; var [PREPARE_COMMIT_MSG_HOOK, POST_COMMIT_HOOK, PRE_PUSH_HOOK] = GIT_HOOK_NAMES; var TRAILER_SESSION_FILE_LIST = TRAILER_SESSION_FILES.join(" "); +var ENV_CODEX_THREAD_ID2 = "CODEX_THREAD_ID"; var PR_REPORT_WORKFLOW_TEMPLATE = `name: Agent Note PR Report on: pull_request: @@ -5027,6 +5085,8 @@ if [ -z "$SESSION_ID" ]; then FALLBACK_FILE="$GIT_DIR/agentnote/${POST_COMMIT_FALLBACK_FILE}" if [ -f "$FALLBACK_FILE" ] && [ "$(cat "$FALLBACK_FILE" 2>/dev/null | tr -d '\\n')" = "${POST_COMMIT_FALLBACK_HEAD}" ]; then SESSION_ID="--fallback-head" + elif [ -n "$${ENV_CODEX_THREAD_ID2}" ]; then + SESSION_ID="--fallback-env" else exit 0 fi @@ -5091,7 +5151,7 @@ async function init(args2) { } const repoRoot3 = await root(); const results = []; - await mkdir5(await agentnoteDir(), { recursive: true }); + await mkdir6(await agentnoteDir(), { recursive: true }); if (!skipHooks && !actionOnly) { for (const agentName of agents) { const adapter = getAgent(agentName); @@ -5109,7 +5169,7 @@ async function init(args2) { if (!skipGitHooks && !actionOnly) { await installLocalCliShim(await agentnoteDir()); const hookDir = await resolveHookDir(repoRoot3); - await mkdir5(hookDir, { recursive: true }); + await mkdir6(hookDir, { recursive: true }); const installed = await installGitHook( hookDir, PREPARE_COMMIT_MSG_HOOK, @@ -5130,7 +5190,7 @@ async function init(args2) { if (!skipAction && !hooksOnly) { const workflowDir = join10(repoRoot3, ".github", "workflows"); const prReportWorkflowPath = join10(workflowDir, PR_REPORT_WORKFLOW_FILENAME); - await mkdir5(workflowDir, { recursive: true }); + await mkdir6(workflowDir, { recursive: true }); if (existsSync10(prReportWorkflowPath)) { results.push( ` \xB7 workflow already exists at .github/workflows/${PR_REPORT_WORKFLOW_FILENAME}` @@ -5236,7 +5296,7 @@ async function installLocalCliShim(agentnoteDirPath) { const shim = `#!/bin/sh exec ${shellSingleQuote(process.execPath)} ${shellSingleQuote(cliPath)} "$@" `; - await mkdir5(shimDir, { recursive: true }); + await mkdir6(shimDir, { recursive: true }); await writeFile7(shimPath, shim); await chmod(shimPath, 493); } @@ -5379,7 +5439,7 @@ async function deinit(args2) { // src/commands/hook.ts import { randomUUID } from "node:crypto"; import { existsSync as existsSync13 } from "node:fs"; -import { mkdir as mkdir6, readFile as readFile12, realpath, unlink as unlink3, writeFile as writeFile8 } from "node:fs/promises"; +import { mkdir as mkdir7, readFile as readFile12, realpath, unlink as unlink3, writeFile as writeFile8 } from "node:fs/promises"; import { isAbsolute as isAbsolute3, join as join13, relative as relative2 } from "node:path"; // src/core/rotate.ts @@ -5506,7 +5566,7 @@ async function hook(args2 = []) { } const agentnoteDirPath = await agentnoteDir(); const sessionDir = join13(agentnoteDirPath, SESSIONS_DIR, event.sessionId); - await mkdir6(sessionDir, { recursive: true }); + await mkdir7(sessionDir, { recursive: true }); if (!(adapter.name === AGENT_NAMES.gemini && event.kind === NORMALIZED_EVENT_KINDS.stop)) { await refreshHeartbeat(agentnoteDirPath, event.sessionId); } @@ -6640,7 +6700,7 @@ async function session(sessionId) { } // src/commands/show.ts -import { stat as stat2 } from "node:fs/promises"; +import { stat as stat3 } from "node:fs/promises"; import { join as join15 } from "node:path"; var DEFAULT_COMMIT_REF = "HEAD"; var COMMIT_REF_PATTERN = /^(HEAD|[0-9a-f]{7,40})$/i; @@ -6716,7 +6776,7 @@ async function show(commitRef) { const transcriptPath = await readSessionTranscriptPath(sessionDir) ?? adapter.findTranscript(sessionId); if (transcriptPath) { console.log(); - const stats = await stat2(transcriptPath); + const stats = await stat3(transcriptPath); const sizeKb = (stats.size / BYTES_PER_KILOBYTE).toFixed(1); console.log(`transcript: ${transcriptPath} (${sizeKb} KB)`); } diff --git a/packages/cli/src/commands/commit.ts b/packages/cli/src/commands/commit.ts index 604423c3..77016138 100644 --- a/packages/cli/src/commands/commit.ts +++ b/packages/cli/src/commands/commit.ts @@ -13,7 +13,7 @@ import { import { recordCommitEntry } from "../core/record.js"; import { hasRecordableSessionData } from "../core/session.js"; import { agentnoteDir, sessionFile } from "../paths.js"; -import { recordHeadFallback } from "./record.js"; +import { recordEnvironmentFallback, recordHeadFallback } from "./record.js"; const AMEND_LIKE_COMMIT_ARGS = new Set([ "--amend", @@ -103,6 +103,7 @@ export async function commit(args: string[]): Promise { } else if (!skipAgentNoteRecording) { try { await recordHeadFallback(); + await recordEnvironmentFallback(); } catch (err: unknown) { // Never let agentnote fallback recording break a commit. console.error(`agent-note: warning: fallback recording failed: ${(err as Error).message}`); diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index b607f00d..14e18303 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -1,6 +1,14 @@ import assert from "node:assert/strict"; import { execFileSync, execSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { after, before, describe, it } from "node:test"; @@ -20,6 +28,61 @@ function shellSingleQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'`; } +function writeCodexTranscript( + codexHome: string, + sessionId: string, + cwd: string, + filePath: 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 patch = [ + "*** Begin Patch", + `*** Add File: ${filePath}`, + "+export const cmuxEnvFallback = true;", + "*** End Patch", + ].join("\n"); + writeFileSync( + transcriptPath, + `${[ + JSON.stringify({ + type: "session_meta", + timestamp: "2026-05-12T12:00:00Z", + payload: { id: sessionId, cwd }, + }), + JSON.stringify({ + type: "response_item", + timestamp: "2026-05-12T12:00:01Z", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "add cmux env fallback" }], + }, + }), + JSON.stringify({ + type: "response_item", + timestamp: "2026-05-12T12:00:02Z", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "I will add the cmux fallback file." }], + }, + }), + JSON.stringify({ + type: "response_item", + timestamp: "2026-05-12T12:00:03Z", + payload: { + type: "function_call", + name: "apply_patch", + arguments: { patch }, + }, + }), + ].join("\n")}\n`, + ); + return transcriptPath; +} + describe("agentnote init", () => { let testDir: string; const cliPath = join(process.cwd(), "dist", "cli.js"); @@ -333,6 +396,102 @@ AGENTNOTE_PUSHING=1 git push "$REMOTE" refs/notes/agentnote 2>/dev/null & rmSync(dir, { recursive: true, force: true }); }); + it("post-commit environment fallback records fresh Codex transcript sessions", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-codex-env-fallback-")); + 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 staleClaudeSessionId = "a1b2c3d4-7777-7777-7777-000000000777"; + const staleClaudeSessionDir = join( + dir, + ".git", + AGENTNOTE_DIR, + SESSIONS_DIR, + staleClaudeSessionId, + ); + mkdirSync(staleClaudeSessionDir, { recursive: true }); + writeFileSync(join(dir, ".git", AGENTNOTE_DIR, SESSION_FILE), staleClaudeSessionId); + writeFileSync(join(staleClaudeSessionDir, HEARTBEAT_FILE), String(Date.now())); + writeFileSync(join(staleClaudeSessionDir, TURN_FILE), "1"); + writeFileSync( + join(staleClaudeSessionDir, PROMPTS_FILE), + '{"event":"prompt","timestamp":"2026-04-02T10:00:00Z","prompt":"unrelated prompt","turn":1}\n', + ); + + const codexSessionId = "019da962-23cc-7aa0-bbe3-a10f60fddada"; + const codexHome = join(dir, "codex-home"); + const filePath = "src/cmux-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: cmux 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), "env fallback should not inject a trailer"); + + 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 Codex transcript sessions", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-stale-codex-env-")); + 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/stale-cmux-env.ts"; + const transcriptPath = writeCodexTranscript(codexHome, codexSessionId, dir, filePath); + const oldDate = new Date(Date.now() - 2 * 60 * 60 * 1000); + utimesSync(transcriptPath, oldDate, oldDate); + + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(join(dir, filePath), "export const staleCmuxEnvFallback = true;\n"); + execSync(`git add ${shellSingleQuote(filePath)}`, { cwd: dir }); + execSync("git commit -m 'chore: stale cmux env fallback'", { + cwd: dir, + env: { ...process.env, CODEX_HOME: codexHome, CODEX_THREAD_ID: codexSessionId }, + }); + + assert.throws(() => { + execFileSync("git", ["notes", "--ref=agentnote", "show", "HEAD"], { + cwd: dir, + encoding: "utf-8", + stdio: "pipe", + }); + }); + + rmSync(dir, { recursive: true, force: true }); + }); + it("post-commit fallback records stale sessions for quoted raw diff paths", () => { const dir = mkdtempSync(join(tmpdir(), "agentnote-stale-quoted-path-")); execSync("git init", { cwd: dir }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index d1f1ec47..6eb0a408 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -25,6 +25,7 @@ export const DASHBOARD_WORKFLOW_FILENAME = "agentnote-dashboard.yml"; const [PREPARE_COMMIT_MSG_HOOK, POST_COMMIT_HOOK, PRE_PUSH_HOOK] = GIT_HOOK_NAMES; const TRAILER_SESSION_FILE_LIST = TRAILER_SESSION_FILES.join(" "); +const ENV_CODEX_THREAD_ID = "CODEX_THREAD_ID"; const PR_REPORT_WORKFLOW_TEMPLATE = `name: Agent Note PR Report on: @@ -171,6 +172,8 @@ if [ -z "$SESSION_ID" ]; then FALLBACK_FILE="$GIT_DIR/agentnote/${POST_COMMIT_FALLBACK_FILE}" if [ -f "$FALLBACK_FILE" ] && [ "$(cat "$FALLBACK_FILE" 2>/dev/null | tr -d '\\n')" = "${POST_COMMIT_FALLBACK_HEAD}" ]; then SESSION_ID="--fallback-head" + elif [ -n "$${ENV_CODEX_THREAD_ID}" ]; then + SESSION_ID="--fallback-env" else exit 0 fi diff --git a/packages/cli/src/commands/record.ts b/packages/cli/src/commands/record.ts index 4d64a556..a4393b43 100644 --- a/packages/cli/src/commands/record.ts +++ b/packages/cli/src/commands/record.ts @@ -1,13 +1,31 @@ import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; +import { mkdir, readFile, stat } from "node:fs/promises"; import { join } from "node:path"; -import { SESSION_FILE, SESSIONS_DIR, TEXT_ENCODING, TRAILER_KEY } from "../core/constants.js"; +import { getAgent } from "../agents/index.js"; +import { AGENT_NAMES } from "../agents/types.js"; +import { + HEARTBEAT_FILE, + HEARTBEAT_TTL_SECONDS, + MILLISECONDS_PER_SECOND, + SESSION_FILE, + SESSIONS_DIR, + TEXT_ENCODING, + TRAILER_KEY, +} from "../core/constants.js"; import { hasSessionHeadBlobEvidence, recordCommitEntry } from "../core/record.js"; -import { hasRecordableSessionData } from "../core/session.js"; +import { + hasRecordableSessionData, + readSessionAgent, + readSessionTranscriptPath, + writeSessionAgent, + writeSessionTranscriptPath, +} from "../core/session.js"; import { git } from "../git.js"; import { agentnoteDir } from "../paths.js"; const FALLBACK_HEAD_FLAG = "--fallback-head"; +const FALLBACK_ENV_FLAG = "--fallback-env"; +const ENV_CODEX_THREAD_ID = "CODEX_THREAD_ID"; const SESSION_ID_SEGMENT_RE = /^[A-Za-z0-9._-]+$/; const RAW_DIFF_STATUS_RE = /^:\d+ \d+ [0-9a-f]+ ([0-9a-f]+) ([A-Z][0-9]*)$/; const RAW_DIFF_RENAME_OR_COPY_PREFIXES = ["R", "C"] as const; @@ -19,6 +37,10 @@ export async function record(args: string[]): Promise { await recordHeadFallback(); return; } + if (args[0] === FALLBACK_ENV_FLAG) { + await recordEnvironmentFallback(); + return; + } const sessionId = args[0]; if (!sessionId) return; @@ -49,6 +71,17 @@ 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()) return; + + const agentnoteDirPath = await agentnoteDir(); + const sessionId = await resolveEnvironmentSessionId(agentnoteDirPath); + if (!sessionId) return; + + await recordCommitEntry({ agentnoteDirPath, sessionId }); +} + async function readActiveSessionId(agentnoteDirPath: string): Promise { const activeSessionPath = join(agentnoteDirPath, SESSION_FILE); if (!existsSync(activeSessionPath)) return null; @@ -57,6 +90,57 @@ async function readActiveSessionId(agentnoteDirPath: string): Promise { + const codexSessionId = sanitizeSessionId(process.env[ENV_CODEX_THREAD_ID]); + if (!codexSessionId) return null; + + const sessionDir = join(agentnoteDirPath, SESSIONS_DIR, codexSessionId); + await mkdir(sessionDir, { recursive: true }); + + const existingAgent = await readSessionAgent(sessionDir); + if (existingAgent && existingAgent !== AGENT_NAMES.codex) return null; + if (!existingAgent) await writeSessionAgent(sessionDir, AGENT_NAMES.codex); + + const transcriptPath = + (await readSessionTranscriptPath(sessionDir)) ?? + getAgent(AGENT_NAMES.codex).findTranscript(codexSessionId); + if (transcriptPath) await writeSessionTranscriptPath(sessionDir, transcriptPath); + + if (!(await hasFreshEnvironmentEvidence(sessionDir, transcriptPath))) return null; + return codexSessionId; +} + +function sanitizeSessionId(value: string | undefined): string | null { + const sessionId = value?.trim(); + if (!sessionId || sessionId === "." || sessionId === "..") return null; + return SESSION_ID_SEGMENT_RE.test(sessionId) ? sessionId : null; +} + +async function hasFreshEnvironmentEvidence( + sessionDir: string, + transcriptPath: string | null, +): Promise { + if ( + (await hasRecordableSessionData(sessionDir)) && + (await isFreshFile(join(sessionDir, HEARTBEAT_FILE))) + ) { + return true; + } + if (transcriptPath && (await isFreshFile(transcriptPath))) return true; + return false; +} + +async function isFreshFile(filePath: string): Promise { + try { + const stats = await stat(filePath); + if (!stats.isFile()) return false; + const ageMs = Date.now() - stats.mtimeMs; + return ageMs >= 0 && ageMs <= HEARTBEAT_TTL_SECONDS * MILLISECONDS_PER_SECOND; + } catch { + return false; + } +} + async function readHeadCommittedBlobs(): Promise> { const raw = await git(["diff-tree", "-z", "--raw", "--root", "--no-commit-id", "-r", "HEAD"]); return parseCommittedBlobs(raw); From 8a61c4a69c8851f92334f788d99915274df5c312 Mon Sep 17 00:00:00 2001 From: wasabeef Date: Tue, 12 May 2026 11:39:17 +0900 Subject: [PATCH 02/11] chore(cli): update development dependencies --- package-lock.json | 96 +++++++++++++++++++-------------------- packages/cli/package.json | 6 +-- 2 files changed, 51 insertions(+), 51 deletions(-) diff --git a/package-lock.json b/package-lock.json index e0fb5a92..0ec8fb95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,9 +76,9 @@ "license": "MIT" }, "node_modules/@biomejs/biome": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.13.tgz", - "integrity": "sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.15.tgz", + "integrity": "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -92,20 +92,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.13", - "@biomejs/cli-darwin-x64": "2.4.13", - "@biomejs/cli-linux-arm64": "2.4.13", - "@biomejs/cli-linux-arm64-musl": "2.4.13", - "@biomejs/cli-linux-x64": "2.4.13", - "@biomejs/cli-linux-x64-musl": "2.4.13", - "@biomejs/cli-win32-arm64": "2.4.13", - "@biomejs/cli-win32-x64": "2.4.13" + "@biomejs/cli-darwin-arm64": "2.4.15", + "@biomejs/cli-darwin-x64": "2.4.15", + "@biomejs/cli-linux-arm64": "2.4.15", + "@biomejs/cli-linux-arm64-musl": "2.4.15", + "@biomejs/cli-linux-x64": "2.4.15", + "@biomejs/cli-linux-x64-musl": "2.4.15", + "@biomejs/cli-win32-arm64": "2.4.15", + "@biomejs/cli-win32-x64": "2.4.15" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.13.tgz", - "integrity": "sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.15.tgz", + "integrity": "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==", "cpu": [ "arm64" ], @@ -120,9 +120,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.13.tgz", - "integrity": "sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.15.tgz", + "integrity": "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==", "cpu": [ "x64" ], @@ -137,9 +137,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.13.tgz", - "integrity": "sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.15.tgz", + "integrity": "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==", "cpu": [ "arm64" ], @@ -154,9 +154,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.13.tgz", - "integrity": "sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.15.tgz", + "integrity": "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==", "cpu": [ "arm64" ], @@ -171,9 +171,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.13.tgz", - "integrity": "sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.15.tgz", + "integrity": "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==", "cpu": [ "x64" ], @@ -188,9 +188,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.13.tgz", - "integrity": "sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.15.tgz", + "integrity": "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==", "cpu": [ "x64" ], @@ -205,9 +205,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.13.tgz", - "integrity": "sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.15.tgz", + "integrity": "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==", "cpu": [ "arm64" ], @@ -222,9 +222,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.13.tgz", - "integrity": "sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.15.tgz", + "integrity": "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==", "cpu": [ "x64" ], @@ -1521,13 +1521,13 @@ } }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", + "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": "~7.21.0" } }, "node_modules/@vercel/ncc": { @@ -1858,9 +1858,9 @@ } }, "node_modules/publint": { - "version": "0.3.18", - "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.18.tgz", - "integrity": "sha512-JRJFeBTrfx4qLwEuGFPk+haJOJN97KnPuK01yj+4k/Wj5BgoOK5uNsivporiqBjk2JDaslg7qJOhGRnpltGeog==", + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.20.tgz", + "integrity": "sha512-UWqFYP7VBVCe9l/leEEGJrDs6Am4K4KapLmLi5qbt+9fA+Ny38ghdW+bw1nYfVqCK8/3kgsxjjhFjTYqYYRpyw==", "dev": true, "license": "MIT", "dependencies": { @@ -2016,9 +2016,9 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", + "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", "dev": true, "license": "MIT" }, @@ -2072,10 +2072,10 @@ "agent-note": "dist/cli.js" }, "devDependencies": { - "@biomejs/biome": "2.4.13", - "@types/node": "^25.6.0", + "@biomejs/biome": "2.4.15", + "@types/node": "^25.7.0", "esbuild": "^0.28.0", - "publint": "^0.3.18", + "publint": "^0.3.20", "tsx": "^4.21.0", "typescript": "^6.0.3" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index e06ca691..b0563433 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -50,10 +50,10 @@ "node": ">=20" }, "devDependencies": { - "@biomejs/biome": "2.4.13", - "@types/node": "^25.6.0", + "@biomejs/biome": "2.4.15", + "@types/node": "^25.7.0", "esbuild": "^0.28.0", - "publint": "^0.3.18", + "publint": "^0.3.20", "tsx": "^4.21.0", "typescript": "^6.0.3" } From 3b7d244604348b0857ba22e2358f982420845139 Mon Sep 17 00:00:00 2001 From: wasabeef Date: Tue, 12 May 2026 13:05:03 +0900 Subject: [PATCH 03/11] fix(record): restore Codex commit-level attribution Why Codex shell-only or host-mediated commits could have a trusted current transcript but no exact per-prompt file touch evidence. v1 guarded stale sessions correctly, but became too strict and lost the v0.2-era value of marking AI-assisted commit files. User impact Trusted current Codex tool-backed work now produces file-level AI Ratio again while keeping files_touched exact-only. Stale prompt-only active-session pointers and true human-only commits remain skipped. Verification npm run build npm run typecheck npm run lint npm test npm run build --prefix website Focused Codex shell-only and mid-session regression tests 576-case Codex shell-only fallback simulation --- AGENTS.md | 2 +- CLAUDE.md | 2 +- docs/architecture.md | 4 +- docs/knowledge/agent-skill.md | 5 +- docs/knowledge/agent-support-policy.md | 5 +- docs/knowledge/investigations.md | 13 +-- packages/cli/dist/cli.js | 6 ++ packages/cli/src/commands/codex.test.ts | 10 +- packages/cli/src/core/record.test.ts | 100 +++++++++++++++--- packages/cli/src/core/record.ts | 16 ++- website/src/content/docs/agent-support.mdx | 2 +- website/src/content/docs/data-and-privacy.mdx | 2 +- .../src/content/docs/de/data-and-privacy.mdx | 2 +- website/src/content/docs/de/how-it-works.mdx | 2 +- .../src/content/docs/es/data-and-privacy.mdx | 2 +- website/src/content/docs/es/how-it-works.mdx | 2 +- .../src/content/docs/fr/data-and-privacy.mdx | 2 +- website/src/content/docs/fr/how-it-works.mdx | 2 +- website/src/content/docs/how-it-works.mdx | 2 +- .../src/content/docs/id/data-and-privacy.mdx | 2 +- website/src/content/docs/id/how-it-works.mdx | 2 +- .../src/content/docs/it/data-and-privacy.mdx | 2 +- website/src/content/docs/it/how-it-works.mdx | 2 +- website/src/content/docs/ja/agent-support.mdx | 2 +- .../src/content/docs/ja/data-and-privacy.mdx | 2 +- website/src/content/docs/ja/how-it-works.mdx | 2 +- .../src/content/docs/ko/data-and-privacy.mdx | 2 +- website/src/content/docs/ko/how-it-works.mdx | 2 +- .../content/docs/pt-br/data-and-privacy.mdx | 2 +- .../src/content/docs/pt-br/how-it-works.mdx | 2 +- .../src/content/docs/ru/data-and-privacy.mdx | 2 +- website/src/content/docs/ru/how-it-works.mdx | 2 +- .../content/docs/zh-cn/data-and-privacy.mdx | 2 +- .../src/content/docs/zh-cn/how-it-works.mdx | 2 +- .../content/docs/zh-tw/data-and-privacy.mdx | 2 +- .../src/content/docs/zh-tw/how-it-works.mdx | 2 +- 36 files changed, 153 insertions(+), 62 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d94b15f2..921396e3 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 `CODEX_THREAD_ID`, it calls `agent-note record --fallback-env` to recover a fresh Codex transcript without trusting a stale active-session pointer. +- **`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 `CODEX_THREAD_ID`, it calls `agent-note record --fallback-env` to recover a fresh Codex transcript without trusting a stale active-session pointer; trusted tool-backed transcript work can become commit-level attribution even when exact `files_touched` is unavailable. - **`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 1fb3185a..098ac688 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 `CODEX_THREAD_ID`, it calls `agent-note record --fallback-env` to recover a fresh Codex transcript without trusting a stale active-session pointer. +- **`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 `CODEX_THREAD_ID`, it calls `agent-note record --fallback-env` to recover a fresh Codex transcript without trusting a stale active-session pointer; trusted tool-backed transcript work can become commit-level attribution even when exact `files_touched` is unavailable. - **`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 f5680bda..6eb53668 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 agent session environment such as `CODEX_THREAD_ID`, calls `agent-note record --fallback-env` and records only when that environment session has fresh evidence. 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 no trailer or marker exists but the current process exposes an agent session environment such as `CODEX_THREAD_ID`, calls `agent-note record --fallback-env`; fresh tool-backed transcript work can become commit-level attribution even when exact `files_touched` is unavailable. Idempotent — skips if note already exists. | | `pre-push` | Before push to remote | Auto-pushes `refs/notes/agentnote` to the actual remote (`$1`) in background. 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 environment variables. Today, `CODEX_THREAD_ID` 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 the current process environment, 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. +Environment fallback is narrower than trailer injection. It does not trust `.git/agentnote/session`; it trusts only the current process environment, 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 current tool-backed 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. ### Git hook installation diff --git a/docs/knowledge/agent-skill.md b/docs/knowledge/agent-skill.md index 75f9ef37..bc205f1c 100644 --- a/docs/knowledge/agent-skill.md +++ b/docs/knowledge/agent-skill.md @@ -224,8 +224,9 @@ not make them the primary user action. confirmation for every commit. - Preserve existing hooks and workflows. If a hook or workflow exists, update it carefully rather than replacing it blindly. -- Never infer AI-authored files from shell-only evidence. Follow Agent Note's - recorded note data. +- Do not invent per-prompt `files_touched` from shell command text. Follow + Agent Note's recorded note data; commit-level attribution may still mark + files AI-assisted when the current Agent transcript is trusted. - When troubleshooting missing data, distinguish "no tracked commits" from a true `0%` AI Ratio. diff --git a/docs/knowledge/agent-support-policy.md b/docs/knowledge/agent-support-policy.md index c707cb66..2c940d04 100644 --- a/docs/knowledge/agent-support-policy.md +++ b/docs/knowledge/agent-support-policy.md @@ -170,7 +170,8 @@ Codex CLI は `Supported` とする。 - patch 行数が commit と一致したときの safe line-level upgrade は成立している - 通常は file-level attribution で説明可能である - transcript が読めない、または不確かな場合は note を作らず安全側に倒れる -- shell-only の変更を transcript だけから AI-authored file と推測しない +- shell command text だけから per-prompt `files_touched` は作らない +- current Agent transcript を信頼できる場合は commit-level attribution として commit files を AI-assisted 扱いできる 判断: @@ -235,7 +236,7 @@ Status: 完了。 - transcript path validation の hardening - parser の fixture 強化 -- shell-only change recovery の到達範囲を docs に明示 +- shell-only change recovery と commit-level attribution の到達範囲を docs に明示 - `status` で Codex の capture 詳細を表示するか検討し、必要なら追加する - README と website の `Codex CLI | Supported` を維持する diff --git a/docs/knowledge/investigations.md b/docs/knowledge/investigations.md index 6da7245e..c1fc2ffd 100644 --- a/docs/knowledge/investigations.md +++ b/docs/knowledge/investigations.md @@ -47,15 +47,16 @@ - 観測結果: PR `#71` の直近 commit 群は長時間作業ではなく短時間でも欠落しました。`17c2d1d` も `Agentnote-Session` trailer と git note を持たないため、PR Report では `—` になります。 - 原因: `.git/agentnote/session` が現在の Codex 作業 session を正しく表しておらず、fresh な active session pointer が `prompts.jsonl` だけを持つ状態でした。plain `git commit` の `prepare-commit-msg` は commit command が Agent 内で観測されたかを判断できないため、prompt-only session に trailer を付けると別 session hijack の危険があります。 - 修正: plain `git commit` 経路(`prepare-commit-msg`)は、fresh heartbeat に加えて `changes.jsonl` または `pre_blobs.jsonl` の file evidence がある session だけに trailer を付けます。Agent の `PreToolUse git commit` 経路は、commit command 自体が Agent 内で観測されているため prompt-only rescue を維持します。`agent-note commit` も wrapper 内で session を確認できるため、`prompts.jsonl` / `changes.jsonl` / `pre_blobs.jsonl` のいずれかを recordable data として扱います。 -- Follow-up 修正: cmux などの Agent host 上では、`.git/agentnote/session` が更新されなくても process environment に現在の Agent session が残る場合があります。`CODEX_THREAD_ID` がある場合、`post-commit` は `--fallback-env` で fresh な Codex transcript を探し、transcript が現在 commit file に接続できる場合だけ note を作ります。古い transcript mtime は拒否するため、stale な Codex session は救済しません。 +- Follow-up 修正: cmux などの Agent host 上では、`.git/agentnote/session` が更新されなくても process environment に現在の Agent session が残る場合があります。`CODEX_THREAD_ID` がある場合、`post-commit` は `--fallback-env` で fresh な Codex transcript を探します。transcript が現在 commit file に直接接続できる場合は通常の file/line attribution を使い、file touch を特定できなくても current transcript の tool-backed work がある場合は v0.2 系に近い commit-level attribution として commit files を AI 扱いします。古い transcript mtime は拒否するため、stale な Codex session は救済しません。 #### Safe fallback - `prepare-commit-msg` が stale heartbeat のため trailer 注入を skip した場合だけ、one-shot の `post_commit_fallback` marker を書きます。 - `post-commit` は trailer がなく、かつ marker がある場合だけ `agent-note record --fallback-head` を呼びます。 - fallback は `.git/agentnote/session` を無条件に信じません。active session に recordable data があり、かつ `changes.jsonl` の post-edit `blob` が HEAD の committed blob と一致する場合だけ `recordCommitEntry()` に進みます。 -- prompt-only / metadata-only / unrelated file evidence / same-path different-blob evidence は救済しません。 -- environment fallback は `.git/agentnote/session` を使わず、現在 process の `CODEX_THREAD_ID` だけを候補にします。Codex transcript は adapter の transcript discovery で探し、heartbeat または transcript mtime が fresh な場合だけ `recordCommitEntry()` に進みます。これは cmux のような host が Codex process environment を維持しているケースの救済であり、古い active pointer を再び信用するものではありません。 +- `--fallback-head` は prompt-only / metadata-only / unrelated file evidence / same-path different-blob evidence を救済しません。これは stale `.git/agentnote/session` pointer を再び信用しないためです。 +- `--fallback-env` は `.git/agentnote/session` を使わず、現在 process の `CODEX_THREAD_ID` だけを候補にします。Codex transcript は adapter の transcript discovery で探し、heartbeat または transcript mtime が fresh な場合だけ `recordCommitEntry()` に進みます。これは cmux のような host が Codex process environment を維持しているケースの救済であり、古い active pointer を再び信用するものではありません。 +- `--fallback-env` で選ばれた current Codex transcript に tool-backed interaction がある場合、file touch が取れなくても commit-level attribution として commit files を `by_ai: true` にします。これは v1 の stale pointer guard は残しつつ、v0.2 系の「AI が関わった commit を見失わない」挙動へ戻すためです。`files_touched` は per-prompt file evidence なので推測では埋めません。 - HEAD blob 読み取りは `git diff-tree -z --raw` を使います。NUL 区切りで読むことで、Git の `core.quotePath=true` による path quote を避け、`src/日本語 file.ts` のような path でも post-edit blob evidence を正しく照合します。 #### Display behavior @@ -76,11 +77,11 @@ - 対象 PR: `#59` - 対象 commit: `afcb2d9 docs: normalize agent names on website` - 観測結果: commit message には `Agentnote-Session: 019da962-23cc-7aa0-bbe3-a10f60fddada` が入っていましたが、`git notes --ref=agentnote show afcb2d9` は `no note found` でした。そのため PR Report では AI 判定できず、commit table では prompt / file 情報が欠落しました。 -- 直接原因: 変更は Codex の `apply_patch` ではなく shell command による一括置換で行われていました。Codex adapter は安全側のため、shell command だけから `files_touched` や AI-authored files を推測しません。 +- 直接原因: 変更は Codex の `apply_patch` ではなく shell command による一括置換で行われていました。当時の Codex adapter は安全側のため、shell command だけから `files_touched` や AI-authored files を推測しませんでした。 - 設計漏れ: transcript 内に古い `apply_patch` edit が残っている場合、human-only skip guard が「current commit file には transcript edit がなく、別 file への transcript edit だけがある」と判断し、current turn の shell-only tool activity まで空 note として skip していました。結果として trailer はあるのに note がない状態が再発しました。 -- 修正: current prompt window に `files_touched` を持たない tool-backed Codex interaction がある場合は、shell-only work として prompt-only note を残します。ただし shell command から file attribution は推測せず、`files_touched` は付けず、AI ratio は 0% のままにします。cross-turn commit では shell-only fallback を出さず、古い `apply_patch` が別 file にあるだけの human-only commit は引き続き skip します。 +- 修正: current prompt window に `files_touched` を持たない tool-backed Codex interaction がある場合は、shell-only work として note を残します。v1 の初期修正では AI ratio を 0% にしていましたが、これは v0.2 系の良かった「AI が関わった commit を広く拾う」体験を落としすぎました。現在は、guard を通過した current tool-backed work については commit-level attribution として commit files を `by_ai: true` にします。ただし shell command から per-prompt file attribution は推測せず、`files_touched` は付けません。cross-turn commit では shell-only fallback を出さず、古い `apply_patch` が別 file にあるだけの human-only commit は引き続き skip します。 - 追加修正: Codex でも `transcript_path` だけの metadata-only session は recordable としません。少なくとも `prompts.jsonl` / `changes.jsonl` / `pre_blobs.jsonl` のいずれかが必要です。 -- Regression coverage: `packages/cli/src/core/record.test.ts` に PR #59 型の shell-only Codex regression を追加し、古い transcript edit があっても current shell-only prompt が prompt-only note として残ること、file attribution は付かないことを確認します。同じ test file に 100+ case の shell-only fallback simulation を追加し、current no-file tool activity だけが rescue され、true human-only commit は skip されることを確認します。`packages/cli/src/core/session.test.ts` に 100+ case の recordable session matrix を追加し、`transcript_path` 単体ではどの Agent でも recordable にならないことを確認します。`packages/cli/src/commands/codex.test.ts` は shell の `echo` 経由ではなく stdin に JSON を直接渡すようにし、改行を含む prompt でも実際の hook と同じ形で `prompts.jsonl` が作られることを確認します。 +- Regression coverage: `packages/cli/src/core/record.test.ts` に PR #59 型の shell-only Codex regression を追加し、古い transcript edit があっても current shell-only prompt が note として残り、commit files が AI 扱いになること、ただし `files_touched` は推測されないことを確認します。同じ test file に 100+ case の shell-only fallback simulation を追加し、current no-file tool activity だけが rescue され、true human-only commit は skip されることを確認します。`packages/cli/src/core/session.test.ts` に 100+ case の recordable session matrix を追加し、`transcript_path` 単体ではどの Agent でも recordable にならないことを確認します。`packages/cli/src/commands/codex.test.ts` は shell の `echo` 経由ではなく stdin に JSON を直接渡すようにし、改行を含む prompt でも実際の hook と同じ形で `prompts.jsonl` が作られることを確認します。 ### Prompt window policy の module 分離 diff --git a/packages/cli/dist/cli.js b/packages/cli/dist/cli.js index fbd4b468..e48511f0 100755 --- a/packages/cli/dist/cli.js +++ b/packages/cli/dist/cli.js @@ -3526,6 +3526,7 @@ async function recordCommitEntry(opts) { } let interactions; let transcriptLineCounts; + let useCommitLevelAttribution = false; let consumedPromptEntries = []; let consumedTranscriptPromptFiles = []; let allInteractions = []; @@ -3609,6 +3610,7 @@ async function recordCommitEntry(opts) { return { prompt: entry2.prompt ?? "", response: null }; }); consumedPromptEntries = promptOnlyFallbackEntries.consumed; + useCommitLevelAttribution = true; } else if (transcriptPath && allInteractions.length > 0) { const transcriptMatched = allInteractions.filter( (i) => (i.files_touched ?? []).some((f) => commitFileSet.has(f)) @@ -3682,6 +3684,7 @@ async function recordCommitEntry(opts) { commitFileSet, currentUnattributedToolPromptIds ); + useCommitLevelAttribution = interactions.length > 0; } else { interactions = []; } @@ -3708,6 +3711,9 @@ async function recordCommitEntry(opts) { } else { interactions = prompts.map((p) => ({ prompt: p, response: null })); } + if (useCommitLevelAttribution && aiFiles.length === 0 && interactions.length > 0) { + aiFiles = commitFiles; + } await fillInteractionResponsesFromEvents(sessionDir, relevantPromptEntries, interactions); await attachInteractionContexts( sessionDir, diff --git a/packages/cli/src/commands/codex.test.ts b/packages/cli/src/commands/codex.test.ts index f078360f..dc3af19d 100644 --- a/packages/cli/src/commands/codex.test.ts +++ b/packages/cli/src/commands/codex.test.ts @@ -322,7 +322,7 @@ describe("agentnote codex", () => { ); }); - it("records shell-only Codex transcripts without guessing AI-authored files", () => { + it("records shell-only Codex transcripts with commit-level AI attribution", () => { const sessionId = "codex-session-shell-only"; const transcriptDir = join(testHome, ".codex", "sessions"); mkdirSync(transcriptDir, { recursive: true }); @@ -357,7 +357,7 @@ describe("agentnote codex", () => { env: { ...process.env, HOME: testHome }, encoding: "utf-8", }); - assert.match(output, /agent-note: 1 prompts, AI ratio 0%/); + assert.match(output, /agent-note: 1 prompts, AI ratio 100%/); const note = JSON.parse( execSync("git notes --ref=agentnote show HEAD", { @@ -366,8 +366,8 @@ describe("agentnote codex", () => { }), ); assert.equal(note.attribution.method, "file"); - assert.equal(note.attribution.ai_ratio, 0); - assert.deepEqual(note.files, [{ path: "shell-note.txt", by_ai: false }]); + assert.equal(note.attribution.ai_ratio, 100); + assert.deepEqual(note.files, [{ path: "shell-note.txt", by_ai: true }]); assert.equal(note.interactions[0].prompt, "Update shell-note.txt via shell."); assert.equal(note.interactions[0].response, "I will update it with a shell command."); assert.deepEqual(note.interactions[0].tools, ["exec_command"]); @@ -379,7 +379,7 @@ describe("agentnote codex", () => { encoding: "utf-8", }); assert.ok(showOutput.includes("shell-note.txt"), "show should list the committed file"); - assert.ok(showOutput.includes("0%"), "show should report zero AI attribution"); + assert.ok(showOutput.includes("100%"), "show should report commit-level AI attribution"); }); it("does not write a git note when the Codex transcript cannot be read", () => { diff --git a/packages/cli/src/core/record.test.ts b/packages/cli/src/core/record.test.ts index 8549df9b..b84dc2ce 100644 --- a/packages/cli/src/core/record.test.ts +++ b/packages/cli/src/core/record.test.ts @@ -226,6 +226,7 @@ type CodexShellOnlyFallbackSimulationCase = { crossTurnCommit: boolean; expectedSkip: boolean; legacySkip: boolean; + expectedCommitLevelAttribution: boolean; }; const PROMPT_BOUNDARY_SIMULATION_ANCHOR_SHAPE_SCORE = 44; @@ -654,6 +655,8 @@ function buildCodexShellOnlyFallbackSimulationCases(): CodexShellOnlyFallbackSim ...promptCase, expectedSkip: shouldSkipCodexHumanOnlySimulation(promptCase, "current"), legacySkip: shouldSkipCodexHumanOnlySimulation(promptCase, "legacy"), + expectedCommitLevelAttribution: + shouldUseCodexCommitLevelAttributionSimulation(promptCase), }); } } @@ -667,7 +670,10 @@ function buildCodexShellOnlyFallbackSimulationCases(): CodexShellOnlyFallbackSim } function shouldSkipCodexHumanOnlySimulation( - promptCase: Omit, + promptCase: Omit< + CodexShellOnlyFallbackSimulationCase, + "name" | "expectedSkip" | "legacySkip" | "expectedCommitLevelAttribution" + >, mode: "current" | "legacy", ): boolean { const hasCurrentUnattributedTool = promptCase.currentToolName !== "none"; @@ -682,6 +688,23 @@ function shouldSkipCodexHumanOnlySimulation( ); } +function shouldUseCodexCommitLevelAttributionSimulation( + promptCase: Omit< + CodexShellOnlyFallbackSimulationCase, + "name" | "expectedSkip" | "legacySkip" | "expectedCommitLevelAttribution" + >, +): boolean { + if (promptCase.hasPromptWindow || promptCase.hasAiFiles || promptCase.transcriptEditsCommit) { + return false; + } + + const hasCurrentToolFallback = + promptCase.currentToolName !== "none" && !promptCase.crossTurnCommit; + const hasPromptWindowFallback = + promptCase.canUsePromptOnlyFallback && promptCase.transcriptEditsOthers; + return hasPromptWindowFallback || hasCurrentToolFallback; +} + describe("prompt task-boundary policy simulation", () => { it("separates stale primary revival from legitimate split-commit carryover across 100+ cases", () => { const cases = buildPromptBoundarySimulationCases(); @@ -833,7 +856,7 @@ describe("prompt task-boundary policy simulation", () => { ); assert.ok( fixedLegacySkips.every((promptCase) => promptCase.currentToolName !== "none"), - "only current no-file tool activity should rescue the prompt-only note", + "only current no-file tool activity should rescue the commit-level note", ); assert.ok( fixedLegacySkips.every((promptCase) => !promptCase.crossTurnCommit), @@ -868,6 +891,44 @@ describe("prompt task-boundary policy simulation", () => { .every((promptCase) => promptCase.expectedSkip), "cross-turn shell-only activity must not be rescued without stronger evidence", ); + + const commitLevelCases = cases.filter( + (promptCase) => promptCase.expectedCommitLevelAttribution, + ); + assert.ok( + commitLevelCases.length >= 16, + "the simulation should cover many v0.2-style commit-level attribution rescues", + ); + assert.ok( + commitLevelCases.every((promptCase) => !promptCase.expectedSkip), + "commit-level attribution rescue must never apply to skipped human-only cases", + ); + assert.ok( + commitLevelCases.some( + (promptCase) => + promptCase.currentToolName !== "none" && + !promptCase.canUsePromptOnlyFallback && + !promptCase.crossTurnCommit, + ), + "current tool-backed Codex work should regain v0.2-style commit-level attribution", + ); + assert.ok( + commitLevelCases.some((promptCase) => promptCase.canUsePromptOnlyFallback), + "prompt-window fallback should also regain commit-level attribution", + ); + assert.ok( + cases + .filter( + (promptCase) => + promptCase.currentToolName === "none" && + !promptCase.canUsePromptOnlyFallback && + !promptCase.hasPromptWindow && + !promptCase.hasAiFiles && + !promptCase.transcriptEditsCommit, + ) + .every((promptCase) => !promptCase.expectedCommitLevelAttribution), + "prompt-only stale pointer cases without current tool evidence must stay unattributed", + ); }); }); @@ -1880,7 +1941,7 @@ describe("recordCommitEntry", () => { }); const note = await readNote(commitSha); - assert.ok(note !== null, "missing transcript should still leave a prompt-only note"); + assert.ok(note !== null, "missing transcript should still leave a file-level note"); const interactions = note.interactions as Array<{ prompt: string; response: string | null; @@ -2073,7 +2134,7 @@ describe("recordCommitEntry", () => { writeFileSync( join(sessionDir, PROMPTS_FILE), '{"event":"prompt","prompt":"long earlier discussion about generated files and dashboard deploy details that is no longer the best explanation","turn":1,"timestamp":"2026-04-13T10:00:00Z"}\n' + - '{"event":"prompt","prompt":"Missing commit notes should keep a prompt-only note when Codex misses commit files\\n- keep the human-only skip\\n- rescue only the current implementation thread","turn":2,"timestamp":"2026-04-13T10:00:01Z"}\n' + + '{"event":"prompt","prompt":"Missing commit notes should keep commit-level attribution when Codex misses commit files\\n- keep the human-only skip\\n- rescue only the current implementation thread","turn":2,"timestamp":"2026-04-13T10:00:01Z"}\n' + '{"event":"prompt","prompt":"yes, implement that","turn":3,"timestamp":"2026-04-13T10:00:02Z"}\n' + '{"event":"prompt","prompt":"apply the record fallback in record.ts","turn":4,"timestamp":"2026-04-13T10:00:03Z"}\n', ); @@ -2100,7 +2161,7 @@ describe("recordCommitEntry", () => { (interaction) => interaction.prompt, ); assert.deepEqual(prompts, [ - "Missing commit notes should keep a prompt-only note when Codex misses commit files\n- keep the human-only skip\n- rescue only the current implementation thread", + "Missing commit notes should keep commit-level attribution when Codex misses commit files\n- keep the human-only skip\n- rescue only the current implementation thread", "yes, implement that", "apply the record fallback in record.ts", ]); @@ -2765,7 +2826,7 @@ describe("recordCommitEntry", () => { } }); - it("transcript-driven Codex keeps prompt-only notes for current shell-only tool turns without guessing files", async () => { + it("transcript-driven Codex gives current shell-only tool turns commit-level attribution", async () => { const codexHome = mkdtempSync(join(tmpdir(), "codex-home-")); const prevCodexHome = process.env.CODEX_HOME; process.env.CODEX_HOME = codexHome; @@ -2817,7 +2878,11 @@ describe("recordCommitEntry", () => { const note = await readNote(commitSha); assert.ok(note !== null, "current shell-only Codex work should still leave a note"); - assert.equal((note.attribution as { ai_ratio: number }).ai_ratio, 0); + assert.equal((note.attribution as { ai_ratio: number; method: string }).method, "file"); + assert.equal((note.attribution as { ai_ratio: number }).ai_ratio, 100); + assert.deepEqual(note.files, [ + { path: "website/src/content/docs/agent-support.mdx", by_ai: true }, + ]); const interactions = note.interactions as Array<{ prompt: string; response: string | null; @@ -2844,7 +2909,7 @@ describe("recordCommitEntry", () => { } }); - it("mid-session Codex commit keeps a prompt-only note when transcript attribution misses commit files", async () => { + it("mid-session Codex commit keeps AI attribution when transcript file matching misses", async () => { const codexHome = mkdtempSync(join(tmpdir(), "codex-home-")); const prevCodexHome = process.env.CODEX_HOME; process.env.CODEX_HOME = codexHome; @@ -2905,7 +2970,7 @@ describe("recordCommitEntry", () => { }); const note = await readNote(commitSha); - assert.ok(note !== null, "mid-session false negative should still leave a prompt-only note"); + assert.ok(note !== null, "mid-session false negative should still leave a file-level note"); const interactions = note.interactions as Array<{ prompt: string; response: string | null; @@ -2922,7 +2987,12 @@ describe("recordCommitEntry", () => { assert.equal(interactions[1].response, "Proceeding with the workflow cleanup."); assert.equal(interactions[0].files_touched, undefined); assert.equal(interactions[1].files_touched, undefined); - assert.equal((note.attribution as { ai_ratio: number }).ai_ratio, 0); + assert.equal((note.attribution as { ai_ratio: number; method: string }).method, "file"); + assert.equal((note.attribution as { ai_ratio: number }).ai_ratio, 100); + assert.deepEqual(note.files, [ + { path: ".github/workflows/test.yml", by_ai: true }, + { path: "docs.md", by_ai: true }, + ]); } finally { if (prevCodexHome === undefined) { delete process.env.CODEX_HOME; @@ -2933,7 +3003,7 @@ describe("recordCommitEntry", () => { } }); - it("mid-session Codex prompt-only fallback trims stale discussion before the commit window anchor", async () => { + it("mid-session Codex commit-level fallback trims stale discussion before the commit window anchor", async () => { const codexHome = mkdtempSync(join(tmpdir(), "codex-home-")); const prevCodexHome = process.env.CODEX_HOME; process.env.CODEX_HOME = codexHome; @@ -2953,7 +3023,7 @@ describe("recordCommitEntry", () => { '{"timestamp":"2026-04-15T09:30:03Z","type":"response_item","payload":{"type":"function_call","name":"apply_patch","call_id":"c1","arguments":"{\\"input\\":\\"*** Begin Patch\\\\n*** Add File: first.ts\\\\n+export const first = 1;\\\\n*** End Patch\\"}"}}', '{"timestamp":"2026-04-15T09:30:04Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"older prompt-selection v2 and generated artifact discussion that should not be the main note anchor"}]}}', '{"timestamp":"2026-04-15T09:30:05Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"older context"}]}}', - '{"timestamp":"2026-04-15T09:30:06Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"keep a prompt-only note for Codex when transcript attribution misses commit files\\n- keep human-only commits skipped\\n- keep only the current commit window"}]}}', + '{"timestamp":"2026-04-15T09:30:06Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"keep commit-level attribution for Codex when transcript attribution misses commit files\\n- keep human-only commits skipped\\n- keep only the current commit window"}]}}', '{"timestamp":"2026-04-15T09:30:07Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"I\\u0027ll keep the fallback scoped to the current commit window."}]}}', '{"timestamp":"2026-04-15T09:30:08Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"yes, implement that"}]}}', '{"timestamp":"2026-04-15T09:30:09Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Implementing the narrow fallback now."}]}}', @@ -2965,7 +3035,7 @@ describe("recordCommitEntry", () => { join(sessionDir, PROMPTS_FILE), '{"event":"prompt","prompt":"edit first.ts","prompt_id":"id-first","turn":1,"timestamp":"2026-04-15T09:30:01Z"}\n' + '{"event":"prompt","prompt":"older prompt-selection v2 and generated artifact discussion that should not be the main note anchor","prompt_id":"id-old","turn":2,"timestamp":"2026-04-15T09:30:04Z"}\n' + - '{"event":"prompt","prompt":"keep a prompt-only note for Codex when transcript attribution misses commit files\\n- keep human-only commits skipped\\n- keep only the current commit window","prompt_id":"id-plan","turn":3,"timestamp":"2026-04-15T09:30:06Z"}\n' + + '{"event":"prompt","prompt":"keep commit-level attribution for Codex when transcript attribution misses commit files\\n- keep human-only commits skipped\\n- keep only the current commit window","prompt_id":"id-plan","turn":3,"timestamp":"2026-04-15T09:30:06Z"}\n' + '{"event":"prompt","prompt":"yes, implement that","prompt_id":"id-go","turn":4,"timestamp":"2026-04-15T09:30:08Z"}\n', ); writeFileSync(join(sessionDir, TURN_FILE), "4\n"); @@ -2998,11 +3068,13 @@ describe("recordCommitEntry", () => { const note = await readNote(commitSha); assert.ok(note !== null); + assert.equal((note.attribution as { ai_ratio: number; method: string }).method, "file"); + assert.equal((note.attribution as { ai_ratio: number }).ai_ratio, 100); const interactions = note.interactions as Array<{ prompt: string }>; assert.deepEqual( interactions.map((interaction) => interaction.prompt), [ - "keep a prompt-only note for Codex when transcript attribution misses commit files\n- keep human-only commits skipped\n- keep only the current commit window", + "keep commit-level attribution for Codex when transcript attribution misses commit files\n- keep human-only commits skipped\n- keep only the current commit window", "yes, implement that", ], ); diff --git a/packages/cli/src/core/record.ts b/packages/cli/src/core/record.ts index 0c75a845..da741f62 100644 --- a/packages/cli/src/core/record.ts +++ b/packages/cli/src/core/record.ts @@ -263,6 +263,7 @@ export async function recordCommitEntry(opts: { let interactions: Interaction[]; let transcriptLineCounts: LineCounts | undefined; + let useCommitLevelAttribution = false; // Session entries that contributed to this commit's interactions. Passed // to recordConsumedPairs so maxConsumedTurn advances even when no // file_change/pre_blob entries exist (e.g. Codex transcript-driven path). @@ -307,7 +308,7 @@ export async function recordCommitEntry(opts: { // Two restrictions keep this narrow: // 1. transcript must reference edits on other files — a shell-only // Codex session legitimately wants the prompt/response preserved - // even with no file attribution. + // with commit-level file attribution. // 2. transcript must NOT reference commit files — legitimate AI work on // this commit still goes through the pairing path below. const transcriptEditsCommit = allInteractions.some((i) => @@ -401,6 +402,7 @@ export async function recordCommitEntry(opts: { return { prompt: (entry.prompt as string) ?? "", response: null }; }); consumedPromptEntries = promptOnlyFallbackEntries.consumed; + useCommitLevelAttribution = true; } else if (transcriptPath && allInteractions.length > 0) { // Transcript-driven path: sessions that don't emit `file_change` events // (e.g. Codex) derive their causal window from transcript interactions. @@ -490,6 +492,7 @@ export async function recordCommitEntry(opts: { commitFileSet, currentUnattributedToolPromptIds, ); + useCommitLevelAttribution = interactions.length > 0; } else { interactions = []; } @@ -518,6 +521,13 @@ export async function recordCommitEntry(opts: { interactions = prompts.map((p) => ({ prompt: p, response: null })); } + // If the current Agent transcript proves tool-backed work but exact file + // touches are unavailable, keep the v0.2-style commit-level attribution. + // Per-prompt files_touched still stays empty because that data is not exact. + if (useCommitLevelAttribution && aiFiles.length === 0 && interactions.length > 0) { + aiFiles = commitFiles; + } + await fillInteractionResponsesFromEvents(sessionDir, relevantPromptEntries, interactions); await attachInteractionContexts( sessionDir, @@ -773,7 +783,7 @@ function filterInteractionCommitFiles( ); } -/** Prefer tool-backed fallback rows without guessing shell-only file authorship. */ +/** Prefer tool-backed fallback rows when exact file touches are unavailable. */ function selectTranscriptFallbackInteractions( interactions: TranscriptInteraction[], commitFileSet: Set, @@ -798,7 +808,7 @@ function selectTranscriptFallbackInteractions( return latestToolBacked ? [toRecordedInteraction(latestToolBacked, commitFileSet)] : []; } -/** Current-window tool turns without file evidence, kept as prompt-only notes. */ +/** Current-window tool turns without per-file evidence. */ function collectCurrentUnattributedToolPromptIds( interactions: TranscriptInteraction[], promptEntries: Record[], diff --git a/website/src/content/docs/agent-support.mdx b/website/src/content/docs/agent-support.mdx index 3b87e42a..d464abaf 100644 --- a/website/src/content/docs/agent-support.mdx +++ b/website/src/content/docs/agent-support.mdx @@ -66,7 +66,7 @@ Claude Code provides hook-native prompt, response, and edit data. Agent Note rec Codex CLI support is transcript-driven. Agent Note reads local transcripts, connects `apply_patch` operations to committed files, and upgrades to line-level only when patch counts match the final diff. -Shell-only edits are not guessed from command text. If no reliable transcript is available, Agent Note avoids writing uncertain attribution. +Shell-only edits do not create per-prompt `files_touched` from command text. When the current Codex transcript is trusted, Agent Note can still mark the commit files as AI-assisted at commit-level. ### Cursor diff --git a/website/src/content/docs/data-and-privacy.mdx b/website/src/content/docs/data-and-privacy.mdx index 32a05889..ff4a217b 100644 --- a/website/src/content/docs/data-and-privacy.mdx +++ b/website/src/content/docs/data-and-privacy.mdx @@ -67,7 +67,7 @@ Agent Note does not store everything the agent can see. - It does not store your full workspace. - It does not store every shell command output. -- It does not infer AI-authored files from shell-only edits. +- It does not store shell command output as file evidence; trusted Agent transcripts may still produce commit-level attribution. - It does not upload data to an Agent Note backend. ## Team Visibility diff --git a/website/src/content/docs/de/data-and-privacy.mdx b/website/src/content/docs/de/data-and-privacy.mdx index 2d5b1a89..65231d19 100644 --- a/website/src/content/docs/de/data-and-privacy.mdx +++ b/website/src/content/docs/de/data-and-privacy.mdx @@ -55,7 +55,7 @@ Das Schema steht in [How It Works](./how-it-works/#note-schema). - Der vollständige Workspace. - Alle Shell-Ausgaben. -- Dateien, die nur anhand von Shell-Befehlen der KI zugeschrieben würden. +- Shell command output als file evidence. Vertrauenswürdige Agent transcripts können trotzdem commit-level attribution erzeugen. - Daten an einen Agent Note Backend-Service. ## Sichtbarkeit im Team diff --git a/website/src/content/docs/de/how-it-works.mdx b/website/src/content/docs/de/how-it-works.mdx index f3b08895..1c214618 100644 --- a/website/src/content/docs/de/how-it-works.mdx +++ b/website/src/content/docs/de/how-it-works.mdx @@ -167,7 +167,7 @@ Agent Note und Entire lösen verwandte, aber unterschiedliche Probleme. | **Stop** | Protokolliert das Stop-Ereignis (Heartbeat bleibt aktiv — Stop = Ende der Antwort, nicht Ende der Sitzung) |