@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.
Files changed (34) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.release-please-manifest.json +4 -3
  4. package/CHANGELOG.md +14 -0
  5. package/CLAUDE.md +2 -0
  6. package/docs/architecture.md +4 -0
  7. package/package.json +3 -3
  8. package/plugins/compass/.claude-plugin/plugin.json +1 -1
  9. package/plugins/compass/CHANGELOG.md +7 -0
  10. package/plugins/compass/scripts/hooks/compass-pre-tool-use.sh +9 -4
  11. package/plugins/lineage/.claude-plugin/plugin.json +14 -0
  12. package/plugins/lineage/CHANGELOG.md +9 -0
  13. package/plugins/lineage/README.md +133 -0
  14. package/plugins/lineage/config.json +11 -0
  15. package/plugins/lineage/hooks/hooks.json +33 -0
  16. package/plugins/lineage/scripts/hooks/lineage-post-tool-use.sh +132 -0
  17. package/plugins/lineage/scripts/lib/lineage-config.sh +100 -0
  18. package/plugins/lineage/scripts/lib/lineage-events.sh +81 -0
  19. package/plugins/lineage/scripts/lib/lineage-project-key.sh +85 -0
  20. package/plugins/lineage/scripts/lib/lineage-query.sh +88 -0
  21. package/plugins/lineage/scripts/lib/lineage-record.sh +132 -0
  22. package/plugins/lineage/scripts/lib/lineage-redact.sh +51 -0
  23. package/plugins/lineage/scripts/lib/lineage-ulid.sh +53 -0
  24. package/plugins/lineage/scripts/lib/portable-lock.sh +59 -0
  25. package/plugins/lineage/skills/lineage/SKILL.md +165 -0
  26. package/release-please-config.json +16 -0
  27. package/test/bats/lineage-config.bats +73 -0
  28. package/test/bats/lineage-events.bats +81 -0
  29. package/test/bats/lineage-post-tool-use.bats +115 -0
  30. package/test/bats/lineage-project-key.bats +51 -0
  31. package/test/bats/lineage-query.bats +85 -0
  32. package/test/bats/lineage-record.bats +79 -0
  33. package/test/bats/lineage-redact.bats +63 -0
  34. 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
+ }