diff --git a/AGENTS.md b/AGENTS.md index 91c9e604..b3aa2443 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 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`. - **`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. @@ -157,6 +157,6 @@ Each `UserPromptSubmit` increments a turn counter. File changes inherit the curr - **Never break git commit.** All agent-note recording is wrapped in try/catch. If agent-note fails, the commit must still succeed. - **All source code in English.** Comments, variable names, CLI output, test descriptions — everything in English. - **PreToolUse hooks are synchronous.** Must write JSON to stdout, must not be marked `async: true`. -- **Input validation.** Session IDs must match UUID v4. `transcript_path` must be under the agent's home directory (e.g. `~/.claude/` for Claude Code, `~/.gemini/` for Gemini CLI). +- **Input validation.** Environment-provided session IDs must use canonical UUID format. `transcript_path` must be under the agent's home directory (e.g. `~/.claude/` for Claude Code, `~/.gemini/` for Gemini CLI). - **Git notes for persistent storage.** Entry data goes to `refs/notes/agentnote`, not to files. - **Biome for lint + format.** Run `npm run lint` (biome check) and `npm run typecheck` (tsc) separately. Both must pass in CI. diff --git a/CLAUDE.md b/CLAUDE.md index bcd2370d..0fafd53e 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 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`. - **`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. @@ -157,6 +157,6 @@ Each `UserPromptSubmit` increments a turn counter. File changes inherit the curr - **Never break git commit.** All agent-note recording is wrapped in try/catch. If agent-note fails, the commit must still succeed. - **All source code in English.** Comments, variable names, CLI output, test descriptions — everything in English. - **PreToolUse hooks are synchronous.** Must write JSON to stdout, must not be marked `async: true`. -- **Input validation.** Session IDs must match UUID v4. `transcript_path` must be under the agent's home directory (e.g. `~/.claude/` for Claude Code, `~/.gemini/` for Gemini CLI). +- **Input validation.** Environment-provided session IDs must use canonical UUID format. `transcript_path` must be under the agent's home directory (e.g. `~/.claude/` for Claude Code, `~/.gemini/` for Gemini CLI). - **Git notes for persistent storage.** Entry data goes to `refs/notes/agentnote`, not to files. - **Biome for lint + format.** Run `npm run lint` (biome check) and `npm run typecheck` (tsc) separately. Both must pass in CI. diff --git a/docs/architecture.md b/docs/architecture.md index 7d660787..1d78076c 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. | -| `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. | +| `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. | +| `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. +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. + ### Git hook installation `agent-note init` installs git hooks respecting the repository's hook directory: @@ -623,7 +625,7 @@ Notes are automatically pushed to the remote via the `pre-push` git hook install - **All source in English.** Comments, output, tests. - **Git hooks for commit ops.** Trailer injection (`prepare-commit-msg`) and note recording (`post-commit`) use git hooks, not agent hooks. Respects `core.hooksPath` and chains with existing hooks. - **No telemetry, no auth, no external services.** Data stays local until pushed. The `pre-push` git hook (installed by `agent-note init`) auto-pushes notes alongside code on every `git push`. -- **Input validation.** Session IDs must match `/^[0-9a-f-]{36}$/` (UUID v4). `transcript_path` must be under `~/.claude/` (or agent equivalent). Reject anything else silently. +- **Input validation.** Environment-provided session IDs must use canonical UUID format. `transcript_path` must be under `~/.claude/` (or agent equivalent). Reject anything else silently. - **Full response storage.** AI responses are stored in full. Git notes blobs are compressed and well within GitHub limits. ## Security @@ -642,7 +644,7 @@ Agent Note records prompts, optional display-only context excerpts, and AI respo | Threat | Mitigation | |---|---| | Secrets in prompts/context/responses | **Not automatically redacted.** Users are responsible for reviewing notes before pushing them to public repos. | -| Command injection via session ID | Session ID validated as UUID v4 before trailer injection. Non-matching IDs are silently dropped. | +| Command injection via session ID | Environment-provided session IDs are validated as canonical UUIDs before fallback recording. Non-matching IDs are silently dropped. | | Transcript path traversal | `transcript_path` must be under `~/.claude/` (or agent equivalent). Paths outside are rejected. | | git notes tampering | Anyone with repo write access can modify or delete notes. Notes are **not signed or encrypted**. Treat them as advisory, not as audit trail. | | GitHub Action markdown injection | PR Report renders prompts/context/responses as markdown inside the PR body. Treat git notes as trusted repository data; do not push untrusted prompt content to public notes. | 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 34b6772a..e6c2f672 100644 --- a/docs/knowledge/investigations.md +++ b/docs/knowledge/investigations.md @@ -47,14 +47,26 @@ - 観測結果: 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 に直接接続できる場合は通常の file/line attribution を使い、file touch を特定できなくても current transcript の mutating shell work がある場合は v0.2 系に近い commit-level attribution として commit files を AI 扱いします。古い transcript mtime は拒否するため、stale な Codex session は救済しません。 +- Follow-up 修正 2: `--fallback-env` は current process の fresh transcript を信用する一方で、record 時の prompt selection が stale な `.git/agentnote/sessions//prompts.jsonl` に引きずられると、fresh transcript が commit file に接続できても note が作られないことがありました。environment fallback では stale repo-local prompt window に依存せず、fresh transcript の末尾から commit files を覆う最小の matched interaction を選びます。親 commit 以降の matched row があればそれを優先し、なければ前 commit の直前から準備していた同一 task の matched row を救済します。これにより、cmux のような host で `.git/agentnote/session` が古くても、現在の Codex transcript に file evidence があれば note が作られます。 +- Follow-up 修正 2 の tolerance: Git commit timestamp と transcript JSONL write timestamp には数秒のズレがあり得るため、parent / HEAD 境界には 30 秒の許容幅を持たせます。これは clock skew の吸収には十分で、commit 後に行う debug / verification prompt を混ぜるには短い値として選んでいます。 +- Follow-up 修正 3: 実 repository の Codex transcript は 600MB を超えることがあり、adapter が `readFile()` で transcript 全体を文字列化すると Node の string size / memory 制限で失敗し、post-commit hook の safety catch により note が silently skipped されました。Codex transcript parser は JSONL を stream で読み、巨大 transcript でも現在 commit に接続できる interaction を抽出できるようにします。 +- Follow-up 修正 4: 後追い recording や遅延した fallback では、fresh transcript の中に対象 commit 後の debug / verification conversation も含まれます。recording は HEAD commit timestamp より後の transcript row を除外し、未来の同一 file edit を誤って prompt として採用しません。 +- Follow-up 修正 5: `writeNote()` が `git notes add` の non-zero exit を `gitSafe()` 経由で飲み込むと、recording は `promptCount > 0` を返すのに git note が存在しない状態になります。`writeNote()` は git note 書き込み失敗を例外にし、hook entrypoint 側の safety catch で warning に変換します。これにより commit は壊さず、PR Report の `—` 原因を診断できます。 #### 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 は救済しません。 +- `--fallback-head` は prompt-only / metadata-only / unrelated file evidence / same-path different-blob evidence を救済しません。これは stale `.git/agentnote/session` pointer を再び信用しないためです。 +- `--fallback-env` は `.git/agentnote/session` を使わず、adapter が current process から読み出した session id だけを候補にします。現時点では Codex の `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 に commit file と直接つながる interaction がある場合、stale な repo-local prompt window ではなく transcript 側の matched interaction を採用します。複数 file の commit では、transcript 末尾から commit files を覆う最小の interaction set を使います。 +- `--fallback-env` で選ばれた current Codex transcript に mutating shell interaction がある場合、file touch が取れなくても commit-level attribution として commit files を `by_ai: true` にします。これは v1 の stale pointer guard は残しつつ、v0.2 系の「AI が関わった commit を見失わない」挙動へ戻すためです。`git status` や test run のような read-only shell activity は env fallback attribution には使いません。`files_touched` は per-prompt file evidence なので推測では埋めません。 +- Codex transcript は stream で読みます。これは memory optimization ではなく correctness requirement です。長期 session の transcript が巨大化しても、recording should fail safe only for unreadable files, not for large but valid JSONL. +- Transcript row の timestamp が HEAD commit より後の場合は recording から除外します。親 commit 以降の matched row がある場合は、親 commit より前の row も除外します。親 commit 以降に matched row がまったくない場合だけ、前 commit の直前から準備していた同一 task の matched row を救済します。これは後追い `--fallback-env` で、過去 task の同一ファイル prompt や対象 commit 後に実行した調査・debug prompt が PR Report に混入することを防ぎつつ、連続 commit の作業順で正しい transcript を見失わないためです。 - HEAD blob 読み取りは `git diff-tree -z --raw` を使います。NUL 区切りで読むことで、Git の `core.quotePath=true` による path quote を避け、`src/日本語 file.ts` のような path でも post-edit blob evidence を正しく照合します。 +- Git note 書き込み失敗は silent success にしません。`record` command は warning を出して終了し、git commit 自体は成功させます。 #### Display behavior @@ -64,8 +76,9 @@ #### 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 は拒否することを確認します。Codex session directory に古い `prompts.jsonl` が残っていても、fresh transcript が commit file に直接接続できれば note が作られる regression、parent commit 以降の matched row がある場合は parent より前の stale row を選ばない regression、parent commit より前に準備済みで newer match がない同一 task を救済する regression、read-only shell transcript を AI attribution にしない regression、mutating shell transcript を commit-level attribution として救済する regression、commit 後の future transcript row を選ばない regression もここで固定します。 - `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/core/storage.test.ts`: `git notes add` が失敗した場合に `writeNote()` が例外を返すことを確認し、`promptCount > 0` なのに note がない silent success を防ぎます。 - `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%` と表示されることを確認します。 @@ -74,11 +87,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/package-lock.json b/package-lock.json index e0fb5a92..c055044f 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": "^20.19.41", "esbuild": "^0.28.0", - "publint": "^0.3.18", + "publint": "^0.3.20", "tsx": "^4.21.0", "typescript": "^6.0.3" }, @@ -2525,6 +2525,16 @@ "node": ">=18" } }, + "packages/cli/node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "packages/cli/node_modules/esbuild": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", @@ -2567,6 +2577,13 @@ "@esbuild/win32-x64": "0.28.0" } }, + "packages/cli/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "packages/pr-report": { "name": "agent-note-pr-report", "version": "0.1.0", diff --git a/packages/cli/dist/cli.js b/packages/cli/dist/cli.js index c022b58f..d0178826 100755 --- a/packages/cli/dist/cli.js +++ b/packages/cli/dist/cli.js @@ -95,11 +95,12 @@ async function git(args2, options) { async function gitSafe(args2, options) { try { const stdout = await git(args2, options); - return { stdout, exitCode: 0 }; + return { stdout, stderr: "", exitCode: 0 }; } catch (err) { const e = err; return { stdout: typeof e.stdout === "string" ? e.stdout.trim() : "", + stderr: typeof e.stderr === "string" ? e.stderr.trim() : "", exitCode: typeof e.code === "number" ? e.code : 1 }; } @@ -700,15 +701,18 @@ var claude = { }; // src/agents/codex.ts -import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync } from "node:fs"; +import { createReadStream, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync } from "node:fs"; import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises"; import { homedir as homedir2 } from "node:os"; import { isAbsolute, join as join2, relative, resolve as resolve2, sep as sep2 } from "node:path"; +import { createInterface } from "node:readline"; var CONFIG_REL_PATH = ".codex/config.toml"; var ENV_CODEX_HOME = "CODEX_HOME"; +var ENV_CODEX_THREAD_ID = "CODEX_THREAD_ID"; var HOOKS_REL_PATH = ".codex/hooks.json"; var HOOK_COMMAND2 = `npx --yes agent-note hook --agent ${AGENT_NAMES.codex}`; var TRANSCRIPT_PREVIEW_CHARS = 4096; +var SHELL_MUTATION_COMMAND_RE = /(^|[;&|]\s*)(apply_patch|cat\s+>|cp\b|install\b|mkdir\b|mv\b|npm\s+(audit\s+fix|dedupe|install|update|version)\b|perl\s+-[^\n;&|]*i|pnpm\s+(add|install|update)\b|rm\b|sed\s+-[^\n;&|]*i|tee\b|touch\b|yarn\s+(add|install|upgrade)\b)|(\s|^)(>|>>)\s*\S+/; var CODEX_HOOK_EVENTS = { sessionStart: "SessionStart", userPromptSubmit: "UserPromptSubmit", @@ -778,6 +782,29 @@ function collectPatchStrings(value, seen = /* @__PURE__ */ new Set()) { ...collectPatchStrings(value.text, seen) ]; } +function collectCommandStrings(value, seen = /* @__PURE__ */ new Set()) { + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return []; + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + const parsed = parseJsonString(value); + if (parsed !== value) return collectCommandStrings(parsed, seen); + } + return [trimmed]; + } + if (!value || seen.has(value)) return []; + seen.add(value); + if (Array.isArray(value)) { + return value.flatMap((item) => collectCommandStrings(item, seen)); + } + if (!isRecord(value)) return []; + return [ + ...collectCommandStrings(value.cmd, seen), + ...collectCommandStrings(value.command, seen), + ...collectCommandStrings(value.script, seen), + ...collectCommandStrings(value.shell, seen) + ]; +} function readTranscriptSessionId(candidate) { try { const preview = readFileSync(candidate, TEXT_ENCODING).slice(0, TRANSCRIPT_PREVIEW_CHARS); @@ -961,6 +988,15 @@ function appendInteractionTool(interaction, toolName) { if (tools.includes(toolName)) return; interaction.tools = [...tools, toolName]; } +function appendInteractionMutationTool(interaction, toolName) { + if (!toolName) return; + const tools = interaction.mutation_tools ?? []; + if (tools.includes(toolName)) return; + interaction.mutation_tools = [...tools, toolName]; +} +function isMutatingShellCommand(command2) { + return SHELL_MUTATION_COMMAND_RE.test(command2); +} var codex = { name: AGENT_NAMES.codex, settingsRelPath: CONFIG_REL_PATH, @@ -1063,6 +1099,9 @@ var codex = { if (!existsSync2(sessionsDir)) return null; return findTranscriptCandidate(sessionsDir, sessionId); }, + readEnvironmentSessionId() { + return process.env[ENV_CODEX_THREAD_ID] ?? null; + }, async extractInteractions(transcriptPath) { if (!isValidTranscriptPath2(transcriptPath)) { throw new Error(`Invalid Codex transcript path: ${transcriptPath}`); @@ -1070,85 +1109,94 @@ var codex = { if (!existsSync2(transcriptPath)) { throw new Error(`Codex transcript not found: ${transcriptPath}`); } - let content; - try { - content = await readFile2(transcriptPath, TEXT_ENCODING); - } catch { - throw new Error(`Failed to read Codex transcript: ${transcriptPath}`); - } const interactions = []; let current = null; let sessionCwd; - for (const rawLine of content.split("\n")) { - const line = rawLine.trim(); - if (!line) continue; - let entry; - try { - entry = JSON.parse(line); - } catch { - continue; - } - if (entry.type === "session_meta" && typeof entry.payload?.cwd === "string") { - sessionCwd = entry.payload.cwd; - continue; - } - if (entry.type !== "response_item" || !entry.payload) continue; - const payload = entry.payload; - const payloadType = typeof payload.type === "string" ? payload.type : void 0; - const payloadRole = typeof payload.role === "string" ? payload.role : void 0; - if (payloadType === "message" && payloadRole === "user") { - const prompt = collectMessageText(payload.content).join("\n"); - if (!prompt) continue; - if (current) interactions.push(current); - current = { prompt, response: null }; - if (typeof entry.timestamp === "string") current.timestamp = entry.timestamp; - continue; - } - if (!current) continue; - if (payloadType === "message" && payloadRole === "assistant") { - const response = collectMessageText(payload.content).join("\n"); - if (response) { - current.response = current.response ? `${current.response} -${response}` : response; + const lines = createInterface({ + input: createReadStream(transcriptPath, { encoding: TEXT_ENCODING }), + crlfDelay: Number.POSITIVE_INFINITY + }); + try { + for await (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) continue; + let entry; + try { + entry = JSON.parse(line); + } catch { + continue; } - continue; - } - const toolName = typeof payload.name === "string" ? payload.name : typeof payload.call_name === "string" ? payload.call_name : void 0; - if ((payloadType === "custom_tool_call" || payloadType === "function_call" || payloadType === "tool_use") && toolName) { - appendInteractionTool(current, toolName); - } - if ((payloadType === "custom_tool_call" || payloadType === "function_call") && toolName === "apply_patch") { - const patchInputs = [ - ...collectPatchStrings(payload.input), - ...collectPatchStrings(payload.arguments) - ]; - const files = []; - const fileSeen = /* @__PURE__ */ new Set(); - current.line_stats = current.line_stats ?? {}; - for (const patchInput of patchInputs) { - for (const file of extractFilesFromApplyPatch(patchInput)) { - const normalized = normalizeInteractionFilePath(file, sessionCwd); - if (!normalized) continue; - appendUnique(files, fileSeen, normalized); - } - const lineStats = extractLineStatsFromApplyPatch(patchInput); - for (const [file, stats] of Object.entries(lineStats)) { - const normalized = normalizeInteractionFilePath(file, sessionCwd); - if (!normalized) continue; - const previous = current.line_stats[normalized] ?? { added: 0, deleted: 0 }; - current.line_stats[normalized] = { - added: previous.added + stats.added, - deleted: previous.deleted + stats.deleted - }; + if (entry.type === "session_meta" && typeof entry.payload?.cwd === "string") { + sessionCwd = entry.payload.cwd; + continue; + } + if (entry.type !== "response_item" || !entry.payload) continue; + const payload = entry.payload; + const payloadType = typeof payload.type === "string" ? payload.type : void 0; + const payloadRole = typeof payload.role === "string" ? payload.role : void 0; + if (payloadType === "message" && payloadRole === "user") { + const prompt = collectMessageText(payload.content).join("\n"); + if (!prompt) continue; + if (current) interactions.push(current); + current = { prompt, response: null }; + if (typeof entry.timestamp === "string") current.timestamp = entry.timestamp; + continue; + } + if (!current) continue; + if (payloadType === "message" && payloadRole === "assistant") { + const response = collectMessageText(payload.content).join("\n"); + if (response) { + current.response = current.response ? `${current.response} +${response}` : response; } + continue; } - if (files.length > 0) { - current.files_touched = [.../* @__PURE__ */ new Set([...current.files_touched ?? [], ...files])]; + const toolName = typeof payload.name === "string" ? payload.name : typeof payload.call_name === "string" ? payload.call_name : void 0; + if ((payloadType === "custom_tool_call" || payloadType === "function_call" || payloadType === "tool_use") && toolName) { + appendInteractionTool(current, toolName); + if (toolName === "exec_command" && [ + ...collectCommandStrings(payload.input), + ...collectCommandStrings(payload.arguments) + ].some(isMutatingShellCommand)) { + appendInteractionMutationTool(current, toolName); + } } - if (Object.keys(current.line_stats).length === 0) { - delete current.line_stats; + if ((payloadType === "custom_tool_call" || payloadType === "function_call") && toolName === "apply_patch") { + const patchInputs = [ + ...collectPatchStrings(payload.input), + ...collectPatchStrings(payload.arguments) + ]; + const files = []; + const fileSeen = /* @__PURE__ */ new Set(); + current.line_stats = current.line_stats ?? {}; + for (const patchInput of patchInputs) { + for (const file of extractFilesFromApplyPatch(patchInput)) { + const normalized = normalizeInteractionFilePath(file, sessionCwd); + if (!normalized) continue; + appendUnique(files, fileSeen, normalized); + } + const lineStats = extractLineStatsFromApplyPatch(patchInput); + for (const [file, stats] of Object.entries(lineStats)) { + const normalized = normalizeInteractionFilePath(file, sessionCwd); + if (!normalized) continue; + const previous = current.line_stats[normalized] ?? { added: 0, deleted: 0 }; + current.line_stats[normalized] = { + added: previous.added + stats.added, + deleted: previous.deleted + stats.deleted + }; + } + } + if (files.length > 0) { + current.files_touched = [.../* @__PURE__ */ new Set([...current.files_touched ?? [], ...files])]; + } + appendInteractionMutationTool(current, toolName); + if (Object.keys(current.line_stats).length === 0) { + delete current.line_stats; + } } } + } catch { + throw new Error(`Failed to read Codex transcript: ${transcriptPath}`); } if (current) interactions.push(current); return interactions; @@ -3369,7 +3417,10 @@ async function hasRecordableSessionData(sessionDir) { // src/core/storage.ts async function writeNote(commitSha, data) { const body = JSON.stringify(data, null, 2); - await gitSafe(["notes", `--ref=${NOTES_REF}`, "add", "-f", "-m", body, commitSha]); + const result = await gitSafe(["notes", `--ref=${NOTES_REF}`, "add", "-f", "-m", body, commitSha]); + if (result.exitCode !== 0) { + throw new Error(result.stderr || "failed to write Agent Note git note"); + } } async function readNote(commitSha) { const { stdout, exitCode } = await gitSafe(["notes", `--ref=${NOTES_REF}`, "show", commitSha]); @@ -3385,6 +3436,8 @@ async function readNote(commitSha) { var AGENTNOTE_IGNORE_MAX_PATTERN_LENGTH = 200; 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; async function recordCommitEntry(opts) { const sessionDir = join6(opts.agentnoteDirPath, SESSIONS_DIR, opts.sessionId); const sessionAgent = await readSessionAgent(sessionDir); @@ -3395,7 +3448,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 { } @@ -3405,6 +3466,8 @@ async function recordCommitEntry(opts) { commitSubject = await git(["show", "-s", "--format=%s", "HEAD"]); } catch { } + const commitTimestampMs = await readHeadCommitTimestampMs(); + const parentCommitTimestampMs = await readHeadParentCommitTimestampMs(); let commitDiffText = ""; try { commitDiffText = await git(["show", "--format=", "--patch", "--unified=0", "HEAD"]); @@ -3518,12 +3581,18 @@ async function recordCommitEntry(opts) { } let interactions; let transcriptLineCounts; + let useCommitLevelAttribution = false; let consumedPromptEntries = []; let consumedTranscriptPromptFiles = []; let allInteractions = []; if (transcriptPath) { try { allInteractions = await adapter.extractInteractions(transcriptPath); + allInteractions = filterTranscriptInteractionsForCommitWindow( + allInteractions, + null, + commitTimestampMs + ); } catch (err) { if (!crossTurnCommit) throw err; } @@ -3601,6 +3670,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)) @@ -3612,6 +3682,7 @@ async function recordCommitEntry(opts) { consumedPromptState, currentTurn ); + let attributionTranscriptMatched = selectableTranscriptMatched; const transcriptPrimaryTurns = await selectTranscriptPrimaryTurns( selectableTranscriptMatched, promptEntries, @@ -3668,30 +3739,48 @@ async function recordCommitEntry(opts) { (i) => toRecordedInteraction(i, commitFileSet, consumedPromptState) ); useSelectableTranscriptAttribution = true; - } else if (!crossTurnCommit && transcriptMatched.length === 0) { + } else if (opts.allowEnvironmentTranscriptFallback && transcriptMatched.length > 0) { + const envTranscriptMatched = selectEnvironmentTranscriptSourceInteractions( + transcriptMatched, + parentCommitTimestampMs + ); + const envMatched = selectEnvironmentTranscriptMatchedInteractions( + envTranscriptMatched, + commitFileSet, + consumedPromptState + ); + interactions = envMatched.map( + (i) => toRecordedInteraction(i, commitFileSet, consumedPromptState) + ); + attributionTranscriptMatched = envMatched; + useSelectableTranscriptAttribution = true; + } else if (!crossTurnCommit && transcriptMatched.length === 0 && canUseUnmatchedTranscriptFallback(opts.allowEnvironmentTranscriptFallback, allInteractions)) { + const fallbackSourceInteractions = opts.allowEnvironmentTranscriptFallback ? filterTranscriptInteractionsAfterParent(allInteractions, parentCommitTimestampMs) : allInteractions; interactions = selectTranscriptFallbackInteractions( - allInteractions, + fallbackSourceInteractions, commitFileSet, - currentUnattributedToolPromptIds + currentUnattributedToolPromptIds, + { requireMutationTool: opts.allowEnvironmentTranscriptFallback === true } ); + useCommitLevelAttribution = interactions.length > 0; } else { interactions = []; } - if (useSelectableTranscriptAttribution && selectableTranscriptMatched.length > 0) { + if (useSelectableTranscriptAttribution && attributionTranscriptMatched.length > 0) { aiFiles = [ ...new Set( - selectableTranscriptMatched.flatMap( + attributionTranscriptMatched.flatMap( (i) => filterInteractionCommitFiles(i, commitFileSet, consumedPromptState) ) ) ]; transcriptLineCounts = await resolveTranscriptLineCounts( lineCountCommitFileSet, - selectableTranscriptMatched, + attributionTranscriptMatched, consumedPromptState ); consumedTranscriptPromptFiles = collectConsumedTranscriptPromptFiles( - selectableTranscriptMatched, + attributionTranscriptMatched, promptEntries, commitFileSet, consumedPromptState @@ -3700,6 +3789,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, @@ -3816,6 +3908,46 @@ function parseTimestampMs(value) { const parsed = Date.parse(value); return Number.isNaN(parsed) ? null : parsed; } +async function readHeadCommitTimestampMs() { + try { + return parseTimestampMs(await git(["show", "-s", "--format=%cI", "HEAD"])); + } catch { + return null; + } +} +async function readHeadParentCommitTimestampMs() { + try { + return parseTimestampMs(await git(["show", "-s", "--format=%cI", "HEAD^"])); + } catch { + return null; + } +} +function filterTranscriptInteractionsForCommitWindow(interactions, parentCommitTimestampMs, commitTimestampMs) { + const lowerBoundMs = parentCommitTimestampMs === null ? null : parentCommitTimestampMs - TRANSCRIPT_COMMIT_PAST_TOLERANCE_MS; + const upperBoundMs = commitTimestampMs === null ? null : commitTimestampMs + TRANSCRIPT_COMMIT_FUTURE_TOLERANCE_MS; + return interactions.filter((interaction) => { + const interactionMs = parseTimestampMs(interaction.timestamp); + if (interactionMs === null) return true; + if (lowerBoundMs !== null && interactionMs < lowerBoundMs) return false; + return upperBoundMs === null || interactionMs <= upperBoundMs; + }); +} +function selectEnvironmentTranscriptSourceInteractions(interactions, parentCommitTimestampMs) { + const bounded = filterTranscriptInteractionsAfterParent(interactions, parentCommitTimestampMs); + return bounded.length > 0 ? bounded : interactions; +} +function filterTranscriptInteractionsAfterParent(interactions, parentCommitTimestampMs) { + if (parentCommitTimestampMs === null) return interactions; + const lowerBoundMs = parentCommitTimestampMs - TRANSCRIPT_COMMIT_PAST_TOLERANCE_MS; + return interactions.filter((interaction) => { + const interactionMs = parseTimestampMs(interaction.timestamp); + return interactionMs === null || interactionMs >= lowerBoundMs; + }); +} +function canUseUnmatchedTranscriptFallback(allowEnvironmentTranscriptFallback, interactions) { + if (!allowEnvironmentTranscriptFallback) return true; + return !interactions.some((interaction) => (interaction.files_touched ?? []).length > 0); +} function toRecordedInteraction(interaction, commitFileSet, consumedPromptState) { const recorded = { prompt: interaction.prompt, @@ -3842,14 +3974,30 @@ function filterInteractionCommitFiles(interaction, commitFileSet, consumedPrompt (file) => !consumedPromptState.promptFilePairs.has(promptFilePairKey(promptId, file)) ); } -function selectTranscriptFallbackInteractions(interactions, commitFileSet, preferredPromptIds = /* @__PURE__ */ new Set()) { +function selectTranscriptFallbackInteractions(interactions, commitFileSet, preferredPromptIds = /* @__PURE__ */ new Set(), opts = {}) { + const isEligible = (interaction) => (interaction.tools?.length ?? 0) > 0 && (!opts.requireMutationTool || hasMutationToolEvidence(interaction)); const preferredToolBacked = preferredPromptIds.size > 0 ? [...interactions].reverse().find( - (interaction) => !!interaction.prompt_id && preferredPromptIds.has(interaction.prompt_id) && (interaction.tools?.length ?? 0) > 0 + (interaction) => !!interaction.prompt_id && preferredPromptIds.has(interaction.prompt_id) && isEligible(interaction) ) : void 0; if (preferredToolBacked) return [toRecordedInteraction(preferredToolBacked, commitFileSet)]; - const latestToolBacked = [...interactions].reverse().find((interaction) => (interaction.tools?.length ?? 0) > 0); + const latestToolBacked = [...interactions].reverse().find(isEligible); return latestToolBacked ? [toRecordedInteraction(latestToolBacked, commitFileSet)] : []; } +function hasMutationToolEvidence(interaction) { + return (interaction.mutation_tools?.length ?? 0) > 0; +} +function selectEnvironmentTranscriptMatchedInteractions(interactions, commitFileSet, consumedPromptState) { + const uncoveredFiles = new Set(commitFileSet); + const selected = []; + for (const interaction of [...interactions].reverse()) { + const files = filterInteractionCommitFiles(interaction, commitFileSet, consumedPromptState); + if (!files.some((file) => uncoveredFiles.has(file))) continue; + selected.push(interaction); + for (const file of files) uncoveredFiles.delete(file); + if (uncoveredFiles.size === 0) break; + } + return selected.reverse(); +} function collectCurrentUnattributedToolPromptIds(interactions, promptEntries, maxConsumedTurn, currentTurn) { const candidatePromptIds = /* @__PURE__ */ new Set(); for (const entry of promptEntries) { @@ -4744,10 +4892,13 @@ 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_AGENTNOTE_DEBUG = "AGENTNOTE_DEBUG"; var SESSION_ID_SEGMENT_RE = /^[A-Za-z0-9._-]+$/; +var UUID_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; 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"]; async function record(args2) { @@ -4756,10 +4907,15 @@ 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 }); - } catch { + } catch (err) { + console.error(`agent-note: warning: recording failed: ${err.message}`); } } async function recordHeadFallback() { @@ -4777,6 +4933,24 @@ async function recordHeadFallback() { requireAiFileEvidence: true }); } +async function recordEnvironmentFallback() { + if (await readHeadTrailerSessionId()) { + debugRecord("env fallback skipped: HEAD already has trailer"); + return; + } + const agentnoteDirPath = await agentnoteDir(); + const sessionId = await resolveEnvironmentSessionId(agentnoteDirPath); + if (!sessionId) { + debugRecord("env fallback skipped: no fresh environment session"); + return; + } + const result = await recordCommitEntry({ + agentnoteDirPath, + sessionId, + allowEnvironmentTranscriptFallback: true + }); + debugRecord(`env fallback recorded ${result.promptCount} prompt(s), aiRatio=${result.aiRatio}`); +} async function readActiveSessionId(agentnoteDirPath) { const activeSessionPath = join8(agentnoteDirPath, SESSION_FILE); if (!existsSync8(activeSessionPath)) return null; @@ -4784,6 +4958,57 @@ async function readActiveSessionId(agentnoteDirPath) { if (sessionId === "." || sessionId === "..") return null; return SESSION_ID_SEGMENT_RE.test(sessionId) ? sessionId : null; } +async function resolveEnvironmentSessionId(agentnoteDirPath) { + for (const agentName of listAgents()) { + const candidate = await resolveAgentEnvironmentSession(agentnoteDirPath, agentName); + if (candidate) return candidate; + } + return null; +} +async function resolveAgentEnvironmentSession(agentnoteDirPath, agentName) { + const adapter = getAgent(agentName); + const sessionId = sanitizeSessionId(adapter.readEnvironmentSessionId?.() ?? void 0); + if (!sessionId) return null; + const sessionDir = join8(agentnoteDirPath, SESSIONS_DIR, sessionId); + const existingAgent = await readSessionAgent(sessionDir); + if (existingAgent && existingAgent !== agentName) return null; + const savedTranscriptPath = await readSessionTranscriptPath(sessionDir); + const transcriptPath = savedTranscriptPath ?? adapter.findTranscript(sessionId); + if (!await hasFreshEnvironmentEvidence(sessionDir, transcriptPath)) { + debugRecord(`env fallback skipped: no fresh evidence for ${agentName} ${sessionId}`); + return null; + } + await mkdir5(sessionDir, { recursive: true }); + if (!existingAgent) await writeSessionAgent(sessionDir, agentName); + if (!savedTranscriptPath && transcriptPath) + await writeSessionTranscriptPath(sessionDir, transcriptPath); + return sessionId; +} +function debugRecord(message) { + if (process.env[ENV_AGENTNOTE_DEBUG]) console.error(`agent-note: debug: ${message}`); +} +function sanitizeSessionId(value) { + const sessionId = value?.trim(); + if (!sessionId || sessionId === "." || sessionId === "..") return null; + return UUID_SESSION_ID_RE.test(sessionId) ? sessionId.toLowerCase() : 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 +5098,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 +5112,14 @@ 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 SHELL_CODEX_THREAD_ID = `$${ENV_CODEX_THREAD_ID2}`; var PR_REPORT_WORKFLOW_TEMPLATE = `name: Agent Note PR Report on: pull_request: @@ -5027,6 +5255,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 "${SHELL_CODEX_THREAD_ID}" ]; then + SESSION_ID="--fallback-env" else exit 0 fi @@ -5091,7 +5321,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 +5339,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 +5360,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 +5466,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 +5609,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 +5736,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 +6870,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 +6946,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/package.json b/packages/cli/package.json index e06ca691..7dfa4182 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": "^20.19.41", "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/src/agents/codex.test.ts b/packages/cli/src/agents/codex.test.ts index 9bc4b5c3..96cfd06b 100644 --- a/packages/cli/src/agents/codex.test.ts +++ b/packages/cli/src/agents/codex.test.ts @@ -146,6 +146,7 @@ describe("codex adapter", () => { "I will inspect the current state and update the status output.", ); assert.deepEqual(interactions[0].tools, ["exec_command", "apply_patch"]); + assert.deepEqual(interactions[0].mutation_tools, ["apply_patch"]); assert.deepEqual(interactions[0].files_touched, ["src/status.ts"]); assert.deepEqual(interactions[0].line_stats, { "src/status.ts": { added: 1, deleted: 0 }, diff --git a/packages/cli/src/agents/codex.ts b/packages/cli/src/agents/codex.ts index d4078320..af502d77 100644 --- a/packages/cli/src/agents/codex.ts +++ b/packages/cli/src/agents/codex.ts @@ -1,7 +1,8 @@ -import { type Dirent, existsSync, readdirSync, readFileSync } from "node:fs"; +import { createReadStream, type Dirent, existsSync, readdirSync, readFileSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { createInterface } from "node:readline"; import { TEXT_ENCODING } from "../core/constants.js"; import { isAgentNoteHookCommand } from "./hook-command.js"; import { @@ -15,9 +16,12 @@ import { const CONFIG_REL_PATH = ".codex/config.toml"; const ENV_CODEX_HOME = "CODEX_HOME"; +const ENV_CODEX_THREAD_ID = "CODEX_THREAD_ID"; const HOOKS_REL_PATH = ".codex/hooks.json"; const HOOK_COMMAND = `npx --yes agent-note hook --agent ${AGENT_NAMES.codex}`; const TRANSCRIPT_PREVIEW_CHARS = 4096; +const SHELL_MUTATION_COMMAND_RE = + /(^|[;&|]\s*)(apply_patch|cat\s+>|cp\b|install\b|mkdir\b|mv\b|npm\s+(audit\s+fix|dedupe|install|update|version)\b|perl\s+-[^\n;&|]*i|pnpm\s+(add|install|update)\b|rm\b|sed\s+-[^\n;&|]*i|tee\b|touch\b|yarn\s+(add|install|upgrade)\b)|(\s|^)(>|>>)\s*\S+/; const CODEX_HOOK_EVENTS = { sessionStart: "SessionStart", userPromptSubmit: "UserPromptSubmit", @@ -125,6 +129,34 @@ function collectPatchStrings(value: unknown, seen = new Set()): string[ ]; } +function collectCommandStrings(value: unknown, seen = new Set()): string[] { + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return []; + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + const parsed = parseJsonString(value); + if (parsed !== value) return collectCommandStrings(parsed, seen); + } + return [trimmed]; + } + + if (!value || seen.has(value)) return []; + seen.add(value); + + if (Array.isArray(value)) { + return value.flatMap((item) => collectCommandStrings(item, seen)); + } + + if (!isRecord(value)) return []; + + return [ + ...collectCommandStrings(value.cmd, seen), + ...collectCommandStrings(value.command, seen), + ...collectCommandStrings(value.script, seen), + ...collectCommandStrings(value.shell, seen), + ]; +} + /** * Identify the Agent Note session inside a Codex transcript candidate. * @@ -361,6 +393,20 @@ function appendInteractionTool( interaction.tools = [...tools, toolName]; } +function appendInteractionMutationTool( + interaction: TranscriptInteraction, + toolName: string | undefined, +): void { + if (!toolName) return; + const tools = interaction.mutation_tools ?? []; + if (tools.includes(toolName)) return; + interaction.mutation_tools = [...tools, toolName]; +} + +function isMutatingShellCommand(command: string): boolean { + return SHELL_MUTATION_COMMAND_RE.test(command); +} + /** Codex CLI adapter for transcript-driven prompt, patch, and attribution recovery. */ export const codex: AgentAdapter = { name: AGENT_NAMES.codex, @@ -483,6 +529,10 @@ export const codex: AgentAdapter = { return findTranscriptCandidate(sessionsDir, sessionId); }, + readEnvironmentSessionId(): string | null { + return process.env[ENV_CODEX_THREAD_ID] ?? null; + }, + async extractInteractions(transcriptPath: string): Promise { if (!isValidTranscriptPath(transcriptPath)) { throw new Error(`Invalid Codex transcript path: ${transcriptPath}`); @@ -491,112 +541,124 @@ export const codex: AgentAdapter = { throw new Error(`Codex transcript not found: ${transcriptPath}`); } - let content: string; - try { - content = await readFile(transcriptPath, TEXT_ENCODING); - } catch { - throw new Error(`Failed to read Codex transcript: ${transcriptPath}`); - } - const interactions: TranscriptInteraction[] = []; let current: TranscriptInteraction | null = null; let sessionCwd: string | undefined; - for (const rawLine of content.split("\n")) { - const line = rawLine.trim(); - if (!line) continue; - - let entry: RolloutLine; - try { - entry = JSON.parse(line) as RolloutLine; - } catch { - continue; - } - - if (entry.type === "session_meta" && typeof entry.payload?.cwd === "string") { - sessionCwd = entry.payload.cwd; - continue; - } + const lines = createInterface({ + input: createReadStream(transcriptPath, { encoding: TEXT_ENCODING }), + crlfDelay: Number.POSITIVE_INFINITY, + }); - if (entry.type !== "response_item" || !entry.payload) continue; - const payload = entry.payload; - const payloadType = typeof payload.type === "string" ? payload.type : undefined; - const payloadRole = typeof payload.role === "string" ? payload.role : undefined; - - if (payloadType === "message" && payloadRole === "user") { - const prompt = collectMessageText(payload.content).join("\n"); - if (!prompt) continue; - if (current) interactions.push(current); - current = { prompt, response: null }; - if (typeof entry.timestamp === "string") current.timestamp = entry.timestamp; - continue; - } + try { + for await (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) continue; + + let entry: RolloutLine; + try { + entry = JSON.parse(line) as RolloutLine; + } catch { + continue; + } - if (!current) continue; + if (entry.type === "session_meta" && typeof entry.payload?.cwd === "string") { + sessionCwd = entry.payload.cwd; + continue; + } - if (payloadType === "message" && payloadRole === "assistant") { - const response = collectMessageText(payload.content).join("\n"); - if (response) { - current.response = current.response ? `${current.response}\n${response}` : response; + if (entry.type !== "response_item" || !entry.payload) continue; + const payload = entry.payload; + const payloadType = typeof payload.type === "string" ? payload.type : undefined; + const payloadRole = typeof payload.role === "string" ? payload.role : undefined; + + if (payloadType === "message" && payloadRole === "user") { + const prompt = collectMessageText(payload.content).join("\n"); + if (!prompt) continue; + if (current) interactions.push(current); + current = { prompt, response: null }; + if (typeof entry.timestamp === "string") current.timestamp = entry.timestamp; + continue; } - continue; - } - const toolName = - typeof payload.name === "string" - ? payload.name - : typeof payload.call_name === "string" - ? payload.call_name - : undefined; - - if ( - (payloadType === "custom_tool_call" || - payloadType === "function_call" || - payloadType === "tool_use") && - toolName - ) { - appendInteractionTool(current, toolName); - } + if (!current) continue; - if ( - (payloadType === "custom_tool_call" || payloadType === "function_call") && - toolName === "apply_patch" - ) { - const patchInputs = [ - ...collectPatchStrings(payload.input), - ...collectPatchStrings(payload.arguments), - ]; - const files: string[] = []; - const fileSeen = new Set(); - current.line_stats = current.line_stats ?? {}; - - for (const patchInput of patchInputs) { - for (const file of extractFilesFromApplyPatch(patchInput)) { - const normalized = normalizeInteractionFilePath(file, sessionCwd); - if (!normalized) continue; - appendUnique(files, fileSeen, normalized); + if (payloadType === "message" && payloadRole === "assistant") { + const response = collectMessageText(payload.content).join("\n"); + if (response) { + current.response = current.response ? `${current.response}\n${response}` : response; } + continue; + } - const lineStats = extractLineStatsFromApplyPatch(patchInput); - for (const [file, stats] of Object.entries(lineStats)) { - const normalized = normalizeInteractionFilePath(file, sessionCwd); - if (!normalized) continue; - const previous = current.line_stats[normalized] ?? { added: 0, deleted: 0 }; - current.line_stats[normalized] = { - added: previous.added + stats.added, - deleted: previous.deleted + stats.deleted, - }; + const toolName = + typeof payload.name === "string" + ? payload.name + : typeof payload.call_name === "string" + ? payload.call_name + : undefined; + + if ( + (payloadType === "custom_tool_call" || + payloadType === "function_call" || + payloadType === "tool_use") && + toolName + ) { + appendInteractionTool(current, toolName); + if ( + toolName === "exec_command" && + [ + ...collectCommandStrings(payload.input), + ...collectCommandStrings(payload.arguments), + ].some(isMutatingShellCommand) + ) { + appendInteractionMutationTool(current, toolName); } } - if (files.length > 0) { - current.files_touched = [...new Set([...(current.files_touched ?? []), ...files])]; - } + if ( + (payloadType === "custom_tool_call" || payloadType === "function_call") && + toolName === "apply_patch" + ) { + const patchInputs = [ + ...collectPatchStrings(payload.input), + ...collectPatchStrings(payload.arguments), + ]; + const files: string[] = []; + const fileSeen = new Set(); + current.line_stats = current.line_stats ?? {}; + + for (const patchInput of patchInputs) { + for (const file of extractFilesFromApplyPatch(patchInput)) { + const normalized = normalizeInteractionFilePath(file, sessionCwd); + if (!normalized) continue; + appendUnique(files, fileSeen, normalized); + } - if (Object.keys(current.line_stats).length === 0) { - delete current.line_stats; + const lineStats = extractLineStatsFromApplyPatch(patchInput); + for (const [file, stats] of Object.entries(lineStats)) { + const normalized = normalizeInteractionFilePath(file, sessionCwd); + if (!normalized) continue; + const previous = current.line_stats[normalized] ?? { added: 0, deleted: 0 }; + current.line_stats[normalized] = { + added: previous.added + stats.added, + deleted: previous.deleted + stats.deleted, + }; + } + } + + if (files.length > 0) { + current.files_touched = [...new Set([...(current.files_touched ?? []), ...files])]; + } + appendInteractionMutationTool(current, toolName); + + if (Object.keys(current.line_stats).length === 0) { + delete current.line_stats; + } } } + } catch { + throw new Error(`Failed to read Codex transcript: ${transcriptPath}`); } if (current) interactions.push(current); diff --git a/packages/cli/src/agents/types.ts b/packages/cli/src/agents/types.ts index 5184e254..d0d814d6 100644 --- a/packages/cli/src/agents/types.ts +++ b/packages/cli/src/agents/types.ts @@ -51,6 +51,7 @@ export interface TranscriptInteraction { files_touched?: string[]; line_stats?: Record; tools?: string[] | null; + mutation_tools?: string[] | null; } /** Agent-agnostic event shape consumed by the hook command. */ @@ -101,4 +102,7 @@ export interface AgentAdapter { /** Extract all prompt-response pairs from the agent's transcript. */ extractInteractions(transcriptPath: string): Promise; + + /** Return an environment-provided current session id when the agent exposes one. */ + readEnvironmentSessionId?(): string | null; } 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/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..4e0b7991 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,11 +28,129 @@ function shellSingleQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'`; } +function withoutCodexThreadEnv(): NodeJS.ProcessEnv { + const env = { ...process.env }; + delete env.CODEX_THREAD_ID; + return env; +} + +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 baseTimestampMs = Date.now(); + const timestamp = (offsetMs: number) => new Date(baseTimestampMs + offsetMs).toISOString(); + const patch = [ + "*** Begin Patch", + `*** Add File: ${filePath}`, + "+export const cmuxEnvFallback = true;", + "*** End Patch", + ].join("\n"); + writeFileSync( + transcriptPath, + `${[ + JSON.stringify({ + type: "session_meta", + timestamp: timestamp(0), + payload: { id: sessionId, cwd }, + }), + JSON.stringify({ + type: "response_item", + timestamp: timestamp(1000), + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "add cmux env fallback" }], + }, + }), + JSON.stringify({ + type: "response_item", + timestamp: timestamp(2000), + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "I will add the cmux fallback file." }], + }, + }), + JSON.stringify({ + type: "response_item", + timestamp: timestamp(3000), + payload: { + type: "function_call", + name: "apply_patch", + arguments: { patch }, + }, + }), + ].join("\n")}\n`, + ); + return transcriptPath; +} + +function writeCodexShellTranscript( + codexHome: string, + sessionId: string, + cwd: string, + prompt: string, + command: string, + baseTimestampMs = Date.now(), +): string { + const transcriptDir = join(codexHome, "sessions", "2026", "05", "12"); + mkdirSync(transcriptDir, { recursive: true }); + const transcriptPath = join(transcriptDir, `rollout-2026-05-12T12-30-00-${sessionId}.jsonl`); + const timestamp = (offsetMs: number) => new Date(baseTimestampMs + offsetMs).toISOString(); + writeFileSync( + transcriptPath, + `${[ + JSON.stringify({ + type: "session_meta", + timestamp: timestamp(0), + payload: { id: sessionId, cwd }, + }), + JSON.stringify({ + type: "response_item", + timestamp: timestamp(1000), + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: prompt }], + }, + }), + JSON.stringify({ + type: "response_item", + timestamp: timestamp(2000), + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "I will run the requested shell command." }], + }, + }), + JSON.stringify({ + type: "response_item", + timestamp: timestamp(3000), + payload: { + type: "function_call", + name: "exec_command", + arguments: { cmd: command }, + }, + }), + ].join("\n")}\n`, + ); + return transcriptPath; +} + describe("agentnote init", () => { let testDir: string; + let originalCodexThreadId: string | undefined; const cliPath = join(process.cwd(), "dist", "cli.js"); before(() => { + originalCodexThreadId = process.env.CODEX_THREAD_ID; + delete process.env.CODEX_THREAD_ID; testDir = mkdtempSync(join(tmpdir(), "agentnote-init-")); execSync("git init", { cwd: testDir }); execSync("git config user.email test@test.com", { cwd: testDir }); @@ -36,6 +162,11 @@ describe("agentnote init", () => { }); after(() => { + if (originalCodexThreadId === undefined) { + delete process.env.CODEX_THREAD_ID; + } else { + process.env.CODEX_THREAD_ID = originalCodexThreadId; + } rmSync(testDir, { recursive: true, force: true }); }); @@ -109,6 +240,14 @@ describe("agentnote init", () => { !postCommitHook.includes("npx --yes agent-note record"), "post-commit should not resolve an unpinned package at commit time", ); + assert.ok( + postCommitHook.includes('[ -n "$CODEX_THREAD_ID" ]'), + "post-commit should check the real Codex session environment variable", + ); + assert.ok( + !postCommitHook.includes("ENV_CODEX_THREAD_ID"), + "post-commit should not leave TypeScript constant names in shell output", + ); }); it("is idempotent", () => { @@ -333,6 +472,488 @@ 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 local prompts when the Codex transcript is fresh", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-codex-env-transcript-only-")); + 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-local-prompt.ts"; + const oldFilePath = "src/old-package-task.ts"; + const transcriptPath = writeCodexTranscript(codexHome, codexSessionId, dir, filePath); + writeFileSync( + transcriptPath, + `${[ + JSON.stringify({ + type: "response_item", + timestamp: "2000-01-01T00:00:00Z", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "old package task" }], + }, + }), + JSON.stringify({ + type: "response_item", + timestamp: "2000-01-01T00:00:01Z", + payload: { + type: "function_call", + name: "apply_patch", + arguments: { + patch: [ + "*** Begin Patch", + `*** Add File: ${oldFilePath}`, + "+export const oldPackageTask = true;", + "*** End Patch", + ].join("\n"), + }, + }, + }), + JSON.stringify({ + type: "response_item", + timestamp: "2999-01-01T00:00:00Z", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "future debug prompt" }], + }, + }), + JSON.stringify({ + type: "response_item", + timestamp: "2999-01-01T00:00:01Z", + payload: { + type: "function_call", + name: "apply_patch", + arguments: { + patch: [ + "*** Begin Patch", + `*** Update File: ${filePath}`, + "@@", + "-export const cmuxEnvFallback = true;", + "+export const futureDebug = true;", + "*** End Patch", + ].join("\n"), + }, + }, + }), + ].join("\n")}\n`, + { flag: "a" }, + ); + + const codexSessionDir = join(dir, ".git", AGENTNOTE_DIR, SESSIONS_DIR, codexSessionId); + mkdirSync(codexSessionDir, { recursive: true }); + writeFileSync(join(codexSessionDir, "agent"), "codex\n"); + writeFileSync(join(codexSessionDir, TURN_FILE), "1196\n"); + writeFileSync( + join(codexSessionDir, PROMPTS_FILE), + '{"event":"prompt","timestamp":"2026-05-10T03:27:40Z","prompt":"old CodeRabbit task","prompt_id":"old-prompt","turn":1196}\n', + ); + + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(join(dir, filePath), "export const cmuxEnvFallback = true;\n"); + writeFileSync(join(dir, oldFilePath), "export const oldPackageTask = true;\n"); + execSync(`git add ${shellSingleQuote(filePath)} ${shellSingleQuote(oldFilePath)}`, { + cwd: dir, + }); + execSync("git commit -m 'feat: transcript-only cmux env fallback'", { + 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); + assert.equal(entry.agent, "codex"); + assert.equal(entry.session_id, codexSessionId); + assert.equal(entry.attribution.method, "file"); + assert.equal(entry.interactions[0].prompt, "add cmux env fallback"); + assert.deepEqual(entry.interactions[0].files_touched, [filePath]); + assert.equal( + entry.interactions.some( + (interaction: { prompt?: string }) => interaction.prompt === "old CodeRabbit task", + ), + false, + "stale repo-local prompts should not be revived by env fallback", + ); + assert.equal( + entry.interactions.some( + (interaction: { prompt?: string }) => interaction.prompt === "old package task", + ), + false, + "transcript rows before the parent commit should not be selected", + ); + assert.equal( + entry.interactions.some( + (interaction: { prompt?: string }) => interaction.prompt === "future debug prompt", + ), + false, + "transcript rows written after the commit should not be selected", + ); + assert.deepEqual(entry.files, [ + { path: oldFilePath, by_ai: false }, + { path: filePath, by_ai: true }, + ]); + + rmSync(dir, { recursive: true, force: true }); + }); + + it("post-commit environment fallback recovers work prepared before the previous commit", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-codex-env-prepared-")); + 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/prepared-before-parent.ts"; + writeCodexTranscript(codexHome, codexSessionId, dir, filePath); + + writeFileSync(join(dir, "parent.txt"), "parent\n"); + execSync("git add parent.txt", { cwd: dir }); + execSync("git commit -m 'chore: unrelated parent'", { + cwd: dir, + env: withoutCodexThreadEnv(), + }); + + 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: prepared env fallback'", { + 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); + 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 transcripts that only touch other files", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-codex-env-other-files-")); + 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 transcriptOnlyPath = "src/transcript-only.ts"; + writeCodexTranscript(codexHome, codexSessionId, dir, transcriptOnlyPath); + + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(join(dir, "human-only.ts"), "export const humanOnly = true;\n"); + execSync("git add human-only.ts", { cwd: dir }); + execSync("git commit -m 'chore: human only'", { + 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 environment fallback ignores read-only shell transcripts", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-codex-env-readonly-shell-")); + 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"); + writeCodexShellTranscript( + codexHome, + codexSessionId, + dir, + "inspect repository status", + "git status --short", + ); + + writeFileSync(join(dir, "manual.ts"), "export const manual = true;\n"); + execSync("git add manual.ts", { cwd: dir }); + execSync("git commit -m 'chore: manual edit'", { + 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 environment fallback ignores old unmatched mutating shell transcripts", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-codex-env-old-shell-")); + 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"); + writeCodexShellTranscript( + codexHome, + codexSessionId, + dir, + "old generated mutation", + "perl -pi -e s/old/new/g generated.ts", + Date.now() - 2 * 60 * 1000, + ); + + writeFileSync(join(dir, "parent.txt"), "parent\n"); + execSync("git add parent.txt", { cwd: dir }); + execSync("git commit -m 'chore: unrelated parent'", { + cwd: dir, + env: withoutCodexThreadEnv(), + }); + + writeFileSync(join(dir, "manual.ts"), "export const manual = true;\n"); + execSync("git add manual.ts", { cwd: dir }); + execSync("git commit -m 'chore: manual edit'", { + 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 environment fallback keeps mutating shell transcripts", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-codex-env-mutating-shell-")); + 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"); + writeCodexShellTranscript( + codexHome, + codexSessionId, + dir, + "replace generated wording", + "perl -pi -e s/old/new/g generated.ts", + ); + + writeFileSync(join(dir, "generated.ts"), "export const generated = 'new';\n"); + execSync("git add generated.ts", { cwd: dir }); + execSync("git commit -m 'chore: generated wording'", { + 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); + assert.equal(entry.session_id, codexSessionId); + assert.equal(entry.interactions[0].prompt, "replace generated wording"); + assert.deepEqual(entry.files, [{ path: "generated.ts", by_ai: true }]); + + 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", + }); + }); + assert.equal( + existsSync(join(dir, ".git", AGENTNOTE_DIR, SESSIONS_DIR, codexSessionId)), + false, + "stale environment candidates should not create empty session directories", + ); + + rmSync(dir, { recursive: true, force: true }); + }); + + it("post-commit environment fallback ignores non-UUID Codex session ids", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-codex-env-invalid-session-")); + 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 = "codex-session-not-a-uuid"; + const codexHome = join(dir, "codex-home"); + const filePath = "src/invalid-env-session.ts"; + writeCodexTranscript(codexHome, codexSessionId, dir, filePath); + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(join(dir, filePath), "export const invalidEnvSession = true;\n"); + + execSync(`git add ${shellSingleQuote(filePath)}`, { cwd: dir }); + execSync("git commit -m 'chore: invalid env session'", { + 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..09eb3d44 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -25,6 +25,8 @@ 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 SHELL_CODEX_THREAD_ID = `$${ENV_CODEX_THREAD_ID}`; const PR_REPORT_WORKFLOW_TEMPLATE = `name: Agent Note PR Report on: @@ -171,6 +173,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 "${SHELL_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..f0960f50 100644 --- a/packages/cli/src/commands/record.ts +++ b/packages/cli/src/commands/record.ts @@ -1,14 +1,33 @@ 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, listAgents } from "../agents/index.js"; +import type { AgentName } 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_AGENTNOTE_DEBUG = "AGENTNOTE_DEBUG"; const SESSION_ID_SEGMENT_RE = /^[A-Za-z0-9._-]+$/; +const UUID_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; 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,12 +38,17 @@ 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; await recordCommitEntry({ agentnoteDirPath: await agentnoteDir(), sessionId }); - } catch { + } catch (err: unknown) { // Never break git commit hooks. + console.error(`agent-note: warning: recording failed: ${(err as Error).message}`); } } @@ -49,6 +73,28 @@ 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"); + return; + } + + const agentnoteDirPath = await agentnoteDir(); + const sessionId = await resolveEnvironmentSessionId(agentnoteDirPath); + if (!sessionId) { + debugRecord("env fallback skipped: no fresh environment session"); + return; + } + + const result = await recordCommitEntry({ + agentnoteDirPath, + sessionId, + allowEnvironmentTranscriptFallback: true, + }); + debugRecord(`env fallback recorded ${result.promptCount} prompt(s), aiRatio=${result.aiRatio}`); +} + async function readActiveSessionId(agentnoteDirPath: string): Promise { const activeSessionPath = join(agentnoteDirPath, SESSION_FILE); if (!existsSync(activeSessionPath)) return null; @@ -57,6 +103,75 @@ async function readActiveSessionId(agentnoteDirPath: string): Promise { + for (const agentName of listAgents()) { + const candidate = await resolveAgentEnvironmentSession(agentnoteDirPath, agentName); + if (candidate) return candidate; + } + return null; +} + +async function resolveAgentEnvironmentSession( + agentnoteDirPath: string, + agentName: AgentName, +): Promise { + const adapter = getAgent(agentName); + const sessionId = sanitizeSessionId(adapter.readEnvironmentSessionId?.() ?? undefined); + if (!sessionId) return null; + + const sessionDir = join(agentnoteDirPath, SESSIONS_DIR, sessionId); + const existingAgent = await readSessionAgent(sessionDir); + if (existingAgent && existingAgent !== agentName) return null; + + const savedTranscriptPath = await readSessionTranscriptPath(sessionDir); + const transcriptPath = savedTranscriptPath ?? adapter.findTranscript(sessionId); + if (!(await hasFreshEnvironmentEvidence(sessionDir, transcriptPath))) { + debugRecord(`env fallback skipped: no fresh evidence for ${agentName} ${sessionId}`); + return null; + } + + await mkdir(sessionDir, { recursive: true }); + if (!existingAgent) await writeSessionAgent(sessionDir, agentName); + if (!savedTranscriptPath && transcriptPath) + await writeSessionTranscriptPath(sessionDir, transcriptPath); + return sessionId; +} + +function debugRecord(message: string): void { + if (process.env[ENV_AGENTNOTE_DEBUG]) console.error(`agent-note: debug: ${message}`); +} + +function sanitizeSessionId(value: string | undefined): string | null { + const sessionId = value?.trim(); + if (!sessionId || sessionId === "." || sessionId === "..") return null; + return UUID_SESSION_ID_RE.test(sessionId) ? sessionId.toLowerCase() : 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); 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..580894c0 100644 --- a/packages/cli/src/core/record.ts +++ b/packages/cli/src/core/record.ts @@ -59,6 +59,10 @@ type AgentnoteIgnorePattern = { const AGENTNOTE_IGNORE_MAX_PATTERN_LENGTH = 200; const AGENTNOTE_IGNORE_MAX_WILDCARD_TOKENS = 10; const AGENTNOTE_IGNORE_OVERLAPPING_WILDCARD_RE = /\*{3,}|\*\.\*/; +// Git commit timestamps and transcript JSONL writes can differ slightly. +// 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; /** Record an agentnote entry as a git note after a successful commit. */ export async function recordCommitEntry(opts: { @@ -66,6 +70,7 @@ export async function recordCommitEntry(opts: { sessionId: string; transcriptPath?: string; requireAiFileEvidence?: boolean; + allowEnvironmentTranscriptFallback?: boolean; }): Promise<{ promptCount: number; aiRatio: number }> { const sessionDir = join(opts.agentnoteDirPath, SESSIONS_DIR, opts.sessionId); const sessionAgent = await readSessionAgent(sessionDir); @@ -102,6 +107,8 @@ export async function recordCommitEntry(opts: { } catch { // Subject is only a prompt-trimming hint. Recording should still proceed. } + const commitTimestampMs = await readHeadCommitTimestampMs(); + const parentCommitTimestampMs = await readHeadParentCommitTimestampMs(); let commitDiffText = ""; try { commitDiffText = await git(["show", "--format=", "--patch", "--unified=0", "HEAD"]); @@ -263,6 +270,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). @@ -277,6 +285,11 @@ export async function recordCommitEntry(opts: { if (transcriptPath) { try { allInteractions = await adapter.extractInteractions(transcriptPath); + allInteractions = filterTranscriptInteractionsForCommitWindow( + allInteractions, + null, + commitTimestampMs, + ); } catch (err) { if (!crossTurnCommit) throw err; // cross-turn: fall through with no interactions — handled below as prompts-only. @@ -307,7 +320,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 +414,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. @@ -414,6 +428,7 @@ export async function recordCommitEntry(opts: { consumedPromptState, currentTurn, ); + let attributionTranscriptMatched = selectableTranscriptMatched; const transcriptPrimaryTurns = await selectTranscriptPrimaryTurns( selectableTranscriptMatched, promptEntries, @@ -484,31 +499,55 @@ export async function recordCommitEntry(opts: { toRecordedInteraction(i, commitFileSet, consumedPromptState), ); useSelectableTranscriptAttribution = true; - } else if (!crossTurnCommit && transcriptMatched.length === 0) { + } else if (opts.allowEnvironmentTranscriptFallback && transcriptMatched.length > 0) { + const envTranscriptMatched = selectEnvironmentTranscriptSourceInteractions( + transcriptMatched, + parentCommitTimestampMs, + ); + const envMatched = selectEnvironmentTranscriptMatchedInteractions( + envTranscriptMatched, + commitFileSet, + consumedPromptState, + ); + interactions = envMatched.map((i) => + toRecordedInteraction(i, commitFileSet, consumedPromptState), + ); + attributionTranscriptMatched = envMatched; + useSelectableTranscriptAttribution = true; + } else if ( + !crossTurnCommit && + transcriptMatched.length === 0 && + canUseUnmatchedTranscriptFallback(opts.allowEnvironmentTranscriptFallback, allInteractions) + ) { + const fallbackSourceInteractions = opts.allowEnvironmentTranscriptFallback + ? filterTranscriptInteractionsAfterParent(allInteractions, parentCommitTimestampMs) + : allInteractions; interactions = selectTranscriptFallbackInteractions( - allInteractions, + fallbackSourceInteractions, commitFileSet, currentUnattributedToolPromptIds, + { requireMutationTool: opts.allowEnvironmentTranscriptFallback === true }, ); + useCommitLevelAttribution = interactions.length > 0; } else { interactions = []; } - if (useSelectableTranscriptAttribution && selectableTranscriptMatched.length > 0) { + if (useSelectableTranscriptAttribution && attributionTranscriptMatched.length > 0) { aiFiles = [ ...new Set( - selectableTranscriptMatched.flatMap((i) => + attributionTranscriptMatched.flatMap((i) => filterInteractionCommitFiles(i, commitFileSet, consumedPromptState), ), ), ]; transcriptLineCounts = await resolveTranscriptLineCounts( lineCountCommitFileSet, - selectableTranscriptMatched, + attributionTranscriptMatched, consumedPromptState, ); consumedTranscriptPromptFiles = collectConsumedTranscriptPromptFiles( - selectableTranscriptMatched, + attributionTranscriptMatched, promptEntries, commitFileSet, consumedPromptState, @@ -518,6 +557,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, @@ -728,6 +774,73 @@ function parseTimestampMs(value: unknown): number | null { return Number.isNaN(parsed) ? null : parsed; } +async function readHeadCommitTimestampMs(): Promise { + try { + return parseTimestampMs(await git(["show", "-s", "--format=%cI", "HEAD"])); + } catch { + return null; + } +} + +async function readHeadParentCommitTimestampMs(): Promise { + try { + return parseTimestampMs(await git(["show", "-s", "--format=%cI", "HEAD^"])); + } catch { + return null; + } +} + +/** Keep transcript rows inside the parent-to-HEAD commit window when timestamps exist. */ +function filterTranscriptInteractionsForCommitWindow( + interactions: TranscriptInteraction[], + parentCommitTimestampMs: number | null, + commitTimestampMs: number | null, +): TranscriptInteraction[] { + const lowerBoundMs = + parentCommitTimestampMs === null + ? null + : parentCommitTimestampMs - TRANSCRIPT_COMMIT_PAST_TOLERANCE_MS; + const upperBoundMs = + commitTimestampMs === null ? null : commitTimestampMs + TRANSCRIPT_COMMIT_FUTURE_TOLERANCE_MS; + return interactions.filter((interaction) => { + const interactionMs = parseTimestampMs(interaction.timestamp); + if (interactionMs === null) return true; + if (lowerBoundMs !== null && interactionMs < lowerBoundMs) return false; + return upperBoundMs === null || interactionMs <= upperBoundMs; + }); +} + +/** Prefer parent-to-HEAD transcript evidence, but recover work prepared just before the parent. */ +function selectEnvironmentTranscriptSourceInteractions( + interactions: TranscriptInteraction[], + parentCommitTimestampMs: number | null, +): TranscriptInteraction[] { + const bounded = filterTranscriptInteractionsAfterParent(interactions, parentCommitTimestampMs); + return bounded.length > 0 ? bounded : interactions; +} + +/** Keep transcript rows after the parent commit when that newer evidence exists. */ +function filterTranscriptInteractionsAfterParent( + interactions: TranscriptInteraction[], + parentCommitTimestampMs: number | null, +): TranscriptInteraction[] { + if (parentCommitTimestampMs === null) return interactions; + const lowerBoundMs = parentCommitTimestampMs - TRANSCRIPT_COMMIT_PAST_TOLERANCE_MS; + return interactions.filter((interaction) => { + const interactionMs = parseTimestampMs(interaction.timestamp); + return interactionMs === null || interactionMs >= lowerBoundMs; + }); +} + +/** Env fallback can use unmatched transcript rows only when they are shell-only evidence. */ +function canUseUnmatchedTranscriptFallback( + allowEnvironmentTranscriptFallback: boolean | undefined, + interactions: TranscriptInteraction[], +): boolean { + if (!allowEnvironmentTranscriptFallback) return true; + return !interactions.some((interaction) => (interaction.files_touched ?? []).length > 0); +} + /** * Convert an agent transcript row into the persisted interaction shape. * @@ -773,12 +886,16 @@ 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, preferredPromptIds = new Set(), + opts: { requireMutationTool?: boolean } = {}, ): Interaction[] { + const isEligible = (interaction: TranscriptInteraction) => + (interaction.tools?.length ?? 0) > 0 && + (!opts.requireMutationTool || hasMutationToolEvidence(interaction)); const preferredToolBacked = preferredPromptIds.size > 0 ? [...interactions] @@ -787,18 +904,41 @@ function selectTranscriptFallbackInteractions( (interaction) => !!interaction.prompt_id && preferredPromptIds.has(interaction.prompt_id) && - (interaction.tools?.length ?? 0) > 0, + isEligible(interaction), ) : undefined; if (preferredToolBacked) return [toRecordedInteraction(preferredToolBacked, commitFileSet)]; - const latestToolBacked = [...interactions] - .reverse() - .find((interaction) => (interaction.tools?.length ?? 0) > 0); + const latestToolBacked = [...interactions].reverse().find(isEligible); return latestToolBacked ? [toRecordedInteraction(latestToolBacked, commitFileSet)] : []; } -/** Current-window tool turns without file evidence, kept as prompt-only notes. */ +function hasMutationToolEvidence(interaction: TranscriptInteraction): boolean { + return (interaction.mutation_tools?.length ?? 0) > 0; +} + +/** Select the newest transcript rows needed to cover commit files for env fallback. */ +function selectEnvironmentTranscriptMatchedInteractions( + interactions: TranscriptInteraction[], + commitFileSet: Set, + consumedPromptState: ConsumedPromptState, +): TranscriptInteraction[] { + const uncoveredFiles = new Set(commitFileSet); + const selected: TranscriptInteraction[] = []; + + for (const interaction of [...interactions].reverse()) { + const files = filterInteractionCommitFiles(interaction, commitFileSet, consumedPromptState); + if (!files.some((file) => uncoveredFiles.has(file))) continue; + + selected.push(interaction); + for (const file of files) uncoveredFiles.delete(file); + if (uncoveredFiles.size === 0) break; + } + + return selected.reverse(); +} + +/** Current-window tool turns without per-file evidence. */ function collectCurrentUnattributedToolPromptIds( interactions: TranscriptInteraction[], promptEntries: Record[], diff --git a/packages/cli/src/core/storage.test.ts b/packages/cli/src/core/storage.test.ts index 3b97fcb7..4f02392f 100644 --- a/packages/cli/src/core/storage.test.ts +++ b/packages/cli/src/core/storage.test.ts @@ -82,4 +82,11 @@ describe("storage: writeNote / readNote", () => { const result = await readNote(commitSha); assert.deepEqual(result, data); }); + + it("throws when git refuses to write a note", async () => { + await assert.rejects( + () => writeNote("not-a-commit", { v: 1 }), + (err: unknown) => err instanceof Error && err.message.length > 0, + ); + }); }); diff --git a/packages/cli/src/core/storage.ts b/packages/cli/src/core/storage.ts index 22d23f5a..a4fe0706 100644 --- a/packages/cli/src/core/storage.ts +++ b/packages/cli/src/core/storage.ts @@ -4,7 +4,10 @@ import { NOTES_REF } from "./constants.js"; /** Write an Agent Note entry as a git note on a commit. */ export async function writeNote(commitSha: string, data: Record): Promise { const body = JSON.stringify(data, null, 2); - await gitSafe(["notes", `--ref=${NOTES_REF}`, "add", "-f", "-m", body, commitSha]); + const result = await gitSafe(["notes", `--ref=${NOTES_REF}`, "add", "-f", "-m", body, commitSha]); + if (result.exitCode !== 0) { + throw new Error(result.stderr || "failed to write Agent Note git note"); + } } /** Read an Agent Note entry from a git note, returning null when none exists. */ diff --git a/packages/cli/src/git.ts b/packages/cli/src/git.ts index d3260a50..49af9062 100644 --- a/packages/cli/src/git.ts +++ b/packages/cli/src/git.ts @@ -52,14 +52,15 @@ export async function git( export async function gitSafe( args: string[], options?: Pick, -): Promise<{ stdout: string; exitCode: number }> { +): Promise<{ stdout: string; stderr: string; exitCode: number }> { try { const stdout = await git(args, options); - return { stdout, exitCode: 0 }; + return { stdout, stderr: "", exitCode: 0 }; } catch (err: unknown) { const e = err as Record; return { stdout: typeof e.stdout === "string" ? e.stdout.trim() : "", + stderr: typeof e.stderr === "string" ? e.stderr.trim() : "", exitCode: typeof e.code === "number" ? e.code : 1, }; } 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..71e1080d 100644 --- a/website/src/content/docs/data-and-privacy.mdx +++ b/website/src/content/docs/data-and-privacy.mdx @@ -66,10 +66,13 @@ The schema is shown in [How It Works](./how-it-works/#note-schema). 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. - It does not upload data to an Agent Note backend. + + ## Team Visibility Anyone who can fetch `refs/notes/agentnote` can read the stored prompts and responses. Treat git notes like review metadata: useful for collaboration, but still repository data. diff --git a/website/src/content/docs/de/data-and-privacy.mdx b/website/src/content/docs/de/data-and-privacy.mdx index 2d5b1a89..ad25751e 100644 --- a/website/src/content/docs/de/data-and-privacy.mdx +++ b/website/src/content/docs/de/data-and-privacy.mdx @@ -54,10 +54,13 @@ Das Schema steht in [How It Works](./how-it-works/#note-schema). ## Nicht gespeicherte Daten - Der vollständige Workspace. -- Alle Shell-Ausgaben. -- Dateien, die nur anhand von Shell-Befehlen der KI zugeschrieben würden. +- Shell command output wird nicht als file evidence gespeichert. - Daten an einen Agent Note Backend-Service. + + ## Sichtbarkeit im Team Wer `refs/notes/agentnote` fetchen kann, kann gespeicherte Prompts und Responses lesen. Behandeln Sie git notes als Repository-Daten. 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) |