@onlooker-community/ecosystem 0.17.0 → 0.18.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.
@@ -98,6 +98,19 @@
98
98
  "license": "MIT",
99
99
  "keywords": ["documentation", "intent", "decisions", "tradeoffs", "why", "context"],
100
100
  "tags": ["documentation", "memory"]
101
+ },
102
+ {
103
+ "name": "counsel",
104
+ "source": "./plugins/counsel",
105
+ "description": "Weekly synthesis and recommendations from your full observability stack. Reads all plugin event logs, identifies patterns, surfaces improvement opportunities, and injects a structured brief at session start when the last brief is stale. Turns disparate logs into a coaching signal. Requires the ecosystem plugin.",
106
+ "author": {
107
+ "name": "Onlooker Community"
108
+ },
109
+ "homepage": "https://onlooker.dev",
110
+ "repository": "https://github.com/onlooker-community/ecosystem",
111
+ "license": "MIT",
112
+ "keywords": ["synthesis", "recommendations", "observability", "coaching", "patterns", "weekly"],
113
+ "tags": ["observability", "coaching"]
101
114
  }
102
115
  ]
103
116
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ecosystem",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Observability substrate for Claude Code. Provides the shared ~/.onlooker/ storage root, canonical schema-validated event emission, session and tool tracking hooks, and prompt rules. Required by all other Onlooker plugins.",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -1,10 +1,11 @@
1
1
  {
2
- ".": "0.17.0",
2
+ ".": "0.18.0",
3
3
  "plugins/archivist": "0.1.0",
4
4
  "plugins/tribunal": "1.0.1",
5
5
  "plugins/echo": "0.2.0",
6
6
  "plugins/cartographer": "0.2.0",
7
7
  "plugins/governor": "0.2.0",
8
8
  "plugins/compass": "0.2.0",
9
- "plugins/scribe": "0.2.0"
9
+ "plugins/scribe": "0.2.0",
10
+ "plugins/counsel": "0.2.0"
10
11
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.18.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.17.0...ecosystem-v0.18.0) (2026-06-02)
4
+
5
+
6
+ ### Features
7
+
8
+ * **counsel:** weekly observability synthesis and coaching brief :robot: ([#51](https://github.com/onlooker-community/ecosystem/issues/51)) ([6364586](https://github.com/onlooker-community/ecosystem/commit/63645863cf3a1d7bbf0353aacb9b71e4f977dd56))
9
+
3
10
  ## [0.17.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.16.0...ecosystem-v0.17.0) (2026-06-01)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlooker-community/ecosystem",
3
- "version": "0.17.0",
3
+ "version": "0.18.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",
@@ -26,7 +26,7 @@
26
26
  "test": "npm run test:bats && npm run test:schema",
27
27
  "test:bats": "bats test/bats",
28
28
  "test:schema": "node --test test/node/*.test.mjs",
29
- "test:shellcheck": "shellcheck -S error -x install.sh scripts/common.sh scripts/hooks/*.sh scripts/lib/*.sh plugins/archivist/scripts/hooks/*.sh plugins/archivist/scripts/lib/*.sh plugins/tribunal/scripts/hooks/*.sh plugins/tribunal/scripts/lib/*.sh plugins/echo/scripts/hooks/*.sh plugins/echo/scripts/lib/*.sh plugins/governor/scripts/hooks/*.sh plugins/governor/scripts/lib/*.sh plugins/compass/scripts/hooks/*.sh plugins/compass/scripts/lib/*.sh plugins/scribe/scripts/hooks/*.sh plugins/scribe/scripts/lib/*.sh",
29
+ "test:shellcheck": "shellcheck -S error -x install.sh scripts/common.sh scripts/hooks/*.sh scripts/lib/*.sh plugins/archivist/scripts/hooks/*.sh plugins/archivist/scripts/lib/*.sh plugins/tribunal/scripts/hooks/*.sh plugins/tribunal/scripts/lib/*.sh plugins/echo/scripts/hooks/*.sh plugins/echo/scripts/lib/*.sh plugins/governor/scripts/hooks/*.sh plugins/governor/scripts/lib/*.sh plugins/compass/scripts/hooks/*.sh plugins/compass/scripts/lib/*.sh plugins/scribe/scripts/hooks/*.sh plugins/scribe/scripts/lib/*.sh plugins/counsel/scripts/hooks/*.sh plugins/counsel/scripts/lib/*.sh",
30
30
  "lint:references": "node scripts/lint/check-references.mjs",
31
31
  "lint:manifests": "node scripts/lint/check-manifests.mjs",
32
32
  "coverage:node": "node scripts/coverage/run-coverage.mjs",
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "counsel",
3
+ "version": "0.2.0",
4
+ "description": "Weekly synthesis and recommendations from the full observability stack. Reads all plugin event logs, produces a structured improvement brief, and injects it at session start when the last brief is stale.",
5
+ "author": {
6
+ "name": "Onlooker Community",
7
+ "url": "https://onlooker.dev"
8
+ },
9
+ "homepage": "https://onlooker.dev",
10
+ "repository": "https://github.com/onlooker-community/ecosystem",
11
+ "license": "MIT",
12
+ "skills": [],
13
+ "agents": []
14
+ }
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0](https://github.com/onlooker-community/ecosystem/compare/counsel-v0.1.0...counsel-v0.2.0) (2026-06-02)
4
+
5
+
6
+ ### Features
7
+
8
+ * **counsel:** weekly observability synthesis and coaching brief :robot: ([#51](https://github.com/onlooker-community/ecosystem/issues/51)) ([6364586](https://github.com/onlooker-community/ecosystem/commit/63645863cf3a1d7bbf0353aacb9b71e4f977dd56))
@@ -0,0 +1,20 @@
1
+ {
2
+ "counsel": {
3
+ "enabled": true,
4
+ "synthesis_interval_days": 7,
5
+ "lookback_days": 30,
6
+ "evaluator": {
7
+ "model": "claude-haiku-4-5-20251001",
8
+ "timeout": 90,
9
+ "max_tokens": 4096,
10
+ "temperature": 0.4
11
+ },
12
+ "capture": {
13
+ "min_events": 10,
14
+ "events_chars_max": 60000
15
+ },
16
+ "output": {
17
+ "brief_max_chars": 3000
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "*",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/counsel-session-start.sh"
10
+ }
11
+ ]
12
+ }
13
+ ]
14
+ }
15
+ }
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env bash
2
+ # Counsel SessionStart hook — weekly improvement brief injection.
3
+ #
4
+ # Fires at session start. If the last brief for this project is older than
5
+ # synthesis_interval_days (default: 7), runs a Haiku synthesis pass over the
6
+ # full event log and injects the resulting brief as additionalContext.
7
+ #
8
+ # Skip conditions (all silent):
9
+ # - counsel.enabled is false
10
+ # - no project key (non-git directory)
11
+ # - brief is still fresh
12
+ # - fewer than min_events events in the lookback window
13
+ #
14
+ # Hook contract:
15
+ # - Always exits 0. Never blocks session start.
16
+ # - Emits hookSpecificOutput JSON on stdout (even when context is empty).
17
+ # - Errors are written to stderr only.
18
+
19
+ set -uo pipefail
20
+
21
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22
+ PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
23
+
24
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
25
+
26
+ # shellcheck source=../lib/counsel-config.sh
27
+ source "${PLUGIN_ROOT}/scripts/lib/counsel-config.sh"
28
+ # shellcheck source=../lib/counsel-events.sh
29
+ source "${PLUGIN_ROOT}/scripts/lib/counsel-events.sh"
30
+ # shellcheck source=../lib/counsel-project-key.sh
31
+ source "${PLUGIN_ROOT}/scripts/lib/counsel-project-key.sh"
32
+ # shellcheck source=../lib/counsel-ulid.sh
33
+ source "${PLUGIN_ROOT}/scripts/lib/counsel-ulid.sh"
34
+ # shellcheck source=../lib/counsel-reader.sh
35
+ source "${PLUGIN_ROOT}/scripts/lib/counsel-reader.sh"
36
+ # shellcheck source=../lib/counsel-synthesize.sh
37
+ source "${PLUGIN_ROOT}/scripts/lib/counsel-synthesize.sh"
38
+ # shellcheck source=../lib/counsel-brief.sh
39
+ source "${PLUGIN_ROOT}/scripts/lib/counsel-brief.sh"
40
+
41
+ _emit() {
42
+ local context="${1:-}"
43
+ jq -cn --arg ctx "$context" '
44
+ {
45
+ hookSpecificOutput: {
46
+ hookEventName: "SessionStart",
47
+ additionalContext: $ctx
48
+ }
49
+ }
50
+ '
51
+ }
52
+
53
+ INPUT=$(cat)
54
+ SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""' 2>/dev/null) || SESSION_ID=""
55
+ CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || CWD=""
56
+
57
+ export _HOOK_SESSION_ID="$SESSION_ID"
58
+
59
+ REPO_ROOT=$(counsel_project_repo_root "$CWD")
60
+ counsel_config_load "$REPO_ROOT"
61
+
62
+ if ! counsel_config_enabled; then
63
+ _emit ""
64
+ exit 0
65
+ fi
66
+
67
+ PROJECT_KEY=$(counsel_project_key "$CWD")
68
+ if [[ -z "$PROJECT_KEY" ]]; then
69
+ _emit ""
70
+ exit 0
71
+ fi
72
+
73
+ _generate_rc=0
74
+ OUTPUT_PATH=$(counsel_generate_brief "$SESSION_ID" "$CWD") || _generate_rc=$?
75
+
76
+ if [[ $_generate_rc -ne 0 ]]; then
77
+ # rc=2 means stale check passed (brief is fresh) or too few events — silent skip.
78
+ [[ $_generate_rc -ne 2 ]] && printf 'counsel-session-start: brief generation failed for session %s\n' "$SESSION_ID" >&2
79
+ _emit ""
80
+ exit 0
81
+ fi
82
+
83
+ if [[ -z "$OUTPUT_PATH" || ! -f "$OUTPUT_PATH" ]]; then
84
+ _emit ""
85
+ exit 0
86
+ fi
87
+
88
+ # Load the brief content and apply the configured char budget.
89
+ BRIEF_MAX_CHARS=$(counsel_config_get '.counsel.output.brief_max_chars')
90
+ [[ -z "$BRIEF_MAX_CHARS" || "$BRIEF_MAX_CHARS" == "null" ]] && BRIEF_MAX_CHARS="3000"
91
+
92
+ BRIEF_CONTENT=$(head -c "$BRIEF_MAX_CHARS" "$OUTPUT_PATH" 2>/dev/null) || BRIEF_CONTENT=""
93
+
94
+ if [[ -z "$BRIEF_CONTENT" ]]; then
95
+ _emit ""
96
+ exit 0
97
+ fi
98
+
99
+ CONTEXT="Counsel — weekly improvement brief (auto-generated from your onlooker event log):
100
+
101
+ ${BRIEF_CONTENT}
102
+
103
+ (Counsel injected this brief for project key ${PROJECT_KEY}. Set counsel.enabled=false to disable.)"
104
+
105
+ _emit "$CONTEXT"
106
+ exit 0
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env bash
2
+ # Brief orchestrator for Counsel.
3
+ #
4
+ # Checks whether a fresh brief is needed, runs the full synthesis pipeline,
5
+ # writes a Markdown brief to ~/.onlooker/counsel/<project-key>/briefs/,
6
+ # and emits counsel.brief.generated.
7
+ #
8
+ # Exposes:
9
+ # counsel_brief_is_stale <project_key> <interval_days>
10
+ # Returns 0 if a new brief should be generated, 1 if the existing one is fresh.
11
+ #
12
+ # counsel_generate_brief <session_id> <cwd>
13
+ # Runs the full pipeline. Echoes the output path on success.
14
+ # Returns 2 if skipped (not stale or too few events).
15
+ # Returns 1 on hard failure.
16
+
17
+ # shellcheck source=./counsel-reader.sh
18
+ # shellcheck source=./counsel-synthesize.sh
19
+ # shellcheck source=./counsel-events.sh
20
+ # shellcheck source=./counsel-project-key.sh
21
+ # (caller must source these before counsel-brief.sh)
22
+
23
+ _counsel_latest_brief_path() {
24
+ local briefs_dir="${1:-}"
25
+ [[ -d "$briefs_dir" ]] || return 1
26
+ # Briefs are named YYYY-WW.md; newest sorts last lexicographically.
27
+ local latest
28
+ latest=$(ls -1 "$briefs_dir"/*.md 2>/dev/null | sort | tail -1)
29
+ [[ -n "$latest" ]] || return 1
30
+ printf '%s' "$latest"
31
+ }
32
+
33
+ _counsel_brief_mtime_epoch() {
34
+ local path="${1:-}"
35
+ [[ -f "$path" ]] || { printf '0'; return 0; }
36
+ if [[ "$(uname)" == "Darwin" ]]; then
37
+ stat -f '%m' "$path" 2>/dev/null || printf '0'
38
+ else
39
+ stat -c '%Y' "$path" 2>/dev/null || printf '0'
40
+ fi
41
+ }
42
+
43
+ counsel_brief_is_stale() {
44
+ local project_key="${1:-}"
45
+ local interval_days="${2:-7}"
46
+
47
+ local briefs_dir
48
+ briefs_dir=$(counsel_project_dir "$project_key")
49
+
50
+ local latest_path
51
+ latest_path=$(_counsel_latest_brief_path "$briefs_dir") || { return 0; }
52
+
53
+ local mtime now age_days
54
+ mtime=$(_counsel_brief_mtime_epoch "$latest_path")
55
+ now=$(date +%s 2>/dev/null) || now=0
56
+ age_days=$(( (now - mtime) / 86400 ))
57
+
58
+ [[ "$age_days" -ge "$interval_days" ]]
59
+ }
60
+
61
+ _counsel_format_brief() {
62
+ local brief_json="${1:-}"
63
+ local period_start="${2:-}"
64
+ local period_end="${3:-}"
65
+ local event_count="${4:-0}"
66
+
67
+ local summary patterns_json recs_json wins_json watch_json
68
+ summary=$(printf '%s' "$brief_json" | jq -r '.summary // ""' 2>/dev/null) || summary=""
69
+ patterns_json=$(printf '%s' "$brief_json" | jq -c '.patterns // []' 2>/dev/null) || patterns_json="[]"
70
+ recs_json=$(printf '%s' "$brief_json" | jq -c '.recommendations // []' 2>/dev/null) || recs_json="[]"
71
+ wins_json=$(printf '%s' "$brief_json" | jq -c '.wins // []' 2>/dev/null) || wins_json="[]"
72
+ watch_json=$(printf '%s' "$brief_json" | jq -c '.watch // []' 2>/dev/null) || watch_json="[]"
73
+
74
+ local timestamp
75
+ timestamp=$(date '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) || timestamp="unknown"
76
+
77
+ {
78
+ printf '# Counsel Brief: %s — %s\n\n' "$period_start" "$period_end"
79
+ [[ -n "$summary" ]] && printf '> %s\n\n' "$summary"
80
+
81
+ printf '## Recommendations\n\n'
82
+ local rec_count
83
+ rec_count=$(printf '%s' "$recs_json" | jq 'length' 2>/dev/null) || rec_count=0
84
+ if [[ "$rec_count" -gt 0 ]]; then
85
+ local i
86
+ for ((i = 0; i < rec_count; i++)); do
87
+ local title rationale priority
88
+ title=$(printf '%s' "$recs_json" | jq -r ".[$i].title // \"\"" 2>/dev/null) || title=""
89
+ rationale=$(printf '%s' "$recs_json" | jq -r ".[$i].rationale // \"\"" 2>/dev/null) || rationale=""
90
+ priority=$(printf '%s' "$recs_json" | jq -r ".[$i].priority // \"medium\"" 2>/dev/null) || priority="medium"
91
+ [[ -z "$title" ]] && continue
92
+ printf '### [%s] %s\n\n' "$priority" "$title"
93
+ [[ -n "$rationale" ]] && printf '%s\n\n' "$rationale"
94
+ done
95
+ else
96
+ printf '*No recommendations this period.*\n\n'
97
+ fi
98
+
99
+ printf '## Patterns Observed\n\n'
100
+ local pat_count
101
+ pat_count=$(printf '%s' "$patterns_json" | jq 'length' 2>/dev/null) || pat_count=0
102
+ if [[ "$pat_count" -gt 0 ]]; then
103
+ printf '%s' "$patterns_json" | jq -r '.[]' 2>/dev/null | while IFS= read -r item; do
104
+ [[ -n "$item" ]] && printf '- %s\n' "$item"
105
+ done
106
+ printf '\n'
107
+ else
108
+ printf '*None identified.*\n\n'
109
+ fi
110
+
111
+ printf '## Wins\n\n'
112
+ local win_count
113
+ win_count=$(printf '%s' "$wins_json" | jq 'length' 2>/dev/null) || win_count=0
114
+ if [[ "$win_count" -gt 0 ]]; then
115
+ printf '%s' "$wins_json" | jq -r '.[]' 2>/dev/null | while IFS= read -r item; do
116
+ [[ -n "$item" ]] && printf '- %s\n' "$item"
117
+ done
118
+ printf '\n'
119
+ else
120
+ printf '*None noted.*\n\n'
121
+ fi
122
+
123
+ printf '## Watch\n\n'
124
+ local watch_count
125
+ watch_count=$(printf '%s' "$watch_json" | jq 'length' 2>/dev/null) || watch_count=0
126
+ if [[ "$watch_count" -gt 0 ]]; then
127
+ printf '%s' "$watch_json" | jq -r '.[]' 2>/dev/null | while IFS= read -r item; do
128
+ [[ -n "$item" ]] && printf '- %s\n' "$item"
129
+ done
130
+ printf '\n'
131
+ else
132
+ printf '*Nothing flagged.*\n\n'
133
+ fi
134
+
135
+ printf '---\n'
136
+ printf '*Generated by counsel · %s · %d events analyzed*\n' "$timestamp" "$event_count"
137
+ }
138
+ }
139
+
140
+ counsel_generate_brief() {
141
+ local session_id="${1:-}"
142
+ local cwd="${2:-}"
143
+
144
+ [[ -z "$session_id" ]] && return 1
145
+
146
+ local onlooker_dir="${ONLOOKER_DIR:-${HOME}/.onlooker}"
147
+
148
+ # Resolve config values.
149
+ local interval_days lookback_days model timeout_s max_tokens temperature chars_max min_events
150
+ interval_days=$(counsel_config_get '.counsel.synthesis_interval_days')
151
+ [[ -z "$interval_days" || "$interval_days" == "null" ]] && interval_days="7"
152
+ lookback_days=$(counsel_config_get '.counsel.lookback_days')
153
+ [[ -z "$lookback_days" || "$lookback_days" == "null" ]] && lookback_days="30"
154
+ model=$(counsel_config_get '.counsel.evaluator.model')
155
+ [[ -z "$model" || "$model" == "null" ]] && model="claude-haiku-4-5-20251001"
156
+ timeout_s=$(counsel_config_get '.counsel.evaluator.timeout')
157
+ [[ -z "$timeout_s" || "$timeout_s" == "null" ]] && timeout_s="90"
158
+ max_tokens=$(counsel_config_get '.counsel.evaluator.max_tokens')
159
+ [[ -z "$max_tokens" || "$max_tokens" == "null" ]] && max_tokens="4096"
160
+ temperature=$(counsel_config_get '.counsel.evaluator.temperature')
161
+ [[ -z "$temperature" || "$temperature" == "null" ]] && temperature="0.4"
162
+ chars_max=$(counsel_config_get '.counsel.capture.events_chars_max')
163
+ [[ -z "$chars_max" || "$chars_max" == "null" ]] && chars_max="60000"
164
+ min_events=$(counsel_config_get '.counsel.capture.min_events')
165
+ [[ -z "$min_events" || "$min_events" == "null" ]] && min_events="10"
166
+
167
+ local project_key
168
+ project_key=$(counsel_project_key "$cwd")
169
+
170
+ # Stale check.
171
+ if ! counsel_brief_is_stale "$project_key" "$interval_days"; then
172
+ return 2
173
+ fi
174
+
175
+ # Read events.
176
+ local events_text
177
+ events_text=$(counsel_read_events "$lookback_days" "$chars_max")
178
+
179
+ local event_count
180
+ event_count=$(counsel_count_events "$events_text")
181
+
182
+ if [[ "$event_count" -lt "$min_events" ]]; then
183
+ return 2
184
+ fi
185
+
186
+ # Run synthesis.
187
+ local brief_json
188
+ brief_json=$(counsel_synthesize "$events_text" "$model" "$timeout_s" "$max_tokens" "$temperature") || {
189
+ printf 'counsel_generate_brief: synthesis failed for session %s\n' "$session_id" >&2
190
+ return 1
191
+ }
192
+
193
+ # Compute period bounds.
194
+ local period_start period_end
195
+ period_end=$(date '+%Y-%m-%d' 2>/dev/null) || period_end="unknown"
196
+ if [[ "$(uname)" == "Darwin" ]]; then
197
+ period_start=$(date -v "-${lookback_days}d" '+%Y-%m-%d' 2>/dev/null) || period_start="unknown"
198
+ else
199
+ period_start=$(date -d "-${lookback_days} days" '+%Y-%m-%d' 2>/dev/null) || period_start="unknown"
200
+ fi
201
+
202
+ # Determine sources consulted.
203
+ local sources_json
204
+ sources_json=$(counsel_sources_from_events "$events_text")
205
+
206
+ # Write brief.
207
+ local briefs_dir output_path
208
+ if [[ -n "$project_key" ]]; then
209
+ briefs_dir=$(counsel_project_dir "$project_key")
210
+ else
211
+ briefs_dir="${onlooker_dir}/counsel/unknown/briefs"
212
+ fi
213
+
214
+ mkdir -p "$briefs_dir" 2>/dev/null || {
215
+ printf 'counsel_generate_brief: cannot create briefs dir %s\n' "$briefs_dir" >&2
216
+ return 1
217
+ }
218
+
219
+ local week_label
220
+ week_label=$(date '+%G-%V' 2>/dev/null) || week_label=$(date '+%Y-%U' 2>/dev/null) || week_label="unknown"
221
+ local filename="${week_label}.md"
222
+ output_path="${briefs_dir}/${filename}"
223
+
224
+ local doc
225
+ doc=$(_counsel_format_brief "$brief_json" "$period_start" "$period_end" "$event_count")
226
+
227
+ printf '%s\n' "$doc" > "$output_path" 2>/dev/null || {
228
+ printf 'counsel_generate_brief: failed to write %s\n' "$output_path" >&2
229
+ return 1
230
+ }
231
+
232
+ # Emit counsel.brief.generated.
233
+ local rec_count
234
+ rec_count=$(printf '%s' "$brief_json" | jq '.recommendations | length' 2>/dev/null) || rec_count=0
235
+
236
+ local payload
237
+ payload=$(jq -n \
238
+ --arg ps "$period_start" \
239
+ --arg pe "$period_end" \
240
+ --argjson rc "$rec_count" \
241
+ --argjson src "$sources_json" \
242
+ '{period_start: $ps, period_end: $pe, recommendation_count: $rc, sources_consulted: $src}') || payload=""
243
+
244
+ [[ -n "$payload" ]] && counsel_emit_event "counsel.brief.generated" "$payload" || true
245
+
246
+ printf '%s' "$output_path"
247
+ }
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env bash
2
+ # Config resolution for Counsel.
3
+ #
4
+ # Three-layer merge, latest wins:
5
+ # 1. plugins/counsel/config.json (plugin defaults)
6
+ # 2. ~/.claude/settings.json
7
+ # 3. <repo>/.claude/settings.json
8
+ #
9
+ # Exposes:
10
+ # counsel_config_load <repo_root> # populates _COUNSEL_CONFIG (JSON)
11
+ # counsel_config_get <jq-path> # echoes string value (empty if unset)
12
+ # counsel_config_get_json <jq-path> # echoes JSON value (null if unset)
13
+ # counsel_config_enabled # 0 if counsel.enabled is true
14
+
15
+ _COUNSEL_CONFIG="{}"
16
+
17
+ counsel_config_load() {
18
+ local repo_root="${1:-}"
19
+ local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
20
+ local home_dir="${HOME:-}"
21
+
22
+ local merged="{}"
23
+ local file
24
+
25
+ file="${plugin_root}/config.json"
26
+ if [[ -f "$file" ]]; then
27
+ local defaults
28
+ defaults=$(jq '.' "$file" 2>/dev/null) || defaults="{}"
29
+ merged=$(jq -n --argjson a "$merged" --argjson b "$defaults" '$a * $b' 2>/dev/null) \
30
+ || merged="$defaults"
31
+ fi
32
+
33
+ local repo_settings=""
34
+ [[ -n "$repo_root" ]] && repo_settings="${repo_root}/.claude/settings.json"
35
+
36
+ for file in "${home_dir}/.claude/settings.json" "$repo_settings"; do
37
+ [[ -n "$file" && -f "$file" ]] || continue
38
+ local overlay
39
+ overlay=$(jq '{ counsel: (.counsel // {}) }' "$file" 2>/dev/null) || continue
40
+ [[ -z "$overlay" ]] && continue
41
+ local attempt
42
+ if attempt=$(jq -n --argjson a "$merged" --argjson b "$overlay" '
43
+ def deepmerge($a; $b):
44
+ if ($a|type) == "object" and ($b|type) == "object" then
45
+ reduce (($a|keys) + ($b|keys) | unique)[] as $k
46
+ ({}; .[$k] = deepmerge($a[$k]; $b[$k]))
47
+ elif $b == null then $a
48
+ else $b end;
49
+ deepmerge($a; $b)
50
+ ' 2>/dev/null) && [[ -n "$attempt" ]]; then
51
+ merged="$attempt"
52
+ fi
53
+ done
54
+
55
+ _COUNSEL_CONFIG="$merged"
56
+ }
57
+
58
+ counsel_config_get() {
59
+ local path="$1"
60
+ printf '%s' "$_COUNSEL_CONFIG" | jq -r "${path} // empty" 2>/dev/null
61
+ }
62
+
63
+ counsel_config_get_json() {
64
+ local path="$1"
65
+ printf '%s' "$_COUNSEL_CONFIG" | jq -c "${path}" 2>/dev/null
66
+ }
67
+
68
+ counsel_config_enabled() {
69
+ local v
70
+ v=$(counsel_config_get '.counsel.enabled')
71
+ [[ "$v" == "true" ]]
72
+ }
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bash
2
+ # Canonical counsel.* event emission.
3
+ #
4
+ # Thin wrapper around the ecosystem plugin's onlooker-event.mjs `emit` mode.
5
+ # Every emission is validated against @onlooker-community/schema before being
6
+ # appended to $ONLOOKER_EVENTS_LOG (defaults to $ONLOOKER_DIR/logs/onlooker-events.jsonl).
7
+ #
8
+ # Usage:
9
+ # counsel_emit_event "counsel.brief.generated" '{"period_start":"...","period_end":"...","recommendation_count":3}'
10
+
11
+ _COUNSEL_PLUGIN_NAME="counsel"
12
+
13
+ _counsel_event_js_path() {
14
+ if [[ -n "${_ONLOOKER_EVENT_JS:-}" && -f "$_ONLOOKER_EVENT_JS" ]]; then
15
+ printf '%s' "$_ONLOOKER_EVENT_JS"
16
+ return 0
17
+ fi
18
+ local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
19
+ local candidates=(
20
+ "${plugin_root}/scripts/lib/onlooker-event.mjs"
21
+ "${plugin_root}/../../scripts/lib/onlooker-event.mjs"
22
+ )
23
+ local c
24
+ for c in "${candidates[@]}"; do
25
+ [[ -f "$c" ]] && { printf '%s' "$c"; return 0; }
26
+ done
27
+ return 1
28
+ }
29
+
30
+ _counsel_session_id() {
31
+ if [[ -n "${_HOOK_SESSION_ID:-}" ]]; then
32
+ printf '%s' "$_HOOK_SESSION_ID"
33
+ return 0
34
+ fi
35
+ if [[ -n "${CLAUDE_SESSION_ID:-}" ]]; then
36
+ printf '%s' "$CLAUDE_SESSION_ID"
37
+ return 0
38
+ fi
39
+ printf 'unknown'
40
+ }
41
+
42
+ counsel_emit_event() {
43
+ local event_type="${1:-}"
44
+ local payload="${2:-}"
45
+
46
+ [[ -z "$event_type" || -z "$payload" ]] && return 1
47
+
48
+ local event_js
49
+ event_js=$(_counsel_event_js_path) || return 1
50
+
51
+ local session_id
52
+ session_id=$(_counsel_session_id)
53
+
54
+ local params
55
+ params=$(jq -n \
56
+ --arg plugin "$_COUNSEL_PLUGIN_NAME" \
57
+ --arg sid "$session_id" \
58
+ --arg type "$event_type" \
59
+ --argjson payload "$payload" \
60
+ '{plugin: $plugin, session_id: $sid, event_type: $type, payload: $payload}' \
61
+ 2>/dev/null) || return 1
62
+
63
+ local event
64
+ local stderr_file
65
+ stderr_file=$(mktemp -t counsel-event-err.XXXXXX 2>/dev/null) || stderr_file="/tmp/counsel-event-err.$$"
66
+ event=$(printf '%s' "$params" \
67
+ | ONLOOKER_DIR="${ONLOOKER_DIR:-$HOME/.onlooker}" \
68
+ ONLOOKER_PLUGIN_NAME="$_COUNSEL_PLUGIN_NAME" \
69
+ node "$event_js" emit 2>"$stderr_file") || {
70
+ printf 'counsel_emit_event: schema validation failed for %s\n' "$event_type" >&2
71
+ [[ -s "$stderr_file" ]] && cat "$stderr_file" >&2
72
+ rm -f "$stderr_file"
73
+ return 1
74
+ }
75
+ rm -f "$stderr_file"
76
+
77
+ local log_path="${ONLOOKER_EVENTS_LOG:-${ONLOOKER_DIR:-$HOME/.onlooker}/logs/onlooker-events.jsonl}"
78
+ mkdir -p "$(dirname "$log_path")" 2>/dev/null || return 1
79
+ printf '%s\n' "$event" >> "$log_path"
80
+ }
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env bash
2
+ # Project key derivation for Counsel.
3
+ #
4
+ # Mirrors the tribunal/scribe project-key scheme so plugins partition storage
5
+ # identically. A project key is a stable 12-char hex identifier derived from
6
+ # the git remote URL (preferred) or repo root path (fallback).
7
+
8
+ _counsel_sha256_first12() {
9
+ local input="$1"
10
+ if command -v shasum >/dev/null 2>&1; then
11
+ printf '%s' "$input" | shasum -a 256 2>/dev/null | cut -c1-12
12
+ elif command -v sha256sum >/dev/null 2>&1; then
13
+ printf '%s' "$input" | sha256sum 2>/dev/null | cut -c1-12
14
+ else
15
+ return 1
16
+ fi
17
+ }
18
+
19
+ counsel_project_remote_url() {
20
+ local cwd="${1:-}"
21
+ [[ -z "$cwd" || ! -d "$cwd" ]] && return 0
22
+ git -C "$cwd" remote get-url origin 2>/dev/null || true
23
+ }
24
+
25
+ # Worktree-aware: uses common-dir so worktrees share a key with the main repo.
26
+ counsel_project_repo_root() {
27
+ local cwd="${1:-}"
28
+ [[ -z "$cwd" || ! -d "$cwd" ]] && return 0
29
+
30
+ if ! git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
31
+ return 0
32
+ fi
33
+
34
+ local common_dir toplevel
35
+ common_dir=$(git -C "$cwd" rev-parse --git-common-dir 2>/dev/null) || return 0
36
+
37
+ if [[ -n "$common_dir" && "$common_dir" != /* ]]; then
38
+ common_dir="$(cd "$cwd" && cd "$common_dir" 2>/dev/null && pwd -P)" || common_dir=""
39
+ fi
40
+
41
+ if [[ -n "$common_dir" && -d "$common_dir" ]]; then
42
+ toplevel="$(cd "$common_dir/.." 2>/dev/null && pwd -P)" || toplevel=""
43
+ fi
44
+
45
+ if [[ -z "$toplevel" ]]; then
46
+ toplevel=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null || true)
47
+ [[ -n "$toplevel" ]] && toplevel="$(cd "$toplevel" 2>/dev/null && pwd -P)"
48
+ fi
49
+
50
+ printf '%s' "$toplevel"
51
+ }
52
+
53
+ counsel_project_key() {
54
+ local cwd="${1:-}"
55
+ [[ -z "$cwd" ]] && cwd="$(pwd)"
56
+
57
+ local remote
58
+ remote=$(counsel_project_remote_url "$cwd")
59
+ if [[ -n "$remote" ]]; then
60
+ _counsel_sha256_first12 "remote:$remote"
61
+ return 0
62
+ fi
63
+
64
+ local root
65
+ root=$(counsel_project_repo_root "$cwd")
66
+ if [[ -n "$root" ]]; then
67
+ _counsel_sha256_first12 "root:$root"
68
+ return 0
69
+ fi
70
+
71
+ return 0
72
+ }
73
+
74
+ counsel_project_dir() {
75
+ local project_key="${1:-}"
76
+ [[ -z "$project_key" ]] && return 1
77
+ local onlooker_dir="${ONLOOKER_DIR:-${HOME}/.onlooker}"
78
+ printf '%s' "${onlooker_dir}/counsel/${project_key}/briefs"
79
+ }
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env bash
2
+ # Event log reader for Counsel.
3
+ #
4
+ # Reads $ONLOOKER_EVENTS_LOG and returns a filtered, summarized view of
5
+ # the last N days suitable for passing to the synthesis prompt.
6
+ #
7
+ # Exposes:
8
+ # counsel_read_events <lookback_days> <chars_max>
9
+ # Echoes a structured text summary of events, or empty string on failure.
10
+ #
11
+ # counsel_sources_from_events <events_json>
12
+ # Echoes a JSON array of CounselSource strings present in the event batch.
13
+
14
+ # Maps event_type prefixes to CounselSource values.
15
+ _counsel_source_for_type() {
16
+ local event_type="${1:-}"
17
+ case "$event_type" in
18
+ tribunal.*) printf 'tribunal_verdicts' ;;
19
+ echo.*) printf 'echo_regressions' ;;
20
+ sentinel.*) printf 'sentinel_audit' ;;
21
+ warden.*) printf 'warden_audit' ;;
22
+ oracle.*) printf 'oracle_calibrations' ;;
23
+ meridian.*) printf 'meridian_reliance' ;;
24
+ *) printf 'onlooker_events' ;;
25
+ esac
26
+ }
27
+
28
+ counsel_read_events() {
29
+ local lookback_days="${1:-30}"
30
+ local chars_max="${2:-60000}"
31
+
32
+ local log_path="${ONLOOKER_EVENTS_LOG:-${ONLOOKER_DIR:-$HOME/.onlooker}/logs/onlooker-events.jsonl}"
33
+ [[ -f "$log_path" ]] || { printf ''; return 0; }
34
+
35
+ # Compute cutoff as an ISO 8601 date string. ISO 8601 strings are
36
+ # lexicographically sortable, so string comparison is safe for filtering.
37
+ local cutoff_ts
38
+ if [[ "$(uname)" == "Darwin" ]]; then
39
+ cutoff_ts=$(date -v "-${lookback_days}d" -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) \
40
+ || cutoff_ts=""
41
+ else
42
+ cutoff_ts=$(date -d "-${lookback_days} days" -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) \
43
+ || cutoff_ts=""
44
+ fi
45
+
46
+ # Filter to events within the lookback window. If cutoff_ts is empty (date
47
+ # command unavailable) fall through and include all events.
48
+ local summary
49
+ # -rc: compact output keeps each object on one line (JSONL-shaped), which
50
+ # downstream counsel_count_events and counsel_sources_from_events require.
51
+ summary=$(jq -rc --arg cutoff "$cutoff_ts" '
52
+ select(.timestamp != null) |
53
+ select($cutoff == "" or .timestamp >= $cutoff) |
54
+ {
55
+ type: .event_type,
56
+ plugin: (.plugin // "unknown"),
57
+ ts: .timestamp,
58
+ session: (.session_id // ""),
59
+ payload: (.payload // {})
60
+ }
61
+ ' "$log_path" 2>/dev/null | head -c "$chars_max") || summary=""
62
+
63
+ printf '%s' "$summary"
64
+ }
65
+
66
+ counsel_sources_from_events() {
67
+ local events_text="${1:-}"
68
+ [[ -z "$events_text" ]] && { printf '["onlooker_events"]'; return 0; }
69
+
70
+ local sources=()
71
+ local seen_tribunal=0 seen_echo=0 seen_sentinel=0 seen_warden=0 seen_oracle=0 seen_meridian=0 seen_other=0
72
+
73
+ while IFS= read -r line; do
74
+ [[ -z "$line" ]] && continue
75
+ local etype
76
+ etype=$(printf '%s' "$line" | jq -r '.type // ""' 2>/dev/null) || continue
77
+ case "$etype" in
78
+ tribunal.*) seen_tribunal=1 ;;
79
+ echo.*) seen_echo=1 ;;
80
+ sentinel.*) seen_sentinel=1 ;;
81
+ warden.*) seen_warden=1 ;;
82
+ oracle.*) seen_oracle=1 ;;
83
+ meridian.*) seen_meridian=1 ;;
84
+ *) seen_other=1 ;;
85
+ esac
86
+ done <<< "$events_text"
87
+
88
+ [[ "$seen_other" -eq 1 ]] && sources+=("\"onlooker_events\"")
89
+ [[ "$seen_tribunal" -eq 1 ]] && sources+=("\"tribunal_verdicts\"")
90
+ [[ "$seen_echo" -eq 1 ]] && sources+=("\"echo_regressions\"")
91
+ [[ "$seen_sentinel" -eq 1 ]] && sources+=("\"sentinel_audit\"")
92
+ [[ "$seen_warden" -eq 1 ]] && sources+=("\"warden_audit\"")
93
+ [[ "$seen_oracle" -eq 1 ]] && sources+=("\"oracle_calibrations\"")
94
+ [[ "$seen_meridian" -eq 1 ]] && sources+=("\"meridian_reliance\"")
95
+
96
+ if [[ "${#sources[@]}" -eq 0 ]]; then
97
+ printf '["onlooker_events"]'
98
+ return 0
99
+ fi
100
+
101
+ local joined
102
+ joined=$(IFS=,; printf '%s' "${sources[*]}")
103
+ printf '[%s]' "$joined"
104
+ }
105
+
106
+ counsel_count_events() {
107
+ local events_text="${1:-}"
108
+ [[ -z "$events_text" ]] && { printf '0'; return 0; }
109
+ local count=0
110
+ while IFS= read -r line; do
111
+ [[ -n "$line" ]] && count=$((count + 1))
112
+ done <<< "$events_text"
113
+ printf '%s' "$count"
114
+ }
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ # Synthesis pass for Counsel.
3
+ #
4
+ # Runs a single Haiku call over the event summary to produce a structured
5
+ # improvement brief. The brief is returned as a JSON object.
6
+ #
7
+ # Exposes:
8
+ # counsel_synthesize <events_text> <model> <timeout_s> <max_tokens> <temperature>
9
+ # Echoes a JSON object on success, empty string on failure.
10
+ # JSON shape:
11
+ # {
12
+ # "summary": string,
13
+ # "patterns": [string],
14
+ # "recommendations": [{title, rationale, priority:"high"|"medium"|"low"}],
15
+ # "wins": [string],
16
+ # "watch": [string]
17
+ # }
18
+
19
+ _COUNSEL_SYNTHESIS_PROMPT='You are an engineering coach analyzing an AI agent observability log. You have been given a structured dump of plugin events from the onlooker ecosystem over the past several weeks. Your job is to synthesize patterns, surface improvement opportunities, and highlight what is working well.
20
+
21
+ Focus on:
22
+ - Recurring failure modes or blocked gates (tribunal, sentinel, warden)
23
+ - Prompt regression trends (echo plugin)
24
+ - Budget or resource pressure patterns (governor plugin)
25
+ - Quality trends over time
26
+ - What the team is consistently doing well
27
+
28
+ Return a JSON object with exactly these keys:
29
+ {
30
+ "summary": "2-3 sentence executive summary of the period",
31
+ "patterns": ["observed pattern — what is happening and how often"],
32
+ "recommendations": [
33
+ {
34
+ "title": "short action title",
35
+ "rationale": "1-2 sentences explaining why this matters",
36
+ "priority": "high"
37
+ }
38
+ ],
39
+ "wins": ["thing that is working well — be specific"],
40
+ "watch": ["trend to monitor — not urgent but worth watching"]
41
+ }
42
+
43
+ Rules:
44
+ - All fields are required; use empty arrays [] if no items found
45
+ - recommendations must have priority: "high", "medium", or "low"
46
+ - Keep each item to 1-2 sentences
47
+ - Return ONLY the JSON object — no prose, no markdown fences, no explanation
48
+ - If there is insufficient data to draw conclusions, say so in summary and return empty arrays
49
+
50
+ '
51
+
52
+ counsel_synthesize() {
53
+ local events_text="${1:-}"
54
+ local model="${2:-claude-haiku-4-5-20251001}"
55
+ local timeout_s="${3:-90}"
56
+ local max_tokens="${4:-4096}"
57
+ local temperature="${5:-0.4}"
58
+
59
+ [[ -z "$events_text" ]] && return 1
60
+
61
+ if ! command -v claude >/dev/null 2>&1; then
62
+ printf 'counsel_synthesize: claude CLI not found\n' >&2
63
+ return 1
64
+ fi
65
+
66
+ local prompt_file
67
+ prompt_file=$(mktemp -t counsel-synth.XXXXXX 2>/dev/null) || prompt_file="/tmp/counsel-synth.$$"
68
+ trap 'rm -f "$prompt_file"' RETURN
69
+
70
+ {
71
+ printf '%s' "$_COUNSEL_SYNTHESIS_PROMPT"
72
+ printf '<event_log>\n'
73
+ printf '%s\n' "$events_text"
74
+ printf '</event_log>\n'
75
+ } > "$prompt_file"
76
+
77
+ local claude_args=(-p --max-turns 1 --model "$model" --max-tokens "$max_tokens")
78
+
79
+ local response=""
80
+ if command -v timeout >/dev/null 2>&1; then
81
+ response=$(timeout "$timeout_s" claude "${claude_args[@]}" < "$prompt_file" 2>/dev/null) || response=""
82
+ elif command -v gtimeout >/dev/null 2>&1; then
83
+ response=$(gtimeout "$timeout_s" claude "${claude_args[@]}" < "$prompt_file" 2>/dev/null) || response=""
84
+ else
85
+ response=$(claude "${claude_args[@]}" < "$prompt_file" 2>/dev/null) || response=""
86
+ fi
87
+
88
+ [[ -z "$response" ]] && return 1
89
+
90
+ # Strip markdown fences if present.
91
+ local clean
92
+ clean=$(printf '%s' "$response" \
93
+ | sed -e 's/^```json[[:space:]]*//' -e 's/^```[[:space:]]*//' -e 's/[[:space:]]*```$//')
94
+
95
+ if ! printf '%s' "$clean" | jq -e \
96
+ '.summary and (.patterns | type == "array") and (.recommendations | type == "array") and (.wins | type == "array") and (.watch | type == "array")' \
97
+ >/dev/null 2>&1; then
98
+ printf 'counsel_synthesize: response missing required keys\n' >&2
99
+ return 1
100
+ fi
101
+
102
+ printf '%s' "$clean"
103
+ }
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bash
2
+ # Minimal ULID generator for Counsel brief IDs.
3
+
4
+ _COUNSEL_ULID_ALPHABET="0123456789ABCDEFGHJKMNPQRSTVWXYZ"
5
+
6
+ _counsel_ulid_encode() {
7
+ local n="$1"
8
+ local len="$2"
9
+ local out=""
10
+ local i
11
+ for ((i = 0; i < len; i++)); do
12
+ out="${_COUNSEL_ULID_ALPHABET:$((n % 32)):1}${out}"
13
+ n=$((n / 32))
14
+ done
15
+ printf '%s' "$out"
16
+ }
17
+
18
+ counsel_ulid() {
19
+ local now_ms
20
+ if [[ "$(uname)" == "Darwin" ]]; then
21
+ now_ms=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null) \
22
+ || now_ms=$(($(date +%s) * 1000))
23
+ else
24
+ now_ms=$(date +%s%3N 2>/dev/null) || now_ms=$(($(date +%s) * 1000))
25
+ fi
26
+
27
+ local rand_hex rand_hi rand_lo
28
+ rand_hex=$(openssl rand -hex 10 2>/dev/null)
29
+ if [[ -n "$rand_hex" && ${#rand_hex} -eq 20 ]]; then
30
+ rand_hi=$((16#${rand_hex:0:10}))
31
+ rand_lo=$((16#${rand_hex:10:10}))
32
+ else
33
+ rand_hi=$((RANDOM * 32768 + RANDOM))
34
+ rand_lo=$((RANDOM * 32768 + RANDOM))
35
+ rand_hi=$(((rand_hi * 256 + RANDOM % 256) & ((1 << 40) - 1)))
36
+ rand_lo=$(((rand_lo * 256 + RANDOM % 256) & ((1 << 40) - 1)))
37
+ fi
38
+
39
+ local ts_part hi_part lo_part
40
+ ts_part=$(_counsel_ulid_encode "$now_ms" 10)
41
+ hi_part=$(_counsel_ulid_encode "$rand_hi" 8)
42
+ lo_part=$(_counsel_ulid_encode "$rand_lo" 8)
43
+
44
+ printf '%s%s%s' "$ts_part" "$hi_part" "$lo_part"
45
+ }
@@ -126,6 +126,22 @@
126
126
  "jsonpath": "$.version"
127
127
  }
128
128
  ]
129
+ },
130
+ "plugins/counsel": {
131
+ "changelog-path": "CHANGELOG.md",
132
+ "release-type": "simple",
133
+ "bump-minor-pre-major": true,
134
+ "bump-patch-for-minor-pre-major": false,
135
+ "component": "counsel",
136
+ "draft": false,
137
+ "prerelease": false,
138
+ "extra-files": [
139
+ {
140
+ "type": "json",
141
+ "path": ".claude-plugin/plugin.json",
142
+ "jsonpath": "$.version"
143
+ }
144
+ ]
129
145
  }
130
146
  },
131
147
  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ # shellcheck source=../helpers/setup.bash
5
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
6
+ setup_test_env
7
+
8
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/counsel"
9
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
10
+ # shellcheck disable=SC1091
11
+ source "${PLUGIN_ROOT}/scripts/lib/counsel-project-key.sh"
12
+ }
13
+
14
+ @test "non-git directory returns empty key" {
15
+ local d="${BATS_TEST_TMPDIR}/non-git"
16
+ mkdir -p "$d"
17
+ run counsel_project_key "$d"
18
+ [ "$status" -eq 0 ]
19
+ [ -z "$output" ]
20
+ }
21
+
22
+ @test "git repo without remote falls back to repo-root hash" {
23
+ local d="${BATS_TEST_TMPDIR}/local-only-repo"
24
+ mkdir -p "$d"
25
+ git -C "$d" init -q
26
+ git -C "$d" config user.email t@example.com
27
+ git -C "$d" config user.name "Test"
28
+
29
+ local k1
30
+ k1=$(counsel_project_key "$d")
31
+ [ -n "$k1" ]
32
+ [ "${#k1}" -eq 12 ]
33
+
34
+ local k2
35
+ k2=$(counsel_project_key "$d")
36
+ [ "$k1" = "$k2" ]
37
+ }
38
+
39
+ @test "git repo with remote uses remote hash, ignores local path" {
40
+ local a="${BATS_TEST_TMPDIR}/clone-a"
41
+ local b="${BATS_TEST_TMPDIR}/clone-b"
42
+ mkdir -p "$a" "$b"
43
+ for d in "$a" "$b"; do
44
+ git -C "$d" init -q
45
+ git -C "$d" config user.email t@example.com
46
+ git -C "$d" config user.name "Test"
47
+ git -C "$d" remote add origin git@github.com:org/proj.git
48
+ done
49
+
50
+ local ka kb
51
+ ka=$(counsel_project_key "$a")
52
+ kb=$(counsel_project_key "$b")
53
+ [ -n "$ka" ]
54
+ [ "$ka" = "$kb" ]
55
+ }
56
+
57
+ @test "different remotes yield different keys" {
58
+ local a="${BATS_TEST_TMPDIR}/proj-a"
59
+ local b="${BATS_TEST_TMPDIR}/proj-b"
60
+ mkdir -p "$a" "$b"
61
+ for d in "$a" "$b"; do
62
+ git -C "$d" init -q
63
+ git -C "$d" config user.email t@example.com
64
+ git -C "$d" config user.name "Test"
65
+ done
66
+ git -C "$a" remote add origin git@github.com:org/proj-a.git
67
+ git -C "$b" remote add origin git@github.com:org/proj-b.git
68
+
69
+ local ka kb
70
+ ka=$(counsel_project_key "$a")
71
+ kb=$(counsel_project_key "$b")
72
+ [ -n "$ka" ]
73
+ [ -n "$kb" ]
74
+ [ "$ka" != "$kb" ]
75
+ }
76
+
77
+ @test "counsel_project_dir includes project key in path" {
78
+ local key="abc123def456"
79
+ run counsel_project_dir "$key"
80
+ [ "$status" -eq 0 ]
81
+ [[ "$output" == *"counsel/${key}/briefs" ]]
82
+ }
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ # shellcheck source=../helpers/setup.bash
5
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
6
+ setup_test_env
7
+
8
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/counsel"
9
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
10
+ # shellcheck disable=SC1091
11
+ source "${PLUGIN_ROOT}/scripts/lib/counsel-reader.sh"
12
+ }
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # counsel_count_events
16
+ # ---------------------------------------------------------------------------
17
+
18
+ @test "count_events returns 0 for empty input" {
19
+ run counsel_count_events ""
20
+ [ "$status" -eq 0 ]
21
+ [ "$output" = "0" ]
22
+ }
23
+
24
+ @test "count_events counts non-blank lines" {
25
+ local text
26
+ text=$(printf '%s\n%s\n%s\n' '{"type":"a"}' '{"type":"b"}' '{"type":"c"}')
27
+ run counsel_count_events "$text"
28
+ [ "$status" -eq 0 ]
29
+ [ "$output" = "3" ]
30
+ }
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # counsel_sources_from_events
34
+ # ---------------------------------------------------------------------------
35
+
36
+ @test "sources_from_events returns onlooker_events for unknown types" {
37
+ local text='{"type":"scribe.distill.complete"}'
38
+ run counsel_sources_from_events "$text"
39
+ [ "$status" -eq 0 ]
40
+ [[ "$output" == *"onlooker_events"* ]]
41
+ }
42
+
43
+ @test "sources_from_events detects tribunal events" {
44
+ local text='{"type":"tribunal.gate.blocked"}'
45
+ run counsel_sources_from_events "$text"
46
+ [ "$status" -eq 0 ]
47
+ [[ "$output" == *"tribunal_verdicts"* ]]
48
+ }
49
+
50
+ @test "sources_from_events detects echo events" {
51
+ local text='{"type":"echo.regression.detected"}'
52
+ run counsel_sources_from_events "$text"
53
+ [ "$status" -eq 0 ]
54
+ [[ "$output" == *"echo_regressions"* ]]
55
+ }
56
+
57
+ @test "sources_from_events returns onlooker_events for empty input" {
58
+ run counsel_sources_from_events ""
59
+ [ "$status" -eq 0 ]
60
+ [ "$output" = '["onlooker_events"]' ]
61
+ }
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # counsel_read_events — file-based
65
+ # ---------------------------------------------------------------------------
66
+
67
+ @test "read_events returns empty when log does not exist" {
68
+ export ONLOOKER_EVENTS_LOG="${BATS_TEST_TMPDIR}/no-log.jsonl"
69
+ run counsel_read_events "30" "60000"
70
+ [ "$status" -eq 0 ]
71
+ [ -z "$output" ]
72
+ }
73
+
74
+ @test "read_events returns empty for empty log" {
75
+ local log="${BATS_TEST_TMPDIR}/empty-log.jsonl"
76
+ touch "$log"
77
+ export ONLOOKER_EVENTS_LOG="$log"
78
+ run counsel_read_events "30" "60000"
79
+ [ "$status" -eq 0 ]
80
+ [ -z "$output" ]
81
+ }
82
+
83
+ @test "read_events filters events within lookback window" {
84
+ local log="${BATS_TEST_TMPDIR}/events.jsonl"
85
+ # Use a timestamp far in the future to ensure it passes any lookback filter.
86
+ local ts
87
+ ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) || ts="2099-01-01T00:00:00Z"
88
+ printf '%s\n' \
89
+ "{\"event_type\":\"scribe.distill.complete\",\"timestamp\":\"${ts}\",\"session_id\":\"s1\",\"payload\":{}}" \
90
+ > "$log"
91
+ export ONLOOKER_EVENTS_LOG="$log"
92
+ run counsel_read_events "30" "60000"
93
+ [ "$status" -eq 0 ]
94
+ [[ "$output" == *"scribe.distill.complete"* ]]
95
+ }
96
+
97
+ @test "read_events output is JSONL-shaped: one object per line" {
98
+ local log="${BATS_TEST_TMPDIR}/multi-events.jsonl"
99
+ local ts
100
+ ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) || ts="2099-01-01T00:00:00Z"
101
+ printf '%s\n' \
102
+ "{\"event_type\":\"tribunal.gate.blocked\",\"timestamp\":\"${ts}\",\"session_id\":\"s1\",\"payload\":{}}" \
103
+ "{\"event_type\":\"echo.regression.detected\",\"timestamp\":\"${ts}\",\"session_id\":\"s2\",\"payload\":{}}" \
104
+ "{\"event_type\":\"scribe.distill.complete\",\"timestamp\":\"${ts}\",\"session_id\":\"s3\",\"payload\":{}}" \
105
+ > "$log"
106
+ export ONLOOKER_EVENTS_LOG="$log"
107
+
108
+ local events
109
+ events=$(counsel_read_events "30" "60000")
110
+
111
+ # count_events must see exactly 3 records, not inflated by pretty-printing.
112
+ run counsel_count_events "$events"
113
+ [ "$status" -eq 0 ]
114
+ [ "$output" = "3" ]
115
+ }
116
+
117
+ @test "read_events output preserves source types for sources_from_events" {
118
+ local log="${BATS_TEST_TMPDIR}/source-events.jsonl"
119
+ local ts
120
+ ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) || ts="2099-01-01T00:00:00Z"
121
+ printf '%s\n' \
122
+ "{\"event_type\":\"tribunal.gate.blocked\",\"timestamp\":\"${ts}\",\"session_id\":\"s1\",\"payload\":{}}" \
123
+ > "$log"
124
+ export ONLOOKER_EVENTS_LOG="$log"
125
+
126
+ local events
127
+ events=$(counsel_read_events "30" "60000")
128
+
129
+ run counsel_sources_from_events "$events"
130
+ [ "$status" -eq 0 ]
131
+ [[ "$output" == *"tribunal_verdicts"* ]]
132
+ }