opencode-skill-audit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sven Depickere
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,367 @@
1
+ # skill-audit
2
+
3
+ [![CI](https://github.com/DepickereSven/skill-audit/actions/workflows/ci.yml/badge.svg)](https://github.com/DepickereSven/skill-audit/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
+
6
+ Deterministic audit trail for [Claude Code](https://code.claude.com),
7
+ [Codex](https://developers.openai.com/codex/) and [opencode](https://opencode.ai) sessions: which
8
+ observable **skills** were invoked, when, and which **files** were changed afterwards. It gives you
9
+ a quick skill-compliance signal before you read the code.
10
+
11
+ On opencode it also renders live in the TUI sidebar, so the audit sits next to the conversation
12
+ with no command to run.
13
+
14
+ ```text
15
+ ● Skill audit — f3a91c2e · ~/Dev/Web · 14:02→14:20 UTC
16
+
17
+ 14:02 ⚡ superpowers:brainstorming
18
+ 14:05 ⚡ superpowers:test-driven-development
19
+ 14:06 │ ✎ src/auth/token.ts Edit
20
+ 14:09 │ ✎ src/auth/token.test.ts Write
21
+ 14:20 ⚠ (no skill active)
22
+ 14:20 │ ✎ src/index.ts Edit
23
+
24
+ ● 2 skill runs (2 distinct) · 3 files touched · ⚠ 1 edits outside skill context
25
+ ```
26
+
27
+ ## Why
28
+
29
+ You ask an agent to follow a skill. Did it? Reading the transcript to find out is slow, and asking
30
+ another model to judge costs tokens and is itself non-deterministic.
31
+
32
+ LLMs are not deterministic; hook events are. This plugin logs the facts exposed by each host's
33
+ documented hook API:
34
+
35
+ - **No LLM judging, no tokens.** Everything except the two in-session slash/skill commands runs
36
+ entirely outside the model.
37
+ - **No transcript parsing.** Only documented hook payloads, so nothing silently breaks on a
38
+ transcript format change.
39
+ - **One data model across three hosts.** The NDJSON log is the only contract, so the CLI reads
40
+ opencode sessions and the opencode sidebar reads Claude Code sessions.
41
+ - **The red flag is a number.** `⚠ edits outside skill context` counts files changed while no
42
+ observable skill was active.
43
+
44
+ ## Contents
45
+
46
+ - [How it works](#how-it-works)
47
+ - [Install](#install)
48
+ - [Verify the install](#verify-the-install)
49
+ - [Usage](#usage)
50
+ - [Data format](#data-format)
51
+ - [Troubleshooting](#troubleshooting)
52
+ - [Honest limitations](#honest-limitations)
53
+ - [Uninstall](#uninstall)
54
+ - [Requirements](#requirements)
55
+ - [Development](#development)
56
+ - [License](#license)
57
+
58
+ ## How it works
59
+
60
+ `PostToolUse` hooks capture skill-tool calls and file edits. On Codex, a `UserPromptSubmit` hook
61
+ also captures explicit `$skill-name` references, and `apply_patch` payloads are expanded into one
62
+ file event per path. On opencode a plugin does the same job through the `tool.execute.after` hook.
63
+ Events are appended as NDJSON to `~/.claude/skill-audit/<session_id>.ndjson`; override the location
64
+ with `SKILL_AUDIT_DIR`.
65
+
66
+ ```text
67
+ Claude Code / Codex ──hooks───▶ logger.sh ──┐
68
+ ├─▶ ~/.claude/skill-audit/<sid>.ndjson
69
+ opencode ──tool.execute.after──▶ plugin ────┘ │
70
+ ├─▶ skill-audit status/report/watch/list
71
+ └─▶ opencode sidebar (live)
72
+ ```
73
+
74
+ The log is the only contract between the writers and the viewers, so the CLI reads opencode
75
+ sessions and the opencode sidebar reads Claude Code sessions.
76
+
77
+ Subagent hook calls use the parent session ID, so delegated edits appear in the same audit.
78
+
79
+ ## Install
80
+
81
+ ### Claude Code
82
+
83
+ ```text
84
+ /plugin marketplace add DepickereSven/skill-audit
85
+ /plugin install skill-audit@depickeresven-skill-audit
86
+ ```
87
+
88
+ Restart Claude Code so the hooks load.
89
+
90
+ > **Migrating from a manual setup?** Remove any `logger.sh` entries from the `hooks` block of
91
+ > `~/.claude/settings.json` first, or every event is logged twice.
92
+
93
+ ### Codex
94
+
95
+ ```bash
96
+ codex plugin marketplace add DepickereSven/skill-audit
97
+ codex plugin add skill-audit@depickeresven-skill-audit
98
+ ```
99
+
100
+ Start a new Codex session after installation. Codex asks you to review and trust plugin-bundled
101
+ hooks before they run. Plugins are available in Codex CLI and the ChatGPT desktop app's Codex
102
+ surface, but not in the IDE extension. See the official
103
+ [plugin](https://developers.openai.com/codex/plugins/build) and
104
+ [hook](https://developers.openai.com/codex/hooks) documentation.
105
+
106
+ Invoke the bundled Codex skill with `$skill-audit`.
107
+
108
+ ### opencode
109
+
110
+ ```bash
111
+ opencode plugin opencode-skill-audit --global
112
+ ```
113
+
114
+ Restart opencode so the plugin loads. It registers two things: a server hook that logs
115
+ `skill`, `edit`, `write` and `apply_patch` tool calls, and a sidebar section that renders the
116
+ current session's timeline live.
117
+
118
+
119
+ ![OpenCode Image](docs/opencode.png)
120
+
121
+ Click the header to collapse the section, or a skill row to fold its files away.
122
+
123
+ opencode discovers skills natively, including from `.claude/skills/` and `.agents/skills/`, so
124
+ skills you already use are logged without moving them. To get the in-session report as well, link
125
+ the bundled skill into a directory opencode scans:
126
+
127
+ ```bash
128
+ ln -sf "$PWD/skills/skill-audit" ~/.config/opencode/skills/skill-audit
129
+ ```
130
+
131
+ ### CLI on your PATH (recommended)
132
+
133
+ The viewer works from any terminal. Link the installed script somewhere on your PATH.
134
+
135
+ Claude Code:
136
+
137
+ ```bash
138
+ ln -sf ~/.claude/plugins/cache/*/skill-audit/*/scripts/skill-audit ~/.local/bin/skill-audit
139
+ ```
140
+
141
+ Codex:
142
+
143
+ ```bash
144
+ ln -sf ~/.codex/plugins/cache/*/skill-audit/*/scripts/skill-audit ~/.local/bin/skill-audit
145
+ ```
146
+
147
+ You can also clone this repository and link `scripts/skill-audit` directly.
148
+
149
+ `~/.local/bin` is not on every system's `PATH`. Check with `command -v skill-audit`; if it prints
150
+ nothing, add the directory in your shell profile:
151
+
152
+ ```bash
153
+ export PATH="$HOME/.local/bin:$PATH"
154
+ ```
155
+
156
+ ### Claude Code statusline segment (optional)
157
+
158
+ Plugins cannot modify your statusline. Add this to your own statusline script to get a live
159
+ `⚡2 ✎5 tdd` segment:
160
+
161
+ ```sh
162
+ sid=$(echo "$input" | jq -r '.session_id // empty')
163
+ audit_log="$HOME/.claude/skill-audit/$sid.ndjson"
164
+ if [ -n "$sid" ] && [ -s "$audit_log" ]; then
165
+ audit=$(jq -rs '
166
+ [ .[] | select(.kind=="skill") ] as $s
167
+ | ([ .[] | select(.kind=="file") | .path ] | unique | length) as $f
168
+ | "⚡\($s|length) ✎\($f)"
169
+ + (if ($s|length) > 0 then " " + ($s[-1].name | split(":") | last) else "" end)
170
+ ' "$audit_log" 2>/dev/null)
171
+ [ -n "$audit" ] && parts="$parts | $audit"
172
+ fi
173
+ ```
174
+
175
+ ## Verify the install
176
+
177
+ Hooks that never fire look exactly like a session with no skill usage, so confirm once after
178
+ installing.
179
+
180
+ 1. In a **new** session on the host you installed, invoke any skill and edit one file. Invoking
181
+ this plugin's own skill is enough:
182
+ - Claude Code: `/skill-audit`
183
+ - Codex: `$skill-audit`
184
+ - opencode: watch the sidebar section appear
185
+ 2. Check that a log exists and is growing:
186
+
187
+ ```bash
188
+ ls -la ~/.claude/skill-audit/
189
+ ```
190
+
191
+ 3. Read it back from any terminal:
192
+
193
+ ```bash
194
+ skill-audit list # sessions, newest first
195
+ skill-audit status # counts + recent timeline for the newest session
196
+ ```
197
+
198
+ If `list` prints `no session logs in ...`, nothing was written — go to
199
+ [Troubleshooting](#troubleshooting).
200
+
201
+ ## Usage
202
+
203
+ | Command | What | Tokens |
204
+ |----------------------------|------------------------------------------------------------------|-----------:|
205
+ | `skill-audit status [sid]` | Compact counts and recent timeline | 0 |
206
+ | `skill-audit report [sid]` | Full timeline | 0 |
207
+ | `skill-audit watch [sid]` | Live view, refreshed every two seconds; `q` quits | 0 |
208
+ | `skill-audit list` | Recent sessions | 0 |
209
+ | `skill-audit --help` | Usage summary | 0 |
210
+ | `! skill-audit status` | Run inside a Claude Code session; queues while the model is busy | 0 |
211
+ | opencode sidebar | Live timeline beside the conversation; no command to run | 0 |
212
+ | `/skill-audit` | Show the report inside Claude Code | Model turn |
213
+ | `$skill-audit` | Show the report inside Codex | Model turn |
214
+
215
+ `status`, `report` and `watch` all take an optional session ID. Without one they use the most
216
+ recently modified log, which is the wrong session if you run several at once — get the ID from
217
+ `skill-audit list` and pass it explicitly.
218
+
219
+ The `⚠ edits outside skill context` counter is the compliance red flag: files changed while no
220
+ observable skill was active.
221
+
222
+ ## Data format
223
+
224
+ One NDJSON file per session, one event per line, appended in chronological order:
225
+
226
+ ```json
227
+ {"ts":"2026-07-10T14:05:11Z","kind":"skill","name":"superpowers:test-driven-development","args":"","cwd":"/Users/me/proj","source":"tool"}
228
+ {"ts":"2026-07-10T14:06:40Z","kind":"file","tool":"apply_patch","path":"/Users/me/proj/src/auth/token.ts","cwd":"/Users/me/proj"}
229
+ ```
230
+
231
+ | Field | On | Meaning |
232
+ |-----------|---------|-------------------------------------------------------------------------------|
233
+ | `ts` | both | UTC timestamp, `YYYY-MM-DDThh:mm:ssZ` |
234
+ | `kind` | both | `skill` or `file` — the only two event kinds |
235
+ | `cwd` | both | Session working directory as reported by the host |
236
+ | `name` | `skill` | Skill identifier, e.g. `superpowers:test-driven-development` |
237
+ | `args` | `skill` | Arguments passed to the skill tool; empty string when there were none |
238
+ | `source` | `skill` | `tool` for an observed skill tool call, `prompt` for a Codex `$skill-name` |
239
+ | `turn_id` | `skill` | Codex turn identifier; present only when the host supplies one |
240
+ | `tool` | `file` | Tool that made the edit: `Edit`, `Write`, `NotebookEdit`, `apply_patch`… |
241
+ | `path` | `file` | Absolute path of the changed file (relative paths are resolved against `cwd`) |
242
+
243
+ Within a Codex turn, a repeated `(turn_id, name)` skill pair is written once, so a skill named
244
+ several times in one prompt does not inflate the counts.
245
+
246
+ The format is open, so you can build other viewers on top. Prune old logs with:
247
+
248
+ ```bash
249
+ find ~/.claude/skill-audit -name '*.ndjson' -mtime +30 -delete
250
+ ```
251
+
252
+ ## Troubleshooting
253
+
254
+ **Nothing is logged / `no session logs in ...`**
255
+
256
+ - Did you restart the host after installing? Hooks and plugins load at startup, and an existing
257
+ session keeps running without them.
258
+ - Confirm the plugin is installed: `claude plugin list`, `codex plugin list`, or check the
259
+ `plugin` array in `~/.config/opencode/opencode.json`.
260
+ - On Codex, hooks only run after you review and trust them — accept the prompt.
261
+ - Is `jq` installed? `logger.sh` exits silently without it, by design: the hook must never block a
262
+ session. Check with `command -v jq`.
263
+
264
+ **Every event appears twice.** A manual `logger.sh` entry is still in the `hooks` block of
265
+ `~/.claude/settings.json` alongside the plugin's. Remove the manual one.
266
+
267
+ **The CLI shows an empty or unrelated session.** Without an argument the viewer picks the most
268
+ recently modified log, which is the wrong one when sessions run in parallel. Run `skill-audit list`
269
+ and pass the ID: `skill-audit report <sid>`.
270
+
271
+ **The CLI finds nothing but the logs exist.** Writer and viewer disagree about the directory. If
272
+ you set `SKILL_AUDIT_DIR` for the host, export it for your shell too — otherwise the CLI looks in
273
+ `~/.claude/skill-audit`.
274
+
275
+ **`skill-audit: command not found`.** The symlink is missing or its directory is not on `PATH`;
276
+ see [CLI on your PATH](#cli-on-your-path-recommended).
277
+
278
+ **A skill ran but is missing from the timeline.** Expected in some cases — see
279
+ [Honest limitations](#honest-limitations).
280
+
281
+ ## Honest limitations
282
+
283
+ - **Invocation is not compliance.** The log proves that a skill was explicitly selected or exposed
284
+ as a tool event, not that the result followed every instruction.
285
+ - **Codex automatic skill loading is not a hook event today.** Explicit `$skill-name` references
286
+ are captured; skills that Codex chooses automatically are not. No transcript parsing is used to
287
+ fill that gap.
288
+ - **Prompt-sourced entries are syntax-level evidence.** Codex supplies plain prompt text to the
289
+ hook, so a lower-case dollar-prefixed token can be logged even if it does not resolve to an
290
+ installed skill. The NDJSON `source: "prompt"` field distinguishes these entries.
291
+ - **Only observable file tools are captured.** Claude `Edit`/`Write`/`NotebookEdit`, Codex
292
+ `apply_patch`, and opencode `edit`/`write`/`apply_patch` edits are logged. Files created
293
+ indirectly by shell commands are not visible as separate file events.
294
+ - **opencode agents and subagents are not logged.** They have no equivalent on the other two
295
+ hosts, so logging them would add an event kind only one host can emit. The audit keeps one data
296
+ model across all three.
297
+ - **Session-start injected skills** are context, not skill tool calls, and do not appear.
298
+ - **Concurrent sessions:** the newest-log default can pick the wrong session; pass the session ID
299
+ explicitly after using `skill-audit list`.
300
+
301
+ ## Uninstall
302
+
303
+ Claude Code:
304
+
305
+ ```bash
306
+ claude plugin uninstall skill-audit@depickeresven-skill-audit
307
+ ```
308
+
309
+ Codex:
310
+
311
+ ```bash
312
+ codex plugin remove skill-audit@depickeresven-skill-audit
313
+ ```
314
+
315
+ opencode has no removal subcommand — delete the `"opencode-skill-audit"` entry from the `plugin`
316
+ array in `~/.config/opencode/opencode.json` (or the project-local `opencode.json`), and remove the
317
+ skill symlink if you made one:
318
+
319
+ ```bash
320
+ rm -f ~/.config/opencode/skills/skill-audit
321
+ ```
322
+
323
+ Then restart the host. Finally, clean up the CLI symlink and the logs, which no uninstall touches:
324
+
325
+ ```bash
326
+ rm -f ~/.local/bin/skill-audit
327
+ rm -rf ~/.claude/skill-audit
328
+ ```
329
+
330
+ ## Requirements
331
+
332
+ - macOS or Linux
333
+ - `bash` and `jq` for the CLI viewer and the Claude Code / Codex hooks
334
+ - opencode `>= 1.14` for the plugin and its sidebar
335
+
336
+ ## Development
337
+
338
+ The hooks and the CLI viewer are plain bash (`scripts/`) with no build step. The opencode plugin
339
+ and sidebar are TypeScript (`src/`) built with [Bun](https://bun.sh).
340
+
341
+ ```bash
342
+ bun install
343
+ bun run check # format:check + lint + typecheck (src and test) + tests
344
+ bun run build # dist/index.js (plugin) and dist/tui.js (sidebar)
345
+ ```
346
+
347
+ | Script | What |
348
+ |--------------------------|----------------------------------------------------------|
349
+ | `bun run format` | Prettier, write mode |
350
+ | `bun run lint` | ESLint over `src` and `test` |
351
+ | `bun run typecheck` | `tsc --noEmit` for `src` |
352
+ | `bun test` | Bun test suite in `test/` |
353
+ | `bun run check` | Everything CI runs |
354
+ | `bun run check:versions` | Assert `package.json` and both `plugin.json` files agree |
355
+ | `bun run sync:versions` | Rewrite the plugin manifests from `package.json` |
356
+
357
+ Tests live in `test/`. `test/format-contract.sh` pins the rendered CLI output against fixtures, so
358
+ a change to the timeline format has to be updated there deliberately — that output is the contract
359
+ the sidebar and any third-party viewer rely on.
360
+
361
+ Version bumps go through `npm version`, which runs `scripts/sync-versions.mjs` and stages
362
+ `.claude-plugin/plugin.json` and `.codex-plugin/plugin.json` alongside it. CI (`.github/workflows/ci.yml`)
363
+ runs `bun run check` plus a manifest-version job on every push and pull request.
364
+
365
+ ## License
366
+
367
+ [MIT](LICENSE) © Sven Depickere
@@ -0,0 +1,13 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ /**
3
+ * Server plugin: turns completed tool calls into audit events.
4
+ *
5
+ * The equivalent of the PostToolUse hooks the plugin registers on Claude Code
6
+ * and Codex, writing the same NDJSON log those hosts write.
7
+ */
8
+ export declare const SkillAuditServer: Plugin;
9
+ declare const _default: {
10
+ id: string;
11
+ server: Plugin;
12
+ };
13
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,126 @@
1
+ // @bun
2
+ // src/log.ts
3
+ import { appendFileSync, mkdirSync, readFileSync } from "fs";
4
+ import { homedir } from "os";
5
+ import { isAbsolute, join } from "path";
6
+ function logDir() {
7
+ return process.env.SKILL_AUDIT_DIR || join(homedir(), ".claude", "skill-audit");
8
+ }
9
+ function logPath(sessionID) {
10
+ return join(logDir(), `${sessionID}.ndjson`);
11
+ }
12
+ function append(sessionID, event) {
13
+ try {
14
+ mkdirSync(logDir(), { recursive: true });
15
+ appendFileSync(logPath(sessionID), `${JSON.stringify(event)}
16
+ `);
17
+ } catch {
18
+ return;
19
+ }
20
+ }
21
+ function appendSkill(sessionID, input) {
22
+ if (!input.name)
23
+ return;
24
+ append(sessionID, {
25
+ ts: input.ts,
26
+ kind: "skill",
27
+ name: input.name,
28
+ args: "",
29
+ cwd: input.cwd,
30
+ source: "tool"
31
+ });
32
+ }
33
+ function appendFile(sessionID, input) {
34
+ if (!input.path || input.path === "/dev/null") {
35
+ return;
36
+ }
37
+ const path = isAbsolute(input.path) || !input.cwd ? input.path : join(input.cwd, input.path);
38
+ append(sessionID, {
39
+ ts: input.ts,
40
+ kind: "file",
41
+ tool: input.tool,
42
+ path,
43
+ cwd: input.cwd
44
+ });
45
+ }
46
+ function parsePatch(args) {
47
+ const patchText = typeof args === "string" ? args : typeof args?.patchText === "string" ? args.patchText : typeof args?.patch === "string" ? args.patch : "";
48
+ const paths = [];
49
+ for (const line of patchText.split(`
50
+ `)) {
51
+ const match = /^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$/.exec(line.trim());
52
+ const path = match?.[1]?.trim();
53
+ if (path && !paths.includes(path)) {
54
+ paths.push(path);
55
+ }
56
+ }
57
+ return paths;
58
+ }
59
+ function nowTs(date = new Date) {
60
+ return `${date.toISOString().slice(0, 19)}Z`;
61
+ }
62
+
63
+ // src/record.ts
64
+ var FILE_TOOLS = new Set(["edit", "write", "multiedit"]);
65
+ var PATCH_TOOLS = new Set(["apply_patch", "patch"]);
66
+ function stringField(args, key) {
67
+ const value = args?.[key];
68
+ return typeof value === "string" ? value : "";
69
+ }
70
+ function record(call, cwd) {
71
+ try {
72
+ const { tool, sessionID, args } = call;
73
+ const ts = nowTs();
74
+ if (tool === "skill") {
75
+ appendSkill(sessionID, {
76
+ ts,
77
+ name: stringField(args, "name") || stringField(args, "skill"),
78
+ cwd
79
+ });
80
+ return;
81
+ }
82
+ if (FILE_TOOLS.has(tool)) {
83
+ appendFile(sessionID, {
84
+ ts,
85
+ tool,
86
+ path: stringField(args, "filePath"),
87
+ cwd
88
+ });
89
+ return;
90
+ }
91
+ if (PATCH_TOOLS.has(tool)) {
92
+ for (const path of parsePatch(args)) {
93
+ appendFile(sessionID, {
94
+ ts,
95
+ tool,
96
+ path,
97
+ cwd
98
+ });
99
+ }
100
+ }
101
+ } catch {
102
+ return;
103
+ }
104
+ }
105
+
106
+ // src/index.ts
107
+ var SkillAuditServer = async ({ directory, worktree }) => {
108
+ const cwd = directory || worktree || process.cwd();
109
+ return {
110
+ "tool.execute.after": async ({ tool, sessionID, args }) => {
111
+ record({
112
+ tool,
113
+ sessionID,
114
+ args
115
+ }, cwd);
116
+ }
117
+ };
118
+ };
119
+ var src_default = {
120
+ id: "skill-audit",
121
+ server: SkillAuditServer
122
+ };
123
+ export {
124
+ SkillAuditServer,
125
+ src_default as default
126
+ };
package/dist/log.d.ts ADDED
@@ -0,0 +1,77 @@
1
+ /** Directory holding the per-session NDJSON logs, shared with the bash logger. */
2
+ export declare function logDir(): string;
3
+ export declare function logPath(sessionID: string): string;
4
+ export type SkillEvent = {
5
+ ts: string;
6
+ kind: "skill";
7
+ name: string;
8
+ args?: string;
9
+ cwd?: string;
10
+ source?: string;
11
+ };
12
+ export type FileEvent = {
13
+ ts: string;
14
+ kind: "file";
15
+ tool: string;
16
+ path: string;
17
+ cwd?: string;
18
+ };
19
+ export type AuditEvent = SkillEvent | FileEvent;
20
+ /**
21
+ * Parse NDJSON log text. Malformed lines are dropped rather than thrown on: the
22
+ * sidebar reads the log while the logger is appending to it, so a truncated
23
+ * trailing line is expected, not exceptional.
24
+ */
25
+ export declare function parse(text: string): AuditEvent[];
26
+ export declare const NO_SKILL = "(no skill active)";
27
+ export type TimelineFile = {
28
+ ts: string;
29
+ tool: string;
30
+ path: string;
31
+ };
32
+ export type SkillRun = {
33
+ skill: string;
34
+ ts: string;
35
+ files: TimelineFile[];
36
+ };
37
+ /**
38
+ * Group a flat event list into skill runs, each carrying the files edited after
39
+ * it. Port of the jq reduce in scripts/skill-audit; the two must agree.
40
+ */
41
+ export declare function group(events: AuditEvent[]): SkillRun[];
42
+ export type Summary = {
43
+ runs: number;
44
+ distinct: number;
45
+ files: number;
46
+ orphan: number;
47
+ };
48
+ export declare function summarize(events: AuditEvent[]): Summary;
49
+ export declare function appendSkill(sessionID: string, input: {
50
+ ts: string;
51
+ name: string;
52
+ cwd: string;
53
+ }): void;
54
+ export declare function appendFile(sessionID: string, input: {
55
+ ts: string;
56
+ tool: string;
57
+ path: string;
58
+ cwd: string;
59
+ }): void;
60
+ /**
61
+ * Extract touched paths from an apply_patch payload. opencode uses the same
62
+ * `*** Begin Patch` envelope as Codex, so this mirrors the sed parsing in
63
+ * logger.sh. Duplicates are collapsed, order of first appearance kept.
64
+ */
65
+ export declare function parsePatch(args: unknown): string[];
66
+ /** Second-precision UTC timestamp, matching `date -u +%FT%TZ` in logger.sh. */
67
+ export declare function nowTs(date?: Date): string;
68
+ export type SessionView = {
69
+ runs: SkillRun[];
70
+ summary: Summary;
71
+ cwd: string;
72
+ };
73
+ /**
74
+ * Read one session's log. A missing or unreadable file is an empty session, not
75
+ * an error: the sidebar renders before the first event is ever written.
76
+ */
77
+ export declare function readSession(sessionID: string): SessionView;
@@ -0,0 +1,10 @@
1
+ export type ToolCall = {
2
+ tool: string;
3
+ sessionID: string;
4
+ args: unknown;
5
+ };
6
+ /**
7
+ * Map one completed opencode tool call onto audit events. Never throws: a hook
8
+ * that disturbs the session is worse than a missing log line.
9
+ */
10
+ export declare function record(call: ToolCall, cwd: string): void;
package/dist/tui.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { TuiPlugin } from "@opencode-ai/plugin/tui";
2
+ export declare const tui: TuiPlugin;
3
+ declare const _default: {
4
+ id: string;
5
+ tui: TuiPlugin;
6
+ };
7
+ export default _default;
package/dist/tui.js ADDED
@@ -0,0 +1,307 @@
1
+ // @bun
2
+ // src/tui.ts
3
+ import { createElement, insert, setProp } from "@opentui/solid";
4
+ import { createSignal, onCleanup } from "solid-js";
5
+
6
+ // src/log.ts
7
+ import { appendFileSync, mkdirSync, readFileSync } from "fs";
8
+ import { homedir } from "os";
9
+ import { isAbsolute, join } from "path";
10
+ function logDir() {
11
+ return process.env.SKILL_AUDIT_DIR || join(homedir(), ".claude", "skill-audit");
12
+ }
13
+ function logPath(sessionID) {
14
+ return join(logDir(), `${sessionID}.ndjson`);
15
+ }
16
+ function parse(text) {
17
+ const events = [];
18
+ for (const line of text.split(`
19
+ `)) {
20
+ if (!line.trim()) {
21
+ continue;
22
+ }
23
+ try {
24
+ const event = JSON.parse(line);
25
+ if (event?.kind === "skill" || event?.kind === "file") {
26
+ events.push(event);
27
+ }
28
+ } catch {}
29
+ }
30
+ return events;
31
+ }
32
+ var NO_SKILL = "(no skill active)";
33
+ function group(events) {
34
+ const runs = [];
35
+ for (const event of events) {
36
+ if (event.kind === "skill") {
37
+ runs.push({
38
+ skill: event.name,
39
+ ts: event.ts,
40
+ files: []
41
+ });
42
+ continue;
43
+ }
44
+ if (runs.length === 0) {
45
+ runs.push({
46
+ skill: NO_SKILL,
47
+ ts: event.ts,
48
+ files: []
49
+ });
50
+ }
51
+ runs[runs.length - 1].files.push({
52
+ ts: event.ts,
53
+ tool: event.tool,
54
+ path: event.path
55
+ });
56
+ }
57
+ return runs;
58
+ }
59
+ function summarize(events) {
60
+ const names = [];
61
+ const paths = new Set;
62
+ for (const event of events) {
63
+ if (event.kind === "skill") {
64
+ names.push(event.name);
65
+ } else {
66
+ paths.add(event.path);
67
+ }
68
+ }
69
+ const orphan = group(events).filter((run) => run.skill === NO_SKILL).reduce((total, run) => total + run.files.length, 0);
70
+ return {
71
+ runs: names.length,
72
+ distinct: new Set(names).size,
73
+ files: paths.size,
74
+ orphan
75
+ };
76
+ }
77
+ var EMPTY = {
78
+ runs: [],
79
+ summary: {
80
+ runs: 0,
81
+ distinct: 0,
82
+ files: 0,
83
+ orphan: 0
84
+ },
85
+ cwd: ""
86
+ };
87
+ function readSession(sessionID) {
88
+ let text;
89
+ try {
90
+ text = readFileSync(logPath(sessionID), "utf8");
91
+ } catch {
92
+ return EMPTY;
93
+ }
94
+ const events = parse(text);
95
+ const cwd = events.find((event) => event.cwd)?.cwd ?? "";
96
+ return {
97
+ runs: group(events),
98
+ summary: summarize(events),
99
+ cwd
100
+ };
101
+ }
102
+
103
+ // src/view.ts
104
+ import { basename, relative } from "path";
105
+ function hhmm(ts) {
106
+ return ts.slice(11, 16);
107
+ }
108
+ function shortName(name) {
109
+ const parts = name.split(":");
110
+ return parts[parts.length - 1] || name;
111
+ }
112
+ function headerLine(summary) {
113
+ const counts = `\u26A1${summary.runs} \u270E${summary.files}${summary.orphan > 0 ? ` \u26A0${summary.orphan}` : ""}`;
114
+ return `Skill audit ${counts}`;
115
+ }
116
+ function runTitle(run, collapsed) {
117
+ const time = hhmm(run.ts);
118
+ if (run.skill === NO_SKILL) {
119
+ return `\u26A0 ${time} no skill`;
120
+ }
121
+ const name = shortName(run.skill);
122
+ if (run.files.length === 0) {
123
+ return ` ${time} ${name}`;
124
+ }
125
+ return collapsed ? `\u25B6 ${time} ${name} (${run.files.length})` : `\u25BC ${time} ${name}`;
126
+ }
127
+ function displayPath(path, cwd, width) {
128
+ const rel = cwd && path.startsWith(`${cwd}/`) ? relative(cwd, path) : path;
129
+ if (rel.length <= width) {
130
+ return rel;
131
+ }
132
+ const base = basename(rel);
133
+ if (base.length <= width) {
134
+ return base;
135
+ }
136
+ return `${base.slice(0, Math.max(0, width - 1))}\u2026`;
137
+ }
138
+ var FILE_INDENT = " \u270E ";
139
+ function sidebarLines(view, options) {
140
+ const marker = options.sectionOpen ? "\u25BC" : "\u25B6";
141
+ const lines = [
142
+ {
143
+ text: `${marker} ${headerLine(view.summary)}`,
144
+ tone: "text"
145
+ }
146
+ ];
147
+ if (!options.sectionOpen) {
148
+ return lines;
149
+ }
150
+ if (view.runs.length === 0) {
151
+ lines.push({
152
+ text: " no events yet",
153
+ tone: "muted"
154
+ });
155
+ return lines;
156
+ }
157
+ view.runs.forEach((run, index) => {
158
+ const collapsed = options.collapsed.has(index);
159
+ lines.push({
160
+ text: runTitle(run, collapsed),
161
+ tone: run.skill === NO_SKILL ? "warning" : "accent",
162
+ runIndex: index
163
+ });
164
+ if (collapsed) {
165
+ return;
166
+ }
167
+ for (const file of run.files) {
168
+ lines.push({
169
+ text: `${FILE_INDENT}${displayPath(file.path, view.cwd, options.width - FILE_INDENT.length)}`,
170
+ tone: "muted"
171
+ });
172
+ }
173
+ });
174
+ return lines;
175
+ }
176
+
177
+ // src/watch.ts
178
+ import { statSync, watch } from "fs";
179
+ function mtime(path) {
180
+ try {
181
+ return statSync(path).mtimeMs;
182
+ } catch {
183
+ return 0;
184
+ }
185
+ }
186
+ function watchSession(sessionID, onChange, options = {}) {
187
+ const debounceMs = options.debounceMs ?? 100;
188
+ const pollMs = options.pollMs ?? 2000;
189
+ const path = logPath(sessionID);
190
+ let last = mtime(path);
191
+ let timer;
192
+ let stopped = false;
193
+ const fire = () => {
194
+ if (stopped) {
195
+ return;
196
+ }
197
+ const current = mtime(path);
198
+ if (current === last) {
199
+ return;
200
+ }
201
+ last = current;
202
+ onChange();
203
+ };
204
+ const schedule = () => {
205
+ if (stopped) {
206
+ return;
207
+ }
208
+ clearTimeout(timer);
209
+ timer = setTimeout(fire, debounceMs);
210
+ };
211
+ let watcher;
212
+ try {
213
+ watcher = watch(logDir(), schedule);
214
+ } catch {
215
+ watcher = undefined;
216
+ }
217
+ const poll = setInterval(fire, pollMs);
218
+ return () => {
219
+ stopped = true;
220
+ clearTimeout(timer);
221
+ clearInterval(poll);
222
+ watcher?.close();
223
+ };
224
+ }
225
+
226
+ // src/tui.ts
227
+ var ORDER = 800;
228
+ var DEFAULT_WIDTH = 30;
229
+ function element(tag, props, children = []) {
230
+ const node = createElement(tag);
231
+ for (const [key, value] of Object.entries(props)) {
232
+ if (value !== undefined) {
233
+ setProp(node, key, value);
234
+ }
235
+ }
236
+ for (const child of children) {
237
+ if (child !== null && child !== undefined && child !== false) {
238
+ insert(node, child);
239
+ }
240
+ }
241
+ return node;
242
+ }
243
+ function toneColor(theme, tone) {
244
+ if (tone === "accent") {
245
+ return theme.accent;
246
+ }
247
+ if (tone === "warning") {
248
+ return theme.warning;
249
+ }
250
+ if (tone === "muted") {
251
+ return theme.textMuted;
252
+ }
253
+ return theme.text;
254
+ }
255
+ function toggle(set, index) {
256
+ const next = new Set(set);
257
+ if (!next.delete(index)) {
258
+ next.add(index);
259
+ }
260
+ return next;
261
+ }
262
+ function Section(api, sessionID, width) {
263
+ const [view, setView] = createSignal(readSession(sessionID));
264
+ const [sectionOpen, setSectionOpen] = createSignal(true);
265
+ const [collapsed, setCollapsed] = createSignal(new Set);
266
+ const redraw = () => api.renderer.requestRender();
267
+ onCleanup(watchSession(sessionID, () => {
268
+ setView(readSession(sessionID));
269
+ redraw();
270
+ }));
271
+ const rows = () => sidebarLines(view(), {
272
+ sectionOpen: sectionOpen(),
273
+ collapsed: new Set(collapsed()),
274
+ width
275
+ }).map((line, index) => {
276
+ const onMouseDown = index === 0 ? () => {
277
+ setSectionOpen((open) => !open);
278
+ redraw();
279
+ } : line.runIndex !== undefined ? () => {
280
+ setCollapsed((current) => toggle(current, line.runIndex));
281
+ redraw();
282
+ } : undefined;
283
+ return element("text", {
284
+ fg: toneColor(api.theme.current, line.tone),
285
+ onMouseDown
286
+ }, [line.text]);
287
+ });
288
+ return element("box", {
289
+ width: "100%",
290
+ flexDirection: "column"
291
+ }, [rows]);
292
+ }
293
+ var tui = async (api, options) => {
294
+ const width = typeof options?.["width"] === "number" ? options["width"] : DEFAULT_WIDTH;
295
+ api.slots.register({
296
+ order: ORDER,
297
+ slots: { sidebar_content: (_ctx, props) => Section(api, props.session_id, width) }
298
+ });
299
+ };
300
+ var tui_default = {
301
+ id: "skill-audit",
302
+ tui
303
+ };
304
+ export {
305
+ tui_default as default,
306
+ tui
307
+ };
package/dist/view.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { type SessionView, type SkillRun, type Summary } from "./log";
2
+ export declare function hhmm(ts: string): string;
3
+ /** `superpowers:brainstorming` -> `brainstorming`, the way the statusline snippet does. */
4
+ export declare function shortName(name: string): string;
5
+ export declare function headerLine(summary: Summary): string;
6
+ export declare function runTitle(run: SkillRun, collapsed: boolean): string;
7
+ /**
8
+ * Fit a path into the sidebar: relative to the session directory, then the
9
+ * basename, then a truncated basename.
10
+ */
11
+ export declare function displayPath(path: string, cwd: string, width: number): string;
12
+ export type Tone = "text" | "muted" | "accent" | "warning";
13
+ export type Line = {
14
+ text: string;
15
+ tone: Tone;
16
+ runIndex?: number;
17
+ };
18
+ export type SidebarOptions = {
19
+ /** Whether the whole section is expanded. */
20
+ sectionOpen: boolean;
21
+ /** Indices of runs whose files are hidden. */
22
+ collapsed: Set<number>;
23
+ /** Usable sidebar width, in columns. */
24
+ width: number;
25
+ };
26
+ /**
27
+ * The entire sidebar section as plain lines. Keeping layout here rather than in
28
+ * the OpenTUI glue means it can be tested without a terminal.
29
+ */
30
+ export declare function sidebarLines(view: SessionView, options: SidebarOptions): Line[];
@@ -0,0 +1,12 @@
1
+ export type WatchOptions = {
2
+ debounceMs?: number;
3
+ pollMs?: number;
4
+ };
5
+ /**
6
+ * Call `onChange` when a session's log changes.
7
+ *
8
+ * Watches the log directory rather than the file, because the file does not
9
+ * exist until the session's first event. The interval is a fallback: fs.watch
10
+ * misses changes on some network and container filesystems.
11
+ */
12
+ export declare function watchSession(sessionID: string, onChange: () => void, options?: WatchOptions): () => void;
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "opencode-skill-audit",
3
+ "version": "0.1.0",
4
+ "description": "Audit trail of skill invocations and file changes, with a live timeline in the opencode sidebar",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": {
8
+ "name": "Sven Depickere",
9
+ "url": "https://github.com/DepickereSven"
10
+ },
11
+ "homepage": "https://github.com/DepickereSven/skill-audit",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/DepickereSven/skill-audit.git"
15
+ },
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "audit",
20
+ "skills",
21
+ "sidebar",
22
+ "tui"
23
+ ],
24
+ "oc-plugin": [
25
+ "tui"
26
+ ],
27
+ "main": "dist/index.js",
28
+ "exports": {
29
+ ".": {
30
+ "import": "./dist/index.js",
31
+ "types": "./dist/index.d.ts"
32
+ },
33
+ "./tui": {
34
+ "import": "./dist/tui.js",
35
+ "types": "./dist/tui.d.ts"
36
+ }
37
+ },
38
+ "files": [
39
+ "dist"
40
+ ],
41
+ "scripts": {
42
+ "clean": "rm -rf dist",
43
+ "format": "prettier --write src test package.json tsconfig.json tsconfig.test.json eslint.config.js .github/workflows/ci.yml",
44
+ "format:check": "prettier --check src test package.json tsconfig.json tsconfig.test.json eslint.config.js .github/workflows/ci.yml",
45
+ "lint": "eslint src test",
46
+ "build:types": "tsc --emitDeclarationOnly",
47
+ "build:index": "bun build src/index.ts --outfile dist/index.js --target bun --format esm --external @opencode-ai/plugin --external @opencode-ai/sdk",
48
+ "build:tui": "bun build src/tui.ts --outfile dist/tui.js --target bun --format esm --external @opencode-ai/plugin --external @opencode-ai/sdk --external @opentui/core --external @opentui/solid --external solid-js",
49
+ "build": "bun run clean && bun run build:types && bun run build:index && bun run build:tui",
50
+ "test": "bun test && ./test/format-contract.sh",
51
+ "typecheck": "tsc --noEmit",
52
+ "typecheck:test": "tsc -p tsconfig.test.json",
53
+ "check": "bun run format:check && bun run lint && bun run typecheck && bun run typecheck:test && bun test && ./test/format-contract.sh",
54
+ "check:versions": "node scripts/sync-versions.mjs --check",
55
+ "sync:versions": "node scripts/sync-versions.mjs",
56
+ "version": "node scripts/sync-versions.mjs && git add .claude-plugin/plugin.json .codex-plugin/plugin.json",
57
+ "prepublishOnly": "bun run check:versions && bun run check && bun run build"
58
+ },
59
+ "peerDependencies": {
60
+ "@opencode-ai/plugin": ">=1.14.0",
61
+ "@opentui/core": ">=0.4.5",
62
+ "@opentui/solid": ">=0.4.5",
63
+ "solid-js": ">=1.9.0"
64
+ },
65
+ "devDependencies": {
66
+ "@eslint/js": "^10.0.1",
67
+ "@opencode-ai/plugin": "1.18.23",
68
+ "@opentui/core": "0.4.5",
69
+ "@opentui/solid": "0.4.5",
70
+ "@types/bun": "latest",
71
+ "eslint": "^10.9.1",
72
+ "eslint-plugin-simple-import-sort": "^14.0.0",
73
+ "prettier": "^3.9.6",
74
+ "solid-js": "1.9.9",
75
+ "typescript": "^5.8.2",
76
+ "typescript-eslint": "^8.69.0"
77
+ },
78
+ "engines": {
79
+ "bun": ">=1.2.0"
80
+ }
81
+ }