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 +26 -10
- 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
|
@@ -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→
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
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.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",
|