@onlooker-community/ecosystem 0.3.2 → 0.4.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.
@@ -12,7 +12,7 @@
12
12
  "name": "ecosystem",
13
13
  "source": "./",
14
14
  "description": "Fill this out",
15
- "version": "0.3.2",
15
+ "version": "0.3.3",
16
16
  "author": {
17
17
  "name": "Onlooker Community"
18
18
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ecosystem",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "TODO fill this out",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -0,0 +1,152 @@
1
+ name: Publish npm Package
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*.*.*'
7
+ # Manual trigger for emergency releases.
8
+ workflow_dispatch:
9
+ inputs:
10
+ version:
11
+ description: 'Version to publish (without v prefix)'
12
+ required: true
13
+ type: string
14
+ tag:
15
+ description: 'npm dist-tag (latest, beta, next)'
16
+ required: true
17
+ default: 'latest'
18
+ type: choice
19
+ options:
20
+ - latest
21
+ - beta
22
+ - next
23
+
24
+ permissions:
25
+ contents: read
26
+ id-token: write # for npm provenance
27
+
28
+ jobs:
29
+ validate:
30
+ name: Validate
31
+ runs-on: ubuntu-latest
32
+ outputs:
33
+ version: ${{ steps.version.outputs.value }}
34
+ npm_tag: ${{ steps.tag.outputs.value }}
35
+ is_prerelease: ${{ steps.prerelease.outputs.value }}
36
+ steps:
37
+ - uses: actions/checkout@v4
38
+
39
+ - name: Resolve version
40
+ id: version
41
+ run: |
42
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
43
+ echo "value=${{ inputs.version }}" >> "$GITHUB_OUTPUT"
44
+ else
45
+ echo "value=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
46
+ fi
47
+
48
+ - name: Resolve npm tag
49
+ id: tag
50
+ run: |
51
+ VERSION=${{ steps.version.outputs.value }}
52
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
53
+ echo "value=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
54
+ elif [[ "$VERSION" == *"-beta"* ]]; then
55
+ echo "value=beta" >> "$GITHUB_OUTPUT"
56
+ elif [[ "$VERSION" == *"-rc"* ]]; then
57
+ echo "value=next" >> "$GITHUB_OUTPUT"
58
+ else
59
+ echo "value=latest" >> "$GITHUB_OUTPUT"
60
+ fi
61
+
62
+ - name: Check if prerelease
63
+ id: prerelease
64
+ run: |
65
+ VERSION=${{ steps.version.outputs.value }}
66
+ if [[ "$VERSION" == *"-"* ]]; then
67
+ echo "value=true" >> "$GITHUB_OUTPUT"
68
+ else
69
+ echo "value=false" >> "$GITHUB_OUTPUT"
70
+ fi
71
+
72
+ - name: Verify package.json version matches
73
+ run: |
74
+ PKG_VERSION=$(node -p "require('./package.json').version")
75
+ TAG_VERSION=${{ steps.version.outputs.value }}
76
+ if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
77
+ echo "ERROR: package.json version ($PKG_VERSION) does not match tag ($TAG_VERSION)"
78
+ exit 1
79
+ fi
80
+
81
+ test:
82
+ name: Test
83
+ runs-on: ubuntu-latest
84
+ strategy:
85
+ matrix:
86
+ node: ['20', '22']
87
+ steps:
88
+ - uses: actions/checkout@v4
89
+
90
+ - uses: actions/setup-node@v4
91
+ with:
92
+ node-version: ${{ matrix.node }}
93
+ cache: npm
94
+
95
+ - run: npm ci
96
+ - run: npm run build
97
+ - run: npm test
98
+ - run: npm run typecheck
99
+
100
+ publish:
101
+ name: Publish to npm
102
+ runs-on: ubuntu-latest
103
+ needs: [validate, test]
104
+ environment: npm-publish
105
+ steps:
106
+ - uses: actions/checkout@v4
107
+
108
+ - uses: actions/setup-node@v4
109
+ with:
110
+ node-version: '22'
111
+ registry-url: 'https://registry.npmjs.org'
112
+ cache: npm
113
+
114
+ - run: npm ci
115
+
116
+ - name: Publish
117
+ run: |
118
+ npm publish \
119
+ --tag ${{ needs.validate.outputs.npm_tag }} \
120
+ --access public \
121
+ --provenance
122
+ env:
123
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
124
+
125
+ release:
126
+ name: Create GitHub Release
127
+ runs-on: ubuntu-latest
128
+ needs: [validate, publish]
129
+ permissions:
130
+ contents: write
131
+ steps:
132
+ - uses: actions/checkout@v4
133
+
134
+ - name: Extract changelog entry
135
+ id: changelog
136
+ run: |
137
+ VERSION=${{ needs.validate.outputs.version }}
138
+ NOTES=$(awk "/^## \[?${VERSION}\]?/{found=1; next} found && /^## /{exit} found{print}" CHANGELOG.md)
139
+ if [ -z "$NOTES" ]; then
140
+ NOTES="See CHANGELOG.md for details."
141
+ fi
142
+ echo "notes<<EOF" >> "$GITHUB_OUTPUT"
143
+ echo "$NOTES" >> "$GITHUB_OUTPUT"
144
+ echo "EOF" >> "$GITHUB_OUTPUT"
145
+
146
+ - name: Create GitHub release
147
+ uses: softprops/action-gh-release@v2
148
+ with:
149
+ name: v${{ needs.validate.outputs.version }}
150
+ body: ${{ steps.changelog.outputs.notes }}
151
+ prerelease: ${{ needs.validate.outputs.is_prerelease == 'true' }}
152
+ draft: false
@@ -11,45 +11,19 @@ on:
11
11
  permissions:
12
12
  contents: write
13
13
  pull-requests: write
14
- issues: write
15
- id-token: write
16
14
 
17
15
  jobs:
18
16
  release-please:
19
17
  name: Release Please
20
18
  runs-on: ubuntu-latest
21
19
  steps:
22
- - name: Create Release
23
- id: release
24
- uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0
20
+ - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0
25
21
  with:
22
+ release-type: node
26
23
  # Use a PAT instead of GITHUB_TOKEN so the release PR triggers
27
24
  # downstream workflows (CI, etc.). Events caused by GITHUB_TOKEN
28
25
  # deliberately do not fan out to other workflows; that policy
29
26
  # leaves release PRs unchecked. Set RELEASE_PLEASE_PAT to a
30
27
  # fine-grained token with Contents:write + Pull requests:write
31
28
  # scoped to this repo.
32
- token: ${{ secrets.RELEASE_PLEASE_PAT }}
33
-
34
- # release-please-action does not check out the repo; npm publish needs the
35
- # tagged release tree (package.json, install.sh, hooks, etc.).
36
- - name: Checkout release tag
37
- if: ${{ steps.release.outputs.releases_created == 'true' }}
38
- uses: actions/checkout@v4
39
- with:
40
- ref: ${{ steps.release.outputs.tag_name }}
41
-
42
- - name: Setup Node
43
- if: ${{ steps.release.outputs.releases_created == 'true' }}
44
- uses: actions/setup-node@v6
45
- with:
46
- node-version: '22'
47
- registry-url: 'https://registry.npmjs.org'
48
-
49
- - name: Publish to npm
50
- if: ${{ steps.release.outputs.releases_created == 'true' }}
51
- env:
52
- NPM_CONFIG_PROVENANCE: true
53
- run: |
54
- npm ci
55
- npm publish --access=public
29
+ token: ${{ secrets.RELEASE_PLEASE_PAT }}
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.3.2"
2
+ ".": "0.4.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0](https://github.com/onlooker-community/ecosystem/compare/v0.3.3...v0.4.0) (2026-05-22)
4
+
5
+
6
+ ### Features
7
+
8
+ * **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))
9
+
10
+ ## [0.3.3](https://github.com/onlooker-community/ecosystem/compare/v0.3.2...v0.3.3) (2026-05-22)
11
+
12
+
13
+ ### Chores
14
+
15
+ * enhance release workflow with conditional publishing ([d14a868](https://github.com/onlooker-community/ecosystem/commit/d14a86858dcdeb3ed87aa00985c2c79f9ca8a4d3))
16
+
3
17
  ## [0.3.2](https://github.com/onlooker-community/ecosystem/compare/v0.3.1...v0.3.2) (2026-05-22)
4
18
 
5
19
 
package/hooks/hooks.json CHANGED
@@ -61,6 +61,28 @@
61
61
  }
62
62
  ]
63
63
  }
64
+ ],
65
+ "SessionStart": [
66
+ {
67
+ "matcher": "*",
68
+ "hooks": [
69
+ {
70
+ "type": "command",
71
+ "command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/session-start-tracker.sh"
72
+ }
73
+ ]
74
+ }
75
+ ],
76
+ "SessionEnd": [
77
+ {
78
+ "matcher": "*",
79
+ "hooks": [
80
+ {
81
+ "type": "command",
82
+ "command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/session-end-tracker.sh"
83
+ }
84
+ ]
85
+ }
64
86
  ]
65
87
  }
66
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlooker-community/ecosystem",
3
- "version": "0.3.2",
3
+ "version": "0.4.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,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,166 @@
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
+ # Emit a validated canonical session event and append to logs.
146
+ # Usage: session_tracker_emit "$SESSION_ID" "session.start" "$payload_json"
147
+ session_tracker_emit() {
148
+ local session_id="${1:-}"
149
+ local event_type="${2:-}"
150
+ local payload_json="${3:-}"
151
+ [[ -z "$session_id" || -z "$event_type" || -z "$payload_json" ]] && return 0
152
+
153
+ local params event
154
+ params=$(jq -n \
155
+ --arg plugin "${ONLOOKER_PLUGIN_NAME:-onlooker}" \
156
+ --arg sid "$session_id" \
157
+ --arg type "$event_type" \
158
+ --argjson payload "$payload_json" \
159
+ '{plugin: $plugin, session_id: $sid, event_type: $type, payload: $payload}')
160
+
161
+ event=$(printf '%s' "$params" | ONLOOKER_DIR="$ONLOOKER_DIR" ONLOOKER_PLUGIN_NAME="$ONLOOKER_PLUGIN_NAME" \
162
+ node "${_ONLOOKER_EVENT_JS:-${CLAUDE_PLUGIN_ROOT:-}/scripts/lib/onlooker-event.mjs}" emit 2>/dev/null) || return 1
163
+
164
+ tool_history_append "$session_id" "$event" || return 1
165
+ onlooker_append_event "$event" || return 1
166
+ }
@@ -84,6 +84,34 @@ setup_file() {
84
84
  [[ "$hook_cmd" == *tool-history-tracker.sh ]]
85
85
  }
86
86
 
87
+ @test "hooks.json SessionStart references session-start-tracker" {
88
+ run jq -e '.hooks.SessionStart[0].matcher == "*"' "${REPO_ROOT}/hooks/hooks.json"
89
+ [ "$status" -eq 0 ]
90
+
91
+ local hook_cmd
92
+ hook_cmd=$(jq -r '.hooks.SessionStart[0].hooks[0].command' "${REPO_ROOT}/hooks/hooks.json")
93
+ [[ "$hook_cmd" == *session-start-tracker.sh ]]
94
+
95
+ local script_path="${hook_cmd//\$CLAUDE_PLUGIN_ROOT/$REPO_ROOT}"
96
+ script_path="${script_path//\"/}"
97
+ run test -x "$script_path"
98
+ [ "$status" -eq 0 ]
99
+ }
100
+
101
+ @test "hooks.json SessionEnd references session-end-tracker" {
102
+ run jq -e '.hooks.SessionEnd[0].matcher == "*"' "${REPO_ROOT}/hooks/hooks.json"
103
+ [ "$status" -eq 0 ]
104
+
105
+ local hook_cmd
106
+ hook_cmd=$(jq -r '.hooks.SessionEnd[0].hooks[0].command' "${REPO_ROOT}/hooks/hooks.json")
107
+ [[ "$hook_cmd" == *session-end-tracker.sh ]]
108
+
109
+ local script_path="${hook_cmd//\$CLAUDE_PLUGIN_ROOT/$REPO_ROOT}"
110
+ script_path="${script_path//\"/}"
111
+ run test -x "$script_path"
112
+ [ "$status" -eq 0 ]
113
+ }
114
+
87
115
  @test "plugin.json is valid JSON" {
88
116
  run jq -e '.name and .version' "${REPO_ROOT}/.claude-plugin/plugin.json"
89
117
  [ "$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,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
+ }