opencode-skill-audit 0.1.1 → 0.2.0-dev.pr3.36976113060

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
@@ -12,22 +12,29 @@ Deterministic audit trail for [Claude Code](https://code.claude.com),
12
12
  observable **skills** were invoked, when, and which **files** were changed afterwards. It gives you
13
13
  a quick skill-compliance signal before you read the code.
14
14
 
15
- On opencode it also renders live in the TUI sidebar, so the audit sits next to the conversation
16
- with no command to run.
15
+ On opencode it also renders live in the TUI sidebar, and on Claude Code in a live pane, so the audit
16
+ sits next to the conversation with no command to run.
17
17
 
18
18
  ```text
19
- ● Skill audit — f3a91c2e · ~/Dev/Web · 14:02→14:20 UTC
20
-
21
- 14:02 ⚡ superpowers:brainstorming
22
- 14:05 ⚡ superpowers:test-driven-development
23
- 14:06 │ ✎ src/auth/token.ts Edit
24
- 14:09 │ ✎ src/auth/token.test.ts Write
25
- 14:20 ⚠ (no skill active)
26
- 14:20 │ ✎ src/index.ts Edit
27
-
28
- ● 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
29
33
  ```
30
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
+
31
38
  ## Why
32
39
 
33
40
  You ask an agent to follow a skill. Did it? Reading the transcript to find out is slow, and asking
@@ -69,12 +76,22 @@ Events are appended as NDJSON to `~/.claude/skill-audit/<session_id>.ndjson`. Al
69
76
  write to that one directory on purpose, so any viewer can read any host's session. Override the
70
77
  location with `SKILL_AUDIT_DIR`.
71
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.
87
+
72
88
  ```text
73
89
  Claude Code / Codex ──hooks───▶ logger.sh ──┐
74
90
  ├─▶ ~/.claude/skill-audit/<sid>.ndjson
75
91
  opencode ──tool.execute.after──▶ plugin ────┘ │
76
92
  ├─▶ skill-audit status/report/watch/list
77
- └─▶ opencode sidebar (live)
93
+ ├─▶ opencode sidebar (live)
94
+ └─▶ Claude Code pane (live)
78
95
  ```
79
96
 
80
97
  The log is the only contract between the writers and the viewers, so the CLI reads opencode
@@ -93,6 +110,22 @@ Subagent hook calls use the parent session ID, so delegated edits appear in the
93
110
 
94
111
  Restart Claude Code so the hooks load.
95
112
 
113
+ #### Live pane
114
+
115
+ The plugin also opens a **Skill audit** pane showing the current session's timeline. It refreshes
116
+ after every skill and file-edit tool call, and polls the log every two seconds.
117
+
118
+ - **Opening:** it opens by itself at session start once the terminal is 144 columns or wider.
119
+ Narrower terminals hold it back until you run `/skill-audit-pane`, which opens it at any width.
120
+ - **Placement:** in fullscreen from 110 columns it docks beside the transcript and shows the full
121
+ timeline. Press a `▼`/`▶` marker to collapse or expand a run or an hour. Elsewhere it sits above
122
+ the prompt in a compact form: the header and the latest run.
123
+ - **Other panes:** panes from other plugins become tabs beside it (click a tab, or ctrl+x tab).
124
+ Only one is shown at a time. Esc or the close mark closes it.
125
+
126
+ The pane is display only. The shell hooks keep writing the log through `logger.sh`, so recording
127
+ works the same on Claude Code builds without plugin function hooks.
128
+
96
129
  > **Migrating from a manual setup?** Remove any `logger.sh` entries from the `hooks` block of
97
130
  > `~/.claude/settings.json` first, or every event is logged twice.
98
131
 
@@ -218,6 +251,7 @@ If `list` prints `no session logs in ...`, nothing was written. Go to
218
251
  | `skill-audit --help` | Usage summary | 0 |
219
252
  | `! skill-audit status` | Run inside a Claude Code session. Queues while the model is busy | 0 |
220
253
  | opencode sidebar | Live timeline beside the conversation. No command to run | 0 |
254
+ | Claude Code pane | Live timeline in a pane. `/skill-audit-pane` opens it | 0 |
221
255
  | `/skill-audit` | Show the report inside Claude Code | Model turn |
222
256
  | `$skill-audit` | Show the report inside Codex | Model turn |
223
257
 
@@ -345,6 +379,8 @@ and sidebar are TypeScript (`src/`) built with [Bun](https://bun.sh).
345
379
  |-------------------|----------------------------------------------------------------------------|
346
380
  | `scripts/` | `logger.sh` hook target, the `skill-audit` CLI, `sync-versions.mjs` |
347
381
  | `src/` | opencode plugin (`index.ts`), sidebar (`tui.ts`), shared log and view code |
382
+ | `hooks/` | Claude Code shell hooks (`hooks.json`) and the live pane (`claude.tsx`) |
383
+ | `types/` | `$.state` contract for the Claude Code pane |
348
384
  | `test/` | Bun tests, fixtures, and `test/format-contract.sh` |
349
385
  | `.claude-plugin/` | Claude Code plugin manifest and marketplace entry |
350
386
  | `.codex-plugin/` | Codex plugin manifest |
@@ -359,17 +395,28 @@ bun run check # format:check + lint + typecheck (src and test) + tests
359
395
  bun run build # dist/index.js (plugin) and dist/tui.js (sidebar)
360
396
  ```
361
397
 
362
- | Script | What |
363
- |--------------------------|----------------------------------------------------------|
364
- | `bun run format` | Prettier, write mode |
365
- | `bun run lint` | ESLint over `src` and `test` |
366
- | `bun run typecheck` | `tsc --noEmit` for `src` |
367
- | `bun test` | Bun test suite in `test/` |
368
- | `bun run check` | Everything CI runs |
369
- | `bun run check:versions` | Assert `package.json` and both `plugin.json` files agree |
370
- | `bun run sync:versions` | Rewrite the plugin manifests from `package.json` |
371
-
372
- Tests live in `test/`. `test/format-contract.sh` pins the rendered CLI output against fixtures, so
398
+ | Script | What |
399
+ |----------------------------|----------------------------------------------------------|
400
+ | `bun run format` | Prettier, write mode |
401
+ | `bun run lint` | ESLint over `src` and `test` |
402
+ | `bun run typecheck` | `tsc --noEmit` for `src` |
403
+ | `bun test` | Bun test suite in `test/` |
404
+ | `bun run check` | Everything CI runs |
405
+ | `bun run test:claude` | Validate and test the Claude Code pane (needs `claude`) |
406
+ | `bun run typecheck:claude` | Type-check the pane (needs `.claude-plugin/types/`) |
407
+ | `bun run check:versions` | Assert `package.json` and both `plugin.json` files agree |
408
+ | `bun run sync:versions` | Rewrite the plugin manifests from `package.json` |
409
+
410
+ Tests live in `test/`. The Claude Code pane is the exception: its tests (`hooks/*.test.tsx`) run
411
+ in Claude Code's mod sandbox, which has no Node or Bun, so `bun test` is rooted at `test/`
412
+ (`bunfig.toml`). `test:claude` copies the pane's files into a scratch folder and runs
413
+ `claude plugin validate` and `claude plugin test` there. `src/core.ts` and `src/view.ts` must stay
414
+ free of `node:*` imports and `process` because the pane imports them; `test/core.test.ts` checks
415
+ this. `typecheck:claude` reads the API types the engine writes to `.claude-plugin/types/` once it
416
+ has loaded the plugin from a local folder. That folder is gitignored and not available in CI, so
417
+ neither pane check runs in CI.
418
+
419
+ `test/format-contract.sh` pins the rendered CLI output against fixtures, so
373
420
  a change to the timeline format has to be updated there deliberately. That output is the contract
374
421
  the sidebar and any third-party viewer rely on.
375
422
 
package/dist/core.d.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The log model with no Node dependency. The Claude Code pane runs in a
3
+ * sandbox without `node:*` modules or `process`, so everything it shares with
4
+ * the CLI and the opencode sidebar lives here; `log.ts` adds the file I/O.
5
+ */
6
+ export type SkillEvent = {
7
+ ts: string;
8
+ kind: "skill";
9
+ name: string;
10
+ args?: string;
11
+ cwd?: string;
12
+ source?: string;
13
+ };
14
+ export type FileEvent = {
15
+ ts: string;
16
+ kind: "file";
17
+ tool: string;
18
+ path: string;
19
+ cwd?: string;
20
+ };
21
+ export type AuditEvent = SkillEvent | FileEvent;
22
+ /**
23
+ * An environment variable where the host has one, `undefined` where it does
24
+ * not. Read through `globalThis` because a bare `process` throws in the Claude
25
+ * Code sandbox.
26
+ */
27
+ export declare function env(name: string): string | undefined;
28
+ /**
29
+ * Parse NDJSON log text. Malformed lines are dropped rather than thrown on: the
30
+ * sidebar reads the log while the logger is appending to it, so a truncated
31
+ * trailing line is expected, not exceptional.
32
+ */
33
+ export declare function parse(text: string): AuditEvent[];
34
+ export declare const NO_SKILL = "(no skill active)";
35
+ export type TimelineFile = {
36
+ ts: string;
37
+ tool: string;
38
+ path: string;
39
+ };
40
+ export type SkillRun = {
41
+ skill: string;
42
+ ts: string;
43
+ files: TimelineFile[];
44
+ };
45
+ /** How long a skill run may sit idle before it stops claiming later edits. */
46
+ export declare const DEFAULT_IDLE_GAP_MS: number;
47
+ /** `SKILL_AUDIT_IDLE_MINUTES` as milliseconds, or the default when unusable. */
48
+ export declare function idleGapFrom(minutes: string | undefined): number;
49
+ /**
50
+ * The idle gap, in milliseconds. Without one a single skill invoked in the
51
+ * morning claims every edit made for the rest of the day.
52
+ */
53
+ export declare function idleGapMs(): number;
54
+ /**
55
+ * Group a flat event list into skill runs, each carrying the files edited after
56
+ * it. Port of the jq reduce in scripts/skill-audit; the two must agree.
57
+ *
58
+ * A run only claims edits that keep arriving: once `gapMs` passes with no
59
+ * activity, later files fall into a synthetic run instead. An unparseable
60
+ * timestamp compares as NaN, which never exceeds the gap, so logs written
61
+ * before this rule existed group exactly as they did before.
62
+ */
63
+ export declare function group(events: AuditEvent[], gapMs?: number): SkillRun[];
64
+ export type Summary = {
65
+ runs: number;
66
+ distinct: number;
67
+ files: number;
68
+ orphan: number;
69
+ };
70
+ export declare function summarize(events: AuditEvent[], gapMs?: number): Summary;
71
+ export type SessionView = {
72
+ runs: SkillRun[];
73
+ summary: Summary;
74
+ cwd: string;
75
+ };
76
+ /** A session's view from its raw log text; empty text is an empty session. */
77
+ export declare function toView(text: string, gapMs?: number): SessionView;
78
+ /** `node:path` basename for POSIX paths: the last segment, trailing slashes ignored. */
79
+ export declare function baseName(path: string): string;
80
+ /**
81
+ * `path` relative to `cwd` when it sits under it, else `path` as given. The
82
+ * sidebar only ever shortens paths inside the session directory.
83
+ */
84
+ export declare function relativeTo(path: string, cwd: string): string;
package/dist/index.js CHANGED
@@ -3,6 +3,11 @@
3
3
  import { appendFileSync, mkdirSync, readFileSync } from "fs";
4
4
  import { homedir } from "os";
5
5
  import { isAbsolute, join } from "path";
6
+
7
+ // src/core.ts
8
+ var DEFAULT_IDLE_GAP_MS = 30 * 60000;
9
+
10
+ // src/log.ts
6
11
  function logDir() {
7
12
  return process.env.SKILL_AUDIT_DIR || join(homedir(), ".claude", "skill-audit");
8
13
  }
package/dist/log.d.ts CHANGED
@@ -1,51 +1,8 @@
1
+ import { type SessionView } from "./core";
2
+ export * from "./core";
1
3
  /** Directory holding the per-session NDJSON logs, shared with the bash logger. */
2
4
  export declare function logDir(): string;
3
5
  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
6
  export declare function appendSkill(sessionID: string, input: {
50
7
  ts: string;
51
8
  name: string;
@@ -65,11 +22,6 @@ export declare function appendFile(sessionID: string, input: {
65
22
  export declare function parsePatch(args: unknown): string[];
66
23
  /** Second-precision UTC timestamp, matching `date -u +%FT%TZ` in logger.sh. */
67
24
  export declare function nowTs(date?: Date): string;
68
- export type SessionView = {
69
- runs: SkillRun[];
70
- summary: Summary;
71
- cwd: string;
72
- };
73
25
  /**
74
26
  * Read one session's log. A missing or unreadable file is an empty session, not
75
27
  * an error: the sidebar renders before the first event is ever written.
package/dist/tui.js CHANGED
@@ -7,11 +7,11 @@ import { createSignal, onCleanup } from "solid-js";
7
7
  import { appendFileSync, mkdirSync, readFileSync } from "fs";
8
8
  import { homedir } from "os";
9
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`);
10
+
11
+ // src/core.ts
12
+ function env(name) {
13
+ const host = globalThis;
14
+ return host.process?.env?.[name];
15
15
  }
16
16
  function parse(text) {
17
17
  const events = [];
@@ -30,8 +30,20 @@ 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 idleGapFrom(minutes) {
35
+ const value = Number(minutes);
36
+ return Number.isFinite(value) && value > 0 ? value * 60000 : DEFAULT_IDLE_GAP_MS;
37
+ }
38
+ function idleGapMs() {
39
+ return idleGapFrom(env("SKILL_AUDIT_IDLE_MINUTES"));
40
+ }
41
+ function epoch(ts) {
42
+ return new Date(ts).getTime();
43
+ }
44
+ function group(events, gapMs = idleGapMs()) {
34
45
  const runs = [];
46
+ let lastActivity = NaN;
35
47
  for (const event of events) {
36
48
  if (event.kind === "skill") {
37
49
  runs.push({
@@ -39,9 +51,12 @@ function group(events) {
39
51
  ts: event.ts,
40
52
  files: []
41
53
  });
54
+ lastActivity = epoch(event.ts);
42
55
  continue;
43
56
  }
44
- if (runs.length === 0) {
57
+ const current = runs[runs.length - 1];
58
+ const stale = epoch(event.ts) - lastActivity > gapMs;
59
+ if (!current || stale && current.skill !== NO_SKILL) {
45
60
  runs.push({
46
61
  skill: NO_SKILL,
47
62
  ts: event.ts,
@@ -53,10 +68,11 @@ function group(events) {
53
68
  tool: event.tool,
54
69
  path: event.path
55
70
  });
71
+ lastActivity = epoch(event.ts);
56
72
  }
57
73
  return runs;
58
74
  }
59
- function summarize(events) {
75
+ function summarize(events, gapMs = idleGapMs()) {
60
76
  const names = [];
61
77
  const paths = new Set;
62
78
  for (const event of events) {
@@ -66,7 +82,7 @@ function summarize(events) {
66
82
  paths.add(event.path);
67
83
  }
68
84
  }
69
- const orphan = group(events).filter((run) => run.skill === NO_SKILL).reduce((total, run) => total + run.files.length, 0);
85
+ const orphan = group(events, gapMs).filter((run) => run.skill === NO_SKILL).reduce((total, run) => total + run.files.length, 0);
70
86
  return {
71
87
  runs: names.length,
72
88
  distinct: new Set(names).size,
@@ -74,73 +90,137 @@ function summarize(events) {
74
90
  orphan
75
91
  };
76
92
  }
77
- var EMPTY = {
78
- runs: [],
79
- summary: {
80
- runs: 0,
81
- distinct: 0,
82
- files: 0,
83
- orphan: 0
84
- },
85
- cwd: ""
86
- };
93
+ function toView(text, gapMs = idleGapMs()) {
94
+ const events = parse(text);
95
+ const cwd = events.find((event) => event.cwd)?.cwd ?? "";
96
+ return {
97
+ runs: group(events, gapMs),
98
+ summary: summarize(events, gapMs),
99
+ cwd
100
+ };
101
+ }
102
+ function baseName(path) {
103
+ const trimmed = path.replace(/\/+$/, "");
104
+ return trimmed.slice(trimmed.lastIndexOf("/") + 1);
105
+ }
106
+ function relativeTo(path, cwd) {
107
+ return cwd && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path;
108
+ }
109
+
110
+ // src/log.ts
111
+ function logDir() {
112
+ return process.env.SKILL_AUDIT_DIR || join(homedir(), ".claude", "skill-audit");
113
+ }
114
+ function logPath(sessionID) {
115
+ return join(logDir(), `${sessionID}.ndjson`);
116
+ }
87
117
  function readSession(sessionID) {
88
118
  let text;
89
119
  try {
90
120
  text = readFileSync(logPath(sessionID), "utf8");
91
121
  } catch {
92
- return EMPTY;
122
+ text = "";
93
123
  }
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
- };
124
+ return toView(text);
101
125
  }
102
126
 
103
127
  // src/view.ts
104
- import { basename, relative } from "path";
128
+ var TEXT_ICONS = {
129
+ run: "\xB7",
130
+ file: "\u270E",
131
+ warn: "!",
132
+ open: "\u25BC",
133
+ closed: "\u25B6"
134
+ };
135
+ var EMOJI_ICONS = {
136
+ run: "\u26A1",
137
+ file: "\u270E",
138
+ warn: "\u26A0",
139
+ open: "\u25BC",
140
+ closed: "\u25B6"
141
+ };
142
+ function iconsFor(style) {
143
+ return style === "emoji" ? EMOJI_ICONS : TEXT_ICONS;
144
+ }
145
+ function icons() {
146
+ return iconsFor(env("SKILL_AUDIT_ICONS"));
147
+ }
148
+ function pad(value) {
149
+ return String(value).padStart(2, "0");
150
+ }
151
+ function local(ts) {
152
+ const date = new Date(ts);
153
+ return Number.isNaN(date.getTime()) ? null : date;
154
+ }
105
155
  function hhmm(ts) {
106
- return ts.slice(11, 16);
156
+ const date = local(ts);
157
+ return date ? `${pad(date.getHours())}:${pad(date.getMinutes())}` : "";
158
+ }
159
+ function hourKey(ts) {
160
+ const date = local(ts);
161
+ return date ? `${pad(date.getHours())}:00` : "";
107
162
  }
108
163
  function shortName(name) {
109
164
  const parts = name.split(":");
110
165
  return parts[parts.length - 1] || name;
111
166
  }
112
- function headerLine(summary) {
113
- const counts = `\u26A1${summary.runs} \u270E${summary.files}${summary.orphan > 0 ? ` \u26A0${summary.orphan}` : ""}`;
167
+ function bucketByHour(files) {
168
+ const buckets = [];
169
+ for (const file of files) {
170
+ const day = file.ts.slice(0, 10);
171
+ const hour = hourKey(file.ts);
172
+ const last = buckets[buckets.length - 1];
173
+ if (!last || last.day !== day || last.hour !== hour) {
174
+ buckets.push({
175
+ day,
176
+ hour,
177
+ files: []
178
+ });
179
+ }
180
+ buckets[buckets.length - 1].files.push(file);
181
+ }
182
+ return buckets;
183
+ }
184
+ function headerLine(summary, icon = icons()) {
185
+ const counts = `${icon.run}${summary.runs} ${icon.file}${summary.files}${summary.orphan > 0 ? ` ${icon.warn}${summary.orphan}` : ""}`;
114
186
  return `Skill audit ${counts}`;
115
187
  }
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);
188
+ function runTitle(run, collapsed, icon = icons()) {
189
+ const name = run.skill === NO_SKILL ? `${icon.warn} no skill` : shortName(run.skill);
122
190
  if (run.files.length === 0) {
123
- return ` ${time} ${name}`;
191
+ return ` ${name}`;
124
192
  }
125
- return collapsed ? `\u25B6 ${time} ${name} (${run.files.length})` : `\u25BC ${time} ${name}`;
193
+ return collapsed ? `${icon.closed} ${name} (${run.files.length})` : `${icon.open} ${name}`;
194
+ }
195
+ function hourTitle(bucket, collapsed, icon = icons()) {
196
+ const marker = collapsed ? icon.closed : icon.open;
197
+ return `${marker} ${bucket.hour} (${bucket.files.length})`;
126
198
  }
127
199
  function displayPath(path, cwd, width) {
128
- const rel = cwd && path.startsWith(`${cwd}/`) ? relative(cwd, path) : path;
200
+ const rel = relativeTo(path, cwd);
129
201
  if (rel.length <= width) {
130
202
  return rel;
131
203
  }
132
- const base = basename(rel);
204
+ const base = baseName(rel);
133
205
  if (base.length <= width) {
134
206
  return base;
135
207
  }
136
208
  return `${base.slice(0, Math.max(0, width - 1))}\u2026`;
137
209
  }
138
- var FILE_INDENT = " \u270E ";
210
+ var HOUR_INDENT = " ";
211
+ var FILE_INDENT = " ";
212
+ function runKey(index) {
213
+ return `run:${index}`;
214
+ }
215
+ function hourKeyOf(index, bucket) {
216
+ return `${runKey(index)}/hour:${bucket.day} ${bucket.hour}`;
217
+ }
139
218
  function sidebarLines(view, options) {
140
- const marker = options.sectionOpen ? "\u25BC" : "\u25B6";
219
+ const icon = options.icons ?? icons();
220
+ const marker = options.sectionOpen ? icon.open : icon.closed;
141
221
  const lines = [
142
222
  {
143
- text: `${marker} ${headerLine(view.summary)}`,
223
+ text: `${marker} ${headerLine(view.summary, icon)}`,
144
224
  tone: "text"
145
225
  }
146
226
  ];
@@ -155,20 +235,35 @@ function sidebarLines(view, options) {
155
235
  return lines;
156
236
  }
157
237
  view.runs.forEach((run, index) => {
158
- const collapsed = options.collapsed.has(index);
238
+ const key = runKey(index);
239
+ const runCollapsed = options.collapsed.has(key);
159
240
  lines.push({
160
- text: runTitle(run, collapsed),
241
+ text: runTitle(run, runCollapsed, icon),
161
242
  tone: run.skill === NO_SKILL ? "warning" : "accent",
162
- runIndex: index
243
+ key
163
244
  });
164
- if (collapsed) {
245
+ if (runCollapsed) {
165
246
  return;
166
247
  }
167
- for (const file of run.files) {
248
+ for (const bucket of bucketByHour(run.files)) {
249
+ const bucketKey = hourKeyOf(index, bucket);
250
+ const hourCollapsed = options.collapsed.has(bucketKey);
168
251
  lines.push({
169
- text: `${FILE_INDENT}${displayPath(file.path, view.cwd, options.width - FILE_INDENT.length)}`,
170
- tone: "muted"
252
+ text: `${HOUR_INDENT}${hourTitle(bucket, hourCollapsed, icon)}`,
253
+ tone: "text",
254
+ key: bucketKey
171
255
  });
256
+ if (hourCollapsed) {
257
+ continue;
258
+ }
259
+ for (const file of bucket.files) {
260
+ const stamp = `${hhmm(file.ts)} ${icon.file} `;
261
+ const room = options.width - FILE_INDENT.length - stamp.length;
262
+ lines.push({
263
+ text: `${FILE_INDENT}${stamp}${displayPath(file.path, view.cwd, room)}`,
264
+ tone: "muted"
265
+ });
266
+ }
172
267
  }
173
268
  });
174
269
  return lines;
@@ -252,10 +347,10 @@ function toneColor(theme, tone) {
252
347
  }
253
348
  return theme.text;
254
349
  }
255
- function toggle(set, index) {
350
+ function toggle(set, key) {
256
351
  const next = new Set(set);
257
- if (!next.delete(index)) {
258
- next.add(index);
352
+ if (!next.delete(key)) {
353
+ next.add(key);
259
354
  }
260
355
  return next;
261
356
  }
@@ -276,8 +371,8 @@ function Section(api, sessionID, width) {
276
371
  const onMouseDown = index === 0 ? () => {
277
372
  setSectionOpen((open) => !open);
278
373
  redraw();
279
- } : line.runIndex !== undefined ? () => {
280
- setCollapsed((current) => toggle(current, line.runIndex));
374
+ } : line.key !== undefined ? () => {
375
+ setCollapsed((current) => toggle(current, line.key));
281
376
  redraw();
282
377
  } : undefined;
283
378
  return element("text", {
package/dist/view.d.ts CHANGED
@@ -1,28 +1,59 @@
1
- import { type SessionView, type SkillRun, type Summary } from "./log";
1
+ import { type SessionView, type SkillRun, type Summary, type TimelineFile } from "./core";
2
+ export type IconSet = {
3
+ run: string;
4
+ file: string;
5
+ warn: string;
6
+ open: string;
7
+ closed: string;
8
+ };
9
+ /** The icon set for a `SKILL_AUDIT_ICONS` value; anything but `emoji` is the text set. */
10
+ export declare function iconsFor(style: string | undefined): IconSet;
11
+ export declare function icons(): IconSet;
2
12
  export declare function hhmm(ts: string): string;
13
+ /** The local hour a timestamp belongs to, as a bucket label. */
14
+ export declare function hourKey(ts: string): string;
3
15
  /** `superpowers:brainstorming` -> `brainstorming`, the way the statusline snippet does. */
4
16
  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;
17
+ /** `day` is carried so a run spanning midnight gets one bucket per calendar hour. */
18
+ export type HourBucket = {
19
+ day: string;
20
+ hour: string;
21
+ files: TimelineFile[];
22
+ };
23
+ /**
24
+ * Split a run's files into consecutive local-hour buckets. Consecutive rather
25
+ * than keyed, so the same hour on the next day opens a second bucket instead of
26
+ * folding a day's gap into one row.
27
+ */
28
+ export declare function bucketByHour(files: TimelineFile[]): HourBucket[];
29
+ export declare function headerLine(summary: Summary, icon?: IconSet): string;
30
+ /** The skill name leads the row; its times live on the hour buckets below it. */
31
+ export declare function runTitle(run: SkillRun, collapsed: boolean, icon?: IconSet): string;
32
+ export declare function hourTitle(bucket: HourBucket, collapsed: boolean, icon?: IconSet): string;
7
33
  /**
8
34
  * Fit a path into the sidebar: relative to the session directory, then the
9
35
  * basename, then a truncated basename.
10
36
  */
11
37
  export declare function displayPath(path: string, cwd: string, width: number): string;
12
38
  export type Tone = "text" | "muted" | "accent" | "warning";
39
+ /** `key` marks a collapsible node; the sidebar toggles whatever key it carries. */
13
40
  export type Line = {
14
41
  text: string;
15
42
  tone: Tone;
16
- runIndex?: number;
43
+ key?: string;
17
44
  };
18
45
  export type SidebarOptions = {
19
46
  /** Whether the whole section is expanded. */
20
47
  sectionOpen: boolean;
21
- /** Indices of runs whose files are hidden. */
22
- collapsed: Set<number>;
48
+ /** Keys of runs and hour buckets whose children are hidden. */
49
+ collapsed: Set<string>;
23
50
  /** Usable sidebar width, in columns. */
24
51
  width: number;
52
+ /** Icons to draw with; the environment's set when omitted. */
53
+ icons?: IconSet;
25
54
  };
55
+ export declare function runKey(index: number): string;
56
+ export declare function hourKeyOf(index: number, bucket: HourBucket): string;
26
57
  /**
27
58
  * The entire sidebar section as plain lines. Keeping layout here rather than in
28
59
  * 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.1",
3
+ "version": "0.2.0-dev.pr3.36976113060",
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",
@@ -40,17 +40,19 @@
40
40
  ],
41
41
  "scripts": {
42
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",
43
+ "format": "prettier --write src test hooks/claude.tsx hooks/claude.test.tsx types package.json tsconfig.json tsconfig.test.json tsconfig.claude.json eslint.config.js .github/workflows/ci.yml",
44
+ "format:check": "prettier --check src test hooks/claude.tsx hooks/claude.test.tsx types package.json tsconfig.json tsconfig.test.json tsconfig.claude.json eslint.config.js .github/workflows/ci.yml",
45
45
  "lint": "eslint src test",
46
46
  "build:types": "tsc --emitDeclarationOnly",
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
+ "test:claude": "./test/claude-pane.sh",
51
52
  "typecheck": "tsc --noEmit",
52
53
  "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
+ "typecheck:claude": "tsc -p tsconfig.claude.json",
55
+ "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
56
  "check:versions": "node scripts/sync-versions.mjs --check",
55
57
  "sync:versions": "node scripts/sync-versions.mjs",
56
58
  "version": "node scripts/sync-versions.mjs && git add .claude-plugin/plugin.json .codex-plugin/plugin.json",