@onlooker-community/ecosystem 0.28.1 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.release-please-manifest.json +2 -2
  4. package/CHANGELOG.md +7 -0
  5. package/CLAUDE.md +2 -0
  6. package/docs/plugin-catalog.md +125 -0
  7. package/package.json +3 -3
  8. package/plugins/compass/.claude-plugin/plugin.json +1 -1
  9. package/plugins/compass/CHANGELOG.md +7 -0
  10. package/plugins/compass/README.md +1 -3
  11. package/plugins/compass/config.json +1 -2
  12. package/plugins/compass/docs/design.md +1 -2
  13. package/plugins/compass/scripts/hooks/compass-bash-gate.sh +8 -1
  14. package/plugins/compass/scripts/hooks/compass-pre-tool-use.sh +8 -1
  15. package/plugins/compass/scripts/hooks/compass-record-write.sh +5 -0
  16. package/plugins/compass/scripts/hooks/compass-session-start.sh +0 -8
  17. package/plugins/compass/scripts/lib/compass-evaluator.sh +58 -98
  18. package/plugins/compass/scripts/lib/compass-gate.sh +15 -18
  19. package/plugins/compass/scripts/lib/compass-sanitizer.sh +4 -4
  20. package/plugins/compass/scripts/lib/compass-transcript.sh +79 -112
  21. package/plugins/inspector/.claude-plugin/plugin.json +14 -0
  22. package/plugins/inspector/README.md +155 -0
  23. package/plugins/inspector/config.json +25 -0
  24. package/plugins/inspector/docs/design.md +286 -0
  25. package/plugins/inspector/hooks/hooks.json +33 -0
  26. package/plugins/inspector/scripts/hooks/inspector-post-write.sh +124 -0
  27. package/plugins/inspector/scripts/lib/inspector-config.sh +108 -0
  28. package/plugins/inspector/scripts/lib/inspector-events.sh +82 -0
  29. package/plugins/inspector/scripts/lib/inspector-project-key.sh +55 -0
  30. package/plugins/inspector/scripts/lib/inspector-run.sh +305 -0
  31. package/plugins/inspector/scripts/lib/inspector-ulid.sh +45 -0
  32. package/test/bats/archivist-project-key.bats +79 -0
  33. package/test/bats/archivist-storage.bats +79 -0
  34. package/test/bats/compact-tracker.bats +125 -0
  35. package/test/bats/compass-config.bats +65 -0
  36. package/test/bats/compass-gate.bats +129 -0
  37. package/test/bats/compass-sanitizer.bats +69 -0
  38. package/test/bats/compass-symbolic-skip.bats +88 -0
  39. package/test/bats/compass-transcript.bats +80 -0
  40. package/test/bats/inspector-config.bats +118 -0
  41. package/test/bats/inspector-events.bats +156 -0
  42. package/test/bats/inspector-post-write-hook.bats +164 -0
  43. package/test/bats/inspector-project-key.bats +68 -0
  44. package/test/bats/inspector-ulid.bats +34 -0
  45. package/test/bats/onlooker-schema.bats +111 -0
  46. package/test/bats/prompt-rules.bats +98 -0
  47. package/test/bats/session-tracker.bats +260 -0
  48. package/test/bats/skill-usage-tracker.bats +63 -0
  49. package/test/bats/task-tracker.bats +102 -0
  50. package/test/bats/turn-tracker.bats +180 -0
  51. package/test/bats/validate-path.bats +125 -0
  52. package/test/bats/worktree-tracker.bats +167 -0
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env bash
2
+ # inspector-run.sh — execute the configured checks for a single touched file.
3
+ #
4
+ # Reads: $INSPECTOR_FILE, $INSPECTOR_FILE_RELATIVE, $INSPECTOR_REPO_ROOT,
5
+ # $INSPECTOR_PROJECT_KEY, $INSPECTOR_TOOL_NAME
6
+ # Emits: inspector.check.passed / .failed / .skipped (per check), then
7
+ # inspector.run.completed (once).
8
+ # Writes: a compact per-file summary to stdout, intended to be shown to the
9
+ # agent as PostToolUse additional context.
10
+
11
+ set -uo pipefail
12
+
13
+ _inspector_now_ms() {
14
+ if date +%s%3N &>/dev/null && [[ "$(date +%s%3N)" =~ ^[0-9]{13}$ ]]; then
15
+ date +%s%3N
16
+ else
17
+ python3 -c 'import time; print(int(time.time() * 1000))'
18
+ fi
19
+ }
20
+
21
+ # Substitute ${file}, ${file_relative}, ${repo_root} in each argv element.
22
+ _inspector_expand_argv() {
23
+ local raw_argv_json="$1"
24
+ jq -c \
25
+ --arg file "$INSPECTOR_FILE" \
26
+ --arg rel "$INSPECTOR_FILE_RELATIVE" \
27
+ --arg root "$INSPECTOR_REPO_ROOT" \
28
+ 'map(
29
+ gsub("\\$\\{file\\}"; $file)
30
+ | gsub("\\$\\{file_relative\\}"; $rel)
31
+ | gsub("\\$\\{repo_root\\}"; $root)
32
+ )' <<<"$raw_argv_json"
33
+ }
34
+
35
+ # Run a single check. Stdout: the captured combined output, truncated to
36
+ # output_excerpt_max_bytes. Exit code: the underlying command's exit, or 124 on
37
+ # timeout, or 127 when the command is not on PATH.
38
+ _inspector_invoke_check() {
39
+ local expanded_json="$1"
40
+ local timeout_s="$2"
41
+ local max_bytes="$3"
42
+
43
+ # Build argv array from JSON for safe execution.
44
+ # bash 3.2 (macOS default) has no `mapfile`; collect with a while-read loop.
45
+ local expanded_argv=()
46
+ local _line
47
+ while IFS= read -r _line; do
48
+ expanded_argv+=("$_line")
49
+ done < <(jq -r '.[]' <<<"$expanded_json")
50
+ local cmd="${expanded_argv[0]:-}"
51
+ if [[ -z "$cmd" ]]; then
52
+ printf 'inspector: empty argv\n'
53
+ return 127
54
+ fi
55
+ if ! command -v "$cmd" >/dev/null 2>&1; then
56
+ return 127
57
+ fi
58
+
59
+ local output_file rc=0
60
+ output_file=$(mktemp -t inspector-out.XXXXXX 2>/dev/null) \
61
+ || output_file="/tmp/inspector-out.$$"
62
+
63
+ if command -v timeout >/dev/null 2>&1; then
64
+ timeout "${timeout_s}s" "${expanded_argv[@]}" >"$output_file" 2>&1
65
+ rc=$?
66
+ else
67
+ "${expanded_argv[@]}" >"$output_file" 2>&1
68
+ rc=$?
69
+ fi
70
+
71
+ # Truncate output for the event payload and the agent-facing line.
72
+ local bytes=0
73
+ bytes=$(wc -c <"$output_file" 2>/dev/null || printf '0')
74
+ if (( bytes > max_bytes )); then
75
+ head -c "$max_bytes" "$output_file"
76
+ printf '\n…[truncated]\n'
77
+ else
78
+ cat "$output_file"
79
+ fi
80
+ rm -f "$output_file"
81
+ return "$rc"
82
+ }
83
+
84
+ # Count issues from output — best-effort. One non-empty, non-whitespace line per
85
+ # issue, ignoring trivial header/footer markers. Returns the literal string
86
+ # "null" when the output is empty or only whitespace.
87
+ _inspector_count_issues() {
88
+ local text="$1"
89
+ [[ -z "$text" ]] && { printf 'null'; return; }
90
+ local count
91
+ count=$(printf '%s' "$text" | grep -cE '^[^[:space:]]' || true)
92
+ if [[ -z "$count" || "$count" == "0" ]]; then
93
+ printf 'null'
94
+ else
95
+ printf '%s' "$count"
96
+ fi
97
+ }
98
+
99
+ # Public entrypoint. Iterates the configured checks for the file's extension.
100
+ inspector_run() {
101
+ local checks_json="${1:-[]}"
102
+ local timeout_per_check
103
+ timeout_per_check=$(inspector_config_timeout_per_check)
104
+ local total_timeout
105
+ total_timeout=$(inspector_config_total_timeout)
106
+ local max_bytes
107
+ max_bytes=$(inspector_config_output_excerpt_max_bytes)
108
+ local show_clean=0
109
+ inspector_config_show_clean_runs && show_clean=1
110
+
111
+ local check_count
112
+ check_count=$(jq 'length' <<<"$checks_json")
113
+
114
+ local run_start
115
+ run_start=$(_inspector_now_ms)
116
+ local passed=0 failed=0 skipped=0 ran=0
117
+
118
+ # Buffer agent-facing output so we only print the file header when at least
119
+ # one issue (or, when show_clean is set, one check) is worth reporting.
120
+ local agent_lines=()
121
+ local issues_seen=0
122
+
123
+ local i
124
+ for (( i = 0; i < check_count; i++ )); do
125
+ # Budget check before each run.
126
+ local now_ms
127
+ now_ms=$(_inspector_now_ms)
128
+ if (( (now_ms - run_start) >= (total_timeout * 1000) )); then
129
+ local rem
130
+ rem=$(jq -c --argjson i "$i" '.[$i:]' <<<"$checks_json")
131
+ local rem_count
132
+ rem_count=$(jq 'length' <<<"$rem")
133
+ local j
134
+ for (( j = 0; j < rem_count; j++ )); do
135
+ local r_name r_kind
136
+ r_name=$(jq -r --argjson j "$j" '.[$j].name // "check"' <<<"$rem")
137
+ r_kind=$(jq -r --argjson j "$j" '.[$j].kind // "lint"' <<<"$rem")
138
+ _inspector_emit_skipped "$r_name" "$r_kind" "total_budget_exhausted"
139
+ (( skipped++ ))
140
+ done
141
+ break
142
+ fi
143
+
144
+ local check_name check_kind argv_raw
145
+ check_name=$(jq -r --argjson i "$i" '.[$i].name' <<<"$checks_json")
146
+ check_kind=$(jq -r --argjson i "$i" '.[$i].kind' <<<"$checks_json")
147
+ argv_raw=$(jq -c --argjson i "$i" '.[$i].argv' <<<"$checks_json")
148
+ local argv_expanded
149
+ argv_expanded=$(_inspector_expand_argv "$argv_raw")
150
+
151
+ local check_start
152
+ check_start=$(_inspector_now_ms)
153
+ local output rc=0
154
+ output=$(_inspector_invoke_check "$argv_expanded" "$timeout_per_check" "$max_bytes")
155
+ rc=$?
156
+ local check_end
157
+ check_end=$(_inspector_now_ms)
158
+ local dur=$(( check_end - check_start ))
159
+
160
+ if (( rc == 127 )); then
161
+ # Tool not on PATH.
162
+ _inspector_emit_skipped "$check_name" "$check_kind" "tool_missing"
163
+ (( skipped++ ))
164
+ continue
165
+ fi
166
+
167
+ if (( rc == 124 )); then
168
+ # Timed out — emit skipped + a single agent-facing line.
169
+ _inspector_emit_skipped "$check_name" "$check_kind" "timeout"
170
+ agent_lines+=(" · $check_name timed out after ${timeout_per_check}s")
171
+ (( skipped++ ))
172
+ (( issues_seen++ ))
173
+ continue
174
+ fi
175
+
176
+ (( ran++ ))
177
+ if (( rc == 0 )); then
178
+ (( passed++ ))
179
+ _inspector_emit_passed "$check_name" "$check_kind" "$argv_expanded" "$dur"
180
+ if (( show_clean )); then
181
+ agent_lines+=(" ✓ $check_name (${dur}ms)")
182
+ fi
183
+ else
184
+ (( failed++ ))
185
+ local issue_count
186
+ issue_count=$(_inspector_count_issues "$output")
187
+ _inspector_emit_failed "$check_name" "$check_kind" "$argv_expanded" "$dur" "$rc" "$issue_count" "$output"
188
+ local issues_label="${issue_count}"
189
+ if [[ "$issues_label" == "null" ]]; then
190
+ issues_label="issues"
191
+ else
192
+ issues_label="${issues_label} issue(s)"
193
+ fi
194
+ agent_lines+=(" ✗ $check_name (${issues_label}, exit ${rc})")
195
+ # Append up to 6 issue lines for at-a-glance context.
196
+ local snippet
197
+ snippet=$(printf '%s\n' "$output" | grep -E '^[^[:space:]]' | head -n 6 || true)
198
+ while IFS= read -r line; do
199
+ [[ -z "$line" ]] && continue
200
+ agent_lines+=(" $line")
201
+ done <<<"$snippet"
202
+ (( issues_seen++ ))
203
+ fi
204
+ done
205
+
206
+ local run_end
207
+ run_end=$(_inspector_now_ms)
208
+ local run_dur=$(( run_end - run_start ))
209
+
210
+ _inspector_emit_run_completed "$ran" "$passed" "$failed" "$skipped" "$run_dur"
211
+
212
+ if (( issues_seen > 0 )) || (( show_clean && (passed + failed) > 0 )); then
213
+ printf 'inspector: %s\n' "$INSPECTOR_FILE_RELATIVE"
214
+ printf '%s\n' "${agent_lines[@]}"
215
+ fi
216
+ }
217
+
218
+ _inspector_emit_passed() {
219
+ local name="$1" kind="$2" argv_json="$3" dur="$4"
220
+ local payload
221
+ payload=$(jq -n \
222
+ --arg file "$INSPECTOR_FILE" \
223
+ --arg rel "$INSPECTOR_FILE_RELATIVE" \
224
+ --arg tool "$INSPECTOR_TOOL_NAME" \
225
+ --arg name "$name" \
226
+ --arg kind "$kind" \
227
+ --arg pk "$INSPECTOR_PROJECT_KEY" \
228
+ --argjson argv "$argv_json" \
229
+ --argjson dur "$dur" \
230
+ '{file_path:$file,file_path_relative:$rel,tool_name:$tool,check_name:$name,check_kind:$kind,argv:$argv,duration_ms:$dur,project_key:$pk}')
231
+ inspector_emit_event "inspector.check.passed" "$payload" || true
232
+ }
233
+
234
+ _inspector_emit_failed() {
235
+ local name="$1" kind="$2" argv_json="$3" dur="$4" rc="$5" issues="$6" output="$7"
236
+ local issues_arg
237
+ if [[ "$issues" == "null" ]]; then
238
+ issues_arg="null"
239
+ else
240
+ issues_arg="$issues"
241
+ fi
242
+ local truncated="false"
243
+ [[ "$output" == *"…[truncated]"* ]] && truncated="true"
244
+ local payload
245
+ payload=$(jq -n \
246
+ --arg file "$INSPECTOR_FILE" \
247
+ --arg rel "$INSPECTOR_FILE_RELATIVE" \
248
+ --arg tool "$INSPECTOR_TOOL_NAME" \
249
+ --arg name "$name" \
250
+ --arg kind "$kind" \
251
+ --arg pk "$INSPECTOR_PROJECT_KEY" \
252
+ --arg output "$output" \
253
+ --argjson argv "$argv_json" \
254
+ --argjson dur "$dur" \
255
+ --argjson rc "$rc" \
256
+ --argjson issues "$issues_arg" \
257
+ --argjson truncated "$truncated" \
258
+ '{file_path:$file,file_path_relative:$rel,tool_name:$tool,check_name:$name,check_kind:$kind,argv:$argv,duration_ms:$dur,exit_code:$rc,issue_count:$issues,output_excerpt:$output,output_truncated:$truncated,project_key:$pk}')
259
+ inspector_emit_event "inspector.check.failed" "$payload" || true
260
+ }
261
+
262
+ _inspector_emit_skipped() {
263
+ local name="$1" kind="$2" reason="$3"
264
+ local payload
265
+ payload=$(jq -n \
266
+ --arg file "$INSPECTOR_FILE" \
267
+ --arg rel "$INSPECTOR_FILE_RELATIVE" \
268
+ --arg tool "$INSPECTOR_TOOL_NAME" \
269
+ --arg name "$name" \
270
+ --arg kind "$kind" \
271
+ --arg reason "$reason" \
272
+ --arg pk "$INSPECTOR_PROJECT_KEY" \
273
+ '{file_path:$file,file_path_relative:$rel,tool_name:$tool,check_name:$name,check_kind:$kind,reason:$reason,project_key:$pk}')
274
+ inspector_emit_event "inspector.check.skipped" "$payload" || true
275
+ }
276
+
277
+ inspector_emit_whole_file_skipped() {
278
+ local reason="$1"
279
+ local payload
280
+ payload=$(jq -n \
281
+ --arg file "${INSPECTOR_FILE:-}" \
282
+ --arg rel "${INSPECTOR_FILE_RELATIVE:-}" \
283
+ --arg tool "${INSPECTOR_TOOL_NAME:-}" \
284
+ --arg reason "$reason" \
285
+ --arg pk "${INSPECTOR_PROJECT_KEY:-}" \
286
+ '{file_path:$file,file_path_relative:$rel,tool_name:$tool,reason:$reason,project_key:$pk}')
287
+ inspector_emit_event "inspector.check.skipped" "$payload" || true
288
+ }
289
+
290
+ _inspector_emit_run_completed() {
291
+ local ran="$1" passed="$2" failed="$3" skipped="$4" dur="$5"
292
+ local payload
293
+ payload=$(jq -n \
294
+ --arg file "$INSPECTOR_FILE" \
295
+ --arg rel "$INSPECTOR_FILE_RELATIVE" \
296
+ --arg tool "$INSPECTOR_TOOL_NAME" \
297
+ --arg pk "$INSPECTOR_PROJECT_KEY" \
298
+ --argjson ran "$ran" \
299
+ --argjson passed "$passed" \
300
+ --argjson failed "$failed" \
301
+ --argjson skipped "$skipped" \
302
+ --argjson dur "$dur" \
303
+ '{file_path:$file,file_path_relative:$rel,tool_name:$tool,checks_run:$ran,checks_passed:$passed,checks_failed:$failed,checks_skipped:$skipped,duration_ms:$dur,project_key:$pk}')
304
+ inspector_emit_event "inspector.run.completed" "$payload" || true
305
+ }
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bash
2
+ # inspector-ulid.sh — ULID generation for Inspector.
3
+ #
4
+ # 10-char Crockford Base32 timestamp + 16-char random component = 26 chars.
5
+ #
6
+ # Usage:
7
+ # id=$(inspector_ulid)
8
+
9
+ _INSPECTOR_ULID_ALPHABET="0123456789ABCDEFGHJKMNPQRSTVWXYZ"
10
+
11
+ _inspector_ulid_encode() {
12
+ local n="${1:-0}"
13
+ local len="${2:-10}"
14
+ local result=""
15
+ local i
16
+ for (( i = 0; i < len; i++ )); do
17
+ result="${_INSPECTOR_ULID_ALPHABET:$(( n & 31 )):1}${result}"
18
+ n=$(( n >> 5 ))
19
+ done
20
+ printf '%s' "$result"
21
+ }
22
+
23
+ inspector_ulid() {
24
+ local ts_ms
25
+ if date +%s%3N &>/dev/null && [[ "$(date +%s%3N)" =~ ^[0-9]{13}$ ]]; then
26
+ ts_ms=$(date +%s%3N)
27
+ else
28
+ ts_ms=$(python3 -c 'import time; print(int(time.time() * 1000))')
29
+ fi
30
+
31
+ local ts_encoded
32
+ ts_encoded=$(_inspector_ulid_encode "$ts_ms" 10)
33
+
34
+ local rand_hex
35
+ rand_hex=$(openssl rand -hex 10 2>/dev/null) \
36
+ || rand_hex=$(printf '%020x' $(( (RANDOM * RANDOM & 0xFFFFF) * 0x100000 + (RANDOM * RANDOM & 0xFFFFF) )))
37
+
38
+ local rand_hi rand_lo
39
+ rand_hi=$(( 16#${rand_hex:0:10} ))
40
+ rand_lo=$(( 16#${rand_hex:10:10} ))
41
+ local rand_encoded
42
+ rand_encoded="$(_inspector_ulid_encode "$rand_hi" 8)$(_inspector_ulid_encode "$rand_lo" 8)"
43
+
44
+ printf '%s%s' "$ts_encoded" "$rand_encoded"
45
+ }
@@ -73,3 +73,82 @@ setup() {
73
73
  [ -n "$kb" ]
74
74
  [ "$ka" != "$kb" ]
75
75
  }
76
+
77
+ @test "archivist_project_remote_url prints origin url when set" {
78
+ local d="${BATS_TEST_TMPDIR}/remote-set"
79
+ mkdir -p "$d"
80
+ git -C "$d" init -q
81
+ git -C "$d" remote add origin https://example.com/x.git
82
+
83
+ run archivist_project_remote_url "$d"
84
+ [ "$status" -eq 0 ]
85
+ [ "$output" = "https://example.com/x.git" ]
86
+ }
87
+
88
+ @test "archivist_project_remote_url returns empty when repo has no remote" {
89
+ local d="${BATS_TEST_TMPDIR}/remote-none"
90
+ mkdir -p "$d"
91
+ git -C "$d" init -q
92
+
93
+ run archivist_project_remote_url "$d"
94
+ [ "$status" -eq 0 ]
95
+ [ -z "$output" ]
96
+ }
97
+
98
+ @test "archivist_project_remote_url returns empty for a non-git directory" {
99
+ local d="${BATS_TEST_TMPDIR}/remote-non-git"
100
+ mkdir -p "$d"
101
+
102
+ run archivist_project_remote_url "$d"
103
+ [ "$status" -eq 0 ]
104
+ [ -z "$output" ]
105
+ }
106
+
107
+ @test "archivist_project_remote_url returns empty for a nonexistent path" {
108
+ run archivist_project_remote_url "${BATS_TEST_TMPDIR}/does-not-exist"
109
+ [ "$status" -eq 0 ]
110
+ [ -z "$output" ]
111
+ }
112
+
113
+ @test "archivist_project_repo_root prints the repo toplevel realpath" {
114
+ local d="${BATS_TEST_TMPDIR}/root-repo"
115
+ mkdir -p "$d"
116
+ git -C "$d" init -q
117
+
118
+ # Physical path: matches the function's pwd -P resolution on platforms
119
+ # (e.g. macOS) where the temp dir is reached through a symlink.
120
+ local expected
121
+ expected="$(cd "$d" && pwd -P)"
122
+
123
+ run archivist_project_repo_root "$d"
124
+ [ "$status" -eq 0 ]
125
+ [ "$output" = "$expected" ]
126
+ }
127
+
128
+ @test "archivist_project_repo_root resolves toplevel from a subdirectory" {
129
+ local d="${BATS_TEST_TMPDIR}/root-repo-sub"
130
+ mkdir -p "$d/nested/deep"
131
+ git -C "$d" init -q
132
+
133
+ local expected
134
+ expected="$(cd "$d" && pwd -P)"
135
+
136
+ run archivist_project_repo_root "$d/nested/deep"
137
+ [ "$status" -eq 0 ]
138
+ [ "$output" = "$expected" ]
139
+ }
140
+
141
+ @test "archivist_project_repo_root returns empty for a non-git directory" {
142
+ local d="${BATS_TEST_TMPDIR}/root-non-git"
143
+ mkdir -p "$d"
144
+
145
+ run archivist_project_repo_root "$d"
146
+ [ "$status" -eq 0 ]
147
+ [ -z "$output" ]
148
+ }
149
+
150
+ @test "archivist_project_repo_root returns empty for a nonexistent path" {
151
+ run archivist_project_repo_root "${BATS_TEST_TMPDIR}/root-missing"
152
+ [ "$status" -eq 0 ]
153
+ [ -z "$output" ]
154
+ }
@@ -117,3 +117,82 @@ setup() {
117
117
  first_id=$(printf '%s' "$ranked" | jq -r '.[0].id')
118
118
  [ "$first_id" = "$newer_id" ]
119
119
  }
120
+
121
+ @test "storage_root prints the archivist dir under ONLOOKER_DIR" {
122
+ run archivist_storage_root
123
+ [ "$status" -eq 0 ]
124
+ [ "$output" = "${ONLOOKER_DIR}/archivist" ]
125
+ }
126
+
127
+ @test "project_dir prints root joined with the key" {
128
+ local key="abc123def456"
129
+ run archivist_project_dir "$key"
130
+ [ "$status" -eq 0 ]
131
+ [ "$output" = "${ONLOOKER_DIR}/archivist/${key}" ]
132
+ }
133
+
134
+ @test "kind_dir prints the per-kind subdir under the project dir" {
135
+ local key="abc123def456"
136
+ run archivist_kind_dir "$key" "decisions"
137
+ [ "$status" -eq 0 ]
138
+ [ "$output" = "${ONLOOKER_DIR}/archivist/${key}/decisions" ]
139
+ }
140
+
141
+ @test "kind_dir honors an arbitrary kind name" {
142
+ local key="abc123def456"
143
+ run archivist_kind_dir "$key" "dead_ends"
144
+ [ "$status" -eq 0 ]
145
+ [ "$output" = "${ONLOOKER_DIR}/archivist/${key}/dead_ends" ]
146
+ }
147
+
148
+ @test "write_manifest creates manifest.json under the project dir" {
149
+ local key="abc123def456"
150
+ run archivist_storage_write_manifest "$key" "git@github.com:org/repo.git" "$REPO"
151
+ [ "$status" -eq 0 ]
152
+ [ -f "${ONLOOKER_DIR}/archivist/${key}/manifest.json" ]
153
+ }
154
+
155
+ @test "write_manifest records the project_key, remote_url, and repo_root" {
156
+ local key="abc123def456"
157
+ local remote="git@github.com:org/repo.git"
158
+ archivist_storage_write_manifest "$key" "$remote" "$REPO"
159
+ local manifest="${ONLOOKER_DIR}/archivist/${key}/manifest.json"
160
+
161
+ [ "$(jq -r '.project_key' "$manifest")" = "$key" ]
162
+ [ "$(jq -r '.remote_url' "$manifest")" = "$remote" ]
163
+ [ "$(jq -r '.repo_root' "$manifest")" = "$REPO" ]
164
+ [ "$(jq -r '.source' "$manifest")" = "local" ]
165
+ }
166
+
167
+ @test "write_manifest stamps an ISO-8601 last_compact_at timestamp" {
168
+ local key="abc123def456"
169
+ archivist_storage_write_manifest "$key" "remote" "$REPO"
170
+ local manifest="${ONLOOKER_DIR}/archivist/${key}/manifest.json"
171
+
172
+ local ts
173
+ ts=$(jq -r '.last_compact_at' "$manifest")
174
+ [[ "$ts" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]
175
+ }
176
+
177
+ @test "write_manifest stores null for an empty remote_url" {
178
+ local key="abc123def456"
179
+ archivist_storage_write_manifest "$key" "" "$REPO"
180
+ local manifest="${ONLOOKER_DIR}/archivist/${key}/manifest.json"
181
+
182
+ [ "$(jq -r '.remote_url' "$manifest")" = "null" ]
183
+ [ "$(jq '.remote_url == null' "$manifest")" = "true" ]
184
+ }
185
+
186
+ @test "write_manifest stores null for an empty repo_root" {
187
+ local key="abc123def456"
188
+ archivist_storage_write_manifest "$key" "remote" ""
189
+ local manifest="${ONLOOKER_DIR}/archivist/${key}/manifest.json"
190
+
191
+ [ "$(jq -r '.repo_root' "$manifest")" = "null" ]
192
+ [ "$(jq '.repo_root == null' "$manifest")" = "true" ]
193
+ }
194
+
195
+ @test "write_manifest rejects an empty key" {
196
+ run archivist_storage_write_manifest "" "remote" "$REPO"
197
+ [ "$status" -ne 0 ]
198
+ }
@@ -71,3 +71,128 @@ setup() {
71
71
  @test "compact_tracker_estimate_tokens estimates from string length" {
72
72
  [ "$(compact_tracker_estimate_tokens "abcdefghij" false)" -eq 2 ]
73
73
  }
74
+
75
+ @test "compact_tracker_state_file prints path under compact trackers dir" {
76
+ local session_id="unit-state-file"
77
+ local result
78
+ result=$(compact_tracker_state_file "$session_id")
79
+ [ "$result" = "${ONLOOKER_COMPACT_TRACKERS_DIR}/${session_id}" ]
80
+ }
81
+
82
+ @test "compact_tracker_record_pre creates state file with pending and compact_count 1" {
83
+ local session_id="unit-record-pre-1"
84
+ local state_file="${ONLOOKER_COMPACT_TRACKERS_DIR}/${session_id}"
85
+ rm -f "$state_file"
86
+
87
+ compact_tracker_record_pre "$session_id" '{"trigger":"manual","transcript_path":""}'
88
+
89
+ [ -f "$state_file" ]
90
+ jq -e '.pending == true
91
+ and .trigger == "manual"
92
+ and .compact_count == 1
93
+ and (.started_ms | type) == "number"' \
94
+ "$state_file" >/dev/null
95
+ }
96
+
97
+ @test "compact_tracker_record_pre increments compact_count on second call" {
98
+ local session_id="unit-record-pre-2"
99
+ local state_file="${ONLOOKER_COMPACT_TRACKERS_DIR}/${session_id}"
100
+ rm -f "$state_file"
101
+
102
+ compact_tracker_record_pre "$session_id" '{"trigger":"manual","transcript_path":""}'
103
+ jq -e '.compact_count == 1' "$state_file" >/dev/null
104
+
105
+ compact_tracker_record_pre "$session_id" '{"trigger":"manual","transcript_path":""}'
106
+ jq -e '.compact_count == 2' "$state_file" >/dev/null
107
+ }
108
+
109
+ @test "compact_tracker_record_pre preserves custom_instructions when present" {
110
+ local session_id="unit-record-pre-3"
111
+ local state_file="${ONLOOKER_COMPACT_TRACKERS_DIR}/${session_id}"
112
+ rm -f "$state_file"
113
+
114
+ compact_tracker_record_pre "$session_id" \
115
+ '{"trigger":"manual","transcript_path":"","custom_instructions":"keep the API design notes"}'
116
+
117
+ jq -e '.custom_instructions == "keep the API design notes"' "$state_file" >/dev/null
118
+ }
119
+
120
+ @test "compact_tracker_append_summary appends a JSONL record" {
121
+ local session_id="unit-append-1"
122
+ local summary_file="${ONLOOKER_SESSION_SUMMARIES_DIR}/${session_id}.jsonl"
123
+ rm -f "$summary_file"
124
+
125
+ compact_tracker_append_summary "$session_id" \
126
+ '{"trigger":"manual","compact_summary":"first summary"}'
127
+
128
+ [ -f "$summary_file" ]
129
+ # Records are written as a JSON stream; count objects rather than physical lines.
130
+ [ "$(jq -s 'length' "$summary_file")" -eq 1 ]
131
+ jq -se '.[0].compact_summary == "first summary" and .[0].trigger == "manual"' "$summary_file" >/dev/null
132
+ }
133
+
134
+ @test "compact_tracker_append_summary appends a second line on second call" {
135
+ local session_id="unit-append-2"
136
+ local summary_file="${ONLOOKER_SESSION_SUMMARIES_DIR}/${session_id}.jsonl"
137
+ rm -f "$summary_file"
138
+
139
+ compact_tracker_append_summary "$session_id" '{"trigger":"manual","compact_summary":"one"}'
140
+ compact_tracker_append_summary "$session_id" '{"trigger":"auto","compact_summary":"two"}'
141
+
142
+ [ "$(jq -s 'length' "$summary_file")" -eq 2 ]
143
+ [ "$(jq -s -r '.[0].compact_summary' "$summary_file")" = "one" ]
144
+ [ "$(jq -s -r '.[1].trigger' "$summary_file")" = "auto" ]
145
+ }
146
+
147
+ @test "compact_tracker_append_summary is a no-op when compact_summary is empty" {
148
+ local session_id="unit-append-3"
149
+ local summary_file="${ONLOOKER_SESSION_SUMMARIES_DIR}/${session_id}.jsonl"
150
+ rm -f "$summary_file"
151
+
152
+ run compact_tracker_append_summary "$session_id" '{"trigger":"manual","compact_summary":""}'
153
+ [ "$status" -eq 0 ]
154
+ [ ! -f "$summary_file" ]
155
+ }
156
+
157
+ @test "compact_tracker_build_compact_payload uses tokens_before from state file" {
158
+ local session_id="unit-build-payload"
159
+ local state_file="${ONLOOKER_COMPACT_TRACKERS_DIR}/${session_id}"
160
+ printf '%s\n' '{"tokens_before":1000}' >"$state_file"
161
+
162
+ local payload
163
+ payload=$(compact_tracker_build_compact_payload "$session_id" \
164
+ '{"compact_summary":"a short compacted summary of the prior context"}')
165
+
166
+ echo "$payload" | jq -e '.tokens_before == 1000
167
+ and (.tokens_after | type) == "number"
168
+ and (.compression_ratio | type) == "number"' >/dev/null
169
+ }
170
+
171
+ @test "compact_tracker_record_post finalizes state and resets turn_tool_seq" {
172
+ local session_id="unit-record-post-1"
173
+ local state_file="${ONLOOKER_COMPACT_TRACKERS_DIR}/${session_id}"
174
+ local tracker_file="${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
175
+
176
+ printf '%s\n' '{"pending":true,"started_ms":1700000000000,"compact_count":1}' >"$state_file"
177
+ printf '%s\n' '{"turn_number":3,"turn_tool_seq":5}' >"$tracker_file"
178
+
179
+ compact_tracker_record_post "$session_id" '{"trigger":"manual","compact_summary":"done"}'
180
+
181
+ jq -e '.pending == false and (.completed_ms | type) == "number"' "$state_file" >/dev/null
182
+ jq -e '.turn_tool_seq == 0 and .turn_number == 3' "$tracker_file" >/dev/null
183
+ }
184
+
185
+ @test "compact_tracker_record_post falls back to create state when none exists" {
186
+ local session_id="unit-record-post-2"
187
+ local state_file="${ONLOOKER_COMPACT_TRACKERS_DIR}/${session_id}"
188
+ local tracker_file="${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
189
+ rm -f "$state_file" "$tracker_file"
190
+
191
+ compact_tracker_record_post "$session_id" '{"trigger":"auto","compact_summary":"recovered"}'
192
+
193
+ [ -f "$state_file" ]
194
+ jq -e '.pending == false
195
+ and (.completed_ms | type) == "number"
196
+ and .compact_count == 1' \
197
+ "$state_file" >/dev/null
198
+ }