Skip to main content

claude_scriptorium/
gloss.rs

1//! What the harness wrote into a session, and how each note is set.
2//!
3//! A gloss is a note entered by a hand other than the conversation's: a hook's
4//! output, a rule file pulled into context, the instructions a skill carries,
5//! a slash command the user typed, a plan-mode boundary. None of it is the
6//! conversation and all of it is why the conversation went the way it did, so
7//! the folio sets it rather than dropping it.
8//!
9//! The shapes here are the harness's rather than a contract, so every reading
10//! is lenient and answers `Option`: a note whose shape this doesn't recognise
11//! is left unset rather than aborting a render, the same way a tool with no
12//! view of its own falls back in [`crate::tools`]. What a note has to say is
13//! also what decides whether it is set at all: one that only reports that
14//! something ran is dropped, as an acknowledging tool result is.
15
16use std::borrow::Cow;
17
18use maud::html;
19use serde_json::Value;
20
21use crate::{
22    render::Scribe,
23    tools::{Setting, plain, printed, prose},
24};
25
26/// What entered the note, which is what its panel is labelled by. The summary
27/// line says which hook, which file, which command; the label says only who
28/// wrote it there.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum GlossKind {
31    Hook,
32    Rule,
33    Skill,
34    Command,
35    Plan,
36    Note,
37}
38
39impl GlossKind {
40    pub fn label(self) -> &'static str {
41        match self {
42            GlossKind::Hook => "hook",
43            GlossKind::Rule => "rule",
44            GlossKind::Skill => "skill",
45            GlossKind::Command => "command",
46            GlossKind::Plan => "plan",
47            GlossKind::Note => "note",
48        }
49    }
50}
51
52/// What a gloss's fold holds, and how the body should be set.
53///
54/// Three readings, not two, because a hook's output sits between them: a program
55/// printed it, so its line breaks are its own and must be kept, but it is
56/// markdown often enough (headings, lists) that setting it as preformatted text
57/// throws away structure a reader wants. [`Body::Printed`] is that middle
58/// reading, and [`crate::render::Scribe::markdown_printed`] says what it costs.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Body {
61    /// Text composed as markdown, which reflows: a skill's instructions, a rule
62    /// pulled into context, a plan.
63    Prose(String),
64    /// Text a program printed, read as markdown but keeping its own line breaks.
65    Printed(String),
66    /// Output with no shape of its own, set exactly as it came.
67    Plain(String),
68}
69
70/// Which firing of a hook a note belongs to. One hook writes several lines
71/// (what it decided, what it injected, what it printed), and this is what lets
72/// [`crate::transcript::Folio::panels`] gather them into one panel, the way a
73/// tool result is gathered into the call it answers.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct Firing {
76    /// The harness's `toolUseID`, which names the *event* and not the hook: one
77    /// event runs every hook matching it, so four `SessionStart` hooks share
78    /// this.
79    event: String,
80    /// The hook's own command, which is what tells one hook from another within
81    /// an event. The lines a hook writes *through* the harness (what it decided,
82    /// what it injected) carry no command of their own, which is what lets them
83    /// join whichever hook of the event they turn up beside.
84    command: Option<String>,
85}
86
87impl Firing {
88    /// True when two notes could have come from one hook: the same event, and
89    /// no two *different* commands between them. Two named commands under one
90    /// event are two hooks and must stay two panels, or the folio claims a
91    /// single hook ran four commands.
92    fn joins(&self, other: &Firing) -> bool {
93        self.event == other.event
94            && match (&self.command, &other.command) {
95                (Some(mine), Some(theirs)) => mine == theirs,
96                _ => true,
97            }
98    }
99}
100
101/// One note the harness wrote into the session, as data: the markup comes
102/// later, from [`setting`].
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct Gloss {
105    pub kind: GlossKind,
106    /// What the note is about: the hook that ran, the file pulled in, the
107    /// command typed.
108    pub gist: Option<String>,
109    /// Qualifiers the gist doesn't carry: a non-zero exit, a timeout, the
110    /// arguments a command took.
111    pub notes: Vec<String>,
112    /// What the note has to say, in the order it was said. One firing of a hook
113    /// can write more than one, so this is a list rather than the one body a
114    /// single line carries.
115    pub body: Vec<Body>,
116    pub firing: Option<Firing>,
117}
118
119impl Gloss {
120    fn new(kind: GlossKind) -> Self {
121        Self {
122            kind,
123            gist: None,
124            notes: Vec::new(),
125            body: Vec::new(),
126            firing: None,
127        }
128    }
129
130    /// Names the firing this note belongs to.
131    fn firing(mut self, event: Option<&str>, command: Option<&str>) -> Self {
132        self.firing = event.map(|event| Firing {
133            event: event.to_owned(),
134            command: command.map(str::to_owned),
135        });
136        self
137    }
138
139    /// Takes in another note from the same hook firing. The first line a hook
140    /// writes says what it decided and the next carries what it injected, so
141    /// what is already set labels the panel and the incoming note fills the
142    /// gaps. Nothing it had to say is dropped: the bodies stack in the fold, and
143    /// a named command narrows the firing so a *third* hook of the same event
144    /// can no longer join what is now identified.
145    pub(crate) fn absorb(&mut self, other: Gloss) {
146        if self.gist.is_none() {
147            self.gist = other.gist;
148        }
149        self.body.extend(other.body);
150        for note in other.notes {
151            if !self.notes.contains(&note) {
152                self.notes.push(note);
153            }
154        }
155        if let (Some(firing), Some(joined)) = (&mut self.firing, other.firing)
156            && firing.command.is_none()
157        {
158            firing.command = joined.command;
159        }
160    }
161
162    /// Re-reads this note as the instructions of the slash command that ran it.
163    ///
164    /// [`meta`] knows a skill by the directory it opens on, and a built-in one
165    /// has no directory on disk to name, so its instructions arrive as bare
166    /// prose and fall through to the catch-all. The command standing directly in
167    /// front of them is what says otherwise. Naming it after that command is
168    /// what makes a built-in read as the same thing a skill with a directory
169    /// does, and as the same thing again when the model reaches for one with no
170    /// command at all: one shape for "a skill was loaded", however it was.
171    ///
172    /// A note with no body is the whole of what was said on its gist alone, so
173    /// that text becomes the body rather than being dropped by the name taking
174    /// its place.
175    pub(crate) fn ran_by(&mut self, command: &str) {
176        self.kind = GlossKind::Skill;
177        if self.body.is_empty()
178            && let Some(said) = self.gist.take()
179        {
180            self.body.push(Body::Prose(said));
181        }
182        self.gist = Some(command.trim_start_matches('/').to_owned());
183    }
184
185    /// True when this note and the next belong to the same firing of one hook,
186    /// so the folio should set them as one panel rather than two.
187    pub(crate) fn same_firing_as(&self, other: &Gloss) -> bool {
188        self.kind == GlossKind::Hook
189            && other.kind == GlossKind::Hook
190            && match (&self.firing, &other.firing) {
191                (Some(mine), Some(theirs)) => mine.joins(theirs),
192                _ => false,
193            }
194    }
195
196    fn gist(mut self, gist: impl Into<String>) -> Self {
197        self.gist = Some(gist.into());
198        self
199    }
200
201    fn maybe_gist(self, gist: Option<impl Into<String>>) -> Self {
202        match gist {
203            Some(gist) => self.gist(gist),
204            None => self,
205        }
206    }
207
208    fn note(mut self, note: impl Into<String>) -> Self {
209        self.notes.push(note.into());
210        self
211    }
212
213    fn maybe_note(self, note: Option<impl Into<String>>) -> Self {
214        match note {
215            Some(note) => self.note(note),
216            None => self,
217        }
218    }
219
220    fn prose(mut self, body: impl Into<String>) -> Self {
221        self.body.push(Body::Prose(body.into()));
222        self
223    }
224
225    fn printed(mut self, body: impl Into<String>) -> Self {
226        self.body.push(Body::Printed(body.into()));
227        self
228    }
229
230    fn plain(mut self, body: impl Into<String>) -> Self {
231        self.body.push(Body::Plain(body.into()));
232        self
233    }
234
235    fn maybe_plain(self, body: Option<impl Into<String>>) -> Self {
236        match body {
237            Some(body) => self.plain(body),
238            None => self,
239        }
240    }
241
242    fn maybe_prose(self, body: Option<impl Into<String>>) -> Self {
243        match body {
244            Some(body) => self.prose(body),
245            None => self,
246        }
247    }
248
249    /// True when the note says nothing a reader could act on. Such a gloss is
250    /// dropped rather than set as a bare line reporting that something ran.
251    fn is_bare(&self) -> bool {
252        self.gist.is_none() && self.body.is_empty()
253    }
254}
255
256/// How a gloss is set: the same summary-line-and-fold vocabulary a tool call
257/// takes, so the two read as one kind of thing on the page. A firing that had
258/// several things to say stacks them in the one fold, in the order it said them.
259pub fn setting(scribe: &Scribe, gloss: &Gloss) -> Setting {
260    let setting = Setting::new().maybe_gist(gloss.gist.as_deref());
261    let setting = gloss
262        .notes
263        .iter()
264        .fold(setting, |setting, note| setting.note(note.as_str()));
265    if gloss.body.is_empty() {
266        return setting;
267    }
268    setting.body(html! {
269        @for body in &gloss.body {
270            (match body {
271                Body::Prose(text) => prose(scribe, text),
272                Body::Printed(text) => printed(scribe, text),
273                Body::Plain(text) => plain(scribe, text),
274            })
275        }
276    })
277}
278
279/// The note an `attachment` line carries, or `None` when it is scaffolding: an
280/// inventory of the tools, skills, or agents available, a restatement of the
281/// checklist a `TodoWrite` already shows, or a change of date the timestamps
282/// already say.
283pub fn attachment(attachment: &Value) -> Option<Gloss> {
284    let gloss = match text(attachment, "type")? {
285        "hook_success" => hook_output(attachment)?,
286        "hook_system_message" => Gloss::new(GlossKind::Hook)
287            .firing(text(attachment, "toolUseID"), None)
288            .maybe_gist(text(attachment, "content"))
289            .maybe_note(text(attachment, "hookName")),
290        // A hook printed this, so its line breaks are its own: markdown folds a
291        // single newline into a space, and `M  CLAUDE.md\nM  src/gloss.rs` came
292        // out as one run of filenames, which is the shape a hook reporting on a
293        // working tree always takes. It is still markdown often enough that
294        // setting it as preformatted text would throw away headings and lists
295        // the corpus is full of, so it takes the middle reading.
296        "hook_additional_context" => Gloss::new(GlossKind::Hook)
297            .firing(text(attachment, "toolUseID"), None)
298            .maybe_gist(text(attachment, "hookName"))
299            .note("added context")
300            .printed(paragraphs(attachment.get("content")?)?),
301        "hook_cancelled" => Gloss::new(GlossKind::Hook)
302            .firing(text(attachment, "toolUseID"), text(attachment, "command"))
303            .maybe_gist(text(attachment, "hookName"))
304            .maybe_note(text(attachment, "command"))
305            .note(
306                if flag(attachment, "timedOut") {
307                    "timed out"
308                } else {
309                    "cancelled"
310                },
311            ),
312        // Only the rules and `CLAUDE.md` files the harness pulls in *during* a
313        // session are recorded, one attachment each: what a session starts with
314        // (the project's own `CLAUDE.md`, the user's global one, every rule with
315        // no path to trigger on) is assembled into the system prompt, which the
316        // transcript does not carry at all. So a folio can only ever set the
317        // instructions a session reached for as it worked, and never the ones it
318        // began with.
319        "nested_memory" => Gloss::new(GlossKind::Rule)
320            .maybe_gist(subject(attachment))
321            .maybe_note(text(attachment.get("content")?, "type").map(str::to_lowercase))
322            .prose(text(attachment.get("content")?, "content")?),
323        "plan_mode" => plan_entered(attachment)?,
324        "plan_mode_exit" => plan_left(
325            attachment,
326            if flag(attachment, "planExists") {
327                "left, plan written"
328            } else {
329                "left, no plan written"
330            },
331        ),
332        // A file the user changed in their own editor while the session ran:
333        // the code the assistant went on to read is not the code it had seen.
334        "edited_text_file" => Gloss::new(GlossKind::Note)
335            .maybe_gist(text(attachment, "filename"))
336            .note("edited outside the session")
337            .plain(text(attachment, "snippet")?),
338        "file" => Gloss::new(GlossKind::Note)
339            .maybe_gist(subject(attachment))
340            .note("attached")
341            .prose(text(attachment.get("content")?.get("file")?, "content")?),
342        "directory" => Gloss::new(GlossKind::Note)
343            .maybe_gist(subject(attachment))
344            .note("attached")
345            .plain(text(attachment, "content")?),
346        // Only part of a file reached the assistant, which is why what followed
347        // may not answer to the whole of it.
348        "read_truncation_notice" => Gloss::new(GlossKind::Note)
349            .gist("read truncated")
350            .plain(text(attachment, "banner")?),
351        "goal_status" => Gloss::new(GlossKind::Note)
352            .maybe_gist(text(attachment, "condition"))
353            .note(
354                if flag(attachment, "met") {
355                    "met"
356                } else {
357                    "not met"
358                },
359            ),
360        _ => return None,
361    };
362    (!gloss.is_bare()).then_some(gloss)
363}
364
365/// What a hook's own output has to say.
366///
367/// `content` is what the hook contributed to the session and `stdout` is what
368/// it printed, and for a hook that simply prints context they are the same text
369/// twice. A hook that instead answers in the control protocol prints JSON that
370/// contributes nothing itself: what it asked for arrives as the
371/// `hook_system_message` and `hook_additional_context` notes beside it, so
372/// setting the protocol here would say the same thing a second time in a form
373/// no reader wants. `stderr` never reaches the model at all, and is the hook
374/// talking to whoever is watching.
375fn hook_output(attachment: &Value) -> Option<Gloss> {
376    let contributed = text(attachment, "content")
377        .or_else(|| text(attachment, "stdout").filter(|stdout| !is_protocol(stdout)))
378        .map(str::trim)
379        .filter(|contributed| !contributed.is_empty());
380    let printed = text(attachment, "stderr")
381        .map(str::trim)
382        .filter(|printed| !printed.is_empty());
383    let said: Vec<&str> = contributed.into_iter().chain(printed).collect();
384    if said.is_empty() {
385        return None;
386    }
387    let exit = attachment.get("exitCode").and_then(Value::as_i64);
388    Some(
389        Gloss::new(GlossKind::Hook)
390            .firing(text(attachment, "toolUseID"), text(attachment, "command"))
391            .maybe_gist(text(attachment, "hookName"))
392            .maybe_note(text(attachment, "command"))
393            .maybe_note(
394                exit.filter(|code| *code != 0)
395                    .map(|code| format!("exit {code}")),
396            )
397            .plain(said.join("\n")),
398    )
399}
400
401/// Commands that work the harness rather than the conversation: copying the last
402/// reply, listing what is installed, signing in, picking a session to resume. A
403/// reader learns nothing from having been told one ran, and what it printed was
404/// for whoever was sitting at the terminal.
405///
406/// This is a list rather than a rule because the transcript records every slash
407/// command the same way, whether it injected a whole skill or only redrew the
408/// screen. Match a new one against real transcripts before adding it, the way
409/// [`crate::tools::acknowledges`] is matched: dropping a command that *did*
410/// inject something loses the reason a session changed course. Commands that
411/// change the conversation stay, even the terse ones: `/compact` rewrites the
412/// context and `/model` changes who answers.
413///
414/// `/clear` is not here. It is a session boundary rather than a command with
415/// nothing to say, and [`crate::transcript::Turn::is_clear_command`] drops it
416/// before this is ever reached.
417const HARNESS_CONTROLS: &[&str] = &[
418    "/copy",
419    "/skills",
420    "/agents",
421    "/login",
422    "/logout",
423    "/resume",
424    "/config",
425    "/status",
426    "/cost",
427    "/doctor",
428    "/help",
429    "/terminal-setup",
430];
431
432fn controls_the_harness(name: &str) -> bool {
433    HARNESS_CONTROLS.contains(&name)
434}
435
436/// True for hook output written in the control protocol rather than addressed
437/// to the session: a JSON object asking the harness to say or inject something,
438/// which arrives as its own note.
439fn is_protocol(stdout: &str) -> bool {
440    serde_json::from_str::<Value>(stdout.trim()).is_ok_and(|value| {
441        value.get("systemMessage").is_some() || value.get("hookSpecificOutput").is_some()
442    })
443}
444
445/// Leaving plan mode, named by the plan's own file the way a rule or a skill is
446/// named by its path: the subject goes on the summary line and what happened to
447/// it qualifies, so a long path wraps where a short phrase would be crushed. The
448/// file is worth naming here and not on the way in, because a session that
449/// writes its plan writes it with an ordinary tool call the folio already sets,
450/// and the path is what ties this boundary to that panel. A boundary the harness
451/// recorded no path for is named by what happened instead, since a gloss with
452/// neither gist nor body is dropped.
453fn plan_left(attachment: &Value, state: &str) -> Gloss {
454    match text(attachment, "planFilePath") {
455        Some(path) => Gloss::new(GlossKind::Plan).gist(path).note(state),
456        None => Gloss::new(GlossKind::Plan).gist(state),
457    }
458}
459
460/// Entering plan mode, which is the whole of what the boundary says. The plan
461/// file the harness names here is not set: nothing has been written to it yet,
462/// and a reader holding only the folio can't open it in any case.
463///
464/// The harness repeats the note while the mode stays on, so only the one that
465/// marks the boundary is set: the reminders after it say nothing that has
466/// changed.
467fn plan_entered(attachment: &Value) -> Option<Gloss> {
468    (text(attachment, "reminderType") == Some("full")).then(|| {
469        Gloss::new(GlossKind::Plan)
470            .gist("entered plan mode")
471            .maybe_note(flag(attachment, "isSubAgent").then_some("subagent"))
472    })
473}
474
475/// The note a `system` line carries, or `None` for the ones that say nothing a
476/// reader needs: how long a turn took, and a summary of the hooks whose own
477/// notes sit beside it.
478pub fn system(system: &Value) -> Option<Gloss> {
479    let said = text(system, "content").map(str::trim).unwrap_or_default();
480    let gloss = match text(system, "subtype")? {
481        "local_command" => {
482            let printed = unwrap_tag(said, "local-command-stdout")?.trim();
483            (!printed.is_empty())
484                .then(|| Gloss::new(GlossKind::Command).gist("output").plain(printed))?
485        }
486        // A message was retracted and the model swapped under the conversation,
487        // which is why the reply that follows came from a different one.
488        "model_refusal_fallback" => Gloss::new(GlossKind::Note)
489            .gist("model switched")
490            .maybe_note(
491                text(system, "originalModel")
492                    .zip(text(system, "fallbackModel"))
493                    .map(|(from, to)| format!("{from} → {to}")),
494            )
495            .prose(said),
496        "informational" | "away_summary" => told(GlossKind::Note, said),
497        _ => return None,
498    };
499    (!gloss.is_bare()).then_some(gloss)
500}
501
502/// What a `user` turn carries once the wrappers the harness records around it
503/// are accounted for. A turn in the user's role is not always the user: the
504/// harness writes its own notes there, and wraps what the user typed in
505/// scaffolding addressed to the model.
506#[derive(Debug, Clone, PartialEq, Eq)]
507pub enum Wrapped {
508    /// A note the harness wrote, to be set as a gloss.
509    Note(Gloss),
510    /// A wrapper with nothing under it, to be dropped: the caveat standing in
511    /// front of a slash command is its own turn, and says only that what
512    /// follows isn't addressed to the model.
513    Nothing,
514}
515
516/// What a `user` turn really is, or `None` when it is the user speaking and
517/// belongs in the conversation as it stands.
518pub fn wrapped(said: &str, is_meta: bool) -> Option<Wrapped> {
519    // The caveat is addressed to the model, telling it not to answer what
520    // follows; only what it wraps is the reader's business.
521    let said = strip_tag(said, "local-command-caveat");
522    let said = said.trim();
523    if let Some(wrapped) = command(said) {
524        return Some(wrapped);
525    }
526    if !is_meta {
527        return None;
528    }
529    Some(match meta(said) {
530        Some(gloss) => Wrapped::Note(gloss),
531        None => Wrapped::Nothing,
532    })
533}
534
535/// The note a `user` turn the harness wrote for itself carries. A skill (or a
536/// slash command standing on one) is injected as its base directory followed by
537/// the whole of what it instructs, which is the most load-bearing context a
538/// session has and the thing the folio hid outright; anything else the harness
539/// says to itself is set as it came.
540fn meta(said: &str) -> Option<Gloss> {
541    const SKILL_OPENING: &str = "Base directory for this skill:";
542    // How an image was scaled on its way to the model, so it can map what it
543    // sees back onto the original. The image is set beside this note and shows
544    // a reader everything the note is about.
545    const IMAGE_SCALING: (&str, &str) = ("[Image: original ", "Multiply coordinates");
546
547    if said.is_empty() || (said.starts_with(IMAGE_SCALING.0) && said.contains(IMAGE_SCALING.1)) {
548        return None;
549    }
550    let Some(rest) = said.strip_prefix(SKILL_OPENING) else {
551        return Some(told(GlossKind::Note, said));
552    };
553    // The base directory is its own line, and everything under it is what the
554    // skill instructs. A skill recorded as that line alone still names itself.
555    let (directory, instructions) = rest.trim_start().split_once('\n').unwrap_or((rest, ""));
556    Some(
557        Gloss::new(GlossKind::Skill)
558            .maybe_gist(directory.trim().rsplit('/').next())
559            .maybe_prose(Some(instructions.trim_start()).filter(|body| !body.is_empty())),
560    )
561}
562
563/// The slash command a `user` turn stands for. The harness records the command
564/// as a wrapper around the name, the arguments, and whatever the command
565/// printed, and the tags arrive in different orders across harness versions, so
566/// each is taken where it sits rather than by splitting the text up.
567/// Answers three ways, and the third is what keeps a dropped command from
568/// reading as the user speaking: `Nothing` for one that only works the harness,
569/// `Note` for one that shaped the conversation, and `None` when the turn is no
570/// command at all.
571fn command(said: &str) -> Option<Wrapped> {
572    let name = unwrap_tag(said, "command-name")?.trim();
573    if name.is_empty() {
574        return None;
575    }
576    if controls_the_harness(name) {
577        return Some(Wrapped::Nothing);
578    }
579    let args = unwrap_tag(said, "command-args").unwrap_or_default().trim();
580    let printed = unwrap_tag(said, "local-command-stdout")
581        .unwrap_or_default()
582        .trim();
583    Some(Wrapped::Note(
584        Gloss::new(GlossKind::Command)
585            .gist(name)
586            .maybe_note((!args.is_empty()).then_some(args))
587            .maybe_plain((!printed.is_empty()).then_some(printed)),
588    ))
589}
590
591/// Something the harness stated in prose. A single short line is the whole of
592/// what it says, so it goes on the summary line with no fold to open; anything
593/// longer is titled by its opening line and held in the fold.
594fn told(kind: GlossKind, said: &str) -> Gloss {
595    const AT_A_GLANCE: usize = 90;
596
597    let opening = said.lines().next().unwrap_or(said).trim();
598    if said.lines().count() == 1 && said.chars().count() <= AT_A_GLANCE {
599        return Gloss::new(kind).gist(opening);
600    }
601    Gloss::new(kind).gist(opening).prose(said)
602}
603
604/// The path a note is about, preferring the one written for a reader.
605fn subject(attachment: &Value) -> Option<&str> {
606    ["displayPath", "path", "filename"]
607        .iter()
608        .find_map(|field| text(attachment, field))
609}
610
611/// Several pieces of injected context as one document, or `None` when the
612/// harness recorded something other than a list of them.
613fn paragraphs(content: &Value) -> Option<String> {
614    let joined = content
615        .as_array()?
616        .iter()
617        .filter_map(Value::as_str)
618        .collect::<Vec<&str>>()
619        .join("\n\n");
620    (!joined.trim().is_empty()).then_some(joined)
621}
622
623/// What one of the harness's XML-ish wrappers encloses, or `None` when the text
624/// carries no such wrapper.
625fn unwrap_tag<'a>(text: &'a str, tag: &str) -> Option<&'a str> {
626    let opening = format!("<{tag}>");
627    let closing = format!("</{tag}>");
628    let from = text.find(&opening)? + opening.len();
629    let to = text[from..].find(&closing)? + from;
630    Some(&text[from..to])
631}
632
633/// The text with one of the harness's wrappers, and everything it encloses,
634/// taken out of it.
635fn strip_tag<'a>(text: &'a str, tag: &str) -> Cow<'a, str> {
636    let closing = format!("</{tag}>");
637    let Some(from) = text.find(&format!("<{tag}>")) else {
638        return Cow::Borrowed(text);
639    };
640    let Some(to) = text[from..]
641        .find(&closing)
642        .map(|at| at + from + closing.len())
643    else {
644        return Cow::Borrowed(text);
645    };
646    Cow::Owned(format!("{}{}", &text[..from], &text[to..]))
647}
648
649fn text<'a>(value: &'a Value, field: &str) -> Option<&'a str> {
650    value.get(field)?.as_str()
651}
652
653fn flag(value: &Value, field: &str) -> bool {
654    value.get(field).and_then(Value::as_bool).unwrap_or(false)
655}
656
657#[cfg(test)]
658mod tests {
659    use serde_json::json;
660
661    use super::*;
662
663    /// The note a command wrapper makes, for the tests that are about the
664    /// command rather than about whether it is one.
665    fn noted(said: &str) -> Option<Gloss> {
666        match command(said) {
667            Some(Wrapped::Note(gloss)) => Some(gloss),
668            _ => None,
669        }
670    }
671
672    #[test]
673    fn a_slash_command_is_read_out_of_its_wrapper() {
674        let gloss = noted(
675            "<command-message>debug-gha</command-message>\n\
676             <command-name>/debug-gha</command-name>",
677        )
678        .expect("the wrapper names a command");
679
680        assert_eq!(gloss.kind, GlossKind::Command);
681        assert_eq!(gloss.gist.as_deref(), Some("/debug-gha"));
682        assert!(gloss.notes.is_empty());
683        assert!(gloss.body.is_empty());
684    }
685
686    #[test]
687    fn a_command_carries_its_arguments_and_what_it_printed() {
688        // The tags arrive in either order across harness versions, so this one
689        // leads with the name where the test above leads with the message.
690        let gloss = noted(
691            "<command-name>/loop</command-name>\n\
692             <command-message>loop</command-message>\n\
693             <command-args>5m /babysit-prs</command-args>\n\
694             <local-command-stdout>scheduled</local-command-stdout>",
695        )
696        .expect("the wrapper names a command");
697
698        assert_eq!(gloss.gist.as_deref(), Some("/loop"));
699        assert_eq!(gloss.notes, ["5m /babysit-prs"]);
700        assert_eq!(gloss.body, [Body::Plain("scheduled".into())]);
701    }
702
703    #[test]
704    fn a_command_that_only_controls_the_harness_is_not_set() {
705        // `/copy` puts the last reply on the clipboard and says so. Neither the
706        // command nor what it printed is about the conversation. It answers
707        // `Nothing` rather than `None`, or the turn would fall through and be
708        // set as the user speaking the wrapper aloud.
709        assert_eq!(
710            command(
711                "<command-name>/copy</command-name>\n\
712                 <local-command-stdout>Copied to clipboard (464 characters, 7 lines)\
713                 </local-command-stdout>"
714            ),
715            Some(Wrapped::Nothing)
716        );
717        assert_eq!(
718            command("<command-name>/config</command-name>"),
719            Some(Wrapped::Nothing)
720        );
721    }
722
723    #[test]
724    fn a_command_that_changes_the_conversation_is_still_set() {
725        // The terse ones are not all alike: compacting rewrites the context the
726        // model sees, which is exactly the sort of turn a reader needs to see.
727        let gloss = noted("<command-name>/compact</command-name>")
728            .expect("a command that changes the conversation is a gloss");
729
730        assert_eq!(gloss.gist.as_deref(), Some("/compact"));
731    }
732
733    #[test]
734    fn a_command_that_printed_nothing_has_no_fold() {
735        let gloss = noted(
736            "<command-name>/clear</command-name>\n\
737             <command-args></command-args>\n\
738             <local-command-stdout></local-command-stdout>",
739        )
740        .expect("the wrapper names a command");
741
742        assert!(gloss.notes.is_empty());
743        assert!(gloss.body.is_empty());
744    }
745
746    #[test]
747    fn the_caveat_wrapping_a_command_is_not_set() {
748        // The caveat is addressed to the model, telling it not to answer what
749        // follows; only the command it wraps is the reader's business.
750        let said = "<local-command-caveat>Caveat: The messages below were generated \
751                    by the user while running local commands.</local-command-caveat>\n\
752                    <command-name>/review</command-name>";
753
754        let Some(Wrapped::Note(gloss)) = wrapped(said, true) else {
755            panic!("the command should survive the caveat");
756        };
757        assert_eq!(gloss.kind, GlossKind::Command);
758        assert_eq!(gloss.gist.as_deref(), Some("/review"));
759    }
760
761    #[test]
762    fn a_caveat_with_nothing_left_under_it_is_dropped_rather_than_left_as_a_turn() {
763        // The caveat stands in front of a slash command as a turn of its own,
764        // so answering "no note here" would leave it to render as the user
765        // speaking, which is exactly what it isn't.
766        assert_eq!(
767            wrapped(
768                "<local-command-caveat>Caveat: do not respond.</local-command-caveat>",
769                true
770            ),
771            Some(Wrapped::Nothing)
772        );
773    }
774
775    #[test]
776    fn what_the_user_actually_typed_is_left_in_the_conversation() {
777        assert_eq!(wrapped("Does the formatter run on nightly?", false), None);
778    }
779
780    #[test]
781    fn a_skill_is_named_by_its_directory_and_holds_its_instructions() {
782        let gloss = meta(
783            "Base directory for this skill: /home/jtk/.claude/skills/debug-gha\n\n\
784             # Debug GitHub Actions Runs\n\nThe objective is to **fix** the failing run.",
785        )
786        .expect("a skill body is a gloss");
787
788        assert_eq!(gloss.kind, GlossKind::Skill);
789        assert_eq!(gloss.gist.as_deref(), Some("debug-gha"));
790        assert_eq!(
791            gloss.body,
792            [Body::Prose(
793                "# Debug GitHub Actions Runs\n\nThe objective is to **fix** the failing run."
794                    .into()
795            )]
796        );
797    }
798
799    #[test]
800    fn a_short_harness_note_is_stated_on_the_line_with_no_fold() {
801        let gloss = meta("Continue from where you left off").expect("a note is a gloss");
802
803        assert_eq!(gloss.kind, GlossKind::Note);
804        assert_eq!(
805            gloss.gist.as_deref(),
806            Some("Continue from where you left off")
807        );
808        assert!(gloss.body.is_empty());
809    }
810
811    #[test]
812    fn how_an_image_was_scaled_for_the_model_is_not_set() {
813        // The note tells the model how to map coordinates back onto the
814        // original; the image itself sits beside it and shows a reader all of
815        // what the note is about.
816        assert_eq!(
817            meta(
818                "[Image: original 1400x2006, displayed at 1396x2000. \
819                 Multiply coordinates by 1.00 to map to original image.]"
820            ),
821            None
822        );
823    }
824
825    #[test]
826    fn a_hook_sets_what_it_contributed_and_what_it_printed_to_stderr() {
827        let gloss = attachment(&json!({
828            "type": "hook_success",
829            "hookName": "SessionStart:clear",
830            "command": "claude-branch-journal",
831            "content": "",
832            "stdout": "",
833            "stderr": "No journal yet for branch 'wide-margins'\n",
834            "exitCode": 0,
835        }))
836        .expect("a hook that printed something is a gloss");
837
838        assert_eq!(gloss.kind, GlossKind::Hook);
839        assert_eq!(gloss.gist.as_deref(), Some("SessionStart:clear"));
840        assert_eq!(gloss.notes, ["claude-branch-journal"]);
841        assert_eq!(
842            gloss.body,
843            [Body::Plain(
844                "No journal yet for branch 'wide-margins'".into()
845            )]
846        );
847    }
848
849    #[test]
850    fn a_hooks_contribution_is_set_once_though_it_is_recorded_twice() {
851        // `content` and `stdout` are the same text: what the hook printed, and
852        // what that put into the session.
853        let gloss = attachment(&json!({
854            "type": "hook_success",
855            "hookName": "SessionStart:clear",
856            "content": "Current git status:\n\n## wide-margins",
857            "stdout": "Current git status:\n\n## wide-margins\n",
858            "stderr": "",
859            "exitCode": 0,
860        }))
861        .expect("a hook that contributed context is a gloss");
862
863        assert_eq!(
864            gloss.body,
865            [Body::Plain("Current git status:\n\n## wide-margins".into())]
866        );
867    }
868
869    #[test]
870    fn a_failing_hook_says_what_it_exited_with() {
871        let gloss = attachment(&json!({
872            "type": "hook_success",
873            "hookName": "PreToolUse:Bash",
874            "command": "claude-read-check",
875            "stdout": "use the Read tool",
876            "exitCode": 2,
877        }))
878        .expect("a hook that printed something is a gloss");
879
880        assert_eq!(gloss.notes, ["claude-read-check", "exit 2"]);
881    }
882
883    #[test]
884    fn hook_output_in_the_control_protocol_is_left_to_the_notes_it_asks_for() {
885        // The JSON contributes nothing itself: what it asks for arrives as the
886        // system-message and additional-context notes beside it.
887        assert_eq!(
888            attachment(&json!({
889                "type": "hook_success",
890                "hookName": "Stop",
891                "command": "claude-stop",
892                "content": "",
893                "stdout": "{\"systemMessage\": \"finishing pass\", \
894                            \"hookSpecificOutput\": {\"additionalContext\": \"…\"}}",
895                "stderr": "",
896                "exitCode": 0,
897            })),
898            None
899        );
900    }
901
902    #[test]
903    fn context_a_hook_injected_is_joined_into_one_document() {
904        let gloss = attachment(&json!({
905            "type": "hook_additional_context",
906            "hookName": "Stop",
907            "content": ["first thing", "second thing"],
908        }))
909        .expect("injected context is a gloss");
910
911        assert_eq!(gloss.gist.as_deref(), Some("Stop"));
912        assert_eq!(gloss.notes, ["added context"]);
913        // A hook printed this, so its own line breaks are kept: `Printed`, not
914        // `Prose`, which would fold a single newline into a space and run a
915        // list of files together.
916        assert_eq!(
917            gloss.body,
918            [Body::Printed("first thing\n\nsecond thing".into())]
919        );
920    }
921
922    #[test]
923    fn a_rule_pulled_into_context_is_named_by_the_path_written_for_a_reader() {
924        let gloss = attachment(&json!({
925            "type": "nested_memory",
926            "path": "/home/jtk/dotfiles/claude/rules/rust.md",
927            "displayPath": "claude/rules/rust.md",
928            "content": {
929                "path": "/home/jtk/dotfiles/claude/rules/rust.md",
930                "type": "User",
931                "content": "# Rust Style Guide\n\nUse the latest stable edition.",
932            },
933        }))
934        .expect("a memory file is a gloss");
935
936        assert_eq!(gloss.kind, GlossKind::Rule);
937        assert_eq!(gloss.gist.as_deref(), Some("claude/rules/rust.md"));
938        assert_eq!(gloss.notes, ["user"]);
939        assert_eq!(
940            gloss.body,
941            [Body::Prose(
942                "# Rust Style Guide\n\nUse the latest stable edition.".into()
943            )]
944        );
945    }
946
947    #[test]
948    fn plan_mode_marks_its_boundaries_and_says_whether_a_plan_was_written() {
949        let entered = attachment(&json!({
950            "type": "plan_mode",
951            "reminderType": "full",
952            "isSubAgent": false,
953            "planFilePath": "/home/jtk/.claude/plans/wide-margins.md",
954            "planExists": false,
955        }))
956        .expect("entering plan mode is a gloss");
957        assert_eq!(entered.kind, GlossKind::Plan);
958        // Nothing has been written to the plan file yet, and a reader holding
959        // only the folio could not open it, so entering says only that.
960        assert_eq!(entered.gist.as_deref(), Some("entered plan mode"));
961        assert!(entered.notes.is_empty());
962
963        let written = attachment(&json!({
964            "type": "plan_mode_exit",
965            "planFilePath": "/home/jtk/.claude/plans/wide-margins.md",
966            "planExists": true,
967        }))
968        .expect("leaving plan mode is a gloss");
969        // Leaving does name the file: a session that writes its plan writes it
970        // with a tool call the folio sets, so the path ties the two together.
971        assert_eq!(
972            written.gist.as_deref(),
973            Some("/home/jtk/.claude/plans/wide-margins.md")
974        );
975        assert_eq!(written.notes, ["left, plan written"]);
976
977        let unwritten = attachment(&json!({
978            "type": "plan_mode_exit",
979            "planFilePath": "/home/jtk/.claude/plans/wide-margins.md",
980            "planExists": false,
981        }))
982        .expect("leaving plan mode is a gloss");
983        assert_eq!(unwritten.notes, ["left, no plan written"]);
984    }
985
986    #[test]
987    fn a_plan_boundary_with_no_file_is_named_by_what_happened() {
988        // A gloss with neither gist nor body is dropped, so the phrase has to
989        // become the gist when there is no path to stand as the subject.
990        let gloss = attachment(&json!({ "type": "plan_mode_exit", "planExists": false }))
991            .expect("leaving plan mode is a gloss even with no file named");
992
993        assert_eq!(gloss.gist.as_deref(), Some("left, no plan written"));
994        assert!(gloss.notes.is_empty());
995    }
996
997    #[test]
998    fn a_reminder_that_plan_mode_is_still_on_is_not_a_boundary() {
999        assert_eq!(
1000            attachment(&json!({
1001                "type": "plan_mode",
1002                "reminderType": "sparse",
1003                "planFilePath": "/home/jtk/.claude/plans/wide-margins.md",
1004                "planExists": true,
1005            })),
1006            None
1007        );
1008    }
1009
1010    #[test]
1011    fn a_file_edited_outside_the_session_is_set() {
1012        let gloss = attachment(&json!({
1013            "type": "edited_text_file",
1014            "filename": "/home/jtk/projects/scriptorium/src/gloss.rs",
1015            "snippet": "9\tfn setting() {}",
1016        }))
1017        .expect("an external edit is a gloss");
1018
1019        assert_eq!(gloss.kind, GlossKind::Note);
1020        assert_eq!(gloss.notes, ["edited outside the session"]);
1021        assert_eq!(gloss.body, [Body::Plain("9\tfn setting() {}".into())]);
1022    }
1023
1024    #[test]
1025    fn scaffolding_the_reader_cannot_act_on_is_not_set() {
1026        for kind in [
1027            "task_reminder",
1028            "skill_listing",
1029            "deferred_tools_delta",
1030            "agent_listing_delta",
1031            "command_permissions",
1032            "date_change",
1033        ] {
1034            assert_eq!(
1035                attachment(&json!({ "type": kind, "content": "…" })),
1036                None,
1037                "{kind} should stay unset"
1038            );
1039        }
1040    }
1041
1042    #[test]
1043    fn a_local_commands_output_is_set_and_an_empty_one_is_not() {
1044        let gloss = system(&json!({
1045            "subtype": "local_command",
1046            "content": "<local-command-stdout>Copied to clipboard</local-command-stdout>",
1047        }))
1048        .expect("a command that printed something is a gloss");
1049
1050        assert_eq!(gloss.kind, GlossKind::Command);
1051        assert_eq!(gloss.body, [Body::Plain("Copied to clipboard".into())]);
1052
1053        assert_eq!(
1054            system(&json!({
1055                "subtype": "local_command",
1056                "content": "<local-command-stdout></local-command-stdout>",
1057            })),
1058            None
1059        );
1060    }
1061
1062    #[test]
1063    fn a_model_swapped_under_the_conversation_is_set() {
1064        let gloss = system(&json!({
1065            "subtype": "model_refusal_fallback",
1066            "originalModel": "claude-fable-5",
1067            "fallbackModel": "claude-opus-4-8",
1068            "content": "The safeguards flagged this message.",
1069        }))
1070        .expect("a fallback is a gloss");
1071
1072        assert_eq!(gloss.gist.as_deref(), Some("model switched"));
1073        assert_eq!(gloss.notes, ["claude-fable-5 → claude-opus-4-8"]);
1074    }
1075
1076    #[test]
1077    fn a_systems_own_bookkeeping_is_not_set() {
1078        for subtype in ["turn_duration", "stop_hook_summary"] {
1079            assert_eq!(
1080                system(&json!({ "subtype": subtype, "content": "…" })),
1081                None,
1082                "{subtype} should stay unset"
1083            );
1084        }
1085    }
1086}