@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.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.release-please-manifest.json +3 -2
- package/CHANGELOG.md +7 -0
- package/CLAUDE.md +2 -0
- package/docs/architecture.md +4 -0
- package/package.json +3 -3
- 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 +16 -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,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"
|