@onlooker-community/ecosystem 0.28.1 → 0.29.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 (56) 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/README.md +114 -13
  7. package/docs/plugin-catalog.md +125 -0
  8. package/package.json +3 -3
  9. package/plugins/compass/.claude-plugin/plugin.json +1 -1
  10. package/plugins/compass/CHANGELOG.md +7 -0
  11. package/plugins/compass/README.md +1 -3
  12. package/plugins/compass/config.json +1 -2
  13. package/plugins/compass/docs/design.md +1 -2
  14. package/plugins/compass/scripts/hooks/compass-bash-gate.sh +8 -1
  15. package/plugins/compass/scripts/hooks/compass-pre-tool-use.sh +8 -1
  16. package/plugins/compass/scripts/hooks/compass-record-write.sh +5 -0
  17. package/plugins/compass/scripts/hooks/compass-session-start.sh +0 -8
  18. package/plugins/compass/scripts/lib/compass-evaluator.sh +58 -98
  19. package/plugins/compass/scripts/lib/compass-gate.sh +15 -18
  20. package/plugins/compass/scripts/lib/compass-sanitizer.sh +4 -4
  21. package/plugins/compass/scripts/lib/compass-transcript.sh +79 -112
  22. package/plugins/inspector/.claude-plugin/plugin.json +14 -0
  23. package/plugins/inspector/CHANGELOG.md +8 -0
  24. package/plugins/inspector/README.md +155 -0
  25. package/plugins/inspector/config.json +25 -0
  26. package/plugins/inspector/docs/design.md +286 -0
  27. package/plugins/inspector/hooks/hooks.json +33 -0
  28. package/plugins/inspector/scripts/hooks/inspector-post-write.sh +124 -0
  29. package/plugins/inspector/scripts/lib/inspector-config.sh +108 -0
  30. package/plugins/inspector/scripts/lib/inspector-events.sh +82 -0
  31. package/plugins/inspector/scripts/lib/inspector-project-key.sh +55 -0
  32. package/plugins/inspector/scripts/lib/inspector-run.sh +305 -0
  33. package/plugins/inspector/scripts/lib/inspector-ulid.sh +45 -0
  34. package/release-please-config.json +17 -1
  35. package/test/bats/archivist-project-key.bats +79 -0
  36. package/test/bats/archivist-storage.bats +79 -0
  37. package/test/bats/compact-tracker.bats +125 -0
  38. package/test/bats/compass-config.bats +65 -0
  39. package/test/bats/compass-gate.bats +129 -0
  40. package/test/bats/compass-sanitizer.bats +69 -0
  41. package/test/bats/compass-symbolic-skip.bats +88 -0
  42. package/test/bats/compass-transcript.bats +80 -0
  43. package/test/bats/inspector-config.bats +118 -0
  44. package/test/bats/inspector-events.bats +156 -0
  45. package/test/bats/inspector-post-write-hook.bats +164 -0
  46. package/test/bats/inspector-project-key.bats +68 -0
  47. package/test/bats/inspector-ulid.bats +34 -0
  48. package/test/bats/librarian-session-end.bats +8 -1
  49. package/test/bats/onlooker-schema.bats +111 -0
  50. package/test/bats/prompt-rules.bats +98 -0
  51. package/test/bats/session-tracker.bats +260 -0
  52. package/test/bats/skill-usage-tracker.bats +63 -0
  53. package/test/bats/task-tracker.bats +102 -0
  54. package/test/bats/turn-tracker.bats +180 -0
  55. package/test/bats/validate-path.bats +125 -0
  56. package/test/bats/worktree-tracker.bats +167 -0
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Exercises scripts/lib/onlooker-schema.sh — the bash wrappers around the
4
+ # canonical event emitter (scripts/lib/onlooker-event.mjs). Events are
5
+ # validated against the real installed @onlooker-community/schema.
6
+
7
+ setup() {
8
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
9
+ load_validate_path
10
+ export _ONLOOKER_EVENT_JS="${REPO_ROOT}/scripts/lib/onlooker-event.mjs"
11
+ export ONLOOKER_PLUGIN_NAME="onlooker"
12
+ # shellcheck disable=SC1091
13
+ source "${REPO_ROOT}/scripts/lib/onlooker-schema.sh"
14
+ }
15
+
16
+ _fixture() {
17
+ cat "${REPO_ROOT}/test/fixtures/hook-inputs/${1}"
18
+ }
19
+
20
+ # --- onlooker_append_event -------------------------------------------------
21
+
22
+ @test "onlooker_append_event appends a parseable line carrying the event_type" {
23
+ local line
24
+ line='{"event_type":"tool.file.read","payload":{"path":"/x"}}'
25
+ run onlooker_append_event "$line"
26
+ [ "$status" -eq 0 ]
27
+
28
+ local last
29
+ last=$(tail -n 1 "$ONLOOKER_EVENTS_LOG")
30
+ # Round-trips through jq (valid JSON) and preserves the event_type.
31
+ [ "$(printf '%s' "$last" | jq -r '.event_type')" = "tool.file.read" ]
32
+ }
33
+
34
+ @test "onlooker_append_event with empty input is a no-op returning 0" {
35
+ run onlooker_append_event ""
36
+ [ "$status" -eq 0 ]
37
+ # No log line written.
38
+ [ ! -s "$ONLOOKER_EVENTS_LOG" ]
39
+ }
40
+
41
+ @test "onlooker_append_event creates parent dirs when missing" {
42
+ # Point the log at a path whose parent does not yet exist.
43
+ export ONLOOKER_EVENTS_LOG="${ONLOOKER_DIR}/nested/deeper/onlooker-events.jsonl"
44
+ [ ! -d "$(dirname "$ONLOOKER_EVENTS_LOG")" ]
45
+
46
+ run onlooker_append_event '{"event_type":"tool.file.read"}'
47
+ [ "$status" -eq 0 ]
48
+ [ -d "$(dirname "$ONLOOKER_EVENTS_LOG")" ]
49
+ [ "$(tail -n 1 "$ONLOOKER_EVENTS_LOG" | jq -r '.event_type')" = "tool.file.read" ]
50
+ }
51
+
52
+ @test "onlooker_append_event appends rather than truncating" {
53
+ onlooker_append_event '{"event_type":"tool.file.read"}'
54
+ onlooker_append_event '{"event_type":"tool.shell.exec"}'
55
+ [ "$(wc -l <"$ONLOOKER_EVENTS_LOG" | tr -d ' ')" -eq 2 ]
56
+ [ "$(tail -n 1 "$ONLOOKER_EVENTS_LOG" | jq -r '.event_type')" = "tool.shell.exec" ]
57
+ }
58
+
59
+ # --- onlooker_event_from_hook ----------------------------------------------
60
+
61
+ @test "onlooker_event_from_hook maps a mappable fixture to tool.file.read" {
62
+ local event
63
+ event=$(onlooker_event_from_hook "$(_fixture post-tool-use-read.json)")
64
+ [ -n "$event" ]
65
+ [ "$(printf '%s' "$event" | jq -r '.event_type')" = "tool.file.read" ]
66
+ }
67
+
68
+ @test "onlooker_event_from_hook output validates against the schema" {
69
+ local event
70
+ event=$(onlooker_event_from_hook "$(_fixture post-tool-use-read.json)")
71
+ [ -n "$event" ]
72
+ printf '%s' "$event" | node "$_ONLOOKER_EVENT_JS" validate
73
+ }
74
+
75
+ @test "onlooker_event_from_hook prints empty for an unmappable fixture" {
76
+ local event
77
+ event=$(onlooker_event_from_hook "$(_fixture session-start-startup.json)")
78
+ [ -z "$event" ]
79
+ }
80
+
81
+ @test "onlooker_event_from_hook with empty input returns 0 and prints nothing" {
82
+ run onlooker_event_from_hook ""
83
+ [ "$status" -eq 0 ]
84
+ [ -z "$output" ]
85
+ }
86
+
87
+ # --- onlooker_emit_from_hook -----------------------------------------------
88
+
89
+ @test "onlooker_emit_from_hook writes exactly one tool.file.read line" {
90
+ run onlooker_emit_from_hook "$(_fixture post-tool-use-read.json)"
91
+ [ "$status" -eq 0 ]
92
+
93
+ [ "$(wc -l <"$ONLOOKER_EVENTS_LOG" | tr -d ' ')" -eq 1 ]
94
+ [ "$(tail -n 1 "$ONLOOKER_EVENTS_LOG" | jq -r '.event_type')" = "tool.file.read" ]
95
+ }
96
+
97
+ @test "onlooker_emit_from_hook leaves the log empty for an unmappable fixture" {
98
+ run onlooker_emit_from_hook "$(_fixture session-start-startup.json)"
99
+ [ "$status" -eq 0 ]
100
+ [ ! -s "$ONLOOKER_EVENTS_LOG" ]
101
+ }
102
+
103
+ @test "onlooker_emit_from_hook records blocked=true for a failed Bash tool" {
104
+ run onlooker_emit_from_hook "$(_fixture post-tool-use-failure-bash.json)"
105
+ [ "$status" -eq 0 ]
106
+
107
+ local last
108
+ last=$(tail -n 1 "$ONLOOKER_EVENTS_LOG")
109
+ [ "$(printf '%s' "$last" | jq -r '.event_type')" = "tool.shell.exec" ]
110
+ [ "$(printf '%s' "$last" | jq -r '.payload.blocked')" = "true" ]
111
+ }
@@ -267,3 +267,101 @@ write_project_rules() {
267
267
  echo "$out" | grep -A2 "id: r1" | grep -q "fired: yes"
268
268
  echo "$out" | grep -A2 "id: r2" | grep -q "fired: no"
269
269
  }
270
+
271
+ @test "project_path: appends .claude/prompt-rules.json to the given cwd" {
272
+ local path
273
+ path=$(prompt_rules_project_path "/some/where")
274
+ [ "$path" = "/some/where/.claude/prompt-rules.json" ]
275
+ }
276
+
277
+ @test "project_path: defaults to \$PWD when no cwd is given" {
278
+ local path expected
279
+ path=$(prompt_rules_project_path)
280
+ expected="$PWD/.claude/prompt-rules.json"
281
+ [ "$path" = "$expected" ]
282
+ }
283
+
284
+ @test "fired_path: builds path under the sessions dir from the session id" {
285
+ local path expected
286
+ path=$(prompt_rules_fired_path "sess-XYZ")
287
+ expected="$ONLOOKER_PROMPT_RULES_SESSIONS_DIR/sess-XYZ.json"
288
+ [ "$path" = "$expected" ]
289
+ }
290
+
291
+ @test "fired_path: defaults session id to 'unknown' when none is given" {
292
+ local path expected
293
+ path=$(prompt_rules_fired_path)
294
+ expected="$ONLOOKER_PROMPT_RULES_SESSIONS_DIR/unknown.json"
295
+ [ "$path" = "$expected" ]
296
+ }
297
+
298
+ @test "emit: appends a JSON event line with type, session, payload, and plugin" {
299
+ : >"$ONLOOKER_EVENTS_LOG"
300
+ run prompt_rules_emit "sess-emit" "prompt_rule.matched" '{"rule_id":"rule-1"}'
301
+ [ "$status" -eq 0 ]
302
+
303
+ local line
304
+ line=$(tail -1 "$ONLOOKER_EVENTS_LOG")
305
+ [ "$(echo "$line" | jq -r '.event_type')" = "prompt_rule.matched" ]
306
+ [ "$(echo "$line" | jq -r '.session_id')" = "sess-emit" ]
307
+ [ "$(echo "$line" | jq -r '.payload.rule_id')" = "rule-1" ]
308
+ # No ONLOOKER_PLUGIN_NAME exported in this test → defaults to "onlooker".
309
+ [ "$(echo "$line" | jq -r '.plugin')" = "onlooker" ]
310
+ }
311
+
312
+ @test "emit: honors ONLOOKER_PLUGIN_NAME for the plugin field" {
313
+ : >"$ONLOOKER_EVENTS_LOG"
314
+ ONLOOKER_PLUGIN_NAME="prompt-rules" prompt_rules_emit "sess-plugin" "prompt_rule.applied" '{"rule_id":"r9"}'
315
+
316
+ local line
317
+ line=$(tail -1 "$ONLOOKER_EVENTS_LOG")
318
+ [ "$(echo "$line" | jq -r '.plugin')" = "prompt-rules" ]
319
+ }
320
+
321
+ @test "emit: defaults payload to an empty object when none is given" {
322
+ : >"$ONLOOKER_EVENTS_LOG"
323
+ run prompt_rules_emit "sess-nopayload" "prompt_rule.matched"
324
+ [ "$status" -eq 0 ]
325
+
326
+ local line
327
+ line=$(tail -1 "$ONLOOKER_EVENTS_LOG")
328
+ [ "$(echo "$line" | jq -c '.payload')" = "{}" ]
329
+ }
330
+
331
+ @test "emit: defaults session id to 'unknown' when none is given" {
332
+ : >"$ONLOOKER_EVENTS_LOG"
333
+ prompt_rules_emit "" "prompt_rule.matched" '{}'
334
+
335
+ local line
336
+ line=$(tail -1 "$ONLOOKER_EVENTS_LOG")
337
+ [ "$(echo "$line" | jq -r '.session_id')" = "unknown" ]
338
+ }
339
+
340
+ @test "emit: includes a numeric turn field when ONLOOKER_TURN_NUMBER is exported" {
341
+ : >"$ONLOOKER_EVENTS_LOG"
342
+ ONLOOKER_TURN_NUMBER=7 prompt_rules_emit "sess-turn" "prompt_rule.matched" '{}'
343
+
344
+ local line
345
+ line=$(tail -1 "$ONLOOKER_EVENTS_LOG")
346
+ [ "$(echo "$line" | jq -r '.turn')" = "7" ]
347
+ [ "$(echo "$line" | jq -r '.turn | type')" = "number" ]
348
+ }
349
+
350
+ @test "emit: omits the turn field when ONLOOKER_TURN_NUMBER is unset" {
351
+ : >"$ONLOOKER_EVENTS_LOG"
352
+ # Guard against any ambient value leaking in from the environment.
353
+ unset ONLOOKER_TURN_NUMBER
354
+ prompt_rules_emit "sess-noturn" "prompt_rule.matched" '{}'
355
+
356
+ local line
357
+ line=$(tail -1 "$ONLOOKER_EVENTS_LOG")
358
+ [ "$(echo "$line" | jq -e 'has("turn")')" = "false" ] || \
359
+ [ "$(echo "$line" | jq 'has("turn")')" = "false" ]
360
+ }
361
+
362
+ @test "emit: returns 1 and writes nothing when event_type is empty" {
363
+ : >"$ONLOOKER_EVENTS_LOG"
364
+ run prompt_rules_emit "sess-empty" ""
365
+ [ "$status" -eq 1 ]
366
+ [ ! -s "$ONLOOKER_EVENTS_LOG" ]
367
+ }
@@ -73,3 +73,263 @@ setup() {
73
73
  @test "session_tracker_map_end_reason maps logout to user_exit" {
74
74
  [ "$(session_tracker_map_end_reason logout)" = "user_exit" ]
75
75
  }
76
+
77
+ # Seed a tracker file's start_time_ms to a deterministic point in the past.
78
+ # Usage: seed_start_ms_ago "$session_id" 5000
79
+ seed_start_ms_ago() {
80
+ local session_id="$1"
81
+ local ago_ms="$2"
82
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
83
+ turn_state_ensure_session "$session_id"
84
+ local past
85
+ past=$(( $(session_tracker_now_ms) - ago_ms ))
86
+ jq --argjson start_ms "$past" '.start_time_ms = $start_ms' "$tracker" >"${tracker}.tmp"
87
+ mv "${tracker}.tmp" "$tracker"
88
+ }
89
+
90
+ @test "session_tracker_now_ms returns 13-digit epoch milliseconds" {
91
+ local ms
92
+ ms=$(session_tracker_now_ms)
93
+ [[ "$ms" =~ ^[0-9]+$ ]]
94
+ [ "${#ms}" -eq 13 ]
95
+ }
96
+
97
+ @test "session_tracker_now_ms is monotonic-ish across two calls" {
98
+ local first second
99
+ first=$(session_tracker_now_ms)
100
+ second=$(session_tracker_now_ms)
101
+ [ "$second" -ge "$first" ]
102
+ }
103
+
104
+ @test "session_tracker_git_context returns nothing for empty cwd" {
105
+ run session_tracker_git_context ""
106
+ [ "$status" -eq 0 ]
107
+ [ -z "$output" ]
108
+ }
109
+
110
+ @test "session_tracker_git_context returns two empty lines for non-git dir" {
111
+ local non_git="${BATS_TEST_TMPDIR}/not-a-repo"
112
+ mkdir -p "$non_git"
113
+ run session_tracker_git_context "$non_git"
114
+ [ "$status" -eq 0 ]
115
+ # Two empty fields: branch line + commit line, both empty.
116
+ [ -z "$output" ]
117
+ local branch commit
118
+ branch=$(session_tracker_git_context "$non_git" | sed -n '1p')
119
+ commit=$(session_tracker_git_context "$non_git" | sed -n '2p')
120
+ [ -z "$branch" ]
121
+ [ -z "$commit" ]
122
+ }
123
+
124
+ @test "session_tracker_git_context returns branch and short commit for a repo" {
125
+ local repo="${BATS_TEST_TMPDIR}/gitrepo"
126
+ mkdir -p "$repo"
127
+ git -C "$repo" init -q -b trunk
128
+ git -C "$repo" config user.email "test@example.com"
129
+ git -C "$repo" config user.name "Test"
130
+ touch "$repo/file.txt"
131
+ git -C "$repo" add file.txt
132
+ git -C "$repo" commit -q -m "initial"
133
+
134
+ local out branch commit
135
+ out=$(session_tracker_git_context "$repo")
136
+ branch=$(echo "$out" | sed -n '1p')
137
+ commit=$(echo "$out" | sed -n '2p')
138
+ [ "$branch" = "trunk" ]
139
+ [[ "$commit" =~ ^[0-9a-f]{7}$ ]]
140
+ }
141
+
142
+ @test "session_tracker_record_start writes start metadata to tracker" {
143
+ local sid="rec-start-001"
144
+ local input='{"cwd":"/p","source":"startup","model":"m","transcript_path":"/t","agent_type":"a"}'
145
+ session_tracker_record_start "$sid" "$input"
146
+
147
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}"
148
+ [ -f "$tracker" ]
149
+ jq -e '.cwd == "/p"
150
+ and .start_source == "startup"
151
+ and .model == "m"
152
+ and .transcript_path == "/t"
153
+ and .agent_type == "a"
154
+ and (.start_time_ms | type) == "number"
155
+ and .start_time_ms > 0' "$tracker" >/dev/null
156
+ }
157
+
158
+ @test "session_tracker_record_start no-ops on empty input" {
159
+ local sid="rec-start-empty"
160
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}"
161
+ rm -f "$tracker"
162
+ run session_tracker_record_start "$sid" ""
163
+ [ "$status" -eq 0 ]
164
+ [ ! -f "$tracker" ]
165
+ }
166
+
167
+ @test "session_tracker_record_start no-ops on null session_id" {
168
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/null"
169
+ rm -f "$tracker"
170
+ run session_tracker_record_start "null" '{"cwd":"/p"}'
171
+ [ "$status" -eq 0 ]
172
+ [ ! -f "$tracker" ]
173
+ }
174
+
175
+ @test "session_tracker_build_start_payload sets working_directory and git fields in repo" {
176
+ local repo="${BATS_TEST_TMPDIR}/payload-repo"
177
+ mkdir -p "$repo"
178
+ git -C "$repo" init -q -b main
179
+ git -C "$repo" config user.email "test@example.com"
180
+ git -C "$repo" config user.name "Test"
181
+ touch "$repo/f"
182
+ git -C "$repo" add f
183
+ git -C "$repo" commit -q -m "init"
184
+
185
+ local payload
186
+ payload=$(session_tracker_build_start_payload "{\"cwd\":\"$repo\"}")
187
+ echo "$payload" | jq -e --arg wd "$repo" '.working_directory == $wd
188
+ and .git_branch == "main"
189
+ and (.git_commit | test("^[0-9a-f]{7}$"))' >/dev/null
190
+ }
191
+
192
+ @test "session_tracker_build_start_payload falls back to pwd and omits git fields outside repo" {
193
+ local non_git="${BATS_TEST_TMPDIR}/payload-nogit"
194
+ mkdir -p "$non_git"
195
+ local payload
196
+ payload=$(cd "$non_git" && session_tracker_build_start_payload '{}')
197
+ echo "$payload" | jq -e --arg wd "$non_git" '.working_directory == $wd
198
+ and (has("git_branch") | not)
199
+ and (has("git_commit") | not)' >/dev/null
200
+ }
201
+
202
+ @test "session_tracker_build_end_payload computes duration, turn_count, and end_reason" {
203
+ local sid="end-payload-001"
204
+ seed_start_ms_ago "$sid" 5000
205
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}"
206
+ jq '.turn_number = 2' "$tracker" >"${tracker}.tmp"
207
+ mv "${tracker}.tmp" "$tracker"
208
+
209
+ local payload
210
+ payload=$(session_tracker_build_end_payload "$sid" '{"reason":"logout"}')
211
+ echo "$payload" | jq -e '.duration_ms >= 5000
212
+ and .turn_count == 2
213
+ and .end_reason == "user_exit"' >/dev/null
214
+ }
215
+
216
+ @test "session_tracker_build_end_payload defaults when no tracker exists" {
217
+ local sid="end-payload-missing"
218
+ rm -f "${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}"
219
+ local payload
220
+ payload=$(session_tracker_build_end_payload "$sid" '{"reason":"logout"}')
221
+ echo "$payload" | jq -e '.duration_ms == 0 and .turn_count == 1' >/dev/null
222
+ }
223
+
224
+ @test "session_tracker_build_end_payload returns 1 for null session_id" {
225
+ run session_tracker_build_end_payload "null" '{"reason":"logout"}'
226
+ [ "$status" -eq 1 ]
227
+ }
228
+
229
+ @test "session_tracker_duration_ms returns elapsed ms for seeded start" {
230
+ local sid="dur-001"
231
+ seed_start_ms_ago "$sid" 3000
232
+ local dur
233
+ dur=$(session_tracker_duration_ms "$sid")
234
+ [[ "$dur" =~ ^[0-9]+$ ]]
235
+ [ "$dur" -ge 3000 ]
236
+ }
237
+
238
+ @test "session_tracker_duration_ms returns 0 for null session_id" {
239
+ [ "$(session_tracker_duration_ms null)" = "0" ]
240
+ }
241
+
242
+ @test "session_tracker_duration_ms returns 0 for missing tracker" {
243
+ local sid="dur-missing"
244
+ rm -f "${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}"
245
+ [ "$(session_tracker_duration_ms "$sid")" = "0" ]
246
+ }
247
+
248
+ @test "session_tracker_duration_ms returns 0 for invalid start_time_ms" {
249
+ local sid="dur-invalid"
250
+ turn_state_ensure_session "$sid"
251
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}"
252
+ jq '.start_time_ms = "not-a-number"' "$tracker" >"${tracker}.tmp"
253
+ mv "${tracker}.tmp" "$tracker"
254
+ [ "$(session_tracker_duration_ms "$sid")" = "0" ]
255
+ }
256
+
257
+ @test "session_tracker_update_duration persists session_duration_ms" {
258
+ local sid="upd-001"
259
+ seed_start_ms_ago "$sid" 2000
260
+ session_tracker_update_duration "$sid"
261
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}"
262
+ jq -e '.session_duration_ms >= 2000' "$tracker" >/dev/null
263
+ }
264
+
265
+ @test "session_tracker_update_duration returns 0 for null session_id" {
266
+ run session_tracker_update_duration "null"
267
+ [ "$status" -eq 0 ]
268
+ }
269
+
270
+ @test "session_tracker_update_duration creates tracker with zero duration for new sid" {
271
+ local sid="upd-new"
272
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}"
273
+ rm -f "$tracker"
274
+ session_tracker_update_duration "$sid"
275
+ [ -f "$tracker" ]
276
+ jq -e '.session_duration_ms == 0' "$tracker" >/dev/null
277
+ }
278
+
279
+ @test "session_tracker_build_duration_context renders turn and humanized elapsed" {
280
+ local sid="ctx-001"
281
+ seed_start_ms_ago "$sid" 154000
282
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}"
283
+ jq '.turn_number = 3' "$tracker" >"${tracker}.tmp"
284
+ mv "${tracker}.tmp" "$tracker"
285
+
286
+ local out
287
+ out=$(session_tracker_build_duration_context "$sid")
288
+ [[ "$out" == *"turn 3"* ]]
289
+ [[ "$out" == *"2m 34s"* ]]
290
+ }
291
+
292
+ @test "session_tracker_build_duration_context defaults for missing tracker" {
293
+ local sid="ctx-missing"
294
+ rm -f "${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}"
295
+ local out
296
+ out=$(session_tracker_build_duration_context "$sid")
297
+ [[ "$out" == *"turn 1"* ]]
298
+ [[ "$out" == *"0s"* ]]
299
+ }
300
+
301
+ @test "session_tracker_emit appends to both session history and events log" {
302
+ export _ONLOOKER_EVENT_JS="${REPO_ROOT}/scripts/lib/onlooker-event.mjs"
303
+ local sid="emit-001"
304
+ local history_file="${ONLOOKER_SESSION_HISTORY_DIR}/${sid}.jsonl"
305
+ rm -f "$history_file"
306
+ : >"$ONLOOKER_EVENTS_LOG"
307
+
308
+ run session_tracker_emit "$sid" "session.start" '{"working_directory":"/tmp"}'
309
+ [ "$status" -eq 0 ]
310
+
311
+ [ -f "$history_file" ]
312
+ jq -e '.event_type == "session.start" and .session_id == "emit-001"' "$history_file" >/dev/null
313
+ grep -q '"event_type":"session.start"' "$ONLOOKER_EVENTS_LOG" \
314
+ || jq -e 'select(.event_type == "session.start" and .session_id == "emit-001")' "$ONLOOKER_EVENTS_LOG" >/dev/null
315
+ }
316
+
317
+ @test "session_tracker_emit no-ops on empty session_id" {
318
+ export _ONLOOKER_EVENT_JS="${REPO_ROOT}/scripts/lib/onlooker-event.mjs"
319
+ : >"$ONLOOKER_EVENTS_LOG"
320
+ run session_tracker_emit "" "session.start" '{"working_directory":"/tmp"}'
321
+ [ "$status" -eq 0 ]
322
+ [ ! -s "$ONLOOKER_EVENTS_LOG" ]
323
+ }
324
+
325
+ @test "session_tracker_emit no-ops on empty event_type" {
326
+ export _ONLOOKER_EVENT_JS="${REPO_ROOT}/scripts/lib/onlooker-event.mjs"
327
+ local sid="emit-noevent"
328
+ local history_file="${ONLOOKER_SESSION_HISTORY_DIR}/${sid}.jsonl"
329
+ rm -f "$history_file"
330
+ : >"$ONLOOKER_EVENTS_LOG"
331
+ run session_tracker_emit "$sid" "" '{"working_directory":"/tmp"}'
332
+ [ "$status" -eq 0 ]
333
+ [ ! -f "$history_file" ]
334
+ [ ! -s "$ONLOOKER_EVENTS_LOG" ]
335
+ }
@@ -74,3 +74,66 @@ setup() {
74
74
  tail -n 1 "$ONLOOKER_EVENTS_LOG" | jq -e '.event_type == "skill.invoked"' >/dev/null
75
75
  tail -n 1 "$ONLOOKER_EVENTS_LOG" | onlooker_validate_event
76
76
  }
77
+
78
+ @test "skill_usage_append writes a built skill.invoked record to the session history file" {
79
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/user-prompt-expansion-skill.json"
80
+ local history_file="${ONLOOKER_SESSION_HISTORY_DIR}/skill-session-001.jsonl"
81
+ rm -f "$history_file" "${history_file}.lock"
82
+
83
+ local record
84
+ record=$(skill_usage_build_record "$(cat "$fixture")")
85
+
86
+ run skill_usage_append "skill-session-001" "$record"
87
+ [ "$status" -eq 0 ]
88
+
89
+ [ -f "$history_file" ]
90
+ tail -n 1 "$history_file" | jq -e \
91
+ '.event_type == "skill.invoked"
92
+ and .payload.skill_name == "code-review"
93
+ and .payload.invocation_source == "slash_command"
94
+ and .session_id == "skill-session-001"' \
95
+ >/dev/null
96
+ tail -n 1 "$history_file" | onlooker_validate_event
97
+ }
98
+
99
+ @test "skill_usage_append routes the record to the per-session file named after the session id" {
100
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/pre-tool-use-skill.json"
101
+ local session_id="skill-session-custom"
102
+ local history_file="${ONLOOKER_SESSION_HISTORY_DIR}/${session_id}.jsonl"
103
+ rm -f "$history_file" "${history_file}.lock"
104
+
105
+ # Build a canonical record, then retarget its session_id; skill_usage_append
106
+ # must file it under the session_id argument, not the embedded one.
107
+ local record
108
+ record=$(skill_usage_build_record "$(cat "$fixture")" \
109
+ | jq -c --arg sid "$session_id" '.session_id = $sid')
110
+
111
+ run skill_usage_append "$session_id" "$record"
112
+ [ "$status" -eq 0 ]
113
+
114
+ [ -f "$history_file" ]
115
+ [ "$(wc -l <"$history_file")" -eq 1 ]
116
+ tail -n 1 "$history_file" | jq -e \
117
+ '.session_id == "skill-session-custom"
118
+ and .payload.skill_name == "code-review"
119
+ and .payload.invocation_source == "tool"' \
120
+ >/dev/null
121
+ tail -n 1 "$history_file" | onlooker_validate_event
122
+ }
123
+
124
+ @test "skill_usage_append is a no-op when the session id is empty" {
125
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/pre-tool-use-skill.json"
126
+ local record
127
+ record=$(skill_usage_build_record "$(cat "$fixture")")
128
+
129
+ # Snapshot the history dir, then assert the empty-session call adds nothing.
130
+ local before
131
+ before=$(find "$ONLOOKER_SESSION_HISTORY_DIR" -type f | sort)
132
+
133
+ run skill_usage_append "" "$record"
134
+ [ "$status" -eq 0 ]
135
+
136
+ local after
137
+ after=$(find "$ONLOOKER_SESSION_HISTORY_DIR" -type f | sort)
138
+ [ "$before" = "$after" ]
139
+ }
@@ -97,3 +97,105 @@ setup() {
97
97
  tail -n 1 "$ONLOOKER_EVENTS_LOG" | jq -e '.event_type == "task.start"' >/dev/null
98
98
  tail -n 1 "$ONLOOKER_EVENTS_LOG" | onlooker_validate_event
99
99
  }
100
+
101
+ @test "task_tracker_record_created writes start_time_ms as a number" {
102
+ local sid="rec-session-001"
103
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}"
104
+
105
+ run task_tracker_record_created "$sid" "task-rec-001"
106
+ [ "$status" -eq 0 ]
107
+ [ -f "$tracker" ]
108
+ jq -e '.tasks["task-rec-001"].start_time_ms | type == "number"' "$tracker" >/dev/null
109
+ }
110
+
111
+ @test "task_tracker_record_created no-ops on empty session_id" {
112
+ run task_tracker_record_created "" "task-rec-002"
113
+ [ "$status" -eq 0 ]
114
+ # load_validate_path always creates the trackers dir; assert no tracker file landed.
115
+ [ -z "$(find "${ONLOOKER_SESSION_TRACKERS_DIR}" -type f 2>/dev/null)" ]
116
+ }
117
+
118
+ @test "task_tracker_record_created no-ops on null session_id" {
119
+ run task_tracker_record_created "null" "task-rec-003"
120
+ [ "$status" -eq 0 ]
121
+ [ ! -f "${ONLOOKER_SESSION_TRACKERS_DIR}/null" ]
122
+ }
123
+
124
+ @test "task_tracker_record_created no-ops on empty task_id" {
125
+ local sid="rec-session-empty-task"
126
+ run task_tracker_record_created "$sid" ""
127
+ [ "$status" -eq 0 ]
128
+ [ ! -f "${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}" ]
129
+ }
130
+
131
+ @test "task_tracker_duration_ms returns elapsed ms for a seeded task" {
132
+ local sid="dur-session-001"
133
+ turn_state_ensure_session "$sid"
134
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}"
135
+
136
+ local past
137
+ past=$(( $(session_tracker_now_ms) - 2000 ))
138
+ jq --argjson ms "$past" '.tasks["task-dur-001"] = {start_time_ms: $ms}' \
139
+ "$tracker" >"${tracker}.tmp"
140
+ mv "${tracker}.tmp" "$tracker"
141
+
142
+ local elapsed
143
+ elapsed=$(task_tracker_duration_ms "$sid" "task-dur-001")
144
+ [ -n "$elapsed" ]
145
+ echo "$elapsed" | grep -Eq '^[0-9]+$'
146
+ [ "$elapsed" -ge 1900 ]
147
+ }
148
+
149
+ @test "task_tracker_duration_ms is empty for an unknown task_id" {
150
+ local sid="dur-session-unknown"
151
+ turn_state_ensure_session "$sid"
152
+
153
+ local out
154
+ run task_tracker_duration_ms "$sid" "task-does-not-exist"
155
+ [ "$status" -eq 0 ]
156
+ [ -z "$output" ]
157
+ }
158
+
159
+ @test "task_tracker_clear removes the task timing entry" {
160
+ local sid="clear-session-001"
161
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}"
162
+
163
+ task_tracker_record_created "$sid" "task-clear-001"
164
+ jq -e '.tasks["task-clear-001"]' "$tracker" >/dev/null
165
+
166
+ run task_tracker_clear "$sid" "task-clear-001"
167
+ [ "$status" -eq 0 ]
168
+
169
+ run jq -e '.tasks["task-clear-001"]' "$tracker"
170
+ [ "$status" -ne 0 ]
171
+ }
172
+
173
+ @test "task_tracker_clear no-ops when tracker file is missing" {
174
+ local sid="clear-session-missing"
175
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${sid}"
176
+ rm -f "$tracker"
177
+
178
+ run task_tracker_clear "$sid" "task-nope"
179
+ [ "$status" -eq 0 ]
180
+ [ ! -f "$tracker" ]
181
+ }
182
+
183
+ @test "task_tracker_append delegates to tool_history_append" {
184
+ local sid="append-session-001"
185
+ local history_file="${ONLOOKER_SESSION_HISTORY_DIR}/${sid}.jsonl"
186
+ rm -f "$history_file" "${history_file}.lock"
187
+
188
+ local event
189
+ event=$(jq -c -n --arg sid "$sid" \
190
+ '{schema_version: "1.0",
191
+ plugin: "ecosystem",
192
+ session_id: $sid,
193
+ event_type: "task.start",
194
+ payload: {task_summary: "append test"}}')
195
+
196
+ run task_tracker_append "$sid" "$event"
197
+ [ "$status" -eq 0 ]
198
+ [ -f "$history_file" ]
199
+ tail -n 1 "$history_file" | jq -e '.event_type == "task.start"' >/dev/null
200
+ tail -n 1 "$history_file" | jq -e '.session_id == "append-session-001"' >/dev/null
201
+ }