@onlooker-community/ecosystem 0.27.0 → 0.28.1

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 (34) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.release-please-manifest.json +4 -3
  4. package/CHANGELOG.md +14 -0
  5. package/CLAUDE.md +2 -0
  6. package/docs/architecture.md +4 -0
  7. package/package.json +3 -3
  8. package/plugins/compass/.claude-plugin/plugin.json +1 -1
  9. package/plugins/compass/CHANGELOG.md +7 -0
  10. package/plugins/compass/scripts/hooks/compass-pre-tool-use.sh +9 -4
  11. package/plugins/lineage/.claude-plugin/plugin.json +14 -0
  12. package/plugins/lineage/CHANGELOG.md +9 -0
  13. package/plugins/lineage/README.md +133 -0
  14. package/plugins/lineage/config.json +11 -0
  15. package/plugins/lineage/hooks/hooks.json +33 -0
  16. package/plugins/lineage/scripts/hooks/lineage-post-tool-use.sh +132 -0
  17. package/plugins/lineage/scripts/lib/lineage-config.sh +100 -0
  18. package/plugins/lineage/scripts/lib/lineage-events.sh +81 -0
  19. package/plugins/lineage/scripts/lib/lineage-project-key.sh +85 -0
  20. package/plugins/lineage/scripts/lib/lineage-query.sh +88 -0
  21. package/plugins/lineage/scripts/lib/lineage-record.sh +132 -0
  22. package/plugins/lineage/scripts/lib/lineage-redact.sh +51 -0
  23. package/plugins/lineage/scripts/lib/lineage-ulid.sh +53 -0
  24. package/plugins/lineage/scripts/lib/portable-lock.sh +59 -0
  25. package/plugins/lineage/skills/lineage/SKILL.md +165 -0
  26. package/release-please-config.json +16 -0
  27. package/test/bats/lineage-config.bats +73 -0
  28. package/test/bats/lineage-events.bats +81 -0
  29. package/test/bats/lineage-post-tool-use.bats +115 -0
  30. package/test/bats/lineage-project-key.bats +51 -0
  31. package/test/bats/lineage-query.bats +85 -0
  32. package/test/bats/lineage-record.bats +79 -0
  33. package/test/bats/lineage-redact.bats +63 -0
  34. package/test/bats/lineage-ulid.bats +28 -0
@@ -202,6 +202,19 @@
202
202
  "license": "MIT",
203
203
  "keywords": ["budget", "cost", "rollup", "multi-session", "accounting", "audit"],
204
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"]
205
218
  }
206
219
  ]
207
220
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ecosystem",
3
- "version": "0.27.0",
3
+ "version": "0.28.1",
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,11 +1,11 @@
1
1
  {
2
- ".": "0.27.0",
2
+ ".": "0.28.1",
3
3
  "plugins/archivist": "0.1.0",
4
4
  "plugins/tribunal": "1.0.1",
5
5
  "plugins/echo": "0.2.0",
6
6
  "plugins/cartographer": "0.2.1",
7
7
  "plugins/governor": "0.2.1",
8
- "plugins/compass": "0.2.0",
8
+ "plugins/compass": "0.2.1",
9
9
  "plugins/scribe": "0.2.1",
10
10
  "plugins/counsel": "0.3.1",
11
11
  "plugins/warden": "0.2.0",
@@ -13,5 +13,6 @@
13
13
  "plugins/curator": "0.1.0",
14
14
  "plugins/historian": "0.2.0",
15
15
  "plugins/assayer": "1.0.0",
16
- "plugins/bursar": "0.1.0"
16
+ "plugins/bursar": "0.1.0",
17
+ "plugins/lineage": "0.1.0"
17
18
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.28.1](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.28.0...ecosystem-v0.28.1) (2026-06-12)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **compass:** resolve MultiEdit file path from the top-level field ([#85](https://github.com/onlooker-community/ecosystem/issues/85)) ([468abaa](https://github.com/onlooker-community/ecosystem/commit/468abaaad4bef59fe4308bd8887dfcf6d633921a))
9
+
10
+ ## [0.28.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.27.0...ecosystem-v0.28.0) (2026-06-12)
11
+
12
+
13
+ ### Features
14
+
15
+ * **lineage:** introduce per-change provenance plugin ([#83](https://github.com/onlooker-community/ecosystem/issues/83)) ([86b00d3](https://github.com/onlooker-community/ecosystem/commit/86b00d3d7393e2b63c5b04d60692fc89f202bf6c))
16
+
3
17
  ## [0.27.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.26.1...ecosystem-v0.27.0) (2026-06-12)
4
18
 
5
19
 
package/CLAUDE.md CHANGED
@@ -16,6 +16,7 @@ plugins/
16
16
  compass/ ← pre-write alignment gate (design phase)
17
17
  echo/ ← prompt-change regression detection
18
18
  governor/ ← resource governance and budget enforcement
19
+ lineage/ ← per-change provenance ("why does this line exist?")
19
20
  tribunal/ ← multi-agent quality gate (Actor → Jury → Meta-Judge → Gate)
20
21
 
21
22
  docs/
@@ -40,6 +41,7 @@ scripts/lib/onlooker-event.mjs ← canonical event builder; all plugins route t
40
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` |
41
42
  | assayer | Stop | Verifies the agent's final-message claims against actual command results in the transcript; advisory |
42
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 |
43
45
 
44
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.
45
47
 
@@ -60,6 +60,10 @@ Findings are stored in `~/.onlooker/cartographer/<project-key>/findings/` and de
60
60
 
61
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
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
+
63
67
  ### Plugin dependency model
64
68
 
65
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.27.0",
3
+ "version": "0.28.1",
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.7.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 plugins/bursar/scripts/hooks/*.sh plugins/bursar/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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "compass",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Pre-write intent clarity gate. Intercepts write-class tool calls and requires a confidence threshold before allowing them to proceed. Evaluates the pending write against the prior assistant turn as context to avoid false positives on question-answer turns. Builds on the Onlooker ecosystem plugin.",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.1](https://github.com/onlooker-community/ecosystem/compare/compass-v0.2.0...compass-v0.2.1) (2026-06-12)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **compass:** resolve MultiEdit file path from the top-level field ([#85](https://github.com/onlooker-community/ecosystem/issues/85)) ([468abaa](https://github.com/onlooker-community/ecosystem/commit/468abaaad4bef59fe4308bd8887dfcf6d633921a))
9
+
3
10
  ## [0.2.0](https://github.com/onlooker-community/ecosystem/compare/compass-v0.1.0...compass-v0.2.0) (2026-06-01)
4
11
 
5
12
 
@@ -65,14 +65,19 @@ case "$TOOL_NAME" in
65
65
  OPERATION="edit"
66
66
  ;;
67
67
  MultiEdit)
68
- # For MultiEdit, use the first file path and a summary as context.
68
+ # MultiEdit applies to one file via a top-level file_path; fall back to
69
+ # the first edit's path for any nested shape.
69
70
  FILE_PATH=$(printf '%s' "$INPUT" \
70
- | jq -r '.tool_input.edits[0].file_path // ""' 2>/dev/null) || FILE_PATH=""
71
+ | jq -r '.tool_input.file_path // .tool_input.edits[0].file_path // ""' 2>/dev/null) || FILE_PATH=""
71
72
  _edit_count=$(printf '%s' "$INPUT" \
72
73
  | jq '.tool_input.edits | length' 2>/dev/null) || _edit_count="?"
74
+ # Combine the top-level path with any per-edit paths, drop blanks/nulls.
73
75
  _file_list=$(printf '%s' "$INPUT" \
74
- | jq -r '[.tool_input.edits[].file_path] | unique | join(", ")' 2>/dev/null) \
75
- || _file_list="(multiple files)"
76
+ | jq -r '([.tool_input.file_path] + [.tool_input.edits[]?.file_path] | map(select(. != null and . != "")) | unique) | join(", ")' 2>/dev/null) \
77
+ || _file_list=""
78
+ [[ -z "$_file_list" ]] && _file_list="$FILE_PATH"
79
+ # Both blank (a MultiEdit with no resolvable path) — keep the context legible.
80
+ [[ -z "$_file_list" ]] && _file_list="(unknown)"
76
81
  CONTEXT="MultiEdit: ${_edit_count} edit(s) across: ${_file_list}"
77
82
  OPERATION="multi_edit"
78
83
  ;;
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "lineage",
3
+ "version": "0.1.0",
4
+ "description": "Per-change provenance for the Onlooker ecosystem. Records session/turn metadata plus a secret-redacted, size-capped snippet for every Edit/Write/MultiEdit at PostToolUse, then answers \"why does this line exist?\" via /lineage by joining change records to historian-preserved transcripts. Emits lineage.* events for audit. 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": ["./skills/lineage"],
13
+ "agents": []
14
+ }
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0](https://github.com/onlooker-community/ecosystem/compare/lineage-v0.0.1...lineage-v0.1.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
+
@@ -0,0 +1,133 @@
1
+ # Lineage
2
+
3
+ Per-change provenance for the Onlooker ecosystem — "why does this line exist?"
4
+
5
+ Git records *what* changed and scribe records a session's *intent*, but nothing
6
+ connects a **specific piece of code** to the prompt, agent, and session that
7
+ produced it. Lineage records provenance for every `Edit`/`Write`/`MultiEdit` at
8
+ `PostToolUse`, then answers `/lineage <file>:<line>` by joining its change records
9
+ to the transcripts [historian](../historian) preserves.
10
+
11
+ Lineage is a sibling plugin to [`ecosystem`](../../) and assumes the Onlooker
12
+ observability substrate (`~/.onlooker/`) is present.
13
+
14
+ ## How it works
15
+
16
+ | Hook | Matcher | What Lineage does |
17
+ |------|---------|-------------------|
18
+ | `PostToolUse` | `Edit`, `Write`, `MultiEdit` | Derives the project key from `cwd`, reads the current turn from the session tracker, extracts the change's added content, redacts secrets + caps size, and appends one record to the per-project change ledger. Emits a lean `lineage.change.recorded` (metadata + digest, never the content). Skips disabled sessions, ignored paths, and files outside the repo. |
19
+
20
+ The `/lineage` skill is the query side: it reads the ledger and resolves each
21
+ change's originating prompt at query time. It makes no LLM call.
22
+
23
+ ### Content-anchored provenance
24
+
25
+ Lineage records the **added content** of each change (redacted, capped at
26
+ `max_snippet_chars`). To answer "why does line N exist?", it reads the current
27
+ line's text and finds the most recent change whose added content contains it.
28
+ This is honest about what it is: *what change introduced this content, and why* —
29
+ not a git-blame-exact line mapping. If later edits move or rewrite the line, the
30
+ match is the most recent change that introduced the matching text.
31
+
32
+ ### The historian join
33
+
34
+ Lineage records only `session_id` + `turn` (+ a `transcript_path` pointer) on the
35
+ hot path — never the prompt. The prompt is resolved lazily at query time:
36
+
37
+ 1. **historian** — read the session's durable chunks at
38
+ `~/.onlooker/historian/<project-key>/sessions/<session-id>.jsonl` and take the
39
+ chunk whose turn range contains the change's turn (tolerant: nearest preceding,
40
+ else the last chunk). This is the preferred source because historian persists
41
+ transcripts long after the live `transcript_path` is gone.
42
+ 2. **transcript** — fall back to the live `transcript_path` (the turn-th user
43
+ message).
44
+ 3. **none** — neither available; report the change without a prompt.
45
+
46
+ The content match — not the turn — is the precise provenance key; the prompt is
47
+ best-effort context, since historian's turn indices need not line up exactly with
48
+ the substrate's turn counter.
49
+
50
+ ## Activation
51
+
52
+ Lineage is **off by default**. Enable it per-project in `.claude/settings.json`:
53
+
54
+ ```json
55
+ {
56
+ "lineage": {
57
+ "enabled": true
58
+ }
59
+ }
60
+ ```
61
+
62
+ Or globally in `~/.claude/settings.json`. While disabled, the hook skips silently
63
+ and no ledger is written.
64
+
65
+ ## Configuration
66
+
67
+ All keys are optional. Unset keys fall back to the plugin's `config.json` defaults.
68
+
69
+ ```json
70
+ {
71
+ "lineage": {
72
+ "enabled": false,
73
+ "max_snippet_chars": 4000,
74
+ "redact_secrets": true,
75
+ "ignore_globs": ["**/.git/**", "**/node_modules/**", "**/dist/**", "**/*.lock"],
76
+ "prompt_source": "historian_then_transcript"
77
+ }
78
+ }
79
+ ```
80
+
81
+ | Key | Default | Description |
82
+ |-----|---------|-------------|
83
+ | `enabled` | `false` | Must be `true` for recording, querying, or event emission to run. |
84
+ | `max_snippet_chars` | `4000` | Cap on the added-content snippet stored per change. |
85
+ | `redact_secrets` | `true` | Scrub secret-shaped substrings (AWS/GitHub/Anthropic/OpenAI keys, bearer tokens, KEY=value secrets) before storing a snippet. |
86
+ | `ignore_globs` | `[".git", "node_modules", "dist", "*.lock"]` | Paths matching these are not recorded. Supports `**/<dir>/**` (path segment) and `**/*.<ext>` (suffix) shapes. |
87
+ | `prompt_source` | `"historian_then_transcript"` | Prompt-resolution strategy: `historian_then_transcript`, `historian_only`, or `transcript_only`. |
88
+
89
+ Config resolves in three layers, latest wins: plugin `config.json` →
90
+ `~/.claude/settings.json` → `<repo>/.claude/settings.json`.
91
+
92
+ ## The `/lineage` query
93
+
94
+ | Invocation | Answers |
95
+ |------------|---------|
96
+ | `/lineage <file>` | Full change history for the file, newest first, each with its resolved prompt context. |
97
+ | `/lineage <file>:<line>` or `/lineage <file> --line N` | Which change introduced the content currently on line N — with the prompt/agent/session behind it. |
98
+ | `/lineage <file> --grep <text>` | Which change introduced content matching `<text>`. |
99
+ | `/lineage --status` | Ledger stats for the project (changes recorded, files touched). |
100
+
101
+ ## Storage layout
102
+
103
+ ```text
104
+ ~/.onlooker/lineage/<project-key>/
105
+ ├── changes.jsonl # append-only, one change record per line
106
+ └── changes.jsonl.lock # write lock
107
+ ```
108
+
109
+ Each record: `{ change_id, ts, ts_epoch, session_id, turn?, tool, operation,
110
+ file_path, lines_added, lines_removed, bytes, edit_count, content_sha256,
111
+ added_snippets[], transcript_path }`. The added content lives only in this ledger;
112
+ the bus event carries metadata and the `content_sha256` digest, never the content.
113
+
114
+ Lineage honors `$ONLOOKER_DIR`; it never hardcodes `~/.onlooker`, so the test
115
+ suite's isolated temp home is respected.
116
+
117
+ ## Events emitted
118
+
119
+ Lineage emits the canonical `lineage.*` surface from
120
+ [`@onlooker-community/schema`](https://github.com/onlooker-community/schema) v2.8.0+.
121
+
122
+ | Event | When |
123
+ |-------|------|
124
+ | `lineage.change.recorded` | At `PostToolUse`, after a change is appended to the ledger. Carries `project_key`, `session_id`, `file_path`, `tool`, `operation`, `change_id`, and metadata (`lines_added`/`lines_removed`/`bytes`/`edit_count`/`content_sha256`); no content. |
125
+ | `lineage.query.answered` | When `/lineage` answers. Carries `project_key`, `file_path`, `matches`, and (for line queries) `line` and `resolved_via`. |
126
+
127
+ ## Requirements
128
+
129
+ - The `ecosystem` plugin installed (for the `~/.onlooker/` substrate and canonical event emission).
130
+ - The [`historian`](../historian) plugin enabled, for the most reliable prompt resolution. Without it, lineage falls back to the live transcript, then to "prompt unavailable."
131
+ - `jq` for JSON manipulation.
132
+ - `node` for canonical-event emission.
133
+ - `python3` for secret redaction (the same dependency historian uses for sanitizing).
@@ -0,0 +1,11 @@
1
+ {
2
+ "plugin_name": "lineage",
3
+ "storage_path": "~/.onlooker",
4
+ "lineage": {
5
+ "enabled": false,
6
+ "max_snippet_chars": 4000,
7
+ "redact_secrets": true,
8
+ "ignore_globs": ["**/.git/**", "**/node_modules/**", "**/dist/**", "**/*.lock"],
9
+ "prompt_source": "historian_then_transcript"
10
+ }
11
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "hooks": {
3
+ "PostToolUse": [
4
+ {
5
+ "matcher": "Write",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/lineage-post-tool-use.sh"
10
+ }
11
+ ]
12
+ },
13
+ {
14
+ "matcher": "Edit",
15
+ "hooks": [
16
+ {
17
+ "type": "command",
18
+ "command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/lineage-post-tool-use.sh"
19
+ }
20
+ ]
21
+ },
22
+ {
23
+ "matcher": "MultiEdit",
24
+ "hooks": [
25
+ {
26
+ "type": "command",
27
+ "command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/lineage-post-tool-use.sh"
28
+ }
29
+ ]
30
+ }
31
+ ]
32
+ }
33
+ }
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env bash
2
+ # Lineage PostToolUse hook (Edit / Write / MultiEdit).
3
+ #
4
+ # Records per-change provenance into the per-project change ledger and emits a
5
+ # lean lineage.change.recorded event. Kept cheap: metadata + a redacted,
6
+ # size-capped snippet of the added content + a digest — no transcript parsing
7
+ # (the prompt is resolved lazily at /lineage query time).
8
+ #
9
+ # Hook contract: always exits 0; never blocks the tool. Skips silently when
10
+ # disabled, when the path is ignored, or when the file is outside the repo.
11
+
12
+ set -uo pipefail
13
+
14
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+ PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
16
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
17
+
18
+ # shellcheck source=../lib/portable-lock.sh
19
+ source "${PLUGIN_ROOT}/scripts/lib/portable-lock.sh"
20
+ # shellcheck source=../lib/lineage-config.sh
21
+ source "${PLUGIN_ROOT}/scripts/lib/lineage-config.sh"
22
+ # shellcheck source=../lib/lineage-events.sh
23
+ source "${PLUGIN_ROOT}/scripts/lib/lineage-events.sh"
24
+ # shellcheck source=../lib/lineage-project-key.sh
25
+ source "${PLUGIN_ROOT}/scripts/lib/lineage-project-key.sh"
26
+ # shellcheck source=../lib/lineage-ulid.sh
27
+ source "${PLUGIN_ROOT}/scripts/lib/lineage-ulid.sh"
28
+ # shellcheck source=../lib/lineage-redact.sh
29
+ source "${PLUGIN_ROOT}/scripts/lib/lineage-redact.sh"
30
+ # shellcheck source=../lib/lineage-record.sh
31
+ source "${PLUGIN_ROOT}/scripts/lib/lineage-record.sh"
32
+
33
+ INPUT=$(cat)
34
+ _done() { exit 0; }
35
+
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
+ TOOL=$(printf '%s' "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null) || TOOL=""
39
+ TOOL_INPUT=$(printf '%s' "$INPUT" | jq -c '.tool_input // {}' 2>/dev/null) || TOOL_INPUT="{}"
40
+ TOOL_USE_ID=$(printf '%s' "$INPUT" | jq -r '.tool_use_id // ""' 2>/dev/null) || TOOL_USE_ID=""
41
+ TRANSCRIPT_PATH=$(printf '%s' "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null) || TRANSCRIPT_PATH=""
42
+
43
+ case "$TOOL" in
44
+ Edit | Write | MultiEdit) ;;
45
+ *) _done ;;
46
+ esac
47
+
48
+ [[ -z "$CWD" ]] && CWD="$(pwd)"
49
+ REPO_ROOT=$(lineage_project_repo_root "$CWD")
50
+ lineage_config_load "$REPO_ROOT"
51
+ lineage_config_enabled || _done
52
+
53
+ PROJECT_KEY=$(lineage_project_key "$CWD")
54
+ [[ -z "$PROJECT_KEY" ]] && _done
55
+
56
+ FILE_PATH=""
57
+ case "$TOOL" in
58
+ MultiEdit)
59
+ # MultiEdit applies to one file via a top-level file_path; some shapes
60
+ # nest file_path per edit, so fall back to the first edit's.
61
+ FILE_PATH=$(printf '%s' "$TOOL_INPUT" | jq -r '.file_path // .edits[0].file_path // ""' 2>/dev/null) || FILE_PATH=""
62
+ # If edits carry distinct per-file paths spanning more than one file,
63
+ # skip to avoid misattribution. (Future: split into one record per file.)
64
+ unique_count=$(printf '%s' "$TOOL_INPUT" | jq -r '[.edits[]?.file_path // empty] | unique | length' 2>/dev/null) || unique_count=0
65
+ [[ "${unique_count:-0}" -gt 1 ]] && _done
66
+ ;;
67
+ *)
68
+ FILE_PATH=$(printf '%s' "$TOOL_INPUT" | jq -r '.file_path // .path // ""' 2>/dev/null) || FILE_PATH=""
69
+ ;;
70
+ esac
71
+ [[ -z "$FILE_PATH" ]] && _done
72
+
73
+ # Skip ignored paths. Supports the common glob shapes in config:
74
+ # **/<dir>/** → path-segment match ; **/*.<ext> → suffix match.
75
+ _lineage_ignored() {
76
+ local path="$1" glob core
77
+ while IFS= read -r glob; do
78
+ [[ -z "$glob" ]] && continue
79
+ core="$glob"
80
+ core="${core#\*\*/}"
81
+ core="${core%/\*\*}"
82
+ case "$core" in
83
+ \*.*) [[ "$path" == *"${core#\*}" ]] && return 0 ;;
84
+ *) [[ "/$path/" == *"/$core/"* ]] && return 0 ;;
85
+ esac
86
+ done < <(lineage_config_ignore_globs)
87
+ return 1
88
+ }
89
+ _lineage_ignored "$FILE_PATH" && _done
90
+
91
+ # Skip files outside the repo (best-effort). Resolve the file's directory to a
92
+ # real path so the prefix test survives symlinked roots (e.g. macOS /var →
93
+ # /private/var, where REPO_ROOT is already realpath-resolved).
94
+ if [[ -n "$REPO_ROOT" && "$FILE_PATH" == /* ]]; then
95
+ _file_dir=$(cd "$(dirname "$FILE_PATH")" 2>/dev/null && pwd -P) || _file_dir=""
96
+ if [[ -n "$_file_dir" && "$_file_dir" != "$REPO_ROOT" && "$_file_dir"/ != "$REPO_ROOT"/* ]]; then
97
+ _done
98
+ fi
99
+ fi
100
+
101
+ # Turn number (best-effort) from the substrate session tracker.
102
+ TURN=""
103
+ TRACKER="${ONLOOKER_DIR:-$HOME/.onlooker}/session-trackers/${SESSION_ID}"
104
+ [[ -n "$SESSION_ID" && -f "$TRACKER" ]] && TURN=$(jq -r '.turn_number // empty' "$TRACKER" 2>/dev/null)
105
+
106
+ MAX_CHARS=$(lineage_config_max_snippet_chars)
107
+ DO_REDACT=true
108
+ lineage_config_redact_enabled || DO_REDACT=false
109
+ CHANGE_ID=$(lineage_ulid)
110
+ TS=$(lineage_now_iso)
111
+ TS_EPOCH=$(lineage_now_epoch)
112
+
113
+ RECORD=$(lineage_build_record "$CHANGE_ID" "$TS" "$TS_EPOCH" "$SESSION_ID" "$TURN" \
114
+ "$TOOL" "$FILE_PATH" "$TOOL_INPUT" "$MAX_CHARS" "$DO_REDACT" "$TRANSCRIPT_PATH")
115
+ [[ -z "$RECORD" ]] && _done
116
+
117
+ if lineage_append "$PROJECT_KEY" "$RECORD"; then
118
+ # Lean bus event: metadata + digest only — never the added content.
119
+ EV=$(printf '%s' "$RECORD" | jq -c --arg pk "$PROJECT_KEY" --arg tuid "$TOOL_USE_ID" '
120
+ {
121
+ project_key: $pk, session_id: .session_id, file_path: .file_path,
122
+ tool: .tool, operation: .operation, change_id: .change_id,
123
+ lines_added: .lines_added, lines_removed: .lines_removed,
124
+ bytes: .bytes, edit_count: .edit_count, content_sha256: .content_sha256
125
+ }
126
+ + (if .turn != null then {turn: .turn} else {} end)
127
+ + (if $tuid != "" then {tool_use_id: $tuid} else {} end)
128
+ ' 2>/dev/null)
129
+ [[ -n "$EV" ]] && lineage_emit_event "lineage.change.recorded" "$EV" "$SESSION_ID" || true
130
+ fi
131
+
132
+ _done
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env bash
2
+ # Config resolution for lineage.
3
+ #
4
+ # Reads three layers, latest wins:
5
+ # 1. plugins/lineage/config.json (defaults shipped with the plugin)
6
+ # 2. ~/.claude/settings.json
7
+ # 3. <repo>/.claude/settings.json
8
+ #
9
+ # Exposes:
10
+ # lineage_config_load <repo_root> # populates _LINEAGE_CONFIG (JSON)
11
+ # lineage_config_get <jq-path> # echoes string value (empty if unset)
12
+ # lineage_config_get_json <jq-path> # echoes JSON value (null if unset)
13
+ # lineage_config_enabled # 0 if lineage.enabled is true
14
+ # lineage_config_max_snippet_chars # echoes the snippet cap (default 4000)
15
+ # lineage_config_redact_enabled # 0 unless redact_secrets is false
16
+ # lineage_config_prompt_source # echoes the prompt-source strategy
17
+ # lineage_config_ignore_globs # echoes ignore globs, one per line
18
+
19
+ _LINEAGE_CONFIG="{}"
20
+
21
+ lineage_config_load() {
22
+ local repo_root="${1:-}"
23
+ local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
24
+ local home_dir="${HOME:-}"
25
+
26
+ local merged="{}"
27
+ local file
28
+
29
+ file="${plugin_root}/config.json"
30
+ if [[ -f "$file" ]]; then
31
+ local defaults
32
+ defaults=$(jq '.' "$file" 2>/dev/null) || defaults="{}"
33
+ merged=$(jq -n --argjson a "$merged" --argjson b "$defaults" '$a * $b' 2>/dev/null) \
34
+ || merged="$defaults"
35
+ fi
36
+
37
+ local repo_settings=""
38
+ [[ -n "$repo_root" ]] && repo_settings="${repo_root}/.claude/settings.json"
39
+
40
+ for file in "${home_dir}/.claude/settings.json" "$repo_settings"; do
41
+ [[ -n "$file" && -f "$file" ]] || continue
42
+ local overlay
43
+ overlay=$(jq '{ lineage: (.lineage // {}) }' "$file" 2>/dev/null) || continue
44
+ [[ -z "$overlay" ]] && continue
45
+ local attempt
46
+ if attempt=$(jq -n --argjson a "$merged" --argjson b "$overlay" '
47
+ def deepmerge($a; $b):
48
+ if ($a|type) == "object" and ($b|type) == "object" then
49
+ reduce (($a|keys) + ($b|keys) | unique)[] as $k
50
+ ({}; .[$k] = deepmerge($a[$k]; $b[$k]))
51
+ elif $b == null then $a
52
+ else $b end;
53
+ deepmerge($a; $b)
54
+ ' 2>/dev/null) && [[ -n "$attempt" ]]; then
55
+ merged="$attempt"
56
+ fi
57
+ done
58
+
59
+ _LINEAGE_CONFIG="$merged"
60
+ }
61
+
62
+ lineage_config_get() {
63
+ local path="$1"
64
+ printf '%s' "$_LINEAGE_CONFIG" | jq -r "${path} // empty" 2>/dev/null
65
+ }
66
+
67
+ lineage_config_get_json() {
68
+ local path="$1"
69
+ printf '%s' "$_LINEAGE_CONFIG" | jq -c "${path}" 2>/dev/null
70
+ }
71
+
72
+ lineage_config_enabled() {
73
+ local v
74
+ v=$(lineage_config_get '.lineage.enabled')
75
+ [[ "$v" == "true" ]]
76
+ }
77
+
78
+ lineage_config_max_snippet_chars() {
79
+ local v
80
+ v=$(lineage_config_get '.lineage.max_snippet_chars')
81
+ printf '%s' "${v:-4000}"
82
+ }
83
+
84
+ lineage_config_redact_enabled() {
85
+ # Default on. jq's `//` treats a literal `false` as empty, so read the raw
86
+ # JSON value and only disable on an explicit false.
87
+ local v
88
+ v=$(lineage_config_get_json '.lineage.redact_secrets')
89
+ [[ "$v" != "false" ]]
90
+ }
91
+
92
+ lineage_config_prompt_source() {
93
+ local v
94
+ v=$(lineage_config_get '.lineage.prompt_source')
95
+ printf '%s' "${v:-historian_then_transcript}"
96
+ }
97
+
98
+ lineage_config_ignore_globs() {
99
+ printf '%s' "$_LINEAGE_CONFIG" | jq -r '.lineage.ignore_globs[]? // empty' 2>/dev/null
100
+ }