@onlooker-community/ecosystem 0.26.0 → 0.27.0
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/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.release-please-manifest.json +4 -3
- package/CHANGELOG.md +15 -0
- package/CLAUDE.md +2 -0
- package/docs/architecture.md +4 -0
- package/package.json +3 -3
- package/plugins/bursar/.claude-plugin/plugin.json +14 -0
- package/plugins/bursar/CHANGELOG.md +10 -0
- package/plugins/bursar/README.md +100 -0
- package/plugins/bursar/config.json +11 -0
- package/plugins/bursar/hooks/hooks.json +26 -0
- package/plugins/bursar/scripts/hooks/bursar-session-end.sh +142 -0
- package/plugins/bursar/scripts/hooks/bursar-session-start.sh +130 -0
- package/plugins/bursar/scripts/lib/bursar-config.sh +108 -0
- package/plugins/bursar/scripts/lib/bursar-events.sh +82 -0
- package/plugins/bursar/scripts/lib/bursar-ledger.sh +152 -0
- package/plugins/bursar/scripts/lib/bursar-project-key.sh +85 -0
- package/plugins/bursar/scripts/lib/bursar-ulid.sh +53 -0
- package/plugins/bursar/scripts/lib/portable-lock.sh +59 -0
- package/plugins/counsel/.claude-plugin/plugin.json +1 -1
- package/plugins/counsel/CHANGELOG.md +8 -0
- package/plugins/counsel/scripts/lib/counsel-reader.sh +7 -1
- package/plugins/counsel/scripts/lib/counsel-synthesize.sh +8 -1
- package/release-please-config.json +16 -0
- package/test/bats/bursar-config.bats +79 -0
- package/test/bats/bursar-events.bats +73 -0
- package/test/bats/bursar-ledger.bats +116 -0
- package/test/bats/bursar-project-key.bats +51 -0
- package/test/bats/bursar-session-end.bats +131 -0
- package/test/bats/bursar-session-start.bats +126 -0
- package/test/bats/bursar-ulid.bats +28 -0
- package/test/bats/counsel-reader.bats +28 -0
|
@@ -189,6 +189,19 @@
|
|
|
189
189
|
"license": "MIT",
|
|
190
190
|
"keywords": ["verification", "claims", "exit-codes", "honesty", "testing", "transcript"],
|
|
191
191
|
"tags": ["verification", "testing"]
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
"name": "bursar",
|
|
195
|
+
"source": "./plugins/bursar",
|
|
196
|
+
"description": "Multi-session, per-project budget accounting. Rolls each session's spend into a per-project ledger on SessionEnd and surfaces \"this project burned $X this week\" at SessionStart. Where governor regulates a single session, bursar is the cross-session rollup: it reads governor.session.complete off the shared event bus and degrades to a session count when governor is absent. Requires the ecosystem plugin.",
|
|
197
|
+
"author": {
|
|
198
|
+
"name": "Onlooker Community"
|
|
199
|
+
},
|
|
200
|
+
"homepage": "https://onlooker.dev",
|
|
201
|
+
"repository": "https://github.com/onlooker-community/ecosystem",
|
|
202
|
+
"license": "MIT",
|
|
203
|
+
"keywords": ["budget", "cost", "rollup", "multi-session", "accounting", "audit"],
|
|
204
|
+
"tags": ["governance", "observability"]
|
|
192
205
|
}
|
|
193
206
|
]
|
|
194
207
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ecosystem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Observability substrate for Claude Code. Provides the shared $ONLOOKER_DIR storage root (default $HOME/.onlooker), canonical schema-validated event emission, session and tool tracking hooks, and prompt rules. Required by all other Onlooker plugins.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Onlooker Community",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
".": "0.
|
|
2
|
+
".": "0.27.0",
|
|
3
3
|
"plugins/archivist": "0.1.0",
|
|
4
4
|
"plugins/tribunal": "1.0.1",
|
|
5
5
|
"plugins/echo": "0.2.0",
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
"plugins/governor": "0.2.1",
|
|
8
8
|
"plugins/compass": "0.2.0",
|
|
9
9
|
"plugins/scribe": "0.2.1",
|
|
10
|
-
"plugins/counsel": "0.3.
|
|
10
|
+
"plugins/counsel": "0.3.1",
|
|
11
11
|
"plugins/warden": "0.2.0",
|
|
12
12
|
"plugins/librarian": "0.2.0",
|
|
13
13
|
"plugins/curator": "0.1.0",
|
|
14
14
|
"plugins/historian": "0.2.0",
|
|
15
|
-
"plugins/assayer": "1.0.0"
|
|
15
|
+
"plugins/assayer": "1.0.0",
|
|
16
|
+
"plugins/bursar": "0.1.0"
|
|
16
17
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.27.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.26.1...ecosystem-v0.27.0) (2026-06-12)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **bursar:** introduce multi-session budget rollup plugin ([#81](https://github.com/onlooker-community/ecosystem/issues/81)) ([b11e687](https://github.com/onlooker-community/ecosystem/commit/b11e687744bab70a94025c46c4aaa58fb7ea97f4))
|
|
9
|
+
|
|
10
|
+
## [0.26.1](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.26.0...ecosystem-v0.26.1) (2026-06-12)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **counsel:** drop unsupported --max-tokens flag from claude synthesis call :relieved: ([#79](https://github.com/onlooker-community/ecosystem/issues/79)) ([ade85ce](https://github.com/onlooker-community/ecosystem/commit/ade85cecb3243781f47e14fea4990ce31e69e8f4))
|
|
16
|
+
* **counsel:** stop pipefail from discarding all events on large logs :relieved: ([#78](https://github.com/onlooker-community/ecosystem/issues/78)) ([638347d](https://github.com/onlooker-community/ecosystem/commit/638347dec3b9df740b7a85c3e475fa2ffe5d054b))
|
|
17
|
+
|
|
3
18
|
## [0.26.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.25.1...ecosystem-v0.26.0) (2026-06-11)
|
|
4
19
|
|
|
5
20
|
|
package/CLAUDE.md
CHANGED
|
@@ -11,6 +11,7 @@ ecosystem/ ← substrate plugin (always-on observability)
|
|
|
11
11
|
|
|
12
12
|
plugins/
|
|
13
13
|
archivist/ ← session memory across context truncation
|
|
14
|
+
bursar/ ← multi-session, per-project budget rollup (governor's cross-session view)
|
|
14
15
|
cartographer/ ← instruction-file auditor (CLAUDE.md, AGENTS.md, rules/)
|
|
15
16
|
compass/ ← pre-write alignment gate (design phase)
|
|
16
17
|
echo/ ← prompt-change regression detection
|
|
@@ -38,6 +39,7 @@ scripts/lib/onlooker-event.mjs ← canonical event builder; all plugins route t
|
|
|
38
39
|
| tribunal | Stop + skill invocation | Post-task quality gate; also invokable via `/tribunal` |
|
|
39
40
|
| warden | PostToolUse (WebFetch, Read), PreToolUse (Write, Edit, MultiEdit, Bash), SessionStart + skill invocation | Scans ingested content for injection; closes a content gate that blocks write-class tools until cleared via `/warden` |
|
|
40
41
|
| assayer | Stop | Verifies the agent's final-message claims against actual command results in the transcript; advisory |
|
|
42
|
+
| bursar | SessionStart, SessionEnd | Rolls each session's spend into a per-project ledger on SessionEnd; surfaces "this project burned $X this week" at SessionStart. Governor is per-session; bursar is the cross-session rollup |
|
|
41
43
|
|
|
42
44
|
Plugins communicate by emitting events to the JSONL log — they do not call each other directly. All plugins depend on the ecosystem substrate; no plugin depends on another plugin directly.
|
|
43
45
|
|
package/docs/architecture.md
CHANGED
|
@@ -56,6 +56,10 @@ Plugins communicate by **emitting events**, not by calling each other directly.
|
|
|
56
56
|
|
|
57
57
|
Findings are stored in `~/.onlooker/cartographer/<project-key>/findings/` and delivered at-least-once (deduplicated on `payload.finding_hash`). The audit runs as a detached background process; your session is never blocked.
|
|
58
58
|
|
|
59
|
+
### Bursar
|
|
60
|
+
|
|
61
|
+
[Bursar](../plugins/bursar) is the clearest example of one plugin consuming another's output **through the bus rather than by direct coupling**. Governor tracks spend per session and emits `governor.session.complete`; bursar reads those events at `SessionEnd`, rolls each session's totals into a per-project ledger under `~/.onlooker/bursar/projects/<project-key>/`, and surfaces "this project burned $X this week" at the next `SessionStart`. It never imports governor's code — if governor is disabled the events simply aren't there, and bursar degrades to a session count. This is the dependency model working as intended: the cross-session rollup is a separate, independently installable plugin that observes governor's event stream.
|
|
62
|
+
|
|
59
63
|
### Plugin dependency model
|
|
60
64
|
|
|
61
65
|
All plugins depend on `ecosystem`. No plugin depends on another plugin at runtime. This means:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlooker-community/ecosystem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Agents, skills, hooks, commands, rules, and MCP configurations that power [Onlooker](https://onlooker.dev)",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Onlooker Community",
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
"onlooker-install": "install.sh"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@onlooker-community/schema": "^2.
|
|
22
|
+
"@onlooker-community/schema": "^2.7.0"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
25
|
"postinstall": "echo '\\n onlooker-ecosystem installed!\\n Run: npx onlooker-install typescript\\n Docs: https://github.com/onlooker-community/ecosystem\\n'",
|
|
26
26
|
"test": "npm run test:bats && npm run test:schema",
|
|
27
27
|
"test:bats": "bats test/bats",
|
|
28
28
|
"test:schema": "node --test test/node/*.test.mjs",
|
|
29
|
-
"test:shellcheck": "shellcheck -S error -x install.sh scripts/common.sh scripts/hooks/*.sh scripts/lib/*.sh plugins/archivist/scripts/hooks/*.sh plugins/archivist/scripts/lib/*.sh plugins/tribunal/scripts/hooks/*.sh plugins/tribunal/scripts/lib/*.sh plugins/echo/scripts/hooks/*.sh plugins/echo/scripts/lib/*.sh plugins/governor/scripts/hooks/*.sh plugins/governor/scripts/lib/*.sh plugins/compass/scripts/hooks/*.sh plugins/compass/scripts/lib/*.sh plugins/scribe/scripts/hooks/*.sh plugins/scribe/scripts/lib/*.sh plugins/counsel/scripts/hooks/*.sh plugins/counsel/scripts/lib/*.sh plugins/warden/scripts/hooks/*.sh plugins/warden/scripts/lib/*.sh plugins/librarian/scripts/hooks/*.sh plugins/librarian/scripts/lib/*.sh plugins/curator/scripts/hooks/*.sh plugins/curator/scripts/lib/*.sh plugins/historian/scripts/hooks/*.sh plugins/historian/scripts/lib/*.sh plugins/assayer/scripts/hooks/*.sh plugins/assayer/scripts/lib/*.sh plugins/cartographer/scripts/hooks/*.sh plugins/cartographer/scripts/lib/*.sh",
|
|
29
|
+
"test:shellcheck": "shellcheck -S error -x install.sh scripts/common.sh scripts/hooks/*.sh scripts/lib/*.sh plugins/archivist/scripts/hooks/*.sh plugins/archivist/scripts/lib/*.sh plugins/tribunal/scripts/hooks/*.sh plugins/tribunal/scripts/lib/*.sh plugins/echo/scripts/hooks/*.sh plugins/echo/scripts/lib/*.sh plugins/governor/scripts/hooks/*.sh plugins/governor/scripts/lib/*.sh plugins/compass/scripts/hooks/*.sh plugins/compass/scripts/lib/*.sh plugins/scribe/scripts/hooks/*.sh plugins/scribe/scripts/lib/*.sh plugins/counsel/scripts/hooks/*.sh plugins/counsel/scripts/lib/*.sh plugins/warden/scripts/hooks/*.sh plugins/warden/scripts/lib/*.sh plugins/librarian/scripts/hooks/*.sh plugins/librarian/scripts/lib/*.sh plugins/curator/scripts/hooks/*.sh plugins/curator/scripts/lib/*.sh plugins/historian/scripts/hooks/*.sh plugins/historian/scripts/lib/*.sh plugins/assayer/scripts/hooks/*.sh plugins/assayer/scripts/lib/*.sh plugins/cartographer/scripts/hooks/*.sh plugins/cartographer/scripts/lib/*.sh plugins/bursar/scripts/hooks/*.sh plugins/bursar/scripts/lib/*.sh",
|
|
30
30
|
"lint:references": "node scripts/lint/check-references.mjs",
|
|
31
31
|
"lint:manifests": "node scripts/lint/check-manifests.mjs",
|
|
32
32
|
"coverage:node": "node scripts/coverage/run-coverage.mjs",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bursar",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Multi-session, per-project budget accounting for the Onlooker ecosystem. Rolls each session's spend into a per-project ledger on SessionEnd and surfaces \"this project burned $X this week\" at SessionStart. Where governor regulates a single session, bursar is the cross-session rollup: it reads governor.session.complete off the shared event bus and emits bursar.* events for audit. Named for the officer who keeps the accounts. Builds on the Onlooker ecosystem plugin.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Onlooker Community",
|
|
7
|
+
"url": "https://onlooker.dev"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://onlooker.dev",
|
|
10
|
+
"repository": "https://github.com/onlooker-community/ecosystem",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"skills": [],
|
|
13
|
+
"agents": []
|
|
14
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0](https://github.com/onlooker-community/ecosystem/compare/bursar-v0.0.1...bursar-v0.1.0) (2026-06-12)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **bursar:** introduce multi-session budget rollup plugin ([#81](https://github.com/onlooker-community/ecosystem/issues/81)) ([b11e687](https://github.com/onlooker-community/ecosystem/commit/b11e687744bab70a94025c46c4aaa58fb7ea97f4))
|
|
9
|
+
|
|
10
|
+
## Changelog
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Bursar
|
|
2
|
+
|
|
3
|
+
Multi-session, per-project budget accounting for the Onlooker ecosystem.
|
|
4
|
+
|
|
5
|
+
Bursar rolls each session's spend into a per-project ledger when the session ends, and surfaces "this project burned $X this week" at the next session start. Named for the officer who keeps an institution's accounts, it answers a question [`governor`](../governor) cannot: not "is *this* session over budget?" but "what has *this project* cost me lately?"
|
|
6
|
+
|
|
7
|
+
Where governor regulates a single session, bursar is the cross-session rollup. The two do not call each other — bursar reads governor's per-session totals off the shared event bus (`governor.session.complete`) and degrades gracefully when governor is not running.
|
|
8
|
+
|
|
9
|
+
Bursar is a sibling plugin to [`ecosystem`](../../) and assumes the Onlooker observability substrate (`~/.onlooker/`) is present.
|
|
10
|
+
|
|
11
|
+
## How it works
|
|
12
|
+
|
|
13
|
+
| Hook | Matcher | What Bursar does |
|
|
14
|
+
|------|---------|------------------|
|
|
15
|
+
| `SessionStart` | `*` | Derives the project key from the session `cwd`, writes a breadcrumb so `SessionEnd` can attribute spend, then sums the per-project ledger over the active window and surfaces the total as `additionalContext`. Emits `bursar.rollup.surfaced` (or `bursar.rollup.skipped` when there is nothing in the window). |
|
|
16
|
+
| `SessionEnd` | `*` | Resolves the ending session's project key (breadcrumb → substrate session tracker → live cwd), reads the session's spend from the latest `governor.session.complete` on the event bus, upserts one record into the per-project ledger, and emits `bursar.session.recorded`. |
|
|
17
|
+
|
|
18
|
+
### Attributing a session to a project
|
|
19
|
+
|
|
20
|
+
`SessionEnd`'s hook payload only reliably carries `session_id`, so bursar cannot derive a project key from a `cwd` at that point. Instead, `SessionStart` — which *does* receive `cwd` — derives the [project key](../tribunal/scripts/lib/tribunal-project-key.sh) (SHA256 of the git origin URL, or the repo root for remote-less repos) and stashes a breadcrumb at `$ONLOOKER_DIR/bursar/sessions/<session-id>.json`. `SessionEnd` reads it back, falling back to the substrate session tracker's recorded `cwd`, then to the live `cwd`.
|
|
21
|
+
|
|
22
|
+
Records are keyed by `session_id`: re-recording a session replaces its line rather than appending, so a `SessionEnd` that fires more than once is idempotent.
|
|
23
|
+
|
|
24
|
+
### Reading spend off the bus
|
|
25
|
+
|
|
26
|
+
On `SessionEnd`, bursar scans `~/.onlooker/logs/onlooker-events.jsonl` for the **last** `governor.session.complete` matching the session — governor re-emits cumulatively, so the final one carries the session's totals. It records `total_cost_usd`, `total_tokens`, and `total_api_calls`. When no such event exists (governor disabled or absent), the session is still recorded with `governor_present: false` and no cost, and the surfaced message degrades to a session count.
|
|
27
|
+
|
|
28
|
+
## Activation
|
|
29
|
+
|
|
30
|
+
Bursar is **off by default**. Enable it per-project in `.claude/settings.json`:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"bursar": {
|
|
35
|
+
"enabled": true
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or globally in `~/.claude/settings.json`. While disabled, every hook skips silently and no ledger is written.
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
All keys are optional. Unset keys fall back to the plugin's `config.json` defaults.
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"bursar": {
|
|
49
|
+
"enabled": false,
|
|
50
|
+
"window": "rolling_7d",
|
|
51
|
+
"week_start": "monday",
|
|
52
|
+
"surface_at_session_start": true,
|
|
53
|
+
"min_cost_to_surface_usd": 0
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
| Key | Default | Description |
|
|
59
|
+
|-----|---------|-------------|
|
|
60
|
+
| `enabled` | `false` | Must be `true` for any recording, surfacing, or event emission to run. |
|
|
61
|
+
| `window` | `"rolling_7d"` | Rollup window. `"rolling_7d"` sums the trailing 7×24h; `"calendar_week"` sums from the most recent week start. |
|
|
62
|
+
| `week_start` | `"monday"` | First day of the calendar week — `"monday"` or `"sunday"`. Only consulted when `window` is `"calendar_week"`. |
|
|
63
|
+
| `surface_at_session_start` | `true` | When `false`, bursar still records sessions and writes breadcrumbs but prints nothing at `SessionStart`. |
|
|
64
|
+
| `min_cost_to_surface_usd` | `0` | Suppress the `SessionStart` message when the windowed total is below this dollar amount. |
|
|
65
|
+
|
|
66
|
+
Config resolves in three layers, latest wins: plugin `config.json` → `~/.claude/settings.json` → `<repo>/.claude/settings.json`.
|
|
67
|
+
|
|
68
|
+
## Storage layout
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
~/.onlooker/bursar/
|
|
72
|
+
├── projects/
|
|
73
|
+
│ └── <project-key>/
|
|
74
|
+
│ ├── sessions.jsonl # one record per session (project key sanitized to [a-zA-Z0-9-_])
|
|
75
|
+
│ └── sessions.jsonl.lock # upsert lock
|
|
76
|
+
└── sessions/
|
|
77
|
+
└── <session-id>.json # SessionStart→SessionEnd breadcrumb (removed once recorded)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Each ledger line is a JSON record: `{ ts, ts_epoch, session_id, project_key, cost_usd?, tokens?, api_calls?, governor_present, model? }`. `ts_epoch` (seconds) is stored alongside the RFC3339 `ts` so windowing is a portable numeric compare with no date parsing. Cost fields are omitted when governor was not running for the session.
|
|
81
|
+
|
|
82
|
+
Bursar honors `$ONLOOKER_DIR`; it never hardcodes `~/.onlooker`, so the test suite's isolated temp home is respected.
|
|
83
|
+
|
|
84
|
+
## Events emitted
|
|
85
|
+
|
|
86
|
+
Bursar emits the canonical `bursar.*` event surface from [`@onlooker-community/schema`](https://github.com/onlooker-community/schema) v2.7.0+. All events land in `~/.onlooker/logs/onlooker-events.jsonl` and are validated against the schema before write.
|
|
87
|
+
|
|
88
|
+
| Event | When |
|
|
89
|
+
|-------|------|
|
|
90
|
+
| `bursar.session.recorded` | At `SessionEnd`, after a session's spend is upserted into the project ledger. Carries `project_key`, `session_id`, `governor_present`, and — when governor supplied them — `cost_usd`, `tokens`, `api_calls`, `model`. |
|
|
91
|
+
| `bursar.rollup.surfaced` | At `SessionStart`, when a windowed total is shown. Carries `project_key`, `window`, `window_start`, `total_cost_usd`, `session_count`, `total_tokens`, and `sessions_with_cost`. |
|
|
92
|
+
| `bursar.rollup.skipped` | At `SessionStart`, when nothing is surfaced because the window is empty. Carries `reason` and `project_key`. |
|
|
93
|
+
|
|
94
|
+
## Requirements
|
|
95
|
+
|
|
96
|
+
- The `ecosystem` plugin installed (for the `~/.onlooker/` substrate and canonical event emission).
|
|
97
|
+
- The [`governor`](../governor) plugin enabled, to populate the dollar figures bursar rolls up. Without it, bursar still reports session counts.
|
|
98
|
+
- `jq` for JSON manipulation.
|
|
99
|
+
- `node` for canonical-event emission.
|
|
100
|
+
- `awk` for fractional cost arithmetic and token formatting (standard on macOS and most Linux distributions).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "*",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/bursar-session-start.sh"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"SessionEnd": [
|
|
15
|
+
{
|
|
16
|
+
"matcher": "*",
|
|
17
|
+
"hooks": [
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/bursar-session-end.sh"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Bursar SessionEnd hook.
|
|
3
|
+
#
|
|
4
|
+
# Fires when a session ends. Responsibilities:
|
|
5
|
+
# 1. Skip silently when bursar.enabled is false.
|
|
6
|
+
# 2. Resolve the project key for the ending session (breadcrumb first, then
|
|
7
|
+
# the substrate session-tracker cwd, then the current cwd).
|
|
8
|
+
# 3. Read this session's spend from the shared event bus — the latest
|
|
9
|
+
# governor.session.complete for this session_id. When governor is absent
|
|
10
|
+
# the session is still recorded, with cost unknown (governor_present:false).
|
|
11
|
+
# 4. Upsert the session's spend into the per-project rollup ledger.
|
|
12
|
+
# 5. Emit bursar.session.recorded and drop the breadcrumb.
|
|
13
|
+
#
|
|
14
|
+
# Hook contract:
|
|
15
|
+
# - Always exits 0. Never blocks session termination.
|
|
16
|
+
# - Errors are written to stderr only; stdout is kept clean.
|
|
17
|
+
|
|
18
|
+
set -uo pipefail
|
|
19
|
+
|
|
20
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
21
|
+
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
|
22
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
23
|
+
|
|
24
|
+
# shellcheck source=../lib/portable-lock.sh
|
|
25
|
+
source "${PLUGIN_ROOT}/scripts/lib/portable-lock.sh"
|
|
26
|
+
# shellcheck source=../lib/bursar-config.sh
|
|
27
|
+
source "${PLUGIN_ROOT}/scripts/lib/bursar-config.sh"
|
|
28
|
+
# shellcheck source=../lib/bursar-events.sh
|
|
29
|
+
source "${PLUGIN_ROOT}/scripts/lib/bursar-events.sh"
|
|
30
|
+
# shellcheck source=../lib/bursar-project-key.sh
|
|
31
|
+
source "${PLUGIN_ROOT}/scripts/lib/bursar-project-key.sh"
|
|
32
|
+
# shellcheck source=../lib/bursar-ledger.sh
|
|
33
|
+
source "${PLUGIN_ROOT}/scripts/lib/bursar-ledger.sh"
|
|
34
|
+
|
|
35
|
+
INPUT=$(cat)
|
|
36
|
+
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""' 2>/dev/null) || SESSION_ID=""
|
|
37
|
+
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || CWD=""
|
|
38
|
+
|
|
39
|
+
_done() { exit 0; }
|
|
40
|
+
|
|
41
|
+
[[ -z "$SESSION_ID" ]] && _done
|
|
42
|
+
|
|
43
|
+
ONLOOKER_DIR="${ONLOOKER_DIR:-${HOME}/.onlooker}"
|
|
44
|
+
SAFE_SID=$(printf '%s' "$SESSION_ID" | tr -c 'a-zA-Z0-9-' '_')
|
|
45
|
+
BREADCRUMB="${ONLOOKER_DIR}/bursar/sessions/${SAFE_SID}.json"
|
|
46
|
+
TRACKER="${ONLOOKER_DIR}/session-trackers/${SESSION_ID}"
|
|
47
|
+
|
|
48
|
+
# -----------------------------------------------------------------------
|
|
49
|
+
# Resolve project key + cwd: breadcrumb → substrate tracker → live cwd.
|
|
50
|
+
# -----------------------------------------------------------------------
|
|
51
|
+
PROJECT_KEY=""
|
|
52
|
+
if [[ -f "$BREADCRUMB" ]]; then
|
|
53
|
+
PROJECT_KEY=$(jq -r '.project_key // ""' "$BREADCRUMB" 2>/dev/null) || PROJECT_KEY=""
|
|
54
|
+
[[ -z "$CWD" ]] && CWD=$(jq -r '.cwd // ""' "$BREADCRUMB" 2>/dev/null)
|
|
55
|
+
fi
|
|
56
|
+
if [[ -z "$CWD" && -f "$TRACKER" ]]; then
|
|
57
|
+
CWD=$(jq -r '.cwd // ""' "$TRACKER" 2>/dev/null) || CWD=""
|
|
58
|
+
fi
|
|
59
|
+
[[ -z "$CWD" ]] && CWD="$(pwd)"
|
|
60
|
+
|
|
61
|
+
bursar_config_load "$CWD"
|
|
62
|
+
bursar_config_enabled || _done
|
|
63
|
+
|
|
64
|
+
[[ -z "$PROJECT_KEY" ]] && PROJECT_KEY=$(bursar_project_key "$CWD")
|
|
65
|
+
[[ -z "$PROJECT_KEY" ]] && _done
|
|
66
|
+
|
|
67
|
+
# -----------------------------------------------------------------------
|
|
68
|
+
# Read this session's spend off the shared event bus: the last
|
|
69
|
+
# governor.session.complete carries the session's cumulative totals.
|
|
70
|
+
# -----------------------------------------------------------------------
|
|
71
|
+
LOG="${ONLOOKER_EVENTS_LOG:-${ONLOOKER_DIR}/logs/onlooker-events.jsonl}"
|
|
72
|
+
GOV_PRESENT="false"
|
|
73
|
+
COST=""
|
|
74
|
+
TOKENS=""
|
|
75
|
+
CALLS=""
|
|
76
|
+
|
|
77
|
+
# Reads event-log lines on stdin; echoes the latest matching session.complete payload.
|
|
78
|
+
_latest_governor_payload() {
|
|
79
|
+
grep -F '"governor.session.complete"' 2>/dev/null \
|
|
80
|
+
| jq -c --arg sid "$SESSION_ID" \
|
|
81
|
+
'select(.event_type == "governor.session.complete" and .payload.session_id == $sid) | .payload' \
|
|
82
|
+
2>/dev/null \
|
|
83
|
+
| tail -n 1
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if [[ -f "$LOG" ]]; then
|
|
87
|
+
# The matching event was emitted seconds ago (governor's final Stop), so it is
|
|
88
|
+
# almost always near the tail. Scan a recent slice first to keep this hook
|
|
89
|
+
# fast as the global log grows; fall back to the full file only on a miss.
|
|
90
|
+
SPEND=$(tail -n 2000 "$LOG" 2>/dev/null | _latest_governor_payload)
|
|
91
|
+
[[ -z "$SPEND" ]] && SPEND=$(_latest_governor_payload < "$LOG")
|
|
92
|
+
if [[ -n "$SPEND" ]]; then
|
|
93
|
+
GOV_PRESENT="true"
|
|
94
|
+
COST=$(printf '%s' "$SPEND" | jq -r '.total_cost_usd // empty' 2>/dev/null) || COST=""
|
|
95
|
+
TOKENS=$(printf '%s' "$SPEND" | jq -r '.total_tokens // empty' 2>/dev/null) || TOKENS=""
|
|
96
|
+
CALLS=$(printf '%s' "$SPEND" | jq -r '.total_api_calls // empty' 2>/dev/null) || CALLS=""
|
|
97
|
+
fi
|
|
98
|
+
fi
|
|
99
|
+
|
|
100
|
+
MODEL=""
|
|
101
|
+
[[ -f "$TRACKER" ]] && MODEL=$(jq -r '.model // ""' "$TRACKER" 2>/dev/null)
|
|
102
|
+
|
|
103
|
+
# -----------------------------------------------------------------------
|
|
104
|
+
# Build the record and the event payload, attaching spend fields only when
|
|
105
|
+
# governor supplied them.
|
|
106
|
+
# -----------------------------------------------------------------------
|
|
107
|
+
add_fields() {
|
|
108
|
+
# Echoes the input JSON ($1) with cost/tokens/calls/model merged in.
|
|
109
|
+
local base="$1"
|
|
110
|
+
[[ -n "$COST" ]] && base=$(printf '%s' "$base" | jq --argjson v "$COST" '. + {cost_usd: $v}' 2>/dev/null)
|
|
111
|
+
[[ -n "$TOKENS" ]] && base=$(printf '%s' "$base" | jq --argjson v "$TOKENS" '. + {tokens: $v}' 2>/dev/null)
|
|
112
|
+
[[ -n "$CALLS" ]] && base=$(printf '%s' "$base" | jq --argjson v "$CALLS" '. + {api_calls: $v}' 2>/dev/null)
|
|
113
|
+
[[ -n "$MODEL" ]] && base=$(printf '%s' "$base" | jq --arg v "$MODEL" '. + {model: $v}' 2>/dev/null)
|
|
114
|
+
printf '%s' "$base"
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
RECORD=$(jq -n \
|
|
118
|
+
--arg ts "$(bursar_now_iso)" \
|
|
119
|
+
--argjson te "$(bursar_now_epoch)" \
|
|
120
|
+
--arg sid "$SESSION_ID" \
|
|
121
|
+
--arg pk "$PROJECT_KEY" \
|
|
122
|
+
--argjson gp "$GOV_PRESENT" \
|
|
123
|
+
'{ts: $ts, ts_epoch: $te, session_id: $sid, project_key: $pk, governor_present: $gp}' 2>/dev/null)
|
|
124
|
+
RECORD=$(add_fields "$RECORD")
|
|
125
|
+
|
|
126
|
+
# Only claim the session was recorded — and only drop the breadcrumb — once the
|
|
127
|
+
# ledger upsert actually succeeds. A failed write (lock timeout, mv failure)
|
|
128
|
+
# must keep the breadcrumb so the session→project attribution survives for a
|
|
129
|
+
# later attempt rather than being lost behind a false "recorded" event.
|
|
130
|
+
if [[ -n "$RECORD" ]] && bursar_ledger_record "$PROJECT_KEY" "$RECORD"; then
|
|
131
|
+
EV=$(jq -n \
|
|
132
|
+
--arg pk "$PROJECT_KEY" \
|
|
133
|
+
--arg sid "$SESSION_ID" \
|
|
134
|
+
--argjson gp "$GOV_PRESENT" \
|
|
135
|
+
'{project_key: $pk, session_id: $sid, governor_present: $gp}' 2>/dev/null)
|
|
136
|
+
EV=$(add_fields "$EV")
|
|
137
|
+
[[ -n "$EV" ]] && bursar_emit_event "bursar.session.recorded" "$EV" "$SESSION_ID" || true
|
|
138
|
+
|
|
139
|
+
rm -f "$BREADCRUMB" 2>/dev/null || true
|
|
140
|
+
fi
|
|
141
|
+
|
|
142
|
+
_done
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Bursar SessionStart hook.
|
|
3
|
+
#
|
|
4
|
+
# Fires at every session start. Responsibilities:
|
|
5
|
+
# 1. Skip silently when bursar.enabled is false.
|
|
6
|
+
# 2. Derive the project key from the session cwd and stash a breadcrumb
|
|
7
|
+
# (project_key + cwd) so SessionEnd can attribute spend even though the
|
|
8
|
+
# SessionEnd payload only reliably carries session_id.
|
|
9
|
+
# 3. Surface "this project burned $X this week" by summing the per-project
|
|
10
|
+
# ledger over the configured window and emitting it as SessionStart
|
|
11
|
+
# additionalContext.
|
|
12
|
+
# 4. Emit bursar.rollup.surfaced (or bursar.rollup.skipped) for audit.
|
|
13
|
+
#
|
|
14
|
+
# Hook contract:
|
|
15
|
+
# - Always exits 0. Never blocks SessionStart.
|
|
16
|
+
# - Only the additionalContext JSON is written to stdout; errors go to stderr.
|
|
17
|
+
|
|
18
|
+
set -uo pipefail
|
|
19
|
+
|
|
20
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
21
|
+
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
|
22
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
23
|
+
|
|
24
|
+
# shellcheck source=../lib/portable-lock.sh
|
|
25
|
+
source "${PLUGIN_ROOT}/scripts/lib/portable-lock.sh"
|
|
26
|
+
# shellcheck source=../lib/bursar-config.sh
|
|
27
|
+
source "${PLUGIN_ROOT}/scripts/lib/bursar-config.sh"
|
|
28
|
+
# shellcheck source=../lib/bursar-events.sh
|
|
29
|
+
source "${PLUGIN_ROOT}/scripts/lib/bursar-events.sh"
|
|
30
|
+
# shellcheck source=../lib/bursar-project-key.sh
|
|
31
|
+
source "${PLUGIN_ROOT}/scripts/lib/bursar-project-key.sh"
|
|
32
|
+
# shellcheck source=../lib/bursar-ledger.sh
|
|
33
|
+
source "${PLUGIN_ROOT}/scripts/lib/bursar-ledger.sh"
|
|
34
|
+
|
|
35
|
+
INPUT=$(cat)
|
|
36
|
+
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""' 2>/dev/null) || SESSION_ID=""
|
|
37
|
+
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || CWD=""
|
|
38
|
+
|
|
39
|
+
_done() { exit 0; }
|
|
40
|
+
|
|
41
|
+
bursar_config_load "$CWD"
|
|
42
|
+
bursar_config_enabled || _done
|
|
43
|
+
|
|
44
|
+
[[ -z "$CWD" ]] && CWD="$(pwd)"
|
|
45
|
+
PROJECT_KEY=$(bursar_project_key "$CWD")
|
|
46
|
+
[[ -z "$PROJECT_KEY" ]] && _done # not a recognizable project — nothing to attribute
|
|
47
|
+
|
|
48
|
+
ONLOOKER_DIR="${ONLOOKER_DIR:-${HOME}/.onlooker}"
|
|
49
|
+
|
|
50
|
+
# -----------------------------------------------------------------------
|
|
51
|
+
# Breadcrumb: lets SessionEnd resolve the project key without re-deriving
|
|
52
|
+
# from a cwd it may not have.
|
|
53
|
+
# -----------------------------------------------------------------------
|
|
54
|
+
if [[ -n "$SESSION_ID" ]]; then
|
|
55
|
+
BREADCRUMB_DIR="${ONLOOKER_DIR}/bursar/sessions"
|
|
56
|
+
mkdir -p "$BREADCRUMB_DIR" 2>/dev/null || true
|
|
57
|
+
safe_sid=$(printf '%s' "$SESSION_ID" | tr -c 'a-zA-Z0-9-' '_')
|
|
58
|
+
jq -n --arg pk "$PROJECT_KEY" --arg cwd "$CWD" --arg ts "$(bursar_now_iso)" \
|
|
59
|
+
'{project_key: $pk, cwd: $cwd, started_at: $ts}' \
|
|
60
|
+
>"${BREADCRUMB_DIR}/${safe_sid}.json" 2>/dev/null || true
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
# -----------------------------------------------------------------------
|
|
64
|
+
# Surface the rolling total (opt-out via bursar.surface_at_session_start).
|
|
65
|
+
# -----------------------------------------------------------------------
|
|
66
|
+
bursar_config_surface_enabled || _done
|
|
67
|
+
|
|
68
|
+
WINDOW=$(bursar_config_window)
|
|
69
|
+
WEEK_START=$(bursar_config_week_start)
|
|
70
|
+
CUTOFF=$(bursar_window_cutoff_epoch "$WINDOW" "$WEEK_START")
|
|
71
|
+
TOTALS=$(bursar_window_totals "$PROJECT_KEY" "$CUTOFF")
|
|
72
|
+
|
|
73
|
+
SESSION_COUNT=$(printf '%s' "$TOTALS" | jq -r '.session_count // 0' 2>/dev/null) || SESSION_COUNT=0
|
|
74
|
+
TOTAL_COST=$(printf '%s' "$TOTALS" | jq -r '.total_cost_usd // 0' 2>/dev/null) || TOTAL_COST=0
|
|
75
|
+
TOTAL_TOKENS=$(printf '%s' "$TOTALS" | jq -r '.total_tokens // 0' 2>/dev/null) || TOTAL_TOKENS=0
|
|
76
|
+
SESSIONS_WITH_COST=$(printf '%s' "$TOTALS" | jq -r '.sessions_with_cost // 0' 2>/dev/null) || SESSIONS_WITH_COST=0
|
|
77
|
+
|
|
78
|
+
# Nothing recorded yet in the window.
|
|
79
|
+
if [[ "${SESSION_COUNT:-0}" -eq 0 ]]; then
|
|
80
|
+
skipped=$(jq -n --arg pk "$PROJECT_KEY" '{reason: "no_data", project_key: $pk}' 2>/dev/null) || skipped='{"reason":"no_data"}'
|
|
81
|
+
bursar_emit_event "bursar.rollup.skipped" "$skipped" "$SESSION_ID" || true
|
|
82
|
+
_done
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
# Below the noise threshold — record nothing on screen.
|
|
86
|
+
MIN_COST=$(bursar_config_min_cost)
|
|
87
|
+
if [[ "$(awk -v c="$TOTAL_COST" -v m="$MIN_COST" 'BEGIN { print (c < m) ? 1 : 0 }')" == "1" ]]; then
|
|
88
|
+
_done
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
WINDOW_LABEL="in the last 7 days"
|
|
92
|
+
[[ "$WINDOW" == "calendar_week" ]] && WINDOW_LABEL="this week"
|
|
93
|
+
COST_FMT=$(awk -v c="$TOTAL_COST" 'BEGIN { printf "%.2f", c }')
|
|
94
|
+
TOKENS_FMT=$(bursar_fmt_tokens "$TOTAL_TOKENS")
|
|
95
|
+
SESS_NOUN=$([[ "${SESSION_COUNT:-0}" -eq 1 ]] && printf 'session' || printf 'sessions')
|
|
96
|
+
|
|
97
|
+
# Key the "enable governor" prompt on cost *coverage*, not on the dollar total:
|
|
98
|
+
# governor can legitimately report $0.00 for a window, and that should still
|
|
99
|
+
# render as a tracked total rather than a nudge to enable governor.
|
|
100
|
+
if [[ "${SESSIONS_WITH_COST:-0}" -eq 0 ]]; then
|
|
101
|
+
MSG="Bursar: ${SESSION_COUNT} ${SESS_NOUN} in this project ${WINDOW_LABEL}. Enable governor for \$ cost tracking."
|
|
102
|
+
elif [[ "${SESSIONS_WITH_COST:-0}" -lt "${SESSION_COUNT:-0}" ]]; then
|
|
103
|
+
MSG="Bursar: this project burned \$${COST_FMT} across ${SESSIONS_WITH_COST} of ${SESSION_COUNT} sessions ${WINDOW_LABEL} (~${TOKENS_FMT} tokens); enable governor in the rest for full cost tracking."
|
|
104
|
+
else
|
|
105
|
+
MSG="Bursar: this project burned \$${COST_FMT} across ${SESSION_COUNT} ${SESS_NOUN} ${WINDOW_LABEL} (~${TOKENS_FMT} tokens)."
|
|
106
|
+
fi
|
|
107
|
+
|
|
108
|
+
WINDOW_START_ISO=$(bursar_epoch_to_iso "$CUTOFF")
|
|
109
|
+
surfaced=$(jq -n \
|
|
110
|
+
--arg pk "$PROJECT_KEY" \
|
|
111
|
+
--arg w "$WINDOW" \
|
|
112
|
+
--argjson cost "$TOTAL_COST" \
|
|
113
|
+
--argjson sc "$SESSION_COUNT" \
|
|
114
|
+
--argjson tok "$TOTAL_TOKENS" \
|
|
115
|
+
--argjson swc "$SESSIONS_WITH_COST" \
|
|
116
|
+
--arg ws "$WINDOW_START_ISO" \
|
|
117
|
+
'{
|
|
118
|
+
project_key: $pk,
|
|
119
|
+
window: $w,
|
|
120
|
+
total_cost_usd: $cost,
|
|
121
|
+
session_count: $sc,
|
|
122
|
+
total_tokens: $tok,
|
|
123
|
+
sessions_with_cost: $swc
|
|
124
|
+
}
|
|
125
|
+
+ (if $ws != "" then {window_start: $ws} else {} end)' 2>/dev/null) || surfaced=""
|
|
126
|
+
[[ -n "$surfaced" ]] && bursar_emit_event "bursar.rollup.surfaced" "$surfaced" "$SESSION_ID" || true
|
|
127
|
+
|
|
128
|
+
jq -cn --arg ctx "$MSG" '{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $ctx}}'
|
|
129
|
+
|
|
130
|
+
_done
|