@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,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`.
|
|
@@ -222,6 +222,38 @@
|
|
|
222
222
|
"jsonpath": "$.version"
|
|
223
223
|
}
|
|
224
224
|
]
|
|
225
|
+
},
|
|
226
|
+
"plugins/bursar": {
|
|
227
|
+
"changelog-path": "CHANGELOG.md",
|
|
228
|
+
"release-type": "simple",
|
|
229
|
+
"bump-minor-pre-major": true,
|
|
230
|
+
"bump-patch-for-minor-pre-major": false,
|
|
231
|
+
"component": "bursar",
|
|
232
|
+
"draft": false,
|
|
233
|
+
"prerelease": false,
|
|
234
|
+
"extra-files": [
|
|
235
|
+
{
|
|
236
|
+
"type": "json",
|
|
237
|
+
"path": ".claude-plugin/plugin.json",
|
|
238
|
+
"jsonpath": "$.version"
|
|
239
|
+
}
|
|
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
|
+
]
|
|
225
257
|
}
|
|
226
258
|
},
|
|
227
259
|
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
setup() {
|
|
4
|
+
source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
|
|
5
|
+
setup_test_env
|
|
6
|
+
|
|
7
|
+
PLUGIN_ROOT="${REPO_ROOT}/plugins/bursar"
|
|
8
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
9
|
+
# shellcheck disable=SC1091
|
|
10
|
+
source "${PLUGIN_ROOT}/scripts/lib/bursar-config.sh"
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@test "bursar is disabled by default" {
|
|
14
|
+
bursar_config_load ""
|
|
15
|
+
run bursar_config_enabled
|
|
16
|
+
[ "$status" -ne 0 ]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@test "user-level settings.json can enable bursar" {
|
|
20
|
+
mkdir -p "${HOME}/.claude"
|
|
21
|
+
printf '%s\n' '{"bursar":{"enabled":true}}' > "${HOME}/.claude/settings.json"
|
|
22
|
+
bursar_config_load ""
|
|
23
|
+
run bursar_config_enabled
|
|
24
|
+
[ "$status" -eq 0 ]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@test "repo-level settings.json overrides user-level" {
|
|
28
|
+
mkdir -p "${HOME}/.claude"
|
|
29
|
+
printf '%s\n' '{"bursar":{"enabled":true}}' > "${HOME}/.claude/settings.json"
|
|
30
|
+
local repo="${BATS_TEST_TMPDIR}/repo"
|
|
31
|
+
mkdir -p "${repo}/.claude"
|
|
32
|
+
printf '%s\n' '{"bursar":{"enabled":false}}' > "${repo}/.claude/settings.json"
|
|
33
|
+
bursar_config_load "$repo"
|
|
34
|
+
run bursar_config_enabled
|
|
35
|
+
[ "$status" -ne 0 ]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@test "default window is rolling_7d" {
|
|
39
|
+
bursar_config_load ""
|
|
40
|
+
[ "$(bursar_config_window)" = "rolling_7d" ]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@test "window can be set to calendar_week" {
|
|
44
|
+
mkdir -p "${HOME}/.claude"
|
|
45
|
+
printf '%s\n' '{"bursar":{"window":"calendar_week"}}' > "${HOME}/.claude/settings.json"
|
|
46
|
+
bursar_config_load ""
|
|
47
|
+
[ "$(bursar_config_window)" = "calendar_week" ]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@test "an invalid window falls back to rolling_7d" {
|
|
51
|
+
mkdir -p "${HOME}/.claude"
|
|
52
|
+
printf '%s\n' '{"bursar":{"window":"yearly"}}' > "${HOME}/.claude/settings.json"
|
|
53
|
+
bursar_config_load ""
|
|
54
|
+
[ "$(bursar_config_window)" = "rolling_7d" ]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@test "default week_start is monday" {
|
|
58
|
+
bursar_config_load ""
|
|
59
|
+
[ "$(bursar_config_week_start)" = "monday" ]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@test "week_start can be set to sunday" {
|
|
63
|
+
mkdir -p "${HOME}/.claude"
|
|
64
|
+
printf '%s\n' '{"bursar":{"week_start":"sunday"}}' > "${HOME}/.claude/settings.json"
|
|
65
|
+
bursar_config_load ""
|
|
66
|
+
[ "$(bursar_config_week_start)" = "sunday" ]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@test "surfacing is on by default and can be disabled" {
|
|
70
|
+
bursar_config_load ""
|
|
71
|
+
run bursar_config_surface_enabled
|
|
72
|
+
[ "$status" -eq 0 ]
|
|
73
|
+
|
|
74
|
+
mkdir -p "${HOME}/.claude"
|
|
75
|
+
printf '%s\n' '{"bursar":{"surface_at_session_start":false}}' > "${HOME}/.claude/settings.json"
|
|
76
|
+
bursar_config_load ""
|
|
77
|
+
run bursar_config_surface_enabled
|
|
78
|
+
[ "$status" -ne 0 ]
|
|
79
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# Validates that bursar.* events pass @onlooker-community/schema validation.
|
|
4
|
+
|
|
5
|
+
setup() {
|
|
6
|
+
source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
|
|
7
|
+
setup_test_env
|
|
8
|
+
|
|
9
|
+
PLUGIN_ROOT="${REPO_ROOT}/plugins/bursar"
|
|
10
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
11
|
+
export ONLOOKER_EVENTS_LOG="${ONLOOKER_DIR}/logs/onlooker-events.jsonl"
|
|
12
|
+
mkdir -p "$(dirname "$ONLOOKER_EVENTS_LOG")"
|
|
13
|
+
|
|
14
|
+
export _ONLOOKER_EVENT_JS="${REPO_ROOT}/scripts/lib/onlooker-event.mjs"
|
|
15
|
+
|
|
16
|
+
# shellcheck disable=SC1091
|
|
17
|
+
source "${PLUGIN_ROOT}/scripts/lib/bursar-events.sh"
|
|
18
|
+
|
|
19
|
+
export CLAUDE_SESSION_ID="bats-bursar-session-$$"
|
|
20
|
+
PK="proj0123abcd"
|
|
21
|
+
SID="bats-bursar-sid-000"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_validate_latest_event() {
|
|
25
|
+
local last
|
|
26
|
+
last=$(tail -n 1 "$ONLOOKER_EVENTS_LOG")
|
|
27
|
+
[ -n "$last" ] || return 1
|
|
28
|
+
printf '%s' "$last" | ONLOOKER_DIR="$ONLOOKER_DIR" \
|
|
29
|
+
node "${REPO_ROOT}/scripts/lib/onlooker-event.mjs" validate >/dev/null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@test "bursar.session.recorded with governor present validates" {
|
|
33
|
+
local p
|
|
34
|
+
p=$(jq -n --arg pk "$PK" --arg sid "$SID" \
|
|
35
|
+
'{project_key:$pk, session_id:$sid, governor_present:true,
|
|
36
|
+
cost_usd:0.42, tokens:42000, api_calls:12, model:"claude-opus-4-8"}')
|
|
37
|
+
bursar_emit_event "bursar.session.recorded" "$p" "$SID"
|
|
38
|
+
run _validate_latest_event
|
|
39
|
+
[ "$status" -eq 0 ]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@test "bursar.session.recorded with governor absent validates" {
|
|
43
|
+
local p
|
|
44
|
+
p=$(jq -n --arg pk "$PK" --arg sid "$SID" \
|
|
45
|
+
'{project_key:$pk, session_id:$sid, governor_present:false}')
|
|
46
|
+
bursar_emit_event "bursar.session.recorded" "$p" "$SID"
|
|
47
|
+
run _validate_latest_event
|
|
48
|
+
[ "$status" -eq 0 ]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@test "bursar.rollup.surfaced validates" {
|
|
52
|
+
local p
|
|
53
|
+
p=$(jq -n --arg pk "$PK" \
|
|
54
|
+
'{project_key:$pk, window:"rolling_7d", total_cost_usd:3.17,
|
|
55
|
+
session_count:8, total_tokens:310000, sessions_with_cost:7,
|
|
56
|
+
window_start:"2026-06-05T00:00:00Z"}')
|
|
57
|
+
bursar_emit_event "bursar.rollup.surfaced" "$p" "$SID"
|
|
58
|
+
run _validate_latest_event
|
|
59
|
+
[ "$status" -eq 0 ]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@test "bursar.rollup.skipped validates" {
|
|
63
|
+
local p
|
|
64
|
+
p=$(jq -n --arg pk "$PK" '{reason:"no_data", project_key:$pk}')
|
|
65
|
+
bursar_emit_event "bursar.rollup.skipped" "$p" "$SID"
|
|
66
|
+
run _validate_latest_event
|
|
67
|
+
[ "$status" -eq 0 ]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@test "bursar_emit_event returns nonzero for an unknown event type" {
|
|
71
|
+
run bursar_emit_event "bursar.no_such_event" '{"project_key":"x"}' "$SID"
|
|
72
|
+
[ "$status" -ne 0 ]
|
|
73
|
+
}
|