@onlooker-community/ecosystem 0.19.0 → 0.20.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 (29) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.release-please-manifest.json +3 -2
  3. package/CHANGELOG.md +7 -0
  4. package/docs/memory-architecture.md +102 -0
  5. package/package.json +3 -3
  6. package/plugins/curator/docs/adr/001-staleness-tiers.md +100 -0
  7. package/plugins/curator/docs/design.md +311 -0
  8. package/plugins/historian/docs/adr/001-local-embeddings-only.md +96 -0
  9. package/plugins/historian/docs/design.md +317 -0
  10. package/plugins/librarian/.claude-plugin/plugin.json +14 -0
  11. package/plugins/librarian/CHANGELOG.md +10 -0
  12. package/plugins/librarian/README.md +51 -0
  13. package/plugins/librarian/config.json +52 -0
  14. package/plugins/librarian/docs/adr/001-propose-dont-auto-write.md +87 -0
  15. package/plugins/librarian/docs/design.md +301 -0
  16. package/plugins/librarian/hooks/hooks.json +26 -0
  17. package/plugins/librarian/scripts/hooks/librarian-session-end.sh +312 -0
  18. package/plugins/librarian/scripts/hooks/librarian-session-start.sh +103 -0
  19. package/plugins/librarian/scripts/lib/librarian-archivist-reader.sh +67 -0
  20. package/plugins/librarian/scripts/lib/librarian-classifier.sh +139 -0
  21. package/plugins/librarian/scripts/lib/librarian-config.sh +74 -0
  22. package/plugins/librarian/scripts/lib/librarian-durability.sh +77 -0
  23. package/plugins/librarian/scripts/lib/librarian-emit.sh +72 -0
  24. package/plugins/librarian/scripts/lib/librarian-project-key.sh +83 -0
  25. package/plugins/librarian/scripts/lib/librarian-storage.sh +222 -0
  26. package/plugins/librarian/scripts/lib/librarian-ulid.sh +50 -0
  27. package/release-please-config.json +16 -0
  28. package/test/bats/librarian-session-end.bats +182 -0
  29. package/test/bats/librarian-session-start.bats +136 -0
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ # Librarian SessionStart surfacer.
3
+ #
4
+ # Counts pending proposals in the project's queue and injects a one-line
5
+ # `additionalContext` pointer if any exist. The full proposal bodies live
6
+ # in ~/.onlooker/librarian/<project-key>/proposals/ and are reviewed via
7
+ # the /librarian review skill rather than inlined here — SessionStart
8
+ # context is precious, and a queue of 20 distilled-but-unreviewed memories
9
+ # isn't where it should go.
10
+ #
11
+ # Hook contract:
12
+ # - Always exits 0. Never blocks session start.
13
+ # - Emits valid hookSpecificOutput JSON, even when nothing to say.
14
+ # - No-ops when librarian.enabled is not true.
15
+ # - No-ops when there is no project key (no git context).
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/librarian-config.sh
35
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-config.sh"
36
+ # shellcheck source=../lib/librarian-project-key.sh
37
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-project-key.sh"
38
+ # shellcheck source=../lib/librarian-storage.sh
39
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-storage.sh"
40
+
41
+ # Emit hookSpecificOutput with the given additionalContext string. An
42
+ # empty string is fine — the harness sees "nothing to say".
43
+ _emit() {
44
+ local context="${1:-}"
45
+ jq -cn --arg ctx "$context" '
46
+ {
47
+ hookSpecificOutput: {
48
+ hookEventName: "SessionStart",
49
+ additionalContext: $ctx
50
+ }
51
+ }
52
+ '
53
+ }
54
+
55
+ INPUT=$(cat 2>/dev/null || true)
56
+ CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || CWD=""
57
+ [[ -z "$CWD" ]] && CWD="$(pwd)"
58
+
59
+ REPO_ROOT=$(librarian_project_repo_root "$CWD")
60
+ librarian_config_load "$REPO_ROOT"
61
+
62
+ if ! librarian_config_enabled; then
63
+ _emit ""
64
+ exit 0
65
+ fi
66
+
67
+ PROJECT_KEY=$(librarian_project_key "$CWD")
68
+ if [[ -z "$PROJECT_KEY" ]]; then
69
+ _emit ""
70
+ exit 0
71
+ fi
72
+
73
+ SKIP_WHEN_ZERO=$(librarian_config_get '.librarian.surfacer.skip_inject_when_zero')
74
+ [[ -z "$SKIP_WHEN_ZERO" || "$SKIP_WHEN_ZERO" == "null" ]] && SKIP_WHEN_ZERO="true"
75
+
76
+ MAX_PENDING=$(librarian_config_get '.librarian.surfacer.max_pending_for_inject')
77
+ [[ -z "$MAX_PENDING" || "$MAX_PENDING" == "null" ]] && MAX_PENDING=20
78
+
79
+ PENDING=$(librarian_storage_count_pending "$PROJECT_KEY")
80
+ [[ -z "$PENDING" || "$PENDING" == "null" ]] && PENDING=0
81
+
82
+ if [[ "$PENDING" -eq 0 && "$SKIP_WHEN_ZERO" == "true" ]]; then
83
+ _emit ""
84
+ exit 0
85
+ fi
86
+
87
+ # Cap the surfaced number so a runaway queue doesn't make the pointer
88
+ # itself look alarming. Users still see the truthful count in
89
+ # /librarian review.
90
+ if [[ "$PENDING" -gt "$MAX_PENDING" ]]; then
91
+ DISPLAY_COUNT="${MAX_PENDING}+"
92
+ else
93
+ DISPLAY_COUNT="$PENDING"
94
+ fi
95
+
96
+ NOUN="proposals"
97
+ [[ "$PENDING" -eq 1 ]] && NOUN="proposal"
98
+
99
+ CONTEXT=$(printf 'Librarian has %s pending memory promotion %s. Review with `/librarian review`.' \
100
+ "$DISPLAY_COUNT" "$NOUN")
101
+
102
+ _emit "$CONTEXT"
103
+ exit 0
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env bash
2
+ # Reads archivist artifacts for the librarian scan pipeline.
3
+ #
4
+ # Archivist stores per-session artifacts under:
5
+ # $ONLOOKER_DIR/archivist/<project-key>/{decisions,dead_ends,open_questions}/<ulid>.json
6
+ #
7
+ # Each artifact has the shape (see archivist's storage.sh):
8
+ # { id, kind, project_key, source, created_at, updated_at, summary,
9
+ # detail, files, session_id, trigger }
10
+ #
11
+ # Librarian reads the same project-key directory and filters by created_at,
12
+ # returning candidates newer than the watermark.
13
+
14
+ # Resolve the archivist project dir for a given project key.
15
+ # Returns empty if archivist artifacts are not present.
16
+ librarian_archivist_project_dir() {
17
+ local project_key="$1"
18
+ [[ -z "$project_key" ]] && return 0
19
+ local base="${ONLOOKER_DIR:-$HOME/.onlooker}"
20
+ local dir="${base}/archivist/${project_key}"
21
+ [[ -d "$dir" ]] || return 0
22
+ printf '%s' "$dir"
23
+ }
24
+
25
+ # Load archivist artifacts created since the given watermark.
26
+ #
27
+ # Usage: librarian_archivist_load_since <project_key> <watermark_iso>
28
+ #
29
+ # Watermark format: ISO-8601 (e.g., "2026-06-01T12:34:56Z"). When the
30
+ # watermark is empty, all artifacts are returned (used on first scan).
31
+ #
32
+ # Output: JSON array, one element per artifact, in chronological order.
33
+ librarian_archivist_load_since() {
34
+ local project_key="$1"
35
+ local watermark="${2:-}"
36
+
37
+ local project_dir
38
+ project_dir=$(librarian_archivist_project_dir "$project_key")
39
+ [[ -z "$project_dir" ]] && { echo '[]'; return 0; }
40
+
41
+ local kind file all='[]'
42
+ for kind in decisions dead_ends open_questions; do
43
+ [[ -d "${project_dir}/${kind}" ]] || continue
44
+ for file in "${project_dir}/${kind}"/*.json; do
45
+ [[ -f "$file" ]] || continue
46
+ local item created_at
47
+ item=$(jq '.' "$file" 2>/dev/null) || continue
48
+ [[ -z "$item" || "$item" == "null" ]] && continue
49
+
50
+ # Filter by watermark when provided.
51
+ if [[ -n "$watermark" ]]; then
52
+ created_at=$(printf '%s' "$item" | jq -r '.created_at // .updated_at // ""' 2>/dev/null)
53
+ [[ -z "$created_at" ]] && continue
54
+ # Lexicographic compare works for ISO-8601 UTC strings.
55
+ if [[ "$created_at" < "$watermark" || "$created_at" == "$watermark" ]]; then
56
+ continue
57
+ fi
58
+ fi
59
+
60
+ all=$(printf '%s' "$all" | jq --argjson item "$item" '. + [$item]')
61
+ done
62
+ done
63
+
64
+ # Sort chronologically; downstream classifier groups by session_id and
65
+ # benefits from stable order.
66
+ printf '%s' "$all" | jq 'sort_by(.created_at // .updated_at // "")'
67
+ }
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env bash
2
+ # Type classifier for librarian candidates.
3
+ #
4
+ # Calls `claude -p` with a structured prompt that maps a single archivist
5
+ # artifact to one of the four memory types (user, feedback, project,
6
+ # reference) or null when the artifact is interesting but session-only.
7
+ #
8
+ # Returns the model's JSON response on stdout, or empty string on any
9
+ # error (timeout, missing CLI, invalid JSON, low confidence). Callers
10
+ # treat empty as "drop this candidate".
11
+ #
12
+ # Config inputs (read via librarian_config_get from the caller):
13
+ # librarian.classifier.model Anthropic model id
14
+ # librarian.classifier.temperature Sampling temperature
15
+ # librarian.classifier.max_output_tokens Output cap
16
+ # librarian.classifier.min_classifier_confidence Drop below this
17
+
18
+ # Hard wall-clock ceiling for a single classifier call. We never want a
19
+ # hung LLM to delay SessionEnd more than this.
20
+ _LIBRARIAN_CLASSIFIER_TIMEOUT_SECONDS=20
21
+
22
+ # Build the classifier prompt for a single artifact.
23
+ # Usage: librarian_classifier_build_prompt <artifact_json>
24
+ librarian_classifier_build_prompt() {
25
+ local artifact="$1"
26
+ local kind summary detail files_list session_id created_at
27
+
28
+ kind=$(printf '%s' "$artifact" | jq -r '.kind // ""')
29
+ summary=$(printf '%s' "$artifact" | jq -r '.summary // ""')
30
+ detail=$(printf '%s' "$artifact" | jq -r '.detail // ""')
31
+ files_list=$(printf '%s' "$artifact" | jq -r '(.files // []) | join(", ")')
32
+ session_id=$(printf '%s' "$artifact" | jq -r '.session_id // ""')
33
+ created_at=$(printf '%s' "$artifact" | jq -r '.created_at // ""')
34
+
35
+ cat <<EOF
36
+ You are classifying a session artifact for promotion into a long-term memory store.
37
+
38
+ The store has four types:
39
+ - user: durable facts about the user's role, expertise, or working style
40
+ - feedback: corrections or validated preferences ("don't do X", "yes, keep doing Y")
41
+ - project: ongoing work facts, decisions, constraints not derivable from the code
42
+ - reference: pointers to external systems (issue trackers, dashboards, channels)
43
+
44
+ RULES:
45
+ - Output ONLY a single JSON object on one line, no markdown fences, no prose.
46
+ - Schema: { "type": "<user|feedback|project|reference|null>",
47
+ "title": "<<=60 chars>",
48
+ "body": "<the memory content; structure per type>",
49
+ "confidence": <float 0-1> }
50
+ - Use "type": null when the artifact is interesting but session-only (a
51
+ specific bug fix, a one-off question that got answered, an exploration
52
+ that didn't change anything).
53
+ - For feedback and project types, include **Why:** and **How to apply:**
54
+ lines inside the body.
55
+
56
+ <artifact>
57
+ kind: ${kind}
58
+ summary: ${summary}
59
+ detail: ${detail}
60
+ files: ${files_list}
61
+ session_id: ${session_id}
62
+ created_at: ${created_at}
63
+ </artifact>
64
+ EOF
65
+ }
66
+
67
+ # Call the classifier for one artifact. Prints the model's JSON output or
68
+ # empty string on error.
69
+ #
70
+ # Usage: librarian_classifier_call <artifact_json> <model> <temperature>
71
+ # <max_output_tokens>
72
+ librarian_classifier_call() {
73
+ local artifact="$1"
74
+ local model="${2:-}"
75
+ local temperature="${3:-0.2}"
76
+ local max_tokens="${4:-256}"
77
+
78
+ command -v claude >/dev/null 2>&1 || return 0
79
+ [[ -z "$artifact" ]] && return 0
80
+
81
+ local prompt_file
82
+ prompt_file=$(mktemp -t librarian-classify.XXXXXX 2>/dev/null) \
83
+ || prompt_file="/tmp/librarian-classify.$$"
84
+ # shellcheck disable=SC2064
85
+ trap "rm -f '$prompt_file'" EXIT
86
+
87
+ librarian_classifier_build_prompt "$artifact" > "$prompt_file" || return 0
88
+
89
+ local args=(-p --max-turns 1)
90
+ [[ -n "$model" ]] && args+=(--model "$model")
91
+
92
+ local response=""
93
+ if command -v timeout >/dev/null 2>&1; then
94
+ response=$(timeout "$_LIBRARIAN_CLASSIFIER_TIMEOUT_SECONDS" \
95
+ claude "${args[@]}" < "$prompt_file" 2>/dev/null) || response=""
96
+ elif command -v gtimeout >/dev/null 2>&1; then
97
+ response=$(gtimeout "$_LIBRARIAN_CLASSIFIER_TIMEOUT_SECONDS" \
98
+ claude "${args[@]}" < "$prompt_file" 2>/dev/null) || response=""
99
+ else
100
+ response=$(claude "${args[@]}" < "$prompt_file" 2>/dev/null) || response=""
101
+ fi
102
+
103
+ rm -f "$prompt_file"
104
+ trap - EXIT
105
+
106
+ [[ -z "$response" ]] && return 0
107
+
108
+ # Strip accidental markdown fences before parsing.
109
+ local clean
110
+ clean=$(printf '%s' "$response" | sed -e 's/^```json//' -e 's/^```//' -e 's/```$//')
111
+
112
+ # Validate the response shape before passing it back.
113
+ if ! printf '%s' "$clean" | jq -e '
114
+ (.type == null or (.type | IN("user", "feedback", "project", "reference")))
115
+ and (.title | type) == "string"
116
+ and (.body | type) == "string"
117
+ and (.confidence | type) == "number"
118
+ ' >/dev/null 2>&1; then
119
+ return 0
120
+ fi
121
+
122
+ printf '%s' "$clean"
123
+ }
124
+
125
+ # Synthesize a deterministic filename from a classifier result.
126
+ # Used when writing accepted promotions into the typed memory store.
127
+ # Format: <type>_<slugified-title>.md
128
+ #
129
+ # Usage: librarian_classifier_filename <type> <title>
130
+ librarian_classifier_filename() {
131
+ local type="$1"
132
+ local title="$2"
133
+ local slug
134
+ slug=$(printf '%s' "$title" | tr '[:upper:]' '[:lower:]' \
135
+ | sed -E 's/[^a-z0-9]+/_/g; s/^_+|_+$//g' \
136
+ | cut -c1-60)
137
+ [[ -z "$slug" ]] && slug="memory"
138
+ printf '%s_%s.md' "$type" "$slug"
139
+ }
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bash
2
+ # Config resolution for Librarian.
3
+ #
4
+ # Reads three layers, latest wins:
5
+ # 1. plugins/librarian/config.json (defaults shipped with the plugin)
6
+ # 2. ~/.claude/settings.json
7
+ # 3. <repo>/.claude/settings.json
8
+ #
9
+ # Exposes:
10
+ # librarian_config_load <repo_root> # populates _LIBRARIAN_CONFIG (JSON)
11
+ # librarian_config_get <jq-path> # echoes string value (empty if unset)
12
+ # librarian_config_enabled # 0 if librarian.enabled is true
13
+ # librarian_config_auto_promote # 0 if librarian.auto_promote is true
14
+ #
15
+ # Settings overlay only touches the `librarian.*` subtree of settings.json so
16
+ # it coexists with other plugins' configuration.
17
+
18
+ _LIBRARIAN_CONFIG="{}"
19
+
20
+ librarian_config_load() {
21
+ local repo_root="${1:-}"
22
+ local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
23
+ local home_dir="${HOME:-}"
24
+
25
+ local merged="{}"
26
+ local file
27
+
28
+ file="${plugin_root}/config.json"
29
+ if [[ -f "$file" ]]; then
30
+ local defaults
31
+ defaults=$(jq '.' "$file" 2>/dev/null) || defaults="{}"
32
+ merged=$(jq -n --argjson a "$merged" --argjson b "$defaults" '$a * $b' 2>/dev/null) \
33
+ || merged="$defaults"
34
+ fi
35
+
36
+ for file in "${home_dir}/.claude/settings.json" "${repo_root}/.claude/settings.json"; do
37
+ [[ -n "$file" && -f "$file" ]] || continue
38
+ local overlay
39
+ overlay=$(jq '{ librarian: (.librarian // {}) }' "$file" 2>/dev/null) || continue
40
+ [[ -z "$overlay" ]] && continue
41
+ merged=$(jq -n --argjson a "$merged" --argjson b "$overlay" '
42
+ def deepmerge($a; $b):
43
+ if ($a|type) == "object" and ($b|type) == "object" then
44
+ reduce (($a|keys) + ($b|keys) | unique)[] as $k
45
+ ({}; .[$k] = deepmerge($a[$k]; $b[$k]))
46
+ elif $b == null then $a
47
+ else $b end;
48
+ deepmerge($a; $b)
49
+ ' 2>/dev/null) || true
50
+ done
51
+
52
+ _LIBRARIAN_CONFIG="$merged"
53
+ }
54
+
55
+ # Read a value from the loaded config. Usage:
56
+ # librarian_config_get '.librarian.surfacer.max_pending_for_inject'
57
+ librarian_config_get() {
58
+ local path="$1"
59
+ printf '%s' "$_LIBRARIAN_CONFIG" | jq -r "${path} // empty" 2>/dev/null
60
+ }
61
+
62
+ # Returns 0 if librarian.enabled is true, 1 otherwise.
63
+ librarian_config_enabled() {
64
+ local v
65
+ v=$(librarian_config_get '.librarian.enabled')
66
+ [[ "$v" == "true" ]]
67
+ }
68
+
69
+ # Returns 0 if librarian.auto_promote is true, 1 otherwise.
70
+ librarian_config_auto_promote() {
71
+ local v
72
+ v=$(librarian_config_get '.librarian.auto_promote')
73
+ [[ "$v" == "true" ]]
74
+ }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env bash
2
+ # Cheap pre-LLM durability filter for librarian candidates.
3
+ #
4
+ # Drops obvious session-only items before paying for classification:
5
+ # - Drop if detail.length < min_detail_chars
6
+ # - Drop if matches a drop-list phrase
7
+ # - Keep if matches a marker phrase ("always", "never", "remember", ...)
8
+ # - Keep if files[] contains a path that still exists in the repo
9
+ # (caller-supplied flag — we don't shell out to git here)
10
+ #
11
+ # Output: JSON array of surviving candidates plus a drop-reason record for
12
+ # each rejected item so the scan-complete event can report counts.
13
+
14
+ # Default drop-list patterns: terse meta-conversation that almost never
15
+ # promotes well. Matched case-insensitively against summary+detail.
16
+ _LIBRARIAN_DURABILITY_DROP_PATTERNS=(
17
+ "the test is failing"
18
+ "let me check"
19
+ "i'll come back to this"
20
+ "i will come back to this"
21
+ "working on it"
22
+ "investigating"
23
+ )
24
+
25
+ # Apply the durability filter to a JSON array of candidates.
26
+ #
27
+ # Usage: librarian_durability_filter <candidates_json> <markers_json> \
28
+ # <min_detail_chars>
29
+ #
30
+ # Args:
31
+ # candidates_json Array of archivist artifacts (from
32
+ # librarian_archivist_load_since).
33
+ # markers_json JSON array of marker phrases (from config).
34
+ # min_detail_chars Minimum detail length to keep an artifact.
35
+ #
36
+ # Output: JSON object with two keys:
37
+ # { "kept": [<artifact>, ...],
38
+ # "dropped": [{ "artifact_id": "...", "reason": "..." }, ...] }
39
+ librarian_durability_filter() {
40
+ local candidates="${1:-[]}"
41
+ local markers_json="${2:-[]}"
42
+ local min_detail="${3:-40}"
43
+
44
+ local drops
45
+ drops=$(printf '%s\n' "${_LIBRARIAN_DURABILITY_DROP_PATTERNS[@]}" \
46
+ | jq -R . | jq -s .)
47
+
48
+ # All filtering happens in one jq expression to avoid bash-side iteration
49
+ # for what's a pure data transform.
50
+ printf '%s' "$candidates" | jq \
51
+ --argjson markers "$markers_json" \
52
+ --argjson drops "$drops" \
53
+ --argjson min_detail "$min_detail" \
54
+ '
55
+ def normalized: ((.summary // "") + " " + (.detail // "")) | ascii_downcase;
56
+ def matches_any($patterns): . as $text | $patterns | any(. as $p | ($text | contains($p)));
57
+
58
+ def classify(c):
59
+ c | normalized as $text |
60
+ (c.detail // "") as $detail |
61
+ if ($detail | length) < $min_detail then
62
+ { kept: false, reason: "detail_too_short" }
63
+ elif ($text | matches_any($drops)) then
64
+ { kept: false, reason: "filter_drop_pattern" }
65
+ elif ($text | matches_any($markers)) then
66
+ { kept: true, reason: "marker_phrase_match" }
67
+ else
68
+ { kept: false, reason: "filter_marker_missing" }
69
+ end;
70
+
71
+ map(. as $c | classify($c) as $r | $c + { _filter: $r })
72
+ | {
73
+ kept: [.[] | select(._filter.kept) | del(._filter)],
74
+ dropped: [.[] | select(._filter.kept | not) | { artifact_id: .id, reason: ._filter.reason }]
75
+ }
76
+ '
77
+ }
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env bash
2
+ # Event emission helpers for Librarian.
3
+ #
4
+ # Wraps the canonical onlooker-event.mjs `emit` mode so hook scripts can
5
+ # build a librarian.* event without touching node directly. Events are
6
+ # schema-validated by onlooker-event.mjs; if validation fails the line is
7
+ # silently dropped (fail-soft) rather than blocking the hook.
8
+
9
+ # Resolve the ecosystem helper path. The substrate is in the sibling
10
+ # ecosystem plugin at ../../../.. relative to plugins/librarian/scripts/hooks.
11
+ # Honor ONLOOKER_ECOSYSTEM_ROOT when set (test isolation).
12
+ _librarian_resolve_event_js() {
13
+ local script_dir plugin_root ecosystem_root candidate
14
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+ plugin_root="$(cd "${script_dir}/../.." && pwd)"
16
+
17
+ ecosystem_root="${ONLOOKER_ECOSYSTEM_ROOT:-}"
18
+ if [[ -z "$ecosystem_root" ]]; then
19
+ candidate="$(cd "${plugin_root}/../.." 2>/dev/null && pwd)"
20
+ if [[ -f "${candidate}/scripts/lib/onlooker-event.mjs" ]]; then
21
+ ecosystem_root="$candidate"
22
+ fi
23
+ fi
24
+
25
+ if [[ -n "$ecosystem_root" ]]; then
26
+ printf '%s/scripts/lib/onlooker-event.mjs' "$ecosystem_root"
27
+ fi
28
+ }
29
+
30
+ _LIBRARIAN_EVENT_JS="${_LIBRARIAN_EVENT_JS:-$(_librarian_resolve_event_js)}"
31
+
32
+ # Emit a librarian.* event. Fail-soft: returns 0 on success or when the
33
+ # substrate is unavailable; the event is dropped silently.
34
+ #
35
+ # Usage: librarian_emit <event_type> <session_id> <payload_json>
36
+ #
37
+ # Example:
38
+ # librarian_emit "librarian.scan.started" "$SID" "$(jq -n \
39
+ # --arg trigger "session_end" '{ trigger: $trigger }')"
40
+ librarian_emit() {
41
+ local event_type="${1:-}"
42
+ local session_id="${2:-}"
43
+ local payload="${3:-{\}}"
44
+
45
+ [[ -z "$event_type" || -z "$session_id" ]] && return 0
46
+ [[ -z "$_LIBRARIAN_EVENT_JS" || ! -f "$_LIBRARIAN_EVENT_JS" ]] && return 0
47
+ command -v node >/dev/null 2>&1 || return 0
48
+ [[ -z "${ONLOOKER_EVENTS_LOG:-}" ]] && return 0
49
+
50
+ local params event_json
51
+ params=$(jq -cn \
52
+ --arg plugin "librarian" \
53
+ --arg session_id "$session_id" \
54
+ --arg event_type "$event_type" \
55
+ --argjson payload "$payload" \
56
+ '{
57
+ plugin: $plugin,
58
+ session_id: $session_id,
59
+ event_type: $event_type,
60
+ payload: $payload
61
+ }') || return 0
62
+
63
+ event_json=$(
64
+ ONLOOKER_DIR="${ONLOOKER_DIR:-$HOME/.onlooker}" \
65
+ ONLOOKER_PLUGIN_NAME="librarian" \
66
+ printf '%s' "$params" | node "$_LIBRARIAN_EVENT_JS" emit 2>/dev/null
67
+ ) || return 0
68
+ [[ -z "$event_json" ]] && return 0
69
+
70
+ mkdir -p "$(dirname "$ONLOOKER_EVENTS_LOG")" 2>/dev/null
71
+ printf '%s\n' "$event_json" >> "$ONLOOKER_EVENTS_LOG" 2>/dev/null
72
+ }
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env bash
2
+ # Project key derivation for Librarian.
3
+ #
4
+ # Librarian writes its proposal queue and tombstones under the ecosystem-wide
5
+ # 12-char hex project key so a single project's state is shared across clones.
6
+ # (The typed memory store the user maintains lives at a different path keyed by
7
+ # the Claude Code per-checkout encoding; that path is resolved separately at
8
+ # write time.)
9
+ #
10
+ # Resolution order:
11
+ # 1. SHA256(`git remote get-url origin`) — preferred, machine-portable
12
+ # 2. SHA256(realpath of `git rev-parse --show-toplevel`) — fallback for repos
13
+ # without an origin remote (greenfield local-only work)
14
+ #
15
+ # Returns the first 12 hex chars. Returns empty string if neither resolution
16
+ # path works (caller decides whether to skip or treat as a non-repo session).
17
+
18
+ _librarian_sha256_first12() {
19
+ local input="$1"
20
+ if command -v shasum >/dev/null 2>&1; then
21
+ printf '%s' "$input" | shasum -a 256 2>/dev/null | cut -c1-12
22
+ elif command -v sha256sum >/dev/null 2>&1; then
23
+ printf '%s' "$input" | sha256sum 2>/dev/null | cut -c1-12
24
+ else
25
+ return 1
26
+ fi
27
+ }
28
+
29
+ librarian_project_remote_url() {
30
+ local cwd="${1:-}"
31
+ [[ -z "$cwd" || ! -d "$cwd" ]] && return 0
32
+ git -C "$cwd" remote get-url origin 2>/dev/null || true
33
+ }
34
+
35
+ librarian_project_repo_root() {
36
+ local cwd="${1:-}"
37
+ [[ -z "$cwd" || ! -d "$cwd" ]] && return 0
38
+
39
+ if ! git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
40
+ return 0
41
+ fi
42
+
43
+ local common_dir toplevel
44
+ common_dir=$(git -C "$cwd" rev-parse --git-common-dir 2>/dev/null) || return 0
45
+
46
+ if [[ -n "$common_dir" && "$common_dir" != /* ]]; then
47
+ common_dir="$(cd "$cwd" && cd "$common_dir" 2>/dev/null && pwd -P)" || common_dir=""
48
+ fi
49
+
50
+ if [[ -n "$common_dir" && -d "$common_dir" ]]; then
51
+ toplevel="$(cd "$common_dir/.." 2>/dev/null && pwd -P)" || toplevel=""
52
+ fi
53
+
54
+ if [[ -z "$toplevel" ]]; then
55
+ toplevel=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null || true)
56
+ [[ -n "$toplevel" ]] && toplevel="$(cd "$toplevel" 2>/dev/null && pwd -P)"
57
+ fi
58
+
59
+ printf '%s' "$toplevel"
60
+ }
61
+
62
+ # Compute the project key for the given cwd. Prints the key or empty string.
63
+ # Usage: key=$(librarian_project_key "$CWD")
64
+ librarian_project_key() {
65
+ local cwd="${1:-}"
66
+ [[ -z "$cwd" ]] && cwd="$(pwd)"
67
+
68
+ local remote
69
+ remote=$(librarian_project_remote_url "$cwd")
70
+ if [[ -n "$remote" ]]; then
71
+ _librarian_sha256_first12 "remote:$remote"
72
+ return 0
73
+ fi
74
+
75
+ local root
76
+ root=$(librarian_project_repo_root "$cwd")
77
+ if [[ -n "$root" ]]; then
78
+ _librarian_sha256_first12 "root:$root"
79
+ return 0
80
+ fi
81
+
82
+ return 0
83
+ }