@onlooker-community/ecosystem 0.20.0 → 0.22.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 (36) hide show
  1. package/.claude-plugin/marketplace.json +39 -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/package.json +2 -2
  6. package/plugins/curator/.claude-plugin/plugin.json +14 -0
  7. package/plugins/curator/CHANGELOG.md +10 -0
  8. package/plugins/curator/README.md +55 -0
  9. package/plugins/curator/config.json +41 -0
  10. package/plugins/curator/hooks/hooks.json +15 -0
  11. package/plugins/curator/scripts/hooks/curator-session-start.sh +343 -0
  12. package/plugins/curator/scripts/lib/curator-checks.sh +155 -0
  13. package/plugins/curator/scripts/lib/curator-config.sh +67 -0
  14. package/plugins/curator/scripts/lib/curator-emit.sh +61 -0
  15. package/plugins/curator/scripts/lib/curator-memory-reader.sh +225 -0
  16. package/plugins/curator/scripts/lib/curator-project-key.sh +82 -0
  17. package/plugins/curator/scripts/lib/curator-storage.sh +176 -0
  18. package/plugins/curator/scripts/lib/curator-ulid.sh +43 -0
  19. package/plugins/historian/.claude-plugin/plugin.json +14 -0
  20. package/plugins/historian/CHANGELOG.md +10 -0
  21. package/plugins/historian/README.md +70 -0
  22. package/plugins/historian/config.json +30 -0
  23. package/plugins/historian/hooks/hooks.json +26 -0
  24. package/plugins/historian/scripts/hooks/historian-prompt-submit.sh +15 -0
  25. package/plugins/historian/scripts/hooks/historian-session-end.sh +204 -0
  26. package/plugins/historian/scripts/lib/historian-chunker.sh +129 -0
  27. package/plugins/historian/scripts/lib/historian-config.sh +66 -0
  28. package/plugins/historian/scripts/lib/historian-emit.sh +61 -0
  29. package/plugins/historian/scripts/lib/historian-project-key.sh +80 -0
  30. package/plugins/historian/scripts/lib/historian-sanitizer.sh +123 -0
  31. package/plugins/historian/scripts/lib/historian-storage.sh +110 -0
  32. package/plugins/historian/scripts/lib/historian-transcript.sh +83 -0
  33. package/plugins/historian/scripts/lib/historian-ulid.sh +43 -0
  34. package/release-please-config.json +32 -0
  35. package/test/bats/curator-session-start.bats +316 -0
  36. package/test/bats/historian-session-end.bats +296 -0
@@ -124,6 +124,45 @@
124
124
  "license": "MIT",
125
125
  "keywords": ["security", "prompt-injection", "rule-of-two", "safety", "content-gate", "untrusted-content"],
126
126
  "tags": ["safety", "security"]
127
+ },
128
+ {
129
+ "name": "librarian",
130
+ "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.",
132
+ "author": {
133
+ "name": "Onlooker Community"
134
+ },
135
+ "homepage": "https://onlooker.dev",
136
+ "repository": "https://github.com/onlooker-community/ecosystem",
137
+ "license": "MIT",
138
+ "keywords": ["memory", "consolidation", "promotion", "classifier", "auto-memory", "session"],
139
+ "tags": ["memory", "context-engineering"]
140
+ },
141
+ {
142
+ "name": "curator",
143
+ "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.",
145
+ "author": {
146
+ "name": "Onlooker Community"
147
+ },
148
+ "homepage": "https://onlooker.dev",
149
+ "repository": "https://github.com/onlooker-community/ecosystem",
150
+ "license": "MIT",
151
+ "keywords": ["memory", "audit", "staleness", "findings", "auto-memory", "decay"],
152
+ "tags": ["memory", "context-engineering"]
153
+ },
154
+ {
155
+ "name": "historian",
156
+ "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.",
158
+ "author": {
159
+ "name": "Onlooker Community"
160
+ },
161
+ "homepage": "https://onlooker.dev",
162
+ "repository": "https://github.com/onlooker-community/ecosystem",
163
+ "license": "MIT",
164
+ "keywords": ["memory", "episodic", "transcript", "indexing", "session", "retrieval"],
165
+ "tags": ["memory", "context-engineering"]
127
166
  }
128
167
  ]
129
168
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ecosystem",
3
- "version": "0.20.0",
3
+ "version": "0.22.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,5 +1,5 @@
1
1
  {
2
- ".": "0.20.0",
2
+ ".": "0.22.0",
3
3
  "plugins/archivist": "0.1.0",
4
4
  "plugins/tribunal": "1.0.1",
5
5
  "plugins/echo": "0.2.0",
@@ -9,5 +9,7 @@
9
9
  "plugins/scribe": "0.2.0",
10
10
  "plugins/counsel": "0.2.0",
11
11
  "plugins/warden": "0.2.0",
12
- "plugins/librarian": "0.1.0"
12
+ "plugins/librarian": "0.1.0",
13
+ "plugins/curator": "0.1.0",
14
+ "plugins/historian": "0.1.0"
13
15
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.22.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.21.0...ecosystem-v0.22.0) (2026-06-04)
4
+
5
+
6
+ ### Features
7
+
8
+ * **historian:** introduce SessionEnd indexing :spiral_notepad: ([#59](https://github.com/onlooker-community/ecosystem/issues/59)) ([dd6c7f6](https://github.com/onlooker-community/ecosystem/commit/dd6c7f6ea872437cab6b16de50838dfc72750c7b))
9
+
10
+ ## [0.21.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.20.0...ecosystem-v0.21.0) (2026-06-04)
11
+
12
+
13
+ ### Features
14
+
15
+ * **curator:** introduce plugin with cheap-tier checks :microscope: ([#57](https://github.com/onlooker-community/ecosystem/issues/57)) ([7f9fa18](https://github.com/onlooker-community/ecosystem/commit/7f9fa18bbde29c8b5bd1eaad185bd4c5595a3762))
16
+
3
17
  ## [0.20.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.19.0...ecosystem-v0.20.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.20.0",
3
+ "version": "0.22.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",
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",
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": "curator",
3
+ "version": "0.1.0",
4
+ "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. Builds on the Onlooker ecosystem plugin.",
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,10 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0](https://github.com/onlooker-community/ecosystem/compare/curator-v0.0.1...curator-v0.1.0) (2026-06-04)
4
+
5
+
6
+ ### Features
7
+
8
+ * **curator:** introduce plugin with cheap-tier checks :microscope: ([#57](https://github.com/onlooker-community/ecosystem/issues/57)) ([7f9fa18](https://github.com/onlooker-community/ecosystem/commit/7f9fa18bbde29c8b5bd1eaad185bd4c5595a3762))
9
+
10
+ ## Changelog
@@ -0,0 +1,55 @@
1
+ # Curator
2
+
3
+ Maintenance layer for the user's typed auto-memory store.
4
+
5
+ At every `SessionStart`, Curator runs cheap heuristic checks against the memories at `~/.claude/projects/<encoded-project>/memory/` — stale ISO-8601 dates past the grace period, broken path references, broken `MEMORY.md` index entries, and orphaned memory files — and surfaces findings as a one-line pointer to `/curator review`. Curator never edits the memory store directly.
6
+
7
+ The weekly LLM-backed contradiction sweep described in [`docs/design.md`](docs/design.md) is a future capability — see the **Status** section below for what's actually shipped today.
8
+
9
+ Curator is a sibling plugin to [`ecosystem`](../../) and assumes the Onlooker observability substrate (`~/.onlooker/`) is present. It is parallel to [`cartographer`](../cartographer) (which audits hand-maintained instruction files like `CLAUDE.md`) — same audit shape, different substrate.
10
+
11
+ ## How it works
12
+
13
+ | Hook | What Curator does |
14
+ |------|---------------------|
15
+ | `SessionStart` | Runs the four cheap-tier checks (date_decayed, path_broken, broken_index, orphaned_memory) against the memory store, inside a wall-clock budget (`cheap_checks.wall_clock_budget_ms`, default 500ms). Writes new findings under `~/.onlooker/curator/<project-key>/findings/` keyed by ULID, deduping repeat findings via `deduped_hash`. Injects a one-line `additionalContext` pointer when open findings exist. |
16
+
17
+ ## Activation
18
+
19
+ Curator is **off by default**. Enable per-project in `.claude/settings.json`:
20
+
21
+ ```json
22
+ {
23
+ "curator": {
24
+ "enabled": true
25
+ }
26
+ }
27
+ ```
28
+
29
+ Or globally in `~/.claude/settings.json`. See [`config.json`](config.json) for the full set of tunable defaults.
30
+
31
+ ## Storage layout
32
+
33
+ ```text
34
+ ~/.onlooker/curator/<project-key>/
35
+ ├── manifest.json # project metadata
36
+ ├── last_llm_sweep.json # watermark for the weekly LLM pass
37
+ ├── last_cheap_scan.json # watermark for the per-session check
38
+ └── findings/<ulid>.json # one finding per file
39
+ ```
40
+
41
+ ## Status
42
+
43
+ This plugin ships **scaffolding + four cheap-tier checks (date_decayed, path_broken, broken_index, orphaned_memory) + SessionStart surfacer**. Deferred to follow-up landings:
44
+
45
+ - **LLM contradiction sweep** — design and the watermark plumbing (`last_llm_sweep.json`) are in place; the Haiku pair-evaluation loop is not implemented. `llm_sweep.enabled` defaults to `false` and is a no-op until the sweep ships.
46
+ - **`/curator review` interactive walkthrough** — accept / prune / edit / reclassify / acknowledge / defer for surfaced findings.
47
+ - **Usage tracker** (zero-recall-window findings) — depends on a substrate-level `memory.recalled` emitter that doesn't exist yet. `usage_tracker.enabled` defaults to `false`; see [`docs/design.md`](docs/design.md) Open Question #1.
48
+ - **Symbol reference check** — backtick-wrapped identifiers grep'd against the repo. Not yet wired.
49
+
50
+ ## Requirements
51
+
52
+ - The `ecosystem` plugin installed (for `~/.onlooker/` substrate).
53
+ - `jq` for JSON manipulation.
54
+ - `python3` for date math and path resolution.
55
+ - `git` to resolve the project key and (for the reference check) the repo root.
@@ -0,0 +1,41 @@
1
+ {
2
+ "plugin_name": "curator",
3
+ "storage_path": "~/.onlooker",
4
+ "curator": {
5
+ "enabled": false,
6
+ "memory_store_path": "${HOME}/.claude/projects/${CLAUDE_PROJECT_ENCODED}/memory",
7
+ "cheap_checks": {
8
+ "enabled": true,
9
+ "wall_clock_budget_ms": 500,
10
+ "skip_if_session_age_under_seconds": 5
11
+ },
12
+ "date_check": {
13
+ "enabled": true,
14
+ "date_grace_period_days": 14
15
+ },
16
+ "reference_check": {
17
+ "enabled": true,
18
+ "check_urls": false,
19
+ "url_allowlist": []
20
+ },
21
+ "usage_tracker": {
22
+ "enabled": false,
23
+ "usage_window_days": 30,
24
+ "_note": "Disabled until the substrate memory.recalled emitter ships. Setting to true today is a no-op."
25
+ },
26
+ "llm_sweep": {
27
+ "enabled": false,
28
+ "_note": "Disabled until the Haiku pair-evaluation loop ships. Setting to true today is a no-op.",
29
+ "model": "claude-haiku-4-5-20251001",
30
+ "temperature": 0.2,
31
+ "max_output_tokens": 96,
32
+ "interval_days": 7,
33
+ "max_pair_evaluations_per_sweep": 50,
34
+ "contradiction_similarity_threshold": 0.4
35
+ },
36
+ "surfacer": {
37
+ "max_pointer_chars": 200,
38
+ "skip_when_zero": true
39
+ }
40
+ }
41
+ }
@@ -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/curator-session-start.sh"
10
+ }
11
+ ]
12
+ }
13
+ ]
14
+ }
15
+ }
@@ -0,0 +1,343 @@
1
+ #!/usr/bin/env bash
2
+ # Curator SessionStart hook.
3
+ #
4
+ # Runs cheap-tier checks against the typed memory store and emits findings
5
+ # under ~/.onlooker/curator/<project-key>/findings/. Surfaces a one-line
6
+ # pointer to /curator review when open findings exist.
7
+ #
8
+ # Hook contract:
9
+ # - Always exits 0. Never blocks session start.
10
+ # - Emits valid hookSpecificOutput JSON even when nothing to inject.
11
+ # - No-ops when curator.enabled is not true.
12
+ # - No-ops when no git context, no memory store path, or no checks pass
13
+ # the rate gate.
14
+ #
15
+ # LLM contradiction sweep is deferred to a follow-up commit.
16
+
17
+ set -uo pipefail
18
+
19
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20
+ PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
21
+
22
+ _ECOSYSTEM_ROOT="${ONLOOKER_ECOSYSTEM_ROOT:-}"
23
+ if [[ -z "$_ECOSYSTEM_ROOT" ]]; then
24
+ _candidate="$(cd "${PLUGIN_ROOT}/../.." 2>/dev/null && pwd)"
25
+ if [[ -f "${_candidate}/scripts/lib/validate-path.sh" ]]; then
26
+ _ECOSYSTEM_ROOT="$_candidate"
27
+ fi
28
+ fi
29
+ if [[ -n "$_ECOSYSTEM_ROOT" && -f "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh" ]]; then
30
+ # shellcheck disable=SC1091
31
+ CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
32
+ fi
33
+
34
+ # shellcheck source=../lib/curator-config.sh
35
+ source "${PLUGIN_ROOT}/scripts/lib/curator-config.sh"
36
+ # shellcheck source=../lib/curator-project-key.sh
37
+ source "${PLUGIN_ROOT}/scripts/lib/curator-project-key.sh"
38
+ # shellcheck source=../lib/curator-ulid.sh
39
+ source "${PLUGIN_ROOT}/scripts/lib/curator-ulid.sh"
40
+ # shellcheck source=../lib/curator-storage.sh
41
+ source "${PLUGIN_ROOT}/scripts/lib/curator-storage.sh"
42
+ # shellcheck source=../lib/curator-emit.sh
43
+ source "${PLUGIN_ROOT}/scripts/lib/curator-emit.sh"
44
+ # shellcheck source=../lib/curator-memory-reader.sh
45
+ source "${PLUGIN_ROOT}/scripts/lib/curator-memory-reader.sh"
46
+ # shellcheck source=../lib/curator-checks.sh
47
+ source "${PLUGIN_ROOT}/scripts/lib/curator-checks.sh"
48
+
49
+ _emit() {
50
+ local context="${1:-}"
51
+ jq -cn --arg ctx "$context" '{
52
+ hookSpecificOutput: {
53
+ hookEventName: "SessionStart",
54
+ additionalContext: $ctx
55
+ }
56
+ }'
57
+ }
58
+
59
+ INPUT=$(cat 2>/dev/null || true)
60
+ CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || CWD=""
61
+ SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""' 2>/dev/null) || SESSION_ID=""
62
+ [[ -z "$CWD" ]] && CWD="$(pwd)"
63
+ [[ -z "$SESSION_ID" ]] && SESSION_ID="unknown"
64
+
65
+ REPO_ROOT=$(curator_project_repo_root "$CWD")
66
+ curator_config_load "$REPO_ROOT"
67
+
68
+ if ! curator_config_enabled; then
69
+ _emit ""
70
+ exit 0
71
+ fi
72
+
73
+ PROJECT_KEY=$(curator_project_key "$CWD")
74
+ if [[ -z "$PROJECT_KEY" ]]; then
75
+ _emit ""
76
+ exit 0
77
+ fi
78
+
79
+ curator_storage_init "$PROJECT_KEY" || { _emit ""; exit 0; }
80
+ REMOTE_URL=$(curator_project_remote_url "$CWD")
81
+ curator_storage_write_manifest "$PROJECT_KEY" "$REMOTE_URL" "$REPO_ROOT" || true
82
+
83
+ # ----------------------------------------------------------------------------
84
+ # Resolve the typed memory store path. Skip the audit if it can't be resolved.
85
+ # ----------------------------------------------------------------------------
86
+
87
+ MEM_PATH_TEMPLATE=$(curator_config_get '.curator.memory_store_path')
88
+ if [[ -z "$MEM_PATH_TEMPLATE" || "$MEM_PATH_TEMPLATE" == "null" ]]; then
89
+ MEM_PATH_TEMPLATE='${HOME}/.claude/projects/${CLAUDE_PROJECT_ENCODED}/memory'
90
+ fi
91
+ MEM_DIR=$(curator_memory_resolve_path "$MEM_PATH_TEMPLATE")
92
+
93
+ if [[ -z "$MEM_DIR" || ! -d "$MEM_DIR" ]]; then
94
+ # No memory store, nothing to audit. Still emit a scan event so the
95
+ # observability stream shows curator ran.
96
+ curator_emit "curator.scan.started" "$SESSION_ID" "$(jq -cn '{ mode: "cheap" }')"
97
+ curator_emit "curator.scan.complete" "$SESSION_ID" "$(jq -cn '{
98
+ mode: "cheap", outcome: "ok",
99
+ findings_new: 0, findings_resolved: 0, duration_ms: 0
100
+ }')"
101
+ _emit ""
102
+ exit 0
103
+ fi
104
+
105
+ # ----------------------------------------------------------------------------
106
+ # Cheap-tier rate gate.
107
+ #
108
+ # Three knobs:
109
+ # cheap_checks.enabled global on/off for the cheap tier
110
+ # cheap_checks.wall_clock_budget_ms abort phases past this elapsed
111
+ # surfacer.max_pointer_chars truncate additionalContext at this
112
+ # ----------------------------------------------------------------------------
113
+
114
+ CHEAP_ENABLED=$(curator_config_get '.curator.cheap_checks.enabled')
115
+ SCAN_START_MS=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null) \
116
+ || SCAN_START_MS=$(($(date +%s) * 1000))
117
+ SCAN_START_S=$((SCAN_START_MS / 1000))
118
+
119
+ curator_emit "curator.scan.started" "$SESSION_ID" "$(jq -cn '{ mode: "cheap" }')"
120
+
121
+ if [[ "$CHEAP_ENABLED" == "false" ]]; then
122
+ # Cheap tier explicitly off — emit scan.complete with skip_reason
123
+ # and skip straight to the surfacer (which reads previously-persisted
124
+ # findings, if any).
125
+ curator_emit "curator.scan.complete" "$SESSION_ID" "$(jq -cn \
126
+ --arg mode "cheap" --arg outcome "skipped" \
127
+ --arg skip_reason "disabled" \
128
+ --argjson findings_new 0 --argjson findings_resolved 0 \
129
+ --argjson duration_ms 0 \
130
+ '{ mode: $mode, outcome: $outcome, skip_reason: $skip_reason,
131
+ findings_new: $findings_new, findings_resolved: $findings_resolved,
132
+ duration_ms: $duration_ms }')"
133
+ FINDINGS_NEW=0
134
+ # Skip the per-check pipeline; fall through to the surfacer.
135
+ OUTCOME_FOR_SCAN_COMPLETE="skipped"
136
+ else
137
+ OUTCOME_FOR_SCAN_COMPLETE="ok"
138
+ fi
139
+
140
+ BUDGET_MS=$(curator_config_get '.curator.cheap_checks.wall_clock_budget_ms')
141
+ [[ -z "$BUDGET_MS" || "$BUDGET_MS" == "null" ]] && BUDGET_MS=500
142
+
143
+ _curator_now_ms() {
144
+ python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null \
145
+ || echo "$(( $(date +%s) * 1000 ))"
146
+ }
147
+
148
+ _curator_over_budget() {
149
+ local now elapsed
150
+ now=$(_curator_now_ms)
151
+ elapsed=$((now - SCAN_START_MS))
152
+ (( elapsed > BUDGET_MS ))
153
+ }
154
+
155
+ # When the cheap tier is enabled, run the four checks under the budget
156
+ # gate. Each phase checks the budget BEFORE its work — partial phases
157
+ # are allowed to finish since check work itself is cheap.
158
+ DATE_FINDINGS='[]'
159
+ PATH_FINDINGS='[]'
160
+ BROKEN_INDEX='[]'
161
+ ORPHANED='[]'
162
+ BUDGET_TRIPPED="false"
163
+ MEMORIES='[]'
164
+
165
+ if [[ "$CHEAP_ENABLED" != "false" ]]; then
166
+ if _curator_over_budget; then
167
+ BUDGET_TRIPPED="true"
168
+ else
169
+ MEMORIES=$(curator_memory_load_all "$MEM_DIR")
170
+ fi
171
+
172
+ DATE_GRACE=$(curator_config_get '.curator.date_check.date_grace_period_days')
173
+ [[ -z "$DATE_GRACE" || "$DATE_GRACE" == "null" ]] && DATE_GRACE=14
174
+ DATE_CHECK_ENABLED=$(curator_config_get '.curator.date_check.enabled')
175
+
176
+ if [[ "$BUDGET_TRIPPED" != "true" && "$DATE_CHECK_ENABLED" != "false" ]]; then
177
+ if _curator_over_budget; then
178
+ BUDGET_TRIPPED="true"
179
+ else
180
+ DATE_FINDINGS=$(curator_check_dates "$MEMORIES" "$DATE_GRACE") || DATE_FINDINGS='[]'
181
+ fi
182
+ fi
183
+
184
+ REF_CHECK_ENABLED=$(curator_config_get '.curator.reference_check.enabled')
185
+ if [[ "$BUDGET_TRIPPED" != "true" && "$REF_CHECK_ENABLED" != "false" && -n "$REPO_ROOT" ]]; then
186
+ if _curator_over_budget; then
187
+ BUDGET_TRIPPED="true"
188
+ else
189
+ PATH_FINDINGS=$(curator_check_paths "$MEMORIES" "$REPO_ROOT") || PATH_FINDINGS='[]'
190
+ fi
191
+ fi
192
+
193
+ if [[ "$BUDGET_TRIPPED" != "true" ]]; then
194
+ if _curator_over_budget; then
195
+ BUDGET_TRIPPED="true"
196
+ else
197
+ BROKEN_INDEX=$(curator_check_broken_index "$MEMORIES")
198
+ ORPHANED=$(curator_check_orphaned "$MEMORIES")
199
+ fi
200
+ fi
201
+ fi
202
+
203
+ # ----------------------------------------------------------------------------
204
+ # Persist findings (with dedup by deduped_hash) and emit per-finding events.
205
+ # Skipped entirely when the cheap tier is disabled — the disabled path above
206
+ # already emitted scan.complete and set FINDINGS_NEW=0.
207
+ # ----------------------------------------------------------------------------
208
+
209
+ NOW_TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
210
+ [[ "$CHEAP_ENABLED" == "false" ]] || FINDINGS_NEW=0
211
+
212
+ _write_finding() {
213
+ local kind="$1"
214
+ local payload="$2"
215
+ local hash_input
216
+ hash_input="${kind}|$(printf '%s' "$payload" | jq -cS '.')"
217
+ local hash
218
+ hash=$(curator_finding_hash "$hash_input") || hash=""
219
+ [[ -z "$hash" ]] && return 0
220
+
221
+ # Dedup: skip if an open finding with the same hash already exists.
222
+ if curator_storage_has_finding_with_hash "$PROJECT_KEY" "$hash"; then
223
+ return 0
224
+ fi
225
+
226
+ local id record
227
+ id=$(curator_ulid)
228
+ record=$(jq -n \
229
+ --arg id "$id" \
230
+ --arg kind "$kind" \
231
+ --arg created_at "$NOW_TS" \
232
+ --arg deduped_hash "$hash" \
233
+ --argjson detail "$payload" \
234
+ '{
235
+ id: $id, kind: $kind, created_at: $created_at,
236
+ status: "open", deduped_hash: $deduped_hash, detail: $detail
237
+ }')
238
+ curator_storage_write_finding "$PROJECT_KEY" "$id" "$record" >/dev/null || return 0
239
+ FINDINGS_NEW=$((FINDINGS_NEW + 1))
240
+
241
+ # Per-kind event payload.
242
+ local event_type event_payload
243
+ event_type="curator.finding.${kind}"
244
+ event_payload=$(jq -cn --arg fid "$id" --argjson detail "$payload" \
245
+ '{ finding_id: $fid } + $detail')
246
+ curator_emit "$event_type" "$SESSION_ID" "$event_payload"
247
+ }
248
+
249
+ # Convert each finding-array entry into a stored + emitted finding.
250
+ _emit_kind_findings() {
251
+ local kind="$1" findings_json="$2"
252
+ local n
253
+ n=$(printf '%s' "$findings_json" | jq 'length' 2>/dev/null) || n=0
254
+ local i payload
255
+ for ((i = 0; i < n; i++)); do
256
+ payload=$(printf '%s' "$findings_json" | jq -c ".[$i]")
257
+ [[ -z "$payload" || "$payload" == "null" ]] && continue
258
+ _write_finding "$kind" "$payload"
259
+ done
260
+ }
261
+
262
+ if [[ "$CHEAP_ENABLED" != "false" ]]; then
263
+ _emit_kind_findings "date_decayed" "$DATE_FINDINGS"
264
+ _emit_kind_findings "path_broken" "$PATH_FINDINGS"
265
+ _emit_kind_findings "broken_index" "$BROKEN_INDEX"
266
+ _emit_kind_findings "orphaned_memory" "$ORPHANED"
267
+ fi
268
+
269
+ # ----------------------------------------------------------------------------
270
+ # Watermark + scan.complete. The disabled-tier branch above already emitted
271
+ # scan.complete; this branch fires only when the cheap tier ran (success or
272
+ # budget tripped).
273
+ # ----------------------------------------------------------------------------
274
+
275
+ if [[ "$CHEAP_ENABLED" != "false" ]]; then
276
+ curator_storage_write_watermark "$(curator_last_cheap_scan_path "$PROJECT_KEY")" || true
277
+
278
+ DURATION_MS=$(( $(_curator_now_ms) - SCAN_START_MS ))
279
+ if [[ "$BUDGET_TRIPPED" == "true" ]]; then
280
+ curator_emit "curator.scan.complete" "$SESSION_ID" "$(jq -cn \
281
+ --arg mode "cheap" --arg outcome "skipped" \
282
+ --arg skip_reason "over_budget" \
283
+ --argjson findings_new "$FINDINGS_NEW" \
284
+ --argjson findings_resolved 0 \
285
+ --argjson duration_ms "$DURATION_MS" \
286
+ '{ mode: $mode, outcome: $outcome, skip_reason: $skip_reason,
287
+ findings_new: $findings_new,
288
+ findings_resolved: $findings_resolved,
289
+ duration_ms: $duration_ms }')"
290
+ else
291
+ curator_emit "curator.scan.complete" "$SESSION_ID" "$(jq -cn \
292
+ --arg mode "cheap" --arg outcome "ok" \
293
+ --argjson findings_new "$FINDINGS_NEW" \
294
+ --argjson findings_resolved 0 \
295
+ --argjson duration_ms "$DURATION_MS" \
296
+ '{ mode: $mode, outcome: $outcome,
297
+ findings_new: $findings_new,
298
+ findings_resolved: $findings_resolved,
299
+ duration_ms: $duration_ms }')"
300
+ fi
301
+ fi
302
+
303
+ # ----------------------------------------------------------------------------
304
+ # Surfacer.
305
+ # ----------------------------------------------------------------------------
306
+
307
+ SKIP_WHEN_ZERO=$(curator_config_get '.curator.surfacer.skip_when_zero')
308
+ [[ -z "$SKIP_WHEN_ZERO" || "$SKIP_WHEN_ZERO" == "null" ]] && SKIP_WHEN_ZERO="true"
309
+
310
+ OPEN_COUNT=$(curator_storage_count_open "$PROJECT_KEY")
311
+ [[ -z "$OPEN_COUNT" || "$OPEN_COUNT" == "null" ]] && OPEN_COUNT=0
312
+
313
+ if [[ "$OPEN_COUNT" -eq 0 && "$SKIP_WHEN_ZERO" == "true" ]]; then
314
+ _emit ""
315
+ exit 0
316
+ fi
317
+
318
+ # Build a compact "2 path-broken, 1 date-decayed" descriptor for the
319
+ # pointer message.
320
+ COUNTS_BY_KIND=$(curator_storage_open_counts_by_kind "$PROJECT_KEY")
321
+ SUMMARY=$(printf '%s' "$COUNTS_BY_KIND" | jq -r '
322
+ map( (.count|tostring) + " " + (.kind | gsub("_"; "-")) )
323
+ | join(", ")
324
+ ')
325
+
326
+ CONTEXT=$(printf 'Curator: %s open finding%s (%s). Review with `/curator review`.' \
327
+ "$OPEN_COUNT" \
328
+ "$([ "$OPEN_COUNT" -eq 1 ] && echo "" || echo "s")" \
329
+ "$SUMMARY")
330
+
331
+ # Cap the pointer length so a long per-kind summary never overflows the
332
+ # user's SessionStart context.
333
+ MAX_POINTER=$(curator_config_get '.curator.surfacer.max_pointer_chars')
334
+ [[ -z "$MAX_POINTER" || "$MAX_POINTER" == "null" ]] && MAX_POINTER=200
335
+ if [[ "${#CONTEXT}" -gt "$MAX_POINTER" ]]; then
336
+ # Reserve room for the truncation ellipsis without exceeding the cap.
337
+ TRUNC=$((MAX_POINTER - 1))
338
+ (( TRUNC < 1 )) && TRUNC=1
339
+ CONTEXT="${CONTEXT:0:TRUNC}…"
340
+ fi
341
+
342
+ _emit "$CONTEXT"
343
+ exit 0