@onlooker-community/ecosystem 0.25.0 → 0.26.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.
@@ -5,26 +5,26 @@
5
5
  "email": "community@onlooker.dev"
6
6
  },
7
7
  "metadata": {
8
- "description": "TODO Fill this out"
8
+ "description": "Composable observability, memory, and quality-gate plugins for Claude Code — all built on the Onlooker ecosystem event substrate."
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "ecosystem",
13
13
  "source": "./",
14
- "description": "Fill this out",
14
+ "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.",
15
15
  "author": {
16
16
  "name": "Onlooker Community"
17
17
  },
18
18
  "homepage": "https://onlooker.dev",
19
19
  "repository": "https://github.com/onlooker-community/ecosystem",
20
20
  "license": "MIT",
21
- "keywords": [],
22
- "tags": []
21
+ "keywords": ["observability", "substrate", "events", "hooks", "telemetry"],
22
+ "tags": ["observability", "substrate"]
23
23
  },
24
24
  {
25
25
  "name": "archivist",
26
26
  "source": "./plugins/archivist",
27
- "description": "Structured session memory across context truncation. Extracts decisions, dead ends, and open questions on PreCompact and reinjects the most important items at SessionStart. Requires the ecosystem plugin.",
27
+ "description": "Structured session memory across context truncation: extracts decisions, dead ends, and open questions on PreCompact and reinjects the most important items at SessionStart. Requires the ecosystem plugin.",
28
28
  "author": {
29
29
  "name": "Onlooker Community"
30
30
  },
@@ -34,10 +34,23 @@
34
34
  "keywords": ["memory", "compaction", "context", "session"],
35
35
  "tags": ["memory", "context-engineering"]
36
36
  },
37
+ {
38
+ "name": "cartographer",
39
+ "source": "./plugins/cartographer",
40
+ "description": "Proactive periodic auditor of the persistent instruction layer (CLAUDE.md, AGENTS.md, .claude/rules/). Discovers all instruction files in the repo, extracts semantic maps, and surfaces contradictions, shadowing, gaps, and drift before they cause expensive agent misbehavior. Requires the ecosystem plugin.",
41
+ "author": {
42
+ "name": "Onlooker Community"
43
+ },
44
+ "homepage": "https://onlooker.dev",
45
+ "repository": "https://github.com/onlooker-community/ecosystem",
46
+ "license": "MIT",
47
+ "keywords": ["instructions", "audit", "claude-md", "agents-md", "drift", "consistency"],
48
+ "tags": ["instructions", "context-engineering"]
49
+ },
37
50
  {
38
51
  "name": "tribunal",
39
52
  "source": "./plugins/tribunal",
40
- "description": "Multi-agent execution with LLM-as-a-Judge quality gates. An Actor performs work; a jury of typed Judges scores it against a project-overridable rubric; a Meta-Judge reviews the jury for bias; the gate decides accept, retry, or exhaust. Requires the ecosystem plugin.",
53
+ "description": "Multi-agent execution with LLM-as-a-Judge quality gates. An Actor performs work; a jury of typed Judges scores it against a project-overridable rubric; a Meta-Judge reviews the jury for bias; the gate decides accept, retry, or exhaust. Grounded in LLM-as-a-Judge (Zheng et al. 2023) and LLM-as-a-Meta-Judge (Wu et al. 2024). Requires the ecosystem plugin.",
41
54
  "author": {
42
55
  "name": "Onlooker Community"
43
56
  },
@@ -76,7 +89,7 @@
76
89
  {
77
90
  "name": "compass",
78
91
  "source": "./plugins/compass",
79
- "description": "Pre-write intent clarity gate. Intercepts write-class tool calls and samples N=5 parallel evaluators to score intent clarity before allowing writes to proceed. Blocks when confidence is low or evaluators disagree, surfacing a structured clarification prompt. Requires the ecosystem plugin.",
92
+ "description": "Pre-write intent clarity gate. Intercepts write-class tool calls and requires a confidence threshold before allowing them to proceed. Evaluates the pending write against the prior assistant turn as context to avoid false positives on question-answer turns. Requires the ecosystem plugin.",
80
93
  "author": {
81
94
  "name": "Onlooker Community"
82
95
  },
@@ -89,7 +102,7 @@
89
102
  {
90
103
  "name": "scribe",
91
104
  "source": "./plugins/scribe",
92
- "description": "Intent documentation from agent activity. Captures why changes were made — problem context, decisions, tradeoffs, and constraints — and distills them into readable Markdown artifacts at session end. Git logs record what changed; scribe records why. Requires the ecosystem plugin.",
105
+ "description": "Intent documentation from agent activity. Captures why changes were made — problem context, decisions, tradeoffs — and distills them into readable artifacts at session end. Requires the ecosystem plugin.",
93
106
  "author": {
94
107
  "name": "Onlooker Community"
95
108
  },
@@ -102,7 +115,7 @@
102
115
  {
103
116
  "name": "counsel",
104
117
  "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.",
118
+ "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. Requires the ecosystem plugin.",
106
119
  "author": {
107
120
  "name": "Onlooker Community"
108
121
  },
@@ -115,7 +128,7 @@
115
128
  {
116
129
  "name": "warden",
117
130
  "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.",
131
+ "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: an agent should hold no more than two of {private data, external actions, untrusted content} at once — warden removes the external-actions property while untrusted content is in play. Requires the ecosystem plugin.",
119
132
  "author": {
120
133
  "name": "Onlooker Community"
121
134
  },
@@ -128,7 +141,7 @@
128
141
  {
129
142
  "name": "librarian",
130
143
  "source": "./plugins/librarian",
131
- "description": "Consolidation layer between archivist's per-session artifacts and the user's durable typed memory store. Reads archivist artifacts at SessionEnd, applies a durability filter, classifies survivors via Haiku into the four memory types (user, feedback, project, reference), and queues proposals for explicit confirmation via /librarian review. Auto-promotion is opt-in. Requires the ecosystem plugin.",
144
+ "description": "Consolidation layer between archivist's per-session artifacts and the user's durable typed memory store. Detects which session decisions, dead-ends, and open questions deserve to live across sessions, classifies them into the user/feedback/project/reference types, and queues them as proposals for explicit confirmation. Auto-promotion is opt-in. Requires the ecosystem plugin.",
132
145
  "author": {
133
146
  "name": "Onlooker Community"
134
147
  },
@@ -141,7 +154,7 @@
141
154
  {
142
155
  "name": "curator",
143
156
  "source": "./plugins/curator",
144
- "description": "Maintenance layer for the typed auto-memory store. At every SessionStart, runs four cheap heuristic checks against the memories at ~/.claude/projects/<encoded>/memory/ date_decayed (ISO-8601 dates past the grace period), path_broken (path-shaped references that don't resolve under the repo root), broken_index (MEMORY.md pointing at missing files), and orphaned_memory (files in the dir not referenced from MEMORY.md). Surfaces findings via /curator review; never edits the memory store directly. Parallel to cartographer (which audits hand-maintained instruction files), curator audits the auto-memory substrate. Requires the ecosystem plugin.",
157
+ "description": "Maintenance layer for the user's typed auto-memory store. At every SessionStart, runs four cheap heuristic checks (date_decayed, path_broken, broken_index, orphaned_memory) against the memories at ~/.claude/projects/<encoded>/memory/ inside a wall-clock budget. Surfaces findings as a one-line pointer to /curator review; never edits the memory store directly. Requires the ecosystem plugin.",
145
158
  "author": {
146
159
  "name": "Onlooker Community"
147
160
  },
@@ -154,7 +167,7 @@
154
167
  {
155
168
  "name": "historian",
156
169
  "source": "./plugins/historian",
157
- "description": "Episodic memory layer for past Claude Code sessions. At SessionEnd, reads the session transcript, drops tool calls and tool results, chunks the remaining user + assistant turns at turn boundaries with overlap, redacts secret-shaped substrings (AWS keys, GitHub PATs, Anthropic API keys, KEY=value env assignments), and appends one JSONL line per surviving chunk to ~/.onlooker/historian/<project-key>/sessions/<session-id>.jsonl. Future-tense retrieval (vector embeddings + UserPromptSubmit similarity surfacer) lands in a follow-up; this version ships the indexing pipeline only. Requires the ecosystem plugin.",
170
+ "description": "Episodic memory layer. At SessionEnd, chunks and sanitizes the session transcript and stores chunks under $ONLOOKER_DIR/historian/<project-key>/sessions/ (default $HOME/.onlooker). On UserPromptSubmit, embeds the prompt and performs similarity retrieval over stored chunks to surface relevant past context. Requires the ecosystem plugin.",
158
171
  "author": {
159
172
  "name": "Onlooker Community"
160
173
  },
@@ -167,7 +180,7 @@
167
180
  {
168
181
  "name": "assayer",
169
182
  "source": "./plugins/assayer",
170
- "description": "Claim verification. At session end, parses the agent's final message for testable success claims (\"I ran the tests, they pass\", \"the build is green\") and cross-checks each against the actual command results in the session transcript, classifying it corroborated, contradicted, or unverifiable. Catches lying-without-malice. Advisory by default. Requires the ecosystem plugin.",
183
+ "description": "Claim verification. At session end, parses the agent's final message for testable claims (\"I ran the tests, they pass\", \"the build is green\") and checks each against the actual command results in the session transcript, classifying it corroborated, contradicted, or unverifiable. Catches lying-without-malice. Advisory by default. Requires the ecosystem plugin.",
171
184
  "author": {
172
185
  "name": "Onlooker Community"
173
186
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ecosystem",
3
- "version": "0.25.0",
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.",
3
+ "version": "0.26.0",
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",
7
7
  "url": "https://onlooker.dev"
@@ -1,13 +1,13 @@
1
1
  {
2
- ".": "0.25.0",
2
+ ".": "0.26.0",
3
3
  "plugins/archivist": "0.1.0",
4
4
  "plugins/tribunal": "1.0.1",
5
5
  "plugins/echo": "0.2.0",
6
- "plugins/cartographer": "0.2.0",
7
- "plugins/governor": "0.2.0",
6
+ "plugins/cartographer": "0.2.1",
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.0",
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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.26.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.25.1...ecosystem-v0.26.0) (2026-06-11)
4
+
5
+
6
+ ### Features
7
+
8
+ * **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))
9
+
10
+ ## [0.25.1](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.25.0...ecosystem-v0.25.1) (2026-06-10)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * vendor portable-lock.sh into cartographer and governor ([#73](https://github.com/onlooker-community/ecosystem/issues/73)) ([ab2c354](https://github.com/onlooker-community/ecosystem/commit/ab2c354b131c26cc642ebb51e84a043dc43cbaa1))
16
+
3
17
  ## [0.25.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.24.0...ecosystem-v0.25.0) (2026-06-04)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlooker-community/ecosystem",
3
- "version": "0.25.0",
3
+ "version": "0.26.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 plugins/counsel/scripts/hooks/*.sh plugins/counsel/scripts/lib/*.sh plugins/warden/scripts/hooks/*.sh plugins/warden/scripts/lib/*.sh plugins/librarian/scripts/hooks/*.sh plugins/librarian/scripts/lib/*.sh plugins/curator/scripts/hooks/*.sh plugins/curator/scripts/lib/*.sh plugins/historian/scripts/hooks/*.sh plugins/historian/scripts/lib/*.sh plugins/assayer/scripts/hooks/*.sh plugins/assayer/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 plugins/librarian/scripts/hooks/*.sh plugins/librarian/scripts/lib/*.sh plugins/curator/scripts/hooks/*.sh plugins/curator/scripts/lib/*.sh plugins/historian/scripts/hooks/*.sh plugins/historian/scripts/lib/*.sh plugins/assayer/scripts/hooks/*.sh plugins/assayer/scripts/lib/*.sh plugins/cartographer/scripts/hooks/*.sh plugins/cartographer/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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cartographer",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Proactive periodic auditor of the persistent instruction layer (CLAUDE.md, AGENTS.md, .claude/rules/). Discovers all instruction files in the repo, extracts semantic maps, and surfaces contradictions, shadowing, gaps, and drift before they cause expensive agent misbehavior. Builds on the Onlooker ecosystem plugin.",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to the Cartographer plugin are documented here.
4
4
 
5
+ ## [0.2.1](https://github.com/onlooker-community/ecosystem/compare/cartographer-v0.2.0...cartographer-v0.2.1) (2026-06-10)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * vendor portable-lock.sh into cartographer and governor ([#73](https://github.com/onlooker-community/ecosystem/issues/73)) ([ab2c354](https://github.com/onlooker-community/ecosystem/commit/ab2c354b131c26cc642ebb51e84a043dc43cbaa1))
11
+
5
12
  ## [0.2.0](https://github.com/onlooker-community/ecosystem/compare/cartographer-v0.1.0...cartographer-v0.2.0) (2026-05-25)
6
13
 
7
14
 
@@ -9,17 +9,27 @@
9
9
  # cartographer_lock_acquire <lock_file> # returns 0=acquired, 1=timeout
10
10
  # cartographer_lock_release <lock_file>
11
11
 
12
- _CARTOGRAPHER_LOCK_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../scripts/lib" && pwd)/portable-lock.sh"
12
+ # portable-lock.sh is vendored into this plugin's lib dir (a sibling of this
13
+ # file) so cartographer stays self-contained when installed standalone from
14
+ # the marketplace, where the ecosystem repo's top-level scripts/lib/ is absent.
15
+ _CARTOGRAPHER_LOCK_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/portable-lock.sh"
13
16
 
14
- if [[ ! -f "$_CARTOGRAPHER_LOCK_LIB" ]]; then
15
- printf '[cartographer-lock] ERROR: portable-lock.sh not found at %s\n' \
17
+ if [[ -f "$_CARTOGRAPHER_LOCK_LIB" ]]; then
18
+ # shellcheck source=./portable-lock.sh
19
+ source "$_CARTOGRAPHER_LOCK_LIB"
20
+ else
21
+ # The vendored lock should always be present, but if an unexpected
22
+ # packaging or path issue removes it we must degrade gracefully: the
23
+ # cartographer hooks are fail-soft and contractually exit 0, so a hard
24
+ # exit here would crash a session this plugin was only meant to observe.
25
+ # Define a primitive that always fails to acquire, so the hooks'
26
+ # `cartographer_lock_acquire ... || exit 0` skips the audit instead.
27
+ printf '[cartographer-lock] WARN: portable-lock.sh not found at %s; locking disabled, skipping audit\n' \
16
28
  "$_CARTOGRAPHER_LOCK_LIB" >&2
17
- exit 1
29
+ lock_acquire() { return 1; }
30
+ lock_release() { return 0; }
18
31
  fi
19
32
 
20
- # shellcheck source=../../../../scripts/lib/portable-lock.sh
21
- source "$_CARTOGRAPHER_LOCK_LIB"
22
-
23
33
  cartographer_lock_acquire() {
24
34
  local lock_file="${1:?lock_file required}"
25
35
  mkdir -p "$(dirname "$lock_file")" 2>/dev/null || true
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env bash
2
+ # portable-lock.sh — vendored copy of the ecosystem substrate's portable lock.
3
+ #
4
+ # Vendored into the cartographer plugin so the plugin is self-contained when
5
+ # installed standalone from the marketplace: the cache layout
6
+ # (~/.claude/plugins/cache/<owner>/cartographer/<version>/) does not include
7
+ # the ecosystem repo's top-level scripts/lib/, so reaching up to it breaks.
8
+ # This mirrors the per-plugin vendoring of cartographer-ulid.sh and friends.
9
+ # Keep in sync with scripts/lib/portable-lock.sh at the repo root.
10
+ #
11
+ # Portable advisory file locking via mkdir() atomicity.
12
+ #
13
+ # Replaces flock(1), which ships with util-linux on Linux but is not present
14
+ # in stock macOS. This matters because the Onlooker hooks run on user
15
+ # machines, not just in CI: a macOS user without util-linux would otherwise
16
+ # see concurrent writes to $ONLOOKER_DIR silently clobber each other.
17
+ #
18
+ # mkdir() is atomic on POSIX local filesystems, which is the only place
19
+ # $ONLOOKER_DIR ever lives. Network filesystems (NFS) do not guarantee
20
+ # atomicity, but Claude Code state is local-only.
21
+ #
22
+ # Usage:
23
+ # lock_acquire "/path/to/file.lock" [timeout_seconds=5]
24
+ # # ... critical section ...
25
+ # lock_release "/path/to/file.lock"
26
+ #
27
+ # Avoid associative arrays so bash 3.2 (macOS default) keeps working.
28
+
29
+ # Acquire an exclusive lock at LOCKPATH. Returns 0 on success, 1 on timeout.
30
+ lock_acquire() {
31
+ local lockpath="${1:-}"
32
+ local timeout="${2:-5}"
33
+ [[ -z "$lockpath" ]] && return 1
34
+
35
+ local lockdir="${lockpath}.d"
36
+ local waited=0
37
+ # Poll at 10 Hz so a 5s timeout = 50 attempts.
38
+ local max_iter=$((timeout * 10))
39
+ while ! mkdir "$lockdir" 2>/dev/null; do
40
+ if ((waited >= max_iter)); then
41
+ return 1
42
+ fi
43
+ # `sleep 0.1` works on Linux + macOS; the `|| sleep 1` is a paranoid
44
+ # fallback for embedded shells that only accept integer seconds.
45
+ sleep 0.1 2>/dev/null || sleep 1
46
+ waited=$((waited + 1))
47
+ done
48
+ return 0
49
+ }
50
+
51
+ # Release the lock previously acquired for LOCKPATH. Safe to call when the
52
+ # lock is not held (no-op in that case).
53
+ lock_release() {
54
+ local lockpath="${1:-}"
55
+ [[ -z "$lockpath" ]] && return 0
56
+ rmdir "${lockpath}.d" 2>/dev/null || true
57
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "counsel",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0](https://github.com/onlooker-community/ecosystem/compare/counsel-v0.2.0...counsel-v0.3.0) (2026-06-11)
4
+
5
+
6
+ ### Features
7
+
8
+ * **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))
9
+
3
10
  ## [0.2.0](https://github.com/onlooker-community/ecosystem/compare/counsel-v0.1.0...counsel-v0.2.0) (2026-06-02)
4
11
 
5
12
 
@@ -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
  }
@@ -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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "governor",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Resource governance and budget enforcement for the Onlooker ecosystem. Tracks per-session token and cost spend, gates Task spawns before they exceed a configurable budget ceiling, and emits governor.* events for audit. Named for the steam-engine governor — a device that regulates output. Builds on the Onlooker ecosystem plugin.",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.1](https://github.com/onlooker-community/ecosystem/compare/governor-v0.2.0...governor-v0.2.1) (2026-06-10)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * vendor portable-lock.sh into cartographer and governor ([#73](https://github.com/onlooker-community/ecosystem/issues/73)) ([ab2c354](https://github.com/onlooker-community/ecosystem/commit/ab2c354b131c26cc642ebb51e84a043dc43cbaa1))
9
+
3
10
  ## [0.2.0](https://github.com/onlooker-community/ecosystem/compare/governor-v0.1.0...governor-v0.2.0) (2026-05-26)
4
11
 
5
12
 
@@ -24,12 +24,16 @@ fi
24
24
  if [[ -n "$_ECOSYSTEM_ROOT" && -f "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh" ]]; then
25
25
  # shellcheck disable=SC1091
26
26
  CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
27
- # shellcheck disable=SC1091
28
- CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/portable-lock.sh"
29
27
  fi
30
28
 
31
29
  export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
32
30
 
31
+ # portable-lock.sh is vendored into this plugin's lib dir so the ledger's
32
+ # atomic appends keep working when governor is installed standalone, where the
33
+ # ecosystem repo's top-level scripts/lib/ is absent from the plugin cache.
34
+ # shellcheck source=../lib/portable-lock.sh
35
+ source "${PLUGIN_ROOT}/scripts/lib/portable-lock.sh"
36
+
33
37
  # shellcheck source=../lib/governor-config.sh
34
38
  source "${PLUGIN_ROOT}/scripts/lib/governor-config.sh"
35
39
  # shellcheck source=../lib/governor-events.sh
@@ -36,12 +36,16 @@ fi
36
36
  if [[ -n "$_ECOSYSTEM_ROOT" && -f "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh" ]]; then
37
37
  # shellcheck disable=SC1091
38
38
  CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
39
- # shellcheck disable=SC1091
40
- CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/portable-lock.sh"
41
39
  fi
42
40
 
43
41
  export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
44
42
 
43
+ # portable-lock.sh is vendored into this plugin's lib dir so the ledger's
44
+ # atomic appends keep working when governor is installed standalone, where the
45
+ # ecosystem repo's top-level scripts/lib/ is absent from the plugin cache.
46
+ # shellcheck source=../lib/portable-lock.sh
47
+ source "${PLUGIN_ROOT}/scripts/lib/portable-lock.sh"
48
+
45
49
  # shellcheck source=../lib/governor-config.sh
46
50
  source "${PLUGIN_ROOT}/scripts/lib/governor-config.sh"
47
51
  # shellcheck source=../lib/governor-events.sh
@@ -28,12 +28,16 @@ fi
28
28
  if [[ -n "$_ECOSYSTEM_ROOT" && -f "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh" ]]; then
29
29
  # shellcheck disable=SC1091
30
30
  CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
31
- # shellcheck disable=SC1091
32
- CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/portable-lock.sh"
33
31
  fi
34
32
 
35
33
  export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
36
34
 
35
+ # portable-lock.sh is vendored into this plugin's lib dir so the ledger's
36
+ # atomic appends keep working when governor is installed standalone, where the
37
+ # ecosystem repo's top-level scripts/lib/ is absent from the plugin cache.
38
+ # shellcheck source=../lib/portable-lock.sh
39
+ source "${PLUGIN_ROOT}/scripts/lib/portable-lock.sh"
40
+
37
41
  # shellcheck source=../lib/governor-config.sh
38
42
  source "${PLUGIN_ROOT}/scripts/lib/governor-config.sh"
39
43
  # shellcheck source=../lib/governor-events.sh
@@ -25,12 +25,16 @@ fi
25
25
  if [[ -n "$_ECOSYSTEM_ROOT" && -f "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh" ]]; then
26
26
  # shellcheck disable=SC1091
27
27
  CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
28
- # shellcheck disable=SC1091
29
- CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/portable-lock.sh"
30
28
  fi
31
29
 
32
30
  export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
33
31
 
32
+ # portable-lock.sh is vendored into this plugin's lib dir so the ledger's
33
+ # atomic appends keep working when governor is installed standalone, where the
34
+ # ecosystem repo's top-level scripts/lib/ is absent from the plugin cache.
35
+ # shellcheck source=../lib/portable-lock.sh
36
+ source "${PLUGIN_ROOT}/scripts/lib/portable-lock.sh"
37
+
34
38
  # shellcheck source=../lib/governor-config.sh
35
39
  source "${PLUGIN_ROOT}/scripts/lib/governor-config.sh"
36
40
  # shellcheck source=../lib/governor-events.sh
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env bash
2
+ # portable-lock.sh — vendored copy of the ecosystem substrate's portable lock.
3
+ #
4
+ # Vendored into the governor plugin so the ledger's atomic appends keep
5
+ # working when governor is installed standalone from the marketplace: the
6
+ # cache layout (~/.claude/plugins/cache/<owner>/governor/<version>/) does not
7
+ # include the ecosystem repo's top-level scripts/lib/. Without a local copy,
8
+ # lock_acquire would be undefined and governor_ledger_append would poison the
9
+ # ledger after exhausting its retries. This mirrors the per-plugin vendoring
10
+ # of governor-ulid.sh and friends.
11
+ # Keep in sync with scripts/lib/portable-lock.sh at the repo root.
12
+ #
13
+ # Portable advisory file locking via mkdir() atomicity.
14
+ #
15
+ # Replaces flock(1), which ships with util-linux on Linux but is not present
16
+ # in stock macOS. This matters because the Onlooker hooks run on user
17
+ # machines, not just in CI: a macOS user without util-linux would otherwise
18
+ # see concurrent writes to $ONLOOKER_DIR silently clobber each other.
19
+ #
20
+ # mkdir() is atomic on POSIX local filesystems, which is the only place
21
+ # $ONLOOKER_DIR ever lives. Network filesystems (NFS) do not guarantee
22
+ # atomicity, but Claude Code state is local-only.
23
+ #
24
+ # Usage:
25
+ # lock_acquire "/path/to/file.lock" [timeout_seconds=5]
26
+ # # ... critical section ...
27
+ # lock_release "/path/to/file.lock"
28
+ #
29
+ # Avoid associative arrays so bash 3.2 (macOS default) keeps working.
30
+
31
+ # Acquire an exclusive lock at LOCKPATH. Returns 0 on success, 1 on timeout.
32
+ lock_acquire() {
33
+ local lockpath="${1:-}"
34
+ local timeout="${2:-5}"
35
+ [[ -z "$lockpath" ]] && return 1
36
+
37
+ local lockdir="${lockpath}.d"
38
+ local waited=0
39
+ # Poll at 10 Hz so a 5s timeout = 50 attempts.
40
+ local max_iter=$((timeout * 10))
41
+ while ! mkdir "$lockdir" 2>/dev/null; do
42
+ if ((waited >= max_iter)); then
43
+ return 1
44
+ fi
45
+ # `sleep 0.1` works on Linux + macOS; the `|| sleep 1` is a paranoid
46
+ # fallback for embedded shells that only accept integer seconds.
47
+ sleep 0.1 2>/dev/null || sleep 1
48
+ waited=$((waited + 1))
49
+ done
50
+ return 0
51
+ }
52
+
53
+ # Release the lock previously acquired for LOCKPATH. Safe to call when the
54
+ # lock is not held (no-op in that case).
55
+ lock_release() {
56
+ local lockpath="${1:-}"
57
+ [[ -z "$lockpath" ]] && return 0
58
+ rmdir "${lockpath}.d" 2>/dev/null || true
59
+ }
@@ -4,7 +4,7 @@
4
4
  # Replaces flock(1), which ships with util-linux on Linux but is not present
5
5
  # in stock macOS. This matters because the Onlooker hooks run on user
6
6
  # machines, not just in CI: a macOS user without util-linux would otherwise
7
- # see every PostToolUse history append silently fail.
7
+ # see concurrent writes to $ONLOOKER_DIR silently clobber each other.
8
8
  #
9
9
  # mkdir() is atomic on POSIX local filesystems, which is the only place
10
10
  # $ONLOOKER_DIR ever lives. Network filesystems (NFS) do not guarantee
@@ -75,3 +75,22 @@ teardown() {
75
75
  [ "$status" -ne 0 ]
76
76
  wait
77
77
  }
78
+
79
+ @test "missing vendored portable-lock.sh degrades to a no-op lock, never crashes" {
80
+ # Copy only the wrapper into an isolated dir WITHOUT its sibling
81
+ # portable-lock.sh to simulate a broken packaging/path. The cartographer
82
+ # hooks are fail-soft (exit 0), so sourcing must not abort and acquire must
83
+ # fail so the caller's `... || exit 0` skips the audit instead of crashing.
84
+ cp "${REPO_ROOT}/plugins/cartographer/scripts/lib/cartographer-lock.sh" "${BATS_TEST_TMPDIR}/cartographer-lock.sh"
85
+ run bash -c "
86
+ source '${BATS_TEST_TMPDIR}/cartographer-lock.sh'
87
+ echo SOURCED_OK
88
+ cartographer_lock_acquire '${BATS_TEST_TMPDIR}/x.lock' && echo ACQUIRED || echo ACQUIRE_FAILED
89
+ cartographer_lock_release '${BATS_TEST_TMPDIR}/x.lock' && echo RELEASE_OK
90
+ "
91
+ [ "$status" -eq 0 ]
92
+ [[ "$output" == *"SOURCED_OK"* ]]
93
+ [[ "$output" == *"ACQUIRE_FAILED"* ]]
94
+ [[ "$output" == *"RELEASE_OK"* ]]
95
+ [[ "$output" == *"locking disabled"* ]]
96
+ }
@@ -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
+ }