@onlooker-community/ecosystem 0.23.0 → 0.24.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.
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env bash
2
+ # Interactive control surface for the /librarian review skill.
3
+ #
4
+ # Exposes:
5
+ # librarian_cli list # one-line summary + table of pending proposals
6
+ # librarian_cli show <proposal_id> # full proposal body + provenance + conflict state
7
+ # librarian_cli accept <proposal_id> # write to typed memory store, mark accepted
8
+ # librarian_cli reject <proposal_id> [reason] # tombstone + mark rejected
9
+ # librarian_cli defer <proposal_id> # mark as deferred=true while keeping status pending
10
+ # librarian_cli status # one-line counts (pending / accepted / rejected)
11
+ #
12
+ # Memory store writes go to:
13
+ # ${HOME}/.claude/projects/${CLAUDE_PROJECT_ENCODED}/memory/<filename>
14
+ #
15
+ # When CLAUDE_PROJECT_ENCODED is unset, the CLI derives the encoded form
16
+ # from the current working directory (replace `/` with `-`). The MEMORY.md
17
+ # index is updated in-place — the accepted memory is appended as a new
18
+ # bullet line, and the file is created if it doesn't exist.
19
+ #
20
+ # Depends on (sourced by the caller): librarian-config.sh,
21
+ # librarian-project-key.sh, librarian-storage.sh, librarian-emit.sh
22
+
23
+ # ----------------------------------------------------------------------------
24
+ # Project key + memory store path resolution
25
+ # ----------------------------------------------------------------------------
26
+
27
+ _librarian_cli_project_key() {
28
+ local cwd="${1:-}"
29
+ [[ -z "$cwd" ]] && cwd="$(pwd)"
30
+ librarian_project_key "$cwd"
31
+ }
32
+
33
+ _librarian_cli_memory_dir() {
34
+ local cwd="${1:-}"
35
+ [[ -z "$cwd" ]] && cwd="$(pwd)"
36
+
37
+ local encoded="${CLAUDE_PROJECT_ENCODED:-}"
38
+ if [[ -z "$encoded" ]]; then
39
+ local abs
40
+ abs=$(cd "$cwd" 2>/dev/null && pwd -P) || abs=""
41
+ [[ -n "$abs" ]] && encoded=$(printf '%s' "$abs" | sed -E 's#/#-#g')
42
+ fi
43
+ [[ -z "$encoded" ]] && return 0
44
+
45
+ printf '%s/.claude/projects/%s/memory' "${HOME:-}" "$encoded"
46
+ }
47
+
48
+ # ----------------------------------------------------------------------------
49
+ # Helpers for the typed memory store
50
+ # ----------------------------------------------------------------------------
51
+
52
+ # Write a single memory file with provenance frontmatter and update
53
+ # MEMORY.md to reference it. Returns 0 on success.
54
+ #
55
+ # Usage: _librarian_cli_write_memory <memory_dir> <proposal_json>
56
+ _librarian_cli_write_memory() {
57
+ local mem_dir="$1"
58
+ local proposal="$2"
59
+ [[ -z "$mem_dir" || -z "$proposal" ]] && return 1
60
+ mkdir -p "$mem_dir" 2>/dev/null || return 1
61
+
62
+ local id type title body filename confidence src_session_ids src_artifact_ids now
63
+ id=$(printf '%s' "$proposal" | jq -r '.id // ""')
64
+ type=$(printf '%s' "$proposal" | jq -r '.proposed.type // ""')
65
+ title=$(printf '%s' "$proposal" | jq -r '.proposed.title // ""')
66
+ body=$(printf '%s' "$proposal" | jq -r '.proposed.body // ""')
67
+ filename=$(printf '%s' "$proposal" | jq -r '.proposed.filename // ""')
68
+ confidence=$(printf '%s' "$proposal" | jq -r '.proposed.classifier_confidence // 0')
69
+ src_session_ids=$(printf '%s' "$proposal" | jq -c '.source_session_ids // []')
70
+ src_artifact_ids=$(printf '%s' "$proposal" | jq -c '.source_artifact_ids // []')
71
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
72
+
73
+ [[ -z "$type" || -z "$title" || -z "$body" || -z "$filename" ]] && return 1
74
+
75
+ # Strip any `/` or `..` from the filename — proposals come from a
76
+ # trusted source, but the safety check costs nothing.
77
+ case "$filename" in
78
+ */*|*..*|.*) return 1 ;;
79
+ esac
80
+ [[ "$filename" == *.md ]] || return 1
81
+
82
+ local out_path="${mem_dir}/${filename}"
83
+
84
+ # Build the provenance YAML frontmatter. Single-line description and
85
+ # title come from the classifier; the source* arrays carry traceability.
86
+ {
87
+ printf -- '---\n'
88
+ printf 'name: %s\n' "$title"
89
+ printf 'description: librarian-promoted from proposal %s\n' "$id"
90
+ printf 'type: %s\n' "$type"
91
+ printf 'source: librarian\n'
92
+ printf 'classifier_confidence: %s\n' "$confidence"
93
+ printf 'promoted_at: %s\n' "$now"
94
+ printf 'source_session_ids: %s\n' "$src_session_ids"
95
+ printf 'source_artifact_ids: %s\n' "$src_artifact_ids"
96
+ printf -- '---\n\n'
97
+ printf '%s\n' "$body"
98
+ } > "$out_path" || return 1
99
+
100
+ # Update MEMORY.md: append a one-line entry referencing the new file.
101
+ local index_path="${mem_dir}/MEMORY.md"
102
+ if [[ ! -f "$index_path" ]]; then
103
+ printf '# Memory index\n\n' > "$index_path" || return 1
104
+ fi
105
+ # Avoid duplicate entries on repeated accepts of the same id (shouldn't
106
+ # happen but is cheap to guard against).
107
+ if ! grep -F -q "(${filename})" "$index_path"; then
108
+ printf -- '- [%s](%s) — %s\n' "$title" "$filename" "$type" >> "$index_path"
109
+ fi
110
+
111
+ printf '%s' "$out_path"
112
+ }
113
+
114
+ # Update a proposal's status field (pending → accepted | rejected | deferred).
115
+ # Returns 0 on success.
116
+ _librarian_cli_set_proposal_status() {
117
+ local key="$1"
118
+ local proposal_id="$2"
119
+ local new_status="$3"
120
+ local extra_json="${4:-{\}}"
121
+ [[ -z "$key" || -z "$proposal_id" || -z "$new_status" ]] && return 1
122
+
123
+ local path
124
+ path="$(librarian_proposals_dir "$key")/${proposal_id}.json"
125
+ [[ -f "$path" ]] || return 1
126
+
127
+ local now updated
128
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
129
+ updated=$(jq --arg status "$new_status" --arg t "$now" --argjson extra "$extra_json" \
130
+ '. * { status: $status, updated_at: $t } * $extra' "$path" 2>/dev/null) || return 1
131
+ [[ -z "$updated" || "$updated" == "null" ]] && return 1
132
+ printf '%s\n' "$updated" > "$path"
133
+ }
134
+
135
+ # ----------------------------------------------------------------------------
136
+ # Public surface
137
+ # ----------------------------------------------------------------------------
138
+
139
+ librarian_cli_list() {
140
+ local cwd="${1:-}"
141
+ local key
142
+ key=$(_librarian_cli_project_key "$cwd")
143
+ [[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 0; }
144
+
145
+ local pending
146
+ pending=$(librarian_storage_load_proposals "$key" \
147
+ | jq '[.[] | select((.status // "pending") == "pending")]')
148
+ local count
149
+ count=$(printf '%s' "$pending" | jq 'length' 2>/dev/null) || count=0
150
+
151
+ if [[ "$count" -eq 0 ]]; then
152
+ printf 'No pending proposals.\n'
153
+ return 0
154
+ fi
155
+
156
+ printf '%s pending proposal%s:\n\n' "$count" "$([ "$count" -eq 1 ] && echo "" || echo "s")"
157
+ # Print header + rows together so `column -t` (BSD-portable) aligns both.
158
+ # We can't use util-linux's `column -N` here — macOS ships BSD column,
159
+ # which only supports `-t` and `-s`.
160
+ {
161
+ printf 'ID\tTYPE\tCONFIDENCE\tCONFLICT\tTITLE\n'
162
+ printf '%s' "$pending" | jq -r '
163
+ .[] | [
164
+ (.id // ""),
165
+ (.proposed.type // "?"),
166
+ ((.proposed.classifier_confidence // 0) | tostring),
167
+ (.conflict_state // "none"),
168
+ (.proposed.title // "")
169
+ ] | @tsv
170
+ '
171
+ } | column -t -s $'\t'
172
+ }
173
+
174
+ librarian_cli_show() {
175
+ local proposal_id="${1:-}"
176
+ local cwd="${2:-}"
177
+ [[ -z "$proposal_id" ]] && { printf 'usage: librarian_cli show <proposal_id>\n'; return 1; }
178
+
179
+ local key
180
+ key=$(_librarian_cli_project_key "$cwd")
181
+ [[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 1; }
182
+
183
+ local path
184
+ path="$(librarian_proposals_dir "$key")/${proposal_id}.json"
185
+ if [[ ! -f "$path" ]]; then
186
+ printf 'Proposal %s not found.\n' "$proposal_id"
187
+ return 1
188
+ fi
189
+
190
+ jq -r '
191
+ "--- proposal " + .id + " (" + (.status // "pending") + ") ---",
192
+ "created_at: " + (.created_at // ""),
193
+ "type: " + (.proposed.type // ""),
194
+ "title: " + (.proposed.title // ""),
195
+ "filename: " + (.proposed.filename // ""),
196
+ "classifier_confidence: " + ((.proposed.classifier_confidence // 0) | tostring),
197
+ "conflict_state: " + (.conflict_state // "none"),
198
+ "source_session_ids: " + ((.source_session_ids // []) | join(", ")),
199
+ "source_artifact_ids: " + ((.source_artifact_ids // []) | join(", ")),
200
+ "",
201
+ "body:",
202
+ (.proposed.body // "")
203
+ ' "$path"
204
+ }
205
+
206
+ librarian_cli_accept() {
207
+ local proposal_id="${1:-}"
208
+ local cwd="${2:-}"
209
+ [[ -z "$proposal_id" ]] && { printf 'usage: librarian_cli accept <proposal_id>\n'; return 1; }
210
+
211
+ local key session_id
212
+ key=$(_librarian_cli_project_key "$cwd")
213
+ [[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 1; }
214
+ session_id="${CLAUDE_SESSION_ID:-cli}"
215
+
216
+ local path proposal
217
+ path="$(librarian_proposals_dir "$key")/${proposal_id}.json"
218
+ [[ -f "$path" ]] || { printf 'Proposal %s not found.\n' "$proposal_id"; return 1; }
219
+ proposal=$(jq '.' "$path") || { printf 'Could not parse proposal.\n'; return 1; }
220
+
221
+ local mem_dir
222
+ mem_dir=$(_librarian_cli_memory_dir "$cwd")
223
+ [[ -z "$mem_dir" ]] && { printf 'Could not resolve typed memory store path. Set CLAUDE_PROJECT_ENCODED or run from inside the project.\n'; return 1; }
224
+
225
+ local out_path
226
+ out_path=$(_librarian_cli_write_memory "$mem_dir" "$proposal") || {
227
+ printf 'Failed to write memory file.\n'
228
+ return 1
229
+ }
230
+
231
+ local filename
232
+ filename=$(printf '%s' "$proposal" | jq -r '.proposed.filename')
233
+ _librarian_cli_set_proposal_status "$key" "$proposal_id" "accepted" \
234
+ "$(jq -cn --arg final_filename "$filename" '{accepted_via: "manual", final_filename: $final_filename}')" \
235
+ || { printf 'Wrote memory file at %s but failed to update proposal status.\n' "$out_path"; return 1; }
236
+
237
+ librarian_emit "librarian.proposal.accepted" "$session_id" "$(jq -cn \
238
+ --arg proposal_id "$proposal_id" \
239
+ --arg final_filename "$filename" \
240
+ --arg accepted_via "manual" \
241
+ '{proposal_id: $proposal_id, final_filename: $final_filename, accepted_via: $accepted_via}')"
242
+
243
+ printf 'Accepted. Wrote %s\n' "$out_path"
244
+ }
245
+
246
+ librarian_cli_reject() {
247
+ local proposal_id="${1:-}"
248
+ local reason="${2:-}"
249
+ local cwd="${3:-}"
250
+ [[ -z "$proposal_id" ]] && { printf 'usage: librarian_cli reject <proposal_id> [reason]\n'; return 1; }
251
+
252
+ local key session_id
253
+ key=$(_librarian_cli_project_key "$cwd")
254
+ [[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 1; }
255
+ session_id="${CLAUDE_SESSION_ID:-cli}"
256
+
257
+ local path proposal body original_filename body_hash
258
+ path="$(librarian_proposals_dir "$key")/${proposal_id}.json"
259
+ [[ -f "$path" ]] || { printf 'Proposal %s not found.\n' "$proposal_id"; return 1; }
260
+ proposal=$(jq '.' "$path") || { printf 'Could not parse proposal.\n'; return 1; }
261
+ body=$(printf '%s' "$proposal" | jq -r '.proposed.body // ""')
262
+ original_filename=$(printf '%s' "$proposal" | jq -r '.proposed.filename // ""')
263
+
264
+ # Tombstone keyed on body hash so the same content does not re-propose.
265
+ body_hash=$(librarian_body_hash "$body")
266
+ if [[ -n "$body_hash" ]]; then
267
+ if librarian_storage_write_tombstone "$key" "$body_hash" "$original_filename"; then
268
+ librarian_emit "librarian.tombstone.created" "$session_id" "$(jq -cn \
269
+ --arg body_hash "$body_hash" \
270
+ --arg original_filename "$original_filename" \
271
+ '{body_hash: $body_hash, original_filename: (if $original_filename == "" then null else $original_filename end)}
272
+ | with_entries(select(.value != null))')"
273
+ else
274
+ printf 'Failed to write tombstone for proposal %s.\n' "$proposal_id"
275
+ return 1
276
+ fi
277
+ fi
278
+
279
+ _librarian_cli_set_proposal_status "$key" "$proposal_id" "rejected" \
280
+ "$(jq -cn --arg reason "$reason" '{reason: (if $reason == "" then null else $reason end)}')" \
281
+ || return 1
282
+
283
+ librarian_emit "librarian.proposal.rejected" "$session_id" "$(jq -cn \
284
+ --arg proposal_id "$proposal_id" \
285
+ --arg reason "$reason" \
286
+ '{proposal_id: $proposal_id, reason: (if $reason == "" then null else $reason end)}
287
+ | with_entries(select(.value != null))')"
288
+
289
+ printf 'Rejected proposal %s%s\n' "$proposal_id" "$([ -n "$reason" ] && echo " (reason: $reason)" || echo "")"
290
+ }
291
+
292
+ librarian_cli_defer() {
293
+ local proposal_id="${1:-}"
294
+ local cwd="${2:-}"
295
+ [[ -z "$proposal_id" ]] && { printf 'usage: librarian_cli defer <proposal_id>\n'; return 1; }
296
+
297
+ local key
298
+ key=$(_librarian_cli_project_key "$cwd")
299
+ [[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 1; }
300
+
301
+ local path
302
+ path="$(librarian_proposals_dir "$key")/${proposal_id}.json"
303
+ [[ -f "$path" ]] || { printf 'Proposal %s not found.\n' "$proposal_id"; return 1; }
304
+
305
+ # Deferred proposals remain pending — we just stamp the proposal with
306
+ # an updated_at so a reviewer can tell it was visited.
307
+ _librarian_cli_set_proposal_status "$key" "$proposal_id" "pending" \
308
+ '{"deferred": true}' || return 1
309
+ printf 'Deferred proposal %s — still in the queue for next session.\n' "$proposal_id"
310
+ }
311
+
312
+ librarian_cli_status() {
313
+ local cwd="${1:-}"
314
+ local key
315
+ key=$(_librarian_cli_project_key "$cwd")
316
+ [[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 0; }
317
+
318
+ local all
319
+ all=$(librarian_storage_load_proposals "$key")
320
+ local pending accepted rejected
321
+ pending=$(printf '%s' "$all" | jq '[.[] | select((.status // "pending") == "pending")] | length')
322
+ accepted=$(printf '%s' "$all" | jq '[.[] | select(.status == "accepted")] | length')
323
+ rejected=$(printf '%s' "$all" | jq '[.[] | select(.status == "rejected")] | length')
324
+ printf 'pending: %s, accepted: %s, rejected: %s\n' "$pending" "$accepted" "$rejected"
325
+ }
326
+
327
+ librarian_cli() {
328
+ local action="${1:-list}"
329
+ shift || true
330
+ case "$action" in
331
+ list) librarian_cli_list "$@" ;;
332
+ show) librarian_cli_show "$@" ;;
333
+ accept) librarian_cli_accept "$@" ;;
334
+ reject) librarian_cli_reject "$@" ;;
335
+ defer) librarian_cli_defer "$@" ;;
336
+ status) librarian_cli_status "$@" ;;
337
+ *) printf 'unknown action: %s\n' "$action"; return 2 ;;
338
+ esac
339
+ }
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: librarian
3
+ description: Review the librarian's pending memory promotion proposals queued from past sessions. Walk pending entries with the user one at a time, surfacing provenance and conflict state, and route each to accept (writes the typed memory file and updates MEMORY.md), reject (writes a body-hash tombstone so the same content won't re-propose), or defer (leave in the queue). Use when the user types `/librarian`, `/librarian review`, `/librarian triage`, `/librarian status`, or `/librarian list`, or asks to review librarian proposals.
4
+ ---
5
+
6
+ # Librarian: Promotion Queue Review
7
+
8
+ You are operating the **Librarian** review surface — the user-facing control for promoting per-session artifacts (decisions, dead-ends, open questions captured by Archivist) into the user's durable typed memory store.
9
+
10
+ Auto-promotion is intentionally off. Librarian queues proposals; the user (with your help) confirms each one. Every accept writes a real file into `~/.claude/projects/<encoded>/memory/`, so every accept matters.
11
+
12
+ ## Parse the request
13
+
14
+ Read the user's argument after `/librarian`:
15
+
16
+ - no argument, or `review`, `triage`, `walk` → **walk the queue** (default)
17
+ - `list` → print the pending table and stop
18
+ - `status` → print one-line counts and stop
19
+ - a proposal id (starts with a ULID-shaped string) → jump straight to **show** for that id
20
+
21
+ If the user passes a free-form intent ("clear out the queue", "what's pending?"), map it to `review` or `list` as appropriate.
22
+
23
+ ## Run the control surface
24
+
25
+ Source the plugin helpers and invoke `librarian_cli`. Run this in a single bash call when you need state:
26
+
27
+ ```bash
28
+ set -uo pipefail
29
+ source "$CLAUDE_PLUGIN_ROOT/scripts/lib/librarian-config.sh"
30
+ source "$CLAUDE_PLUGIN_ROOT/scripts/lib/librarian-project-key.sh"
31
+ source "$CLAUDE_PLUGIN_ROOT/scripts/lib/librarian-storage.sh"
32
+ source "$CLAUDE_PLUGIN_ROOT/scripts/lib/librarian-emit.sh"
33
+ source "$CLAUDE_PLUGIN_ROOT/scripts/lib/librarian-cli.sh"
34
+
35
+ # action is one of: list | show <id> | accept <id> | reject <id> [reason] | defer <id> | status
36
+ librarian_cli "<action>" "<args...>"
37
+ ```
38
+
39
+ `librarian_cli` resolves the project key from the current working directory automatically and routes writes to the typed memory store under `${HOME}/.claude/projects/${CLAUDE_PROJECT_ENCODED}/memory/` (deriving the encoded path from `cwd` when the env var is unset).
40
+
41
+ ## The review walkthrough
42
+
43
+ For `review` (the default), loop:
44
+
45
+ 1. Call `librarian_cli list`. If the output says `No pending proposals.`, tell the user the queue is clear and stop.
46
+ 2. Take the first pending id from the table. Call `librarian_cli show <id>` to render the proposal's provenance, classifier confidence, conflict state, and full body.
47
+ 3. Present the proposal to the user in plain English. Lead with the title and the proposed memory **type** (user / feedback / project / reference) — those determine where it lands in the user's memory store. If the **conflict_state** is anything other than `none` (typically `near_duplicate` or `contradicts_existing`), call this out explicitly and recommend a careful read before accepting.
48
+ 4. Ask the user how to route it: **accept**, **reject** (optionally with a reason), **defer** (revisit next session), or **skip** (move on without recording a decision).
49
+ 5. Route the answer:
50
+ - **accept** → `librarian_cli accept <id>`. Confirm the resulting path Librarian printed and move to the next proposal.
51
+ - **reject** → `librarian_cli reject <id> "<reason>"`. The reason is optional but valuable — it's recorded on the proposal and the tombstone is keyed on body hash so the same content won't be re-proposed.
52
+ - **defer** → `librarian_cli defer <id>`. The proposal stays pending; mention it'll resurface next session.
53
+ - **skip** → don't call the CLI for this id, move to the next proposal.
54
+ 6. After each routed decision, fetch the next pending id (the previous one will have flipped status, so `list` reorders naturally) and repeat. When `list` returns no rows, finish with `librarian_cli status` so the user sees the final counts.
55
+
56
+ For `list` and `status`, just call `librarian_cli <action>` once and render the output.
57
+
58
+ ## Safety rules
59
+
60
+ - **Never accept a proposal on the user's behalf without explicit confirmation.** Accepting writes a file to the user's typed memory store and that memory will be loaded into every future session in this project. Treat each accept like editing a CLAUDE.md.
61
+ - **Do not edit MEMORY.md directly.** `accept` updates the index for you; hand-editing risks duplicate entries or stale links.
62
+ - **Do not delete proposal files manually.** Reject (with a tombstone) is the cleanup path. Direct deletion would let the same body re-propose on the next scan.
63
+ - **Conflict-state proposals deserve a careful read.** When `conflict_state` is `near_duplicate` or `contradicts_existing`, surface the conflict to the user before they decide. Often the right answer is reject (the existing memory is better) or accept-and-then-prune (you can mention that follow-up).
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scribe",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Intent documentation from agent activity. Captures why changes were made — problem context, decisions, tradeoffs — and distills them into readable artifacts at session end.",
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/scribe-v0.2.0...scribe-v0.2.1) (2026-06-04)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **scribe:** mark hook scripts executable :relieved: ([#64](https://github.com/onlooker-community/ecosystem/issues/64)) ([05603e5](https://github.com/onlooker-community/ecosystem/commit/05603e56895c009c1435d1712592adbbc4c15e61))
9
+
3
10
  ## [0.2.0](https://github.com/onlooker-community/ecosystem/compare/scribe-v0.1.0...scribe-v0.2.0) (2026-06-01)
4
11
 
5
12
 
@@ -0,0 +1,118 @@
1
+ # Scribe
2
+
3
+ Intent documentation from agent activity.
4
+
5
+ Scribe captures *why* changes were made — the problem context, the decisions and their reasons, the tradeoffs accepted, and the constraints that shaped the work — and distills them into a readable Markdown artifact at session end. Git logs and code comments record *what* changed; Scribe records the intent behind it.
6
+
7
+ Scribe is a sibling plugin to [`ecosystem`](../../) and assumes the Onlooker observability substrate (`~/.onlooker/`) is present.
8
+
9
+ ## How it works
10
+
11
+ | Hook | What Scribe does |
12
+ |------|------------------|
13
+ | `SessionStart` | Creates storage directories and initializes a per-session state file with `captured_prompt` and `captured_at` set to null. |
14
+ | `UserPromptSubmit` | On the first turn of a session (when `captured_prompt` is still null), stores the prompt text — truncated to `capture.prompt_max_chars` — as the problem-statement seed. Subsequent turns are ignored, since the full transcript is available at Stop time. |
15
+ | `Stop` | Reads the full session transcript, runs a single Haiku extraction pass via `claude -p` to identify the problem, decisions, tradeoffs, constraints, and out-of-scope items, formats the result as a Markdown intent document, and writes it under `~/.onlooker/scribe/<project-key>/`. Emits `scribe.distill.complete`. |
16
+
17
+ The Stop hook silently skips when the session has fewer than `capture.min_turns` user turns, when `scribe.enabled` is false, or when no readable `transcript_path` is present in the hook input. Every hook always exits 0 — Scribe never blocks a session.
18
+
19
+ ## Activation
20
+
21
+ Scribe is **on by default**. Disable it per-project in `.claude/settings.json`:
22
+
23
+ ```json
24
+ {
25
+ "scribe": {
26
+ "enabled": false
27
+ }
28
+ }
29
+ ```
30
+
31
+ Or globally in `~/.claude/settings.json`.
32
+
33
+ ## Configuration
34
+
35
+ All keys are optional. Unset keys fall back to the plugin's `config.json` defaults.
36
+
37
+ ```json
38
+ {
39
+ "scribe": {
40
+ "enabled": true,
41
+ "evaluator": {
42
+ "model": "claude-haiku-4-5-20251001",
43
+ "timeout": 60,
44
+ "max_tokens": 2048,
45
+ "temperature": 0.3
46
+ },
47
+ "capture": {
48
+ "min_turns": 3,
49
+ "prompt_max_chars": 1000,
50
+ "transcript_chars_max": 40000
51
+ },
52
+ "output": {
53
+ "mirror_to_project": false,
54
+ "project_dir": "docs/decisions"
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ | Key | Default | Description |
61
+ |-----|---------|-------------|
62
+ | `enabled` | `true` | Must be `true` for any capture or distillation to run. |
63
+ | `evaluator.model` | `claude-haiku-4-5-20251001` | Model used for the intent-extraction pass. Haiku is fast and cheap; the extraction prompt is structured and does not require deep reasoning. |
64
+ | `evaluator.timeout` | `60` | Wall-clock timeout in seconds passed to the `timeout` command around the `claude -p` call. |
65
+ | `evaluator.max_tokens` | `2048` | Token ceiling for the extraction response. |
66
+ | `evaluator.temperature` | `0.3` | Sampling temperature for the extraction pass. |
67
+ | `capture.min_turns` | `3` | Minimum number of user turns in the transcript before a session is distilled. Shorter sessions are skipped silently. |
68
+ | `capture.prompt_max_chars` | `1000` | Maximum number of characters of the first prompt stored as the problem-statement seed. |
69
+ | `capture.transcript_chars_max` | `40000` | Maximum number of characters of the rendered transcript fed into extraction. Larger values capture more context at higher cost. |
70
+ | `output.mirror_to_project` | `false` | When `true`, also copies the generated document into the repo tree under `output.project_dir`. |
71
+ | `output.project_dir` | `docs/decisions` | Directory (relative to the repo root) the intent document is mirrored into when `output.mirror_to_project` is `true`. |
72
+
73
+ ## Storage layout
74
+
75
+ ```text
76
+ ~/.onlooker/scribe/
77
+ ├── sessions/
78
+ │ └── <session-id>.json # per-session state: captured_prompt, captured_at
79
+ └── <project-key>/
80
+ └── <date>-<session-short>.md # intent document, e.g. 2026-06-04-01j8x9ab.md
81
+ ```
82
+
83
+ When the project key cannot be resolved, the document is written under `~/.onlooker/scribe/unknown/` instead. With `output.mirror_to_project` enabled, the same Markdown file is also copied to `<repo-root>/<project_dir>/<date>-<session-short>.md`.
84
+
85
+ Project key: first 12 hex chars of SHA256 of `git remote get-url origin` (prefixed `remote:`), falling back to a SHA256 of the repo root realpath (prefixed `root:`). The scheme mirrors `tribunal` and is worktree-aware — a worktree shares its parent repo's key.
86
+
87
+ The intent document is a Markdown file with the following sections:
88
+
89
+ ```markdown
90
+ # Session Intent: <date>
91
+
92
+ > <executive summary>
93
+
94
+ ## Problem
95
+ ## Decisions
96
+ ## Tradeoffs
97
+ ## Constraints
98
+ ## Out of Scope
99
+ ## Initial Prompt # only when a prompt was captured
100
+ ```
101
+
102
+ Each decision is rendered as a headline, its reason, and any considered-but-rejected alternatives. A footer records the short session ID, the generation timestamp, and the project root.
103
+
104
+ ## Events emitted
105
+
106
+ Scribe emits the canonical `scribe.*` event surface from [`@onlooker-community/schema`](https://github.com/onlooker-community/schema). All events land in `~/.onlooker/logs/onlooker-events.jsonl` and are validated against the schema before write.
107
+
108
+ | Event | When |
109
+ |-------|------|
110
+ | `scribe.distill.complete` | After an intent document is written at session end. Includes `session_id`, `captures_processed`, and `artifacts_produced` (`2` when mirrored to the project tree, otherwise `1`). |
111
+
112
+ ## Requirements
113
+
114
+ - The `ecosystem` plugin installed (for the `~/.onlooker/` substrate and canonical event emission). Scribe declares `"requires": ["ecosystem"]`.
115
+ - `claude` CLI on `PATH` (the Stop hook shells out to `claude -p` for the extraction pass).
116
+ - `jq` for JSON manipulation.
117
+ - `node` for canonical-event emission.
118
+ - `shasum` or `sha256sum` for project-key derivation (standard on macOS and most Linux distributions).
File without changes
File without changes