@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.
- package/.claude-plugin/plugin.json +1 -1
- package/.release-please-manifest.json +3 -2
- package/CHANGELOG.md +7 -0
- package/docs/memory-architecture.md +102 -0
- package/package.json +3 -3
- package/plugins/curator/docs/adr/001-staleness-tiers.md +100 -0
- package/plugins/curator/docs/design.md +311 -0
- package/plugins/historian/docs/adr/001-local-embeddings-only.md +96 -0
- package/plugins/historian/docs/design.md +317 -0
- package/plugins/librarian/.claude-plugin/plugin.json +14 -0
- package/plugins/librarian/CHANGELOG.md +10 -0
- package/plugins/librarian/README.md +51 -0
- package/plugins/librarian/config.json +52 -0
- package/plugins/librarian/docs/adr/001-propose-dont-auto-write.md +87 -0
- package/plugins/librarian/docs/design.md +301 -0
- package/plugins/librarian/hooks/hooks.json +26 -0
- package/plugins/librarian/scripts/hooks/librarian-session-end.sh +312 -0
- package/plugins/librarian/scripts/hooks/librarian-session-start.sh +103 -0
- package/plugins/librarian/scripts/lib/librarian-archivist-reader.sh +67 -0
- package/plugins/librarian/scripts/lib/librarian-classifier.sh +139 -0
- package/plugins/librarian/scripts/lib/librarian-config.sh +74 -0
- package/plugins/librarian/scripts/lib/librarian-durability.sh +77 -0
- package/plugins/librarian/scripts/lib/librarian-emit.sh +72 -0
- package/plugins/librarian/scripts/lib/librarian-project-key.sh +83 -0
- package/plugins/librarian/scripts/lib/librarian-storage.sh +222 -0
- package/plugins/librarian/scripts/lib/librarian-ulid.sh +50 -0
- package/release-please-config.json +16 -0
- package/test/bats/librarian-session-end.bats +182 -0
- package/test/bats/librarian-session-start.bats +136 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# Librarian — Plugin Design
|
|
2
|
+
|
|
3
|
+
**Plugin name:** `librarian`
|
|
4
|
+
**Tagline:** *Promotes what's worth keeping.*
|
|
5
|
+
**Status:** Design (pre-implementation)
|
|
6
|
+
|
|
7
|
+
Librarian is the consolidation layer between archivist's per-session artifacts and the user's durable typed memory store. It watches what archivist writes during a session, decides which of those decisions, dead-ends, and open questions deserve to live beyond the session, classifies them into the existing memory types (user/feedback/project/reference), and proposes promotions for user confirmation. It does not write to the typed memory store automatically by default — see [ADR-001](adr/001-propose-dont-auto-write.md).
|
|
8
|
+
|
|
9
|
+
It sits in the [memory architecture](../../../docs/memory-architecture.md) between archivist (session-scoped) and curator (maintenance). Where archivist treats every session as fresh and ranks by recency, librarian asks: "should this fact survive across sessions, and if so, what kind of memory is it?"
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Failure Modes Librarian Addresses
|
|
14
|
+
|
|
15
|
+
**A — The same decision rediscovered every session.** A user explains why the auth middleware is being rewritten (legal compliance, not tech debt). Archivist captures it as a decision; it gets reinjected next session within the recency budget. Three sessions later it has aged out, and the agent re-asks "why are we rewriting this?" Librarian promotes load-bearing project facts into the typed memory store, where they don't decay on recency alone.
|
|
16
|
+
|
|
17
|
+
**B — Feedback observed but not generalized.** During a session, the user says "stop summarizing what you just did at the end of every response." The model corrects course for the rest of that session. Archivist may or may not capture it as a decision (it's not a code decision). Next session the model summarizes again. Librarian detects the corrective pattern, classifies it as `feedback`, and proposes it for the typed memory store with **Why:** and **How to apply:** fields filled in.
|
|
18
|
+
|
|
19
|
+
**C — Memory store starvation.** A user who never says "remember that…" ends up with a near-empty typed memory store, even after months of work. Archivist captures everything per-session but nothing accumulates. Librarian provides the promotion path that doesn't depend on the user explicitly invoking it.
|
|
20
|
+
|
|
21
|
+
**D — Pollution from automatic promotion (counter-failure).** If librarian wrote directly to the typed memory store, every false positive would silently bloat the file and degrade future sessions. The design avoids this by defaulting to propose-only — see [ADR-001](adr/001-propose-dont-auto-write.md).
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Architecture
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
SessionEnd hook fires (or skill invocation)
|
|
29
|
+
│
|
|
30
|
+
▼
|
|
31
|
+
┌──────────────────────┐
|
|
32
|
+
│ Artifact Reader │ reads archivist artifacts since last librarian
|
|
33
|
+
│ │ scan; reads recent transcript tail for context
|
|
34
|
+
└─────────┬────────────┘
|
|
35
|
+
│
|
|
36
|
+
▼
|
|
37
|
+
┌──────────────────────┐
|
|
38
|
+
│ Durability Filter │ cheap heuristics — keep candidates that show
|
|
39
|
+
│ │ signs of durability (repetition, marker phrases,
|
|
40
|
+
│ │ explicit user preference language)
|
|
41
|
+
└─────────┬────────────┘
|
|
42
|
+
│ candidates remain
|
|
43
|
+
▼
|
|
44
|
+
┌──────────────────────┐
|
|
45
|
+
│ Type Classifier │ Haiku call: user / feedback / project / reference
|
|
46
|
+
│ (LLM) │ emits null for "session-only — don't promote"
|
|
47
|
+
└─────────┬────────────┘
|
|
48
|
+
│
|
|
49
|
+
▼
|
|
50
|
+
┌──────────────────────┐
|
|
51
|
+
│ Conflict / Dup │ compare against existing memory files; merge,
|
|
52
|
+
│ Detector │ supersede, or flag conflict
|
|
53
|
+
└─────────┬────────────┘
|
|
54
|
+
│
|
|
55
|
+
▼
|
|
56
|
+
┌──────────────────────┐
|
|
57
|
+
│ Proposal Queue │ written to ~/.onlooker/librarian/<key>/proposals/
|
|
58
|
+
│ │ │
|
|
59
|
+
└─────────┬────────────┘
|
|
60
|
+
│ at next SessionStart
|
|
61
|
+
▼
|
|
62
|
+
┌──────────────────────┐
|
|
63
|
+
│ Surfacer │ injects "Librarian proposes N promotions"
|
|
64
|
+
│ │ pointer; full review via /librarian skill
|
|
65
|
+
└──────────────────────┘
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Artifact Reader
|
|
69
|
+
|
|
70
|
+
Reads `~/.onlooker/archivist/<project-key>/decisions/`, `dead_ends/`, and `open_questions/` for artifacts created since the last librarian scan. The last-scan watermark is `~/.onlooker/librarian/<project-key>/last_scan.json` (an ISO-8601 timestamp). On first run, librarian scans the last `bootstrap_lookback_days` (default: 14) of artifacts.
|
|
71
|
+
|
|
72
|
+
Each artifact carries `session_id` and `created_at`. Librarian groups candidates by `session_id` so the classifier has session-shaped context, not a flat fire-hose.
|
|
73
|
+
|
|
74
|
+
### Durability Filter
|
|
75
|
+
|
|
76
|
+
A cheap pre-LLM filter that drops obvious session-only items before paying for classification. Heuristics, in order:
|
|
77
|
+
|
|
78
|
+
1. **Marker-phrase boost.** Artifact summary or detail contains one of: `always`, `never`, `remember`, `from now on`, `every time`, `whenever`, `prefer`, `the reason`, `because`, `historically`, `legacy`, `compliance`, `requirement`. These markers correlate strongly with durable facts in calibration runs (target: ≥60% precision; revalidate per repo via `/librarian calibrate`).
|
|
79
|
+
2. **Reference grounding.** Artifact `files` field lists at least one path that still exists in the repo (paths that have been deleted suggest a fact about completed/abandoned work, not durable knowledge).
|
|
80
|
+
3. **Repetition across sessions.** Same canonical summary (after normalization: lowercased, stopwords removed, top-3 tokens) appears in archivist artifacts from ≥2 distinct sessions. Strong durability signal.
|
|
81
|
+
4. **Drop list.** Specific patterns that almost never promote well: "the test is failing", "let me check", "I'll come back to this", artifact whose detail is shorter than `min_detail_chars` (default: 40).
|
|
82
|
+
|
|
83
|
+
Filter outputs a candidate set. If the set is empty, librarian emits `librarian.scan.empty` and exits.
|
|
84
|
+
|
|
85
|
+
### Type Classifier
|
|
86
|
+
|
|
87
|
+
For each remaining candidate, librarian calls Haiku once with a structured prompt. The prompt presents the four memory types from the user's CLAUDE.md (user, feedback, project, reference) with examples, and asks the model to emit one of those four labels or `null` for "session-only — don't promote."
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
You are classifying a session artifact for promotion into a long-term memory store.
|
|
91
|
+
|
|
92
|
+
The store has four types:
|
|
93
|
+
- user: durable facts about the user's role, expertise, or working style
|
|
94
|
+
- feedback: corrections or validated preferences ("don't do X", "yes, keep doing Y")
|
|
95
|
+
- project: ongoing work facts, decisions, constraints not derivable from the code
|
|
96
|
+
- reference: pointers to external systems (issue trackers, dashboards, channels)
|
|
97
|
+
|
|
98
|
+
RULES:
|
|
99
|
+
- Output only JSON: {"type": "<user|feedback|project|reference|null>",
|
|
100
|
+
"title": "<≤60 chars>",
|
|
101
|
+
"body": "<the memory content; structure per type>",
|
|
102
|
+
"confidence": <float 0–1>}
|
|
103
|
+
- Use null when the artifact is interesting but session-only (a specific bug fix,
|
|
104
|
+
a one-off question that got answered, an exploration that didn't change anything).
|
|
105
|
+
- For feedback and project types, include **Why:** and **How to apply:** lines.
|
|
106
|
+
|
|
107
|
+
<artifact>
|
|
108
|
+
kind: {{ARTIFACT_KIND}}
|
|
109
|
+
summary: {{SUMMARY}}
|
|
110
|
+
detail: {{DETAIL}}
|
|
111
|
+
files: {{FILES_LIST}}
|
|
112
|
+
session_id: {{SESSION_ID}}
|
|
113
|
+
created_at: {{CREATED_AT}}
|
|
114
|
+
</artifact>
|
|
115
|
+
|
|
116
|
+
<surrounding_session_context>
|
|
117
|
+
{{SESSION_CONTEXT_EXCERPT}}
|
|
118
|
+
</surrounding_session_context>
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Model: `claude-haiku-4-5-20251001`. Temperature 0.2 (slight stability vs. 0 for noise-floor robustness). Max output tokens: 256.
|
|
122
|
+
|
|
123
|
+
Candidates with `confidence < min_classifier_confidence` (default: 0.6) are dropped silently — the cost of a missed promotion is low; the cost of a noisy proposal queue is high.
|
|
124
|
+
|
|
125
|
+
### Conflict / Duplicate Detector
|
|
126
|
+
|
|
127
|
+
For each surviving candidate, librarian:
|
|
128
|
+
|
|
129
|
+
1. Reads `MEMORY.md` and each referenced memory file from `~/.claude/projects/<encoded-project>/memory/`.
|
|
130
|
+
2. Computes token-set similarity (Jaccard on lowercased, stopword-stripped token sets) against each existing memory's body.
|
|
131
|
+
3. If max similarity ≥ `duplicate_threshold` (default: 0.7), classifies as `duplicate` — annotated and dropped from proposals, but logged as `librarian.candidate.dropped` with `reason: "duplicate"`.
|
|
132
|
+
4. If `merge_candidate_threshold` (default: 0.45) ≤ similarity < `duplicate_threshold`, classifies as `merge_candidate` — the proposal records the existing memory's filename so the surfacer can offer "merge into X" as a resolution path.
|
|
133
|
+
5. If `conflict_keyword_overlap` (default: 0.5) overlap on the body but opposing sentiment markers (`always`/`never`, `do`/`don't`, `prefer`/`avoid`), classifies as `conflict_candidate` — the surfacer offers "this contradicts X — supersede, keep both, or drop new."
|
|
134
|
+
|
|
135
|
+
Conflict detection at this stage is cheap pattern matching, not an LLM call. Curator does the deeper LLM-based contradiction sweep separately.
|
|
136
|
+
|
|
137
|
+
### Proposal Queue
|
|
138
|
+
|
|
139
|
+
Each proposal is written to `~/.onlooker/librarian/<project-key>/proposals/<ulid>.json`:
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"id": "01J...",
|
|
144
|
+
"created_at": "2026-06-02T18:24:11Z",
|
|
145
|
+
"source_artifact_ids": ["01J...", "01J..."],
|
|
146
|
+
"source_session_ids": ["..."],
|
|
147
|
+
"proposed": {
|
|
148
|
+
"type": "feedback",
|
|
149
|
+
"filename": "feedback_no_trailing_summaries.md",
|
|
150
|
+
"title": "Don't write trailing summaries",
|
|
151
|
+
"body": "...",
|
|
152
|
+
"classifier_confidence": 0.84
|
|
153
|
+
},
|
|
154
|
+
"conflict_state": "none | duplicate | merge_candidate | conflict_candidate",
|
|
155
|
+
"conflict_with": ["existing_memory_filename.md"],
|
|
156
|
+
"status": "pending | accepted | rejected | superseded"
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Surfacer
|
|
161
|
+
|
|
162
|
+
At `SessionStart`, librarian counts proposals where `status == "pending"`. If `count > 0`, it injects a single short pointer into `additionalContext`:
|
|
163
|
+
|
|
164
|
+
> Librarian has 4 pending memory promotion proposals. Review with `/librarian review`.
|
|
165
|
+
|
|
166
|
+
It does not inject the proposal bodies — the SessionStart context budget is precious and the user shouldn't have to skim them inline. The `/librarian review` skill walks the user through each proposal interactively, accepting, rejecting, or editing each one. Accepted proposals are written to the typed memory store with a provenance trailer:
|
|
167
|
+
|
|
168
|
+
```markdown
|
|
169
|
+
---
|
|
170
|
+
name: Don't write trailing summaries
|
|
171
|
+
description: user-specific terseness preference — corrected during session
|
|
172
|
+
type: feedback
|
|
173
|
+
source: librarian
|
|
174
|
+
source_session_id: 01J...
|
|
175
|
+
source_artifact_ids: ["01J...", "01J..."]
|
|
176
|
+
classifier_confidence: 0.84
|
|
177
|
+
promoted_at: 2026-06-02T19:01:33Z
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
Don't write trailing summaries of what you just did.
|
|
181
|
+
|
|
182
|
+
**Why:** User explicitly said "stop summarizing... I can read the diff" during session 01J...
|
|
183
|
+
**How to apply:** Treat the end of turn as a stopping point; surface only what's new since the user last looked, not a recap.
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The provenance fields let curator later detect that a memory was librarian-promoted (so curator can use a different staleness heuristic for it) and trace a promoted memory back to the originating session if the user wants to investigate.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Integration Points
|
|
191
|
+
|
|
192
|
+
**Archivist.** Librarian reads archivist's artifact directories. If archivist is not installed, librarian emits `librarian.scan.skipped` with `reason: "archivist_not_present"` and exits. Librarian does not require archivist to be running in the current session — it reads artifacts from the on-disk store, which is durable.
|
|
193
|
+
|
|
194
|
+
**Curator.** Once a memory is promoted, curator owns its maintenance. Librarian does not re-touch memories it has already promoted. The `source: "librarian"` provenance field tells curator which memories came through this pipeline; curator may want different staleness criteria for librarian-promoted memories vs. hand-written ones (open question).
|
|
195
|
+
|
|
196
|
+
**Historian.** Independent. A librarian-promoted memory is a distilled summary; the corresponding historian transcript chunk is the verbatim source. They complement each other and do not need cross-references at the storage level.
|
|
197
|
+
|
|
198
|
+
**Scribe.** Different goal. Scribe produces readable narrative artifacts of the session. Librarian produces structured durable knowledge. The same session might generate both. They can run in parallel without coordination.
|
|
199
|
+
|
|
200
|
+
**Compass / Tribunal / Warden / Echo / Governor.** No interaction.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Configuration (`config.json`)
|
|
205
|
+
|
|
206
|
+
```json
|
|
207
|
+
{
|
|
208
|
+
"plugin_name": "librarian",
|
|
209
|
+
"storage_path": "${ONLOOKER_DIR:-$HOME/.onlooker}",
|
|
210
|
+
"librarian": {
|
|
211
|
+
"enabled": false,
|
|
212
|
+
"auto_promote": false,
|
|
213
|
+
"memory_store_path": "${HOME}/.claude/projects/${CLAUDE_PROJECT_ENCODED}/memory",
|
|
214
|
+
"scan": {
|
|
215
|
+
"trigger": "SessionEnd",
|
|
216
|
+
"bootstrap_lookback_days": 14,
|
|
217
|
+
"min_detail_chars": 40
|
|
218
|
+
},
|
|
219
|
+
"classifier": {
|
|
220
|
+
"model": "claude-haiku-4-5-20251001",
|
|
221
|
+
"temperature": 0.2,
|
|
222
|
+
"max_output_tokens": 256,
|
|
223
|
+
"min_classifier_confidence": 0.6
|
|
224
|
+
},
|
|
225
|
+
"durability_filter": {
|
|
226
|
+
"marker_phrases": [
|
|
227
|
+
"always", "never", "remember", "from now on", "every time",
|
|
228
|
+
"whenever", "prefer", "the reason", "because", "historically",
|
|
229
|
+
"legacy", "compliance", "requirement"
|
|
230
|
+
],
|
|
231
|
+
"require_grounding": false,
|
|
232
|
+
"repetition_min_sessions": 2
|
|
233
|
+
},
|
|
234
|
+
"conflict": {
|
|
235
|
+
"duplicate_threshold": 0.70,
|
|
236
|
+
"merge_candidate_threshold": 0.45,
|
|
237
|
+
"conflict_keyword_overlap": 0.50
|
|
238
|
+
},
|
|
239
|
+
"surfacer": {
|
|
240
|
+
"max_pending_for_inject": 20,
|
|
241
|
+
"skip_inject_when_zero": true
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`memory_store_path` resolves `${CLAUDE_PROJECT_ENCODED}` at hook time from the Claude Code project-path encoding scheme. If the env var is not set, librarian degrades to skill-only mode and emits `librarian.config.warning`.
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Events
|
|
252
|
+
|
|
253
|
+
| Event | Trigger | Key payload fields |
|
|
254
|
+
|---|---|---|
|
|
255
|
+
| `librarian.scan.started` | Scan begins | `last_scan_at`, `artifact_count_in_window` |
|
|
256
|
+
| `librarian.scan.empty` | Scan finds no candidates | `artifact_count_in_window` |
|
|
257
|
+
| `librarian.scan.skipped` | Scan cannot run | `reason: archivist_not_present\|memory_path_unresolved\|disabled` |
|
|
258
|
+
| `librarian.candidate.proposed` | Proposal written to queue | `proposal_id`, `type`, `classifier_confidence`, `conflict_state` |
|
|
259
|
+
| `librarian.candidate.dropped` | Candidate dropped pre-queue | `reason: duplicate\|low_confidence\|classified_null` |
|
|
260
|
+
| `librarian.proposal.accepted` | User accepted via skill | `proposal_id`, `final_filename` |
|
|
261
|
+
| `librarian.proposal.rejected` | User rejected via skill | `proposal_id`, `reason` (optional) |
|
|
262
|
+
| `librarian.proposal.merged` | User chose merge-into-X | `proposal_id`, `merged_into_filename` |
|
|
263
|
+
| `librarian.proposal.superseded` | User chose supersede-X | `proposal_id`, `superseded_filename` |
|
|
264
|
+
| `librarian.tombstone.created` | A previously-accepted promotion was manually deleted; recorded to prevent re-proposal | `original_filename`, `body_hash` |
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Skills
|
|
269
|
+
|
|
270
|
+
**`/librarian review`** — interactive walkthrough of pending proposals. For each: shows the proposed memory, the source artifact(s), the conflict state and existing memory if applicable, and offers accept / reject / edit / merge / supersede / defer.
|
|
271
|
+
|
|
272
|
+
**`/librarian calibrate`** — runs the durability filter and classifier against the last N sessions' archivist artifacts (default: 30 days) without writing proposals, and reports precision/recall against a labeled subset. Outputs a recommended `min_classifier_confidence` threshold for the project.
|
|
273
|
+
|
|
274
|
+
**`/librarian scan`** — manual trigger for the scan pipeline outside of `SessionEnd`. Useful when archivist artifacts have accumulated but no session has ended since librarian was enabled.
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Open Questions
|
|
279
|
+
|
|
280
|
+
1. **Provenance loop with curator.** If curator prunes a librarian-promoted memory and librarian later re-detects the same content, librarian will re-propose. A tombstone mechanism (recording `body_hash` of rejected/pruned proposals) prevents this but introduces its own staleness concerns — when does a tombstone expire?
|
|
281
|
+
|
|
282
|
+
2. **Cross-clone consistency.** The typed memory store is per-checkout. A promotion made in one clone is invisible to another clone of the same repo. Mirroring promoted bodies (not the full memory store) to `~/.onlooker/librarian/<project-key>/promoted/` would enable cross-clone sync but doubles the write path.
|
|
283
|
+
|
|
284
|
+
3. **Calibration baselines.** The marker-phrase list is a guess. `/librarian calibrate` is the answer in principle, but it requires a labeled set of "did this artifact deserve promotion?" judgments. The skill can bootstrap by treating any artifact whose summary later appeared in a hand-written memory as a positive label, but that's circular when the typed memory store is sparse.
|
|
285
|
+
|
|
286
|
+
4. **Memory body authorship.** The classifier writes the proposed memory body. The user may want to edit it before acceptance. The `/librarian review` skill provides edit, but heavy editing implies the classifier output was bad — which calibration the skill should surface (not just the threshold).
|
|
287
|
+
|
|
288
|
+
5. **Type drift over time.** A `project` memory ("we're rewriting auth for compliance") might become a `feedback` memory once the rewrite is done ("the compliance constraint is why this directory looks weird"). Librarian only assigns type at promotion; curator may need a re-classification capability.
|
|
289
|
+
|
|
290
|
+
6. **Encoding scheme for `CLAUDE_PROJECT_ENCODED`.** Claude Code encodes the project path by replacing `/` with `-` and prepending `-`. This is a Claude Code internal convention; relying on it makes librarian fragile to encoding changes. A more robust resolution would call Claude Code's own project-resolution logic if exposed.
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Non-Goals
|
|
295
|
+
|
|
296
|
+
- Does not write to the typed memory store without user confirmation by default (see [ADR-001](adr/001-propose-dont-auto-write.md)).
|
|
297
|
+
- Does not curate, prune, or audit existing memories — that is curator's job.
|
|
298
|
+
- Does not perform retrieval at runtime — that is historian's job for transcripts and the typed memory store's reinjection for distilled facts.
|
|
299
|
+
- Does not generate readable session artifacts — that is scribe's job.
|
|
300
|
+
- Does not score the *quality* of a promoted memory after the fact — that is curator's domain.
|
|
301
|
+
- Does not synthesize cross-session patterns into briefs — that is counsel's job.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionEnd": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "*",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/librarian-session-end.sh"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"SessionStart": [
|
|
15
|
+
{
|
|
16
|
+
"matcher": "*",
|
|
17
|
+
"hooks": [
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/librarian-session-start.sh"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Librarian SessionEnd scan.
|
|
3
|
+
#
|
|
4
|
+
# Reads archivist artifacts created since the last librarian scan, runs them
|
|
5
|
+
# through the durability filter, classifies survivors via Haiku, and writes
|
|
6
|
+
# proposals to the queue for review at next SessionStart.
|
|
7
|
+
#
|
|
8
|
+
# Hook contract:
|
|
9
|
+
# - Always exits 0. Never blocks session shutdown.
|
|
10
|
+
# - No-ops when librarian.enabled is not true.
|
|
11
|
+
# - No-ops when no project key (no git context) or no archivist artifacts.
|
|
12
|
+
# - Classifier failures degrade gracefully: the affected candidate is
|
|
13
|
+
# dropped, the rest of the scan proceeds.
|
|
14
|
+
|
|
15
|
+
set -uo pipefail
|
|
16
|
+
|
|
17
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
18
|
+
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
|
19
|
+
|
|
20
|
+
# Source the ecosystem substrate so $ONLOOKER_DIR / $ONLOOKER_EVENTS_LOG
|
|
21
|
+
# resolve correctly under the test harness's isolated temp home.
|
|
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
|
+
|
|
30
|
+
if [[ -n "$_ECOSYSTEM_ROOT" && -f "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh" ]]; then
|
|
31
|
+
# shellcheck disable=SC1091
|
|
32
|
+
CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
# shellcheck source=../lib/librarian-config.sh
|
|
36
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-config.sh"
|
|
37
|
+
# shellcheck source=../lib/librarian-project-key.sh
|
|
38
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-project-key.sh"
|
|
39
|
+
# shellcheck source=../lib/librarian-ulid.sh
|
|
40
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-ulid.sh"
|
|
41
|
+
# shellcheck source=../lib/librarian-storage.sh
|
|
42
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-storage.sh"
|
|
43
|
+
# shellcheck source=../lib/librarian-emit.sh
|
|
44
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-emit.sh"
|
|
45
|
+
# shellcheck source=../lib/librarian-archivist-reader.sh
|
|
46
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-archivist-reader.sh"
|
|
47
|
+
# shellcheck source=../lib/librarian-durability.sh
|
|
48
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-durability.sh"
|
|
49
|
+
# shellcheck source=../lib/librarian-classifier.sh
|
|
50
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-classifier.sh"
|
|
51
|
+
|
|
52
|
+
INPUT=$(cat 2>/dev/null || true)
|
|
53
|
+
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || CWD=""
|
|
54
|
+
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""' 2>/dev/null) || SESSION_ID=""
|
|
55
|
+
[[ -z "$CWD" ]] && CWD="$(pwd)"
|
|
56
|
+
[[ -z "$SESSION_ID" ]] && SESSION_ID="unknown"
|
|
57
|
+
|
|
58
|
+
librarian_config_load "$(librarian_project_repo_root "$CWD")"
|
|
59
|
+
librarian_config_enabled || exit 0
|
|
60
|
+
|
|
61
|
+
PROJECT_KEY=$(librarian_project_key "$CWD")
|
|
62
|
+
[[ -z "$PROJECT_KEY" ]] && exit 0
|
|
63
|
+
|
|
64
|
+
# Storage init + manifest refresh.
|
|
65
|
+
librarian_storage_init "$PROJECT_KEY" || exit 0
|
|
66
|
+
REMOTE_URL=$(librarian_project_remote_url "$CWD")
|
|
67
|
+
REPO_ROOT=$(librarian_project_repo_root "$CWD")
|
|
68
|
+
librarian_storage_write_manifest "$PROJECT_KEY" "$REMOTE_URL" "$REPO_ROOT" || true
|
|
69
|
+
|
|
70
|
+
# ----------------------------------------------------------------------------
|
|
71
|
+
# Determine the watermark. Empty means "first scan" — fall back to N days ago.
|
|
72
|
+
# ----------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
WATERMARK=$(librarian_storage_read_last_scan "$PROJECT_KEY")
|
|
75
|
+
|
|
76
|
+
if [[ -z "$WATERMARK" ]]; then
|
|
77
|
+
BOOTSTRAP_DAYS=$(librarian_config_get '.librarian.scan.bootstrap_lookback_days')
|
|
78
|
+
[[ -z "$BOOTSTRAP_DAYS" || "$BOOTSTRAP_DAYS" == "null" ]] && BOOTSTRAP_DAYS=14
|
|
79
|
+
WATERMARK=$(python3 -c "
|
|
80
|
+
import datetime
|
|
81
|
+
delta = datetime.timedelta(days=${BOOTSTRAP_DAYS})
|
|
82
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
83
|
+
print((now - delta).strftime('%Y-%m-%dT%H:%M:%SZ'))
|
|
84
|
+
" 2>/dev/null) || WATERMARK=""
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
# ----------------------------------------------------------------------------
|
|
88
|
+
# Emit scan.started and load candidate window.
|
|
89
|
+
# ----------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
SCAN_START_TS_S=$(date +%s)
|
|
92
|
+
ARTIFACTS=$(librarian_archivist_load_since "$PROJECT_KEY" "$WATERMARK")
|
|
93
|
+
ARTIFACT_COUNT=$(printf '%s' "$ARTIFACTS" | jq 'length' 2>/dev/null) || ARTIFACT_COUNT=0
|
|
94
|
+
|
|
95
|
+
librarian_emit "librarian.scan.started" "$SESSION_ID" "$(jq -cn \
|
|
96
|
+
--arg trigger "session_end" \
|
|
97
|
+
--arg last_scan_at "$WATERMARK" \
|
|
98
|
+
--argjson artifact_count_in_window "$ARTIFACT_COUNT" \
|
|
99
|
+
'{ trigger: $trigger, last_scan_at: (if $last_scan_at == "" then null else $last_scan_at end),
|
|
100
|
+
artifact_count_in_window: $artifact_count_in_window } | with_entries(select(.value != null))')"
|
|
101
|
+
|
|
102
|
+
# Bail with scan.complete{outcome: ok, candidates: 0} when archivist has
|
|
103
|
+
# nothing new for us. We still advance the watermark so subsequent scans
|
|
104
|
+
# don't re-walk the same window.
|
|
105
|
+
if [[ "$ARTIFACT_COUNT" == "0" ]]; then
|
|
106
|
+
librarian_storage_write_last_scan "$PROJECT_KEY" || true
|
|
107
|
+
DURATION_MS=$(( ($(date +%s) - SCAN_START_TS_S) * 1000 ))
|
|
108
|
+
librarian_emit "librarian.scan.complete" "$SESSION_ID" "$(jq -cn \
|
|
109
|
+
--arg outcome "empty" \
|
|
110
|
+
--argjson duration_ms "$DURATION_MS" \
|
|
111
|
+
--argjson candidates_proposed 0 \
|
|
112
|
+
--argjson candidates_dropped 0 \
|
|
113
|
+
--argjson artifact_count_in_window 0 \
|
|
114
|
+
'{ outcome: $outcome, duration_ms: $duration_ms,
|
|
115
|
+
candidates_proposed: $candidates_proposed,
|
|
116
|
+
candidates_dropped: $candidates_dropped,
|
|
117
|
+
artifact_count_in_window: $artifact_count_in_window }')"
|
|
118
|
+
exit 0
|
|
119
|
+
fi
|
|
120
|
+
|
|
121
|
+
# ----------------------------------------------------------------------------
|
|
122
|
+
# Durability filter — cheap, deterministic, no network.
|
|
123
|
+
# ----------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
MARKERS_JSON=$(librarian_config_get '.librarian.durability_filter.marker_phrases | tojson')
|
|
126
|
+
[[ -z "$MARKERS_JSON" || "$MARKERS_JSON" == "null" ]] && MARKERS_JSON='[]'
|
|
127
|
+
MIN_DETAIL=$(librarian_config_get '.librarian.scan.min_detail_chars')
|
|
128
|
+
[[ -z "$MIN_DETAIL" || "$MIN_DETAIL" == "null" ]] && MIN_DETAIL=40
|
|
129
|
+
|
|
130
|
+
FILTERED=$(librarian_durability_filter "$ARTIFACTS" "$MARKERS_JSON" "$MIN_DETAIL")
|
|
131
|
+
KEPT=$(printf '%s' "$FILTERED" | jq '.kept')
|
|
132
|
+
DROPPED=$(printf '%s' "$FILTERED" | jq '.dropped')
|
|
133
|
+
|
|
134
|
+
# Emit one librarian.candidate.dropped event per artifact we filtered out
|
|
135
|
+
# pre-classifier. Caps at a sane number per scan so the event log stays
|
|
136
|
+
# scannable even if archivist piled up months of artifacts.
|
|
137
|
+
MAX_DROPPED_EVENTS=20
|
|
138
|
+
DROPPED_TOTAL=$(printf '%s' "$DROPPED" | jq 'length' 2>/dev/null) || DROPPED_TOTAL=0
|
|
139
|
+
DROPPED_EMIT_COUNT=$(( DROPPED_TOTAL < MAX_DROPPED_EVENTS ? DROPPED_TOTAL : MAX_DROPPED_EVENTS ))
|
|
140
|
+
for ((i = 0; i < DROPPED_EMIT_COUNT; i++)); do
|
|
141
|
+
DROP=$(printf '%s' "$DROPPED" | jq -c ".[$i]")
|
|
142
|
+
librarian_emit "librarian.candidate.dropped" "$SESSION_ID" "$(jq -cn \
|
|
143
|
+
--argjson drop "$DROP" \
|
|
144
|
+
'{ reason: $drop.reason, source_artifact_id: $drop.artifact_id }
|
|
145
|
+
| with_entries(select(.value != null))')"
|
|
146
|
+
done
|
|
147
|
+
|
|
148
|
+
# ----------------------------------------------------------------------------
|
|
149
|
+
# Classifier loop — one Haiku call per surviving candidate.
|
|
150
|
+
# ----------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
CLASSIFIER_MODEL=$(librarian_config_get '.librarian.classifier.model')
|
|
153
|
+
CLASSIFIER_TEMP=$(librarian_config_get '.librarian.classifier.temperature')
|
|
154
|
+
CLASSIFIER_MAX=$(librarian_config_get '.librarian.classifier.max_output_tokens')
|
|
155
|
+
MIN_CONFIDENCE=$(librarian_config_get '.librarian.classifier.min_classifier_confidence')
|
|
156
|
+
[[ -z "$MIN_CONFIDENCE" || "$MIN_CONFIDENCE" == "null" ]] && MIN_CONFIDENCE="0.6"
|
|
157
|
+
TOMBSTONE_TTL=$(librarian_config_get '.librarian.tombstones.ttl_days')
|
|
158
|
+
[[ -z "$TOMBSTONE_TTL" || "$TOMBSTONE_TTL" == "null" ]] && TOMBSTONE_TTL=180
|
|
159
|
+
AUTO_PROMOTE_THRESHOLD=$(librarian_config_get '.librarian.auto_promote_threshold')
|
|
160
|
+
[[ -z "$AUTO_PROMOTE_THRESHOLD" || "$AUTO_PROMOTE_THRESHOLD" == "null" ]] && AUTO_PROMOTE_THRESHOLD="0.85"
|
|
161
|
+
|
|
162
|
+
KEPT_COUNT=$(printf '%s' "$KEPT" | jq 'length' 2>/dev/null) || KEPT_COUNT=0
|
|
163
|
+
PROPOSED_COUNT=0
|
|
164
|
+
POST_CLASSIFIER_DROPPED=0
|
|
165
|
+
NOW_TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
166
|
+
|
|
167
|
+
for ((i = 0; i < KEPT_COUNT; i++)); do
|
|
168
|
+
ARTIFACT=$(printf '%s' "$KEPT" | jq -c ".[$i]")
|
|
169
|
+
[[ -z "$ARTIFACT" || "$ARTIFACT" == "null" ]] && continue
|
|
170
|
+
|
|
171
|
+
RESPONSE=$(librarian_classifier_call \
|
|
172
|
+
"$ARTIFACT" "$CLASSIFIER_MODEL" "$CLASSIFIER_TEMP" "$CLASSIFIER_MAX")
|
|
173
|
+
|
|
174
|
+
if [[ -z "$RESPONSE" ]]; then
|
|
175
|
+
POST_CLASSIFIER_DROPPED=$((POST_CLASSIFIER_DROPPED + 1))
|
|
176
|
+
librarian_emit "librarian.candidate.dropped" "$SESSION_ID" "$(jq -cn \
|
|
177
|
+
--arg reason "classified_null" \
|
|
178
|
+
--arg src "$(printf '%s' "$ARTIFACT" | jq -r '.id // ""')" \
|
|
179
|
+
'{ reason: $reason, source_artifact_id: (if $src == "" then null else $src end) }
|
|
180
|
+
| with_entries(select(.value != null))')"
|
|
181
|
+
continue
|
|
182
|
+
fi
|
|
183
|
+
|
|
184
|
+
# Drop nulls and low-confidence classifications silently — by design,
|
|
185
|
+
# the proposal queue prefers misses over noise.
|
|
186
|
+
MEMORY_TYPE=$(printf '%s' "$RESPONSE" | jq -r '.type // ""')
|
|
187
|
+
CONFIDENCE=$(printf '%s' "$RESPONSE" | jq -r '.confidence // 0')
|
|
188
|
+
BODY=$(printf '%s' "$RESPONSE" | jq -r '.body // ""')
|
|
189
|
+
TITLE=$(printf '%s' "$RESPONSE" | jq -r '.title // ""')
|
|
190
|
+
|
|
191
|
+
BELOW_MIN=$(awk -v a="$CONFIDENCE" -v b="$MIN_CONFIDENCE" 'BEGIN { print (a < b) ? 1 : 0 }')
|
|
192
|
+
|
|
193
|
+
if [[ -z "$MEMORY_TYPE" || "$MEMORY_TYPE" == "null" ]]; then
|
|
194
|
+
POST_CLASSIFIER_DROPPED=$((POST_CLASSIFIER_DROPPED + 1))
|
|
195
|
+
librarian_emit "librarian.candidate.dropped" "$SESSION_ID" "$(jq -cn \
|
|
196
|
+
--arg reason "classified_null" \
|
|
197
|
+
--arg src "$(printf '%s' "$ARTIFACT" | jq -r '.id // ""')" \
|
|
198
|
+
'{ reason: $reason, source_artifact_id: (if $src == "" then null else $src end) }
|
|
199
|
+
| with_entries(select(.value != null))')"
|
|
200
|
+
continue
|
|
201
|
+
fi
|
|
202
|
+
|
|
203
|
+
if [[ "$BELOW_MIN" == "1" ]]; then
|
|
204
|
+
POST_CLASSIFIER_DROPPED=$((POST_CLASSIFIER_DROPPED + 1))
|
|
205
|
+
librarian_emit "librarian.candidate.dropped" "$SESSION_ID" "$(jq -cn \
|
|
206
|
+
--arg reason "low_confidence" \
|
|
207
|
+
--arg src "$(printf '%s' "$ARTIFACT" | jq -r '.id // ""')" \
|
|
208
|
+
'{ reason: $reason, source_artifact_id: (if $src == "" then null else $src end) }
|
|
209
|
+
| with_entries(select(.value != null))')"
|
|
210
|
+
continue
|
|
211
|
+
fi
|
|
212
|
+
|
|
213
|
+
# Skip if a tombstone exists for this exact body — the user already
|
|
214
|
+
# rejected this content, don't re-surface it.
|
|
215
|
+
BODY_HASH=$(librarian_body_hash "$BODY")
|
|
216
|
+
if [[ -n "$BODY_HASH" ]] && librarian_storage_has_tombstone \
|
|
217
|
+
"$PROJECT_KEY" "$BODY_HASH" "$TOMBSTONE_TTL"; then
|
|
218
|
+
POST_CLASSIFIER_DROPPED=$((POST_CLASSIFIER_DROPPED + 1))
|
|
219
|
+
librarian_emit "librarian.candidate.dropped" "$SESSION_ID" "$(jq -cn \
|
|
220
|
+
--arg reason "duplicate" \
|
|
221
|
+
--arg src "$(printf '%s' "$ARTIFACT" | jq -r '.id // ""')" \
|
|
222
|
+
'{ reason: $reason, source_artifact_id: (if $src == "" then null else $src end) }
|
|
223
|
+
| with_entries(select(.value != null))')"
|
|
224
|
+
continue
|
|
225
|
+
fi
|
|
226
|
+
|
|
227
|
+
# Build and persist the proposal. Conflict detection against the user's
|
|
228
|
+
# memory store is deferred to a follow-up commit; everything ships as
|
|
229
|
+
# conflict_state: "none" for now.
|
|
230
|
+
PROPOSAL_ID=$(librarian_ulid)
|
|
231
|
+
FILENAME=$(librarian_classifier_filename "$MEMORY_TYPE" "$TITLE")
|
|
232
|
+
ARTIFACT_ID=$(printf '%s' "$ARTIFACT" | jq -r '.id // ""')
|
|
233
|
+
ARTIFACT_SESSION=$(printf '%s' "$ARTIFACT" | jq -r '.session_id // ""')
|
|
234
|
+
|
|
235
|
+
PROPOSAL_JSON=$(jq -n \
|
|
236
|
+
--arg id "$PROPOSAL_ID" \
|
|
237
|
+
--arg created_at "$NOW_TS" \
|
|
238
|
+
--arg memory_type "$MEMORY_TYPE" \
|
|
239
|
+
--arg filename "$FILENAME" \
|
|
240
|
+
--arg title "$TITLE" \
|
|
241
|
+
--arg body "$BODY" \
|
|
242
|
+
--argjson classifier_confidence "$CONFIDENCE" \
|
|
243
|
+
--arg conflict_state "none" \
|
|
244
|
+
--arg artifact_id "$ARTIFACT_ID" \
|
|
245
|
+
--arg artifact_session "$ARTIFACT_SESSION" \
|
|
246
|
+
'{
|
|
247
|
+
id: $id,
|
|
248
|
+
created_at: $created_at,
|
|
249
|
+
source_artifact_ids: (if $artifact_id == "" then [] else [$artifact_id] end),
|
|
250
|
+
source_session_ids: (if $artifact_session == "" then [] else [$artifact_session] end),
|
|
251
|
+
proposed: {
|
|
252
|
+
type: $memory_type,
|
|
253
|
+
filename: $filename,
|
|
254
|
+
title: $title,
|
|
255
|
+
body: $body,
|
|
256
|
+
classifier_confidence: $classifier_confidence
|
|
257
|
+
},
|
|
258
|
+
conflict_state: $conflict_state,
|
|
259
|
+
conflict_with: [],
|
|
260
|
+
status: "pending"
|
|
261
|
+
}')
|
|
262
|
+
|
|
263
|
+
librarian_storage_write_proposal "$PROJECT_KEY" "$PROPOSAL_ID" "$PROPOSAL_JSON" >/dev/null \
|
|
264
|
+
|| continue
|
|
265
|
+
|
|
266
|
+
PROPOSED_COUNT=$((PROPOSED_COUNT + 1))
|
|
267
|
+
|
|
268
|
+
librarian_emit "librarian.candidate.proposed" "$SESSION_ID" "$(jq -cn \
|
|
269
|
+
--arg proposal_id "$PROPOSAL_ID" \
|
|
270
|
+
--arg memory_type "$MEMORY_TYPE" \
|
|
271
|
+
--argjson classifier_confidence "$CONFIDENCE" \
|
|
272
|
+
--arg conflict_state "none" \
|
|
273
|
+
--arg src "$ARTIFACT_ID" \
|
|
274
|
+
'{
|
|
275
|
+
proposal_id: $proposal_id,
|
|
276
|
+
memory_type: $memory_type,
|
|
277
|
+
classifier_confidence: $classifier_confidence,
|
|
278
|
+
conflict_state: $conflict_state,
|
|
279
|
+
source_artifact_ids: (if $src == "" then [] else [$src] end)
|
|
280
|
+
}')"
|
|
281
|
+
done
|
|
282
|
+
|
|
283
|
+
# ----------------------------------------------------------------------------
|
|
284
|
+
# Watermark advance + scan.complete.
|
|
285
|
+
# ----------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
librarian_storage_write_last_scan "$PROJECT_KEY" || true
|
|
288
|
+
|
|
289
|
+
TOTAL_DROPPED=$((DROPPED_TOTAL + POST_CLASSIFIER_DROPPED))
|
|
290
|
+
OUTCOME="ok"
|
|
291
|
+
[[ "$PROPOSED_COUNT" == "0" ]] && OUTCOME="empty"
|
|
292
|
+
DURATION_MS=$(( ($(date +%s) - SCAN_START_TS_S) * 1000 ))
|
|
293
|
+
|
|
294
|
+
librarian_emit "librarian.scan.complete" "$SESSION_ID" "$(jq -cn \
|
|
295
|
+
--arg outcome "$OUTCOME" \
|
|
296
|
+
--argjson candidates_proposed "$PROPOSED_COUNT" \
|
|
297
|
+
--argjson candidates_dropped "$TOTAL_DROPPED" \
|
|
298
|
+
--argjson duration_ms "$DURATION_MS" \
|
|
299
|
+
--argjson artifact_count_in_window "$ARTIFACT_COUNT" \
|
|
300
|
+
'{
|
|
301
|
+
outcome: $outcome,
|
|
302
|
+
candidates_proposed: $candidates_proposed,
|
|
303
|
+
candidates_dropped: $candidates_dropped,
|
|
304
|
+
duration_ms: $duration_ms,
|
|
305
|
+
artifact_count_in_window: $artifact_count_in_window
|
|
306
|
+
}')"
|
|
307
|
+
|
|
308
|
+
# Suppress AUTO_PROMOTE_THRESHOLD shellcheck warning — read for future use
|
|
309
|
+
# (auto-promote path lands in the next commit).
|
|
310
|
+
: "${AUTO_PROMOTE_THRESHOLD}"
|
|
311
|
+
|
|
312
|
+
exit 0
|