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 CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
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
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
+ [![npm](https://img.shields.io/npm/v/opencode-skill-audit?logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/opencode-skill-audit)
6
+ [![Claude Code](https://img.shields.io/badge/Claude_Code-supported-D97757?logo=claude&logoColor=white)](#claude-code)
7
+ [![Codex](https://img.shields.io/badge/Codex-supported-000000?logoColor=white)](#codex)
8
+ [![OpenCode](https://img.shields.io/badge/OpenCode-supported-FBBF24?logo=opencode&logoColor=white)](#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→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
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; hook events are. This plugin logs the facts exposed by each host's
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`; override the location
64
- with `SKILL_AUDIT_DIR`.
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
- 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.
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
- ![OpenCode Image](docs/opencode.png)
143
+ ![The opencode sidebar rendering a live skill-audit timeline beside a conversation](docs/opencode.png)
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`; if it prints
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 — go to
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; `q` quits | 0 |
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; queues while the model is busy | 0 |
211
- | opencode sidebar | Live timeline beside the conversation; no command to run | 0 |
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 — get the ID from
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` — the only two event kinds |
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; empty string when there were none |
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; present only when the host supplies one |
240
- | `tool` | `file` | Tool that made the edit: `Edit`, `Write`, `NotebookEdit`, `apply_patch`… |
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 — accept the prompt.
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 — otherwise the CLI looks in
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
- see [CLI on your PATH](#cli-on-your-path-recommended).
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 — see
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; skills that Codex chooses automatically are not. No transcript parsing is used to
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`, 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.
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; pass the session ID
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 — delete the `"opencode-skill-audit"` entry from the `plugin`
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 — that output is the contract
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
- 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.
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
@@ -9,6 +9,7 @@ function logDir() {
9
9
  function logPath(sessionID) {
10
10
  return join(logDir(), `${sessionID}.ndjson`);
11
11
  }
12
+ var DEFAULT_IDLE_GAP_MS = 30 * 60000;
12
13
  function append(sessionID, event) {
13
14
  try {
14
15
  mkdirSync(logDir(), { recursive: true });
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
- function group(events) {
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
- if (runs.length === 0) {
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
- return ts.slice(11, 16);
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 counts = `\u26A1${summary.runs} \u270E${summary.files}${summary.orphan > 0 ? ` \u26A0${summary.orphan}` : ""}`;
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 time = hhmm(run.ts);
118
- if (run.skill === NO_SKILL) {
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 ` ${time} ${name}`;
180
+ return ` ${name}`;
124
181
  }
125
- return collapsed ? `\u25B6 ${time} ${name} (${run.files.length})` : `\u25BC ${time} ${name}`;
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 FILE_INDENT = " \u270E ";
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 marker = options.sectionOpen ? "\u25BC" : "\u25B6";
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 collapsed = options.collapsed.has(index);
228
+ const key = runKey(index);
229
+ const runCollapsed = options.collapsed.has(key);
159
230
  lines.push({
160
- text: runTitle(run, collapsed),
231
+ text: runTitle(run, runCollapsed),
161
232
  tone: run.skill === NO_SKILL ? "warning" : "accent",
162
- runIndex: index
233
+ key
163
234
  });
164
- if (collapsed) {
235
+ if (runCollapsed) {
165
236
  return;
166
237
  }
167
- for (const file of run.files) {
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: `${FILE_INDENT}${displayPath(file.path, view.cwd, options.width - FILE_INDENT.length)}`,
170
- tone: "muted"
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, index) {
340
+ function toggle(set, key) {
256
341
  const next = new Set(set);
257
- if (!next.delete(index)) {
258
- next.add(index);
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.runIndex !== undefined ? () => {
280
- setCollapsed((current) => toggle(current, line.runIndex));
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
- runIndex?: number;
41
+ key?: string;
17
42
  };
18
43
  export type SidebarOptions = {
19
44
  /** Whether the whole section is expanded. */
20
45
  sectionOpen: boolean;
21
- /** Indices of runs whose files are hidden. */
22
- collapsed: Set<number>;
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.0",
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",