@onlooker-community/ecosystem 0.25.1 → 0.26.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ecosystem",
3
- "version": "0.25.1",
3
+ "version": "0.26.1",
4
4
  "description": "Observability substrate for Claude Code. Provides the shared $ONLOOKER_DIR storage root (default $HOME/.onlooker), 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,5 +1,5 @@
1
1
  {
2
- ".": "0.25.1",
2
+ ".": "0.26.1",
3
3
  "plugins/archivist": "0.1.0",
4
4
  "plugins/tribunal": "1.0.1",
5
5
  "plugins/echo": "0.2.0",
@@ -7,7 +7,7 @@
7
7
  "plugins/governor": "0.2.1",
8
8
  "plugins/compass": "0.2.0",
9
9
  "plugins/scribe": "0.2.1",
10
- "plugins/counsel": "0.2.0",
10
+ "plugins/counsel": "0.3.1",
11
11
  "plugins/warden": "0.2.0",
12
12
  "plugins/librarian": "0.2.0",
13
13
  "plugins/curator": "0.1.0",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.26.1](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.26.0...ecosystem-v0.26.1) (2026-06-12)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **counsel:** drop unsupported --max-tokens flag from claude synthesis call :relieved: ([#79](https://github.com/onlooker-community/ecosystem/issues/79)) ([ade85ce](https://github.com/onlooker-community/ecosystem/commit/ade85cecb3243781f47e14fea4990ce31e69e8f4))
9
+ * **counsel:** stop pipefail from discarding all events on large logs :relieved: ([#78](https://github.com/onlooker-community/ecosystem/issues/78)) ([638347d](https://github.com/onlooker-community/ecosystem/commit/638347dec3b9df740b7a85c3e475fa2ffe5d054b))
10
+
11
+ ## [0.26.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.25.1...ecosystem-v0.26.0) (2026-06-11)
12
+
13
+
14
+ ### Features
15
+
16
+ * **counsel:** add /counsel on-demand weekly-review command :rocket: ([#76](https://github.com/onlooker-community/ecosystem/issues/76)) ([8ce951c](https://github.com/onlooker-community/ecosystem/commit/8ce951cd5cb7b173f194f86c2960a31fb0d6889d))
17
+
3
18
  ## [0.25.1](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.25.0...ecosystem-v0.25.1) (2026-06-10)
4
19
 
5
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlooker-community/ecosystem",
3
- "version": "0.25.1",
3
+ "version": "0.26.1",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "counsel",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
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
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -9,6 +9,6 @@
9
9
  "homepage": "https://onlooker.dev",
10
10
  "repository": "https://github.com/onlooker-community/ecosystem",
11
11
  "license": "MIT",
12
- "skills": [],
12
+ "skills": ["./skills/counsel"],
13
13
  "agents": []
14
14
  }
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.1](https://github.com/onlooker-community/ecosystem/compare/counsel-v0.3.0...counsel-v0.3.1) (2026-06-12)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **counsel:** drop unsupported --max-tokens flag from claude synthesis call :relieved: ([#79](https://github.com/onlooker-community/ecosystem/issues/79)) ([ade85ce](https://github.com/onlooker-community/ecosystem/commit/ade85cecb3243781f47e14fea4990ce31e69e8f4))
9
+ * **counsel:** stop pipefail from discarding all events on large logs :relieved: ([#78](https://github.com/onlooker-community/ecosystem/issues/78)) ([638347d](https://github.com/onlooker-community/ecosystem/commit/638347dec3b9df740b7a85c3e475fa2ffe5d054b))
10
+
11
+ ## [0.3.0](https://github.com/onlooker-community/ecosystem/compare/counsel-v0.2.0...counsel-v0.3.0) (2026-06-11)
12
+
13
+
14
+ ### Features
15
+
16
+ * **counsel:** add /counsel on-demand weekly-review command :rocket: ([#76](https://github.com/onlooker-community/ecosystem/issues/76)) ([8ce951c](https://github.com/onlooker-community/ecosystem/commit/8ce951cd5cb7b173f194f86c2960a31fb0d6889d))
17
+
3
18
  ## [0.2.0](https://github.com/onlooker-community/ecosystem/compare/counsel-v0.1.0...counsel-v0.2.0) (2026-06-02)
4
19
 
5
20
 
@@ -18,6 +18,18 @@ Counsel partitions the event stream by source plugin, recognizing `tribunal`, `e
18
18
 
19
19
  The hook always exits 0 — it never blocks a session from starting. It skips silently when Counsel is disabled, the directory has no project key (non-git), the latest brief is still fresh, or fewer than `capture.min_events` events fall inside the lookback window.
20
20
 
21
+ ## On-demand brief — `/counsel`
22
+
23
+ The SessionStart path only regenerates when the latest brief is stale. To run the weekly review immediately — regardless of freshness — invoke the `/counsel` skill. It forces a synthesis pass, writes the brief, emits `counsel.brief.generated`, and renders the result in the conversation instead of injecting it invisibly.
24
+
25
+ | Invocation | What it does |
26
+ |------------|--------------|
27
+ | `/counsel` | Forces a fresh synthesis now (bypassing the staleness gate), writes `<YYYY-WW>.md`, and prints the brief. Re-running in the same ISO week overwrites that week's brief in place. |
28
+ | `/counsel --show` | Renders the most recent brief already on disk. No LLM call, no events emitted. |
29
+ | `/counsel --status` | Reports the latest brief's age, last-generated time, and whether it is stale. No LLM call. |
30
+
31
+ The on-demand path bypasses only the staleness gate — output, events, storage layout, project keying, and the `capture.min_events` floor are identical to the SessionStart path. If too few events fall inside the lookback window, `/counsel` reports that rather than emitting a thin brief.
32
+
21
33
  ## Activation
22
34
 
23
35
  Counsel is **on by default**. Disable it per-project in `.claude/settings.json` (or globally in `~/.claude/settings.json`):
@@ -9,8 +9,10 @@
9
9
  # counsel_brief_is_stale <project_key> <interval_days>
10
10
  # Returns 0 if a new brief should be generated, 1 if the existing one is fresh.
11
11
  #
12
- # counsel_generate_brief <session_id> <cwd>
12
+ # counsel_generate_brief <session_id> <cwd> [force]
13
13
  # Runs the full pipeline. Echoes the output path on success.
14
+ # When [force] is "1" or "force", the staleness gate is bypassed (used by
15
+ # the on-demand /counsel skill); the min_events gate always applies.
14
16
  # Returns 2 if skipped (not stale or too few events).
15
17
  # Returns 1 on hard failure.
16
18
 
@@ -101,7 +103,7 @@ _counsel_format_brief() {
101
103
  pat_count=$(printf '%s' "$patterns_json" | jq 'length' 2>/dev/null) || pat_count=0
102
104
  if [[ "$pat_count" -gt 0 ]]; then
103
105
  printf '%s' "$patterns_json" | jq -r '.[]' 2>/dev/null | while IFS= read -r item; do
104
- [[ -n "$item" ]] && printf '- %s\n' "$item"
106
+ [[ -n "$item" ]] && printf -- '- %s\n' "$item"
105
107
  done
106
108
  printf '\n'
107
109
  else
@@ -113,7 +115,7 @@ _counsel_format_brief() {
113
115
  win_count=$(printf '%s' "$wins_json" | jq 'length' 2>/dev/null) || win_count=0
114
116
  if [[ "$win_count" -gt 0 ]]; then
115
117
  printf '%s' "$wins_json" | jq -r '.[]' 2>/dev/null | while IFS= read -r item; do
116
- [[ -n "$item" ]] && printf '- %s\n' "$item"
118
+ [[ -n "$item" ]] && printf -- '- %s\n' "$item"
117
119
  done
118
120
  printf '\n'
119
121
  else
@@ -125,14 +127,14 @@ _counsel_format_brief() {
125
127
  watch_count=$(printf '%s' "$watch_json" | jq 'length' 2>/dev/null) || watch_count=0
126
128
  if [[ "$watch_count" -gt 0 ]]; then
127
129
  printf '%s' "$watch_json" | jq -r '.[]' 2>/dev/null | while IFS= read -r item; do
128
- [[ -n "$item" ]] && printf '- %s\n' "$item"
130
+ [[ -n "$item" ]] && printf -- '- %s\n' "$item"
129
131
  done
130
132
  printf '\n'
131
133
  else
132
134
  printf '*Nothing flagged.*\n\n'
133
135
  fi
134
136
 
135
- printf '---\n'
137
+ printf -- '---\n'
136
138
  printf '*Generated by counsel · %s · %d events analyzed*\n' "$timestamp" "$event_count"
137
139
  }
138
140
  }
@@ -140,6 +142,7 @@ _counsel_format_brief() {
140
142
  counsel_generate_brief() {
141
143
  local session_id="${1:-}"
142
144
  local cwd="${2:-}"
145
+ local force="${3:-0}"
143
146
 
144
147
  [[ -z "$session_id" ]] && return 1
145
148
 
@@ -167,9 +170,11 @@ counsel_generate_brief() {
167
170
  local project_key
168
171
  project_key=$(counsel_project_key "$cwd")
169
172
 
170
- # Stale check.
171
- if ! counsel_brief_is_stale "$project_key" "$interval_days"; then
172
- return 2
173
+ # Stale check. On-demand invocations pass force to bypass it.
174
+ if [[ "$force" != "1" && "$force" != "force" ]]; then
175
+ if ! counsel_brief_is_stale "$project_key" "$interval_days"; then
176
+ return 2
177
+ fi
173
178
  fi
174
179
 
175
180
  # Read events.
@@ -190,14 +195,28 @@ counsel_generate_brief() {
190
195
  return 1
191
196
  }
192
197
 
193
- # Compute period bounds.
194
- local period_start period_end
195
- period_end=$(date '+%Y-%m-%d' 2>/dev/null) || period_end="unknown"
198
+ # Compute period bounds. The brief heading shows calendar dates; the emitted
199
+ # event requires RFC 3339 date-time strings (schema format: date-time), so
200
+ # compute the timestamps first and derive the date-only strings from them.
201
+ # Prefer UTC; fall back to local time (still RFC 3339, just a different zone)
202
+ # if -u is unavailable. If even that fails the date subsystem is broken and
203
+ # the bounds stay empty — the emit below skips rather than send garbage.
204
+ local period_start_ts period_end_ts
205
+ period_end_ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) \
206
+ || period_end_ts=$(date '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) \
207
+ || period_end_ts=""
196
208
  if [[ "$(uname)" == "Darwin" ]]; then
197
- period_start=$(date -v "-${lookback_days}d" '+%Y-%m-%d' 2>/dev/null) || period_start="unknown"
209
+ period_start_ts=$(date -u -v "-${lookback_days}d" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) \
210
+ || period_start_ts=$(date -v "-${lookback_days}d" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) \
211
+ || period_start_ts=""
198
212
  else
199
- period_start=$(date -d "-${lookback_days} days" '+%Y-%m-%d' 2>/dev/null) || period_start="unknown"
213
+ period_start_ts=$(date -u -d "-${lookback_days} days" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) \
214
+ || period_start_ts=$(date -d "-${lookback_days} days" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) \
215
+ || period_start_ts=""
200
216
  fi
217
+ local period_start period_end
218
+ period_end="${period_end_ts%%T*}"; [[ -z "$period_end" ]] && period_end="unknown"
219
+ period_start="${period_start_ts%%T*}"; [[ -z "$period_start" ]] && period_start="unknown"
201
220
 
202
221
  # Determine sources consulted.
203
222
  local sources_json
@@ -235,13 +254,19 @@ counsel_generate_brief() {
235
254
 
236
255
  local payload
237
256
  payload=$(jq -n \
238
- --arg ps "$period_start" \
239
- --arg pe "$period_end" \
257
+ --arg ps "$period_start_ts" \
258
+ --arg pe "$period_end_ts" \
240
259
  --argjson rc "$rec_count" \
241
260
  --argjson src "$sources_json" \
242
261
  '{period_start: $ps, period_end: $pe, recommendation_count: $rc, sources_consulted: $src}') || payload=""
243
262
 
244
- [[ -n "$payload" ]] && counsel_emit_event "counsel.brief.generated" "$payload" || true
263
+ # Only emit when the period bounds are valid RFC 3339 date-times — emitting
264
+ # empty bounds would fail schema validation and silently drop the event.
265
+ if [[ -n "$payload" && -n "$period_start_ts" && -n "$period_end_ts" ]]; then
266
+ counsel_emit_event "counsel.brief.generated" "$payload" || true
267
+ else
268
+ printf 'counsel_generate_brief: skipped counsel.brief.generated (no valid period bounds)\n' >&2
269
+ fi
245
270
 
246
271
  printf '%s' "$output_path"
247
272
  }
@@ -46,9 +46,15 @@ counsel_read_events() {
46
46
  # Filter to events within the lookback window. If cutoff_ts is empty (date
47
47
  # command unavailable) fall through and include all events.
48
48
  local summary
49
+ # Run inside a subshell with pipefail disabled. head -c closes the pipe once
50
+ # chars_max bytes have arrived, which sends jq SIGPIPE; under the caller's
51
+ # `set -o pipefail` (the SessionStart hook and the /counsel skill both set it)
52
+ # that marks the whole pipeline failed, and the `|| summary=""` fallback would
53
+ # then discard *every* event on any log large enough to exceed chars_max.
54
+ # Disabling pipefail locally keeps the truncated output.
49
55
  # -rc: compact output keeps each object on one line (JSONL-shaped), which
50
56
  # downstream counsel_count_events and counsel_sources_from_events require.
51
- summary=$(jq -rc --arg cutoff "$cutoff_ts" '
57
+ summary=$(set +o pipefail; jq -rc --arg cutoff "$cutoff_ts" '
52
58
  select(.timestamp != null) |
53
59
  select($cutoff == "" or .timestamp >= $cutoff) |
54
60
  {
@@ -53,7 +53,11 @@ counsel_synthesize() {
53
53
  local events_text="${1:-}"
54
54
  local model="${2:-claude-haiku-4-5-20251001}"
55
55
  local timeout_s="${3:-90}"
56
+ # shellcheck disable=SC2034 # accepted for call-site compatibility; the
57
+ # claude CLI print mode exposes no max-tokens/temperature flags, so neither
58
+ # is forwarded (see claude_args below).
56
59
  local max_tokens="${4:-4096}"
60
+ # shellcheck disable=SC2034
57
61
  local temperature="${5:-0.4}"
58
62
 
59
63
  [[ -z "$events_text" ]] && return 1
@@ -74,7 +78,10 @@ counsel_synthesize() {
74
78
  printf '</event_log>\n'
75
79
  } > "$prompt_file"
76
80
 
77
- local claude_args=(-p --max-turns 1 --model "$model" --max-tokens "$max_tokens")
81
+ # NOTE: `claude -p` does not accept --max-tokens (it errors with "unknown
82
+ # option") and has no temperature flag, so we pass neither. Output length is
83
+ # governed by the model/prompt; the synthesis prompt asks for terse JSON.
84
+ local claude_args=(-p --max-turns 1 --model "$model")
78
85
 
79
86
  local response=""
80
87
  if command -v timeout >/dev/null 2>&1; then
@@ -0,0 +1,146 @@
1
+ ---
2
+ name: counsel
3
+ description: Run the weekly observability synthesis on demand and render the coaching brief in the conversation. Reads the onlooker event log, runs a single synthesis pass, writes the brief, and prints it — bypassing the SessionStart staleness gate. Use when the user explicitly invokes /counsel, or wants a fresh improvement brief right now instead of waiting for the next stale-brief regeneration. Supports --show (print the latest brief, no LLM call) and --status.
4
+ ---
5
+
6
+ # Counsel Skill
7
+
8
+ Counsel's SessionStart hook regenerates a weekly improvement brief only when the
9
+ last one has gone stale (`synthesis_interval_days`, default 7) and injects it
10
+ invisibly. This skill is the on-demand path: it forces a fresh synthesis right
11
+ now and renders the brief into the conversation.
12
+
13
+ ## Setup
14
+
15
+ Run this once at the start. It sources the plugin helpers, loads config, and
16
+ resolves project context.
17
+
18
+ ```bash
19
+ set -uo pipefail
20
+ PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
21
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
22
+
23
+ source "$PLUGIN_ROOT/scripts/lib/counsel-config.sh"
24
+ source "$PLUGIN_ROOT/scripts/lib/counsel-events.sh"
25
+ source "$PLUGIN_ROOT/scripts/lib/counsel-project-key.sh"
26
+ source "$PLUGIN_ROOT/scripts/lib/counsel-ulid.sh"
27
+ source "$PLUGIN_ROOT/scripts/lib/counsel-reader.sh"
28
+ source "$PLUGIN_ROOT/scripts/lib/counsel-synthesize.sh"
29
+ source "$PLUGIN_ROOT/scripts/lib/counsel-brief.sh"
30
+
31
+ REPO_ROOT=$(counsel_project_repo_root "$(pwd)")
32
+ counsel_config_load "$REPO_ROOT"
33
+
34
+ if ! counsel_config_enabled; then
35
+ echo "Counsel is disabled. Set counsel.enabled=true in .claude/settings.json to enable."
36
+ exit 0
37
+ fi
38
+
39
+ PROJECT_KEY=$(counsel_project_key "$(pwd)")
40
+ if [[ -z "$PROJECT_KEY" ]]; then
41
+ echo "No project key — Counsel needs a git repository (remote or root) to scope briefs. Skipping."
42
+ exit 0
43
+ fi
44
+ BRIEFS_DIR=$(counsel_project_dir "$PROJECT_KEY")
45
+ ```
46
+
47
+ ## Invocation Modes
48
+
49
+ ### `/counsel` — run the weekly review now (default)
50
+
51
+ Forces a synthesis pass regardless of brief freshness, writes the brief to
52
+ `${ONLOOKER_DIR:-~/.onlooker}/counsel/<project-key>/briefs/<YYYY-WW>.md`, emits
53
+ `counsel.brief.generated`, and renders the result. Re-running in the same ISO
54
+ week overwrites that week's brief in place. (`$ONLOOKER_DIR` overrides the
55
+ storage root; the test suite and non-default installs rely on it.)
56
+
57
+ ```bash
58
+ SESSION_ID="${CLAUDE_SESSION_ID:-$(counsel_ulid)}"
59
+ export _HOOK_SESSION_ID="$SESSION_ID"
60
+
61
+ LOOKBACK=$(counsel_config_get '.counsel.lookback_days'); LOOKBACK="${LOOKBACK:-30}"
62
+ echo "Running Counsel synthesis over the last ${LOOKBACK} days of events…"
63
+
64
+ _rc=0
65
+ OUTPUT_PATH=$(counsel_generate_brief "$SESSION_ID" "$(pwd)" force) || _rc=$?
66
+
67
+ if [[ "$_rc" -eq 2 ]]; then
68
+ min_events=$(counsel_config_get '.counsel.capture.min_events'); min_events="${min_events:-10}"
69
+ echo "Not enough events to synthesize a brief (fewer than ${min_events} in the lookback window). Use the ecosystem long enough to accumulate telemetry, then try again."
70
+ exit 0
71
+ elif [[ "$_rc" -ne 0 || -z "$OUTPUT_PATH" || ! -f "$OUTPUT_PATH" ]]; then
72
+ echo "Counsel synthesis failed. Check that the \`claude\` CLI is on PATH and the onlooker log is readable."
73
+ exit 1
74
+ fi
75
+ ```
76
+
77
+ Then render the brief verbatim to the conversation:
78
+
79
+ ```bash
80
+ echo "## Counsel weekly review (\`$(basename "$OUTPUT_PATH" .md)\`)"
81
+ echo ""
82
+ cat "$OUTPUT_PATH"
83
+ echo ""
84
+ echo "_Brief saved to ${OUTPUT_PATH}._"
85
+ ```
86
+
87
+ ### `/counsel --show` — print the latest brief (no LLM call)
88
+
89
+ Renders the most recent brief already on disk. No synthesis, no events emitted.
90
+
91
+ ```bash
92
+ LATEST=$(ls -1 "$BRIEFS_DIR"/*.md 2>/dev/null | sort | tail -1)
93
+ if [[ -z "$LATEST" || ! -f "$LATEST" ]]; then
94
+ echo "No brief on disk yet for this project. Run \`/counsel\` to generate one."
95
+ exit 0
96
+ fi
97
+ echo "## Counsel brief (\`$(basename "$LATEST" .md)\`)"
98
+ echo ""
99
+ cat "$LATEST"
100
+ ```
101
+
102
+ ### `/counsel --status` — brief freshness
103
+
104
+ Reports the latest brief's age, last-generated time, and path. No LLM call.
105
+
106
+ ```bash
107
+ INTERVAL=$(counsel_config_get '.counsel.synthesis_interval_days'); INTERVAL="${INTERVAL:-7}"
108
+ LATEST=$(ls -1 "$BRIEFS_DIR"/*.md 2>/dev/null | sort | tail -1)
109
+
110
+ echo "## Counsel status"
111
+ echo "- Project key: ${PROJECT_KEY}"
112
+ echo "- Briefs dir: ${BRIEFS_DIR}"
113
+ echo "- Stale after: ${INTERVAL} days"
114
+
115
+ if [[ -z "$LATEST" || ! -f "$LATEST" ]]; then
116
+ echo "- Latest brief: none yet (run \`/counsel\`)"
117
+ exit 0
118
+ fi
119
+
120
+ if [[ "$(uname)" == "Darwin" ]]; then
121
+ mtime=$(stat -f '%m' "$LATEST" 2>/dev/null || echo 0)
122
+ human=$(date -r "$mtime" 2>/dev/null || echo "$mtime")
123
+ else
124
+ mtime=$(stat -c '%Y' "$LATEST" 2>/dev/null || echo 0)
125
+ human=$(date -d "@$mtime" 2>/dev/null || echo "$mtime")
126
+ fi
127
+ now=$(date +%s 2>/dev/null || echo "$mtime")
128
+ age_days=$(( (now - mtime) / 86400 ))
129
+
130
+ echo "- Latest brief: $(basename "$LATEST") (generated ${human}, ${age_days}d ago)"
131
+ if [[ "$age_days" -ge "$INTERVAL" ]]; then
132
+ echo "- Status: stale — SessionStart will regenerate, or run \`/counsel\` now."
133
+ else
134
+ echo "- Status: fresh — run \`/counsel\` to force a regeneration anyway."
135
+ fi
136
+ ```
137
+
138
+ ## Notes
139
+
140
+ - The synthesis pass shells out to `claude -p` with the configured
141
+ `evaluator.model` (default Haiku) — see the plugin README for all config keys.
142
+ - The on-demand path honors the same `capture.min_events` floor as the hook: if
143
+ too few events fall inside the lookback window, it reports that instead of
144
+ emitting a thin brief.
145
+ - Output, events, storage layout, and project keying are identical to the
146
+ SessionStart path — this skill only bypasses the staleness gate.
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Exercises counsel_generate_brief's staleness gate and the force bypass the
4
+ # on-demand /counsel skill relies on. The claude CLI is stubbed so synthesis is
5
+ # deterministic and offline.
6
+
7
+ setup() {
8
+ # shellcheck source=../helpers/setup.bash
9
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
10
+ setup_test_env
11
+
12
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/counsel"
13
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
14
+ # shellcheck disable=SC1091
15
+ source "${PLUGIN_ROOT}/scripts/lib/counsel-config.sh"
16
+ # shellcheck disable=SC1091
17
+ source "${PLUGIN_ROOT}/scripts/lib/counsel-events.sh"
18
+ # shellcheck disable=SC1091
19
+ source "${PLUGIN_ROOT}/scripts/lib/counsel-project-key.sh"
20
+ # shellcheck disable=SC1091
21
+ source "${PLUGIN_ROOT}/scripts/lib/counsel-ulid.sh"
22
+ # shellcheck disable=SC1091
23
+ source "${PLUGIN_ROOT}/scripts/lib/counsel-reader.sh"
24
+ # shellcheck disable=SC1091
25
+ source "${PLUGIN_ROOT}/scripts/lib/counsel-synthesize.sh"
26
+ # shellcheck disable=SC1091
27
+ source "${PLUGIN_ROOT}/scripts/lib/counsel-brief.sh"
28
+
29
+ # A git work tree so counsel_project_key resolves to a stable root key.
30
+ WORK="${BATS_TEST_TMPDIR}/repo"
31
+ mkdir -p "$WORK"
32
+ git -C "$WORK" init -q
33
+ git -C "$WORK" config user.email test@example.com
34
+ git -C "$WORK" config user.name test
35
+
36
+ counsel_config_load "$WORK"
37
+
38
+ # Stub the claude CLI: ignore stdin/args, emit a valid synthesis object whose
39
+ # summary carries a marker we can assert on in the written brief.
40
+ STUB_BIN="${BATS_TEST_TMPDIR}/bin"
41
+ mkdir -p "$STUB_BIN"
42
+ cat > "${STUB_BIN}/claude" <<'STUB'
43
+ #!/usr/bin/env bash
44
+ cat <<'JSON'
45
+ {"summary":"SYNTH_MARKER weekly review","patterns":["pattern one"],"recommendations":[{"title":"do x","rationale":"because y","priority":"high"}],"wins":["win one"],"watch":["watch one"]}
46
+ JSON
47
+ STUB
48
+ chmod +x "${STUB_BIN}/claude"
49
+ export PATH="${STUB_BIN}:${PATH}"
50
+
51
+ # Event log with comfortably more than min_events records in-window.
52
+ export ONLOOKER_EVENTS_LOG="${ONLOOKER_DIR}/logs/onlooker-events.jsonl"
53
+ mkdir -p "$(dirname "$ONLOOKER_EVENTS_LOG")"
54
+ local ts
55
+ ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) || ts="2099-01-01T00:00:00Z"
56
+ : > "$ONLOOKER_EVENTS_LOG"
57
+ local i
58
+ for ((i = 0; i < 12; i++)); do
59
+ printf '%s\n' \
60
+ "{\"event_type\":\"tribunal.gate.blocked\",\"timestamp\":\"${ts}\",\"session_id\":\"s${i}\",\"payload\":{}}" \
61
+ >> "$ONLOOKER_EVENTS_LOG"
62
+ done
63
+
64
+ PROJECT_KEY=$(counsel_project_key "$WORK")
65
+ BRIEFS_DIR=$(counsel_project_dir "$PROJECT_KEY")
66
+ mkdir -p "$BRIEFS_DIR"
67
+ }
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Staleness gate (default, non-force path used by the SessionStart hook)
71
+ # ---------------------------------------------------------------------------
72
+
73
+ # counsel_generate_brief prints the brief path on stdout and diagnostics on
74
+ # stderr; capture stdout only so $out is exactly the path.
75
+ gen() {
76
+ GEN_STATUS=0
77
+ GEN_OUT=$(counsel_generate_brief "$@" 2>/dev/null) || GEN_STATUS=$?
78
+ }
79
+
80
+ @test "generate_brief writes a brief when none exists yet" {
81
+ gen "sess-1" "$WORK"
82
+ [ "$GEN_STATUS" -eq 0 ]
83
+ [ -f "$GEN_OUT" ]
84
+ grep -q "SYNTH_MARKER" "$GEN_OUT"
85
+ }
86
+
87
+ @test "generate_brief skips (rc=2) when the latest brief is still fresh" {
88
+ # A freshly written brief makes counsel_brief_is_stale return false.
89
+ printf '# old brief\n' > "${BRIEFS_DIR}/2099-01.md"
90
+ gen "sess-2" "$WORK"
91
+ [ "$GEN_STATUS" -eq 2 ]
92
+ }
93
+
94
+ # Bullet lists and the rule in the rendered brief must not be mangled by
95
+ # printf treating a leading dash as an option.
96
+ @test "rendered brief contains intact bullets and horizontal rule" {
97
+ gen "sess-bullets" "$WORK"
98
+ [ "$GEN_STATUS" -eq 0 ]
99
+ grep -q '^- pattern one$' "$GEN_OUT"
100
+ grep -q '^- win one$' "$GEN_OUT"
101
+ grep -q '^---$' "$GEN_OUT"
102
+ }
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Force bypass (on-demand /counsel skill path)
106
+ # ---------------------------------------------------------------------------
107
+
108
+ @test "force bypasses the staleness gate and regenerates a fresh brief" {
109
+ printf '# stale-looking but fresh-on-disk brief\n' > "${BRIEFS_DIR}/2099-01.md"
110
+
111
+ gen "sess-3" "$WORK" force
112
+ [ "$GEN_STATUS" -eq 0 ]
113
+ [ -f "$GEN_OUT" ]
114
+ grep -q "SYNTH_MARKER" "$GEN_OUT"
115
+ }
116
+
117
+ @test "force accepts the literal \"1\" as well" {
118
+ printf '# fresh\n' > "${BRIEFS_DIR}/2099-01.md"
119
+ gen "sess-4" "$WORK" 1
120
+ [ "$GEN_STATUS" -eq 0 ]
121
+ grep -q "SYNTH_MARKER" "$GEN_OUT"
122
+ }
123
+
124
+ @test "force still respects the min_events floor" {
125
+ # Only a couple of events — below the default min_events of 10.
126
+ local ts
127
+ ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) || ts="2099-01-01T00:00:00Z"
128
+ printf '%s\n' \
129
+ "{\"event_type\":\"tribunal.gate.blocked\",\"timestamp\":\"${ts}\",\"session_id\":\"s1\",\"payload\":{}}" \
130
+ "{\"event_type\":\"echo.regression.detected\",\"timestamp\":\"${ts}\",\"session_id\":\"s2\",\"payload\":{}}" \
131
+ > "$ONLOOKER_EVENTS_LOG"
132
+
133
+ gen "sess-5" "$WORK" force
134
+ [ "$GEN_STATUS" -eq 2 ]
135
+ }
136
+
137
+ # Regression: counsel.brief.generated must validate against the schema and land
138
+ # in the event log. The period bounds are emitted as RFC 3339 date-time strings.
139
+ @test "generated brief emits a schema-valid counsel.brief.generated event" {
140
+ gen "sess-evt" "$WORK"
141
+ [ "$GEN_STATUS" -eq 0 ]
142
+ run grep -c '"event_type":"counsel.brief.generated"' "$ONLOOKER_EVENTS_LOG"
143
+ [ "$status" -eq 0 ]
144
+ [ "$output" -ge 1 ]
145
+ # period_start must be a full date-time, not a bare calendar date.
146
+ run grep -o '"period_start":"[^"]*"' "$ONLOOKER_EVENTS_LOG"
147
+ [[ "$output" == *"T"*"Z"* ]]
148
+ }
149
+
150
+ # If date cannot produce timestamps the bounds are empty; rather than emit an
151
+ # event that fails schema validation, the emit is skipped and the brief is
152
+ # still written.
153
+ @test "emit is skipped (never invalid) when date cannot produce bounds" {
154
+ local datestub="${BATS_TEST_TMPDIR}/datebin"
155
+ mkdir -p "$datestub"
156
+ printf '#!/usr/bin/env bash\nexit 1\n' > "${datestub}/date"
157
+ chmod +x "${datestub}/date"
158
+ PATH="${datestub}:${PATH}"
159
+
160
+ gen "sess-nodate" "$WORK" force
161
+ [ "$GEN_STATUS" -eq 0 ]
162
+ [ -f "$GEN_OUT" ]
163
+ grep -q "SYNTH_MARKER" "$GEN_OUT"
164
+ # No counsel.brief.generated event should have been appended.
165
+ run grep -c '"event_type":"counsel.brief.generated"' "$ONLOOKER_EVENTS_LOG"
166
+ [ "$output" -eq 0 ]
167
+ }
@@ -114,6 +114,34 @@ setup() {
114
114
  [ "$output" = "3" ]
115
115
  }
116
116
 
117
+ @test "read_events survives caller pipefail when output exceeds chars_max" {
118
+ # Regression: head -c closes the pipe once chars_max bytes arrive, sending jq
119
+ # SIGPIPE. Under the caller's `set -o pipefail` (as the hook and skill run),
120
+ # the reader must still return the truncated output, not discard everything.
121
+ local log="${BATS_TEST_TMPDIR}/big-events.jsonl"
122
+ local ts
123
+ ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) || ts="2099-01-01T00:00:00Z"
124
+ : > "$log"
125
+ local i
126
+ for ((i = 0; i < 60; i++)); do
127
+ printf '%s\n' \
128
+ "{\"event_type\":\"tribunal.gate.blocked\",\"timestamp\":\"${ts}\",\"session_id\":\"s${i}\",\"payload\":{\"k\":\"vvvvvvvvvvvvvvvvvvvvvvvvvvvvvv\"}}" \
129
+ >> "$log"
130
+ done
131
+ export ONLOOKER_EVENTS_LOG="$log"
132
+
133
+ # Tiny cap so head closes the pipe after the first event or two.
134
+ local out
135
+ set -o pipefail
136
+ out=$(counsel_read_events "30" "200")
137
+ set +o pipefail
138
+
139
+ [ -n "$out" ]
140
+ run counsel_count_events "$out"
141
+ [ "$status" -eq 0 ]
142
+ [ "$output" -ge 1 ]
143
+ }
144
+
117
145
  @test "read_events output preserves source types for sources_from_events" {
118
146
  local log="${BATS_TEST_TMPDIR}/source-events.jsonl"
119
147
  local ts