@onlooker-community/ecosystem 0.26.1 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.claude-plugin/marketplace.json +26 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.release-please-manifest.json +4 -2
  4. package/CHANGELOG.md +14 -0
  5. package/CLAUDE.md +4 -0
  6. package/docs/architecture.md +8 -0
  7. package/package.json +3 -3
  8. package/plugins/bursar/.claude-plugin/plugin.json +14 -0
  9. package/plugins/bursar/CHANGELOG.md +10 -0
  10. package/plugins/bursar/README.md +100 -0
  11. package/plugins/bursar/config.json +11 -0
  12. package/plugins/bursar/hooks/hooks.json +26 -0
  13. package/plugins/bursar/scripts/hooks/bursar-session-end.sh +142 -0
  14. package/plugins/bursar/scripts/hooks/bursar-session-start.sh +130 -0
  15. package/plugins/bursar/scripts/lib/bursar-config.sh +108 -0
  16. package/plugins/bursar/scripts/lib/bursar-events.sh +82 -0
  17. package/plugins/bursar/scripts/lib/bursar-ledger.sh +152 -0
  18. package/plugins/bursar/scripts/lib/bursar-project-key.sh +85 -0
  19. package/plugins/bursar/scripts/lib/bursar-ulid.sh +53 -0
  20. package/plugins/bursar/scripts/lib/portable-lock.sh +59 -0
  21. package/plugins/lineage/.claude-plugin/plugin.json +14 -0
  22. package/plugins/lineage/CHANGELOG.md +9 -0
  23. package/plugins/lineage/README.md +133 -0
  24. package/plugins/lineage/config.json +11 -0
  25. package/plugins/lineage/hooks/hooks.json +33 -0
  26. package/plugins/lineage/scripts/hooks/lineage-post-tool-use.sh +132 -0
  27. package/plugins/lineage/scripts/lib/lineage-config.sh +100 -0
  28. package/plugins/lineage/scripts/lib/lineage-events.sh +81 -0
  29. package/plugins/lineage/scripts/lib/lineage-project-key.sh +85 -0
  30. package/plugins/lineage/scripts/lib/lineage-query.sh +88 -0
  31. package/plugins/lineage/scripts/lib/lineage-record.sh +132 -0
  32. package/plugins/lineage/scripts/lib/lineage-redact.sh +51 -0
  33. package/plugins/lineage/scripts/lib/lineage-ulid.sh +53 -0
  34. package/plugins/lineage/scripts/lib/portable-lock.sh +59 -0
  35. package/plugins/lineage/skills/lineage/SKILL.md +165 -0
  36. package/release-please-config.json +32 -0
  37. package/test/bats/bursar-config.bats +79 -0
  38. package/test/bats/bursar-events.bats +73 -0
  39. package/test/bats/bursar-ledger.bats +116 -0
  40. package/test/bats/bursar-project-key.bats +51 -0
  41. package/test/bats/bursar-session-end.bats +131 -0
  42. package/test/bats/bursar-session-start.bats +126 -0
  43. package/test/bats/bursar-ulid.bats +28 -0
  44. package/test/bats/lineage-config.bats +73 -0
  45. package/test/bats/lineage-events.bats +81 -0
  46. package/test/bats/lineage-post-tool-use.bats +115 -0
  47. package/test/bats/lineage-project-key.bats +51 -0
  48. package/test/bats/lineage-query.bats +85 -0
  49. package/test/bats/lineage-record.bats +79 -0
  50. package/test/bats/lineage-redact.bats +63 -0
  51. package/test/bats/lineage-ulid.bats +28 -0
@@ -0,0 +1,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
+ }