@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,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"
|
|
@@ -0,0 +1,73 @@
|
|
|
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/lineage"
|
|
8
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
9
|
+
# shellcheck disable=SC1091
|
|
10
|
+
source "${PLUGIN_ROOT}/scripts/lib/lineage-config.sh"
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@test "lineage is disabled by default" {
|
|
14
|
+
lineage_config_load ""
|
|
15
|
+
run lineage_config_enabled
|
|
16
|
+
[ "$status" -ne 0 ]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@test "user-level settings.json can enable lineage" {
|
|
20
|
+
mkdir -p "${HOME}/.claude"
|
|
21
|
+
printf '%s\n' '{"lineage":{"enabled":true}}' > "${HOME}/.claude/settings.json"
|
|
22
|
+
lineage_config_load ""
|
|
23
|
+
run lineage_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' '{"lineage":{"enabled":true}}' > "${HOME}/.claude/settings.json"
|
|
30
|
+
local repo="${BATS_TEST_TMPDIR}/repo"
|
|
31
|
+
mkdir -p "${repo}/.claude"
|
|
32
|
+
printf '%s\n' '{"lineage":{"enabled":false}}' > "${repo}/.claude/settings.json"
|
|
33
|
+
lineage_config_load "$repo"
|
|
34
|
+
run lineage_config_enabled
|
|
35
|
+
[ "$status" -ne 0 ]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@test "default max_snippet_chars is 4000" {
|
|
39
|
+
lineage_config_load ""
|
|
40
|
+
[ "$(lineage_config_max_snippet_chars)" = "4000" ]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@test "max_snippet_chars is configurable" {
|
|
44
|
+
mkdir -p "${HOME}/.claude"
|
|
45
|
+
printf '%s\n' '{"lineage":{"max_snippet_chars":256}}' > "${HOME}/.claude/settings.json"
|
|
46
|
+
lineage_config_load ""
|
|
47
|
+
[ "$(lineage_config_max_snippet_chars)" = "256" ]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@test "redaction is on by default and can be disabled with an explicit false" {
|
|
51
|
+
lineage_config_load ""
|
|
52
|
+
run lineage_config_redact_enabled
|
|
53
|
+
[ "$status" -eq 0 ]
|
|
54
|
+
|
|
55
|
+
mkdir -p "${HOME}/.claude"
|
|
56
|
+
printf '%s\n' '{"lineage":{"redact_secrets":false}}' > "${HOME}/.claude/settings.json"
|
|
57
|
+
lineage_config_load ""
|
|
58
|
+
run lineage_config_redact_enabled
|
|
59
|
+
[ "$status" -ne 0 ]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@test "default prompt_source is historian_then_transcript" {
|
|
63
|
+
lineage_config_load ""
|
|
64
|
+
[ "$(lineage_config_prompt_source)" = "historian_then_transcript" ]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@test "ignore_globs are exposed one per line" {
|
|
68
|
+
lineage_config_load ""
|
|
69
|
+
run lineage_config_ignore_globs
|
|
70
|
+
[ "$status" -eq 0 ]
|
|
71
|
+
[[ "$output" == *"node_modules"* ]]
|
|
72
|
+
[[ "$output" == *".lock"* ]]
|
|
73
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# Validates that lineage.* 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/lineage"
|
|
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
|
+
export _ONLOOKER_EVENT_JS="${REPO_ROOT}/scripts/lib/onlooker-event.mjs"
|
|
14
|
+
|
|
15
|
+
# shellcheck disable=SC1091
|
|
16
|
+
source "${PLUGIN_ROOT}/scripts/lib/lineage-events.sh"
|
|
17
|
+
|
|
18
|
+
export CLAUDE_SESSION_ID="bats-lineage-session-$$"
|
|
19
|
+
PK="proj0123abcd"
|
|
20
|
+
SID="bats-lineage-sid-000"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_validate_latest_event() {
|
|
24
|
+
local last
|
|
25
|
+
last=$(tail -n 1 "$ONLOOKER_EVENTS_LOG")
|
|
26
|
+
[ -n "$last" ] || return 1
|
|
27
|
+
printf '%s' "$last" | ONLOOKER_DIR="$ONLOOKER_DIR" \
|
|
28
|
+
node "${REPO_ROOT}/scripts/lib/onlooker-event.mjs" validate >/dev/null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@test "lineage.change.recorded validates (full payload)" {
|
|
32
|
+
local p
|
|
33
|
+
p=$(jq -n --arg pk "$PK" --arg sid "$SID" \
|
|
34
|
+
'{project_key:$pk, session_id:$sid, file_path:"src/main.ts", tool:"Edit",
|
|
35
|
+
operation:"edit", change_id:"01JLNG0000000000000000CHG1", turn:4,
|
|
36
|
+
tool_use_id:"toolu_1", lines_added:3, lines_removed:1, bytes:142,
|
|
37
|
+
edit_count:1, content_sha256:"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}')
|
|
38
|
+
lineage_emit_event "lineage.change.recorded" "$p" "$SID"
|
|
39
|
+
run _validate_latest_event
|
|
40
|
+
[ "$status" -eq 0 ]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@test "lineage.change.recorded validates (minimal Write)" {
|
|
44
|
+
local p
|
|
45
|
+
p=$(jq -n --arg pk "$PK" --arg sid "$SID" \
|
|
46
|
+
'{project_key:$pk, session_id:$sid, file_path:"README.md", tool:"Write", operation:"create"}')
|
|
47
|
+
lineage_emit_event "lineage.change.recorded" "$p" "$SID"
|
|
48
|
+
run _validate_latest_event
|
|
49
|
+
[ "$status" -eq 0 ]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@test "lineage.query.answered validates" {
|
|
53
|
+
local p
|
|
54
|
+
p=$(jq -n --arg pk "$PK" \
|
|
55
|
+
'{project_key:$pk, file_path:"src/main.ts", matches:2, query:"src/main.ts:42", line:42, resolved_via:"historian"}')
|
|
56
|
+
lineage_emit_event "lineage.query.answered" "$p" "$SID"
|
|
57
|
+
run _validate_latest_event
|
|
58
|
+
[ "$status" -eq 0 ]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@test "lineage.query.answered validates with no matches" {
|
|
62
|
+
local p
|
|
63
|
+
p=$(jq -n --arg pk "$PK" \
|
|
64
|
+
'{project_key:$pk, file_path:"src/gone.ts", matches:0, resolved_via:"none"}')
|
|
65
|
+
lineage_emit_event "lineage.query.answered" "$p" "$SID"
|
|
66
|
+
run _validate_latest_event
|
|
67
|
+
[ "$status" -eq 0 ]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@test "an invalid tool enum is rejected by the schema" {
|
|
71
|
+
local p
|
|
72
|
+
p=$(jq -n --arg pk "$PK" --arg sid "$SID" \
|
|
73
|
+
'{project_key:$pk, session_id:$sid, file_path:"x", tool:"NotebookEdit", operation:"edit"}')
|
|
74
|
+
run lineage_emit_event "lineage.change.recorded" "$p" "$SID"
|
|
75
|
+
[ "$status" -ne 0 ]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@test "lineage_emit_event returns nonzero for an unknown event type" {
|
|
79
|
+
run lineage_emit_event "lineage.no_such_event" '{"project_key":"x"}' "$SID"
|
|
80
|
+
[ "$status" -ne 0 ]
|
|
81
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# Exercises the PostToolUse hook end-to-end against an isolated $ONLOOKER_DIR.
|
|
4
|
+
|
|
5
|
+
setup() {
|
|
6
|
+
source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
|
|
7
|
+
setup_test_env
|
|
8
|
+
|
|
9
|
+
PLUGIN_ROOT="${REPO_ROOT}/plugins/lineage"
|
|
10
|
+
HOOK="${PLUGIN_ROOT}/scripts/hooks/lineage-post-tool-use.sh"
|
|
11
|
+
export _ONLOOKER_EVENT_JS="${REPO_ROOT}/scripts/lib/onlooker-event.mjs"
|
|
12
|
+
export ONLOOKER_EVENTS_LOG="${ONLOOKER_DIR}/logs/onlooker-events.jsonl"
|
|
13
|
+
mkdir -p "$(dirname "$ONLOOKER_EVENTS_LOG")" "${ONLOOKER_DIR}/session-trackers"
|
|
14
|
+
|
|
15
|
+
# shellcheck disable=SC1091
|
|
16
|
+
source "${PLUGIN_ROOT}/scripts/lib/lineage-project-key.sh"
|
|
17
|
+
|
|
18
|
+
REPO="${BATS_TEST_TMPDIR}/proj"
|
|
19
|
+
mkdir -p "$REPO"
|
|
20
|
+
git init -q "$REPO" 2>/dev/null
|
|
21
|
+
git -C "$REPO" remote add origin https://example.com/onlooker/lineage-test.git 2>/dev/null
|
|
22
|
+
KEY=$(lineage_project_key "$REPO")
|
|
23
|
+
SID="bats-lin-001"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_enable() {
|
|
27
|
+
mkdir -p "${HOME}/.claude"
|
|
28
|
+
printf '%s\n' '{"lineage":{"enabled":true}}' > "${HOME}/.claude/settings.json"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_ledger() { printf '%s/lineage/%s/changes.jsonl' "$ONLOOKER_DIR" "$KEY"; }
|
|
32
|
+
|
|
33
|
+
# _run <tool> <file_path> <tool_input_json>
|
|
34
|
+
_run() {
|
|
35
|
+
local tool="$1" file="$2" ti="$3"
|
|
36
|
+
jq -nc --arg sid "$SID" --arg cwd "$REPO" --arg tool "$tool" --argjson ti "$ti" \
|
|
37
|
+
'{session_id:$sid, cwd:$cwd, tool_name:$tool, tool_use_id:"toolu_x", transcript_path:"", tool_input:$ti}' \
|
|
38
|
+
> "${BATS_TEST_TMPDIR}/in.json"
|
|
39
|
+
run bash "$HOOK" < "${BATS_TEST_TMPDIR}/in.json"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@test "records an Edit change into the project ledger" {
|
|
43
|
+
_enable
|
|
44
|
+
_run Edit "${REPO}/foo.py" "$(jq -nc --arg f "${REPO}/foo.py" '{file_path:$f, old_string:"x = 1", new_string:"x = 2"}')"
|
|
45
|
+
[ "$status" -eq 0 ]
|
|
46
|
+
[ -f "$(_ledger)" ]
|
|
47
|
+
[ "$(jq -rs '.[0].file_path' "$(_ledger)")" = "${REPO}/foo.py" ]
|
|
48
|
+
[ "$(jq -rs '.[0].tool' "$(_ledger)")" = "Edit" ]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@test "records a single-file MultiEdit (top-level file_path)" {
|
|
52
|
+
_enable
|
|
53
|
+
_run MultiEdit "${REPO}/foo.py" "$(jq -nc --arg f "${REPO}/foo.py" \
|
|
54
|
+
'{file_path:$f, edits:[{old_string:"a", new_string:"a1"},{old_string:"b", new_string:"b1"}]}')"
|
|
55
|
+
[ "$status" -eq 0 ]
|
|
56
|
+
[ -f "$(_ledger)" ]
|
|
57
|
+
[ "$(jq -rs '.[0].file_path' "$(_ledger)")" = "${REPO}/foo.py" ]
|
|
58
|
+
[ "$(jq -rs '.[0].tool' "$(_ledger)")" = "MultiEdit" ]
|
|
59
|
+
[ "$(jq -rs '.[0].operation' "$(_ledger)")" = "multi_edit" ]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@test "skips a MultiEdit whose edits span multiple distinct files" {
|
|
63
|
+
_enable
|
|
64
|
+
# Hypothetical per-edit-file_path shape spanning two files — skip to avoid misattribution.
|
|
65
|
+
local ti
|
|
66
|
+
ti=$(jq -nc --arg a "${REPO}/a.py" --arg b "${REPO}/b.py" \
|
|
67
|
+
'{edits:[{file_path:$a, old_string:"x", new_string:"x1"},{file_path:$b, old_string:"y", new_string:"y1"}]}')
|
|
68
|
+
_run MultiEdit "${REPO}/a.py" "$ti"
|
|
69
|
+
[ "$status" -eq 0 ]
|
|
70
|
+
[ ! -f "$(_ledger)" ]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@test "records the turn number from the session tracker" {
|
|
74
|
+
_enable
|
|
75
|
+
printf '%s' '{"turn_number":7}' > "${ONLOOKER_DIR}/session-trackers/${SID}"
|
|
76
|
+
_run Write "${REPO}/bar.py" "$(jq -nc --arg f "${REPO}/bar.py" '{file_path:$f, content:"print(1)"}')"
|
|
77
|
+
[ "$(jq -rs '.[0].turn' "$(_ledger)")" = "7" ]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@test "writes nothing when lineage is disabled" {
|
|
81
|
+
_run Edit "${REPO}/foo.py" "$(jq -nc --arg f "${REPO}/foo.py" '{file_path:$f, old_string:"a", new_string:"b"}')"
|
|
82
|
+
[ "$status" -eq 0 ]
|
|
83
|
+
[ ! -d "${ONLOOKER_DIR}/lineage" ]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@test "skips paths matching ignore_globs" {
|
|
87
|
+
_enable
|
|
88
|
+
mkdir -p "${REPO}/node_modules"
|
|
89
|
+
_run Write "${REPO}/node_modules/x.js" "$(jq -nc --arg f "${REPO}/node_modules/x.js" '{file_path:$f, content:"y"}')"
|
|
90
|
+
[ "$status" -eq 0 ]
|
|
91
|
+
[ ! -f "$(_ledger)" ]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@test "skips files outside the repo" {
|
|
95
|
+
_enable
|
|
96
|
+
mkdir -p "${BATS_TEST_TMPDIR}/outside"
|
|
97
|
+
local f="${BATS_TEST_TMPDIR}/outside/x.js"
|
|
98
|
+
_run Write "$f" "$(jq -nc --arg f "$f" '{file_path:$f, content:"y"}')"
|
|
99
|
+
[ "$status" -eq 0 ]
|
|
100
|
+
[ ! -f "$(_ledger)" ]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@test "emits lineage.change.recorded" {
|
|
104
|
+
_enable
|
|
105
|
+
_run Edit "${REPO}/foo.py" "$(jq -nc --arg f "${REPO}/foo.py" '{file_path:$f, old_string:"a", new_string:"b"}')"
|
|
106
|
+
run grep -c '"event_type":"lineage.change.recorded"' "$ONLOOKER_EVENTS_LOG"
|
|
107
|
+
[ "$output" -ge 1 ]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@test "a distinct subagent session_id is recorded as-is" {
|
|
111
|
+
_enable
|
|
112
|
+
SID="bats-subagent-999"
|
|
113
|
+
_run Edit "${REPO}/foo.py" "$(jq -nc --arg f "${REPO}/foo.py" '{file_path:$f, old_string:"a", new_string:"b"}')"
|
|
114
|
+
[ "$(jq -rs '.[0].session_id' "$(_ledger)")" = "bats-subagent-999" ]
|
|
115
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
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/lineage"
|
|
8
|
+
# shellcheck disable=SC1091
|
|
9
|
+
source "${PLUGIN_ROOT}/scripts/lib/lineage-project-key.sh"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
_mk_repo_with_remote() {
|
|
13
|
+
local dir="$1" url="$2"
|
|
14
|
+
mkdir -p "$dir"
|
|
15
|
+
git init -q "$dir" 2>/dev/null
|
|
16
|
+
git -C "$dir" remote add origin "$url" 2>/dev/null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@test "key is a 12-char hex string for a repo with a remote" {
|
|
20
|
+
local repo="${BATS_TEST_TMPDIR}/repo-a"
|
|
21
|
+
_mk_repo_with_remote "$repo" "https://example.com/onlooker/a.git"
|
|
22
|
+
run lineage_project_key "$repo"
|
|
23
|
+
[ "$status" -eq 0 ]
|
|
24
|
+
[ "${#output}" -eq 12 ]
|
|
25
|
+
[[ "$output" =~ ^[0-9a-f]{12}$ ]]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@test "same cwd yields a stable key" {
|
|
29
|
+
local repo="${BATS_TEST_TMPDIR}/repo-b"
|
|
30
|
+
_mk_repo_with_remote "$repo" "https://example.com/onlooker/b.git"
|
|
31
|
+
local a b
|
|
32
|
+
a=$(lineage_project_key "$repo")
|
|
33
|
+
b=$(lineage_project_key "$repo")
|
|
34
|
+
[ -n "$a" ]
|
|
35
|
+
[ "$a" = "$b" ]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@test "different remotes yield different keys" {
|
|
39
|
+
local r1="${BATS_TEST_TMPDIR}/repo-c" r2="${BATS_TEST_TMPDIR}/repo-d"
|
|
40
|
+
_mk_repo_with_remote "$r1" "https://example.com/onlooker/c.git"
|
|
41
|
+
_mk_repo_with_remote "$r2" "https://example.com/onlooker/d.git"
|
|
42
|
+
[ "$(lineage_project_key "$r1")" != "$(lineage_project_key "$r2")" ]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@test "empty key for a non-git directory" {
|
|
46
|
+
local plain="${BATS_TEST_TMPDIR}/not-a-repo"
|
|
47
|
+
mkdir -p "$plain"
|
|
48
|
+
run lineage_project_key "$plain"
|
|
49
|
+
[ "$status" -eq 0 ]
|
|
50
|
+
[ -z "$output" ]
|
|
51
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
setup() {
|
|
4
|
+
source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
|
|
5
|
+
setup_test_env
|
|
6
|
+
PLUGIN_ROOT="${REPO_ROOT}/plugins/lineage"
|
|
7
|
+
# shellcheck disable=SC1091
|
|
8
|
+
source "${PLUGIN_ROOT}/scripts/lib/portable-lock.sh"
|
|
9
|
+
# shellcheck disable=SC1091
|
|
10
|
+
source "${PLUGIN_ROOT}/scripts/lib/lineage-redact.sh"
|
|
11
|
+
# shellcheck disable=SC1091
|
|
12
|
+
source "${PLUGIN_ROOT}/scripts/lib/lineage-record.sh"
|
|
13
|
+
# shellcheck disable=SC1091
|
|
14
|
+
source "${PLUGIN_ROOT}/scripts/lib/lineage-query.sh"
|
|
15
|
+
|
|
16
|
+
KEY="projqueryabc"
|
|
17
|
+
FILE="src/a.py"
|
|
18
|
+
# Two changes to the same file, appended oldest-first.
|
|
19
|
+
local dir
|
|
20
|
+
dir=$(lineage_record_dir "$KEY")
|
|
21
|
+
mkdir -p "$dir"
|
|
22
|
+
{
|
|
23
|
+
jq -nc '{change_id:"C1", ts:"2026-06-12T00:00:01Z", ts_epoch:1, session_id:"s1", turn:2, tool:"Edit", operation:"edit", file_path:"src/a.py", added_snippets:["def foo():"], transcript_path:""}'
|
|
24
|
+
jq -nc '{change_id:"C2", ts:"2026-06-12T00:00:02Z", ts_epoch:2, session_id:"s1", turn:5, tool:"Edit", operation:"edit", file_path:"src/a.py", added_snippets:[" return 42"], transcript_path:""}'
|
|
25
|
+
} > "${dir}/changes.jsonl"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@test "changes_for_file returns records newest-first" {
|
|
29
|
+
run lineage_changes_for_file "$KEY" "$FILE"
|
|
30
|
+
[ "$status" -eq 0 ]
|
|
31
|
+
local first
|
|
32
|
+
first=$(printf '%s\n' "$output" | head -1)
|
|
33
|
+
[ "$(jq -r '.change_id' <<<"$first")" = "C2" ]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@test "match_line content-anchors a line to the change that introduced it" {
|
|
37
|
+
local rec
|
|
38
|
+
rec=$(lineage_match_line "$KEY" "$FILE" "return 42")
|
|
39
|
+
[ "$(jq -r '.change_id' <<<"$rec")" = "C2" ]
|
|
40
|
+
|
|
41
|
+
rec=$(lineage_match_line "$KEY" "$FILE" "def foo")
|
|
42
|
+
[ "$(jq -r '.change_id' <<<"$rec")" = "C1" ]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@test "match_line returns nothing for content no change introduced" {
|
|
46
|
+
run lineage_match_line "$KEY" "$FILE" "nonexistent content"
|
|
47
|
+
[ "$status" -eq 0 ]
|
|
48
|
+
[ -z "$output" ]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@test "match_line ignores an empty needle" {
|
|
52
|
+
run lineage_match_line "$KEY" "$FILE" " "
|
|
53
|
+
[ -z "$output" ]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@test "resolve_prompt reads historian's chunk for the turn range" {
|
|
57
|
+
local hist="${ONLOOKER_DIR}/historian/${KEY}/sessions"
|
|
58
|
+
mkdir -p "$hist"
|
|
59
|
+
printf '%s\n' '{"session_id":"s1","start_turn_index":1,"end_turn_index":6,"body_redacted":"user: write the foo function\n\nassistant: done"}' \
|
|
60
|
+
> "${hist}/s1.jsonl"
|
|
61
|
+
run lineage_resolve_prompt "$KEY" "s1" "5" "" "historian_then_transcript"
|
|
62
|
+
[ "$status" -eq 0 ]
|
|
63
|
+
[ "$(jq -r '.resolved_via' <<<"$output")" = "historian" ]
|
|
64
|
+
[[ "$(jq -r '.prompt' <<<"$output")" == *"write the foo function"* ]]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@test "resolve_prompt falls back to the transcript when historian has nothing" {
|
|
68
|
+
local tp="${BATS_TEST_TMPDIR}/transcript.jsonl"
|
|
69
|
+
{
|
|
70
|
+
printf '%s\n' '{"role":"user","content":"first prompt"}'
|
|
71
|
+
printf '%s\n' '{"role":"assistant","content":"ok"}'
|
|
72
|
+
printf '%s\n' '{"role":"user","content":"second prompt about bar"}'
|
|
73
|
+
} > "$tp"
|
|
74
|
+
run lineage_resolve_prompt "$KEY" "s2" "2" "$tp" "historian_then_transcript"
|
|
75
|
+
[ "$status" -eq 0 ]
|
|
76
|
+
[ "$(jq -r '.resolved_via' <<<"$output")" = "transcript" ]
|
|
77
|
+
[[ "$(jq -r '.prompt' <<<"$output")" == *"second prompt about bar"* ]]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@test "resolve_prompt reports none when neither source is available" {
|
|
81
|
+
run lineage_resolve_prompt "$KEY" "s3" "1" "" "historian_then_transcript"
|
|
82
|
+
[ "$status" -eq 0 ]
|
|
83
|
+
[ "$(jq -r '.resolved_via' <<<"$output")" = "none" ]
|
|
84
|
+
[ "$(jq -r '.prompt' <<<"$output")" = "" ]
|
|
85
|
+
}
|