@onlooker-community/ecosystem 0.3.3 → 0.5.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.
@@ -0,0 +1,5 @@
1
+ {
2
+ "enabledPlugins": {
3
+ "ecosystem@onlooker-community": true
4
+ }
5
+ }
@@ -93,9 +93,7 @@ jobs:
93
93
  cache: npm
94
94
 
95
95
  - run: npm ci
96
- - run: npm run build
97
96
  - run: npm test
98
- - run: npm run typecheck
99
97
 
100
98
  publish:
101
99
  name: Publish to npm
@@ -112,7 +110,6 @@ jobs:
112
110
  cache: npm
113
111
 
114
112
  - run: npm ci
115
- - run: npm run build
116
113
 
117
114
  - name: Publish
118
115
  run: |
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.3.3"
2
+ ".": "0.5.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0](https://github.com/onlooker-community/ecosystem/compare/v0.4.0...v0.5.0) (2026-05-22)
4
+
5
+
6
+ ### Features
7
+
8
+ * **hooks:** add UserPromptSubmit turn and session duration trackers ([#12](https://github.com/onlooker-community/ecosystem/issues/12)) ([cbb7657](https://github.com/onlooker-community/ecosystem/commit/cbb7657979ed144efce506e6b487e037679b9462))
9
+
10
+ ## [0.4.0](https://github.com/onlooker-community/ecosystem/compare/v0.3.3...v0.4.0) (2026-05-22)
11
+
12
+
13
+ ### Features
14
+
15
+ * **hooks:** add SessionStart and SessionEnd session trackers ([#10](https://github.com/onlooker-community/ecosystem/issues/10)) ([a48d680](https://github.com/onlooker-community/ecosystem/commit/a48d680dd24c98e79ef1c0401b07483ecebf9e8b))
16
+
3
17
  ## [0.3.3](https://github.com/onlooker-community/ecosystem/compare/v0.3.2...v0.3.3) (2026-05-22)
4
18
 
5
19
 
package/hooks/hooks.json CHANGED
@@ -40,6 +40,20 @@
40
40
  ]
41
41
  }
42
42
  ],
43
+ "UserPromptSubmit": [
44
+ {
45
+ "hooks": [
46
+ {
47
+ "type": "command",
48
+ "command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/turn-tracker.sh"
49
+ },
50
+ {
51
+ "type": "command",
52
+ "command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/session-duration-tracker.sh"
53
+ }
54
+ ]
55
+ }
56
+ ],
43
57
  "PostToolUse": [
44
58
  {
45
59
  "matcher": "*",
@@ -61,6 +75,28 @@
61
75
  }
62
76
  ]
63
77
  }
78
+ ],
79
+ "SessionStart": [
80
+ {
81
+ "matcher": "*",
82
+ "hooks": [
83
+ {
84
+ "type": "command",
85
+ "command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/session-start-tracker.sh"
86
+ }
87
+ ]
88
+ }
89
+ ],
90
+ "SessionEnd": [
91
+ {
92
+ "matcher": "*",
93
+ "hooks": [
94
+ {
95
+ "type": "command",
96
+ "command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/session-end-tracker.sh"
97
+ }
98
+ ]
99
+ }
64
100
  ]
65
101
  }
66
102
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlooker-community/ecosystem",
3
- "version": "0.3.3",
3
+ "version": "0.5.0",
4
4
  "description": "Agents, skills, hooks, commands, rules, and MCP configurations that power [Onlooker](https://onlooker.dev)",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env bash
2
+ # Onlooker Session Duration Tracker
3
+ # Invoked by UserPromptSubmit when the user submits a prompt.
4
+ #
5
+ # Updates session_duration_ms on the tracker and injects turn + elapsed time
6
+ # into Claude's context via UserPromptSubmit additionalContext.
7
+ #
8
+ # Usage:
9
+ # echo "$INPUT" | session-duration-tracker.sh
10
+
11
+ set -uo pipefail # No -e: never block prompt submission
12
+
13
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
+ source "$SCRIPT_DIR/../lib/validate-path.sh"
15
+ source "$SCRIPT_DIR/../lib/session-tracker.sh"
16
+
17
+ hook_register "session-duration-tracker" "Session Duration Tracker" "Surfaces session elapsed time on each user prompt"
18
+
19
+ INPUT=$(cat)
20
+ hook_set_context "$INPUT" "UserPromptSubmit"
21
+
22
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
23
+
24
+ session_tracker_update_duration "$SESSION_ID" || hook_failure "Failed to update session duration"
25
+
26
+ CONTEXT=$(session_tracker_build_duration_context "$SESSION_ID")
27
+ if [[ -n "$CONTEXT" ]]; then
28
+ jq -n \
29
+ --arg ctx "$CONTEXT" \
30
+ '{
31
+ hookSpecificOutput: {
32
+ hookEventName: "UserPromptSubmit",
33
+ additionalContext: $ctx
34
+ }
35
+ }'
36
+ fi
37
+
38
+ hook_success
39
+ exit 0
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bash
2
+ # Onlooker Session End Tracker
3
+ # Invoked by SessionEnd (matcher: *) when a session ends.
4
+ #
5
+ # Emits session.end with duration and turn count, then cleans up hook bus dirs.
6
+ # Default SessionEnd budget is 1.5s — keep this hook fast.
7
+ #
8
+ # Usage:
9
+ # echo "$INPUT" | session-end-tracker.sh
10
+
11
+ set -uo pipefail # No -e: never block session termination
12
+
13
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
+ source "$SCRIPT_DIR/../lib/validate-path.sh"
15
+ source "$SCRIPT_DIR/../lib/onlooker-schema.sh"
16
+ source "$SCRIPT_DIR/../lib/tool-history.sh"
17
+ source "$SCRIPT_DIR/../lib/session-tracker.sh"
18
+
19
+ hook_register "session-end-tracker" "Session End Tracker" "Records session.end and cleans up session resources"
20
+
21
+ INPUT=$(cat)
22
+ hook_set_context "$INPUT" "SessionEnd"
23
+
24
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
25
+
26
+ PAYLOAD=$(session_tracker_build_end_payload "$SESSION_ID" "$INPUT")
27
+ if [[ -n "$PAYLOAD" ]]; then
28
+ session_tracker_emit "$SESSION_ID" "session.end" "$PAYLOAD" \
29
+ || hook_failure "Failed to emit session.end"
30
+ fi
31
+
32
+ hook_bus_cleanup
33
+
34
+ hook_success
35
+ exit 0
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env bash
2
+ # Onlooker Session Start Tracker
3
+ # Invoked by SessionStart (matcher: *) when a session starts or resumes.
4
+ #
5
+ # Initializes per-session tracker state and emits session.start for
6
+ # startup, resume, and clear sources (compact is metadata-only).
7
+ #
8
+ # Usage:
9
+ # echo "$INPUT" | session-start-tracker.sh
10
+
11
+ set -uo pipefail # No -e: never block session startup
12
+
13
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
+ source "$SCRIPT_DIR/../lib/validate-path.sh"
15
+ source "$SCRIPT_DIR/../lib/onlooker-schema.sh"
16
+ source "$SCRIPT_DIR/../lib/tool-history.sh"
17
+ source "$SCRIPT_DIR/../lib/session-tracker.sh"
18
+
19
+ hook_register "session-start-tracker" "Session Start Tracker" "Records session.start and initializes session tracker"
20
+
21
+ INPUT=$(cat)
22
+ hook_set_context "$INPUT" "SessionStart"
23
+
24
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
25
+ SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"')
26
+
27
+ session_tracker_record_start "$SESSION_ID" "$INPUT" || hook_failure "Failed to record session start metadata"
28
+
29
+ # Compaction reuses the session; do not emit another session.start.
30
+ if [[ "$SOURCE" != "compact" ]]; then
31
+ PAYLOAD=$(session_tracker_build_start_payload "$INPUT")
32
+ if [[ -n "$PAYLOAD" ]]; then
33
+ session_tracker_emit "$SESSION_ID" "session.start" "$PAYLOAD" \
34
+ || hook_failure "Failed to emit session.start"
35
+ fi
36
+ fi
37
+
38
+ hook_success
39
+ exit 0
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bash
2
+ # Onlooker Turn Tracker
3
+ # Invoked by UserPromptSubmit when the user submits a prompt.
4
+ #
5
+ # Advances per-session turn_number (first prompt stays at turn 1) and emits
6
+ # canonical session.prompt for telemetry.
7
+ #
8
+ # Usage:
9
+ # echo "$INPUT" | turn-tracker.sh
10
+
11
+ set -uo pipefail # No -e: never block prompt submission
12
+
13
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
+ source "$SCRIPT_DIR/../lib/validate-path.sh"
15
+ source "$SCRIPT_DIR/../lib/onlooker-schema.sh"
16
+ source "$SCRIPT_DIR/../lib/tool-history.sh"
17
+ source "$SCRIPT_DIR/../lib/session-tracker.sh"
18
+ source "$SCRIPT_DIR/../lib/turn-tracker.sh"
19
+
20
+ hook_register "turn-tracker" "Turn Tracker" "Tracks conversation turns and emits session.prompt"
21
+
22
+ INPUT=$(cat)
23
+ hook_set_context "$INPUT" "UserPromptSubmit"
24
+
25
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
26
+ PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""')
27
+
28
+ turn_tracker_on_user_prompt "$SESSION_ID" || hook_failure "Failed to advance turn state"
29
+ turn_state_export "$SESSION_ID"
30
+
31
+ PAYLOAD=$(turn_tracker_build_prompt_payload "$SESSION_ID" "$PROMPT")
32
+ if [[ -n "$PAYLOAD" ]]; then
33
+ session_tracker_emit "$SESSION_ID" "session.prompt" "$PAYLOAD" \
34
+ || hook_failure "Failed to emit session.prompt"
35
+ fi
36
+
37
+ hook_success
38
+ exit 0
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env bash
2
+ # Session lifecycle helpers — session.start / session.end canonical events.
3
+ #
4
+ # Source after validate-path.sh, onlooker-schema.sh, and tool-history.sh:
5
+ # source "$CLAUDE_PLUGIN_ROOT/scripts/lib/session-tracker.sh"
6
+
7
+ # Milliseconds since epoch (macOS-compatible).
8
+ session_tracker_now_ms() {
9
+ if [[ "$(uname)" == "Darwin" ]]; then
10
+ python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || date +%s000
11
+ else
12
+ date +%s%3N 2>/dev/null || date +%s000
13
+ fi
14
+ }
15
+
16
+ # Optional git_branch and git_commit for a working directory (empty when not a repo).
17
+ session_tracker_git_context() {
18
+ local cwd="${1:-}"
19
+ local branch="" commit=""
20
+ [[ -z "$cwd" ]] && return 0
21
+
22
+ if git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
23
+ branch=$(git -C "$cwd" branch --show-current 2>/dev/null) || branch=""
24
+ commit=$(git -C "$cwd" rev-parse --short HEAD 2>/dev/null) || commit=""
25
+ fi
26
+
27
+ printf '%s\n%s' "$branch" "$commit"
28
+ }
29
+
30
+ # Map Claude Code SessionEnd reason to schema end_reason.
31
+ session_tracker_map_end_reason() {
32
+ local reason="${1:-other}"
33
+ case "$reason" in
34
+ clear | logout | prompt_input_exit) echo "user_exit" ;;
35
+ timeout) echo "timeout" ;;
36
+ error) echo "error" ;;
37
+ task_complete) echo "task_complete" ;;
38
+ *) echo "unknown" ;;
39
+ esac
40
+ }
41
+
42
+ # Merge session start metadata into the per-session tracker file.
43
+ # Usage: session_tracker_record_start "$SESSION_ID" "$INPUT_JSON"
44
+ session_tracker_record_start() {
45
+ local session_id="${1:-}"
46
+ local input_json="${2:-}"
47
+ [[ -z "$session_id" || "$session_id" == "null" || -z "$input_json" ]] && return 0
48
+
49
+ turn_state_ensure_session "$session_id" || return 1
50
+
51
+ local tracker_file="$ONLOOKER_SESSION_TRACKERS_DIR/$session_id"
52
+ local now_ms source model cwd transcript_path agent_type
53
+ now_ms=$(session_tracker_now_ms)
54
+ source=$(echo "$input_json" | jq -r '.source // ""' 2>/dev/null) || source=""
55
+ model=$(echo "$input_json" | jq -r '.model // ""' 2>/dev/null) || model=""
56
+ cwd=$(echo "$input_json" | jq -r '.cwd // ""' 2>/dev/null) || cwd=""
57
+ transcript_path=$(echo "$input_json" | jq -r '.transcript_path // ""' 2>/dev/null) || transcript_path=""
58
+ agent_type=$(echo "$input_json" | jq -r '.agent_type // ""' 2>/dev/null) || agent_type=""
59
+
60
+ local temp_file
61
+ temp_file=$(mktemp)
62
+ if ! jq \
63
+ --argjson start_ms "$now_ms" \
64
+ --arg source "$source" \
65
+ --arg model "$model" \
66
+ --arg cwd "$cwd" \
67
+ --arg transcript "$transcript_path" \
68
+ --arg agent_type "$agent_type" \
69
+ '.start_time_ms = $start_ms
70
+ | .start_source = (if $source != "" then $source else .start_source end)
71
+ | .model = (if $model != "" then $model else .model end)
72
+ | .cwd = (if $cwd != "" then $cwd else .cwd end)
73
+ | .transcript_path = (if $transcript != "" then $transcript else .transcript_path end)
74
+ | .agent_type = (if $agent_type != "" then $agent_type else .agent_type end)' \
75
+ "$tracker_file" >"$temp_file" 2>/dev/null; then
76
+ rm -f "$temp_file"
77
+ return 1
78
+ fi
79
+ mv "$temp_file" "$tracker_file"
80
+ }
81
+
82
+ # Build session.start payload JSON from hook input and tracker state.
83
+ # Usage: payload=$(session_tracker_build_start_payload "$INPUT_JSON")
84
+ session_tracker_build_start_payload() {
85
+ local input_json="${1:-}"
86
+ local cwd
87
+ cwd=$(echo "$input_json" | jq -r '.cwd // ""' 2>/dev/null) || cwd=""
88
+ [[ -z "$cwd" ]] && cwd="$(pwd)"
89
+
90
+ local git_lines branch commit
91
+ git_lines=$(session_tracker_git_context "$cwd")
92
+ branch=$(echo "$git_lines" | sed -n '1p')
93
+ commit=$(echo "$git_lines" | sed -n '2p')
94
+
95
+ jq -n \
96
+ --arg wd "$cwd" \
97
+ --arg branch "$branch" \
98
+ --arg commit "$commit" \
99
+ '{
100
+ working_directory: $wd
101
+ }
102
+ + (if $branch != "" then {git_branch: $branch} else {} end)
103
+ + (if $commit != "" then {git_commit: $commit} else {} end)'
104
+ }
105
+
106
+ # Build session.end payload JSON from hook input and tracker file.
107
+ # Usage: payload=$(session_tracker_build_end_payload "$SESSION_ID" "$INPUT_JSON")
108
+ session_tracker_build_end_payload() {
109
+ local session_id="${1:-}"
110
+ local input_json="${2:-}"
111
+ [[ -z "$session_id" || "$session_id" == "null" ]] && return 1
112
+
113
+ local tracker_file="$ONLOOKER_SESSION_TRACKERS_DIR/$session_id"
114
+ local now_ms start_ms turn_count reason end_reason duration_ms
115
+ now_ms=$(session_tracker_now_ms)
116
+ reason=$(echo "$input_json" | jq -r '.reason // "other"' 2>/dev/null) || reason="other"
117
+ end_reason=$(session_tracker_map_end_reason "$reason")
118
+
119
+ if [[ -f "$tracker_file" ]]; then
120
+ start_ms=$(jq -r '.start_time_ms // 0' "$tracker_file" 2>/dev/null) || start_ms=0
121
+ turn_count=$(jq -r '.turn_number // 1' "$tracker_file" 2>/dev/null) || turn_count=1
122
+ else
123
+ start_ms=0
124
+ turn_count=1
125
+ fi
126
+
127
+ if [[ "$start_ms" =~ ^[0-9]+$ ]] && (( start_ms > 0 )); then
128
+ duration_ms=$((now_ms - start_ms))
129
+ else
130
+ duration_ms=0
131
+ fi
132
+ (( duration_ms < 0 )) && duration_ms=0
133
+
134
+ jq -n \
135
+ --argjson duration_ms "$duration_ms" \
136
+ --argjson turn_count "$turn_count" \
137
+ --arg end_reason "$end_reason" \
138
+ '{
139
+ duration_ms: $duration_ms,
140
+ turn_count: $turn_count,
141
+ end_reason: $end_reason
142
+ }'
143
+ }
144
+
145
+ # Elapsed session time in milliseconds (0 when start_time_ms is unset).
146
+ # Usage: duration_ms=$(session_tracker_duration_ms "$SESSION_ID")
147
+ session_tracker_duration_ms() {
148
+ local session_id="${1:-}"
149
+ [[ -z "$session_id" || "$session_id" == "null" ]] && echo 0 && return 0
150
+
151
+ local tracker_file="$ONLOOKER_SESSION_TRACKERS_DIR/$session_id"
152
+ local now_ms start_ms duration_ms
153
+ now_ms=$(session_tracker_now_ms)
154
+
155
+ if [[ ! -f "$tracker_file" ]]; then
156
+ echo 0
157
+ return 0
158
+ fi
159
+
160
+ start_ms=$(jq -r '.start_time_ms // 0' "$tracker_file" 2>/dev/null) || start_ms=0
161
+ if [[ ! "$start_ms" =~ ^[0-9]+$ ]] || (( start_ms <= 0 )); then
162
+ echo 0
163
+ return 0
164
+ fi
165
+
166
+ duration_ms=$((now_ms - start_ms))
167
+ (( duration_ms < 0 )) && duration_ms=0
168
+ echo "$duration_ms"
169
+ }
170
+
171
+ # Human-readable duration for hook context (e.g. "12m 34s", "45s").
172
+ # Usage: label=$(session_tracker_format_duration 754000)
173
+ session_tracker_format_duration() {
174
+ local duration_ms="${1:-0}"
175
+ [[ ! "$duration_ms" =~ ^[0-9]+$ ]] && duration_ms=0
176
+
177
+ local total_sec=$((duration_ms / 1000))
178
+ local hours=$((total_sec / 3600))
179
+ local minutes=$(((total_sec % 3600) / 60))
180
+ local seconds=$((total_sec % 60))
181
+
182
+ if (( hours > 0 )); then
183
+ printf '%dh %dm' "$hours" "$minutes"
184
+ elif (( minutes > 0 )); then
185
+ printf '%dm %ds' "$minutes" "$seconds"
186
+ else
187
+ printf '%ds' "$seconds"
188
+ fi
189
+ }
190
+
191
+ # Persist session_duration_ms on the per-session tracker file.
192
+ # Usage: session_tracker_update_duration "$SESSION_ID"
193
+ session_tracker_update_duration() {
194
+ local session_id="${1:-}"
195
+ [[ -z "$session_id" || "$session_id" == "null" ]] && return 0
196
+
197
+ turn_state_ensure_session "$session_id" || return 1
198
+
199
+ local tracker_file="$ONLOOKER_SESSION_TRACKERS_DIR/$session_id"
200
+ local duration_ms
201
+ duration_ms=$(session_tracker_duration_ms "$session_id")
202
+
203
+ local temp_file
204
+ temp_file=$(mktemp)
205
+ if ! jq --argjson duration_ms "$duration_ms" \
206
+ '.session_duration_ms = $duration_ms' \
207
+ "$tracker_file" >"$temp_file" 2>/dev/null; then
208
+ rm -f "$temp_file"
209
+ return 1
210
+ fi
211
+ mv "$temp_file" "$tracker_file"
212
+ }
213
+
214
+ # Build additionalContext line for UserPromptSubmit (turn + elapsed time).
215
+ # Usage: context=$(session_tracker_build_duration_context "$SESSION_ID")
216
+ session_tracker_build_duration_context() {
217
+ local session_id="${1:-}"
218
+ local duration_ms turn_number elapsed
219
+ duration_ms=$(session_tracker_duration_ms "$session_id")
220
+ elapsed=$(session_tracker_format_duration "$duration_ms")
221
+
222
+ if [[ -f "$ONLOOKER_SESSION_TRACKERS_DIR/$session_id" ]]; then
223
+ turn_number=$(jq -r '.turn_number // 1' "$ONLOOKER_SESSION_TRACKERS_DIR/$session_id" 2>/dev/null) || turn_number=1
224
+ else
225
+ turn_number=1
226
+ fi
227
+
228
+ printf 'Onlooker session: turn %s · elapsed %s' "$turn_number" "$elapsed"
229
+ }
230
+
231
+ # Emit a validated canonical session event and append to logs.
232
+ # Usage: session_tracker_emit "$SESSION_ID" "session.start" "$payload_json"
233
+ session_tracker_emit() {
234
+ local session_id="${1:-}"
235
+ local event_type="${2:-}"
236
+ local payload_json="${3:-}"
237
+ [[ -z "$session_id" || -z "$event_type" || -z "$payload_json" ]] && return 0
238
+
239
+ local params event
240
+ params=$(jq -n \
241
+ --arg plugin "${ONLOOKER_PLUGIN_NAME:-onlooker}" \
242
+ --arg sid "$session_id" \
243
+ --arg type "$event_type" \
244
+ --argjson payload "$payload_json" \
245
+ '{plugin: $plugin, session_id: $sid, event_type: $type, payload: $payload}')
246
+
247
+ event=$(printf '%s' "$params" | ONLOOKER_DIR="$ONLOOKER_DIR" ONLOOKER_PLUGIN_NAME="$ONLOOKER_PLUGIN_NAME" \
248
+ node "${_ONLOOKER_EVENT_JS:-${CLAUDE_PLUGIN_ROOT:-}/scripts/lib/onlooker-event.mjs}" emit 2>/dev/null) || return 1
249
+
250
+ tool_history_append "$session_id" "$event" || return 1
251
+ onlooker_append_event "$event" || return 1
252
+ }
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bash
2
+ # Turn tracking helpers for UserPromptSubmit hooks.
3
+ #
4
+ # Source after validate-path.sh, onlooker-schema.sh, tool-history.sh, session-tracker.sh:
5
+ # source "$CLAUDE_PLUGIN_ROOT/scripts/lib/turn-tracker.sh"
6
+
7
+ # Truncate prompt text for session.prompt input_summary.
8
+ # Usage: summary=$(turn_tracker_summarize_prompt "$PROMPT")
9
+ turn_tracker_summarize_prompt() {
10
+ local prompt="${1:-}"
11
+ [[ -z "$prompt" ]] && return 0
12
+
13
+ local summary
14
+ summary=$(printf '%s' "$prompt" | tr '\n' ' ' | sed 's/ */ /g' | sed 's/^ //;s/ $//')
15
+ if ((${#summary} > 200)); then
16
+ summary="${summary:0:200}…"
17
+ fi
18
+ printf '%s' "$summary"
19
+ }
20
+
21
+ # Advance turn state when the user submits a prompt (first prompt stays at turn 1).
22
+ # Usage: turn_tracker_on_user_prompt "$SESSION_ID"
23
+ turn_tracker_on_user_prompt() {
24
+ local session_id="${1:-}"
25
+ [[ -z "$session_id" || "$session_id" == "null" ]] && return 0
26
+
27
+ turn_state_ensure_session "$session_id" || return 1
28
+
29
+ local tracker_file="$ONLOOKER_SESSION_TRACKERS_DIR/$session_id"
30
+ local seen
31
+ seen=$(jq -r '.user_prompts_seen // false' "$tracker_file" 2>/dev/null) || seen="false"
32
+
33
+ if [[ "$seen" == "true" ]]; then
34
+ turn_state_next_turn "$session_id" || return 1
35
+ else
36
+ local temp_file
37
+ temp_file=$(mktemp)
38
+ if ! jq '.user_prompts_seen = true | .turn_tool_seq = 0' \
39
+ "$tracker_file" >"$temp_file" 2>/dev/null; then
40
+ rm -f "$temp_file"
41
+ return 1
42
+ fi
43
+ mv "$temp_file" "$tracker_file"
44
+ fi
45
+ }
46
+
47
+ # Build session.prompt payload for the current turn.
48
+ # Usage: payload=$(turn_tracker_build_prompt_payload "$SESSION_ID" "$PROMPT")
49
+ turn_tracker_build_prompt_payload() {
50
+ local session_id="${1:-}"
51
+ local prompt="${2:-}"
52
+ [[ -z "$session_id" || "$session_id" == "null" ]] && return 1
53
+
54
+ local turn_number summary
55
+ if [[ -f "$ONLOOKER_SESSION_TRACKERS_DIR/$session_id" ]]; then
56
+ turn_number=$(jq -r '.turn_number // 1' "$ONLOOKER_SESSION_TRACKERS_DIR/$session_id" 2>/dev/null) || turn_number=1
57
+ else
58
+ turn_number=1
59
+ fi
60
+
61
+ summary=$(turn_tracker_summarize_prompt "$prompt")
62
+
63
+ jq -n \
64
+ --argjson turn_number "$turn_number" \
65
+ --arg summary "$summary" \
66
+ '{turn_number: $turn_number}
67
+ + (if $summary != "" then {input_summary: $summary} else {} end)'
68
+ }
@@ -84,6 +84,52 @@ setup_file() {
84
84
  [[ "$hook_cmd" == *tool-history-tracker.sh ]]
85
85
  }
86
86
 
87
+ @test "hooks.json UserPromptSubmit references turn and session-duration trackers" {
88
+ run jq -e '.hooks.UserPromptSubmit[0].hooks | length == 2' "${REPO_ROOT}/hooks/hooks.json"
89
+ [ "$status" -eq 0 ]
90
+
91
+ local turn_cmd duration_cmd
92
+ turn_cmd=$(jq -r '.hooks.UserPromptSubmit[0].hooks[0].command' "${REPO_ROOT}/hooks/hooks.json")
93
+ duration_cmd=$(jq -r '.hooks.UserPromptSubmit[0].hooks[1].command' "${REPO_ROOT}/hooks/hooks.json")
94
+ [[ "$turn_cmd" == *turn-tracker.sh ]]
95
+ [[ "$duration_cmd" == *session-duration-tracker.sh ]]
96
+
97
+ for hook_cmd in "$turn_cmd" "$duration_cmd"; do
98
+ local script_path="${hook_cmd//\$CLAUDE_PLUGIN_ROOT/$REPO_ROOT}"
99
+ script_path="${script_path//\"/}"
100
+ run test -x "$script_path"
101
+ [ "$status" -eq 0 ]
102
+ done
103
+ }
104
+
105
+ @test "hooks.json SessionStart references session-start-tracker" {
106
+ run jq -e '.hooks.SessionStart[0].matcher == "*"' "${REPO_ROOT}/hooks/hooks.json"
107
+ [ "$status" -eq 0 ]
108
+
109
+ local hook_cmd
110
+ hook_cmd=$(jq -r '.hooks.SessionStart[0].hooks[0].command' "${REPO_ROOT}/hooks/hooks.json")
111
+ [[ "$hook_cmd" == *session-start-tracker.sh ]]
112
+
113
+ local script_path="${hook_cmd//\$CLAUDE_PLUGIN_ROOT/$REPO_ROOT}"
114
+ script_path="${script_path//\"/}"
115
+ run test -x "$script_path"
116
+ [ "$status" -eq 0 ]
117
+ }
118
+
119
+ @test "hooks.json SessionEnd references session-end-tracker" {
120
+ run jq -e '.hooks.SessionEnd[0].matcher == "*"' "${REPO_ROOT}/hooks/hooks.json"
121
+ [ "$status" -eq 0 ]
122
+
123
+ local hook_cmd
124
+ hook_cmd=$(jq -r '.hooks.SessionEnd[0].hooks[0].command' "${REPO_ROOT}/hooks/hooks.json")
125
+ [[ "$hook_cmd" == *session-end-tracker.sh ]]
126
+
127
+ local script_path="${hook_cmd//\$CLAUDE_PLUGIN_ROOT/$REPO_ROOT}"
128
+ script_path="${script_path//\"/}"
129
+ run test -x "$script_path"
130
+ [ "$status" -eq 0 ]
131
+ }
132
+
87
133
  @test "plugin.json is valid JSON" {
88
134
  run jq -e '.name and .version' "${REPO_ROOT}/.claude-plugin/plugin.json"
89
135
  [ "$status" -eq 0 ]
@@ -0,0 +1,75 @@
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/session-tracker.sh
9
+ source "${REPO_ROOT}/scripts/lib/onlooker-schema.sh"
10
+ source "${REPO_ROOT}/scripts/lib/tool-history.sh"
11
+ source "${REPO_ROOT}/scripts/lib/session-tracker.sh"
12
+ }
13
+
14
+ @test "session-start-tracker emits session.start for startup source" {
15
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/session-start-startup.json"
16
+ local history_file="${ONLOOKER_SESSION_HISTORY_DIR}/session-start-001.jsonl"
17
+ rm -f "$history_file"
18
+
19
+ run bash -c "cat '${fixture}' | '${REPO_ROOT}/scripts/hooks/session-start-tracker.sh' 2>/dev/null"
20
+ [ "$status" -eq 0 ]
21
+
22
+ [ -f "$history_file" ]
23
+ jq -e '.event_type == "session.start"
24
+ and .session_id == "session-start-001"
25
+ and .payload.working_directory == "/project/repo"' \
26
+ "$history_file" >/dev/null
27
+
28
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/session-start-001"
29
+ jq -e '.start_source == "startup" and (.start_time_ms | type) == "number"' "$tracker" >/dev/null
30
+ }
31
+
32
+ @test "session-start-tracker does not emit session.start for compact source" {
33
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/session-start-compact.json"
34
+ local history_file="${ONLOOKER_SESSION_HISTORY_DIR}/session-start-002.jsonl"
35
+ rm -f "$history_file"
36
+
37
+ run bash -c "cat '${fixture}' | '${REPO_ROOT}/scripts/hooks/session-start-tracker.sh' 2>/dev/null"
38
+ [ "$status" -eq 0 ]
39
+ [ ! -f "$history_file" ]
40
+
41
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/session-start-002"
42
+ jq -e '.start_source == "compact"' "$tracker" >/dev/null
43
+ }
44
+
45
+ @test "session-end-tracker emits session.end with duration and turn count" {
46
+ local start_fixture="${REPO_ROOT}/test/fixtures/hook-inputs/session-start-startup.json"
47
+ local end_fixture="${REPO_ROOT}/test/fixtures/hook-inputs/session-end-other.json"
48
+ local session_id="session-end-001"
49
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
50
+ local history_file="${ONLOOKER_SESSION_HISTORY_DIR}/${session_id}.jsonl"
51
+
52
+ rm -f "$history_file" "$tracker"
53
+
54
+ # Seed tracker as if session had been running
55
+ turn_state_ensure_session "$session_id"
56
+ local past_ms
57
+ past_ms=$(python3 -c 'import time; print(int((time.time() - 2) * 1000))' 2>/dev/null || echo 0)
58
+ jq --argjson start_ms "$past_ms" '.start_time_ms = $start_ms | .turn_number = 3' "$tracker" >"${tracker}.tmp"
59
+ mv "${tracker}.tmp" "$tracker"
60
+
61
+ run bash -c "cat '${end_fixture}' | '${REPO_ROOT}/scripts/hooks/session-end-tracker.sh' 2>/dev/null"
62
+ [ "$status" -eq 0 ]
63
+
64
+ jq -e '.event_type == "session.end"
65
+ and .session_id == "session-end-001"
66
+ and .payload.turn_count == 3
67
+ and .payload.end_reason == "unknown"
68
+ and (.payload.duration_ms | type) == "number"
69
+ and .payload.duration_ms >= 0' \
70
+ "$history_file" >/dev/null
71
+ }
72
+
73
+ @test "session_tracker_map_end_reason maps logout to user_exit" {
74
+ [ "$(session_tracker_map_end_reason logout)" = "user_exit" ]
75
+ }
@@ -0,0 +1,74 @@
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
+ source "${REPO_ROOT}/scripts/lib/onlooker-schema.sh"
9
+ source "${REPO_ROOT}/scripts/lib/tool-history.sh"
10
+ source "${REPO_ROOT}/scripts/lib/session-tracker.sh"
11
+ source "${REPO_ROOT}/scripts/lib/turn-tracker.sh"
12
+ }
13
+
14
+ @test "turn-tracker emits session.prompt on first user prompt at turn 1" {
15
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/user-prompt-submit.json"
16
+ local session_id="prompt-session-001"
17
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
18
+ local history_file="${ONLOOKER_SESSION_HISTORY_DIR}/${session_id}.jsonl"
19
+ rm -f "$tracker" "$history_file"
20
+
21
+ turn_state_ensure_session "$session_id"
22
+ jq '.start_time_ms = 1000' "$tracker" >"${tracker}.tmp" && mv "${tracker}.tmp" "$tracker"
23
+
24
+ run bash -c "cat '${fixture}' | '${REPO_ROOT}/scripts/hooks/turn-tracker.sh' 2>/dev/null"
25
+ [ "$status" -eq 0 ]
26
+
27
+ jq -e '.turn_number == 1 and .user_prompts_seen == true' "$tracker" >/dev/null
28
+ jq -e '.event_type == "session.prompt"
29
+ and .payload.turn_number == 1
30
+ and (.payload.input_summary | contains("UserPromptSubmit"))' \
31
+ "$history_file" >/dev/null
32
+ }
33
+
34
+ @test "turn-tracker increments turn on second user prompt" {
35
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/user-prompt-submit-turn2.json"
36
+ local session_id="prompt-session-002"
37
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
38
+ rm -f "$tracker"
39
+
40
+ turn_state_ensure_session "$session_id"
41
+ jq '.user_prompts_seen = true | .turn_number = 1' "$tracker" >"${tracker}.tmp" && mv "${tracker}.tmp" "$tracker"
42
+
43
+ cat "$fixture" | "${REPO_ROOT}/scripts/hooks/turn-tracker.sh" >/dev/null 2>&1
44
+
45
+ jq -e '.turn_number == 2 and .turn_tool_seq == 0' "$tracker" >/dev/null
46
+ }
47
+
48
+ @test "session-duration-tracker outputs additionalContext with elapsed time" {
49
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/user-prompt-submit.json"
50
+ local session_id="prompt-session-001"
51
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
52
+ rm -f "$tracker"
53
+
54
+ turn_state_ensure_session "$session_id"
55
+ local past_ms
56
+ past_ms=$(python3 -c 'import time; print(int((time.time() - 65) * 1000))' 2>/dev/null || echo 0)
57
+ jq --argjson start_ms "$past_ms" \
58
+ '.start_time_ms = $start_ms | .turn_number = 2' \
59
+ "$tracker" >"${tracker}.tmp" && mv "${tracker}.tmp" "$tracker"
60
+
61
+ run bash -c "cat '${fixture}' | '${REPO_ROOT}/scripts/hooks/session-duration-tracker.sh' 2>/dev/null"
62
+ [ "$status" -eq 0 ]
63
+
64
+ echo "$output" | jq -e \
65
+ '.hookSpecificOutput.hookEventName == "UserPromptSubmit"
66
+ and (.hookSpecificOutput.additionalContext | contains("turn 2"))
67
+ and (.hookSpecificOutput.additionalContext | contains("elapsed"))' >/dev/null
68
+
69
+ jq -e '(.session_duration_ms | type) == "number" and .session_duration_ms >= 60000' "$tracker" >/dev/null
70
+ }
71
+
72
+ @test "session_tracker_format_duration renders minutes and seconds" {
73
+ [ "$(session_tracker_format_duration 65000)" = "1m 5s" ]
74
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "session_id": "session-end-001",
3
+ "transcript_path": "/tmp/transcript.jsonl",
4
+ "cwd": "/project/repo",
5
+ "hook_event_name": "SessionEnd",
6
+ "reason": "other"
7
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "session_id": "session-start-002",
3
+ "transcript_path": "/tmp/transcript.jsonl",
4
+ "cwd": "/project/repo",
5
+ "hook_event_name": "SessionStart",
6
+ "source": "compact",
7
+ "model": "claude-sonnet-4-6"
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "session_id": "session-start-001",
3
+ "transcript_path": "/tmp/transcript.jsonl",
4
+ "cwd": "/project/repo",
5
+ "hook_event_name": "SessionStart",
6
+ "source": "startup",
7
+ "model": "claude-sonnet-4-6"
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "session_id": "prompt-session-002",
3
+ "transcript_path": "/tmp/transcript.jsonl",
4
+ "cwd": "/project/repo",
5
+ "permission_mode": "default",
6
+ "hook_event_name": "UserPromptSubmit",
7
+ "prompt": "Follow-up prompt for turn increment test"
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "session_id": "prompt-session-001",
3
+ "transcript_path": "/tmp/transcript.jsonl",
4
+ "cwd": "/project/repo",
5
+ "permission_mode": "default",
6
+ "hook_event_name": "UserPromptSubmit",
7
+ "prompt": "Add turn tracking hooks for UserPromptSubmit"
8
+ }