AI sessions
How madelog reads your AI tools' own logs, which tools it supports and how well, how the lines a model wrote become attributed lines in a commit, what is never captured, and how to keep a session off the page.
madelog reads the logs the AI tools on your machine already keep. There is nothing to install, nothing to configure and no second tool in the loop: after a commit, the post-commit hook looks at the sessions that ran since the previous commit, compares the exact lines those sessions wrote with the lines the commit added, and records what actually matched.
That comparison is the whole point. A number like "7 of 8 lines in src/reconcile.rs came from the model" is only worth putting in front of a company if it can be checked, and the only thing that can be checked is bytes. madelog never asks a tool how much it helped.
Opt in
madelog init asks once:
Record the AI sessions behind your commits? [Y/n] yAn empty answer means yes. The answer is stored in the intent event as record_ai_sessions, and the hook reads the newest intent before it looks at anything. madelog init --no-record-ai-sessions answers no without asking; madelog init --record-ai-sessions answers yes. Change your mind later by running madelog init --record-ai-sessions again: it records a new intent, and the hook follows the newest one.
With it off, nothing under ~/.claude, ~/.codex or Cursor's storage is opened at all.
Which tools, and how well
| Tool | What madelog reads | Prompt | Written lines | Attribution |
|---|---|---|---|---|
| Claude Code | ~/.claude/history.jsonl, ~/.claude/projects/<dir>/<session>.jsonl and its subagents/** | yes, the first one | yes, verbatim | per file, line by line |
| Codex CLI | ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl | yes, the first one | yes, verbatim | per file, line by line |
| Cursor | workspaceStorage/*/workspace.json, globalStorage/state.vscdb | no | no | none; the session is recorded as having happened |
| GitHub Copilot | detected only | no | no | none |
Claude Code and Codex are the two tools that write the model's exact output to disk, so they are the two that can be attributed. Cursor 3.20 keeps conversation bodies encrypted or server-side — the composerData rows on disk are shells with an empty conversationMap and an empty text — so a Cursor session on the page says that it happened, with its model and its times, and claims nothing else. Copilot's two stores (VS Code's chatSessions and the CLI's ~/.copilot/session-state) have no published schema that madelog has been able to verify against a real install, so madelog reports that Copilot is there and stops.
madelog status, madelog doctor and madelog onboard all print the same four lines:
ai tools claude-code transcripts found in /Users/ana/.claude/projects, 1 session touches this repo
codex no rollouts in ~/.codex, so nothing to read
cursor Cursor keeps conversation bodies encrypted; madelog records that a session happened, 1 session touches this repo
copilot not installed
ai sessions recordingThe session count looks back ninety days and reads only the tool's own index — history.jsonl for Claude Code, the first line of each rollout for Codex, one SQLite query for Cursor — so madelog status stays instant.
How a session is found
Claude Code encodes the working directory into the directory name under ~/.claude/projects, and that encoding is lossy: CODING PROJECTS and CODING-PROJECTS collapse to the same string. madelog never reverses it. It reads ~/.claude/history.jsonl, which carries the real unencoded path in project, takes the session ids whose path is this repository, and then finds each transcript by globbing for <session id>.jsonl and confirming the cwd field inside the records.
Three details follow from that:
- Subagents count. Everything under
<session id>/subagents/**/*.jsonlis read and attributed to the parent session, so work a subagent did is not lost and does not turn into a second session on the page. - Worktrees fold back. A session run in a git worktree names the worktree, not the repository. madelog looks for a
worktree-staterecord whoseoriginalCwdis this repository and folds those sessions in. The hook does this; the session count inmadelog statusdoes not, because folding means peeking into the first records of every other project's sessions and a count is not worth the wait. A worktree session is recorded, and shows up one short in that one number. - Nothing is loaded whole. Transcripts here run to 17 MB each and 1.2 GB in total, so every file is streamed a line at a time and a line that will not parse is skipped rather than fatal.
Codex needs none of this: line 0 of every rollout is a session_meta record carrying the real cwd, so enumeration is one short read per file.
Which sessions are looked at
A session is never chosen by when you last typed in it. A long session keeps writing for hours after its last prompt — subagents run on, and you are not at the keyboard while they do — so choosing by prompt time loses the whole session, and with it every line it wrote.
Instead: a session counts when it had started by the end of the window and at
least one of its files was written at or after the start of it. That is
exactly the condition for it to be able to hold a write inside the window, and
it is one stat per file to check. A transcript last written before the
window opened is never opened at all, which is what keeps the hook fast when
one session's files run to a hundred megabytes.
Each write is then kept or dropped on its own record's timestamp, so a session spanning four commits gives each commit only the lines it wrote for that one.
The window
The hook looks at sessions between the previous commit and now:
- the window starts when the previous commit was made — its
committed_at, not when madelog happened to record it; - never more than 24 hours back, so the first commit after
madelog initbackfilled two years of history does not sweep up everything you have ever asked a model; - never less than 5 minutes before this commit was made, so two commits a minute apart still both see the session that produced them.
madelog backfill attaches no sessions at all. A commit from March did not come out of a session that ran this afternoon, and the logs of the session that did produce it are long gone. The command says so:
recorded 6 commits, oldest first
from ff8a9fd to cd2619d
no AI sessions were attached: the logs of a session that ran months ago are long gone, and guessing at one would be worse than leaving it outHow lines are attributed
For each file a session wrote and this commit carries:
ai_lines— lines the commit added that the session wrote verbatim. Counted with multiplicity and capped by how often each side has the line, so a session that wrote one}cannot claim the twelve the commit added.ai_lines_modified_by_human— lines the session wrote that the committed file does not contain. The model proposed them; you changed them before committing.accepted— whether anything the session wrote survived into the commit at all.
Blank and whitespace-only lines never count for anyone. They are identical in every file ever written, and attributing them would inflate every number on the page.
A session that wrote files but landed none of them in this commit is not attached to this commit — it is recorded against the commit that does carry its work. A session that wrote no files at all (a conversation, or a Cursor session) is recorded as having happened.
Here is the real event for a Claude Code session that wrote src/reconcile.rs and added one line to src/main.rs:
{
"seq": 10,
"ts": "2026-09-18T12:36:14.820Z",
"kind": "ai_session",
"commit_sha": "9a1246f17ed3b776cfae898d8ebc1ce2b3e874d2",
"payload": {
"accepted": true,
"files": [
{ "ai_lines": 1, "ai_lines_modified_by_human": 0, "path": "src/main.rs" },
{ "ai_lines": 7, "ai_lines_modified_by_human": 0, "path": "src/reconcile.rs" }
],
"model": "claude-sonnet-4-5-20250929",
"prompt": "match bank rows to invoices by amount and reference",
"response_excerpt": null,
"session_id": "5f2c8a41-9d0e-47b6-bb3a-1c4e7a90d2f8",
"tool": "claude-code"
}
}And the Cursor session that ran in the same window:
{
"seq": 9,
"kind": "ai_session",
"payload": {
"accepted": false,
"files": [],
"model": "claude-4.5-sonnet",
"prompt": null,
"response_excerpt": null,
"session_id": "cmp-9f31-4a02-8c77-2d5b",
"tool": "cursor"
}
}What is captured, exactly
The first real prompt. For Claude Code it comes from history.jsonl, which already holds every prompt with its session id — so a sentence never costs a thirteen-megabyte read. Slash commands are skipped: /model fable is something you told the tool, not something you asked it to build, and it does not count towards the prompt count either. (A pasted path is not a slash command: its first token has another slash in it.) Where history.jsonl has nothing, the transcript is used instead: the first user record that is not a subagent's, not an injected system reminder (isMeta), not a compaction summary (isCompactSummary) and whose message.content is a plain string. For Codex: the first UserMessage item. Whitespace is collapsed to one line and the result is cut to 500 characters.
The model. For Claude Code, the model that appears most often across the session's assistant records. For Codex, the model of the turn context. For Cursor, modelConfig.modelName.
The times. The first and last record in the session.
The written lines. For Claude Code, the applied outcome in the top-level toolUseResult: a Write contributes its content, an Edit contributes its newString together with the + lines of its structuredPatch, counted once rather than twice. For Codex, FileChange entries: add contributes the whole content, update contributes the + lines of the unified_diff.
What is never captured
Tool output. Bash stdout and stderr are in the transcript and are never read. Neither is the originalFile field, which holds the file's contents before an edit.
Failed writes. A tool result marked is_error, or a toolUseResult that is a plain string starting Error:, means nothing landed. Nothing is recorded.
Anything outside the repository. A write whose path is not inside the working tree is dropped before it reaches an event.
Anything git ignores. A write to a path matched by .gitignore or .git/info/exclude is dropped. A session that wrote .env leaves no trace in the log.
Anything privacy.redact matches. A prompt matching one of your regexes is dropped whole rather than masked — a masked prompt still shows its shape. A write whose path matches is dropped too. See Privacy.
The model's replies. response_excerpt is in the schema and is always null. What the model said has no bearing on what the commit contains.
Writes done through a shell. A heredoc, a > redirect, sed -i or tee leaves no structured record in any tool. Those lines count as yours. madelog accepts the gap rather than parsing command lines and guessing.
What the page shows
The ratio section adds up ai_lines and ai_lines_modified_by_human across every session:
- AI-proposed — every line a session wrote that reached a commit.
- Accepted unchanged — lines committed exactly as written.
- Reworked by hand — lines the model wrote that you changed before committing.
- Written by you — every other added line in the project.
With no sessions recorded, the section says "No AI attribution was recorded for this project." rather than showing zeros.
Where the logs are
| Tool | Path | Override for testing |
|---|---|---|
| Claude Code | ~/.claude | MADELOG_CLAUDE_HOME, or MADELOG_CLAUDE_PROJECTS_DIR for the transcripts alone |
| Codex CLI | ~/.codex | MADELOG_CODEX_HOME |
| Cursor | ~/Library/Application Support/Cursor/User on macOS, ~/.config/Cursor/User on Linux, %APPDATA%\Cursor\User on Windows | MADELOG_CURSOR_HOME |
| Copilot CLI | ~/.copilot/session-state | MADELOG_COPILOT_HOME |
Cursor's database is live, so madelog copies state.vscdb together with its -wal and -shm companions into a temporary directory and opens the copy read-only. Cursor's own file is never opened for writing and never locked.
Hiding a session
A session you do not want on the page stays in the chain and is withheld from the page. The page shows a dashed point on the timeline, "AI session hidden by the author" in the record list, and "1 session hidden by the author before publishing" in the badge. Hiding is itself a recorded event, so the chain stays whole.
Find the event id and hide it:
$ madelog log --json | grep -B4 '"kind": "ai_session"' | grep '"id"'
"id": "01a0b381-e2df-7002-92b7-c2716ed68011",
$ madelog hide 01a0b381-e2df-7002-92b7-c2716ed68011
hidden. The session stays in the chain; the page will say one session is hidden.Or press h on the session in the preview that madelog publish opens; the same line is printed after the preview closes. Only ai_session events can be hidden:
$ madelog hide 01a0b381-e273-7473-93a7-566c69a0eb6d
madelog: only ai_session events can be hidden; event `01a0b381-e273-7473-93a7-566c69a0eb6d` is a commit
$ madelog hide 0192c6f2-4a1e-7b3c-9d0e-000000000000
madelog: no event with id `0192c6f2-4a1e-7b3c-9d0e-000000000000` in this logHiding the same session twice records nothing the second time: already hidden. Nothing was recorded; the page already leaves this session out., exit 0. The page counts one hidden session either way. There is no unhide in this version: a hidden session stays hidden on every later publish.
When nothing is recorded
The hook is silent about AI. A machine with no AI tool on it is not a broken machine, and a commit you wrote by hand is not a commit with something missing. If you expected a session and did not get one, madelog status says why in one line:
ai sessions recording
ai sessions not recording (you said no at `madelog init`; `madelog init --record-ai-sessions` turns it on)
ai sessions recording (no sessions have touched this repository yet)
ai sessions recording, but no AI tool on this machine keeps a log madelog can readmadelog capture answers the rest. It replays exactly what the hook did for a commit and prints the sessions, the window and the per-file attribution, without recording anything:
$ madelog capture --commit 55e37d5
commit 55e37d5 feat(cli): own AI-session capture …
window 2026-09-18T11:53:37Z to 2026-09-18T12:48:43Z
found 1 session in that window
dry run nothing was recorded
claude-code f6a75ad1-60d0-4dbd-a43d-417a147b0171 claude-opus-5
ran 2026-09-17T20:23:26Z to 2026-09-18T13:02:08Z
wrote 14 files in this window
would be recorded, accepted = true
cli/src/capture/mod.rs 478 ai, 7 reworkedWhen the answer is that the hook simply missed them — an older madelog, or tool logs that were out of reach at the time — madelog capture --record appends what the dry run shows, for a commit that has none. madelog capture --record --all does it for every commit in the log that is still unanswered. Neither ever changes a commit that already has sessions: the chain is append-only, and what is in it stays as it is.
The usual reasons for an empty result when everything is configured: the session wrote nothing at all in the window; the lines were written through a shell rather than through an edit tool; the file is ignored by git; the work landed in a different commit, which is where the session was recorded instead. madelog capture names the last one directly — it lists the files a session wrote that this commit does not carry.