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

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.
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:
⚠ edits outside skill context counts files changed while no observable skill was active.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.
/plugin marketplace add DepickereSven/skill-audit
/plugin install skill-audit@depickeresven-skill-audit
Restart Claude Code so the hooks load.
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.

/skill-audit-pane, which opens it at any width.▼/▶ 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.| Command | What |
|---|---|
/skill-audit-pane | Close the pane when it is open, open it (at any width) when it is not |
| Esc or the close mark | Close 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.shentries from thehooksblock of~/.claude/settings.jsonfirst, or every event is logged twice.
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 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.

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
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"
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
Hooks that never fire look exactly like a session with no skill usage, so confirm once after installing.
/skill-audit$skill-audit~/.claude/skill-audit, or in the directory you set as SKILL_AUDIT_DIR: ls -la ~/.claude/skill-audit/
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.
Works from any terminal, on logs from any host. None of these cost tokens.
| Command | What |
|---|---|
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 list | Recent sessions, newest first |
skill-audit --help | Usage 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.
| Host | Command | What | Tokens |
|---|---|---|---|
| Claude Code | /skill-audit-pane | Show or hide the live pane (details) | 0 |
| Claude Code | ! skill-audit status | Run the CLI in the session. Queues while the model is busy | 0 |
| Claude Code | /skill-audit | Print the full report in the transcript | Model turn |
| Codex | $skill-audit | Print the full report in the transcript | Model turn |
| opencode | skill-audit skill | Print the full report, once linked (opencode) | Model turn |
| Where | What |
|---|---|
| Claude Code pane | Opens at session start from 144 columns; /skill-audit-pane at any width |
| opencode sidebar | Always on beside the conversation. Click the header to collapse it |
skill-audit watch | Any terminal, any host |
Set these in the environment of the host (and of your shell, for the CLI). All are optional.
| Variable | Default | What |
|---|---|---|
SKILL_AUDIT_DIR | ~/.claude/skill-audit | Where logs are written and read |
SKILL_AUDIT_IDLE_MINUTES | 30 | Idle minutes after which a skill run stops claiming edits |
SKILL_AUDIT_ICONS | one-cell icons | emoji 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.
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"}
| Field | On | Meaning |
|---|---|---|
ts | both | UTC timestamp, YYYY-MM-DDThh:mm:ssZ |
kind | both | skill or file, the only two event kinds |
cwd | both | Session working directory as reported by the host |
name | skill | Skill identifier, e.g. superpowers:test-driven-development |
args | skill | Arguments passed to the skill tool, empty string when there were none |
source | skill | tool for an observed skill tool call, prompt for a Codex $skill-name |
turn_id | skill | Codex turn identifier, present only when the host supplies one |
tool | file | Tool that made the edit: Edit, Write, MultiEdit, apply_patch, etc. |
path | file | Absolute 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
Nothing is logged / no session logs in ...
claude plugin list, codex plugin list, or check the plugin array in ~/.config/opencode/opencode.json.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.
$skill-name references are captured. Skills that Codex chooses automatically are not. No transcript parsing is used to fill that gap.source: "prompt" field distinguishes these entries.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.skill-audit list.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
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.
| Path | What |
|---|---|
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)
| Script | What |
|---|---|
bun run format | Prettier, write mode |
bun run lint | ESLint over src and test |
bun run typecheck | tsc --noEmit for src |
bun test | Bun test suite in test/ |
bun run check | Everything CI runs |
bun run test:claude | Validate and test the Claude Code pane (needs claude) |
bun run typecheck:claude | Type-check the pane (needs .claude-plugin/types/) |
bun run check:versions | Assert package.json and both plugin.json files agree |
bun run sync:versions | Rewrite 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.
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:
main does.hooks/claude.tsx 207 lines1import 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};
207src/core.ts 182 lines1/**
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}
182src/view.ts 231 lines1import {
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}
231types/index.d.ts 28 lines1// 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