@onlooker-community/ecosystem 0.8.0 → 0.10.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.github/workflows/release.yml +0 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +41 -0
- package/hooks/hooks.json +20 -0
- package/package.json +3 -3
- package/scripts/hooks/worktree-tracker.sh +121 -0
- package/scripts/lib/onlooker-event.mjs +138 -10
- package/scripts/lib/onlooker-schema.sh +1 -0
- package/scripts/lib/worktree-tracker.sh +148 -0
- package/test/bats/config.bats +22 -0
- package/test/bats/read-chunk-tracking.bats +73 -0
- package/test/bats/tool-history-tracker.bats +1 -0
- package/test/bats/validate-path.bats +1 -1
- package/test/bats/worktree-tracker.bats +129 -0
- package/test/fixtures/hook-inputs/post-tool-use-read-chunked.json +15 -0
- package/test/fixtures/hook-inputs/worktree-create.json +8 -0
- package/test/fixtures/hook-inputs/worktree-remove.json +8 -0
- package/test/node/schema-events.test.mjs +79 -1
- package/.claude/settings.local.json +0 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,47 @@
|
|
|
7
7
|
|
|
8
8
|
# Changelog
|
|
9
9
|
|
|
10
|
+
## [0.10.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.9.0...ecosystem-v0.10.0) (2026-05-22)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **hooks:** enrich tool.file.read with read chunking observability ([#25](https://github.com/onlooker-community/ecosystem/issues/25)) ([8eb23c8](https://github.com/onlooker-community/ecosystem/commit/8eb23c8f4f03dfbeb701a30de1fa50c1c8ee48ac))
|
|
16
|
+
|
|
17
|
+
## [0.9.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.8.0...ecosystem-v0.9.0) (2026-05-22)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Features
|
|
21
|
+
|
|
22
|
+
* add configuration and hooks for agent spawn tracking ([3ef4590](https://github.com/onlooker-community/ecosystem/commit/3ef459006bbbda246604bdd1ffaf9af0a59f9740))
|
|
23
|
+
* add settings.json for plugin configuration ([67fbdfe](https://github.com/onlooker-community/ecosystem/commit/67fbdfe37f067a45801e7d0355c4a533b687f6b2))
|
|
24
|
+
* **hooks:** add PreCompact and PostCompact context compaction trackers ([#15](https://github.com/onlooker-community/ecosystem/issues/15)) ([1ec5632](https://github.com/onlooker-community/ecosystem/commit/1ec5632404676ed8b35d324b79ad71a2e9093505))
|
|
25
|
+
* **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))
|
|
26
|
+
* **hooks:** add TaskCreated and TaskCompleted task lifecycle trackers ([#21](https://github.com/onlooker-community/ecosystem/issues/21)) ([986ffa8](https://github.com/onlooker-community/ecosystem/commit/986ffa84bdd857a464ca0d556671628190ed27bc))
|
|
27
|
+
* **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))
|
|
28
|
+
* **hooks:** add WorktreeCreate and WorktreeRemove lifecycle trackers ([#24](https://github.com/onlooker-community/ecosystem/issues/24)) ([ff55e39](https://github.com/onlooker-community/ecosystem/commit/ff55e397a0c0adc3e76f66aba12c6b237149ad17))
|
|
29
|
+
* **hooks:** emit canonical schema events for tool history :sparkles: ([1e49a24](https://github.com/onlooker-community/ecosystem/commit/1e49a24bfb930942fa477b594395ef352618f574))
|
|
30
|
+
* **hooks:** track skill usage via skill.invoked events ([23fff0f](https://github.com/onlooker-community/ecosystem/commit/23fff0f0bfad8ab91788d8c45a0457d099d2e870))
|
|
31
|
+
* **hooks:** track tool call sequence on every PreToolUse :sparkles: ([0ad9546](https://github.com/onlooker-community/ecosystem/commit/0ad95465cc22a237e26115a67814a6e7b2951b1d))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
### Bug Fixes
|
|
35
|
+
|
|
36
|
+
* **ci:** apply release-please extra-files for Claude plugin manifests ([#17](https://github.com/onlooker-community/ecosystem/issues/17)) ([da9913c](https://github.com/onlooker-community/ecosystem/commit/da9913ca4f7497280edc34f8c64baa903c1e6754))
|
|
37
|
+
* **ci:** checkout release tag before npm publish :relieved: ([bc7bbdc](https://github.com/onlooker-community/ecosystem/commit/bc7bbdc7a886a55ba8f04fe09bfa60043648c766))
|
|
38
|
+
* **ci:** grant id-token write for npm provenance on publish ([c78c9f0](https://github.com/onlooker-community/ecosystem/commit/c78c9f054c1d48ca8a83d0d26b76ce991fffe51b))
|
|
39
|
+
* **ci:** parse release-please paths_released JSON for npm publish ([749e1a0](https://github.com/onlooker-community/ecosystem/commit/749e1a02b563f37f81a8da21fc3f6e10e179314a))
|
|
40
|
+
* **ci:** stop upgrading npm globally before publish ([a7c7a0e](https://github.com/onlooker-community/ecosystem/commit/a7c7a0e1f25aee1bbb75bdd2af130dbc276480a6))
|
|
41
|
+
* **ci:** use HTTPS repository URL for npm provenance ([a7e8927](https://github.com/onlooker-community/ecosystem/commit/a7e89275c5a025a8afee009853265b717091f6ca))
|
|
42
|
+
* **package:** update repository URL format in package.json ([591ce9f](https://github.com/onlooker-community/ecosystem/commit/591ce9f54dd605ec04ceb77b9dcca40b3e08621e))
|
|
43
|
+
|
|
44
|
+
## [0.8.0](https://github.com/onlooker-community/ecosystem/compare/v0.7.2...v0.8.0) (2026-05-22)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
### Features
|
|
48
|
+
|
|
49
|
+
* **hooks:** add TaskCreated and TaskCompleted task lifecycle trackers ([#21](https://github.com/onlooker-community/ecosystem/issues/21)) ([986ffa8](https://github.com/onlooker-community/ecosystem/commit/986ffa84bdd857a464ca0d556671628190ed27bc))
|
|
50
|
+
|
|
10
51
|
## [0.7.2](https://github.com/onlooker-community/ecosystem/compare/v0.7.1...v0.7.2) (2026-05-22)
|
|
11
52
|
|
|
12
53
|
|
package/hooks/hooks.json
CHANGED
|
@@ -157,6 +157,26 @@
|
|
|
157
157
|
}
|
|
158
158
|
]
|
|
159
159
|
}
|
|
160
|
+
],
|
|
161
|
+
"WorktreeCreate": [
|
|
162
|
+
{
|
|
163
|
+
"hooks": [
|
|
164
|
+
{
|
|
165
|
+
"type": "command",
|
|
166
|
+
"command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/worktree-tracker.sh"
|
|
167
|
+
}
|
|
168
|
+
]
|
|
169
|
+
}
|
|
170
|
+
],
|
|
171
|
+
"WorktreeRemove": [
|
|
172
|
+
{
|
|
173
|
+
"hooks": [
|
|
174
|
+
{
|
|
175
|
+
"type": "command",
|
|
176
|
+
"command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/worktree-tracker.sh"
|
|
177
|
+
}
|
|
178
|
+
]
|
|
179
|
+
}
|
|
160
180
|
]
|
|
161
181
|
}
|
|
162
182
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlooker-community/ecosystem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
12
|
-
"url": "https://github.com/onlooker-community/ecosystem"
|
|
12
|
+
"url": "git+https://github.com/onlooker-community/ecosystem.git"
|
|
13
13
|
},
|
|
14
14
|
"homepage": "https://github.com/onlooker-community/ecosystem#readme",
|
|
15
15
|
"bugs": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"onlooker-install": "install.sh"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@onlooker-community/schema": "^1.4.
|
|
22
|
+
"@onlooker-community/schema": "^1.4.1"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
25
|
"postinstall": "echo '\\n onlooker-ecosystem installed!\\n Run: npx onlooker-install typescript\\n Docs: https://github.com/onlooker-community/ecosystem\\n'",
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Onlooker Worktree Tracker
|
|
3
|
+
# Invoked by WorktreeCreate and WorktreeRemove for isolated agent/git worktree sessions.
|
|
4
|
+
#
|
|
5
|
+
# WorktreeCreate replaces default git behavior: this hook creates the worktree, records
|
|
6
|
+
# telemetry, and prints the absolute worktree path on stdout (stderr for diagnostics).
|
|
7
|
+
# WorktreeRemove records telemetry and removes the git worktree when present.
|
|
8
|
+
#
|
|
9
|
+
# Records canonical tool.shell.exec events (interim until worktree.* schema types exist) to:
|
|
10
|
+
# ~/.onlooker/session-history/<session_id>.jsonl
|
|
11
|
+
# ~/.onlooker/logs/onlooker-events.jsonl
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# echo "$INPUT" | worktree-tracker.sh
|
|
15
|
+
|
|
16
|
+
set -uo pipefail
|
|
17
|
+
|
|
18
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
source "$SCRIPT_DIR/../lib/validate-path.sh"
|
|
20
|
+
source "$SCRIPT_DIR/../lib/onlooker-schema.sh"
|
|
21
|
+
source "$SCRIPT_DIR/../lib/session-tracker.sh"
|
|
22
|
+
source "$SCRIPT_DIR/../lib/tool-history.sh"
|
|
23
|
+
source "$SCRIPT_DIR/../lib/worktree-tracker.sh"
|
|
24
|
+
|
|
25
|
+
hook_register "worktree-tracker" "Worktree Tracker" "Creates/removes git worktrees and records lifecycle telemetry"
|
|
26
|
+
|
|
27
|
+
INPUT=$(cat)
|
|
28
|
+
|
|
29
|
+
HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // ""')
|
|
30
|
+
hook_set_context "$INPUT" "$HOOK_EVENT"
|
|
31
|
+
|
|
32
|
+
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
|
|
33
|
+
CWD=$(echo "$INPUT" | jq -r '.cwd // ""')
|
|
34
|
+
|
|
35
|
+
turn_state_export "$SESSION_ID"
|
|
36
|
+
|
|
37
|
+
worktree_tracker_emit() {
|
|
38
|
+
local enriched_input="${1:-}"
|
|
39
|
+
local record
|
|
40
|
+
record=$(worktree_tracker_build_record "$enriched_input")
|
|
41
|
+
if [[ -n "$record" ]]; then
|
|
42
|
+
worktree_tracker_append "$SESSION_ID" "$record" || hook_failure "Failed to append session history"
|
|
43
|
+
onlooker_append_event "$record" || hook_failure "Failed to append global event log"
|
|
44
|
+
fi
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
case "$HOOK_EVENT" in
|
|
48
|
+
WorktreeCreate)
|
|
49
|
+
NAME=$(echo "$INPUT" | jq -r '.name // ""')
|
|
50
|
+
if [[ -z "$NAME" ]]; then
|
|
51
|
+
echo "WorktreeCreate requires a worktree name" >&2
|
|
52
|
+
exit 1
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
REPO_ROOT=$(worktree_tracker_repo_root "$CWD")
|
|
56
|
+
if [[ -z "$REPO_ROOT" ]]; then
|
|
57
|
+
echo "WorktreeCreate requires a git repository (cwd: ${CWD:-unknown})" >&2
|
|
58
|
+
exit 1
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
START_MS=$(session_tracker_now_ms)
|
|
62
|
+
WORKTREE_PATH=$(worktree_tracker_git_create "$REPO_ROOT" "$NAME")
|
|
63
|
+
if [[ -z "$WORKTREE_PATH" ]]; then
|
|
64
|
+
echo "Failed to create git worktree for name: $NAME" >&2
|
|
65
|
+
exit 1
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
BRANCH="worktree-${NAME}"
|
|
69
|
+
worktree_tracker_record_created "$SESSION_ID" "$NAME" "$WORKTREE_PATH" "$BRANCH" \
|
|
70
|
+
|| hook_failure "Failed to record worktree start time"
|
|
71
|
+
|
|
72
|
+
END_MS=$(session_tracker_now_ms)
|
|
73
|
+
export ONLOOKER_WORKTREE_DURATION_MS="$((END_MS - START_MS))"
|
|
74
|
+
|
|
75
|
+
ENRICHED=$(echo "$INPUT" | jq \
|
|
76
|
+
--arg path "$WORKTREE_PATH" \
|
|
77
|
+
--arg branch "$BRANCH" \
|
|
78
|
+
--arg repo "$REPO_ROOT" \
|
|
79
|
+
'. + {worktree_path: $path, branch_name: $branch, repo_root: $repo}')
|
|
80
|
+
RECORD=$(worktree_tracker_build_record "$ENRICHED")
|
|
81
|
+
if [[ -n "$RECORD" ]]; then
|
|
82
|
+
worktree_tracker_append "$SESSION_ID" "$RECORD" \
|
|
83
|
+
|| hook_failure "Failed to append session history"
|
|
84
|
+
onlooker_append_event "$RECORD" \
|
|
85
|
+
|| hook_failure "Failed to append global event log"
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
# stdout must be only the absolute worktree path for Claude Code
|
|
89
|
+
printf '%s' "$WORKTREE_PATH"
|
|
90
|
+
exit 0
|
|
91
|
+
;;
|
|
92
|
+
WorktreeRemove)
|
|
93
|
+
WORKTREE_PATH=$(echo "$INPUT" | jq -r '.worktree_path // ""')
|
|
94
|
+
if [[ -z "$WORKTREE_PATH" ]]; then
|
|
95
|
+
hook_success
|
|
96
|
+
exit 0
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
REPO_ROOT=$(worktree_tracker_repo_root "$CWD")
|
|
100
|
+
DURATION_MS=$(worktree_tracker_duration_ms "$SESSION_ID" "$WORKTREE_PATH")
|
|
101
|
+
if [[ -n "$DURATION_MS" ]]; then
|
|
102
|
+
export ONLOOKER_WORKTREE_DURATION_MS="$DURATION_MS"
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
ENRICHED=$(echo "$INPUT" | jq --arg repo "${REPO_ROOT:-}"} '. + {repo_root: $repo}')
|
|
106
|
+
worktree_tracker_emit "$ENRICHED"
|
|
107
|
+
|
|
108
|
+
if [[ -n "$REPO_ROOT" ]]; then
|
|
109
|
+
worktree_tracker_git_remove "$REPO_ROOT" "$WORKTREE_PATH"
|
|
110
|
+
fi
|
|
111
|
+
|
|
112
|
+
worktree_tracker_clear_by_path "$SESSION_ID" "$WORKTREE_PATH"
|
|
113
|
+
|
|
114
|
+
hook_success
|
|
115
|
+
exit 0
|
|
116
|
+
;;
|
|
117
|
+
*)
|
|
118
|
+
hook_success
|
|
119
|
+
exit 0
|
|
120
|
+
;;
|
|
121
|
+
esac
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Uses @onlooker-community/schema for envelope shape and validation.
|
|
5
5
|
*/
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import {
|
|
10
10
|
createEvent,
|
|
@@ -81,6 +81,85 @@ function extractPath(toolInput, toolResponse) {
|
|
|
81
81
|
return toolInput?.file_path ?? toolInput?.path ?? toolResponse?.filePath ?? toolResponse?.path ?? undefined;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** Bytes on disk above which a full read is flagged as large_file_full_read. */
|
|
85
|
+
export const LARGE_FILE_BYTES_ON_DISK = 100_000;
|
|
86
|
+
|
|
87
|
+
const MAX_FILE_LINES_STAT_BYTES = 512 * 1024;
|
|
88
|
+
|
|
89
|
+
function parseNonNegativeInt(value) {
|
|
90
|
+
if (value == null || value === '') return undefined;
|
|
91
|
+
const n = Number.parseInt(String(value), 10);
|
|
92
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parsePositiveInt(value, min = 1) {
|
|
96
|
+
if (value == null || value === '') return undefined;
|
|
97
|
+
const n = Number.parseInt(String(value), 10);
|
|
98
|
+
return Number.isFinite(n) && n >= min ? n : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Derive read_mode and line range from Read tool_input (supports common field aliases).
|
|
103
|
+
*/
|
|
104
|
+
export function extractReadRange(toolInput) {
|
|
105
|
+
const input = toolInput ?? {};
|
|
106
|
+
const offset = parseNonNegativeInt(
|
|
107
|
+
input.offset ?? input.start_line ?? input.start_line_one_indexed ?? input.line_offset,
|
|
108
|
+
);
|
|
109
|
+
const limit = parsePositiveInt(input.limit ?? input.line_limit ?? input.num_lines ?? input.line_count);
|
|
110
|
+
const read_mode = offset != null || limit != null ? 'partial' : 'full';
|
|
111
|
+
return stripUndefined({ read_mode, offset, limit });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Stat file on disk for chunking analytics (line count omitted for very large files).
|
|
116
|
+
*/
|
|
117
|
+
export function measureFileOnDisk(filePath) {
|
|
118
|
+
try {
|
|
119
|
+
if (!filePath || !existsSync(filePath)) return {};
|
|
120
|
+
const st = statSync(filePath);
|
|
121
|
+
if (!st.isFile()) return {};
|
|
122
|
+
const file_bytes_on_disk = st.size;
|
|
123
|
+
let file_lines_on_disk;
|
|
124
|
+
if (st.size <= MAX_FILE_LINES_STAT_BYTES) {
|
|
125
|
+
const text = readFileSync(filePath, 'utf8');
|
|
126
|
+
file_lines_on_disk = text.split('\n').length;
|
|
127
|
+
}
|
|
128
|
+
return stripUndefined({ file_bytes_on_disk, file_lines_on_disk });
|
|
129
|
+
} catch {
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build tool.file.read payload from Read tool hook fields.
|
|
136
|
+
*/
|
|
137
|
+
export function buildToolFileReadPayload(toolInput, toolResponse, options = {}) {
|
|
138
|
+
const path = extractPath(toolInput, toolResponse);
|
|
139
|
+
if (!path) return null;
|
|
140
|
+
|
|
141
|
+
const payload = { path, ...extractReadRange(toolInput) };
|
|
142
|
+
const content = toolResponse?.content;
|
|
143
|
+
if (typeof content === 'string') {
|
|
144
|
+
payload.lines_read = content.split('\n').length;
|
|
145
|
+
payload.file_size_bytes = content.length;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (options.measureOnDisk !== false) {
|
|
149
|
+
Object.assign(payload, measureFileOnDisk(path));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (
|
|
153
|
+
payload.read_mode === 'full' &&
|
|
154
|
+
payload.file_bytes_on_disk != null &&
|
|
155
|
+
payload.file_bytes_on_disk >= LARGE_FILE_BYTES_ON_DISK
|
|
156
|
+
) {
|
|
157
|
+
payload.large_file_full_read = true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return stripUndefined(payload);
|
|
161
|
+
}
|
|
162
|
+
|
|
84
163
|
function stripUndefined(obj) {
|
|
85
164
|
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== ''));
|
|
86
165
|
}
|
|
@@ -197,6 +276,59 @@ export function mapTaskHookInput(hookInput, options) {
|
|
|
197
276
|
return { valid: true, event: result.event };
|
|
198
277
|
}
|
|
199
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Map WorktreeCreate / WorktreeRemove hook input to tool.shell.exec (interim until
|
|
281
|
+
* worktree.* event types exist in @onlooker-community/schema).
|
|
282
|
+
*/
|
|
283
|
+
export function mapWorktreeHookInput(hookInput, options) {
|
|
284
|
+
const { onlookerDir, plugin, runtime = 'claude-code', adapter_id = 'ecosystem.hooks' } = options;
|
|
285
|
+
const hookEvent = hookInput?.hook_event_name;
|
|
286
|
+
const sessionId = hookInput?.session_id ?? 'unknown';
|
|
287
|
+
const cwd = hookInput?.cwd;
|
|
288
|
+
|
|
289
|
+
let command;
|
|
290
|
+
let worktreePath = hookInput?.worktree_path;
|
|
291
|
+
|
|
292
|
+
if (hookEvent === 'WorktreeCreate') {
|
|
293
|
+
const name = hookInput?.name;
|
|
294
|
+
if (!name) return null;
|
|
295
|
+
const branch = hookInput?.branch_name ?? `worktree-${name}`;
|
|
296
|
+
worktreePath = hookInput?.worktree_path;
|
|
297
|
+
command = `worktree:create name=${name} branch=${branch}${worktreePath ? ` path=${worktreePath}` : ''}`;
|
|
298
|
+
} else if (hookEvent === 'WorktreeRemove') {
|
|
299
|
+
if (!worktreePath) return null;
|
|
300
|
+
command = `worktree:remove path=${worktreePath}`;
|
|
301
|
+
} else {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const durationRaw = process.env.ONLOOKER_WORKTREE_DURATION_MS;
|
|
306
|
+
const durationMs = durationRaw != null && durationRaw !== '' ? Number.parseInt(String(durationRaw), 10) : undefined;
|
|
307
|
+
|
|
308
|
+
const payload = stripUndefined({
|
|
309
|
+
command,
|
|
310
|
+
exit_code: 0,
|
|
311
|
+
duration_ms: Number.isFinite(durationMs) && durationMs >= 0 ? durationMs : undefined,
|
|
312
|
+
working_directory: cwd,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const event = buildCanonicalEvent({
|
|
316
|
+
onlookerDir,
|
|
317
|
+
runtime,
|
|
318
|
+
adapter_id,
|
|
319
|
+
plugin,
|
|
320
|
+
session_id: sessionId,
|
|
321
|
+
event_type: TOOL_SHELL_EXEC,
|
|
322
|
+
payload,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const result = validate(event);
|
|
326
|
+
if (!result.valid) {
|
|
327
|
+
return { valid: false, errors: result.errors, event_type: TOOL_SHELL_EXEC };
|
|
328
|
+
}
|
|
329
|
+
return { valid: true, event: result.event };
|
|
330
|
+
}
|
|
331
|
+
|
|
200
332
|
/**
|
|
201
333
|
* Map Claude Code hook input to a canonical event.
|
|
202
334
|
* Returns null when the hook input is not mapped to a schema event type.
|
|
@@ -208,6 +340,9 @@ export function mapHookInputToCanonical(hookInput, options) {
|
|
|
208
340
|
const taskMapped = mapTaskHookInput(hookInput, options);
|
|
209
341
|
if (taskMapped) return taskMapped;
|
|
210
342
|
|
|
343
|
+
const worktreeMapped = mapWorktreeHookInput(hookInput, options);
|
|
344
|
+
if (worktreeMapped) return worktreeMapped;
|
|
345
|
+
|
|
211
346
|
const { onlookerDir, plugin, runtime = 'claude-code', adapter_id = 'ecosystem.hooks' } = options;
|
|
212
347
|
|
|
213
348
|
const toolName = hookInput?.tool_name;
|
|
@@ -223,16 +358,9 @@ export function mapHookInputToCanonical(hookInput, options) {
|
|
|
223
358
|
|
|
224
359
|
switch (toolName) {
|
|
225
360
|
case 'Read': {
|
|
226
|
-
|
|
227
|
-
if (!
|
|
361
|
+
payload = buildToolFileReadPayload(toolInput, toolResponse);
|
|
362
|
+
if (!payload) return null;
|
|
228
363
|
eventType = TOOL_FILE_READ;
|
|
229
|
-
payload = { path };
|
|
230
|
-
const content = toolResponse?.content;
|
|
231
|
-
if (typeof content === 'string') {
|
|
232
|
-
const lines = content.split('\n').length;
|
|
233
|
-
payload.lines_read = lines;
|
|
234
|
-
payload.file_size_bytes = content.length;
|
|
235
|
-
}
|
|
236
364
|
break;
|
|
237
365
|
}
|
|
238
366
|
case 'Write': {
|
|
@@ -27,6 +27,7 @@ onlooker_event_from_hook() {
|
|
|
27
27
|
printf '%s' "$hook_input" | ONLOOKER_DIR="$ONLOOKER_DIR" ONLOOKER_PLUGIN_NAME="$ONLOOKER_PLUGIN_NAME" \
|
|
28
28
|
ONLOOKER_TURN_NUMBER="${ONLOOKER_TURN_NUMBER:-}" \
|
|
29
29
|
ONLOOKER_TASK_DURATION_MS="${ONLOOKER_TASK_DURATION_MS:-}" \
|
|
30
|
+
ONLOOKER_WORKTREE_DURATION_MS="${ONLOOKER_WORKTREE_DURATION_MS:-}" \
|
|
30
31
|
node "$_ONLOOKER_EVENT_JS" emit-from-hook 2>/dev/null
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Worktree lifecycle helpers — git worktree create/remove and telemetry state.
|
|
3
|
+
#
|
|
4
|
+
# Source after validate-path.sh, onlooker-schema.sh, session-tracker.sh, and tool-history.sh:
|
|
5
|
+
# source "$CLAUDE_PLUGIN_ROOT/scripts/lib/worktree-tracker.sh"
|
|
6
|
+
|
|
7
|
+
# Resolve repository root from hook cwd (must be inside a git work tree).
|
|
8
|
+
# Usage: repo_root=$(worktree_tracker_repo_root "$CWD")
|
|
9
|
+
worktree_tracker_repo_root() {
|
|
10
|
+
local cwd="${1:-}"
|
|
11
|
+
[[ -z "$cwd" ]] && return 1
|
|
12
|
+
git -C "$cwd" rev-parse --show-toplevel 2>/dev/null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
# Create a Claude-style git worktree; prints absolute path on stdout, diagnostics on stderr.
|
|
16
|
+
# Usage: path=$(worktree_tracker_git_create "$REPO_ROOT" "$NAME")
|
|
17
|
+
worktree_tracker_git_create() {
|
|
18
|
+
local repo_root="${1:-}"
|
|
19
|
+
local name="${2:-}"
|
|
20
|
+
[[ -z "$repo_root" || -z "$name" ]] && return 1
|
|
21
|
+
|
|
22
|
+
local worktree_dir="${repo_root}/.claude/worktrees/${name}"
|
|
23
|
+
local branch="worktree-${name}"
|
|
24
|
+
|
|
25
|
+
mkdir -p "${repo_root}/.claude/worktrees"
|
|
26
|
+
|
|
27
|
+
if [[ -d "$worktree_dir" ]]; then
|
|
28
|
+
(cd "$worktree_dir" && pwd -P)
|
|
29
|
+
return 0
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
local base_ref="HEAD"
|
|
33
|
+
if git -C "$repo_root" rev-parse --abbrev-ref HEAD >/dev/null 2>&1; then
|
|
34
|
+
base_ref=$(git -C "$repo_root" rev-parse --abbrev-ref HEAD)
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch}" 2>/dev/null; then
|
|
38
|
+
git -C "$repo_root" worktree add "$worktree_dir" "$branch" >&2 || return 1
|
|
39
|
+
else
|
|
40
|
+
git -C "$repo_root" worktree add -b "$branch" "$worktree_dir" "$base_ref" >&2 || return 1
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
(cd "$worktree_dir" && pwd -P)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Remove a git worktree when it is registered for the repo (best-effort).
|
|
47
|
+
# Usage: worktree_tracker_git_remove "$REPO_ROOT" "$WORKTREE_PATH"
|
|
48
|
+
worktree_tracker_git_remove() {
|
|
49
|
+
local repo_root="${1:-}"
|
|
50
|
+
local worktree_path="${2:-}"
|
|
51
|
+
[[ -z "$repo_root" || -z "$worktree_path" ]] && return 0
|
|
52
|
+
|
|
53
|
+
local resolved_path="$worktree_path"
|
|
54
|
+
if [[ -d "$worktree_path" ]]; then
|
|
55
|
+
resolved_path=$(cd "$worktree_path" && pwd -P)
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
if git -C "$repo_root" worktree list --porcelain 2>/dev/null | grep -Fq "worktree ${resolved_path}"; then
|
|
59
|
+
git -C "$repo_root" worktree remove --force "$resolved_path" >&2 || true
|
|
60
|
+
fi
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Record worktree creation in the per-session tracker for duration on remove.
|
|
64
|
+
# Usage: worktree_tracker_record_created "$SESSION_ID" "$NAME" "$PATH" "$BRANCH"
|
|
65
|
+
worktree_tracker_record_created() {
|
|
66
|
+
local session_id="${1:-}"
|
|
67
|
+
local name="${2:-}"
|
|
68
|
+
local worktree_path="${3:-}"
|
|
69
|
+
local branch="${4:-}"
|
|
70
|
+
[[ -z "$session_id" || -z "$name" || -z "$worktree_path" ]] && return 0
|
|
71
|
+
|
|
72
|
+
turn_state_ensure_session "$session_id" || return 1
|
|
73
|
+
|
|
74
|
+
local tracker_file="${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
|
|
75
|
+
local now_ms
|
|
76
|
+
now_ms=$(session_tracker_now_ms)
|
|
77
|
+
|
|
78
|
+
local temp_file
|
|
79
|
+
temp_file=$(mktemp)
|
|
80
|
+
if jq --arg name "$name" --arg path "$worktree_path" --arg branch "$branch" --argjson ms "$now_ms" \
|
|
81
|
+
'.worktrees[$name] = {path: $path, branch: $branch, start_time_ms: $ms}' \
|
|
82
|
+
"$tracker_file" >"$temp_file" 2>/dev/null; then
|
|
83
|
+
mv "$temp_file" "$tracker_file"
|
|
84
|
+
else
|
|
85
|
+
rm -f "$temp_file"
|
|
86
|
+
return 1
|
|
87
|
+
fi
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Compute duration for a worktree path; prints empty when unknown.
|
|
91
|
+
# Usage: duration_ms=$(worktree_tracker_duration_ms "$SESSION_ID" "$WORKTREE_PATH")
|
|
92
|
+
worktree_tracker_duration_ms() {
|
|
93
|
+
local session_id="${1:-}"
|
|
94
|
+
local worktree_path="${2:-}"
|
|
95
|
+
[[ -z "$session_id" || -z "$worktree_path" ]] && return 0
|
|
96
|
+
|
|
97
|
+
local tracker_file="${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
|
|
98
|
+
[[ ! -f "$tracker_file" ]] && return 0
|
|
99
|
+
|
|
100
|
+
local start_ms
|
|
101
|
+
start_ms=$(
|
|
102
|
+
jq -r --arg path "$worktree_path" '
|
|
103
|
+
[.worktrees // {} | to_entries[] | select(.value.path == $path) | .value.start_time_ms] | first // empty
|
|
104
|
+
' "$tracker_file" 2>/dev/null
|
|
105
|
+
)
|
|
106
|
+
[[ -z "$start_ms" || "$start_ms" == "null" ]] && return 0
|
|
107
|
+
|
|
108
|
+
local now_ms elapsed
|
|
109
|
+
now_ms=$(session_tracker_now_ms)
|
|
110
|
+
elapsed=$((now_ms - start_ms))
|
|
111
|
+
if [[ "$elapsed" -ge 0 ]]; then
|
|
112
|
+
printf '%s' "$elapsed"
|
|
113
|
+
fi
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# Remove worktree timing entry after removal telemetry is recorded.
|
|
117
|
+
# Usage: worktree_tracker_clear_by_path "$SESSION_ID" "$WORKTREE_PATH"
|
|
118
|
+
worktree_tracker_clear_by_path() {
|
|
119
|
+
local session_id="${1:-}"
|
|
120
|
+
local worktree_path="${2:-}"
|
|
121
|
+
[[ -z "$session_id" || -z "$worktree_path" ]] && return 0
|
|
122
|
+
|
|
123
|
+
local tracker_file="${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
|
|
124
|
+
[[ ! -f "$tracker_file" ]] && return 0
|
|
125
|
+
|
|
126
|
+
local temp_file
|
|
127
|
+
temp_file=$(mktemp)
|
|
128
|
+
if jq --arg path "$worktree_path" '
|
|
129
|
+
.worktrees |= with_entries(select(.value.path != $path))
|
|
130
|
+
' "$tracker_file" >"$temp_file" 2>/dev/null; then
|
|
131
|
+
mv "$temp_file" "$tracker_file"
|
|
132
|
+
else
|
|
133
|
+
rm -f "$temp_file"
|
|
134
|
+
fi
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
# Build a canonical event from hook stdin (empty when unmapped).
|
|
138
|
+
# Usage: record=$(worktree_tracker_build_record "$INPUT")
|
|
139
|
+
worktree_tracker_build_record() {
|
|
140
|
+
local input_json="${1:-}"
|
|
141
|
+
onlooker_event_from_hook "$input_json"
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
# Append a canonical worktree event to session history.
|
|
145
|
+
# Usage: worktree_tracker_append "$SESSION_ID" "$event_json"
|
|
146
|
+
worktree_tracker_append() {
|
|
147
|
+
tool_history_append "$1" "$2"
|
|
148
|
+
}
|
package/test/bats/config.bats
CHANGED
|
@@ -197,6 +197,28 @@ setup_file() {
|
|
|
197
197
|
[ "$status" -eq 0 ]
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
@test "hooks.json WorktreeCreate references worktree-tracker" {
|
|
201
|
+
local hook_cmd
|
|
202
|
+
hook_cmd=$(jq -r '.hooks.WorktreeCreate[0].hooks[0].command' "${REPO_ROOT}/hooks/hooks.json")
|
|
203
|
+
[[ "$hook_cmd" == *worktree-tracker.sh ]]
|
|
204
|
+
|
|
205
|
+
local script_path="${hook_cmd//\$CLAUDE_PLUGIN_ROOT/$REPO_ROOT}"
|
|
206
|
+
script_path="${script_path//\"/}"
|
|
207
|
+
run test -x "$script_path"
|
|
208
|
+
[ "$status" -eq 0 ]
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
@test "hooks.json WorktreeRemove references worktree-tracker" {
|
|
212
|
+
local hook_cmd
|
|
213
|
+
hook_cmd=$(jq -r '.hooks.WorktreeRemove[0].hooks[0].command' "${REPO_ROOT}/hooks/hooks.json")
|
|
214
|
+
[[ "$hook_cmd" == *worktree-tracker.sh ]]
|
|
215
|
+
|
|
216
|
+
local script_path="${hook_cmd//\$CLAUDE_PLUGIN_ROOT/$REPO_ROOT}"
|
|
217
|
+
script_path="${script_path//\"/}"
|
|
218
|
+
run test -x "$script_path"
|
|
219
|
+
[ "$status" -eq 0 ]
|
|
220
|
+
}
|
|
221
|
+
|
|
200
222
|
@test "plugin.json is valid JSON" {
|
|
201
223
|
run jq -e '.name and .version' "${REPO_ROOT}/.claude-plugin/plugin.json"
|
|
202
224
|
[ "$status" -eq 0 ]
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
# shellcheck source=../../scripts/lib/onlooker-schema.sh
|
|
8
|
+
source "${REPO_ROOT}/scripts/lib/onlooker-schema.sh"
|
|
9
|
+
# shellcheck source=../../scripts/lib/tool-history.sh
|
|
10
|
+
source "${REPO_ROOT}/scripts/lib/tool-history.sh"
|
|
11
|
+
export CLAUDE_PLUGIN_ROOT="${REPO_ROOT}"
|
|
12
|
+
|
|
13
|
+
LARGE_FILE="${BATS_TEST_TMPDIR}/large-source.ts"
|
|
14
|
+
# > LARGE_FILE_BYTES_ON_DISK (100_000) for large_file_full_read
|
|
15
|
+
printf '%*s\n' 120000 "" >"$LARGE_FILE"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@test "tool_history maps full Read to read_mode full" {
|
|
19
|
+
local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/post-tool-use-read.json"
|
|
20
|
+
local record
|
|
21
|
+
record=$(tool_history_build_record "$(cat "$fixture")")
|
|
22
|
+
echo "$record" | jq -e \
|
|
23
|
+
'.payload.read_mode == "full"
|
|
24
|
+
and .payload.path == "/project/src/main.ts"
|
|
25
|
+
and (.payload.large_file_full_read | not)' \
|
|
26
|
+
>/dev/null
|
|
27
|
+
echo "$record" | onlooker_validate_event
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@test "tool_history maps chunked Read to read_mode partial with offset and limit" {
|
|
31
|
+
local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/post-tool-use-read-chunked.json"
|
|
32
|
+
local record
|
|
33
|
+
record=$(tool_history_build_record "$(cat "$fixture")")
|
|
34
|
+
echo "$record" | jq -e \
|
|
35
|
+
'.payload.read_mode == "partial"
|
|
36
|
+
and .payload.offset == 400
|
|
37
|
+
and .payload.limit == 80
|
|
38
|
+
and .payload.lines_read == 3' \
|
|
39
|
+
>/dev/null
|
|
40
|
+
echo "$record" | onlooker_validate_event
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@test "tool_history flags large_file_full_read for full read of large on-disk file" {
|
|
44
|
+
local input
|
|
45
|
+
input=$(jq -n \
|
|
46
|
+
--arg path "$LARGE_FILE" \
|
|
47
|
+
--arg content "peek\n" \
|
|
48
|
+
'{
|
|
49
|
+
session_id: "history-session-002",
|
|
50
|
+
hook_event_name: "PostToolUse",
|
|
51
|
+
tool_name: "Read",
|
|
52
|
+
tool_input: {file_path: $path},
|
|
53
|
+
tool_response: {content: $content}
|
|
54
|
+
}')
|
|
55
|
+
local record
|
|
56
|
+
record=$(tool_history_build_record "$input")
|
|
57
|
+
echo "$record" | jq -e \
|
|
58
|
+
'.payload.read_mode == "full"
|
|
59
|
+
and .payload.large_file_full_read == true
|
|
60
|
+
and .payload.file_bytes_on_disk > 100000' \
|
|
61
|
+
>/dev/null
|
|
62
|
+
echo "$record" | onlooker_validate_event
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@test "tool-history-tracker appends chunked read to session JSONL" {
|
|
66
|
+
local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/post-tool-use-read-chunked.json"
|
|
67
|
+
local history_file="${ONLOOKER_SESSION_HISTORY_DIR}/history-session-001.jsonl"
|
|
68
|
+
rm -f "$history_file" "${history_file}.lock"
|
|
69
|
+
|
|
70
|
+
cat "$fixture" | "${REPO_ROOT}/scripts/hooks/tool-history-tracker.sh" >/dev/null 2>&1
|
|
71
|
+
|
|
72
|
+
tail -n 1 "$history_file" | jq -e '.payload.read_mode == "partial"' >/dev/null
|
|
73
|
+
}
|
|
@@ -19,6 +19,7 @@ setup() {
|
|
|
19
19
|
'.schema_version == "1.0"
|
|
20
20
|
and .event_type == "tool.file.read"
|
|
21
21
|
and .payload.path == "/project/src/main.ts"
|
|
22
|
+
and .payload.read_mode == "full"
|
|
22
23
|
and .session_id == "history-session-001"' \
|
|
23
24
|
>/dev/null
|
|
24
25
|
echo "$record" | onlooker_validate_event
|
|
@@ -96,7 +96,7 @@ setup() {
|
|
|
96
96
|
export _HOOK_SESSION_ID="emit-session"
|
|
97
97
|
export ONLOOKER_HOOK_TYPE="PreToolUse"
|
|
98
98
|
export ONLOOKER_TOOL_NAME="Read"
|
|
99
|
-
local payload='{"path":"/tmp/example.txt"}'
|
|
99
|
+
local payload='{"path":"/tmp/example.txt","read_mode":"full"}'
|
|
100
100
|
safe_emit "tool.file.read" "$payload"
|
|
101
101
|
[ "$?" -eq 0 ]
|
|
102
102
|
[ -f "$ONLOOKER_EVENTS_LOG" ]
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
# shellcheck source=../../scripts/lib/onlooker-schema.sh
|
|
8
|
+
source "${REPO_ROOT}/scripts/lib/onlooker-schema.sh"
|
|
9
|
+
# shellcheck source=../../scripts/lib/session-tracker.sh
|
|
10
|
+
source "${REPO_ROOT}/scripts/lib/session-tracker.sh"
|
|
11
|
+
# shellcheck source=../../scripts/lib/tool-history.sh
|
|
12
|
+
source "${REPO_ROOT}/scripts/lib/tool-history.sh"
|
|
13
|
+
# shellcheck source=../../scripts/lib/worktree-tracker.sh
|
|
14
|
+
source "${REPO_ROOT}/scripts/lib/worktree-tracker.sh"
|
|
15
|
+
export CLAUDE_PLUGIN_ROOT="${REPO_ROOT}"
|
|
16
|
+
|
|
17
|
+
GIT_REPO="${BATS_TEST_TMPDIR}/git-repo"
|
|
18
|
+
rm -rf "$GIT_REPO"
|
|
19
|
+
mkdir -p "$GIT_REPO"
|
|
20
|
+
git -C "$GIT_REPO" init -q
|
|
21
|
+
git -C "$GIT_REPO" config user.email "test@example.com"
|
|
22
|
+
git -C "$GIT_REPO" config user.name "Test User"
|
|
23
|
+
echo "hello" >"$GIT_REPO/README.md"
|
|
24
|
+
git -C "$GIT_REPO" add README.md
|
|
25
|
+
git -C "$GIT_REPO" commit -q -m "init"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@test "worktree_tracker_build_record maps WorktreeCreate to tool.shell.exec" {
|
|
29
|
+
local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/worktree-create.json"
|
|
30
|
+
local enriched
|
|
31
|
+
enriched=$(jq \
|
|
32
|
+
--arg path "${GIT_REPO}/.claude/worktrees/feature-auth" \
|
|
33
|
+
--arg branch "worktree-feature-auth" \
|
|
34
|
+
'. + {worktree_path: $path, branch_name: $branch}' \
|
|
35
|
+
"$fixture")
|
|
36
|
+
export ONLOOKER_WORKTREE_DURATION_MS=42
|
|
37
|
+
local record
|
|
38
|
+
record=$(worktree_tracker_build_record "$enriched")
|
|
39
|
+
echo "$record" | jq -e \
|
|
40
|
+
'.event_type == "tool.shell.exec"
|
|
41
|
+
and .payload.exit_code == 0
|
|
42
|
+
and .payload.duration_ms == 42
|
|
43
|
+
and (.payload.command | test("worktree:create"))
|
|
44
|
+
and .payload.working_directory == "/project/repo"' \
|
|
45
|
+
>/dev/null
|
|
46
|
+
echo "$record" | onlooker_validate_event
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@test "worktree_tracker_build_record maps WorktreeRemove to tool.shell.exec" {
|
|
50
|
+
local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/worktree-remove.json"
|
|
51
|
+
export ONLOOKER_WORKTREE_DURATION_MS=9000
|
|
52
|
+
local record
|
|
53
|
+
record=$(worktree_tracker_build_record "$(cat "$fixture")")
|
|
54
|
+
echo "$record" | jq -e \
|
|
55
|
+
'.event_type == "tool.shell.exec"
|
|
56
|
+
and (.payload.command | test("worktree:remove"))
|
|
57
|
+
and .payload.duration_ms == 9000' \
|
|
58
|
+
>/dev/null
|
|
59
|
+
echo "$record" | onlooker_validate_event
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@test "worktree-tracker WorktreeCreate prints absolute path on stdout" {
|
|
63
|
+
local history_file="${ONLOOKER_SESSION_HISTORY_DIR}/worktree-session-001.jsonl"
|
|
64
|
+
rm -f "$history_file" "${history_file}.lock"
|
|
65
|
+
rm -f "${ONLOOKER_SESSION_TRACKERS_DIR}/worktree-session-001"
|
|
66
|
+
|
|
67
|
+
local input_file="${BATS_TEST_TMPDIR}/worktree-create-input.json"
|
|
68
|
+
jq \
|
|
69
|
+
--arg cwd "$GIT_REPO" \
|
|
70
|
+
--arg sid "worktree-session-001" \
|
|
71
|
+
'.cwd = $cwd | .session_id = $sid' \
|
|
72
|
+
"${REPO_ROOT}/test/fixtures/hook-inputs/worktree-create.json" >"$input_file"
|
|
73
|
+
|
|
74
|
+
local worktree_path
|
|
75
|
+
worktree_path=$(cat "$input_file" | "${REPO_ROOT}/scripts/hooks/worktree-tracker.sh")
|
|
76
|
+
[ -d "$worktree_path" ]
|
|
77
|
+
[[ "$worktree_path" == "$(cd "$worktree_path" && pwd -P)" ]]
|
|
78
|
+
[ -f "$history_file" ]
|
|
79
|
+
tail -n 1 "$history_file" | jq -e '.event_type == "tool.shell.exec"' >/dev/null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@test "worktree-tracker WorktreeRemove records telemetry and removes worktree" {
|
|
83
|
+
local history_file="${ONLOOKER_SESSION_HISTORY_DIR}/worktree-session-001.jsonl"
|
|
84
|
+
rm -f "$history_file" "${history_file}.lock"
|
|
85
|
+
rm -f "${ONLOOKER_SESSION_TRACKERS_DIR}/worktree-session-001"
|
|
86
|
+
|
|
87
|
+
local create_input_file="${BATS_TEST_TMPDIR}/worktree-create-input.json"
|
|
88
|
+
local remove_input_file="${BATS_TEST_TMPDIR}/worktree-remove-input.json"
|
|
89
|
+
jq \
|
|
90
|
+
--arg cwd "$GIT_REPO" \
|
|
91
|
+
--arg sid "worktree-session-001" \
|
|
92
|
+
'.cwd = $cwd | .session_id = $sid' \
|
|
93
|
+
"${REPO_ROOT}/test/fixtures/hook-inputs/worktree-create.json" >"$create_input_file"
|
|
94
|
+
|
|
95
|
+
local worktree_path
|
|
96
|
+
worktree_path=$(cat "$create_input_file" | "${REPO_ROOT}/scripts/hooks/worktree-tracker.sh")
|
|
97
|
+
|
|
98
|
+
jq \
|
|
99
|
+
--arg cwd "$GIT_REPO" \
|
|
100
|
+
--arg path "$worktree_path" \
|
|
101
|
+
--arg sid "worktree-session-001" \
|
|
102
|
+
'.cwd = $cwd | .worktree_path = $path | .session_id = $sid' \
|
|
103
|
+
"${REPO_ROOT}/test/fixtures/hook-inputs/worktree-remove.json" >"$remove_input_file"
|
|
104
|
+
|
|
105
|
+
run bash -c "cat '${remove_input_file}' | '${REPO_ROOT}/scripts/hooks/worktree-tracker.sh' 2>/dev/null"
|
|
106
|
+
[ "$status" -eq 0 ]
|
|
107
|
+
[ ! -d "$worktree_path" ]
|
|
108
|
+
|
|
109
|
+
tail -n 1 "$history_file" | jq -e \
|
|
110
|
+
'.event_type == "tool.shell.exec"
|
|
111
|
+
and (.payload.command | test("worktree:remove"))' \
|
|
112
|
+
>/dev/null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@test "worktree-tracker mirrors worktree events to global events log" {
|
|
116
|
+
local input
|
|
117
|
+
input=$(jq \
|
|
118
|
+
--arg cwd "$GIT_REPO" \
|
|
119
|
+
'.cwd = $cwd' \
|
|
120
|
+
"${REPO_ROOT}/test/fixtures/hook-inputs/worktree-create.json")
|
|
121
|
+
: >"$ONLOOKER_EVENTS_LOG"
|
|
122
|
+
|
|
123
|
+
printf '%s' "$input" | "${REPO_ROOT}/scripts/hooks/worktree-tracker.sh" >/dev/null
|
|
124
|
+
|
|
125
|
+
tail -n 1 "$ONLOOKER_EVENTS_LOG" | jq -e \
|
|
126
|
+
'.event_type == "tool.shell.exec"
|
|
127
|
+
and (.payload.command | test("worktree:create"))' \
|
|
128
|
+
>/dev/null
|
|
129
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"session_id": "history-session-001",
|
|
3
|
+
"hook_event_name": "PostToolUse",
|
|
4
|
+
"tool_name": "Read",
|
|
5
|
+
"tool_use_id": "toolu_read_002",
|
|
6
|
+
"duration_ms": 18,
|
|
7
|
+
"tool_input": {
|
|
8
|
+
"file_path": "/project/src/large-module.ts",
|
|
9
|
+
"offset": 400,
|
|
10
|
+
"limit": 80
|
|
11
|
+
},
|
|
12
|
+
"tool_response": {
|
|
13
|
+
"content": "// chunk line 1\n// chunk line 2\n"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { test } from 'node:test';
|
|
@@ -7,9 +7,13 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { validate } from '@onlooker-community/schema';
|
|
8
8
|
import {
|
|
9
9
|
buildCanonicalEvent,
|
|
10
|
+
buildToolFileReadPayload,
|
|
11
|
+
extractReadRange,
|
|
12
|
+
LARGE_FILE_BYTES_ON_DISK,
|
|
10
13
|
mapHookInputToCanonical,
|
|
11
14
|
mapSkillHookInput,
|
|
12
15
|
mapTaskHookInput,
|
|
16
|
+
mapWorktreeHookInput,
|
|
13
17
|
} from '../../scripts/lib/onlooker-event.mjs';
|
|
14
18
|
|
|
15
19
|
const REPO_ROOT = join(fileURLToPath(new URL('../..', import.meta.url)));
|
|
@@ -31,6 +35,43 @@ test('mapHookInputToCanonical maps PostToolUse Read to tool.file.read', () => {
|
|
|
31
35
|
assert.equal(mapped.event.event_type, 'tool.file.read');
|
|
32
36
|
assert.equal(mapped.event.schema_version, '1.0');
|
|
33
37
|
assert.equal(mapped.event.payload.path, '/project/src/main.ts');
|
|
38
|
+
assert.equal(mapped.event.payload.read_mode, 'full');
|
|
39
|
+
assert.equal(validate(mapped.event).valid, true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('extractReadRange detects partial reads from offset and limit', () => {
|
|
43
|
+
const range = extractReadRange({ offset: 10, limit: 50 });
|
|
44
|
+
assert.equal(range.read_mode, 'partial');
|
|
45
|
+
assert.equal(range.offset, 10);
|
|
46
|
+
assert.equal(range.limit, 50);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('buildToolFileReadPayload flags large_file_full_read', () => {
|
|
50
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'onlooker-read-chunk-'));
|
|
51
|
+
const filePath = join(tmpDir, 'big.txt');
|
|
52
|
+
const bytes = LARGE_FILE_BYTES_ON_DISK + 1;
|
|
53
|
+
writeFileSync(filePath, 'x'.repeat(bytes), 'utf8');
|
|
54
|
+
|
|
55
|
+
const payload = buildToolFileReadPayload({ file_path: filePath }, { content: 'x\n' });
|
|
56
|
+
assert.equal(payload.read_mode, 'full');
|
|
57
|
+
assert.equal(payload.large_file_full_read, true);
|
|
58
|
+
assert.equal(payload.file_bytes_on_disk, bytes);
|
|
59
|
+
|
|
60
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('mapHookInputToCanonical maps chunked Read to partial read_mode', () => {
|
|
64
|
+
const hookInput = loadFixture('post-tool-use-read-chunked.json');
|
|
65
|
+
const tmpDir = join(REPO_ROOT, 'test/tmp-schema-events');
|
|
66
|
+
const mapped = mapHookInputToCanonical(hookInput, {
|
|
67
|
+
onlookerDir: tmpDir,
|
|
68
|
+
plugin: 'onlooker',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
assert.equal(mapped.valid, true);
|
|
72
|
+
assert.equal(mapped.event.payload.read_mode, 'partial');
|
|
73
|
+
assert.equal(mapped.event.payload.offset, 400);
|
|
74
|
+
assert.equal(mapped.event.payload.limit, 80);
|
|
34
75
|
assert.equal(validate(mapped.event).valid, true);
|
|
35
76
|
});
|
|
36
77
|
|
|
@@ -111,6 +152,43 @@ test('mapTaskHookInput maps TaskCompleted to task.complete', () => {
|
|
|
111
152
|
assert.equal(validate(mapped.event).valid, true);
|
|
112
153
|
});
|
|
113
154
|
|
|
155
|
+
test('mapWorktreeHookInput maps WorktreeCreate to tool.shell.exec', () => {
|
|
156
|
+
const hookInput = {
|
|
157
|
+
...loadFixture('worktree-create.json'),
|
|
158
|
+
worktree_path: '/project/repo/.claude/worktrees/feature-auth',
|
|
159
|
+
branch_name: 'worktree-feature-auth',
|
|
160
|
+
};
|
|
161
|
+
const tmpDir = join(REPO_ROOT, 'test/tmp-schema-events');
|
|
162
|
+
const prev = process.env.ONLOOKER_WORKTREE_DURATION_MS;
|
|
163
|
+
process.env.ONLOOKER_WORKTREE_DURATION_MS = '15';
|
|
164
|
+
const mapped = mapWorktreeHookInput(hookInput, {
|
|
165
|
+
onlookerDir: tmpDir,
|
|
166
|
+
plugin: 'onlooker',
|
|
167
|
+
});
|
|
168
|
+
if (prev === undefined) delete process.env.ONLOOKER_WORKTREE_DURATION_MS;
|
|
169
|
+
else process.env.ONLOOKER_WORKTREE_DURATION_MS = prev;
|
|
170
|
+
|
|
171
|
+
assert.equal(mapped.valid, true);
|
|
172
|
+
assert.equal(mapped.event.event_type, 'tool.shell.exec');
|
|
173
|
+
assert.equal(mapped.event.payload.exit_code, 0);
|
|
174
|
+
assert.equal(mapped.event.payload.duration_ms, 15);
|
|
175
|
+
assert.match(mapped.event.payload.command, /worktree:create/);
|
|
176
|
+
assert.equal(validate(mapped.event).valid, true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('mapWorktreeHookInput maps WorktreeRemove to tool.shell.exec', () => {
|
|
180
|
+
const hookInput = loadFixture('worktree-remove.json');
|
|
181
|
+
const tmpDir = join(REPO_ROOT, 'test/tmp-schema-events');
|
|
182
|
+
const mapped = mapWorktreeHookInput(hookInput, {
|
|
183
|
+
onlookerDir: tmpDir,
|
|
184
|
+
plugin: 'onlooker',
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
assert.equal(mapped.valid, true);
|
|
188
|
+
assert.match(mapped.event.payload.command, /worktree:remove/);
|
|
189
|
+
assert.equal(validate(mapped.event).valid, true);
|
|
190
|
+
});
|
|
191
|
+
|
|
114
192
|
test('mapHookInputToCanonical routes TaskCreated through task mapping', () => {
|
|
115
193
|
const hookInput = loadFixture('task-created.json');
|
|
116
194
|
const tmpDir = join(REPO_ROOT, 'test/tmp-schema-events');
|