SLOPSHOPPER

skill-audit

Audit trail of skill invocations and file changes for Claude Code and Codex sessions

newpaneguardcommandtimer
v0.3.0MITupdated 2026-10-04DepickereSven/skill-audit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skill-audit
│ ┃ skill-audit ✕ › fix the failing auth test and add an audit log call │ ┃ Skill audit ·0 ✎0 │ ┃ no events yet ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /skill-audit-pane │ ⎿ skill-audit: Skill audit pane hidden. /skill-audit-pane brings i │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · skill-audit
Skill audit ·0 ✎0 no events yet
README

skill-audit

CI License: MIT npm Claude Code Codex OpenCode

Deterministic audit trail for Claude Code, Codex and opencode sessions: which observable skills were invoked, when, and which files were changed afterwards. It gives you a quick skill-compliance signal before you read the code.

On opencode it also renders live in the TUI sidebar, and on Claude Code in a live pane, so the audit sits next to the conversation with no command to run.

● Skill audit — f3a91c2e · ~/Dev/Web · 14:02→16:40

  ⚡ superpowers:brainstorming
  ⚡ superpowers:test-driven-development
     14:00  (2)
       14:06 ✎ src/auth/token.ts  Edit
       14:09 ✎ src/auth/token.test.ts  Write
     15:00  (1)
       15:12 ✎ src/auth/session.ts  Edit
  ⚠ (no skill active)
     16:00  (1)
       16:40 ✎ src/index.ts  Edit

● 2 skill runs (2 distinct) · 4 files touched · ⚠ 1 edits outside skill context

The skill leads each block; the hours beneath it show when its work actually happened. Times are your local clock — the log itself stays UTC, so a session stays readable across machines.

Why

You ask an agent to follow a skill. Did it? Reading the transcript to find out is slow, and asking another model to judge costs tokens and is itself non-deterministic.

LLMs are not deterministic. Hook events are. This plugin logs the facts exposed by each host's documented hook API:

  • No LLM judging, no tokens. Everything except the two in-session slash/skill commands runs entirely outside the model.
  • No transcript parsing. Only documented hook payloads, so nothing silently breaks on a transcript format change.
  • One data model across three hosts. The NDJSON log is the only contract, so the CLI reads opencode sessions and the opencode sidebar reads Claude Code sessions.
  • The red flag is a number. ⚠ edits outside skill context counts files changed while no observable skill was active.

Contents

How it works

PostToolUse hooks capture skill-tool calls and file edits. On Codex, a UserPromptSubmit hook also captures explicit $skill-name references, and apply_patch payloads are expanded into one file event per path. On opencode a plugin does the same job through the tool.execute.after hook. Events are appended as NDJSON to ~/.claude/skill-audit/<session_id>.ndjson. All three hosts write to that one directory on purpose, so any viewer can read any host's session. Override the location with SKILL_AUDIT_DIR.

A skill run only claims the edits that keep arriving under it. Once 30 minutes pass with no activity the run closes, and later files are counted as (no skill active) rather than being attributed to whatever skill happened to run that morning. Tune the window with SKILL_AUDIT_IDLE_MINUTES.

The opencode sidebar draws in fixed columns, so it defaults to icons that are always one terminal cell wide (· ✎ !); emoji such as ⚡ and ⚠ render two cells wide in most terminals and shift every row that carries them. Set SKILL_AUDIT_ICONS=emoji to use the emoji set anyway.

Claude Code / Codex ──hooks───▶ logger.sh ──┐
                                            ├─▶ ~/.claude/skill-audit/<sid>.ndjson
opencode ──tool.execute.after──▶ plugin ────┘             │
                                                          ├─▶ skill-audit status/report/watch/list
                                                          ├─▶ opencode sidebar (live)
                                                          └─▶ Claude Code pane (live)

The log is the only contract between the writers and the viewers, so the CLI reads opencode sessions and the opencode sidebar reads Claude Code sessions.

Subagent hook calls use the parent session ID, so delegated edits appear in the same audit.

Install

Claude Code

/plugin marketplace add DepickereSven/skill-audit
/plugin install skill-audit@depickeresven-skill-audit

Restart Claude Code so the hooks load.

Live pane

The plugin also opens a Skill audit pane showing the current session's timeline. It refreshes after every skill and file-edit tool call, and polls the log every two seconds.

The Claude Code Skill audit pane docked beside the transcript

  • Opening: it opens by itself at session start once the terminal is 144 columns or wider. Narrower terminals hold it back until you run /skill-audit-pane, which opens it at any width.
  • Placement: in fullscreen from 110 columns it docks beside the transcript, 40 columns wide (drag the edge to change it; your width is kept), and shows the full timeline. Press a ▼/▶ marker to collapse or expand a run or an hour. Elsewhere it sits above the prompt in a compact form: the header and the latest run.
  • Other panes: panes from other plugins become tabs beside it (click a tab, or ctrl+x tab). Only one is shown at a time.
Showing and hiding the pane
CommandWhat
/skill-audit-paneClose the pane when it is open, open it (at any width) when it is not
Esc or the close markClose the pane for this session only; the next session opens it again

Closing with /skill-audit-pane is remembered in the plugin's own store (a JSON file under your Claude Code configuration directory), so new sessions keep it closed until you run /skill-audit-pane again, which opens it and turns auto-open back on. Hiding never stops recording: the shell hooks keep writing the log, and the CLI and the pane read it back the moment you open it again.

The pane is display only. The shell hooks keep writing the log through logger.sh, so recording works the same on Claude Code builds without plugin function hooks.

Migrating from a manual setup? Remove any logger.sh entries from the hooks block of ~/.claude/settings.json first, or every event is logged twice.

Codex

codex plugin marketplace add DepickereSven/skill-audit
codex plugin add skill-audit@depickeresven-skill-audit

Start a new Codex session after installation. Codex asks you to review and trust plugin-bundled hooks before they run. Plugins are available in Codex CLI and the ChatGPT desktop app's Codex surface, but not in the IDE extension. See the official plugin and hook documentation.

Invoke the bundled Codex skill with $skill-audit.

opencode

opencode plugin opencode-skill-audit --global

The plugin is published on npm as opencode-skill-audit.

Restart opencode so the plugin loads. It registers two things: a server hook that logs skill, edit, write, multiedit, apply_patch and patch tool calls, and a sidebar section that renders the current session's timeline live.

The opencode sidebar rendering a live skill-audit timeline beside a conversation

Click the header to collapse the section, or a skill row to fold its files away.

opencode discovers skills natively, including from .claude/skills/ and .agents/skills/, so skills you already use are logged without moving them. To get the in-session report as well, link the bundled skill into a directory opencode scans:

ln -sf "$PWD/skills/skill-audit" ~/.config/opencode/skills/skill-audit

CLI on your PATH (recommended)

The viewer works from any terminal. Link the installed script somewhere on your PATH.

Claude Code:

ln -sf ~/.claude/plugins/cache/*/skill-audit/*/scripts/skill-audit ~/.local/bin/skill-audit

Codex:

ln -sf ~/.codex/plugins/cache/*/skill-audit/*/scripts/skill-audit ~/.local/bin/skill-audit

You can also clone this repository and link scripts/skill-audit directly.

~/.local/bin is not on every system's PATH. Check with command -v skill-audit. If it prints nothing, add the directory in your shell profile:

export PATH="$HOME/.local/bin:$PATH"

Claude Code statusline segment (optional)

Plugins cannot modify your statusline. Add this to your own statusline script to get a live ⚡2 ✎5 tdd segment:

sid=$(echo "$input" | jq -r '.session_id // empty')
audit_log="$HOME/.claude/skill-audit/$sid.ndjson"
if [ -n "$sid" ] && [ -s "$audit_log" ]; then
  audit=$(jq -rs '
    [ .[] | select(.kind=="skill") ] as $s
    | ([ .[] | select(.kind=="file") | .path ] | unique | length) as $f
    | "⚡\($s|length) ✎\($f)"
      + (if ($s|length) > 0 then " " + ($s[-1].name | split(":") | last) else "" end)
  ' "$audit_log" 2>/dev/null)
  [ -n "$audit" ] && parts="$parts | $audit"
fi

Verify the install

Hooks that never fire look exactly like a session with no skill usage, so confirm once after installing.

  1. In a new session on the host you installed, invoke any skill and edit one file. Invoking this plugin's own skill is enough:
  2. Claude Code: /skill-audit
  3. Codex: $skill-audit
  4. opencode: watch the sidebar section appear
  5. Check that a log exists and is growing. Look in ~/.claude/skill-audit, or in the directory you set as SKILL_AUDIT_DIR:
   ls -la ~/.claude/skill-audit/
  1. Read it back from any terminal:
   skill-audit list      # sessions, newest first
   skill-audit status    # counts + recent timeline for the newest session

If list prints no session logs in ..., nothing was written. Go to Troubleshooting.

Usage

CLI

Works from any terminal, on logs from any host. None of these cost tokens.

CommandWhat
skill-audit status [sid]Compact counts and recent timeline
skill-audit report [sid]Full timeline
skill-audit watch [sid]Live view, refreshed every two seconds. q quits
skill-audit listRecent sessions, newest first
skill-audit --helpUsage summary

status, report and watch all take an optional session ID. Without one they use the most recently modified log, which is the wrong session if you run several at once. Get the ID from skill-audit list and pass it explicitly.

Inside a session

HostCommandWhatTokens
Claude Code/skill-audit-paneShow or hide the live pane (details)0
Claude Code! skill-audit statusRun the CLI in the session. Queues while the model is busy0
Claude Code/skill-auditPrint the full report in the transcriptModel turn
Codex$skill-auditPrint the full report in the transcriptModel turn
opencodeskill-audit skillPrint the full report, once linked (opencode)Model turn

Live views

WhereWhat
Claude Code paneOpens at session start from 144 columns; /skill-audit-pane at any width
opencode sidebarAlways on beside the conversation. Click the header to collapse it
skill-audit watchAny terminal, any host

Configuration

Set these in the environment of the host (and of your shell, for the CLI). All are optional.

VariableDefaultWhat
SKILL_AUDIT_DIR~/.claude/skill-auditWhere logs are written and read
SKILL_AUDIT_IDLE_MINUTES30Idle minutes after which a skill run stops claiming edits
SKILL_AUDIT_ICONSone-cell iconsemoji uses ⚡ / ⚠ in the pane and sidebar (two cells wide in most terminals)

The ⚠ edits outside skill context counter is the compliance red flag: files changed while no observable skill was active.

Data format

One NDJSON file per session, one event per line, appended in chronological order:

{"ts":"2026-07-10T14:05:11Z","kind":"skill","name":"superpowers:test-driven-development","args":"","cwd":"/Users/me/proj","source":"tool"}
{"ts":"2026-07-10T14:06:40Z","kind":"file","tool":"apply_patch","path":"/Users/me/proj/src/auth/token.ts","cwd":"/Users/me/proj"}
FieldOnMeaning
tsbothUTC timestamp, YYYY-MM-DDThh:mm:ssZ
kindbothskill or file, the only two event kinds
cwdbothSession working directory as reported by the host
nameskillSkill identifier, e.g. superpowers:test-driven-development
argsskillArguments passed to the skill tool, empty string when there were none
sourceskilltool for an observed skill tool call, prompt for a Codex $skill-name
turn_idskillCodex turn identifier, present only when the host supplies one
toolfileTool that made the edit: Edit, Write, MultiEdit, apply_patch, etc.
pathfileAbsolute path of the changed file (relative paths are resolved against cwd)

Within a Codex turn, a repeated (turn_id, name) skill pair is written once, so a skill named several times in one prompt does not inflate the counts.

The format is open, so you can build other viewers on top. Prune old logs with:

find ~/.claude/skill-audit -name '*.ndjson' -mtime +30 -delete

Troubleshooting

Nothing is logged / no session logs in ...

  • Did you restart the host after installing? Hooks and plugins load at startup, and an existing session keeps running without them.
  • Confirm the plugin is installed: claude plugin list, codex plugin list, or check the plugin array in ~/.config/opencode/opencode.json.
  • On Codex, hooks only run after you review and trust them, so accept the prompt.
  • Is jq installed? logger.sh exits silently without it, by design: the hook must never block a session. Check with command -v jq.

Every event appears twice. A manual logger.sh entry is still in the hooks block of ~/.claude/settings.json alongside the plugin's. Remove the manual one.

The CLI shows an empty or unrelated session. Without an argument the viewer picks the most recently modified log, which is the wrong one when sessions run in parallel. Run skill-audit list and pass the ID: skill-audit report <sid>.

The CLI finds nothing but the logs exist. Writer and viewer disagree about the directory. If you set SKILL_AUDIT_DIR for the host, export it for your shell too. Otherwise the CLI looks in ~/.claude/skill-audit.

skill-audit: command not found. The symlink is missing or its directory is not on PATH. See CLI on your PATH.

The Claude Code pane no longer opens at session start. You closed it with /skill-audit-pane, which is remembered across sessions. Run /skill-audit-pane again to show it and turn auto-open back on. If you never hid it, your terminal is likely narrower than 144 columns; the command opens it at any width.

A skill ran but is missing from the timeline. Expected in some cases. See Honest limitations.

Honest limitations

  • Invocation is not compliance. The log proves that a skill was explicitly selected or exposed as a tool event, not that the result followed every instruction.
  • Codex automatic skill loading is not a hook event today. Explicit $skill-name references are captured. Skills that Codex chooses automatically are not. No transcript parsing is used to fill that gap.
  • Prompt-sourced entries are syntax-level evidence. Codex supplies plain prompt text to the hook, so a lower-case dollar-prefixed token can be logged even if it does not resolve to an installed skill. The NDJSON source: "prompt" field distinguishes these entries.
  • Only observable file tools are captured. Claude Edit/Write/MultiEdit/NotebookEdit, Codex apply_patch, and opencode edit/write/multiedit/apply_patch/patch edits are logged. Files created indirectly by shell commands are not visible as separate file events.
  • opencode agents and subagents are not logged. They have no equivalent on the other two hosts, so logging them would add an event kind only one host can emit. The audit keeps one data model across all three.
  • Session-start injected skills are context, not skill tool calls, and do not appear.
  • Concurrent sessions: the newest-log default can pick the wrong session. Pass the session ID explicitly after using skill-audit list.

Uninstall

Claude Code:

claude plugin uninstall skill-audit@depickeresven-skill-audit

Codex:

codex plugin remove skill-audit@depickeresven-skill-audit

opencode has no removal subcommand. Delete the "opencode-skill-audit" entry from the plugin array in ~/.config/opencode/opencode.json (or the project-local opencode.json), and remove the skill symlink if you made one:

rm -f ~/.config/opencode/skills/skill-audit

Then restart the host. Finally, clean up the CLI symlink and the logs, which no uninstall touches:

rm -f ~/.local/bin/skill-audit
rm -rf ~/.claude/skill-audit

Development

The hooks and the CLI viewer are plain bash (scripts/) with no build step. The opencode plugin and sidebar are TypeScript (src/) built with Bun.

PathWhat
scripts/logger.sh hook target, the skill-audit CLI, sync-versions.mjs
src/opencode plugin (index.ts), sidebar (tui.ts), shared log and view code
hooks/Claude Code shell hooks (hooks.json) and the live pane (claude.tsx)
types/$.state contract for the Claude Code pane
test/Bun tests, fixtures, and test/format-contract.sh
.claude-plugin/Claude Code plugin manifest and marketplace entry
.codex-plugin/Codex plugin manifest
.agents/Codex marketplace manifest
skills/The skill-audit skill used by Codex and opencode
commands/The /skill-audit slash command for Claude Code
.opencode/Local opencode workspace for testing the plugin during development
bun install
bun run check     # format:check + lint + typecheck (src and test) + tests
bun run build     # dist/index.js (plugin) and dist/tui.js (sidebar)
ScriptWhat
bun run formatPrettier, write mode
bun run lintESLint over src and test
bun run typechecktsc --noEmit for src
bun testBun test suite in test/
bun run checkEverything CI runs
bun run test:claudeValidate and test the Claude Code pane (needs claude)
bun run typecheck:claudeType-check the pane (needs .claude-plugin/types/)
bun run check:versionsAssert package.json and both plugin.json files agree
bun run sync:versionsRewrite the plugin manifests from package.json

Tests live in test/. The Claude Code pane is the exception: its tests (hooks/*.test.tsx) run in Claude Code's mod sandbox, which has no Node or Bun, so bun test is rooted at test/ (bunfig.toml). test:claude copies the pane's files into a scratch folder and runs claude plugin validate and claude plugin test there. src/core.ts and src/view.ts must stay free of node:* imports and process because the pane imports them; test/core.test.ts checks this. typecheck:claude reads the API types the engine writes to .claude-plugin/types/ once it has loaded the plugin from a local folder. That folder is gitignored and not available in CI, so neither pane check runs in CI.

test/format-contract.sh pins the rendered CLI output against fixtures, so a change to the timeline format has to be updated there deliberately. That output is the contract the sidebar and any third-party viewer rely on.

Releasing

Two workflows, chained.

.github/workflows/ci.yml runs four jobs: it checks that both plugin manifests carry the same version as package.json, runs bun run check on Bun 1.2.0 and on the latest Bun, builds the bundles, and packs the npm tarball to confirm it ships every entry point package.json exports. It runs on pull requests and on pushes to main, not on every branch, so a pull request is never tested twice.

.github/workflows/publish.yml starts only when a CI run on main finishes successfully. It checks out that exact commit and asks npm whether the version in package.json already exists:

  • Already on npm. Nothing is released. This is what an ordinary push to main does.
Source 4 files
hooks/claude.tsx 207 lines
1import type { EngineInterface, Register } from "claude-code";
2import { atom, read, update } from "claude-code";
3
4import { idleGapFrom, type SessionView, toView } from "../src/core";
5import { iconsFor, type Line, sidebarLines, type Tone } from "../src/view";
6import type { AuditPaneView } from "../types";
7
8/**
9 * The skill-audit timeline as a live Claude Code pane. Display only: the shell
10 * hooks in hooks.json keep writing the log through logger.sh, so recording
11 * never depends on this early-access API.
12 */
13
14const PANE = "skill-audit";
15const TITLE = "Skill audit";
16const COMMAND = "skill-audit-pane";
17const POLL_MS = 2000;
18/**
19 * `$.store` key, kept between sessions: set when `/skill-audit-pane` closes the
20 * pane, cleared when it opens it. While set, a new session does not open the
21 * pane by itself.
22 */
23const HIDDEN = "hidden";
24/**
25 * The dock width asked for, in body columns: room for a file row (indent, time
26 * and icon take 12) plus a readable path. A width the person drags wins.
27 */
28const COLUMNS = 40;
29
30const EMPTY_VIEW: AuditPaneView = {
31    runs: [],
32    summary: {
33        runs: 0,
34        distinct: 0,
35        files: 0,
36        orphan: 0,
37    },
38    cwd: "",
39};
40
41const view = atom({ plugin: "skill-audit", key: "view" } as const, EMPTY_VIEW);
42const source = atom({ plugin: "skill-audit", key: "source" } as const, "");
43const collapsed = atom({ plugin: "skill-audit", key: "collapsed" } as const, [] as string[]);
44const readError = atom({ plugin: "skill-audit", key: "readError" } as const, false);
45
46/** Same resolution as `logPath` in src/log.ts, through `$` instead of Node. */
47async function logFile($: EngineInterface): Promise<string> {
48    const dir: string =
49        (await $.env.get("SKILL_AUDIT_DIR")) || `${await $.env.get("HOME")}/.claude/skill-audit`;
50    return `${dir}/${await $.session.id()}.ndjson`;
51}
52
53/**
54 * Re-read the session log. The id is asked every time, so after `/clear` the
55 * pane follows the new session's log. An unchanged log writes nothing, so an
56 * idle poll never redraws.
57 */
58async function refresh($: EngineInterface): Promise<void> {
59    let text = "";
60    try {
61        const path: string = await logFile($);
62        if (await $.fs.exists(path)) {
63            const body = await $.fs.read(path);
64            text = typeof body === "string" ? body : "";
65        }
66    } catch {
67        await update($, readError, () => true);
68        return;
69    }
70
71    await update($, readError, () => false);
72    if (text === (await read($, source))) {
73        return;
74    }
75    const next: SessionView = toView(
76        text,
77        idleGapFrom(await $.env.get("SKILL_AUDIT_IDLE_MINUTES")),
78    );
79    await update($, view, () => next);
80    await update($, source, () => text);
81}
82
83function toggle(keys: string[], key: string): string[] {
84    return keys.includes(key) ? keys.filter((one) => one !== key) : [...keys, key];
85}
86
87function toneProps(tone: Tone): { color?: string; dimColor?: boolean } {
88    if (tone === "accent") {
89        return { color: "cyan" };
90    }
91    if (tone === "warning") {
92        return { color: "yellow" };
93    }
94    if (tone === "muted") {
95        return { dimColor: true };
96    }
97    return {};
98}
99
100/** `  ▼ 14:00 (4)` -> indent 2, marker `▼`, rest `14:00 (4)`. */
101function splitMarker(text: string): { indent: number; marker: string; rest: string } | null {
102    const match = /^( *)(\S+) (.*)$/.exec(text);
103    return match ? { indent: match[1]!.length, marker: match[2]!, rest: match[3]! } : null;
104}
105
106export const register: Register = (on) => {
107    on("session.start", async ($, e, next) => {
108        await $.command.register({
109            name: COMMAND,
110            description: "Show or hide this session's skill-audit timeline pane",
111        });
112        // Opened unasked, so it seats from 144 columns and waits below that;
113        // no `focus`, so it never takes a tab another plugin is showing.
114        if ((await $.store.get(HIDDEN)) !== true) {
115            void $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS });
116        }
117        $.clock.every(POLL_MS, () => void refresh($));
118        await refresh($);
119
120        return next(e);
121    });
122
123    on("command.run", { command: COMMAND }, async ($) => {
124        const isOpen: boolean = (await $.ui.panes()).some((pane) => pane.id === PANE);
125
126        if (isOpen) {
127            await $.store.set(HIDDEN, true);
128            await $.ui.close({ id: PANE });
129            return { text: `Skill audit pane hidden. /${COMMAND} brings it back.` };
130        }
131
132        await $.store.delete(HIDDEN);
133        await refresh($);
134        await $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS });
135
136        return { text: "Skill audit pane opened." };
137    });
138
139    // The tools whose PostToolUse hooks in hooks.json append to the log, written
140    // out literally so the matcher stays readable to `claude plugin validate`.
141    // MultiEdit is left out: current builds have no such tool to match.
142    // The shell hook may land after this refresh; the poll picks that up.
143    on("tool.call", { tool: ["Skill", "Edit", "Write", "NotebookEdit"] }, async ($, e, next) => {
144        const ran = await next(e);
145        await refresh($);
146
147        return ran;
148    });
149
150    on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
151        const { Box, Button, Text } = $.ui.resolve(e);
152        const current: AuditPaneView = await read($, view);
153        const keys: string[] = await read($, collapsed);
154        const failed: boolean = await read($, readError);
155        const icons = iconsFor(await $.env.get("SKILL_AUDIT_ICONS"));
156
157        // Inline sits above the prompt: header and the latest run only, and no
158        // toggles, since the run keys there would not match the full list.
159        const isDocked: boolean = e.props.placement === "dock";
160        const shown: SessionView = isDocked
161            ? current
162            : { ...current, runs: current.runs.slice(-1) };
163        const lines: Line[] = sidebarLines(shown, {
164            sectionOpen: true,
165            collapsed: new Set(keys),
166            width: e.props.bodyColumns,
167            icons,
168        });
169        // The pane has its own frame and close mark; the section marker is noise.
170        const header: string = lines[0]!.text.replace(`${icons.open} `, "");
171
172        return (
173            <Box flexDirection="column">
174                <Text bold>{header}</Text>
175                {lines.slice(1).map((line, index) => {
176                    const parts = line.key && isDocked ? splitMarker(line.text) : null;
177                    const isToggle: boolean =
178                        parts !== null &&
179                        (parts.marker === icons.open || parts.marker === icons.closed);
180                    if (!parts || !line.key || !isToggle) {
181                        return (
182                            <Text key={`line:${index}`} {...toneProps(line.tone)}>
183                                {line.text}
184                            </Text>
185                        );
186                    }
187                    const key: string = line.key;
188                    return (
189                        <Box key={`row:${key}`} flexDirection="row" marginLeft={parts.indent}>
190                            <Button
191                                key={key}
192                                plain
193                                label={parts.marker}
194                                onPress={() =>
195                                    update($, collapsed, (list) => toggle(list ?? [], key))
196                                }
197                            />
198                            <Text {...toneProps(line.tone)}> {parts.rest}</Text>
199                        </Box>
200                    );
201                })}
202                {failed && <Text dimColor>log unreadable, showing last read</Text>}
203            </Box>
204        );
205    });
206};
207
src/core.ts 182 lines
1/**
2 * The log model with no Node dependency. The Claude Code pane runs in a
3 * sandbox without `node:*` modules or `process`, so everything it shares with
4 * the CLI and the opencode sidebar lives here; `log.ts` adds the file I/O.
5 */
6
7export type SkillEvent = {
8    ts: string;
9    kind: "skill";
10    name: string;
11    args?: string;
12    cwd?: string;
13    source?: string;
14};
15
16export type FileEvent = {
17    ts: string;
18    kind: "file";
19    tool: string;
20    path: string;
21    cwd?: string;
22};
23
24export type AuditEvent = SkillEvent | FileEvent;
25
26/**
27 * An environment variable where the host has one, `undefined` where it does
28 * not. Read through `globalThis` because a bare `process` throws in the Claude
29 * Code sandbox.
30 */
31export function env(name: string): string | undefined {
32    const host = globalThis as { process?: { env?: Record<string, string | undefined> } };
33    return host.process?.env?.[name];
34}
35
36/**
37 * Parse NDJSON log text. Malformed lines are dropped rather than thrown on: the
38 * sidebar reads the log while the logger is appending to it, so a truncated
39 * trailing line is expected, not exceptional.
40 */
41export function parse(text: string): AuditEvent[] {
42    const events: AuditEvent[] = [];
43    for (const line of text.split("\n")) {
44        if (!line.trim()) {
45            continue;
46        }
47        try {
48            const event = JSON.parse(line);
49            if (event?.kind === "skill" || event?.kind === "file") {
50                events.push(event);
51            }
52        } catch {
53            // Ignore malformed or incomplete lines while a log is being written.
54        }
55    }
56    return events;
57}
58
59export const NO_SKILL = "(no skill active)";
60
61export type TimelineFile = { ts: string; tool: string; path: string };
62export type SkillRun = { skill: string; ts: string; files: TimelineFile[] };
63
64/** How long a skill run may sit idle before it stops claiming later edits. */
65export const DEFAULT_IDLE_GAP_MS = 30 * 60_000;
66
67/** `SKILL_AUDIT_IDLE_MINUTES` as milliseconds, or the default when unusable. */
68export function idleGapFrom(minutes: string | undefined): number {
69    const value = Number(minutes);
70    return Number.isFinite(value) && value > 0 ? value * 60_000 : DEFAULT_IDLE_GAP_MS;
71}
72
73/**
74 * The idle gap, in milliseconds. Without one a single skill invoked in the
75 * morning claims every edit made for the rest of the day.
76 */
77export function idleGapMs(): number {
78    return idleGapFrom(env("SKILL_AUDIT_IDLE_MINUTES"));
79}
80
81/** Epoch milliseconds, or NaN for a timestamp this log did not write. */
82function epoch(ts: string): number {
83    return new Date(ts).getTime();
84}
85
86/**
87 * Group a flat event list into skill runs, each carrying the files edited after
88 * it. Port of the jq reduce in scripts/skill-audit; the two must agree.
89 *
90 * A run only claims edits that keep arriving: once `gapMs` passes with no
91 * activity, later files fall into a synthetic run instead. An unparseable
92 * timestamp compares as NaN, which never exceeds the gap, so logs written
93 * before this rule existed group exactly as they did before.
94 */
95export function group(events: AuditEvent[], gapMs: number = idleGapMs()): SkillRun[] {
96    const runs: SkillRun[] = [];
97    let lastActivity: number = NaN;
98
99    for (const event of events) {
100        if (event.kind === "skill") {
101            runs.push({
102                skill: event.name,
103                ts: event.ts,
104                files: [],
105            });
106            lastActivity = epoch(event.ts);
107            continue;
108        }
109
110        const current: SkillRun | undefined = runs[runs.length - 1];
111        const stale: boolean = epoch(event.ts) - lastActivity > gapMs;
112        // A synthetic run has no skill to go stale, so a gap never splits it.
113        if (!current || (stale && current.skill !== NO_SKILL)) {
114            runs.push({
115                skill: NO_SKILL,
116                ts: event.ts,
117                files: [],
118            });
119        }
120        runs[runs.length - 1]!.files.push({
121            ts: event.ts,
122            tool: event.tool,
123            path: event.path,
124        });
125        lastActivity = epoch(event.ts);
126    }
127    return runs;
128}
129
130export type Summary = { runs: number; distinct: number; files: number; orphan: number };
131
132export function summarize(events: AuditEvent[], gapMs: number = idleGapMs()): Summary {
133    const names: string[] = [];
134    const paths = new Set<string>();
135
136    for (const event of events) {
137        if (event.kind === "skill") {
138            names.push(event.name);
139        } else {
140            paths.add(event.path);
141        }
142    }
143
144    const orphan = group(events, gapMs)
145        .filter((run) => run.skill === NO_SKILL)
146        .reduce((total, run) => total + run.files.length, 0);
147
148    return {
149        runs: names.length,
150        distinct: new Set(names).size,
151        files: paths.size,
152        orphan,
153    };
154}
155
156export type SessionView = { runs: SkillRun[]; summary: Summary; cwd: string };
157
158/** A session's view from its raw log text; empty text is an empty session. */
159export function toView(text: string, gapMs: number = idleGapMs()): SessionView {
160    const events: AuditEvent[] = parse(text);
161    const cwd: string = events.find((event) => event.cwd)?.cwd ?? "";
162    return {
163        runs: group(events, gapMs),
164        summary: summarize(events, gapMs),
165        cwd,
166    };
167}
168
169/** `node:path` basename for POSIX paths: the last segment, trailing slashes ignored. */
170export function baseName(path: string): string {
171    const trimmed: string = path.replace(/\/+$/, "");
172    return trimmed.slice(trimmed.lastIndexOf("/") + 1);
173}
174
175/**
176 * `path` relative to `cwd` when it sits under it, else `path` as given. The
177 * sidebar only ever shortens paths inside the session directory.
178 */
179export function relativeTo(path: string, cwd: string): string {
180    return cwd && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path;
181}
182
src/view.ts 231 lines
1import {
2    baseName,
3    env,
4    NO_SKILL,
5    relativeTo,
6    type SessionView,
7    type SkillRun,
8    type Summary,
9    type TimelineFile,
10} from "./core";
11
12export type IconSet = {
13    run: string;
14    file: string;
15    warn: string;
16    open: string;
17    closed: string;
18};
19
20/**
21 * The sidebar is laid out in fixed columns, so an icon the terminal draws two
22 * cells wide shifts every row carrying it. `⚡` and `⚠` are emoji-presentation
23 * characters and do exactly that, so the default set replaces them with glyphs
24 * that are always one cell. `SKILL_AUDIT_ICONS=emoji` opts back in.
25 */
26const TEXT_ICONS: IconSet = {
27    run: "·",
28    file: "✎",
29    warn: "!",
30    open: "▼",
31    closed: "▶",
32};
33const EMOJI_ICONS: IconSet = {
34    run: "⚡",
35    file: "✎",
36    warn: "⚠",
37    open: "▼",
38    closed: "▶",
39};
40
41/** The icon set for a `SKILL_AUDIT_ICONS` value; anything but `emoji` is the text set. */
42export function iconsFor(style: string | undefined): IconSet {
43    return style === "emoji" ? EMOJI_ICONS : TEXT_ICONS;
44}
45
46export function icons(): IconSet {
47    return iconsFor(env("SKILL_AUDIT_ICONS"));
48}
49
50function pad(value: number): string {
51    return String(value).padStart(2, "0");
52}
53
54/**
55 * Logs are written in UTC so they stay comparable across machines; the sidebar
56 * shows the clock the user was actually looking at.
57 */
58function local(ts: string): Date | null {
59    const date = new Date(ts);
60    return Number.isNaN(date.getTime()) ? null : date;
61}
62
63export function hhmm(ts: string): string {
64    const date = local(ts);
65    return date ? `${pad(date.getHours())}:${pad(date.getMinutes())}` : "";
66}
67
68/** The local hour a timestamp belongs to, as a bucket label. */
69export function hourKey(ts: string): string {
70    const date = local(ts);
71    return date ? `${pad(date.getHours())}:00` : "";
72}
73
74/** `superpowers:brainstorming` -> `brainstorming`, the way the statusline snippet does. */
75export function shortName(name: string): string {
76    const parts = name.split(":");
77    return parts[parts.length - 1] || name;
78}
79
80/** `day` is carried so a run spanning midnight gets one bucket per calendar hour. */
81export type HourBucket = { day: string; hour: string; files: TimelineFile[] };
82
83/**
84 * Split a run's files into consecutive local-hour buckets. Consecutive rather
85 * than keyed, so the same hour on the next day opens a second bucket instead of
86 * folding a day's gap into one row.
87 */
88export function bucketByHour(files: TimelineFile[]): HourBucket[] {
89    const buckets: HourBucket[] = [];
90
91    for (const file of files) {
92        const day: string = file.ts.slice(0, 10);
93        const hour: string = hourKey(file.ts);
94        const last: HourBucket | undefined = buckets[buckets.length - 1];
95        if (!last || last.day !== day || last.hour !== hour) {
96            buckets.push({
97                day,
98                hour,
99                files: [],
100            });
101        }
102        buckets[buckets.length - 1]!.files.push(file);
103    }
104    return buckets;
105}
106
107export function headerLine(summary: Summary, icon: IconSet = icons()): string {
108    const counts = `${icon.run}${summary.runs} ${icon.file}${summary.files}${
109        summary.orphan > 0 ? ` ${icon.warn}${summary.orphan}` : ""
110    }`;
111    return `Skill audit  ${counts}`;
112}
113
114/** The skill name leads the row; its times live on the hour buckets below it. */
115export function runTitle(run: SkillRun, collapsed: boolean, icon: IconSet = icons()): string {
116    const name: string = run.skill === NO_SKILL ? `${icon.warn} no skill` : shortName(run.skill);
117    if (run.files.length === 0) {
118        return `  ${name}`;
119    }
120    return collapsed ? `${icon.closed} ${name} (${run.files.length})` : `${icon.open} ${name}`;
121}
122
123export function hourTitle(bucket: HourBucket, collapsed: boolean, icon: IconSet = icons()): string {
124    const marker: string = collapsed ? icon.closed : icon.open;
125    return `${marker} ${bucket.hour} (${bucket.files.length})`;
126}
127
128/**
129 * Fit a path into the sidebar: relative to the session directory, then the
130 * basename, then a truncated basename.
131 */
132export function displayPath(path: string, cwd: string, width: number): string {
133    const rel: string = relativeTo(path, cwd);
134    if (rel.length <= width) {
135        return rel;
136    }
137    const base: string = baseName(rel);
138    if (base.length <= width) {
139        return base;
140    }
141    return `${base.slice(0, Math.max(0, width - 1))}…`;
142}
143
144export type Tone = "text" | "muted" | "accent" | "warning";
145/** `key` marks a collapsible node; the sidebar toggles whatever key it carries. */
146export type Line = { text: string; tone: Tone; key?: string };
147
148export type SidebarOptions = {
149    /** Whether the whole section is expanded. */
150    sectionOpen: boolean;
151    /** Keys of runs and hour buckets whose children are hidden. */
152    collapsed: Set<string>;
153    /** Usable sidebar width, in columns. */
154    width: number;
155    /** Icons to draw with; the environment's set when omitted. */
156    icons?: IconSet;
157};
158
159const HOUR_INDENT = "  ";
160const FILE_INDENT = "    ";
161
162export function runKey(index: number): string {
163    return `run:${index}`;
164}
165
166export function hourKeyOf(index: number, bucket: HourBucket): string {
167    return `${runKey(index)}/hour:${bucket.day} ${bucket.hour}`;
168}
169
170/**
171 * The entire sidebar section as plain lines. Keeping layout here rather than in
172 * the OpenTUI glue means it can be tested without a terminal.
173 */
174export function sidebarLines(view: SessionView, options: SidebarOptions): Line[] {
175    const icon: IconSet = options.icons ?? icons();
176    const marker = options.sectionOpen ? icon.open : icon.closed;
177    const lines: Line[] = [
178        {
179            text: `${marker} ${headerLine(view.summary, icon)}`,
180            tone: "text",
181        },
182    ];
183
184    if (!options.sectionOpen) {
185        return lines;
186    }
187
188    if (view.runs.length === 0) {
189        lines.push({
190            text: "  no events yet",
191            tone: "muted",
192        });
193        return lines;
194    }
195
196    view.runs.forEach((run, index) => {
197        const key: string = runKey(index);
198        const runCollapsed: boolean = options.collapsed.has(key);
199        lines.push({
200            text: runTitle(run, runCollapsed, icon),
201            tone: run.skill === NO_SKILL ? "warning" : "accent",
202            key,
203        });
204        if (runCollapsed) {
205            return;
206        }
207
208        for (const bucket of bucketByHour(run.files)) {
209            const bucketKey: string = hourKeyOf(index, bucket);
210            const hourCollapsed: boolean = options.collapsed.has(bucketKey);
211            lines.push({
212                text: `${HOUR_INDENT}${hourTitle(bucket, hourCollapsed, icon)}`,
213                tone: "text",
214                key: bucketKey,
215            });
216            if (hourCollapsed) {
217                continue;
218            }
219            for (const file of bucket.files) {
220                const stamp = `${hhmm(file.ts)} ${icon.file} `;
221                const room: number = options.width - FILE_INDENT.length - stamp.length;
222                lines.push({
223                    text: `${FILE_INDENT}${stamp}${displayPath(file.path, view.cwd, room)}`,
224                    tone: "muted",
225                });
226            }
227        }
228    });
229    return lines;
230}
231
types/index.d.ts 28 lines
1// The Claude Code pane's `$.state` contract. Values live with the host, not the
2// module, so a hot reload keeps the timeline and the person's collapsed rows.
3// Self-contained by rule: these mirror `SessionView` in src/core.ts, and the
4// hooks module assigns one to the other, so tsc catches any drift.
5
6export type AuditPaneFile = { ts: string; tool: string; path: string };
7export type AuditPaneRun = { skill: string; ts: string; files: AuditPaneFile[] };
8export type AuditPaneView = {
9    runs: AuditPaneRun[];
10    summary: { runs: number; distinct: number; files: number; orphan: number };
11    cwd: string;
12};
13
14declare module "claude-code" {
15    interface PluginState {
16        "skill-audit": {
17            /** The last view built from the session log. */
18            view: AuditPaneView;
19            /** The log text `view` was built from; an unchanged read redraws nothing. */
20            source: string;
21            /** Keys of runs and hour buckets the person collapsed. */
22            collapsed: string[];
23            /** The last read failed for a reason other than a missing log. */
24            readError: boolean;
25        };
26    }
27}
28