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 +71 -24
- package/dist/core.d.ts +84 -0
- package/dist/index.js +5 -0
- package/dist/log.d.ts +2 -50
- package/dist/tui.js +151 -56
- package/dist/view.d.ts +37 -6
- package/package.json +7 -5
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,
|
|
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→
|
|
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,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
|
-
|
|
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
|
|
363
|
-
|
|
364
|
-
| `bun run format`
|
|
365
|
-
| `bun run lint`
|
|
366
|
-
| `bun run typecheck`
|
|
367
|
-
| `bun test`
|
|
368
|
-
| `bun run check`
|
|
369
|
-
| `bun run
|
|
370
|
-
| `bun run
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
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
|
-
|
|
122
|
+
text = "";
|
|
93
123
|
}
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
const
|
|
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
|
|
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 ` ${
|
|
191
|
+
return ` ${name}`;
|
|
124
192
|
}
|
|
125
|
-
return collapsed ?
|
|
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 =
|
|
200
|
+
const rel = relativeTo(path, cwd);
|
|
129
201
|
if (rel.length <= width) {
|
|
130
202
|
return rel;
|
|
131
203
|
}
|
|
132
|
-
const base =
|
|
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
|
|
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
|
|
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
|
|
238
|
+
const key = runKey(index);
|
|
239
|
+
const runCollapsed = options.collapsed.has(key);
|
|
159
240
|
lines.push({
|
|
160
|
-
text: runTitle(run,
|
|
241
|
+
text: runTitle(run, runCollapsed, icon),
|
|
161
242
|
tone: run.skill === NO_SKILL ? "warning" : "accent",
|
|
162
|
-
|
|
243
|
+
key
|
|
163
244
|
});
|
|
164
|
-
if (
|
|
245
|
+
if (runCollapsed) {
|
|
165
246
|
return;
|
|
166
247
|
}
|
|
167
|
-
for (const
|
|
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: `${
|
|
170
|
-
tone: "
|
|
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,
|
|
350
|
+
function toggle(set, key) {
|
|
256
351
|
const next = new Set(set);
|
|
257
|
-
if (!next.delete(
|
|
258
|
-
next.add(
|
|
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.
|
|
280
|
-
setCollapsed((current) => toggle(current, line.
|
|
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 "./
|
|
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
|
-
|
|
6
|
-
export
|
|
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
|
-
|
|
43
|
+
key?: string;
|
|
17
44
|
};
|
|
18
45
|
export type SidebarOptions = {
|
|
19
46
|
/** Whether the whole section is expanded. */
|
|
20
47
|
sectionOpen: boolean;
|
|
21
|
-
/**
|
|
22
|
-
collapsed: Set<
|
|
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.
|
|
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
|
-
"
|
|
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",
|