@onlooker-community/ecosystem 0.26.1 → 0.28.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.
Files changed (51) hide show
  1. package/.claude-plugin/marketplace.json +26 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.release-please-manifest.json +4 -2
  4. package/CHANGELOG.md +14 -0
  5. package/CLAUDE.md +4 -0
  6. package/docs/architecture.md +8 -0
  7. package/package.json +3 -3
  8. package/plugins/bursar/.claude-plugin/plugin.json +14 -0
  9. package/plugins/bursar/CHANGELOG.md +10 -0
  10. package/plugins/bursar/README.md +100 -0
  11. package/plugins/bursar/config.json +11 -0
  12. package/plugins/bursar/hooks/hooks.json +26 -0
  13. package/plugins/bursar/scripts/hooks/bursar-session-end.sh +142 -0
  14. package/plugins/bursar/scripts/hooks/bursar-session-start.sh +130 -0
  15. package/plugins/bursar/scripts/lib/bursar-config.sh +108 -0
  16. package/plugins/bursar/scripts/lib/bursar-events.sh +82 -0
  17. package/plugins/bursar/scripts/lib/bursar-ledger.sh +152 -0
  18. package/plugins/bursar/scripts/lib/bursar-project-key.sh +85 -0
  19. package/plugins/bursar/scripts/lib/bursar-ulid.sh +53 -0
  20. package/plugins/bursar/scripts/lib/portable-lock.sh +59 -0
  21. package/plugins/lineage/.claude-plugin/plugin.json +14 -0
  22. package/plugins/lineage/CHANGELOG.md +9 -0
  23. package/plugins/lineage/README.md +133 -0
  24. package/plugins/lineage/config.json +11 -0
  25. package/plugins/lineage/hooks/hooks.json +33 -0
  26. package/plugins/lineage/scripts/hooks/lineage-post-tool-use.sh +132 -0
  27. package/plugins/lineage/scripts/lib/lineage-config.sh +100 -0
  28. package/plugins/lineage/scripts/lib/lineage-events.sh +81 -0
  29. package/plugins/lineage/scripts/lib/lineage-project-key.sh +85 -0
  30. package/plugins/lineage/scripts/lib/lineage-query.sh +88 -0
  31. package/plugins/lineage/scripts/lib/lineage-record.sh +132 -0
  32. package/plugins/lineage/scripts/lib/lineage-redact.sh +51 -0
  33. package/plugins/lineage/scripts/lib/lineage-ulid.sh +53 -0
  34. package/plugins/lineage/scripts/lib/portable-lock.sh +59 -0
  35. package/plugins/lineage/skills/lineage/SKILL.md +165 -0
  36. package/release-please-config.json +32 -0
  37. package/test/bats/bursar-config.bats +79 -0
  38. package/test/bats/bursar-events.bats +73 -0
  39. package/test/bats/bursar-ledger.bats +116 -0
  40. package/test/bats/bursar-project-key.bats +51 -0
  41. package/test/bats/bursar-session-end.bats +131 -0
  42. package/test/bats/bursar-session-start.bats +126 -0
  43. package/test/bats/bursar-ulid.bats +28 -0
  44. package/test/bats/lineage-config.bats +73 -0
  45. package/test/bats/lineage-events.bats +81 -0
  46. package/test/bats/lineage-post-tool-use.bats +115 -0
  47. package/test/bats/lineage-project-key.bats +51 -0
  48. package/test/bats/lineage-query.bats +85 -0
  49. package/test/bats/lineage-record.bats +79 -0
  50. package/test/bats/lineage-redact.bats +63 -0
  51. package/test/bats/lineage-ulid.bats +28 -0
@@ -189,6 +189,32 @@
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"]
205
+ },
206
+ {
207
+ "name": "lineage",
208
+ "source": "./plugins/lineage",
209
+ "description": "Per-change provenance — answers \"why does this line exist?\". Records session/turn metadata plus a secret-redacted, size-capped snippet for every Edit/Write/MultiEdit at PostToolUse into a per-project change ledger, then traces a file or line back to the change that introduced it by joining records to the transcripts historian preserves. Emits lineage.* events for audit. Requires the ecosystem plugin.",
210
+ "author": {
211
+ "name": "Onlooker Community"
212
+ },
213
+ "homepage": "https://onlooker.dev",
214
+ "repository": "https://github.com/onlooker-community/ecosystem",
215
+ "license": "MIT",
216
+ "keywords": ["provenance", "blame", "history", "tool-use", "transcript", "audit"],
217
+ "tags": ["observability", "provenance"]
192
218
  }
193
219
  ]
194
220
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ecosystem",
3
- "version": "0.26.1",
3
+ "version": "0.28.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.26.1",
2
+ ".": "0.28.0",
3
3
  "plugins/archivist": "0.1.0",
4
4
  "plugins/tribunal": "1.0.1",
5
5
  "plugins/echo": "0.2.0",
@@ -12,5 +12,7 @@
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",
17
+ "plugins/lineage": "0.1.0"
16
18
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.28.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.27.0...ecosystem-v0.28.0) (2026-06-12)
4
+
5
+
6
+ ### Features
7
+
8
+ * **lineage:** introduce per-change provenance plugin ([#83](https://github.com/onlooker-community/ecosystem/issues/83)) ([86b00d3](https://github.com/onlooker-community/ecosystem/commit/86b00d3d7393e2b63c5b04d60692fc89f202bf6c))
9
+
10
+ ## [0.27.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.26.1...ecosystem-v0.27.0) (2026-06-12)
11
+
12
+
13
+ ### Features
14
+
15
+ * **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))
16
+
3
17
  ## [0.26.1](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.26.0...ecosystem-v0.26.1) (2026-06-12)
4
18
 
5
19
 
package/CLAUDE.md CHANGED
@@ -11,10 +11,12 @@ 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
17
18
  governor/ ← resource governance and budget enforcement
19
+ lineage/ ← per-change provenance ("why does this line exist?")
18
20
  tribunal/ ← multi-agent quality gate (Actor → Jury → Meta-Judge → Gate)
19
21
 
20
22
  docs/
@@ -38,6 +40,8 @@ scripts/lib/onlooker-event.mjs ← canonical event builder; all plugins route t
38
40
  | tribunal | Stop + skill invocation | Post-task quality gate; also invokable via `/tribunal` |
39
41
  | 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
42
  | assayer | Stop | Verifies the agent's final-message claims against actual command results in the transcript; advisory |
43
+ | 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 |
44
+ | lineage | PostToolUse (Edit, Write, MultiEdit) + skill invocation | Records per-change provenance (session_id/turn + redacted, size-capped snippets) into a per-project ledger; `/lineage <file>:<line>` answers "why does this line exist?" by joining records to historian transcripts to recover prompt context |
41
45
 
42
46
  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
47
 
@@ -56,6 +56,14 @@ 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
+
63
+ ### Lineage
64
+
65
+ [Lineage](../plugins/lineage) is the provenance graph — it answers "why does this line exist?" by **joining its own tool-use records to another plugin's transcript store**. On `PostToolUse` it records each `Edit`/`Write`/`MultiEdit` (content-anchored, secret-redacted) into a per-project ledger under `~/.onlooker/lineage/<project-key>/`. The originating prompt is resolved lazily at query time: the `/lineage` skill reads the change ledger, content-anchors a line to the change that introduced it, and joins to [historian](../plugins/historian)'s durable per-session chunks (`start_turn_index`/`end_turn_index`) to recover the conversation context — falling back to the live transcript, then to "unavailable." Historian is the join target precisely because it persists transcripts long after the ephemeral `transcript_path` is gone; lineage stays decoupled and degrades gracefully when historian is absent.
66
+
59
67
  ### Plugin dependency model
60
68
 
61
69
  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.26.1",
3
+ "version": "0.28.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.6.0"
22
+ "@onlooker-community/schema": "^2.8.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 plugins/lineage/scripts/hooks/*.sh plugins/lineage/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,11 @@
1
+ {
2
+ "plugin_name": "bursar",
3
+ "storage_path": "~/.onlooker",
4
+ "bursar": {
5
+ "enabled": false,
6
+ "window": "rolling_7d",
7
+ "week_start": "monday",
8
+ "surface_at_session_start": true,
9
+ "min_cost_to_surface_usd": 0
10
+ }
11
+ }
@@ -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