Skip to main content

ski/
hook.rs

1//! `ski hook` — the hot path. Reads a hook event on stdin, decides which skills
2//! to inject, writes the host's injection contract on stdout.
3//!
4//! **Fail open is the contract.** Any error — bad stdin, missing index, IO
5//! failure — results in an empty injection and exit 0, never a blocked prompt.
6//!
7//! Output by host:
8//! - Claude (`UserPromptSubmit`): `{hookSpecificOutput:{hookEventName,
9//!   additionalContext}}`, or nothing when there's no injection.
10//! - opencode: `{skills:[...], inject:"..."}` always (the TS adapter parses it).
11
12use crate::confidence::{self, Stage};
13use crate::config::{Config, InjectMode, Strength};
14use crate::embed::{self, EmbedKind};
15use crate::index::{self, Index};
16use crate::inject::Rec;
17use crate::rank::Hit;
18use crate::session::Session;
19use crate::{context, inject, paths, pipeline, rank, skill, telemetry};
20use serde::Deserialize;
21use std::io::Read;
22use std::str::FromStr;
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum Host {
26    Claude,
27    Opencode,
28}
29
30impl FromStr for Host {
31    type Err = anyhow::Error;
32    fn from_str(s: &str) -> Result<Self, Self::Err> {
33        match s.to_ascii_lowercase().as_str() {
34            "claude" => Ok(Host::Claude),
35            "opencode" => Ok(Host::Opencode),
36            other => anyhow::bail!("unknown host '{other}' (expected 'claude' or 'opencode')"),
37        }
38    }
39}
40
41/// The normalized hook event. Claude's `UserPromptSubmit` payload already uses
42/// these field names; the opencode adapter sends the same shape.
43#[derive(Debug, Default, Deserialize)]
44struct RawEvent {
45    #[serde(default)]
46    prompt: String,
47    #[serde(default)]
48    session_id: String,
49    #[serde(default)]
50    cwd: String,
51}
52
53#[derive(Debug, Default)]
54struct Decision {
55    inject: String,
56    skills: Vec<String>,
57}
58
59/// Run the hook for `host`. Always exits 0 (fail open); set `SKI_DEBUG=1` to
60/// see the swallowed error on stderr when injection silently stops.
61pub fn run(host: Host) -> anyhow::Result<()> {
62    let decision = decide(host).unwrap_or_else(|e| {
63        crate::trace::debug("hook decide failed, injecting nothing", &e);
64        Decision::default()
65    });
66    let out = match host {
67        Host::Claude => render_claude(&decision),
68        Host::Opencode => render_opencode(&decision),
69    };
70    if !out.is_empty() {
71        println!("{out}");
72    }
73    Ok(())
74}
75
76fn decide(host: Host) -> anyhow::Result<Decision> {
77    let mut buf = String::new();
78    std::io::stdin().read_to_string(&mut buf)?;
79    let event: RawEvent = serde_json::from_str(&buf).unwrap_or_default();
80    if event.prompt.trim().is_empty() {
81        return Ok(Decision::default());
82    }
83    // Host-generated control payloads (task notifications, injected reminders) are
84    // not user requests; embedding them as a query only surfaces noise matches the
85    // model never acts on. Skip them outright. See `is_control_prompt`.
86    if is_control_prompt(&event.prompt) {
87        return Ok(Decision::default());
88    }
89    // A leading `/<name>` is an explicit skill invocation by the user; we must not
90    // re-recommend the very skill they just ran (it always reads as an unused
91    // false positive). Captured here, filtered out of `selected` below.
92    let invoked_skill = slash_command_id(&event.prompt);
93
94    let (mut cfg, file) = Config::load(host);
95    telemetry::init(cfg.telemetry); // config.toml can enable telemetry (or the env var).
96    let embedder = embed::build(&cfg.model)?;
97    cfg.calibrate_to(embedder.as_ref());
98    file.apply_cosine(&mut cfg); // user pin wins over embedder calibration.
99    let idx = load_or_build_index(&cfg, embedder.as_ref(), host)?;
100    if idx.skills.is_empty() {
101        return Ok(Decision::default());
102    }
103
104    let session_path = paths::session_path(&event.session_id);
105    let mut session = Session::load(&session_path);
106
107    let query = embedder
108        .embed(std::slice::from_ref(&event.prompt), EmbedKind::Query)?
109        .remove(0);
110    // Conversational context (Goal 3): the prior-turn window disambiguates a vague
111    // prompt. Built from the *previous* turns before the current prompt is pushed
112    // below; inert (no vector, no enrichment) unless the feature is enabled.
113    let cvec = context::vector(embedder.as_ref(), &session.recent_prompts, &cfg).unwrap_or(None);
114    // File-type channel: a file named in the prompt (or a recent turn that attached
115    // one) boosts its skill — the directly-attributable context signal. Empty/no-IO
116    // when the channel is off.
117    let file_ids = if cfg.file_boost > 0.0 {
118        let file_text = format!("{} {}", session.recent_prompts.join(" "), event.prompt);
119        context::file_ids(&file_text)
120    } else {
121        std::collections::BTreeSet::new()
122    };
123    // Project-type channel: ecosystem terms from the working directory's manifests
124    // (`uv.lock`, `Cargo.toml`, ...) plus any code file named in the conversation
125    // (covers a session editing files outside its cwd), resolved dynamically
126    // against the installed library — an ambient signal, so gated downstream on
127    // the skill's own cosine. The id→term map is kept for the injection's
128    // evidence line. Empty/no-IO when the channel is off.
129    let project_hits: std::collections::BTreeMap<String, String> = if cfg.project_boost > 0.0 {
130        let mut terms = context::project_terms(&event.cwd);
131        let code_text = format!("{} {}", session.recent_prompts.join(" "), event.prompt);
132        terms.extend(context::code_terms(&code_text));
133        context::skills_for_terms(&terms, &idx)
134    } else {
135        std::collections::BTreeMap::new()
136    };
137    let project_ids: std::collections::BTreeSet<String> = project_hits.keys().cloned().collect();
138    let hits = rank::rank_all_ctx(
139        &query,
140        cvec.as_deref(),
141        &file_ids,
142        &project_ids,
143        &event.prompt,
144        &idx,
145        &cfg,
146    );
147    let prompt_top = hits.iter().map(|h| h.cosine).fold(0.0_f32, f32::max);
148    let rerank_query = context::rerank_query(
149        &event.prompt,
150        prompt_top,
151        &session.recent_prompts,
152        !file_ids.is_empty(),
153        &cfg,
154    );
155    // Append this turn to the rolling window now that context has been built from
156    // the prior turns. Persisted immediately (best-effort) so a later vague turn
157    // sees it even if this turn injects nothing. No-op/no-IO when the feature is off.
158    if cfg.context_depth > 0 {
159        session.push_prompt(&event.prompt, cfg.context_depth);
160        let _ = session.save_merged(&session_path);
161    }
162
163    // With telemetry on, remember the active prompt now (before any early return)
164    // so a self-load later in this conversation — including after a prompt that
165    // injected nothing — can be tied back to it as a recall miss. One extra write
166    // per prompt, paid only by telemetry users.
167    if telemetry::enabled() {
168        session.last_prompt = event.prompt.clone();
169        let _ = session.save_merged(&session_path);
170    }
171    // Stage 1.5 + 2 cascade — single-sourced in `pipeline`, shared with `ski why`
172    // and `examples/eval`: a dominant lexical (BM25) winner injects directly unless
173    // stage-1 has a confident lone dense winner; else the cross-encoder arbitrates
174    // the ambiguous middle; else the cheap stage-1 cosine result stands. The winning
175    // stage sets which confidence mapping the recs carry.
176    let plan = pipeline::decide(&hits, &idx, &event.prompt, &rerank_query, &cfg);
177    let stage = plan.stage;
178    // `considered` snapshots the top of the winning stage's ranking *before* the gate
179    // (id + raw stage score: BM25 for lexical, cosine-blend for stage 1, reranker
180    // logit for stage 2). Logged on every prompt — including abstentions — so a later
181    // native pick can be measured against where ski actually ranked it.
182    let considered = match &plan.lexical {
183        Some(win) => vec![(win.id.clone(), win.score)],
184        None => top_considered(&plan.rows),
185    };
186    // Drop the skill the user invoked by slash command *before* the cap —
187    // recommending it back is pure noise (the dominant false positive in
188    // telemetry: `/pickup` -> pickup), and if it were removed after `finalize`
189    // it would first consume one of the `max_skills` slots, pushing out a
190    // legitimate co-relevant skill.
191    let passed = without_invoked(&plan.passed, invoked_skill.as_deref());
192    let selected = finalize(&passed, stage, &cfg, &session, &project_hits);
193    if selected.is_empty() {
194        // Nothing cleared the gate (or dedup/deny/slash removal emptied it). Record
195        // the considered ranking anyway so a native pick on this prompt can be
196        // scored against where ski ranked it — the abstention case is the whole
197        // point of always logging.
198        telemetry::record_recommend(
199            &event.session_id,
200            &event.prompt,
201            stage,
202            &considered,
203            &[],
204            &[],
205            Some("below_gate"),
206        );
207        return Ok(Decision::default());
208    }
209
210    let strength = resolve_strength(cfg.directive_strength, host);
211    // Escalate a lone, near-certain match from a directive pointer to a full body
212    // inject: inline the SKILL.md so the model can't skip the Skill-tool round-trip.
213    // Two co-relevant peers mean we are less certain, so they stay directives.
214    let mode = inject_mode(&selected, &cfg);
215    let (text, ids) = inject::build(&selected, &idx, mode, strength, cfg.char_budget);
216    if text.is_empty() {
217        telemetry::record_recommend(
218            &event.session_id,
219            &event.prompt,
220            stage,
221            &considered,
222            &selected,
223            &[],
224            Some("empty_text"),
225        );
226        return Ok(Decision::default());
227    }
228
229    // Record each injected id at the confidence we displayed, so next turn's
230    // score-aware dedup is accurate.
231    let injected: Vec<(String, f32)> = ids
232        .iter()
233        .map(|id| (id.clone(), confidence_of(&selected, id)))
234        .collect();
235    for (id, conf) in &injected {
236        session.mark_recommended(id, *conf);
237    }
238    let _ = session.save_merged(&session_path); // best-effort: state IO never blocks.
239
240    // Successful inject: `abstained` is None and `considered` carries the pre-gate
241    // ranking so the injected ids can be located within it during analysis.
242    telemetry::record_recommend(
243        &event.session_id,
244        &event.prompt,
245        stage,
246        &considered,
247        &selected,
248        &injected,
249        None,
250    );
251
252    Ok(Decision {
253        inject: text,
254        skills: ids,
255    })
256}
257
258/// How many top-ranked skills to snapshot into a `recommend` event's `considered`
259/// list. Deep enough to locate a native pick that ski ranked but abstained on;
260/// anything past this reads as "ski never surfaced it" in the comparison.
261const CONSIDER_K: usize = 10;
262
263/// Top-`CONSIDER_K` of a ranking as `(id, raw stage score)` for telemetry — the
264/// chooser's pre-gate view. Hits arrive already sorted by descending score.
265fn top_considered(hits: &[Hit]) -> Vec<(String, f32)> {
266    hits.iter()
267        .take(CONSIDER_K)
268        .map(|h| (h.id.clone(), h.score))
269        .collect()
270}
271
272/// Host-generated control payloads that arrive on the prompt channel but aren't
273/// user requests — task-completion notifications and injected reminder blocks.
274/// They are detected by a leading control tag so a genuine prompt that merely
275/// quotes one in prose still injects normally. Embedding these as a query only
276/// produces noise matches (telemetry: a `<task-notification>` blob surfaced
277/// `skill-development`/`claude-automation-recommender`, both unused).
278fn is_control_prompt(prompt: &str) -> bool {
279    let p = prompt.trim_start();
280    p.starts_with("<task-notification") || p.starts_with("<system-reminder")
281}
282
283/// The skill id a leading `/command` invokes, if the prompt is one. `/pickup` or
284/// `/pickup keep going` -> `Some("pickup")`; plain prose or a bare `/` -> `None`.
285/// A slash command is one leading token of command-name characters; anything else
286/// (a path like `/etc/hosts`, a fraction) bails so only real invocations match.
287fn slash_command_id(prompt: &str) -> Option<String> {
288    let rest = prompt.trim_start().strip_prefix('/')?;
289    let name = rest.split_whitespace().next()?;
290    let ok = !name.is_empty()
291        && name
292            .chars()
293            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':'));
294    // A namespaced command (`/plugin:skill`) maps to its trailing skill segment.
295    ok.then(|| name.rsplit(':').next().unwrap_or(name).to_string())
296}
297
298/// The confidence of `id` within `recs` (the value we displayed for it).
299fn confidence_of(recs: &[Rec], id: &str) -> f32 {
300    recs.iter()
301        .find(|r| r.id == id)
302        .map(|r| r.confidence)
303        .unwrap_or(0.0)
304}
305
306/// Load the persisted index; build it on first run so the hook works before an
307/// explicit `ski index`. Rebuilds when the stored index was made by a different
308/// embedder — its vectors live in another space (and often another dimension), so
309/// cosine against a query from the current embedder would be meaningless. This
310/// makes switching embedders (e.g. bag-of-words -> bge) self-healing in the hot
311/// path rather than only on the next `SessionStart`.
312fn load_or_build_index(
313    cfg: &Config,
314    embedder: &dyn embed::Embedder,
315    host: Host,
316) -> anyhow::Result<Index> {
317    let path = paths::index_path(host);
318    // Swallow a load error (corrupt/truncated index) and fall through to a
319    // rebuild rather than propagating — a bad index file must not brick the hook
320    // on every prompt. Mirrors the self-healing read in `session_start::reindex`.
321    match Index::load(&path) {
322        Ok(Some(idx)) if idx.model == embedder.id() => return Ok(idx),
323        Ok(_) => {}
324        Err(e) => crate::trace::debug(
325            &format!("index {} unreadable; rebuilding", path.display()),
326            &e,
327        ),
328    }
329    let skills = skill::discover(&cfg.roots)?;
330    let idx = index::build(&skills, embedder, None)?;
331    let _ = idx.save(&path);
332    Ok(idx)
333}
334
335/// Drop the slash-invoked skill from the gate survivors *before* [`finalize`]'s
336/// `max_skills` cap, so it cannot consume a slot on its way to being removed.
337fn without_invoked(passed: &[Hit], invoked: Option<&str>) -> Vec<Hit> {
338    passed
339        .iter()
340        .filter(|h| Some(h.id.as_str()) != invoked)
341        .cloned()
342        .collect()
343}
344
345/// Apply the caller-side guardrails to the gate survivors from [`pipeline::decide`]:
346/// drop denied skills, attach the winning stage's confidence, drop any the session's
347/// score-aware dedup rejects, and cap at `max_skills`. The stage gate itself — the
348/// absolute floor / relative margin / reranker thresholds / lexical dominance —
349/// already ran in `pipeline`, measured against the global best *before* this dedup,
350/// so re-injecting a prompt whose strong matches are already loaded falls silent
351/// rather than scraping the weak tail.
352///
353/// Uniform across stages: for the lexical winner the `Stage::Lexical` mapping is a
354/// fixed High-band confidence ([`confidence::LEXICAL_CONF`]) that ignores the score;
355/// the reranker uses its logit; stage-1 uses its cosine blend.
356///
357/// `project_hits` (skill id → matched ecosystem term) feeds the evidence line each
358/// [`Rec`] carries into the injection.
359fn finalize(
360    passed: &[Hit],
361    stage: Stage,
362    cfg: &Config,
363    session: &Session,
364    project_hits: &std::collections::BTreeMap<String, String>,
365) -> Vec<Rec> {
366    passed
367        .iter()
368        .filter(|h| !cfg.deny.contains(&h.id))
369        .map(|h| Rec {
370            confidence: confidence::of(h.score, stage, cfg),
371            why: evidence(h, project_hits),
372            id: h.id.clone(),
373        })
374        .filter(|r| session.should_recommend(&r.id, r.confidence, confidence::HIGH))
375        .take(cfg.max_skills)
376        .collect()
377}
378
379/// The concrete grounds (if any) behind a hit, for the injection's evidence line.
380/// Only the situational channels are surfaced — a named file of the skill's type,
381/// or the workspace/conversation's ecosystem — because those are the observable
382/// facts a model can check against the request; the dense/lexical scores are not
383/// evidence a model can reason about.
384fn evidence(h: &Hit, project_hits: &std::collections::BTreeMap<String, String>) -> Option<String> {
385    if h.file > 0.0 {
386        return Some("a file of this skill's document type is part of this conversation".into());
387    }
388    if h.project > 0.0 {
389        return project_hits
390            .get(&h.id)
391            .map(|term| format!("you are working in a {term} project"));
392    }
393    None
394}
395
396/// Pick the inject shape for the chosen `recs`. Normally `cfg.inject_mode`, but a
397/// lone match at/above `cfg.body_inject_min` confidence is escalated to
398/// [`InjectMode::Body`] — the full `SKILL.md` is inlined so a near-certain skill
399/// is applied, not merely pointed at. Requires `directive` mode (an explicit
400/// `body` config already inlines everything) and exactly one rec (co-relevant
401/// peers signal lower certainty and a heavier dump, so they stay directives).
402fn inject_mode(recs: &[Rec], cfg: &Config) -> InjectMode {
403    if cfg.inject_mode == InjectMode::Directive
404        && recs.len() == 1
405        && recs[0].confidence >= cfg.body_inject_min
406    {
407        InjectMode::Body
408    } else {
409        cfg.inject_mode
410    }
411}
412
413/// Resolve [`Strength::Auto`] from the host: Claude has a strong native chooser
414/// (a nudge suffices); opencode's local models need an imperative.
415fn resolve_strength(strength: Strength, host: Host) -> Strength {
416    match strength {
417        Strength::Auto => match host {
418            Host::Claude => Strength::Soft,
419            Host::Opencode => Strength::Hard,
420        },
421        other => other,
422    }
423}
424
425fn render_claude(d: &Decision) -> String {
426    if d.inject.is_empty() {
427        return String::new();
428    }
429    serde_json::json!({
430        "hookSpecificOutput": {
431            "hookEventName": "UserPromptSubmit",
432            "additionalContext": d.inject,
433        }
434    })
435    .to_string()
436}
437
438fn render_opencode(d: &Decision) -> String {
439    serde_json::json!({ "skills": d.skills, "inject": d.inject }).to_string()
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use crate::config::Config;
446    use crate::session::Source;
447
448    fn hit(id: &str, score: f32, keyword: f32) -> Hit {
449        Hit {
450            id: id.to_string(),
451            name: id.to_string(),
452            cosine: score - keyword,
453            context: 0.0,
454            file: 0.0,
455            project: 0.0,
456            keyword,
457            phrase: 0.0,
458            score,
459        }
460    }
461
462    #[test]
463    fn host_parse() {
464        assert_eq!("claude".parse::<Host>().unwrap(), Host::Claude);
465        assert_eq!("OpenCode".parse::<Host>().unwrap(), Host::Opencode);
466        assert!("bogus".parse::<Host>().is_err());
467    }
468
469    #[test]
470    fn raw_event_parses_claude_and_opencode_shapes() {
471        let claude = r#"{"session_id":"s1","cwd":"/r","prompt":"hi","transcript_path":"/t"}"#;
472        let ev: RawEvent = serde_json::from_str(claude).unwrap();
473        assert_eq!(ev.prompt, "hi");
474        assert_eq!(ev.session_id, "s1");
475
476        let oc = r#"{"host":"opencode","session_id":"s2","cwd":"/r","prompt":"yo"}"#;
477        let ev: RawEvent = serde_json::from_str(oc).unwrap();
478        assert_eq!(ev.prompt, "yo");
479        assert_eq!(ev.session_id, "s2");
480    }
481
482    #[test]
483    fn strength_resolution() {
484        assert_eq!(
485            resolve_strength(Strength::Auto, Host::Claude),
486            Strength::Soft
487        );
488        assert_eq!(
489            resolve_strength(Strength::Auto, Host::Opencode),
490            Strength::Hard
491        );
492        // Explicit settings pass through unchanged.
493        assert_eq!(
494            resolve_strength(Strength::Hard, Host::Claude),
495            Strength::Hard
496        );
497    }
498
499    /// The stage-1 cosine path as the hook runs it: gate in `pipeline`, then the
500    /// caller-side `finalize` (deny/confidence/dedup/cap).
501    fn select_cosine(hits: &[Hit], cfg: &Config, session: &Session) -> Vec<Rec> {
502        finalize(
503            &pipeline::cosine_passed(hits, cfg),
504            Stage::Cosine,
505            cfg,
506            session,
507            &std::collections::BTreeMap::new(),
508        )
509    }
510
511    #[test]
512    fn select_threshold_and_cap() {
513        let cfg = Config::default(); // min 0.30, margin 0.15, max 2
514        let session = Session::default();
515        let hits = vec![
516            hit("a", 0.90, 0.0),
517            hit("b", 0.85, 0.0),
518            hit("c", 0.84, 0.0), // within margin but over the cap
519            hit("d", 0.10, 0.0), // below threshold
520        ];
521        let got: Vec<String> = select_cosine(&hits, &cfg, &session)
522            .into_iter()
523            .map(|h| h.id)
524            .collect();
525        assert_eq!(got, ["a", "b"]); // capped at 2, d dropped
526    }
527
528    #[test]
529    fn select_skips_loaded_and_denied() {
530        let cfg = Config {
531            deny: vec!["a".to_string()],
532            ..Default::default()
533        };
534        let mut session = Session::default();
535        session.mark("b", Source::Model);
536        let hits = vec![
537            hit("a", 0.90, 0.0),
538            hit("b", 0.85, 0.0),
539            hit("c", 0.80, 0.0),
540        ];
541        let got: Vec<String> = select_cosine(&hits, &cfg, &session)
542            .into_iter()
543            .map(|h| h.id)
544            .collect();
545        assert_eq!(got, ["c"]); // a denied, b already loaded, c within margin
546    }
547
548    #[test]
549    fn select_margin_drops_weak_tail() {
550        let cfg = Config::default(); // margin 0.15
551        let session = Session::default();
552        // Both clear the 0.30 floor, but b is far below the 0.90 leader.
553        let hits = vec![hit("a", 0.90, 0.0), hit("b", 0.50, 0.0)];
554        let got: Vec<String> = select_cosine(&hits, &cfg, &session)
555            .into_iter()
556            .map(|h| h.id)
557            .collect();
558        assert_eq!(got, ["a"]);
559    }
560
561    #[test]
562    fn select_repeat_falls_silent() {
563        // The strong match was already recommended at high confidence, so its
564        // repeat is suppressed; the rest are a weak tail measured against the
565        // (still-global) leader, so nothing rides along either.
566        let cfg = Config::default();
567        let mut session = Session::default();
568        session.mark_recommended("a", 0.95);
569        let hits = vec![hit("a", 0.90, 0.0), hit("b", 0.50, 0.0)];
570        assert!(select_cosine(&hits, &cfg, &session).is_empty());
571    }
572
573    #[test]
574    fn select_repeats_on_rise_into_high() {
575        // A skill shown earlier at medium confidence is re-recommended when a
576        // later prompt makes it a strong match.
577        let cfg = Config::default();
578        let mut session = Session::default();
579        session.mark_recommended("a", 0.60); // earlier: medium
580        let hits = vec![hit("a", 0.90, 0.0)]; // now: cosine 0.90 -> high
581        let got: Vec<String> = select_cosine(&hits, &cfg, &session)
582            .into_iter()
583            .map(|r| r.id)
584            .collect();
585        assert_eq!(got, ["a"]);
586    }
587
588    #[test]
589    fn select_keeps_co_relevant_cluster() {
590        let cfg = Config::default(); // margin 0.15
591        let session = Session::default();
592        let hits = vec![hit("a", 0.90, 0.0), hit("b", 0.80, 0.0)];
593        let got: Vec<String> = select_cosine(&hits, &cfg, &session)
594            .into_iter()
595            .map(|h| h.id)
596            .collect();
597        assert_eq!(got, ["a", "b"]);
598    }
599
600    #[test]
601    fn select_force_bypasses_threshold_on_keyword() {
602        let cfg = Config {
603            force: vec!["x".to_string()],
604            ..Default::default()
605        };
606        let session = Session::default();
607        // x is below threshold but forced with a keyword hit; y just below, no force.
608        let hits = vec![hit("x", 0.1, 0.15), hit("y", 0.2, 0.0)];
609        let got: Vec<String> = select_cosine(&hits, &cfg, &session)
610            .into_iter()
611            .map(|h| h.id)
612            .collect();
613        assert_eq!(got, ["x"]);
614    }
615
616    fn rec(id: &str, confidence: f32) -> Rec {
617        Rec {
618            id: id.to_string(),
619            confidence,
620            why: None,
621        }
622    }
623
624    #[test]
625    fn finalize_attaches_project_evidence() {
626        let cfg = Config::default();
627        let session = Session::default();
628        let mut h = hit("uv-development", 0.90, 0.0);
629        h.project = 0.15; // the project channel fired for this skill
630        let project_hits: std::collections::BTreeMap<String, String> =
631            [("uv-development".to_string(), "uv".to_string())].into();
632        let got = finalize(&[h], Stage::Cosine, &cfg, &session, &project_hits);
633        assert_eq!(
634            got[0].why.as_deref(),
635            Some("you are working in a uv project")
636        );
637        // A purely semantic hit carries no evidence line.
638        let got = finalize(
639            &[hit("a", 0.90, 0.0)],
640            Stage::Cosine,
641            &cfg,
642            &session,
643            &project_hits,
644        );
645        assert_eq!(got[0].why, None);
646    }
647
648    #[test]
649    fn lone_near_certain_match_escalates_to_body() {
650        let cfg = Config::default(); // directive mode, body_inject_min 0.92
651        assert_eq!(inject_mode(&[rec("a", 0.95)], &cfg), InjectMode::Body);
652    }
653
654    #[test]
655    fn body_escalation_needs_high_confidence() {
656        let cfg = Config::default();
657        // A High-band but not near-certain match stays a directive pointer.
658        assert_eq!(inject_mode(&[rec("a", 0.85)], &cfg), InjectMode::Directive);
659    }
660
661    #[test]
662    fn body_escalation_needs_a_lone_match() {
663        let cfg = Config::default();
664        // Two co-relevant near-certain peers stay directives (less certain; a
665        // double body dump is too heavy).
666        assert_eq!(
667            inject_mode(&[rec("a", 0.95), rec("b", 0.95)], &cfg),
668            InjectMode::Directive
669        );
670    }
671
672    #[test]
673    fn body_escalation_disabled_above_one() {
674        let cfg = Config {
675            body_inject_min: 1.1, // the documented "off" setting
676            ..Default::default()
677        };
678        assert_eq!(inject_mode(&[rec("a", 0.99)], &cfg), InjectMode::Directive);
679    }
680
681    #[test]
682    fn explicit_body_mode_is_unchanged() {
683        let cfg = Config {
684            inject_mode: InjectMode::Body,
685            ..Default::default()
686        };
687        // A weak, multi-skill selection still inlines when the user pinned body mode.
688        assert_eq!(
689            inject_mode(&[rec("a", 0.2), rec("b", 0.2)], &cfg),
690            InjectMode::Body
691        );
692    }
693
694    #[test]
695    fn invoked_skill_does_not_consume_a_cap_slot() {
696        // `/a something` with three gate survivors [a, b, c] and max_skills 2:
697        // dropping `a` after the cap would leave only [b]; dropping it before
698        // (as the hook now does) keeps the full [b, c].
699        let cfg = Config::default(); // max_skills 2
700        let session = Session::default();
701        let hits = vec![
702            hit("a", 0.90, 0.0),
703            hit("b", 0.85, 0.0),
704            hit("c", 0.84, 0.0),
705        ];
706        let passed = pipeline::cosine_passed(&hits, &cfg);
707        let got: Vec<String> = finalize(
708            &without_invoked(&passed, Some("a")),
709            Stage::Cosine,
710            &cfg,
711            &session,
712            &std::collections::BTreeMap::new(),
713        )
714        .into_iter()
715        .map(|r| r.id)
716        .collect();
717        assert_eq!(got, ["b", "c"]);
718    }
719
720    #[test]
721    fn control_prompts_detected() {
722        assert!(is_control_prompt(
723            "<task-notification>\n<task-id>x</task-id>\n</task-notification>"
724        ));
725        assert!(is_control_prompt(
726            "  <system-reminder>foo</system-reminder>"
727        ));
728        // Genuine prompts that only mention a tag in prose still inject.
729        assert!(!is_control_prompt(
730            "explain the <task-notification> payload"
731        ));
732        assert!(!is_control_prompt("set up a python project"));
733    }
734
735    #[test]
736    fn slash_command_id_extracts_name() {
737        assert_eq!(slash_command_id("/pickup"), Some("pickup".into()));
738        assert_eq!(
739            slash_command_id("/pickup keep going"),
740            Some("pickup".into())
741        );
742        assert_eq!(slash_command_id("  /handoff now"), Some("handoff".into()));
743        // Namespaced command -> trailing skill segment.
744        assert_eq!(
745            slash_command_id("/caveman:caveman-commit"),
746            Some("caveman-commit".into())
747        );
748        // Not slash commands.
749        assert_eq!(slash_command_id("commit and push"), None);
750        assert_eq!(slash_command_id("/etc/hosts is a path"), None);
751        assert_eq!(slash_command_id("/"), None);
752    }
753
754    #[test]
755    fn render_claude_empty_is_silent() {
756        assert_eq!(render_claude(&Decision::default()), "");
757    }
758
759    #[test]
760    fn render_claude_wraps_context() {
761        let d = Decision {
762            inject: "ctx".to_string(),
763            skills: vec!["a".to_string()],
764        };
765        let v: serde_json::Value = serde_json::from_str(&render_claude(&d)).unwrap();
766        assert_eq!(v["hookSpecificOutput"]["hookEventName"], "UserPromptSubmit");
767        assert_eq!(v["hookSpecificOutput"]["additionalContext"], "ctx");
768    }
769
770    #[test]
771    fn render_opencode_always_json() {
772        let v: serde_json::Value = serde_json::from_str(&render_opencode(&Decision::default()))
773            .expect("opencode output is always valid JSON");
774        assert_eq!(v["inject"], "");
775        assert!(v["skills"].as_array().unwrap().is_empty());
776    }
777}