feat(outreach): interest-triggered outreach — salience event, quality gate, feedback loop (PN-94, Phase 1) - #97
Merged
Conversation
Every event in the bus was about the health of the machinery — a document
at its limit, a frozen pipeline, a failed provider. None was about the
thinking, so every unprompted message the entity could generate was
structurally a status report. This adds the content event and the controls
that stand between raising one and D's phone buzzing.
EntityEvent::Salience { kind, thread_id, headline, evidence, confidence }
plus SalienceKind (Finding/Development/Blocking/Callback). The listener
turns an admitted candidate into a Share-routed intent; Blocking gets
Urgent priority. Call routing stays manual (allow_call_routing = false).
The quality gate (spec §2.3) is three mechanical checks, none of which is
"the entity judges it worthwhile" — that is the judgement under audit:
2. External referent. The evidence must cite a file:line, a prediction id
that actually resolved, a URL, or a number on the same line as the
command that produced it. A bare digit is rejected: "chewing on this
for 3 days" would clear a digits-anywhere check and clear it forever.
Stricter for Development, never softer — a file reference into the
entity's own journal is self-authored prose wearing a file path, so it
does not count for that kind (spec §6.1).
3. Stated cost. The message must say whether it wants nothing, a read, or
a decision.
1. Novelty. Cosine similarity of the headline against prior outreach plus
journal headings, over recall-echo's fastembed path (the same model the
graph indexes episodes with). Run last because it is the only expensive
check; the verdict is unchanged, the reported reason more actionable.
Fail-closed throughout: an unavailable embedder, an unreadable store or a
missing cost line all reject. Under-firing is recoverable; over-firing is
not, because a channel D starts skimming past does not come back (§6.4).
Feedback (spec §2.4) is the part that matters. Rolling response rate per
kind over the last 20 messages; below the 0.3 floor the daily cap halves
and D is told. Two properties are deliberate: neglect tightens rather than
loosens (an unread control is not a control), and the tightening is
announced — the notice is emitted on rejections too, because a cap halved
to zero admits nothing and a notice riding on admission would never fire.
Development halving 1 → 0 silences the kind outright; that is the spec
taken literally, survivable only because it is announced.
outreach.json carries the sent log, responses and rejections under the same
flock + atomic-rename + quarantine-on-corrupt discipline as predictions.json
(PN-86). The admission decision and the write that records it happen inside
one locked read-modify-write, so two candidates racing on a cap of one
cannot both observe an empty budget. Sends are counted at admission, which
over-counts rather than under-counts — the recoverable direction.
Quiet hours 23:00–08:00 in D's local time, wrap-aware; Blocking overrides
them and nothing else does. Salience is exempt from the listener's generic
60-minute cooldown and circuit breaker: those would silently throttle the
kind the spec requires to be uncapped, and retire the channel after three
messages. The caps are the control.
`pulse-null outreach status` publishes caps, rates and rejections — a gate
whose rejections nobody can read cannot be audited, and a gate that never
rejects is not a gate. `outreach respond` records D's reaction, without
which the only scoring signal in the system has no way in and every cap
tightens to half and stays there.
Refs PN-94
Wires the outreach channel to something that can actually raise a candidate. The marker sits alongside [INTENT:] / [SCHEDULE:] / [CHAIN:] and is routed from both execution paths — a scheduled task and an intent mean the same thing by it. Extraction uses the brace-balanced scan rather than a lazy regex, the same way [FARM:] does. The evidence field carries free-form text that routinely contains brackets — file paths, citations, quoted markers — and a lazy regex stopping at the first `}]` would truncate it, silently stripping the external referent gate 2 exists to check for. `kind` is required and never defaulted: a typo must not inherit Blocking's uncapped, quiet-hours-overriding budget or dodge Development's stricter gate. An optional `cost` field is normalized onto the evidence as a `Cost:` line, so gate 3 stays a pure function of the event whatever raised it — the marker today, the tension store in Phase 2. An inline cost line already in the evidence wins, since that is the one written in context. Emitting the marker sends nothing. The event goes on the bus and the listener runs the gate, the quiet window and the daily cap; a malformed marker is warned about and dropped. This is knowingly the weak trigger. The entity decides when it is interesting, which is unaudited — that is the whole of §3 Phase 1. It ships first because it makes the behaviour observable, and observable behaviour is what makes the Phase 2 tension-store trigger falsifiable: same gate, same caps, compare response rates. The marker is documented to the entity only when [outreach] enabled is true, so it is never told about a marker whose output would be discarded. Refs PN-94
…atus `outreach status` rendered an unfilled response window as "0/1 messages", because the denominator was the fill count guarded with `.max(1)` rather than the configured `feedback_window`. That reads as a window one message from acting, when in fact twenty are needed before the rate moves a cap — exactly backwards for the number D would use to judge whether the tightening machinery is close to firing. KindStatus now carries `window_filled` and `window_target` separately; the ambiguous `window_size` is gone. Refs PN-94
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #94.
Spec:
/opt/pulse-vault/pulse-null/specs/in-progress/interest-triggered-outreach-spec.md(v1.0, D + Echo).Merge order: before PN-95 (#96) per spec §9 — this is the outlet; the tension store is its Phase 2 trigger.
What's in (Phase 1)
EntityEvent::Salience+SalienceKind(Finding / Development / Blocking / Callback);[SALIENCE:]marker parsed alongside[INTENT:]/[SCHEDULE:]; listener converts to a Share-routed intent.src/outreach/), cheapest-first (referent → cost → novelty), pass requires all three:recall_echoLazyEmbedder, BGE-Small, shared model cache); embedder error = rejection, never a pass.Development: journal-internal file paths don't count — self-authored prose wearing a path is still self-authored.evidenceas aCost:line so the event stays five fields.{entity}/outreach.json(flock-guarded fail-closed), rolling response rate per kind over 20, cap auto-halves below 0.3 with an announced notice — recorded as announced only after the webhook confirms delivery; fires on rejections too, since a 1→0 halving admits nothing.Blockinguncapped and quiet-hours-exempt, also exempt from the listener's generic cooldown/circuit-breaker (which would silently throttle it).allow_call_routing = false, no automatic Call.pulse-null outreach status+pulse-null outreach respond <id> [--rating useful|noise](the response signal needed an inbound path; Discord reply detection remains unwired).Verification
fmt clean;
clippy --all-targets --features discord-text -- -D warningszero warnings;cargo test --features discord-text910 passed / 0 failed (~120 new); release build OK;outreach statussmoke-tested on a scratch entity. Pre-existing unrelated flake inclaude_code_provider::tests(ExecutableFileBusy, ~1 in 3) reproduced on unmodified main.Known seams (deliberate, documented)
cap_development1 → 0 (silences the kind until responses restore the window) — no.max(1)floor was added; the notice says how to recover.IntentOutput::Shareis set per spec butoutput_routinghas no consumer anywhere — delivery actually rides[SHARE:]; the intent instructs the prompt to emit it. Cleanup issue worth filing.[autonomy] enabled = true— the listener only spawns with autonomy on (off in prod since 2026-08-16, with 16 stranded intents that will replay when flipped).🤖 Generated with Claude Code
https://claude.ai/code/session_01EDtGQtXxs4fVK4g484Y1Fj