opencode-skill-audit 0.1.1 → 0.1.2

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
@@ -16,18 +16,25 @@ On opencode it also renders live in the TUI sidebar, so the audit sits next to t
16
16
  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,6 +76,15 @@ 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
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.1",
3
+ "version": "0.1.2",
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",