Skip to main content

forge_workspace/
handoff.rs

1//! The handoff brief: forge's own ground truth, and the handing-over
2//! session's judgment.
3//!
4//! The ground truth is derived at swap time from the transcript, git and the
5//! board, so every path, command and number in it is read rather than
6//! remembered. The judgment is the old session's reply to forge's own prompt,
7//! captured before the swap while its context still holds the work.
8
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::SystemTime;
12
13use forge_agent::client::SessionLaunchSettings;
14use forge_primitives::{
15    ContentBlock, HandoffBrief, HandoffCycle, HandoffGitState, HandoffReason, HandoffRecord,
16    HandoffSection, Message, Task, TaskStatus,
17};
18
19use crate::SessionSlot;
20use crate::protocol::{Command, NoticeSeverity, SessionUpdate};
21
22/// How many files and dirty paths the brief names. Past this it starts
23/// re-summarising the session, which is the failure it exists to avoid.
24const FILE_CAP: usize = 10;
25const DIRTY_CAP: usize = 20;
26/// The judgment half's ceiling: room for the 400-word note the prompt asks
27/// for, with its pointer lines, and no more.
28const JUDGMENT_CAP: usize = 5_000;
29
30/// What the seat is, so the brief can say which charter it belongs to
31/// without repeating a charter the launch already carries.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub(crate) enum Role {
34    Lead,
35    Worker { charter_opening: Option<String> },
36}
37
38/// The seat a brief is written for.
39pub(crate) struct HandoffSeat<'a> {
40    pub slot: &'a SessionSlot,
41    pub from: &'a str,
42    pub cwd: &'a Path,
43    pub transcript_path: &'a Path,
44    pub role: Role,
45}
46
47/// The ground truth's sections plus the cycle they end.
48pub(crate) struct GroundTruth {
49    pub sections: Vec<HandoffSection>,
50    pub cycle: HandoffCycle,
51    /// Every path the handover read, which the successor's re-reads are
52    /// measured against. Store-only: no view draws it.
53    pub reads: Vec<String>,
54}
55
56/// How many of the successor's turns are measured.
57pub(crate) const SUCCESSOR_TURNS: u32 = 10;
58
59/// How long a handoff waits for its judgment prompt to start.
60///
61/// A prompt that never starts never produces the turn whose Result would
62/// finish the handoff, and a CLI that advertises no lifecycle frames never
63/// says it started at all - so the bound is the only backstop left once the
64/// cancellation and settlement frames have said nothing.
65pub(crate) const JUDGMENT_DEADLINE: std::time::Duration = std::time::Duration::from_secs(600);
66
67/// The predecessor's read set the successor is measured against is bounded:
68/// a record is not a transcript, and the count only needs the files the two
69/// sessions plausibly meet in.
70const READ_SET_CAP: usize = 200;
71
72/// Build the brief's first half. The transcript read blocks, so it runs off
73/// the actor; the git probe waits on child processes and stays async.
74pub(crate) async fn ground_truth(
75    config_dir: &Path,
76    seat: &HandoffSeat<'_>,
77    board: &[Task],
78    live: Vec<HandoffSection>,
79) -> GroundTruth {
80    let read = {
81        let config_dir = config_dir.to_path_buf();
82        let session_id = seat.from.to_owned();
83        let cwd = seat.cwd.to_path_buf();
84        // The read is the whole transcript; the brief needs the files and calls
85        // in it, which only a pass over the file can answer. **The session's own
86        // rows only**: a sub-agent's reads and edits are the sub-agent's work,
87        // not the predecessor's, and the brief hands the successor the
88        // predecessor's. Sidecar frames also carry no chat row.
89        tokio::task::spawn_blocking(move || {
90            forge_agent::session_history_main_thread(
91                &config_dir,
92                &session_id,
93                &cwd.to_string_lossy(),
94            )
95        })
96        .await
97    };
98    // **An absent transcript is a read that failed, not an empty session.**
99    // The loader answers a missing file with an empty history, so the path
100    // itself is the only thing that can tell the two apart.
101    let read_failed = read.is_err() || !seat.transcript_path.exists();
102    let messages = read.map(|history| history.messages).unwrap_or_default();
103    let git = forge_agent::env::git_state::probe(seat.cwd).await;
104    // **The handoff writes memory itself, so that directory is not the
105    // session's work.** Counting it in the files row would displace the
106    // project files the successor needs, and counting its reads would make
107    // the successor's re-read measurement measure the handoff's own cost.
108    let memory_roots = memory_roots(config_dir, seat.cwd);
109    let mut truth = assemble(seat, &messages, &git, board, &memory_roots, live);
110    if read_failed {
111        // **An empty PART 1 is not a session with nothing in it.** Without
112        // this line the successor reads a blank ground truth as a blank
113        // session, when what happened is that its predecessor's transcript
114        // could not be read at all.
115        truth.sections.insert(
116            0,
117            HandoffSection {
118                label: "note".to_owned(),
119                lines: vec![
120                    "the previous session's transcript could not be read; the files this brief \
121                     would list cannot be read for that reason"
122                        .to_owned(),
123                ],
124            },
125        );
126    }
127    truth
128}
129
130/// The deterministic half, pure over its inputs: what the tests drive.
131pub(crate) fn assemble(
132    seat: &HandoffSeat<'_>,
133    messages: &[Message],
134    git: &HandoffGitState,
135    board: &[Task],
136    memory_roots: &[PathBuf],
137    live: Vec<HandoffSection>,
138) -> GroundTruth {
139    let turns = user_prompt_count(messages);
140    let mut sections = seat_sections(seat, git);
141    sections.push(role_section(&seat.role));
142    sections.push(files_section(messages, git, memory_roots));
143    sections.push(board_section(seat.slot, board));
144    sections.extend(live);
145    sections.push(record_section(seat));
146    GroundTruth { sections, cycle: cycle(messages, turns), reads: read_set(messages, memory_roots) }
147}
148
149/// The directories the handoff's own memory writes land in: the project's
150/// memory dir, and the environment's own where the cross-project notes live.
151///
152/// **Both, not only the project's.** Either one in the files row would
153/// displace the project files the successor needs, and either read would
154/// measure the handoff's own cost as the predecessor's.
155fn memory_roots(config_dir: &Path, cwd: &Path) -> Vec<PathBuf> {
156    let project = forge_agent::userdata::memory::project_memory_path(config_dir, cwd);
157    [project.parent().map(Path::to_path_buf), Some(config_dir.join("memory"))]
158        .into_iter()
159        .flatten()
160        .collect()
161}
162
163/// Whether `path` is inside one of the memory directories.
164///
165/// **The handoff turn writes memory itself.** Counting those paths would
166/// displace the project files the successor needs, and counting their reads
167/// would measure the successor against the handoff's own cost.
168fn is_memory_path(path: &str, memory_roots: &[PathBuf]) -> bool {
169    // **The same file can be spelled more than one way.** A `~/...` path and a
170    // symlinked root both name the directory the memory lives in, so every
171    // spelling on either side is compared.
172    let forms = |path: &Path| {
173        let spelled = path.to_string_lossy().into_owned();
174        let expanded = match spelled.strip_prefix("~/") {
175            Some(rest) => dirs::home_dir().map_or_else(
176                || spelled.clone(),
177                |home| home.join(rest).to_string_lossy().into_owned(),
178            ),
179            None => spelled.clone(),
180        };
181        let canonical = std::fs::canonicalize(path)
182            .or_else(|_| std::fs::canonicalize(&expanded))
183            .map(|path| path.to_string_lossy().into_owned())
184            .ok();
185        let mut forms = vec![spelled, expanded];
186        if let Some(canonical) = canonical {
187            forms.push(canonical);
188        }
189        forms
190    };
191    let given = forms(Path::new(path));
192    memory_roots.iter().any(|root| {
193        let roots = forms(root);
194        roots.iter().any(|root| {
195            given.iter().any(|path| {
196                path == root
197                    || path.strip_prefix(root.as_str()).is_some_and(|rest| rest.starts_with('/'))
198            })
199        })
200    })
201}
202
203/// Every path the session read, in transcript order, deduped and capped.
204fn read_set(messages: &[Message], memory_roots: &[PathBuf]) -> Vec<String> {
205    let mut reads: Vec<String> = Vec::new();
206    for (_, name, input) in tool_uses(messages) {
207        if !matches!(name, "Read" | "NotebookRead") {
208            continue;
209        }
210        let Some(path) = input.get("file_path").or_else(|| input.get("notebook_path")) else {
211            continue;
212        };
213        let Some(path) = path.as_str() else { continue };
214        if is_memory_path(path, memory_roots) {
215            continue;
216        }
217        if !reads.iter().any(|seen| seen == path) {
218            reads.push(path.to_owned());
219        }
220        if reads.len() >= READ_SET_CAP {
221            break;
222        }
223    }
224    reads
225}
226
227/// How the successor's first turns went, from its own transcript.
228///
229/// The first user row is the brief forge gave it, not a turn of its own, so
230/// it is skipped - and it must be, because the judgment half can carry a
231/// "do not" rule that would otherwise read as a correction. A reply the
232/// user typed later is measured for correction cues; the count is a proxy,
233/// and the anchor is that it only ever counts words a person wrote.
234pub(crate) fn successor_counts(
235    messages: &[Message],
236    predecessor_reads: &[String],
237) -> forge_primitives::HandoffSuccessor {
238    let mut turns = 0;
239    let mut reads: Vec<String> = Vec::new();
240    let mut questions = 0;
241    let mut corrections = 0;
242    for message in messages {
243        match message {
244            Message::User { message: envelope, synthetic, .. } if !*synthetic => {
245                let Some(text) = text_of(&envelope.content) else { continue };
246                // **The machine's own rows are not turns.** The brief, a task
247                // notice, an interrupt marker and the no-output nudge all
248                // arrive as ordinary user rows, and counting one would
249                // measure the successor against the harness's words.
250                if is_machine_prompt(message) {
251                    continue;
252                }
253                // The window closes when an eleventh prompt arrives: that
254                // turn is the successor's own work, not the brief's doing,
255                // and the tenth turn's own frames are already counted.
256                if turns >= SUCCESSOR_TURNS {
257                    break;
258                }
259                turns += 1;
260                if turns > 1 && states_a_correction(&text) {
261                    corrections += 1;
262                }
263            }
264            Message::Assistant { message: envelope, .. } => {
265                for block in &envelope.content {
266                    let ContentBlock::ToolUse { name, input, .. } = block else { continue };
267                    match name.as_str() {
268                        "AskUserQuestion" => questions += 1,
269                        "Read" | "NotebookRead" => {
270                            let path = input
271                                .get("file_path")
272                                .or_else(|| input.get("notebook_path"))
273                                .and_then(serde_json::Value::as_str);
274                            if let Some(path) = path
275                                && predecessor_reads.iter().any(|read| read == path)
276                                && !reads.iter().any(|seen| seen == path)
277                            {
278                                reads.push(path.to_owned());
279                            }
280                        }
281                        _ => {}
282                    }
283                }
284            }
285            _ => {}
286        }
287    }
288    forge_primitives::HandoffSuccessor {
289        turns,
290        reread_files: u32::try_from(reads.len()).unwrap_or(u32::MAX),
291        questions,
292        corrections,
293    }
294}
295
296/// The counts, or `None` when the transcript read answered nothing.
297///
298/// **A failed read is not a successor that re-read nothing.** The read is a
299/// whole transcript; a successor with turns to count cannot have an empty
300/// one, so an empty answer is a failure and writing zeros for it would read
301/// as the best possible brief.
302fn successor_measurement(
303    messages: &[Message],
304    predecessor_reads: &[String],
305) -> Option<forge_primitives::HandoffSuccessor> {
306    if messages.is_empty() {
307        return None;
308    }
309    let counts = successor_counts(messages, predecessor_reads);
310    // A transcript holding only the brief and the assistant's rows has no
311    // turn to measure: zeros would read as a successor that re-read nothing,
312    // which is the best possible brief.
313    (counts.turns > 0).then_some(counts)
314}
315
316/// Whether a user prompt reads as a correction of what came before.
317fn states_a_correction(text: &str) -> bool {
318    let lower = text.to_lowercase();
319    [
320        "actually",
321        "i said",
322        "as i said",
323        "not what i asked",
324        "that is wrong",
325        "that's wrong",
326        "you missed",
327        "you forgot",
328    ]
329    .iter()
330    .any(|cue| lower.contains(cue))
331}
332
333/// The seat's facts, one section per label: the brief draws a table, and a
334/// fact carried inside another section's value has no row of its own.
335fn seat_sections(seat: &HandoffSeat<'_>, git: &HandoffGitState) -> Vec<HandoffSection> {
336    let mut sections =
337        vec![fact("seat", seat.slot.display()), fact("cwd", seat.cwd.display().to_string())];
338    sections.push(match (git.branch.as_deref(), git.head.as_deref()) {
339        (Some(branch), Some(head)) => fact("branch", format!("{branch} @ {head}")),
340        (Some(branch), None) => fact("branch", branch.to_owned()),
341        (None, _) => fact("branch", "not a git repository".to_owned()),
342    });
343    sections
344}
345
346fn fact(label: &str, line: String) -> HandoffSection {
347    HandoffSection { label: label.to_owned(), lines: vec![line] }
348}
349
350fn role_section(role: &Role) -> HandoffSection {
351    let lines = match role {
352        Role::Lead => vec!["project lead".to_owned()],
353        Role::Worker { charter_opening } => match charter_opening {
354            Some(opening) => vec![format!("worker, charter opens: {opening}")],
355            None => vec!["worker".to_owned()],
356        },
357    };
358    HandoffSection { label: "role".to_owned(), lines }
359}
360
361fn files_section(
362    messages: &[Message],
363    git: &HandoffGitState,
364    memory_roots: &[PathBuf],
365) -> HandoffSection {
366    let mut changed: Vec<String> = Vec::new();
367    let mut read_paths: Vec<String> = Vec::new();
368    for (_, name, input) in tool_uses(messages) {
369        let Some(path) = input.get("file_path").or_else(|| input.get("notebook_path")) else {
370            continue;
371        };
372        let Some(path) = path.as_str() else { continue };
373        if is_memory_path(path, memory_roots) {
374            continue;
375        }
376        let bucket = match name {
377            "Edit" | "Write" | "NotebookEdit" | "MultiEdit" => &mut changed,
378            "Read" | "NotebookRead" => &mut read_paths,
379            _ => continue,
380        };
381        if !bucket.iter().any(|seen| seen == path) {
382            bucket.push(path.to_owned());
383        }
384    }
385    let mut lines = Vec::new();
386    if !changed.is_empty() {
387        lines.push(list_line("changed", &changed, FILE_CAP));
388    }
389    if !read_paths.is_empty() {
390        lines.push(list_line("read", &read_paths, FILE_CAP));
391    }
392    if git.dirty.is_empty() {
393        lines.push("uncommitted: none".to_owned());
394    } else {
395        let shown = tail(&git.dirty, DIRTY_CAP);
396        // The count is the tree's, not the list's: the probe caps what it
397        // keeps, and a brief that said 20 for 60 paths loses the one thing
398        // the line is for.
399        let total = if git.dirty_total > 0 {
400            usize::try_from(git.dirty_total).unwrap_or(usize::MAX)
401        } else {
402            git.dirty.len()
403        };
404        let omitted = total.saturating_sub(shown.len());
405        if omitted > 0 {
406            lines.push(format!(
407                "uncommitted: {total} paths, newest {} shown ({omitted} omitted)",
408                shown.len()
409            ));
410        } else {
411            lines.push(format!("uncommitted: {total} paths"));
412        }
413        lines.extend(shown);
414    }
415    if !git.diff.is_empty() {
416        lines.push("diff:".to_owned());
417        lines.extend(git.diff.iter().cloned());
418    }
419    HandoffSection { label: "files".to_owned(), lines }
420}
421
422fn board_section(slot: &SessionSlot, board: &[Task]) -> HandoffSection {
423    let mut lines = Vec::new();
424    let mut refs: Vec<String> = Vec::new();
425    for task in board.iter().filter(|task| task.owner.as_ref() == Some(slot)) {
426        lines.push(format!("{} ({})", task.subject, status_name(task.status)));
427        for link in &task.links {
428            let entry = format!("{}: {}", link_kind(link.kind), link.target);
429            if !refs.contains(&entry) {
430                refs.push(entry);
431            }
432        }
433    }
434    if lines.is_empty() {
435        lines.push("no rows owned".to_owned());
436    }
437    if !refs.is_empty() {
438        lines.push("links:".to_owned());
439        lines.extend(tail(&refs, FILE_CAP));
440    }
441    HandoffSection { label: "board".to_owned(), lines }
442}
443
444/// What is still running when the seat hands over: live sub-agents,
445/// background tasks, monitors and the process tree.
446///
447/// **The successor has to know what to resume.** The note is an index and may
448/// mention it, but the live set is a fact forge reads itself, so it rides in
449/// the brief whether or not the note names it.
450pub(crate) fn live_work_sections(
451    domain: Option<&crate::domain_session::DomainSession>,
452) -> Vec<HandoffSection> {
453    // **An absent domain is unknown, not empty.** Every row says the same
454    // thing rather than claiming nothing was running.
455    let Some(domain) = domain else {
456        return ["agents", "background", "monitors", "processes"]
457            .into_iter()
458            .map(|label| fact(label, "not read".to_owned()))
459            .collect();
460    };
461    let agents: Vec<String> = domain
462        .card_tracker
463        .cards()
464        .into_iter()
465        .filter(|card| card.running)
466        .map(|card| {
467            let kind = card.agent_type.unwrap_or_else(|| "agent".to_owned());
468            let backgrounded = if card.backgrounded { ", backgrounded" } else { "" };
469            format!("{kind}: {} ({} calls{backgrounded})", card.name, card.calls)
470        })
471        .collect();
472    let tasks: Vec<String> = domain
473        .background_tasks
474        .iter()
475        .map(|task| format!("{}: {} [{}]", task.task_type, task.description, task.task_id))
476        .collect();
477    let watching: Vec<String> = domain
478        .monitors
479        .iter()
480        .filter(|monitor| !monitor.status.is_terminal())
481        .map(|monitor| {
482            format!("{} [{}]: {}", monitor.description, monitor.tool_use_id, monitor.command)
483        })
484        .collect();
485    let ps: Vec<String> = match &domain.process_snapshot {
486        Some(snapshot) => snapshot
487            .processes
488            .iter()
489            .take(FILE_CAP)
490            .map(|process| format!("{} {}: {}", process.pid, process.name, process.command))
491            .collect(),
492        None => vec!["not scanned".to_owned()],
493    };
494    let section = |label: &str, lines: Vec<String>| HandoffSection {
495        label: label.to_owned(),
496        lines: if lines.is_empty() { vec!["none running".to_owned()] } else { lines },
497    };
498    vec![
499        section("agents", agents),
500        section("background", tasks),
501        section("monitors", watching),
502        section("processes", ps),
503    ]
504}
505
506fn record_section(seat: &HandoffSeat<'_>) -> HandoffSection {
507    HandoffSection {
508        label: "record".to_owned(),
509        lines: vec![
510            seat.transcript_path.display().to_string(),
511            format!("session {}", seat.from),
512            "anything this brief does not carry is in that transcript, not guessed".to_owned(),
513        ],
514    }
515}
516
517fn status_name(status: TaskStatus) -> &'static str {
518    match status {
519        TaskStatus::Pending => "pending",
520        TaskStatus::InProgress => "in progress",
521        TaskStatus::Waiting => "waiting",
522        TaskStatus::Completed => "completed",
523        TaskStatus::Failed => "failed",
524        TaskStatus::Canceled => "canceled",
525    }
526}
527
528fn link_kind(kind: forge_primitives::tasks::LinkKind) -> &'static str {
529    use forge_primitives::tasks::LinkKind;
530    match kind {
531        LinkKind::Spec => "spec",
532        LinkKind::Plan => "plan",
533        LinkKind::Issue => "issue",
534        LinkKind::Pr => "pr",
535        LinkKind::Branch => "branch",
536        LinkKind::Path => "path",
537        LinkKind::Other => "link",
538    }
539}
540
541/// The cycle a handoff ends: one request per assistant row that carries a
542/// usage block, one turn per real user prompt, and the prompt tokens the
543/// model read.
544fn cycle(messages: &[Message], turns: usize) -> HandoffCycle {
545    let mut requests = 0;
546    let mut prompt_tokens = 0;
547    for message in messages {
548        let Message::Assistant { message: envelope, .. } = message else { continue };
549        let Some(usage) = &envelope.usage else { continue };
550        requests += 1;
551        prompt_tokens +=
552            usage.input_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens;
553    }
554    HandoffCycle { requests, turns: u32::try_from(turns).unwrap_or(u32::MAX), prompt_tokens }
555}
556
557/// How many real user prompts the transcript holds. A synthetic row is the
558/// harness speaking, a row with no text is a tool result, and a machine
559/// prompt is forge's own words.
560fn user_prompt_count(messages: &[Message]) -> usize {
561    messages
562        .iter()
563        .filter(|message| {
564            let Message::User { message: envelope, synthetic, .. } = message else {
565                return false;
566            };
567            !*synthetic && !is_machine_prompt(message) && text_of(&envelope.content).is_some()
568        })
569        .count()
570}
571
572/// Whether a user row is forge's own prompt rather than a person's or a
573/// peer's.
574///
575/// **A forged prompt is an ordinary user row in the transcript**, and the
576/// forged id is the only mark the CLI keeps. Without this the judgment
577/// prompt and the brief a session arrived by would count as its own turns.
578fn is_machine_prompt(message: &Message) -> bool {
579    let Message::User { uuid, message: envelope, .. } = message else { return false };
580    if uuid.as_deref().is_some_and(|id| id.starts_with("forge_nudge_")) {
581        return true;
582    }
583    let Some(text) = text_of(&envelope.content) else { return false };
584    let text = text.trim_start();
585    text.starts_with("HANDOFF - ")
586        || text.starts_with(MACHINE_PREFIX)
587        || crate::conversation_turns::is_task_notice(text)
588        || crate::conversation_turns::is_interrupt_marker(text)
589        // The CLI's own nudge after an empty response; the client draws it as
590        // its own line from the same opening (`noOutputNudge`).
591        || text.starts_with("[Your previous response had no visible output.")
592}
593
594/// Every tool call's id, name and input, in transcript order.
595fn tool_uses(messages: &[Message]) -> impl Iterator<Item = (&str, &str, &serde_json::Value)> {
596    messages.iter().flat_map(|message| {
597        let Message::Assistant { message: envelope, parent_tool_use_id, .. } = message else {
598            return Vec::new().into_iter();
599        };
600        // A sub-agent's calls are the instance's, not the session's: they
601        // never name the predecessor's files or reads in the brief.
602        if forge_primitives::messages::names_a_dispatch(parent_tool_use_id.as_deref()) {
603            return Vec::new().into_iter();
604        }
605        envelope
606            .content
607            .iter()
608            .filter_map(|block| match block {
609                ContentBlock::ToolUse { id, name, input, .. } => {
610                    Some((id.as_str(), name.as_str(), input))
611                }
612                _ => None,
613            })
614            .collect::<Vec<_>>()
615            .into_iter()
616    })
617}
618
619/// The plain text of an assistant turn's blocks, when it holds any.
620pub(crate) fn assistant_text(blocks: &[ContentBlock]) -> Option<String> {
621    text_of(blocks)
622}
623
624/// The plain text of a block list, when it holds any.
625fn text_of(blocks: &[ContentBlock]) -> Option<String> {
626    let parts: Vec<&str> = blocks
627        .iter()
628        .filter_map(|block| match block {
629            ContentBlock::Text { text, .. } => Some(text.as_str()),
630            _ => None,
631        })
632        .collect();
633    let text = parts.join("\n");
634    (!text.trim().is_empty()).then_some(text)
635}
636
637/// A list line: everything when it fits, and when it does not, the first
638/// entry plus the newest `cap - 1` with the omission stated.
639///
640/// **The first is kept on purpose.** The file a successor first changed, or
641/// the command that started the work, is usually the one it will need again;
642/// a newest-only cut loses exactly that one.
643fn list_line(label: &str, items: &[String], cap: usize) -> String {
644    let (shown, omitted) = first_and_newest(items, cap);
645    if omitted == 0 {
646        return format!("{label}: {}", items.join(", "));
647    }
648    format!(
649        "{label}: {} shown of {} ({omitted} omitted), first and newest: {}",
650        shown.len(),
651        items.len(),
652        shown.iter().map(|item| item.as_str()).collect::<Vec<_>>().join(", "),
653    )
654}
655
656/// The first entry plus the newest `cap - 1`, and how many that leaves out.
657fn first_and_newest<T>(items: &[T], cap: usize) -> (Vec<&T>, usize) {
658    if items.len() <= cap {
659        return (items.iter().collect(), 0);
660    }
661    let newest = cap.saturating_sub(1);
662    let mut shown: Vec<&T> = Vec::with_capacity(cap);
663    shown.push(&items[0]);
664    shown.extend(items[items.len() - newest..].iter());
665    let omitted = items.len() - shown.len();
666    (shown, omitted)
667}
668
669/// The last `cap` entries.
670fn tail<T: Clone>(items: &[T], cap: usize) -> Vec<T> {
671    items[items.len().saturating_sub(cap)..].to_vec()
672}
673
674/// Parse the handing-over session's reply into the judgment half.
675///
676/// **The note's shape is the model's, so the parser follows it.** A heading
677/// line opens a section and every other line is body; a reply with no heading
678/// at all is one section. The row draws what the successor was given, not an
679/// idealised version of it.
680pub(crate) fn parse_judgment(raw: &str) -> Vec<HandoffSection> {
681    let mut sections: Vec<HandoffSection> = Vec::new();
682    for line in raw.lines() {
683        let trimmed = line.trim();
684        if let Some((heading, rest)) = judgment_heading(trimmed) {
685            sections.push(HandoffSection { label: heading, lines: vec![rest] });
686            continue;
687        }
688        match sections.last_mut() {
689            Some(section) => section.lines.push(trimmed.to_owned()),
690            None if !trimmed.is_empty() => {
691                sections.push(HandoffSection {
692                    label: "note".to_owned(),
693                    lines: vec![trimmed.to_owned()],
694                });
695            }
696            None => {}
697        }
698    }
699    for section in &mut sections {
700        section.lines.retain(|line| !line.is_empty());
701    }
702    cap_judgment(sections)
703}
704
705/// Whether a reply is a note. **Nothing is prescribed about its shape, so any
706/// reply with content is one**; an empty reply or a headings-only shell is
707/// not, and the caller answers that with [`missing_note`].
708pub(crate) fn parses_as_note(raw: &str) -> bool {
709    !parse_judgment(raw).is_empty()
710}
711
712/// The judgment half when no exit note arrived. **Never the turn's prose
713/// dressed as the note**: the successor is told the note is missing and where
714/// the session's own account lives.
715pub(crate) fn missing_note() -> Vec<HandoffSection> {
716    vec![HandoffSection {
717        label: "note".to_owned(),
718        lines: vec![
719            "the handoff note did not arrive; this session's transcript is named in the record \
720             above, and anything it left is there"
721                .to_owned(),
722        ],
723    }]
724}
725
726/// The heading a line opens, and its body when the line carries one.
727///
728/// A model writes a heading as `Where things stand: ...`, `## Next`,
729/// `**Next:** ...`, `**Next** run the gate.`, `## Next.` or `1. **Next**`. The
730/// label still has to open the line: a marked line, or a short title-shaped
731/// label before a colon, is a heading. A sentence that carries a colon in the
732/// middle stays body text, and a marked label still has to read as one -
733/// `**I fixed it. The gate:** is green` stays body.
734fn judgment_heading(line: &str) -> Option<(String, String)> {
735    let trimmed = line.trim();
736    let unbulleted = trimmed.trim_start_matches(['#', '*', '-', ' ']);
737    // The furniture comes off before the ordered marker is looked for, so
738    // `## 1. Next` opens one heading.
739    let ordered = ordered_marker(unbulleted);
740    let marked = trimmed.starts_with('#')
741        || trimmed.starts_with("**")
742        || trimmed.trim_start_matches(['-', ' ']).starts_with("**")
743        || ordered.is_some_and(|rest| rest.starts_with("**"));
744    let text = match ordered {
745        Some(rest) => rest.trim_start_matches(['#', '*', '-', ' ']),
746        None => unbulleted,
747    };
748    if text.is_empty() {
749        return None;
750    }
751    let emphasis = text.split_once("**").filter(|(_, rest)| !rest.trim().is_empty());
752    let colon = text.split_once(':');
753    let (label, rest) = match (marked, emphasis) {
754        // **Whichever delimiter opens first ends the label.** A colon before
755        // the closing emphasis wins, so `Where things stand: the **cut** is
756        // in` keeps its whole label.
757        (true, Some((label, rest))) => match colon {
758            Some((colon_label, colon_rest)) if colon_label.len() < label.len() => {
759                (colon_label, Some(colon_rest))
760            }
761            _ => (label, Some(rest)),
762        },
763        _ => match colon {
764            Some((label, rest)) => (label, Some(rest)),
765            None if marked => (text, None),
766            None => return None,
767        },
768    };
769    // `**Now:**` closes the emphasis around the label. Only that literal pair
770    // comes off, so a body's own `*emphasis*` survives.
771    let label =
772        label.trim_end_matches("**").trim().trim_end_matches(':').trim_end_matches('.').trim();
773    if !heading_label(label, marked) {
774        return None;
775    }
776    let body = rest.map_or_else(String::new, |rest| {
777        // The label's closing emphasis comes off only when the body begins
778        // with it, then the delimiter's own colon (`**Next**: run`). A body
779        // that opens with its own bold or colon keeps it (`Next: **run**`,
780        // `Next: ::1`).
781        let rest = strip_emphasis(rest);
782        rest.strip_prefix(':').unwrap_or(rest).trim().to_owned()
783    });
784    Some((label.to_owned(), body))
785}
786
787/// Whether `label` reads as a heading rather than the front of a sentence.
788/// A marked line may be a longer title; a bare one has to be short and
789/// title-shaped.
790fn heading_label(label: &str, marked: bool) -> bool {
791    let chars = label.chars().count();
792    if chars == 0 || chars > if marked { 60 } else { 40 } {
793        return false;
794    }
795    if label.contains(['.', '!', '?', ',']) {
796        return false;
797    }
798    marked
799        || label.chars().next().is_some_and(char::is_uppercase)
800            && label.chars().all(|c| c.is_alphanumeric() || c == ' ' || c == '\'' || c == '-')
801}
802
803/// The text after a leading `1.` / `1)` marker, or `None` when there is none.
804///
805/// A list marker is followed by a space, which is what keeps `2024 report`
806/// and `1.5 million` prose.
807fn ordered_marker(text: &str) -> Option<&str> {
808    let digits = text.bytes().take_while(u8::is_ascii_digit).count();
809    if digits == 0 {
810        return None;
811    }
812    let rest = text[digits..].strip_prefix('.').or_else(|| text[digits..].strip_prefix(')'))?;
813    rest.starts_with(' ').then(|| rest.trim_start())
814}
815
816/// The label's own closing `**`, and only that literal pair.
817fn strip_emphasis(text: &str) -> &str {
818    text.strip_prefix("**").unwrap_or(text)
819}
820
821/// Keep the whole half under its ceiling. A cut is stated - the brief never
822/// silently loses the tail of what the old session wrote.
823fn cap_judgment(mut sections: Vec<HandoffSection>) -> Vec<HandoffSection> {
824    let mut budget = JUDGMENT_CAP;
825    let mut kept: Vec<HandoffSection> = Vec::new();
826    let mut cut = false;
827    for section in sections.drain(..) {
828        let mut lines = Vec::new();
829        for line in section.lines {
830            let cost = line.chars().count() + 1;
831            if cost > budget {
832                cut = true;
833                break;
834            }
835            budget -= cost;
836            lines.push(line);
837        }
838        if !lines.is_empty() {
839            kept.push(HandoffSection { label: section.label, lines });
840        }
841        if cut {
842            break;
843        }
844    }
845    if cut {
846        // The cut is stated even when nothing fit: a half that silently
847        // vanished would read as a reply that said nothing.
848        match kept.last_mut() {
849            Some(section) => section
850                .lines
851                .push("[the rest of this reply is in the transcript named above]".to_owned()),
852            None => kept.push(HandoffSection {
853                label: "note".to_owned(),
854                lines: vec![
855                    "[the reply was longer than the brief allows; it is in the transcript \
856                     named above]"
857                        .to_owned(),
858                ],
859            }),
860        }
861    }
862    kept
863}
864
865/// The brief as the successor receives it.
866///
867/// **Which half is authoritative is stated first.** PART 1 is read off the
868/// transcript, git and the board, so it is the half the successor trusts for
869/// facts; PART 2 is its predecessor's own account, written in whatever shape
870/// that session chose.
871pub(crate) fn brief_text(pair: &str, brief: &forge_primitives::HandoffBrief) -> String {
872    let mut out = format!("HANDOFF - {pair}\n");
873    // **A source row is forge's sentence inside the session's half**, so both
874    // the precedence line and the part header name the exception rather than
875    // letting it read as the predecessor's words.
876    let sourced = brief.judgment.iter().any(|section| section.label == "source");
877    out.push_str(if sourced {
878        "\nPART 1 is forge's own ground truth and is authoritative for facts; PART 2 is the \
879         handing-over session's own words, apart from the source row forge added.\n"
880    } else {
881        "\nPART 1 is forge's own ground truth and is authoritative for facts; PART 2 is the \
882         handing-over session's own words.\n"
883    });
884    out.push_str("\nPART 1: GROUND TRUTH (written by forge, not by a model)\n");
885    render_sections(&mut out, &brief.ground_truth);
886    if sourced {
887        out.push_str(
888            "\nPART 2: JUDGMENT (the source row is forge's; the rest is the session's own \
889             words)\n",
890        );
891    } else {
892        out.push_str("\nPART 2: JUDGMENT (written by the session that is handing over)\n");
893    }
894    render_sections(&mut out, &brief.judgment);
895    out
896}
897
898fn render_sections(out: &mut String, sections: &[HandoffSection]) {
899    use std::fmt::Write as _;
900    for section in sections {
901        for (at, line) in section.lines.iter().enumerate() {
902            let _ = if at == 0 {
903                writeln!(out, "{}: {line}", section.label)
904            } else {
905                writeln!(out, "    {line}")
906            };
907        }
908        if section.lines.is_empty() {
909            let _ = writeln!(out, "{}:", section.label);
910        }
911    }
912}
913
914/// The prompt's opening. `is_machine_prompt` recognizes forge's own judgment
915/// prompt by this prefix, so whatever procedure the turn runs, it opens here.
916/// The prefix `is_machine_prompt` matches, shared so the prompt and the
917/// matcher cannot drift apart.
918const MACHINE_PREFIX: &str = "A handoff is starting for this session:";
919
920/// The opening, built from the matcher's prefix so the two cannot drift.
921fn judgment_opening() -> String {
922    format!(
923        "{MACHINE_PREFIX} forge will replace it with a new session that continues the work. \
924         This turn has two jobs, in this order, and it ends on the text that becomes the \
925         judgment half of the brief the new session is given."
926    )
927}
928
929/// What the turn is told to do: run the skill, then exit through the tool.
930const JUDGMENT_BODY: &str = "\
931Invoke the handoff skill and follow it. It completes this session's learnings \
932into memory, records and stops what is still running so the new session can \
933resume it, then calls `agents__handoff` with the note. If this environment has \
934no such skill, do those three steps from this prompt: memory first, then close \
935out what is running, then call `agents__handoff` with the note.";
936
937/// Forge's output contract for the note the exit call carries.
938///
939/// **The minimum, not a template.** Forge asks for the continuation the next
940/// session needs and no shape to write it in, so the note can read however its
941/// author finds clearest.
942const JUDGMENT_CONTRACT: &str = "\
943Write the continuation the next session needs, in whatever shape reads best - \
944there is no set of headings to fill in. It must carry: where things stand, and \
945what is done and verified; what is in flight, with the row, PR, branch or \
946session id that names it; what you tried that failed, and what stopped it; and \
947the exact next action, with the file, command or row it starts at. Point at \
948what you recorded instead of retelling it: the memory files by name, the rolling \
949handoff document, the board rows by id, the PR numbers, the transcript. Never \
950write a key, token, password or personal data into the note. This note is Step \
9512's handoff document; pass the same text to `agents__handoff` as `brief`. Keep \
952it under 400 words.";
953
954/// The judgment prompt the turn runs: forge's own words, the skill it names,
955/// and the output contract for the note the exit call carries.
956pub(crate) fn judgment_prompt() -> String {
957    format!("{}\n\n{JUDGMENT_BODY}\n\n{JUDGMENT_CONTRACT}", judgment_opening())
958}
959
960/// The handoff skill forge ships and installs into the config dir.
961const SKILL: &str = include_str!("handoff/SKILL.md");
962
963/// The wizard skill's procedure, and the bash library its stages ride.
964const WIZARD_SKILL: &str = include_str!("wizard/SKILL.md");
965const WIZARD_TEMPLATE: &str = include_str!("wizard/template.sh");
966
967/// One skill forge ships: its name, every file that lands in
968/// `<config_dir>/skills/<name>/`, and the files a previous version
969/// installed that the current asset no longer carries.
970pub struct ShippedSkill {
971    pub name: &'static str,
972    pub files: &'static [(&'static str, &'static str)],
973    /// **Only these names, and a temp file the installer itself left
974    /// behind, are ever swept.** The skills directory is the person's own as
975    /// well as forge's: anything else they put beside ours is theirs and is
976    /// left alone.
977    pub retired: &'static [&'static str],
978}
979
980const HANDOFF_FILES: &[(&str, &str)] = &[("SKILL.md", SKILL)];
981const WIZARD_FILES: &[(&str, &str)] =
982    &[("SKILL.md", WIZARD_SKILL), ("template.sh", WIZARD_TEMPLATE)];
983
984/// Every skill forge installs at boot.
985///
986/// **Handoff stays first and keeps its installed path.** Its directory and
987/// bytes are what an existing config dir already holds and what the handoff
988/// prompt invokes; the wizard is a second directory beside it.
989const SHIPPED_SKILLS: &[ShippedSkill] = &[
990    ShippedSkill { name: "handoff", files: HANDOFF_FILES, retired: &[] },
991    ShippedSkill { name: "wizard", files: WIZARD_FILES, retired: &[] },
992];
993
994/// The skills forge installs at boot.
995pub fn shipped_skills() -> &'static [ShippedSkill] {
996    SHIPPED_SKILLS
997}
998
999/// How an install left a skill on disk.
1000#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1001pub enum SkillInstall {
1002    Created,
1003    Replaced,
1004    /// The content was already current; only a `*.tmp` leftover or a
1005    /// retired file was removed. **No file was written**, so a boot that
1006    /// sees this must not say the skill was written.
1007    Swept,
1008    Unchanged,
1009}
1010
1011/// The directory one shipped skill installs into:
1012/// `<config_dir>/skills/<name>/`.
1013pub fn skill_dir(config_dir: &Path, name: &str) -> PathBuf {
1014    config_dir.join("skills").join(name)
1015}
1016
1017/// The file a skill's procedure lands in:
1018/// `<config_dir>/skills/<name>/SKILL.md`.
1019pub fn skill_path(config_dir: &Path, name: &str) -> PathBuf {
1020    skill_dir(config_dir, name).join("SKILL.md")
1021}
1022
1023/// Install one shipped skill into [`skill_dir`].
1024///
1025/// **forge.toml is not written by this path; these skill directories are
1026/// forge's own inside the config dir.** Every file is compared byte for byte
1027/// and only the ones that differ are rewritten, all staged as temp files
1028/// first and renamed together, so a failed write never lands a half-updated
1029/// skill. A failed second rename can still leave the files split, and the
1030/// next boot finishes it. A temp file this installer left behind, or a name
1031/// the skill retired, is removed; nothing else in the directory is, because
1032/// the person's own files live there too.
1033pub fn install_skill(config_dir: &Path, skill: &ShippedSkill) -> std::io::Result<SkillInstall> {
1034    let dir = skill_dir(config_dir, skill.name);
1035    // **A symlinked skill directory is refused, not followed.** Writing
1036    // through it would put forge's files wherever the link points, and the
1037    // sweep would read outside the config dir.
1038    if let Ok(metadata) = std::fs::symlink_metadata(&dir)
1039        && metadata.file_type().is_symlink()
1040    {
1041        tracing::warn!(
1042            target: "forge_workspace::skills",
1043            event_name = "skill_dir_is_a_symlink",
1044            skill = skill.name,
1045            path = %dir.display(),
1046            "the skill directory is a symlink; nothing is installed through it",
1047        );
1048        return Err(std::io::Error::new(
1049            std::io::ErrorKind::InvalidInput,
1050            format!("{}: the skill directory is a symlink", dir.display()),
1051        ));
1052    }
1053    let mut held_a_copy = false;
1054    let mut differing: Vec<(&'static str, &'static str)> = Vec::new();
1055    for (name, contents) in skill.files {
1056        match std::fs::read(dir.join(name)) {
1057            Ok(existing) => {
1058                held_a_copy = true;
1059                if existing != contents.as_bytes() {
1060                    differing.push((name, contents));
1061                }
1062            }
1063            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1064                differing.push((name, contents));
1065            }
1066            Err(error) => return Err(named(&error, &dir.join(name))),
1067        }
1068    }
1069
1070    let sweep = sweep_candidates(&dir, skill);
1071    let outcome = if !held_a_copy {
1072        SkillInstall::Created
1073    } else if differing.is_empty() && sweep.is_empty() {
1074        return Ok(SkillInstall::Unchanged);
1075    } else if differing.is_empty() {
1076        // **A sweep is not a write.** The content was already current, so
1077        // the boot's line must not claim the skill was written.
1078        SkillInstall::Swept
1079    } else {
1080        SkillInstall::Replaced
1081    };
1082
1083    std::fs::create_dir_all(&dir)?;
1084    // **All content is staged before any of it is renamed into place.** A
1085    // two-file skill whose second write failed would otherwise leave the
1086    // first file new and the second old.
1087    let mut staged: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(differing.len());
1088    for (name, contents) in &differing {
1089        let path = dir.join(name);
1090        let temp = dir.join(format!("{name}.tmp"));
1091        if let Err(error) = std::fs::write(&temp, contents.as_bytes()) {
1092            let _ = std::fs::remove_file(&temp);
1093            remove_all(&staged);
1094            return Err(named(&error, &path));
1095        }
1096        staged.push((temp, path));
1097    }
1098    for (temp, path) in &staged {
1099        // **Nothing half-written is left behind.** The temps sit in the
1100        // config dir a sync daemon watches, so a failure removes every one.
1101        if let Err(error) = std::fs::rename(temp, path) {
1102            remove_all(&staged);
1103            return Err(named(&error, path));
1104        }
1105    }
1106
1107    // **A removal that fails is not an install that failed.** The new bytes
1108    // are already on disk; the leftover is retried on the next boot, and a
1109    // WARN says so without sending the boot down its failure line.
1110    let mut removed = 0_usize;
1111    for path in &sweep {
1112        match std::fs::remove_file(path) {
1113            Ok(()) => {
1114                removed += 1;
1115                tracing::debug!(
1116                    target: "forge_workspace::skills",
1117                    event_name = "skill_file_removed",
1118                    skill = skill.name,
1119                    path = %path.display(),
1120                    "a skill file was removed",
1121                );
1122            }
1123            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1124            Err(error) => tracing::warn!(
1125                target: "forge_workspace::skills",
1126                event_name = "skill_file_remove_failed",
1127                skill = skill.name,
1128                path = %path.display(),
1129                %error,
1130                "a skill file could not be removed; the install itself is unaffected",
1131            ),
1132        }
1133    }
1134    if removed > 0 {
1135        tracing::info!(
1136            target: "forge_workspace::skills",
1137            event_name = "skill_files_swept",
1138            skill = skill.name,
1139            removed,
1140            "skill files were removed",
1141        );
1142    }
1143    Ok(outcome)
1144}
1145
1146/// The files inside one skill's directory that are forge's to remove: the
1147/// `{file}.tmp` one of its own files leaves when a write failed, or a name
1148/// the skill retires.
1149///
1150/// **Only its own temp names.** The directory sits in a sync-replicated
1151/// tree, so any other `.tmp` beside it belongs to something else. A name the
1152/// asset still ships is never swept, even when it is also listed as retired:
1153/// a future asset ending in `.tmp` would otherwise delete itself on its next
1154/// boot.
1155fn sweep_candidates(dir: &Path, skill: &ShippedSkill) -> Vec<PathBuf> {
1156    let mut sweep = Vec::new();
1157    let mut push = |path: PathBuf| {
1158        if std::fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.file_type().is_file()) {
1159            sweep.push(path);
1160        }
1161    };
1162    for (name, _) in skill.files {
1163        push(dir.join(format!("{name}.tmp")));
1164    }
1165    for retired in skill.retired {
1166        if !skill.files.iter().any(|(name, _)| name == retired) {
1167            push(dir.join(retired));
1168        }
1169    }
1170    sweep
1171}
1172
1173fn remove_all(staged: &[(PathBuf, PathBuf)]) {
1174    for (temp, _) in staged {
1175        let _ = std::fs::remove_file(temp);
1176    }
1177}
1178
1179fn named(error: &std::io::Error, path: &Path) -> std::io::Error {
1180    std::io::Error::new(error.kind(), format!("{}: {error}", path.display()))
1181}
1182
1183/// A prompt held because the seat was swapping its occupant.
1184#[derive(Debug, Clone)]
1185pub(crate) struct HeldPrompt {
1186    pub text: String,
1187    pub attachments: Vec<forge_primitives::ImageAttachment>,
1188    pub source: crate::protocol::PromptSource,
1189    /// The id the prompt was dispatched under. The successor dispatches under
1190    /// the same id, because the delivery frame and the transcript row already
1191    /// share it - a fresh one at drain leaves a cron row with nothing to pair
1192    /// its schedule to (#1815).
1193    pub uuid: String,
1194    /// The seat that sent it, when a peer message has one: a handoff that
1195    /// never reaches a successor answers that sender, the way the parked
1196    /// bucket does, rather than only telling the seat.
1197    pub sender: Option<SessionSlot>,
1198}
1199
1200/// The handoff a session arrived by, while its first turns are measured.
1201#[derive(Debug, Clone)]
1202pub(crate) struct SuccessorWatch {
1203    /// The occupant the handoff named.
1204    pub to: String,
1205    /// Prompts counted so far, matching what `successor_counts` counts.
1206    pub turns: u32,
1207    /// What its predecessor had read.
1208    pub predecessor_reads: Vec<String>,
1209    /// The tenth prompt has run; the measurement is taken at its turn end,
1210    /// when the transcript holds all ten.
1211    pub measure_at_boundary: bool,
1212}
1213
1214/// What a swap left for the occupant that continues: the text it is given,
1215/// and the record the seat draws. Held until that occupant connects.
1216#[derive(Debug, Clone)]
1217pub(crate) struct PendingBrief {
1218    /// The occupant this was written for. A connect under any other id is
1219    /// not the session the brief belongs to.
1220    pub to: String,
1221    pub text: String,
1222}
1223
1224/// The fill reading a handoff fired on.
1225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1226pub struct FillReading {
1227    /// The share, rounded, as the row draws it.
1228    pub percent: u8,
1229    pub total_tokens: u64,
1230    pub max_tokens: u64,
1231}
1232
1233impl FillReading {
1234    /// Whether the seat has crossed `at_percent`.
1235    ///
1236    /// **Compared on the pair, not the rounded share.** Rounding first would
1237    /// fire a 84.6% reading at a 85% threshold; the record carries both
1238    /// numbers so a later reader can see the exact ratio the trigger saw.
1239    pub fn fires(&self, at_percent: u8) -> bool {
1240        if self.max_tokens == 0 {
1241            // A reading that named only its share: all this can compare.
1242            return self.percent >= at_percent;
1243        }
1244        self.total_tokens.saturating_mul(100)
1245            >= u64::from(at_percent).saturating_mul(self.max_tokens)
1246    }
1247}
1248
1249/// One handoff, decided and ready to run.
1250pub(crate) struct SwapRequest {
1251    pub slot: SessionSlot,
1252    pub from: String,
1253    pub to: String,
1254    pub reason: HandoffReason,
1255    pub fill: Option<FillReading>,
1256    pub at_percent: u8,
1257    pub role: Role,
1258    pub judgment: Vec<HandoffSection>,
1259    pub cwd: String,
1260    pub launch_settings: SessionLaunchSettings,
1261    /// The token the gate was opened under; the build abandons if it is no
1262    /// longer the current one.
1263    pub token: Option<u64>,
1264}
1265
1266impl crate::Workspace {
1267    /// Hold the brief a handoff has written until its successor connects.
1268    pub(crate) fn set_pending_brief(&self, slot: &SessionSlot, brief: PendingBrief) {
1269        self.pending_handoffs.lock().insert(slot.clone(), brief);
1270    }
1271
1272    /// Take the brief a swap left for `slot`, if one is waiting.
1273    pub(crate) fn take_pending_brief(&self, slot: &SessionSlot) -> Option<PendingBrief> {
1274        self.pending_handoffs.lock().remove(slot)
1275    }
1276
1277    /// Forget a brief whose swap never reached its successor.
1278    pub(crate) fn clear_pending_brief(&self, slot: &SessionSlot) {
1279        self.pending_handoffs.lock().remove(slot);
1280    }
1281
1282    /// Mark the seat as swapping its occupant: from the moment the brief is
1283    /// built until the successor connects, a prompt aimed at it is held
1284    /// rather than run by the session on its way out.
1285    pub(crate) fn begin_swap(&self, slot: &SessionSlot) -> u64 {
1286        let token = self.swap_epochs.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
1287        self.swapping.lock().insert(slot.clone(), token);
1288        token
1289    }
1290
1291    /// Whether a swap is in flight on this seat.
1292    pub(crate) fn swap_in_progress(&self, slot: &SessionSlot) -> bool {
1293        self.swapping.lock().contains_key(slot)
1294    }
1295
1296    /// Whether a handoff owns this seat's composer right now: its judgment
1297    /// turn is running, or the swap it opened is still building or spawning.
1298    ///
1299    /// **The domain's own flag is cleared at the swap**, before the successor
1300    /// connects - so a page attaching in that window would otherwise read a
1301    /// live composer over a seat that has no session at all.
1302    pub fn handoff_in_flight(&self, slot: &SessionSlot) -> bool {
1303        self.swap_in_progress(slot)
1304            || self.domain_session_for(slot).is_some_and(|domain| domain.lock().handoff.awaiting)
1305    }
1306
1307    /// End the machine turn a handoff raised, wherever its build stops.
1308    ///
1309    /// **Every path out of the build that leaves no successor needs this.**
1310    /// The frame at the handoff's begin is the live page's only source, and a
1311    /// successor's record read is what normally clears it - so a build that
1312    /// stops without one would leave the seat's composer down for good.
1313    fn end_machine_turn(&self, slot: &SessionSlot) {
1314        let _ =
1315            self.update_tx.send(SessionUpdate::MachineTurn { key: slot.clone(), machine: None });
1316    }
1317
1318    /// Whether `token` still owns the seat's swap.
1319    ///
1320    /// **A build that lost the seat must not spawn.** A replacement during
1321    /// `ground_truth` closes the gate, and a later handoff may open a new
1322    /// one; only the token says whether the NewSession this build is about
1323    /// to dispatch is still its own.
1324    pub(crate) fn swap_is_current(&self, slot: &SessionSlot, token: u64) -> bool {
1325        self.swapping.lock().get(slot) == Some(&token)
1326    }
1327
1328    /// Dispatch a peer message's prompt, holding it for the successor when a
1329    /// swap is in flight and keeping its sender so a handoff that fails can
1330    /// answer it.
1331    pub(crate) fn dispatch_peer_prompt_under(
1332        self: &Arc<Self>,
1333        caller: &SessionSlot,
1334        key: &SessionSlot,
1335        text: String,
1336        uuid: String,
1337    ) -> Result<(), crate::DispatchError> {
1338        let held = HeldPrompt {
1339            text: text.clone(),
1340            attachments: Vec::new(),
1341            source: crate::protocol::PromptSource::Peer,
1342            uuid: uuid.clone(),
1343            sender: Some(caller.clone()),
1344        };
1345        if self.hold_prompt(key, held) {
1346            return Ok(());
1347        }
1348        self.route(Command::PromptUnder {
1349            key: key.clone(),
1350            text,
1351            attachments: Vec::new(),
1352            uuid,
1353            source: crate::protocol::PromptSource::Peer,
1354            dispatch_id: None,
1355        })
1356    }
1357
1358    /// Hold a prompt for the successor. `true` when a swap took it.
1359    pub(crate) fn hold_prompt(&self, slot: &SessionSlot, prompt: HeldPrompt) -> bool {
1360        if !self.swap_in_progress(slot) {
1361            return false;
1362        }
1363        self.held_prompts.lock().entry(slot.clone()).or_default().push(prompt);
1364        true
1365    }
1366
1367    /// End the swap and take everything held for it, in arrival order.
1368    pub(crate) fn end_swap(&self, slot: &SessionSlot) -> Vec<HeldPrompt> {
1369        self.swapping.lock().remove(slot);
1370        self.held_prompts.lock().remove(slot).unwrap_or_default()
1371    }
1372
1373    /// Write one handoff to the store, which is what `at_percent` is tuned
1374    /// from later. A store that cannot write is logged, never a reason to
1375    /// hold up the swap: the session is already handing over.
1376    pub(crate) fn record_handoff(&self, record: &HandoffRecord, predecessor_reads: &[String]) {
1377        let guard = self.db.lock();
1378        let Some(db) = guard.as_ref() else { return };
1379        if let Err(error) = crate::store::handoffs::put(db, record, predecessor_reads) {
1380            tracing::warn!(
1381                target: "forge_workspace::handoff",
1382                event_name = "handoff_record_failed",
1383                slot = %record.slot.display(),
1384                %error,
1385                "the handoff record could not be written; the swap itself is unaffected",
1386            );
1387        }
1388    }
1389
1390    /// The brief waiting for `slot`'s successor, when one is.
1391    pub(crate) fn pending_brief_for(&self, slot: &SessionSlot) -> Option<PendingBrief> {
1392        self.pending_handoffs.lock().get(slot).cloned()
1393    }
1394
1395    /// The handoff `occupant` arrived by, if it arrived by one.
1396    pub(crate) fn handoff_for_successor(
1397        &self,
1398        slot: &SessionSlot,
1399        occupant: &str,
1400    ) -> Option<(HandoffRecord, Vec<String>)> {
1401        let guard = self.db.lock();
1402        let db = guard.as_ref()?;
1403        match crate::store::handoffs::for_successor(db, slot, occupant) {
1404            Ok(found) => found,
1405            Err(error) => {
1406                tracing::warn!(
1407                    target: "forge_workspace::handoff",
1408                    event_name = "handoff_record_unreadable",
1409                    slot = %slot.display(),
1410                    %error,
1411                    "the handoff record could not be read; this seat's first turns go unmeasured",
1412                );
1413                None
1414            }
1415        }
1416    }
1417
1418    /// Measure the successor's first turns off its own transcript and write
1419    /// them onto the record. Runs detached: the read is a whole transcript
1420    /// and the seat has work to do.
1421    pub(crate) fn measure_successor(
1422        self: &Arc<Self>,
1423        slot: SessionSlot,
1424        occupant: String,
1425        cwd: String,
1426        predecessor_reads: Vec<String>,
1427    ) {
1428        let workspace = Arc::clone(self);
1429        tokio::spawn(async move {
1430            let config_dir = workspace.config_dir().to_path_buf();
1431            let read_id = occupant.clone();
1432            // The successor's OWN reads are what the measurement counts; a
1433            // sub-agent's reads are not the successor re-reading the brief.
1434            let read = tokio::task::spawn_blocking(move || {
1435                forge_agent::session_history_main_thread(&config_dir, &read_id, &cwd)
1436            })
1437            .await;
1438            let messages = read.map(|history| history.messages).unwrap_or_default();
1439            let Some(counts) = successor_measurement(&messages, &predecessor_reads) else {
1440                tracing::debug!(
1441                    target: "forge_workspace::handoff",
1442                    event_name = "handoff_successor_measurement_skipped",
1443                    slot = %slot.display(),
1444                    "the successor's transcript read answered nothing; no counts are written",
1445                );
1446                return;
1447            };
1448            let guard = workspace.db.lock();
1449            let Some(db) = guard.as_ref() else { return };
1450            if let Err(error) = crate::store::handoffs::set_successor(db, &slot, &occupant, counts)
1451            {
1452                tracing::warn!(
1453                    target: "forge_workspace::handoff",
1454                    event_name = "handoff_successor_measurement_failed",
1455                    slot = %slot.display(),
1456                    %error,
1457                    "the successor's first turns could not be written to the record",
1458                );
1459            }
1460        });
1461    }
1462
1463    /// Run a handoff: build the ground truth, announce the swap, and start the
1464    /// new session on the same order `/new` uses.
1465    ///
1466    /// The build reads the transcript and probes git, so it runs off the
1467    /// actor that decided it. The swap signal goes out before the spawn, so
1468    /// the seat knows the join before the successor's transcript starts.
1469    pub(crate) fn start_handoff(self: &Arc<Self>, request: SwapRequest) {
1470        let workspace = Arc::clone(self);
1471        tokio::spawn(async move {
1472            workspace.run_handoff(request).await;
1473        });
1474    }
1475
1476    async fn run_handoff(self: Arc<Self>, request: SwapRequest) {
1477        // **The gate was opened at the decision, and the token says whether
1478        // it is still this build's.** A replacement during the read below
1479        // closes it, and a later handoff may open a new one; this build must
1480        // not then spawn a second successor.
1481        let Some(token) = request.token else {
1482            tracing::debug!(
1483                target: "forge_workspace::handoff",
1484                event_name = "handoff_build_without_token",
1485                slot = %request.slot.display(),
1486                "a handoff build had no swap token; it is not run",
1487            );
1488            self.end_machine_turn(&request.slot);
1489            return;
1490        };
1491        if !self.swap_is_current(&request.slot, token) {
1492            tracing::debug!(
1493                target: "forge_workspace::handoff",
1494                event_name = "handoff_build_lost_the_seat",
1495                slot = %request.slot.display(),
1496                "the swap gate closed before the build began; the handoff is not run",
1497            );
1498            self.end_machine_turn(&request.slot);
1499            return;
1500        }
1501        let cwd = PathBuf::from(&request.cwd);
1502        let transcript_path = transcript_path(self.config_dir(), &cwd, &request.from);
1503        let board: Vec<Task> = self
1504            .board_rows(request.slot.project(), SystemTime::now(), crate::board::DEFAULT_STALE_SECS)
1505            .into_iter()
1506            .map(|row| row.task)
1507            .collect();
1508        let seat = HandoffSeat {
1509            slot: &request.slot,
1510            from: &request.from,
1511            cwd: &cwd,
1512            transcript_path: &transcript_path,
1513            role: request.role.clone(),
1514        };
1515        let live = {
1516            let domain = self.domain_session_for(&request.slot);
1517            match domain {
1518                Some(domain) => live_work_sections(Some(&domain.lock())),
1519                None => live_work_sections(None),
1520            }
1521        };
1522        let truth = ground_truth(self.config_dir(), &seat, &board, live).await;
1523        let GroundTruth { sections, cycle, reads } = truth;
1524        let record = HandoffRecord {
1525            slot: request.slot.clone(),
1526            from: request.from.clone(),
1527            to: request.to.clone(),
1528            reason: request.reason,
1529            fill_percent: request.fill.map(|fill| fill.percent),
1530            total_tokens: request.fill.map(|fill| fill.total_tokens),
1531            max_tokens: request.fill.map(|fill| fill.max_tokens),
1532            at_percent: request.at_percent,
1533            brief: HandoffBrief { ground_truth: sections, judgment: request.judgment },
1534            transcript_path: transcript_path.display().to_string(),
1535            at: crate::install::rfc3339_now(),
1536            cycle,
1537            successor: None,
1538        };
1539        let text = brief_text(&record.pair(), &record.brief);
1540        // **The seat is checked again with the build in hand.** The read and
1541        // the git probe take seconds, and a replacement in that stretch
1542        // means this successor is no longer the seat's: no row, no brief and
1543        // no spawn.
1544        if !self.swap_is_current(&request.slot, token) {
1545            tracing::debug!(
1546                target: "forge_workspace::handoff",
1547                event_name = "handoff_build_lost_the_seat",
1548                slot = %request.slot.display(),
1549                "the swap gate closed while the brief was built; the handoff is not run",
1550            );
1551            self.end_machine_turn(&request.slot);
1552            return;
1553        }
1554        self.record_handoff(&record, &reads);
1555        // The brief is held before the swap signal and the spawn, so a
1556        // successor that connects immediately cannot outrun it.
1557        self.set_pending_brief(&request.slot, PendingBrief { to: request.to.clone(), text });
1558        let _ = self
1559            .update_tx
1560            .send(SessionUpdate::Handoff { key: request.slot.clone(), handoff: record });
1561        // **A swap is a replacement**: the previous occupant's tabs close
1562        // before its successor starts, or the new occupant's first call would
1563        // inherit them. A parked hand-off's tab is the fleet's to keep.
1564        self.replace_browser_seat(&request.slot);
1565        if let Err(error) = self.dispatch(Command::NewSession {
1566            key: request.slot.clone(),
1567            cwd: request.cwd,
1568            launch_settings: request.launch_settings,
1569            session_id: Some(request.to),
1570        }) {
1571            self.clear_pending_brief(&request.slot);
1572            let held = self.end_swap(&request.slot);
1573            if !held.is_empty() {
1574                tracing::warn!(
1575                    target: "forge_workspace::handoff",
1576                    event_name = "handoff_prompts_dropped",
1577                    slot = %request.slot.display(),
1578                    held = held.len(),
1579                    "the handoff could not start its successor; prompts held for it were dropped",
1580                );
1581            }
1582            for prompt in &held {
1583                // The press never ran, so its composer mark settles here.
1584                let _ = self.update_tx.send(SessionUpdate::PromptLifecycle {
1585                    key: request.slot.clone(),
1586                    uuid: prompt.uuid.clone(),
1587                    state: "cancelled".to_owned(),
1588                });
1589                if let Some(sender) = &prompt.sender {
1590                    self.notice_undelivered_message(
1591                        sender,
1592                        &request.slot,
1593                        crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
1594                    );
1595                } else {
1596                    let first = prompt.text.lines().next().unwrap_or_default();
1597                    let _ = self.update_tx.send(SessionUpdate::Notice {
1598                        key: request.slot.clone(),
1599                        severity: NoticeSeverity::Error,
1600                        text: format!("a prompt held for the failed handoff did not run: {first}"),
1601                    });
1602                }
1603            }
1604            let _ = self.update_tx.send(SessionUpdate::Notice {
1605                key: request.slot.clone(),
1606                severity: NoticeSeverity::Error,
1607                text: format!("the handoff could not start a new session: {error}"),
1608            });
1609            self.end_machine_turn(&request.slot);
1610        }
1611    }
1612}
1613
1614/// A fresh session id, deliberately NOT recorded against a slot's row.
1615///
1616/// **A handoff records its successor on Connected, not before the spawn.**
1617/// Minting-and-recording ahead of a spawn that then fails would leave the
1618/// next boot resuming an id that never ran, which an empty session then fills
1619/// and a handoff record claims to have joined.
1620pub(crate) fn mint_session_id() -> String {
1621    uuid::Uuid::new_v4().to_string()
1622}
1623
1624/// Where a session's transcript lives: the project directory the run's cwd
1625/// hashes to, then the id.
1626pub(crate) fn transcript_path(config_dir: &Path, cwd: &Path, session_id: &str) -> PathBuf {
1627    let cwd = cwd.to_string_lossy();
1628    let key = forge_agent::userdata::catalog::scan::project_key_for_directory(
1629        (!cwd.is_empty()).then_some(cwd.as_ref()),
1630    );
1631    forge_sdk::projects_dir_for(config_dir).join(key).join(format!("{session_id}.jsonl"))
1632}
1633
1634#[cfg(test)]
1635mod tests {
1636    use super::*;
1637    use forge_primitives::messages::{AssistantEnvelope, Extras, Usage, UserEnvelope};
1638    use forge_primitives::tasks::{LinkKind, TaskLink};
1639
1640    fn user(text: &str) -> Message {
1641        Message::User {
1642            message: UserEnvelope {
1643                role: "user".to_owned(),
1644                content: vec![ContentBlock::Text { text: text.to_owned(), extras: Extras::new() }],
1645                extras: Extras::new(),
1646            },
1647            session_id: "s".to_owned(),
1648            parent_tool_use_id: None,
1649            uuid: None,
1650            tool_use_result: None,
1651            timestamp: None,
1652            synthetic: false,
1653            extras: Extras::new(),
1654        }
1655    }
1656
1657    fn assistant(content: Vec<ContentBlock>, usage: Option<Usage>) -> Message {
1658        Message::Assistant {
1659            message: AssistantEnvelope {
1660                id: "m".to_owned(),
1661                role: "assistant".to_owned(),
1662                model: "a-model".to_owned(),
1663                content,
1664                stop_reason: None,
1665                stop_sequence: None,
1666                usage,
1667                extras: Extras::new(),
1668            },
1669            session_id: "s".to_owned(),
1670            parent_tool_use_id: None,
1671            error: None,
1672            uuid: None,
1673            timestamp: None,
1674            extras: Extras::new(),
1675        }
1676    }
1677
1678    fn text(value: &str) -> ContentBlock {
1679        ContentBlock::Text { text: value.to_owned(), extras: Extras::new() }
1680    }
1681
1682    fn call(id: &str, name: &str, input: serde_json::Value) -> Message {
1683        assistant(
1684            vec![ContentBlock::ToolUse {
1685                id: id.to_owned(),
1686                name: name.to_owned(),
1687                input,
1688                extras: Extras::new(),
1689            }],
1690            None,
1691        )
1692    }
1693
1694    fn result(id: &str, output: &str, is_error: bool) -> Message {
1695        Message::User {
1696            message: UserEnvelope {
1697                role: "user".to_owned(),
1698                content: vec![ContentBlock::ToolResult {
1699                    tool_use_id: id.to_owned(),
1700                    content: serde_json::Value::String(output.to_owned()),
1701                    is_error,
1702                    extras: Extras::new(),
1703                }],
1704                extras: Extras::new(),
1705            },
1706            session_id: "s".to_owned(),
1707            parent_tool_use_id: None,
1708            uuid: None,
1709            tool_use_result: None,
1710            timestamp: None,
1711            synthetic: false,
1712            extras: Extras::new(),
1713        }
1714    }
1715
1716    fn usage(input: u64, cache_read: u64) -> Usage {
1717        Usage {
1718            input_tokens: input,
1719            output_tokens: 10,
1720            cache_creation_input_tokens: 0,
1721            cache_read_input_tokens: cache_read,
1722            extras: Extras::new(),
1723        }
1724    }
1725
1726    fn task(subject: &str, owner: Option<SessionSlot>, link: Option<&str>) -> Task {
1727        Task {
1728            id: forge_primitives::TaskId::from(subject),
1729            project_name: "forge".to_owned(),
1730            subject: subject.to_owned(),
1731            active_form: None,
1732            detail: None,
1733            status: TaskStatus::InProgress,
1734            owner,
1735            parent: None,
1736            waiting_on: None,
1737            estimate: None,
1738            rank: None,
1739            verify: None,
1740            attested: None,
1741            reserved: None,
1742            links: link
1743                .map(|target| TaskLink {
1744                    kind: LinkKind::Pr,
1745                    label: None,
1746                    target: target.to_owned(),
1747                    state: None,
1748                    added_at: SystemTime::UNIX_EPOCH,
1749                })
1750                .into_iter()
1751                .collect(),
1752            attempt: 0,
1753            archived_at: None,
1754            created_at: SystemTime::UNIX_EPOCH,
1755            updated_at: SystemTime::UNIX_EPOCH,
1756        }
1757    }
1758
1759    fn seat() -> HandoffSeat<'static> {
1760        HandoffSeat {
1761            slot: Box::leak(Box::new(SessionSlot::worker("Busytools", "forge", "handoff-cut"))),
1762            from: "9cfe315c-aaaa-bbbb-cccc-dddddddddddd",
1763            cwd: Path::new("/tmp/forge"),
1764            transcript_path: Path::new("/tmp/projects/forge/9cfe315c.jsonl"),
1765            role: Role::Worker { charter_opening: Some("You build the cut.".to_owned()) },
1766        }
1767    }
1768
1769    /// The seat and the record are what tell the successor which session
1770    /// this brief belongs to, and where the rest of the truth is.
1771    ///
1772    /// **The seat's facts are one labelled row each.** The brief draws a
1773    /// table, so a fact carried inside another row's value has no column of
1774    /// its own and reads as part of the label above it.
1775    #[test]
1776    fn the_seat_and_the_record_name_the_identity_and_the_transcript() {
1777        let ground = assemble(&seat(), &[], &HandoffGitState::default(), &[], &[], Vec::new());
1778        let labels: Vec<&str> = ground.sections.iter().map(|s| s.label.as_str()).collect();
1779        assert_eq!(
1780            &labels[..3],
1781            ["seat", "cwd", "branch"],
1782            "the seat block is one fact per row: {labels:?}",
1783        );
1784        let values: Vec<Vec<&str>> = ground.sections[..3]
1785            .iter()
1786            .map(|section| section.lines.iter().map(String::as_str).collect())
1787            .collect();
1788        assert_eq!(
1789            values,
1790            vec![
1791                vec!["Busytools/forge/handoff-cut"],
1792                vec!["/tmp/forge"],
1793                vec!["not a git repository"],
1794            ],
1795            "each row carries its own value",
1796        );
1797        let git = HandoffGitState {
1798            branch: Some("worktree-handoff-cut".to_owned()),
1799            head: Some("94cab2f20".to_owned()),
1800            ..HandoffGitState::default()
1801        };
1802        let on_a_branch = assemble(&seat(), &[], &git, &[], &[], Vec::new());
1803        assert_eq!(
1804            on_a_branch.sections[2].label, "branch",
1805            "the branch keeps its own row: {:?}",
1806            on_a_branch.sections[2],
1807        );
1808        assert_eq!(
1809            on_a_branch.sections[2].lines,
1810            vec!["worktree-handoff-cut @ 94cab2f20".to_owned()],
1811            "and names the commit it is at",
1812        );
1813        let record = ground.sections.last().expect("the record");
1814        assert_eq!(record.label, "record");
1815        assert!(
1816            record.lines.contains(&"/tmp/projects/forge/9cfe315c.jsonl".to_owned()),
1817            "the transcript path: {:?}",
1818            record.lines,
1819        );
1820        assert!(
1821            record.lines.contains(&"session 9cfe315c-aaaa-bbbb-cccc-dddddddddddd".to_owned()),
1822            "and the session id: {:?}",
1823            record.lines,
1824        );
1825        let role = ground.sections.iter().find(|section| section.label == "role").expect("role");
1826        assert_eq!(
1827            role.lines,
1828            vec!["worker, charter opens: You build the cut.".to_owned()],
1829            "the role line says which charter this brief belongs to",
1830        );
1831    }
1832
1833    /// **Forge's own prompts are not the person's.** A forged row reads back
1834    /// as a user turn, so without the filter the judgment prompt and the brief
1835    /// this session arrived by would count as its own turns.
1836    #[test]
1837    fn forge_prompts_and_prior_briefs_are_not_turns() {
1838        let forged = |text: &str| {
1839            let mut frame = user(text);
1840            if let Message::User { uuid, .. } = &mut frame {
1841                *uuid = Some("forge_nudge_7_deadbeef".to_owned());
1842            }
1843            frame
1844        };
1845        let messages = vec![
1846            user("ship the handoff"),
1847            forged("A handoff is starting for this session: its context is nearly full"),
1848            user("and open the PR"),
1849            user("HANDOFF - old -> new\n\nPART 1: GROUND TRUTH"),
1850        ];
1851        assert_eq!(user_prompt_count(&messages), 2, "only the two real prompts are turns");
1852    }
1853
1854    /// The CLI's own user rows are not prompts, and they are not turns a
1855    /// successor is measured by either.
1856    #[test]
1857    fn machine_rows_are_neither_prompts_nor_successor_turns() {
1858        let messages = vec![
1859            user("ship the handoff"),
1860            user("<task-notification>\n<task-id>b1</task-id>\n</task-notification>"),
1861            user("[Request interrupted by user for tool use]"),
1862            user("[Your previous response had no visible output. Please continue.]"),
1863        ];
1864        assert_eq!(user_prompt_count(&messages), 1, "only the person's own row is a turn");
1865        let counts = successor_counts(&messages, &[]);
1866        assert_eq!(counts.turns, 1, "and only that row is a successor turn: {counts:?}");
1867    }
1868
1869    /// A peer message runs a turn of its own, so the cycle counts it.
1870    #[test]
1871    fn a_peer_message_is_a_turn() {
1872        let messages = vec![
1873            user(
1874                "[Message id=m-1 from agent 'forge/steward' (org 'Busytools')]\n\nis the cron filed?",
1875            ),
1876            user("ship the handoff"),
1877        ];
1878        assert_eq!(user_prompt_count(&messages), 2, "the peer's row and the person's");
1879    }
1880
1881    /// The uncommitted count is the tree's, not the capped list's.
1882    #[test]
1883    fn the_uncommitted_count_is_the_trees_not_the_caps() {
1884        let git = HandoffGitState {
1885            branch: Some("main".to_owned()),
1886            head: Some("abc".to_owned()),
1887            dirty: (0..20).map(|n| format!(" M file{n}.rs")).collect(),
1888            dirty_total: 60,
1889            diff: Vec::new(),
1890        };
1891        let section = files_section(&[], &git, &[]);
1892        assert!(
1893            section
1894                .lines
1895                .iter()
1896                .any(|line| line.contains("uncommitted: 60 paths, newest 20 shown (40 omitted)")),
1897            "the line states the total and what it omits: {:?}",
1898            section.lines,
1899        );
1900    }
1901
1902    /// **A sub-agent's calls are the instance's, not the session's.**
1903    /// A parented Read or Edit never names the predecessor's files or read
1904    /// set, whether the handoff read folded a sidecar or a caller handed the
1905    /// fold a parented frame (#1763).
1906    #[test]
1907    fn a_subagents_calls_stay_out_of_the_brief() {
1908        let mut child = call("t-child", "Read", serde_json::json!({ "file_path": "p/child.txt" }));
1909        if let Message::Assistant { parent_tool_use_id, .. } = &mut child {
1910            *parent_tool_use_id = Some("toolu_dispatch".to_owned());
1911        }
1912        let main = call("t-main", "Edit", serde_json::json!({ "file_path": "p/main.txt" }));
1913        let messages = vec![child, main];
1914
1915        let section = files_section(&messages, &HandoffGitState::default(), &[]);
1916        let body = section.lines.join("\n");
1917
1918        assert!(body.contains("p/main.txt"), "the session's own edit is named: {body}");
1919        assert!(!body.contains("p/child.txt"), "a sub-agent's read is not the brief's: {body}");
1920        assert!(
1921            !read_set(&messages, &[]).iter().any(|path| path == "p/child.txt"),
1922            "and it is not in the successor's read set",
1923        );
1924    }
1925
1926    /// The brief names what changed, what was read and what is still
1927    /// uncommitted: the successor cannot infer any of it from the note.
1928    #[test]
1929    fn the_files_section_names_edits_reads_and_the_uncommitted_paths() {
1930        let messages = vec![
1931            call("t1", "Read", serde_json::json!({ "file_path": "src/lib.rs" })),
1932            call("t2", "Edit", serde_json::json!({ "file_path": "src/handoff.rs" })),
1933            call("t3", "Write", serde_json::json!({ "file_path": "docs/handoff.md" })),
1934        ];
1935        let git = HandoffGitState {
1936            branch: Some("worktree-handoff-cut".to_owned()),
1937            head: Some("94cab2f20".to_owned()),
1938            dirty: vec![" M src/handoff.rs".to_owned()],
1939            dirty_total: 1,
1940            diff: vec![" src/handoff.rs | 10 +++++-----".to_owned()],
1941        };
1942        let section = files_section(&messages, &git, &[]);
1943        assert!(
1944            section.lines.contains(&"changed: src/handoff.rs, docs/handoff.md".to_owned()),
1945            "both edits are named: {:?}",
1946            section.lines,
1947        );
1948        assert!(
1949            section.lines.contains(&"read: src/lib.rs".to_owned()),
1950            "the read is named: {:?}",
1951            section.lines,
1952        );
1953        assert!(
1954            section.lines.contains(&" M src/handoff.rs".to_owned()),
1955            "the uncommitted path rides whatever git said: {:?}",
1956            section.lines,
1957        );
1958        assert!(
1959            section.lines.iter().any(|line| line.starts_with("uncommitted: 1 paths")),
1960            "and the count is stated: {:?}",
1961            section.lines,
1962        );
1963        assert!(
1964            section.lines.contains(&"diff:".to_owned()),
1965            "the probe's diff keeps its own block: {:?}",
1966            section.lines,
1967        );
1968        assert!(
1969            section.lines.iter().any(|line| line.contains("src/handoff.rs | 10")),
1970            "and carries the hunks under it: {:?}",
1971            section.lines,
1972        );
1973    }
1974
1975    /// **PART 1 is exactly the mechanical sections, in order.** A section
1976    /// added back (an account, a model, an ask) or dropped (the board) changes
1977    /// this list.
1978    #[test]
1979    fn the_ground_truth_is_exactly_the_mechanical_sections() {
1980        let messages = vec![
1981            user("go"),
1982            call("t1", "Edit", serde_json::json!({ "file_path": "src/a.rs" })),
1983            call("t2", "Read", serde_json::json!({ "file_path": "src/b.rs" })),
1984        ];
1985        let git = HandoffGitState {
1986            branch: Some("main".to_owned()),
1987            head: Some("abc".to_owned()),
1988            dirty: Vec::new(),
1989            dirty_total: 0,
1990            diff: vec![" src/a.rs | 2 ++".to_owned()],
1991        };
1992        let ground = assemble(&seat(), &messages, &git, &[], &[], live_work_sections(None));
1993        let labels: Vec<&str> = ground.sections.iter().map(|s| s.label.as_str()).collect();
1994        assert_eq!(
1995            labels,
1996            [
1997                "seat",
1998                "cwd",
1999                "branch",
2000                "role",
2001                "files",
2002                "board",
2003                "agents",
2004                "background",
2005                "monitors",
2006                "processes",
2007                "record",
2008            ],
2009            "the brief is exactly the mechanical facts",
2010        );
2011    }
2012
2013    /// A long list keeps the first entry and the newest, and says what it
2014    /// left out: the first file a successor changed is the one it will
2015    /// re-read, so a newest-only cut loses the wrong one.
2016    #[test]
2017    fn a_long_file_list_keeps_the_first_and_the_newest() {
2018        let messages: Vec<Message> = (0..12)
2019            .map(|n| {
2020                call(
2021                    &format!("t{n}"),
2022                    "Edit",
2023                    serde_json::json!({ "file_path": format!("src/f{n}.rs") }),
2024                )
2025            })
2026            .collect();
2027        let section = files_section(&messages, &HandoffGitState::default(), &[]);
2028        let line = section.lines.first().expect("the changed line");
2029        assert!(line.contains("10 shown of 12 (2 omitted)"), "the omission is stated: {line}");
2030        assert!(line.contains("src/f0.rs"), "the first is kept: {line}");
2031        assert!(line.contains("src/f11.rs"), "and the newest: {line}");
2032        assert!(!line.contains("src/f1.rs"), "the second-oldest is what the cap takes: {line}");
2033    }
2034
2035    /// Only the slot's own rows are its work, and their links are what say
2036    /// which PR and issue the successor is continuing.
2037    #[test]
2038    fn the_board_section_lists_only_the_slots_own_rows_and_their_links() {
2039        let mine = SessionSlot::worker("Busytools", "forge", "handoff-cut");
2040        let rows = vec![
2041            task("the automatic handoff", Some(mine.clone()), Some("https://github.com/x/pull/9")),
2042            task(
2043                "someone else's work",
2044                Some(SessionSlot::worker("Busytools", "forge", "other")),
2045                None,
2046            ),
2047        ];
2048        let section = board_section(&mine, &rows);
2049        assert_eq!(
2050            section.lines.first().map(String::as_str),
2051            Some("the automatic handoff (in progress)"),
2052            "the owned row is drawn with its state",
2053        );
2054        assert!(
2055            section.lines.contains(&"pr: https://github.com/x/pull/9".to_owned()),
2056            "and its link: {:?}",
2057            section.lines,
2058        );
2059        assert!(
2060            !section.lines.iter().any(|line| line.contains("someone else")),
2061            "another seat's row is not the successor's work: {:?}",
2062            section.lines,
2063        );
2064    }
2065
2066    /// **PART 1 carries what is still running.** The successor has to know
2067    /// what to resume; the note is an index and may mention it, but the live
2068    /// set is a fact forge reads itself.
2069    #[test]
2070    fn the_live_work_sections_list_agents_background_monitors_and_processes() {
2071        let mut domain = crate::domain_session::DomainSession::new(
2072            SessionSlot::worker("Busytools", "forge", "handoff-cut"),
2073            None,
2074        );
2075        domain.card_tracker.apply(&assistant(
2076            vec![ContentBlock::ToolUse {
2077                id: "t1".to_owned(),
2078                name: "Task".to_owned(),
2079                input: serde_json::json!({
2080                    "description": "scan the pile",
2081                    "subagent_type": "Explore",
2082                    "prompt": "scan",
2083                }),
2084                extras: Extras::new(),
2085            }],
2086            None,
2087        ));
2088        domain.background_tasks = vec![forge_primitives::background::BackgroundTask {
2089            task_id: "b1".to_owned(),
2090            is_backgrounded: true,
2091            task_type: "local_bash".to_owned(),
2092            description: "cargo watch".to_owned(),
2093            command: Some("cargo watch".to_owned()),
2094            tool_use_id: None,
2095        }];
2096        domain.monitors = vec![forge_primitives::runtime::MonitorRecord {
2097            tool_use_id: "m1".to_owned(),
2098            task_id: Some("mt1".to_owned()),
2099            description: "watch the build".to_owned(),
2100            command: "tail -f build.log".to_owned(),
2101            persistent: false,
2102            timeout_ms: 0,
2103            status: forge_primitives::runtime::MonitorStatus::Running,
2104            output_file: None,
2105            ended_at: None,
2106        }];
2107        domain.process_snapshot = Some(forge_agent::env::processes::ProcessSnapshot {
2108            processes: vec![forge_agent::env::processes::ProcessEntry {
2109                pid: 42,
2110                parent_pid: 1,
2111                name: "cargo".to_owned(),
2112                command: "cargo nextest run".to_owned(),
2113                memory_bytes: 1024,
2114            }],
2115            scanned_at: SystemTime::UNIX_EPOCH,
2116        });
2117
2118        let sections = live_work_sections(Some(&domain));
2119        let labels: Vec<&str> = sections.iter().map(|section| section.label.as_str()).collect();
2120        assert_eq!(labels, ["agents", "background", "monitors", "processes"]);
2121        assert!(
2122            sections[0].lines[0].contains("Explore: scan the pile"),
2123            "the live sub-agent: {:?}",
2124            sections[0].lines,
2125        );
2126        assert!(
2127            sections[1].lines[0].contains("local_bash: cargo watch [b1]"),
2128            "the background task: {:?}",
2129            sections[1].lines,
2130        );
2131        assert!(
2132            sections[2].lines[0].contains("watch the build [m1]"),
2133            "the monitor: {:?}",
2134            sections[2].lines,
2135        );
2136        assert!(
2137            sections[3].lines[0].contains("42 cargo: cargo nextest run"),
2138            "the process: {:?}",
2139            sections[3].lines,
2140        );
2141
2142        let fresh = crate::domain_session::DomainSession::new(
2143            SessionSlot::worker("Busytools", "forge", "handoff-cut"),
2144            None,
2145        );
2146        let idle = live_work_sections(Some(&fresh));
2147        assert_eq!(idle[0].lines, vec!["none running".to_owned()]);
2148        assert_eq!(idle[3].lines, vec!["not scanned".to_owned()], "an absent scan is stated");
2149
2150        // **Settled work is not live.** A finished sub-agent and a stopped
2151        // monitor must not list as still running.
2152        let mut settled = crate::domain_session::DomainSession::new(
2153            SessionSlot::worker("Busytools", "forge", "handoff-cut"),
2154            None,
2155        );
2156        settled.card_tracker.apply(&assistant(
2157            vec![ContentBlock::ToolUse {
2158                id: "t1".to_owned(),
2159                name: "Task".to_owned(),
2160                input: serde_json::json!({ "description": "scan the pile", "prompt": "scan" }),
2161                extras: Extras::new(),
2162            }],
2163            None,
2164        ));
2165        settled.card_tracker.apply(&result("t1", "done", false));
2166        settled.monitors = vec![forge_primitives::runtime::MonitorRecord {
2167            tool_use_id: "m2".to_owned(),
2168            task_id: Some("mt2".to_owned()),
2169            description: "watch the build".to_owned(),
2170            command: "tail -f build.log".to_owned(),
2171            persistent: false,
2172            timeout_ms: 0,
2173            status: forge_primitives::runtime::MonitorStatus::Stopped,
2174            output_file: None,
2175            ended_at: None,
2176        }];
2177        let done = live_work_sections(Some(&settled));
2178        assert_eq!(
2179            done[0].lines,
2180            vec!["none running".to_owned()],
2181            "a finished sub-agent is not live",
2182        );
2183        let stopped = "a stopped monitor is not live";
2184        assert_eq!(done[2].lines, vec!["none running".to_owned()], "{stopped}");
2185
2186        let absent = live_work_sections(None);
2187        assert_eq!(absent.len(), 4);
2188        for section in &absent {
2189            assert_eq!(
2190                section.lines,
2191                vec!["not read".to_owned()],
2192                "an absent domain is stated as unread, not as nothing running",
2193            );
2194        }
2195    }
2196
2197    /// The cycle counts one request per assistant row that has a usage
2198    /// block, one turn per real user prompt, and every prompt token the
2199    /// model read - cache reads included.
2200    #[test]
2201    fn the_cycle_counts_requests_turns_and_the_prompt_tokens_the_model_read() {
2202        let messages = vec![
2203            user("go"),
2204            assistant(vec![text("working")], Some(usage(100, 1_000))),
2205            result("g1", "ok", false),
2206            user("and now this"),
2207            assistant(vec![text("done")], Some(usage(200, 2_000))),
2208        ];
2209        let cycle = cycle(&messages, user_prompt_count(&messages));
2210        assert_eq!(cycle.requests, 2, "one request per usage-bearing assistant row");
2211        assert_eq!(cycle.turns, 2, "one turn per real user prompt");
2212        assert_eq!(cycle.prompt_tokens, 3_300, "input and cache reads both count");
2213    }
2214
2215    /// A reply with no heading at all is still the successor's brief: the
2216    /// note's shape is its author's, and the row draws it as one `note` row.
2217    #[test]
2218    fn a_note_with_no_headings_is_one_note_section() {
2219        let raw = "the work is half done.\nthe next step is the client row.";
2220        assert!(parses_as_note(raw), "prose with no heading is the note");
2221        let sections = parse_judgment(raw);
2222        assert_eq!(sections.len(), 1, "one section, not zero");
2223        assert_eq!(sections[0].label, "note");
2224        assert_eq!(sections[0].lines.len(), 2, "and its lines survive");
2225    }
2226
2227    /// **The model's own headings split the note.** Nothing is prescribed
2228    /// about its shape, so whatever headings its author chose open sections,
2229    /// in the order written.
2230    #[test]
2231    fn the_models_own_headings_split_the_note() {
2232        let raw = "## Where things stand\nthe cut is half in.\n\
2233                   ## What failed\nthe first spawn.\n## Next\nfix the parser.";
2234        let sections = parse_judgment(raw);
2235        let labels: Vec<&str> = sections.iter().map(|section| section.label.as_str()).collect();
2236        assert_eq!(
2237            labels,
2238            ["Where things stand", "What failed", "Next"],
2239            "the note's own headings, in order",
2240        );
2241        assert_eq!(
2242            sections[1].lines,
2243            vec!["the first spawn.".to_owned()],
2244            "and each heading keeps the body under it",
2245        );
2246    }
2247
2248    /// **Every spelling of a heading line the model might write parses.**
2249    /// A note written under `## Next` used to arrive as the missing-note line,
2250    /// so the bare, hashed and emphasised forms all have to land - alone on
2251    /// the line, and with its colon and body.
2252    #[test]
2253    fn every_heading_spelling_parses() {
2254        for (label, body) in [
2255            ("Where things stand", "the cut is half in."),
2256            ("What failed", "the first spawn, on a cold cache."),
2257            ("Next", "fix the parser."),
2258        ] {
2259            for line in [
2260                format!("{label}: {body}"),
2261                format!("## {label}: {body}"),
2262                format!("**{label}:** {body}"),
2263                format!("- **{label}:** {body}"),
2264            ] {
2265                let sections = parse_judgment(&line);
2266                assert_eq!(sections.len(), 1, "one section for {line}");
2267                assert_eq!(sections[0].label, label, "the heading for {line}");
2268                assert_eq!(sections[0].lines, vec![body.to_owned()], "its body for {line}");
2269            }
2270            // A marked label closing on its emphasis takes the rest of the
2271            // same line as its body, colon or not.
2272            for line in [
2273                format!("**{label}** {body}"),
2274                format!("- **{label}** {body}"),
2275                format!("1. **{label}** {body}"),
2276            ] {
2277                let sections = parse_judgment(&line);
2278                assert_eq!(sections.len(), 1, "one section for {line}");
2279                assert_eq!(sections[0].label, label, "the heading for {line}");
2280                assert_eq!(sections[0].lines, vec![body.to_owned()], "its body for {line}");
2281            }
2282            // A heading alone on its line takes the body from the lines
2283            // below.
2284            for line in [
2285                format!("## {label}"),
2286                format!("**{label}**"),
2287                format!("## {label}."),
2288                format!("1. **{label}**"),
2289                format!("## 1. {label}"),
2290            ] {
2291                let sections = parse_judgment(&format!("{line}\n{body}"));
2292                assert_eq!(sections.len(), 1, "one section for {line}");
2293                assert_eq!(sections[0].label, label, "the heading for {line}");
2294                assert_eq!(
2295                    sections[0].lines,
2296                    vec![body.to_owned()],
2297                    "the body from the line below for {line}",
2298                );
2299            }
2300        }
2301    }
2302
2303    /// **Whichever delimiter opens first ends the label.** A colon before the
2304    /// closing emphasis keeps the label whole, and a colon that follows the
2305    /// emphasis is the body's, not the label's.
2306    #[test]
2307    fn the_first_delimiter_ends_the_label() {
2308        let sections = parse_judgment("## Where things stand: the **cut** is in");
2309        assert_eq!(sections.len(), 1, "one section: {sections:?}");
2310        assert_eq!(sections[0].label, "Where things stand");
2311        assert_eq!(sections[0].lines, vec!["the **cut** is in".to_owned()]);
2312
2313        let sections = parse_judgment("**Next**: run the gate");
2314        assert_eq!(sections.len(), 1, "one section: {sections:?}");
2315        assert_eq!(sections[0].label, "Next");
2316        assert_eq!(sections[0].lines, vec!["run the gate".to_owned()]);
2317
2318        // A body that opens with its own bold keeps it: only the label's
2319        // closing pair comes off, and only when the body begins with it.
2320        let sections = parse_judgment("Next: **run** the gate.");
2321        assert_eq!(sections.len(), 1, "one section: {sections:?}");
2322        assert_eq!(sections[0].label, "Next");
2323        assert_eq!(sections[0].lines, vec!["**run** the gate.".to_owned()]);
2324
2325        // Only the delimiter's own colon comes off, so a body that opens
2326        // with one (`::1`) is not trimmed into a number.
2327        let sections = parse_judgment("Next: ::1 is the address");
2328        assert_eq!(sections.len(), 1, "one section: {sections:?}");
2329        assert_eq!(sections[0].label, "Next");
2330        assert_eq!(sections[0].lines, vec!["::1 is the address".to_owned()]);
2331    }
2332
2333    /// **The label's own emphasis comes off; the body's stays.** Stripping a
2334    /// leading asterisk run rather than the matched pair left a note reading
2335    /// `nothing*` where its author wrote `*nothing*`.
2336    #[test]
2337    fn a_heading_strips_its_own_emphasis_and_leaves_the_body_s() {
2338        let sections = parse_judgment("**Now:** *nothing* has changed.");
2339        assert_eq!(sections.len(), 1, "one section");
2340        assert_eq!(sections[0].label, "Now");
2341        assert_eq!(sections[0].lines, vec!["*nothing* has changed.".to_owned()]);
2342    }
2343
2344    /// **A note written in markdown is the judgment half.** The regression
2345    /// this fixes: a correctly written markdown note read as no note at all,
2346    /// and the successor was told the note had not arrived.
2347    #[test]
2348    fn a_markdown_note_arrives_as_the_judgment_half() {
2349        let note = "\
2350## Where things stand
2351The cut is half in and the parser is next.
2352## What failed
2353worktree handoff-index-first at abc1234, no PR yet.
2354## Next
2355fix the parser, then the client row.";
2356        assert!(parses_as_note(note), "the note is recognised");
2357        let sections = parse_judgment(note);
2358        assert_eq!(
2359            sections.iter().map(|section| section.label.as_str()).collect::<Vec<_>>(),
2360            ["Where things stand", "What failed", "Next"],
2361            "the model's headings, in order",
2362        );
2363        assert_eq!(
2364            sections[0].lines,
2365            vec!["The cut is half in and the parser is next.".to_owned()],
2366            "the body under the heading",
2367        );
2368    }
2369
2370    /// **Any reply with content is a note.** forge asks for no particular
2371    /// shape, so a heading-less paragraph is the brief as much as a headed
2372    /// one; only an empty reply is missing.
2373    #[test]
2374    fn any_reply_with_content_is_a_note() {
2375        let prose = "I tried the first spawn and it failed. The row is still there.";
2376        assert!(parses_as_note(prose), "prose with no heading is the note");
2377        let sections = parse_judgment(prose);
2378        assert_eq!(sections.len(), 1, "one section: {sections:?}");
2379        assert_eq!(sections[0].label, "note");
2380        assert_eq!(sections[0].lines, vec![prose.to_owned()], "the whole paragraph survives");
2381        assert!(!parses_as_note("   \n  "), "an empty reply is not the note");
2382    }
2383
2384    /// **A short title-shaped label before a colon opens a section; a
2385    /// sentence carrying a colon mid-line stays body.** The parser follows the
2386    /// line's shape, not a list of known labels.
2387    #[test]
2388    fn a_title_shaped_label_opens_a_section_and_a_sentence_does_not() {
2389        let sections = parse_judgment("The export format is still open: row 1d14d131");
2390        assert_eq!(sections.len(), 1, "one section: {sections:?}");
2391        assert_eq!(sections[0].label, "The export format is still open");
2392        assert_eq!(sections[0].lines, vec!["row 1d14d131".to_owned()]);
2393
2394        let prose = parse_judgment("the parser fails: it drops the body.");
2395        assert_eq!(prose.len(), 1, "one section: {prose:?}");
2396        assert_eq!(prose[0].label, "note", "a lower-case sentence stays prose");
2397        assert_eq!(prose[0].lines, vec!["the parser fails: it drops the body.".to_owned()]);
2398    }
2399
2400    /// **The cap states its cut.** A note past the ceiling loses its tail,
2401    /// and the row says so rather than presenting what is left as the whole
2402    /// of what the old session wrote.
2403    #[test]
2404    fn a_note_past_the_cap_states_the_cut() {
2405        let note = format!("Now: {}\nNext: {}", "x".repeat(JUDGMENT_CAP - 200), "y".repeat(400));
2406        let sections = parse_judgment(&note);
2407        assert_eq!(sections.len(), 1, "one section survives the cut: {sections:?}");
2408        assert_eq!(sections[0].label, "Now", "and it is the note's own front: {sections:?}");
2409        assert!(
2410            sections[0].lines.first().is_some_and(|line| line.starts_with("xxx")),
2411            "the cap keeps the front of the note, not its tail: {sections:?}",
2412        );
2413        assert!(
2414            sections[0]
2415                .lines
2416                .last()
2417                .is_some_and(|line| line.contains("in the transcript named above")),
2418            "the cut is stated as the kept section's last line: {sections:?}",
2419        );
2420
2421        // A note with nothing that fits states the cut under the same `note`
2422        // label every heading-less reply takes.
2423        let huge = parse_judgment(&"x".repeat(JUDGMENT_CAP + 10));
2424        assert_eq!(huge.len(), 1, "the cut is stated: {huge:?}");
2425        assert_eq!(huge[0].label, "note", "the fallback is the note's own label");
2426        assert!(
2427            huge[0].lines[0].contains("longer than the brief allows"),
2428            "and says what happened: {huge:?}",
2429        );
2430    }
2431
2432    /// The successor reads the facts before the judgment, under the two
2433    /// part headings.
2434    #[test]
2435    fn the_brief_puts_the_ground_truth_before_the_judgment() {
2436        let brief = HandoffBrief {
2437            ground_truth: vec![HandoffSection {
2438                label: "seat".to_owned(),
2439                lines: vec!["Busytools/forge/lead".to_owned()],
2440            }],
2441            judgment: vec![HandoffSection {
2442                label: "Next".to_owned(),
2443                lines: vec!["the client row".to_owned()],
2444            }],
2445        };
2446        let text = brief_text("9cfe315c -> 4b21e0a7", &brief);
2447        let authority = text
2448            .find("PART 1 is forge's own ground truth and is authoritative for facts")
2449            .expect("the precedence is stated");
2450        let ground = text.find("PART 1: GROUND TRUTH").expect("part 1");
2451        let judgment = text.find("PART 2: JUDGMENT").expect("part 2");
2452        assert!(
2453            authority < ground,
2454            "the successor is told which half to trust before it reads either: {text}",
2455        );
2456        assert!(ground < judgment, "ground truth first: {text}");
2457        assert!(text.contains("seat: Busytools/forge/lead"), "the facts are rendered");
2458        assert!(text.contains("Next: the client row"), "and so is the judgment");
2459        assert!(
2460            text.contains("PART 2: JUDGMENT (written by the session that is handing over)"),
2461            "a note the session wrote names it as the author: {text}",
2462        );
2463
2464        // **A source row is forge's sentence in the session's half**, so the
2465        // header names the exception rather than letting it read as the
2466        // predecessor's words.
2467        let sourced = HandoffBrief {
2468            ground_truth: Vec::new(),
2469            judgment: vec![
2470                HandoffSection {
2471                    label: "source".to_owned(),
2472                    lines: vec![
2473                        "the exit call carried no note; this is the session's closing \
2474                                 text"
2475                            .to_owned(),
2476                    ],
2477                },
2478                HandoffSection {
2479                    label: "note".to_owned(),
2480                    lines: vec!["the cut is half in".to_owned()],
2481                },
2482            ],
2483        };
2484        let text = brief_text("9cfe315c -> 4b21e0a7", &sourced);
2485        assert!(
2486            text.contains("the source row is forge's; the rest is the session's own words"),
2487            "the header names the exception: {text}",
2488        );
2489        assert!(
2490            text.contains("apart from the source row forge added"),
2491            "and so does the precedence line: {text}",
2492        );
2493    }
2494
2495    /// **The brief forges the successor's procedure row.** The successor's
2496    /// transcript opens with it and the client draws it as the handoff
2497    /// procedure; the CLI never echoes a stdin prompt, so this frame is the
2498    /// page's only carrier of it.
2499    #[test]
2500    fn the_brief_forges_the_successors_procedure_row() {
2501        use crate::protocol::PromptSource;
2502
2503        let (workspace, mut rx) = crate::Workspace::testing_stub();
2504        workspace.enable_test_dispatch_intercept();
2505        let slot = SessionSlot::lead("Busytools", "forge");
2506
2507        workspace
2508            .dispatch_handoff_brief(&slot, "HANDOFF - a -> b".to_owned(), "brief-1".to_owned())
2509            .expect("the brief is routed");
2510
2511        let routed = workspace.drain_test_dispatch_buffer();
2512        assert!(
2513            routed.iter().any(|command| matches!(
2514                command,
2515                Command::PromptUnder { text, source: PromptSource::Forge, .. }
2516                    if text.starts_with("HANDOFF - ")
2517            )),
2518            "the successor runs it as forge's first turn: {routed:?}",
2519        );
2520        let mut drawn = Vec::new();
2521        while let Ok(frame) = rx.try_recv() {
2522            if let SessionUpdate::ChatAppended { msg, source, .. } = &*frame.update {
2523                drawn.push((msg.clone(), *source));
2524            }
2525        }
2526        let [(msg, source)] = drawn.as_slice() else {
2527            panic!("the brief draws one row: {drawn:?}");
2528        };
2529        assert_eq!(*source, Some(PromptSource::Forge), "the machinery's own words");
2530        let forge_primitives::Message::User { message, uuid, .. } = msg else {
2531            panic!("the row is the user turn it draws: {msg:?}");
2532        };
2533        assert_eq!(uuid.as_deref(), Some("brief-1"), "under the id the brief was sent with");
2534        assert!(
2535            matches!(
2536                &message.content[0],
2537                forge_primitives::ContentBlock::Text { text, .. } if text.starts_with("HANDOFF - ")
2538            ),
2539            "carrying the brief the successor arrived by: {message:?}",
2540        );
2541    }
2542
2543    /// **A handoff owns the seat from its begin until its successor lands.**
2544    /// The domain's own flag is cleared at the swap, so the gate is what
2545    /// carries the build and spawn window - a page attaching there must not
2546    /// read a live composer over a seat with no session.
2547    #[test]
2548    fn a_handoff_is_in_flight_from_its_begin_until_the_swap_closes() {
2549        let (workspace, _rx) = crate::Workspace::testing_stub();
2550        let slot = SessionSlot::lead("TestOrg", "forge");
2551        workspace.register_domain_session(slot.clone(), None);
2552
2553        assert!(!workspace.handoff_in_flight(&slot), "an idle seat takes input");
2554
2555        workspace.domain_session_for(&slot).expect("the domain").lock().handoff.awaiting = true;
2556        assert!(workspace.handoff_in_flight(&slot), "the judgment turn owns it");
2557        workspace.domain_session_for(&slot).expect("the domain").lock().handoff.awaiting = false;
2558
2559        workspace.begin_swap(&slot);
2560        assert!(workspace.handoff_in_flight(&slot), "the swap's build and spawn own it");
2561        workspace.end_swap(&slot);
2562        assert!(!workspace.handoff_in_flight(&slot), "the successor hands the seat back");
2563    }
2564
2565    /// Every machinery delivery goes through one gate, so a cron, a peer
2566    /// message, a Gotify push and a Slack message all wait for the successor
2567    /// rather than starting a turn on the session that is leaving.
2568    #[test]
2569    fn a_machinery_prompt_waits_for_the_successor() {
2570        use crate::protocol::PromptSource;
2571
2572        let (workspace, _rx) = crate::Workspace::testing_stub();
2573        let slot = SessionSlot::lead("Busytools", "forge");
2574        workspace.begin_swap(&slot);
2575
2576        workspace
2577            .dispatch_workspace_prompt_under(
2578                &slot,
2579                "the cron fired".to_owned(),
2580                PromptSource::Cron,
2581                "cron-1".to_owned(),
2582            )
2583            .expect("a held prompt is an accepted prompt");
2584
2585        let held = workspace.end_swap(&slot);
2586        assert_eq!(held.len(), 1, "the delivery is held, not run: {held:?}");
2587        assert_eq!(held[0].text, "the cron fired");
2588        assert_eq!(held[0].source, PromptSource::Cron, "and it keeps who sent it");
2589        assert!(!workspace.swap_in_progress(&slot), "the gate closes with the take");
2590    }
2591
2592    fn swap_request(slot: &SessionSlot, token: Option<u64>) -> SwapRequest {
2593        SwapRequest {
2594            slot: slot.clone(),
2595            from: "old-session-id".to_owned(),
2596            to: "new-session-id".to_owned(),
2597            token,
2598            reason: forge_primitives::HandoffReason::Manual,
2599            fill: None,
2600            at_percent: 85,
2601            role: Role::Lead,
2602            judgment: Vec::new(),
2603            cwd: "/tmp/handoff-build".to_owned(),
2604            launch_settings: crate::SessionLaunchSettings::default(),
2605        }
2606    }
2607
2608    /// **A successor replaces the seat's tabs before it starts**: the old
2609    /// occupant's tabs belong to it, and the new occupant's first call must
2610    /// not inherit them.
2611    #[tokio::test]
2612    async fn a_successor_replaces_the_seats_tabs_before_it_starts() {
2613        let (workspace, _rx) = crate::Workspace::testing_stub();
2614        workspace.enable_test_dispatch_intercept();
2615        let (calls, mut heard) = tokio::sync::mpsc::unbounded_channel();
2616        workspace
2617            .install_browser_host(std::sync::Arc::new(crate::browser::testing::StubHost { calls }));
2618        let slot = SessionSlot::lead("Busytools", "forge");
2619        let token = workspace.begin_swap(&slot);
2620
2621        Arc::clone(&workspace).run_handoff(swap_request(&slot, Some(token))).await;
2622
2623        let (what, args) = tokio::time::timeout(std::time::Duration::from_secs(2), heard.recv())
2624            .await
2625            .expect("the fleet hears the replacement")
2626            .expect("a replacement");
2627        assert_eq!(what, "replace_seat", "the seat is replaced: {args}");
2628        assert_eq!(args, serde_json::json!(slot));
2629        assert!(
2630            workspace
2631                .drain_test_dispatch_buffer()
2632                .iter()
2633                .any(|command| matches!(command, Command::NewSession { .. })),
2634            "and the successor is dispatched after it",
2635        );
2636    }
2637
2638    /// A new swap on the seat invalidates the token that opened the last one,
2639    /// and a closed gate owns nothing.
2640    #[test]
2641    fn a_new_swap_invalidates_the_previous_token() {
2642        let (workspace, _rx) = crate::Workspace::testing_stub();
2643        let slot = SessionSlot::lead("Busytools", "forge");
2644        let first = workspace.begin_swap(&slot);
2645        assert!(workspace.swap_is_current(&slot, first), "the opener owns the gate");
2646
2647        let second = workspace.begin_swap(&slot);
2648        assert!(!workspace.swap_is_current(&slot, first), "the replacement owns the seat now");
2649        assert!(workspace.swap_is_current(&slot, second));
2650
2651        workspace.end_swap(&slot);
2652        assert!(!workspace.swap_is_current(&slot, second), "a closed gate owns nothing");
2653    }
2654
2655    /// **A build that loses the seat mid-read writes nothing.** The token is
2656    /// checked again once the transcript and git read are in hand, because a
2657    /// replacement in that stretch would otherwise draw a row and spawn a
2658    /// successor for a seat it no longer runs.
2659    #[tokio::test]
2660    async fn a_build_that_loses_the_seat_writes_nothing() {
2661        use std::task::{Context, Poll, Waker};
2662
2663        let (workspace, mut rx) = crate::Workspace::testing_stub();
2664        workspace.enable_test_dispatch_intercept();
2665        let (calls, mut heard) = tokio::sync::mpsc::unbounded_channel();
2666        workspace
2667            .install_browser_host(std::sync::Arc::new(crate::browser::testing::StubHost { calls }));
2668        let slot = SessionSlot::lead("Busytools", "forge");
2669        let token = workspace.begin_swap(&slot);
2670        let mut build =
2671            Box::pin(Arc::clone(&workspace).run_handoff(swap_request(&slot, Some(token))));
2672
2673        // Drive the build to its first await: the transcript read.
2674        let waker = Waker::noop();
2675        let mut cx = Context::from_waker(waker);
2676        assert!(
2677            matches!(build.as_mut().poll(&mut cx), Poll::Pending),
2678            "precondition: the build suspended on its read",
2679        );
2680        // A second swap takes the seat while the first is still reading.
2681        workspace.begin_swap(&slot);
2682        build.await;
2683
2684        assert!(
2685            !workspace
2686                .drain_test_dispatch_buffer()
2687                .iter()
2688                .any(|command| matches!(command, Command::NewSession { .. })),
2689            "no successor is spawned for a seat the build no longer owns",
2690        );
2691        assert!(
2692            workspace.pending_brief_for(&slot).is_none(),
2693            "and no brief is left waiting for a successor that was never started",
2694        );
2695        assert!(
2696            tokio::time::timeout(std::time::Duration::from_millis(100), heard.recv())
2697                .await
2698                .is_err(),
2699            "a lost seat's tabs are not replaced",
2700        );
2701        let mut turns = Vec::new();
2702        while let Ok(frame) = rx.try_recv() {
2703            assert!(
2704                !matches!(&*frame.update, SessionUpdate::Handoff { .. }),
2705                "no handoff row is drawn for it: {:?}",
2706                frame.update,
2707            );
2708            if let SessionUpdate::MachineTurn { machine, .. } =
2709                std::sync::Arc::unwrap_or_clone(frame.update)
2710            {
2711                turns.push(machine);
2712            }
2713        }
2714        // **The build's own clear.** The begin announced the machine turn and
2715        // the lost seat spawns no successor whose record read would end it.
2716        assert_eq!(turns, vec![None], "the lost build hands the machine turn back");
2717    }
2718
2719    /// The machine-turn frames a run emitted, in order.
2720    fn machine_turns(
2721        rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::update_fanout::SeatUpdate>,
2722    ) -> Vec<Option<forge_primitives::runtime::MachineTurn>> {
2723        let mut out = Vec::new();
2724        while let Ok(frame) = rx.try_recv() {
2725            if let SessionUpdate::MachineTurn { machine, .. } =
2726                std::sync::Arc::unwrap_or_clone(frame.update)
2727            {
2728                out.push(machine);
2729            }
2730        }
2731        out
2732    }
2733
2734    /// **Every build that stops without a successor hands the machine turn
2735    /// back.** The begin announced it and no successor's record read follows,
2736    /// so a seat left holding the blocker would be one nobody can type into.
2737    #[tokio::test]
2738    async fn a_build_that_never_starts_hands_the_machine_turn_back() {
2739        let slot = SessionSlot::lead("TestOrg", "forge");
2740
2741        // No token: the gate was never this build's.
2742        let (workspace, mut rx) = crate::Workspace::testing_stub();
2743        Arc::clone(&workspace).run_handoff(swap_request(&slot, None)).await;
2744        assert_eq!(machine_turns(&mut rx), vec![None], "no token, no machine turn");
2745
2746        // The seat taken again before the build looks at it.
2747        let (workspace, mut rx) = crate::Workspace::testing_stub();
2748        let token = workspace.begin_swap(&slot);
2749        workspace.begin_swap(&slot);
2750        Arc::clone(&workspace).run_handoff(swap_request(&slot, Some(token))).await;
2751        assert_eq!(machine_turns(&mut rx), vec![None], "a seat lost before the build");
2752    }
2753
2754    /// **A build whose successor cannot start hands the machine turn back.**
2755    /// No dispatch intercept means no route for `NewSession`, which is the
2756    /// failure the begin's own frame would otherwise outlive.
2757    #[tokio::test]
2758    async fn a_build_whose_successor_cannot_start_hands_the_machine_turn_back() {
2759        let (workspace, mut rx) = crate::Workspace::testing_stub();
2760        let slot = SessionSlot::lead("TestOrg", "forge");
2761        let token = workspace.begin_swap(&slot);
2762
2763        Arc::clone(&workspace).run_handoff(swap_request(&slot, Some(token))).await;
2764
2765        assert_eq!(machine_turns(&mut rx), vec![None], "the successor never started");
2766    }
2767
2768    /// The successor's own first user row is the brief forge gave it, not a
2769    /// turn of its own - and its judgment half can carry a "do not" rule
2770    /// that would otherwise read as a correction.
2771    #[test]
2772    fn the_successor_counts_skip_the_brief_row() {
2773        let messages = vec![
2774            user("HANDOFF - 9cfe315c -> 4b21e0a7\n\nPART 2: JUDGMENT\ndo not delete the row"),
2775            user("carry on"),
2776            user("actually, keep the row"),
2777        ];
2778        let counts = successor_counts(&messages, &[]);
2779        assert_eq!(counts.turns, 2, "two turns, not the brief");
2780        assert_eq!(counts.corrections, 1, "and the correction is the later prompt: {counts:?}");
2781    }
2782
2783    /// A transcript read that answered nothing writes no counts: zeros would
2784    /// read as a successor that re-read nothing, the best possible brief.
2785    #[test]
2786    fn an_empty_read_measures_nothing() {
2787        assert!(
2788            successor_measurement(&[], &[]).is_none(),
2789            "an empty read is a failure, not a perfect successor",
2790        );
2791        assert!(
2792            successor_measurement(&[user("carry on")], &[]).is_some(),
2793            "a read with a turn in it measures",
2794        );
2795        assert!(
2796            successor_measurement(
2797                &[
2798                    user("HANDOFF - old -> new\n\nPART 1: GROUND TRUTH"),
2799                    assistant(Vec::new(), None),
2800                ],
2801                &[],
2802            )
2803            .is_none(),
2804            "a read holding only the brief and the assistant's rows measures nothing",
2805        );
2806    }
2807
2808    /// A predecessor transcript that is not there is stated, not read as a
2809    /// session with nothing in it.
2810    #[tokio::test]
2811    async fn an_absent_transcript_is_stated_in_the_ground_truth() {
2812        let dir = tempfile::tempdir().expect("tempdir");
2813        let transcript_path = dir.path().join("not-there.jsonl");
2814        let seat = HandoffSeat {
2815            slot: &SessionSlot::worker("Busytools", "forge", "handoff-cut"),
2816            from: "missing-session",
2817            cwd: dir.path(),
2818            transcript_path: &transcript_path,
2819            role: Role::Worker { charter_opening: None },
2820        };
2821        let ground = ground_truth(dir.path(), &seat, &[], Vec::new()).await;
2822        assert_eq!(ground.sections[0].label, "note", "the failure leads the brief");
2823        assert!(
2824            ground.sections[0].lines[0].contains("could not be read"),
2825            "and says what it is: {:?}",
2826            ground.sections[0].lines,
2827        );
2828    }
2829
2830    /// A re-read is a file the PREDECESSOR had read: the same file the
2831    /// successor opened for its own reasons is not evidence the brief lost
2832    /// anything.
2833    #[test]
2834    fn a_reread_is_a_file_the_predecessor_had_read() {
2835        let messages = vec![
2836            call("r1", "Read", serde_json::json!({ "file_path": "src/a.rs" })),
2837            call("r2", "Read", serde_json::json!({ "file_path": "src/b.rs" })),
2838        ];
2839        let counts = successor_counts(&messages, &["src/a.rs".to_owned()]);
2840        assert_eq!(counts.reread_files, 1, "only the file both sessions read: {counts:?}");
2841    }
2842
2843    /// Questions and corrections are what say the successor could not pick
2844    /// the work up from the brief alone.
2845    #[test]
2846    fn questions_and_corrections_are_counted() {
2847        let messages = vec![
2848            user("HANDOFF - brief"),
2849            user("go on"),
2850            call("q1", "AskUserQuestion", serde_json::json!({ "questions": [] })),
2851            user("as i said, the server row first"),
2852        ];
2853        let counts = successor_counts(&messages, &[]);
2854        assert_eq!(counts.questions, 1, "the ask is counted");
2855        assert_eq!(counts.corrections, 1, "and the repeat is a correction: {counts:?}");
2856    }
2857
2858    /// The threshold is compared on the pair, before the share is rounded:
2859    /// an 84.6% reading rounds to 85 and must still not fire at 85.
2860    #[test]
2861    fn the_threshold_is_compared_before_rounding() {
2862        let just_under = FillReading { percent: 85, total_tokens: 846_000, max_tokens: 1_000_000 };
2863        assert!(!just_under.fires(85), "84.6% is under the line");
2864        let at = FillReading { percent: 85, total_tokens: 850_000, max_tokens: 1_000_000 };
2865        assert!(at.fires(85), "exactly 85% is at it");
2866        let share_only = FillReading { percent: 85, total_tokens: 0, max_tokens: 0 };
2867        assert!(share_only.fires(85), "a share-only reading falls back to its share");
2868    }
2869
2870    /// **The turn runs memory first and ends on the exit call.** The prompt
2871    /// states the order and names the skill before the note's contract.
2872    #[test]
2873    fn the_judgment_prompt_runs_memory_then_the_exit() {
2874        let prompt = judgment_prompt();
2875        assert!(
2876            prompt.contains(
2877                "memory first, then close out what is running, then call `agents__handoff`"
2878            ),
2879            "the order is stated: memory, close out, exit",
2880        );
2881        assert!(
2882            prompt.find("Invoke the handoff skill").expect("the skill is named")
2883                < prompt.find("Write the continuation").expect("the contract follows"),
2884            "the skill runs before the note's contract",
2885        );
2886        assert!(
2887            prompt.contains("This note is Step 2's handoff document")
2888                && prompt.contains("pass the same text to `agents__handoff` as `brief`"),
2889            "the contract names the skill's document as the one note, not a separate file",
2890        );
2891    }
2892
2893    /// **The contract asks for the continuation, not a template.** It has to
2894    /// name every fact the next session needs and leave the shape to its
2895    /// author; a heading list here would put forge's five back by the door
2896    /// the cut closed.
2897    #[test]
2898    fn the_contract_asks_for_the_continuation_in_the_models_own_shape() {
2899        let prompt = judgment_prompt();
2900        for clause in [
2901            "in whatever shape reads best",
2902            "there is no set of headings to fill in",
2903            "where things stand",
2904            "what is done and verified",
2905            "what is in flight",
2906            "the row, PR, branch or session",
2907            "what you tried that failed",
2908            "the exact next action",
2909            "Point at what you recorded instead of retelling it",
2910            "the memory files by name",
2911            "Never write a key, token, password or personal data",
2912        ] {
2913            assert!(prompt.contains(clause), "the contract drops '{clause}'");
2914        }
2915        for heading in ["Now", "Where it stood", "Pointers", "Next", "Done means"] {
2916            assert!(!prompt.contains(heading), "the contract prescribes '{heading}' again");
2917        }
2918    }
2919
2920    /// **The shipped skill is Ved's handoff skill, verbatim, plus the forge
2921    /// additions.** His own sentences are never rewritten: the memory step, the
2922    /// handoff document and the index line are his text. The forge additions are
2923    /// two: the closing section, and the seat split that sends a worker to its
2924    /// own file - an override section rather than an edit to his sentences.
2925    #[test]
2926    fn the_shipped_skill_keeps_the_whole_handoff_procedure() {
2927        // Pinned on the words, not the wrap: the file is prose.
2928        let flat = SKILL.split_whitespace().collect::<Vec<_>>().join(" ");
2929        for (clause, why) in [
2930            ("## Step 1 - Memory first (REQUIRED, before any handoff text)", "his memory step"),
2931            ("## Where the handoff goes: lead or worker", "the seat decides the destination"),
2932            ("workers/<label>.md` in the same memory directory", "a worker writes its own file"),
2933            ("A worker writes no index line", "a worker writes no index line"),
2934            ("update it in place and delete superseded narration", "in-place updates"),
2935            (
2936                "Cross-project patterns or workarounds -> TIL files (`til/`), no need to ask.",
2937                "TILs",
2938            ),
2939            ("Update the memory index (`MEMORY.md`) in the SAME edit", "index in the same edit"),
2940            ("A learning that exists only in the handoff is not recorded", "memory over handoff"),
2941            ("The file is `handoff.md` in that directory", "the document he writes"),
2942            (
2943                "Write the continuation the next session needs, in your own shape",
2944                "the shape is its author's",
2945            ),
2946            ("There is no set of parts or headings to fill in", "no prescribed parts"),
2947            ("what is done and verified", "the verification clause"),
2948            ("the board row, PR, branch or session", "in-flight names its id"),
2949            ("what you tried that failed, and what stopped it", "dead ends are named"),
2950            (
2951                "the exact next action, and the file, command or row it starts at",
2952                "the next action starts somewhere",
2953            ),
2954            ("Redact sensitive information (API keys, passwords, PII).", "redaction"),
2955            ("SHIPPED / IN FLIGHT / BLOCKED", "the index token"),
2956            ("## Forge: close out and exit", "the forge addition"),
2957            ("record in memory what it is and how to resume it", "the close-out step"),
2958            ("Leave nothing unfinished unrecorded", "unfinished work is named, not dropped"),
2959            ("pass the same text to `agents__handoff` as `brief`", "the exit call"),
2960            ("there are no headings to fill in", "forge prescribes no headings"),
2961            ("Memory first, then close out what is running, then the note.", "the order"),
2962        ] {
2963            let clause = clause.split_whitespace().collect::<Vec<_>>().join(" ");
2964            assert!(flat.contains(&clause), "the shipped skill drops '{clause}' ({why})");
2965        }
2966        for heading in ["Where it stood", "Pointers", "Done means"] {
2967            assert!(!flat.contains(heading), "the shipped skill prescribes '{heading}' again");
2968        }
2969    }
2970
2971    /// `is_machine_prompt` recognizes forge's own turn by this exact prefix;
2972    /// a reworded opening would have the judgment prompt read back as the
2973    /// newest ask, and its own instructions as a user constraint. The matcher
2974    /// is exercised itself, not only the string it is supposed to match.
2975    #[test]
2976    fn the_judgment_prompt_is_a_machine_row() {
2977        let prompt = judgment_prompt();
2978        assert!(
2979            judgment_opening().starts_with(MACHINE_PREFIX),
2980            "the opening carries the matcher's prefix",
2981        );
2982        assert!(
2983            is_machine_prompt(&user(&prompt)),
2984            "the matcher recognises the real prompt as forge's own row",
2985        );
2986        assert!(
2987            !prompt.contains("context is nearly full"),
2988            "the opening is true for /handoff as well as the fill trigger",
2989        );
2990    }
2991
2992    /// The prompt ships to every install and names only forge's own things,
2993    /// the skill and the tool. It carries no path from one machine's tree.
2994    #[test]
2995    fn the_judgment_prompt_names_no_path() {
2996        let prompt = judgment_prompt();
2997        assert!(
2998            prompt.contains("Invoke the handoff skill"),
2999            "the compiled-in prompt is the real one, not an empty string",
3000        );
3001        for (token, why) in [
3002            ("CLAUDE", "one machine's instruction file"),
3003            ("MEMORY.md", "one machine's memory index"),
3004            ("~", "a home-relative path"),
3005            ("/", "a path at all"),
3006        ] {
3007            assert!(!prompt.contains(token), "the judgment prompt names '{token}' ({why})");
3008        }
3009    }
3010
3011    fn skill_named(name: &str) -> &'static ShippedSkill {
3012        shipped_skills().iter().find(|skill| skill.name == name).expect("a shipped skill")
3013    }
3014
3015    /// **Every shipped skill lands whole.** A fresh config dir gets both
3016    /// skills with every file the asset carries, byte for byte.
3017    #[test]
3018    fn every_shipped_skill_installs_all_its_files() {
3019        let dir = tempfile::tempdir().expect("tempdir");
3020        let names: Vec<&str> = shipped_skills().iter().map(|skill| skill.name).collect();
3021        assert_eq!(names, ["handoff", "wizard"], "the shipped set is both skills");
3022
3023        for skill in shipped_skills() {
3024            assert_eq!(
3025                install_skill(dir.path(), skill).expect("install"),
3026                SkillInstall::Created,
3027                "{} is created",
3028                skill.name,
3029            );
3030            for (name, contents) in skill.files {
3031                let path = skill_dir(dir.path(), skill.name).join(name);
3032                assert_eq!(
3033                    std::fs::read(&path).expect("read"),
3034                    contents.as_bytes(),
3035                    "{}/{} is the asset, byte for byte",
3036                    skill.name,
3037                    name,
3038                );
3039            }
3040        }
3041        assert_eq!(
3042            std::fs::read(skill_path(dir.path(), "handoff")).expect("read"),
3043            SKILL.as_bytes(),
3044            "handoff's installed bytes are still its own asset",
3045        );
3046    }
3047
3048    /// **An unchanged copy is not rewritten.** Its mtime stays put, and one
3049    /// edited file replaces only its own skill.
3050    #[test]
3051    fn a_second_install_touches_nothing_and_an_edit_replaces_only_its_skill() {
3052        let dir = tempfile::tempdir().expect("tempdir");
3053        for skill in shipped_skills() {
3054            install_skill(dir.path(), skill).expect("install");
3055        }
3056        let handoff = skill_path(dir.path(), "handoff");
3057        let before = std::fs::metadata(&handoff).expect("stat").modified().expect("mtime");
3058        for skill in shipped_skills() {
3059            assert_eq!(
3060                install_skill(dir.path(), skill).expect("install"),
3061                SkillInstall::Unchanged,
3062                "{} is unchanged",
3063                skill.name,
3064            );
3065        }
3066        let after = std::fs::metadata(&handoff).expect("stat").modified().expect("mtime");
3067        assert_eq!(before, after, "an identical skill is not rewritten");
3068
3069        // **One byte is a difference.** A length-only or prefix comparator
3070        // would keep this copy and hand a fresh install an edited skill.
3071        let mut bytes = std::fs::read(&handoff).expect("read");
3072        let last = bytes.len() - 1;
3073        bytes[last] ^= 0x01;
3074        std::fs::write(&handoff, &bytes).expect("one byte off");
3075        assert_eq!(
3076            install_skill(dir.path(), skill_named("wizard")).expect("install"),
3077            SkillInstall::Unchanged,
3078            "the other skill is untouched",
3079        );
3080        assert_eq!(
3081            install_skill(dir.path(), skill_named("handoff")).expect("install"),
3082            SkillInstall::Replaced,
3083        );
3084        assert_eq!(
3085            std::fs::read(&handoff).expect("read"),
3086            SKILL.as_bytes(),
3087            "replaced with the asset"
3088        );
3089    }
3090
3091    /// **Only its own `{file}.tmp` leftovers and names the skill retired are
3092    /// swept.** `<config_dir>/skills/` is the person's own directory too, and
3093    /// it sits in a sync-replicated tree, so a note, a helper, a foreign temp
3094    /// or a subdirectory beside `SKILL.md` survives a boot that rewrites the
3095    /// skill.
3096    #[test]
3097    fn the_sweep_takes_only_its_own_temps_and_retired_names() {
3098        let dir = tempfile::tempdir().expect("tempdir");
3099        let retired =
3100            ShippedSkill { name: "wizard", files: WIZARD_FILES, retired: &["old-stage.sh"] };
3101        install_skill(dir.path(), &retired).expect("install");
3102        let root = skill_dir(dir.path(), "wizard");
3103        std::fs::write(root.join("SKILL.md.tmp"), "half a write").expect("temp");
3104        std::fs::write(root.join("old-stage.sh"), "#!/bin/sh\n").expect("retired");
3105        std::fs::write(root.join("note.md"), "mine").expect("user file");
3106        std::fs::write(root.join("someone-else.tmp"), "theirs").expect("foreign temp");
3107        std::fs::create_dir(root.join("helpers")).expect("user dir");
3108        std::fs::write(root.join("helpers").join("old-stage.sh"), "keep").expect("inside a dir");
3109
3110        assert_eq!(
3111            install_skill(dir.path(), &retired).expect("install"),
3112            SkillInstall::Swept,
3113            "a sweep alone is not a rewrite",
3114        );
3115        assert!(!root.join("SKILL.md.tmp").exists(), "its own leftover temp is swept");
3116        assert!(!root.join("old-stage.sh").exists(), "a retired name is swept");
3117        assert!(root.join("note.md").exists(), "the person's file survives");
3118        assert!(root.join("someone-else.tmp").exists(), "a foreign temp is not forge's");
3119        assert!(root.join("helpers").join("old-stage.sh").exists(), "a subdirectory is theirs");
3120    }
3121
3122    /// A name the asset still ships is never swept, even if a retired list
3123    /// names it: a future asset ending in `.tmp` would delete itself.
3124    #[test]
3125    fn a_name_the_asset_still_ships_is_never_swept() {
3126        let dir = tempfile::tempdir().expect("tempdir");
3127        let retired =
3128            ShippedSkill { name: "wizard", files: WIZARD_FILES, retired: &["template.sh"] };
3129        install_skill(dir.path(), &retired).expect("install");
3130        assert_eq!(
3131            install_skill(dir.path(), &retired).expect("install"),
3132            SkillInstall::Unchanged,
3133            "a shipped file listed as retired is still not swept",
3134        );
3135        assert!(
3136            skill_dir(dir.path(), "wizard").join("template.sh").exists(),
3137            "the shipped file is there",
3138        );
3139    }
3140
3141    /// The shipped skills retire nothing today, so an unrecognised file is
3142    /// not even a change, let alone a removal.
3143    #[test]
3144    fn a_shipped_skill_sweeps_nothing_it_did_not_retire() {
3145        let dir = tempfile::tempdir().expect("tempdir");
3146        let wizard = skill_named("wizard");
3147        install_skill(dir.path(), wizard).expect("install");
3148        let user_file = skill_dir(dir.path(), "wizard").join("old-stage.sh");
3149        std::fs::write(&user_file, "#!/bin/sh\n").expect("user file");
3150        assert_eq!(
3151            install_skill(dir.path(), wizard).expect("install"),
3152            SkillInstall::Unchanged,
3153            "an unknown file is not the skill's to sweep",
3154        );
3155        assert!(user_file.exists(), "and it is still there");
3156    }
3157
3158    /// A file the asset carries that has gone missing is put back, and the
3159    /// whole skill reports replaced.
3160    #[test]
3161    fn a_deleted_file_comes_back_on_the_next_install() {
3162        let dir = tempfile::tempdir().expect("tempdir");
3163        let wizard = skill_named("wizard");
3164        install_skill(dir.path(), wizard).expect("install");
3165        let template = skill_dir(dir.path(), "wizard").join("template.sh");
3166        std::fs::remove_file(&template).expect("delete");
3167        assert_eq!(install_skill(dir.path(), wizard).expect("install"), SkillInstall::Replaced);
3168        assert_eq!(std::fs::read(&template).expect("read"), WIZARD_TEMPLATE.as_bytes());
3169    }
3170
3171    /// **A file the asset still carries is not rewritten when a sibling
3172    /// changes.** A whole-directory rewrite would move this file's mtime and
3173    /// hand a sync daemon two writes where one was owed.
3174    #[test]
3175    fn a_second_install_rewrites_only_the_file_that_differs() {
3176        let dir = tempfile::tempdir().expect("tempdir");
3177        let wizard = skill_named("wizard");
3178        install_skill(dir.path(), wizard).expect("install");
3179        let root = skill_dir(dir.path(), "wizard");
3180        let template = root.join("template.sh");
3181        let before = std::fs::metadata(&template).expect("stat").modified().expect("mtime");
3182        let skill_md = root.join("SKILL.md");
3183        let mut bytes = std::fs::read(&skill_md).expect("read");
3184        let last = bytes.len() - 1;
3185        bytes[last] ^= 0x01;
3186        std::fs::write(&skill_md, bytes).expect("edit");
3187        assert_eq!(install_skill(dir.path(), wizard).expect("install"), SkillInstall::Replaced);
3188        let after = std::fs::metadata(&template).expect("stat").modified().expect("mtime");
3189        assert_eq!(before, after, "the unchanged file is left alone");
3190        assert_eq!(std::fs::read(&skill_md).expect("read"), WIZARD_SKILL.as_bytes());
3191    }
3192
3193    /// A file this process may not read is an error from the read itself,
3194    /// and the bytes on disk are untouched.
3195    #[test]
3196    fn an_unreadable_skill_file_is_an_error_and_leaves_the_bytes_alone() {
3197        use std::os::unix::fs::PermissionsExt;
3198        let dir = tempfile::tempdir().expect("tempdir");
3199        let skill_md = skill_path(dir.path(), "handoff");
3200        std::fs::create_dir_all(skill_md.parent().expect("parent")).expect("dir");
3201        std::fs::write(&skill_md, b"mine").expect("write");
3202        std::fs::set_permissions(&skill_md, std::fs::Permissions::from_mode(0o000)).expect("mode");
3203        let result = install_skill(dir.path(), skill_named("handoff"));
3204        // Restore before asserting, so the tempdir can clean up either way.
3205        std::fs::set_permissions(&skill_md, std::fs::Permissions::from_mode(0o644)).expect("mode");
3206
3207        let error = result.expect_err("an unreadable skill file is an error");
3208        assert_eq!(
3209            error.kind(),
3210            std::io::ErrorKind::PermissionDenied,
3211            "the read's own error, not a later write: {error}",
3212        );
3213        assert_eq!(std::fs::read(&skill_md).expect("read"), b"mine", "the bytes are untouched");
3214    }
3215
3216    /// **A removal that fails is not an install that failed.** The content is
3217    /// already current; the leftover is retried next boot and a WARN says so.
3218    #[test]
3219    fn a_failed_sweep_does_not_fail_the_install() {
3220        use std::os::unix::fs::PermissionsExt;
3221        let dir = tempfile::tempdir().expect("tempdir");
3222        let wizard = skill_named("wizard");
3223        install_skill(dir.path(), wizard).expect("install");
3224        let root = skill_dir(dir.path(), "wizard");
3225        let leftover = root.join("SKILL.md.tmp");
3226        std::fs::write(&leftover, "half a write").expect("temp");
3227        std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o555)).expect("mode");
3228        let result = install_skill(dir.path(), wizard);
3229        std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).expect("mode");
3230
3231        assert_eq!(
3232            result.expect("a failed removal is not an install failure"),
3233            SkillInstall::Swept,
3234            "the content was already current; only the sweep was owed",
3235        );
3236        assert!(leftover.exists(), "the leftover is left for the next boot");
3237    }
3238
3239    /// **No file is renamed until every differing file is staged.** With a
3240    /// directory sitting where the second temp belongs, the first file must
3241    /// still hold its edited bytes and leave no temp behind.
3242    #[test]
3243    fn a_staged_write_that_fails_leaves_every_file_as_it_was() {
3244        let dir = tempfile::tempdir().expect("tempdir");
3245        let wizard = skill_named("wizard");
3246        install_skill(dir.path(), wizard).expect("install");
3247        let root = skill_dir(dir.path(), "wizard");
3248        let skill_md = root.join("SKILL.md");
3249        let template = root.join("template.sh");
3250        let edited = b"edited by the person".to_vec();
3251        std::fs::write(&skill_md, &edited).expect("edit");
3252        std::fs::write(&template, b"edited too").expect("edit");
3253        // The second file's temp cannot be written: a directory is there.
3254        std::fs::create_dir(root.join("template.sh.tmp")).expect("block the second temp");
3255
3256        assert!(install_skill(dir.path(), wizard).is_err(), "the staged write fails");
3257        assert_eq!(std::fs::read(&skill_md).expect("read"), edited, "nothing was renamed");
3258        assert!(!root.join("SKILL.md.tmp").exists(), "the staged temp is cleaned up");
3259    }
3260
3261    /// **A symlinked skill directory is refused, not written through and not
3262    /// swept.** Both would reach a tree outside the config dir.
3263    #[test]
3264    fn a_symlinked_skill_directory_is_refused_and_left_alone() {
3265        use std::os::unix::fs::symlink;
3266        let dir = tempfile::tempdir().expect("tempdir");
3267        let outside = tempfile::tempdir().expect("outside");
3268        std::fs::write(outside.path().join("SKILL.md.tmp"), "half a write").expect("leftover");
3269        std::fs::create_dir_all(dir.path().join("skills")).expect("skills dir");
3270        symlink(outside.path(), skill_dir(dir.path(), "wizard")).expect("symlink");
3271
3272        let error = install_skill(dir.path(), skill_named("wizard")).expect_err("refused");
3273        assert_eq!(
3274            error.kind(),
3275            std::io::ErrorKind::InvalidInput,
3276            "a refusal, not a write: {error}",
3277        );
3278        assert!(outside.path().join("SKILL.md.tmp").exists(), "nothing outside is swept");
3279        assert!(!outside.path().join("SKILL.md").exists(), "nothing outside is written");
3280    }
3281
3282    /// The name the prompt invokes is the name the skill declares and the
3283    /// directory it installs under, so the three cannot drift apart.
3284    #[test]
3285    fn the_prompt_invokes_the_skill_by_its_shipped_name() {
3286        let name = SKILL
3287            .lines()
3288            .find_map(|line| line.strip_prefix("name:"))
3289            .map(str::trim)
3290            .expect("the skill declares its name");
3291        assert!(
3292            judgment_prompt().contains(&format!("Invoke the {name} skill")),
3293            "the prompt names '{name}', the frontmatter's own name",
3294        );
3295        assert_eq!(
3296            skill_path(Path::new("/tmp/cfg"), name),
3297            Path::new("/tmp/cfg").join("skills").join(name).join("SKILL.md"),
3298            "and the install directory is that name",
3299        );
3300    }
3301
3302    /// **The wizard is a skill, not a stub.** Its two files are real, its
3303    /// frontmatter names the directory it installs under, and it keeps every
3304    /// part this cut merged in: the terminal protocol, the browser step, the
3305    /// credentials order, the stage-state file and the handoff pointer.
3306    #[test]
3307    fn the_wizard_asset_is_whole() {
3308        assert!(WIZARD_SKILL.len() > 1_000, "the skill is a document, not a placeholder");
3309        assert!(WIZARD_TEMPLATE.len() > 1_000, "the template carries the library");
3310        let name = WIZARD_SKILL
3311            .lines()
3312            .find_map(|line| line.strip_prefix("name:"))
3313            .map(str::trim)
3314            .expect("the skill declares its name");
3315        assert_eq!(name, "wizard");
3316        assert_eq!(
3317            skill_path(Path::new("/tmp/cfg"), name),
3318            Path::new("/tmp/cfg").join("skills").join("wizard").join("SKILL.md"),
3319            "the install directory is the frontmatter's name",
3320        );
3321        // **Pinned on the words, not the wrap**: the file is prose, and a
3322        // clause that happens to straddle a line is still the same promise.
3323        let flat = WIZARD_SKILL.split_whitespace().collect::<Vec<_>>().join(" ");
3324        for (clause, why) in [
3325            ("terminal__open", "the seat's own terminal"),
3326            ("terminal__hand_off", "the human-only hand-off"),
3327            ("`terminal__read` and `terminal__write` are refused", "the blind window"),
3328            ("browser_navigate", "the seat's browser"),
3329            ("browser_hand_off", "the tab hand-off"),
3330            ("`open_url` in the script is the fallback", "the no-browser fallback"),
3331            ("`settings__get`", "credentials: settings first"),
3332            (
3333                "when it is a stored credential and your tool list carries the vault family",
3334                "the \
3335              vault is conditional on the family",
3336            ),
3337            ("If the family was withheld from this session, ask your lead", "the withheld family"),
3338            ("If forge has no `[vault]` configured", "the unconfigured vault"),
3339            ("`ask_secret` in the terminal", "then hidden entry"),
3340            ("Never ask for a secret in chat", "no secret in chat"),
3341            ("never embedded in the script", "a found value is not written into the script"),
3342            (
3343                "the agent types it at the stage's own prompt with `terminal__write`",
3344                "the prompt's answer",
3345            ),
3346            ("`gh variable`", "the CI destination set"),
3347            ("`wizards/<slug>.md`", "the stage file a handoff resumes"),
3348            ("a worker in its own `workers/<label>.md`", "the seat rule in the opening"),
3349            ("a worker keeps it in its own `workers/<label>.md`", "the seat rule"),
3350            ("if confirm \"Delete the old webhook?\"", "the confirm idiom"),
3351            ("the `handoff` skill's", "the handoff pointer, not a copy of it"),
3352            ("never invent UI steps", "the no-invented-UI boundary"),
3353        ] {
3354            let clause = clause.split_whitespace().collect::<Vec<_>>().join(" ");
3355            assert!(flat.contains(&clause), "the wizard skill drops '{clause}' ({why})");
3356        }
3357        assert!(
3358            WIZARD_TEMPLATE.contains("Wizard library") && WIZARD_TEMPLATE.contains("STAGES"),
3359            "the template keeps its library and its authoring marker",
3360        );
3361        assert!(
3362            WIZARD_TEMPLATE.contains("The agent runs this in the seat's own terminal"),
3363            "the template's header tells the same story as the skill",
3364        );
3365    }
3366
3367    /// **The shipped skills ship to every install, so they name only what a
3368    /// fresh install has.** A user-scope skill, a plugin, a slash command or
3369    /// a path in one machine's home resolves to nothing there.
3370    #[test]
3371    fn the_shipped_skills_assume_no_local_environment() {
3372        for (skill, text) in
3373            [("handoff", SKILL), ("wizard", WIZARD_SKILL), ("wizard template", WIZARD_TEMPLATE)]
3374        {
3375            for (token, why) in [
3376                ("~/.claude", "a path in one machine's home"),
3377                ("superpowers", "a plugin, absent on a fresh install"),
3378                ("pr-review-loop", "a user-scope skill, absent on a fresh install"),
3379                ("dev-stack", "a project skill, absent on a fresh install"),
3380                ("commit-commands", "a plugin, absent on a fresh install"),
3381                ("`/audit-claude`", "a user-scope command, absent on a fresh install"),
3382                ("unslop", "a user-scope skill, absent on a fresh install"),
3383            ] {
3384                assert!(!text.contains(token), "{skill} names '{token}' ({why})");
3385            }
3386        }
3387    }
3388
3389    /// **The memory roots are wired into the brief itself.** Passing `&[]` in
3390    /// `ground_truth` would leave every other test green; this seeds a real
3391    /// transcript and reads the brief that build produces.
3392    #[tokio::test]
3393    async fn the_ground_truth_drops_the_memory_edits_from_the_files_row() {
3394        let dir = tempfile::tempdir().expect("tempdir");
3395        let config = dir.path();
3396        let project = dir.path().join("project");
3397        std::fs::create_dir_all(&project).expect("project dir");
3398        let cwd = std::fs::canonicalize(&project).expect("canonical");
3399        let session = "9cfe315c-aaaa-bbbb-cccc-dddddddddddd";
3400        let key = forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
3401            &cwd.to_string_lossy(),
3402        ));
3403        let transcript =
3404            forge_sdk::projects_dir_for(config).join(&key).join(format!("{session}.jsonl"));
3405        std::fs::create_dir_all(transcript.parent().expect("parent")).expect("projects dir");
3406        let memory = config.join("projects").join(&key).join("memory").join("note.md");
3407        let rows = [
3408            serde_json::json!({
3409                "type": "user",
3410                "message": {"role": "user", "content": [{"type": "text", "text": "do the thing"}]},
3411                "session_id": session,
3412            }),
3413            serde_json::json!({
3414                "type": "assistant",
3415                "session_id": session,
3416                "message": {
3417                    "id": "m1",
3418                    "role": "assistant",
3419                    "model": "a-model",
3420                    "content": [{"type": "tool_use", "id": "t1", "name": "Edit",
3421                                 "input": {"file_path": memory.to_string_lossy()}}],
3422                },
3423            }),
3424            serde_json::json!({
3425                "type": "assistant",
3426                "session_id": session,
3427                "message": {
3428                    "id": "m2",
3429                    "role": "assistant",
3430                    "model": "a-model",
3431                    "content": [{"type": "tool_use", "id": "t2", "name": "Edit",
3432                                 "input": {"file_path": "src/lib.rs"}}],
3433                },
3434            }),
3435        ];
3436        let body = rows.iter().fold(String::new(), |mut body, row| {
3437            body.push_str(&row.to_string());
3438            body.push('\n');
3439            body
3440        });
3441        std::fs::write(&transcript, body).expect("write transcript");
3442
3443        let seat = HandoffSeat {
3444            slot: &SessionSlot::worker("Busytools", "forge", "handoff-cut"),
3445            from: session,
3446            cwd: &cwd,
3447            transcript_path: &transcript,
3448            role: Role::Worker { charter_opening: None },
3449        };
3450        let ground = ground_truth(config, &seat, &[], Vec::new()).await;
3451        let files = ground.sections.iter().find(|section| section.label == "files").expect("files");
3452        let changed =
3453            files.lines.iter().find(|line| line.starts_with("changed:")).expect("changed");
3454        assert!(changed.contains("src/lib.rs"), "the project edit is named: {changed}");
3455        assert!(!changed.contains("memory"), "and the memory edit is not: {changed}");
3456    }
3457
3458    /// **A headings-only shell is not the note.** A reply carrying nothing but
3459    /// its own labels is an empty shell; one with any body under them is the
3460    /// brief.
3461    #[test]
3462    fn a_headings_only_reply_is_not_a_note() {
3463        assert!(!parses_as_note("## Where things stand\n## Next"));
3464        assert!(parses_as_note("## Next\nfix the parser."));
3465    }
3466
3467    /// **A note at the length the prompt asks for is not truncated.** A later
3468    /// lowering of the cap would cut a note that followed the prompt.
3469    #[test]
3470    fn a_compliant_note_fits_under_the_cap() {
3471        // Realistic word lengths: at 7-8 letters a 400-word note is roughly
3472        // 3,200 characters, so a cap set at the note's own length proves
3473        // nothing about the headroom a complying session has.
3474        let words = |n: usize, tag: &str| vec![tag; n].join(" ");
3475        let note = format!("{}\n{}", words(200, "morning"), words(200, "abandon"));
3476        assert!(
3477            note.split_whitespace().count() >= 400,
3478            "the fixture is at least the length the prompt asks for",
3479        );
3480        assert!(
3481            note.chars().count() > 3_000,
3482            "and long enough that a materially lower ceiling would cut it: {} chars",
3483            note.chars().count(),
3484        );
3485        let sections = parse_judgment(&note);
3486        assert_eq!(sections.len(), 1, "a heading-less note parses whole");
3487        assert_eq!(sections[0].label, "note", "as its one note section");
3488        assert!(
3489            sections
3490                .iter()
3491                .flat_map(|section| &section.lines)
3492                .all(|line| !line.contains("in the transcript named above")),
3493            "and the cap does not cut it",
3494        );
3495    }
3496
3497    /// Both memory roots are excluded: the project's and the environment's
3498    /// own, where the cross-project notes live.
3499    #[test]
3500    fn the_memory_roots_cover_the_project_and_the_environment() {
3501        let roots = memory_roots(Path::new("/tmp/cfg"), Path::new("/tmp/proj"));
3502        assert!(
3503            roots.contains(&PathBuf::from("/tmp/cfg/projects/-tmp-proj/memory")),
3504            "the project's memory dir exactly, not its key dir: {roots:?}",
3505        );
3506        assert!(
3507            roots.contains(&PathBuf::from("/tmp/cfg/memory")),
3508            "and the environment's, where the cross-project notes live: {roots:?}",
3509        );
3510    }
3511
3512    /// **Every spelling of the memory directory matches, and the boundary
3513    /// still holds.** A `~/` path, a canonical spelling of a symlinked root,
3514    /// and a sibling sharing the prefix are the three ways a raw string
3515    /// comparison gets this wrong.
3516    #[test]
3517    fn the_memory_path_match_covers_spellings_and_stops_at_the_boundary() {
3518        let dir = tempfile::tempdir().expect("tempdir");
3519        let root = dir.path().join("memory");
3520        std::fs::create_dir_all(&root).expect("dir");
3521        let roots = [root.clone()];
3522        assert!(
3523            is_memory_path(&root.join("note.md").to_string_lossy(), &roots),
3524            "the plain spelling",
3525        );
3526        let canonical = std::fs::canonicalize(&root).expect("canonical");
3527        assert!(
3528            is_memory_path(&canonical.join("note.md").to_string_lossy(), &roots),
3529            "a canonical spelling of a root reached through a symlink",
3530        );
3531        let home = dirs::home_dir().expect("home");
3532        assert!(
3533            is_memory_path("~/.claude-memory-test/note.md", &[home.join(".claude-memory-test")]),
3534            "a home-relative spelling",
3535        );
3536        assert!(
3537            !is_memory_path(&dir.path().join("memory-not/x.md").to_string_lossy(), &roots),
3538            "a sibling that merely shares the prefix is not inside",
3539        );
3540    }
3541
3542    /// **The memory the handoff writes is not the session's work.** Counting
3543    /// it would displace the project files the successor needs.
3544    #[test]
3545    fn the_memory_the_handoff_writes_is_not_a_project_change() {
3546        let roots =
3547            [PathBuf::from("/tmp/cfg/projects/-tmp-proj/memory"), PathBuf::from("/tmp/cfg/memory")];
3548        let messages = vec![
3549            call(
3550                "m1",
3551                "Edit",
3552                serde_json::json!({ "file_path": "/tmp/cfg/projects/-tmp-proj/memory/note.md" }),
3553            ),
3554            call("m2", "Edit", serde_json::json!({ "file_path": "src/handoff.rs" })),
3555            call(
3556                "m3",
3557                "Read",
3558                serde_json::json!({ "file_path": "/tmp/cfg/projects/-tmp-proj/memory/index.md" }),
3559            ),
3560            call("m4", "Read", serde_json::json!({ "file_path": "src/lib.rs" })),
3561            call(
3562                "m5",
3563                "Edit",
3564                serde_json::json!({ "file_path": "/tmp/cfg/memory/til/a-lesson.md" }),
3565            ),
3566        ];
3567        let section = files_section(&messages, &HandoffGitState::default(), &roots);
3568        assert!(
3569            section.lines.contains(&"changed: src/handoff.rs".to_owned()),
3570            "only the project edit is a change: {:?}",
3571            section.lines,
3572        );
3573        assert!(
3574            section.lines.contains(&"read: src/lib.rs".to_owned()),
3575            "and only the project read is a read: {:?}",
3576            section.lines,
3577        );
3578    }
3579
3580    /// The successor is measured against what its PREDECESSOR read; the
3581    /// predecessor's own handoff writes are not that.
3582    #[test]
3583    fn the_memory_the_handoff_reads_is_not_a_predecessor_read() {
3584        let roots =
3585            [PathBuf::from("/tmp/cfg/projects/-tmp-proj/memory"), PathBuf::from("/tmp/cfg/memory")];
3586        let messages = vec![
3587            call(
3588                "r1",
3589                "Read",
3590                serde_json::json!({ "file_path": "/tmp/cfg/projects/-tmp-proj/memory/index.md" }),
3591            ),
3592            call("r2", "Read", serde_json::json!({ "file_path": "src/lib.rs" })),
3593            call(
3594                "r3",
3595                "Read",
3596                serde_json::json!({ "file_path": "/tmp/cfg/memory/til/a-lesson.md" }),
3597            ),
3598        ];
3599        assert_eq!(read_set(&messages, &roots), vec!["src/lib.rs".to_owned()]);
3600    }
3601}