Session History

workflow no always-loaded rules -- loads on demand updated 2026-08-03

Cross-platform session historian agent. Searches prior Claude Code and Codex session transcripts for what was tried, what failed, and what was decided in earlier sessions on the same repo. Invoked by other skills (/compound, /xplan, /debug) to surface institutional knowledge a fresh session cannot see.

Tags

  • session
  • history
  • retrieval
  • agent
  • recall
  • cross-platform
  • claude-code
  • codex

README

Session History

Cross-platform session historian agent. Searches prior Claude Code and Codex session transcripts for related work, failed approaches, and decisions from earlier sessions on the same repo - context that a fresh session cannot see.

Agent logs and project-story.md capture what shipped. This module answers a different question: "when I was debugging this last week, what did I try?"

What This Module Provides

Files installed globally to ~/.claude/:

Source Target Purpose
agents/session-historian.md agents/session-historian.md Retrieval agent, invoked by other skills for deep synthesis
commands/recall.md commands/recall.md /recall slash command (lightweight, user-facing)
scripts/discover-sessions.sh scripts/discover-sessions.sh Enumerate session files across platforms
scripts/extract-metadata.py scripts/extract-metadata.py Batch-extract session metadata (branch, cwd, timestamps)
scripts/recall.py scripts/recall.py /recall implementation — unified session view across clones
scripts/repo_detect.py scripts/repo_detect.py Canonical repo-name detection + multi-clone project-dir matching
scripts/add-agents-md-symlinks.sh scripts/add-agents-md-symlinks.sh Sets up AGENTS.md symlinks so Codex transcripts share the same rule surface as Claude Code

Two consumption patterns:

  1. /recall slash command — fast, deterministic summary / query over the last N days of sessions for the current repo (unified across all clones). Default 7 days. No agent dispatch, no LLM calls.
  2. session-historian agent — heavier synthesis via Agent tool dispatch when you need "what was tried, what failed, what was decided" analysis across platforms (Claude Code + Codex).

Use /recall for quick lookups. Use the agent when you need the history interpreted, not just listed.

Supported Platforms

Platform Session path Correlation signal
Claude Code ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl Git branch + encoded CWD in directory name
Codex ~/.codex/sessions/YYYY/MM/DD/*.jsonl (and ~/.agents/sessions/YYYY/MM/DD/) cwd field in session_meta

Cursor is intentionally out of scope for this module (different transcript format, different correlation signals, and not part of the typical CCGM workflow). If Cursor support is needed later, add a third platform branch to discover-sessions.sh and a detector to extract-metadata.py.

Manual Installation

# From the CCGM repo root:

mkdir -p ~/.claude/agents
mkdir -p ~/.claude/commands
mkdir -p ~/.claude/scripts

cp modules/session-history/agents/session-historian.md \
   ~/.claude/agents/session-historian.md

cp modules/session-history/commands/recall.md \
   ~/.claude/commands/recall.md

cp modules/session-history/scripts/discover-sessions.sh \
   ~/.claude/scripts/discover-sessions.sh
chmod +x ~/.claude/scripts/discover-sessions.sh

cp modules/session-history/scripts/extract-metadata.py \
   ~/.claude/scripts/extract-metadata.py
chmod +x ~/.claude/scripts/extract-metadata.py

cp modules/session-history/scripts/recall.py \
   ~/.claude/scripts/recall.py

cp modules/session-history/scripts/repo_detect.py \
   ~/.claude/scripts/repo_detect.py

cp modules/session-history/scripts/add-agents-md-symlinks.sh \
   ~/.claude/scripts/add-agents-md-symlinks.sh
chmod +x ~/.claude/scripts/add-agents-md-symlinks.sh

Usage

/recall slash command

/recall                     # Last 7 days, current repo, all clones, summary
/recall migration           # Last 7 days, filter turns by "migration"
/recall --days 30 auth      # Custom window + filter
/recall --repo other-repo   # Different repo (canonical name required)
/recall --session 65b57a04  # Dump a specific session
/recall --summary --limit 3 # Top 3 most recent sessions, compact format

--repo takes the canonical repo name as returned by git remote get-url origin — substring matching is NOT supported to avoid false positives (e.g., ccgm matching ccgm-agent-learning).

From another skill or command

Dispatch the agent via the Agent tool. Pass:

  • A one-paragraph task_summary describing the current problem.
  • An optional time_range hint (today, this week, last month, ...).
  • Any platform restriction (claude or codex) if relevant; otherwise the agent searches both.

The agent returns text findings - usually a short header ("Sessions searched: N...") followed by a synthesis of what was tried, what failed, and what was decided in those prior sessions.

Ad-hoc

Ask directly in a session:

Dispatch the session-historian agent. Find out what I tried when I
debugged the Vite CSS preflight issue in my-app this past week.

Scripts directly

The discovery and metadata scripts are usable on their own if you want to inspect session metadata without a full agent dispatch:

# List Claude Code + Codex sessions for this repo from the last 7 days
bash ~/.claude/scripts/discover-sessions.sh my-app 7

# Get metadata for all of them in one pipeline
bash ~/.claude/scripts/discover-sessions.sh my-app 7 \
  | tr '\n' '\0' \
  | xargs -0 python3 ~/.claude/scripts/extract-metadata.py --cwd-filter my-app

Output is one JSON object per session plus a final _meta line with files_processed and parse_errors counts.

Guardrails

The agent enforces these rules at all times:

  • Never reads entire session files (they can be 1-7MB).
  • Never extracts or reproduces tool call inputs/outputs verbatim.
  • Never includes thinking or reasoning block content.
  • Never analyzes the current session - its history is already available to the caller.
  • Never writes files. Text findings only.
  • Fails fast on permission errors rather than retrying with different tools.

Full guardrail list is in agents/session-historian.md.

Dependencies

None. The agent depends only on bash, find, and python3 (all present on macOS/Linux by default) plus the Claude Code / Codex transcript files the user has already been producing.

Non-Goals

This module does not:

  • Wire itself into /xplan, /debug, or /compound. Those integrations are follow-up work (see CCGM issue #276 for compound, and future integration PRs).
  • Index or persist a summary of sessions. It retrieves and synthesizes on demand.
  • Support Cursor (see "Supported Platforms" above).
  • Cross-correlate sessions across different users or machines.

Source

Ported from EveryInc/compound-engineering-plugin's agents/research/session-historian.md and companion session-history-scripts/. The CCGM port drops Cursor (out of scope for the typical CCGM workflow), folds skeleton/error extraction back into the agent itself (using native Read + Grep rather than additional Python scripts - keeping the surface minimal), and uses absolute ~/.claude/scripts/ paths matching CCGM's install convention rather than the plugin-relative paths the original assumes.

Will install

Path Action Target Type
agents/session-historian.md agents/session-historian.md agent
commands/recall.md commands/recall.md command
scripts/discover-sessions.sh scripts/discover-sessions.sh script
scripts/extract-metadata.py scripts/extract-metadata.py script
scripts/recall.py scripts/recall.py script
scripts/repo_detect.py scripts/repo_detect.py script
scripts/add-agents-md-symlinks.sh scripts/add-agents-md-symlinks.sh script

Dependencies

No dependencies.

Required by

Included in presets

Install this module

Agent prompt

Recommended for agent users -- hands the whole install off to your assistant.

Fetch https://cd23a9be.ccgm-site.pages.dev/modules/session-history.md and install this module into my Claude Code setup.

Native plugin marketplace

One command via the native plugin marketplace -- additive, does not merge settings.json.

claude plugin install session-history@ccgm

The marketplace path is additive, not a replacement: it installs commands, agents, and skills as native plugin components, but it does not perform the bash installer's deep settings.json merge, and it does not write the always-loaded global CLAUDE.md context. Rules are only injected via an opt-in SessionStart hook rather than being auto-loaded. Use the bash installer when those pieces matter to you.

Manual, per file

Full control -- copy exactly the files you want from the sections below.

Files

Files

command (1)

commands/recall.md

# /recall - Search session history across all clones of a repo

Run `recall.py` and display its output verbatim:

```bash
python3 ~/.claude/scripts/recall.py $ARGUMENTS
```

## What It Does

`/recall` reads Claude Code's native JSONL transcripts at `~/.claude/projects/**/*.jsonl` and surfaces session history for the current repo, unified across all of its clones (flat-clone and workspace models).

It does NOT maintain a separate index or database — transcripts are the source of truth, read on demand.

## Usage

| Invocation | Behavior |
|-----------|----------|
| `/recall` | Last 7 days, current repo (auto-detected), all clones, summary view |
| `/recall <query>` | Last 7 days, filtered to turns matching `<query>` (case-insensitive regex) |
| `/recall --days N` | Custom time window |
| `/recall --days N <query>` | Custom window + query filter |
| `/recall --repo <name>` | Different repo. Pass the canonical name (e.g. `ccgm`, `my-multi-word-repo`) as returned by `git remote get-url origin` — substring matching is NOT supported |
| `/recall --summary` | Force summary mode even when a query is given |
| `/recall --full <query>` | Do not truncate matched turn content |
| `/recall --limit N` | Maximum sessions/results to display (default 50) |
| `/recall --session <id>` | Dump a specific session's transcript as readable text. Accepts full session id or a unique prefix |

## Examples

```
/recall                       # What have I been doing in this repo this week?
/recall migration             # What did I try with that migration?
/recall --days 30 auth        # Broader lookback on auth work
/recall --repo other-repo     # Switch to another repo's sessions
/recall --session 65b57a04    # Read a specific session
```

## Design

- Reads JSONL directly (no SQLite, no pre-built index). Fast enough for ~4,000-session corpora.
- Unifies across clones by matching `~/.claude/projects/*` dirs whose encoded path ends with the canonical repo name plus a known clone-suffix pattern (`-N`, `-wN`, `-wN-cM`).
- Skips tool_result-only user turns and `<system-reminder>`-wrapped messages from summary extraction.
- Exits 0 when no repo is detected (so dashboard wrappers can gracefully skip the Recent Activity block).
agent (1)

agents/session-historian.md

---
name: session-historian
description: >
  Searches Claude Code and Codex session history for related prior sessions about the same problem or topic. Use to surface investigation context, failed approaches, and decisions from previous sessions that the current session cannot see. Supports time-based queries ("today", "last week", "this month") and correlates by git branch or working directory.
tools: Bash, Glob, Grep, Read
---

You are an expert at extracting institutional knowledge from coding agent session history. Your mission is to find *prior sessions* about the same problem, feature, or topic across Claude Code and Codex, and surface what was learned, tried, and decided - context that the current session cannot see.

This agent serves two modes of use:

- **Compound enrichment** - dispatched by `/compound` to add cross-session context to a learning doc.
- **Conversational** - invoked directly when someone wants to ask about past work, recent activity, or what happened in prior sessions.

## Guardrails

These rules apply at all times during extraction and synthesis.

- **Never read entire session files into context.** Session files can be 1-7MB. Always use the extraction scripts below to filter first, then reason over the filtered output.
- **Never extract or reproduce tool call inputs/outputs verbatim.** Summarize what was attempted and what happened.
- **Never include thinking or reasoning block content.** Claude Code thinking blocks are internal reasoning; Codex reasoning blocks are encrypted. Neither is actionable.
- **Never analyze the current session.** Its conversation history is already available to the caller.
- **Never write any files.** Return text findings only.
- **Surface technical content, not personal content.** Sessions contain everything - credentials, frustration, half-formed opinions. Use judgment about what belongs in a technical summary and what does not.
- **Never substitute other data sources when session files are inaccessible.** If session files cannot be read (permission errors, missing directories), report the limitation and what was attempted. Do not fall back to git history or other sources - that is a different agent's job.
- **Fail fast on access errors.** If the first extraction attempt fails on permissions, report the issue immediately. Do not retry the same operation with different tools or approaches - repeated retries waste tokens without changing the outcome.

## Why this matters

Agent logs and `project-story.md` capture what happened in shipped work. But problems often span multiple sessions across different tools - a developer might investigate in Claude Code and later try an approach in Codex. Each session only sees its own conversation. This agent bridges that gap by searching across session transcripts.

## Time Range

The caller may specify a time range - either explicitly ("last 3 days", "this past week", "last month") or implicitly through context ("what did I work on recently" implies a few days; "how did this feature evolve" implies the full feature branch lifetime).

Infer the time range from the request and map it to a scan window. **Start narrow** - recent sessions on the same branch are almost always sufficient. Only widen if the narrow scan finds nothing relevant and the request warrants it.

| Signal | Scan window |
|--------|-------------|
| "today", "this morning" | 1 day |
| "recently", "last few days", "this week", or no time signal (default) | 7 days |
| "last few weeks", "this month" | 30 days |
| "last few months", broad feature history | 90 days |

**Widen only when needed.** If the initial scan finds related sessions, stop there. If it comes up empty and the request suggests a longer history matters (feature evolution, recurring problem), widen to the next tier and scan again. Do not jump straight to 30 or 90 days - step through the tiers one at a time.

**When widening the time window**, re-run both discovery and metadata extraction with the new `<days>` parameter. The discovery script applies `-mtime` filtering, so files outside the original window are never returned.

## Session Sources

### Claude Code

Sessions stored at `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`, where `<encoded-cwd>` replaces `/` with `-` in the working directory path (e.g., `/Users/alice/Code/my-project` becomes `-Users-alice-Code-my-project`). Claude Code retains session history for ~30 days by default - wider scan tiers (90 days) may find nothing unless retention has been extended.

Key message types:

- `type: "user"` - Human messages. First user message includes `gitBranch` and `cwd` metadata.
- `type: "assistant"` - Claude responses. `content` array contains `thinking`, `text`, and `tool_use` blocks.
- Tool results appear as `type: "user"` messages with `content[].type: "tool_result"`.

### Codex

Sessions stored at `~/.codex/sessions/YYYY/MM/DD/<session-file>.jsonl`, organized by date. Also check `~/.agents/sessions/YYYY/MM/DD/` as Codex may migrate to this location.

Unlike Claude Code, Codex sessions are not organized by project directory. Filter by matching the `cwd` field in `session_meta` against the current working directory.

Key message types:

- `session_meta` - Contains `cwd`, session `id`, `source`, `cli_version`.
- `turn_context` - Contains `cwd`, `model`, `current_date`.
- `event_msg/user_message` - User message text.
- `response_item/message` with `role: "assistant"` - Assistant text in `output_text` blocks.
- `event_msg/exec_command_end` - Command execution results with exit codes.
- Codex does not store git branch in session metadata. Correlation relies on CWD matching and keyword search.

## Extraction Scripts

**Execute scripts by path, not by reading them into context.** The scripts are installed to `~/.claude/scripts/` by the `session-history` module. Do not use the Read tool to load script content and pass it via `python3 -c`.

Scripts:

- `~/.claude/scripts/discover-sessions.sh` - Discovers session files across Claude Code and Codex. Handles directory structures, mtime filtering, and repo-name matching. Usage: `bash ~/.claude/scripts/discover-sessions.sh <repo-name> <days> [--platform claude|codex]`
- `~/.claude/scripts/extract-metadata.py` - Extracts session metadata in batch. Pass `--cwd-filter <repo-name>` to filter Codex sessions at the script level. Usage: `bash ~/.claude/scripts/discover-sessions.sh <repo-name> <days> | tr '\n' '\0' | xargs -0 python3 ~/.claude/scripts/extract-metadata.py --cwd-filter <repo-name>`

The metadata script emits a `_meta` line at the end with `files_processed` and `parse_errors` counts. When `parse_errors > 0`, note in the response that extraction was partial.

## Methodology

### Step 1: Determine scope and discover sessions

**Scope decision.** Two dimensions to resolve before scanning:

- **Project scope**: Default to the current project. Widen to all projects only when the question explicitly asks.
- **Platform scope**: Default to both platforms (Claude Code and Codex). Narrow to a single platform when the question specifies one.

Determine the scan window from the Time Range table above.

**Derive the repo name** using a worktree-safe approach: check `git rev-parse --git-common-dir` first - in a normal checkout it returns `.git` (use `--show-toplevel` to get the repo root), but in a linked worktree it returns the absolute path to the main repo's `.git` directory (use `dirname` on that path to get the repo root). In either case, `basename` the result to get the repo name. Example:

```bash
common=$(git rev-parse --git-common-dir 2>/dev/null)
if [ "$common" = ".git" ]; then
  basename "$(git rev-parse --show-toplevel 2>/dev/null)"
else
  basename "$(dirname "$common")"
fi
```

If the repo name was pre-resolved in the dispatch prompt, use that instead.

**Discover session files** via the discovery script. Run it by path:

```bash
bash ~/.claude/scripts/discover-sessions.sh <repo-name> <days>
```

To restrict to a single platform: `--platform claude|codex`. Pipe the output to the metadata script with `--cwd-filter` to drop Codex sessions from other repos:

```bash
bash ~/.claude/scripts/discover-sessions.sh <repo-name> <days> \
  | tr '\n' '\0' \
  | xargs -0 python3 ~/.claude/scripts/extract-metadata.py --cwd-filter <repo-name>
```

If no files are found, return: "No session history found within the requested time range." If the `_meta` line shows `parse_errors > 0`, note that some sessions could not be parsed.

### Step 2: Identify related sessions

Correlate sessions to the current problem using these signals (in priority order):

1. **Same git branch** (Claude Code) - Sessions on the same branch are almost certainly about the same feature/problem. Strongest signal.
2. **Same CWD** (Codex) - Sessions in the same working directory are likely the same project.
3. **Related branch names** - Branches with overlapping keywords (e.g., `feat/auth-fix` and `feat/auth-refactor`).
4. **Keyword matching** - If the caller provides topic keywords, search session user messages for those terms via Grep.

**Exclude the current session** - its conversation history is already available to the caller.

**Drop sessions outside the scan window before selecting.** A session is within the window if it was active during that period - use `last_ts` (session end) when available, fall back to `ts` (session start). A session that started 10 days ago but ended 2 days ago IS within a 7-day window. Discard sessions where both `ts` and `last_ts` fall before the window start.

From the remaining sessions, select the most relevant (typically 2-5 total across sources). Prefer sessions that are:

- Strongly correlated (same branch or same CWD)
- Substantive (file size > 30KB suggests meaningful work)

### Step 3: Read selected transcripts with surgical precision

For each selected session, read only what is needed to understand the arc:

- **Opening turns** - the first 2-3 user messages establish the topic. Use Read with a small `limit` and `offset: 0`.
- **Closing turns** - the final 2-3 exchanges show the conclusion. Use Read with an `offset` close to the end of the file.

Do not Read the middle of large session files. Use Grep on the file to find specific keyword hits, then Read a narrow window around each hit.

### Step 4: Synthesize findings

Reason over the extracted excerpts. Look for:

- **Investigation journey** - What approaches were tried? What failed and why? What led to the eventual solution?
- **User corrections** - Moments where the user redirected the approach. These reveal what NOT to do and why.
- **Decisions and rationale** - Why one approach was chosen over alternatives.
- **Error patterns** - Recurring errors across sessions that indicate a systemic issue.
- **Evolution across sessions** - How understanding of the problem changed from session to session, potentially across different tools.
- **Cross-tool blind spots** - When findings come from both Claude Code and Codex, look for things the user might not realize from either tool alone (complementary work, duplicated effort, or gaps). Only mention cross-tool observations when they are genuinely informative.
- **Staleness** - Older sessions may reflect conclusions about code that has since changed. When surfacing findings from sessions more than a few days old, consider whether the relevant code has likely moved on. Caveat older findings rather than presenting them with the same confidence as recent ones.

## Output

**If the caller specifies an output format**, use it. The dispatching skill or user knows what structure serves their workflow best. Follow their format instructions and do not add extra sections.

**If no format is specified**, respond in whatever way best answers the question. Include a brief header noting what was searched:

```
**Sessions searched**: [count] ([N] Claude Code, [N] Codex) | [date range]
```

## When to Invoke

The caller decides. Typical invocations:

- By `/compound` during the research phase, to add cross-session context to a learning doc.
- By `/xplan` at the start of planning, to surface prior approaches to a similar problem.
- By `/debug` when the error message or stack trace hints at something previously investigated.
- Manually, when a user asks "what did I try when I debugged this last week?"

This agent is a drop-in. Callers do not need to pre-process or post-process results - just forward the output as context into their own reasoning.
script (5)

scripts/discover-sessions.sh

#!/usr/bin/env bash
# Discover session files across Claude Code and Codex.
#
# Usage: discover-sessions.sh <repo-name> <days> [--platform claude|codex]
#
# Outputs one file path per line. Safe in both bash and zsh (all globs guarded).
# Pass output to extract-metadata.py:
#   bash discover-sessions.sh <repo-name> 7 | tr '\n' '\0' | xargs -0 python3 extract-metadata.py --cwd-filter <repo-name>
#
# Arguments:
#   repo-name  Folder name of the repo (e.g., "my-repo"). Used for directory matching.
#   days       Scan window in days (e.g., 7). Files older than this are skipped.
#   --platform Restrict to a single platform. Omit to search all.

set -euo pipefail

REPO_NAME="${1:?Usage: discover-sessions.sh <repo-name> <days> [--platform claude|codex]}"
DAYS="${2:?Usage: discover-sessions.sh <repo-name> <days> [--platform claude|codex]}"
PLATFORM="all"

# Parse optional --platform flag
shift 2
while [ $# -gt 0 ]; do
    case "$1" in
        --platform) PLATFORM="$2"; shift 2 ;;
        *) shift ;;
    esac
done

# --- Claude Code ---
discover_claude() {
    local base="$HOME/.claude/projects"
    [ -d "$base" ] || return 0

    # Find all project dirs matching repo name (CWD-encoded: / -> -)
    for dir in "$base"/*"$REPO_NAME"*/; do
        [ -d "$dir" ] || continue
        find "$dir" -maxdepth 1 -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null
    done
}

# --- Codex ---
discover_codex() {
    # Codex sessions are not organized by project directory. Discover by mtime
    # across the whole tree, then let extract-metadata.py filter by --cwd-filter.
    for base in "$HOME/.codex/sessions" "$HOME/.agents/sessions"; do
        [ -d "$base" ] || continue
        find "$base" -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null
    done
}

# --- Dispatch ---
case "$PLATFORM" in
    claude)  discover_claude ;;
    codex)   discover_codex ;;
    all)
        discover_claude
        discover_codex
        ;;
    *)
        echo "Unknown platform: $PLATFORM (expected claude|codex|all)" >&2
        exit 1
        ;;
esac

scripts/extract-metadata.py

#!/usr/bin/env python3
"""Extract session metadata from Claude Code and Codex JSONL files.

Batch mode (preferred - one invocation for all files):
  python3 extract-metadata.py /path/to/dir/*.jsonl
  python3 extract-metadata.py file1.jsonl file2.jsonl file3.jsonl

Single-file mode (stdin):
  head -20 <session.jsonl> | python3 extract-metadata.py

Auto-detects platform from the JSONL structure.
Outputs one JSON object per file, one per line.
Includes a final _meta line with processing stats.

Use --cwd-filter <substring> to drop Codex sessions whose cwd does not contain
the substring. Claude Code sessions are already scoped by directory at discovery
time, so the filter is a no-op for them.
"""
import sys
import json
import os

MAX_LINES = 25  # Only need first ~25 lines for metadata
TAIL_BYTES = 16384  # Read last 16KB to find final timestamp past trailing metadata


def try_claude(lines):
    for line in lines:
        try:
            obj = json.loads(line.strip())
            if obj.get("type") == "user" and "gitBranch" in obj:
                return {
                    "platform": "claude",
                    "branch": obj["gitBranch"],
                    "ts": obj.get("timestamp", ""),
                    "session": obj.get("sessionId", ""),
                }
        except (json.JSONDecodeError, KeyError):
            pass
    return None


def try_codex(lines):
    meta = {}
    for line in lines:
        try:
            obj = json.loads(line.strip())
            if obj.get("type") == "session_meta":
                p = obj.get("payload", {})
                meta["platform"] = "codex"
                meta["cwd"] = p.get("cwd", "")
                meta["session"] = p.get("id", "")
                meta["ts"] = p.get("timestamp", obj.get("timestamp", ""))
                meta["source"] = p.get("source", "")
                meta["cli_version"] = p.get("cli_version", "")
            elif obj.get("type") == "turn_context":
                p = obj.get("payload", {})
                meta["model"] = p.get("model", "")
                meta["cwd"] = meta.get("cwd") or p.get("cwd", "")
        except (json.JSONDecodeError, KeyError):
            pass
    return meta if meta else None


def extract_from_lines(lines):
    return try_claude(lines) or try_codex(lines)


def get_last_timestamp(filepath, size):
    """Read the tail of a file to find the last message with a timestamp."""
    try:
        with open(filepath, "rb") as f:
            f.seek(max(0, size - TAIL_BYTES))
            tail = f.read().decode("utf-8", errors="ignore")
            lines = tail.strip().split("\n")
        for line in reversed(lines):
            try:
                obj = json.loads(line.strip())
                if "timestamp" in obj:
                    return obj["timestamp"]
            except (json.JSONDecodeError, KeyError):
                pass
    except (OSError, IOError):
        pass
    return None


def process_file(filepath):
    try:
        size = os.path.getsize(filepath)
        with open(filepath, "r") as f:
            lines = []
            for i, line in enumerate(f):
                if i >= MAX_LINES:
                    break
                lines.append(line)
        result = extract_from_lines(lines)
        if result:
            result["file"] = filepath
            result["size"] = size
            last_ts = get_last_timestamp(filepath, size)
            if last_ts:
                result["last_ts"] = last_ts
            return result, None
        else:
            return None, filepath
    except (OSError, IOError):
        return None, filepath


# Parse arguments: files and optional --cwd-filter <substring>
files = []
cwd_filter = None
args = sys.argv[1:]
i = 0
while i < len(args):
    if args[i] == "--cwd-filter" and i + 1 < len(args):
        cwd_filter = args[i + 1]
        i += 2
    elif not args[i].startswith("-"):
        files.append(args[i])
        i += 1
    else:
        i += 1

if files:
    # Batch mode: process all files
    processed = 0
    parse_errors = 0
    filtered = 0
    for filepath in files:
        if not filepath.endswith(".jsonl"):
            continue
        result, error = process_file(filepath)
        processed += 1
        if result:
            # Apply CWD filter: skip Codex sessions from other repos
            if cwd_filter and result.get("cwd") and cwd_filter not in result["cwd"]:
                filtered += 1
                continue
            print(json.dumps(result))
        elif error:
            parse_errors += 1

    meta = {"_meta": True, "files_processed": processed, "parse_errors": parse_errors}
    if filtered:
        meta["filtered_by_cwd"] = filtered
    print(json.dumps(meta))
else:
    # No file arguments: either single-file stdin mode or empty xargs invocation.
    # When xargs runs us with no input (e.g., discover found no files), stdin is
    # empty or a TTY - emit a clean zero-file result instead of a false parse error.
    if sys.stdin.isatty():
        lines = []
    else:
        lines = list(sys.stdin)

    if not lines:
        # No input at all - zero-file result (clean exit for empty pipelines)
        print(json.dumps({"_meta": True, "files_processed": 0, "parse_errors": 0}))
    else:
        # Genuine single-file stdin mode (backward compatible)
        result = extract_from_lines(lines)
        if result:
            print(json.dumps(result))
        print(json.dumps({"_meta": True, "files_processed": 1, "parse_errors": 0 if result else 1}))

scripts/recall.py

#!/usr/bin/env python3
"""/recall - unified view of Claude Code session history for a repo.

Reads ~/.claude/projects/**/*.jsonl across all clones of the current repo
and renders either a summary list or query-filtered turns.

Default: last 7 days of sessions for the current repo (detected from cwd/git
remote), unified across all clones (flat and workspace models).

Usage:
  recall.py                         # summary, current repo, 7 days
  recall.py <query>                 # query-filtered turns
  recall.py --days 30 [query]       # custom window
  recall.py --repo <name> [query]   # different repo
  recall.py --summary --limit 3     # compact summary, N most-recent sessions
  recall.py --session <id>          # dump a single session as readable text
  recall.py --full <query>          # include full turn content (no truncation)
"""
from __future__ import annotations

import argparse
import json
import re
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator

from repo_detect import clone_label, detect_repo, list_project_dirs

DEFAULT_DAYS = 7
DEFAULT_LIMIT = 50
CONTENT_TRUNCATE = 160


@dataclass
class SessionMeta:
    session_id: str
    path: Path
    project_dir: Path
    clone: str
    repo: str
    mtime: float
    turn_count: int
    first_user_msg: str
    last_user_msg: str
    branch: str


def _iter_jsonl(path: Path) -> Iterator[dict]:
    """Stream a JSONL file, skipping malformed lines."""
    try:
        with path.open("r", errors="replace") as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    yield json.loads(line)
                except json.JSONDecodeError:
                    continue
    except OSError:
        return


def _extract_text(content, include_tool_markers: bool = True) -> str:
    """Convert a Claude Code message content field (string or list of parts)
    to a single plain-text string. When include_tool_markers is False, skip
    tool_use/tool_result entries entirely (used for human-readable summaries)."""
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        parts: list[str] = []
        for item in content:
            if not isinstance(item, dict):
                continue
            t = item.get("type")
            if t == "text":
                parts.append(item.get("text", ""))
            elif include_tool_markers and t == "tool_use":
                parts.append(f"[tool_use: {item.get('name', '?')}]")
            elif include_tool_markers and t == "tool_result":
                parts.append("[tool_result]")
        return " ".join(parts).strip()
    return ""


def _summarize_session(path: Path, repo: str, project_dir: Path) -> SessionMeta | None:
    """Scan a JSONL file to produce a one-line summary."""
    try:
        stat = path.stat()
    except OSError:
        return None

    first_user = ""
    last_user = ""
    turn_count = 0
    branch = ""
    session_id = path.stem

    for obj in _iter_jsonl(path):
        t = obj.get("type")
        if t == "user":
            msg = obj.get("message", {})
            # For summaries, ignore tool_result-only messages (they come wrapped
            # in user-role turns but aren't real user intent).
            text = _extract_text(msg.get("content", ""), include_tool_markers=False)
            # Skip synthetic system-injected messages
            if not text or text.startswith("<") or "<system-reminder>" in text[:200]:
                continue
            turn_count += 1
            if not first_user:
                first_user = text
            last_user = text
            if not branch:
                branch = obj.get("gitBranch", "")
        elif t == "assistant":
            # Count assistant turns toward turn_count too (conversation pairs)
            pass

    if turn_count == 0:
        return None

    return SessionMeta(
        session_id=session_id,
        path=path,
        project_dir=project_dir,
        clone=clone_label(project_dir, repo),
        repo=repo,
        mtime=stat.st_mtime,
        turn_count=turn_count,
        first_user_msg=first_user,
        last_user_msg=last_user,
        branch=branch,
    )


def _find_sessions(repo: str, days: int) -> list[SessionMeta]:
    """Enumerate sessions across all clones of a repo within the last N days."""
    cutoff = time.time() - days * 86400
    project_dirs = list_project_dirs(repo)
    sessions: list[SessionMeta] = []

    for project_dir in project_dirs:
        for jsonl in project_dir.glob("*.jsonl"):
            try:
                if jsonl.stat().st_mtime < cutoff:
                    continue
            except OSError:
                continue
            meta = _summarize_session(jsonl, repo, project_dir)
            if meta:
                sessions.append(meta)

    sessions.sort(key=lambda s: s.mtime, reverse=True)
    return sessions


def _truncate(s: str, n: int = CONTENT_TRUNCATE) -> str:
    s = re.sub(r"\s+", " ", s).strip()
    return s if len(s) <= n else s[: n - 1] + "…"


def _fmt_date(mtime: float) -> str:
    return time.strftime("%Y-%m-%d", time.localtime(mtime))


def _print_summary(sessions: list[SessionMeta], limit: int) -> None:
    if not sessions:
        return
    for s in sessions[:limit]:
        print(
            f"{_fmt_date(s.mtime)}  {s.clone:<14}  {s.session_id[:8]}  "
            f"{s.turn_count:>3} turns  {_truncate(s.last_user_msg, 60)}"
        )


def _print_header(repo: str, days: int, sessions: list[SessionMeta], mode: str) -> None:
    clones = sorted({s.clone for s in sessions})
    suffix = f", {len(clones)} clones" if len(clones) > 1 else ""
    count = len(sessions)
    print(f"Recent activity: {repo} (last {days} days{suffix}, {count} sessions) — {mode}")


def _query_session(meta: SessionMeta, query_re: re.Pattern, full: bool) -> list[str]:
    """Return formatted matching turns from a single session."""
    lines: list[str] = []
    for obj in _iter_jsonl(meta.path):
        t = obj.get("type")
        if t not in ("user", "assistant"):
            continue
        text = _extract_text(obj.get("message", {}).get("content", ""))
        if not text or not query_re.search(text):
            continue
        ts = obj.get("timestamp", "")
        role = t
        body = text if full else _truncate(text, 240)
        lines.append(f"    {role}: {body}")
    if lines:
        header = (
            f"{_fmt_date(meta.mtime)}  {meta.clone}  {meta.session_id[:8]}  "
            f"({meta.turn_count} turns total)"
        )
        return [header, *lines, ""]
    return []


def cmd_dump_session(session_id: str) -> int:
    """Dump a specific session's JSONL as readable text."""
    # Search all project dirs for a matching session
    claude_projects = Path.home() / ".claude" / "projects"
    if not claude_projects.exists():
        print(f"No Claude Code projects directory at {claude_projects}", file=sys.stderr)
        return 1
    for project_dir in claude_projects.iterdir():
        if not project_dir.is_dir():
            continue
        candidate = project_dir / f"{session_id}.jsonl"
        if candidate.exists():
            _dump_full(candidate)
            return 0
        # Also match by prefix if user gave a short id
        matches = list(project_dir.glob(f"{session_id}*.jsonl"))
        if matches:
            _dump_full(matches[0])
            return 0
    print(f"Session '{session_id}' not found", file=sys.stderr)
    return 1


def _dump_full(path: Path) -> None:
    print(f"# Session: {path.stem}")
    print(f"# File: {path}")
    print()
    for obj in _iter_jsonl(path):
        t = obj.get("type")
        if t in ("user", "assistant"):
            ts = obj.get("timestamp", "")
            text = _extract_text(obj.get("message", {}).get("content", ""))
            if text:
                print(f"[{ts}] {t}:")
                print(text)
                print()


def main() -> int:
    p = argparse.ArgumentParser(
        prog="recall",
        description="Search Claude Code session history across all clones of a repo.",
    )
    p.add_argument("query", nargs="?", help="substring/regex to search (default: summary mode)")
    p.add_argument("--days", type=int, default=DEFAULT_DAYS, help=f"window in days (default {DEFAULT_DAYS})")
    p.add_argument("--repo", help="canonical repo name (full name from `git remote`, not a substring)")
    p.add_argument("--dir", dest="project_dir", help="override: specific ~/.claude/projects/ subdir")
    p.add_argument("--summary", action="store_true", help="summary mode (one line per session)")
    p.add_argument("--full", action="store_true", help="do not truncate matched turn content")
    p.add_argument("--limit", type=int, default=DEFAULT_LIMIT, help=f"max sessions to show (default {DEFAULT_LIMIT})")
    p.add_argument("--session", help="dump a specific session by id (full or prefix)")
    args = p.parse_args()

    if args.session:
        return cmd_dump_session(args.session)

    repo = args.repo or detect_repo()
    if not repo:
        # Graceful: no repo detected, exit 0 with a note so dashboard wrappers
        # can skip the Recent Activity block.
        print("(no repo detected from cwd)", file=sys.stderr)
        return 0

    sessions = _find_sessions(repo, args.days)

    if not sessions:
        print(f"No sessions found for {repo} in the last {args.days} days.")
        return 0

    # Summary mode: default when no query is given, or explicitly requested.
    if args.summary or not args.query:
        mode = "summary"
        _print_header(repo, args.days, sessions, mode)
        _print_summary(sessions, args.limit)
        return 0

    # Query mode
    try:
        query_re = re.compile(args.query, re.IGNORECASE)
    except re.error:
        query_re = re.compile(re.escape(args.query), re.IGNORECASE)

    _print_header(repo, args.days, sessions, f"query: {args.query}")
    any_hits = False
    for s in sessions[: args.limit]:
        block = _query_session(s, query_re, args.full)
        if block:
            any_hits = True
            for line in block:
                print(line)
    if not any_hits:
        print(f"No turns matched '{args.query}' in the last {args.days} days.")
    return 0


if __name__ == "__main__":
    sys.exit(main())

scripts/repo_detect.py

#!/usr/bin/env python3
"""Detect the canonical repo name from a cwd, and list Claude Code project
directories for all clones of that repo.

Used by /recall to unify session transcripts across clones.
"""
import os
import re
import subprocess
from pathlib import Path

CLAUDE_PROJECTS = Path.home() / ".claude" / "projects"

# Regex matches a clone suffix on a path basename:
#   flat clone:     "myrepo-0", "myrepo-1", "my-multi-word-repo-0"
#   workspace:      "myrepo-w0", "my-multi-word-repo-w1"
#   workspace+clone: "myrepo-w0-c2", "my-multi-word-repo-w1-c3"
CLONE_SUFFIX = re.compile(r"-(?:w\d+(?:-c\d+)?|\d+)$")


def detect_repo(cwd: str | None = None) -> str | None:
    """Return the canonical repo name for the given cwd (defaults to os.getcwd()).

    Strategy:
    1. Prefer `git remote get-url origin` and parse the repo name from it.
    2. Fall back to the cwd basename with any clone-suffix regex stripped.
    3. Return None if cwd is not inside any git repo and the basename heuristic
       does not strip anything (i.e., we do not know this is a repo).
    """
    cwd = cwd or os.getcwd()
    cwd_path = Path(cwd).resolve()

    # Try git remote first
    try:
        out = subprocess.run(
            ["git", "-C", str(cwd_path), "remote", "get-url", "origin"],
            capture_output=True,
            text=True,
            timeout=2,
        )
        if out.returncode == 0:
            url = out.stdout.strip()
            # Handle git@github.com:user/repo.git and https://github.com/user/repo.git
            name = url.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git")
            if name:
                return name
    except (subprocess.TimeoutExpired, FileNotFoundError):
        pass

    # Fallback: strip clone-suffix from cwd basename
    basename = cwd_path.name
    stripped = CLONE_SUFFIX.sub("", basename)
    if stripped != basename:
        return stripped

    # No confident detection
    return None


def list_project_dirs(repo: str) -> list[Path]:
    """Return all Claude Code project directories that represent a clone of
    the named repo.

    Claude Code encodes cwd paths by replacing '/' with '-' in the project-dir
    name. Example: /home/alice/code/myrepo/myrepo-1 becomes
    -home-alice-code-myrepo-myrepo-1. Decoding is ambiguous because literal
    '-' chars collide with path-separator encoding, so we match on the TAIL of
    the encoded name with a strict regex instead of decoding.

    A project dir matches if its encoded name ends with one of:
      -{repo}                    (non-multi-clone, e.g. -home-alice-code-myrepo)
      -{repo}-\\d+                (flat clone: myrepo-0, myrepo-1, ...)
      -{repo}-w\\d+               (workspace root: myrepo-w0)
      -{repo}-w\\d+-c\\d+          (workspace clone: myrepo-w0-c2)
    """
    if not CLAUDE_PROJECTS.exists():
        return []

    tail_re = re.compile(
        rf"-{re.escape(repo)}(?:-(?:w\d+(?:-c\d+)?|\d+))?$"
    )

    matches: list[Path] = []
    for child in sorted(CLAUDE_PROJECTS.iterdir()):
        if not child.is_dir():
            continue
        if tail_re.search(child.name):
            matches.append(child)
    return matches


def clone_label(project_dir: Path, repo: str) -> str:
    """Short label identifying which clone this project dir represents.

    Example mappings (based on trailing encoded-path segment):
      repo='ccgm', project_dir=-home-alice-code-ccgm-repos-ccgm-1   → 'ccgm-1'
      repo='ccgm', -home-alice-code-ccgm-workspaces-ccgm-w0-c2      → 'ccgm-w0-c2'
      repo='ccgm', -home-alice-code-ccgm                            → 'ccgm'

    Requires the canonical repo name to disambiguate (since encoded path names
    cannot be uniquely reversed when repo names contain hyphens).
    """
    name = project_dir.name
    pattern = re.compile(
        rf"-{re.escape(repo)}(-(?:w\d+(?:-c\d+)?|\d+))?$"
    )
    m = pattern.search(name)
    if m:
        return f"{repo}{m.group(1) or ''}"
    return name


if __name__ == "__main__":
    import sys

    if len(sys.argv) > 1 and sys.argv[1] == "--list":
        repo = sys.argv[2] if len(sys.argv) > 2 else detect_repo()
        if not repo:
            print("repo_detect: could not detect repo from cwd", file=sys.stderr)
            sys.exit(1)
        for p in list_project_dirs(repo):
            print(f"{clone_label(p, repo)}\t{p}")
    else:
        repo = detect_repo()
        if repo:
            print(repo)
        else:
            print("(none)", file=sys.stderr)
            sys.exit(1)

scripts/add-agents-md-symlinks.sh

#!/usr/bin/env bash
# add-agents-md-symlinks.sh
# Create AGENTS.md -> CLAUDE.md symlinks in a list of repos.
#
# The emerging AGENTS.md convention (2026) lets multiple agentic coding tools
# read project instructions from a single file. CCGM repos already have
# CLAUDE.md; symlinking AGENTS.md -> CLAUDE.md future-proofs them without
# duplicating content.
#
# Behavior:
# - Accepts a list of repo paths as arguments. If none provided, auto-discovers
#   candidate repos under $CODE_DIR (default $HOME/code): any immediate
#   subdirectory containing a CLAUDE.md, plus the first clone of common
#   multi-clone layouts (-repos/-N and -workspaces/-wX/-wX-cY).
# - Skips any path that is not a directory containing CLAUDE.md.
# - Skips any path where AGENTS.md already exists as a non-symlink (warns).
# - If AGENTS.md is already a symlink, leaves it alone.
# - Otherwise creates a relative symlink AGENTS.md -> CLAUDE.md.

set -u

CODE_DIR="${CODE_DIR:-$HOME/code}"

# Discover repos under $CODE_DIR that have a CLAUDE.md. Pass explicit paths as
# arguments to override discovery. No personal repo names are hardcoded here.
discover_targets() {
  [ -d "$CODE_DIR" ] || return 0
  # Single-clone repos: $CODE_DIR/<repo>/CLAUDE.md
  for d in "$CODE_DIR"/*/; do
    [ -f "${d}CLAUDE.md" ] && printf '%s\n' "${d%/}"
  done
  # Flat-clone repos: $CODE_DIR/<repo>-repos/<repo>-0/CLAUDE.md
  for d in "$CODE_DIR"/*-repos/*-0/; do
    [ -f "${d}CLAUDE.md" ] && printf '%s\n' "${d%/}"
  done
  # Workspace repos: $CODE_DIR/<repo>-workspaces/<repo>-w0/<repo>-w0-c0/CLAUDE.md
  for d in "$CODE_DIR"/*-workspaces/*-w0/*-w0-c0/; do
    [ -f "${d}CLAUDE.md" ] && printf '%s\n' "${d%/}"
  done
}

if [ "$#" -gt 0 ]; then
  TARGETS=("$@")
else
  TARGETS=()
  while IFS= read -r line; do
    [ -n "$line" ] && TARGETS+=("$line")
  done < <(discover_targets)
fi

CREATED=0
EXISTING_SYMLINK=0
BLOCKED_BY_REGULAR_FILE=0
SKIPPED_NO_CLAUDE_MD=0
SKIPPED_NOT_A_DIR=0

for target in "${TARGETS[@]}"; do
  if [ ! -d "$target" ]; then
    echo "skip: $target (not a directory)"
    SKIPPED_NOT_A_DIR=$((SKIPPED_NOT_A_DIR + 1))
    continue
  fi

  if [ ! -f "$target/CLAUDE.md" ]; then
    echo "skip: $target (no CLAUDE.md)"
    SKIPPED_NO_CLAUDE_MD=$((SKIPPED_NO_CLAUDE_MD + 1))
    continue
  fi

  agents_path="$target/AGENTS.md"

  if [ -L "$agents_path" ]; then
    echo "ok:   $agents_path (already a symlink)"
    EXISTING_SYMLINK=$((EXISTING_SYMLINK + 1))
    continue
  fi

  if [ -e "$agents_path" ]; then
    echo "warn: $agents_path exists as a regular file — leaving it alone"
    BLOCKED_BY_REGULAR_FILE=$((BLOCKED_BY_REGULAR_FILE + 1))
    continue
  fi

  ( cd "$target" && ln -s CLAUDE.md AGENTS.md )
  echo "new:  $agents_path -> CLAUDE.md"
  CREATED=$((CREATED + 1))
done

echo ""
echo "Summary:"
echo "  new symlinks created:      $CREATED"
echo "  existing symlinks kept:    $EXISTING_SYMLINK"
echo "  blocked (regular file):    $BLOCKED_BY_REGULAR_FILE"
echo "  skipped (no CLAUDE.md):    $SKIPPED_NO_CLAUDE_MD"
echo "  skipped (not a directory): $SKIPPED_NOT_A_DIR"