opencode-skill-audit 0.1.0 → 0.1.1-dev.pr2.34397019140
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/README.md +119 -49
- package/dist/index.js +1 -0
- package/dist/log.d.ts +13 -1
- package/dist/tui.js +110 -25
- package/dist/view.d.ts +31 -4
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/DepickereSven/skill-audit/actions/workflows/ci.yml)
|
|
4
4
|
[](LICENSE)
|
|
5
|
+
[](https://www.npmjs.com/package/opencode-skill-audit)
|
|
6
|
+
[](#claude-code)
|
|
7
|
+
[](#codex)
|
|
8
|
+
[](#opencode)
|
|
5
9
|
|
|
6
10
|
Deterministic audit trail for [Claude Code](https://code.claude.com),
|
|
7
11
|
[Codex](https://developers.openai.com/codex/) and [opencode](https://opencode.ai) sessions: which
|
|
@@ -12,24 +16,31 @@ On opencode it also renders live in the TUI sidebar, so the audit sits next to t
|
|
|
12
16
|
with no command to run.
|
|
13
17
|
|
|
14
18
|
```text
|
|
15
|
-
● Skill audit — f3a91c2e · ~/Dev/Web · 14:02→
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
● Skill audit — f3a91c2e · ~/Dev/Web · 14:02→16:40
|
|
20
|
+
|
|
21
|
+
⚡ superpowers:brainstorming
|
|
22
|
+
⚡ superpowers:test-driven-development
|
|
23
|
+
14:00 (2)
|
|
24
|
+
14:06 ✎ src/auth/token.ts Edit
|
|
25
|
+
14:09 ✎ src/auth/token.test.ts Write
|
|
26
|
+
15:00 (1)
|
|
27
|
+
15:12 ✎ src/auth/session.ts Edit
|
|
28
|
+
⚠ (no skill active)
|
|
29
|
+
16:00 (1)
|
|
30
|
+
16:40 ✎ src/index.ts Edit
|
|
31
|
+
|
|
32
|
+
● 2 skill runs (2 distinct) · 4 files touched · ⚠ 1 edits outside skill context
|
|
25
33
|
```
|
|
26
34
|
|
|
35
|
+
The skill leads each block; the hours beneath it show when its work actually happened. Times are
|
|
36
|
+
your local clock — the log itself stays UTC, so a session stays readable across machines.
|
|
37
|
+
|
|
27
38
|
## Why
|
|
28
39
|
|
|
29
40
|
You ask an agent to follow a skill. Did it? Reading the transcript to find out is slow, and asking
|
|
30
41
|
another model to judge costs tokens and is itself non-deterministic.
|
|
31
42
|
|
|
32
|
-
LLMs are not deterministic
|
|
43
|
+
LLMs are not deterministic. Hook events are. This plugin logs the facts exposed by each host's
|
|
33
44
|
documented hook API:
|
|
34
45
|
|
|
35
46
|
- **No LLM judging, no tokens.** Everything except the two in-session slash/skill commands runs
|
|
@@ -43,6 +54,7 @@ documented hook API:
|
|
|
43
54
|
|
|
44
55
|
## Contents
|
|
45
56
|
|
|
57
|
+
- [Why](#why)
|
|
46
58
|
- [How it works](#how-it-works)
|
|
47
59
|
- [Install](#install)
|
|
48
60
|
- [Verify the install](#verify-the-install)
|
|
@@ -60,8 +72,18 @@ documented hook API:
|
|
|
60
72
|
`PostToolUse` hooks capture skill-tool calls and file edits. On Codex, a `UserPromptSubmit` hook
|
|
61
73
|
also captures explicit `$skill-name` references, and `apply_patch` payloads are expanded into one
|
|
62
74
|
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
|
|
64
|
-
|
|
75
|
+
Events are appended as NDJSON to `~/.claude/skill-audit/<session_id>.ndjson`. All three hosts
|
|
76
|
+
write to that one directory on purpose, so any viewer can read any host's session. Override the
|
|
77
|
+
location with `SKILL_AUDIT_DIR`.
|
|
78
|
+
|
|
79
|
+
A skill run only claims the edits that keep arriving under it. Once 30 minutes pass with no
|
|
80
|
+
activity the run closes, and later files are counted as `(no skill active)` rather than being
|
|
81
|
+
attributed to whatever skill happened to run that morning. Tune the window with
|
|
82
|
+
`SKILL_AUDIT_IDLE_MINUTES`.
|
|
83
|
+
|
|
84
|
+
The opencode sidebar draws in fixed columns, so it defaults to icons that are always one terminal
|
|
85
|
+
cell wide (`·` `✎` `!`); emoji such as `⚡` and `⚠` render two cells wide in most terminals and
|
|
86
|
+
shift every row that carries them. Set `SKILL_AUDIT_ICONS=emoji` to use the emoji set anyway.
|
|
65
87
|
|
|
66
88
|
```text
|
|
67
89
|
Claude Code / Codex ──hooks───▶ logger.sh ──┐
|
|
@@ -111,12 +133,14 @@ Invoke the bundled Codex skill with `$skill-audit`.
|
|
|
111
133
|
opencode plugin opencode-skill-audit --global
|
|
112
134
|
```
|
|
113
135
|
|
|
114
|
-
|
|
115
|
-
`skill
|
|
116
|
-
current session's timeline live.
|
|
136
|
+
The plugin is published on npm as
|
|
137
|
+
[`opencode-skill-audit`](https://www.npmjs.com/package/opencode-skill-audit).
|
|
117
138
|
|
|
139
|
+
Restart opencode so the plugin loads. It registers two things: a server hook that logs
|
|
140
|
+
`skill`, `edit`, `write`, `multiedit`, `apply_patch` and `patch` tool calls, and a sidebar section
|
|
141
|
+
that renders the current session's timeline live.
|
|
118
142
|
|
|
119
|
-

|
|
120
144
|
|
|
121
145
|
Click the header to collapse the section, or a skill row to fold its files away.
|
|
122
146
|
|
|
@@ -146,7 +170,7 @@ ln -sf ~/.codex/plugins/cache/*/skill-audit/*/scripts/skill-audit ~/.local/bin/s
|
|
|
146
170
|
|
|
147
171
|
You can also clone this repository and link `scripts/skill-audit` directly.
|
|
148
172
|
|
|
149
|
-
`~/.local/bin` is not on every system's `PATH`. Check with `command -v skill-audit
|
|
173
|
+
`~/.local/bin` is not on every system's `PATH`. Check with `command -v skill-audit`. If it prints
|
|
150
174
|
nothing, add the directory in your shell profile:
|
|
151
175
|
|
|
152
176
|
```bash
|
|
@@ -182,7 +206,8 @@ installing.
|
|
|
182
206
|
- Claude Code: `/skill-audit`
|
|
183
207
|
- Codex: `$skill-audit`
|
|
184
208
|
- opencode: watch the sidebar section appear
|
|
185
|
-
2. Check that a log exists and is growing
|
|
209
|
+
2. Check that a log exists and is growing. Look in `~/.claude/skill-audit`, or in the directory
|
|
210
|
+
you set as `SKILL_AUDIT_DIR`:
|
|
186
211
|
|
|
187
212
|
```bash
|
|
188
213
|
ls -la ~/.claude/skill-audit/
|
|
@@ -195,7 +220,7 @@ installing.
|
|
|
195
220
|
skill-audit status # counts + recent timeline for the newest session
|
|
196
221
|
```
|
|
197
222
|
|
|
198
|
-
If `list` prints `no session logs in ...`, nothing was written
|
|
223
|
+
If `list` prints `no session logs in ...`, nothing was written. Go to
|
|
199
224
|
[Troubleshooting](#troubleshooting).
|
|
200
225
|
|
|
201
226
|
## Usage
|
|
@@ -204,16 +229,16 @@ If `list` prints `no session logs in ...`, nothing was written — go to
|
|
|
204
229
|
|----------------------------|------------------------------------------------------------------|-----------:|
|
|
205
230
|
| `skill-audit status [sid]` | Compact counts and recent timeline | 0 |
|
|
206
231
|
| `skill-audit report [sid]` | Full timeline | 0 |
|
|
207
|
-
| `skill-audit watch [sid]` | Live view, refreshed every two seconds
|
|
232
|
+
| `skill-audit watch [sid]` | Live view, refreshed every two seconds. `q` quits | 0 |
|
|
208
233
|
| `skill-audit list` | Recent sessions | 0 |
|
|
209
234
|
| `skill-audit --help` | Usage summary | 0 |
|
|
210
|
-
| `! skill-audit status` | Run inside a Claude Code session
|
|
211
|
-
| opencode sidebar | Live timeline beside the conversation
|
|
235
|
+
| `! skill-audit status` | Run inside a Claude Code session. Queues while the model is busy | 0 |
|
|
236
|
+
| opencode sidebar | Live timeline beside the conversation. No command to run | 0 |
|
|
212
237
|
| `/skill-audit` | Show the report inside Claude Code | Model turn |
|
|
213
238
|
| `$skill-audit` | Show the report inside Codex | Model turn |
|
|
214
239
|
|
|
215
240
|
`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
|
|
241
|
+
recently modified log, which is the wrong session if you run several at once. Get the ID from
|
|
217
242
|
`skill-audit list` and pass it explicitly.
|
|
218
243
|
|
|
219
244
|
The `⚠ edits outside skill context` counter is the compliance red flag: files changed while no
|
|
@@ -231,13 +256,13 @@ One NDJSON file per session, one event per line, appended in chronological order
|
|
|
231
256
|
| Field | On | Meaning |
|
|
232
257
|
|-----------|---------|-------------------------------------------------------------------------------|
|
|
233
258
|
| `ts` | both | UTC timestamp, `YYYY-MM-DDThh:mm:ssZ` |
|
|
234
|
-
| `kind` | both | `skill` or `file
|
|
259
|
+
| `kind` | both | `skill` or `file`, the only two event kinds |
|
|
235
260
|
| `cwd` | both | Session working directory as reported by the host |
|
|
236
261
|
| `name` | `skill` | Skill identifier, e.g. `superpowers:test-driven-development` |
|
|
237
|
-
| `args` | `skill` | Arguments passed to the skill tool
|
|
262
|
+
| `args` | `skill` | Arguments passed to the skill tool, empty string when there were none |
|
|
238
263
|
| `source` | `skill` | `tool` for an observed skill tool call, `prompt` for a Codex `$skill-name` |
|
|
239
|
-
| `turn_id` | `skill` | Codex turn identifier
|
|
240
|
-
| `tool` | `file` | Tool that made the edit: `Edit`, `Write`, `
|
|
264
|
+
| `turn_id` | `skill` | Codex turn identifier, present only when the host supplies one |
|
|
265
|
+
| `tool` | `file` | Tool that made the edit: `Edit`, `Write`, `MultiEdit`, `apply_patch`, etc. |
|
|
241
266
|
| `path` | `file` | Absolute path of the changed file (relative paths are resolved against `cwd`) |
|
|
242
267
|
|
|
243
268
|
Within a Codex turn, a repeated `(turn_id, name)` skill pair is written once, so a skill named
|
|
@@ -257,7 +282,7 @@ find ~/.claude/skill-audit -name '*.ndjson' -mtime +30 -delete
|
|
|
257
282
|
session keeps running without them.
|
|
258
283
|
- Confirm the plugin is installed: `claude plugin list`, `codex plugin list`, or check the
|
|
259
284
|
`plugin` array in `~/.config/opencode/opencode.json`.
|
|
260
|
-
- On Codex, hooks only run after you review and trust them
|
|
285
|
+
- On Codex, hooks only run after you review and trust them, so accept the prompt.
|
|
261
286
|
- Is `jq` installed? `logger.sh` exits silently without it, by design: the hook must never block a
|
|
262
287
|
session. Check with `command -v jq`.
|
|
263
288
|
|
|
@@ -269,13 +294,13 @@ recently modified log, which is the wrong one when sessions run in parallel. Run
|
|
|
269
294
|
and pass the ID: `skill-audit report <sid>`.
|
|
270
295
|
|
|
271
296
|
**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
|
|
297
|
+
you set `SKILL_AUDIT_DIR` for the host, export it for your shell too. Otherwise the CLI looks in
|
|
273
298
|
`~/.claude/skill-audit`.
|
|
274
299
|
|
|
275
|
-
**`skill-audit: command not found`.** The symlink is missing or its directory is not on `PATH
|
|
276
|
-
|
|
300
|
+
**`skill-audit: command not found`.** The symlink is missing or its directory is not on `PATH`.
|
|
301
|
+
See [CLI on your PATH](#cli-on-your-path-recommended).
|
|
277
302
|
|
|
278
|
-
**A skill ran but is missing from the timeline.** Expected in some cases
|
|
303
|
+
**A skill ran but is missing from the timeline.** Expected in some cases. See
|
|
279
304
|
[Honest limitations](#honest-limitations).
|
|
280
305
|
|
|
281
306
|
## Honest limitations
|
|
@@ -283,19 +308,19 @@ see [CLI on your PATH](#cli-on-your-path-recommended).
|
|
|
283
308
|
- **Invocation is not compliance.** The log proves that a skill was explicitly selected or exposed
|
|
284
309
|
as a tool event, not that the result followed every instruction.
|
|
285
310
|
- **Codex automatic skill loading is not a hook event today.** Explicit `$skill-name` references
|
|
286
|
-
are captured
|
|
311
|
+
are captured. Skills that Codex chooses automatically are not. No transcript parsing is used to
|
|
287
312
|
fill that gap.
|
|
288
313
|
- **Prompt-sourced entries are syntax-level evidence.** Codex supplies plain prompt text to the
|
|
289
314
|
hook, so a lower-case dollar-prefixed token can be logged even if it does not resolve to an
|
|
290
315
|
installed skill. The NDJSON `source: "prompt"` field distinguishes these entries.
|
|
291
|
-
- **Only observable file tools are captured.** Claude `Edit`/`Write`/`NotebookEdit`,
|
|
292
|
-
`apply_patch`, and opencode `edit`/`write`/`apply_patch` edits are
|
|
293
|
-
indirectly by shell commands are not visible as separate file events.
|
|
316
|
+
- **Only observable file tools are captured.** Claude `Edit`/`Write`/`MultiEdit`/`NotebookEdit`,
|
|
317
|
+
Codex `apply_patch`, and opencode `edit`/`write`/`multiedit`/`apply_patch`/`patch` edits are
|
|
318
|
+
logged. Files created indirectly by shell commands are not visible as separate file events.
|
|
294
319
|
- **opencode agents and subagents are not logged.** They have no equivalent on the other two
|
|
295
320
|
hosts, so logging them would add an event kind only one host can emit. The audit keeps one data
|
|
296
321
|
model across all three.
|
|
297
322
|
- **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
|
|
323
|
+
- **Concurrent sessions:** the newest-log default can pick the wrong session. Pass the session ID
|
|
299
324
|
explicitly after using `skill-audit list`.
|
|
300
325
|
|
|
301
326
|
## Uninstall
|
|
@@ -312,7 +337,7 @@ Codex:
|
|
|
312
337
|
codex plugin remove skill-audit@depickeresven-skill-audit
|
|
313
338
|
```
|
|
314
339
|
|
|
315
|
-
opencode has no removal subcommand
|
|
340
|
+
opencode has no removal subcommand. Delete the `"opencode-skill-audit"` entry from the `plugin`
|
|
316
341
|
array in `~/.config/opencode/opencode.json` (or the project-local `opencode.json`), and remove the
|
|
317
342
|
skill symlink if you made one:
|
|
318
343
|
|
|
@@ -327,17 +352,23 @@ rm -f ~/.local/bin/skill-audit
|
|
|
327
352
|
rm -rf ~/.claude/skill-audit
|
|
328
353
|
```
|
|
329
354
|
|
|
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
355
|
## Development
|
|
337
356
|
|
|
338
357
|
The hooks and the CLI viewer are plain bash (`scripts/`) with no build step. The opencode plugin
|
|
339
358
|
and sidebar are TypeScript (`src/`) built with [Bun](https://bun.sh).
|
|
340
359
|
|
|
360
|
+
| Path | What |
|
|
361
|
+
|-------------------|----------------------------------------------------------------------------|
|
|
362
|
+
| `scripts/` | `logger.sh` hook target, the `skill-audit` CLI, `sync-versions.mjs` |
|
|
363
|
+
| `src/` | opencode plugin (`index.ts`), sidebar (`tui.ts`), shared log and view code |
|
|
364
|
+
| `test/` | Bun tests, fixtures, and `test/format-contract.sh` |
|
|
365
|
+
| `.claude-plugin/` | Claude Code plugin manifest and marketplace entry |
|
|
366
|
+
| `.codex-plugin/` | Codex plugin manifest |
|
|
367
|
+
| `.agents/` | Codex marketplace manifest |
|
|
368
|
+
| `skills/` | The `skill-audit` skill used by Codex and opencode |
|
|
369
|
+
| `commands/` | The `/skill-audit` slash command for Claude Code |
|
|
370
|
+
| `.opencode/` | Local opencode workspace for testing the plugin during development |
|
|
371
|
+
|
|
341
372
|
```bash
|
|
342
373
|
bun install
|
|
343
374
|
bun run check # format:check + lint + typecheck (src and test) + tests
|
|
@@ -355,12 +386,51 @@ bun run build # dist/index.js (plugin) and dist/tui.js (sidebar)
|
|
|
355
386
|
| `bun run sync:versions` | Rewrite the plugin manifests from `package.json` |
|
|
356
387
|
|
|
357
388
|
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
|
|
389
|
+
a change to the timeline format has to be updated there deliberately. That output is the contract
|
|
359
390
|
the sidebar and any third-party viewer rely on.
|
|
360
391
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
392
|
+
### Releasing
|
|
393
|
+
|
|
394
|
+
Two workflows, chained.
|
|
395
|
+
|
|
396
|
+
`.github/workflows/ci.yml` runs four jobs: it checks that both plugin manifests carry the same
|
|
397
|
+
version as `package.json`, runs `bun run check` on Bun 1.2.0 and on the latest Bun, builds the
|
|
398
|
+
bundles, and packs the npm tarball to confirm it ships every entry point `package.json` exports.
|
|
399
|
+
It runs on pull requests and on pushes to `main`, not on every branch, so a pull request is never
|
|
400
|
+
tested twice.
|
|
401
|
+
|
|
402
|
+
`.github/workflows/publish.yml` starts only when a CI run on `main` finishes successfully. It
|
|
403
|
+
checks out that exact commit and asks npm whether the version in `package.json` already exists:
|
|
404
|
+
|
|
405
|
+
- **Already on npm.** Nothing is released. This is what an ordinary push to `main` does.
|
|
406
|
+
- **Not on npm.** It publishes with provenance over OIDC, tags the commit, and opens a GitHub
|
|
407
|
+
release with notes generated from the merged commits.
|
|
408
|
+
|
|
409
|
+
So a version bump landing on `main` *is* the release, and it can only happen after CI has gone
|
|
410
|
+
green on that commit.
|
|
411
|
+
|
|
412
|
+
To cut one:
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
npm version patch --no-git-tag-version # or minor / major
|
|
416
|
+
git commit -am "chore: release $(node -p 'require("./package.json").version')"
|
|
417
|
+
git push
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
`--no-git-tag-version` matters. The workflow creates the `v<version>` tag itself, and it aborts
|
|
421
|
+
the release if that tag already exists while npm has never seen the version. The bump still runs
|
|
422
|
+
`scripts/sync-versions.mjs` and stages `.claude-plugin/plugin.json` and `.codex-plugin/plugin.json`
|
|
423
|
+
alongside it, keeping both manifests on the same version as `package.json`, which CI fails the
|
|
424
|
+
build over if they ever drift.
|
|
425
|
+
|
|
426
|
+
Use the publish workflow's manual trigger (`workflow_dispatch`) with *dry run* enabled to rehearse
|
|
427
|
+
a publication without releasing anything.
|
|
428
|
+
|
|
429
|
+
## Requirements
|
|
430
|
+
|
|
431
|
+
- macOS or Linux
|
|
432
|
+
- `bash` and `jq` for the CLI viewer and the Claude Code / Codex hooks
|
|
433
|
+
- opencode `>= 1.14` for the plugin and its sidebar
|
|
364
434
|
|
|
365
435
|
## License
|
|
366
436
|
|
package/dist/index.js
CHANGED
package/dist/log.d.ts
CHANGED
|
@@ -34,11 +34,23 @@ export type SkillRun = {
|
|
|
34
34
|
ts: string;
|
|
35
35
|
files: TimelineFile[];
|
|
36
36
|
};
|
|
37
|
+
/** How long a skill run may sit idle before it stops claiming later edits. */
|
|
38
|
+
export declare const DEFAULT_IDLE_GAP_MS: number;
|
|
39
|
+
/**
|
|
40
|
+
* The idle gap, in milliseconds. Without one a single skill invoked in the
|
|
41
|
+
* morning claims every edit made for the rest of the day.
|
|
42
|
+
*/
|
|
43
|
+
export declare function idleGapMs(): number;
|
|
37
44
|
/**
|
|
38
45
|
* Group a flat event list into skill runs, each carrying the files edited after
|
|
39
46
|
* it. Port of the jq reduce in scripts/skill-audit; the two must agree.
|
|
47
|
+
*
|
|
48
|
+
* A run only claims edits that keep arriving: once `gapMs` passes with no
|
|
49
|
+
* activity, later files fall into a synthetic run instead. An unparseable
|
|
50
|
+
* timestamp compares as NaN, which never exceeds the gap, so logs written
|
|
51
|
+
* before this rule existed group exactly as they did before.
|
|
40
52
|
*/
|
|
41
|
-
export declare function group(events: AuditEvent[]): SkillRun[];
|
|
53
|
+
export declare function group(events: AuditEvent[], gapMs?: number): SkillRun[];
|
|
42
54
|
export type Summary = {
|
|
43
55
|
runs: number;
|
|
44
56
|
distinct: number;
|
package/dist/tui.js
CHANGED
|
@@ -30,8 +30,17 @@ function parse(text) {
|
|
|
30
30
|
return events;
|
|
31
31
|
}
|
|
32
32
|
var NO_SKILL = "(no skill active)";
|
|
33
|
-
|
|
33
|
+
var DEFAULT_IDLE_GAP_MS = 30 * 60000;
|
|
34
|
+
function idleGapMs() {
|
|
35
|
+
const minutes = Number(process.env.SKILL_AUDIT_IDLE_MINUTES);
|
|
36
|
+
return Number.isFinite(minutes) && minutes > 0 ? minutes * 60000 : DEFAULT_IDLE_GAP_MS;
|
|
37
|
+
}
|
|
38
|
+
function epoch(ts) {
|
|
39
|
+
return new Date(ts).getTime();
|
|
40
|
+
}
|
|
41
|
+
function group(events, gapMs = idleGapMs()) {
|
|
34
42
|
const runs = [];
|
|
43
|
+
let lastActivity = NaN;
|
|
35
44
|
for (const event of events) {
|
|
36
45
|
if (event.kind === "skill") {
|
|
37
46
|
runs.push({
|
|
@@ -39,9 +48,12 @@ function group(events) {
|
|
|
39
48
|
ts: event.ts,
|
|
40
49
|
files: []
|
|
41
50
|
});
|
|
51
|
+
lastActivity = epoch(event.ts);
|
|
42
52
|
continue;
|
|
43
53
|
}
|
|
44
|
-
|
|
54
|
+
const current = runs[runs.length - 1];
|
|
55
|
+
const stale = epoch(event.ts) - lastActivity > gapMs;
|
|
56
|
+
if (!current || stale && current.skill !== NO_SKILL) {
|
|
45
57
|
runs.push({
|
|
46
58
|
skill: NO_SKILL,
|
|
47
59
|
ts: event.ts,
|
|
@@ -53,6 +65,7 @@ function group(events) {
|
|
|
53
65
|
tool: event.tool,
|
|
54
66
|
path: event.path
|
|
55
67
|
});
|
|
68
|
+
lastActivity = epoch(event.ts);
|
|
56
69
|
}
|
|
57
70
|
return runs;
|
|
58
71
|
}
|
|
@@ -102,27 +115,76 @@ function readSession(sessionID) {
|
|
|
102
115
|
|
|
103
116
|
// src/view.ts
|
|
104
117
|
import { basename, relative } from "path";
|
|
118
|
+
var TEXT_ICONS = {
|
|
119
|
+
run: "\xB7",
|
|
120
|
+
file: "\u270E",
|
|
121
|
+
warn: "!",
|
|
122
|
+
open: "\u25BC",
|
|
123
|
+
closed: "\u25B6"
|
|
124
|
+
};
|
|
125
|
+
var EMOJI_ICONS = {
|
|
126
|
+
run: "\u26A1",
|
|
127
|
+
file: "\u270E",
|
|
128
|
+
warn: "\u26A0",
|
|
129
|
+
open: "\u25BC",
|
|
130
|
+
closed: "\u25B6"
|
|
131
|
+
};
|
|
132
|
+
function icons() {
|
|
133
|
+
return process.env.SKILL_AUDIT_ICONS === "emoji" ? EMOJI_ICONS : TEXT_ICONS;
|
|
134
|
+
}
|
|
135
|
+
function pad(value) {
|
|
136
|
+
return String(value).padStart(2, "0");
|
|
137
|
+
}
|
|
138
|
+
function local(ts) {
|
|
139
|
+
const date = new Date(ts);
|
|
140
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
141
|
+
}
|
|
105
142
|
function hhmm(ts) {
|
|
106
|
-
|
|
143
|
+
const date = local(ts);
|
|
144
|
+
return date ? `${pad(date.getHours())}:${pad(date.getMinutes())}` : "";
|
|
145
|
+
}
|
|
146
|
+
function hourKey(ts) {
|
|
147
|
+
const date = local(ts);
|
|
148
|
+
return date ? `${pad(date.getHours())}:00` : "";
|
|
107
149
|
}
|
|
108
150
|
function shortName(name) {
|
|
109
151
|
const parts = name.split(":");
|
|
110
152
|
return parts[parts.length - 1] || name;
|
|
111
153
|
}
|
|
154
|
+
function bucketByHour(files) {
|
|
155
|
+
const buckets = [];
|
|
156
|
+
for (const file of files) {
|
|
157
|
+
const day = file.ts.slice(0, 10);
|
|
158
|
+
const hour = hourKey(file.ts);
|
|
159
|
+
const last = buckets[buckets.length - 1];
|
|
160
|
+
if (!last || last.day !== day || last.hour !== hour) {
|
|
161
|
+
buckets.push({
|
|
162
|
+
day,
|
|
163
|
+
hour,
|
|
164
|
+
files: []
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
buckets[buckets.length - 1].files.push(file);
|
|
168
|
+
}
|
|
169
|
+
return buckets;
|
|
170
|
+
}
|
|
112
171
|
function headerLine(summary) {
|
|
113
|
-
const
|
|
172
|
+
const icon = icons();
|
|
173
|
+
const counts = `${icon.run}${summary.runs} ${icon.file}${summary.files}${summary.orphan > 0 ? ` ${icon.warn}${summary.orphan}` : ""}`;
|
|
114
174
|
return `Skill audit ${counts}`;
|
|
115
175
|
}
|
|
116
176
|
function runTitle(run, collapsed) {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
return `\u26A0 ${time} no skill`;
|
|
120
|
-
}
|
|
121
|
-
const name = shortName(run.skill);
|
|
177
|
+
const icon = icons();
|
|
178
|
+
const name = run.skill === NO_SKILL ? `${icon.warn} no skill` : shortName(run.skill);
|
|
122
179
|
if (run.files.length === 0) {
|
|
123
|
-
return ` ${
|
|
180
|
+
return ` ${name}`;
|
|
124
181
|
}
|
|
125
|
-
return collapsed ?
|
|
182
|
+
return collapsed ? `${icon.closed} ${name} (${run.files.length})` : `${icon.open} ${name}`;
|
|
183
|
+
}
|
|
184
|
+
function hourTitle(bucket, collapsed) {
|
|
185
|
+
const icon = icons();
|
|
186
|
+
const marker = collapsed ? icon.closed : icon.open;
|
|
187
|
+
return `${marker} ${bucket.hour} (${bucket.files.length})`;
|
|
126
188
|
}
|
|
127
189
|
function displayPath(path, cwd, width) {
|
|
128
190
|
const rel = cwd && path.startsWith(`${cwd}/`) ? relative(cwd, path) : path;
|
|
@@ -135,9 +197,17 @@ function displayPath(path, cwd, width) {
|
|
|
135
197
|
}
|
|
136
198
|
return `${base.slice(0, Math.max(0, width - 1))}\u2026`;
|
|
137
199
|
}
|
|
138
|
-
var
|
|
200
|
+
var HOUR_INDENT = " ";
|
|
201
|
+
var FILE_INDENT = " ";
|
|
202
|
+
function runKey(index) {
|
|
203
|
+
return `run:${index}`;
|
|
204
|
+
}
|
|
205
|
+
function hourKeyOf(index, bucket) {
|
|
206
|
+
return `${runKey(index)}/hour:${bucket.day} ${bucket.hour}`;
|
|
207
|
+
}
|
|
139
208
|
function sidebarLines(view, options) {
|
|
140
|
-
const
|
|
209
|
+
const icon = icons();
|
|
210
|
+
const marker = options.sectionOpen ? icon.open : icon.closed;
|
|
141
211
|
const lines = [
|
|
142
212
|
{
|
|
143
213
|
text: `${marker} ${headerLine(view.summary)}`,
|
|
@@ -155,20 +225,35 @@ function sidebarLines(view, options) {
|
|
|
155
225
|
return lines;
|
|
156
226
|
}
|
|
157
227
|
view.runs.forEach((run, index) => {
|
|
158
|
-
const
|
|
228
|
+
const key = runKey(index);
|
|
229
|
+
const runCollapsed = options.collapsed.has(key);
|
|
159
230
|
lines.push({
|
|
160
|
-
text: runTitle(run,
|
|
231
|
+
text: runTitle(run, runCollapsed),
|
|
161
232
|
tone: run.skill === NO_SKILL ? "warning" : "accent",
|
|
162
|
-
|
|
233
|
+
key
|
|
163
234
|
});
|
|
164
|
-
if (
|
|
235
|
+
if (runCollapsed) {
|
|
165
236
|
return;
|
|
166
237
|
}
|
|
167
|
-
for (const
|
|
238
|
+
for (const bucket of bucketByHour(run.files)) {
|
|
239
|
+
const bucketKey = hourKeyOf(index, bucket);
|
|
240
|
+
const hourCollapsed = options.collapsed.has(bucketKey);
|
|
168
241
|
lines.push({
|
|
169
|
-
text: `${
|
|
170
|
-
tone: "
|
|
242
|
+
text: `${HOUR_INDENT}${hourTitle(bucket, hourCollapsed)}`,
|
|
243
|
+
tone: "text",
|
|
244
|
+
key: bucketKey
|
|
171
245
|
});
|
|
246
|
+
if (hourCollapsed) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
for (const file of bucket.files) {
|
|
250
|
+
const stamp = `${hhmm(file.ts)} ${icon.file} `;
|
|
251
|
+
const room = options.width - FILE_INDENT.length - stamp.length;
|
|
252
|
+
lines.push({
|
|
253
|
+
text: `${FILE_INDENT}${stamp}${displayPath(file.path, view.cwd, room)}`,
|
|
254
|
+
tone: "muted"
|
|
255
|
+
});
|
|
256
|
+
}
|
|
172
257
|
}
|
|
173
258
|
});
|
|
174
259
|
return lines;
|
|
@@ -252,10 +337,10 @@ function toneColor(theme, tone) {
|
|
|
252
337
|
}
|
|
253
338
|
return theme.text;
|
|
254
339
|
}
|
|
255
|
-
function toggle(set,
|
|
340
|
+
function toggle(set, key) {
|
|
256
341
|
const next = new Set(set);
|
|
257
|
-
if (!next.delete(
|
|
258
|
-
next.add(
|
|
342
|
+
if (!next.delete(key)) {
|
|
343
|
+
next.add(key);
|
|
259
344
|
}
|
|
260
345
|
return next;
|
|
261
346
|
}
|
|
@@ -276,8 +361,8 @@ function Section(api, sessionID, width) {
|
|
|
276
361
|
const onMouseDown = index === 0 ? () => {
|
|
277
362
|
setSectionOpen((open) => !open);
|
|
278
363
|
redraw();
|
|
279
|
-
} : line.
|
|
280
|
-
setCollapsed((current) => toggle(current, line.
|
|
364
|
+
} : line.key !== undefined ? () => {
|
|
365
|
+
setCollapsed((current) => toggle(current, line.key));
|
|
281
366
|
redraw();
|
|
282
367
|
} : undefined;
|
|
283
368
|
return element("text", {
|
package/dist/view.d.ts
CHANGED
|
@@ -1,28 +1,55 @@
|
|
|
1
|
-
import { type SessionView, type SkillRun, type Summary } from "./log";
|
|
1
|
+
import { type SessionView, type SkillRun, type Summary, type TimelineFile } from "./log";
|
|
2
|
+
export type IconSet = {
|
|
3
|
+
run: string;
|
|
4
|
+
file: string;
|
|
5
|
+
warn: string;
|
|
6
|
+
open: string;
|
|
7
|
+
closed: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function icons(): IconSet;
|
|
2
10
|
export declare function hhmm(ts: string): string;
|
|
11
|
+
/** The local hour a timestamp belongs to, as a bucket label. */
|
|
12
|
+
export declare function hourKey(ts: string): string;
|
|
3
13
|
/** `superpowers:brainstorming` -> `brainstorming`, the way the statusline snippet does. */
|
|
4
14
|
export declare function shortName(name: string): string;
|
|
15
|
+
/** `day` is carried so a run spanning midnight gets one bucket per calendar hour. */
|
|
16
|
+
export type HourBucket = {
|
|
17
|
+
day: string;
|
|
18
|
+
hour: string;
|
|
19
|
+
files: TimelineFile[];
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Split a run's files into consecutive local-hour buckets. Consecutive rather
|
|
23
|
+
* than keyed, so the same hour on the next day opens a second bucket instead of
|
|
24
|
+
* folding a day's gap into one row.
|
|
25
|
+
*/
|
|
26
|
+
export declare function bucketByHour(files: TimelineFile[]): HourBucket[];
|
|
5
27
|
export declare function headerLine(summary: Summary): string;
|
|
28
|
+
/** The skill name leads the row; its times live on the hour buckets below it. */
|
|
6
29
|
export declare function runTitle(run: SkillRun, collapsed: boolean): string;
|
|
30
|
+
export declare function hourTitle(bucket: HourBucket, collapsed: boolean): string;
|
|
7
31
|
/**
|
|
8
32
|
* Fit a path into the sidebar: relative to the session directory, then the
|
|
9
33
|
* basename, then a truncated basename.
|
|
10
34
|
*/
|
|
11
35
|
export declare function displayPath(path: string, cwd: string, width: number): string;
|
|
12
36
|
export type Tone = "text" | "muted" | "accent" | "warning";
|
|
37
|
+
/** `key` marks a collapsible node; the sidebar toggles whatever key it carries. */
|
|
13
38
|
export type Line = {
|
|
14
39
|
text: string;
|
|
15
40
|
tone: Tone;
|
|
16
|
-
|
|
41
|
+
key?: string;
|
|
17
42
|
};
|
|
18
43
|
export type SidebarOptions = {
|
|
19
44
|
/** Whether the whole section is expanded. */
|
|
20
45
|
sectionOpen: boolean;
|
|
21
|
-
/**
|
|
22
|
-
collapsed: Set<
|
|
46
|
+
/** Keys of runs and hour buckets whose children are hidden. */
|
|
47
|
+
collapsed: Set<string>;
|
|
23
48
|
/** Usable sidebar width, in columns. */
|
|
24
49
|
width: number;
|
|
25
50
|
};
|
|
51
|
+
export declare function runKey(index: number): string;
|
|
52
|
+
export declare function hourKeyOf(index: number, bucket: HourBucket): string;
|
|
26
53
|
/**
|
|
27
54
|
* The entire sidebar section as plain lines. Keeping layout here rather than in
|
|
28
55
|
* the OpenTUI glue means it can be tested without a terminal.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-skill-audit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1-dev.pr2.34397019140",
|
|
4
4
|
"description": "Audit trail of skill invocations and file changes, with a live timeline in the opencode sidebar",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,10 +47,10 @@
|
|
|
47
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
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
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",
|
|
50
|
+
"test": "TZ=UTC bun test && TZ=UTC ./test/format-contract.sh",
|
|
51
51
|
"typecheck": "tsc --noEmit",
|
|
52
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",
|
|
53
|
+
"check": "bun run format:check && bun run lint && bun run typecheck && bun run typecheck:test && TZ=UTC bun test && TZ=UTC ./test/format-contract.sh",
|
|
54
54
|
"check:versions": "node scripts/sync-versions.mjs --check",
|
|
55
55
|
"sync:versions": "node scripts/sync-versions.mjs",
|
|
56
56
|
"version": "node scripts/sync-versions.mjs && git add .claude-plugin/plugin.json .codex-plugin/plugin.json",
|