diff --git a/AGENTS.md b/AGENTS.md index 4a70dfaf..91c9e604 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ Tests shell out to `node dist/cli.js`, so always build before running tests. ``` Agent hooks → agent-note hook --agent (stdin JSON) → .git/agentnote/sessions//*.jsonl (local temp) -git commit → prepare-commit-msg injects trailer when session data exists → post-commit calls agent-note record → git note written +git commit → prepare-commit-msg injects trailer when file evidence exists → post-commit calls agent-note record → git note written git push → pre-push auto-pushes refs/notes/agentnote agent-note show/log/why → reads git notes --ref=agentnote ``` @@ -93,8 +93,8 @@ 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 recordable session data before injecting an `Agentnote-Session` trailer. `transcript_path` alone is metadata, not recordable data. Skips amends. -- **`post-commit`**: Reads session ID from HEAD's trailer, calls `agent-note record ` to write git note. +- **`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. - **`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. @@ -125,6 +125,7 @@ Each `UserPromptSubmit` increments a turn counter. File changes inherit the curr - `init` modifies agent config (`.claude/settings.json` for Claude Code, `.codex/` for Codex, `.cursor/hooks.json` for Cursor, `.gemini/settings.json` for Gemini CLI) and installs git hooks (prepare-commit-msg, post-commit, pre-push). Agent config is intended to be committed to git so the team shares the same hooks config. - `hook` is called by the coding agent at runtime. It never modifies config files. +- Public user installs generate agent hooks that call `npx --yes agent-note hook --agent `. This repository may keep repo-local development hooks that call `node packages/cli/dist/cli.js hook --agent ` so local changes can be tested before publishing. Treat `cli.js hook` as a maintainer-only compatibility path, not public setup guidance. ### Harness hooks diff --git a/CLAUDE.md b/CLAUDE.md index 488c385d..bcd2370d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ Tests shell out to `node dist/cli.js`, so always build before running tests. ``` Agent hooks → agent-note hook --agent (stdin JSON) → .git/agentnote/sessions//*.jsonl (local temp) -git commit → prepare-commit-msg injects trailer when session data exists → post-commit calls agent-note record → git note written +git commit → prepare-commit-msg injects trailer when file evidence exists → post-commit calls agent-note record → git note written git push → pre-push auto-pushes refs/notes/agentnote agent-note show/log/why → reads git notes --ref=agentnote ``` @@ -93,8 +93,8 @@ 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 recordable session data before injecting an `Agentnote-Session` trailer. `transcript_path` alone is metadata, not recordable data. Skips amends. -- **`post-commit`**: Reads session ID from HEAD's trailer, calls `agent-note record ` to write git note. +- **`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. - **`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. @@ -125,6 +125,7 @@ Each `UserPromptSubmit` increments a turn counter. File changes inherit the curr - `init` modifies agent config (`.claude/settings.json` for Claude Code, `.codex/` for Codex, `.cursor/hooks.json` for Cursor, `.gemini/settings.json` for Gemini CLI) and installs git hooks (prepare-commit-msg, post-commit, pre-push). Agent config is intended to be committed to git so the team shares the same hooks config. - `hook` is called by the coding agent at runtime. It never modifies config files. +- Public user installs generate agent hooks that call `npx --yes agent-note hook --agent `. This repository may keep repo-local development hooks that call `node packages/cli/dist/cli.js hook --agent ` so local changes can be tested before publishing. Treat `cli.js hook` as a maintainer-only compatibility path, not public setup guidance. ### Harness hooks diff --git a/README.de.md b/README.de.md index fefdb919..65c2e1d8 100644 --- a/README.de.md +++ b/README.de.md @@ -281,7 +281,7 @@ Die root action hat zwei Modi: PR Report Mode ist der Standard: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` @@ -291,7 +291,7 @@ Setze `prompt_detail` auf `compact` oder `full`, wenn du die Prompt-Historie fok Dashboard Mode nutzt dieselbe action mit `dashboard: true`: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 with: dashboard: true prompt_detail: compact @@ -313,7 +313,7 @@ Wenn Sie bereits eine GitHub Pages Site haben, finden Sie die sichere kombiniert Full example with outputs ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 id: agent-note with: base: main diff --git a/README.es.md b/README.es.md index c6c16620..c8de00f1 100644 --- a/README.es.md +++ b/README.es.md @@ -281,7 +281,7 @@ La root action tiene dos modos: PR Report Mode es el predeterminado: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` @@ -291,7 +291,7 @@ Configura `prompt_detail` como `compact` o `full` cuando quieras un historial de Dashboard Mode usa la misma action con `dashboard: true`: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 with: dashboard: true prompt_detail: compact @@ -313,7 +313,7 @@ Si ya tienes un sitio GitHub Pages, consulta la [documentación Dashboard](https Full example with outputs ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 id: agent-note with: base: main diff --git a/README.fr.md b/README.fr.md index 3c9ca391..9958904f 100644 --- a/README.fr.md +++ b/README.fr.md @@ -281,7 +281,7 @@ L'action racine a deux modes: PR Report Mode est le mode par défaut: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` @@ -291,7 +291,7 @@ Définissez `prompt_detail` sur `compact` ou `full` pour obtenir un historique d Dashboard Mode utilise la même action avec `dashboard: true`: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 with: dashboard: true prompt_detail: compact @@ -313,7 +313,7 @@ Si vous avez déjà un Site GitHub Pages, consultez les [docs Dashboard](https:/ Full example with outputs ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 id: agent-note with: base: main diff --git a/README.id.md b/README.id.md index 59bf4220..2ed93d04 100644 --- a/README.id.md +++ b/README.id.md @@ -281,7 +281,7 @@ Root action punya dua mode: PR Report Mode adalah default: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` @@ -291,7 +291,7 @@ Atur `prompt_detail` ke `compact` atau `full` jika ingin riwayat Prompt yang fok Dashboard Mode memakai action yang sama dengan `dashboard: true`: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 with: dashboard: true prompt_detail: compact @@ -313,7 +313,7 @@ Jika Anda sudah punya GitHub Pages Site, lihat [Dashboard Docs](https://wasabeef Full example with outputs ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 id: agent-note with: base: main diff --git a/README.it.md b/README.it.md index 8bcd8442..2133ee5d 100644 --- a/README.it.md +++ b/README.it.md @@ -281,7 +281,7 @@ La root action ha due mode: PR Report Mode è il default: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` @@ -291,7 +291,7 @@ Imposta `prompt_detail` su `compact` o `full` quando vuoi una cronologia dei Pro Dashboard Mode usa la stessa action con `dashboard: true`: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 with: dashboard: true prompt_detail: compact @@ -313,7 +313,7 @@ Se hai già un sito GitHub Pages, consulta le [Dashboard Docs](https://wasabeef. Full example with outputs ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 id: agent-note with: base: main diff --git a/README.ja.md b/README.ja.md index 73132c75..112c5533 100644 --- a/README.ja.md +++ b/README.ja.md @@ -281,7 +281,7 @@ root action には 2 つの mode があります。 PR Report Mode が既定です。 ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` @@ -291,7 +291,7 @@ Prompt 履歴を絞る、または全件表示する場合は `prompt_detail` Dashboard Mode は同じ action に `dashboard: true` を渡します。 ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 with: dashboard: true prompt_detail: compact @@ -313,7 +313,7 @@ npx agent-note init --agent claude --dashboard Full example with outputs ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 id: agent-note with: base: main diff --git a/README.ko.md b/README.ko.md index 912b9e7d..50ba97ab 100644 --- a/README.ko.md +++ b/README.ko.md @@ -281,7 +281,7 @@ root action 에는 두 가지 mode 가 있습니다. PR Report Mode 가 기본값입니다. ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` @@ -291,7 +291,7 @@ Prompt 기록을 핵심 중심으로 보거나 전체로 보려면 `prompt_detai Dashboard Mode 는 같은 action 에 `dashboard: true` 를 전달합니다. ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 with: dashboard: true prompt_detail: compact @@ -313,7 +313,7 @@ npx agent-note init --agent claude --dashboard Full example with outputs ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 id: agent-note with: base: main diff --git a/README.md b/README.md index 935b7398..54301afb 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ The root action has two modes: PR Report Mode is the default: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` @@ -291,7 +291,7 @@ Set `prompt_detail` to `compact` or `full` when you want a focused or complete p Dashboard Mode uses the same action with `dashboard: true`: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 with: dashboard: true prompt_detail: compact @@ -313,7 +313,7 @@ If you already have a GitHub Pages site, see [Dashboard docs](https://wasabeef.g Full example with outputs ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 id: agent-note with: base: main diff --git a/README.pt-br.md b/README.pt-br.md index a5fc9e68..2f23d53b 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -281,7 +281,7 @@ A root action tem dois modes: PR Report Mode é o default: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` @@ -291,7 +291,7 @@ Defina `prompt_detail` como `compact` ou `full` quando quiser um histórico de P Dashboard Mode usa a mesma action com `dashboard: true`: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 with: dashboard: true prompt_detail: compact @@ -313,7 +313,7 @@ Se você já tem um Site GitHub Pages, veja a configuração combinada segura na Full example with outputs ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 id: agent-note with: base: main diff --git a/README.ru.md b/README.ru.md index e72c1e2d..2cc3343d 100644 --- a/README.ru.md +++ b/README.ru.md @@ -281,7 +281,7 @@ Agent Note записывает Git Note для этого Commit PR Report Mode используется по умолчанию: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` @@ -291,7 +291,7 @@ PR Report Mode используется по умолчанию: Dashboard Mode использует ту же action с `dashboard: true`: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 with: dashboard: true prompt_detail: compact @@ -313,7 +313,7 @@ npx agent-note init --agent claude --dashboard Full example with outputs ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 id: agent-note with: base: main diff --git a/README.zh-cn.md b/README.zh-cn.md index 9e3cdf7d..bb29be87 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -281,7 +281,7 @@ root action 有两种 mode: PR Report Mode 是默认值: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` @@ -291,7 +291,7 @@ PR Report Mode 是默认值: Dashboard Mode 使用同一个 action,并传入 `dashboard: true`: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 with: dashboard: true prompt_detail: compact @@ -313,7 +313,7 @@ npx agent-note init --agent claude --dashboard Full example with outputs ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 id: agent-note with: base: main diff --git a/README.zh-tw.md b/README.zh-tw.md index 41414165..ff0697bb 100644 --- a/README.zh-tw.md +++ b/README.zh-tw.md @@ -281,7 +281,7 @@ root action 有兩種 mode: PR Report Mode 是預設值: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` @@ -291,7 +291,7 @@ PR Report Mode 是預設值: Dashboard Mode 使用同一個 action,並傳入 `dashboard: true`: ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 with: dashboard: true prompt_detail: compact @@ -313,7 +313,7 @@ npx agent-note init --agent claude --dashboard Full example with outputs ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 id: agent-note with: base: main diff --git a/docs/architecture.md b/docs/architecture.md index ac5ee54c..7d660787 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,7 +112,7 @@ The PR Report action reads the same git note schema that the CLI writes and the The dashboard package is a static Astro app. `packages/dashboard/public/notes/` is only the build input inside the workspace; generated note JSON is not committed to `main`. -For the live site, the generated Pages workflow calls `wasabeef/AgentNote@v0` with `dashboard: true`. The root Action delegates restore, sync, build, artifact upload, and note persistence to `packages/dashboard`. If the caller workflow already contains an `actions/upload-pages-artifact` step in the same job, Dashboard Mode auto-detects that artifact path and writes the built app under its `dashboard/` directory instead of uploading a standalone artifact. If another job or another workflow already owns Pages publishing, Dashboard Mode skips standalone publishing to avoid overwriting the existing site. This lets repositories with an existing docs site keep one combined Pages artifact without adding another input. It treats `gh-pages/dashboard/notes/*.json` as the durable store: +For the live site, the generated Pages workflow calls `wasabeef/AgentNote@v1` with `dashboard: true`. The root Action delegates restore, sync, build, artifact upload, and note persistence to `packages/dashboard`. If the caller workflow already contains an `actions/upload-pages-artifact` step in the same job, Dashboard Mode auto-detects that artifact path and writes the built app under its `dashboard/` directory instead of uploading a standalone artifact. If another job or another workflow already owns Pages publishing, Dashboard Mode skips standalone publishing to avoid overwriting the existing site. This lets repositories with an existing docs site keep one combined Pages artifact without adding another input. It treats `gh-pages/dashboard/notes/*.json` as the durable store: - restore those files into `packages/dashboard/public/notes/` - on `pull_request` (`opened`, `reopened`, `synchronize`), rewrite the current PR's note set and persist it back to `gh-pages` @@ -122,14 +122,14 @@ A brand-new Repository can therefore accumulate Dashboard note data before the D ### Root action.yml dispatcher -GitHub resolves `uses: wasabeef/AgentNote@v0` by looking for `action.yml` at the repo root. The root file is the public facade: +GitHub resolves `uses: wasabeef/AgentNote@v1` by looking for `action.yml` at the repo root. The root file is the public facade: ```yaml # PR Report Mode -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 # Dashboard Mode -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 with: dashboard: true ``` @@ -143,6 +143,8 @@ The implementation stays split by responsibility: `packages/pr-report` owns PR b 1. **CLI** (`packages/cli/`) — public user commands are `agent-note init`, `agent-note deinit`, `agent-note status`, `agent-note log`, `agent-note show`, and `agent-note why`. Automation-facing commands such as `agent-note pr`, `agent-note hook`, `agent-note record`, `agent-note commit`, and `agent-note push-notes` are kept for generated workflows and hooks. 2. **Hook handler** — `agent-note hook`, called by agent-specific hooks via stdin JSON (`--agent claude`, `codex`, `cursor`, or `gemini`). All data collection. +Public user installs generate agent hooks that call `npx --yes agent-note hook --agent `. The Agent Note repository itself may use repo-local development hooks such as `node packages/cli/dist/cli.js hook --agent ` so maintainers can exercise the built CLI before publishing. That `cli.js hook` form is a maintainer-only compatibility path and should not appear in public setup guidance. + ### Data flow ``` @@ -374,10 +376,10 @@ Agentnote-Session: a1b2c3d4-5678-4abc-8def-111122223333 ``` Injected via two parallel paths: -1. **Git hook** (`prepare-commit-msg`): reads session ID from `.git/agentnote/session`, verifies the session is fresh and has recordable data, then appends the trailer to the commit message file. -2. **Agent hook** (`PreToolUse Bash(*git commit*)`): Claude Code's hook applies the same guard before rewriting the git commit command to inject `--trailer` directly. +1. **Git hook** (`prepare-commit-msg`): reads session ID from `.git/agentnote/session`, verifies the session is fresh and has file evidence, then appends the trailer to the commit message file. +2. **Agent hook** (`PreToolUse Bash(*git commit*)`): Claude Code's hook can inject `--trailer` directly because the commit command itself came from the agent. -Both paths are redundant by design — if git hooks are not installed (e.g., first clone before `agent-note init`), the agent hook can still inject the trailer when the session has recordable data. +Both paths are redundant by design — if git hooks are not installed (e.g., first clone before `agent-note init`), the agent hook can still inject the trailer when the agent itself runs `git commit`. ### Git hooks for commit integration @@ -385,13 +387,13 @@ 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 recordable session data, then appends `Agentnote-Session` trailer. 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. Idempotent — skips if note already exists. | +| `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. | -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 or stale heartbeat in git hooks = skip (fail closed). +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. -Trailer injection also requires recordable session data. Prompts, file-change records, or pre-edit blobs count as recordable data. Transcript paths are supporting metadata, not recordable data by themselves. Heartbeat, `SessionStart`, and `transcript_path` metadata alone do not receive dangling `Agentnote-Session` trailers. +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. ### Git hook installation @@ -502,7 +504,7 @@ JSON output structure: ### Usage ```yaml -- uses: wasabeef/AgentNote@v0 +- uses: wasabeef/AgentNote@v1 id: agent-note with: base: main @@ -553,7 +555,7 @@ In Dashboard Mode (`dashboard: true`), it prepares the caller repository without ``` CLI: npx agent-note init (or npm install --save-dev) -Action: uses: wasabeef/AgentNote@v0 (Marketplace) +Action: uses: wasabeef/AgentNote@v1 (Marketplace) ``` ### Release procedure @@ -579,15 +581,15 @@ Release steps: 4. Review the generated release note locally before tagging: - `git-cliff --config .github/cliff.toml --latest --strip header` 5. Commit the version bump to `main`. -6. Create and push the matching git tag, for example `v0.1.11`. +6. Create and push the matching git tag, for example `v1.0.1`. Important: - Do **not** cut a release tag before the package version bump lands on `main`. -- If `packages/cli/package.json` still says `0.1.9` and you push `v0.1.10`, the workflow will still try to publish `0.1.9` and npm will reject it as an already published version. +- If `packages/cli/package.json` still says `1.0.0` and you push `v1.0.1`, the workflow will still try to publish `1.0.0` and npm will reject it as an already published version. - Treat `@wasabeef/agentnote` as a reserved alias only. Do not use it in README or website installation commands unless the project intentionally changes the canonical package name. - The npm publish job is rerun-safe: if either `agent-note@` or `@wasabeef/agentnote@` is already published, that package publish step is skipped. -- The workflow updates the floating major tag (`v0`) after the GitHub release is created, but it does not manage package.json versions for you. +- The workflow updates the floating major tag (`v1` for `v1.x.y` releases) after the GitHub release is created, but it does not manage package.json versions for you. ### Team workflow diff --git a/docs/knowledge/agent-skill.md b/docs/knowledge/agent-skill.md index 1a43667e..75f9ef37 100644 --- a/docs/knowledge/agent-skill.md +++ b/docs/knowledge/agent-skill.md @@ -179,7 +179,7 @@ for Agent Note outcomes, not merely for generic words like `git`, `PR`, | "Set up Agent Note" | `agent-note init` | Detect likely agent, suggest explicit `--agent` values, run init only after checking repo state. | Public | | "Set up Agent Note for Claude / Codex / Cursor / Gemini" | `agent-note init --agent ...` | Use the named agent, explain generated hook files, and remind the user to review config changes. | Public | | "Remove Agent Note" | `agent-note deinit` | Remove generated hooks and agent config for the named agents. Use `--remove-workflow` only when the user also wants generated workflows removed, and `--keep-notes` when notes auto-fetch should remain. | Public | -| "Enable PR Report" | GitHub Action inputs | Add or update the workflow that uses `wasabeef/AgentNote@v0`. Do not ask the user to run the PR renderer locally. | Public workflow setup | +| "Enable PR Report" | GitHub Action inputs | Add or update the workflow that uses `wasabeef/AgentNote@v1`. Do not ask the user to run the PR renderer locally. | Public workflow setup | | "Enable Dashboard" | GitHub Action `dashboard: true` and Pages output | Add or update Dashboard workflow settings, explain Pages requirements, and verify `permissions` / `pages` behavior. | Public workflow setup | | "Why did this line change?" | `agent-note why ` | Accept `path:line`, `path#Lline`, GitHub URLs, editor URLs, and `@path` mentions copied from AI output. Run the command and summarize evidence level. | Public | | "Show recent Agent Note history" | `agent-note log` / `agent-note show` | Use read-only commands to inspect notes and summarize relevant prompts, responses, files, and ratio. | Public | diff --git a/docs/knowledge/investigations.md b/docs/knowledge/investigations.md index 4acdf4c3..eca16dcb 100644 --- a/docs/knowledge/investigations.md +++ b/docs/knowledge/investigations.md @@ -25,9 +25,20 @@ - 修正: `agent-note hook` は、正規化された hook event を受けた時点で session heartbeat を更新します。これにより、長い turn の tool event、response、commit hook event が session freshness を延長します。Gemini の `SessionEnd` は true session termination なので、従来通り最後に heartbeat を削除します。 - 表示修正: PR Report は `tracked_commits === 0 && total_commits > 0` の場合、`Total AI Ratio: ░░░░░░░░ 0%` ではなく `Total AI Ratio: —` と `Agent Note data: No tracked commits` を表示します。これで missing note と true 0% attribution commit を分離します。 - Follow-up: PR `#58` の commit `56e6b48 fix(hooks): refresh heartbeat during long turns` では `Agentnote-Session` trailer は入ったものの、git note は作成されませんでした。原因は heartbeat ではなく、commit 時点の session が `SessionStart` / `transcript_path` / heartbeat だけの metadata-only session だったことです。`recordCommitEntry()` は `interactions.length === 0 && aiFiles.length === 0` の空 note を安全側で skip するため、trailer だけが残りました。 -- Follow-up 修正: `prepare-commit-msg`、`agent-note commit`、Agent の `PreToolUse git commit` trailer injection は、fresh heartbeat だけではなく、`prompts.jsonl` / `changes.jsonl` / `pre_blobs.jsonl` のいずれかに実データがある session だけを記録対象にします。`transcript_path` は補助 metadata であり、単体では recordable data として扱いません。これにより、plain shell commit や metadata-only session に dangling `Agentnote-Session` trailer を付けません。 -- 採用しなかった案: trailer がない場合に `post-commit` が `.git/agentnote/session` を fallback として読む案は採用しません。commit trailer を source of truth にする設計を維持し、TOCTOU risk を増やさないためです。 -- 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 を skip し、prompt data がある session だけに trailer を入れることを確認します。`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%` と表示されることを確認します。 +- Follow-up 修正: plain `git commit` 経路(`prepare-commit-msg`)は、fresh heartbeat に加えて `changes.jsonl` または `pre_blobs.jsonl` の file evidence がある session だけに trailer を付けます。`agent-note commit` と Agent の `PreToolUse git commit` trailer injection は、commit command が wrapper / Agent hook 内で観測されているため `prompts.jsonl` / `changes.jsonl` / `pre_blobs.jsonl` のいずれかを recordable data として扱います。`transcript_path` は補助 metadata であり、単体では recordable data として扱いません。これにより、metadata-only session と plain prompt-only session に dangling `Agentnote-Session` trailer を付けません。 +- 追加 Follow-up: PR `#71` の follow-up commit では、作業自体は長時間化していませんでしたが、runtime hook が現在の Codex session として発火しておらず、`.git/agentnote/session` が実際の作業 session ではない古い active pointer を指したままでした。この状態では `prepare-commit-msg` が heartbeat stale と判断し、trailer を入れないため、`post-commit` も従来は記録できませんでした。PR Report は git note を読むだけなので、表示可否は実際に使った Agent 名ではなく、対象 commit に git note が作られているかで決まります。 +- 採用した追加修正: `prepare-commit-msg` が stale heartbeat のため trailer 注入を skip した場合だけ、one-shot の `post_commit_fallback` marker を書きます。`post-commit` は trailer がなく、かつ marker がある場合だけ `agent-note record --fallback-head` を呼びます。amend / reuse commit は marker を書かず、既存 marker も先に削除するため fallback 対象外です。 +- 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 blob 読み取りは `git diff-tree -z --raw` を使います。Git の default `core.quotePath=true` では非 ASCII や空白を含む path が quote されるため、通常の `--raw` text parsing だと session JSONL の path と一致しません。NUL 区切りで読むことで、`src/日本語 file.ts` のような path でも post-edit blob evidence を正しく照合します。 +- 追加 Follow-up 2: PR `#71` の直近 commit 群は長時間作業ではなく短時間でも欠落しました。原因は stale ではなく、fresh な active session pointer が `prompts.jsonl` だけを持ち、実際の commit file evidence を持たない状態で、plain `git commit` の `prepare-commit-msg` が trailer を注入していたことです。PR `#71` の作業では Codex だけを使っていたため、この観測は「別 Agent で作業した」証拠ではなく、active pointer が現在の Codex 作業 session を正しく表していなかったことを示します。後段の `recordCommitEntry()` は `interactions.length === 0 && aiFiles.length === 0` と判断して note を安全側で skip するため、`trailer あり / note なし` になりました。修正後の plain git hook は `changes.jsonl` または `pre_blobs.jsonl` がある場合だけ trailer を注入します。Agent の `PreToolUse git commit` 経路は、commit command 自体が Agent 内で観測されているため prompt-only rescue を維持します。 +- 設計判断: `heartbeat` は active status と fast trailer injection の signal として残します。一方で、commit 紐づけの最後の判断は post-commit fallback marker、post-edit blob match、`recordCommitEntry()` の既存 causal filter に委ねます。これにより、1 時間を超える正当な作業は救いつつ、古い prompt-only session や同一 path の後続 human-only commit の誤 attribution を避けます。 +- 未解決の次作業: `.codex/hooks.json` が存在しても、現在の Codex runtime が Agent Note hook を呼んで `.git/agentnote/session` を更新しているとは限りません。次 PR では `agent-note status` か専用診断で、active session の `agent` / 最終 heartbeat / recordable files / installed git hook template version / agent hook enabled state を表示し、`Codex hook config exists but no recent Codex session was recorded` のような warning を出せるようにします。これにより、note がない原因を PR Report からではなく local diagnostics で切り分けられるようにします。 +- 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/core/record.test.ts`: 180 case の fallback evidence simulation を追加し、`Claude` / `Codex` / `Cursor` / `Gemini`、current / rotated `changes` / `pre_blobs`、matching / unrelated / empty evidence、prompt-only noise を組み合わせて fallback predicate を検証します。 + - `packages/cli/src/commands/commit.test.ts`: manual `agent-note commit` も同じ条件を使うことを確認します。 + - `packages/pr-report/src/report.test.ts`: note missing commit は `Total AI Ratio: —`、true 0% attribution commit は従来通り `░░░░░░░░ 0%` と表示されることを確認します。 ### PR #59 Codex shell-only commit が trailer 付き no-note になる diff --git a/packages/cli/dist/cli.js b/packages/cli/dist/cli.js index 0491ef54..c022b58f 100755 --- a/packages/cli/dist/cli.js +++ b/packages/cli/dist/cli.js @@ -1,63 +1,88 @@ #!/usr/bin/env node -var __defProp = Object.defineProperty; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __esm = (fn, res) => function __init() { - return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; -}; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; + +// src/commands/commit.ts +import { spawn as spawn2 } from "node:child_process"; +import { existsSync as existsSync9 } from "node:fs"; +import { readFile as readFile9 } from "node:fs/promises"; +import { join as join9 } from "node:path"; // src/core/constants.ts -var TRAILER_KEY, AGENTNOTE_HOOK_MARKER, AGENTNOTE_IGNORE_FILE, AGENTNOTE_HOOK_COMMAND, CLI_JS_HOOK_COMMAND, NOTES_REF, NOTES_REF_FULL, NOTES_FETCH_REFSPEC, AGENTNOTE_DIR, SESSIONS_DIR, GIT_HOOK_NAMES, PROMPTS_FILE, CHANGES_FILE, EVENTS_FILE, TRANSCRIPT_PATH_FILE, TURN_FILE, PROMPT_ID_FILE, SESSION_FILE, SESSION_AGENT_FILE, PENDING_COMMIT_FILE, MAX_COMMITS, RECENT_STATUS_COMMIT_LIMIT, DEFAULT_LOG_COUNT, BAR_WIDTH_FULL, TRUNCATE_PROMPT, TRUNCATE_PROMPT_PR, TRUNCATE_RESPONSE_SHOW, TRUNCATE_RESPONSE_PR, ARCHIVE_ID_RE, HEARTBEAT_FILE, HEARTBEAT_TTL_SECONDS, MILLISECONDS_PER_SECOND, PRE_BLOBS_FILE, COMMITTED_PAIRS_FILE, RECORDABLE_SESSION_FILES, EMPTY_BLOB, SCHEMA_VERSION, TEXT_ENCODING; -var init_constants = __esm({ - "src/core/constants.ts"() { - "use strict"; - TRAILER_KEY = "Agentnote-Session"; - AGENTNOTE_HOOK_MARKER = "# agentnote-managed"; - AGENTNOTE_IGNORE_FILE = ".agentnoteignore"; - AGENTNOTE_HOOK_COMMAND = "agent-note hook"; - CLI_JS_HOOK_COMMAND = "cli.js hook"; - NOTES_REF = "agentnote"; - NOTES_REF_FULL = `refs/notes/${NOTES_REF}`; - NOTES_FETCH_REFSPEC = `+${NOTES_REF_FULL}:${NOTES_REF_FULL}`; - AGENTNOTE_DIR = "agentnote"; - SESSIONS_DIR = "sessions"; - GIT_HOOK_NAMES = ["prepare-commit-msg", "post-commit", "pre-push"]; - PROMPTS_FILE = "prompts.jsonl"; - CHANGES_FILE = "changes.jsonl"; - EVENTS_FILE = "events.jsonl"; - TRANSCRIPT_PATH_FILE = "transcript_path"; - TURN_FILE = "turn"; - PROMPT_ID_FILE = "prompt_id"; - SESSION_FILE = "session"; - SESSION_AGENT_FILE = "agent"; - PENDING_COMMIT_FILE = "pending_commit.json"; - MAX_COMMITS = 500; - RECENT_STATUS_COMMIT_LIMIT = 20; - DEFAULT_LOG_COUNT = 10; - BAR_WIDTH_FULL = 20; - TRUNCATE_PROMPT = 120; - TRUNCATE_PROMPT_PR = 500; - TRUNCATE_RESPONSE_SHOW = 200; - TRUNCATE_RESPONSE_PR = 500; - ARCHIVE_ID_RE = /^[0-9a-z]{6,}$/; - HEARTBEAT_FILE = "heartbeat"; - HEARTBEAT_TTL_SECONDS = 60 * 60; - MILLISECONDS_PER_SECOND = 1e3; - PRE_BLOBS_FILE = "pre_blobs.jsonl"; - COMMITTED_PAIRS_FILE = "committed_pairs.jsonl"; - RECORDABLE_SESSION_FILES = [PROMPTS_FILE, CHANGES_FILE, PRE_BLOBS_FILE]; - EMPTY_BLOB = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"; - SCHEMA_VERSION = 1; - TEXT_ENCODING = "utf-8"; - } -}); +var TRAILER_KEY = "Agentnote-Session"; +var AGENTNOTE_HOOK_MARKER = "# agentnote-managed"; +var AGENTNOTE_IGNORE_FILE = ".agentnoteignore"; +var AGENTNOTE_HOOK_COMMAND = "agent-note hook"; +var CLI_JS_HOOK_COMMAND = "cli.js hook"; +var NOTES_REF = "agentnote"; +var NOTES_REF_FULL = `refs/notes/${NOTES_REF}`; +var NOTES_FETCH_REFSPEC = `+${NOTES_REF_FULL}:${NOTES_REF_FULL}`; +var AGENTNOTE_DIR = "agentnote"; +var SESSIONS_DIR = "sessions"; +var GIT_HOOK_NAMES = ["prepare-commit-msg", "post-commit", "pre-push"]; +var PROMPTS_FILE = "prompts.jsonl"; +var CHANGES_FILE = "changes.jsonl"; +var EVENTS_FILE = "events.jsonl"; +var TRANSCRIPT_PATH_FILE = "transcript_path"; +var TURN_FILE = "turn"; +var PROMPT_ID_FILE = "prompt_id"; +var SESSION_FILE = "session"; +var SESSION_AGENT_FILE = "agent"; +var PENDING_COMMIT_FILE = "pending_commit.json"; +var POST_COMMIT_FALLBACK_FILE = "post_commit_fallback"; +var POST_COMMIT_FALLBACK_HEAD = "head"; +var MAX_COMMITS = 500; +var RECENT_STATUS_COMMIT_LIMIT = 20; +var DEFAULT_LOG_COUNT = 10; +var BAR_WIDTH_FULL = 20; +var TRUNCATE_PROMPT = 120; +var TRUNCATE_PROMPT_PR = 500; +var TRUNCATE_RESPONSE_SHOW = 200; +var TRUNCATE_RESPONSE_PR = 500; +var ARCHIVE_ID_RE = /^[0-9a-z]{6,}$/; +var HEARTBEAT_FILE = "heartbeat"; +var HEARTBEAT_TTL_SECONDS = 60 * 60; +var MILLISECONDS_PER_SECOND = 1e3; +var PRE_BLOBS_FILE = "pre_blobs.jsonl"; +var COMMITTED_PAIRS_FILE = "committed_pairs.jsonl"; +var RECORDABLE_SESSION_FILES = [PROMPTS_FILE, CHANGES_FILE, PRE_BLOBS_FILE]; +var TRAILER_SESSION_FILES = [CHANGES_FILE, PRE_BLOBS_FILE]; +var EMPTY_BLOB = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"; +var SCHEMA_VERSION = 1; +var TEXT_ENCODING = "utf-8"; + +// src/core/record.ts +import { spawn } from "node:child_process"; +import { existsSync as existsSync7 } from "node:fs"; +import { readdir, readFile as readFile7, unlink, writeFile as writeFile6 } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join as join6 } from "node:path"; + +// src/agents/claude.ts +import { existsSync, readdirSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve, sep } from "node:path"; // src/git.ts import { execFile } from "node:child_process"; import { promisify } from "node:util"; +var execFileAsync = promisify(execFile); +var GIT_BINARY = "git"; +var GIT_COMMAND_COMMIT = "commit"; +var GIT_COMMAND_ENV = "env"; +var GIT_COMMAND_WRAPPER = "command"; +var GIT_AMEND_FLAG = "--amend"; +var GIT_END_OF_OPTIONS = "--"; +var SHELL_AND_OPERATOR = "&"; +var SHELL_PIPE_OPERATOR = "|"; +var SHELL_SEMICOLON_OPERATOR = ";"; +var SHELL_NEWLINE = "\n"; +var SHELL_ESCAPE = "\\"; +var SHELL_COMMENT = "#"; +var SHELL_SINGLE_QUOTE = "'"; +var SHELL_DOUBLE_QUOTE = '"'; +var ENV_IGNORE_FLAGS = /* @__PURE__ */ new Set(["-i", "--ignore-environment"]); +var GIT_OPTIONS_WITH_VALUES = /* @__PURE__ */ new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace"]); +var GIT_OPTIONS_WITH_INLINE_VALUES = ["--git-dir=", "--work-tree=", "--namespace=", "-c="]; async function git(args2, options) { const { stdout } = await execFileAsync(GIT_BINARY, args2, { cwd: options?.cwd, @@ -245,61 +270,166 @@ function injectGitCommitTrailer(command2, trailer) { if (!match) return null; return `${command2.slice(0, match.insertAt)} ${trailer}${command2.slice(match.insertAt)}`; } -var execFileAsync, GIT_BINARY, GIT_COMMAND_COMMIT, GIT_COMMAND_ENV, GIT_COMMAND_WRAPPER, GIT_AMEND_FLAG, GIT_END_OF_OPTIONS, SHELL_AND_OPERATOR, SHELL_PIPE_OPERATOR, SHELL_SEMICOLON_OPERATOR, SHELL_NEWLINE, SHELL_ESCAPE, SHELL_COMMENT, SHELL_SINGLE_QUOTE, SHELL_DOUBLE_QUOTE, ENV_IGNORE_FLAGS, GIT_OPTIONS_WITH_VALUES, GIT_OPTIONS_WITH_INLINE_VALUES; -var init_git = __esm({ - "src/git.ts"() { - "use strict"; - init_constants(); - execFileAsync = promisify(execFile); - GIT_BINARY = "git"; - GIT_COMMAND_COMMIT = "commit"; - GIT_COMMAND_ENV = "env"; - GIT_COMMAND_WRAPPER = "command"; - GIT_AMEND_FLAG = "--amend"; - GIT_END_OF_OPTIONS = "--"; - SHELL_AND_OPERATOR = "&"; - SHELL_PIPE_OPERATOR = "|"; - SHELL_SEMICOLON_OPERATOR = ";"; - SHELL_NEWLINE = "\n"; - SHELL_ESCAPE = "\\"; - SHELL_COMMENT = "#"; - SHELL_SINGLE_QUOTE = "'"; - SHELL_DOUBLE_QUOTE = '"'; - ENV_IGNORE_FLAGS = /* @__PURE__ */ new Set(["-i", "--ignore-environment"]); - GIT_OPTIONS_WITH_VALUES = /* @__PURE__ */ new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace"]); - GIT_OPTIONS_WITH_INLINE_VALUES = ["--git-dir=", "--work-tree=", "--namespace=", "-c="]; - } -}); -// src/agents/types.ts -var AGENT_NAMES, NORMALIZED_EVENT_KINDS; -var init_types = __esm({ - "src/agents/types.ts"() { - "use strict"; - AGENT_NAMES = { - claude: "claude", - codex: "codex", - cursor: "cursor", - gemini: "gemini" - }; - NORMALIZED_EVENT_KINDS = { - sessionStart: "session_start", - stop: "stop", - response: "response", - prompt: "prompt", - preEdit: "pre_edit", - fileChange: "file_change", - preCommit: "pre_commit", - postCommit: "post_commit" - }; +// src/agents/hook-command.ts +var AGENT_FLAG = "--agent"; +var AGENT_FLAG_PREFIX = `${AGENT_FLAG}=`; +var AGENTNOTE_HOOK_TOKENS = AGENTNOTE_HOOK_COMMAND.split(" "); +var CLI_JS_HOOK_TOKENS = CLI_JS_HOOK_COMMAND.split(" "); +var NODE_COMMAND_NAMES = /* @__PURE__ */ new Set(["node", "nodejs"]); +var NPX_COMMAND_NAME = "npx"; +var PATH_SEPARATOR_RE = /[\\/]/; +function tokenizeHookCommand(command2) { + const tokens = []; + let current = ""; + let quote = null; + let escaped = false; + for (const char of command2) { + if (escaped) { + current += char; + escaped = false; + continue; + } + if (char === "\\" && quote !== "'") { + escaped = true; + continue; + } + if ((char === '"' || char === "'") && quote === null) { + quote = char; + continue; + } + if (char === quote) { + quote = null; + continue; + } + if (quote === null && /\s/.test(char)) { + if (current) tokens.push(current); + current = ""; + continue; + } + current += char; + } + if (current) tokens.push(current); + return tokens; +} +function tokenBasename(token) { + return token.split(PATH_SEPARATOR_RE).pop() ?? token; +} +function hasHookTokenSequence(tokens, sequence) { + if (sequence.length === 0) return false; + return tokens.some((token, index) => { + const firstMatches = token === sequence[0] || sequence[0] === CLI_JS_HOOK_TOKENS[0] && tokenBasename(token) === sequence[0]; + if (!firstMatches || index + sequence.length > tokens.length) return false; + return sequence.slice(1).every((expectedToken, offset) => tokens[index + offset + 1] === expectedToken); + }); +} +function hasPublicHookCommand(tokens) { + return tokens.some((token, index) => { + if (token !== AGENTNOTE_HOOK_TOKENS[0]) return false; + if (!hasHookTokenSequence(tokens.slice(index), AGENTNOTE_HOOK_TOKENS)) return false; + if (index === 0) return true; + if (tokenBasename(tokens[0]) !== NPX_COMMAND_NAME) return false; + return tokens.slice(1, index).every((part) => part.startsWith("-")); + }); +} +function hasRepoLocalHookCommand(tokens) { + return tokens.some((token, index) => { + if (tokenBasename(token) !== CLI_JS_HOOK_TOKENS[0]) return false; + if (!hasHookTokenSequence(tokens.slice(index), CLI_JS_HOOK_TOKENS)) return false; + return index === 0 || index === 1 && NODE_COMMAND_NAMES.has(tokenBasename(tokens[0])); + }); +} +function readAgentFlag(tokens) { + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === AGENT_FLAG) return tokens[index + 1] ?? ""; + if (token.startsWith(AGENT_FLAG_PREFIX)) return token.slice(AGENT_FLAG_PREFIX.length); } -}); + return null; +} +function isAgentNoteHookCommand(command2, agentName, options = {}) { + const tokens = tokenizeHookCommand(command2); + const isPublicHook = hasPublicHookCommand(tokens); + const isRepoLocalHook = hasRepoLocalHookCommand(tokens); + if (!isPublicHook && !isRepoLocalHook) return false; + const agentFlag = readAgentFlag(tokens); + if (agentFlag === agentName) return true; + return options.allowMissingAgent === true && agentFlag === null; +} + +// src/agents/types.ts +var AGENT_NAMES = { + claude: "claude", + codex: "codex", + cursor: "cursor", + gemini: "gemini" +}; +var NORMALIZED_EVENT_KINDS = { + sessionStart: "session_start", + stop: "stop", + response: "response", + prompt: "prompt", + preEdit: "pre_edit", + fileChange: "file_change", + preCommit: "pre_commit", + postCommit: "post_commit" +}; // src/agents/claude.ts -import { existsSync, readdirSync } from "node:fs"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; -import { join, resolve, sep } from "node:path"; +var HOOK_COMMAND = "npx --yes agent-note hook"; +var CLAUDE_HOOK_COMMAND = `${HOOK_COMMAND} --agent ${AGENT_NAMES.claude}`; +var ENV_AGENTNOTE_CLAUDE_HOME = "AGENTNOTE_CLAUDE_HOME"; +var CLAUDE_HOOK_EVENTS = { + sessionStart: "SessionStart", + stop: "Stop", + userPromptSubmit: "UserPromptSubmit", + preToolUse: "PreToolUse", + postToolUse: "PostToolUse" +}; +var CLAUDE_TOOLS = { + bash: "Bash", + edit: "Edit", + write: "Write", + multiEdit: "MultiEdit", + notebookEdit: "NotebookEdit" +}; +var CLAUDE_EDIT_TOOLS = /* @__PURE__ */ new Set([ + CLAUDE_TOOLS.edit, + CLAUDE_TOOLS.write, + CLAUDE_TOOLS.multiEdit, + CLAUDE_TOOLS.notebookEdit +]); +var CLAUDE_EDIT_TOOL_MATCHER = "Edit|Write|MultiEdit|NotebookEdit"; +var CLAUDE_POST_TOOL_MATCHER = `${CLAUDE_EDIT_TOOL_MATCHER}|${CLAUDE_TOOLS.bash}`; +var CLAUDE_GIT_COMMIT_FILTER = "Bash(*git commit*)"; +var HOOKS_CONFIG = { + [CLAUDE_HOOK_EVENTS.sessionStart]: [ + { hooks: [{ type: "command", command: CLAUDE_HOOK_COMMAND, async: true }] } + ], + [CLAUDE_HOOK_EVENTS.stop]: [ + { hooks: [{ type: "command", command: CLAUDE_HOOK_COMMAND, async: true }] } + ], + [CLAUDE_HOOK_EVENTS.userPromptSubmit]: [ + { hooks: [{ type: "command", command: CLAUDE_HOOK_COMMAND, async: true }] } + ], + [CLAUDE_HOOK_EVENTS.preToolUse]: [ + { + matcher: CLAUDE_EDIT_TOOL_MATCHER, + hooks: [{ type: "command", command: CLAUDE_HOOK_COMMAND }] + }, + { + matcher: CLAUDE_TOOLS.bash, + hooks: [{ type: "command", if: CLAUDE_GIT_COMMIT_FILTER, command: CLAUDE_HOOK_COMMAND }] + } + ], + [CLAUDE_HOOK_EVENTS.postToolUse]: [ + { + matcher: CLAUDE_POST_TOOL_MATCHER, + hooks: [{ type: "command", command: CLAUDE_HOOK_COMMAND, async: true }] + } + ] +}; +var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function claudeHome() { return process.env[ENV_AGENTNOTE_CLAUDE_HOME] ?? join(homedir(), ".claude"); } @@ -311,6 +441,7 @@ function isValidTranscriptPath(p) { const normalized = resolve(p); return normalized === base || normalized.startsWith(`${base}${sep}`); } +var SYSTEM_PROMPT_PREFIXES = [" !isManagedClaudeHook(hook2)); + return hooks.length > 0 ? { ...group, hooks } : null; +} +function hasManagedClaudeHook(entry) { + if (!entry || typeof entry !== "object" || !Array.isArray(entry.hooks)) { + return false; + } + return entry.hooks.some((hook2) => { + if (!hook2 || typeof hook2 !== "object") return false; + const command2 = hook2.command; + return typeof command2 === "string" && isAgentNoteHookCommand(command2, AGENT_NAMES.claude); + }); +} +var claude = { + name: AGENT_NAMES.claude, + settingsRelPath: ".claude/settings.json", + async managedPaths() { + return [this.settingsRelPath]; + }, + async installHooks(repoRoot3) { + const settingsPath = join(repoRoot3, this.settingsRelPath); + const { dirname: dirname2 } = await import("node:path"); + await mkdir(dirname2(settingsPath), { recursive: true }); + let settings = {}; + if (existsSync(settingsPath)) { + try { + settings = JSON.parse(await readFile(settingsPath, TEXT_ENCODING)); + } catch { + settings = {}; + } + } + const hooks = settings.hooks ?? {}; + for (const [event, entries] of Object.entries(hooks)) { + hooks[event] = entries.map(removeManagedClaudeHooks).filter((entry) => entry !== null); + if (hooks[event].length === 0) delete hooks[event]; + } + for (const [event, entries] of Object.entries(HOOKS_CONFIG)) { + hooks[event] = [...hooks[event] ?? [], ...entries]; + } + settings.hooks = hooks; + await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)} +`); + }, + async removeHooks(repoRoot3) { + const settingsPath = join(repoRoot3, this.settingsRelPath); + if (!existsSync(settingsPath)) return; + try { + const settings = JSON.parse(await readFile(settingsPath, TEXT_ENCODING)); + if (!settings.hooks) return; + for (const [event, entries] of Object.entries(settings.hooks)) { + settings.hooks[event] = entries.map(removeManagedClaudeHooks).filter((entry) => entry !== null); + if (settings.hooks[event].length === 0) delete settings.hooks[event]; + } + if (Object.keys(settings.hooks).length === 0) delete settings.hooks; + await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)} +`); + } catch { + } + }, + async isEnabled(repoRoot3) { + const settingsPath = join(repoRoot3, this.settingsRelPath); + if (!existsSync(settingsPath)) return false; + try { + const content = await readFile(settingsPath, TEXT_ENCODING); + const settings = JSON.parse(content); + return Object.values(settings.hooks ?? {}).some( + (entries) => entries.some((entry) => hasManagedClaudeHook(entry)) + ); + } catch { + return false; + } + }, + parseEvent(input) { + let e; + try { + e = JSON.parse(input.raw); + } catch { + return null; + } + const sid = e.session_id; + const ts = (/* @__PURE__ */ new Date()).toISOString(); + if (!sid || !isValidSessionId(sid)) return null; + const tp = e.transcript_path && isValidTranscriptPath(e.transcript_path) ? e.transcript_path : void 0; + switch (e.hook_event_name) { + case CLAUDE_HOOK_EVENTS.sessionStart: + return { + kind: NORMALIZED_EVENT_KINDS.sessionStart, + sessionId: sid, + timestamp: ts, + model: e.model, + transcriptPath: tp + }; + case CLAUDE_HOOK_EVENTS.stop: + return { + kind: NORMALIZED_EVENT_KINDS.stop, + sessionId: sid, + timestamp: ts, + transcriptPath: tp + }; + case CLAUDE_HOOK_EVENTS.userPromptSubmit: + if (!e.prompt || isSystemInjectedPrompt(e.prompt)) { + return null; } - const hooks = settings.hooks ?? {}; - for (const [event, entries] of Object.entries(hooks)) { - hooks[event] = entries.filter((entry) => { - const text = JSON.stringify(entry); - return !text.includes(AGENTNOTE_HOOK_COMMAND) && !text.includes(CLI_JS_HOOK_COMMAND); - }); - if (hooks[event].length === 0) delete hooks[event]; + return { + kind: NORMALIZED_EVENT_KINDS.prompt, + sessionId: sid, + timestamp: ts, + prompt: e.prompt + }; + case CLAUDE_HOOK_EVENTS.preToolUse: { + const tool = e.tool_name; + const cmd = e.tool_input?.command ?? ""; + if (tool && CLAUDE_EDIT_TOOLS.has(tool) && e.tool_input?.file_path) { + return { + kind: NORMALIZED_EVENT_KINDS.preEdit, + sessionId: sid, + timestamp: ts, + tool, + file: e.tool_input.file_path, + toolUseId: e.tool_use_id + }; } - for (const [event, entries] of Object.entries(HOOKS_CONFIG)) { - hooks[event] = [...hooks[event] ?? [], ...entries]; + if (tool === CLAUDE_TOOLS.bash && isGitCommit(cmd)) { + return { + kind: NORMALIZED_EVENT_KINDS.preCommit, + sessionId: sid, + timestamp: ts, + commitCommand: cmd + }; } - settings.hooks = hooks; - await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)} -`); - }, - async removeHooks(repoRoot3) { - const settingsPath = join(repoRoot3, this.settingsRelPath); - if (!existsSync(settingsPath)) return; - try { - const settings = JSON.parse(await readFile(settingsPath, TEXT_ENCODING)); - if (!settings.hooks) return; - for (const [event, entries] of Object.entries(settings.hooks)) { - settings.hooks[event] = entries.filter((e) => { - const text = JSON.stringify(e); - return !text.includes(AGENTNOTE_HOOK_COMMAND) && !text.includes(CLI_JS_HOOK_COMMAND); - }); - if (settings.hooks[event].length === 0) delete settings.hooks[event]; - } - if (Object.keys(settings.hooks).length === 0) delete settings.hooks; - await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)} -`); - } catch { + return null; + } + case CLAUDE_HOOK_EVENTS.postToolUse: { + const tool = e.tool_name; + if (tool && CLAUDE_EDIT_TOOLS.has(tool) && e.tool_input?.file_path) { + return { + kind: NORMALIZED_EVENT_KINDS.fileChange, + sessionId: sid, + timestamp: ts, + tool, + file: e.tool_input.file_path, + toolUseId: e.tool_use_id + }; } - }, - async isEnabled(repoRoot3) { - const settingsPath = join(repoRoot3, this.settingsRelPath); - if (!existsSync(settingsPath)) return false; - try { - const content = await readFile(settingsPath, TEXT_ENCODING); - return content.includes(CLAUDE_HOOK_COMMAND); - } catch { - return false; + if (tool === CLAUDE_TOOLS.bash && isGitCommit(e.tool_input?.command ?? "")) { + return { + kind: NORMALIZED_EVENT_KINDS.postCommit, + sessionId: sid, + timestamp: ts, + transcriptPath: tp + }; } - }, - parseEvent(input) { - let e; - try { - e = JSON.parse(input.raw); - } catch { - return null; + return null; + } + default: + return null; + } + }, + findTranscript(sessionId) { + if (!isValidSessionId(sessionId)) return null; + const claudeDir = join(claudeHome(), "projects"); + if (!existsSync(claudeDir)) return null; + try { + for (const project of readdirSync(claudeDir)) { + const sessionsDir = join(claudeDir, project, "sessions"); + if (!existsSync(sessionsDir)) continue; + const candidate = join(sessionsDir, `${sessionId}.jsonl`); + if (existsSync(candidate) && isValidTranscriptPath(candidate)) { + return candidate; } - const sid = e.session_id; - const ts = (/* @__PURE__ */ new Date()).toISOString(); - if (!sid || !isValidSessionId(sid)) return null; - const tp = e.transcript_path && isValidTranscriptPath(e.transcript_path) ? e.transcript_path : void 0; - switch (e.hook_event_name) { - case CLAUDE_HOOK_EVENTS.sessionStart: - return { - kind: NORMALIZED_EVENT_KINDS.sessionStart, - sessionId: sid, - timestamp: ts, - model: e.model, - transcriptPath: tp - }; - case CLAUDE_HOOK_EVENTS.stop: - return { - kind: NORMALIZED_EVENT_KINDS.stop, - sessionId: sid, - timestamp: ts, - transcriptPath: tp - }; - case CLAUDE_HOOK_EVENTS.userPromptSubmit: - if (!e.prompt || isSystemInjectedPrompt(e.prompt)) { - return null; - } - return { - kind: NORMALIZED_EVENT_KINDS.prompt, - sessionId: sid, - timestamp: ts, - prompt: e.prompt - }; - case CLAUDE_HOOK_EVENTS.preToolUse: { - const tool = e.tool_name; - const cmd = e.tool_input?.command ?? ""; - if (tool && CLAUDE_EDIT_TOOLS.has(tool) && e.tool_input?.file_path) { - return { - kind: NORMALIZED_EVENT_KINDS.preEdit, - sessionId: sid, - timestamp: ts, - tool, - file: e.tool_input.file_path, - toolUseId: e.tool_use_id - }; - } - if (tool === CLAUDE_TOOLS.bash && isGitCommit(cmd)) { - return { - kind: NORMALIZED_EVENT_KINDS.preCommit, - sessionId: sid, - timestamp: ts, - commitCommand: cmd - }; - } - return null; - } - case CLAUDE_HOOK_EVENTS.postToolUse: { - const tool = e.tool_name; - if (tool && CLAUDE_EDIT_TOOLS.has(tool) && e.tool_input?.file_path) { - return { - kind: NORMALIZED_EVENT_KINDS.fileChange, - sessionId: sid, - timestamp: ts, - tool, - file: e.tool_input.file_path, - toolUseId: e.tool_use_id - }; - } - if (tool === CLAUDE_TOOLS.bash && isGitCommit(e.tool_input?.command ?? "")) { - return { - kind: NORMALIZED_EVENT_KINDS.postCommit, - sessionId: sid, - timestamp: ts, - transcriptPath: tp - }; - } - return null; + } + } catch { + } + return null; + }, + async extractInteractions(transcriptPath) { + if (!isValidTranscriptPath(transcriptPath) || !existsSync(transcriptPath)) return []; + try { + const content = await readFile(transcriptPath, TEXT_ENCODING); + const lines = content.trim().split("\n"); + const interactions = []; + let pendingPrompt = null; + let pendingPromptTimestamp; + let pendingResponseTexts = []; + const flush = () => { + if (pendingPrompt === null) return; + const response = pendingResponseTexts.length > 0 ? pendingResponseTexts.join("\n") : null; + const interaction = { prompt: pendingPrompt, response }; + if (pendingPromptTimestamp) interaction.timestamp = pendingPromptTimestamp; + interactions.push(interaction); + pendingPrompt = null; + pendingPromptTimestamp = void 0; + pendingResponseTexts = []; + }; + const extractUserText = (content2) => { + if (!Array.isArray(content2)) return null; + const texts = []; + for (const block of content2) { + if (block && typeof block === "object" && block.type === "text" && block.text) { + texts.push(block.text); } - default: - return null; } - }, - findTranscript(sessionId) { - if (!isValidSessionId(sessionId)) return null; - const claudeDir = join(claudeHome(), "projects"); - if (!existsSync(claudeDir)) return null; + return texts.length > 0 ? texts.join("\n") : null; + }; + for (const line of lines) { try { - for (const project of readdirSync(claudeDir)) { - const sessionsDir = join(claudeDir, project, "sessions"); - if (!existsSync(sessionsDir)) continue; - const candidate = join(sessionsDir, `${sessionId}.jsonl`); - if (existsSync(candidate) && isValidTranscriptPath(candidate)) { - return candidate; + const entry = JSON.parse(line); + if (entry.type === "user" && entry.message?.content) { + const userText = extractUserText(entry.message.content); + if (userText) { + flush(); + pendingPrompt = userText; + pendingPromptTimestamp = typeof entry.timestamp === "string" ? entry.timestamp : void 0; } } - } catch { - } - return null; - }, - async extractInteractions(transcriptPath) { - if (!isValidTranscriptPath(transcriptPath) || !existsSync(transcriptPath)) return []; - try { - const content = await readFile(transcriptPath, TEXT_ENCODING); - const lines = content.trim().split("\n"); - const interactions = []; - let pendingPrompt = null; - let pendingPromptTimestamp; - let pendingResponseTexts = []; - const flush = () => { - if (pendingPrompt === null) return; - const response = pendingResponseTexts.length > 0 ? pendingResponseTexts.join("\n") : null; - const interaction = { prompt: pendingPrompt, response }; - if (pendingPromptTimestamp) interaction.timestamp = pendingPromptTimestamp; - interactions.push(interaction); - pendingPrompt = null; - pendingPromptTimestamp = void 0; - pendingResponseTexts = []; - }; - const extractUserText = (content2) => { - if (!Array.isArray(content2)) return null; - const texts = []; - for (const block of content2) { - if (block && typeof block === "object" && block.type === "text" && block.text) { - texts.push(block.text); - } - } - return texts.length > 0 ? texts.join("\n") : null; - }; - for (const line of lines) { - try { - const entry = JSON.parse(line); - if (entry.type === "user" && entry.message?.content) { - const userText = extractUserText(entry.message.content); - if (userText) { - flush(); - pendingPrompt = userText; - pendingPromptTimestamp = typeof entry.timestamp === "string" ? entry.timestamp : void 0; - } + if (entry.type === "assistant" && entry.message?.content && pendingPrompt !== null) { + for (const block of entry.message.content) { + if (block?.type === "text" && block.text) { + pendingResponseTexts.push(block.text); } - if (entry.type === "assistant" && entry.message?.content && pendingPrompt !== null) { - for (const block of entry.message.content) { - if (block?.type === "text" && block.text) { - pendingResponseTexts.push(block.text); - } - } - } - } catch { } } - flush(); - return interactions; } catch { - return []; } } - }; + flush(); + return interactions; + } catch { + return []; + } } -}); +}; // src/agents/codex.ts import { 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"; +var CONFIG_REL_PATH = ".codex/config.toml"; +var ENV_CODEX_HOME = "CODEX_HOME"; +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 CODEX_HOOK_EVENTS = { + sessionStart: "SessionStart", + userPromptSubmit: "UserPromptSubmit", + stop: "Stop" +}; function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -784,7 +881,11 @@ function stripAgentnoteHooks(config) { Object.entries(config.hooks).map(([event, groups]) => { const filteredGroups = groups.map((group) => ({ ...group, - hooks: group.hooks.filter((hook2) => !hook2.command.includes(AGENTNOTE_HOOK_COMMAND)) + hooks: group.hooks.filter( + (hook2) => !isAgentNoteHookCommand(hook2.command, AGENT_NAMES.codex, { + allowMissingAgent: true + }) + ) })).filter((group) => group.hooks.length > 0); return [event, filteredGroups]; }).filter(([, groups]) => groups.length > 0) @@ -860,212 +961,199 @@ function appendInteractionTool(interaction, toolName) { if (tools.includes(toolName)) return; interaction.tools = [...tools, toolName]; } -var CONFIG_REL_PATH, ENV_CODEX_HOME, HOOKS_REL_PATH, HOOK_COMMAND2, TRANSCRIPT_PREVIEW_CHARS, CODEX_HOOK_EVENTS, codex; -var init_codex = __esm({ - "src/agents/codex.ts"() { - "use strict"; - init_constants(); - init_types(); - CONFIG_REL_PATH = ".codex/config.toml"; - ENV_CODEX_HOME = "CODEX_HOME"; - HOOKS_REL_PATH = ".codex/hooks.json"; - HOOK_COMMAND2 = `npx --yes agent-note hook --agent ${AGENT_NAMES.codex}`; - TRANSCRIPT_PREVIEW_CHARS = 4096; - CODEX_HOOK_EVENTS = { - sessionStart: "SessionStart", - userPromptSubmit: "UserPromptSubmit", - stop: "Stop" - }; - codex = { - name: AGENT_NAMES.codex, - settingsRelPath: CONFIG_REL_PATH, - async managedPaths() { - return [CONFIG_REL_PATH, HOOKS_REL_PATH]; - }, - async installHooks(repoRoot3) { - const codexDir = join2(repoRoot3, ".codex"); - const configPath = join2(repoRoot3, CONFIG_REL_PATH); - const hooksPath = join2(repoRoot3, HOOKS_REL_PATH); - await mkdir2(codexDir, { recursive: true }); - const configContent = existsSync2(configPath) ? await readFile2(configPath, TEXT_ENCODING) : ""; - await writeFile2(configPath, normalizeConfigToml(configContent)); - let hooksConfig = {}; - if (existsSync2(hooksPath)) { - try { - hooksConfig = JSON.parse(await readFile2(hooksPath, TEXT_ENCODING)); - } catch { - hooksConfig = {}; - } - } - await writeFile2(hooksPath, `${JSON.stringify(mergeHooksConfig(hooksConfig), null, 2)} +var codex = { + name: AGENT_NAMES.codex, + settingsRelPath: CONFIG_REL_PATH, + async managedPaths() { + return [CONFIG_REL_PATH, HOOKS_REL_PATH]; + }, + async installHooks(repoRoot3) { + const codexDir = join2(repoRoot3, ".codex"); + const configPath = join2(repoRoot3, CONFIG_REL_PATH); + const hooksPath = join2(repoRoot3, HOOKS_REL_PATH); + await mkdir2(codexDir, { recursive: true }); + const configContent = existsSync2(configPath) ? await readFile2(configPath, TEXT_ENCODING) : ""; + await writeFile2(configPath, normalizeConfigToml(configContent)); + let hooksConfig = {}; + if (existsSync2(hooksPath)) { + try { + hooksConfig = JSON.parse(await readFile2(hooksPath, TEXT_ENCODING)); + } catch { + hooksConfig = {}; + } + } + await writeFile2(hooksPath, `${JSON.stringify(mergeHooksConfig(hooksConfig), null, 2)} `); - }, - async removeHooks(repoRoot3) { - const hooksPath = join2(repoRoot3, HOOKS_REL_PATH); - if (!existsSync2(hooksPath)) return; - try { - const parsed = JSON.parse(await readFile2(hooksPath, TEXT_ENCODING)); - await writeFile2(hooksPath, `${JSON.stringify(stripAgentnoteHooks(parsed), null, 2)} + }, + async removeHooks(repoRoot3) { + const hooksPath = join2(repoRoot3, HOOKS_REL_PATH); + if (!existsSync2(hooksPath)) return; + try { + const parsed = JSON.parse(await readFile2(hooksPath, TEXT_ENCODING)); + await writeFile2(hooksPath, `${JSON.stringify(stripAgentnoteHooks(parsed), null, 2)} `); - } catch { - } - }, - async isEnabled(repoRoot3) { - const configPath = join2(repoRoot3, CONFIG_REL_PATH); - const hooksPath = join2(repoRoot3, HOOKS_REL_PATH); - if (!existsSync2(configPath) || !existsSync2(hooksPath)) return false; - try { - const [configContent, hooksContent] = await Promise.all([ - readFile2(configPath, TEXT_ENCODING), - readFile2(hooksPath, TEXT_ENCODING) - ]); - const configOk = configContent.includes("features.codex_hooks = true") || configContent.includes("[features]") && configContent.match(/^\s*codex_hooks\s*=\s*true\s*$/m) !== null; - const hasHook = hooksContent.includes(HOOK_COMMAND2); - return configOk && hasHook; - } catch { - return false; - } - }, - parseEvent(input) { - let payload; - try { - payload = JSON.parse(input.raw); - } catch { - return null; + } catch { + } + }, + async isEnabled(repoRoot3) { + const configPath = join2(repoRoot3, CONFIG_REL_PATH); + const hooksPath = join2(repoRoot3, HOOKS_REL_PATH); + if (!existsSync2(configPath) || !existsSync2(hooksPath)) return false; + try { + const [configContent, hooksContent] = await Promise.all([ + readFile2(configPath, TEXT_ENCODING), + readFile2(hooksPath, TEXT_ENCODING) + ]); + const configOk = configContent.includes("features.codex_hooks = true") || configContent.includes("[features]") && configContent.match(/^\s*codex_hooks\s*=\s*true\s*$/m) !== null; + const parsed = JSON.parse(hooksContent); + const hasHook = Object.values(parsed.hooks ?? {}).some( + (groups) => groups.some( + (group) => group.hooks.some((hook2) => isAgentNoteHookCommand(hook2.command, AGENT_NAMES.codex)) + ) + ); + return configOk && hasHook; + } catch { + return false; + } + }, + parseEvent(input) { + let payload; + try { + payload = JSON.parse(input.raw); + } catch { + return null; + } + const sessionId = payload.session_id?.trim(); + if (!sessionId) return null; + const timestamp = (/* @__PURE__ */ new Date()).toISOString(); + const transcriptPath = normalizeTranscriptPath(payload.transcript_path); + switch (payload.hook_event_name) { + case CODEX_HOOK_EVENTS.sessionStart: + return { + kind: NORMALIZED_EVENT_KINDS.sessionStart, + sessionId, + timestamp, + model: payload.model, + transcriptPath + }; + case CODEX_HOOK_EVENTS.userPromptSubmit: + return payload.prompt ? { + kind: NORMALIZED_EVENT_KINDS.prompt, + sessionId, + timestamp, + prompt: payload.prompt, + transcriptPath, + model: payload.model + } : null; + case CODEX_HOOK_EVENTS.stop: + return { + kind: NORMALIZED_EVENT_KINDS.stop, + sessionId, + timestamp, + response: payload.last_assistant_message ?? void 0, + transcriptPath, + model: payload.model + }; + default: + return null; + } + }, + findTranscript(sessionId) { + const sessionsDir = join2(codexHome(), "sessions"); + if (!existsSync2(sessionsDir)) return null; + return findTranscriptCandidate(sessionsDir, sessionId); + }, + async extractInteractions(transcriptPath) { + if (!isValidTranscriptPath2(transcriptPath)) { + throw new Error(`Invalid Codex transcript path: ${transcriptPath}`); + } + 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 sessionId = payload.session_id?.trim(); - if (!sessionId) return null; - const timestamp = (/* @__PURE__ */ new Date()).toISOString(); - const transcriptPath = normalizeTranscriptPath(payload.transcript_path); - switch (payload.hook_event_name) { - case CODEX_HOOK_EVENTS.sessionStart: - return { - kind: NORMALIZED_EVENT_KINDS.sessionStart, - sessionId, - timestamp, - model: payload.model, - transcriptPath - }; - case CODEX_HOOK_EVENTS.userPromptSubmit: - return payload.prompt ? { - kind: NORMALIZED_EVENT_KINDS.prompt, - sessionId, - timestamp, - prompt: payload.prompt, - transcriptPath, - model: payload.model - } : null; - case CODEX_HOOK_EVENTS.stop: - return { - kind: NORMALIZED_EVENT_KINDS.stop, - sessionId, - timestamp, - response: payload.last_assistant_message ?? void 0, - transcriptPath, - model: payload.model + 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 }; - default: - return null; - } - }, - findTranscript(sessionId) { - const sessionsDir = join2(codexHome(), "sessions"); - if (!existsSync2(sessionsDir)) return null; - return findTranscriptCandidate(sessionsDir, sessionId); - }, - async extractInteractions(transcriptPath) { - if (!isValidTranscriptPath2(transcriptPath)) { - throw new Error(`Invalid Codex transcript path: ${transcriptPath}`); + } } - if (!existsSync2(transcriptPath)) { - throw new Error(`Codex transcript not found: ${transcriptPath}`); + if (files.length > 0) { + current.files_touched = [.../* @__PURE__ */ new Set([...current.files_touched ?? [], ...files])]; } - let content; - try { - content = await readFile2(transcriptPath, TEXT_ENCODING); - } catch { - throw new Error(`Failed to read Codex transcript: ${transcriptPath}`); + if (Object.keys(current.line_stats).length === 0) { + delete current.line_stats; } - 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; - } - 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 (files.length > 0) { - current.files_touched = [.../* @__PURE__ */ new Set([...current.files_touched ?? [], ...files])]; - } - if (Object.keys(current.line_stats).length === 0) { - delete current.line_stats; - } - } - } - if (current) interactions.push(current); - return interactions; } - }; + } + if (current) interactions.push(current); + return interactions; } -}); +}; // src/agents/cursor.ts import { execFileSync } from "node:child_process"; @@ -1073,6 +1161,21 @@ import { existsSync as existsSync3, statSync } from "node:fs"; import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises"; import { homedir as homedir3 } from "node:os"; import { join as join3, resolve as resolve3, sep as sep3 } from "node:path"; +var HOOKS_REL_PATH2 = ".cursor/hooks.json"; +var HOOK_COMMAND3 = `npx --yes agent-note hook --agent ${AGENT_NAMES.cursor}`; +var CURSOR_PROJECTS_DIR = join3(homedir3(), ".cursor", "projects"); +var CURSOR_TRANSCRIPTS_DIR_ENV = "AGENTNOTE_CURSOR_TRANSCRIPTS_DIR"; +var TRANSCRIPT_WAIT_MS = 1500; +var TRANSCRIPT_POLL_MS = 50; +var CURSOR_HOOK_EVENTS = { + beforeSubmitPrompt: "beforeSubmitPrompt", + afterAgentResponse: "afterAgentResponse", + beforeShellExecution: "beforeShellExecution", + afterFileEdit: "afterFileEdit", + afterTabFileEdit: "afterTabFileEdit", + afterShellExecution: "afterShellExecution", + stop: "stop" +}; function isRecord2(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -1305,7 +1408,9 @@ function stripAgentnoteHooks2(config) { const hooks = Object.fromEntries( Object.entries(config.hooks).map(([event, entries]) => [ event, - entries.filter((entry) => !entry.command.includes(AGENTNOTE_HOOK_COMMAND)) + entries.filter( + (entry) => !isAgentNoteHookCommand(entry.command, AGENT_NAMES.cursor, { allowMissingAgent: true }) + ) ]).filter(([, entries]) => entries.length > 0) ); return { version: config.version ?? 1, hooks }; @@ -1322,166 +1427,274 @@ function mergeHooksConfig2(existing) { hooks: mergedHooks }; } -var HOOKS_REL_PATH2, HOOK_COMMAND3, CURSOR_PROJECTS_DIR, CURSOR_TRANSCRIPTS_DIR_ENV, TRANSCRIPT_WAIT_MS, TRANSCRIPT_POLL_MS, CURSOR_HOOK_EVENTS, cursor; -var init_cursor = __esm({ - "src/agents/cursor.ts"() { - "use strict"; - init_constants(); - init_git(); - init_types(); - HOOKS_REL_PATH2 = ".cursor/hooks.json"; - HOOK_COMMAND3 = `npx --yes agent-note hook --agent ${AGENT_NAMES.cursor}`; - CURSOR_PROJECTS_DIR = join3(homedir3(), ".cursor", "projects"); - CURSOR_TRANSCRIPTS_DIR_ENV = "AGENTNOTE_CURSOR_TRANSCRIPTS_DIR"; - TRANSCRIPT_WAIT_MS = 1500; - TRANSCRIPT_POLL_MS = 50; - CURSOR_HOOK_EVENTS = { - beforeSubmitPrompt: "beforeSubmitPrompt", - afterAgentResponse: "afterAgentResponse", - beforeShellExecution: "beforeShellExecution", - afterFileEdit: "afterFileEdit", - afterTabFileEdit: "afterTabFileEdit", - afterShellExecution: "afterShellExecution", - stop: "stop" - }; - cursor = { - name: AGENT_NAMES.cursor, - settingsRelPath: HOOKS_REL_PATH2, - async managedPaths() { - return [HOOKS_REL_PATH2]; - }, - async installHooks(repoRoot3) { - const cursorDir = join3(repoRoot3, ".cursor"); - const hooksPath = join3(repoRoot3, HOOKS_REL_PATH2); - await mkdir3(cursorDir, { recursive: true }); - let hooksConfig = {}; - if (existsSync3(hooksPath)) { - try { - hooksConfig = JSON.parse(await readFile3(hooksPath, TEXT_ENCODING)); - } catch { - hooksConfig = {}; - } - } - await writeFile3(hooksPath, `${JSON.stringify(mergeHooksConfig2(hooksConfig), null, 2)} +var cursor = { + name: AGENT_NAMES.cursor, + settingsRelPath: HOOKS_REL_PATH2, + async managedPaths() { + return [HOOKS_REL_PATH2]; + }, + async installHooks(repoRoot3) { + const cursorDir = join3(repoRoot3, ".cursor"); + const hooksPath = join3(repoRoot3, HOOKS_REL_PATH2); + await mkdir3(cursorDir, { recursive: true }); + let hooksConfig = {}; + if (existsSync3(hooksPath)) { + try { + hooksConfig = JSON.parse(await readFile3(hooksPath, TEXT_ENCODING)); + } catch { + hooksConfig = {}; + } + } + await writeFile3(hooksPath, `${JSON.stringify(mergeHooksConfig2(hooksConfig), null, 2)} `); - }, - async removeHooks(repoRoot3) { - const hooksPath = join3(repoRoot3, HOOKS_REL_PATH2); - if (!existsSync3(hooksPath)) return; - try { - const parsed = JSON.parse(await readFile3(hooksPath, TEXT_ENCODING)); - await writeFile3(hooksPath, `${JSON.stringify(stripAgentnoteHooks2(parsed), null, 2)} + }, + async removeHooks(repoRoot3) { + const hooksPath = join3(repoRoot3, HOOKS_REL_PATH2); + if (!existsSync3(hooksPath)) return; + try { + const parsed = JSON.parse(await readFile3(hooksPath, TEXT_ENCODING)); + await writeFile3(hooksPath, `${JSON.stringify(stripAgentnoteHooks2(parsed), null, 2)} `); - } catch { + } catch { + } + }, + async isEnabled(repoRoot3) { + const hooksPath = join3(repoRoot3, HOOKS_REL_PATH2); + if (!existsSync3(hooksPath)) return false; + try { + const content = await readFile3(hooksPath, TEXT_ENCODING); + const parsed = JSON.parse(content); + return Object.values(parsed.hooks ?? {}).some( + (entries) => entries.some((entry) => isAgentNoteHookCommand(entry.command, AGENT_NAMES.cursor)) + ); + } catch { + return false; + } + }, + parseEvent(input) { + let payload; + try { + payload = JSON.parse(input.raw); + } catch { + return null; + } + const sessionId = sessionIdFromPayload(payload); + if (!sessionId) return null; + const timestamp = (/* @__PURE__ */ new Date()).toISOString(); + switch (payload.hook_event_name) { + case CURSOR_HOOK_EVENTS.beforeSubmitPrompt: + return payload.prompt ? { + kind: NORMALIZED_EVENT_KINDS.prompt, + sessionId, + timestamp, + prompt: payload.prompt, + model: payload.model + } : null; + case CURSOR_HOOK_EVENTS.afterAgentResponse: { + const response = collectMessageText2( + payload.response ?? payload.text ?? payload.content ?? payload.message ?? payload.output + ).join("\n").trim(); + return response ? { + kind: NORMALIZED_EVENT_KINDS.response, + sessionId, + timestamp, + response + } : null; + } + case CURSOR_HOOK_EVENTS.beforeShellExecution: + return payload.command && isGitCommit2(payload.command) ? { + kind: NORMALIZED_EVENT_KINDS.preCommit, + sessionId, + timestamp, + commitCommand: payload.command + } : null; + case CURSOR_HOOK_EVENTS.afterFileEdit: + case CURSOR_HOOK_EVENTS.afterTabFileEdit: { + const filePath = payload.file_path ?? payload.filePath; + const editStats = extractEditStats(payload.edits); + return filePath ? { + kind: NORMALIZED_EVENT_KINDS.fileChange, + sessionId, + timestamp, + file: filePath, + tool: payload.hook_event_name, + ...editStats ? { editStats } : {} + } : null; + } + case CURSOR_HOOK_EVENTS.afterShellExecution: + return payload.command && isGitCommit2(payload.command) ? { + kind: NORMALIZED_EVENT_KINDS.postCommit, + sessionId, + timestamp + } : null; + case CURSOR_HOOK_EVENTS.stop: { + const response = collectMessageText2( + payload.response ?? payload.text ?? payload.content ?? payload.message ?? payload.output + ).join("\n").trim(); + return { + kind: NORMALIZED_EVENT_KINDS.stop, + sessionId, + timestamp, + response: response || void 0 + }; + } + default: + return null; + } + }, + findTranscript(sessionId) { + return resolveTranscriptPath(cursorTranscriptDir(), sessionId); + }, + async extractInteractions(transcriptPath) { + if (!isValidTranscriptPath3(transcriptPath)) return []; + const ready = await waitForTranscriptReady(transcriptPath); + if (!ready) return []; + let content = ""; + try { + content = await readFile3(transcriptPath, TEXT_ENCODING); + } catch { + return []; + } + if (!content.trim()) return []; + const trimmed = content.trimStart(); + return trimmed.startsWith("{") ? extractJsonlInteractions(content) : extractPlainTextInteractions(content); + } +}; + +// src/agents/gemini.ts +import { existsSync as existsSync4, readdirSync as readdirSync3, readFileSync as readFileSync2 } from "node:fs"; +import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile4 } from "node:fs/promises"; +import { homedir as homedir4 } from "node:os"; +import { dirname, join as join4, resolve as resolve4, sep as sep4 } from "node:path"; +var HOOK_COMMAND4 = `npx --yes agent-note hook --agent ${AGENT_NAMES.gemini}`; +var ENV_GEMINI_HOME = "GEMINI_HOME"; +var HOOK_TIMEOUT_MS = 1e4; +var SETTINGS_REL_PATH = ".gemini/settings.json"; +var TRANSCRIPT_PREVIEW_CHARS2 = 4096; +var GEMINI_TRANSCRIPT_MESSAGE_TYPE = "gemini"; +var GEMINI_HOOK_EVENTS = { + sessionStart: "SessionStart", + sessionEnd: "SessionEnd", + beforeAgent: "BeforeAgent", + afterAgent: "AfterAgent", + beforeTool: "BeforeTool", + afterTool: "AfterTool" +}; +var GEMINI_EDIT_TOOL_NAMES = ["write_file", "replace"]; +var GEMINI_SHELL_TOOL_NAMES = [ + "run_shell_command", + "shell", + "bash", + "run_command", + "execute_command" +]; +var GEMINI_EDIT_TOOL_MATCHER = GEMINI_EDIT_TOOL_NAMES.join("|"); +var GEMINI_SHELL_TOOL_MATCHER = GEMINI_SHELL_TOOL_NAMES.join("|"); +var EDIT_TOOLS = new Set(GEMINI_EDIT_TOOL_NAMES); +var SHELL_TOOLS = new Set(GEMINI_SHELL_TOOL_NAMES); +var UUID_PATTERN2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +var HOOKS_CONFIG2 = { + [GEMINI_HOOK_EVENTS.sessionStart]: [ + { + matcher: "*", + hooks: [ + { + name: "agentnote-session-start", + type: "command", + command: HOOK_COMMAND4, + timeout: HOOK_TIMEOUT_MS } - }, - async isEnabled(repoRoot3) { - const hooksPath = join3(repoRoot3, HOOKS_REL_PATH2); - if (!existsSync3(hooksPath)) return false; - try { - const content = await readFile3(hooksPath, TEXT_ENCODING); - return content.includes(HOOK_COMMAND3); - } catch { - return false; + ] + } + ], + [GEMINI_HOOK_EVENTS.sessionEnd]: [ + { + matcher: "*", + hooks: [ + { + name: "agentnote-session-end", + type: "command", + command: HOOK_COMMAND4, + timeout: HOOK_TIMEOUT_MS + } + ] + } + ], + [GEMINI_HOOK_EVENTS.beforeAgent]: [ + { + matcher: "*", + hooks: [ + { + name: "agentnote-before-agent", + type: "command", + command: HOOK_COMMAND4, + timeout: HOOK_TIMEOUT_MS + } + ] + } + ], + [GEMINI_HOOK_EVENTS.afterAgent]: [ + { + matcher: "*", + hooks: [ + { + name: "agentnote-after-agent", + type: "command", + command: HOOK_COMMAND4, + timeout: HOOK_TIMEOUT_MS + } + ] + } + ], + [GEMINI_HOOK_EVENTS.beforeTool]: [ + { + matcher: GEMINI_EDIT_TOOL_MATCHER, + hooks: [ + { + name: "agentnote-before-edit", + type: "command", + command: HOOK_COMMAND4, + timeout: HOOK_TIMEOUT_MS } - }, - parseEvent(input) { - let payload; - try { - payload = JSON.parse(input.raw); - } catch { - return null; + ] + }, + { + matcher: GEMINI_SHELL_TOOL_MATCHER, + hooks: [ + { + name: "agentnote-before-shell", + type: "command", + command: HOOK_COMMAND4, + timeout: HOOK_TIMEOUT_MS } - const sessionId = sessionIdFromPayload(payload); - if (!sessionId) return null; - const timestamp = (/* @__PURE__ */ new Date()).toISOString(); - switch (payload.hook_event_name) { - case CURSOR_HOOK_EVENTS.beforeSubmitPrompt: - return payload.prompt ? { - kind: NORMALIZED_EVENT_KINDS.prompt, - sessionId, - timestamp, - prompt: payload.prompt, - model: payload.model - } : null; - case CURSOR_HOOK_EVENTS.afterAgentResponse: { - const response = collectMessageText2( - payload.response ?? payload.text ?? payload.content ?? payload.message ?? payload.output - ).join("\n").trim(); - return response ? { - kind: NORMALIZED_EVENT_KINDS.response, - sessionId, - timestamp, - response - } : null; - } - case CURSOR_HOOK_EVENTS.beforeShellExecution: - return payload.command && isGitCommit2(payload.command) ? { - kind: NORMALIZED_EVENT_KINDS.preCommit, - sessionId, - timestamp, - commitCommand: payload.command - } : null; - case CURSOR_HOOK_EVENTS.afterFileEdit: - case CURSOR_HOOK_EVENTS.afterTabFileEdit: { - const filePath = payload.file_path ?? payload.filePath; - const editStats = extractEditStats(payload.edits); - return filePath ? { - kind: NORMALIZED_EVENT_KINDS.fileChange, - sessionId, - timestamp, - file: filePath, - tool: payload.hook_event_name, - ...editStats ? { editStats } : {} - } : null; - } - case CURSOR_HOOK_EVENTS.afterShellExecution: - return payload.command && isGitCommit2(payload.command) ? { - kind: NORMALIZED_EVENT_KINDS.postCommit, - sessionId, - timestamp - } : null; - case CURSOR_HOOK_EVENTS.stop: { - const response = collectMessageText2( - payload.response ?? payload.text ?? payload.content ?? payload.message ?? payload.output - ).join("\n").trim(); - return { - kind: NORMALIZED_EVENT_KINDS.stop, - sessionId, - timestamp, - response: response || void 0 - }; - } - default: - return null; + ] + } + ], + [GEMINI_HOOK_EVENTS.afterTool]: [ + { + matcher: GEMINI_EDIT_TOOL_MATCHER, + hooks: [ + { + name: "agentnote-after-edit", + type: "command", + command: HOOK_COMMAND4, + timeout: HOOK_TIMEOUT_MS } - }, - findTranscript(sessionId) { - return resolveTranscriptPath(cursorTranscriptDir(), sessionId); - }, - async extractInteractions(transcriptPath) { - if (!isValidTranscriptPath3(transcriptPath)) return []; - const ready = await waitForTranscriptReady(transcriptPath); - if (!ready) return []; - let content = ""; - try { - content = await readFile3(transcriptPath, TEXT_ENCODING); - } catch { - return []; + ] + }, + { + matcher: GEMINI_SHELL_TOOL_MATCHER, + hooks: [ + { + name: "agentnote-after-shell", + type: "command", + command: HOOK_COMMAND4, + timeout: HOOK_TIMEOUT_MS } - if (!content.trim()) return []; - const trimmed = content.trimStart(); - return trimmed.startsWith("{") ? extractJsonlInteractions(content) : extractPlainTextInteractions(content); - } - }; - } -}); - -// src/agents/gemini.ts -import { existsSync as existsSync4, readdirSync as readdirSync3, readFileSync as readFileSync2 } from "node:fs"; -import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile4 } from "node:fs/promises"; -import { homedir as homedir4 } from "node:os"; -import { dirname, join as join4, resolve as resolve4, sep as sep4 } from "node:path"; + ] + } + ] +}; function geminiHome() { return process.env[ENV_GEMINI_HOME] ?? join4(homedir4(), ".gemini"); } @@ -1513,7 +1726,9 @@ function extractPartText(content) { function stripAgentnoteGroups(groups) { return groups.map((group) => ({ ...group, - hooks: group.hooks.filter((hook2) => !hook2.command.includes(AGENTNOTE_HOOK_COMMAND)) + hooks: group.hooks.filter( + (hook2) => !isAgentNoteHookCommand(hook2.command, AGENT_NAMES.gemini, { allowMissingAgent: true }) + ) })).filter((group) => group.hooks.length > 0); } function readTranscriptSessionId2(candidate) { @@ -1554,353 +1769,226 @@ function findTranscriptCandidate2(rootDir, sessionId) { } return null; } -var HOOK_COMMAND4, ENV_GEMINI_HOME, HOOK_TIMEOUT_MS, SETTINGS_REL_PATH, TRANSCRIPT_PREVIEW_CHARS2, GEMINI_TRANSCRIPT_MESSAGE_TYPE, GEMINI_HOOK_EVENTS, GEMINI_EDIT_TOOL_NAMES, GEMINI_SHELL_TOOL_NAMES, GEMINI_EDIT_TOOL_MATCHER, GEMINI_SHELL_TOOL_MATCHER, EDIT_TOOLS, SHELL_TOOLS, UUID_PATTERN2, HOOKS_CONFIG2, gemini; -var init_gemini = __esm({ - "src/agents/gemini.ts"() { - "use strict"; - init_constants(); - init_git(); - init_types(); - HOOK_COMMAND4 = `npx --yes agent-note hook --agent ${AGENT_NAMES.gemini}`; - ENV_GEMINI_HOME = "GEMINI_HOME"; - HOOK_TIMEOUT_MS = 1e4; - SETTINGS_REL_PATH = ".gemini/settings.json"; - TRANSCRIPT_PREVIEW_CHARS2 = 4096; - GEMINI_TRANSCRIPT_MESSAGE_TYPE = "gemini"; - GEMINI_HOOK_EVENTS = { - sessionStart: "SessionStart", - sessionEnd: "SessionEnd", - beforeAgent: "BeforeAgent", - afterAgent: "AfterAgent", - beforeTool: "BeforeTool", - afterTool: "AfterTool" - }; - GEMINI_EDIT_TOOL_NAMES = ["write_file", "replace"]; - GEMINI_SHELL_TOOL_NAMES = [ - "run_shell_command", - "shell", - "bash", - "run_command", - "execute_command" - ]; - GEMINI_EDIT_TOOL_MATCHER = GEMINI_EDIT_TOOL_NAMES.join("|"); - GEMINI_SHELL_TOOL_MATCHER = GEMINI_SHELL_TOOL_NAMES.join("|"); - EDIT_TOOLS = new Set(GEMINI_EDIT_TOOL_NAMES); - SHELL_TOOLS = new Set(GEMINI_SHELL_TOOL_NAMES); - UUID_PATTERN2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - HOOKS_CONFIG2 = { - [GEMINI_HOOK_EVENTS.sessionStart]: [ - { - matcher: "*", - hooks: [ - { - name: "agentnote-session-start", - type: "command", - command: HOOK_COMMAND4, - timeout: HOOK_TIMEOUT_MS - } - ] - } - ], - [GEMINI_HOOK_EVENTS.sessionEnd]: [ - { - matcher: "*", - hooks: [ - { - name: "agentnote-session-end", - type: "command", - command: HOOK_COMMAND4, - timeout: HOOK_TIMEOUT_MS - } - ] - } - ], - [GEMINI_HOOK_EVENTS.beforeAgent]: [ - { - matcher: "*", - hooks: [ - { - name: "agentnote-before-agent", - type: "command", - command: HOOK_COMMAND4, - timeout: HOOK_TIMEOUT_MS - } - ] - } - ], - [GEMINI_HOOK_EVENTS.afterAgent]: [ - { - matcher: "*", - hooks: [ - { - name: "agentnote-after-agent", - type: "command", - command: HOOK_COMMAND4, - timeout: HOOK_TIMEOUT_MS - } - ] - } - ], - [GEMINI_HOOK_EVENTS.beforeTool]: [ - { - matcher: GEMINI_EDIT_TOOL_MATCHER, - hooks: [ - { - name: "agentnote-before-edit", - type: "command", - command: HOOK_COMMAND4, - timeout: HOOK_TIMEOUT_MS - } - ] - }, - { - matcher: GEMINI_SHELL_TOOL_MATCHER, - hooks: [ - { - name: "agentnote-before-shell", - type: "command", - command: HOOK_COMMAND4, - timeout: HOOK_TIMEOUT_MS - } - ] - } - ], - [GEMINI_HOOK_EVENTS.afterTool]: [ - { - matcher: GEMINI_EDIT_TOOL_MATCHER, - hooks: [ - { - name: "agentnote-after-edit", - type: "command", - command: HOOK_COMMAND4, - timeout: HOOK_TIMEOUT_MS - } - ] - }, - { - matcher: GEMINI_SHELL_TOOL_MATCHER, - hooks: [ - { - name: "agentnote-after-shell", - type: "command", - command: HOOK_COMMAND4, - timeout: HOOK_TIMEOUT_MS - } - ] - } - ] - }; - gemini = { - name: AGENT_NAMES.gemini, - settingsRelPath: SETTINGS_REL_PATH, - async managedPaths() { - return [SETTINGS_REL_PATH]; - }, - async installHooks(repoRoot3) { - const settingsPath = join4(repoRoot3, SETTINGS_REL_PATH); - await mkdir4(dirname(settingsPath), { recursive: true }); - let settings = {}; - if (existsSync4(settingsPath)) { - try { - settings = JSON.parse(await readFile4(settingsPath, TEXT_ENCODING)); - } catch { - settings = {}; - } - } - const hooks = settings.hooks ?? {}; - for (const [event, groups] of Object.entries(hooks)) { - hooks[event] = stripAgentnoteGroups(groups); - if (hooks[event].length === 0) delete hooks[event]; - } - for (const [event, groups] of Object.entries(HOOKS_CONFIG2)) { - hooks[event] = [...hooks[event] ?? [], ...groups]; - } - settings.hooks = hooks; - await writeFile4(settingsPath, `${JSON.stringify(settings, null, 2)} +var gemini = { + name: AGENT_NAMES.gemini, + settingsRelPath: SETTINGS_REL_PATH, + async managedPaths() { + return [SETTINGS_REL_PATH]; + }, + async installHooks(repoRoot3) { + const settingsPath = join4(repoRoot3, SETTINGS_REL_PATH); + await mkdir4(dirname(settingsPath), { recursive: true }); + let settings = {}; + if (existsSync4(settingsPath)) { + try { + settings = JSON.parse(await readFile4(settingsPath, TEXT_ENCODING)); + } catch { + settings = {}; + } + } + const hooks = settings.hooks ?? {}; + for (const [event, groups] of Object.entries(hooks)) { + hooks[event] = stripAgentnoteGroups(groups); + if (hooks[event].length === 0) delete hooks[event]; + } + for (const [event, groups] of Object.entries(HOOKS_CONFIG2)) { + hooks[event] = [...hooks[event] ?? [], ...groups]; + } + settings.hooks = hooks; + await writeFile4(settingsPath, `${JSON.stringify(settings, null, 2)} `); - }, - async removeHooks(repoRoot3) { - const settingsPath = join4(repoRoot3, SETTINGS_REL_PATH); - if (!existsSync4(settingsPath)) return; - try { - const settings = JSON.parse( - await readFile4(settingsPath, TEXT_ENCODING) - ); - if (!settings.hooks) return; - for (const [event, groups] of Object.entries(settings.hooks)) { - settings.hooks[event] = stripAgentnoteGroups(groups); - if (settings.hooks[event].length === 0) delete settings.hooks[event]; - } - if (Object.keys(settings.hooks).length === 0) delete settings.hooks; - await writeFile4(settingsPath, `${JSON.stringify(settings, null, 2)} + }, + async removeHooks(repoRoot3) { + const settingsPath = join4(repoRoot3, SETTINGS_REL_PATH); + if (!existsSync4(settingsPath)) return; + try { + const settings = JSON.parse( + await readFile4(settingsPath, TEXT_ENCODING) + ); + if (!settings.hooks) return; + for (const [event, groups] of Object.entries(settings.hooks)) { + settings.hooks[event] = stripAgentnoteGroups(groups); + if (settings.hooks[event].length === 0) delete settings.hooks[event]; + } + if (Object.keys(settings.hooks).length === 0) delete settings.hooks; + await writeFile4(settingsPath, `${JSON.stringify(settings, null, 2)} `); - } catch { - } - }, - async isEnabled(repoRoot3) { - const settingsPath = join4(repoRoot3, SETTINGS_REL_PATH); - if (!existsSync4(settingsPath)) return false; - try { - const content = await readFile4(settingsPath, TEXT_ENCODING); - return content.includes(HOOK_COMMAND4); - } catch { - return false; + } catch { + } + }, + async isEnabled(repoRoot3) { + const settingsPath = join4(repoRoot3, SETTINGS_REL_PATH); + if (!existsSync4(settingsPath)) return false; + try { + const content = await readFile4(settingsPath, TEXT_ENCODING); + const parsed = JSON.parse(content); + return Object.values(parsed.hooks ?? {}).some( + (groups) => groups.some( + (group) => group.hooks.some((hook2) => isAgentNoteHookCommand(hook2.command, AGENT_NAMES.gemini)) + ) + ); + } catch { + return false; + } + }, + parseEvent(input) { + let e; + try { + e = JSON.parse(input.raw); + } catch { + return null; + } + const sid = e.session_id?.trim(); + const ts = (/* @__PURE__ */ new Date()).toISOString(); + if (!sid || !isValidSessionId2(sid)) return null; + const tp = e.transcript_path && isValidTranscriptPath4(e.transcript_path) ? e.transcript_path : void 0; + switch (e.hook_event_name) { + case GEMINI_HOOK_EVENTS.sessionStart: + return { + kind: NORMALIZED_EVENT_KINDS.sessionStart, + sessionId: sid, + timestamp: ts, + model: e.model, + transcriptPath: tp + }; + case GEMINI_HOOK_EVENTS.sessionEnd: + return { + kind: NORMALIZED_EVENT_KINDS.stop, + sessionId: sid, + timestamp: ts, + transcriptPath: tp + }; + case GEMINI_HOOK_EVENTS.beforeAgent: + return e.prompt ? { + kind: NORMALIZED_EVENT_KINDS.prompt, + sessionId: sid, + timestamp: ts, + prompt: e.prompt, + model: e.model + } : null; + case GEMINI_HOOK_EVENTS.afterAgent: + return e.prompt_response ? { + kind: NORMALIZED_EVENT_KINDS.response, + sessionId: sid, + timestamp: ts, + response: e.prompt_response + } : null; + case GEMINI_HOOK_EVENTS.beforeTool: { + const toolName = e.tool_name?.toLowerCase() ?? ""; + const filePath = e.tool_input?.file_path; + const cmd = e.tool_input?.command ?? ""; + if (EDIT_TOOLS.has(toolName) && filePath) { + return { + kind: NORMALIZED_EVENT_KINDS.preEdit, + sessionId: sid, + timestamp: ts, + tool: e.tool_name, + file: filePath + }; } - }, - parseEvent(input) { - let e; - try { - e = JSON.parse(input.raw); - } catch { - return null; + if (SHELL_TOOLS.has(toolName) && isGitCommit3(cmd)) { + return { + kind: NORMALIZED_EVENT_KINDS.preCommit, + sessionId: sid, + timestamp: ts, + commitCommand: cmd + }; } - const sid = e.session_id?.trim(); - const ts = (/* @__PURE__ */ new Date()).toISOString(); - if (!sid || !isValidSessionId2(sid)) return null; - const tp = e.transcript_path && isValidTranscriptPath4(e.transcript_path) ? e.transcript_path : void 0; - switch (e.hook_event_name) { - case GEMINI_HOOK_EVENTS.sessionStart: - return { - kind: NORMALIZED_EVENT_KINDS.sessionStart, - sessionId: sid, - timestamp: ts, - model: e.model, - transcriptPath: tp - }; - case GEMINI_HOOK_EVENTS.sessionEnd: - return { - kind: NORMALIZED_EVENT_KINDS.stop, - sessionId: sid, - timestamp: ts, - transcriptPath: tp - }; - case GEMINI_HOOK_EVENTS.beforeAgent: - return e.prompt ? { - kind: NORMALIZED_EVENT_KINDS.prompt, - sessionId: sid, - timestamp: ts, - prompt: e.prompt, - model: e.model - } : null; - case GEMINI_HOOK_EVENTS.afterAgent: - return e.prompt_response ? { - kind: NORMALIZED_EVENT_KINDS.response, - sessionId: sid, - timestamp: ts, - response: e.prompt_response - } : null; - case GEMINI_HOOK_EVENTS.beforeTool: { - const toolName = e.tool_name?.toLowerCase() ?? ""; - const filePath = e.tool_input?.file_path; - const cmd = e.tool_input?.command ?? ""; - if (EDIT_TOOLS.has(toolName) && filePath) { - return { - kind: NORMALIZED_EVENT_KINDS.preEdit, - sessionId: sid, - timestamp: ts, - tool: e.tool_name, - file: filePath - }; - } - if (SHELL_TOOLS.has(toolName) && isGitCommit3(cmd)) { - return { - kind: NORMALIZED_EVENT_KINDS.preCommit, - sessionId: sid, - timestamp: ts, - commitCommand: cmd - }; - } - return null; - } - case GEMINI_HOOK_EVENTS.afterTool: { - const toolName = e.tool_name?.toLowerCase() ?? ""; - const filePath = e.tool_input?.file_path; - const cmd = e.tool_input?.command ?? ""; - if (EDIT_TOOLS.has(toolName) && filePath) { - return { - kind: NORMALIZED_EVENT_KINDS.fileChange, - sessionId: sid, - timestamp: ts, - tool: e.tool_name, - file: filePath - }; - } - if (SHELL_TOOLS.has(toolName) && isGitCommit3(cmd)) { - return { - kind: NORMALIZED_EVENT_KINDS.postCommit, - sessionId: sid, - timestamp: ts, - transcriptPath: tp - }; - } - return null; - } - default: - return null; + return null; + } + case GEMINI_HOOK_EVENTS.afterTool: { + const toolName = e.tool_name?.toLowerCase() ?? ""; + const filePath = e.tool_input?.file_path; + const cmd = e.tool_input?.command ?? ""; + if (EDIT_TOOLS.has(toolName) && filePath) { + return { + kind: NORMALIZED_EVENT_KINDS.fileChange, + sessionId: sid, + timestamp: ts, + tool: e.tool_name, + file: filePath + }; } - }, - findTranscript(sessionId) { - if (!isValidSessionId2(sessionId)) return null; - const tmpDir = join4(geminiHome(), "tmp"); - if (!existsSync4(tmpDir)) return null; - return findTranscriptCandidate2(tmpDir, sessionId); - }, - async extractInteractions(transcriptPath) { - if (!isValidTranscriptPath4(transcriptPath) || !existsSync4(transcriptPath)) return []; - let content; - try { - content = await readFile4(transcriptPath, TEXT_ENCODING); - } catch { - return []; + if (SHELL_TOOLS.has(toolName) && isGitCommit3(cmd)) { + return { + kind: NORMALIZED_EVENT_KINDS.postCommit, + sessionId: sid, + timestamp: ts, + transcriptPath: tp + }; } - const interactions = []; - let current = null; - for (const rawLine of content.split("\n")) { - const line = rawLine.trim(); - if (!line) continue; - let record; - try { - record = JSON.parse(line); - } catch { - continue; - } - const type = typeof record.type === "string" ? record.type : void 0; - if (!type) continue; - if (type === "user") { - const prompt = extractPartText(record.content); - if (!prompt) continue; - if (current) interactions.push(current); - current = { prompt, response: null }; - if (typeof record.timestamp === "string") current.timestamp = record.timestamp; - continue; - } - if (type === GEMINI_TRANSCRIPT_MESSAGE_TYPE && current) { - const response = extractPartText(record.content); - if (response) { - current.response = current.response ? `${current.response} + return null; + } + default: + return null; + } + }, + findTranscript(sessionId) { + if (!isValidSessionId2(sessionId)) return null; + const tmpDir = join4(geminiHome(), "tmp"); + if (!existsSync4(tmpDir)) return null; + return findTranscriptCandidate2(tmpDir, sessionId); + }, + async extractInteractions(transcriptPath) { + if (!isValidTranscriptPath4(transcriptPath) || !existsSync4(transcriptPath)) return []; + let content; + try { + content = await readFile4(transcriptPath, TEXT_ENCODING); + } catch { + return []; + } + const interactions = []; + let current = null; + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + let record2; + try { + record2 = JSON.parse(line); + } catch { + continue; + } + const type = typeof record2.type === "string" ? record2.type : void 0; + if (!type) continue; + if (type === "user") { + const prompt = extractPartText(record2.content); + if (!prompt) continue; + if (current) interactions.push(current); + current = { prompt, response: null }; + if (typeof record2.timestamp === "string") current.timestamp = record2.timestamp; + continue; + } + if (type === GEMINI_TRANSCRIPT_MESSAGE_TYPE && current) { + const response = extractPartText(record2.content); + if (response) { + current.response = current.response ? `${current.response} ${response}` : response; - } - const toolCalls = Array.isArray(record.toolCalls) ? record.toolCalls : []; - for (const call of toolCalls) { - if (!isRecord3(call)) continue; - const toolName = typeof call.name === "string" ? call.name : void 0; - if (!toolName || !EDIT_TOOLS.has(toolName)) continue; - const args2 = isRecord3(call.args) ? call.args : void 0; - const filePath = typeof args2?.file_path === "string" ? args2.file_path : void 0; - if (filePath) { - current.files_touched = [.../* @__PURE__ */ new Set([...current.files_touched ?? [], filePath])]; - } - } + } + const toolCalls = Array.isArray(record2.toolCalls) ? record2.toolCalls : []; + for (const call of toolCalls) { + if (!isRecord3(call)) continue; + const toolName = typeof call.name === "string" ? call.name : void 0; + if (!toolName || !EDIT_TOOLS.has(toolName)) continue; + const args2 = isRecord3(call.args) ? call.args : void 0; + const filePath = typeof args2?.file_path === "string" ? args2.file_path : void 0; + if (filePath) { + current.files_touched = [.../* @__PURE__ */ new Set([...current.files_touched ?? [], filePath])]; } } - if (current) interactions.push(current); - return interactions; } - }; + } + if (current) interactions.push(current); + return interactions; } -}); +}; // src/agents/index.ts +var AGENTS = /* @__PURE__ */ new Map([ + [claude.name, claude], + [codex.name, codex], + [cursor.name, cursor], + [gemini.name, gemini] +]); function getAgent(name) { const agent = AGENTS.get(name); if (!agent) { @@ -1914,22 +2002,6 @@ function hasAgent(name) { function listAgents() { return [...AGENTS.keys()]; } -var AGENTS; -var init_agents = __esm({ - "src/agents/index.ts"() { - "use strict"; - init_claude(); - init_codex(); - init_cursor(); - init_gemini(); - AGENTS = /* @__PURE__ */ new Map([ - [claude.name, claude], - [codex.name, codex], - [cursor.name, cursor], - [gemini.name, gemini] - ]); - } -}); // src/core/attribution.ts function parseUnifiedHunks(diffOutput) { @@ -2019,15 +2091,129 @@ async function gitDiffUnified0(blobA, blobB) { } return stdout; } -var init_attribution = __esm({ - "src/core/attribution.ts"() { - "use strict"; - init_git(); - init_constants(); - } -}); // src/core/entry.ts +var DEFAULT_PROMPT_DETAIL = "compact"; +var LEGACY_PROMPT_SCORE = 100; +var PERCENT_DENOMINATOR = 100; +var PRIMARY_SCORE_FLOOR = 80; +var HIGH_SCORE_THRESHOLD = 75; +var MEDIUM_SCORE_THRESHOLD = 45; +var BRIDGE_SCORE_MAX_WITH_SUBSTANTIVE = 55; +var BRIDGE_SCORE_MAX_WITHOUT_SUBSTANTIVE = 44; +var ANCHORED_BRIDGE_SCORE_MAX = 65; +var UNANCHORED_TAIL_SCORE_MAX = 44; +var SHORT_PROMPT_MAX_CHARS = 120; +var SHORT_PROMPT_MAX_WORDS = 12; +var PROMPT_ROLE_BASE_SCORES = { + primary: 90, + direct_anchor: 75, + scope: 60, + tail: 45, + anchored_bridge: 45, + bridge: 25, + background: 15 +}; +var PROMPT_ROLE_SCORE_CLAMPS = { + primary: [80, 100], + direct_anchor: [65, 95], + scope: [50, 80], + tail: [35, 70], + anchored_bridge: [40, 65], + bridge: [20, 55], + background: [0, 30] +}; +var PROMPT_SIGNAL_SCORES = { + primary_edit_turn: 0, + exact_commit_path: 30, + commit_file_basename: 10, + diff_identifier: 20, + response_exact_commit_path: 18, + response_basename_or_identifier: 10, + commit_subject_overlap: 4, + list_or_checklist_shape: 10, + multi_line_instruction: 6, + inline_code_or_path_shape: 6, + substantive_prompt_shape: 12, + before_commit_boundary: 5, + between_non_excluded_prompts: 8 +}; +var GENERATED_DIR_SEGMENTS = /* @__PURE__ */ new Set([ + // Web / JS / TS build outputs + ".next", + ".nuxt", + "coverage", + // Monorepo / remote-cache build outputs + ".turbo", + ".yarn", + "bazel-bin", + "bazel-out", + "bazel-testlogs", + // Mobile / Flutter build caches + ".dart_tool", + "DerivedData" +]); +var GENERATED_FILE_NAMES = /* @__PURE__ */ new Set([ + // Flutter tool-managed dependency snapshot + ".flutter-plugins-dependencies", + // Flutter desktop / mobile plugin registrants + "generated_plugin_registrant.dart", + "GeneratedPluginRegistrant.java", + "GeneratedPluginRegistrant.swift", + "GeneratedPluginRegistrant.m", + "GeneratedPluginRegistrant.h" +]); +var GENERATED_FILE_SUFFIXES = [ + // Web / TS / GraphQL / OpenAPI codegen + ".gen.ts", + ".gen.tsx", + ".generated.js", + ".generated.jsx", + ".generated.ts", + ".generated.tsx", + // Dart / Flutter codegen + ".chopper.dart", + ".config.dart", + ".freezed.dart", + ".g.dart", + ".gr.dart", + ".mocks.dart", + ".pb.dart", + ".pbjson.dart", + ".pbenum.dart", + ".pbserver.dart", + // Go codegen + ".pb.go", + ".pb.gw.go", + ".twirp.go", + ".gen.go", + ".generated.go", + "_gen.go", + "_generated.go", + "_string.go", + // Rust codegen + ".generated.rs", + ".pb.rs", + "_generated.rs", + // Kotlin / Swift codegen + ".g.kt", + ".gen.kt", + ".generated.kt", + ".g.swift", + ".generated.swift", + // Web sourcemaps + ".map" +]; +var GENERATED_CONTENT_PATTERNS = [ + // Cross-language generator banners used by protoc, sqlc, stringer, bindgen, etc. + /\bcode generated\b[\s\S]{0,160}\bdo not edit\b/i, + /\bautomatically generated by\b/i, + /\bthis file was generated by\b/i, + // Annotation-style banners commonly used in Java / Kotlin / JS ecosystems. + /\B@generated\b/i, + // Named generators across Web, mobile, backend, and protobuf toolchains. + /\bgenerated by (?:swiftgen|sourcery|protoc|buf|sqlc|openapi(?:-generator)?|openapitools|wire|freezed|build_runner|mockgen|rust-bindgen|apollo|drift|flutterfire|ksp)\b/i +]; function isGeneratedArtifactPath(path) { const normalized = path.replaceAll("\\", "/"); const segments = normalized.split("/").filter(Boolean); @@ -2271,136 +2457,69 @@ function buildEntry(opts) { attribution }; } -var DEFAULT_PROMPT_DETAIL, LEGACY_PROMPT_SCORE, PERCENT_DENOMINATOR, PRIMARY_SCORE_FLOOR, HIGH_SCORE_THRESHOLD, MEDIUM_SCORE_THRESHOLD, BRIDGE_SCORE_MAX_WITH_SUBSTANTIVE, BRIDGE_SCORE_MAX_WITHOUT_SUBSTANTIVE, ANCHORED_BRIDGE_SCORE_MAX, UNANCHORED_TAIL_SCORE_MAX, SHORT_PROMPT_MAX_CHARS, SHORT_PROMPT_MAX_WORDS, PROMPT_ROLE_BASE_SCORES, PROMPT_ROLE_SCORE_CLAMPS, PROMPT_SIGNAL_SCORES, GENERATED_DIR_SEGMENTS, GENERATED_FILE_NAMES, GENERATED_FILE_SUFFIXES, GENERATED_CONTENT_PATTERNS; -var init_entry = __esm({ - "src/core/entry.ts"() { - "use strict"; - init_constants(); - DEFAULT_PROMPT_DETAIL = "compact"; - LEGACY_PROMPT_SCORE = 100; - PERCENT_DENOMINATOR = 100; - PRIMARY_SCORE_FLOOR = 80; - HIGH_SCORE_THRESHOLD = 75; - MEDIUM_SCORE_THRESHOLD = 45; - BRIDGE_SCORE_MAX_WITH_SUBSTANTIVE = 55; - BRIDGE_SCORE_MAX_WITHOUT_SUBSTANTIVE = 44; - ANCHORED_BRIDGE_SCORE_MAX = 65; - UNANCHORED_TAIL_SCORE_MAX = 44; - SHORT_PROMPT_MAX_CHARS = 120; - SHORT_PROMPT_MAX_WORDS = 12; - PROMPT_ROLE_BASE_SCORES = { - primary: 90, - direct_anchor: 75, - scope: 60, - tail: 45, - anchored_bridge: 45, - bridge: 25, - background: 15 - }; - PROMPT_ROLE_SCORE_CLAMPS = { - primary: [80, 100], - direct_anchor: [65, 95], - scope: [50, 80], - tail: [35, 70], - anchored_bridge: [40, 65], - bridge: [20, 55], - background: [0, 30] - }; - PROMPT_SIGNAL_SCORES = { - primary_edit_turn: 0, - exact_commit_path: 30, - commit_file_basename: 10, - diff_identifier: 20, - response_exact_commit_path: 18, - response_basename_or_identifier: 10, - commit_subject_overlap: 4, - list_or_checklist_shape: 10, - multi_line_instruction: 6, - inline_code_or_path_shape: 6, - substantive_prompt_shape: 12, - before_commit_boundary: 5, - between_non_excluded_prompts: 8 - }; - GENERATED_DIR_SEGMENTS = /* @__PURE__ */ new Set([ - // Web / JS / TS build outputs - ".next", - ".nuxt", - "coverage", - // Monorepo / remote-cache build outputs - ".turbo", - ".yarn", - "bazel-bin", - "bazel-out", - "bazel-testlogs", - // Mobile / Flutter build caches - ".dart_tool", - "DerivedData" - ]); - GENERATED_FILE_NAMES = /* @__PURE__ */ new Set([ - // Flutter tool-managed dependency snapshot - ".flutter-plugins-dependencies", - // Flutter desktop / mobile plugin registrants - "generated_plugin_registrant.dart", - "GeneratedPluginRegistrant.java", - "GeneratedPluginRegistrant.swift", - "GeneratedPluginRegistrant.m", - "GeneratedPluginRegistrant.h" - ]); - GENERATED_FILE_SUFFIXES = [ - // Web / TS / GraphQL / OpenAPI codegen - ".gen.ts", - ".gen.tsx", - ".generated.js", - ".generated.jsx", - ".generated.ts", - ".generated.tsx", - // Dart / Flutter codegen - ".chopper.dart", - ".config.dart", - ".freezed.dart", - ".g.dart", - ".gr.dart", - ".mocks.dart", - ".pb.dart", - ".pbjson.dart", - ".pbenum.dart", - ".pbserver.dart", - // Go codegen - ".pb.go", - ".pb.gw.go", - ".twirp.go", - ".gen.go", - ".generated.go", - "_gen.go", - "_generated.go", - "_string.go", - // Rust codegen - ".generated.rs", - ".pb.rs", - "_generated.rs", - // Kotlin / Swift codegen - ".g.kt", - ".gen.kt", - ".generated.kt", - ".g.swift", - ".generated.swift", - // Web sourcemaps - ".map" - ]; - GENERATED_CONTENT_PATTERNS = [ - // Cross-language generator banners used by protoc, sqlc, stringer, bindgen, etc. - /\bcode generated\b[\s\S]{0,160}\bdo not edit\b/i, - /\bautomatically generated by\b/i, - /\bthis file was generated by\b/i, - // Annotation-style banners commonly used in Java / Kotlin / JS ecosystems. - /\B@generated\b/i, - // Named generators across Web, mobile, backend, and protobuf toolchains. - /\bgenerated by (?:swiftgen|sourcery|protoc|buf|sqlc|openapi(?:-generator)?|openapitools|wire|freezed|build_runner|mockgen|rust-bindgen|apollo|drift|flutterfire|ksp)\b/i - ]; - } -}); // src/core/interaction-context.ts +var MAX_CONTEXT_CHARS = 900; +var MAX_SCOPE_PROMPT_CHARS = 120; +var MAX_SCOPE_LINES = 10; +var MAX_SCOPE_SENTENCES = 4; +var MAX_REFERENCE_PARAGRAPHS = 2; +var MAX_SCOPE_PROMPT_LINES = 3; +var MIN_SCOPE_SCORE = 2; +var REFERENCE_CONTEXT_RANK = 3; +var CONTEXT_SEPARATOR_CHARS = 2; +var SENTENCE_LOOKAHEAD_CHARS = 16; +var SUBJECT_TOKEN_SCOPE_THRESHOLD = 2; +var STRUCTURAL_SCOPE_WEIGHT = 2; +var GENERIC_SUBJECT_TOKEN_MIN_CHARS = 3; +var CONTEXT_KIND_ORDER = { + reference: 0, + scope: 1 +}; +var GENERIC_TOKENS = /* @__PURE__ */ new Set([ + "agent", + "agentnote", + "add", + "added", + "adds", + "build", + "case", + "change", + "commit", + "context", + "diff", + "file", + "files", + "fix", + "html", + "http", + "https", + "implement", + "implemented", + "implements", + "json", + "note", + "prompt", + "record", + "remove", + "removed", + "removes", + "response", + "test", + "tests", + "todo", + "turn", + "update", + "updated", + "updates", + "utf8", + "yaml" +]); +var CAMEL_OR_PASCAL_IDENTIFIER = /\b[A-Za-z_$]*[a-z][A-Za-z0-9_$]*[A-Z][A-Za-z0-9_$]*\b/g; +var SNAKE_IDENTIFIER = /\b[a-z][a-z0-9]*_[a-z][a-z0-9_]*[a-z0-9]\b/g; +var ALL_CAPS_IDENTIFIER = /\b[A-Z][A-Z0-9_]{2,}\b/g; +var ISSUE_OR_PR_REFERENCE = /\b(?:PR|Issue|GH)[\s#-]*\d+\b|#\d+\b/iu; +var MARKDOWN_FILE_REFERENCE = /(?:^|[\s("'`])(?:\.{0,2}\/)?[A-Za-z0-9_.-]+\/[^\s"'`]+\.[A-Za-z0-9]{1,8}\b/; function buildCommitContextSignature(opts) { return { changedFiles: unique(opts.changedFiles.map((file) => normalizePath(file))), @@ -2710,73 +2829,6 @@ function stripRank(context) { text: context.text }; } -var MAX_CONTEXT_CHARS, MAX_SCOPE_PROMPT_CHARS, MAX_SCOPE_LINES, MAX_SCOPE_SENTENCES, MAX_REFERENCE_PARAGRAPHS, MAX_SCOPE_PROMPT_LINES, MIN_SCOPE_SCORE, REFERENCE_CONTEXT_RANK, CONTEXT_SEPARATOR_CHARS, SENTENCE_LOOKAHEAD_CHARS, SUBJECT_TOKEN_SCOPE_THRESHOLD, STRUCTURAL_SCOPE_WEIGHT, GENERIC_SUBJECT_TOKEN_MIN_CHARS, CONTEXT_KIND_ORDER, GENERIC_TOKENS, CAMEL_OR_PASCAL_IDENTIFIER, SNAKE_IDENTIFIER, ALL_CAPS_IDENTIFIER, ISSUE_OR_PR_REFERENCE, MARKDOWN_FILE_REFERENCE; -var init_interaction_context = __esm({ - "src/core/interaction-context.ts"() { - "use strict"; - MAX_CONTEXT_CHARS = 900; - MAX_SCOPE_PROMPT_CHARS = 120; - MAX_SCOPE_LINES = 10; - MAX_SCOPE_SENTENCES = 4; - MAX_REFERENCE_PARAGRAPHS = 2; - MAX_SCOPE_PROMPT_LINES = 3; - MIN_SCOPE_SCORE = 2; - REFERENCE_CONTEXT_RANK = 3; - CONTEXT_SEPARATOR_CHARS = 2; - SENTENCE_LOOKAHEAD_CHARS = 16; - SUBJECT_TOKEN_SCOPE_THRESHOLD = 2; - STRUCTURAL_SCOPE_WEIGHT = 2; - GENERIC_SUBJECT_TOKEN_MIN_CHARS = 3; - CONTEXT_KIND_ORDER = { - reference: 0, - scope: 1 - }; - GENERIC_TOKENS = /* @__PURE__ */ new Set([ - "agent", - "agentnote", - "add", - "added", - "adds", - "build", - "case", - "change", - "commit", - "context", - "diff", - "file", - "files", - "fix", - "html", - "http", - "https", - "implement", - "implemented", - "implements", - "json", - "note", - "prompt", - "record", - "remove", - "removed", - "removes", - "response", - "test", - "tests", - "todo", - "turn", - "update", - "updated", - "updates", - "utf8", - "yaml" - ]); - CAMEL_OR_PASCAL_IDENTIFIER = /\b[A-Za-z_$]*[a-z][A-Za-z0-9_$]*[A-Z][A-Za-z0-9_$]*\b/g; - SNAKE_IDENTIFIER = /\b[a-z][a-z0-9]*_[a-z][a-z0-9_]*[a-z0-9]\b/g; - ALL_CAPS_IDENTIFIER = /\b[A-Z][A-Z0-9_]{2,}\b/g; - ISSUE_OR_PR_REFERENCE = /\b(?:PR|Issue|GH)[\s#-]*\d+\b|#\d+\b/iu; - MARKDOWN_FILE_REFERENCE = /(?:^|[\s("'`])(?:\.{0,2}\/)?[A-Za-z0-9_.-]+\/[^\s"'`]+\.[A-Za-z0-9]{1,8}\b/; - } -}); // src/core/jsonl.ts import { existsSync as existsSync5 } from "node:fs"; @@ -2798,14 +2850,38 @@ async function appendJsonl(filePath, data) { await appendFile(filePath, `${JSON.stringify(data)} `); } -var init_jsonl = __esm({ - "src/core/jsonl.ts"() { - "use strict"; - init_constants(); - } -}); // src/core/prompt-window.ts +var PROMPT_SELECTION_SOURCE = /* @__PURE__ */ Symbol("agentnotePromptSelectionSource"); +var PROMPT_SELECTION_BEFORE_COMMIT_BOUNDARY = /* @__PURE__ */ Symbol( + "agentnotePromptSelectionBeforeCommitBoundary" +); +var PROMPT_WINDOW_MAX_ENTRIES = 24; +var PROMPT_WINDOW_ANCHOR_TEXT_SCORE = 2; +var PROMPT_WINDOW_ANCHOR_FILE_REF_SCORE = 5; +var PROMPT_WINDOW_ANCHOR_SHAPE_SCORE = 44; +var LOW_SHAPE_WINDOW_TEXT_SCORE_MAX = 2; +var LOW_SHAPE_WINDOW_SHAPE_SCORE_MAX = 20; +var PROMPT_SELECTION_SCHEMA = 1; +var QUOTED_HISTORY_MIN_PROMPT_CHARS = 300; +var QUOTED_HISTORY_MIN_INDENTED_LINES = 8; +var QUOTED_HISTORY_MIN_INDENTED_PROMPT_CHARS = 500; +var TEXT_SHAPE_LENGTH_DIVISOR = 4; +var TEXT_SHAPE_LENGTH_SCORE_MAX = 24; +var TEXT_SHAPE_NEWLINE_WEIGHT = 10; +var TEXT_SHAPE_NEWLINE_SCORE_MAX = 30; +var TEXT_SHAPE_INLINE_CODE_SCORE = 18; +var TEXT_SHAPE_PATH_SCORE = 16; +var TEXT_SHAPE_FLAG_SCORE = 14; +var TEXT_SHAPE_LIST_SCORE = 20; +var FILE_REF_EXACT_PATH_SCORE = 80; +var FILE_REF_SEGMENT_MIN_CHARS = 4; +var FILE_REF_SEGMENT_SCORE = 5; +var FILE_REF_BASENAME_SCORE = 20; +var TEXT_OVERLAP_PATH_TOKEN_SCORE = 4; +var TEXT_OVERLAP_WORD_TOKEN_SCORE = 1; +var TOKEN_MIN_CHARS = 2; +var TOKEN_PART_MIN_CHARS = 3; function selectPromptWindowEntries(promptEntries, primaryTurns, editTurns, maxConsumedTurn, currentTurn, commitFiles, commitSubject, contextSignature, consumedPromptState, responsesByTurn) { if (primaryTurns.size === 0) return emptyPromptWindowSelection(); const orderedPrimaryTurns = [...primaryTurns].filter((turn) => turn > 0).sort((a, b) => a - b); @@ -3254,43 +3330,6 @@ function tokenizePromptSelectionText(text) { } return tokens; } -var PROMPT_SELECTION_SOURCE, PROMPT_SELECTION_BEFORE_COMMIT_BOUNDARY, PROMPT_WINDOW_MAX_ENTRIES, PROMPT_WINDOW_ANCHOR_TEXT_SCORE, PROMPT_WINDOW_ANCHOR_FILE_REF_SCORE, PROMPT_WINDOW_ANCHOR_SHAPE_SCORE, LOW_SHAPE_WINDOW_TEXT_SCORE_MAX, LOW_SHAPE_WINDOW_SHAPE_SCORE_MAX, PROMPT_SELECTION_SCHEMA, QUOTED_HISTORY_MIN_PROMPT_CHARS, QUOTED_HISTORY_MIN_INDENTED_LINES, QUOTED_HISTORY_MIN_INDENTED_PROMPT_CHARS, TEXT_SHAPE_LENGTH_DIVISOR, TEXT_SHAPE_LENGTH_SCORE_MAX, TEXT_SHAPE_NEWLINE_WEIGHT, TEXT_SHAPE_NEWLINE_SCORE_MAX, TEXT_SHAPE_INLINE_CODE_SCORE, TEXT_SHAPE_PATH_SCORE, TEXT_SHAPE_FLAG_SCORE, TEXT_SHAPE_LIST_SCORE, FILE_REF_EXACT_PATH_SCORE, FILE_REF_SEGMENT_MIN_CHARS, FILE_REF_SEGMENT_SCORE, FILE_REF_BASENAME_SCORE, TEXT_OVERLAP_PATH_TOKEN_SCORE, TEXT_OVERLAP_WORD_TOKEN_SCORE, TOKEN_MIN_CHARS, TOKEN_PART_MIN_CHARS; -var init_prompt_window = __esm({ - "src/core/prompt-window.ts"() { - "use strict"; - init_entry(); - PROMPT_SELECTION_SOURCE = /* @__PURE__ */ Symbol("agentnotePromptSelectionSource"); - PROMPT_SELECTION_BEFORE_COMMIT_BOUNDARY = /* @__PURE__ */ Symbol( - "agentnotePromptSelectionBeforeCommitBoundary" - ); - PROMPT_WINDOW_MAX_ENTRIES = 24; - PROMPT_WINDOW_ANCHOR_TEXT_SCORE = 2; - PROMPT_WINDOW_ANCHOR_FILE_REF_SCORE = 5; - PROMPT_WINDOW_ANCHOR_SHAPE_SCORE = 44; - LOW_SHAPE_WINDOW_TEXT_SCORE_MAX = 2; - LOW_SHAPE_WINDOW_SHAPE_SCORE_MAX = 20; - PROMPT_SELECTION_SCHEMA = 1; - QUOTED_HISTORY_MIN_PROMPT_CHARS = 300; - QUOTED_HISTORY_MIN_INDENTED_LINES = 8; - QUOTED_HISTORY_MIN_INDENTED_PROMPT_CHARS = 500; - TEXT_SHAPE_LENGTH_DIVISOR = 4; - TEXT_SHAPE_LENGTH_SCORE_MAX = 24; - TEXT_SHAPE_NEWLINE_WEIGHT = 10; - TEXT_SHAPE_NEWLINE_SCORE_MAX = 30; - TEXT_SHAPE_INLINE_CODE_SCORE = 18; - TEXT_SHAPE_PATH_SCORE = 16; - TEXT_SHAPE_FLAG_SCORE = 14; - TEXT_SHAPE_LIST_SCORE = 20; - FILE_REF_EXACT_PATH_SCORE = 80; - FILE_REF_SEGMENT_MIN_CHARS = 4; - FILE_REF_SEGMENT_SCORE = 5; - FILE_REF_BASENAME_SCORE = 20; - TEXT_OVERLAP_PATH_TOKEN_SCORE = 4; - TEXT_OVERLAP_WORD_TOKEN_SCORE = 1; - TOKEN_MIN_CHARS = 2; - TOKEN_PART_MIN_CHARS = 3; - } -}); // src/core/session.ts import { existsSync as existsSync6 } from "node:fs"; @@ -3326,12 +3365,6 @@ async function hasRecordableSessionData(sessionDir) { } return false; } -var init_session = __esm({ - "src/core/session.ts"() { - "use strict"; - init_constants(); - } -}); // src/core/storage.ts async function writeNote(commitSha, data) { @@ -3347,24 +3380,11 @@ async function readNote(commitSha) { return null; } } -var init_storage = __esm({ - "src/core/storage.ts"() { - "use strict"; - init_git(); - init_constants(); - } -}); // src/core/record.ts -var record_exports = {}; -__export(record_exports, { - recordCommitEntry: () => recordCommitEntry -}); -import { spawn } from "node:child_process"; -import { existsSync as existsSync7 } from "node:fs"; -import { readdir, readFile as readFile7, unlink, writeFile as writeFile6 } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join as join6 } from "node:path"; +var AGENTNOTE_IGNORE_MAX_PATTERN_LENGTH = 200; +var AGENTNOTE_IGNORE_MAX_WILDCARD_TOKENS = 10; +var AGENTNOTE_IGNORE_OVERLAPPING_WILDCARD_RE = /\*{3,}|\*\.\*/; async function recordCommitEntry(opts) { const sessionDir = join6(opts.agentnoteDirPath, SESSIONS_DIR, opts.sessionId); const sessionAgent = await readSessionAgent(sessionDir); @@ -3375,8 +3395,8 @@ async function recordCommitEntry(opts) { if (existingNote) return { promptCount: 0, aiRatio: 0 }; let commitFiles = []; try { - const raw = await git(["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"]); - commitFiles = raw.split("\n").filter(Boolean); + const raw = await git(["diff-tree", "-z", "--root", "--no-commit-id", "--name-only", "-r", "HEAD"]); + commitFiles = raw.split("\0").filter(Boolean); } catch { } const commitFileSet = new Set(commitFiles); @@ -3699,7 +3719,7 @@ async function recordCommitEntry(opts) { relevantPromptEntries, commitFileSet ); - if (interactions.length === 0 && aiFiles.length === 0) { + if (opts.requireAiFileEvidence && aiFiles.length === 0 || interactions.length === 0 && aiFiles.length === 0) { return { promptCount: 0, aiRatio: 0 }; } const entry = buildEntry({ @@ -3725,6 +3745,15 @@ async function recordCommitEntry(opts) { ); return { promptCount: interactions.length, aiRatio: entry.attribution.ai_ratio }; } +async function hasSessionHeadBlobEvidence(sessionDir, committedBlobs) { + if (committedBlobs.size === 0) return false; + const changeEntries = await readAllSessionJsonl(sessionDir, CHANGES_FILE); + return changeEntries.some((entry) => { + const file = typeof entry.file === "string" ? entry.file : ""; + const blob = typeof entry.blob === "string" ? entry.blob : ""; + return file !== "" && blob !== "" && committedBlobs.get(file) === blob; + }); +} function correlatePromptIds(interactions, sessionPromptEntries, transcriptCorrelationStartMs = null) { const effectiveCorrelationStartMs = hasTranscriptCandidateAtOrAfter( interactions, @@ -4681,36 +4710,11 @@ async function ensureEmptyBlobInStore() { } } } -var AGENTNOTE_IGNORE_MAX_PATTERN_LENGTH, AGENTNOTE_IGNORE_MAX_WILDCARD_TOKENS, AGENTNOTE_IGNORE_OVERLAPPING_WILDCARD_RE; -var init_record = __esm({ - "src/core/record.ts"() { - "use strict"; - init_agents(); - init_types(); - init_git(); - init_attribution(); - init_constants(); - init_entry(); - init_interaction_context(); - init_jsonl(); - init_prompt_window(); - init_session(); - init_storage(); - AGENTNOTE_IGNORE_MAX_PATTERN_LENGTH = 200; - AGENTNOTE_IGNORE_MAX_WILDCARD_TOKENS = 10; - AGENTNOTE_IGNORE_OVERLAPPING_WILDCARD_RE = /\*{3,}|\*\.\*/; - } -}); // src/paths.ts -var paths_exports = {}; -__export(paths_exports, { - agentnoteDir: () => agentnoteDir, - root: () => root, - sessionFile: () => sessionFile, - settingsFile: () => settingsFile -}); import { join as join7 } from "node:path"; +var _root = null; +var _gitDir = null; async function root() { if (!_root) { try { @@ -4737,39 +4741,98 @@ async function agentnoteDir() { async function sessionFile() { return join7(await agentnoteDir(), SESSION_FILE); } -async function settingsFile() { - return join7(await root(), ".claude", "settings.json"); -} -var _root, _gitDir; -var init_paths = __esm({ - "src/paths.ts"() { - "use strict"; - init_constants(); - init_git(); - _root = null; - _gitDir = null; - } -}); -// src/commands/commit.ts -init_constants(); -init_record(); -init_session(); -init_paths(); -import { spawn as spawn2 } from "node:child_process"; +// src/commands/record.ts import { existsSync as existsSync8 } from "node:fs"; import { readFile as readFile8 } from "node:fs/promises"; import { join as join8 } from "node:path"; +var FALLBACK_HEAD_FLAG = "--fallback-head"; +var SESSION_ID_SEGMENT_RE = /^[A-Za-z0-9._-]+$/; +var RAW_DIFF_STATUS_RE = /^:\d+ \d+ [0-9a-f]+ ([0-9a-f]+) ([A-Z][0-9]*)$/; +var RAW_DIFF_RENAME_OR_COPY_PREFIXES = ["R", "C"]; +async function record(args2) { + try { + if (args2[0] === FALLBACK_HEAD_FLAG) { + await recordHeadFallback(); + return; + } + const sessionId = args2[0]; + if (!sessionId) return; + await recordCommitEntry({ agentnoteDirPath: await agentnoteDir(), sessionId }); + } catch { + } +} +async function recordHeadFallback() { + if (await readHeadTrailerSessionId()) return; + const agentnoteDirPath = await agentnoteDir(); + const sessionId = await readActiveSessionId(agentnoteDirPath); + if (!sessionId) return; + const sessionDir = join8(agentnoteDirPath, SESSIONS_DIR, sessionId); + if (!await hasRecordableSessionData(sessionDir)) return; + const headBlobs = await readHeadCommittedBlobs(); + if (!await hasSessionHeadBlobEvidence(sessionDir, headBlobs)) return; + await recordCommitEntry({ + agentnoteDirPath, + sessionId, + requireAiFileEvidence: true + }); +} +async function readActiveSessionId(agentnoteDirPath) { + const activeSessionPath = join8(agentnoteDirPath, SESSION_FILE); + if (!existsSync8(activeSessionPath)) return null; + const sessionId = (await readFile8(activeSessionPath, TEXT_ENCODING)).trim(); + if (sessionId === "." || sessionId === "..") return null; + return SESSION_ID_SEGMENT_RE.test(sessionId) ? sessionId : null; +} +async function readHeadCommittedBlobs() { + const raw = await git(["diff-tree", "-z", "--raw", "--root", "--no-commit-id", "-r", "HEAD"]); + return parseCommittedBlobs(raw); +} +async function readHeadTrailerSessionId() { + return (await git(["log", "-1", `--format=%(trailers:key=${TRAILER_KEY},valueonly)`, "HEAD"])).trim(); +} +function parseCommittedBlobs(output) { + const blobs = /* @__PURE__ */ new Map(); + const fields = output.split("\0"); + for (let index = 0; index < fields.length; ) { + const metadata = fields[index++]; + if (!metadata) continue; + const match = metadata.match(RAW_DIFF_STATUS_RE); + if (!match) continue; + const [, blob, status2] = match; + const pathCount = RAW_DIFF_RENAME_OR_COPY_PREFIXES.some((prefix) => status2.startsWith(prefix)) ? 2 : 1; + let path = ""; + for (let pathIndex = 0; pathIndex < pathCount && index < fields.length; pathIndex++) { + path = fields[index++] ?? ""; + } + if (path) blobs.set(path, blob); + } + return blobs; +} + +// src/commands/commit.ts +var AMEND_LIKE_COMMIT_ARGS = /* @__PURE__ */ new Set([ + "--amend", + "-c", + "-C", + "--reuse-message", + "--reedit-message" +]); +var AMEND_LIKE_COMMIT_ARG_PREFIXES = ["--reuse-message=", "--reedit-message="]; +function isAmendLikeCommitArg(arg) { + return AMEND_LIKE_COMMIT_ARGS.has(arg) || AMEND_LIKE_COMMIT_ARG_PREFIXES.some((prefix) => arg.startsWith(prefix)); +} async function commit(args2) { const sf = await sessionFile(); let sessionId = ""; - if (existsSync8(sf)) { - sessionId = (await readFile8(sf, TEXT_ENCODING)).trim(); + const skipAgentNoteRecording = args2.some((arg) => isAmendLikeCommitArg(arg)); + if (!skipAgentNoteRecording && existsSync9(sf)) { + sessionId = (await readFile9(sf, TEXT_ENCODING)).trim(); if (sessionId) { const dir = await agentnoteDir(); - const hbPath = join8(dir, SESSIONS_DIR, sessionId, HEARTBEAT_FILE); + const hbPath = join9(dir, SESSIONS_DIR, sessionId, HEARTBEAT_FILE); try { - const hb = Number.parseInt((await readFile8(hbPath, TEXT_ENCODING)).trim(), 10); + const hb = Number.parseInt((await readFile9(hbPath, TEXT_ENCODING)).trim(), 10); if (hb === 0 || Number.isNaN(hb)) { sessionId = ""; } else { @@ -4779,7 +4842,7 @@ async function commit(args2) { } catch { sessionId = ""; } - if (sessionId && !await hasRecordableSessionData(join8(dir, SESSIONS_DIR, sessionId))) { + if (sessionId && !await hasRecordableSessionData(join9(dir, SESSIONS_DIR, sessionId))) { sessionId = ""; } } @@ -4807,31 +4870,28 @@ async function commit(args2) { } catch (err) { console.error(`agent-note: warning: ${err.message}`); } + } else if (!skipAgentNoteRecording) { + try { + await recordHeadFallback(); + } catch (err) { + console.error(`agent-note: warning: fallback recording failed: ${err.message}`); + } } } // src/commands/deinit.ts -init_agents(); -init_constants(); -init_git(); -init_paths(); -import { existsSync as existsSync10 } from "node:fs"; -import { readFile as readFile10, rename, unlink as unlink2 } from "node:fs/promises"; -import { join as join10 } from "node:path"; +import { existsSync as existsSync11 } from "node:fs"; +import { readFile as readFile11, rename, unlink as unlink2 } from "node:fs/promises"; +import { join as join11 } from "node:path"; // src/commands/init.ts -init_agents(); -init_types(); -init_constants(); -init_git(); -init_paths(); -import { existsSync as existsSync9 } from "node:fs"; -import { chmod, mkdir as mkdir5, readFile as readFile9, writeFile as writeFile7 } from "node:fs/promises"; -import { isAbsolute as isAbsolute2, join as join9, resolve as resolve5 } from "node:path"; +import { existsSync as existsSync10 } from "node:fs"; +import { chmod, mkdir as mkdir5, 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 RECORDABLE_SESSION_FILE_LIST = RECORDABLE_SESSION_FILES.join(" "); +var TRAILER_SESSION_FILE_LIST = TRAILER_SESSION_FILES.join(" "); var PR_REPORT_WORKFLOW_TEMPLATE = `name: Agent Note PR Report on: pull_request: @@ -4850,7 +4910,7 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: wasabeef/AgentNote@v0 + - uses: wasabeef/AgentNote@v1 env: GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }} `; @@ -4882,7 +4942,7 @@ jobs: steps: - name: Build Dashboard bundle id: dashboard - uses: wasabeef/AgentNote@v0 + uses: wasabeef/AgentNote@v1 with: dashboard: true @@ -4921,9 +4981,11 @@ ${AGENTNOTE_HOOK_MARKER} # Skip amend/reword/reuse (-c/-C/--amend) \u2014 only brand-new commits get a trailer. # $2 values: "" (normal), "template", "merge", "squash" = new commits. # "commit" = -c/-C/--amend (reuse). Skip those. -case "$2" in commit) exit 0;; esac -# Fail closed: no session file, no heartbeat, stale heartbeat, or metadata-only session \u2192 skip. +# Fail closed: no session file, no heartbeat, or no file evidence \u2192 skip. GIT_DIR="$(git rev-parse --git-dir 2>/dev/null)" +FALLBACK_FILE="$GIT_DIR/agentnote/${POST_COMMIT_FALLBACK_FILE}" +rm -f "$FALLBACK_FILE" 2>/dev/null || true +case "$2" in commit) exit 0;; esac SESSION_FILE="$GIT_DIR/agentnote/session" if [ ! -f "$SESSION_FILE" ]; then exit 0; fi SESSION_ID=$(cat "$SESSION_FILE" 2>/dev/null | tr -d '\\n') @@ -4936,15 +4998,18 @@ NOW=$(date +%s) HB=$(cat "$HEARTBEAT_FILE" 2>/dev/null | tr -d '\\n') HB_SEC=\${HB%???} AGE=$((NOW - HB_SEC)) -if [ "$AGE" -gt ${HEARTBEAT_TTL_SECONDS} ] 2>/dev/null; then exit 0; fi -HAS_RECORDABLE_DATA=0 -for FILE_NAME in ${RECORDABLE_SESSION_FILE_LIST}; do +HAS_TRAILER_DATA=0 +for FILE_NAME in ${TRAILER_SESSION_FILE_LIST}; do if [ -s "$SESSION_DIR/$FILE_NAME" ]; then - HAS_RECORDABLE_DATA=1 + HAS_TRAILER_DATA=1 break fi done -if [ "$HAS_RECORDABLE_DATA" -ne 1 ]; then exit 0; fi +if [ "$HAS_TRAILER_DATA" -ne 1 ]; then exit 0; fi +if [ "$AGE" -gt ${HEARTBEAT_TTL_SECONDS} ] 2>/dev/null; then + printf '%s\\n' '${POST_COMMIT_FALLBACK_HEAD}' > "$FALLBACK_FILE" 2>/dev/null || true + exit 0 +fi if ! grep -q "${TRAILER_KEY}" "$1" 2>/dev/null; then echo "" >> "$1" echo "${TRAILER_KEY}: $SESSION_ID" >> "$1" @@ -4953,12 +5018,20 @@ fi var POST_COMMIT_SCRIPT = `#!/bin/sh ${AGENTNOTE_HOOK_MARKER} # Record agentnote entry as a git note on HEAD. -# Read session ID from the finalized commit's trailer (source of truth), -# not from the mutable session file. This eliminates TOCTOU races between -# prepare-commit-msg and post-commit. +# Prefer the finalized trailer as the source of truth. If no trailer was +# injected because the session heartbeat was stale, the CLI may use a strict +# HEAD fallback that only records when session file evidence matches HEAD. GIT_DIR="$(git rev-parse --git-dir 2>/dev/null)" SESSION_ID=$(git log -1 --format='%(trailers:key=${TRAILER_KEY},valueonly)' HEAD 2>/dev/null | tr -d '\\n') -if [ -z "$SESSION_ID" ]; then exit 0; fi +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" + else + exit 0 + fi + rm -f "$FALLBACK_FILE" 2>/dev/null || true +fi # Prefer the repo-local shim created at init time so post-commit uses the # exact CLI version that generated these hooks. if [ -x "$GIT_DIR/agentnote/bin/agent-note" ]; then @@ -5055,10 +5128,10 @@ async function init(args2) { ); } if (!skipAction && !hooksOnly) { - const workflowDir = join9(repoRoot3, ".github", "workflows"); - const prReportWorkflowPath = join9(workflowDir, PR_REPORT_WORKFLOW_FILENAME); + const workflowDir = join10(repoRoot3, ".github", "workflows"); + const prReportWorkflowPath = join10(workflowDir, PR_REPORT_WORKFLOW_FILENAME); await mkdir5(workflowDir, { recursive: true }); - if (existsSync9(prReportWorkflowPath)) { + if (existsSync10(prReportWorkflowPath)) { results.push( ` \xB7 workflow already exists at .github/workflows/${PR_REPORT_WORKFLOW_FILENAME}` ); @@ -5067,8 +5140,8 @@ async function init(args2) { results.push(` \u2713 workflow created at .github/workflows/${PR_REPORT_WORKFLOW_FILENAME}`); } if (dashboard) { - const dashboardWorkflowPath = join9(workflowDir, DASHBOARD_WORKFLOW_FILENAME); - if (existsSync9(dashboardWorkflowPath)) { + const dashboardWorkflowPath = join10(workflowDir, DASHBOARD_WORKFLOW_FILENAME); + if (existsSync10(dashboardWorkflowPath)) { results.push( ` \xB7 workflow already exists at .github/workflows/${DASHBOARD_WORKFLOW_FILENAME}` ); @@ -5101,23 +5174,23 @@ async function init(args2) { } } if (!skipAction && !hooksOnly) { - const prReportWorkflowPath = join9( + const prReportWorkflowPath = join10( repoRoot3, ".github", "workflows", PR_REPORT_WORKFLOW_FILENAME ); - if (existsSync9(prReportWorkflowPath)) { + if (existsSync10(prReportWorkflowPath)) { toCommit.push(`.github/workflows/${PR_REPORT_WORKFLOW_FILENAME}`); } if (dashboard) { - const dashboardWorkflowPath = join9( + const dashboardWorkflowPath = join10( repoRoot3, ".github", "workflows", DASHBOARD_WORKFLOW_FILENAME ); - if (existsSync9(dashboardWorkflowPath)) { + if (existsSync10(dashboardWorkflowPath)) { toCommit.push(`.github/workflows/${DASHBOARD_WORKFLOW_FILENAME}`); } } @@ -5146,19 +5219,19 @@ async function init(args2) { async function resolveHookDir(repoRoot3) { try { const hooksPath = await git(["config", "--get", "core.hooksPath"]); - if (hooksPath) return isAbsolute2(hooksPath) ? hooksPath : join9(repoRoot3, hooksPath); + if (hooksPath) return isAbsolute2(hooksPath) ? hooksPath : join10(repoRoot3, hooksPath); } catch { } const gitDir2 = await git(["rev-parse", "--git-dir"]); - return join9(gitDir2, "hooks"); + return join10(gitDir2, "hooks"); } function shellSingleQuote(value) { return `'${value.replace(/'/g, `'"'"'`)}'`; } async function installLocalCliShim(agentnoteDirPath) { if (!process.argv[1]) return; - const shimDir = join9(agentnoteDirPath, "bin"); - const shimPath = join9(shimDir, "agent-note"); + const shimDir = join10(agentnoteDirPath, "bin"); + const shimPath = join10(shimDir, "agent-note"); const cliPath = resolve5(process.argv[1]); const shim = `#!/bin/sh exec ${shellSingleQuote(process.execPath)} ${shellSingleQuote(cliPath)} "$@" @@ -5168,12 +5241,12 @@ exec ${shellSingleQuote(process.execPath)} ${shellSingleQuote(cliPath)} "$@" await chmod(shimPath, 493); } async function installGitHook(hookDir, name, script) { - const hookPath = join9(hookDir, name); - if (existsSync9(hookPath)) { - const existing = await readFile9(hookPath, TEXT_ENCODING); + const hookPath = join10(hookDir, name); + if (existsSync10(hookPath)) { + const existing = await readFile10(hookPath, TEXT_ENCODING); if (existing.includes(AGENTNOTE_HOOK_MARKER)) { const backupPath2 = `${hookPath}.agentnote-backup`; - const target = existsSync9(backupPath2) ? script.replace( + const target = existsSync10(backupPath2) ? script.replace( "#!/bin/sh", `#!/bin/sh # Chain to original hook \u2014 preserve exit status. @@ -5185,7 +5258,7 @@ if [ -f ${shellSingleQuote(backupPath2)} ]; then ${shellSingleQuote(backupPath2) return true; } const backupPath = `${hookPath}.agentnote-backup`; - if (!existsSync9(backupPath)) { + if (!existsSync10(backupPath)) { await writeFile7(backupPath, existing); await chmod(backupPath, 493); } @@ -5214,12 +5287,12 @@ async function hasOtherEnabledAgents(repoRoot3, removingAgents) { return false; } async function removeGitHook(hookDir, name) { - const hookPath = join10(hookDir, name); - if (!existsSync10(hookPath)) return false; - const content = await readFile10(hookPath, TEXT_ENCODING); + const hookPath = join11(hookDir, name); + if (!existsSync11(hookPath)) return false; + const content = await readFile11(hookPath, TEXT_ENCODING); if (!content.includes(AGENTNOTE_HOOK_MARKER)) return false; const backupPath = `${hookPath}.agentnote-backup`; - if (existsSync10(backupPath)) { + if (existsSync11(backupPath)) { await rename(backupPath, hookPath); } else { await unlink2(hookPath); @@ -5264,19 +5337,19 @@ async function deinit(args2) { results.push(` \xB7 git hook: ${name} (not found or not managed by agentnote)`); } } - const binDir = join10(await agentnoteDir(), "bin"); - const shimPath = join10(binDir, "agent-note"); - if (existsSync10(shimPath)) { + const binDir = join11(await agentnoteDir(), "bin"); + const shimPath = join11(binDir, "agent-note"); + if (existsSync11(shimPath)) { await unlink2(shimPath); results.push(" \u2713 removed local CLI shim"); } if (removeWorkflow) { const workflowPaths = [ - join10(repoRoot3, ".github", "workflows", PR_REPORT_WORKFLOW_FILENAME), - join10(repoRoot3, ".github", "workflows", DASHBOARD_WORKFLOW_FILENAME) + join11(repoRoot3, ".github", "workflows", PR_REPORT_WORKFLOW_FILENAME), + join11(repoRoot3, ".github", "workflows", DASHBOARD_WORKFLOW_FILENAME) ]; for (const workflowPath of workflowPaths) { - if (!existsSync10(workflowPath)) continue; + if (!existsSync11(workflowPath)) continue; await unlink2(workflowPath); results.push(` \u2713 removed ${workflowPath.replace(`${repoRoot3}/`, "")}`); } @@ -5304,35 +5377,26 @@ async function deinit(args2) { } // src/commands/hook.ts -init_agents(); -init_types(); -init_constants(); -init_jsonl(); -init_record(); import { randomUUID } from "node:crypto"; -import { existsSync as existsSync12 } from "node:fs"; -import { mkdir as mkdir6, readFile as readFile11, realpath, unlink as unlink3, writeFile as writeFile8 } from "node:fs/promises"; -import { isAbsolute as isAbsolute3, join as join12, relative as relative2 } from "node:path"; +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 { isAbsolute as isAbsolute3, join as join13, relative as relative2 } from "node:path"; // src/core/rotate.ts -init_constants(); -import { existsSync as existsSync11 } from "node:fs"; +import { existsSync as existsSync12 } from "node:fs"; import { rename as rename2 } from "node:fs/promises"; -import { join as join11 } from "node:path"; +import { join as join12 } from "node:path"; async function rotateLogs(sessionDir, rotateId, fileNames = [PROMPTS_FILE, CHANGES_FILE]) { for (const name of fileNames) { - const src = join11(sessionDir, name); - if (existsSync11(src)) { + const src = join12(sessionDir, name); + if (existsSync12(src)) { const base = name.replace(".jsonl", ""); - await rename2(src, join11(sessionDir, `${base}-${rotateId}.jsonl`)); + await rename2(src, join12(sessionDir, `${base}-${rotateId}.jsonl`)); } } } // src/commands/hook.ts -init_session(); -init_git(); -init_paths(); var CLAUDE_PRE_TOOL_USE_EVENT = "PreToolUse"; var CURSOR_BEFORE_SUBMIT_PROMPT_EVENT = "beforeSubmitPrompt"; var CURSOR_BEFORE_SHELL_EXECUTION_EVENT = "beforeShellExecution"; @@ -5371,7 +5435,7 @@ async function normalizeToRepoRelative(filePath) { } async function blobHash(absPath) { try { - if (!existsSync12(absPath)) return EMPTY_BLOB; + if (!existsSync13(absPath)) return EMPTY_BLOB; return (await git(["hash-object", "-w", absPath])).trim(); } catch { return EMPTY_BLOB; @@ -5385,15 +5449,15 @@ async function readStdin() { return Buffer.concat(chunks).toString(TEXT_ENCODING); } async function readCurrentTurn2(sessionDir) { - const turnPath = join12(sessionDir, TURN_FILE); - if (!existsSync12(turnPath)) return 0; - const raw = (await readFile11(turnPath, TEXT_ENCODING)).trim(); + const turnPath = join13(sessionDir, TURN_FILE); + if (!existsSync13(turnPath)) return 0; + const raw = (await readFile12(turnPath, TEXT_ENCODING)).trim(); return Number.parseInt(raw, 10) || 0; } async function readCurrentPromptId(sessionDir) { - const p = join12(sessionDir, PROMPT_ID_FILE); - if (!existsSync12(p)) return null; - const raw = (await readFile11(p, TEXT_ENCODING)).trim(); + const p = join13(sessionDir, PROMPT_ID_FILE); + if (!existsSync13(p)) return null; + const raw = (await readFile12(p, TEXT_ENCODING)).trim(); return raw || null; } async function readCurrentHead() { @@ -5404,8 +5468,8 @@ async function readCurrentHead() { } } async function refreshHeartbeat(agentnoteDirPath, sessionId, opts = {}) { - const heartbeatPath = join12(agentnoteDirPath, SESSIONS_DIR, sessionId, HEARTBEAT_FILE); - if (opts.onlyIfExists && !existsSync12(heartbeatPath)) return; + const heartbeatPath = join13(agentnoteDirPath, SESSIONS_DIR, sessionId, HEARTBEAT_FILE); + if (opts.onlyIfExists && !existsSync13(heartbeatPath)) return; await writeFile8(heartbeatPath, String(Date.now())); } async function hook(args2 = []) { @@ -5441,19 +5505,19 @@ async function hook(args2 = []) { return; } const agentnoteDirPath = await agentnoteDir(); - const sessionDir = join12(agentnoteDirPath, SESSIONS_DIR, event.sessionId); + const sessionDir = join13(agentnoteDirPath, SESSIONS_DIR, event.sessionId); await mkdir6(sessionDir, { recursive: true }); if (!(adapter.name === AGENT_NAMES.gemini && event.kind === NORMALIZED_EVENT_KINDS.stop)) { await refreshHeartbeat(agentnoteDirPath, event.sessionId); } switch (event.kind) { case NORMALIZED_EVENT_KINDS.sessionStart: { - await writeFile8(join12(agentnoteDirPath, SESSION_FILE), event.sessionId); + await writeFile8(join13(agentnoteDirPath, SESSION_FILE), event.sessionId); await writeSessionAgent(sessionDir, adapter.name); if (event.transcriptPath) { await writeSessionTranscriptPath(sessionDir, event.transcriptPath); } - await appendJsonl(join12(sessionDir, EVENTS_FILE), { + await appendJsonl(join13(sessionDir, EVENTS_FILE), { event: NORMALIZED_EVENT_KINDS.sessionStart, session_id: event.sessionId, timestamp: event.timestamp, @@ -5468,7 +5532,7 @@ async function hook(args2 = []) { await writeSessionTranscriptPath(sessionDir, event.transcriptPath); } const turn = await readCurrentTurn2(sessionDir); - await appendJsonl(join12(sessionDir, EVENTS_FILE), { + await appendJsonl(join13(sessionDir, EVENTS_FILE), { event: NORMALIZED_EVENT_KINDS.stop, session_id: event.sessionId, timestamp: event.timestamp, @@ -5477,20 +5541,20 @@ async function hook(args2 = []) { }); if (adapter.name === AGENT_NAMES.gemini) { try { - await unlink3(join12(sessionDir, HEARTBEAT_FILE)); + await unlink3(join13(sessionDir, HEARTBEAT_FILE)); } catch { } } break; } case NORMALIZED_EVENT_KINDS.prompt: { - await writeFile8(join12(agentnoteDirPath, SESSION_FILE), event.sessionId); + await writeFile8(join13(agentnoteDirPath, SESSION_FILE), event.sessionId); await writeSessionAgent(sessionDir, adapter.name); if (event.transcriptPath) { await writeSessionTranscriptPath(sessionDir, event.transcriptPath); } - const eventsPath = join12(sessionDir, EVENTS_FILE); - if (!existsSync12(eventsPath)) { + const eventsPath = join13(sessionDir, EVENTS_FILE); + if (!existsSync13(eventsPath)) { await appendJsonl(eventsPath, { event: NORMALIZED_EVENT_KINDS.sessionStart, session_id: event.sessionId, @@ -5501,13 +5565,13 @@ async function hook(args2 = []) { } const rotateId = Date.now().toString(36); await rotateLogs(sessionDir, rotateId, [PROMPTS_FILE, CHANGES_FILE, PRE_BLOBS_FILE]); - const turnPath = join12(sessionDir, TURN_FILE); + const turnPath = join13(sessionDir, TURN_FILE); let turn = await readCurrentTurn2(sessionDir); turn += 1; await writeFile8(turnPath, String(turn)); const promptId = randomUUID(); - await writeFile8(join12(sessionDir, PROMPT_ID_FILE), promptId); - await appendJsonl(join12(sessionDir, PROMPTS_FILE), { + await writeFile8(join13(sessionDir, PROMPT_ID_FILE), promptId); + await appendJsonl(join13(sessionDir, PROMPTS_FILE), { event: NORMALIZED_EVENT_KINDS.prompt, timestamp: event.timestamp, prompt: event.prompt, @@ -5529,7 +5593,7 @@ async function hook(args2 = []) { } case NORMALIZED_EVENT_KINDS.response: { const turn = await readCurrentTurn2(sessionDir); - await appendJsonl(join12(sessionDir, EVENTS_FILE), { + await appendJsonl(join13(sessionDir, EVENTS_FILE), { event: NORMALIZED_EVENT_KINDS.response, session_id: event.sessionId, timestamp: event.timestamp, @@ -5544,7 +5608,7 @@ async function hook(args2 = []) { const turn = await readCurrentTurn2(sessionDir); const promptId = await readCurrentPromptId(sessionDir); const preBlob = isAbsolute3(absPath) ? await blobHash(absPath) : EMPTY_BLOB; - await appendJsonl(join12(sessionDir, PRE_BLOBS_FILE), { + await appendJsonl(join13(sessionDir, PRE_BLOBS_FILE), { event: PRE_BLOB_EVENT, turn, prompt_id: promptId, @@ -5566,7 +5630,7 @@ async function hook(args2 = []) { const promptId = await readCurrentPromptId(sessionDir); const postBlob = isAbsolute3(absPath) ? await blobHash(absPath) : EMPTY_BLOB; const changeId = adapter.name === AGENT_NAMES.cursor ? `${event.timestamp}:${event.tool ?? NORMALIZED_EVENT_KINDS.fileChange}:${filePath}:${postBlob}` : null; - await appendJsonl(join12(sessionDir, CHANGES_FILE), { + await appendJsonl(join13(sessionDir, CHANGES_FILE), { event: NORMALIZED_EVENT_KINDS.fileChange, timestamp: event.timestamp, tool: event.tool, @@ -5588,7 +5652,7 @@ async function hook(args2 = []) { if (adapter.name === AGENT_NAMES.gemini) { const headBefore = await readCurrentHead(); await writeFile8( - join12(sessionDir, PENDING_COMMIT_FILE), + join13(sessionDir, PENDING_COMMIT_FILE), `${JSON.stringify( { command: event.commitCommand ?? "", @@ -5606,7 +5670,7 @@ async function hook(args2 = []) { if (adapter.name === AGENT_NAMES.cursor) { const headBefore = await readCurrentHead(); await writeFile8( - join12(sessionDir, PENDING_COMMIT_FILE), + join13(sessionDir, PENDING_COMMIT_FILE), `${JSON.stringify( { command: event.commitCommand ?? "", @@ -5642,11 +5706,11 @@ async function hook(args2 = []) { } case NORMALIZED_EVENT_KINDS.postCommit: { if (adapter.name === AGENT_NAMES.cursor || adapter.name === AGENT_NAMES.gemini) { - const pendingPath = join12(sessionDir, PENDING_COMMIT_FILE); - if (!existsSync12(pendingPath)) break; + const pendingPath = join13(sessionDir, PENDING_COMMIT_FILE); + if (!existsSync13(pendingPath)) break; let headBefore = null; try { - const pending = JSON.parse(await readFile11(pendingPath, TEXT_ENCODING)); + const pending = JSON.parse(await readFile12(pendingPath, TEXT_ENCODING)); headBefore = pending.head_before?.trim() || null; } catch { headBefore = null; @@ -5671,11 +5735,6 @@ async function hook(args2 = []) { } } -// src/commands/log.ts -init_constants(); -init_storage(); -init_git(); - // src/commands/normalize.ts function isStructuredEntry(raw) { if (!raw || typeof raw !== "object") return false; @@ -5844,12 +5903,8 @@ async function readPrBody(prNumber) { } // ../pr-report/src/report.ts -init_constants(); -init_entry(); -init_storage(); -init_git(); -import { existsSync as existsSync13 } from "node:fs"; -import { join as join13 } from "node:path"; +import { existsSync as existsSync14 } from "node:fs"; +import { join as join14 } from "node:path"; var AI_RATIO_HEADER_BAR_WIDTH = 8; var AI_RATIO_TABLE_BAR_WIDTH = 5; var PERCENT_DENOMINATOR2 = 100; @@ -6039,8 +6094,8 @@ async function collectReport(base, headRef = "HEAD", opts = {}) { repoUrl = null; } const repoRoot3 = await git(["rev-parse", "--show-toplevel"]); - const hasDashboardWorkflow = existsSync13( - join13(repoRoot3, ".github", "workflows", "agentnote-dashboard.yml") + const hasDashboardWorkflow = existsSync14( + join14(repoRoot3, ".github", "workflows", "agentnote-dashboard.yml") ); const dashboardUrl = hasDashboardWorkflow ? inferDashboardUrl(repoUrl, opts.dashboardPrNumber) : null; return { @@ -6392,7 +6447,6 @@ function basename2(path) { } // src/commands/pr.ts -init_entry(); var DEFAULT_HEAD_REF = "HEAD"; var JSON_INDENT_SPACES2 = 2; var PR_FLAG_PREFIX = "--"; @@ -6452,8 +6506,6 @@ async function pr(args2) { } // src/commands/push-notes.ts -init_constants(); -init_git(); var NOTES_PUSH_TIMEOUT_MS = 1e4; var ENV_AGENTNOTE_PUSHING = "AGENTNOTE_PUSHING"; var ENV_GIT_TERMINAL_PROMPT = "GIT_TERMINAL_PROMPT"; @@ -6477,10 +6529,6 @@ async function pushNotes(args2) { } // src/commands/session.ts -init_constants(); -init_entry(); -init_storage(); -init_git(); var PERCENT_DENOMINATOR3 = 100; async function session(sessionId) { if (!sessionId) { @@ -6592,15 +6640,8 @@ async function session(sessionId) { } // src/commands/show.ts -init_agents(); -init_types(); -init_constants(); -init_session(); -init_storage(); -init_git(); -init_paths(); import { stat as stat2 } from "node:fs/promises"; -import { join as join14 } from "node:path"; +import { join as join15 } from "node:path"; var DEFAULT_COMMIT_REF = "HEAD"; var COMMIT_REF_PATTERN = /^(HEAD|[0-9a-f]{7,40})$/i; var BYTES_PER_KILOBYTE = 1024; @@ -6669,7 +6710,7 @@ async function show(commitRef) { } } } - const sessionDir = join14(await agentnoteDir(), SESSIONS_DIR, sessionId); + const sessionDir = join15(await agentnoteDir(), SESSIONS_DIR, sessionId); const sessionAgent = await readSessionAgent(sessionDir) ?? entry.agent ?? AGENT_NAMES.claude; const adapter = hasAgent(sessionAgent) ? getAgent(sessionAgent) : getAgent(AGENT_NAMES.claude); const transcriptPath = await readSessionTranscriptPath(sessionDir) ?? adapter.findTranscript(sessionId); @@ -6693,16 +6734,9 @@ function truncateLines(text, maxLen) { } // src/commands/status.ts -init_agents(); -init_types(); -init_constants(); -init_session(); -init_storage(); -init_git(); -init_paths(); -import { existsSync as existsSync14 } from "node:fs"; -import { readFile as readFile12 } from "node:fs/promises"; -import { isAbsolute as isAbsolute4, join as join15 } from "node:path"; +import { existsSync as existsSync15 } from "node:fs"; +import { readFile as readFile13 } from "node:fs/promises"; +import { isAbsolute as isAbsolute4, join as join16 } from "node:path"; var VERSION = "1.0.0"; var CAPABILITY_LABELS = { edits: "edits", @@ -6768,15 +6802,15 @@ async function status() { } const sessionPath = await sessionFile(); let sessionActive = false; - if (existsSync14(sessionPath)) { - const sid = (await readFile12(sessionPath, TEXT_ENCODING)).trim(); + if (existsSync15(sessionPath)) { + const sid = (await readFile13(sessionPath, TEXT_ENCODING)).trim(); if (sid) { const dir = await agentnoteDir(); - const sessionDir = join15(dir, SESSIONS_DIR, sid); - const hbPath = join15(sessionDir, HEARTBEAT_FILE); - if (existsSync14(hbPath)) { + const sessionDir = join16(dir, SESSIONS_DIR, sid); + const hbPath = join16(sessionDir, HEARTBEAT_FILE); + if (existsSync15(hbPath)) { try { - const hb = Number.parseInt((await readFile12(hbPath, TEXT_ENCODING)).trim(), 10); + const hb = Number.parseInt((await readFile13(hbPath, TEXT_ENCODING)).trim(), 10); const ageSeconds = Math.floor(Date.now() / MILLISECONDS_PER_SECOND) - Math.floor(hb / MILLISECONDS_PER_SECOND); if (hb > 0 && ageSeconds <= HEARTBEAT_TTL_SECONDS) { sessionActive = true; @@ -6838,14 +6872,16 @@ async function readAgentCaptureDetails(repoRoot3, enabledAgents) { return details; } async function readCodexCaptureCapabilities(repoRoot3) { - const hooksPath = join15(repoRoot3, ".codex", "hooks.json"); - if (!existsSync14(hooksPath)) return []; + const hooksPath = join16(repoRoot3, ".codex", "hooks.json"); + if (!existsSync15(hooksPath)) return []; try { - const content = await readFile12(hooksPath, TEXT_ENCODING); + const content = await readFile13(hooksPath, TEXT_ENCODING); const parsed = JSON.parse(content); const hooks = parsed.hooks ?? {}; const hasAgentnoteHook = (eventName) => (hooks[eventName] ?? []).some( - (group) => (group.hooks ?? []).some((hook2) => hook2.command?.includes(AGENTNOTE_HOOK_COMMAND)) + (group) => (group.hooks ?? []).some( + (hook2) => typeof hook2.command === "string" && isAgentNoteHookCommand(hook2.command, AGENT_NAMES.codex) + ) ); const capabilities = []; if (hasAgentnoteHook(CODEX_STATUS_HOOK_EVENTS.userPromptSubmit)) { @@ -6863,13 +6899,15 @@ async function readCodexCaptureCapabilities(repoRoot3) { } } async function readCursorCaptureCapabilities(repoRoot3) { - const hooksPath = join15(repoRoot3, ".cursor", "hooks.json"); - if (!existsSync14(hooksPath)) return []; + const hooksPath = join16(repoRoot3, ".cursor", "hooks.json"); + if (!existsSync15(hooksPath)) return []; try { - const content = await readFile12(hooksPath, TEXT_ENCODING); + const content = await readFile13(hooksPath, TEXT_ENCODING); const parsed = JSON.parse(content); const hooks = parsed.hooks ?? {}; - const hasAgentnoteHook = (eventName) => (hooks[eventName] ?? []).some((entry) => entry.command?.includes(AGENTNOTE_HOOK_COMMAND)); + const hasAgentnoteHook = (eventName) => (hooks[eventName] ?? []).some( + (entry) => typeof entry.command === "string" && isAgentNoteHookCommand(entry.command, AGENT_NAMES.cursor) + ); const capabilities = []; if (hasAgentnoteHook(CURSOR_STATUS_HOOK_EVENTS.beforeSubmitPrompt)) { capabilities.push(CAPABILITY_LABELS.prompt); @@ -6889,14 +6927,16 @@ async function readCursorCaptureCapabilities(repoRoot3) { } } async function readGeminiCaptureCapabilities(repoRoot3) { - const settingsPath = join15(repoRoot3, ".gemini", "settings.json"); - if (!existsSync14(settingsPath)) return []; + const settingsPath = join16(repoRoot3, ".gemini", "settings.json"); + if (!existsSync15(settingsPath)) return []; try { - const content = await readFile12(settingsPath, TEXT_ENCODING); + const content = await readFile13(settingsPath, TEXT_ENCODING); const parsed = JSON.parse(content); const hooks = parsed.hooks ?? {}; const hasAgentnoteHook = (eventName) => (hooks[eventName] ?? []).some( - (group) => (group.hooks ?? []).some((h) => h.command?.includes(AGENTNOTE_HOOK_COMMAND)) + (group) => (group.hooks ?? []).some( + (h) => typeof h.command === "string" && isAgentNoteHookCommand(h.command, AGENT_NAMES.gemini) + ) ); const capabilities = []; if (hasAgentnoteHook(GEMINI_STATUS_HOOK_EVENTS.beforeAgent)) { @@ -6917,10 +6957,10 @@ async function readManagedGitHooks(repoRoot3) { const hookDir = await resolveHookDir2(repoRoot3); const active = []; for (const name of GIT_HOOK_NAMES) { - const hookPath = join15(hookDir, name); - if (!existsSync14(hookPath)) continue; + const hookPath = join16(hookDir, name); + if (!existsSync15(hookPath)) continue; try { - const content = await readFile12(hookPath, TEXT_ENCODING); + const content = await readFile13(hookPath, TEXT_ENCODING); if (content.includes(AGENTNOTE_HOOK_MARKER)) { active.push(name); } @@ -6932,19 +6972,15 @@ async function readManagedGitHooks(repoRoot3) { async function resolveHookDir2(repoRoot3) { const hooksPathConfig = (await gitSafe(["config", "--get", "core.hooksPath"])).stdout.trim(); if (hooksPathConfig) { - return isAbsolute4(hooksPathConfig) ? hooksPathConfig : join15(repoRoot3, hooksPathConfig); + return isAbsolute4(hooksPathConfig) ? hooksPathConfig : join16(repoRoot3, hooksPathConfig); } const gitDir2 = (await gitSafe(["rev-parse", "--git-dir"])).stdout.trim(); - const resolvedGitDir = isAbsolute4(gitDir2) ? gitDir2 : join15(repoRoot3, gitDir2); - return join15(resolvedGitDir, "hooks"); + const resolvedGitDir = isAbsolute4(gitDir2) ? gitDir2 : join16(repoRoot3, gitDir2); + return join16(resolvedGitDir, "hooks"); } // src/commands/why.ts -init_constants(); -init_entry(); -init_storage(); -init_git(); -import { existsSync as existsSync15, realpathSync } from "node:fs"; +import { existsSync as existsSync16, realpathSync } from "node:fs"; import { isAbsolute as isAbsolute5, posix, relative as relative3, resolve as resolvePath } from "node:path"; var ALL_ZERO_COMMIT_RE = /^0{40}$/; var BLAME_HEADER_RE = /^([0-9a-f]{40})\s+\d+\s+\d+(?:\s+\d+)?$/i; @@ -7070,13 +7106,13 @@ async function normalizeTargetPath(path) { } async function findExistingRepositoryPath(candidates) { const root2 = await repoRoot(); - return candidates.find((candidate) => existsSync15(resolvePath(root2, candidate))) ?? null; + return candidates.find((candidate) => existsSync16(resolvePath(root2, candidate))) ?? null; } function stripPathMentionPrefix(value) { return value.startsWith(AI_PATH_MENTION_PREFIX) ? value.slice(AI_PATH_MENTION_PREFIX.length) : value; } function realpathIfExists(path) { - if (!existsSync15(path)) return path; + if (!existsSync16(path)) return path; try { return realpathSync.native(path); } catch { @@ -7088,7 +7124,7 @@ async function stripOptionalPathMentionPrefix(value) { const withoutPrefix = stripPathMentionPrefix(value); if (!withoutPrefix) return value; const root2 = await repoRoot(); - if (existsSync15(resolvePath(root2, value.replace(PATH_PREFIX_RE, "")))) return value; + if (existsSync16(resolvePath(root2, value.replace(PATH_PREFIX_RE, "")))) return value; return withoutPrefix; } function normalizeComparablePath(path) { @@ -7230,7 +7266,6 @@ function printUsageAndExit() { } // src/cli.ts -init_constants(); var VERSION2 = "1.0.0"; var HELP = ` agent-note v${VERSION2} \u2014 remember why your code changed @@ -7296,16 +7331,7 @@ switch (command) { await hook(args); break; case "record": { - const sid = args[0]; - if (sid) { - try { - const { recordCommitEntry: recordCommitEntry2 } = await Promise.resolve().then(() => (init_record(), record_exports)); - const { agentnoteDir: agentnoteDir2 } = await Promise.resolve().then(() => (init_paths(), paths_exports)); - const dir = await agentnoteDir2(); - await recordCommitEntry2({ agentnoteDirPath: dir, sessionId: sid }); - } catch { - } - } + await record(args); break; } case "push-notes": diff --git a/packages/cli/src/agents/claude.test.ts b/packages/cli/src/agents/claude.test.ts index 8f3de724..a6bbfb78 100644 --- a/packages/cli/src/agents/claude.test.ts +++ b/packages/cli/src/agents/claude.test.ts @@ -510,6 +510,15 @@ describe("claude adapter", () => { command: "npx --yes agent-note hook --agent claude", async: true, }, + { + type: "command", + command: "node packages/cli/dist/cli.js hook --agent claude", + async: true, + }, + { + type: "command", + command: "echo keep-inline", + }, ], }, { @@ -532,10 +541,13 @@ describe("claude adapter", () => { const allCommands = settings.hooks?.PostToolUse?.flatMap((g) => g.hooks).map((h) => h.command) ?? []; assert.ok( - !allCommands.some((c) => c?.includes("agent-note hook")), + !allCommands.some( + (c) => c?.includes("agent-note hook") || c?.includes("cli.js hook --agent claude"), + ), "agent-note hook should be removed", ); assert.ok(allCommands.includes("echo custom-hook"), "custom hook should be preserved"); + assert.ok(allCommands.includes("echo keep-inline"), "inline custom hook should be preserved"); }); it("is a no-op when settings.json does not exist", async () => { @@ -551,6 +563,117 @@ describe("claude adapter", () => { assert.equal(enabled, true); }); + it("returns true for legacy repo-local dist hooks", async () => { + const settingsDir = join(repoRoot, ".claude"); + mkdirSync(settingsDir, { recursive: true }); + writeFileSync( + join(settingsDir, "settings.json"), + `${JSON.stringify({ + hooks: { + SessionStart: [ + { + hooks: [ + { + type: "command", + command: "node packages/cli/dist/cli.js hook --agent claude", + async: true, + }, + ], + }, + ], + }, + })}\n`, + ); + + const enabled = await claude.isEnabled(repoRoot); + assert.equal(enabled, true); + }); + + it("removes legacy repo-local dist hooks from SessionStart", async () => { + const settingsDir = join(repoRoot, ".claude"); + mkdirSync(settingsDir, { recursive: true }); + writeFileSync( + join(settingsDir, "settings.json"), + `${JSON.stringify({ + hooks: { + SessionStart: [ + { + hooks: [ + { + type: "command", + command: "node packages/cli/dist/cli.js hook --agent claude", + async: true, + }, + ], + }, + ], + }, + })}\n`, + ); + + await claude.removeHooks(repoRoot); + + const enabled = await claude.isEnabled(repoRoot); + assert.equal(enabled, false); + }); + + it("does not infer enabled state from unrelated hook command fragments", async () => { + const settingsDir = join(repoRoot, ".claude"); + mkdirSync(settingsDir, { recursive: true }); + writeFileSync( + join(settingsDir, "settings.json"), + `${JSON.stringify({ + hooks: { + SessionStart: [ + { + hooks: [ + { + type: "command", + command: "echo agent-note hook", + async: true, + }, + { + type: "command", + command: "echo --agent claude", + async: true, + }, + ], + }, + ], + }, + })}\n`, + ); + + const enabled = await claude.isEnabled(repoRoot); + assert.equal(enabled, false); + }); + + it("does not infer enabled state from an unrelated single command", async () => { + const settingsDir = join(repoRoot, ".claude"); + mkdirSync(settingsDir, { recursive: true }); + writeFileSync( + join(settingsDir, "settings.json"), + `${JSON.stringify({ + hooks: { + SessionStart: [ + { + hooks: [ + { + type: "command", + command: "echo agent-note hook --agent claude", + async: true, + }, + ], + }, + ], + }, + })}\n`, + ); + + const enabled = await claude.isEnabled(repoRoot); + assert.equal(enabled, false); + }); + it("returns false when hooks are not installed", async () => { const enabled = await claude.isEnabled(repoRoot); assert.equal(enabled, false); diff --git a/packages/cli/src/agents/claude.ts b/packages/cli/src/agents/claude.ts index 1b3e76ab..f865a232 100644 --- a/packages/cli/src/agents/claude.ts +++ b/packages/cli/src/agents/claude.ts @@ -2,8 +2,9 @@ import { existsSync, readdirSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join, resolve, sep } from "node:path"; -import { AGENTNOTE_HOOK_COMMAND, CLI_JS_HOOK_COMMAND, TEXT_ENCODING } from "../core/constants.js"; +import { TEXT_ENCODING } from "../core/constants.js"; import { findGitCommitCommand } from "../git.js"; +import { isAgentNoteHookCommand } from "./hook-command.js"; import { AGENT_NAMES, type AgentAdapter, @@ -120,6 +121,36 @@ function isGitCommit(cmd: string): boolean { return findGitCommitCommand(cmd) !== null; } +function isManagedClaudeHook(hook: unknown): boolean { + if (!hook || typeof hook !== "object") return false; + const command = (hook as { command?: unknown }).command; + return ( + typeof command === "string" && + isAgentNoteHookCommand(command, AGENT_NAMES.claude, { allowMissingAgent: true }) + ); +} + +function removeManagedClaudeHooks(entry: unknown): unknown | null { + if (!entry || typeof entry !== "object" || !Array.isArray((entry as { hooks?: unknown }).hooks)) { + return entry; + } + + const group = entry as Record & { hooks: unknown[] }; + const hooks = group.hooks.filter((hook) => !isManagedClaudeHook(hook)); + return hooks.length > 0 ? { ...group, hooks } : null; +} + +function hasManagedClaudeHook(entry: unknown): boolean { + if (!entry || typeof entry !== "object" || !Array.isArray((entry as { hooks?: unknown }).hooks)) { + return false; + } + return (entry as { hooks: unknown[] }).hooks.some((hook) => { + if (!hook || typeof hook !== "object") return false; + const command = (hook as { command?: unknown }).command; + return typeof command === "string" && isAgentNoteHookCommand(command, AGENT_NAMES.claude); + }); +} + /** Claude Code adapter for hook installation, event parsing, and transcript recovery. */ export const claude: AgentAdapter = { name: AGENT_NAMES.claude, @@ -146,10 +177,7 @@ export const claude: AgentAdapter = { const hooks = (settings.hooks ?? {}) as Record; for (const [event, entries] of Object.entries(hooks)) { - hooks[event] = entries.filter((entry) => { - const text = JSON.stringify(entry); - return !text.includes(AGENTNOTE_HOOK_COMMAND) && !text.includes(CLI_JS_HOOK_COMMAND); - }); + hooks[event] = entries.map(removeManagedClaudeHooks).filter((entry) => entry !== null); if (hooks[event].length === 0) delete hooks[event]; } @@ -169,10 +197,9 @@ export const claude: AgentAdapter = { if (!settings.hooks) return; for (const [event, entries] of Object.entries(settings.hooks)) { - settings.hooks[event] = (entries as unknown[]).filter((e) => { - const text = JSON.stringify(e); - return !text.includes(AGENTNOTE_HOOK_COMMAND) && !text.includes(CLI_JS_HOOK_COMMAND); - }); + settings.hooks[event] = (entries as unknown[]) + .map(removeManagedClaudeHooks) + .filter((entry) => entry !== null); if (settings.hooks[event].length === 0) delete settings.hooks[event]; } if (Object.keys(settings.hooks).length === 0) delete settings.hooks; @@ -187,7 +214,10 @@ export const claude: AgentAdapter = { if (!existsSync(settingsPath)) return false; try { const content = await readFile(settingsPath, TEXT_ENCODING); - return content.includes(CLAUDE_HOOK_COMMAND); + const settings = JSON.parse(content) as { hooks?: Record }; + return Object.values(settings.hooks ?? {}).some((entries) => + entries.some((entry) => hasManagedClaudeHook(entry)), + ); } catch { return false; } diff --git a/packages/cli/src/agents/codex.test.ts b/packages/cli/src/agents/codex.test.ts index 9e83d01c..9bc4b5c3 100644 --- a/packages/cli/src/agents/codex.test.ts +++ b/packages/cli/src/agents/codex.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, it } from "node:test"; @@ -238,4 +238,79 @@ describe("codex adapter", () => { /Invalid Codex transcript path/, ); }); + + describe("hooks config", () => { + function writeCodexConfig(repoRoot: string, hooksCommand: string, extraCommand?: string): void { + const codexDir = join(repoRoot, ".codex"); + mkdirSync(codexDir, { recursive: true }); + writeFileSync(join(codexDir, "config.toml"), "[features]\ncodex_hooks = true\n"); + writeFileSync( + join(codexDir, "hooks.json"), + `${JSON.stringify( + { + hooks: { + SessionStart: [ + { + hooks: [ + { type: "command", command: hooksCommand }, + ...(extraCommand ? [{ type: "command", command: extraCommand }] : []), + ], + }, + ], + }, + }, + null, + 2, + )}\n`, + ); + } + + it("accepts repo-local dist hook commands as managed Codex hooks", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "agentnote-codex-repo-")); + try { + writeCodexConfig(repoRoot, "node packages/cli/dist/cli.js hook --agent codex"); + + assert.equal(await codex.isEnabled(repoRoot), true); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("removes legacy repo-local dist hooks while preserving unrelated hooks", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "agentnote-codex-repo-")); + try { + writeCodexConfig( + repoRoot, + "node packages/cli/dist/cli.js hook --agent codex", + "echo keep-me", + ); + + await codex.removeHooks(repoRoot); + + const hooksContent = readFileSync(join(repoRoot, ".codex", "hooks.json"), "utf8"); + assert.equal(hooksContent.includes("cli.js hook --agent codex"), false); + assert.equal(hooksContent.includes("echo keep-me"), true); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("upgrades legacy repo-local dist hooks to the public Codex hook command", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "agentnote-codex-repo-")); + try { + writeCodexConfig(repoRoot, "node packages/cli/dist/cli.js hook --agent codex"); + + await codex.installHooks(repoRoot); + + const hooksContent = readFileSync(join(repoRoot, ".codex", "hooks.json"), "utf8"); + assert.equal( + hooksContent.includes("node packages/cli/dist/cli.js hook --agent codex"), + false, + ); + assert.equal(hooksContent.includes("npx --yes agent-note hook --agent codex"), true); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + }); }); diff --git a/packages/cli/src/agents/codex.ts b/packages/cli/src/agents/codex.ts index 1b4bfbc8..d4078320 100644 --- a/packages/cli/src/agents/codex.ts +++ b/packages/cli/src/agents/codex.ts @@ -2,7 +2,8 @@ import { 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 { AGENTNOTE_HOOK_COMMAND, TEXT_ENCODING } from "../core/constants.js"; +import { TEXT_ENCODING } from "../core/constants.js"; +import { isAgentNoteHookCommand } from "./hook-command.js"; import { AGENT_NAMES, type AgentAdapter, @@ -259,7 +260,12 @@ function stripAgentnoteHooks(config: CodexHooksFile): CodexHooksFile { const filteredGroups = groups .map((group) => ({ ...group, - hooks: group.hooks.filter((hook) => !hook.command.includes(AGENTNOTE_HOOK_COMMAND)), + hooks: group.hooks.filter( + (hook) => + !isAgentNoteHookCommand(hook.command, AGENT_NAMES.codex, { + allowMissingAgent: true, + }), + ), })) .filter((group) => group.hooks.length > 0); return [event, filteredGroups]; @@ -411,7 +417,12 @@ export const codex: AgentAdapter = { configContent.includes("features.codex_hooks = true") || (configContent.includes("[features]") && configContent.match(/^\s*codex_hooks\s*=\s*true\s*$/m) !== null); - const hasHook = hooksContent.includes(HOOK_COMMAND); + const parsed = JSON.parse(hooksContent) as CodexHooksFile; + const hasHook = Object.values(parsed.hooks ?? {}).some((groups) => + groups.some((group) => + group.hooks.some((hook) => isAgentNoteHookCommand(hook.command, AGENT_NAMES.codex)), + ), + ); return configOk && hasHook; } catch { return false; diff --git a/packages/cli/src/agents/cursor.test.ts b/packages/cli/src/agents/cursor.test.ts index 054925af..75764618 100644 --- a/packages/cli/src/agents/cursor.test.ts +++ b/packages/cli/src/agents/cursor.test.ts @@ -53,6 +53,7 @@ describe("cursor adapter", () => { version: 1, hooks: { beforeSubmitPrompt: [{ command: "npx --yes agent-note hook" }, { command: "echo ok" }], + afterShellExecution: [{ command: "node packages/cli/dist/cli.js hook --agent cursor" }], }, }, null, @@ -84,6 +85,22 @@ describe("cursor adapter", () => { assert.equal(parsed.hooks.stop.length, 1); }); + it("accepts repo-local dist hook commands as managed Cursor hooks", async () => { + const hooksPath = join(repoRoot, ".cursor", "hooks.json"); + mkdirSync(join(repoRoot, ".cursor"), { recursive: true }); + writeFileSync( + hooksPath, + `${JSON.stringify({ + version: 1, + hooks: { + beforeSubmitPrompt: [{ command: "node packages/cli/dist/cli.js hook --agent cursor" }], + }, + })}\n`, + ); + + assert.equal(await cursor.isEnabled(repoRoot), true); + }); + it("parses prompt and file edit events", () => { const promptEvent = cursor.parseEvent({ raw: JSON.stringify({ diff --git a/packages/cli/src/agents/cursor.ts b/packages/cli/src/agents/cursor.ts index 07f2585d..5f7f6b5c 100644 --- a/packages/cli/src/agents/cursor.ts +++ b/packages/cli/src/agents/cursor.ts @@ -3,8 +3,9 @@ import { existsSync, statSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join, resolve, sep } from "node:path"; -import { AGENTNOTE_HOOK_COMMAND, TEXT_ENCODING } from "../core/constants.js"; +import { TEXT_ENCODING } from "../core/constants.js"; import { findGitCommitCommand } from "../git.js"; +import { isAgentNoteHookCommand } from "./hook-command.js"; import { AGENT_NAMES, type AgentAdapter, @@ -366,7 +367,10 @@ function stripAgentnoteHooks(config: CursorHooksConfig): CursorHooksConfig { Object.entries(config.hooks) .map(([event, entries]) => [ event, - entries.filter((entry) => !entry.command.includes(AGENTNOTE_HOOK_COMMAND)), + entries.filter( + (entry) => + !isAgentNoteHookCommand(entry.command, AGENT_NAMES.cursor, { allowMissingAgent: true }), + ), ]) .filter(([, entries]) => entries.length > 0), ); @@ -433,7 +437,10 @@ export const cursor: AgentAdapter = { try { const content = await readFile(hooksPath, TEXT_ENCODING); - return content.includes(HOOK_COMMAND); + const parsed = JSON.parse(content) as CursorHooksConfig; + return Object.values(parsed.hooks ?? {}).some((entries) => + entries.some((entry) => isAgentNoteHookCommand(entry.command, AGENT_NAMES.cursor)), + ); } catch { return false; } diff --git a/packages/cli/src/agents/gemini.test.ts b/packages/cli/src/agents/gemini.test.ts index 92db04fe..47848aa9 100644 --- a/packages/cli/src/agents/gemini.test.ts +++ b/packages/cli/src/agents/gemini.test.ts @@ -530,6 +530,11 @@ describe("gemini adapter", () => { type: "command", command: "npx --yes agent-note hook --agent gemini", }, + { + name: "agentnote-before-shell-legacy", + type: "command", + command: "node packages/cli/dist/cli.js hook --agent gemini", + }, { name: "my-custom-hook", type: "command", command: "echo hello" }, ], }, @@ -548,6 +553,10 @@ describe("gemini adapter", () => { }; const allHooks = settings.hooks?.BeforeTool?.flatMap((g) => g.hooks).map((h) => h.name) ?? []; assert.ok(!allHooks.includes("agentnote-before-shell"), "agent-note hook should be removed"); + assert.ok( + !allHooks.includes("agentnote-before-shell-legacy"), + "repo-local agent-note hook should be removed", + ); assert.ok(allHooks.includes("my-custom-hook"), "custom hook should be preserved"); }); @@ -564,6 +573,32 @@ describe("gemini adapter", () => { assert.equal(enabled, true); }); + it("returns true for legacy repo-local dist hooks", async () => { + const settingsDir = join(repoRoot, ".gemini"); + mkdirSync(settingsDir, { recursive: true }); + writeFileSync( + join(settingsDir, "settings.json"), + `${JSON.stringify({ + hooks: { + SessionStart: [ + { + hooks: [ + { + name: "agentnote-session-start", + type: "command", + command: "node packages/cli/dist/cli.js hook --agent gemini", + }, + ], + }, + ], + }, + })}\n`, + ); + + const enabled = await gemini.isEnabled(repoRoot); + assert.equal(enabled, true); + }); + it("returns false when hooks are not installed", async () => { const enabled = await gemini.isEnabled(repoRoot); assert.equal(enabled, false); diff --git a/packages/cli/src/agents/gemini.ts b/packages/cli/src/agents/gemini.ts index 1a9b1dae..1c0dea3a 100644 --- a/packages/cli/src/agents/gemini.ts +++ b/packages/cli/src/agents/gemini.ts @@ -2,8 +2,9 @@ import { type Dirent, existsSync, readdirSync, readFileSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join, resolve, sep } from "node:path"; -import { AGENTNOTE_HOOK_COMMAND, TEXT_ENCODING } from "../core/constants.js"; +import { TEXT_ENCODING } from "../core/constants.js"; import { findGitCommitCommand } from "../git.js"; +import { isAgentNoteHookCommand } from "./hook-command.js"; import { AGENT_NAMES, type AgentAdapter, @@ -226,7 +227,10 @@ function stripAgentnoteGroups(groups: GeminiHookGroup[]): GeminiHookGroup[] { return groups .map((group) => ({ ...group, - hooks: group.hooks.filter((hook) => !hook.command.includes(AGENTNOTE_HOOK_COMMAND)), + hooks: group.hooks.filter( + (hook) => + !isAgentNoteHookCommand(hook.command, AGENT_NAMES.gemini, { allowMissingAgent: true }), + ), })) .filter((group) => group.hooks.length > 0); } @@ -356,7 +360,12 @@ export const gemini: AgentAdapter = { if (!existsSync(settingsPath)) return false; try { const content = await readFile(settingsPath, TEXT_ENCODING); - return content.includes(HOOK_COMMAND); + const parsed = JSON.parse(content) as GeminiSettingsConfig; + return Object.values(parsed.hooks ?? {}).some((groups) => + groups.some((group) => + group.hooks.some((hook) => isAgentNoteHookCommand(hook.command, AGENT_NAMES.gemini)), + ), + ); } catch { return false; } diff --git a/packages/cli/src/agents/hook-command.test.ts b/packages/cli/src/agents/hook-command.test.ts new file mode 100644 index 00000000..c9d9299a --- /dev/null +++ b/packages/cli/src/agents/hook-command.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { isAgentNoteHookCommand } from "./hook-command.js"; + +describe("hook command detection", () => { + it("matches public and repo-local Agent Note hook commands for the requested agent", () => { + assert.equal( + isAgentNoteHookCommand("npx --yes agent-note hook --agent claude", "claude"), + true, + ); + assert.equal( + isAgentNoteHookCommand("node packages/cli/dist/cli.js hook --agent codex", "codex"), + true, + ); + assert.equal( + isAgentNoteHookCommand('node "./packages/cli/dist/cli.js" hook --agent=cursor', "cursor"), + true, + ); + }); + + it("does not match agent names or hook binaries by substring", () => { + assert.equal( + isAgentNoteHookCommand("npx --yes agent-note hook --agent claude-extra", "claude"), + false, + ); + assert.equal( + isAgentNoteHookCommand("node packages/cli/dist/other-cli.js hook --agent codex", "codex"), + false, + ); + assert.equal( + isAgentNoteHookCommand("node packages/cli/dist/cli.js hook-check --agent codex", "codex"), + false, + ); + assert.equal(isAgentNoteHookCommand("echo agent-note hook --agent claude", "claude"), false); + assert.equal( + isAgentNoteHookCommand("echo npx --yes agent-note hook --agent claude", "claude"), + false, + ); + }); + + it("allows missing legacy agent flags only for cleanup paths", () => { + assert.equal( + isAgentNoteHookCommand("npx --yes agent-note hook", "gemini", { allowMissingAgent: true }), + true, + ); + assert.equal(isAgentNoteHookCommand("npx --yes agent-note hook", "gemini"), false); + assert.equal( + isAgentNoteHookCommand("npx --yes agent-note hook --agent claude", "gemini", { + allowMissingAgent: true, + }), + false, + ); + }); +}); diff --git a/packages/cli/src/agents/hook-command.ts b/packages/cli/src/agents/hook-command.ts new file mode 100644 index 00000000..97671fba --- /dev/null +++ b/packages/cli/src/agents/hook-command.ts @@ -0,0 +1,112 @@ +import { AGENTNOTE_HOOK_COMMAND, CLI_JS_HOOK_COMMAND } from "../core/constants.js"; +import type { AgentName } from "./types.js"; + +type HookCommandMatchOptions = { + allowMissingAgent?: boolean; +}; + +const AGENT_FLAG = "--agent"; +const AGENT_FLAG_PREFIX = `${AGENT_FLAG}=`; +const AGENTNOTE_HOOK_TOKENS = AGENTNOTE_HOOK_COMMAND.split(" "); +const CLI_JS_HOOK_TOKENS = CLI_JS_HOOK_COMMAND.split(" "); +const NODE_COMMAND_NAMES = new Set(["node", "nodejs"]); +const NPX_COMMAND_NAME = "npx"; +const PATH_SEPARATOR_RE = /[\\/]/; + +/** Split a hook command into shell-like tokens without executing or expanding it. */ +function tokenizeHookCommand(command: string): string[] { + const tokens: string[] = []; + let current = ""; + let quote: '"' | "'" | null = null; + let escaped = false; + + for (const char of command) { + if (escaped) { + current += char; + escaped = false; + continue; + } + if (char === "\\" && quote !== "'") { + escaped = true; + continue; + } + if ((char === '"' || char === "'") && quote === null) { + quote = char; + continue; + } + if (char === quote) { + quote = null; + continue; + } + if (quote === null && /\s/.test(char)) { + if (current) tokens.push(current); + current = ""; + continue; + } + current += char; + } + + if (current) tokens.push(current); + return tokens; +} + +function tokenBasename(token: string): string { + return token.split(PATH_SEPARATOR_RE).pop() ?? token; +} + +function hasHookTokenSequence(tokens: string[], sequence: string[]): boolean { + if (sequence.length === 0) return false; + return tokens.some((token, index) => { + // Repo-local development hooks pass paths such as `packages/cli/dist/cli.js`. + const firstMatches = + token === sequence[0] || + (sequence[0] === CLI_JS_HOOK_TOKENS[0] && tokenBasename(token) === sequence[0]); + if (!firstMatches || index + sequence.length > tokens.length) return false; + return sequence + .slice(1) + .every((expectedToken, offset) => tokens[index + offset + 1] === expectedToken); + }); +} + +function hasPublicHookCommand(tokens: string[]): boolean { + return tokens.some((token, index) => { + if (token !== AGENTNOTE_HOOK_TOKENS[0]) return false; + if (!hasHookTokenSequence(tokens.slice(index), AGENTNOTE_HOOK_TOKENS)) return false; + if (index === 0) return true; + if (tokenBasename(tokens[0]) !== NPX_COMMAND_NAME) return false; + return tokens.slice(1, index).every((part) => part.startsWith("-")); + }); +} + +function hasRepoLocalHookCommand(tokens: string[]): boolean { + return tokens.some((token, index) => { + if (tokenBasename(token) !== CLI_JS_HOOK_TOKENS[0]) return false; + if (!hasHookTokenSequence(tokens.slice(index), CLI_JS_HOOK_TOKENS)) return false; + return index === 0 || (index === 1 && NODE_COMMAND_NAMES.has(tokenBasename(tokens[0]))); + }); +} + +function readAgentFlag(tokens: string[]): string | null { + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === AGENT_FLAG) return tokens[index + 1] ?? ""; + if (token.startsWith(AGENT_FLAG_PREFIX)) return token.slice(AGENT_FLAG_PREFIX.length); + } + return null; +} + +/** Return true when a command is an Agent Note hook for the requested agent. */ +export function isAgentNoteHookCommand( + command: string, + agentName: AgentName, + options: HookCommandMatchOptions = {}, +): boolean { + const tokens = tokenizeHookCommand(command); + const isPublicHook = hasPublicHookCommand(tokens); + const isRepoLocalHook = hasRepoLocalHookCommand(tokens); + if (!isPublicHook && !isRepoLocalHook) return false; + + const agentFlag = readAgentFlag(tokens); + if (agentFlag === agentName) return true; + return options.allowMissingAgent === true && agentFlag === null; +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 76278b25..6dc21f99 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -5,6 +5,7 @@ import { init } from "./commands/init.js"; import { log } from "./commands/log.js"; import { pr } from "./commands/pr.js"; import { pushNotes } from "./commands/push-notes.js"; +import { record } from "./commands/record.js"; import { session } from "./commands/session.js"; import { show } from "./commands/show.js"; import { status } from "./commands/status.js"; @@ -84,19 +85,7 @@ switch (command) { case "record": { // Record agentnote entry for HEAD — used by post-commit git hook. // Unlike `commit`, this does NOT run `git commit`. - // Session ID is passed as argument (validated by the hook) to avoid re-reading - // the session file and prevent TOCTOU races with concurrent sessions. - const sid = args[0]; - if (sid) { - try { - const { recordCommitEntry } = await import("./core/record.js"); - const { agentnoteDir } = await import("./paths.js"); - const dir = await agentnoteDir(); - await recordCommitEntry({ agentnoteDirPath: dir, sessionId: sid }); - } catch { - /* never break git */ - } - } + await record(args); break; } case "push-notes": diff --git a/packages/cli/src/commands/commit.test.ts b/packages/cli/src/commands/commit.test.ts index f4e38c9b..49f55c46 100644 --- a/packages/cli/src/commands/commit.test.ts +++ b/packages/cli/src/commands/commit.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -13,7 +13,16 @@ import { SESSIONS_DIR, TRAILER_KEY, TRANSCRIPT_PATH_FILE, + TURN_FILE, } from "../core/constants.js"; +import { isAmendLikeCommitArg } from "./commit.js"; + +type CliExecOptions = { + cwd: string; + env?: NodeJS.ProcessEnv; + encoding?: BufferEncoding; + input?: string; +}; /** Write a fresh heartbeat so agentnote commit treats the session as active. */ function ensureHeartbeat(sessionDir: string): void { @@ -25,6 +34,8 @@ describe("agentnote commit", () => { let testDir: string; let testHome: string; const cliPath = join(process.cwd(), "dist", "cli.js"); + const runCli = (args: string[], options: CliExecOptions): Buffer | string => + execFileSync("node", [cliPath, ...args], options); before(() => { testDir = mkdtempSync(join(tmpdir(), "agentnote-commit-")); @@ -34,12 +45,30 @@ describe("agentnote commit", () => { execSync("git config user.email test@test.com", { cwd: testDir }); execSync("git config user.name Test", { cwd: testDir }); execSync("git commit --allow-empty -m 'init'", { cwd: testDir }); - execSync(`node ${cliPath} init --agent claude --hooks --no-git-hooks`, { + runCli(["init", "--agent", "claude", "--hooks", "--no-git-hooks"], { cwd: testDir, env: { ...process.env, HOME: testHome }, }); }); + it("detects amend-like commit message reuse flags", () => { + for (const arg of [ + "--amend", + "-c", + "-C", + "--reuse-message", + "--reuse-message=HEAD", + "--reedit-message", + "--reedit-message=HEAD", + ]) { + assert.equal(isAmendLikeCommitArg(arg), true, `${arg} should skip Agent Note recording`); + } + + for (const arg of ["--message", "-m", "--allow-empty", "--reuse-messageful"]) { + assert.equal(isAmendLikeCommitArg(arg), false, `${arg} should be recordable`); + } + }); + after(() => { rmSync(testDir, { recursive: true, force: true }); }); @@ -56,7 +85,7 @@ describe("agentnote commit", () => { writeFileSync(join(testDir, "hello.txt"), "hello"); execSync("git add hello.txt", { cwd: testDir }); - execSync(`node ${cliPath} commit -m "test commit"`, { cwd: testDir }); + runCli(["commit", "-m", "test commit"], { cwd: testDir }); // verify trailer const msg = execSync("git log -1 --format=%B", { @@ -77,7 +106,7 @@ describe("agentnote commit", () => { writeFileSync(join(testDir, "metadata-only.txt"), "metadata only"); execSync("git add metadata-only.txt", { cwd: testDir }); - execSync(`node ${cliPath} commit -m "metadata-only commit"`, { cwd: testDir }); + runCli(["commit", "-m", "metadata-only commit"], { cwd: testDir }); const msg = execSync("git log -1 --format=%B", { cwd: testDir, @@ -86,6 +115,45 @@ describe("agentnote commit", () => { assert.ok(!msg.includes(TRAILER_KEY), "metadata-only sessions should not get a trailer"); }); + it("records non-UUID stale sessions through the strict post-commit fallback", () => { + const sessionId = "codex-session-stale-555"; + const sessionDir = join(testDir, ".git", AGENTNOTE_DIR, SESSIONS_DIR, sessionId); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(testDir, ".git", AGENTNOTE_DIR, SESSION_FILE), sessionId); + writeFileSync(join(sessionDir, HEARTBEAT_FILE), "1"); + writeFileSync(join(sessionDir, TURN_FILE), "1"); + writeFileSync( + join(sessionDir, PROMPTS_FILE), + '{"event":"prompt","timestamp":"2026-04-02T10:00:00Z","prompt":"add stale commit fallback","turn":1}\n', + ); + writeFileSync(join(testDir, "stale-commit.ts"), "export const staleCommit = true;\n"); + const staleCommitBlob = execSync("git hash-object -w stale-commit.ts", { + cwd: testDir, + encoding: "utf-8", + }).trim(); + writeFileSync( + join(sessionDir, CHANGES_FILE), + `{"event":"file_change","tool":"Write","file":"stale-commit.ts","blob":"${staleCommitBlob}","turn":1}\n`, + ); + + execSync("git add stale-commit.ts", { cwd: testDir }); + runCli(["commit", "-m", "feat: stale commit fallback"], { cwd: testDir }); + + const msg = execSync("git log -1 --format=%B", { + cwd: testDir, + encoding: "utf-8", + }); + assert.ok(!msg.includes(TRAILER_KEY), "stale fallback should not add a trailer"); + + const note = execSync("git notes --ref=agentnote show HEAD", { + cwd: testDir, + encoding: "utf-8", + }); + const entry = JSON.parse(note); + assert.equal(entry.session_id, sessionId); + assert.equal(entry.interactions[0].prompt, "add stale commit fallback"); + }); + it("records entry as git note with prompts and AI ratio", () => { const sessionId = "a1b2c3d4-aaaa-bbbb-cccc-dddddddddddd"; const sessionDir = join(testDir, ".git", AGENTNOTE_DIR, SESSIONS_DIR, sessionId); @@ -109,7 +177,7 @@ describe("agentnote commit", () => { writeFileSync(absPath, "export const x = 1;"); writeFileSync(join(testDir, "human-file.ts"), "export const y = 2;"); execSync("git add .", { cwd: testDir }); - execSync(`node ${cliPath} commit -m "mixed commit"`, { cwd: testDir }); + runCli(["commit", "-m", "mixed commit"], { cwd: testDir }); // verify git note exists const note = execSync("git notes --ref=agentnote show HEAD", { @@ -131,7 +199,7 @@ describe("agentnote commit", () => { writeFileSync(join(testDir, "plain.txt"), "no session"); execSync("git add plain.txt", { cwd: testDir }); - execSync(`node ${cliPath} commit -m "plain commit"`, { cwd: testDir }); + runCli(["commit", "-m", "plain commit"], { cwd: testDir }); const msg = execSync("git log -1 --format=%B", { cwd: testDir, @@ -181,7 +249,7 @@ describe("agentnote commit", () => { writeFileSync(join(testDir, "auth.ts"), "export {}"); execSync("git add auth.ts", { cwd: testDir }); - execSync(`node ${cliPath} commit -m "feat: auth with transcript"`, { + runCli(["commit", "-m", "feat: auth with transcript"], { cwd: testDir, env: { ...process.env, HOME: testHome }, }); @@ -233,7 +301,7 @@ describe("agentnote commit", () => { writeFileSync(join(testDir, "cross-turn.ts"), "export const crossTurn = true;"); execSync("git add cross-turn.ts", { cwd: testDir }); - execSync(`node ${cliPath} commit -m "feat: cross-turn commit"`, { cwd: testDir }); + runCli(["commit", "-m", "feat: cross-turn commit"], { cwd: testDir }); const note = execSync("git notes --ref=agentnote show HEAD", { cwd: testDir, @@ -297,7 +365,7 @@ describe("agentnote commit", () => { writeFileSync(join(testDir, "multi-gap.ts"), "export const multiGap = true;"); execSync("git add multi-gap.ts", { cwd: testDir }); - execSync(`node ${cliPath} commit -m "feat: multi-turn gap commit"`, { cwd: testDir }); + runCli(["commit", "-m", "feat: multi-turn gap commit"], { cwd: testDir }); const note = execSync("git notes --ref=agentnote show HEAD", { cwd: testDir, @@ -335,7 +403,7 @@ describe("agentnote commit", () => { // First commit: only split-a.ts writeFileSync(join(testDir, "split-a.ts"), "export const a = 1;"); execSync("git add split-a.ts", { cwd: testDir }); - execSync(`node ${cliPath} commit -m "feat: split-a"`, { cwd: testDir }); + runCli(["commit", "-m", "feat: split-a"], { cwd: testDir }); const note1 = execSync("git notes --ref=agentnote show HEAD", { cwd: testDir, @@ -349,7 +417,7 @@ describe("agentnote commit", () => { // With the empty-note skip, no note is written for a purely human commit. writeFileSync(join(testDir, "human-only.ts"), "export const h = 2;"); execSync("git add human-only.ts", { cwd: testDir }); - execSync(`node ${cliPath} commit -m "feat: human-only"`, { cwd: testDir }); + runCli(["commit", "-m", "feat: human-only"], { cwd: testDir }); let hasNote = true; try { @@ -370,53 +438,47 @@ describe("agentnote commit", () => { 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 claude --no-action`, { + runCli(["init", "--agent", "claude", "--no-action"], { cwd: dir, env: { ...process.env, HOME: home }, encoding: "utf-8", }); const sessionId = "a1b2c3d4-1111-2222-3333-444444444444"; - execSync( - `echo '${JSON.stringify({ + runCli(["hook", "--agent", "claude"], { + cwd: dir, + env: { ...process.env, HOME: home }, + encoding: "utf-8", + input: JSON.stringify({ hook_event_name: "SessionStart", session_id: sessionId, model: "claude-opus-4-6", - })}' | node ${cliPath} hook --agent claude`, - { - cwd: dir, - env: { ...process.env, HOME: home }, - encoding: "utf-8", - }, - ); + }), + }); - execSync( - `echo '${JSON.stringify({ + runCli(["hook", "--agent", "claude"], { + cwd: dir, + env: { ...process.env, HOME: home }, + encoding: "utf-8", + input: JSON.stringify({ hook_event_name: "UserPromptSubmit", session_id: sessionId, prompt: "Create claude-git-hook.txt", - })}' | node ${cliPath} hook --agent claude`, - { - cwd: dir, - env: { ...process.env, HOME: home }, - encoding: "utf-8", - }, - ); + }), + }); writeFileSync(join(dir, "claude-git-hook.txt"), "Claude git hook\n"); - execSync( - `echo '${JSON.stringify({ + runCli(["hook", "--agent", "claude"], { + cwd: dir, + env: { ...process.env, HOME: home }, + encoding: "utf-8", + input: JSON.stringify({ hook_event_name: "PostToolUse", session_id: sessionId, tool_name: "Write", tool_input: { file_path: join(dir, "claude-git-hook.txt") }, - })}' | node ${cliPath} hook --agent claude`, - { - cwd: dir, - env: { ...process.env, HOME: home }, - encoding: "utf-8", - }, - ); + }), + }); execSync("git add claude-git-hook.txt", { cwd: dir }); execSync(`git commit -m "feat: claude git hook commit"`, { diff --git a/packages/cli/src/commands/commit.ts b/packages/cli/src/commands/commit.ts index 4fa3ae5a..604423c3 100644 --- a/packages/cli/src/commands/commit.ts +++ b/packages/cli/src/commands/commit.ts @@ -13,6 +13,24 @@ import { import { recordCommitEntry } from "../core/record.js"; import { hasRecordableSessionData } from "../core/session.js"; import { agentnoteDir, sessionFile } from "../paths.js"; +import { recordHeadFallback } from "./record.js"; + +const AMEND_LIKE_COMMIT_ARGS = new Set([ + "--amend", + "-c", + "-C", + "--reuse-message", + "--reedit-message", +]); +const AMEND_LIKE_COMMIT_ARG_PREFIXES = ["--reuse-message=", "--reedit-message="] as const; + +/** True for git commit flags that rewrite or reuse an existing commit message. */ +export function isAmendLikeCommitArg(arg: string): boolean { + return ( + AMEND_LIKE_COMMIT_ARGS.has(arg) || + AMEND_LIKE_COMMIT_ARG_PREFIXES.some((prefix) => arg.startsWith(prefix)) + ); +} /** * Provide a hook-compatible manual commit path. @@ -24,8 +42,9 @@ import { agentnoteDir, sessionFile } from "../paths.js"; export async function commit(args: string[]): Promise { const sf = await sessionFile(); let sessionId = ""; + const skipAgentNoteRecording = args.some((arg) => isAmendLikeCommitArg(arg)); - if (existsSync(sf)) { + if (!skipAgentNoteRecording && existsSync(sf)) { sessionId = (await readFile(sf, TEXT_ENCODING)).trim(); // Check heartbeat validity — must match prepare-commit-msg and status logic: // heartbeat must exist, be non-zero, and be at most 1 hour old. @@ -81,5 +100,12 @@ export async function commit(args: string[]): Promise { // Never let agentnote recording break a commit. console.error(`agent-note: warning: ${(err as Error).message}`); } + } else if (!skipAgentNoteRecording) { + try { + await recordHeadFallback(); + } 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 25f68124..b607f00d 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -6,12 +6,14 @@ import { join } from "node:path"; import { after, before, describe, it } from "node:test"; import { AGENTNOTE_DIR, + CHANGES_FILE, HEARTBEAT_FILE, NOTES_REF_FULL, PROMPTS_FILE, SESSION_FILE, SESSIONS_DIR, TRAILER_KEY, + TURN_FILE, } from "../core/constants.js"; function shellSingleQuote(value: string): string { @@ -60,7 +62,7 @@ describe("agentnote init", () => { const workflowPath = join(testDir, ".github", "workflows", "agentnote-pr-report.yml"); assert.ok(existsSync(workflowPath), "workflow should exist"); const workflow = readFileSync(workflowPath, "utf-8"); - assert.ok(workflow.includes("wasabeef/AgentNote@v0"), "workflow should reference the action"); + assert.ok(workflow.includes("wasabeef/AgentNote@v1"), "workflow should reference the action"); // Notes fetch config const fetchConfig = execSync("git config --get-all remote.origin.fetch", { @@ -185,7 +187,7 @@ AGENTNOTE_PUSHING=1 git push "$REMOTE" refs/notes/agentnote 2>/dev/null & rmSync(dir, { recursive: true, force: true }); }); - it("prepare-commit-msg skips metadata-only sessions before injecting trailers", () => { + it("prepare-commit-msg requires file evidence before injecting trailers", () => { const dir = mkdtempSync(join(tmpdir(), "agentnote-prepare-session-data-")); execSync("git init", { cwd: dir }); execSync("git config user.email test@test.com", { cwd: dir }); @@ -216,14 +218,344 @@ AGENTNOTE_PUSHING=1 git push "$REMOTE" refs/notes/agentnote 2>/dev/null & join(sessionDir, PROMPTS_FILE), '{"event":"prompt","timestamp":"2026-04-02T10:00:00Z","prompt":"commit this change"}\n', ); - const recordableMessagePath = join(dir, "recordable-message.txt"); - writeFileSync(recordableMessagePath, "subject\n"); - execFileSync(hookPath, [recordableMessagePath], { cwd: dir }); + const promptOnlyMessagePath = join(dir, "prompt-only-message.txt"); + writeFileSync(promptOnlyMessagePath, "subject\n"); + execFileSync(hookPath, [promptOnlyMessagePath], { cwd: dir }); assert.ok( - readFileSync(recordableMessagePath, "utf-8").includes(`${TRAILER_KEY}: ${sessionId}`), - "sessions with prompts should receive a trailer", + !readFileSync(promptOnlyMessagePath, "utf-8").includes(TRAILER_KEY), + "prompt-only sessions should not receive a plain git hook trailer", ); + writeFileSync( + join(sessionDir, CHANGES_FILE), + '{"event":"file_change","tool":"Write","file":"src/app.ts","blob":"abc123","turn":1}\n', + ); + const fileEvidenceMessagePath = join(dir, "file-evidence-message.txt"); + writeFileSync(fileEvidenceMessagePath, "subject\n"); + execFileSync(hookPath, [fileEvidenceMessagePath], { cwd: dir }); + assert.ok( + readFileSync(fileEvidenceMessagePath, "utf-8").includes(`${TRAILER_KEY}: ${sessionId}`), + "sessions with file evidence should receive a trailer", + ); + + rmSync(dir, { recursive: true, force: true }); + }); + + it("post-commit fallback records stale sessions when file evidence matches", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-stale-post-commit-")); + 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 claude --no-action`, { + cwd: dir, + encoding: "utf-8", + }); + + const sessionId = "a1b2c3d4-3333-3333-3333-000000000333"; + const sessionDir = join(dir, ".git", AGENTNOTE_DIR, SESSIONS_DIR, sessionId); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(dir, ".git", AGENTNOTE_DIR, SESSION_FILE), sessionId); + writeFileSync(join(sessionDir, HEARTBEAT_FILE), "1"); + writeFileSync(join(sessionDir, TURN_FILE), "1"); + writeFileSync( + join(sessionDir, PROMPTS_FILE), + '{"event":"prompt","timestamp":"2026-04-02T10:00:00Z","prompt":"add stale rescue","turn":1}\n', + ); + writeFileSync(join(dir, "stale-rescue.ts"), "export const staleRescue = true;\n"); + const staleRescueBlob = execSync("git hash-object -w stale-rescue.ts", { + cwd: dir, + encoding: "utf-8", + }).trim(); + writeFileSync( + join(sessionDir, CHANGES_FILE), + `{"event":"file_change","tool":"Write","file":"stale-rescue.ts","blob":"${staleRescueBlob}","turn":1}\n`, + ); + + execSync("git add stale-rescue.ts", { cwd: dir }); + execSync("git commit -m 'feat: stale rescue'", { cwd: dir }); + + const message = execSync("git log -1 --format=%B", { cwd: dir, encoding: "utf-8" }); + assert.ok(!message.includes(TRAILER_KEY), "stale heartbeat should still skip trailers"); + + const note = execSync("git notes --ref=agentnote show HEAD", { + cwd: dir, + encoding: "utf-8", + }); + const entry = JSON.parse(note); + assert.equal(entry.session_id, sessionId); + assert.equal(entry.interactions[0].prompt, "add stale rescue"); + + rmSync(dir, { recursive: true, force: true }); + }); + + it("plain git commit does not attach fresh prompt-only active sessions", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-fresh-prompt-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 claude --no-action`, { + cwd: dir, + encoding: "utf-8", + }); + + const sessionId = "a1b2c3d4-9999-9999-9999-000000000999"; + const sessionDir = join(dir, ".git", AGENTNOTE_DIR, SESSIONS_DIR, sessionId); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(dir, ".git", AGENTNOTE_DIR, SESSION_FILE), sessionId); + writeFileSync(join(sessionDir, HEARTBEAT_FILE), String(Date.now())); + writeFileSync(join(sessionDir, TURN_FILE), "1"); + writeFileSync( + join(sessionDir, PROMPTS_FILE), + '{"event":"prompt","timestamp":"2026-04-02T10:00:00Z","prompt":"fresh prompt only","turn":1}\n', + ); + + writeFileSync(join(dir, "terminal-only.ts"), "export const terminalOnly = true;\n"); + execSync("git add terminal-only.ts", { cwd: dir }); + execSync("git commit -m 'chore: terminal only'", { cwd: dir }); + + const message = execSync("git log -1 --format=%B", { cwd: dir, encoding: "utf-8" }); + assert.ok( + !message.includes(TRAILER_KEY), + "fresh prompt-only sessions should not hijack plain terminal commits", + ); + 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 }); + 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 claude --no-action`, { + cwd: dir, + encoding: "utf-8", + }); + + const sessionId = "a1b2c3d4-8888-8888-8888-000000000888"; + const sessionDir = join(dir, ".git", AGENTNOTE_DIR, SESSIONS_DIR, sessionId); + const filePath = "src/日本語 file.ts"; + mkdirSync(join(dir, "src"), { recursive: true }); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(dir, ".git", AGENTNOTE_DIR, SESSION_FILE), sessionId); + writeFileSync(join(sessionDir, HEARTBEAT_FILE), "1"); + writeFileSync(join(sessionDir, TURN_FILE), "1"); + writeFileSync( + join(sessionDir, PROMPTS_FILE), + '{"event":"prompt","timestamp":"2026-04-02T10:00:00Z","prompt":"add quoted path fallback","turn":1}\n', + ); + writeFileSync(join(dir, filePath), "export const quotedPathFallback = true;\n"); + const quotedPathBlob = execSync(`git hash-object -w ${shellSingleQuote(filePath)}`, { + cwd: dir, + encoding: "utf-8", + }).trim(); + writeFileSync( + join(sessionDir, CHANGES_FILE), + `{"event":"file_change","tool":"Write","file":"${filePath}","blob":"${quotedPathBlob}","turn":1}\n`, + ); + + execSync(`git add ${shellSingleQuote(filePath)}`, { cwd: dir }); + execSync("git commit -m 'feat: quoted path fallback'", { cwd: dir }); + + const note = execSync("git notes --ref=agentnote show HEAD", { + cwd: dir, + encoding: "utf-8", + }); + const entry = JSON.parse(note); + assert.equal(entry.session_id, sessionId); + assert.equal(entry.interactions[0].prompt, "add quoted path fallback"); + + rmSync(dir, { recursive: true, force: true }); + }); + + it("post-commit fallback does not record stale prompt-only sessions", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-stale-prompt-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 claude --no-action`, { + cwd: dir, + encoding: "utf-8", + }); + + const sessionId = "a1b2c3d4-4444-4444-4444-000000000444"; + const sessionDir = join(dir, ".git", AGENTNOTE_DIR, SESSIONS_DIR, sessionId); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(dir, ".git", AGENTNOTE_DIR, SESSION_FILE), sessionId); + writeFileSync(join(sessionDir, HEARTBEAT_FILE), "1"); + writeFileSync(join(sessionDir, TURN_FILE), "1"); + writeFileSync( + join(sessionDir, PROMPTS_FILE), + '{"event":"prompt","timestamp":"2026-04-02T10:00:00Z","prompt":"just talking","turn":1}\n', + ); + + 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 }); + + 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 does not record stale same-path sessions when blobs differ", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-stale-blob-mismatch-")); + 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 claude --no-action`, { + cwd: dir, + encoding: "utf-8", + }); + + const sessionId = "a1b2c3d4-5555-5555-5555-000000000555"; + const sessionDir = join(dir, ".git", AGENTNOTE_DIR, SESSIONS_DIR, sessionId); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(dir, ".git", AGENTNOTE_DIR, SESSION_FILE), sessionId); + writeFileSync(join(sessionDir, HEARTBEAT_FILE), "1"); + writeFileSync(join(sessionDir, TURN_FILE), "1"); + writeFileSync( + join(sessionDir, PROMPTS_FILE), + '{"event":"prompt","timestamp":"2026-04-02T10:00:00Z","prompt":"old same path edit","turn":1}\n', + ); + writeFileSync(join(dir, "same-path.ts"), "export const stale = true;\n"); + const staleBlob = execSync("git hash-object -w same-path.ts", { + cwd: dir, + encoding: "utf-8", + }).trim(); + writeFileSync( + join(sessionDir, CHANGES_FILE), + `{"event":"file_change","tool":"Write","file":"same-path.ts","blob":"${staleBlob}","turn":1}\n`, + ); + + writeFileSync(join(dir, "same-path.ts"), "export const human = true;\n"); + execSync("git add same-path.ts", { cwd: dir }); + execSync("git commit -m 'chore: human same path'", { cwd: dir }); + + 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 does not record amend commits", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-stale-amend-")); + execSync("git init", { cwd: dir }); + execSync("git config user.email test@test.com", { cwd: dir }); + execSync("git config user.name Test", { cwd: dir }); + writeFileSync(join(dir, "amend.ts"), "export const before = true;\n"); + execSync("git add amend.ts", { cwd: dir }); + execSync("git commit -m 'feat: initial amend target'", { cwd: dir }); + + execSync(`node ${cliPath} init --agent claude --no-action`, { + cwd: dir, + encoding: "utf-8", + }); + + const sessionId = "a1b2c3d4-6666-6666-6666-000000000666"; + const sessionDir = join(dir, ".git", AGENTNOTE_DIR, SESSIONS_DIR, sessionId); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(dir, ".git", AGENTNOTE_DIR, SESSION_FILE), sessionId); + writeFileSync(join(sessionDir, HEARTBEAT_FILE), "1"); + writeFileSync(join(sessionDir, TURN_FILE), "1"); + writeFileSync( + join(sessionDir, PROMPTS_FILE), + '{"event":"prompt","timestamp":"2026-04-02T10:00:00Z","prompt":"amend should stay skipped","turn":1}\n', + ); + writeFileSync(join(dir, "amend.ts"), "export const after = true;\n"); + const amendBlob = execSync("git hash-object -w amend.ts", { + cwd: dir, + encoding: "utf-8", + }).trim(); + writeFileSync( + join(sessionDir, CHANGES_FILE), + `{"event":"file_change","tool":"Write","file":"amend.ts","blob":"${amendBlob}","turn":1}\n`, + ); + + execSync("git add amend.ts", { cwd: dir }); + execSync("git commit --amend --no-edit", { cwd: dir }); + + 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 on root commits", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-stale-root-")); + execSync("git init", { cwd: dir }); + execSync("git config user.email test@test.com", { cwd: dir }); + execSync("git config user.name Test", { cwd: dir }); + + execSync(`node ${cliPath} init --agent claude --no-action`, { + cwd: dir, + encoding: "utf-8", + }); + + const sessionId = "a1b2c3d4-7777-7777-7777-000000000777"; + const sessionDir = join(dir, ".git", AGENTNOTE_DIR, SESSIONS_DIR, sessionId); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(dir, ".git", AGENTNOTE_DIR, SESSION_FILE), sessionId); + writeFileSync(join(sessionDir, HEARTBEAT_FILE), "1"); + writeFileSync(join(sessionDir, TURN_FILE), "1"); + writeFileSync( + join(sessionDir, PROMPTS_FILE), + '{"event":"prompt","timestamp":"2026-04-02T10:00:00Z","prompt":"root commit fallback","turn":1}\n', + ); + writeFileSync(join(dir, "root.ts"), "export const rootFallback = true;\n"); + const rootBlob = execSync("git hash-object -w root.ts", { + cwd: dir, + encoding: "utf-8", + }).trim(); + writeFileSync( + join(sessionDir, CHANGES_FILE), + `{"event":"file_change","tool":"Write","file":"root.ts","blob":"${rootBlob}","turn":1}\n`, + ); + + execSync("git add root.ts", { cwd: dir }); + execSync("git commit -m 'feat: root fallback'", { cwd: dir }); + + const note = execSync("git notes --ref=agentnote show HEAD", { + cwd: dir, + encoding: "utf-8", + }); + const entry = JSON.parse(note); + assert.equal(entry.session_id, sessionId); + assert.equal(entry.interactions[0].prompt, "root commit fallback"); + rmSync(dir, { recursive: true, force: true }); }); @@ -375,7 +707,7 @@ AGENTNOTE_PUSHING=1 git push "$REMOTE" refs/notes/agentnote 2>/dev/null & "dashboard workflow should have the new name", ); assert.ok( - dashboardWorkflow.includes("uses: wasabeef/AgentNote@v0"), + dashboardWorkflow.includes("uses: wasabeef/AgentNote@v1"), "dashboard workflow should use the public Agent Note action", ); assert.ok( @@ -405,7 +737,7 @@ AGENTNOTE_PUSHING=1 git push "$REMOTE" refs/notes/agentnote 2>/dev/null & "dashboard workflow should keep Pages artifact paths inside the shared action", ); assert.ok( - !dashboardWorkflow.includes("packages/dashboard@v0"), + !dashboardWorkflow.includes("packages/dashboard@"), "dashboard workflow should not expose the internal Dashboard package path", ); assert.ok( diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index e213b984..d1f1ec47 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -9,9 +9,11 @@ import { HEARTBEAT_TTL_SECONDS, NOTES_FETCH_REFSPEC, NOTES_REF_FULL, - RECORDABLE_SESSION_FILES, + POST_COMMIT_FALLBACK_FILE, + POST_COMMIT_FALLBACK_HEAD, TEXT_ENCODING, TRAILER_KEY, + TRAILER_SESSION_FILES, } from "../core/constants.js"; import { git, gitSafe } from "../git.js"; import { agentnoteDir, root } from "../paths.js"; @@ -22,7 +24,7 @@ export const PR_REPORT_WORKFLOW_FILENAME = "agentnote-pr-report.yml"; export const DASHBOARD_WORKFLOW_FILENAME = "agentnote-dashboard.yml"; const [PREPARE_COMMIT_MSG_HOOK, POST_COMMIT_HOOK, PRE_PUSH_HOOK] = GIT_HOOK_NAMES; -const RECORDABLE_SESSION_FILE_LIST = RECORDABLE_SESSION_FILES.join(" "); +const TRAILER_SESSION_FILE_LIST = TRAILER_SESSION_FILES.join(" "); const PR_REPORT_WORKFLOW_TEMPLATE = `name: Agent Note PR Report on: @@ -42,7 +44,7 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: wasabeef/AgentNote@v0 + - uses: wasabeef/AgentNote@v1 env: GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }} `; @@ -75,7 +77,7 @@ jobs: steps: - name: Build Dashboard bundle id: dashboard - uses: wasabeef/AgentNote@v0 + uses: wasabeef/AgentNote@v1 with: dashboard: true @@ -122,9 +124,11 @@ ${AGENTNOTE_HOOK_MARKER} # Skip amend/reword/reuse (-c/-C/--amend) — only brand-new commits get a trailer. # $2 values: "" (normal), "template", "merge", "squash" = new commits. # "commit" = -c/-C/--amend (reuse). Skip those. -case "$2" in commit) exit 0;; esac -# Fail closed: no session file, no heartbeat, stale heartbeat, or metadata-only session → skip. +# Fail closed: no session file, no heartbeat, or no file evidence → skip. GIT_DIR="$(git rev-parse --git-dir 2>/dev/null)" +FALLBACK_FILE="$GIT_DIR/agentnote/${POST_COMMIT_FALLBACK_FILE}" +rm -f "$FALLBACK_FILE" 2>/dev/null || true +case "$2" in commit) exit 0;; esac SESSION_FILE="$GIT_DIR/agentnote/session" if [ ! -f "$SESSION_FILE" ]; then exit 0; fi SESSION_ID=$(cat "$SESSION_FILE" 2>/dev/null | tr -d '\\n') @@ -137,15 +141,18 @@ NOW=$(date +%s) HB=$(cat "$HEARTBEAT_FILE" 2>/dev/null | tr -d '\\n') HB_SEC=\${HB%???} AGE=$((NOW - HB_SEC)) -if [ "$AGE" -gt ${HEARTBEAT_TTL_SECONDS} ] 2>/dev/null; then exit 0; fi -HAS_RECORDABLE_DATA=0 -for FILE_NAME in ${RECORDABLE_SESSION_FILE_LIST}; do +HAS_TRAILER_DATA=0 +for FILE_NAME in ${TRAILER_SESSION_FILE_LIST}; do if [ -s "$SESSION_DIR/$FILE_NAME" ]; then - HAS_RECORDABLE_DATA=1 + HAS_TRAILER_DATA=1 break fi done -if [ "$HAS_RECORDABLE_DATA" -ne 1 ]; then exit 0; fi +if [ "$HAS_TRAILER_DATA" -ne 1 ]; then exit 0; fi +if [ "$AGE" -gt ${HEARTBEAT_TTL_SECONDS} ] 2>/dev/null; then + printf '%s\\n' '${POST_COMMIT_FALLBACK_HEAD}' > "$FALLBACK_FILE" 2>/dev/null || true + exit 0 +fi if ! grep -q "${TRAILER_KEY}" "$1" 2>/dev/null; then echo "" >> "$1" echo "${TRAILER_KEY}: $SESSION_ID" >> "$1" @@ -155,12 +162,20 @@ fi const POST_COMMIT_SCRIPT = `#!/bin/sh ${AGENTNOTE_HOOK_MARKER} # Record agentnote entry as a git note on HEAD. -# Read session ID from the finalized commit's trailer (source of truth), -# not from the mutable session file. This eliminates TOCTOU races between -# prepare-commit-msg and post-commit. +# Prefer the finalized trailer as the source of truth. If no trailer was +# injected because the session heartbeat was stale, the CLI may use a strict +# HEAD fallback that only records when session file evidence matches HEAD. GIT_DIR="$(git rev-parse --git-dir 2>/dev/null)" SESSION_ID=$(git log -1 --format='%(trailers:key=${TRAILER_KEY},valueonly)' HEAD 2>/dev/null | tr -d '\\n') -if [ -z "$SESSION_ID" ]; then exit 0; fi +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" + else + exit 0 + fi + rm -f "$FALLBACK_FILE" 2>/dev/null || true +fi # Prefer the repo-local shim created at init time so post-commit uses the # exact CLI version that generated these hooks. if [ -x "$GIT_DIR/agentnote/bin/agent-note" ]; then diff --git a/packages/cli/src/commands/record.ts b/packages/cli/src/commands/record.ts new file mode 100644 index 00000000..4d64a556 --- /dev/null +++ b/packages/cli/src/commands/record.ts @@ -0,0 +1,94 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { 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 { git } from "../git.js"; +import { agentnoteDir } from "../paths.js"; + +const FALLBACK_HEAD_FLAG = "--fallback-head"; +const SESSION_ID_SEGMENT_RE = /^[A-Za-z0-9._-]+$/; +const RAW_DIFF_STATUS_RE = /^:\d+ \d+ [0-9a-f]+ ([0-9a-f]+) ([A-Z][0-9]*)$/; +const RAW_DIFF_RENAME_OR_COPY_PREFIXES = ["R", "C"] as const; + +/** Record an Agent Note entry for HEAD from post-commit hook inputs. */ +export async function record(args: string[]): Promise { + try { + if (args[0] === FALLBACK_HEAD_FLAG) { + await recordHeadFallback(); + return; + } + + const sessionId = args[0]; + if (!sessionId) return; + await recordCommitEntry({ agentnoteDirPath: await agentnoteDir(), sessionId }); + } catch { + // Never break git commit hooks. + } +} + +/** Strictly recover a missing trailer when session evidence matches HEAD. */ +export async function recordHeadFallback(): Promise { + if (await readHeadTrailerSessionId()) return; + + const agentnoteDirPath = await agentnoteDir(); + const sessionId = await readActiveSessionId(agentnoteDirPath); + if (!sessionId) return; + + const sessionDir = join(agentnoteDirPath, SESSIONS_DIR, sessionId); + if (!(await hasRecordableSessionData(sessionDir))) return; + + const headBlobs = await readHeadCommittedBlobs(); + if (!(await hasSessionHeadBlobEvidence(sessionDir, headBlobs))) return; + + await recordCommitEntry({ + agentnoteDirPath, + sessionId, + requireAiFileEvidence: true, + }); +} + +async function readActiveSessionId(agentnoteDirPath: string): Promise { + const activeSessionPath = join(agentnoteDirPath, SESSION_FILE); + if (!existsSync(activeSessionPath)) return null; + const sessionId = (await readFile(activeSessionPath, TEXT_ENCODING)).trim(); + if (sessionId === "." || sessionId === "..") return null; + return SESSION_ID_SEGMENT_RE.test(sessionId) ? sessionId : null; +} + +async function readHeadCommittedBlobs(): Promise> { + const raw = await git(["diff-tree", "-z", "--raw", "--root", "--no-commit-id", "-r", "HEAD"]); + return parseCommittedBlobs(raw); +} + +async function readHeadTrailerSessionId(): Promise { + return ( + await git(["log", "-1", `--format=%(trailers:key=${TRAILER_KEY},valueonly)`, "HEAD"]) + ).trim(); +} + +function parseCommittedBlobs(output: string): Map { + const blobs = new Map(); + const fields = output.split("\0"); + + for (let index = 0; index < fields.length; ) { + const metadata = fields[index++]; + if (!metadata) continue; + + const match = metadata.match(RAW_DIFF_STATUS_RE); + if (!match) continue; + + const [, blob, status] = match; + const pathCount = RAW_DIFF_RENAME_OR_COPY_PREFIXES.some((prefix) => status.startsWith(prefix)) + ? 2 + : 1; + let path = ""; + for (let pathIndex = 0; pathIndex < pathCount && index < fields.length; pathIndex++) { + path = fields[index++] ?? ""; + } + if (path) blobs.set(path, blob); + } + + return blobs; +} diff --git a/packages/cli/src/commands/status.test.ts b/packages/cli/src/commands/status.test.ts index 1bb29e07..70a76842 100644 --- a/packages/cli/src/commands/status.test.ts +++ b/packages/cli/src/commands/status.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; -import { execSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync, execSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { after, before, describe, it } from "node:test"; @@ -9,6 +9,8 @@ import { AGENTNOTE_DIR, HEARTBEAT_FILE, SESSION_FILE } from "../core/constants.j describe("agentnote status", () => { let testDir: string; const cliPath = join(process.cwd(), "dist", "cli.js"); + const runCli = (args: string[], cwd = testDir): string => + execFileSync("node", [cliPath, ...args], { cwd, encoding: "utf-8" }); before(() => { testDir = mkdtempSync(join(tmpdir(), "agentnote-status-")); @@ -23,10 +25,7 @@ describe("agentnote status", () => { }); it("shows 'not configured' before start", () => { - const output = execSync(`node ${cliPath} status`, { - cwd: testDir, - encoding: "utf-8", - }); + const output = runCli(["status"]); assert.ok(output.includes("not configured"), "should show not configured"); assert.ok(output.includes("commit: not configured"), "should show commit not configured"); @@ -34,12 +33,9 @@ describe("agentnote status", () => { }); it("shows 'active' after start", () => { - execSync(`node ${cliPath} init --agent cursor --hooks --no-git-hooks`, { cwd: testDir }); + runCli(["init", "--agent", "cursor", "--hooks", "--no-git-hooks"]); - const output = execSync(`node ${cliPath} status`, { - cwd: testDir, - encoding: "utf-8", - }); + const output = runCli(["status"]); assert.ok(output.includes("agent: active"), "should show agent hooks active"); assert.ok( @@ -51,12 +47,9 @@ describe("agentnote status", () => { }); it("shows Codex transcript-driven capture details", () => { - execSync(`node ${cliPath} init --agent codex --hooks --no-git-hooks`, { cwd: testDir }); + runCli(["init", "--agent", "codex", "--hooks", "--no-git-hooks"]); - const output = execSync(`node ${cliPath} status`, { - cwd: testDir, - encoding: "utf-8", - }); + const output = runCli(["status"]); assert.ok(output.includes("agent: active"), "should show agent hooks active"); assert.ok( @@ -67,13 +60,88 @@ describe("agentnote status", () => { assert.ok(output.includes("commit: fallback mode"), "should show fallback mode"); }); + it("shows capture details for repo-local legacy hook commands", () => { + const repo = mkdtempSync(join(tmpdir(), "agentnote-status-local-hooks-")); + try { + execSync("git init", { cwd: repo }); + execSync("git config user.email test@test.com", { cwd: repo }); + execSync("git config user.name Test", { cwd: repo }); + execSync("git commit --allow-empty -m 'init'", { cwd: repo }); + + mkdirSync(join(repo, ".codex"), { recursive: true }); + writeFileSync(join(repo, ".codex", "config.toml"), "[features]\ncodex_hooks = true\n"); + writeFileSync( + join(repo, ".codex", "hooks.json"), + `${JSON.stringify({ + hooks: { + UserPromptSubmit: [ + { hooks: [{ command: "node packages/cli/dist/cli.js hook --agent codex" }] }, + ], + Stop: [{ hooks: [{ command: "node packages/cli/dist/cli.js hook --agent codex" }] }], + SessionStart: [ + { hooks: [{ command: "node packages/cli/dist/cli.js hook --agent codex" }] }, + ], + }, + })}\n`, + ); + + mkdirSync(join(repo, ".cursor"), { recursive: true }); + writeFileSync( + join(repo, ".cursor", "hooks.json"), + `${JSON.stringify({ + version: 1, + hooks: { + beforeSubmitPrompt: [{ command: "node packages/cli/dist/cli.js hook --agent cursor" }], + afterAgentResponse: [{ command: "node packages/cli/dist/cli.js hook --agent cursor" }], + afterFileEdit: [{ command: "node packages/cli/dist/cli.js hook --agent cursor" }], + beforeShellExecution: [ + { command: "node packages/cli/dist/cli.js hook --agent cursor" }, + ], + }, + })}\n`, + ); + + mkdirSync(join(repo, ".gemini"), { recursive: true }); + writeFileSync( + join(repo, ".gemini", "settings.json"), + `${JSON.stringify({ + hooks: { + BeforeAgent: [ + { hooks: [{ command: "node packages/cli/dist/cli.js hook --agent gemini" }] }, + ], + AfterAgent: [ + { hooks: [{ command: "node packages/cli/dist/cli.js hook --agent gemini" }] }, + ], + BeforeTool: [ + { hooks: [{ command: "node packages/cli/dist/cli.js hook --agent gemini" }] }, + ], + }, + })}\n`, + ); + + const output = runCli(["status"], repo); + + assert.ok( + output.includes("codex(prompt, response, transcript)"), + "should show Codex repo-local capture capabilities", + ); + assert.ok( + output.includes("cursor(prompt, response, edits, shell)"), + "should show Cursor repo-local capture capabilities", + ); + assert.ok( + output.includes("gemini(prompt, response, edits, shell)"), + "should show Gemini repo-local capture capabilities", + ); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + it("shows git hooks as the primary commit path when fully configured", () => { - execSync(`node ${cliPath} init --agent cursor --no-action`, { cwd: testDir }); + runCli(["init", "--agent", "cursor", "--no-action"]); - const output = execSync(`node ${cliPath} status`, { - cwd: testDir, - encoding: "utf-8", - }); + const output = runCli(["status"]); assert.ok(output.includes("agent: active"), "should show agent hooks active"); assert.ok( @@ -96,19 +164,13 @@ describe("agentnote status", () => { execSync(`mkdir -p "${sessionDir}"`); writeFileSync(join(sessionDir, HEARTBEAT_FILE), String(Date.now())); - const output = execSync(`node ${cliPath} status`, { - cwd: testDir, - encoding: "utf-8", - }); + const output = runCli(["status"]); assert.ok(output.includes("a1b2c3d4…"), "should show truncated session ID"); }); it("shows linked commit count", () => { - const output = execSync(`node ${cliPath} status`, { - cwd: testDir, - encoding: "utf-8", - }); + const output = runCli(["status"]); assert.ok(output.includes("linked:"), "should show linked count"); }); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 19dfbc05..db7e9fe7 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -1,10 +1,10 @@ import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { isAbsolute, join } from "node:path"; +import { isAgentNoteHookCommand } from "../agents/hook-command.js"; import { getAgent, listAgents } from "../agents/index.js"; import { AGENT_NAMES, type AgentName } from "../agents/types.js"; import { - AGENTNOTE_HOOK_COMMAND, AGENTNOTE_HOOK_MARKER, GIT_HOOK_NAMES, HEARTBEAT_FILE, @@ -206,7 +206,11 @@ async function readCodexCaptureCapabilities(repoRoot: string): Promise const hooks = parsed.hooks ?? {}; const hasAgentnoteHook = (eventName: string): boolean => (hooks[eventName] ?? []).some((group) => - (group.hooks ?? []).some((hook) => hook.command?.includes(AGENTNOTE_HOOK_COMMAND)), + (group.hooks ?? []).some( + (hook) => + typeof hook.command === "string" && + isAgentNoteHookCommand(hook.command, AGENT_NAMES.codex), + ), ); const capabilities: string[] = []; @@ -238,7 +242,11 @@ async function readCursorCaptureCapabilities(repoRoot: string): Promise - (hooks[eventName] ?? []).some((entry) => entry.command?.includes(AGENTNOTE_HOOK_COMMAND)); + (hooks[eventName] ?? []).some( + (entry) => + typeof entry.command === "string" && + isAgentNoteHookCommand(entry.command, AGENT_NAMES.cursor), + ); const capabilities: string[] = []; if (hasAgentnoteHook(CURSOR_STATUS_HOOK_EVENTS.beforeSubmitPrompt)) { @@ -282,7 +290,10 @@ async function readGeminiCaptureCapabilities(repoRoot: string): Promise (hooks[eventName] ?? []).some((group) => - (group.hooks ?? []).some((h) => h.command?.includes(AGENTNOTE_HOOK_COMMAND)), + (group.hooks ?? []).some( + (h) => + typeof h.command === "string" && isAgentNoteHookCommand(h.command, AGENT_NAMES.gemini), + ), ); const capabilities: string[] = []; diff --git a/packages/cli/src/core/constants.ts b/packages/cli/src/core/constants.ts index 1e61d39e..582e2160 100644 --- a/packages/cli/src/core/constants.ts +++ b/packages/cli/src/core/constants.ts @@ -49,6 +49,10 @@ export const SESSION_FILE = "session"; export const SESSION_AGENT_FILE = "agent"; /** Gemini pending commit state file used between BeforeTool and AfterTool. */ export const PENDING_COMMIT_FILE = "pending_commit.json"; +/** One-shot marker allowing post-commit to recover a stale-heartbeat session. */ +export const POST_COMMIT_FALLBACK_FILE = "post_commit_fallback"; +/** Marker value indicating that post-commit may try strict HEAD recovery. */ +export const POST_COMMIT_FALLBACK_HEAD = "head"; // ─── Display limits ─── /** Maximum commits scanned by commands that need bounded history traversal. */ @@ -89,6 +93,8 @@ export const PRE_BLOBS_FILE = "pre_blobs.jsonl"; export const COMMITTED_PAIRS_FILE = "committed_pairs.jsonl"; /** Session files that prove a commit can produce a non-empty git note. */ export const RECORDABLE_SESSION_FILES = [PROMPTS_FILE, CHANGES_FILE, PRE_BLOBS_FILE] as const; +/** Session files that let plain git hooks safely attach a session trailer. */ +export const TRAILER_SESSION_FILES = [CHANGES_FILE, PRE_BLOBS_FILE] as const; // ─── Git ─── /** SHA-1 hash of a git blob with empty content (canonical git empty blob). */ diff --git a/packages/cli/src/core/record.test.ts b/packages/cli/src/core/record.test.ts index e7edfc77..8549df9b 100644 --- a/packages/cli/src/core/record.test.ts +++ b/packages/cli/src/core/record.test.ts @@ -16,7 +16,7 @@ import { TURN_FILE, } from "./constants.js"; import { analyzePromptSelection, toPersistedSelection } from "./prompt-window.js"; -import { recordCommitEntry } from "./record.js"; +import { hasSessionHeadBlobEvidence, recordCommitEntry } from "./record.js"; import { readNote } from "./storage.js"; const SESSION_ID = "a0000000-0000-4000-8000-000000000001"; @@ -871,6 +871,83 @@ describe("prompt task-boundary policy simulation", () => { }); }); +describe("post-commit fallback evidence simulation", () => { + it("requires matching post-edit blob evidence across 100+ generated cases", async () => { + const root = mkdtempSync(join(tmpdir(), "agentnote-fallback-evidence-")); + const agents = ["claude", "codex", "cursor", "gemini"] as const; + const evidenceFiles = [ + CHANGES_FILE, + PRE_BLOBS_FILE, + "changes-m3h8k2n1.jsonl", + "pre_blobs-m3h8k2n1.jsonl", + "none", + ] as const; + const relations = ["match", "unrelated", "empty"] as const; + const noiseModes = ["none", "matching-prompt-only", "unrelated-extra"] as const; + let caseCount = 0; + + try { + for (const agent of agents) { + for (const evidenceFile of evidenceFiles) { + for (const relation of relations) { + for (const noiseMode of noiseModes) { + caseCount++; + const sessionDir = join(root, `${agent}-${caseCount}`); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync( + join(sessionDir, PROMPTS_FILE), + `{"event":"prompt","prompt":"${agent} case ${caseCount}","turn":1}\n`, + ); + + if (evidenceFile !== "none") { + const file = + relation === "match" + ? "src/commit-file.ts" + : relation === "unrelated" + ? "src/other-file.ts" + : ""; + const blob = relation === "match" ? "abc123" : "def456"; + writeFileSync( + join(sessionDir, evidenceFile), + file + ? `{"event":"file_change","tool":"Write","file":"${file}","blob":"${blob}","turn":1}\n` + : '{"event":"file_change","tool":"Write","turn":1}\n', + ); + } + + if (noiseMode === "matching-prompt-only") { + writeFileSync( + join(sessionDir, "prompts-m3h8k2n2.jsonl"), + '{"event":"prompt","prompt":"src/commit-file.ts","turn":2}\n', + ); + } else if (noiseMode === "unrelated-extra") { + writeFileSync( + join(sessionDir, "changes-m3h8k2n3.jsonl"), + '{"event":"file_change","tool":"Write","file":"src/unrelated-extra.ts","turn":3}\n', + ); + } + + const hasEvidence = await hasSessionHeadBlobEvidence( + sessionDir, + new Map([["src/commit-file.ts", "abc123"]]), + ); + assert.equal( + hasEvidence, + evidenceFile.includes("changes") && relation === "match", + `${agent}/${evidenceFile}/${relation}/${noiseMode}`, + ); + } + } + } + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + + assert.ok(caseCount >= 100, "simulation should cover at least 100 fallback cases"); + }); +}); + function setupGitRepo(): { repoDir: string; agentnoteDirPath: string; sessionDir: string } { const repoDir = mkdtempSync(join(tmpdir(), "agentnote-record-")); execSync("git init", { cwd: repoDir }); diff --git a/packages/cli/src/core/record.ts b/packages/cli/src/core/record.ts index 48bf2f75..0c75a845 100644 --- a/packages/cli/src/core/record.ts +++ b/packages/cli/src/core/record.ts @@ -65,6 +65,7 @@ export async function recordCommitEntry(opts: { agentnoteDirPath: string; sessionId: string; transcriptPath?: string; + requireAiFileEvidence?: boolean; }): Promise<{ promptCount: number; aiRatio: number }> { const sessionDir = join(opts.agentnoteDirPath, SESSIONS_DIR, opts.sessionId); const sessionAgent = await readSessionAgent(sessionDir); @@ -80,8 +81,16 @@ export async function recordCommitEntry(opts: { // Get files in THIS specific commit. let commitFiles: string[] = []; try { - const raw = await git(["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"]); - commitFiles = raw.split("\n").filter(Boolean); + const raw = await git([ + "diff-tree", + "-z", + "--root", + "--no-commit-id", + "--name-only", + "-r", + "HEAD", + ]); + commitFiles = raw.split("\0").filter(Boolean); } catch { // empty } @@ -539,7 +548,10 @@ export async function recordCommitEntry(opts: { // triggers post-commit but session data has already been rotated. Writing an // empty note would overwrite valuable data if notes are later copied from the // original SHA. - if (interactions.length === 0 && aiFiles.length === 0) { + if ( + (opts.requireAiFileEvidence && aiFiles.length === 0) || + (interactions.length === 0 && aiFiles.length === 0) + ) { return { promptCount: 0, aiRatio: 0 }; } @@ -580,6 +592,21 @@ export async function recordCommitEntry(opts: { return { promptCount: interactions.length, aiRatio: entry.attribution.ai_ratio }; } +/** Return true when a session post-edit blob survives as a HEAD commit blob. */ +export async function hasSessionHeadBlobEvidence( + sessionDir: string, + committedBlobs: Map, +): Promise { + if (committedBlobs.size === 0) return false; + + const changeEntries = await readAllSessionJsonl(sessionDir, CHANGES_FILE); + return changeEntries.some((entry) => { + const file = typeof entry.file === "string" ? entry.file : ""; + const blob = typeof entry.blob === "string" ? entry.blob : ""; + return file !== "" && blob !== "" && committedBlobs.get(file) === blob; + }); +} + /** * Tag each transcript interaction with the prompt_id of its matching session * prompt. Both lists are chronological and the transcript is a superset of diff --git a/packages/dashboard/workflow/resolve-pages-target.test.mjs b/packages/dashboard/workflow/resolve-pages-target.test.mjs index 474ba310..cc04d967 100644 --- a/packages/dashboard/workflow/resolve-pages-target.test.mjs +++ b/packages/dashboard/workflow/resolve-pages-target.test.mjs @@ -122,7 +122,7 @@ test("resolvePagesTarget blocks when the Pages artifact belongs to another job", jobs: dashboard: steps: - - uses: wasabeef/AgentNote@v0 + - uses: wasabeef/AgentNote@v1 with: dashboard: true build: @@ -147,7 +147,7 @@ test("resolvePagesTarget blocks standalone publish when another workflow owns Pa jobs: build: steps: - - uses: wasabeef/AgentNote@v0 + - uses: wasabeef/AgentNote@v1 with: dashboard: true `, diff --git a/skills/agent-note/SKILL.md b/skills/agent-note/SKILL.md index 6582c050..eb1ad2dc 100644 --- a/skills/agent-note/SKILL.md +++ b/skills/agent-note/SKILL.md @@ -82,7 +82,7 @@ PR Report is normally produced by the GitHub Action generated by When troubleshooting: -1. Check that the workflow uses `wasabeef/AgentNote@v0`. +1. Check that the workflow uses `wasabeef/AgentNote@v1`. 2. Check workflow permissions for Pull Request writes. 3. Check that `refs/notes/agentnote` was pushed and fetched. 4. Check whether the report says "No tracked commits" versus a true `0%` AI diff --git a/website/src/content/docs/dashboard.mdx b/website/src/content/docs/dashboard.mdx index a5d49933..fbda6a0c 100644 --- a/website/src/content/docs/dashboard.mdx +++ b/website/src/content/docs/dashboard.mdx @@ -45,7 +45,7 @@ git push 3. In GitHub, enable Pages for the repository and choose **GitHub Actions** as the source. -The generated Dashboard workflow uses `wasabeef/AgentNote@v0` with `dashboard: true` to restore, sync, build, upload the artifact, and persist notes, then publishes the shared `/dashboard/` view for you. If the same job already uploads a GitHub Pages artifact, Agent Note adds the Dashboard to that artifact's `dashboard/` directory instead of replacing the existing site. If another job or workflow already publishes Pages, Agent Note skips standalone publishing; place Agent Note in the same job before `actions/upload-pages-artifact` to combine a docs site and Dashboard. +The generated Dashboard workflow uses `wasabeef/AgentNote@v1` with `dashboard: true` to restore, sync, build, upload the artifact, and persist notes, then publishes the shared `/dashboard/` view for you. If the same job already uploads a GitHub Pages artifact, Agent Note adds the Dashboard to that artifact's `dashboard/` directory instead of replacing the existing site. If another job or workflow already publishes Pages, Agent Note skips standalone publishing; place Agent Note in the same job before `actions/upload-pages-artifact` to combine a docs site and Dashboard. ## Public URL @@ -74,7 +74,7 @@ A brand-new Repository can accumulate Dashboard note data before the Dashboard i ## How It Works - `refs/notes/agentnote` remains the source of truth -- `wasabeef/AgentNote@v0` in Dashboard Mode writes note snapshots to `gh-pages/dashboard/notes/*.json` +- `wasabeef/AgentNote@v1` in Dashboard Mode writes note snapshots to `gh-pages/dashboard/notes/*.json` - GitHub Pages serves the built Dashboard at `/dashboard/` The Repository does not commit sample JSON for the Dashboard. A new Dashboard stays empty until the workflow restores or generates note files. Pull Request runs publish the Open state, and `default branch` pushes replace it with the Merged state. diff --git a/website/src/content/docs/de/dashboard.mdx b/website/src/content/docs/de/dashboard.mdx index 2a7ef0a7..e47fff6c 100644 --- a/website/src/content/docs/de/dashboard.mdx +++ b/website/src/content/docs/de/dashboard.mdx @@ -45,7 +45,7 @@ git push 3. Wähle in GitHub Pages **GitHub Actions** als Quelle. -Der generierte Dashboard-Workflow verwendet `wasabeef/AgentNote@v0` mit `dashboard: true`, um Wiederherstellung, Sync, Build, Artefakt-Upload und Notes-Persistenz auszuführen, und veröffentlicht danach die gemeinsame Ansicht unter `/dashboard/`. Wenn derselbe Job bereits ein GitHub-Pages-Artefakt hochlädt, fügt Agent Note das Dashboard unter `dashboard/` in dieses Artefakt ein, statt die bestehende Site zu ersetzen. Wenn ein anderer Job oder Workflow Pages bereits veröffentlicht, überspringt Agent Note die Standalone-Veröffentlichung; platziere Agent Note im selben Job vor `actions/upload-pages-artifact`, um docs site und Dashboard zu kombinieren. +Der generierte Dashboard-Workflow verwendet `wasabeef/AgentNote@v1` mit `dashboard: true`, um Wiederherstellung, Sync, Build, Artefakt-Upload und Notes-Persistenz auszuführen, und veröffentlicht danach die gemeinsame Ansicht unter `/dashboard/`. Wenn derselbe Job bereits ein GitHub-Pages-Artefakt hochlädt, fügt Agent Note das Dashboard unter `dashboard/` in dieses Artefakt ein, statt die bestehende Site zu ersetzen. Wenn ein anderer Job oder Workflow Pages bereits veröffentlicht, überspringt Agent Note die Standalone-Veröffentlichung; platziere Agent Note im selben Job vor `actions/upload-pages-artifact`, um docs site und Dashboard zu kombinieren. ## Öffentliche URL @@ -68,7 +68,7 @@ Auch ein neues Repository kann schon vor dem ersten Production-Deploy Dashboard- ## Wie es funktioniert - `refs/notes/agentnote` bleibt die Source of Truth -- `wasabeef/AgentNote@v0` im Dashboard-Modus schreibt Notes-Snapshots nach `gh-pages/dashboard/notes/*.json` +- `wasabeef/AgentNote@v1` im Dashboard-Modus schreibt Notes-Snapshots nach `gh-pages/dashboard/notes/*.json` - GitHub Pages liefert das gebaute Dashboard unter `/dashboard/` aus Das Repository committet keine Beispiel-JSONs für das Dashboard. Ein neues Dashboard bleibt leer, bis der Workflow note files wiederherstellt oder erzeugt. `pull_request` veröffentlicht den Open-Status, und Pushes auf `default branch` ersetzen ihn durch den Merged-Status. diff --git a/website/src/content/docs/de/github-action.mdx b/website/src/content/docs/de/github-action.mdx index 1e7ab911..95f6c762 100644 --- a/website/src/content/docs/de/github-action.mdx +++ b/website/src/content/docs/de/github-action.mdx @@ -33,7 +33,7 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: wasabeef/AgentNote@v0 + - uses: wasabeef/AgentNote@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` diff --git a/website/src/content/docs/de/how-it-works.mdx b/website/src/content/docs/de/how-it-works.mdx index a9bdbf80..f3b08895 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) |