@onlooker-community/ecosystem 0.9.0 → 0.14.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 +39 -1
- package/.claude-plugin/plugin.json +2 -2
- package/.github/copilot-instructions.md +46 -0
- package/.github/workflows/coverage.yml +78 -0
- package/.github/workflows/release.yml +24 -8
- package/.github/workflows/test.yml +3 -0
- package/.markdownlintignore +3 -0
- package/.release-please-manifest.json +4 -1
- package/CHANGELOG.md +44 -0
- package/README.md +57 -13
- package/config.json +6 -1
- package/docs/adr/001-claude-code-hooks-as-integration-surface.md +43 -0
- package/docs/adr/002-centralized-jsonl-event-log.md +39 -0
- package/docs/adr/003-ulid-over-uuid.md +40 -0
- package/docs/adr/004-plugin-config-with-settings-overlay.md +34 -0
- package/docs/architecture.md +117 -0
- package/hooks/hooks.json +4 -0
- package/package.json +13 -7
- package/plugins/archivist/.claude-plugin/plugin.json +14 -0
- package/plugins/archivist/CHANGELOG.md +8 -0
- package/plugins/archivist/README.md +105 -0
- package/plugins/archivist/config.json +18 -0
- package/plugins/archivist/hooks/hooks.json +35 -0
- package/plugins/archivist/scripts/hooks/archivist-extract.sh +238 -0
- package/plugins/archivist/scripts/hooks/archivist-inject.sh +159 -0
- package/plugins/archivist/scripts/lib/archivist-config.sh +66 -0
- package/plugins/archivist/scripts/lib/archivist-project-key.sh +91 -0
- package/plugins/archivist/scripts/lib/archivist-storage.sh +215 -0
- package/plugins/archivist/scripts/lib/archivist-ulid.sh +52 -0
- package/plugins/echo/.claude-plugin/plugin.json +14 -0
- package/plugins/echo/CHANGELOG.md +24 -0
- package/plugins/echo/README.md +110 -0
- package/plugins/echo/config.json +15 -0
- package/plugins/echo/docs/adr/001-echo-as-separate-plugin.md +33 -0
- package/plugins/echo/docs/adr/002-direct-evaluation-vs-tribunal-pipeline.md +35 -0
- package/plugins/echo/docs/adr/003-stop-hook-trigger.md +40 -0
- package/plugins/echo/hooks/hooks.json +15 -0
- package/plugins/echo/scripts/hooks/echo-stop-gate.sh +366 -0
- package/plugins/echo/scripts/lib/echo-config.sh +108 -0
- package/plugins/echo/scripts/lib/echo-events.sh +74 -0
- package/plugins/echo/scripts/lib/echo-project-key.sh +81 -0
- package/plugins/echo/scripts/lib/echo-ulid.sh +46 -0
- package/plugins/tribunal/.claude-plugin/plugin.json +20 -0
- package/plugins/tribunal/CHANGELOG.md +10 -0
- package/plugins/tribunal/README.md +134 -0
- package/plugins/tribunal/agents/tribunal-actor.md +35 -0
- package/plugins/tribunal/agents/tribunal-judge-adversarial.md +51 -0
- package/plugins/tribunal/agents/tribunal-judge-security.md +47 -0
- package/plugins/tribunal/agents/tribunal-judge-standard.md +47 -0
- package/plugins/tribunal/agents/tribunal-meta-judge.md +61 -0
- package/plugins/tribunal/config.json +50 -0
- package/plugins/tribunal/docs/adr/001-actor-jury-meta-gate-loop.md +40 -0
- package/plugins/tribunal/docs/adr/002-majority-gate-policy.md +48 -0
- package/plugins/tribunal/hooks/hooks.json +15 -0
- package/plugins/tribunal/scripts/hooks/tribunal-stop-gate.sh +267 -0
- package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +65 -0
- package/plugins/tribunal/scripts/lib/tribunal-config.sh +101 -0
- package/plugins/tribunal/scripts/lib/tribunal-events.sh +97 -0
- package/plugins/tribunal/scripts/lib/tribunal-gate.sh +111 -0
- package/plugins/tribunal/scripts/lib/tribunal-jury.sh +102 -0
- package/plugins/tribunal/scripts/lib/tribunal-project-key.sh +84 -0
- package/plugins/tribunal/scripts/lib/tribunal-rubric.sh +153 -0
- package/plugins/tribunal/scripts/lib/tribunal-ulid.sh +50 -0
- package/plugins/tribunal/scripts/lib/tribunal-verdict.sh +127 -0
- package/plugins/tribunal/skills/tribunal/SKILL.md +129 -0
- package/release-please-config.json +43 -5
- package/scripts/coverage/bash-coverage.mjs +169 -0
- package/scripts/coverage/format-comment.mjs +120 -0
- package/scripts/coverage/run-coverage.mjs +151 -0
- package/scripts/hooks/agent-spawn-tracker.sh +4 -4
- package/scripts/hooks/prompt-rule-injector.sh +122 -0
- package/scripts/lib/onlooker-event.mjs +82 -10
- package/scripts/lib/portable-lock.sh +48 -0
- package/scripts/lib/prompt-rules.sh +207 -0
- package/scripts/lib/tool-history.sh +7 -8
- package/scripts/lib/validate-path.sh +4 -0
- package/scripts/lint/check-manifests.mjs +314 -0
- package/scripts/lint/check-references.mjs +311 -0
- package/skills/list-prompt-rules/SKILL.md +15 -0
- package/test/bats/archivist-config-files.bats +60 -0
- package/test/bats/archivist-config.bats +54 -0
- package/test/bats/archivist-inject.bats +73 -0
- package/test/bats/archivist-project-key.bats +75 -0
- package/test/bats/archivist-storage.bats +119 -0
- package/test/bats/archivist-ulid.bats +36 -0
- package/test/bats/config.bats +10 -10
- package/test/bats/echo-config.bats +90 -0
- package/test/bats/echo-events.bats +121 -0
- package/test/bats/echo-project-key.bats +115 -0
- package/test/bats/echo-stop-hook.bats +101 -0
- package/test/bats/echo-ulid.bats +38 -0
- package/test/bats/portable-lock.bats +62 -0
- package/test/bats/prompt-rules.bats +269 -0
- package/test/bats/read-chunk-tracking.bats +73 -0
- package/test/bats/tool-history-tracker.bats +1 -0
- package/test/bats/tribunal-aggregate.bats +77 -0
- package/test/bats/tribunal-config.bats +86 -0
- package/test/bats/tribunal-events.bats +209 -0
- package/test/bats/tribunal-gate.bats +95 -0
- package/test/bats/tribunal-jury.bats +80 -0
- package/test/bats/tribunal-rubric.bats +119 -0
- package/test/bats/tribunal-stop-hook.bats +73 -0
- package/test/bats/tribunal-verdict.bats +71 -0
- package/test/bats/validate-path.bats +1 -1
- package/test/fixtures/hook-inputs/post-tool-use-read-chunked.json +15 -0
- package/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json +8 -0
- package/test/fixtures/hook-inputs/user-prompt-submit-rule-nomatch.json +8 -0
- package/test/helpers/setup.bash +9 -0
- package/test/node/check-manifests.test.mjs +173 -0
- package/test/node/check-references.test.mjs +279 -0
- package/test/node/coverage.test.mjs +143 -0
- package/test/node/schema-events.test.mjs +41 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Ecosystem Architecture
|
|
2
|
+
|
|
3
|
+
This document describes how the Onlooker ecosystem fits together: the shared substrate, the plugin layer, the event bus, and the configuration system.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
9
|
+
│ Claude Code session │
|
|
10
|
+
│ │
|
|
11
|
+
│ ┌─────────────┐ ┌───────────┐ ┌──────────┐ ┌──────────┐ │
|
|
12
|
+
│ │ ecosystem │ │ archivist │ │ tribunal │ │ echo │ │
|
|
13
|
+
│ │ (substrate)│ │ plugin │ │ plugin │ │ plugin │ │
|
|
14
|
+
│ └──────┬──────┘ └─────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
|
15
|
+
│ │ │ │ │ │
|
|
16
|
+
│ └───────────────┴─────────────┴──────────────┘ │
|
|
17
|
+
│ │ │
|
|
18
|
+
│ ┌──────────▼──────────┐ │
|
|
19
|
+
│ │ onlooker-event.mjs │ schema-validated │
|
|
20
|
+
│ │ (canonical emitter) │ event envelope │
|
|
21
|
+
│ └──────────┬──────────┘ │
|
|
22
|
+
│ │ │
|
|
23
|
+
│ ┌──────────▼──────────┐ │
|
|
24
|
+
│ │ ~/.onlooker/logs/ │ │
|
|
25
|
+
│ │ onlooker-events │ append-only JSONL │
|
|
26
|
+
│ │ .jsonl │ │
|
|
27
|
+
│ └─────────────────────┘ │
|
|
28
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## The substrate layer: `ecosystem`
|
|
32
|
+
|
|
33
|
+
The `ecosystem` plugin (repo root) is not optional — it provides the infrastructure every other plugin builds on:
|
|
34
|
+
|
|
35
|
+
| Component | What it does |
|
|
36
|
+
|-----------|-------------|
|
|
37
|
+
| `~/.onlooker/` directory | Shared storage root, created by the Onlooker installer. All plugins store artifacts here under their own sub-path. |
|
|
38
|
+
| `scripts/lib/onlooker-event.mjs` | Canonical event builder. Accepts a JSON payload on stdin, validates it against `@onlooker-community/schema`, and prints the validated envelope to stdout. Callers capture the output and append it to the JSONL log. |
|
|
39
|
+
| `scripts/lib/onlooker-schema.sh` | Bash convenience wrapper around `onlooker-event.mjs`. Provides `onlooker_event_from_hook` (builds an envelope via `node`) and `onlooker_append_event` (appends a pre-built envelope to the log). Node is still required. |
|
|
40
|
+
| `scripts/lib/validate-path.sh` | Sets canonical `$ONLOOKER_*` environment variables (log path, tracker dirs, etc.) so every hook uses consistent paths. |
|
|
41
|
+
| Session trackers | `SessionStart`, `SessionEnd`, `PreCompact`, `PostCompact`, `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `TaskCreated`, `TaskCompleted`, `WorktreeCreate`, and `WorktreeRemove` hooks that emit `session.*`, `tool.*`, `turn.*` events for the observability layer. |
|
|
42
|
+
| Prompt rules | `UserPromptSubmit` hook that injects declarative guidance on regex match. |
|
|
43
|
+
|
|
44
|
+
## The plugin layer
|
|
45
|
+
|
|
46
|
+
Plugins are independent packages under `plugins/<name>/`. Each has its own:
|
|
47
|
+
- `config.json` — defaults for all knobs.
|
|
48
|
+
- `hooks.json` — declares which Claude Code hook events to subscribe to.
|
|
49
|
+
- `.claude-plugin/plugin.json` — marketplace manifest (name, version, description, agents, skills).
|
|
50
|
+
- `CHANGELOG.md` + release-please track — versioned independently of the ecosystem.
|
|
51
|
+
|
|
52
|
+
Plugins communicate by **emitting events**, not by calling each other directly. An Echo evaluation and a Tribunal jury run both write to the same JSONL log; a dashboard or downstream consumer can query across both.
|
|
53
|
+
|
|
54
|
+
### Plugin dependency model
|
|
55
|
+
|
|
56
|
+
All plugins depend on `ecosystem`. No plugin depends on another plugin at runtime. This means:
|
|
57
|
+
- Tribunal does not require Archivist to be installed.
|
|
58
|
+
- Echo does not require Tribunal to be installed (despite evaluating similar things — see [Echo ADR-002](../plugins/echo/docs/adr/002-direct-evaluation-vs-tribunal-pipeline.md)).
|
|
59
|
+
- You can install any subset of plugins and the others still work.
|
|
60
|
+
|
|
61
|
+
## The event bus
|
|
62
|
+
|
|
63
|
+
Every observable event flows through `onlooker-event.mjs` before being written to disk. The emitter:
|
|
64
|
+
|
|
65
|
+
1. Wraps the plugin-supplied payload in a canonical envelope:
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"id": "01J...",
|
|
69
|
+
"plugin": "echo",
|
|
70
|
+
"session_id": "...",
|
|
71
|
+
"event_type": "echo.suite.complete",
|
|
72
|
+
"timestamp": "2026-05-24T...",
|
|
73
|
+
"schema_version": "2.2.0",
|
|
74
|
+
"payload": { ... }
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
2. Validates the envelope and payload against [`@onlooker-community/schema`](https://github.com/onlooker-community/schema). If validation fails, the node process exits non-zero and prints to stderr; most hooks treat empty output as a skip rather than a hard error, so some validation failures may be silent unless the caller explicitly checks exit status.
|
|
78
|
+
3. Prints the validated envelope to stdout. The calling bash function (`onlooker_append_event`) captures this and appends it as a single JSON line to `~/.onlooker/logs/onlooker-events.jsonl`.
|
|
79
|
+
|
|
80
|
+
The schema is versioned independently and published to npm. Plugin shell scripts invoke `onlooker-event.mjs` at runtime so schema validation always reflects the installed version.
|
|
81
|
+
|
|
82
|
+
> **Note:** Not all events in the JSONL log are schema-validated. `prompt_rule.*` events are currently emitted outside the canonical schema pipeline (the event types are not yet defined in `@onlooker-community/schema`). Schema-first emission is the goal for all future event types.
|
|
83
|
+
|
|
84
|
+
## Project keying
|
|
85
|
+
|
|
86
|
+
Every plugin that stores per-project artifacts uses the same key derivation:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
key = first 12 hex chars of SHA256(git remote get-url origin)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
If no remote exists (local-only repo), the key falls back to `SHA256(realpath of git toplevel)`.
|
|
93
|
+
|
|
94
|
+
This means:
|
|
95
|
+
- Two clones of the same repo share the same key and therefore the same baselines, memories, and Tribunal history.
|
|
96
|
+
- Git worktrees of the same repo also share the key.
|
|
97
|
+
- Moving the repo directory does not change the key (remote URL is stable).
|
|
98
|
+
|
|
99
|
+
## Configuration system
|
|
100
|
+
|
|
101
|
+
Each plugin reads config in two steps:
|
|
102
|
+
|
|
103
|
+
1. **Plugin defaults** — `plugins/<name>/config.json`. Ships with the plugin; defines all available knobs and their defaults.
|
|
104
|
+
2. **Settings overlay** — `.claude/settings.json` (repo-level) or `~/.claude/settings.json` (global). The plugin-specific key (e.g., `echo`, `tribunal`) is merged onto the defaults.
|
|
105
|
+
|
|
106
|
+
Tribunal and Archivist use a recursive `deepmerge` so nested keys can be overridden individually without replacing an entire sub-object. Echo uses a simpler per-key lookup against the flat settings block. Repo-level settings take precedence over global; both override plugin defaults. This lets you:
|
|
107
|
+
- Enable a plugin for a specific project without touching your global config.
|
|
108
|
+
- Override the evaluation model for a high-stakes repo without affecting others.
|
|
109
|
+
|
|
110
|
+
## Architecture decisions
|
|
111
|
+
|
|
112
|
+
Ecosystem-level decisions are recorded in [`docs/adr/`](adr/):
|
|
113
|
+
|
|
114
|
+
- [ADR-001](adr/001-claude-code-hooks-as-integration-surface.md) — Claude Code hooks as the integration surface
|
|
115
|
+
- [ADR-002](adr/002-centralized-jsonl-event-log.md) — Centralized JSONL event log with schema validation
|
|
116
|
+
- [ADR-003](adr/003-ulid-over-uuid.md) — ULID for all identifiers
|
|
117
|
+
- [ADR-004](adr/004-plugin-config-with-settings-overlay.md) — Per-plugin config with settings.json overlay
|
package/hooks/hooks.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlooker-community/ecosystem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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,19 +19,25 @@
|
|
|
19
19
|
"onlooker-install": "install.sh"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@onlooker-community/schema": "^
|
|
22
|
+
"@onlooker-community/schema": "^2.2.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",
|
|
30
|
-
"
|
|
31
|
-
"lint:
|
|
32
|
-
"
|
|
33
|
-
"
|
|
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",
|
|
30
|
+
"lint:references": "node scripts/lint/check-references.mjs",
|
|
31
|
+
"lint:manifests": "node scripts/lint/check-manifests.mjs",
|
|
32
|
+
"coverage:node": "node scripts/coverage/run-coverage.mjs",
|
|
33
|
+
"coverage:bash": "node scripts/coverage/bash-coverage.mjs",
|
|
34
|
+
"coverage": "npm run coverage:node && npm run coverage:bash",
|
|
35
|
+
"test:ci": "npm run test:shellcheck && npm run test:bats && npm run test:schema && npm run lint:check && npm run lint:manifests && npm run lint:references",
|
|
36
|
+
"lint:check": "biome check . && markdownlint '**/*.md'",
|
|
37
|
+
"lint": "biome lint --write && markdownlint --fix '**/*.md'",
|
|
38
|
+
"format": "biome format --write && markdownlint --fix '**/*.md'",
|
|
34
39
|
"format:check": "biome format",
|
|
40
|
+
"format:md": "markdownlint --fix '**/*.md'",
|
|
35
41
|
"biome:ci": "biome ci"
|
|
36
42
|
},
|
|
37
43
|
"engines": {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "archivist",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Structured session memory across context truncation: extracts decisions, dead ends, and open questions on PreCompact and reinjects the most important items at SessionStart. 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,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0](https://github.com/onlooker-community/ecosystem/compare/archivist-v0.0.1...archivist-v0.1.0) (2026-05-23)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **archivist:** introduce structured session memory plugin :rocket: ([378fff3](https://github.com/onlooker-community/ecosystem/commit/378fff3c14b40644af45b1a2335992e7b0428160))
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Archivist
|
|
2
|
+
|
|
3
|
+
Structured session memory across context truncation.
|
|
4
|
+
|
|
5
|
+
When Claude Code compacts a long conversation, Archivist extracts the decisions made, dead ends hit, and open questions raised — then reinjects the most relevant items at the start of the next session. Context windows are finite; important context shouldn't be.
|
|
6
|
+
|
|
7
|
+
Archivist is a sibling plugin to [`ecosystem`](../../) and assumes the Onlooker observability substrate (`~/.onlooker/`) is present.
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
| Hook | What Archivist does |
|
|
12
|
+
|------|---------------------|
|
|
13
|
+
| `PreCompact` | Reads the transcript tail, calls `claude -p` with a structured-extraction prompt, validates referenced file paths against the current repo, and writes ULID-keyed JSON artifacts under `~/.onlooker/archivist/<project-key>/`. |
|
|
14
|
+
| `SessionStart` | Loads artifacts for the current project key, ranks them (pinned first, then recency), and emits them as invisible `additionalContext` within a token budget. |
|
|
15
|
+
|
|
16
|
+
## Activation
|
|
17
|
+
|
|
18
|
+
Archivist is **off by default**. Enable per-project in `.claude/settings.json`:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"archivist": {
|
|
23
|
+
"enabled": true
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or globally in `~/.claude/settings.json`.
|
|
29
|
+
|
|
30
|
+
## Configuration
|
|
31
|
+
|
|
32
|
+
All keys are optional. Unset keys fall back to the plugin's `config.json` defaults.
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"archivist": {
|
|
37
|
+
"enabled": false,
|
|
38
|
+
"extraction": {
|
|
39
|
+
"model": "claude-haiku-4-5-20251001",
|
|
40
|
+
"max_output_tokens": 1500,
|
|
41
|
+
"transcript_tail_chars": 60000
|
|
42
|
+
},
|
|
43
|
+
"injection": {
|
|
44
|
+
"max_items": 8,
|
|
45
|
+
"max_chars": 2400,
|
|
46
|
+
"include_open_questions": true,
|
|
47
|
+
"include_dead_ends": true
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
| Key | Default | Description |
|
|
54
|
+
|-----|---------|-------------|
|
|
55
|
+
| `enabled` | `false` | Must be `true` for any extraction or injection to run. |
|
|
56
|
+
| `extraction.model` | `claude-haiku-4-5-20251001` | Model used for transcript extraction. Haiku is fast and cheap; the extraction prompt is structured and does not require deep reasoning. |
|
|
57
|
+
| `extraction.max_output_tokens` | `1500` | Token ceiling for the extraction response. |
|
|
58
|
+
| `extraction.transcript_tail_chars` | `60000` | How many characters of the transcript tail to feed into extraction. Larger values capture more context at higher cost. |
|
|
59
|
+
| `injection.max_items` | `8` | Maximum number of artifacts to inject at `SessionStart`. |
|
|
60
|
+
| `injection.max_chars` | `2400` | Hard ceiling on total injected context characters. |
|
|
61
|
+
| `injection.include_open_questions` | `true` | Whether to inject open question artifacts. |
|
|
62
|
+
| `injection.include_dead_ends` | `true` | Whether to inject dead end artifacts. |
|
|
63
|
+
|
|
64
|
+
## Storage layout
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
~/.onlooker/archivist/<project-key>/
|
|
68
|
+
├── manifest.json # project_key, remote_url, repo_root, last_compact_at
|
|
69
|
+
├── decisions/<ulid>.json
|
|
70
|
+
├── dead_ends/<ulid>.json
|
|
71
|
+
├── open_questions/<ulid>.json
|
|
72
|
+
└── pinned.json # ULIDs that always inject first, regardless of recency
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Each artifact:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"id": "01J...",
|
|
80
|
+
"kind": "decision",
|
|
81
|
+
"project_key": "abc123def456",
|
|
82
|
+
"source": "local",
|
|
83
|
+
"created_at": "2026-05-22T10:00:00Z",
|
|
84
|
+
"updated_at": "2026-05-22T10:00:00Z",
|
|
85
|
+
"summary": "One-line headline.",
|
|
86
|
+
"detail": "Optional longer text.",
|
|
87
|
+
"files": ["relative/path/from/repo/root.ts"],
|
|
88
|
+
"session_id": "..."
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The `source` field is `"local"` today. Future cloud sync may set it to `"cloud"` or `"team:<id>"`.
|
|
93
|
+
|
|
94
|
+
## Cross-repo isolation
|
|
95
|
+
|
|
96
|
+
Two layers ensure artifacts from one repo never surface in another:
|
|
97
|
+
|
|
98
|
+
1. **Project keying** — the storage path is derived from the repo's git remote URL (SHA256, first 12 chars). Two different repos produce different keys.
|
|
99
|
+
2. **Path validation** — every `files[]` entry extracted from the transcript is resolved against `git rev-parse --show-toplevel`. Entries referencing paths outside the repo or that don't exist are stripped before persisting.
|
|
100
|
+
|
|
101
|
+
## Requirements
|
|
102
|
+
|
|
103
|
+
- The `ecosystem` plugin installed (for `~/.onlooker/` substrate).
|
|
104
|
+
- `claude` CLI on `PATH` for extraction.
|
|
105
|
+
- `jq` for JSON manipulation.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"plugin_name": "archivist",
|
|
3
|
+
"storage_path": "~/.onlooker",
|
|
4
|
+
"archivist": {
|
|
5
|
+
"enabled": false,
|
|
6
|
+
"extraction": {
|
|
7
|
+
"model": "claude-haiku-4-5-20251001",
|
|
8
|
+
"max_output_tokens": 1500,
|
|
9
|
+
"transcript_tail_chars": 60000
|
|
10
|
+
},
|
|
11
|
+
"injection": {
|
|
12
|
+
"max_items": 8,
|
|
13
|
+
"max_chars": 2400,
|
|
14
|
+
"include_open_questions": true,
|
|
15
|
+
"include_dead_ends": true
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PreCompact": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "manual",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/archivist-extract.sh"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"matcher": "auto",
|
|
15
|
+
"hooks": [
|
|
16
|
+
{
|
|
17
|
+
"type": "command",
|
|
18
|
+
"command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/archivist-extract.sh"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"SessionStart": [
|
|
24
|
+
{
|
|
25
|
+
"matcher": "*",
|
|
26
|
+
"hooks": [
|
|
27
|
+
{
|
|
28
|
+
"type": "command",
|
|
29
|
+
"command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/archivist-inject.sh"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Archivist PreCompact extraction hook.
|
|
3
|
+
#
|
|
4
|
+
# Triggered by PreCompact (manual + auto). Reads the transcript tail, asks
|
|
5
|
+
# `claude -p` for a structured JSON extraction of decisions, dead ends, and
|
|
6
|
+
# open questions, validates referenced paths against the current repo, and
|
|
7
|
+
# writes ULID-keyed artifacts under ~/.onlooker/archivist/<project-key>/.
|
|
8
|
+
#
|
|
9
|
+
# Hook contract:
|
|
10
|
+
# - Always exits 0 and approves compaction. Never blocks.
|
|
11
|
+
# - Skips work if archivist.enabled is not true.
|
|
12
|
+
# - Skips work if there is no git context (no project key).
|
|
13
|
+
# - Errors from `claude -p` are swallowed; the worst case is "no new memory
|
|
14
|
+
# for this compact", never "compaction failed".
|
|
15
|
+
|
|
16
|
+
set -uo pipefail
|
|
17
|
+
|
|
18
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
|
20
|
+
|
|
21
|
+
# Ecosystem substrate (validate-path.sh) lives in the sibling ecosystem plugin.
|
|
22
|
+
# Prefer the env var the harness sets; fall back to walking up to the repo
|
|
23
|
+
# root in dev checkouts.
|
|
24
|
+
_ECOSYSTEM_ROOT="${ONLOOKER_ECOSYSTEM_ROOT:-}"
|
|
25
|
+
if [[ -z "$_ECOSYSTEM_ROOT" ]]; then
|
|
26
|
+
# In the marketplace repo, plugins/archivist/scripts/hooks is 4 dirs
|
|
27
|
+
# below the ecosystem root.
|
|
28
|
+
_candidate="$(cd "${PLUGIN_ROOT}/../.." 2>/dev/null && pwd)"
|
|
29
|
+
if [[ -f "${_candidate}/scripts/lib/validate-path.sh" ]]; then
|
|
30
|
+
_ECOSYSTEM_ROOT="$_candidate"
|
|
31
|
+
fi
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
if [[ -n "$_ECOSYSTEM_ROOT" && -f "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh" ]]; then
|
|
35
|
+
# shellcheck disable=SC1091
|
|
36
|
+
CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
# shellcheck source=../lib/archivist-project-key.sh
|
|
40
|
+
source "${PLUGIN_ROOT}/scripts/lib/archivist-project-key.sh"
|
|
41
|
+
# shellcheck source=../lib/archivist-ulid.sh
|
|
42
|
+
source "${PLUGIN_ROOT}/scripts/lib/archivist-ulid.sh"
|
|
43
|
+
# shellcheck source=../lib/archivist-storage.sh
|
|
44
|
+
source "${PLUGIN_ROOT}/scripts/lib/archivist-storage.sh"
|
|
45
|
+
# shellcheck source=../lib/archivist-config.sh
|
|
46
|
+
source "${PLUGIN_ROOT}/scripts/lib/archivist-config.sh"
|
|
47
|
+
|
|
48
|
+
# Always approve compaction at exit, no matter what happened above.
|
|
49
|
+
_approve() {
|
|
50
|
+
jq -cn --arg reason "${1:-Compaction approved}" \
|
|
51
|
+
'{decision: "approve", reason: $reason}'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
INPUT=$(cat)
|
|
55
|
+
trap '_approve "Archivist extraction errored out, compaction approved anyway"' ERR
|
|
56
|
+
|
|
57
|
+
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || CWD=""
|
|
58
|
+
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""' 2>/dev/null) || SESSION_ID=""
|
|
59
|
+
TRANSCRIPT_PATH=$(printf '%s' "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null) || TRANSCRIPT_PATH=""
|
|
60
|
+
TRIGGER=$(printf '%s' "$INPUT" | jq -r '.trigger // "auto"' 2>/dev/null) || TRIGGER="auto"
|
|
61
|
+
CUSTOM_INSTRUCTIONS=$(printf '%s' "$INPUT" | jq -r '.custom_instructions // ""' 2>/dev/null) || CUSTOM_INSTRUCTIONS=""
|
|
62
|
+
|
|
63
|
+
REPO_ROOT=$(archivist_project_repo_root "$CWD")
|
|
64
|
+
PROJECT_KEY=$(archivist_project_key "$CWD")
|
|
65
|
+
|
|
66
|
+
# Config requires repo_root to scan settings.json overlay; load anyway with
|
|
67
|
+
# best-effort empty fallback.
|
|
68
|
+
archivist_config_load "$REPO_ROOT"
|
|
69
|
+
|
|
70
|
+
if ! archivist_config_enabled; then
|
|
71
|
+
_approve "Archivist disabled (skip extraction)"
|
|
72
|
+
exit 0
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
if [[ -z "$PROJECT_KEY" || -z "$REPO_ROOT" ]]; then
|
|
76
|
+
_approve "Archivist: no git context, nothing to extract"
|
|
77
|
+
exit 0
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
if [[ -z "$TRANSCRIPT_PATH" || ! -f "$TRANSCRIPT_PATH" ]]; then
|
|
81
|
+
_approve "Archivist: no transcript available"
|
|
82
|
+
exit 0
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
if ! command -v claude >/dev/null 2>&1; then
|
|
86
|
+
_approve "Archivist: claude CLI not on PATH, skipping extraction"
|
|
87
|
+
exit 0
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
# ----------------------------------------------------------------------------
|
|
91
|
+
# Build the extraction prompt from the transcript tail.
|
|
92
|
+
# ----------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
TRANSCRIPT_TAIL_CHARS=$(archivist_config_get '.archivist.extraction.transcript_tail_chars')
|
|
95
|
+
[[ -z "$TRANSCRIPT_TAIL_CHARS" || "$TRANSCRIPT_TAIL_CHARS" == "null" ]] && TRANSCRIPT_TAIL_CHARS=60000
|
|
96
|
+
|
|
97
|
+
MAX_OUTPUT_TOKENS=$(archivist_config_get '.archivist.extraction.max_output_tokens')
|
|
98
|
+
[[ -z "$MAX_OUTPUT_TOKENS" || "$MAX_OUTPUT_TOKENS" == "null" ]] && MAX_OUTPUT_TOKENS=1500
|
|
99
|
+
|
|
100
|
+
EXTRACTION_MODEL=$(archivist_config_get '.archivist.extraction.model')
|
|
101
|
+
[[ -z "$EXTRACTION_MODEL" || "$EXTRACTION_MODEL" == "null" ]] && EXTRACTION_MODEL=""
|
|
102
|
+
|
|
103
|
+
# Take the last N chars of the transcript file. Using a portable approach
|
|
104
|
+
# (tail -c works on macOS and Linux).
|
|
105
|
+
TRANSCRIPT_TAIL=$(tail -c "$TRANSCRIPT_TAIL_CHARS" "$TRANSCRIPT_PATH" 2>/dev/null) || TRANSCRIPT_TAIL=""
|
|
106
|
+
|
|
107
|
+
if [[ -z "$TRANSCRIPT_TAIL" ]]; then
|
|
108
|
+
_approve "Archivist: empty transcript tail"
|
|
109
|
+
exit 0
|
|
110
|
+
fi
|
|
111
|
+
|
|
112
|
+
PROMPT_FILE=$(mktemp -t archivist-prompt.XXXXXX 2>/dev/null) || PROMPT_FILE="/tmp/archivist-prompt.$$"
|
|
113
|
+
trap 'rm -f "$PROMPT_FILE"; _approve "Archivist extraction errored out, compaction approved anyway"' ERR
|
|
114
|
+
trap 'rm -f "$PROMPT_FILE"' EXIT
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
printf '%s\n' 'You are extracting structured session memory from a Claude Code transcript that is about to be compacted (context truncated). Return JSON only — no prose, no markdown fences.'
|
|
118
|
+
printf '\n'
|
|
119
|
+
printf '%s\n' 'Output schema (JSON, exactly these keys):'
|
|
120
|
+
printf '%s\n' '{'
|
|
121
|
+
printf '%s\n' ' "decisions": [ { "summary": "...", "detail": "...", "files": ["relative/path.ts"] } ],'
|
|
122
|
+
printf '%s\n' ' "dead_ends": [ { "summary": "...", "detail": "what was tried and why it did not work", "files": [] } ],'
|
|
123
|
+
printf '%s\n' ' "open_questions": [ { "summary": "...", "detail": "...", "files": [] } ]'
|
|
124
|
+
printf '%s\n' '}'
|
|
125
|
+
printf '\n'
|
|
126
|
+
printf '%s\n' 'Rules:'
|
|
127
|
+
printf '%s\n' '- Only include items that would meaningfully help a future session continue this work. Skip routine status updates.'
|
|
128
|
+
printf '%s\n' '- "summary" is a single declarative sentence under 120 chars.'
|
|
129
|
+
printf '%s\n' '- "detail" is optional and should add why/how context, not restate the summary.'
|
|
130
|
+
printf '%s\n' '- "files" should list repository-relative paths that the item references. Omit absolute paths and paths outside this repo.'
|
|
131
|
+
printf '%s\n' '- Prefer reusable rules ("decisions") over event recaps. Decisions outlive specific bugs.'
|
|
132
|
+
printf '%s\n' '- Return at most 6 decisions, 6 dead_ends, 6 open_questions. If none qualify in a category, return an empty array.'
|
|
133
|
+
printf '%s\n' '- Output a single JSON object on one line, parseable by JSON.parse.'
|
|
134
|
+
if [[ -n "$CUSTOM_INSTRUCTIONS" ]]; then
|
|
135
|
+
printf '\n'
|
|
136
|
+
printf 'Additional user-provided focus: %s\n' "$CUSTOM_INSTRUCTIONS"
|
|
137
|
+
fi
|
|
138
|
+
printf '\n'
|
|
139
|
+
printf 'Repository root: %s\n' "$REPO_ROOT"
|
|
140
|
+
printf '\n'
|
|
141
|
+
printf '%s\n' '---BEGIN TRANSCRIPT TAIL---'
|
|
142
|
+
printf '%s\n' "$TRANSCRIPT_TAIL"
|
|
143
|
+
printf '%s\n' '---END TRANSCRIPT TAIL---'
|
|
144
|
+
} > "$PROMPT_FILE"
|
|
145
|
+
|
|
146
|
+
# ----------------------------------------------------------------------------
|
|
147
|
+
# Invoke `claude -p`. We rely on its default JSON-only output mode when asked
|
|
148
|
+
# for JSON in the prompt; we don't pass --output-format because we want the
|
|
149
|
+
# raw text we asked for.
|
|
150
|
+
# ----------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
CLAUDE_ARGS=(-p --max-turns 1)
|
|
153
|
+
[[ -n "$EXTRACTION_MODEL" ]] && CLAUDE_ARGS+=(--model "$EXTRACTION_MODEL")
|
|
154
|
+
|
|
155
|
+
# 90-second hard ceiling on extraction so a hung LLM call never blocks the
|
|
156
|
+
# user's compaction more than that.
|
|
157
|
+
EXTRACTION_TIMEOUT=90
|
|
158
|
+
|
|
159
|
+
RESPONSE=""
|
|
160
|
+
if command -v timeout >/dev/null 2>&1; then
|
|
161
|
+
RESPONSE=$(timeout "$EXTRACTION_TIMEOUT" claude "${CLAUDE_ARGS[@]}" < "$PROMPT_FILE" 2>/dev/null) || RESPONSE=""
|
|
162
|
+
elif command -v gtimeout >/dev/null 2>&1; then
|
|
163
|
+
RESPONSE=$(gtimeout "$EXTRACTION_TIMEOUT" claude "${CLAUDE_ARGS[@]}" < "$PROMPT_FILE" 2>/dev/null) || RESPONSE=""
|
|
164
|
+
else
|
|
165
|
+
RESPONSE=$(claude "${CLAUDE_ARGS[@]}" < "$PROMPT_FILE" 2>/dev/null) || RESPONSE=""
|
|
166
|
+
fi
|
|
167
|
+
|
|
168
|
+
if [[ -z "$RESPONSE" ]]; then
|
|
169
|
+
_approve "Archivist: extraction returned no output"
|
|
170
|
+
exit 0
|
|
171
|
+
fi
|
|
172
|
+
|
|
173
|
+
# Strip any accidental markdown fences before parsing.
|
|
174
|
+
CLEAN_RESPONSE=$(printf '%s' "$RESPONSE" | sed -e 's/^```json//' -e 's/^```//' -e 's/```$//')
|
|
175
|
+
|
|
176
|
+
if ! printf '%s' "$CLEAN_RESPONSE" | jq -e '.decisions and .dead_ends and .open_questions' >/dev/null 2>&1; then
|
|
177
|
+
_approve "Archivist: extraction output was not valid JSON"
|
|
178
|
+
exit 0
|
|
179
|
+
fi
|
|
180
|
+
|
|
181
|
+
# ----------------------------------------------------------------------------
|
|
182
|
+
# Persist artifacts.
|
|
183
|
+
# ----------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
REMOTE_URL=$(archivist_project_remote_url "$CWD")
|
|
186
|
+
archivist_storage_write_manifest "$PROJECT_KEY" "$REMOTE_URL" "$REPO_ROOT" || true
|
|
187
|
+
|
|
188
|
+
NOW_TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
189
|
+
WRITE_COUNT=0
|
|
190
|
+
|
|
191
|
+
for KIND_PAIR in "decisions:decision" "dead_ends:dead_end" "open_questions:open_question"; do
|
|
192
|
+
KIND_DIR="${KIND_PAIR%%:*}"
|
|
193
|
+
KIND_LABEL="${KIND_PAIR##*:}"
|
|
194
|
+
|
|
195
|
+
ENTRY_COUNT=$(printf '%s' "$CLEAN_RESPONSE" | jq ".${KIND_DIR} | length" 2>/dev/null) || ENTRY_COUNT=0
|
|
196
|
+
for ((i = 0; i < ENTRY_COUNT; i++)); do
|
|
197
|
+
ENTRY=$(printf '%s' "$CLEAN_RESPONSE" | jq ".${KIND_DIR}[$i]" 2>/dev/null) || continue
|
|
198
|
+
[[ -z "$ENTRY" || "$ENTRY" == "null" ]] && continue
|
|
199
|
+
|
|
200
|
+
SUMMARY=$(printf '%s' "$ENTRY" | jq -r '.summary // ""')
|
|
201
|
+
[[ -z "$SUMMARY" ]] && continue
|
|
202
|
+
|
|
203
|
+
DETAIL=$(printf '%s' "$ENTRY" | jq -r '.detail // ""')
|
|
204
|
+
PATHS_JSON=$(printf '%s' "$ENTRY" | jq '.files // []')
|
|
205
|
+
CLEAN_PATHS=$(archivist_validate_paths_array "$REPO_ROOT" "$PATHS_JSON")
|
|
206
|
+
|
|
207
|
+
ID=$(archivist_ulid)
|
|
208
|
+
ARTIFACT=$(jq -n \
|
|
209
|
+
--arg id "$ID" \
|
|
210
|
+
--arg kind "$KIND_LABEL" \
|
|
211
|
+
--arg project_key "$PROJECT_KEY" \
|
|
212
|
+
--arg now "$NOW_TS" \
|
|
213
|
+
--arg session_id "$SESSION_ID" \
|
|
214
|
+
--arg trigger "$TRIGGER" \
|
|
215
|
+
--arg summary "$SUMMARY" \
|
|
216
|
+
--arg detail "$DETAIL" \
|
|
217
|
+
--argjson files "$CLEAN_PATHS" \
|
|
218
|
+
'{
|
|
219
|
+
id: $id,
|
|
220
|
+
kind: $kind,
|
|
221
|
+
project_key: $project_key,
|
|
222
|
+
source: "local",
|
|
223
|
+
created_at: $now,
|
|
224
|
+
updated_at: $now,
|
|
225
|
+
summary: $summary,
|
|
226
|
+
detail: (if $detail == "" then null else $detail end),
|
|
227
|
+
files: $files,
|
|
228
|
+
session_id: (if $session_id == "" then null else $session_id end),
|
|
229
|
+
trigger: $trigger
|
|
230
|
+
}')
|
|
231
|
+
|
|
232
|
+
archivist_storage_write_artifact "$PROJECT_KEY" "$KIND_DIR" "$ID" "$ARTIFACT" >/dev/null \
|
|
233
|
+
&& WRITE_COUNT=$((WRITE_COUNT + 1))
|
|
234
|
+
done
|
|
235
|
+
done
|
|
236
|
+
|
|
237
|
+
_approve "Archivist: wrote ${WRITE_COUNT} artifacts (trigger=${TRIGGER})"
|
|
238
|
+
exit 0
|