@onlooker-community/ecosystem 0.26.1 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.claude-plugin/marketplace.json +26 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.release-please-manifest.json +4 -2
  4. package/CHANGELOG.md +14 -0
  5. package/CLAUDE.md +4 -0
  6. package/docs/architecture.md +8 -0
  7. package/package.json +3 -3
  8. package/plugins/bursar/.claude-plugin/plugin.json +14 -0
  9. package/plugins/bursar/CHANGELOG.md +10 -0
  10. package/plugins/bursar/README.md +100 -0
  11. package/plugins/bursar/config.json +11 -0
  12. package/plugins/bursar/hooks/hooks.json +26 -0
  13. package/plugins/bursar/scripts/hooks/bursar-session-end.sh +142 -0
  14. package/plugins/bursar/scripts/hooks/bursar-session-start.sh +130 -0
  15. package/plugins/bursar/scripts/lib/bursar-config.sh +108 -0
  16. package/plugins/bursar/scripts/lib/bursar-events.sh +82 -0
  17. package/plugins/bursar/scripts/lib/bursar-ledger.sh +152 -0
  18. package/plugins/bursar/scripts/lib/bursar-project-key.sh +85 -0
  19. package/plugins/bursar/scripts/lib/bursar-ulid.sh +53 -0
  20. package/plugins/bursar/scripts/lib/portable-lock.sh +59 -0
  21. package/plugins/lineage/.claude-plugin/plugin.json +14 -0
  22. package/plugins/lineage/CHANGELOG.md +9 -0
  23. package/plugins/lineage/README.md +133 -0
  24. package/plugins/lineage/config.json +11 -0
  25. package/plugins/lineage/hooks/hooks.json +33 -0
  26. package/plugins/lineage/scripts/hooks/lineage-post-tool-use.sh +132 -0
  27. package/plugins/lineage/scripts/lib/lineage-config.sh +100 -0
  28. package/plugins/lineage/scripts/lib/lineage-events.sh +81 -0
  29. package/plugins/lineage/scripts/lib/lineage-project-key.sh +85 -0
  30. package/plugins/lineage/scripts/lib/lineage-query.sh +88 -0
  31. package/plugins/lineage/scripts/lib/lineage-record.sh +132 -0
  32. package/plugins/lineage/scripts/lib/lineage-redact.sh +51 -0
  33. package/plugins/lineage/scripts/lib/lineage-ulid.sh +53 -0
  34. package/plugins/lineage/scripts/lib/portable-lock.sh +59 -0
  35. package/plugins/lineage/skills/lineage/SKILL.md +165 -0
  36. package/release-please-config.json +32 -0
  37. package/test/bats/bursar-config.bats +79 -0
  38. package/test/bats/bursar-events.bats +73 -0
  39. package/test/bats/bursar-ledger.bats +116 -0
  40. package/test/bats/bursar-project-key.bats +51 -0
  41. package/test/bats/bursar-session-end.bats +131 -0
  42. package/test/bats/bursar-session-start.bats +126 -0
  43. package/test/bats/bursar-ulid.bats +28 -0
  44. package/test/bats/lineage-config.bats +73 -0
  45. package/test/bats/lineage-events.bats +81 -0
  46. package/test/bats/lineage-post-tool-use.bats +115 -0
  47. package/test/bats/lineage-project-key.bats +51 -0
  48. package/test/bats/lineage-query.bats +85 -0
  49. package/test/bats/lineage-record.bats +79 -0
  50. package/test/bats/lineage-redact.bats +63 -0
  51. package/test/bats/lineage-ulid.bats +28 -0
@@ -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
+ }
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env bash
2
+ # Canonical lineage.* event emission.
3
+ #
4
+ # Thin wrapper around the ecosystem plugin's onlooker-event.mjs `emit` mode.
5
+ # Every emission is validated against @onlooker-community/schema (>= 2.8.0,
6
+ # which registers the lineage.* event types) before being appended to
7
+ # ~/.onlooker/logs/onlooker-events.jsonl.
8
+ #
9
+ # Usage:
10
+ # lineage_emit_event "lineage.change.recorded" '{"project_key":"...",...}' "$SESSION_ID"
11
+ _LINEAGE_PLUGIN_NAME="lineage"
12
+
13
+ _lineage_event_js_path() {
14
+ if [[ -n "${_ONLOOKER_EVENT_JS:-}" && -f "$_ONLOOKER_EVENT_JS" ]]; then
15
+ printf '%s' "$_ONLOOKER_EVENT_JS"
16
+ return 0
17
+ fi
18
+ local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
19
+ local candidates=(
20
+ "${plugin_root}/scripts/lib/onlooker-event.mjs"
21
+ "${plugin_root}/../../scripts/lib/onlooker-event.mjs"
22
+ )
23
+ local c
24
+ for c in "${candidates[@]}"; do
25
+ [[ -f "$c" ]] && { printf '%s' "$c"; return 0; }
26
+ done
27
+ return 1
28
+ }
29
+
30
+ _lineage_session_id() {
31
+ if [[ -n "${_HOOK_SESSION_ID:-}" ]]; then
32
+ printf '%s' "$_HOOK_SESSION_ID"
33
+ return 0
34
+ fi
35
+ if [[ -n "${CLAUDE_SESSION_ID:-}" ]]; then
36
+ printf '%s' "$CLAUDE_SESSION_ID"
37
+ return 0
38
+ fi
39
+ printf 'unknown'
40
+ }
41
+
42
+ # Emit a single lineage.* event. Returns 0 on success, non-zero on failure.
43
+ # Usage: lineage_emit_event <event_type> <payload_json> [session_id]
44
+ lineage_emit_event() {
45
+ local event_type="${1:-}"
46
+ local payload="${2:-}"
47
+ local session_id="${3:-}"
48
+
49
+ [[ -z "$event_type" || -z "$payload" ]] && return 1
50
+ [[ -z "$session_id" ]] && session_id=$(_lineage_session_id)
51
+
52
+ local event_js
53
+ event_js=$(_lineage_event_js_path) || return 1
54
+
55
+ local params
56
+ params=$(jq -n \
57
+ --arg plugin "$_LINEAGE_PLUGIN_NAME" \
58
+ --arg sid "$session_id" \
59
+ --arg type "$event_type" \
60
+ --argjson payload "$payload" \
61
+ '{plugin: $plugin, session_id: $sid, event_type: $type, payload: $payload}' \
62
+ 2>/dev/null) || return 1
63
+
64
+ local event
65
+ local stderr_file
66
+ stderr_file=$(mktemp -t lineage-event-err.XXXXXX 2>/dev/null) || stderr_file="/tmp/lineage-event-err.$$"
67
+ event=$(printf '%s' "$params" \
68
+ | ONLOOKER_DIR="${ONLOOKER_DIR:-$HOME/.onlooker}" \
69
+ ONLOOKER_PLUGIN_NAME="$_LINEAGE_PLUGIN_NAME" \
70
+ node "$event_js" emit 2>"$stderr_file") || {
71
+ printf 'lineage_emit_event: schema validation failed for %s\n' "$event_type" >&2
72
+ [[ -s "$stderr_file" ]] && cat "$stderr_file" >&2
73
+ rm -f "$stderr_file"
74
+ return 1
75
+ }
76
+ rm -f "$stderr_file"
77
+
78
+ local log_path="${ONLOOKER_EVENTS_LOG:-${ONLOOKER_DIR:-$HOME/.onlooker}/logs/onlooker-events.jsonl}"
79
+ mkdir -p "$(dirname "$log_path")" 2>/dev/null || return 1
80
+ printf '%s\n' "$event" >> "$log_path"
81
+ }
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env bash
2
+ # Project key derivation for lineage.
3
+ #
4
+ # Mirrors the archivist/tribunal project-key scheme so the plugins partition
5
+ # storage identically. A project key is a stable 12-char hex identifier that
6
+ # survives:
7
+ # - local rename of the repo directory
8
+ # - cloning the same repo to a different path on the same machine
9
+ # - moving the repo between machines (as long as the git remote is preserved)
10
+ # - worktrees (a worktree shares its parent repo's key)
11
+ #
12
+ # Resolution order:
13
+ # 1. SHA256(`git remote get-url origin`) — preferred, machine-portable
14
+ # 2. SHA256(realpath of `git rev-parse --show-toplevel`) — fallback for repos
15
+ # without an origin remote (greenfield local-only work)
16
+ #
17
+ # Returns the first 12 hex chars. Returns empty string if neither resolution
18
+ # path works.
19
+
20
+ _lineage_sha256_first12() {
21
+ local input="$1"
22
+ if command -v shasum >/dev/null 2>&1; then
23
+ printf '%s' "$input" | shasum -a 256 2>/dev/null | cut -c1-12
24
+ elif command -v sha256sum >/dev/null 2>&1; then
25
+ printf '%s' "$input" | sha256sum 2>/dev/null | cut -c1-12
26
+ else
27
+ return 1
28
+ fi
29
+ }
30
+
31
+ lineage_project_remote_url() {
32
+ local cwd="${1:-}"
33
+ [[ -z "$cwd" || ! -d "$cwd" ]] && return 0
34
+ git -C "$cwd" remote get-url origin 2>/dev/null || true
35
+ }
36
+
37
+ # Worktree-aware: uses common-dir so worktrees share a key with the main repo.
38
+ lineage_project_repo_root() {
39
+ local cwd="${1:-}"
40
+ [[ -z "$cwd" || ! -d "$cwd" ]] && return 0
41
+
42
+ if ! git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
43
+ return 0
44
+ fi
45
+
46
+ local common_dir toplevel
47
+ common_dir=$(git -C "$cwd" rev-parse --git-common-dir 2>/dev/null) || return 0
48
+
49
+ if [[ -n "$common_dir" && "$common_dir" != /* ]]; then
50
+ common_dir="$(cd "$cwd" && cd "$common_dir" 2>/dev/null && pwd -P)" || common_dir=""
51
+ fi
52
+
53
+ if [[ -n "$common_dir" && -d "$common_dir" ]]; then
54
+ toplevel="$(cd "$common_dir/.." 2>/dev/null && pwd -P)" || toplevel=""
55
+ fi
56
+
57
+ if [[ -z "$toplevel" ]]; then
58
+ toplevel=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null || true)
59
+ [[ -n "$toplevel" ]] && toplevel="$(cd "$toplevel" 2>/dev/null && pwd -P)"
60
+ fi
61
+
62
+ printf '%s' "$toplevel"
63
+ }
64
+
65
+ # Compute the project key for the given cwd. Prints the key or empty string.
66
+ lineage_project_key() {
67
+ local cwd="${1:-}"
68
+ [[ -z "$cwd" ]] && cwd="$(pwd)"
69
+
70
+ local remote
71
+ remote=$(lineage_project_remote_url "$cwd")
72
+ if [[ -n "$remote" ]]; then
73
+ _lineage_sha256_first12 "remote:$remote"
74
+ return 0
75
+ fi
76
+
77
+ local root
78
+ root=$(lineage_project_repo_root "$cwd")
79
+ if [[ -n "$root" ]]; then
80
+ _lineage_sha256_first12 "root:$root"
81
+ return 0
82
+ fi
83
+
84
+ return 0
85
+ }
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env bash
2
+ # Query side of lineage: read the change ledger and resolve prompts.
3
+ #
4
+ # The /lineage skill is a thin wrapper over these functions; the logic lives
5
+ # here so it is unit-testable in bats without driving the skill runtime.
6
+ #
7
+ # Requires lineage-record.sh (for lineage_record_path) and lineage-redact.sh
8
+ # (for capping/scrubbing resolved prompts) sourced beforehand.
9
+
10
+ # All change records for a file, newest first (one compact JSON per line).
11
+ # Usage: lineage_changes_for_file <project_key> <file_path>
12
+ lineage_changes_for_file() {
13
+ local key="$1" file="$2"
14
+ local path
15
+ path=$(lineage_record_path "$key")
16
+ [[ -f "$path" ]] || return 0
17
+ jq -s -c --arg f "$file" \
18
+ '[ .[] | select(.file_path == $f) ] | reverse | .[]' \
19
+ "$path" 2>/dev/null
20
+ }
21
+
22
+ # The newest change whose added content contains <line_text> (substring),
23
+ # i.e. the change that introduced that content. Echoes one record or nothing.
24
+ # Usage: lineage_match_line <project_key> <file_path> <line_text>
25
+ lineage_match_line() {
26
+ local key="$1" file="$2" needle="$3"
27
+ local path
28
+ path=$(lineage_record_path "$key")
29
+ [[ -f "$path" ]] || return 0
30
+ # An empty/whitespace needle has no meaningful introducing change.
31
+ [[ -z "${needle//[[:space:]]/}" ]] && return 0
32
+ jq -s -c --arg f "$file" --arg t "$needle" '
33
+ [ .[] | select(.file_path == $f) ] | reverse
34
+ | map(select(any(.added_snippets[]?; type == "string" and contains($t))))
35
+ | (.[0] // empty)
36
+ ' "$path" 2>/dev/null
37
+ }
38
+
39
+ # Resolve the originating prompt for a change. Tries historian's durable
40
+ # per-session chunks first (tolerant turn-range match), then the live
41
+ # transcript, then gives up. Echoes {prompt, resolved_via} as JSON.
42
+ # Usage: lineage_resolve_prompt <project_key> <session_id> <turn> <transcript_path> [prompt_source]
43
+ lineage_resolve_prompt() {
44
+ local key="$1" sid="$2" turn="$3" transcript_path="$4" source="${5:-historian_then_transcript}"
45
+ local prompt="" via="none"
46
+ local onlooker="${ONLOOKER_DIR:-$HOME/.onlooker}"
47
+
48
+ # 1) historian: chunk whose [start_turn_index,end_turn_index] contains the
49
+ # turn, else nearest preceding, else the last chunk. body_redacted is the
50
+ # conversation context historian preserved for that span.
51
+ if [[ "$source" != "transcript_only" && -n "$key" && -n "$sid" ]]; then
52
+ local safe_sid hist_file
53
+ safe_sid=$(printf '%s' "$sid" | tr -cd '[:alnum:]._-')
54
+ [[ -z "$safe_sid" ]] && safe_sid="unknown"
55
+ hist_file="${onlooker}/historian/${key}/sessions/${safe_sid}.jsonl"
56
+ if [[ -f "$hist_file" ]]; then
57
+ prompt=$(jq -rs --argjson t "${turn:-0}" '
58
+ ( [ .[] | select((.start_turn_index // 0) <= $t and (.end_turn_index // 0) >= $t) ] | .[0] )
59
+ // ( [ .[] | select((.end_turn_index // 0) <= $t) ] | sort_by(.end_turn_index) | last )
60
+ // (.[-1] // empty)
61
+ | (.body_redacted // "")
62
+ ' "$hist_file" 2>/dev/null) || prompt=""
63
+ [[ -n "$prompt" ]] && via="historian"
64
+ fi
65
+ fi
66
+
67
+ # 2) transcript fallback: the turn-th user message (1-based), else the last.
68
+ # Tolerant of both transcript shapes (.role/.content and .type/.message.content).
69
+ if [[ -z "$prompt" && "$source" != "historian_only" && -n "$transcript_path" && -f "$transcript_path" ]]; then
70
+ prompt=$(jq -rs --argjson t "${turn:-0}" '
71
+ [ .[]
72
+ | select((.role // .type) == "user")
73
+ | ((.content // .message.content) as $c
74
+ | if ($c | type) == "array"
75
+ then [ $c[]? | select(.type == "text") | .text ] | join("\n")
76
+ else ($c // "") end)
77
+ ] as $u
78
+ | ($u[($t - 1)] // ($u[-1] // ""))
79
+ ' "$transcript_path" 2>/dev/null) || prompt=""
80
+ [[ -n "$prompt" ]] && via="transcript"
81
+ fi
82
+
83
+ # Cap + scrub for display (historian bodies are already redacted; this also
84
+ # scrubs the transcript path and keeps the excerpt short).
85
+ prompt=$(printf '%s' "$prompt" | lineage_redact 1000 true)
86
+
87
+ jq -nc --arg p "$prompt" --arg v "$via" '{prompt: $p, resolved_via: $v}'
88
+ }