@onlooker-community/ecosystem 0.27.0 → 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 (31) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.release-please-manifest.json +3 -2
  4. package/CHANGELOG.md +7 -0
  5. package/CLAUDE.md +2 -0
  6. package/docs/architecture.md +4 -0
  7. package/package.json +3 -3
  8. package/plugins/lineage/.claude-plugin/plugin.json +14 -0
  9. package/plugins/lineage/CHANGELOG.md +9 -0
  10. package/plugins/lineage/README.md +133 -0
  11. package/plugins/lineage/config.json +11 -0
  12. package/plugins/lineage/hooks/hooks.json +33 -0
  13. package/plugins/lineage/scripts/hooks/lineage-post-tool-use.sh +132 -0
  14. package/plugins/lineage/scripts/lib/lineage-config.sh +100 -0
  15. package/plugins/lineage/scripts/lib/lineage-events.sh +81 -0
  16. package/plugins/lineage/scripts/lib/lineage-project-key.sh +85 -0
  17. package/plugins/lineage/scripts/lib/lineage-query.sh +88 -0
  18. package/plugins/lineage/scripts/lib/lineage-record.sh +132 -0
  19. package/plugins/lineage/scripts/lib/lineage-redact.sh +51 -0
  20. package/plugins/lineage/scripts/lib/lineage-ulid.sh +53 -0
  21. package/plugins/lineage/scripts/lib/portable-lock.sh +59 -0
  22. package/plugins/lineage/skills/lineage/SKILL.md +165 -0
  23. package/release-please-config.json +16 -0
  24. package/test/bats/lineage-config.bats +73 -0
  25. package/test/bats/lineage-events.bats +81 -0
  26. package/test/bats/lineage-post-tool-use.bats +115 -0
  27. package/test/bats/lineage-project-key.bats +51 -0
  28. package/test/bats/lineage-query.bats +85 -0
  29. package/test/bats/lineage-record.bats +79 -0
  30. package/test/bats/lineage-redact.bats +63 -0
  31. package/test/bats/lineage-ulid.bats +28 -0
@@ -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
+ }
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env bash
2
+ # Build and append lineage change records.
3
+ #
4
+ # Storage: $ONLOOKER_DIR/lineage/<project-key>/changes.jsonl — append-only, one
5
+ # change per line. Each record:
6
+ # { change_id, ts, ts_epoch, session_id, turn?, tool, operation, file_path,
7
+ # lines_added, lines_removed, bytes, edit_count, content_sha256,
8
+ # added_snippets[], transcript_path }
9
+ #
10
+ # The bus event (lineage.change.recorded) carries metadata + content_sha256
11
+ # only; the added content lives here in the per-project ledger, where the
12
+ # /lineage query content-anchors a line back to the change that introduced it.
13
+ #
14
+ # Requires lineage-redact.sh and portable-lock.sh sourced beforehand.
15
+
16
+ lineage_record_dir() {
17
+ local key="${1:-unknown}"
18
+ local safe
19
+ safe=$(printf '%s' "$key" | tr -c 'a-zA-Z0-9-' '_')
20
+ printf '%s/lineage/%s' "${ONLOOKER_DIR:-${HOME}/.onlooker}" "$safe"
21
+ }
22
+
23
+ lineage_record_path() { printf '%s/changes.jsonl' "$(lineage_record_dir "$1")"; }
24
+
25
+ lineage_now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || printf ''; }
26
+ lineage_now_epoch() { date +%s 2>/dev/null || printf '0'; }
27
+
28
+ lineage_sha256() {
29
+ if command -v shasum >/dev/null 2>&1; then
30
+ printf '%s' "$1" | shasum -a 256 2>/dev/null | cut -d' ' -f1
31
+ elif command -v sha256sum >/dev/null 2>&1; then
32
+ printf '%s' "$1" | sha256sum 2>/dev/null | cut -d' ' -f1
33
+ else
34
+ printf ''
35
+ fi
36
+ }
37
+
38
+ # Line count of a text blob (0 for empty). `grep -c ''` counts every line,
39
+ # including a final line with no trailing newline.
40
+ _lineage_count_lines() {
41
+ [[ -z "$1" ]] && { printf '0'; return 0; }
42
+ printf '%s' "$1" | grep -c '' 2>/dev/null || printf '0'
43
+ }
44
+
45
+ # Tool → operation enum (create|overwrite|edit|multi_edit). Write is recorded as
46
+ # a coarse "create"; the create/overwrite distinction is not reliably knowable
47
+ # at PostToolUse and does not affect the provenance answer.
48
+ _lineage_operation() {
49
+ case "$1" in
50
+ Edit) printf 'edit' ;;
51
+ MultiEdit) printf 'multi_edit' ;;
52
+ Write) printf 'create' ;;
53
+ *) printf 'edit' ;;
54
+ esac
55
+ }
56
+
57
+ # Added / removed content extracted from the tool_input JSON, per tool.
58
+ _lineage_added() {
59
+ local tool="$1" ti="$2"
60
+ case "$tool" in
61
+ Edit) printf '%s' "$ti" | jq -r '.new_string // ""' 2>/dev/null ;;
62
+ Write) printf '%s' "$ti" | jq -r '.content // ""' 2>/dev/null ;;
63
+ MultiEdit) printf '%s' "$ti" | jq -r '[.edits[]?.new_string // ""] | join("\n")' 2>/dev/null ;;
64
+ esac
65
+ }
66
+
67
+ _lineage_removed() {
68
+ local tool="$1" ti="$2"
69
+ case "$tool" in
70
+ Edit) printf '%s' "$ti" | jq -r '.old_string // ""' 2>/dev/null ;;
71
+ Write) printf '' ;;
72
+ MultiEdit) printf '%s' "$ti" | jq -r '[.edits[]?.old_string // ""] | join("\n")' 2>/dev/null ;;
73
+ esac
74
+ }
75
+
76
+ # Build a change record JSON (pure — no I/O). Echoes the record.
77
+ # Usage: lineage_build_record <change_id> <ts> <ts_epoch> <session_id> <turn>
78
+ # <tool> <file_path> <tool_input_json> <max_chars> <do_redact> <transcript_path>
79
+ lineage_build_record() {
80
+ local change_id="$1" ts="$2" ts_epoch="$3" session_id="$4" turn="$5"
81
+ local tool="$6" file_path="$7" ti="$8" max_chars="$9" do_redact="${10}" transcript_path="${11}"
82
+
83
+ local added removed added_red lines_added lines_removed bytes digest op edit_count
84
+ added=$(_lineage_added "$tool" "$ti")
85
+ removed=$(_lineage_removed "$tool" "$ti")
86
+ lines_added=$(_lineage_count_lines "$added")
87
+ lines_removed=$(_lineage_count_lines "$removed")
88
+ bytes=$(printf '%s' "$added" | wc -c | tr -d ' ')
89
+ digest=$(lineage_sha256 "$added")
90
+ op=$(_lineage_operation "$tool")
91
+ edit_count=$(printf '%s' "$ti" | jq -r 'if .edits then (.edits | length) else 1 end' 2>/dev/null) || edit_count=1
92
+ added_red=$(printf '%s' "$added" | lineage_redact "$max_chars" "$do_redact")
93
+
94
+ jq -n \
95
+ --arg cid "$change_id" --arg ts "$ts" --argjson te "${ts_epoch:-0}" \
96
+ --arg sid "$session_id" --arg tool "$tool" --arg op "$op" \
97
+ --arg fp "$file_path" --arg snip "$added_red" --arg tp "$transcript_path" \
98
+ --argjson la "${lines_added:-0}" --argjson lr "${lines_removed:-0}" \
99
+ --argjson by "${bytes:-0}" --arg digest "$digest" \
100
+ --argjson ec "${edit_count:-1}" --arg turn "$turn" \
101
+ '{
102
+ change_id: $cid, ts: $ts, ts_epoch: $te,
103
+ session_id: $sid, tool: $tool, operation: $op, file_path: $fp,
104
+ lines_added: $la, lines_removed: $lr, bytes: $by,
105
+ edit_count: $ec, content_sha256: $digest,
106
+ added_snippets: [$snip], transcript_path: $tp
107
+ }
108
+ + (if $turn != "" then {turn: ($turn | tonumber)} else {} end)' 2>/dev/null
109
+ }
110
+
111
+ # Append a record to the project ledger under its write lock.
112
+ # Usage: lineage_append <project_key> <record_json>
113
+ lineage_append() {
114
+ local key="$1" record="$2"
115
+ [[ -z "$key" || -z "$record" ]] && return 1
116
+
117
+ local dir path lock rec_compact
118
+ dir=$(lineage_record_dir "$key")
119
+ path="${dir}/changes.jsonl"
120
+ lock="${path}.lock"
121
+ mkdir -p "$dir" 2>/dev/null || return 1
122
+
123
+ rec_compact=$(printf '%s' "$record" | jq -c . 2>/dev/null) || return 1
124
+
125
+ if lock_acquire "$lock" 5; then
126
+ printf '%s\n' "$rec_compact" >> "$path" 2>/dev/null
127
+ local ok=$?
128
+ lock_release "$lock"
129
+ return "$ok"
130
+ fi
131
+ return 1
132
+ }
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env bash
2
+ # Secret redaction + size capping for lineage change snippets.
3
+ #
4
+ # Mirrors the conservative secret patterns in historian-sanitizer.sh (false
5
+ # positives acceptable; false negatives are the failure mode that matters).
6
+ # The heavy lifting runs in an inline python3 block — the same pattern
7
+ # historian uses — because portable case-insensitive regex across BSD/GNU sed
8
+ # is not worth the fragility on a path that handles user code.
9
+
10
+ # Redact secret-shaped substrings from stdin, then cap to <max_chars>.
11
+ # Usage: printf '%s' "$content" | lineage_redact <max_chars> <redact:true|false>
12
+ lineage_redact() {
13
+ local max_chars="${1:-4000}"
14
+ local do_redact="${2:-true}"
15
+ # Pass the program via -c (not a heredoc on stdin): -c keeps stdin free for
16
+ # the piped content, so sys.stdin.read() actually receives it.
17
+ local _prog
18
+ _prog=$(cat <<'PY'
19
+ import re
20
+ import sys
21
+
22
+ max_chars = int(sys.argv[1] or "4000")
23
+ do_redact = sys.argv[2] != "false"
24
+ text = sys.stdin.read()
25
+
26
+ if do_redact:
27
+ patterns = [
28
+ re.compile(r"\bAKIA[0-9A-Z]{16}\b"), # AWS access key id
29
+ re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"), # GitHub tokens
30
+ re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}\b"), # Anthropic API keys
31
+ re.compile(r"\bsk-[A-Za-z0-9]{20,}\b"), # OpenAI-style keys
32
+ re.compile(r"(?i:Bearer)\s+[A-Za-z0-9._\-+/=]{20,}"), # bearer tokens
33
+ ]
34
+ for pat in patterns:
35
+ text = pat.sub("[REDACTED:secret]", text)
36
+ # KEY=value or "key": "value" where the key name implies a secret;
37
+ # preserve the key, redact the value.
38
+ kv = re.compile(
39
+ r'([A-Za-z0-9_]*(?:KEY|SECRET|TOKEN|PASSWORD|PASSWD)[A-Za-z0-9_]*"?\s*[:=]\s*"?)\S+',
40
+ re.IGNORECASE,
41
+ )
42
+ text = kv.sub(lambda m: m.group(1) + "[REDACTED:secret]", text)
43
+
44
+ if len(text) > max_chars:
45
+ text = text[:max_chars] + "… [truncated %d chars]" % (len(text) - max_chars)
46
+
47
+ sys.stdout.write(text)
48
+ PY
49
+ )
50
+ python3 -c "$_prog" "$max_chars" "$do_redact"
51
+ }
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env bash
2
+ # Minimal ULID generator for lineage record ids.
3
+ #
4
+ # Spec: https://github.com/ulid/spec
5
+ # - 48-bit timestamp (ms since epoch) → 10 chars Crockford Base32
6
+ # - 80-bit randomness → 16 chars Crockford Base32
7
+ # - lexicographically sortable, time-ordered
8
+ #
9
+ # Copied from plugins/tribunal/scripts/lib/tribunal-ulid.sh and renamed; the
10
+ # ecosystem ships one *_ulid helper per plugin rather than a shared one.
11
+
12
+ _LINEAGE_ULID_ALPHABET="0123456789ABCDEFGHJKMNPQRSTVWXYZ"
13
+
14
+ _lineage_ulid_encode() {
15
+ local n="$1"
16
+ local len="$2"
17
+ local out=""
18
+ local i
19
+ for ((i = 0; i < len; i++)); do
20
+ out="${_LINEAGE_ULID_ALPHABET:$((n % 32)):1}${out}"
21
+ n=$((n / 32))
22
+ done
23
+ printf '%s' "$out"
24
+ }
25
+
26
+ lineage_ulid() {
27
+ local now_ms
28
+ if [[ "$(uname)" == "Darwin" ]]; then
29
+ now_ms=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null) \
30
+ || now_ms=$(($(date +%s) * 1000))
31
+ else
32
+ now_ms=$(date +%s%3N 2>/dev/null) || now_ms=$(($(date +%s) * 1000))
33
+ fi
34
+
35
+ local rand_hex rand_hi rand_lo
36
+ rand_hex=$(openssl rand -hex 10 2>/dev/null)
37
+ if [[ -n "$rand_hex" && ${#rand_hex} -eq 20 ]]; then
38
+ rand_hi=$((16#${rand_hex:0:10}))
39
+ rand_lo=$((16#${rand_hex:10:10}))
40
+ else
41
+ rand_hi=$((RANDOM * 32768 + RANDOM))
42
+ rand_lo=$((RANDOM * 32768 + RANDOM))
43
+ rand_hi=$(((rand_hi * 256 + RANDOM % 256) & ((1 << 40) - 1)))
44
+ rand_lo=$(((rand_lo * 256 + RANDOM % 256) & ((1 << 40) - 1)))
45
+ fi
46
+
47
+ local ts_part hi_part lo_part
48
+ ts_part=$(_lineage_ulid_encode "$now_ms" 10)
49
+ hi_part=$(_lineage_ulid_encode "$rand_hi" 8)
50
+ lo_part=$(_lineage_ulid_encode "$rand_lo" 8)
51
+
52
+ printf '%s%s%s' "$ts_part" "$hi_part" "$lo_part"
53
+ }
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env bash
2
+ # portable-lock.sh — vendored copy of the ecosystem substrate's portable lock.
3
+ #
4
+ # Vendored into the lineage plugin so the per-project ledger's atomic upserts
5
+ # keep working when lineage is installed standalone from the marketplace: the
6
+ # cache layout (~/.claude/plugins/cache/<owner>/lineage/<version>/) does not
7
+ # include the ecosystem repo's top-level scripts/lib/. Without a local copy,
8
+ # lock_acquire would be undefined and concurrent PostToolUse appends (e.g.
9
+ # from parallel subagents) could clobber the change ledger. This mirrors the
10
+ # per-plugin vendoring of lineage-ulid.sh and friends.
11
+ # Keep in sync with scripts/lib/portable-lock.sh at the repo root.
12
+ #
13
+ # Portable advisory file locking via mkdir() atomicity.
14
+ #
15
+ # Replaces flock(1), which ships with util-linux on Linux but is not present
16
+ # in stock macOS. This matters because the Onlooker hooks run on user
17
+ # machines, not just in CI: a macOS user without util-linux would otherwise
18
+ # see concurrent writes to $ONLOOKER_DIR silently clobber each other.
19
+ #
20
+ # mkdir() is atomic on POSIX local filesystems, which is the only place
21
+ # $ONLOOKER_DIR ever lives. Network filesystems (NFS) do not guarantee
22
+ # atomicity, but Claude Code state is local-only.
23
+ #
24
+ # Usage:
25
+ # lock_acquire "/path/to/file.lock" [timeout_seconds=5]
26
+ # # ... critical section ...
27
+ # lock_release "/path/to/file.lock"
28
+ #
29
+ # Avoid associative arrays so bash 3.2 (macOS default) keeps working.
30
+
31
+ # Acquire an exclusive lock at LOCKPATH. Returns 0 on success, 1 on timeout.
32
+ lock_acquire() {
33
+ local lockpath="${1:-}"
34
+ local timeout="${2:-5}"
35
+ [[ -z "$lockpath" ]] && return 1
36
+
37
+ local lockdir="${lockpath}.d"
38
+ local waited=0
39
+ # Poll at 10 Hz so a 5s timeout = 50 attempts.
40
+ local max_iter=$((timeout * 10))
41
+ while ! mkdir "$lockdir" 2>/dev/null; do
42
+ if ((waited >= max_iter)); then
43
+ return 1
44
+ fi
45
+ # `sleep 0.1` works on Linux + macOS; the `|| sleep 1` is a paranoid
46
+ # fallback for embedded shells that only accept integer seconds.
47
+ sleep 0.1 2>/dev/null || sleep 1
48
+ waited=$((waited + 1))
49
+ done
50
+ return 0
51
+ }
52
+
53
+ # Release the lock previously acquired for LOCKPATH. Safe to call when the
54
+ # lock is not held (no-op in that case).
55
+ lock_release() {
56
+ local lockpath="${1:-}"
57
+ [[ -z "$lockpath" ]] && return 0
58
+ rmdir "${lockpath}.d" 2>/dev/null || true
59
+ }
@@ -0,0 +1,165 @@
1
+ ---
2
+ name: lineage
3
+ description: Answer "why does this line exist?" — trace a file, or a specific line, back to the change, prompt, agent, and session that produced it. Reads lineage's per-project change ledger and joins it to the transcripts historian preserves. Modes — /lineage <file> (change history), /lineage <file>:<line> or --line N (single-line provenance), /lineage <file> --grep <text> (content search), /lineage --status (ledger stats). Use when the user asks who/what/why introduced code in a file, or invokes /lineage.
4
+ ---
5
+
6
+ # Lineage Skill
7
+
8
+ `/lineage` reads the per-project change ledger that the PostToolUse hook records
9
+ and resolves each change's originating prompt by joining to historian's durable
10
+ session transcripts (falling back to the live transcript). It answers
11
+ "why does this line exist?" without an LLM call — pure read, join, and render.
12
+
13
+ ## Setup
14
+
15
+ Run once. Sources the plugin helpers, loads config, and resolves project context.
16
+
17
+ ```bash
18
+ set -uo pipefail
19
+ PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
20
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
21
+
22
+ source "$PLUGIN_ROOT/scripts/lib/portable-lock.sh"
23
+ source "$PLUGIN_ROOT/scripts/lib/lineage-config.sh"
24
+ source "$PLUGIN_ROOT/scripts/lib/lineage-events.sh"
25
+ source "$PLUGIN_ROOT/scripts/lib/lineage-project-key.sh"
26
+ source "$PLUGIN_ROOT/scripts/lib/lineage-redact.sh"
27
+ source "$PLUGIN_ROOT/scripts/lib/lineage-record.sh"
28
+ source "$PLUGIN_ROOT/scripts/lib/lineage-query.sh"
29
+
30
+ REPO_ROOT=$(lineage_project_repo_root "$(pwd)")
31
+ lineage_config_load "$REPO_ROOT"
32
+ if ! lineage_config_enabled; then
33
+ echo "Lineage is disabled. Set lineage.enabled=true in .claude/settings.json to enable."
34
+ exit 0
35
+ fi
36
+ PROJECT_KEY=$(lineage_project_key "$(pwd)")
37
+ if [[ -z "$PROJECT_KEY" ]]; then
38
+ echo "No project key — lineage needs a git repository (remote or root) to scope its ledger."
39
+ exit 0
40
+ fi
41
+ PROMPT_SOURCE=$(lineage_config_prompt_source)
42
+ QSID="${CLAUDE_SESSION_ID:-lineage-query}"
43
+ ```
44
+
45
+ ## Invocation Modes
46
+
47
+ ### `/lineage <file>` — change history (default)
48
+
49
+ Set `FILE` to the path the user named, then run. (Repo-relative paths are
50
+ resolved against the repo root.)
51
+
52
+ ```bash
53
+ FILE="REPLACE_WITH_FILE"
54
+ [[ "$FILE" != /* && -n "$REPO_ROOT" ]] && FILE="$REPO_ROOT/$FILE"
55
+
56
+ echo "## Lineage — change history for \`$FILE\`"
57
+ count=0
58
+ while IFS= read -r rec; do
59
+ [[ -z "$rec" ]] && continue
60
+ count=$((count + 1))
61
+ ts=$(jq -r '.ts' <<<"$rec"); sid=$(jq -r '.session_id' <<<"$rec")
62
+ turn=$(jq -r '.turn // ""' <<<"$rec"); tool=$(jq -r '.tool' <<<"$rec")
63
+ la=$(jq -r '.lines_added' <<<"$rec"); lr=$(jq -r '.lines_removed' <<<"$rec")
64
+ tp=$(jq -r '.transcript_path // ""' <<<"$rec")
65
+ resolved=$(lineage_resolve_prompt "$PROJECT_KEY" "$sid" "$turn" "$tp" "$PROMPT_SOURCE")
66
+ prompt=$(jq -r '.prompt' <<<"$resolved"); via=$(jq -r '.resolved_via' <<<"$resolved")
67
+ echo ""
68
+ echo "### ${ts} · ${tool} (+${la}/-${lr}) · session ${sid}${turn:+ · turn ${turn}}"
69
+ if [[ -n "$prompt" ]]; then
70
+ echo "Prompt context (${via}):"; echo ""
71
+ printf '%s\n' "$prompt" | head -c 600 | sed 's/^/> /'
72
+ else
73
+ echo "_Prompt unavailable (${via})._"
74
+ fi
75
+ done < <(lineage_changes_for_file "$PROJECT_KEY" "$FILE")
76
+ [[ "$count" -eq 0 ]] && { echo ""; echo "No recorded changes for this file (it may predate lineage)."; }
77
+
78
+ lineage_emit_event "lineage.query.answered" \
79
+ "$(jq -nc --arg pk "$PROJECT_KEY" --arg f "$FILE" --argjson m "$count" \
80
+ '{project_key:$pk, file_path:$f, matches:$m}')" "$QSID" || true
81
+ ```
82
+
83
+ ### `/lineage <file>:<line>` (or `--line N`) — single-line provenance
84
+
85
+ Set `FILE` and `LINE`, then run. Reads the current line's text and content-anchors
86
+ it to the change that introduced it.
87
+
88
+ ```bash
89
+ FILE="REPLACE_WITH_FILE"; LINE="REPLACE_WITH_LINE_NUMBER"
90
+ [[ "$FILE" != /* && -n "$REPO_ROOT" ]] && FILE="$REPO_ROOT/$FILE"
91
+
92
+ line_text=$(sed -n "${LINE}p" "$FILE" 2>/dev/null)
93
+ needle=$(printf '%s' "$line_text" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
94
+
95
+ echo "## Lineage — why does \`$FILE\`:${LINE} exist?"
96
+ echo ""
97
+ echo "Line ${LINE}: \`${line_text}\`"
98
+
99
+ rec=$(lineage_match_line "$PROJECT_KEY" "$FILE" "$needle")
100
+ via="none"; matches=0
101
+ if [[ -z "$rec" ]]; then
102
+ echo ""
103
+ echo "No recorded change introduced this content (it may predate lineage, or the line moved since it was written)."
104
+ else
105
+ matches=1
106
+ ts=$(jq -r '.ts' <<<"$rec"); sid=$(jq -r '.session_id' <<<"$rec")
107
+ turn=$(jq -r '.turn // ""' <<<"$rec"); tool=$(jq -r '.tool' <<<"$rec")
108
+ tp=$(jq -r '.transcript_path // ""' <<<"$rec")
109
+ resolved=$(lineage_resolve_prompt "$PROJECT_KEY" "$sid" "$turn" "$tp" "$PROMPT_SOURCE")
110
+ prompt=$(jq -r '.prompt' <<<"$resolved"); via=$(jq -r '.resolved_via' <<<"$resolved")
111
+ echo ""
112
+ echo "Introduced ${ts} by a ${tool} in session ${sid}${turn:+ (turn ${turn})}."
113
+ if [[ -n "$prompt" ]]; then
114
+ echo ""; echo "Prompt context (${via}):"; echo ""
115
+ printf '%s\n' "$prompt" | sed 's/^/> /'
116
+ else
117
+ echo "_Prompt unavailable (${via})._"
118
+ fi
119
+ fi
120
+
121
+ lineage_emit_event "lineage.query.answered" \
122
+ "$(jq -nc --arg pk "$PROJECT_KEY" --arg f "$FILE" --argjson m "$matches" \
123
+ --argjson ln "${LINE:-0}" --arg via "$via" \
124
+ '{project_key:$pk, file_path:$f, matches:$m, line:$ln, resolved_via:$via}')" "$QSID" || true
125
+ ```
126
+
127
+ ### `/lineage <file> --grep <text>` — content search
128
+
129
+ Same as the line mode, but set `needle` to the user's search text instead of
130
+ reading a line from the file:
131
+
132
+ ```bash
133
+ FILE="REPLACE_WITH_FILE"; needle="REPLACE_WITH_TEXT"
134
+ [[ "$FILE" != /* && -n "$REPO_ROOT" ]] && FILE="$REPO_ROOT/$FILE"
135
+ rec=$(lineage_match_line "$PROJECT_KEY" "$FILE" "$needle")
136
+ # …render as in the line mode…
137
+ ```
138
+
139
+ ### `/lineage --status` — ledger stats
140
+
141
+ ```bash
142
+ LEDGER=$(lineage_record_path "$PROJECT_KEY")
143
+ echo "## Lineage status"
144
+ echo "- Project key: ${PROJECT_KEY}"
145
+ echo "- Ledger: ${LEDGER}"
146
+ if [[ -f "$LEDGER" ]]; then
147
+ total=$(wc -l < "$LEDGER" | tr -d ' ')
148
+ files=$(jq -r '.file_path' "$LEDGER" 2>/dev/null | sort -u | grep -c '')
149
+ echo "- Changes recorded: ${total} across ${files} file(s)"
150
+ else
151
+ echo "- No changes recorded yet. Make some Edit/Write changes with lineage enabled."
152
+ fi
153
+ ```
154
+
155
+ ## Notes
156
+
157
+ - Provenance is **content-anchored**: a line is matched to the change whose added
158
+ content contains it. If later edits moved or rewrote the line, the match is the
159
+ most recent change that introduced the matching text — not a git-blame-exact
160
+ mapping.
161
+ - The prompt is resolved lazily: historian's preserved per-session chunks first
162
+ (durable across transcript cleanup), then the live `transcript_path`, then
163
+ "unavailable." Install and enable historian for the most reliable prompts.
164
+ - Storage, project keying, and event emission match the other ecosystem plugins;
165
+ everything is scoped by project key and honors `$ONLOOKER_DIR`.
@@ -238,6 +238,22 @@
238
238
  "jsonpath": "$.version"
239
239
  }
240
240
  ]
241
+ },
242
+ "plugins/lineage": {
243
+ "changelog-path": "CHANGELOG.md",
244
+ "release-type": "simple",
245
+ "bump-minor-pre-major": true,
246
+ "bump-patch-for-minor-pre-major": false,
247
+ "component": "lineage",
248
+ "draft": false,
249
+ "prerelease": false,
250
+ "extra-files": [
251
+ {
252
+ "type": "json",
253
+ "path": ".claude-plugin/plugin.json",
254
+ "jsonpath": "$.version"
255
+ }
256
+ ]
241
257
  }
242
258
  },
243
259
  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"