@onlooker-community/ecosystem 0.17.0 → 0.19.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 (47) hide show
  1. package/.claude-plugin/marketplace.json +26 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.release-please-manifest.json +4 -2
  4. package/CHANGELOG.md +14 -0
  5. package/CLAUDE.md +1 -0
  6. package/package.json +2 -2
  7. package/plugins/counsel/.claude-plugin/plugin.json +14 -0
  8. package/plugins/counsel/CHANGELOG.md +8 -0
  9. package/plugins/counsel/config.json +20 -0
  10. package/plugins/counsel/hooks/hooks.json +15 -0
  11. package/plugins/counsel/scripts/hooks/counsel-session-start.sh +106 -0
  12. package/plugins/counsel/scripts/lib/counsel-brief.sh +247 -0
  13. package/plugins/counsel/scripts/lib/counsel-config.sh +72 -0
  14. package/plugins/counsel/scripts/lib/counsel-events.sh +80 -0
  15. package/plugins/counsel/scripts/lib/counsel-project-key.sh +79 -0
  16. package/plugins/counsel/scripts/lib/counsel-reader.sh +114 -0
  17. package/plugins/counsel/scripts/lib/counsel-synthesize.sh +103 -0
  18. package/plugins/counsel/scripts/lib/counsel-ulid.sh +45 -0
  19. package/plugins/warden/.claude-plugin/plugin.json +14 -0
  20. package/plugins/warden/CHANGELOG.md +10 -0
  21. package/plugins/warden/config.json +51 -0
  22. package/plugins/warden/docs/adr/001-detect-after-ingest-gate-before-action.md +62 -0
  23. package/plugins/warden/docs/design.md +123 -0
  24. package/plugins/warden/hooks/hooks.json +73 -0
  25. package/plugins/warden/scripts/hooks/warden-post-tool-use.sh +201 -0
  26. package/plugins/warden/scripts/hooks/warden-pre-tool-use.sh +94 -0
  27. package/plugins/warden/scripts/hooks/warden-session-start.sh +52 -0
  28. package/plugins/warden/scripts/lib/warden-cli.sh +124 -0
  29. package/plugins/warden/scripts/lib/warden-config.sh +79 -0
  30. package/plugins/warden/scripts/lib/warden-evaluator.sh +246 -0
  31. package/plugins/warden/scripts/lib/warden-events.sh +85 -0
  32. package/plugins/warden/scripts/lib/warden-gate-state.sh +105 -0
  33. package/plugins/warden/scripts/lib/warden-patterns.sh +132 -0
  34. package/plugins/warden/scripts/lib/warden-sanitizer.sh +80 -0
  35. package/plugins/warden/scripts/lib/warden-scanner.sh +119 -0
  36. package/plugins/warden/scripts/lib/warden-ulid.sh +50 -0
  37. package/plugins/warden/skills/warden/SKILL.md +49 -0
  38. package/release-please-config.json +32 -0
  39. package/test/bats/counsel-project-key.bats +82 -0
  40. package/test/bats/counsel-reader.bats +132 -0
  41. package/test/bats/warden-config.bats +54 -0
  42. package/test/bats/warden-events.bats +85 -0
  43. package/test/bats/warden-gate-state.bats +67 -0
  44. package/test/bats/warden-patterns.bats +58 -0
  45. package/test/bats/warden-sanitizer.bats +53 -0
  46. package/test/bats/warden-scanner.bats +56 -0
  47. package/test/bats/warden-ulid.bats +30 -0
@@ -98,6 +98,32 @@
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"]
114
+ },
115
+ {
116
+ "name": "warden",
117
+ "source": "./plugins/warden",
118
+ "description": "Untrusted-content gate. Scans content flowing in through WebFetch and Read for prompt-injection patterns, and when a threat is detected closes a session-scoped gate that blocks Write, Edit, and Bash until the user explicitly clears it. Grounded in Meta's Agents Rule of Two — warden removes the agent's external-actions property while untrusted content is in play. Requires the ecosystem plugin.",
119
+ "author": {
120
+ "name": "Onlooker Community"
121
+ },
122
+ "homepage": "https://onlooker.dev",
123
+ "repository": "https://github.com/onlooker-community/ecosystem",
124
+ "license": "MIT",
125
+ "keywords": ["security", "prompt-injection", "rule-of-two", "safety", "content-gate", "untrusted-content"],
126
+ "tags": ["safety", "security"]
101
127
  }
102
128
  ]
103
129
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ecosystem",
3
- "version": "0.17.0",
3
+ "version": "0.19.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,12 @@
1
1
  {
2
- ".": "0.17.0",
2
+ ".": "0.19.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",
11
+ "plugins/warden": "0.2.0"
10
12
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.19.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.18.0...ecosystem-v0.19.0) (2026-06-02)
4
+
5
+
6
+ ### Features
7
+
8
+ * **warden:** untrusted-content gate enforcing the Agents Rule of Two :shield: ([#53](https://github.com/onlooker-community/ecosystem/issues/53)) ([210aa51](https://github.com/onlooker-community/ecosystem/commit/210aa51bff66226a0eec1f17292a2af4ea4ef56a))
9
+
10
+ ## [0.18.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.17.0...ecosystem-v0.18.0) (2026-06-02)
11
+
12
+
13
+ ### Features
14
+
15
+ * **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))
16
+
3
17
  ## [0.17.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.16.0...ecosystem-v0.17.0) (2026-06-01)
4
18
 
5
19
 
package/CLAUDE.md CHANGED
@@ -36,6 +36,7 @@ scripts/lib/onlooker-event.mjs ← canonical event builder; all plugins route t
36
36
  | echo | Stop | Regression-tests prompt changes after each agent stop |
37
37
  | governor | SessionStart, PreToolUse (Task), PostToolUse (Task), Stop | Budget gates on subagent spawns; tracks spend per session |
38
38
  | tribunal | Stop + skill invocation | Post-task quality gate; also invokable via `/tribunal` |
39
+ | warden | PostToolUse (WebFetch, Read), PreToolUse (Write, Edit, MultiEdit, Bash), SessionStart + skill invocation | Scans ingested content for injection; closes a content gate that blocks write-class tools until cleared via `/warden` |
39
40
 
40
41
  Plugins communicate by emitting events to the JSONL log — they do not call each other directly. All plugins depend on the ecosystem substrate; no plugin depends on another plugin directly.
41
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlooker-community/ecosystem",
3
- "version": "0.17.0",
3
+ "version": "0.19.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 plugins/warden/scripts/hooks/*.sh plugins/warden/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
+ }