@onlooker-community/ecosystem 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.release-please-manifest.json +2 -2
  4. package/CHANGELOG.md +14 -0
  5. package/CLAUDE.md +2 -0
  6. package/docs/plugin-catalog.md +125 -0
  7. package/package.json +3 -3
  8. package/plugins/compass/.claude-plugin/plugin.json +1 -1
  9. package/plugins/compass/CHANGELOG.md +14 -0
  10. package/plugins/compass/README.md +1 -3
  11. package/plugins/compass/config.json +1 -2
  12. package/plugins/compass/docs/design.md +1 -2
  13. package/plugins/compass/scripts/hooks/compass-bash-gate.sh +8 -1
  14. package/plugins/compass/scripts/hooks/compass-pre-tool-use.sh +17 -5
  15. package/plugins/compass/scripts/hooks/compass-record-write.sh +5 -0
  16. package/plugins/compass/scripts/hooks/compass-session-start.sh +0 -8
  17. package/plugins/compass/scripts/lib/compass-evaluator.sh +58 -98
  18. package/plugins/compass/scripts/lib/compass-gate.sh +15 -18
  19. package/plugins/compass/scripts/lib/compass-sanitizer.sh +4 -4
  20. package/plugins/compass/scripts/lib/compass-transcript.sh +79 -112
  21. package/plugins/inspector/.claude-plugin/plugin.json +14 -0
  22. package/plugins/inspector/README.md +155 -0
  23. package/plugins/inspector/config.json +25 -0
  24. package/plugins/inspector/docs/design.md +286 -0
  25. package/plugins/inspector/hooks/hooks.json +33 -0
  26. package/plugins/inspector/scripts/hooks/inspector-post-write.sh +124 -0
  27. package/plugins/inspector/scripts/lib/inspector-config.sh +108 -0
  28. package/plugins/inspector/scripts/lib/inspector-events.sh +82 -0
  29. package/plugins/inspector/scripts/lib/inspector-project-key.sh +55 -0
  30. package/plugins/inspector/scripts/lib/inspector-run.sh +305 -0
  31. package/plugins/inspector/scripts/lib/inspector-ulid.sh +45 -0
  32. package/test/bats/archivist-project-key.bats +79 -0
  33. package/test/bats/archivist-storage.bats +79 -0
  34. package/test/bats/compact-tracker.bats +125 -0
  35. package/test/bats/compass-config.bats +65 -0
  36. package/test/bats/compass-gate.bats +129 -0
  37. package/test/bats/compass-sanitizer.bats +69 -0
  38. package/test/bats/compass-symbolic-skip.bats +88 -0
  39. package/test/bats/compass-transcript.bats +80 -0
  40. package/test/bats/inspector-config.bats +118 -0
  41. package/test/bats/inspector-events.bats +156 -0
  42. package/test/bats/inspector-post-write-hook.bats +164 -0
  43. package/test/bats/inspector-project-key.bats +68 -0
  44. package/test/bats/inspector-ulid.bats +34 -0
  45. package/test/bats/onlooker-schema.bats +111 -0
  46. package/test/bats/prompt-rules.bats +98 -0
  47. package/test/bats/session-tracker.bats +260 -0
  48. package/test/bats/skill-usage-tracker.bats +63 -0
  49. package/test/bats/task-tracker.bats +102 -0
  50. package/test/bats/turn-tracker.bats +180 -0
  51. package/test/bats/validate-path.bats +125 -0
  52. package/test/bats/worktree-tracker.bats +167 -0
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ # shellcheck source=../helpers/setup.bash
5
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
6
+ load_validate_path
7
+ export CLAUDE_PLUGIN_ROOT="${REPO_ROOT}"
8
+ # shellcheck source=../../scripts/lib/onlooker-schema.sh
9
+ source "${REPO_ROOT}/scripts/lib/onlooker-schema.sh"
10
+ # shellcheck source=../../scripts/lib/tool-history.sh
11
+ source "${REPO_ROOT}/scripts/lib/tool-history.sh"
12
+ # shellcheck source=../../scripts/lib/session-tracker.sh
13
+ source "${REPO_ROOT}/scripts/lib/session-tracker.sh"
14
+ # shellcheck source=../../scripts/lib/turn-tracker.sh
15
+ source "${REPO_ROOT}/scripts/lib/turn-tracker.sh"
16
+ }
17
+
18
+ # ── turn_tracker_summarize_prompt ──────────────────────────────────────────
19
+
20
+ @test "turn_tracker_summarize_prompt collapses newlines and runs of spaces" {
21
+ local out
22
+ out=$(turn_tracker_summarize_prompt $'hello\n\nworld foo')
23
+ [ "$out" = "hello world foo" ]
24
+ }
25
+
26
+ @test "turn_tracker_summarize_prompt trims leading and trailing spaces" {
27
+ local out
28
+ out=$(turn_tracker_summarize_prompt " padded text ")
29
+ [ "$out" = "padded text" ]
30
+ }
31
+
32
+ @test "turn_tracker_summarize_prompt leaves a short prompt unchanged" {
33
+ local out
34
+ out=$(turn_tracker_summarize_prompt "fix the bug")
35
+ [ "$out" = "fix the bug" ]
36
+ }
37
+
38
+ @test "turn_tracker_summarize_prompt does not truncate input of exactly 200 chars" {
39
+ local exact
40
+ exact=$(printf 'b%.0s' {1..200})
41
+ local out
42
+ out=$(turn_tracker_summarize_prompt "$exact")
43
+ [ "${#out}" -eq 200 ]
44
+ [[ "$out" != *…* ]]
45
+ }
46
+
47
+ @test "turn_tracker_summarize_prompt truncates to 200 chars plus an ellipsis when over 200" {
48
+ local long
49
+ long=$(printf 'a%.0s' {1..300})
50
+ local out
51
+ out=$(turn_tracker_summarize_prompt "$long")
52
+ # 200 retained characters + the single-character ellipsis = length 201.
53
+ [ "${#out}" -eq 201 ]
54
+ [[ "$out" == *… ]]
55
+ [ "${out%…}" = "$(printf 'a%.0s' {1..200})" ]
56
+ }
57
+
58
+ @test "turn_tracker_summarize_prompt returns empty and succeeds on empty input" {
59
+ run turn_tracker_summarize_prompt ""
60
+ [ "$status" -eq 0 ]
61
+ [ -z "$output" ]
62
+ }
63
+
64
+ # ── turn_tracker_on_user_prompt ────────────────────────────────────────────
65
+
66
+ @test "turn_tracker_on_user_prompt keeps turn 1 and marks prompts seen on first prompt" {
67
+ local sid="turn-first-001"
68
+ turn_tracker_on_user_prompt "$sid"
69
+
70
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}"
71
+ [ -f "$tracker" ]
72
+ jq -e '.turn_number == 1
73
+ and .user_prompts_seen == true
74
+ and .turn_tool_seq == 0' "$tracker" >/dev/null
75
+ }
76
+
77
+ @test "turn_tracker_on_user_prompt advances the turn on the second prompt" {
78
+ local sid="turn-second-001"
79
+ turn_tracker_on_user_prompt "$sid"
80
+ turn_tracker_on_user_prompt "$sid"
81
+
82
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}"
83
+ jq -e '.turn_number == 2
84
+ and .user_prompts_seen == true
85
+ and .turn_tool_seq == 0' "$tracker" >/dev/null
86
+ }
87
+
88
+ @test "turn_tracker_on_user_prompt increments once per subsequent prompt" {
89
+ local sid="turn-many-001"
90
+ turn_tracker_on_user_prompt "$sid" # turn 1, marks seen
91
+ turn_tracker_on_user_prompt "$sid" # turn 2
92
+ turn_tracker_on_user_prompt "$sid" # turn 3
93
+ turn_tracker_on_user_prompt "$sid" # turn 4
94
+
95
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}"
96
+ jq -e '.turn_number == 4' "$tracker" >/dev/null
97
+ }
98
+
99
+ @test "turn_tracker_on_user_prompt no-ops on empty session_id" {
100
+ run turn_tracker_on_user_prompt ""
101
+ [ "$status" -eq 0 ]
102
+ # No tracker file should have been created for an empty id.
103
+ [ ! -f "${ONLOOKER_SESSION_TRACKERS_DIR}/" ]
104
+ [ -z "$(ls -A "${ONLOOKER_SESSION_TRACKERS_DIR}")" ]
105
+ }
106
+
107
+ @test "turn_tracker_on_user_prompt no-ops on null session_id" {
108
+ run turn_tracker_on_user_prompt "null"
109
+ [ "$status" -eq 0 ]
110
+ [ ! -f "${ONLOOKER_SESSION_TRACKERS_DIR}/null" ]
111
+ }
112
+
113
+ # ── turn_tracker_build_prompt_payload ──────────────────────────────────────
114
+
115
+ @test "turn_tracker_build_prompt_payload reads turn_number from the tracker and includes input_summary" {
116
+ local sid="payload-001"
117
+ turn_state_ensure_session "$sid"
118
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}"
119
+ jq '.turn_number = 5' "$tracker" >"${tracker}.tmp"
120
+ mv "${tracker}.tmp" "$tracker"
121
+
122
+ local payload
123
+ payload=$(turn_tracker_build_prompt_payload "$sid" $'review the\n\ndiff')
124
+ echo "$payload" | jq -e '.turn_number == 5
125
+ and .input_summary == "review the diff"' >/dev/null
126
+ }
127
+
128
+ @test "turn_tracker_build_prompt_payload defaults turn_number to 1 when tracker is missing" {
129
+ local sid="payload-missing"
130
+ rm -f "${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}"
131
+
132
+ local payload
133
+ payload=$(turn_tracker_build_prompt_payload "$sid" "hello")
134
+ echo "$payload" | jq -e '.turn_number == 1 and .input_summary == "hello"' >/dev/null
135
+ }
136
+
137
+ @test "turn_tracker_build_prompt_payload omits input_summary for an empty prompt" {
138
+ local sid="payload-empty"
139
+ turn_state_ensure_session "$sid"
140
+
141
+ local payload
142
+ payload=$(turn_tracker_build_prompt_payload "$sid" "")
143
+ echo "$payload" | jq -e '.turn_number == 1 and (has("input_summary") | not)' >/dev/null
144
+ }
145
+
146
+ @test "turn_tracker_build_prompt_payload truncates a long prompt in input_summary" {
147
+ local sid="payload-long"
148
+ turn_state_ensure_session "$sid"
149
+ local long
150
+ long=$(printf 'x%.0s' {1..300})
151
+
152
+ local payload summary
153
+ payload=$(turn_tracker_build_prompt_payload "$sid" "$long")
154
+ summary=$(echo "$payload" | jq -r '.input_summary')
155
+ [ "${#summary}" -eq 201 ]
156
+ [[ "$summary" == *… ]]
157
+ }
158
+
159
+ @test "turn_tracker_build_prompt_payload returns 1 for null session_id" {
160
+ run turn_tracker_build_prompt_payload "null" "hi"
161
+ [ "$status" -eq 1 ]
162
+ }
163
+
164
+ @test "turn_tracker_build_prompt_payload returns 1 for empty session_id" {
165
+ run turn_tracker_build_prompt_payload "" "hi"
166
+ [ "$status" -eq 1 ]
167
+ }
168
+
169
+ # ── integration: orchestrator + payload reflect the same turn ──────────────
170
+
171
+ @test "turn_tracker payload reflects turn advanced by turn_tracker_on_user_prompt" {
172
+ local sid="turn-integration-001"
173
+ # Two prompts -> turn 2; payload built afterward should report turn 2.
174
+ turn_tracker_on_user_prompt "$sid"
175
+ turn_tracker_on_user_prompt "$sid"
176
+
177
+ local payload
178
+ payload=$(turn_tracker_build_prompt_payload "$sid" "second prompt")
179
+ echo "$payload" | jq -e '.turn_number == 2 and .input_summary == "second prompt"' >/dev/null
180
+ }
@@ -107,3 +107,128 @@ setup() {
107
107
  and .schema_version == "1.0"' \
108
108
  >/dev/null
109
109
  }
110
+
111
+ # ----------------------------------------------------------------------------
112
+ # Hook health instrumentation: hook_register / hook_success / hook_failure
113
+ # ----------------------------------------------------------------------------
114
+
115
+ @test "hook_register seeds hook name and start time" {
116
+ hook_register "my-hook" "My Hook" "A description"
117
+ trap - EXIT # disarm the trap hook_register installed so it can't fire later
118
+ [ "${_HOOK_NAME}" = "my-hook" ]
119
+ [ -n "${_HOOK_START_TIME}" ]
120
+ }
121
+
122
+ @test "hook_success writes a success record to the hook-health log" {
123
+ export _HOOK_SESSION_ID="health-success-session"
124
+ hook_register "success-hook"
125
+ hook_success
126
+ [ -f "$ONLOOKER_HOOK_HEALTH_LOG" ]
127
+ tail -n 1 "$ONLOOKER_HOOK_HEALTH_LOG" | jq -e \
128
+ '.hook == "success-hook"
129
+ and .status == "success"
130
+ and .error == null
131
+ and .session_id == "health-success-session"' \
132
+ >/dev/null
133
+ }
134
+
135
+ @test "hook_failure writes a failure record with the error message" {
136
+ hook_register "failure-hook"
137
+ hook_failure "boom: it broke"
138
+ [ -f "$ONLOOKER_HOOK_HEALTH_LOG" ]
139
+ tail -n 1 "$ONLOOKER_HOOK_HEALTH_LOG" | jq -e \
140
+ '.hook == "failure-hook"
141
+ and .status == "failure"
142
+ and .error == "boom: it broke"' \
143
+ >/dev/null
144
+ }
145
+
146
+ @test "hook_health_summary reflects seeded success and failure records" {
147
+ # Two records for the same hook: one success, one failure.
148
+ hook_register "summary-hook"
149
+ hook_success
150
+ hook_register "summary-hook"
151
+ hook_failure "an error"
152
+
153
+ local summary
154
+ summary=$(hook_health_summary 24)
155
+ echo "$summary" | jq -e \
156
+ 'map(select(.hook == "summary-hook"))
157
+ | .[0]
158
+ | .total == 2
159
+ and .success == 1
160
+ and .failure == 1
161
+ and .last_error == "an error"' \
162
+ >/dev/null
163
+ }
164
+
165
+ # ----------------------------------------------------------------------------
166
+ # Hook composition bus: hook_bus_list / hook_bus_cleanup
167
+ # ----------------------------------------------------------------------------
168
+
169
+ @test "hook_bus_list lists put findings without the .json extension" {
170
+ export _HOOK_SESSION_ID="bus-list-session"
171
+ export _HOOK_TOOL_NAME="Agent"
172
+ hook_bus_init '{"tool_input":{"agent_id":"list"}}'
173
+ hook_bus_put "alpha" '{"a":1}'
174
+ hook_bus_put "beta" '{"b":2}'
175
+ local listing
176
+ listing=$(hook_bus_list | sort | tr '\n' ' ')
177
+ [ "$listing" = "alpha beta " ]
178
+ }
179
+
180
+ @test "hook_bus_cleanup removes aged bus dirs but keeps fresh ones" {
181
+ local tmp_dir
182
+ tmp_dir="$(cd /tmp && pwd -P)"
183
+ local fresh="${tmp_dir}/.onlooker-hook-bus-cleanup-fresh-$$"
184
+ local aged="${tmp_dir}/.onlooker-hook-bus-cleanup-aged-$$"
185
+ mkdir -p "$fresh" "$aged"
186
+ # Backdate the aged dir well past the 5-minute (-mmin +5) cutoff.
187
+ touch -t "$(date -v-10M +%Y%m%d%H%M.%S 2>/dev/null || date -d '10 minutes ago' +%Y%m%d%H%M.%S)" "$aged"
188
+
189
+ hook_bus_cleanup
190
+
191
+ [ ! -d "$aged" ]
192
+ [ -d "$fresh" ]
193
+ rm -rf "$fresh"
194
+ }
195
+
196
+ # ----------------------------------------------------------------------------
197
+ # Readability / writability validators
198
+ # ----------------------------------------------------------------------------
199
+
200
+ @test "validate_file_readable succeeds for existing readable file" {
201
+ local f="${BATS_TEST_TMPDIR}/readable.txt"
202
+ touch "$f"
203
+ validate_file_readable "$f"
204
+ [ "$?" -eq 0 ]
205
+ }
206
+
207
+ @test "validate_file_readable fails for missing file" {
208
+ ! validate_file_readable "${BATS_TEST_TMPDIR}/no-such-file.txt"
209
+ }
210
+
211
+ @test "validate_file_writable succeeds when parent directory is writable" {
212
+ validate_file_writable "${BATS_TEST_TMPDIR}/new-file.txt"
213
+ [ "$?" -eq 0 ]
214
+ }
215
+
216
+ @test "validate_file_writable fails when parent directory does not exist" {
217
+ ! validate_file_writable "${BATS_TEST_TMPDIR}/missing-dir/new-file.txt"
218
+ }
219
+
220
+ # ----------------------------------------------------------------------------
221
+ # Turn state tracking: turn_state_next_turn
222
+ # ----------------------------------------------------------------------------
223
+
224
+ @test "turn_state_next_turn increments turn_number from 1 to 2" {
225
+ local session_id="next-turn-session"
226
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
227
+ turn_state_ensure_session "$session_id"
228
+ [ -f "$tracker" ]
229
+ # Fresh session starts at turn_number 1.
230
+ jq -e '.turn_number == 1' "$tracker" >/dev/null
231
+
232
+ turn_state_next_turn "$session_id"
233
+ jq -e '.turn_number == 2 and .turn_tool_seq == 0' "$tracker" >/dev/null
234
+ }
@@ -127,3 +127,170 @@ setup() {
127
127
  and (.payload.command | test("worktree:create"))' \
128
128
  >/dev/null
129
129
  }
130
+
131
+ @test "worktree_tracker_repo_root prints the git toplevel for a cwd inside the repo" {
132
+ local expected
133
+ expected=$(git -C "$GIT_REPO" rev-parse --show-toplevel)
134
+
135
+ run worktree_tracker_repo_root "$GIT_REPO"
136
+ [ "$status" -eq 0 ]
137
+ [ "$output" = "$expected" ]
138
+ }
139
+
140
+ @test "worktree_tracker_repo_root returns empty for a non-repo directory" {
141
+ local non_repo="${BATS_TEST_TMPDIR}/not-a-repo"
142
+ mkdir -p "$non_repo"
143
+
144
+ run worktree_tracker_repo_root "$non_repo"
145
+ [ "$status" -ne 0 ]
146
+ [ -z "$output" ]
147
+ }
148
+
149
+ @test "worktree_tracker_repo_root returns non-zero for empty cwd" {
150
+ run worktree_tracker_repo_root ""
151
+ [ "$status" -ne 0 ]
152
+ [ -z "$output" ]
153
+ }
154
+
155
+ @test "worktree_tracker_git_create creates a worktree at the expected path" {
156
+ local name="feature-create"
157
+ local expected="${GIT_REPO}/.claude/worktrees/${name}"
158
+
159
+ local worktree_path
160
+ worktree_path=$(worktree_tracker_git_create "$GIT_REPO" "$name" 2>/dev/null)
161
+
162
+ [ "$worktree_path" = "$(cd "$expected" && pwd -P)" ]
163
+ [ -d "$worktree_path" ]
164
+ git -C "$GIT_REPO" worktree list --porcelain | grep -Fq "worktree $(cd "$expected" && pwd -P)"
165
+ git -C "$GIT_REPO" show-ref --verify --quiet "refs/heads/worktree-${name}"
166
+
167
+ worktree_tracker_git_remove "$GIT_REPO" "$worktree_path" 2>/dev/null
168
+ }
169
+
170
+ @test "worktree_tracker_git_create is idempotent for an existing worktree dir" {
171
+ local name="feature-idempotent"
172
+
173
+ local first second
174
+ first=$(worktree_tracker_git_create "$GIT_REPO" "$name" 2>/dev/null)
175
+ second=$(worktree_tracker_git_create "$GIT_REPO" "$name" 2>/dev/null)
176
+
177
+ [ "$first" = "$second" ]
178
+ [ -d "$second" ]
179
+
180
+ worktree_tracker_git_remove "$GIT_REPO" "$first" 2>/dev/null
181
+ }
182
+
183
+ @test "worktree_tracker_git_create returns non-zero with missing args" {
184
+ run worktree_tracker_git_create "$GIT_REPO" ""
185
+ [ "$status" -ne 0 ]
186
+ }
187
+
188
+ @test "worktree_tracker_git_remove removes a registered worktree" {
189
+ local name="feature-remove"
190
+ local worktree_path
191
+ worktree_path=$(worktree_tracker_git_create "$GIT_REPO" "$name" 2>/dev/null)
192
+ [ -d "$worktree_path" ]
193
+
194
+ worktree_tracker_git_remove "$GIT_REPO" "$worktree_path" 2>/dev/null
195
+
196
+ [ ! -d "$worktree_path" ]
197
+ ! git -C "$GIT_REPO" worktree list --porcelain | grep -Fq "worktree ${worktree_path}"
198
+ }
199
+
200
+ @test "worktree_tracker_record_created writes timing into the session tracker" {
201
+ local session_id="worktree-record-001"
202
+ local name="feature-record"
203
+ local worktree_path="${GIT_REPO}/.claude/worktrees/${name}"
204
+ local branch="worktree-${name}"
205
+ rm -f "${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
206
+
207
+ worktree_tracker_record_created "$session_id" "$name" "$worktree_path" "$branch"
208
+
209
+ local tracker_file="${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
210
+ [ -f "$tracker_file" ]
211
+ jq -e \
212
+ --arg name "$name" \
213
+ --arg path "$worktree_path" \
214
+ --arg branch "$branch" \
215
+ '.worktrees[$name].path == $path
216
+ and .worktrees[$name].branch == $branch
217
+ and (.worktrees[$name].start_time_ms | type == "number")' \
218
+ "$tracker_file" >/dev/null
219
+ }
220
+
221
+ @test "worktree_tracker_record_created is a no-op with missing args" {
222
+ local session_id="worktree-record-noop"
223
+ rm -f "${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
224
+
225
+ run worktree_tracker_record_created "$session_id" "" "/some/path" "branch"
226
+ [ "$status" -eq 0 ]
227
+ [ ! -f "${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}" ]
228
+ }
229
+
230
+ @test "worktree_tracker_duration_ms returns elapsed ms from a seeded start" {
231
+ local session_id="worktree-duration-001"
232
+ local name="feature-duration"
233
+ local worktree_path="${GIT_REPO}/.claude/worktrees/${name}"
234
+ rm -f "${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
235
+
236
+ worktree_tracker_record_created "$session_id" "$name" "$worktree_path" "worktree-${name}"
237
+
238
+ # Rewind the recorded start_time_ms by ~2s so elapsed is a stable positive value.
239
+ local tracker_file="${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
240
+ local now_ms past temp
241
+ now_ms=$(session_tracker_now_ms)
242
+ past=$(( now_ms - 2000 ))
243
+ temp=$(mktemp)
244
+ jq --arg name "$name" --argjson ms "$past" \
245
+ '.worktrees[$name].start_time_ms = $ms' "$tracker_file" >"$temp"
246
+ mv "$temp" "$tracker_file"
247
+
248
+ local duration
249
+ duration=$(worktree_tracker_duration_ms "$session_id" "$worktree_path")
250
+ [[ "$duration" =~ ^[0-9]+$ ]]
251
+ [ "$duration" -ge 1900 ]
252
+ }
253
+
254
+ @test "worktree_tracker_duration_ms returns empty for an unknown worktree path" {
255
+ local session_id="worktree-duration-unknown"
256
+ rm -f "${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
257
+ turn_state_ensure_session "$session_id"
258
+
259
+ run worktree_tracker_duration_ms "$session_id" "/never/recorded"
260
+ [ "$status" -eq 0 ]
261
+ [ -z "$output" ]
262
+ }
263
+
264
+ @test "worktree_tracker_clear_by_path removes the recorded entry" {
265
+ local session_id="worktree-clear-001"
266
+ local name="feature-clear"
267
+ local worktree_path="${GIT_REPO}/.claude/worktrees/${name}"
268
+ rm -f "${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
269
+
270
+ worktree_tracker_record_created "$session_id" "$name" "$worktree_path" "worktree-${name}"
271
+ local tracker_file="${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
272
+ jq -e --arg name "$name" '.worktrees | has($name)' "$tracker_file" >/dev/null
273
+
274
+ worktree_tracker_clear_by_path "$session_id" "$worktree_path"
275
+
276
+ jq -e --arg name "$name" '(.worktrees | has($name)) | not' "$tracker_file" >/dev/null
277
+ run worktree_tracker_duration_ms "$session_id" "$worktree_path"
278
+ [ -z "$output" ]
279
+ }
280
+
281
+ @test "worktree_tracker_append lands a JSON line in the session history" {
282
+ local session_id="worktree-append-001"
283
+ local history_file="${ONLOOKER_SESSION_HISTORY_DIR}/${session_id}.jsonl"
284
+ rm -f "$history_file" "${history_file}.lock"
285
+
286
+ local event
287
+ event=$(jq -c -n '{event_type: "tool.shell.exec", payload: {command: "git worktree:create"}}')
288
+
289
+ worktree_tracker_append "$session_id" "$event"
290
+
291
+ [ -f "$history_file" ]
292
+ tail -n 1 "$history_file" | jq -e \
293
+ '.event_type == "tool.shell.exec"
294
+ and (.payload.command | test("worktree:create"))' \
295
+ >/dev/null
296
+ }