@onlooker-community/ecosystem 0.10.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.
Files changed (106) hide show
  1. package/.claude-plugin/marketplace.json +39 -1
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.github/copilot-instructions.md +46 -0
  4. package/.github/workflows/coverage.yml +78 -0
  5. package/.github/workflows/release.yml +24 -8
  6. package/.github/workflows/test.yml +3 -0
  7. package/.markdownlintignore +3 -0
  8. package/.release-please-manifest.json +4 -1
  9. package/CHANGELOG.md +37 -0
  10. package/README.md +57 -13
  11. package/config.json +6 -1
  12. package/docs/adr/001-claude-code-hooks-as-integration-surface.md +43 -0
  13. package/docs/adr/002-centralized-jsonl-event-log.md +39 -0
  14. package/docs/adr/003-ulid-over-uuid.md +40 -0
  15. package/docs/adr/004-plugin-config-with-settings-overlay.md +34 -0
  16. package/docs/architecture.md +117 -0
  17. package/hooks/hooks.json +4 -0
  18. package/package.json +13 -7
  19. package/plugins/archivist/.claude-plugin/plugin.json +14 -0
  20. package/plugins/archivist/CHANGELOG.md +8 -0
  21. package/plugins/archivist/README.md +105 -0
  22. package/plugins/archivist/config.json +18 -0
  23. package/plugins/archivist/hooks/hooks.json +35 -0
  24. package/plugins/archivist/scripts/hooks/archivist-extract.sh +238 -0
  25. package/plugins/archivist/scripts/hooks/archivist-inject.sh +159 -0
  26. package/plugins/archivist/scripts/lib/archivist-config.sh +66 -0
  27. package/plugins/archivist/scripts/lib/archivist-project-key.sh +91 -0
  28. package/plugins/archivist/scripts/lib/archivist-storage.sh +215 -0
  29. package/plugins/archivist/scripts/lib/archivist-ulid.sh +52 -0
  30. package/plugins/echo/.claude-plugin/plugin.json +14 -0
  31. package/plugins/echo/CHANGELOG.md +24 -0
  32. package/plugins/echo/README.md +110 -0
  33. package/plugins/echo/config.json +15 -0
  34. package/plugins/echo/docs/adr/001-echo-as-separate-plugin.md +33 -0
  35. package/plugins/echo/docs/adr/002-direct-evaluation-vs-tribunal-pipeline.md +35 -0
  36. package/plugins/echo/docs/adr/003-stop-hook-trigger.md +40 -0
  37. package/plugins/echo/hooks/hooks.json +15 -0
  38. package/plugins/echo/scripts/hooks/echo-stop-gate.sh +366 -0
  39. package/plugins/echo/scripts/lib/echo-config.sh +108 -0
  40. package/plugins/echo/scripts/lib/echo-events.sh +74 -0
  41. package/plugins/echo/scripts/lib/echo-project-key.sh +81 -0
  42. package/plugins/echo/scripts/lib/echo-ulid.sh +46 -0
  43. package/plugins/tribunal/.claude-plugin/plugin.json +20 -0
  44. package/plugins/tribunal/CHANGELOG.md +10 -0
  45. package/plugins/tribunal/README.md +134 -0
  46. package/plugins/tribunal/agents/tribunal-actor.md +35 -0
  47. package/plugins/tribunal/agents/tribunal-judge-adversarial.md +51 -0
  48. package/plugins/tribunal/agents/tribunal-judge-security.md +47 -0
  49. package/plugins/tribunal/agents/tribunal-judge-standard.md +47 -0
  50. package/plugins/tribunal/agents/tribunal-meta-judge.md +61 -0
  51. package/plugins/tribunal/config.json +50 -0
  52. package/plugins/tribunal/docs/adr/001-actor-jury-meta-gate-loop.md +40 -0
  53. package/plugins/tribunal/docs/adr/002-majority-gate-policy.md +48 -0
  54. package/plugins/tribunal/hooks/hooks.json +15 -0
  55. package/plugins/tribunal/scripts/hooks/tribunal-stop-gate.sh +267 -0
  56. package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +65 -0
  57. package/plugins/tribunal/scripts/lib/tribunal-config.sh +101 -0
  58. package/plugins/tribunal/scripts/lib/tribunal-events.sh +97 -0
  59. package/plugins/tribunal/scripts/lib/tribunal-gate.sh +111 -0
  60. package/plugins/tribunal/scripts/lib/tribunal-jury.sh +102 -0
  61. package/plugins/tribunal/scripts/lib/tribunal-project-key.sh +84 -0
  62. package/plugins/tribunal/scripts/lib/tribunal-rubric.sh +153 -0
  63. package/plugins/tribunal/scripts/lib/tribunal-ulid.sh +50 -0
  64. package/plugins/tribunal/scripts/lib/tribunal-verdict.sh +127 -0
  65. package/plugins/tribunal/skills/tribunal/SKILL.md +129 -0
  66. package/release-please-config.json +43 -5
  67. package/scripts/coverage/bash-coverage.mjs +169 -0
  68. package/scripts/coverage/format-comment.mjs +120 -0
  69. package/scripts/coverage/run-coverage.mjs +151 -0
  70. package/scripts/hooks/agent-spawn-tracker.sh +4 -4
  71. package/scripts/hooks/prompt-rule-injector.sh +122 -0
  72. package/scripts/lib/portable-lock.sh +48 -0
  73. package/scripts/lib/prompt-rules.sh +207 -0
  74. package/scripts/lib/tool-history.sh +7 -8
  75. package/scripts/lib/validate-path.sh +4 -0
  76. package/scripts/lint/check-manifests.mjs +314 -0
  77. package/scripts/lint/check-references.mjs +311 -0
  78. package/skills/list-prompt-rules/SKILL.md +15 -0
  79. package/test/bats/archivist-config-files.bats +60 -0
  80. package/test/bats/archivist-config.bats +54 -0
  81. package/test/bats/archivist-inject.bats +73 -0
  82. package/test/bats/archivist-project-key.bats +75 -0
  83. package/test/bats/archivist-storage.bats +119 -0
  84. package/test/bats/archivist-ulid.bats +36 -0
  85. package/test/bats/config.bats +10 -10
  86. package/test/bats/echo-config.bats +90 -0
  87. package/test/bats/echo-events.bats +121 -0
  88. package/test/bats/echo-project-key.bats +115 -0
  89. package/test/bats/echo-stop-hook.bats +101 -0
  90. package/test/bats/echo-ulid.bats +38 -0
  91. package/test/bats/portable-lock.bats +62 -0
  92. package/test/bats/prompt-rules.bats +269 -0
  93. package/test/bats/tribunal-aggregate.bats +77 -0
  94. package/test/bats/tribunal-config.bats +86 -0
  95. package/test/bats/tribunal-events.bats +209 -0
  96. package/test/bats/tribunal-gate.bats +95 -0
  97. package/test/bats/tribunal-jury.bats +80 -0
  98. package/test/bats/tribunal-rubric.bats +119 -0
  99. package/test/bats/tribunal-stop-hook.bats +73 -0
  100. package/test/bats/tribunal-verdict.bats +71 -0
  101. package/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json +8 -0
  102. package/test/fixtures/hook-inputs/user-prompt-submit-rule-nomatch.json +8 -0
  103. package/test/helpers/setup.bash +9 -0
  104. package/test/node/check-manifests.test.mjs +173 -0
  105. package/test/node/check-references.test.mjs +279 -0
  106. package/test/node/coverage.test.mjs +143 -0
@@ -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
@@ -50,6 +50,10 @@
50
50
  {
51
51
  "type": "command",
52
52
  "command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/session-duration-tracker.sh"
53
+ },
54
+ {
55
+ "type": "command",
56
+ "command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/prompt-rule-injector.sh"
53
57
  }
54
58
  ]
55
59
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlooker-community/ecosystem",
3
- "version": "0.10.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": "^1.4.1"
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
- "test:ci": "npm run test:shellcheck && npm run test:bats && npm run test:schema && npm run lint:check",
31
- "lint:check": "biome check . && markdownlint '**/*.md' --ignore node_modules --ignore CHANGELOG.md",
32
- "lint": "biome lint --write",
33
- "format": "biome format --write",
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