@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.
- package/.claude-plugin/marketplace.json +26 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.release-please-manifest.json +4 -2
- package/CHANGELOG.md +14 -0
- package/CLAUDE.md +4 -0
- package/docs/architecture.md +8 -0
- package/package.json +3 -3
- package/plugins/bursar/.claude-plugin/plugin.json +14 -0
- package/plugins/bursar/CHANGELOG.md +10 -0
- package/plugins/bursar/README.md +100 -0
- package/plugins/bursar/config.json +11 -0
- package/plugins/bursar/hooks/hooks.json +26 -0
- package/plugins/bursar/scripts/hooks/bursar-session-end.sh +142 -0
- package/plugins/bursar/scripts/hooks/bursar-session-start.sh +130 -0
- package/plugins/bursar/scripts/lib/bursar-config.sh +108 -0
- package/plugins/bursar/scripts/lib/bursar-events.sh +82 -0
- package/plugins/bursar/scripts/lib/bursar-ledger.sh +152 -0
- package/plugins/bursar/scripts/lib/bursar-project-key.sh +85 -0
- package/plugins/bursar/scripts/lib/bursar-ulid.sh +53 -0
- package/plugins/bursar/scripts/lib/portable-lock.sh +59 -0
- package/plugins/lineage/.claude-plugin/plugin.json +14 -0
- package/plugins/lineage/CHANGELOG.md +9 -0
- package/plugins/lineage/README.md +133 -0
- package/plugins/lineage/config.json +11 -0
- package/plugins/lineage/hooks/hooks.json +33 -0
- package/plugins/lineage/scripts/hooks/lineage-post-tool-use.sh +132 -0
- package/plugins/lineage/scripts/lib/lineage-config.sh +100 -0
- package/plugins/lineage/scripts/lib/lineage-events.sh +81 -0
- package/plugins/lineage/scripts/lib/lineage-project-key.sh +85 -0
- package/plugins/lineage/scripts/lib/lineage-query.sh +88 -0
- package/plugins/lineage/scripts/lib/lineage-record.sh +132 -0
- package/plugins/lineage/scripts/lib/lineage-redact.sh +51 -0
- package/plugins/lineage/scripts/lib/lineage-ulid.sh +53 -0
- package/plugins/lineage/scripts/lib/portable-lock.sh +59 -0
- package/plugins/lineage/skills/lineage/SKILL.md +165 -0
- package/release-please-config.json +32 -0
- package/test/bats/bursar-config.bats +79 -0
- package/test/bats/bursar-events.bats +73 -0
- package/test/bats/bursar-ledger.bats +116 -0
- package/test/bats/bursar-project-key.bats +51 -0
- package/test/bats/bursar-session-end.bats +131 -0
- package/test/bats/bursar-session-start.bats +126 -0
- package/test/bats/bursar-ulid.bats +28 -0
- package/test/bats/lineage-config.bats +73 -0
- package/test/bats/lineage-events.bats +81 -0
- package/test/bats/lineage-post-tool-use.bats +115 -0
- package/test/bats/lineage-project-key.bats +51 -0
- package/test/bats/lineage-query.bats +85 -0
- package/test/bats/lineage-record.bats +79 -0
- package/test/bats/lineage-redact.bats +63 -0
- 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
|
+
}
|