@onlooker-community/ecosystem 0.27.0 → 0.28.1
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 +4 -3
- package/CHANGELOG.md +14 -0
- package/CLAUDE.md +2 -0
- package/docs/architecture.md +4 -0
- package/package.json +3 -3
- package/plugins/compass/.claude-plugin/plugin.json +1 -1
- package/plugins/compass/CHANGELOG.md +7 -0
- package/plugins/compass/scripts/hooks/compass-pre-tool-use.sh +9 -4
- 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,81 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Canonical lineage.* event emission.
|
|
3
|
+
#
|
|
4
|
+
# Thin wrapper around the ecosystem plugin's onlooker-event.mjs `emit` mode.
|
|
5
|
+
# Every emission is validated against @onlooker-community/schema (>= 2.8.0,
|
|
6
|
+
# which registers the lineage.* event types) before being appended to
|
|
7
|
+
# ~/.onlooker/logs/onlooker-events.jsonl.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# lineage_emit_event "lineage.change.recorded" '{"project_key":"...",...}' "$SESSION_ID"
|
|
11
|
+
_LINEAGE_PLUGIN_NAME="lineage"
|
|
12
|
+
|
|
13
|
+
_lineage_event_js_path() {
|
|
14
|
+
if [[ -n "${_ONLOOKER_EVENT_JS:-}" && -f "$_ONLOOKER_EVENT_JS" ]]; then
|
|
15
|
+
printf '%s' "$_ONLOOKER_EVENT_JS"
|
|
16
|
+
return 0
|
|
17
|
+
fi
|
|
18
|
+
local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
|
|
19
|
+
local candidates=(
|
|
20
|
+
"${plugin_root}/scripts/lib/onlooker-event.mjs"
|
|
21
|
+
"${plugin_root}/../../scripts/lib/onlooker-event.mjs"
|
|
22
|
+
)
|
|
23
|
+
local c
|
|
24
|
+
for c in "${candidates[@]}"; do
|
|
25
|
+
[[ -f "$c" ]] && { printf '%s' "$c"; return 0; }
|
|
26
|
+
done
|
|
27
|
+
return 1
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_lineage_session_id() {
|
|
31
|
+
if [[ -n "${_HOOK_SESSION_ID:-}" ]]; then
|
|
32
|
+
printf '%s' "$_HOOK_SESSION_ID"
|
|
33
|
+
return 0
|
|
34
|
+
fi
|
|
35
|
+
if [[ -n "${CLAUDE_SESSION_ID:-}" ]]; then
|
|
36
|
+
printf '%s' "$CLAUDE_SESSION_ID"
|
|
37
|
+
return 0
|
|
38
|
+
fi
|
|
39
|
+
printf 'unknown'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Emit a single lineage.* event. Returns 0 on success, non-zero on failure.
|
|
43
|
+
# Usage: lineage_emit_event <event_type> <payload_json> [session_id]
|
|
44
|
+
lineage_emit_event() {
|
|
45
|
+
local event_type="${1:-}"
|
|
46
|
+
local payload="${2:-}"
|
|
47
|
+
local session_id="${3:-}"
|
|
48
|
+
|
|
49
|
+
[[ -z "$event_type" || -z "$payload" ]] && return 1
|
|
50
|
+
[[ -z "$session_id" ]] && session_id=$(_lineage_session_id)
|
|
51
|
+
|
|
52
|
+
local event_js
|
|
53
|
+
event_js=$(_lineage_event_js_path) || return 1
|
|
54
|
+
|
|
55
|
+
local params
|
|
56
|
+
params=$(jq -n \
|
|
57
|
+
--arg plugin "$_LINEAGE_PLUGIN_NAME" \
|
|
58
|
+
--arg sid "$session_id" \
|
|
59
|
+
--arg type "$event_type" \
|
|
60
|
+
--argjson payload "$payload" \
|
|
61
|
+
'{plugin: $plugin, session_id: $sid, event_type: $type, payload: $payload}' \
|
|
62
|
+
2>/dev/null) || return 1
|
|
63
|
+
|
|
64
|
+
local event
|
|
65
|
+
local stderr_file
|
|
66
|
+
stderr_file=$(mktemp -t lineage-event-err.XXXXXX 2>/dev/null) || stderr_file="/tmp/lineage-event-err.$$"
|
|
67
|
+
event=$(printf '%s' "$params" \
|
|
68
|
+
| ONLOOKER_DIR="${ONLOOKER_DIR:-$HOME/.onlooker}" \
|
|
69
|
+
ONLOOKER_PLUGIN_NAME="$_LINEAGE_PLUGIN_NAME" \
|
|
70
|
+
node "$event_js" emit 2>"$stderr_file") || {
|
|
71
|
+
printf 'lineage_emit_event: schema validation failed for %s\n' "$event_type" >&2
|
|
72
|
+
[[ -s "$stderr_file" ]] && cat "$stderr_file" >&2
|
|
73
|
+
rm -f "$stderr_file"
|
|
74
|
+
return 1
|
|
75
|
+
}
|
|
76
|
+
rm -f "$stderr_file"
|
|
77
|
+
|
|
78
|
+
local log_path="${ONLOOKER_EVENTS_LOG:-${ONLOOKER_DIR:-$HOME/.onlooker}/logs/onlooker-events.jsonl}"
|
|
79
|
+
mkdir -p "$(dirname "$log_path")" 2>/dev/null || return 1
|
|
80
|
+
printf '%s\n' "$event" >> "$log_path"
|
|
81
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Project key derivation for lineage.
|
|
3
|
+
#
|
|
4
|
+
# Mirrors the archivist/tribunal project-key scheme so the plugins partition
|
|
5
|
+
# storage identically. A project key is a stable 12-char hex identifier that
|
|
6
|
+
# survives:
|
|
7
|
+
# - local rename of the repo directory
|
|
8
|
+
# - cloning the same repo to a different path on the same machine
|
|
9
|
+
# - moving the repo between machines (as long as the git remote is preserved)
|
|
10
|
+
# - worktrees (a worktree shares its parent repo's key)
|
|
11
|
+
#
|
|
12
|
+
# Resolution order:
|
|
13
|
+
# 1. SHA256(`git remote get-url origin`) — preferred, machine-portable
|
|
14
|
+
# 2. SHA256(realpath of `git rev-parse --show-toplevel`) — fallback for repos
|
|
15
|
+
# without an origin remote (greenfield local-only work)
|
|
16
|
+
#
|
|
17
|
+
# Returns the first 12 hex chars. Returns empty string if neither resolution
|
|
18
|
+
# path works.
|
|
19
|
+
|
|
20
|
+
_lineage_sha256_first12() {
|
|
21
|
+
local input="$1"
|
|
22
|
+
if command -v shasum >/dev/null 2>&1; then
|
|
23
|
+
printf '%s' "$input" | shasum -a 256 2>/dev/null | cut -c1-12
|
|
24
|
+
elif command -v sha256sum >/dev/null 2>&1; then
|
|
25
|
+
printf '%s' "$input" | sha256sum 2>/dev/null | cut -c1-12
|
|
26
|
+
else
|
|
27
|
+
return 1
|
|
28
|
+
fi
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
lineage_project_remote_url() {
|
|
32
|
+
local cwd="${1:-}"
|
|
33
|
+
[[ -z "$cwd" || ! -d "$cwd" ]] && return 0
|
|
34
|
+
git -C "$cwd" remote get-url origin 2>/dev/null || true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Worktree-aware: uses common-dir so worktrees share a key with the main repo.
|
|
38
|
+
lineage_project_repo_root() {
|
|
39
|
+
local cwd="${1:-}"
|
|
40
|
+
[[ -z "$cwd" || ! -d "$cwd" ]] && return 0
|
|
41
|
+
|
|
42
|
+
if ! git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
43
|
+
return 0
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
local common_dir toplevel
|
|
47
|
+
common_dir=$(git -C "$cwd" rev-parse --git-common-dir 2>/dev/null) || return 0
|
|
48
|
+
|
|
49
|
+
if [[ -n "$common_dir" && "$common_dir" != /* ]]; then
|
|
50
|
+
common_dir="$(cd "$cwd" && cd "$common_dir" 2>/dev/null && pwd -P)" || common_dir=""
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
if [[ -n "$common_dir" && -d "$common_dir" ]]; then
|
|
54
|
+
toplevel="$(cd "$common_dir/.." 2>/dev/null && pwd -P)" || toplevel=""
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
if [[ -z "$toplevel" ]]; then
|
|
58
|
+
toplevel=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null || true)
|
|
59
|
+
[[ -n "$toplevel" ]] && toplevel="$(cd "$toplevel" 2>/dev/null && pwd -P)"
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
printf '%s' "$toplevel"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Compute the project key for the given cwd. Prints the key or empty string.
|
|
66
|
+
lineage_project_key() {
|
|
67
|
+
local cwd="${1:-}"
|
|
68
|
+
[[ -z "$cwd" ]] && cwd="$(pwd)"
|
|
69
|
+
|
|
70
|
+
local remote
|
|
71
|
+
remote=$(lineage_project_remote_url "$cwd")
|
|
72
|
+
if [[ -n "$remote" ]]; then
|
|
73
|
+
_lineage_sha256_first12 "remote:$remote"
|
|
74
|
+
return 0
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
local root
|
|
78
|
+
root=$(lineage_project_repo_root "$cwd")
|
|
79
|
+
if [[ -n "$root" ]]; then
|
|
80
|
+
_lineage_sha256_first12 "root:$root"
|
|
81
|
+
return 0
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
return 0
|
|
85
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Query side of lineage: read the change ledger and resolve prompts.
|
|
3
|
+
#
|
|
4
|
+
# The /lineage skill is a thin wrapper over these functions; the logic lives
|
|
5
|
+
# here so it is unit-testable in bats without driving the skill runtime.
|
|
6
|
+
#
|
|
7
|
+
# Requires lineage-record.sh (for lineage_record_path) and lineage-redact.sh
|
|
8
|
+
# (for capping/scrubbing resolved prompts) sourced beforehand.
|
|
9
|
+
|
|
10
|
+
# All change records for a file, newest first (one compact JSON per line).
|
|
11
|
+
# Usage: lineage_changes_for_file <project_key> <file_path>
|
|
12
|
+
lineage_changes_for_file() {
|
|
13
|
+
local key="$1" file="$2"
|
|
14
|
+
local path
|
|
15
|
+
path=$(lineage_record_path "$key")
|
|
16
|
+
[[ -f "$path" ]] || return 0
|
|
17
|
+
jq -s -c --arg f "$file" \
|
|
18
|
+
'[ .[] | select(.file_path == $f) ] | reverse | .[]' \
|
|
19
|
+
"$path" 2>/dev/null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# The newest change whose added content contains <line_text> (substring),
|
|
23
|
+
# i.e. the change that introduced that content. Echoes one record or nothing.
|
|
24
|
+
# Usage: lineage_match_line <project_key> <file_path> <line_text>
|
|
25
|
+
lineage_match_line() {
|
|
26
|
+
local key="$1" file="$2" needle="$3"
|
|
27
|
+
local path
|
|
28
|
+
path=$(lineage_record_path "$key")
|
|
29
|
+
[[ -f "$path" ]] || return 0
|
|
30
|
+
# An empty/whitespace needle has no meaningful introducing change.
|
|
31
|
+
[[ -z "${needle//[[:space:]]/}" ]] && return 0
|
|
32
|
+
jq -s -c --arg f "$file" --arg t "$needle" '
|
|
33
|
+
[ .[] | select(.file_path == $f) ] | reverse
|
|
34
|
+
| map(select(any(.added_snippets[]?; type == "string" and contains($t))))
|
|
35
|
+
| (.[0] // empty)
|
|
36
|
+
' "$path" 2>/dev/null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Resolve the originating prompt for a change. Tries historian's durable
|
|
40
|
+
# per-session chunks first (tolerant turn-range match), then the live
|
|
41
|
+
# transcript, then gives up. Echoes {prompt, resolved_via} as JSON.
|
|
42
|
+
# Usage: lineage_resolve_prompt <project_key> <session_id> <turn> <transcript_path> [prompt_source]
|
|
43
|
+
lineage_resolve_prompt() {
|
|
44
|
+
local key="$1" sid="$2" turn="$3" transcript_path="$4" source="${5:-historian_then_transcript}"
|
|
45
|
+
local prompt="" via="none"
|
|
46
|
+
local onlooker="${ONLOOKER_DIR:-$HOME/.onlooker}"
|
|
47
|
+
|
|
48
|
+
# 1) historian: chunk whose [start_turn_index,end_turn_index] contains the
|
|
49
|
+
# turn, else nearest preceding, else the last chunk. body_redacted is the
|
|
50
|
+
# conversation context historian preserved for that span.
|
|
51
|
+
if [[ "$source" != "transcript_only" && -n "$key" && -n "$sid" ]]; then
|
|
52
|
+
local safe_sid hist_file
|
|
53
|
+
safe_sid=$(printf '%s' "$sid" | tr -cd '[:alnum:]._-')
|
|
54
|
+
[[ -z "$safe_sid" ]] && safe_sid="unknown"
|
|
55
|
+
hist_file="${onlooker}/historian/${key}/sessions/${safe_sid}.jsonl"
|
|
56
|
+
if [[ -f "$hist_file" ]]; then
|
|
57
|
+
prompt=$(jq -rs --argjson t "${turn:-0}" '
|
|
58
|
+
( [ .[] | select((.start_turn_index // 0) <= $t and (.end_turn_index // 0) >= $t) ] | .[0] )
|
|
59
|
+
// ( [ .[] | select((.end_turn_index // 0) <= $t) ] | sort_by(.end_turn_index) | last )
|
|
60
|
+
// (.[-1] // empty)
|
|
61
|
+
| (.body_redacted // "")
|
|
62
|
+
' "$hist_file" 2>/dev/null) || prompt=""
|
|
63
|
+
[[ -n "$prompt" ]] && via="historian"
|
|
64
|
+
fi
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
# 2) transcript fallback: the turn-th user message (1-based), else the last.
|
|
68
|
+
# Tolerant of both transcript shapes (.role/.content and .type/.message.content).
|
|
69
|
+
if [[ -z "$prompt" && "$source" != "historian_only" && -n "$transcript_path" && -f "$transcript_path" ]]; then
|
|
70
|
+
prompt=$(jq -rs --argjson t "${turn:-0}" '
|
|
71
|
+
[ .[]
|
|
72
|
+
| select((.role // .type) == "user")
|
|
73
|
+
| ((.content // .message.content) as $c
|
|
74
|
+
| if ($c | type) == "array"
|
|
75
|
+
then [ $c[]? | select(.type == "text") | .text ] | join("\n")
|
|
76
|
+
else ($c // "") end)
|
|
77
|
+
] as $u
|
|
78
|
+
| ($u[($t - 1)] // ($u[-1] // ""))
|
|
79
|
+
' "$transcript_path" 2>/dev/null) || prompt=""
|
|
80
|
+
[[ -n "$prompt" ]] && via="transcript"
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
# Cap + scrub for display (historian bodies are already redacted; this also
|
|
84
|
+
# scrubs the transcript path and keeps the excerpt short).
|
|
85
|
+
prompt=$(printf '%s' "$prompt" | lineage_redact 1000 true)
|
|
86
|
+
|
|
87
|
+
jq -nc --arg p "$prompt" --arg v "$via" '{prompt: $p, resolved_via: $v}'
|
|
88
|
+
}
|
|
@@ -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
|
+
}
|