From d010af199cee06e8feb68a7de2bc538349e8fcc1 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Wed, 20 May 2026 22:33:41 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat(skill):=20add=20ccx-fold=20=E2=80=94?= =?UTF-8?q?=20session=20decision=20extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold a human-agent coding session into auditable decisions and durable agent knowledge. Two outputs: fold.html (human review surface in temp dir) and .ccx/knowledge/ entries (structured markdown for agents). The skill detects decision provenance (human / agent / joint / correction), applies a three-gate quality bar before KB promotion (hard to reverse + surprising + real tradeoff), and reconstructs the scene behind high-attention decisions using the reflect model (tension / observation / decision / tradeoff / next). Five files following the write-a-skill pattern (main < 130 lines): - SKILL.md: process overview, modes, error handling - EVIDENCE.md: input inventory, evidence graph, citation format - DECISIONS.md: detection heuristics, provenance, three-gate bar - HTML-REPORT.md: page structure, card templates, styling - ARCHIVE.md: KB format, naming, promotion, superseding, lint Informed by: - ttc-evolution/dev-vibe-fold (git-first fold methodology) - decode-agent phase-act.md (4-tier decision review surface) - Karpathy LLM Wiki (compounding knowledge > retrieval) - Thariq html-effectiveness (structured HTML for human review) - Matt Pocock skills (write-a-skill patterns, ADR three-gate bar) - /reflect skill (scene reconstruction: tension → next) - dev-log-writer agent (decision tables, tag system, naming rules) Reviewed by two independent agents (20 issues found and fixed): structure/consistency review + Matt Pocock pattern compliance review. --- skills/ccx-fold/ARCHIVE.md | 242 +++++++++++++++++++++++++++++++++ skills/ccx-fold/DECISIONS.md | 137 +++++++++++++++++++ skills/ccx-fold/EVIDENCE.md | 131 ++++++++++++++++++ skills/ccx-fold/HTML-REPORT.md | 145 ++++++++++++++++++++ skills/ccx-fold/SKILL.md | 129 ++++++++++++++++++ 5 files changed, 784 insertions(+) create mode 100644 skills/ccx-fold/ARCHIVE.md create mode 100644 skills/ccx-fold/DECISIONS.md create mode 100644 skills/ccx-fold/EVIDENCE.md create mode 100644 skills/ccx-fold/HTML-REPORT.md create mode 100644 skills/ccx-fold/SKILL.md diff --git a/skills/ccx-fold/ARCHIVE.md b/skills/ccx-fold/ARCHIVE.md new file mode 100644 index 0000000..3d2dbd3 --- /dev/null +++ b/skills/ccx-fold/ARCHIVE.md @@ -0,0 +1,242 @@ +# Agent Knowledge Archive + +How ccx-fold writes, maintains, and queries the project knowledge base. + +## Directory structure + +``` +.ccx/knowledge/ + index.md # catalog of all entries + log.md # append-only fold history + decisions/ + chose-sse-over-websocket.md + raw-sql-only-no-orm.md + discoveries/ + stripe-429-at-100-rpm.md + corrections/ + no-mock-database-in-integration-tests.md + patterns/ + embedded-database-single-binary.md +``` + +## Naming convention + +Name for the knowledge, not the method or the date. + +- GOOD: `chose-sse-over-websocket.md` +- GOOD: `stripe-429-at-100-rpm.md` +- BAD: `2026-05-20-streaming-decision.md` +- BAD: `session-abc123-findings.md` + +The date lives in frontmatter. The filename is the knowledge slug. + +**Collision handling**: if a file with the same slug already exists +from a prior fold, append `-2`, `-3`, etc. If the new entry covers +the same topic as the existing one, use `supersedes` instead of +creating a duplicate (see Superseding below). + +## Entry format: decision + +```markdown +--- +type: decision +date: 2026-05-20 +session: abc12345 +provenance: joint +attention: high +tags: [arch, api] +commits: [abc1234, def5678] +files: [src/stream.go, src/handler.go] +supersedes: null +--- + +# Chose SSE over WebSocket for real-time updates + +## Tension + +Dashboard needed live updates. Two viable transport options. + +## Decision + +Server-Sent Events over a single HTTP connection. Unidirectional +(server to client) is sufficient — the dashboard only receives. + +## Rejected + +- **WebSocket**: bidirectional capability unused. Adds connection + management complexity (reconnection, heartbeat, proxy traversal). + Would require a WebSocket library dependency. +- **Polling**: simple but wastes bandwidth and adds latency. The + dashboard shows sub-second updates. + +## Tradeoff + +SSE is HTTP/1.1 only on some browsers. Limited to ~6 concurrent +connections per domain in HTTP/1.1. Acceptable at current scale +(single dashboard tab). Revisit if multi-tab or mobile is needed. + +## Source + +Session abc12345, exchanges #8-11. +``` + +## Entry format: correction + +```markdown +--- +type: correction +date: 2026-05-20 +session: abc12345 +tags: [test, data] +--- + +# Don't mock the database in integration tests + +## What happened + +Agent wrote integration tests with a mock database layer. User +overrode: prior incident where mock/prod schema divergence masked +a broken migration. + +## Rule + +Integration tests must hit a real database instance. In-memory +equivalents (PGLite, SQLite) are acceptable. Hand-rolled mocks +are not. + +## Apply when + +Writing tests that touch data persistence. + +## Do not apply when + +Unit tests for pure functions with no persistence dependency. +``` + +## Entry format: discovery + +```markdown +--- +type: discovery +date: 2026-05-20 +session: abc12345 +tags: [api, ops] +--- + +# Stripe API rate-limits at 100 req/min on test keys + +## Finding + +Stripe returns HTTP 429 after 100 requests per minute on test-mode +API keys. Not documented in their public rate limit page. + +## Implication + +Batch operations against Stripe must include exponential backoff. +Sequential processing of >100 items hits the limit within 60s. +``` + +## Entry format: pattern + +Patterns emerge when 2+ independent decisions or discoveries share +a common rule. A single occurrence stays as its own entry. Patterns +require human approval before creation. + +```markdown +--- +type: pattern +date: 2026-05-20 +status: candidate +source_entries: [chose-sse-over-websocket, chose-sse-for-log-tail] +tags: [arch, api] +--- + +# Prefer SSE over WebSocket for unidirectional streams + +## Rule + +When the data flow is server-to-client only, use SSE. Reserve +WebSocket for bidirectional communication. + +## Apply when + +Adding real-time updates to a read-only surface (dashboards, +log viewers, status pages). + +## Do not apply when + +The client needs to send structured messages to the server +(chat, collaborative editing, game state sync). +``` + +## Promotion rules + +| Condition | Action | +|---|---| +| 1 occurrence | Keep as decision/discovery entry | +| 2 independent occurrences | Propose pattern promotion; require human approval | +| Human says "remember this" | Propose immediate promotion with source citation | + +## Index format + +Regenerated on every fold: + +```markdown +# Knowledge Base + +Last fold: 2026-05-20 | Entries: 14 + +## Decisions (7) + +- [Chose SSE over WebSocket](decisions/chose-sse-over-websocket.md) -- joint, high, arch +- [Raw SQL only](decisions/raw-sql-only-no-orm.md) -- correction, high, data + +## Discoveries (3) + +- [Stripe 429 at 100 rpm](discoveries/stripe-429-at-100-rpm.md) -- api, ops + +## Corrections (2) + +- [No mock database](corrections/no-mock-database-in-integration-tests.md) -- test + +## Patterns (2) + +- [SSE for unidirectional](patterns/prefer-sse-unidirectional.md) -- arch, api +``` + +## Log format + +Append-only, one line per fold: + +```markdown +## [2026-05-20] fold | session abc12345 | 5 decisions, 1 discovery, 1 correction +## [2026-05-19] fold | session def67890 | 3 decisions, 0 discoveries, 0 corrections +``` + +## Superseding entries + +When a new decision contradicts a prior entry on the same topic: + +1. Set `supersedes: ` in the new entry's frontmatter +2. Add a note at the top of the old entry: + `> Superseded by [](). Kept for historical context.` +3. Do NOT delete the old entry — it records what was true at the time + +## Lint checklist + +When invoked with lint mode: + +1. **Contradictions**: entries with overlapping tags and conflicting + conclusions. Present both to the user. +2. **Staleness**: entries older than 60 days referencing files no + longer in the repo. Flag for review. +3. **Orphans**: entries with zero inbound references from other + entries, CONTEXT.md, or ADRs. +4. **Failed deletion test**: entries where the conclusion is now + obvious from code or conventions. Propose archival. +5. **Pattern candidates**: 2+ entries sharing tags and similar + reasoning without a consolidated pattern. +6. **Vocabulary drift**: terms in entries that don't match CONTEXT.md + definitions. Propose CONTEXT.md updates. + +Output a report to conversation. Do not auto-fix. diff --git a/skills/ccx-fold/DECISIONS.md b/skills/ccx-fold/DECISIONS.md new file mode 100644 index 0000000..5e7ba08 --- /dev/null +++ b/skills/ccx-fold/DECISIONS.md @@ -0,0 +1,137 @@ +# Decision Extraction + +How ccx-fold detects, classifies, and filters decisions from a session. + +## Detection heuristics + +Not every exchange contains a decision. Most are mechanical execution. +A decision exists when the conversation's direction changed or a +choice was made between alternatives. + +### Human direction + +User message contains an imperative or explicit choice: +"use X", "switch to Y", "don't do Z", "go with A over B". + +Provenance: **human**. + +### Agent proposal accepted + +Agent says "I'll do X" or "I recommend Y". User's next message is +acceptance (explicit: "yes", "go ahead"; implicit: proceeds without +objection). + +Provenance: **joint**. + +### Agent autonomous action + +Agent modifies files (Edit, Write, Bash with side effects) without +prior explicit instruction for that specific choice. Detected by: +tool_use blocks that change state, preceded by assistant reasoning +but no user prompt requesting it. + +Provenance: **agent**. These are the highest-value decisions to +surface — invisible without the fold. + +### Human correction + +User contradicts, redirects, or negates the previous agent action: +"no", "don't", "actually", "wait", "not that way", "revert". + +Provenance: **correction**. Always high attention. + +### Design discussion + +Extended back-and-forth (3+ exchanges on the same topic) about +approach or tradeoffs. Often contains "should we", "what about", +"tradeoff", "option A vs B". + +Provenance: **joint**. + +## Discovery detection + +A discovery is a fact uncovered during the session — not a choice, +but a constraint or behavior learned. Signals: + +- Tool result contains an error that changed the approach +- Assistant text after an error: "turns out", "the issue was", + "this means", "found that", "gotcha" +- A measurement (benchmark, test, API response) that informed a + subsequent decision + +## Provenance labels + +| Label | Meaning | +|---|---| +| `human` | User explicitly directed the choice | +| `agent` | Agent chose without prior instruction | +| `joint` | Proposal + acceptance, or iterative discussion | +| `correction` | Human overrode agent behavior | +| `inferred` | Unclear from transcript; mark confidence low | + +## Attention tiers + +| Tier | Criteria | In fold.html | In KB | +|---|---|---|---| +| high | Architecture, data model, security, public API, irreversible | Full card with scene | Entry (if passes three-gate) | +| mid | Library choice, design pattern, approach, error handling | Compact card | Entry (if passes three-gate) | +| low | Formatting, imports, variable names, routine | Collapsed list | Skip | +| correction | Any human override of agent | Warning card | Entry (always — corrections are inherently surprising and hard to re-derive) | +| discovery | Constraint, bug, or behavior learned | Callout | Entry (if non-obvious from code/docs) | + +## Three-gate bar + +Before writing ANY knowledge base entry (regardless of attention +tier), all three must hold: + +1. **Hard to reverse** — changing this later is expensive (schema, + API, architecture, data migration). If the fix is editing one line, + skip the entry. + +2. **Surprising without context** — a future reader would wonder + "why?" If the choice is obvious from conventions or the code, skip. + +3. **Real tradeoff** — genuine alternatives existed and were rejected. + If there was only one sensible option, skip. + +Corrections always pass gate 2 (they record a non-obvious constraint +the agent violated). They pass gate 3 (the agent chose the rejected +alternative). Gate 1 is the only real filter for corrections — if the +correction is "fix the typo," it fails gate 1 and gets skipped. + +Decisions that fail any gate still appear in fold.html for human +review. They just don't enter the knowledge base. + +## Deletion test + +After drafting a KB entry, ask: "If this entry didn't exist, would +the next agent make a worse decision?" If the conclusion is derivable +from code, conventions, or common sense — delete the draft. Only +persist knowledge that prevents dead-end re-exploration. + +## Scene reconstruction + +For high-attention decisions, reconstruct the moment using the +reflect model. Don't just log the fact — write the scene: + +- **Tension**: What was broken, stuck, or wrong? +- **Observation**: What was actually seen (error, benchmark, code)? +- **Decision**: What was chosen AND what was rejected, with reasons. +- **Tradeoff**: What was given up. What pressure will build. +- **Next**: The live wire — what's still open. + +Mid-attention decisions get Decision + Rejected only (no full scene). + +## Tags + +| Tag | When | +|---|---| +| `arch` | Module boundaries, data model, system shape | +| `security` | Auth, authz, crypto, secrets | +| `data` | Schema, migration, storage, query pattern | +| `api` | Public interface, protocol, contract | +| `perf` | Performance, caching, optimization | +| `ux` | User-facing behavior, UI, CLI | +| `test` | Testing strategy, coverage, fixtures | +| `ops` | Deploy, infra, monitoring, CI/CD | +| `process` | Workflow, conventions, tooling | diff --git a/skills/ccx-fold/EVIDENCE.md b/skills/ccx-fold/EVIDENCE.md new file mode 100644 index 0000000..5b172d8 --- /dev/null +++ b/skills/ccx-fold/EVIDENCE.md @@ -0,0 +1,131 @@ +# Evidence Graph + +How ccx-fold gathers context and links evidence to decisions. + +## Input inventory + +Read as much as exists. Missing inputs reduce accuracy but don't block +the fold — except the session JSONL itself, which is required. + +### Session (required) + +The session JSONL is the primary evidence source. + +```bash +# Via ccx (preferred — handles path resolution and format normalization) +ccx export "$SESSION_ID" --format json + +# Direct fallback (if ccx unavailable) +# Claude Code stores sessions at ~/.claude/projects//.jsonl +# The encoded CWD replaces / with - and prepends - +ENCODED=$(echo "$PWD" | sed 's|^/|-|; s|/|-|g') +SESSION_DIR="$HOME/.claude/projects/$ENCODED" +# Pick the most recent JSONL +SESSION_FILE=$(ls -t "$SESSION_DIR"/*.jsonl 2>/dev/null | head -1) +``` + +Parse into exchanges: each user prompt and the assistant response, +tool calls, and results that follow it form one exchange. + +**If the session file is missing**: STOP. No fold without transcript. +**If the file is truncated**: warn, parse what's available, note the +gap in fold.html metadata. + +### Git state + +```bash +git log --after="$SESSION_START" --before="$SESSION_END" \ + --format="%H %ai %s" --all +git diff --stat HEAD +git branch --show-current +``` + +**If git history doesn't overlap the session window**: skip git +correlation. Note "no commits found in session window" in the fold +output. Decisions will be ungrounded (no linked commits). + +### Workspace context + +```bash +cat CLAUDE.md CONTEXT.md AGENTS.md 2>/dev/null +cat .claude/MEMORY.md 2>/dev/null +find docs/adr/ -name '*.md' 2>/dev/null +``` + +### Prior knowledge + +```bash +cat .ccx/knowledge/index.md 2>/dev/null +ls .ccx/knowledge/{decisions,discoveries,corrections}/ 2>/dev/null +``` + +## Evidence nodes + +| Type | Source | Example | +|---|---|---| +| `exchange` | Session JSONL | User prompt + assistant response cycle | +| `tool.call` | Session JSONL | Bash, Edit, Write, Read invocations | +| `tool.result` | Session JSONL | Command output, file contents, errors | +| `thinking` | Session JSONL | Assistant thinking/reasoning blocks | +| `sidechain` | Session JSONL | Sub-agent delegation and result | +| `git.commit` | Git log | Commit in the session time window | +| `doc.section` | Workspace | CLAUDE.md rule, ADR entry, CONTEXT.md term | +| `prior.entry` | Knowledge base | Existing decision or pattern entry | + +## Evidence edges + +Link nodes when the relationship is clear from timestamps, content, +or explicit references: + +| Edge | Meaning | +|---|---| +| `caused` | This exchange caused that tool call | +| `produced` | This tool call produced that commit | +| `contradicted` | This decision contradicts that ADR or prior entry | +| `superseded` | This decision replaces that prior entry | +| `verified_by` | This claim was verified by that test/command result | +| `corrected` | This user message corrected that agent action | + +## Citation format + +Use stable, greppable references. The UUID is always the message-level +`uuid` field from the JSONL (the parent message that contains the +tool_use block), not the tool_use block's `id`. + +``` +session:# # exchange by position +session:# # message by UUID +tool:: # tool call within a message +git: # git commit +file:: # source location +doc:# # documentation section +kb: # knowledge base entry +``` + +When a citation is approximate (e.g., exchange boundary ambiguous after +compaction), mark it: `session:abc123#~14 (post-compaction)`. + +## Compaction handling + +Sessions with compaction boundaries have incomplete context before the +boundary. For decisions detected pre-compaction: + +- Mark the card: "pre-compaction -- reasoning may be incomplete" +- Use git commits in the time window as supplementary evidence +- Do not fabricate reasoning absent from the transcript + +## Sidechain handling + +Claude Code stores sub-agent transcripts alongside the parent session. +The parent JSONL contains `isSidechain: true` messages with an `agentId` +field. The sub-agent's full transcript is in the same session directory +at `/subagents/agent-.jsonl` (relative to the +session's project folder under `~/.claude/projects/`). + +For each sidechain: + +- The delegation (TaskCreate prompt) is a decision by the initiator +- Internal sidechain decisions are attributed to `agent` +- Include the sidechain's summary result, not its full transcript +- File changes from sidechains appear in git correlation as + agent-attributed commits diff --git a/skills/ccx-fold/HTML-REPORT.md b/skills/ccx-fold/HTML-REPORT.md new file mode 100644 index 0000000..7c08cba --- /dev/null +++ b/skills/ccx-fold/HTML-REPORT.md @@ -0,0 +1,145 @@ +# HTML Report + +Specification for fold.html — the human review surface. + +## Design principles + +- **Self-contained**: inline CSS and JS, no CDN, no external deps. + Single file that works offline and prints cleanly. +- **Scannable**: decision cards, not prose. The human should grasp + the session's decisions in under 5 minutes. +- **Spatial**: attention tiers, color-coded provenance badges, + collapsible excerpts. Leverage visual processing. + +## Output location + +Always write to the OS temp directory, never the repo: + +```bash +TMPDIR="${TMPDIR:-/tmp}" +FOLD_HTML="$TMPDIR/ccx-fold-$(date +%Y%m%d-%H%M%S)-${SESSION_SLUG}.html" +``` + +After writing, open in the default browser (`open` on macOS, +`xdg-open` on Linux) and print the absolute path to the user. + +## Generation method + +Build the HTML as a single string using template substitution. +Structure: + +1. Write the static HTML skeleton (doctype, head with inline CSS, + body container, inline JS at the end) +2. For each decision, render a card using the appropriate template + (high-attention scene card, mid-attention compact row, correction + warning, discovery callout) +3. Populate the header stats, summary paragraph, diff summary, and + metadata footer from the evidence graph data + +Do not use external templating libraries. The output is a single +string written to a file. CSS follows ccx's palette: + +```css +:root { + --primary: #da7756; + --provenance-human: #3b82f6; + --provenance-agent: #f59e0b; + --provenance-joint: #8b5cf6; + --provenance-correction: #ef4444; + --discovery: #10b981; +} +``` + +Dark theme default. Light toggle via `data-theme` attribute on +``. Print styles: expand all `
`, black on white. + +## Page sections (in order) + +1. **Header**: session slug, date, duration, model, commit count, + decision count, token cost +2. **Summary**: 1 paragraph — what happened, what shipped, what's + open. Scene style (tension, not changelog). +3. **High-attention decisions**: full cards with scene reconstruction +4. **Corrections**: warning-styled cards +5. **Discoveries**: callout boxes +6. **Mid-attention decisions**: compact expandable rows +7. **Open questions**: the live wires +8. **Low-attention**: collapsed `
` list +9. **Diff summary**: files changed by directory, lines +/- +10. **Metadata**: tokens, cost, compactions, sidechains, session ID + +## Card templates + +### High-attention decision + +```html +
+
+ joint + high + arch +

+
+
+

Tension

+

Decision

+

Rejected

+

Tradeoff

+
+
+ Conversation (exchanges #N-M) +
+
+
+
+``` + +### Correction + +```html +
+
+ correction +

+
+
+

Agent tried:

+

Human directed:

+

Rule:

+
+
+``` + +### Discovery + +```html + +``` + +### Mid-attention (compact) + +```html +
+ + agent + + +

+
+``` + +## Conversation excerpts + +Include the 2-3 messages that contain the decision point. For each +message, show role (User/Assistant), timestamp, and the relevant +text (strip tool noise). Wrap in `
` — collapsed by default, +expandable on click. + +Do not include the full exchange. If the exchange has 15 messages, +show only the decision moment. The user can find the full session +via the session ID in the metadata footer. diff --git a/skills/ccx-fold/SKILL.md b/skills/ccx-fold/SKILL.md new file mode 100644 index 0000000..3c39bcd --- /dev/null +++ b/skills/ccx-fold/SKILL.md @@ -0,0 +1,129 @@ +--- +name: ccx-fold +description: > + Fold a coding session into auditable decisions and durable agent knowledge. + Use after a session, when reviewing agent work, or when the human asks + "what did we decide?" Triggers: /ccx-fold, fold session, session decisions, + what did we decide, extract decisions, session debrief. +--- + +# ccx-fold + +Fold a human-agent session into two outputs: + +1. **fold.html** -- decision trail for human review (temp dir, not repo) +2. **knowledge entries** -- structured markdown for agent consumption (`.ccx/knowledge/`) + +A fold is not a summary. It reconstructs decision provenance: who decided, +what was decided, why, what was rejected, and what pressure will build. + +## Modes + +| Trigger | Behavior | +|---|---| +| `/ccx-fold` | Fold the most recent session for this project | +| `/ccx-fold ` | Fold a specific session (prefix match) | +| `/ccx-fold --since ` | Fold all sessions since a git ref or date (see Multi-session below) | +| "quick fold" or "just highlights" | Light mode: top decisions + open question, no files | +| "check the knowledge base" | Lint mode: audit KB for staleness and contradictions | +| `/ccx-fold --dry-run` | Print the decision plan; write nothing | + +## Process + +### Phase 0: Pre-flight + +1. Verify git repo (`git rev-parse --show-toplevel`). If not a git repo, STOP. +2. Resolve session: if session-id given, find the matching JSONL; if empty, + pick the most recent session for this project's working directory. + If no session found, STOP with guidance. +3. Set output paths: + ``` + TMPDIR="${TMPDIR:-/tmp}" + FOLD_HTML="$TMPDIR/ccx-fold-$(date +%Y%m%d-%H%M%S)-.html" + KB_DIR="/.ccx/knowledge" + ``` +4. Create `$KB_DIR/{decisions,discoveries,corrections,patterns}` if first fold. +5. Read workspace context: CLAUDE.md, CONTEXT.md, docs/adr/, prior KB entries. + See [EVIDENCE.md](EVIDENCE.md) for the full input inventory. + +### Phase 1: Build evidence graph + +Parse the session into exchanges. Correlate with git commits in the session +time window. See [EVIDENCE.md](EVIDENCE.md) for evidence types, edges, and +citation format. + +### Phase 2: Extract and approve decisions + +Detect decisions using [DECISIONS.md](DECISIONS.md) heuristics. For each, +attribute provenance (human / agent / joint / correction) and attention tier. + +Present the full list to the user in conversation: + +``` +Fold Plan — session () + | | | exchanges | commits + + # Attention Provenance Decision + 1 high joint Chose SSE over WebSocket for real-time updates + 2 high correction Don't use ORM — raw SQL only in this codebase + 3 mid agent Added retry with backoff on 429 responses + 4 low agent Sorted imports alphabetically + ... + +Approve all? [y] / Edit specific entries? [e] / Skip fold? [n] +``` + +If user edits: reclassify, add missing, or remove false positives. +Rejected decisions are excluded from both HTML and KB. + +Apply the three-gate bar (hard to reverse + surprising + real tradeoff) +to every decision regardless of attention tier. Decisions that fail any +gate go into fold.html but NOT the knowledge base. + +### Phase 3: Write outputs + +1. Generate `$FOLD_HTML` — see [HTML-REPORT.md](HTML-REPORT.md). + Open in browser. Tell user the path. +2. Write approved KB entries to `$KB_DIR/` — see [ARCHIVE.md](ARCHIVE.md). +3. Update `$KB_DIR/index.md` and append to `$KB_DIR/log.md`. +4. `git add $KB_DIR/ && git commit` with fold summary. + +### Phase 4: Review queue + +Print to conversation (not to a file): + +- Decisions needing human confirmation (inferred provenance, low confidence) +- Agent decisions with high blast radius +- Vocabulary that drifted from CONTEXT.md +- The single most important open question (the live wire) + +## Light mode + +No evidence graph, no HTML, no KB writes. Print to conversation only: + +- Top 3 decisions: one line each with provenance badge +- The live wire: what's unresolved +- Suggested next action + +## Lint mode + +Audit the existing KB. See [ARCHIVE.md](ARCHIVE.md) lint checklist. + +## Multi-session (`--since`) + +When folding multiple sessions: process each independently through +Phases 0-2, then merge decision lists. De-duplicate decisions on the +same topic across sessions (keep the latest). Generate one combined +fold.html and one set of KB entries. Cross-session decisions link via +the `supersedes` field. + +## Error handling + +| Situation | Behavior | +|---|---| +| No session found | STOP with: "No session found. Run `ccx sessions` to list available sessions." | +| Session JSONL corrupted/truncated | Warn, fold what's parseable, note gap in HTML metadata | +| Git history doesn't overlap session | Warn, skip git correlation, note "no commits in session window" | +| Zero decisions detected | Report "no decisions detected — session may have been mechanical." Do not create empty files. | +| KB entry filename collision | Append `-2`, `-3` suffix to the slug | +| `.ccx/knowledge/` doesn't exist | Create it (first fold) | From d8035019c63aaad163d2b6262413415a2a219f24 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Wed, 20 May 2026 23:22:59 -0700 Subject: [PATCH 2/4] docs: add design doc and update README for ccx-fold - docs/design/0001-ccx-fold.md: design rationale, alternatives considered, influences, and future work - README.md: update "Claude Code skill" section to list both ccx (viewer) and ccx-fold (decision extraction) with install and usage instructions --- README.md | 22 +++++- docs/design/0001-ccx-fold.md | 134 +++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 docs/design/0001-ccx-fold.md diff --git a/README.md b/README.md index 01316d7..dde4d0e 100644 --- a/README.md +++ b/README.md @@ -102,14 +102,30 @@ ccx treats all agent data as **read-only**. Writes only to its own directories: - `$XDG_CONFIG_HOME/ccx/` — config - `$XDG_DATA_HOME/ccx/` — stars database -## Claude Code skill +## Claude Code skills -ccx ships as a Claude Code skill. Install alongside the binary: +ccx ships with two Claude Code skills: + +- **ccx** — Session viewer. Browse, search, export sessions from inside Claude Code. +- **ccx-fold** — Session decision extraction. Fold a session into auditable decisions (HTML for humans) and durable knowledge-base entries (markdown for agents). Tracks decision provenance: who decided, what, why, what was rejected. + +Install alongside the binary: ```bash curl -fsSL https://raw.githubusercontent.com/thevibeworks/ccx/main/install.sh | bash ``` -Or manually copy `skills/ccx/` to `~/.claude/skills/ccx/`. +Or manually copy skills to `~/.claude/skills/`: +```bash +cp -r skills/ccx/ ~/.claude/skills/ccx/ +cp -r skills/ccx-fold/ ~/.claude/skills/ccx-fold/ +``` + +Usage: +```bash +/ccx-fold # Fold most recent session +/ccx-fold # Fold specific session +/ccx-fold --dry-run # Preview without writing +``` ## Credits diff --git a/docs/design/0001-ccx-fold.md b/docs/design/0001-ccx-fold.md new file mode 100644 index 0000000..d8fc4d9 --- /dev/null +++ b/docs/design/0001-ccx-fold.md @@ -0,0 +1,134 @@ +# Design: ccx-fold — Session Decision Extraction + +**Created**: 2026-05-20 +**Status**: Accepted +**Skill**: `skills/ccx-fold/` + +## Problem + +Three systems capture different slices of "what happened and why" in +human-agent collaboration, and none capture the whole picture: + +| System | Input | Captures | Misses | +|---|---|---|---| +| ccx | Session JSONL | Who said what, when | What actually shipped | +| dev-vibe-fold | Git log | What shipped | Why it was done that way | +| phase-act (PDCA) | process.jsonl | Structured decisions | Requires SCRUM lane discipline | + +The raw session transcript — where thinking and deciding actually happen — +is treated as an audit trail nobody reads. Knowledge evaporates when the +context window compacts or the session ends. + +## Core insight + +A coding session is a decision stream buried in 1:20 signal-to-noise. +"Folding" collapses this into two artifacts: + +1. **For humans**: a reviewable decision trail (fold.html) +2. **For agents**: a compounding knowledge base (.ccx/knowledge/) + +The fold is the closing move of a session — raw transcript in, +structured knowledge out. + +## Key design decisions + +### Session-first, not git-first + +Unlike dev-vibe-fold (starts from `git log`), ccx-fold starts from the +session transcript. This captures WHY. Git changes are correlated as +evidence, not as the primary source. + +### Decision provenance is the product + +The central question is not "what changed" but "who decided what and why." +Every decision is classified: human / agent / joint / correction. + +### Three-gate quality bar (from Matt Pocock's ADR pattern) + +Before any KB entry: (1) hard to reverse, (2) surprising without context, +(3) real tradeoff. All three required. Prevents the KB from becoming a +session diary. + +### Deletion test + +After drafting a KB entry: "would the next agent make a worse decision +without this?" If derivable from code or conventions, delete the draft. + +### Scene reconstruction (from /reflect) + +High-attention decisions use the reflect model: tension, observation, +decision, tradeoff, next. Don't log facts — reconstruct the moment. + +### Dual-format output (from html-effectiveness research) + +Markdown dies at ~100 lines for human review. HTML survives via spatial +hierarchy, collapsible sections, and visual badges. Agents need structured +markdown with frontmatter. Different audiences, different formats. + +### Compounding KB (from Karpathy LLM Wiki) + +Each fold adds to a persistent knowledge base. Index, log, cross-references, +superseding, and lint keep it healthy. The fold is an ingest operation, not +a disposable report. + +## Alternatives considered + +### Extend dev-vibe-fold to read sessions + +Rejected: dev-vibe-fold is git-centric by design. Bolting session parsing +onto it would violate its architectural assumption (commits are the source +of truth). Better to keep them complementary — different primary sources, +compatible knowledge formats. + +### Build into ccx Go binary + +Partially accepted: the Go binary can do deterministic work (parsing, +correlation, HTML scaffolding). But decision classification requires LLM +reasoning. The skill calls the Go binary for parsing and adds its own +classification layer. Future: `ccx fold` CLI command for the deterministic +parts. + +### Single output format (markdown only) + +Rejected: html-effectiveness research shows structured HTML materially +improves human engagement at document lengths >100 lines. A fold with +9 decisions, excerpts, and diffs easily exceeds that. Agents need +structured markdown. Two audiences, two formats. + +### Knowledge entries in CLAUDE.md memory + +Rejected: CLAUDE.md memory is flat (no index, no lint, no superseding). +The knowledge base needs structure to compound. Entries could optionally +be copied to memory, but the KB is the source of truth. + +## Influences + +| Source | What we took | +|---|---| +| dev-vibe-fold | Fold as closing move; pattern harvest; retro note format | +| phase-act.md | 4-tier attention model (high/mid/low + metadata) | +| /reflect skill | Scene reconstruction: tension/observation/decision/tradeoff/next | +| dev-log-writer | Decision table format; tag system; "name for knowledge, not method" | +| Karpathy LLM Wiki | Compounding KB; index.md + log.md; ingest/query/lint operations | +| html-effectiveness | Self-contained HTML; spatial > sequential; ~100 line readability cliff | +| Matt Pocock skills | Three-gate ADR bar; CONTEXT.md shared language; write-a-skill structure | +| ccx | Session tree model; read-only philosophy; single-binary; CSS palette | + +## File structure + +``` +skills/ccx-fold/ + SKILL.md 129 lines Process, modes, error handling + EVIDENCE.md 131 lines Inputs, evidence graph, citations + DECISIONS.md 137 lines Detection, provenance, three-gate bar + HTML-REPORT.md 145 lines Page structure, card templates + ARCHIVE.md 242 lines KB format, naming, promotion, lint +``` + +## Future work + +- `ccx fold` Go CLI command for deterministic parsing + HTML generation +- CONTEXT.md vocabulary drift detection during fold +- Cross-project knowledge linking +- Team-level KB aggregation +- Automated fold trigger via Claude Code hooks From feb4400682b7c7105e729e27dfefbbfa06b8e5f5 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Wed, 20 May 2026 23:30:21 -0700 Subject: [PATCH 3/4] =?UTF-8?q?feat(cli):=20add=20ccx=20fold=20command=20?= =?UTF-8?q?=E2=80=94=20session=20turn=20analysis=20+=20git=20correlation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go implementation of the deterministic layer for ccx-fold. Parses a session into turns, tracks file mutations per turn, detects user corrections via heuristic pattern matching, correlates turns with git commits by timestamp + file overlap, and outputs structured JSON. The JSON output is designed as the data layer for the ccx-fold Claude Code skill: Go handles parsing and correlation, the LLM skill handles decision classification and KB generation. New packages: - internal/fold/types.go: FoldResult, Turn, GitCorrelation data model - internal/fold/analysis.go: turn segmentation, correction detection, file mutation tracking, sidechain extraction - internal/fold/git.go: git log within session window, commit-file listing, turn-to-commit linking by file overlap - internal/fold/html.go: self-contained HTML report with dark/light theme, turn cards with provenance badges, git correlation table - internal/fold/analysis_test.go: 6 tests covering nil/empty/basic sessions, correction detection, counting, and HTML rendering New command: - internal/cmd/fold.go: ccx fold [session] [-o file] [--html] Defaults to JSON on stdout. --html generates a review page in the OS temp directory and opens it in the browser. Registered in root.go command tree. All existing tests pass. New fold tests pass. --- internal/cmd/fold.go | 144 ++++++++++++++++ internal/cmd/root.go | 1 + internal/fold/analysis.go | 291 +++++++++++++++++++++++++++++++++ internal/fold/analysis_test.go | 139 ++++++++++++++++ internal/fold/git.go | 154 +++++++++++++++++ internal/fold/html.go | 267 ++++++++++++++++++++++++++++++ internal/fold/types.go | 78 +++++++++ 7 files changed, 1074 insertions(+) create mode 100644 internal/cmd/fold.go create mode 100644 internal/fold/analysis.go create mode 100644 internal/fold/analysis_test.go create mode 100644 internal/fold/git.go create mode 100644 internal/fold/html.go create mode 100644 internal/fold/types.go diff --git a/internal/cmd/fold.go b/internal/cmd/fold.go new file mode 100644 index 0000000..7f89166 --- /dev/null +++ b/internal/cmd/fold.go @@ -0,0 +1,144 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/spf13/cobra" + + "github.com/thevibeworks/ccx/internal/fold" + "github.com/thevibeworks/ccx/internal/parser" + "github.com/thevibeworks/ccx/internal/provider" +) + +var foldCmd = &cobra.Command{ + Use: "fold [session]", + Short: "Fold a session into structured decisions", + Long: `Analyze a session and produce structured decision data. + +Parses the session into turns, detects corrections, tracks file mutations, +and correlates with git commits in the session time window. Outputs JSON +for consumption by the ccx-fold Claude Code skill or other tools. + +Examples: + ccx fold # Latest session, JSON to stdout + ccx fold e38536 # Specific session + ccx fold -o fold.json # Write to file + ccx fold --html # Also generate HTML review to temp dir`, + Args: cobra.MaximumNArgs(1), + RunE: runFold, +} + +var ( + foldOutput string + foldProject string + foldHTML bool +) + +func init() { + foldCmd.Flags().StringVarP(&foldOutput, "output", "o", "", "output file (default: stdout)") + foldCmd.Flags().StringVarP(&foldProject, "project", "p", "", "project name") + foldCmd.Flags().BoolVar(&foldHTML, "html", false, "also generate HTML review in temp directory") +} + +func runFold(cmd *cobra.Command, args []string) error { + backend := provider.Default() + + var session *parser.Session + var err error + + if len(args) == 0 { + session, err = selectSession(backend) + } else { + projectName, sessionID := parseSessionArg(args[0]) + if foldProject != "" { + projectName = foldProject + } + session, err = backend.FindSession(projectName, sessionID) + } + if err != nil { + return fmt.Errorf("session: %w", err) + } + if session == nil { + return fmt.Errorf("no session found") + } + + fullSession, err := backend.ParseSession(session.FilePath) + if err != nil { + return fmt.Errorf("parse: %w", err) + } + + result := fold.Analyze(fullSession) + + repoDir := findGitRoot() + if repoDir != "" { + if err := fold.CorrelateGit(result, repoDir); err != nil { + fmt.Fprintf(os.Stderr, "warning: git correlation failed: %v\n", err) + } + } + + jsonBytes, err := json.MarshalIndent(result, "", " ") + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + + if foldOutput != "" { + dir := filepath.Dir(foldOutput) + if dir != "." && dir != "" { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("mkdir: %w", err) + } + } + if err := os.WriteFile(foldOutput, jsonBytes, 0644); err != nil { + return fmt.Errorf("write: %w", err) + } + fmt.Fprintf(os.Stderr, "Fold written to: %s\n", foldOutput) + } else { + fmt.Println(string(jsonBytes)) + } + + if foldHTML { + if err := generateFoldHTML(result, fullSession); err != nil { + fmt.Fprintf(os.Stderr, "warning: HTML generation failed: %v\n", err) + } + } + + return nil +} + +func generateFoldHTML(result *fold.FoldResult, session *parser.Session) error { + tmpDir := os.TempDir() + + slug := session.Slug + if slug == "" && len(session.ID) > 8 { + slug = session.ID[:8] + } + filename := fmt.Sprintf("ccx-fold-%s-%s.html", + time.Now().Format("20060102-150405"), slug) + htmlPath := filepath.Join(tmpDir, filename) + + html := fold.RenderHTML(result) + + if err := os.WriteFile(htmlPath, []byte(html), 0644); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "HTML review: %s\n", htmlPath) + openBrowser(htmlPath) + return nil +} + +func findGitRoot() string { + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + out, err := cmd.Output() + if err != nil { + return "" + } + return filepath.Clean(string(out[:len(out)-1])) +} + +// openBrowser is defined in web.go diff --git a/internal/cmd/root.go b/internal/cmd/root.go index de55f96..aa0f812 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -72,6 +72,7 @@ func init() { rootCmd.AddCommand(exportCmd) rootCmd.AddCommand(configCmd) rootCmd.AddCommand(doctorCmd) + rootCmd.AddCommand(foldCmd) } func initConfig() { diff --git a/internal/fold/analysis.go b/internal/fold/analysis.go new file mode 100644 index 0000000..fd001b6 --- /dev/null +++ b/internal/fold/analysis.go @@ -0,0 +1,291 @@ +package fold + +import ( + "sort" + "strings" + + "github.com/thevibeworks/ccx/internal/parser" +) + +var mutatingTools = map[string]bool{ + "Edit": true, + "Write": true, + "MultiEdit": true, + "NotebookEdit": true, + "Bash": true, + "ApplyPatch": true, +} + +var readTools = map[string]bool{ + "Read": true, + "Glob": true, + "Grep": true, +} + +var correctionSignals = []string{ + "no,", "no ", "don't", "dont", "do not", + "actually,", "actually ", "wait,", "wait ", + "not that", "revert", "undo", "wrong", + "stop", "cancel", "instead,", "instead ", + "I said", "i said", "not what I", +} + +func Analyze(session *parser.Session) *FoldResult { + if session == nil { + return &FoldResult{} + } + + meta := SessionMeta{ + ID: session.ID, + Summary: session.Summary, + Model: session.Model, + Start: session.StartTime, + End: session.EndTime, + CWD: session.CWD, + GitBranch: session.GitBranch, + } + + allMsgs := parser.FlattenSessionMessages(session) + turns := segmentTurns(allMsgs) + + allEdited := make(map[string]struct{}) + corrections := 0 + hasSidechains := false + var totalCost float64 + + for _, t := range turns { + for _, f := range t.FilesEdited { + allEdited[f] = struct{}{} + } + if t.HasCorrection { + corrections++ + } + if t.Sidechain != nil { + hasSidechains = true + } + totalCost += t.CostUSD + } + + dur := session.EndTime.Sub(session.StartTime).Seconds() + if dur < 0 { + dur = 0 + } + + stats := FoldStats{ + TurnCount: len(turns), + Corrections: corrections, + FilesEdited: len(allEdited), + TotalCostUSD: totalCost, + DurationSecs: dur, + HasSidechains: hasSidechains, + } + + return &FoldResult{ + Session: meta, + Turns: turns, + Stats: stats, + } +} + +func segmentTurns(messages []*parser.Message) []Turn { + type block struct { + anchor *parser.Message + messages []*parser.Message + } + + var blocks []block + var current *block + + for _, msg := range messages { + if msg == nil { + continue + } + if msg.Kind == parser.KindCompactSummary { + if current != nil { + blocks = append(blocks, *current) + current = nil + } + continue + } + if msg.Kind == parser.KindUserPrompt || msg.Kind == parser.KindCommand { + if current != nil { + blocks = append(blocks, *current) + } + current = &block{anchor: msg} + continue + } + if current != nil { + current.messages = append(current.messages, msg) + } + } + if current != nil { + blocks = append(blocks, *current) + } + + turns := make([]Turn, 0, len(blocks)) + for i, b := range blocks { + if b.anchor == nil { + continue + } + t := buildTurn(i+1, b.anchor, b.messages) + if t.UserText == "" && len(t.FilesEdited) == 0 && t.AssistantText == "" { + continue + } + turns = append(turns, t) + } + return turns +} + +func buildTurn(index int, anchor *parser.Message, messages []*parser.Message) Turn { + t := Turn{ + Index: index, + AnchorID: anchor.UUID, + Start: anchor.Timestamp, + End: anchor.Timestamp, + } + + if anchor.IsCommand { + t.IsCommand = true + t.CommandName = anchor.CommandName + } + + t.UserText = firstText(anchor) + + toolSet := make(map[string]struct{}) + editSet := make(map[string]struct{}) + readSet := make(map[string]struct{}) + + for _, msg := range messages { + if msg == nil { + continue + } + if !msg.Timestamp.IsZero() && msg.Timestamp.After(t.End) { + t.End = msg.Timestamp + } + if msg.Usage != nil { + t.InputTokens += msg.Usage.InputTokens + t.OutputTokens += msg.Usage.OutputTokens + t.CostUSD += msg.Usage.CostUSD + } + + for _, block := range msg.Content { + if block.Type == "thinking" { + t.HasThinking = true + } + if block.Type == "tool_use" && block.ToolName != "" { + toolSet[block.ToolName] = struct{}{} + path := extractPath(block.ToolInput) + if path != "" { + if mutatingTools[block.ToolName] { + editSet[path] = struct{}{} + } else if readTools[block.ToolName] { + readSet[path] = struct{}{} + } + } + } + } + + if msg.IsSidechain && msg.AgentID != "" && t.Sidechain == nil { + t.Sidechain = &Sidechain{ + AgentID: msg.AgentID, + Summary: lastAssistantInSidechain(msg), + } + } + } + + t.AssistantText = lastAssistantText(messages) + t.FilesEdited = sortedKeys(editSet) + t.FilesRead = sortedKeys(readSet) + t.ToolsUsed = sortedKeys(toolSet) + t.HasCorrection = detectCorrection(t.UserText) + + return t +} + +func detectCorrection(userText string) bool { + if userText == "" { + return false + } + lower := strings.ToLower(userText) + for _, signal := range correctionSignals { + if strings.Contains(lower, signal) { + return true + } + } + return false +} + +func firstText(msg *parser.Message) string { + if msg == nil { + return "" + } + for _, block := range msg.Content { + if block.Type == "text" { + text := strings.TrimSpace(block.Text) + if text != "" { + return text + } + } + } + return "" +} + +func lastAssistantText(messages []*parser.Message) string { + for i := len(messages) - 1; i >= 0; i-- { + m := messages[i] + if m == nil || m.Kind != parser.KindAssistant || m.IsSidechain { + continue + } + text := firstText(m) + if text != "" { + return text + } + } + return "" +} + +func lastAssistantInSidechain(root *parser.Message) string { + if root == nil { + return "" + } + var walk func([]*parser.Message) string + walk = func(msgs []*parser.Message) string { + for i := len(msgs) - 1; i >= 0; i-- { + if result := walk(msgs[i].Children); result != "" { + return result + } + if msgs[i].Kind == parser.KindAssistant { + if t := firstText(msgs[i]); t != "" { + return t + } + } + } + return "" + } + return walk(root.Children) +} + +func extractPath(toolInput any) string { + input, _ := toolInput.(map[string]any) + if input == nil { + return "" + } + for _, key := range []string{"file_path", "notebook_path", "path"} { + if p, ok := input[key].(string); ok && p != "" { + return p + } + } + return "" +} + +func sortedKeys(m map[string]struct{}) []string { + if len(m) == 0 { + return nil + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/fold/analysis_test.go b/internal/fold/analysis_test.go new file mode 100644 index 0000000..40809d2 --- /dev/null +++ b/internal/fold/analysis_test.go @@ -0,0 +1,139 @@ +package fold + +import ( + "testing" + "time" + + "github.com/thevibeworks/ccx/internal/parser" +) + +func TestAnalyzeNilSession(t *testing.T) { + result := Analyze(nil) + if result == nil { + t.Fatal("expected non-nil result for nil session") + } + if result.Stats.TurnCount != 0 { + t.Errorf("expected 0 turns, got %d", result.Stats.TurnCount) + } +} + +func TestAnalyzeEmptySession(t *testing.T) { + session := &parser.Session{} + result := Analyze(session) + if result.Stats.TurnCount != 0 { + t.Errorf("expected 0 turns, got %d", result.Stats.TurnCount) + } +} + +func TestAnalyzeBasicSession(t *testing.T) { + now := time.Now() + session := &parser.Session{ + ID: "test-session-123", + Summary: "Test session", + Model: "claude-opus-4-6", + StartTime: now, + EndTime: now.Add(30 * time.Minute), + RootMessages: []*parser.Message{ + { + UUID: "msg-1", + Kind: parser.KindUserPrompt, + Type: "user", + Timestamp: now, + Content: []parser.ContentBlock{{Type: "text", Text: "Add a health check endpoint"}}, + }, + { + UUID: "msg-2", + Kind: parser.KindAssistant, + Type: "assistant", + Timestamp: now.Add(1 * time.Minute), + Content: []parser.ContentBlock{ + {Type: "text", Text: "I'll add a /health endpoint."}, + {Type: "tool_use", ToolName: "Edit", ToolInput: map[string]any{"file_path": "src/server.go"}}, + }, + }, + }, + } + + result := Analyze(session) + + if result.Session.ID != "test-session-123" { + t.Errorf("session ID: got %q, want %q", result.Session.ID, "test-session-123") + } + if result.Stats.TurnCount != 1 { + t.Errorf("turns: got %d, want 1", result.Stats.TurnCount) + } + if result.Stats.FilesEdited != 1 { + t.Errorf("files edited: got %d, want 1", result.Stats.FilesEdited) + } + + turn := result.Turns[0] + if turn.UserText != "Add a health check endpoint" { + t.Errorf("user text: got %q", turn.UserText) + } + if len(turn.FilesEdited) != 1 || turn.FilesEdited[0] != "src/server.go" { + t.Errorf("files edited: got %v", turn.FilesEdited) + } +} + +func TestDetectCorrection(t *testing.T) { + tests := []struct { + text string + want bool + }{ + {"Add a health check", false}, + {"No, don't use that approach", true}, + {"Actually, let's use SSE instead", true}, + {"Wait, that's wrong", true}, + {"Revert the last change", true}, + {"Sounds good, continue", false}, + {"I said use DuckDB not Postgres", true}, + {"Can you explain that?", false}, + {"Stop doing that", true}, + } + + for _, tt := range tests { + got := detectCorrection(tt.text) + if got != tt.want { + t.Errorf("detectCorrection(%q) = %v, want %v", tt.text, got, tt.want) + } + } +} + +func TestAnalyzeCorrectionCounting(t *testing.T) { + now := time.Now() + session := &parser.Session{ + ID: "correction-test", + StartTime: now, + EndTime: now.Add(10 * time.Minute), + RootMessages: []*parser.Message{ + {UUID: "u1", Kind: parser.KindUserPrompt, Type: "user", Timestamp: now, + Content: []parser.ContentBlock{{Type: "text", Text: "Build auth middleware"}}}, + {UUID: "a1", Kind: parser.KindAssistant, Type: "assistant", Timestamp: now.Add(1 * time.Minute), + Content: []parser.ContentBlock{{Type: "text", Text: "I'll add JWT auth."}}}, + {UUID: "u2", Kind: parser.KindUserPrompt, Type: "user", Timestamp: now.Add(2 * time.Minute), + Content: []parser.ContentBlock{{Type: "text", Text: "No, don't use JWT. Use session cookies instead."}}}, + {UUID: "a2", Kind: parser.KindAssistant, Type: "assistant", Timestamp: now.Add(3 * time.Minute), + Content: []parser.ContentBlock{{Type: "text", Text: "Switching to session cookies."}}}, + }, + } + + result := Analyze(session) + if result.Stats.Corrections != 1 { + t.Errorf("corrections: got %d, want 1", result.Stats.Corrections) + } +} + +func TestRenderHTMLNotEmpty(t *testing.T) { + result := &FoldResult{ + Session: SessionMeta{ID: "abc123", Summary: "Test"}, + Stats: FoldStats{TurnCount: 1}, + Turns: []Turn{{Index: 1, UserText: "hello"}}, + } + html := RenderHTML(result) + if html == "" { + t.Fatal("expected non-empty HTML") + } + if len(html) < 100 { + t.Errorf("HTML suspiciously short: %d bytes", len(html)) + } +} diff --git a/internal/fold/git.go b/internal/fold/git.go new file mode 100644 index 0000000..4a12e90 --- /dev/null +++ b/internal/fold/git.go @@ -0,0 +1,154 @@ +package fold + +import ( + "bufio" + "fmt" + "os/exec" + "strings" + "time" +) + +func CorrelateGit(result *FoldResult, repoDir string) error { + if result == nil || len(result.Turns) == 0 { + return nil + } + + start := result.Session.Start + end := result.Session.End + if start.IsZero() || end.IsZero() { + return nil + } + + commits, err := listCommits(repoDir, start, end) + if err != nil { + return fmt.Errorf("git log: %w", err) + } + if len(commits) == 0 { + return nil + } + + for i := range commits { + files, err := commitFiles(repoDir, commits[i].SHA) + if err == nil { + commits[i].Files = files + } + } + + result.Git.Commits = commits + + editedByTurn := make(map[int]map[string]struct{}) + for i, t := range result.Turns { + m := make(map[string]struct{}) + for _, f := range t.FilesEdited { + m[f] = struct{}{} + } + editedByTurn[i] = m + } + + var links []TurnCommitLink + linkedCommits := make(map[string]struct{}) + + for _, commit := range commits { + bestTurn := -1 + var bestOverlap []string + + for i, t := range result.Turns { + edited := editedByTurn[i] + if len(edited) == 0 { + continue + } + var overlap []string + for _, cf := range commit.Files { + if _, ok := edited[cf]; ok { + overlap = append(overlap, cf) + } + } + if len(overlap) > len(bestOverlap) { + bestOverlap = overlap + bestTurn = i + } + if len(overlap) == 0 && t.End.Before(parseTimestamp(commit.Timestamp)) && + (bestTurn == -1 || len(bestOverlap) == 0) { + bestTurn = i + } + } + + if bestTurn >= 0 { + links = append(links, TurnCommitLink{ + TurnIndex: result.Turns[bestTurn].Index, + CommitSHA: commit.SHA, + FileOverlap: bestOverlap, + }) + linkedCommits[commit.SHA] = struct{}{} + + result.Turns[bestTurn].LinkedCommits = append( + result.Turns[bestTurn].LinkedCommits, commit.SHA) + } + } + + result.Git.TurnCommitLinks = links + result.Stats.CommitsLinked = len(linkedCommits) + return nil +} + +func listCommits(repoDir string, after, before time.Time) ([]GitCommit, error) { + afterStr := after.Add(-1 * time.Minute).Format(time.RFC3339) + beforeStr := before.Add(1 * time.Minute).Format(time.RFC3339) + + cmd := exec.Command("git", "log", + "--after="+afterStr, + "--before="+beforeStr, + "--format=%H\t%aI\t%s", + "--all", + ) + cmd.Dir = repoDir + + out, err := cmd.Output() + if err != nil { + return nil, err + } + + var commits []GitCommit + scanner := bufio.NewScanner(strings.NewReader(string(out))) + for scanner.Scan() { + line := scanner.Text() + parts := strings.SplitN(line, "\t", 3) + if len(parts) < 3 { + continue + } + commits = append(commits, GitCommit{ + SHA: parts[0], + Timestamp: parts[1], + Subject: parts[2], + }) + } + return commits, nil +} + +func commitFiles(repoDir, sha string) ([]string, error) { + cmd := exec.Command("git", "diff-tree", "--no-commit-id", "--name-only", "-r", sha) + cmd.Dir = repoDir + + out, err := cmd.Output() + if err != nil { + return nil, err + } + + var files []string + scanner := bufio.NewScanner(strings.NewReader(string(out))) + for scanner.Scan() { + f := strings.TrimSpace(scanner.Text()) + if f != "" { + files = append(files, f) + } + } + return files, nil +} + +func parseTimestamp(ts string) time.Time { + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + return time.Time{} + } + return t +} diff --git a/internal/fold/html.go b/internal/fold/html.go new file mode 100644 index 0000000..dc68655 --- /dev/null +++ b/internal/fold/html.go @@ -0,0 +1,267 @@ +package fold + +import ( + "fmt" + "html" + "strings" +) + +func RenderHTML(result *FoldResult) string { + if result == nil { + return "" + } + + var b strings.Builder + + b.WriteString(` + + + + +Fold: `) + b.WriteString(html.EscapeString(result.Session.Summary)) + b.WriteString(` + + + +
+`) + + writeHeader(&b, result) + writeTurns(&b, result) + writeGitSection(&b, result) + writeStats(&b, result) + + b.WriteString(`
+ + +`) + + return b.String() +} + +func writeHeader(b *strings.Builder, r *FoldResult) { + b.WriteString(`
`) + b.WriteString(fmt.Sprintf(`

Session Fold: %s

`, html.EscapeString(r.Session.Summary))) + b.WriteString(`
`) + + var parts []string + if r.Session.ID != "" { + id := r.Session.ID + if len(id) > 8 { + id = id[:8] + } + parts = append(parts, fmt.Sprintf(`ID: %s`, id)) + } + if !r.Session.Start.IsZero() { + parts = append(parts, fmt.Sprintf(`%s`, r.Session.Start.Format("2006-01-02 15:04"))) + } + if r.Session.Model != "" { + parts = append(parts, fmt.Sprintf(`%s`, html.EscapeString(r.Session.Model))) + } + parts = append(parts, fmt.Sprintf(`%d turns`, r.Stats.TurnCount)) + if r.Stats.Corrections > 0 { + parts = append(parts, fmt.Sprintf(`%d corrections`, r.Stats.Corrections)) + } + if r.Stats.CommitsLinked > 0 { + parts = append(parts, fmt.Sprintf(`%d commits`, r.Stats.CommitsLinked)) + } + if r.Stats.TotalCostUSD > 0 { + parts = append(parts, fmt.Sprintf(`$%.4f`, r.Stats.TotalCostUSD)) + } + b.WriteString(strings.Join(parts, " · ")) + b.WriteString(`
`) + b.WriteString(``) + b.WriteString(`
`) +} + +func writeTurns(b *strings.Builder, r *FoldResult) { + b.WriteString(`
`) + + for _, t := range r.Turns { + class := "turn" + if t.HasCorrection { + class += " correction" + } + if t.Sidechain != nil { + class += " has-sidechain" + } + + b.WriteString(fmt.Sprintf(`
`, class, t.Index)) + b.WriteString(fmt.Sprintf(`
#%d`, t.Index)) + + if t.HasCorrection { + b.WriteString(`correction`) + } + if t.IsCommand { + b.WriteString(fmt.Sprintf(`/%s`, html.EscapeString(t.CommandName))) + } + if t.HasThinking { + b.WriteString(`thinking`) + } + if t.Sidechain != nil { + b.WriteString(fmt.Sprintf(`agent: %s`, html.EscapeString(t.Sidechain.AgentType))) + } + if len(t.LinkedCommits) > 0 { + for _, sha := range t.LinkedCommits { + short := sha + if len(short) > 7 { + short = short[:7] + } + b.WriteString(fmt.Sprintf(` %s`, short)) + } + } + b.WriteString(`
`) + + if t.UserText != "" { + b.WriteString(`
`) + userPreview := t.UserText + if len(userPreview) > 200 { + userPreview = userPreview[:200] + "..." + } + b.WriteString(html.EscapeString(userPreview)) + b.WriteString(`
`) + } + + if len(t.FilesEdited) > 0 { + b.WriteString(`
Edited: `) + for i, f := range t.FilesEdited { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(fmt.Sprintf(`%s`, html.EscapeString(f))) + } + b.WriteString(`
`) + } + + if len(t.ToolsUsed) > 0 { + b.WriteString(`
`) + for _, tool := range t.ToolsUsed { + b.WriteString(fmt.Sprintf(`%s`, html.EscapeString(tool))) + } + b.WriteString(`
`) + } + + if t.AssistantText != "" { + b.WriteString(`
Assistant response

`) + b.WriteString(html.EscapeString(t.AssistantText)) + b.WriteString(`

`) + } + + b.WriteString(`
`) + } + + b.WriteString(`
`) +} + +func writeGitSection(b *strings.Builder, r *FoldResult) { + if len(r.Git.Commits) == 0 { + return + } + + b.WriteString(`
`) + b.WriteString(`

Git Commits in Session Window

`) + b.WriteString(``) + + commitToTurn := make(map[string]int) + for _, link := range r.Git.TurnCommitLinks { + commitToTurn[link.CommitSHA] = link.TurnIndex + } + + for _, c := range r.Git.Commits { + sha := c.SHA + if len(sha) > 7 { + sha = sha[:7] + } + turnLink := "" + if idx, ok := commitToTurn[c.SHA]; ok { + turnLink = fmt.Sprintf(`#%d`, idx, idx) + } + b.WriteString(fmt.Sprintf(``, + sha, html.EscapeString(c.Subject), len(c.Files), turnLink)) + } + + b.WriteString(`
SHASubjectFilesLinked Turn
%s%s%d%s
`) +} + +func writeStats(b *strings.Builder, r *FoldResult) { + b.WriteString(`
`) + b.WriteString(fmt.Sprintf(`

Session: %s`, html.EscapeString(r.Session.ID))) + if r.Session.CWD != "" { + b.WriteString(fmt.Sprintf(` · CWD: %s`, html.EscapeString(r.Session.CWD))) + } + if r.Session.GitBranch != "" { + b.WriteString(fmt.Sprintf(` · Branch: %s`, html.EscapeString(r.Session.GitBranch))) + } + b.WriteString(`

`) + b.WriteString(`

Generated by ccx fold

`) + b.WriteString(`
`) +} + +func foldCSS() string { + return ` +:root, [data-theme="dark"] { + --bg: #1a1a2e; --surface: #232340; --text: #e0e0e0; --dim: #888; + --primary: #da7756; --correction: #ef4444; --sidechain: #8b5cf6; + --commit: #10b981; --border: #333; +} +[data-theme="light"] { + --bg: #f5f5f5; --surface: #fff; --text: #1a1a1a; --dim: #666; + --primary: #c45a3c; --correction: #dc2626; --sidechain: #7c3aed; + --commit: #059669; --border: #ddd; +} +* { margin: 0; padding: 0; box-sizing: border-box; } +body { font: 14px/1.6 -apple-system, system-ui, sans-serif; background: var(--bg); color: var(--text); } +.container { max-width: 900px; margin: 0 auto; padding: 24px; } +.fold-header { margin-bottom: 24px; border-bottom: 1px solid var(--border); padding-bottom: 16px; } +.fold-header h1 { font-size: 20px; color: var(--primary); } +.meta { margin-top: 8px; color: var(--dim); font-size: 13px; } +.meta-item { white-space: nowrap; } +.correction-count { color: var(--correction); font-weight: 600; } +.theme-toggle { float: right; background: var(--surface); border: 1px solid var(--border); color: var(--text); padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; } +.turn { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 12px 16px; margin-bottom: 8px; } +.turn.correction { border-left: 3px solid var(--correction); } +.turn.has-sidechain { border-left: 3px solid var(--sidechain); } +.turn header { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; } +.turn-num { font-weight: 700; color: var(--dim); font-size: 13px; } +.badge { font-size: 11px; padding: 1px 6px; border-radius: 3px; font-weight: 600; } +.badge.correction { background: var(--correction); color: #fff; } +.badge.command { background: var(--primary); color: #fff; } +.badge.thinking { background: #3b82f6; color: #fff; } +.badge.sidechain { background: var(--sidechain); color: #fff; } +.commit { font-size: 12px; color: var(--commit); } +.user-text { color: var(--text); margin-bottom: 6px; white-space: pre-wrap; } +.files-edited { font-size: 12px; color: var(--dim); margin-bottom: 4px; } +.files-edited code { color: var(--primary); } +.tools { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 4px; } +.tool-badge { font-size: 11px; background: var(--surface); border: 1px solid var(--border); padding: 0 4px; border-radius: 2px; color: var(--dim); } +.assistant-text { font-size: 13px; color: var(--dim); } +.assistant-text summary { cursor: pointer; } +.assistant-text p { margin-top: 8px; white-space: pre-wrap; } +.git-correlation { margin-top: 24px; } +.git-correlation h2 { font-size: 16px; margin-bottom: 8px; } +.git-correlation table { width: 100%; border-collapse: collapse; font-size: 13px; } +.git-correlation th, .git-correlation td { text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--border); } +.git-correlation th { color: var(--dim); font-weight: 600; } +.git-correlation a { color: var(--primary); } +.fold-stats { margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border); font-size: 12px; color: var(--dim); } +.generated { margin-top: 4px; font-style: italic; } +@media print { body { background: #fff; color: #000; } .theme-toggle { display: none; } details { display: block; } details[open] summary { display: none; } } +` +} + +func foldJS() string { + return ` +function toggleTheme() { + var h = document.documentElement; + h.setAttribute('data-theme', h.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'); +} +document.addEventListener('keydown', function(e) { + if (e.key === 'd') toggleTheme(); +}); +` +} diff --git a/internal/fold/types.go b/internal/fold/types.go new file mode 100644 index 0000000..85b5d6a --- /dev/null +++ b/internal/fold/types.go @@ -0,0 +1,78 @@ +package fold + +import "time" + +type FoldResult struct { + Session SessionMeta `json:"session"` + Turns []Turn `json:"turns"` + Git GitCorrelation `json:"git"` + Stats FoldStats `json:"stats"` +} + +type SessionMeta struct { + ID string `json:"id"` + Summary string `json:"summary"` + Model string `json:"model"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + CWD string `json:"cwd"` + GitBranch string `json:"git_branch"` +} + +type Turn struct { + Index int `json:"index"` + AnchorID string `json:"anchor_id"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + UserText string `json:"user_text"` + AssistantText string `json:"assistant_text"` + FilesEdited []string `json:"files_edited,omitempty"` + FilesRead []string `json:"files_read,omitempty"` + ToolsUsed []string `json:"tools_used,omitempty"` + HasCorrection bool `json:"has_correction"` + HasThinking bool `json:"has_thinking"` + IsCommand bool `json:"is_command"` + CommandName string `json:"command_name,omitempty"` + Sidechain *Sidechain `json:"sidechain,omitempty"` + + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CostUSD float64 `json:"cost_usd"` + + LinkedCommits []string `json:"linked_commits,omitempty"` +} + +type Sidechain struct { + AgentID string `json:"agent_id"` + AgentType string `json:"agent_type"` + Summary string `json:"summary"` + ToolCalls int `json:"tool_calls"` +} + +type GitCorrelation struct { + Commits []GitCommit `json:"commits"` + TurnCommitLinks []TurnCommitLink `json:"turn_commit_links"` +} + +type GitCommit struct { + SHA string `json:"sha"` + Timestamp string `json:"timestamp"` + Subject string `json:"subject"` + Files []string `json:"files"` +} + +type TurnCommitLink struct { + TurnIndex int `json:"turn_index"` + CommitSHA string `json:"commit_sha"` + FileOverlap []string `json:"file_overlap"` +} + +type FoldStats struct { + TurnCount int `json:"turn_count"` + Corrections int `json:"corrections"` + FilesEdited int `json:"files_edited"` + CommitsLinked int `json:"commits_linked"` + TotalCostUSD float64 `json:"total_cost_usd"` + DurationSecs float64 `json:"duration_seconds"` + HasSidechains bool `json:"has_sidechains"` +} From e077e22d4134cf07a5c6a66b528b1d4f83d7a45d Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Wed, 20 May 2026 23:58:23 -0700 Subject: [PATCH 4/4] fix(cli): align fold session lookup with scoped picker --- internal/cmd/fold.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/cmd/fold.go b/internal/cmd/fold.go index 7f89166..692d5ef 100644 --- a/internal/cmd/fold.go +++ b/internal/cmd/fold.go @@ -37,11 +37,13 @@ var ( foldOutput string foldProject string foldHTML bool + foldAll bool ) func init() { foldCmd.Flags().StringVarP(&foldOutput, "output", "o", "", "output file (default: stdout)") foldCmd.Flags().StringVarP(&foldProject, "project", "p", "", "project name") + foldCmd.Flags().BoolVar(&foldAll, "all", false, "search across all projects") foldCmd.Flags().BoolVar(&foldHTML, "html", false, "also generate HTML review in temp directory") } @@ -52,13 +54,17 @@ func runFold(cmd *cobra.Command, args []string) error { var err error if len(args) == 0 { - session, err = selectSession(backend) + session, err = selectSession(backend, foldAll) } else { projectName, sessionID := parseSessionArg(args[0]) if foldProject != "" { projectName = foldProject } - session, err = backend.FindSession(projectName, sessionID) + query, qErr := sessionLookupQuery(projectName, foldAll) + if qErr != nil { + return qErr + } + session, err = resolveSessionInQuery(backend, query, sessionID) } if err != nil { return fmt.Errorf("session: %w", err)