Skip to main content

majordomus_cli/capability/builtin/
continuity.rs

1//! The `continuity` module: what this checkout's lifecycle is holding right now — the open
2//! episode, the record the next worker would resume from, the newest progress note, what is
3//! blocking acceptance, and whether the provider's events actually reach the tool.
4//!
5//! Everything else in this executable reads the shared half of the layer: objects that are
6//! tracked, identical in every clone, and safe to project onto a public site. This module
7//! reads the other half, `.ai/local/state/`, and the difference decides the whole design.
8//!
9//! **It is read, never written.** The lifecycle is the shell tool's; this process is one of
10//! its readers. A second writer for the same records would be a second account of events the
11//! ledger already holds, which is the thing that design refuses.
12//!
13//! **It is served, never published.** These records name this machine — a worktree path, the
14//! checkpoint that happened to be newest on this disk — and a fact about a disk is not a
15//! fact about the repository (ADR 0014). So the exposure is the loopback server and MCP,
16//! both of which serve the worker sitting in front of this checkout, and there is no
17//! projection into `docs/generated/` or `site/`. The static site documents the mechanism and
18//! carries none of its content. A generator that published a session's briefing would
19//! publish the one thing in the layer that no other clone can reproduce.
20//!
21//! **Absence is an answer.** Every field here can be legitimately empty: a fresh clone has
22//! no ledger, a worker outside a task has no task, and a branch with no prior episode has no
23//! handover. Each of those is reported as itself rather than as a failure, and the resolver
24//! below never widens its search to make one go away — a record from another worktree
25//! silently becoming your context is worse than having none, because you cannot tell it is
26//! wrong until you have acted on it.
27
28use std::collections::BTreeMap;
29use std::path::{Path, PathBuf};
30
31use schemars::JsonSchema;
32use serde::{Deserialize, Serialize};
33
34use crate::capability::handler::{CapabilityError, Context};
35use crate::capability::model::{
36    BenchmarkPolicy, CapabilityKind, CliExposure, Exposure, McpExposure, McpResource, Stability,
37    WaiverReason,
38};
39use crate::capability::module::ModuleDescriptor;
40use crate::capability::CachePolicy;
41use crate::git::{self, GitState};
42use crate::metadata::frontmatter;
43use crate::metadata::yaml;
44use crate::{capability, module};
45
46use super::continuity_transfer as transfer;
47use super::{get, mcp, post, Empty};
48
49fn cli(words: &[&str]) -> Option<CliExposure> {
50    Some(CliExposure {
51        path: words.iter().map(|w| w.to_string()).collect(),
52    })
53}
54
55/// The URI under which `continuity.state` is read as an MCP resource.
56pub const CONTINUITY_URI: &str = "majordomus://continuity";
57
58/// The local half of the layer, relative to the repository root. Not configurable here: the
59/// shell tool decides where its state lives, and a second opinion about the path would be a
60/// second source of truth for the one thing both halves must agree on.
61const STATE_DIR: &str = ".ai/local/state";
62
63/// How far a record's recorded commit is from the commit this checkout is on.
64///
65/// The four words are the shell tool's, deliberately. A reader that met `advanced` from one
66/// surface and `stale` from another would have to learn the same four facts twice.
67#[derive(
68    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
69)]
70#[serde(rename_all = "snake_case")]
71pub enum Divergence {
72    /// Written at this commit. Trust it.
73    Exact,
74    /// Git has moved forward since. Trust it, and expect some of it to be done.
75    Advanced,
76    /// The recorded commit is not an ancestor: history was rewritten. Trust git, not this.
77    Diverged,
78    /// Another branch or another worktree. This record is not about your work.
79    DifferentContext,
80    /// Git could not answer, so this process says so rather than guessing `exact`.
81    Unknown,
82}
83
84impl Divergence {
85    /// The word as serialised.
86    ///
87    /// ```
88    /// use majordomus_cli::capability::builtin::continuity::Divergence;
89    /// assert_eq!(Divergence::Advanced.as_str(), "advanced");
90    /// ```
91    pub fn as_str(self) -> &'static str {
92        match self {
93            Divergence::Exact => "exact",
94            Divergence::Advanced => "advanced",
95            Divergence::Diverged => "diverged",
96            Divergence::DifferentContext => "different_context",
97            Divergence::Unknown => "unknown",
98        }
99    }
100
101    /// Whether a record carrying this label may be read as current knowledge. `diverged`
102    /// and `different_context` may not: the first describes a history that no longer
103    /// exists, the second describes somebody else's work.
104    ///
105    /// ```
106    /// use majordomus_cli::capability::builtin::continuity::Divergence;
107    /// assert!(Divergence::Advanced.trustworthy());
108    /// assert!(!Divergence::Diverged.trustworthy());
109    /// ```
110    pub fn trustworthy(self) -> bool {
111        matches!(self, Divergence::Exact | Divergence::Advanced)
112    }
113}
114
115/// How old a record is, judged against `session.freshness` in the policy.
116///
117/// This is the second of two independent judgements every record carries, and it exists
118/// because the first one cannot answer this question. [`Divergence`] says where a record's
119/// commit sits relative to HEAD; `advanced` means that commit is an ancestor of this one,
120/// which sounds like agreement and is identically true on the day the record is written and
121/// a month later. Between 2026-09-05 and 2026-09-11 this repository served a handover that
122/// was `advanced` and six days dead, and every surface that read this model — MCP, the HTTP
123/// API, the Cockpit — presented its `Next Action` as the thing to do (ADR 0052).
124///
125/// The thresholds are policy and are read, never restated here: a constant in this file
126/// would be a second source of truth for a number the policy owns, and a repository whose
127/// policy predates the key is reported as [`Freshness::Unknown`] naming the missing key
128/// rather than judged against a default nobody declared.
129///
130/// The five words divide into two groups, and the division is the one a caller acts on:
131/// `fresh` and `aging` are current and may be presented as the instruction, `stale` and
132/// `invalid` must be shown as history, and `unknown` is neither — a record nobody can date
133/// is not thereby an old record, and treating it as one would quietly discard a briefing
134/// that is perfectly good. [`Freshness::history`] is that division, and it is the only
135/// place it is decided.
136///
137/// ```
138/// use majordomus_cli::capability::builtin::continuity::Freshness;
139///
140/// // the two renderings are one value: the serialised word is the word `as_str` gives,
141/// // so a reader who met `stale` from MCP and from the command line met one fact
142/// let stale = serde_json::to_value(Freshness::Stale).unwrap();
143/// assert_eq!(stale, serde_json::json!("stale"));
144/// assert_eq!(stale.as_str(), Some(Freshness::Stale.as_str()));
145///
146/// // and the division a caller acts on: absence of a judgement is not a bad judgement
147/// assert!(Freshness::Stale.history() && Freshness::Invalid.history());
148/// assert!(!Freshness::Unknown.history(), "undated is not old");
149/// assert!(!Freshness::Fresh.history() && !Freshness::Aging.history());
150/// ```
151#[derive(
152    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
153)]
154#[serde(rename_all = "snake_case")]
155pub enum Freshness {
156    /// Inside `session.freshness.fresh_minutes`. Current; act on it.
157    Fresh,
158    /// Between the two thresholds. Still current, but its age is worth stating.
159    Aging,
160    /// At or beyond `session.freshness.stale_minutes`. History, never current.
161    Stale,
162    /// Nothing to judge it by: no timestamp, or no thresholds declared.
163    Unknown,
164    /// A timestamp that does not parse, or one in the future.
165    Invalid,
166}
167
168impl Freshness {
169    /// The word this verdict is reported and serialised under, which is the shell tool's
170    /// own vocabulary rather than a second one invented here — a reader who meets `aging`
171    /// from a briefing and from this executable has met one fact, not two.
172    ///
173    /// ```
174    /// use majordomus_cli::capability::builtin::continuity::Freshness;
175    /// assert_eq!(Freshness::Stale.as_str(), "stale");
176    /// // the serialised form is this word and not a second rendering of the same value
177    /// assert_eq!(
178    ///     serde_json::to_value(Freshness::Aging).unwrap(),
179    ///     serde_json::json!(Freshness::Aging.as_str()),
180    /// );
181    /// ```
182    pub fn as_str(self) -> &'static str {
183        match self {
184            Freshness::Fresh => "fresh",
185            Freshness::Aging => "aging",
186            Freshness::Stale => "stale",
187            Freshness::Unknown => "unknown",
188            Freshness::Invalid => "invalid",
189        }
190    }
191
192    /// Whether a record this old must be shown as history rather than as the current
193    /// instruction. `unknown` is not history: a record with no timestamp is not thereby old.
194    ///
195    /// ```
196    /// use majordomus_cli::capability::builtin::continuity::Freshness;
197    /// assert!(Freshness::Stale.history());
198    /// assert!(!Freshness::Unknown.history());
199    /// ```
200    pub fn history(self) -> bool {
201        matches!(self, Freshness::Stale | Freshness::Invalid)
202    }
203}
204
205/// Seconds since the Unix epoch for an RFC 3339 instant in UTC (`2026-09-05T12:34:56Z`), or
206/// `None` when the string is not one. The inverse of [`crate::peers::rfc3339`], and written
207/// beside it for the same reason: no date crate, and the layer's timestamps are one shape.
208///
209/// ```
210/// use majordomus_cli::capability::builtin::continuity::epoch_seconds;
211/// assert_eq!(epoch_seconds("1970-01-01T00:00:00Z"), Some(0));
212/// assert_eq!(epoch_seconds("2026-08-29T10:40:00Z"), Some(1_788_000_000));
213/// assert_eq!(epoch_seconds("not a timestamp"), None);
214/// ```
215pub fn epoch_seconds(ts: &str) -> Option<i64> {
216    let b = ts.as_bytes();
217    if b.len() != 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[19] != b'Z' {
218        return None;
219    }
220    let n = |a: usize, z: usize| ts.get(a..z)?.parse::<i64>().ok();
221    let (y, mth, d) = (n(0, 4)?, n(5, 7)?, n(8, 10)?);
222    let (h, mi, sec) = (n(11, 13)?, n(14, 16)?, n(17, 19)?);
223    if !(1..=12).contains(&mth) || !(1..=31).contains(&d) || h > 23 || mi > 59 || sec > 60 {
224        return None;
225    }
226    // days-from-civil, Howard Hinnant's algorithm: the inverse of the one in peers.rs.
227    let y = if mth <= 2 { y - 1 } else { y };
228    let era = y.div_euclid(400);
229    let yoe = y - era * 400;
230    let mp = if mth > 2 { mth - 3 } else { mth + 9 };
231    let doy = (153 * mp + 2) / 5 + d - 1;
232    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
233    let days = era * 146_097 + doe - 719_468;
234    Some(days * 86_400 + h * 3600 + mi * 60 + sec)
235}
236
237/// The thresholds a record is judged against, read from `session.freshness:` of the policy
238/// and never defaulted here.
239///
240/// Both fields are `Option` because the policy is allowed to be silent, and the silence is
241/// carried rather than filled in: a constant in this file would be a second source of truth
242/// for a number the policy owns. So the default value of this type is not "judge nothing
243/// old" — it is "this repository has not said", and [`Thresholds::judge`] answers
244/// [`Freshness::Unknown`] naming the missing key, which is a fact a reader can act on by
245/// declaring it.
246///
247/// ```
248/// use majordomus_cli::capability::builtin::continuity::{epoch_seconds, Freshness, Thresholds};
249///
250/// let then = epoch_seconds("2026-09-05T12:00:00Z").unwrap();
251///
252/// // undeclared: the age is still reported, and the verdict withholds itself
253/// let (verdict, age, why) = Thresholds::default().judge("2026-09-05T12:00:00Z", then + 3600);
254/// assert_eq!(verdict, Freshness::Unknown);
255/// assert_eq!(age, Some(60), "the age is measurable without a threshold to judge it by");
256/// assert!(why.contains("session.freshness"), "it names the key to declare: {why}");
257///
258/// // declared: the same record, now placed in a band
259/// let policy = Thresholds { fresh_minutes: Some(30), stale_minutes: Some(240) };
260/// let (verdict, _, _) = policy.judge("2026-09-05T12:00:00Z", then + 3600);
261/// assert_eq!(verdict, Freshness::Aging);
262/// ```
263#[derive(Debug, Clone, Copy, Default)]
264pub struct Thresholds {
265    /// `session.freshness.fresh_minutes`.
266    pub fresh_minutes: Option<i64>,
267    /// `session.freshness.stale_minutes`.
268    pub stale_minutes: Option<i64>,
269}
270
271impl Thresholds {
272    /// Judge one record's `created_at` against these thresholds, at `now` (Unix seconds).
273    /// Returns the verdict, the age in whole minutes when there is one, and the reason —
274    /// which is never empty, because a verdict a reader cannot act on is the failure this
275    /// whole subsystem is being corrected for.
276    ///
277    /// Both bands are closed at the bottom: a record exactly `fresh_minutes` old is already
278    /// `aging`, and one exactly `stale_minutes` old is already `stale`. `now` is a parameter
279    /// rather than a clock read inside, which is what makes the judgement a pure function of
280    /// two recorded facts and lets a test pin the instant.
281    ///
282    /// The three ways there is nothing to judge are kept apart, because they call for
283    /// different actions: no timestamp and no declared threshold are `unknown` — go and
284    /// declare one — while a timestamp that does not parse, or one in the future, is
285    /// `invalid`, which means a writer produced a record this reader should not trust.
286    ///
287    /// ```
288    /// use majordomus_cli::capability::builtin::continuity::{epoch_seconds, Freshness, Thresholds};
289    ///
290    /// let policy = Thresholds { fresh_minutes: Some(30), stale_minutes: Some(240) };
291    /// let at = "2026-09-05T12:00:00Z";
292    /// let then = epoch_seconds(at).unwrap();
293    /// let verdict = |minutes: i64| policy.judge(at, then + minutes * 60).0;
294    ///
295    /// // the bands, and the boundaries, which are closed at the bottom
296    /// assert_eq!(verdict(29), Freshness::Fresh);
297    /// assert_eq!(verdict(30), Freshness::Aging, "at the threshold it has already aged");
298    /// assert_eq!(verdict(239), Freshness::Aging);
299    /// assert_eq!(verdict(240), Freshness::Stale);
300    ///
301    /// // the age comes back measured, and the reason says it in the tool's own words
302    /// let (_, age, why) = policy.judge(at, then + 1500 * 60);
303    /// assert_eq!(age, Some(1500));
304    /// assert!(why.contains("25h"), "spans read as the shell tool writes them: {why}");
305    ///
306    /// // a record from the future is not old, it is wrong, and it says so
307    /// let (verdict, _, why) = policy.judge(at, then - 600);
308    /// assert_eq!(verdict, Freshness::Invalid);
309    /// assert!(why.contains("future"), "{why}");
310    /// assert_eq!(policy.judge("the day before", then).0, Freshness::Invalid);
311    ///
312    /// // and nothing to judge at all is `unknown`, which is not a complaint about the record
313    /// assert_eq!(policy.judge("", then).0, Freshness::Unknown);
314    /// ```
315    pub fn judge(self, created_at: &str, now: i64) -> (Freshness, Option<i64>, String) {
316        if created_at.is_empty() {
317            return (Freshness::Unknown, None, "no timestamp".into());
318        }
319        let Some(then) = epoch_seconds(created_at) else {
320            return (
321                Freshness::Invalid,
322                None,
323                format!("timestamp does not parse: {created_at}"),
324            );
325        };
326        let minutes = (now - then) / 60;
327        if minutes < 0 {
328            return (
329                Freshness::Invalid,
330                Some(minutes),
331                format!("timestamp is {} minute(s) in the future", -minutes),
332            );
333        }
334        let (Some(fresh), Some(stale)) = (self.fresh_minutes, self.stale_minutes) else {
335            return (
336                Freshness::Unknown,
337                Some(minutes),
338                "the policy declares no session.freshness thresholds".into(),
339            );
340        };
341        if minutes >= stale {
342            (
343                Freshness::Stale,
344                Some(minutes),
345                format!(
346                    "{} old, past the {} this repository calls stale",
347                    span(minutes),
348                    span(stale)
349                ),
350            )
351        } else if minutes >= fresh {
352            (
353                Freshness::Aging,
354                Some(minutes),
355                format!(
356                    "{} old, past the {} this repository calls fresh",
357                    span(minutes),
358                    span(fresh)
359                ),
360            )
361        } else {
362            (
363                Freshness::Fresh,
364                Some(minutes),
365                format!("{} old", span(minutes)),
366            )
367        }
368    }
369}
370
371/// Whole minutes as the shell tool renders a span: `18m`, `25h`, `6d`.
372///
373/// ```
374/// use majordomus_cli::capability::builtin::continuity::span;
375/// assert_eq!(span(18), "18m");
376/// assert_eq!(span(1500), "25h");
377/// assert_eq!(span(8640), "6d");
378/// ```
379pub fn span(minutes: i64) -> String {
380    if minutes < 90 {
381        format!("{minutes}m")
382    } else if minutes < 2880 {
383        format!("{}h", minutes / 60)
384    } else {
385        format!("{}d", minutes / 1440)
386    }
387}
388
389/// Which tier of the resolution rule matched. There is no third tier on purpose: a record
390/// from an unrelated worktree or branch is never offered.
391#[derive(
392    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
393)]
394#[serde(rename_all = "snake_case")]
395pub enum Match {
396    /// Same repository, same worktree, same branch.
397    SameWorktreeSameBranch,
398    /// Same repository, same branch, another worktree of it.
399    SameBranch,
400}
401
402/// One durable record of the local half, as much of it as a reader needs to decide whether
403/// to open the file.
404#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
405pub struct Record {
406    /// Repository-relative path. The body is at the path; it is not copied here.
407    pub path: String,
408    /// When the record says it was written.
409    pub created_at: String,
410    /// The task it belongs to, or `none`.
411    pub task_id: String,
412    /// The branch it was written on.
413    pub branch: String,
414    /// The commit it was written at.
415    pub head: String,
416    /// Whether the working tree was clean or dirty then.
417    pub working_tree: String,
418    /// Which tier of the resolution rule matched.
419    pub matched: Match,
420    /// How far its commit is from this one.
421    pub divergence: Divergence,
422    /// How old it is, judged against `session.freshness`. Independent of `divergence`: a
423    /// record is routinely `advanced` and `stale` at once, and that pair is what a reader
424    /// must not collapse into "trustworthy".
425    pub freshness: Freshness,
426    /// Its age in whole minutes, when it has a timestamp that parses.
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub age_minutes: Option<i64>,
429    /// Why it was judged that way, in one phrase. Never empty: a verdict a reader cannot
430    /// act on is the failure this subsystem is being corrected for.
431    pub freshness_reason: String,
432    /// The section a resuming worker acts on, when the record has one and is still current.
433    /// A handover's `Next Action`; empty for a record that carries no sections, and
434    /// deliberately empty for one that is `stale` or `invalid` — see `next_action_withheld`.
435    #[serde(default, skip_serializing_if = "String::is_empty")]
436    pub next_action: String,
437    /// Why `next_action` is empty despite the record having one. Set only when it was
438    /// withheld, never when the record simply carries no such section, so that a reader can
439    /// tell "there is nothing to do" from "what there was to do is six days old".
440    #[serde(default, skip_serializing_if = "String::is_empty")]
441    pub next_action_withheld: String,
442}
443
444/// The open episode this checkout points at, when there is one.
445///
446/// One execution episode belongs to one provider session, and several can be open in one
447/// checkout at once — two windows of the same provider are two workers. What is read here
448/// is `state/session-current.yaml`, the pointer to the episode of this checkout; a worker
449/// that knows its own provider session resolves its own episode instead, which is what
450/// `mj_session_here_file` in `lib/common.sh` does and what stamps each ledger line.
451#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
452pub struct OpenSession {
453    /// The episode's id.
454    pub session_id: String,
455    /// When it opened.
456    pub started_at: String,
457    /// Who opened it.
458    #[serde(default, skip_serializing_if = "String::is_empty")]
459    pub owner: String,
460    /// The worker identity, when one was supplied. Never inferred.
461    #[serde(default, skip_serializing_if = "String::is_empty")]
462    pub worker: String,
463    /// The provider whose event opened it, when one did.
464    #[serde(default, skip_serializing_if = "String::is_empty")]
465    pub provider: String,
466    /// That provider's own session identity — the string the prompt archive stamps on the
467    /// same worker's records, and the name this episode is keyed by. Empty for an episode
468    /// opened by hand, which is the one episode no provider session owns.
469    #[serde(default, skip_serializing_if = "String::is_empty")]
470    pub provider_session: String,
471    /// The branch it opened on.
472    #[serde(default, skip_serializing_if = "String::is_empty")]
473    pub branch: String,
474    /// The commit it opened at.
475    #[serde(default, skip_serializing_if = "String::is_empty")]
476    pub start_head: String,
477    /// True when the open record here belongs to another checkout. Such a record is
478    /// reported and never treated as this checkout's episode.
479    pub foreign: bool,
480}
481
482/// The active task of this checkout, when there is one.
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
484pub struct ActiveTask {
485    /// The task id.
486    pub id: String,
487    /// What is being worked on.
488    pub task: String,
489    /// The execution profile it runs under.
490    pub profile: String,
491    /// Its typed outcome so far: `active`, `handed_over`, or a finished one.
492    pub outcome: String,
493    /// The paths it claims.
494    #[serde(default, skip_serializing_if = "Vec::is_empty")]
495    pub scope: Vec<String>,
496    /// The obligations it owes before the outcome `completed` is available.
497    ///
498    /// Beside `scope` and not inside it, because the two are different promises: scope is
499    /// containment — where a worker may write — and this is delivery. A change can sit
500    /// entirely inside its scope and still be uncommitted on a laptop (ADR 0030).
501    /// [`super::obligations`] is what judges each of these against its evidence; here it
502    /// is reported as declared.
503    #[serde(default, skip_serializing_if = "Vec::is_empty")]
504    pub requires: Vec<String>,
505    /// When it started.
506    #[serde(default, skip_serializing_if = "String::is_empty")]
507    pub started_at: String,
508    /// The commit it started at.
509    #[serde(default, skip_serializing_if = "String::is_empty")]
510    pub head: String,
511    /// The issue the worker said this task executes (`start --issue`). A name, never state:
512    /// what it serves is answered by `intents.binding` on every read (ADR 0111).
513    #[serde(default, skip_serializing_if = "String::is_empty")]
514    pub issue: String,
515    /// The intent the worker said this task serves (`start --intent`).
516    #[serde(default, skip_serializing_if = "String::is_empty")]
517    pub intent: String,
518    /// The exemption class the worker gave instead (`start --exempt`).
519    #[serde(default, skip_serializing_if = "String::is_empty")]
520    pub exemption: String,
521    /// The reason given with the exemption.
522    #[serde(default, skip_serializing_if = "String::is_empty")]
523    pub exemption_because: String,
524    /// The standing `intents.binding` answered when the task started — what `start` was
525    /// told then, not where the work stands now.
526    #[serde(default, skip_serializing_if = "String::is_empty")]
527    pub binding: String,
528    /// The pin of what the work was for when the task started.
529    #[serde(default, skip_serializing_if = "String::is_empty")]
530    pub plan_revision: String,
531    /// The pin of where the served criteria's evidence stood when the task started.
532    #[serde(default, skip_serializing_if = "String::is_empty")]
533    pub evidence_standing: String,
534}
535
536/// What the lifecycle of this checkout is holding.
537#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
538pub struct Continuity {
539    /// Whether this checkout's lifecycle has ever run: a ledger line, a task, an episode or
540    /// a record. False in a fresh clone, which is not a fault.
541    ///
542    /// It is not "the directory exists". Several things create that directory before
543    /// anything has been recorded in it, so a reader that took its presence for evidence
544    /// would be told the lifecycle had run in a checkout where it never had.
545    pub present: bool,
546    /// The worktree this answer is about. Every selection below is scoped to it.
547    pub worktree: String,
548    /// The branch, or `DETACHED`.
549    pub branch: String,
550    /// The commit this checkout is on.
551    #[serde(default, skip_serializing_if = "String::is_empty")]
552    pub head: String,
553    /// `clean` or `dirty`.
554    #[serde(default, skip_serializing_if = "String::is_empty")]
555    pub working_tree: String,
556    /// The open episode, or `None`.
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub session: Option<OpenSession>,
559    /// The active task, or `None`.
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub task: Option<ActiveTask>,
562    /// The record the next worker would resume from, or `None` when nothing resolves here.
563    #[serde(default, skip_serializing_if = "Option::is_none")]
564    pub handover: Option<Record>,
565    /// The newest progress note for this worktree and branch, or `None`.
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub checkpoint: Option<Record>,
568    /// Unresolved questions on this branch. Every one refuses `finish --outcome completed`,
569    /// whichever task opened it.
570    #[serde(default, skip_serializing_if = "Vec::is_empty")]
571    pub blockers: Vec<String>,
572    /// How many records of each kind this checkout holds, against the policy's caps.
573    pub tallies: BTreeMap<String, usize>,
574    /// What a reader should know before trusting any of the above: a diverged record, a
575    /// foreign open session, a malformed file that was skipped. Empty is the good case.
576    #[serde(default, skip_serializing_if = "Vec::is_empty")]
577    pub findings: Vec<String>,
578}
579
580// --------------------------------------------------------------------- reading
581
582/// The scalar fields of a YAML mapping, flattened to strings.
583fn scalars(map: serde_json::Map<String, serde_json::Value>) -> BTreeMap<String, String> {
584    map.into_iter()
585        .filter_map(|(k, v)| yaml::scalar_string(&v).map(|s| (k, s)))
586        .collect()
587}
588
589/// The front matter of a Markdown record. A file without front matter, or with front matter
590/// that does not parse, yields `None` and is counted as skipped by the caller: a malformed
591/// record degrades the answer, it never fails the call.
592fn front(path: &Path) -> Option<BTreeMap<String, String>> {
593    let text = std::fs::read_to_string(path).ok()?;
594    let split = frontmatter::split(&text).ok()?;
595    Some(scalars(yaml::parse_mapping(split.front?).ok()?))
596}
597
598/// A whole YAML document, for the two records of the local half that are not Markdown.
599///
600/// `session-current.yaml` (a symlink into `state/sessions-open/`) and `current.yaml` carry
601/// no `---` fences — they are the state
602/// itself rather than a document about it — so reading them with the front-matter splitter
603/// finds nothing and reports an absent episode in a checkout that has one. Two shapes, two
604/// readers, and the difference stated here rather than discovered.
605pub(crate) fn document(path: &Path) -> Option<BTreeMap<String, String>> {
606    let text = std::fs::read_to_string(path).ok()?;
607    Some(scalars(yaml::parse_mapping(&text).ok()?))
608}
609
610/// One level-one section of a Markdown record, body only, trimmed. Used to lift the part of
611/// a handover a resuming worker acts on without copying the document into every answer.
612fn section(path: &Path, want: &str) -> String {
613    let Ok(text) = std::fs::read_to_string(path) else {
614        return String::new();
615    };
616    let mut out: Vec<&str> = Vec::new();
617    let mut on = false;
618    for line in text.lines() {
619        if let Some(head) = line.strip_prefix("# ") {
620            on = head.trim_end() == want;
621            continue;
622        }
623        if on {
624            out.push(line);
625        }
626    }
627    while out.first().is_some_and(|l| l.trim().is_empty()) {
628        out.remove(0);
629    }
630    while out.last().is_some_and(|l| l.trim().is_empty()) {
631        out.pop();
632    }
633    out.join("\n")
634}
635
636/// Compare a record's commit with this checkout's, the same way the shell tool does: by
637/// ancestry, not by equality of timestamps. `git` being unavailable yields `Unknown`, which
638/// is the honest answer and not `Exact`.
639pub(crate) fn divergence(root: &Path, theirs: &str, ours: Option<&str>) -> Divergence {
640    let Some(ours) = ours else {
641        return Divergence::Unknown;
642    };
643    if theirs == ours {
644        return Divergence::Exact;
645    }
646    if theirs.is_empty() {
647        return Divergence::Unknown;
648    }
649    match git::is_ancestor(root, theirs, ours) {
650        Some(true) => Divergence::Advanced,
651        Some(false) => Divergence::Diverged,
652        None => Divergence::Unknown,
653    }
654}
655
656/// The most relevant record in `dir` for this worktree and branch.
657///
658/// Two tiers and no third, exactly as `mj_resolve_latest` has them: same worktree and
659/// branch, then same branch in another worktree of the same repository, then nothing. The
660/// ordering inside a tier is by the timestamp the record asserts — never by filesystem
661/// modification time, which does not survive a clone and is not the time the record claims.
662///
663/// Returns the record and the number of files that were skipped because they could not be
664/// read as records.
665///
666/// Shared with the preflight (`environment::preflight`), which reports the same record's
667/// freshness at entry: one resolution rule, so the briefing and the entry banner cannot
668/// name two different handovers.
669pub(crate) fn resolve_record(
670    root: &Path,
671    dir: &Path,
672    branch: &str,
673    head: Option<&str>,
674    thresholds: Thresholds,
675    now: i64,
676) -> (Option<Record>, usize) {
677    resolve(root, dir, branch, head, thresholds, now)
678}
679
680fn resolve(
681    root: &Path,
682    dir: &Path,
683    branch: &str,
684    head: Option<&str>,
685    thresholds: Thresholds,
686    now: i64,
687) -> (Option<Record>, usize) {
688    let Ok(entries) = std::fs::read_dir(dir) else {
689        return (None, 0);
690    };
691    let worktree = root.to_string_lossy().to_string();
692    let mut best: Option<(u8, String, Record)> = None;
693    let mut skipped = 0usize;
694
695    let mut paths: Vec<PathBuf> = entries
696        .filter_map(|e| e.ok().map(|e| e.path()))
697        .filter(|p| p.extension().is_some_and(|x| x == "md"))
698        .collect();
699    // A deterministic walk, so two runs over one directory agree about which of two
700    // records written in the same second is newer.
701    paths.sort();
702
703    for path in paths {
704        let Some(f) = front(&path) else {
705            skipped += 1;
706            continue;
707        };
708        let (Some(created), Some(rhead)) = (f.get("created_at"), f.get("head")) else {
709            skipped += 1;
710            continue;
711        };
712        if f.get("schema_version").map(String::as_str) != Some("1") {
713            skipped += 1;
714            continue;
715        }
716        let rbranch = f.get("branch").cloned().unwrap_or_default();
717        let rworktree = f.get("worktree").cloned().unwrap_or_default();
718        let tier = if rworktree == worktree && rbranch == branch {
719            0u8
720        } else if branch != "DETACHED" && rbranch == branch {
721            1u8
722        } else {
723            continue;
724        };
725        let rel = path
726            .strip_prefix(root)
727            .unwrap_or(&path)
728            .to_string_lossy()
729            .to_string();
730        let record = Record {
731            path: rel,
732            created_at: created.clone(),
733            task_id: f.get("task_id").cloned().unwrap_or_else(|| "none".into()),
734            branch: rbranch,
735            head: rhead.clone(),
736            working_tree: f.get("working_tree").cloned().unwrap_or_default(),
737            matched: if tier == 0 {
738                Match::SameWorktreeSameBranch
739            } else {
740                Match::SameBranch
741            },
742            divergence: divergence(root, rhead, head),
743            freshness: Freshness::Unknown,
744            age_minutes: None,
745            freshness_reason: String::new(),
746            next_action: section(&path, "Next Action"),
747            next_action_withheld: String::new(),
748        };
749        // Judged here rather than by each surface: MCP, the HTTP API and the Cockpit all
750        // read this one value, which is the point. A record past the stale threshold keeps
751        // its path — knowing what the last worker was doing is worth having — and loses the
752        // section a reader would otherwise act on, with the reason in its place.
753        let mut record = record;
754        let (f, age, why) = thresholds.judge(&record.created_at, now);
755        record.freshness = f;
756        record.age_minutes = age;
757        record.freshness_reason = why;
758        if f.history() && !record.next_action.is_empty() {
759            record.next_action_withheld = format!(
760                "the record is {}: {}. It is history, not an instruction; read it at the path above.",
761                f.as_str(),
762                record.freshness_reason
763            );
764            record.next_action.clear();
765        }
766        let key = created.clone();
767        let better = match &best {
768            None => true,
769            Some((btier, bkey, _)) => tier < *btier || (tier == *btier && key > *bkey),
770        };
771        if better {
772            best = Some((tier, key, record));
773        }
774    }
775    (best.map(|(_, _, r)| r), skipped)
776}
777
778/// How many `.md` records a directory holds.
779fn count(dir: &Path) -> usize {
780    std::fs::read_dir(dir)
781        .map(|d| {
782            d.filter_map(|e| e.ok())
783                .filter(|e| e.path().extension().is_some_and(|x| x == "md"))
784                .count()
785        })
786        .unwrap_or(0)
787}
788
789/// The unresolved entries of the blocker store. The line format is machine-written precisely
790/// so a reader like this one can rely on it; a line that does not match is not silently
791/// dropped, it is simply not unresolved, and `doctor` is what reports a store that does not
792/// parse.
793///
794/// The HTML comment block is skipped, exactly as the shell reader skips it. The store's
795/// template documents its own line format inside a comment, so a reader that does not skip
796/// it reports a blocker that does not exist in every fresh checkout — and a phantom blocker
797/// is worse than a missed one, because it refuses work nobody can unblock.
798fn blockers(path: &Path) -> Vec<String> {
799    let Ok(text) = std::fs::read_to_string(path) else {
800        return Vec::new();
801    };
802    let mut out = Vec::new();
803    let mut in_comment = false;
804    for line in text.lines() {
805        if line.contains("<!--") {
806            in_comment = true;
807        }
808        if in_comment {
809            if line.contains("-->") {
810                in_comment = false;
811            }
812            continue;
813        }
814        if let Some(rest) = line.strip_prefix("- [unresolved] ") {
815            out.push(rest.trim().to_string());
816        }
817    }
818    out
819}
820
821// --------------------------------------------------------------------- handler
822
823/// The freshness thresholds this repository declares, read from the policy the manifest
824/// names.
825///
826/// Read here rather than carried on the [`Context`] because only this module and
827/// [`super::knowledge_base`] need them, and read from the policy rather than written down
828/// because a constant in this file would be the second copy of a number the policy owns — the
829/// drift `session.freshness` exists in one place to prevent. A repository whose policy predates the key, or whose
830/// policy cannot be read at all, yields no thresholds; every record is then reported as
831/// `unknown` naming the missing key, which is the honest answer and not a default.
832pub(crate) fn thresholds_of(ctx: &Context) -> Thresholds {
833    let root = PathBuf::from(&ctx.index.repository.root);
834    let Ok(repo) = crate::repository::Repository::open(&root) else {
835        return Thresholds::default();
836    };
837    let Ok(loaded) = crate::policy::LoadedPolicy::load(&repo) else {
838        return Thresholds::default();
839    };
840    Thresholds {
841        fresh_minutes: loaded.policy.session.freshness.fresh_minutes,
842        stale_minutes: loaded.policy.session.freshness.stale_minutes,
843    }
844}
845
846fn state(ctx: &Context, _: Empty) -> Result<Continuity, CapabilityError> {
847    let root = PathBuf::from(&ctx.index.repository.root);
848    let dir = root.join(STATE_DIR);
849
850    let (branch, head, working_tree) = match &ctx.index.repository.git {
851        GitState::Available(info) => (
852            info.branch.clone().unwrap_or_else(|| "DETACHED".into()),
853            info.head.clone(),
854            info.working_tree.clone(),
855        ),
856        GitState::Unavailable { .. } => ("DETACHED".into(), None, String::new()),
857    };
858
859    let mut findings = Vec::new();
860    let mut skipped = 0usize;
861
862    // --- the open episode
863    let session = match document(&dir.join("session-current.yaml")) {
864        Some(f) => {
865            let sworktree = f.get("worktree").cloned().unwrap_or_default();
866            let foreign = !sworktree.is_empty() && sworktree != root.to_string_lossy();
867            if foreign {
868                findings.push(format!(
869                    "the open session record here belongs to {sworktree}, not this checkout; nothing about it is about your work"
870                ));
871            }
872            f.get("session_id").map(|id| OpenSession {
873                session_id: id.clone(),
874                started_at: f.get("started_at").cloned().unwrap_or_default(),
875                owner: f.get("owner").cloned().unwrap_or_default(),
876                worker: f.get("worker").cloned().unwrap_or_default(),
877                provider: f.get("provider").cloned().unwrap_or_default(),
878                provider_session: f.get("provider_session").cloned().unwrap_or_default(),
879                branch: f.get("branch").cloned().unwrap_or_default(),
880                start_head: f.get("start_head").cloned().unwrap_or_default(),
881                foreign,
882            })
883        }
884        None => None,
885    };
886
887    // --- the active task. Its `scope` is a list, so it is read from the document rather
888    // than from the scalar flattening the other fields use.
889    let task = read_task(&dir.join("current.yaml"));
890
891    // --- the two resolved records
892    // One reading of the policy's thresholds and one reading of the clock for both records,
893    // so that two records resolved in the same call cannot be judged against different
894    // instants — and so that the numbers come from the policy rather than from this file.
895    let thresholds = thresholds_of(ctx);
896    let now = std::time::SystemTime::now()
897        .duration_since(std::time::UNIX_EPOCH)
898        .map(|d| d.as_secs() as i64)
899        .unwrap_or(0);
900    let (handover, s1) = resolve(
901        &root,
902        &dir.join("handovers"),
903        &branch,
904        head.as_deref(),
905        thresholds,
906        now,
907    );
908    let (checkpoint, s2) = resolve(
909        &root,
910        &dir.join("checkpoints"),
911        &branch,
912        head.as_deref(),
913        thresholds,
914        now,
915    );
916    skipped += s1 + s2;
917    if skipped > 0 {
918        findings.push(format!(
919            "{skipped} file(s) under {STATE_DIR} could not be read as records and were skipped; run `majordomus doctor`"
920        ));
921    }
922    for (what, r) in [("handover", &handover), ("checkpoint", &checkpoint)] {
923        if let Some(r) = r {
924            if !r.divergence.trustworthy() {
925                findings.push(format!(
926                    "the resolved {what} is {}: it was written at {} and that commit is not in this history; trust git over it",
927                    r.divergence.as_str(),
928                    &r.head[..7.min(r.head.len())]
929                ));
930            }
931            // Reported separately, because it is a separate fact. A record is routinely
932            // `advanced` — its commit an ancestor of HEAD, which reads as agreement — and
933            // long dead at the same time, and it was exactly that pair, unreported, that
934            // handed six days of workers a finished instruction (ADR 0052).
935            if r.freshness.history() {
936                findings.push(format!(
937                    "the resolved {what} is {}: {}. It is context, not an instruction; what it said to do next is not offered as current.",
938                    r.freshness.as_str(),
939                    r.freshness_reason
940                ));
941            }
942        }
943    }
944
945    let blockers = blockers(&dir.join("open-questions.md"));
946    if !blockers.is_empty() {
947        findings.push(format!(
948            "{} unresolved question(s) on this branch; every one refuses `majordomus finish --outcome completed`",
949            blockers.len()
950        ));
951    }
952
953    let mut tallies = BTreeMap::new();
954    tallies.insert("handovers".to_string(), count(&dir.join("handovers")));
955    tallies.insert("checkpoints".to_string(), count(&dir.join("checkpoints")));
956    // No `sessions` tally. A closed episode is an object of the shared layer, so the index
957    // already counts it and `objects.list --kind session` already answers it; a second
958    // count here, over a directory the closed records no longer live in, is the drift this
959    // repository forbids rather than a convenience.
960    tallies.insert("blockers".to_string(), blockers.len());
961    tallies.insert(
962        "ledger_lines".to_string(),
963        std::fs::read_to_string(dir.join("ledger.jsonl"))
964            .map(|t| t.lines().filter(|l| !l.trim().is_empty()).count())
965            .unwrap_or(0),
966    );
967
968    let present = task.is_some() || session.is_some() || tallies.values().any(|n| *n > 0);
969
970    Ok(Continuity {
971        present,
972        worktree: root.to_string_lossy().to_string(),
973        branch,
974        head: head.unwrap_or_default(),
975        working_tree,
976        session,
977        task,
978        handover,
979        checkpoint,
980        blockers,
981        tallies,
982        findings,
983    })
984}
985
986/// The active task record. Read whole rather than through the scalar flattening, because
987/// `scope` and `requires` are lists, and a task without its scope is a task whose claim
988/// nobody can check.
989///
990/// Shared with [`super::obligations`], which expands `requires` against the evidence in the
991/// ledger. One reader for one file: a second parse of the same record is how two surfaces
992/// come to disagree about what the task said.
993pub(crate) fn read_task(path: &Path) -> Option<ActiveTask> {
994    let text = std::fs::read_to_string(path).ok()?;
995    let map = yaml::parse_mapping(&text).ok()?;
996    let s = |k: &str| map.get(k).and_then(yaml::scalar_string).unwrap_or_default();
997    let id = s("id");
998    if id.is_empty() {
999        return None;
1000    }
1001    let list = |k: &str| -> Vec<String> {
1002        map.get(k)
1003            .and_then(|v| v.as_array())
1004            .map(|a| a.iter().filter_map(yaml::scalar_string).collect())
1005            .unwrap_or_default()
1006    };
1007    let scope = list("scope");
1008    let requires = list("requires");
1009    Some(ActiveTask {
1010        id,
1011        task: s("task"),
1012        profile: s("profile"),
1013        outcome: s("outcome"),
1014        scope,
1015        requires,
1016        started_at: s("started_at"),
1017        head: s("head"),
1018        issue: s("issue"),
1019        intent: s("intent"),
1020        exemption: s("exemption"),
1021        exemption_because: s("exemption_because"),
1022        binding: s("binding"),
1023        plan_revision: s("plan_revision"),
1024        evidence_standing: s("evidence_standing"),
1025    })
1026}
1027
1028/// The module.
1029pub fn module() -> ModuleDescriptor {
1030    module! {
1031        id: "continuity",
1032        title: "Continuity",
1033        description: "What this checkout's lifecycle is holding: the open episode, the record the next worker would resume from with the label that says how far to trust it, the newest progress note, and what is blocking acceptance. Read from the local half of the layer, which this process serves to the worker in front of it and never publishes.",
1034        stability: Stability::BehaviorallyVerified,
1035        capabilities: [
1036            capability! {
1037                id: "continuity.state",
1038                title: "What the lifecycle is holding",
1039                description: "The open episode, the active task, the handover and checkpoint that resolve for this worktree and branch, each with its divergence label, the unresolved questions that refuse completion, and the record tallies. Selection is two-tiered and never repository-wide: a record from an unrelated worktree or branch is not offered, because a briefing that is quietly about somebody else is worse than none. Absence is reported as absence.",
1040                input: Empty,
1041                output: Continuity,
1042                stability: Stability::BehaviorallyVerified,
1043                exposure: Exposure {
1044                    mcp: Some(McpExposure {
1045                        tool: Some("majordomus_continuity".into()),
1046                        resource: Some(McpResource { uri: CONTINUITY_URI.into(), name: "continuity".into() }),
1047                    }),
1048                    http: get("/api/v1/continuity"),
1049                    cli: None,
1050                },
1051                tags: ["continuity", "session", "handover"],
1052                // Short-lived: the records under it are written by another process, and a
1053                // reader that cached them for a minute would answer with an episode that
1054                // had already closed.
1055                cache: CachePolicy::Process { max_entries: 2, ttl_seconds: Some(2) },
1056                handler: state,
1057            },
1058            capability! {
1059                id: "continuity.status",
1060                title: "Where this device stands in the work published from every device",
1061                description: "This device and repository identity, the record this checkout continues on its branch, how the local continuity store stands towards the remote's (from the refs alone, no network), every line of work with its heads, the handovers other devices published that this checkout could resume, and every refused record or broken lineage as a diagnostic. A diverged line — the same work continued twice — is reported; separate lines of work on separate devices are not a conflict.",
1062                input: Empty,
1063                output: crate::continuity::Status,
1064                stability: Stability::Experimental,
1065                exposure: Exposure {
1066                    mcp: mcp("majordomus_continuity_status"),
1067                    http: get("/api/v1/continuity/status"),
1068                    cli: cli(&["continuity", "status"]),
1069                },
1070                tags: ["continuity", "handover", "devices"],
1071                cache: CachePolicy::Disabled,
1072                handler: transfer::status,
1073            },
1074            capability! {
1075                id: "continuity.device",
1076                kind: CapabilityKind::Command,
1077                title: "This device's identity, and its label",
1078                description: "The device a published handover names: the mesh node key (created when this device has none, in the user's state directory and never in a repository), its node id, its public key — what a trust list admits — and the label a person reads. With a label, renames the device; the key and every signature it made are unchanged.",
1079                input: transfer::DeviceInput,
1080                output: crate::continuity::DeviceView,
1081                stability: Stability::Experimental,
1082                exposure: Exposure {
1083                    mcp: mcp("majordomus_continuity_device"),
1084                    http: post("/api/v1/continuity/device"),
1085                    cli: cli(&["continuity", "device"]),
1086                },
1087                tags: ["continuity", "devices"],
1088                cache: CachePolicy::Disabled,
1089                benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::Destructive },
1090                handler: transfer::device,
1091            },
1092            capability! {
1093                id: "continuity.records",
1094                title: "Every published handover the local store admits",
1095                description: "Every record of refs/majordomus/continuity that passed admission — schema, id, signature, repository identity, portability — by line, with each line's heads, and a diagnostic for every file refused and every lineage defect.",
1096                input: Empty,
1097                output: crate::continuity::Records,
1098                stability: Stability::Experimental,
1099                exposure: Exposure {
1100                    mcp: mcp("majordomus_continuity_records"),
1101                    http: get("/api/v1/continuity/records"),
1102                    cli: cli(&["continuity", "records"]),
1103                },
1104                tags: ["continuity", "handover", "devices"],
1105                cache: CachePolicy::Disabled,
1106                handler: transfer::records,
1107            },
1108            capability! {
1109                id: "continuity.plan",
1110                title: "Whether and how a published handover can be resumed here",
1111                description: "Writes nothing. Chooses the record (named, or the one other device's handover this checkout has not resumed, on this branch first) and decides: is its signer trusted, does it diverge from the record this checkout stands on, does the local source hold the commit and branch it was written against, was it written in a dirty tree whose uncommitted files never reached a commit, may the other device still be working. The verdict is ready, ready_with_warnings, requires_source_update, conflict, choose_record, nothing_to_resume or refused, with blockers, warnings, the commands that resolve them (recommended, never run), what a resume restores and what this checkout recomputes for itself.",
1112                input: transfer::RecordInput,
1113                output: crate::continuity::ResumePlan,
1114                stability: Stability::Experimental,
1115                exposure: Exposure {
1116                    mcp: mcp("majordomus_continuity_plan"),
1117                    http: get("/api/v1/continuity/plan"),
1118                    cli: cli(&["continuity", "plan"]),
1119                },
1120                tags: ["continuity", "handover", "devices"],
1121                cache: CachePolicy::Disabled,
1122                handler: transfer::plan,
1123            },
1124            capability! {
1125                id: "continuity.publish",
1126                kind: CapabilityKind::Command,
1127                title: "Publish this checkout's newest handover for another machine",
1128                description: "Projects the newest handover record into a portable record — the handover body, the repository and device identity, the episode, the source state (branch, commit, and for a dirty tree the changed paths and a fingerprint, never their content), the task and its decisions, and the record it continues — refuses it when any value carries a credential, a secret environment value or a path of this machine's disk, signs it with the device's mesh key and adds it to refs/majordomus/continuity. Touches no branch, index or working tree, and no network: a sync publishes it.",
1129                input: transfer::PublishInput,
1130                output: crate::continuity::Published,
1131                stability: Stability::Experimental,
1132                exposure: Exposure {
1133                    mcp: mcp("majordomus_continuity_publish"),
1134                    http: post("/api/v1/continuity/publish"),
1135                    cli: cli(&["continuity", "publish"]),
1136                },
1137                tags: ["continuity", "handover", "devices"],
1138                cache: CachePolicy::Disabled,
1139                benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::Destructive },
1140                handler: transfer::publish,
1141            }
1142            .writes_repository(),
1143            capability! {
1144                id: "continuity.sync",
1145                kind: CapabilityKind::Command,
1146                title: "Exchange published handovers with a git remote",
1147                description: "Fetches the remote's refs/majordomus/continuity, merges it into the local store as the union of both (records are content-addressed, so a name holding different bytes is reported and the local copy kept), and pushes the result, never forced. Reports each line as equal, remote_newer, local_newer, diverged, local_only or remote_only. An unreachable remote changes nothing and leaves what is pending pending.",
1148                input: transfer::SyncInput,
1149                output: crate::continuity::Synced,
1150                stability: Stability::Experimental,
1151                exposure: Exposure {
1152                    mcp: mcp("majordomus_continuity_sync"),
1153                    http: post("/api/v1/continuity/sync"),
1154                    cli: cli(&["continuity", "sync"]),
1155                },
1156                tags: ["continuity", "handover", "devices"],
1157                cache: CachePolicy::Disabled,
1158                benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::ExternalDependency },
1159                handler: transfer::sync,
1160            }
1161            .writes_repository(),
1162            capability! {
1163                id: "continuity.resume",
1164                kind: CapabilityKind::Command,
1165                title: "Resume a handover another machine published",
1166                description: "Plans exactly as continuity.plan does and acts only on a ready or ready_with_warnings plan: writes the handover into this checkout's handovers (where handover --resolve and the session briefing find it), appends the task's decisions to the decision log once each, and records the record as the one this checkout continues, so that its next publication extends the same line. Runs nothing the record says; the start command for the task is returned as a recommendation.",
1167                input: transfer::RecordInput,
1168                output: crate::continuity::Resumed,
1169                stability: Stability::Experimental,
1170                exposure: Exposure {
1171                    mcp: mcp("majordomus_continuity_resume"),
1172                    http: post("/api/v1/continuity/resume"),
1173                    cli: cli(&["continuity", "resume"]),
1174                },
1175                tags: ["continuity", "handover", "devices"],
1176                cache: CachePolicy::Disabled,
1177                benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::Destructive },
1178                handler: transfer::resume,
1179            }
1180            .writes_repository(),
1181        ],
1182    }
1183}
1184
1185#[cfg(test)]
1186mod tests {
1187    use super::*;
1188    use std::process::Command;
1189
1190    /// A repository with two commits, so that the three answers `is_ancestor` can give are
1191    /// all reachable. `git` itself, because the thing under test is what this module asks
1192    /// git and what it does with the reply; a fake would be testing the fake.
1193    struct Repo(tempfile::TempDir);
1194    impl Repo {
1195        fn new() -> Self {
1196            let dir = tempfile::tempdir().expect("a temporary directory");
1197            let r = Repo(dir);
1198            r.git(&["init", "-q", "."]);
1199            r.git(&["config", "user.email", "t@example.com"]);
1200            r.git(&["config", "user.name", "t"]);
1201            r.git(&["commit", "-q", "--allow-empty", "-m", "one"]);
1202            r
1203        }
1204        fn root(&self) -> &Path {
1205            self.0.path()
1206        }
1207        fn git(&self, args: &[&str]) -> String {
1208            let out = Command::new("git")
1209                .arg("-C")
1210                .arg(self.root())
1211                .args(args)
1212                .output()
1213                .expect("git");
1214            String::from_utf8_lossy(&out.stdout).trim().to_string()
1215        }
1216        fn head(&self) -> String {
1217            self.git(&["rev-parse", "HEAD"])
1218        }
1219        fn write(&self, rel: &str, body: &str) -> PathBuf {
1220            let p = self.root().join(rel);
1221            std::fs::create_dir_all(p.parent().expect("a parent")).expect("mkdir");
1222            std::fs::write(&p, body).expect("write");
1223            p
1224        }
1225    }
1226
1227    /// The front matter of a local record, as the lifecycle writes it.
1228    fn record(created: &str, head: &str, branch: &str, worktree: &str, next: &str) -> String {
1229        format!(
1230            "---\nschema_version: 1\ncreated_at: {created}\ntask_id: t-1\nprofile: implementation\n\
1231             repository_id: x\nworktree: {worktree}\nbranch: {branch}\nhead: {head}\n\
1232             working_tree: clean\nchanged_files:\n---\n\n# Next Action\n\n{next}\n"
1233        )
1234    }
1235
1236    // ------------------------------------------------------------------ vocabulary
1237
1238    /// Every word, in both directions. The serialised form is the shell tool's, and a
1239    /// rename here would be a second vocabulary for the same four facts.
1240    #[test]
1241    fn every_divergence_word_is_the_shell_tools_and_says_whether_it_may_be_trusted() {
1242        let all = [
1243            (Divergence::Exact, "exact", true),
1244            (Divergence::Advanced, "advanced", true),
1245            (Divergence::Diverged, "diverged", false),
1246            (Divergence::DifferentContext, "different_context", false),
1247            (Divergence::Unknown, "unknown", false),
1248        ];
1249        for (d, word, trust) in all {
1250            assert_eq!(d.as_str(), word);
1251            assert_eq!(d.trustworthy(), trust, "{word}");
1252            assert_eq!(
1253                serde_json::to_value(d).expect("json"),
1254                serde_json::json!(word)
1255            );
1256        }
1257        // and the two tiers, which have no third
1258        assert_eq!(
1259            serde_json::to_value(Match::SameWorktreeSameBranch).expect("json"),
1260            serde_json::json!("same_worktree_same_branch")
1261        );
1262        assert_eq!(
1263            serde_json::to_value(Match::SameBranch).expect("json"),
1264            serde_json::json!("same_branch")
1265        );
1266    }
1267
1268    // ------------------------------------------------------------------ divergence
1269
1270    /// The four reachable answers and the two ways of reaching `unknown`. `unknown` is the
1271    /// one that matters: a reader told `exact` because git could not answer would act on a
1272    /// record nothing had checked.
1273    #[test]
1274    fn divergence_answers_unknown_rather_than_guessing_when_git_cannot_say() {
1275        let repo = Repo::new();
1276        let first = repo.head();
1277        repo.git(&["commit", "-q", "--allow-empty", "-m", "two"]);
1278        let second = repo.head();
1279
1280        assert_eq!(
1281            divergence(repo.root(), &first, Some(&first)),
1282            Divergence::Exact
1283        );
1284        assert_eq!(
1285            divergence(repo.root(), &first, Some(&second)),
1286            Divergence::Advanced
1287        );
1288        // a commit this history has never seen is not a yes, and not an unknown either
1289        assert_eq!(
1290            divergence(
1291                repo.root(),
1292                "0000000000000000000000000000000000000000",
1293                Some(&second)
1294            ),
1295            Divergence::Diverged
1296        );
1297        // this checkout has no head at all
1298        assert_eq!(divergence(repo.root(), &first, None), Divergence::Unknown);
1299        // the record names no commit
1300        assert_eq!(
1301            divergence(repo.root(), "", Some(&second)),
1302            Divergence::Unknown
1303        );
1304    }
1305
1306    // ------------------------------------------------------------------ reading
1307
1308    /// A record that cannot be read yields `None` and is counted, never a failed call. The
1309    /// three ways a file in that directory can fail to be one are all here, because the
1310    /// resolver's `skipped` count is what becomes the finding a reader is shown.
1311    #[test]
1312    fn a_file_that_is_not_a_record_is_none_rather_than_an_error() {
1313        let repo = Repo::new();
1314        assert!(front(&repo.root().join("nothing.md")).is_none());
1315        assert!(front(&repo.write("plain.md", "just prose, no front matter\n")).is_none());
1316        assert!(front(&repo.write("broken.md", "---\n: : :\n---\nbody\n")).is_none());
1317        let good = repo.write(
1318            "good.md",
1319            &record("2026-01-01T00:00:00Z", "abc", "main", "/w", "Go"),
1320        );
1321        let f = front(&good).expect("a record");
1322        assert_eq!(f.get("branch").map(String::as_str), Some("main"));
1323        // a list is not a scalar and is dropped rather than stringified
1324        assert!(!f.contains_key("changed_files"));
1325    }
1326
1327    /// The two records of the local half that are not Markdown carry no `---` fences, so
1328    /// they are read whole. Reading one with the front-matter splitter finds nothing and
1329    /// reports an absent episode in a checkout that has one.
1330    #[test]
1331    fn a_fenceless_document_is_read_whole_and_a_missing_one_is_none() {
1332        let repo = Repo::new();
1333        assert!(document(&repo.root().join("nothing.yaml")).is_none());
1334        let p = repo.write(
1335            "session.yaml",
1336            "session_id: s-1\nstarted_at: t\nowner: \"me\"\n",
1337        );
1338        let d = document(&p).expect("a document");
1339        assert_eq!(d.get("session_id").map(String::as_str), Some("s-1"));
1340        assert_eq!(d.get("owner").map(String::as_str), Some("me"));
1341        // and one that does not parse is absence, not a failure
1342        assert!(document(&repo.write("bad.yaml", "\t: [unclosed\n")).is_none());
1343    }
1344
1345    /// The section a resuming worker acts on: found, absent, surrounded by blank lines, and
1346    /// in a file that is not there. Trimming both ends matters because the value is quoted
1347    /// into a briefing, where a leading blank line is a paragraph break that changes what it
1348    /// looks like it is saying.
1349    #[test]
1350    fn one_section_is_lifted_whole_and_trimmed_at_both_ends() {
1351        let repo = Repo::new();
1352        assert_eq!(section(&repo.root().join("nothing.md"), "Next Action"), "");
1353        let p = repo.write(
1354            "r.md",
1355            "# Objective\n\nSomething.\n\n# Next Action\n\n\nDo the thing.\nThen the other.\n\n\n# After\n\nNo.\n",
1356        );
1357        assert_eq!(section(&p, "Next Action"), "Do the thing.\nThen the other.");
1358        assert_eq!(section(&p, "Objective"), "Something.");
1359        assert_eq!(section(&p, "Nothing Like This"), "");
1360        // a heading with trailing whitespace is the same heading
1361        let q = repo.write("s.md", "# Next Action   \n\nHere.\n");
1362        assert_eq!(section(&q, "Next Action"), "Here.");
1363        // a section that is only blank lines trims to nothing rather than to whitespace
1364        let e = repo.write("e.md", "# Next Action\n\n\n\n");
1365        assert_eq!(section(&e, "Next Action"), "");
1366    }
1367
1368    /// The store's own documentation declares its line format inside an HTML comment. A
1369    /// reader that does not skip the comment reports a blocker that does not exist in every
1370    /// fresh checkout — and a phantom blocker is worse than a missed one, because it refuses
1371    /// work nobody can unblock.
1372    #[test]
1373    fn only_unresolved_entries_outside_the_comment_are_blockers() {
1374        let repo = Repo::new();
1375        assert!(blockers(&repo.root().join("nothing.md")).is_empty());
1376        let p = repo.write(
1377            "q.md",
1378            "# Open questions\n\n<!--\n- [unresolved] the template's own example\n-->\n\n\
1379             - [unresolved] can we ship it\n- [resolved] we could not\n- [unresolved]   spaced   \n\
1380             not a list line at all\n",
1381        );
1382        assert_eq!(
1383            blockers(&p),
1384            vec!["can we ship it".to_string(), "spaced".to_string()]
1385        );
1386        // a comment opened and closed on one line does not swallow the rest of the file
1387        let q = repo.write("q2.md", "<!-- a note -->\n- [unresolved] still counted\n");
1388        assert_eq!(blockers(&q), vec!["still counted".to_string()]);
1389    }
1390
1391    /// How many records a directory holds, and none when there is no directory. Not "how
1392    /// many files": the store legitimately holds its own README.md and a staging file.
1393    #[test]
1394    fn only_markdown_is_counted_and_a_missing_directory_is_zero() {
1395        let repo = Repo::new();
1396        assert_eq!(count(&repo.root().join("nowhere")), 0);
1397        repo.write("d/a.md", "x");
1398        repo.write("d/b.md", "x");
1399        repo.write("d/c.yaml", "x");
1400        repo.write("d/.tmp.abcdef", "x");
1401        assert_eq!(count(&repo.root().join("d")), 2);
1402    }
1403
1404    // ------------------------------------------------------------------ the resolver
1405
1406    /// The freshness arguments the resolver's own tests want: a policy that declares no
1407    /// thresholds, so every record is judged `unknown` — which is not history, so the
1408    /// Next Action is kept and these tests assert what they were written to assert. The
1409    /// judgement itself is tested against `Thresholds::judge` below, where it belongs.
1410    fn unjudged() -> (Thresholds, i64) {
1411        (
1412            Thresholds::default(),
1413            epoch_seconds("2099-01-01T00:00:00Z").expect("a parseable instant"),
1414        )
1415    }
1416
1417    /// Two tiers and no third, and the ordering inside a tier is by the time the record
1418    /// asserts. The record from another worktree on this branch is offered *only* when this
1419    /// worktree has none: a briefing that is quietly about somebody else's work is worse
1420    /// than no briefing, and the tier is what the reader is told so it can weigh it.
1421    #[test]
1422    fn the_nearer_tier_wins_and_the_newer_record_wins_inside_one() {
1423        let (t, now) = unjudged();
1424        let repo = Repo::new();
1425        let head = repo.head();
1426        let root = repo.root().to_string_lossy().to_string();
1427        let dir = repo.root().join("h");
1428
1429        // nothing at all, and not even a directory
1430        let (none, skipped) = resolve(repo.root(), &dir, "main", Some(&head), t, now);
1431        assert!(none.is_none() && skipped == 0);
1432
1433        repo.write(
1434            "h/elsewhere.md",
1435            &record("2026-01-03T00:00:00Z", &head, "main", "/other", "Theirs"),
1436        );
1437        let (r, _) = resolve(repo.root(), &dir, "main", Some(&head), t, now);
1438        let r = r.expect("the other worktree's record, for want of one here");
1439        assert_eq!(r.matched, Match::SameBranch);
1440        assert_eq!(r.next_action, "Theirs");
1441
1442        // ...and it stops being offered the moment this worktree has one, even an older one
1443        repo.write(
1444            "h/mine.md",
1445            &record("2026-01-01T00:00:00Z", &head, "main", &root, "Mine"),
1446        );
1447        let (r, _) = resolve(repo.root(), &dir, "main", Some(&head), t, now);
1448        let r = r.expect("this worktree's record");
1449        assert_eq!(r.matched, Match::SameWorktreeSameBranch);
1450        assert_eq!(r.next_action, "Mine");
1451        assert_eq!(r.divergence, Divergence::Exact);
1452        assert_eq!(r.task_id, "t-1");
1453        assert_eq!(r.working_tree, "clean");
1454        assert!(
1455            r.path.starts_with("h/"),
1456            "the path is repository-relative: {}",
1457            r.path
1458        );
1459
1460        // newer wins inside the tier
1461        repo.write(
1462            "h/newer.md",
1463            &record("2026-01-02T00:00:00Z", &head, "main", &root, "Newer"),
1464        );
1465        let (r, _) = resolve(repo.root(), &dir, "main", Some(&head), t, now);
1466        assert_eq!(r.expect("a record").next_action, "Newer");
1467
1468        // another branch is never offered, however new
1469        repo.write(
1470            "h/other-branch.md",
1471            &record("2026-09-09T00:00:00Z", &head, "topic", &root, "No"),
1472        );
1473        let (r, _) = resolve(repo.root(), &dir, "main", Some(&head), t, now);
1474        assert_eq!(r.expect("a record").next_action, "Newer");
1475    }
1476
1477    /// A detached HEAD has no branch to match on, so tier 1 is unreachable: "the same
1478    /// branch" is not a relation a detached checkout has with anything.
1479    #[test]
1480    fn a_detached_checkout_is_offered_no_other_worktrees_record() {
1481        let (t, now) = unjudged();
1482        let repo = Repo::new();
1483        let head = repo.head();
1484        repo.write(
1485            "h/theirs.md",
1486            &record("2026-01-01T00:00:00Z", &head, "DETACHED", "/other", "No"),
1487        );
1488        let (r, skipped) = resolve(
1489            repo.root(),
1490            &repo.root().join("h"),
1491            "DETACHED",
1492            Some(&head),
1493            t,
1494            now,
1495        );
1496        assert!(
1497            r.is_none(),
1498            "a detached checkout was handed another worktree's record"
1499        );
1500        assert_eq!(
1501            skipped, 0,
1502            "a record that does not match is not a record that is broken"
1503        );
1504    }
1505
1506    /// Each way a file in the store can fail to be a record, counted rather than ignored —
1507    /// the count is what becomes the finding `majordomus doctor` is recommended from. A
1508    /// version this executable does not read is in that list: it parsed, and reading its
1509    /// fields under a contract that no longer describes them is the silent accept that is
1510    /// the twin of a silent skip.
1511    #[test]
1512    fn every_unreadable_file_is_counted_and_a_future_version_is_one_of_them() {
1513        let (t, now) = unjudged();
1514        let repo = Repo::new();
1515        let head = repo.head();
1516        let root = repo.root().to_string_lossy().to_string();
1517        repo.write("h/prose.md", "no front matter here\n");
1518        repo.write(
1519            "h/no-created.md",
1520            "---\nschema_version: 1\nhead: abc\n---\n",
1521        );
1522        repo.write(
1523            "h/no-head.md",
1524            "---\nschema_version: 1\ncreated_at: x\n---\n",
1525        );
1526        repo.write(
1527            "h/future.md",
1528            &record("2026-01-01T00:00:00Z", &head, "main", &root, "No")
1529                .replace("schema_version: 1", "schema_version: 2"),
1530        );
1531        // not a record at all, and not counted as a broken one either
1532        repo.write("h/README.md.yaml", "x\n");
1533        let (r, skipped) = resolve(
1534            repo.root(),
1535            &repo.root().join("h"),
1536            "main",
1537            Some(&head),
1538            t,
1539            now,
1540        );
1541        assert!(r.is_none(), "an unreadable file was offered as a record");
1542        assert_eq!(skipped, 4);
1543    }
1544
1545    /// A record that names no task is a record of work done outside one, and the field says
1546    /// `none` rather than being absent. Work outside a task is the ordinary case since
1547    /// ADR 0052 — a checkpoint written when no task is open records `task: none` — so the
1548    /// fallback here is on the normal path and not an edge.
1549    #[test]
1550    fn a_record_with_no_task_says_none_rather_than_leaving_the_field_empty() {
1551        let (t, now) = unjudged();
1552        let repo = Repo::new();
1553        let head = repo.head();
1554        let root = repo.root().to_string_lossy().to_string();
1555        let body = record("2026-01-01T00:00:00Z", &head, "main", &root, "Go")
1556            .replace("task_id: t-1\n", "");
1557        repo.write("h/no-task.md", &body);
1558        let (r, skipped) = resolve(
1559            repo.root(),
1560            &repo.root().join("h"),
1561            "main",
1562            Some(&head),
1563            t,
1564            now,
1565        );
1566        let r = r.expect("a record without a task is still a record");
1567        assert_eq!(r.task_id, "none");
1568        assert_eq!(skipped, 0);
1569    }
1570
1571    /// Two records written in the same second resolve the same way twice. The directory
1572    /// walk is sorted for exactly this: `read_dir` order is the filesystem's, and a briefing
1573    /// that changes between two runs over an unchanged store is one nobody can reason about.
1574    #[test]
1575    fn two_records_in_one_second_resolve_the_same_way_every_time() {
1576        let (t, now) = unjudged();
1577        let repo = Repo::new();
1578        let head = repo.head();
1579        let root = repo.root().to_string_lossy().to_string();
1580        for name in ["b", "a", "c"] {
1581            repo.write(
1582                &format!("h/{name}.md"),
1583                &record("2026-01-01T00:00:00Z", &head, "main", &root, name),
1584            );
1585        }
1586        let first = resolve(
1587            repo.root(),
1588            &repo.root().join("h"),
1589            "main",
1590            Some(&head),
1591            t,
1592            now,
1593        )
1594        .0;
1595        for _ in 0..5 {
1596            let again = resolve(
1597                repo.root(),
1598                &repo.root().join("h"),
1599                "main",
1600                Some(&head),
1601                t,
1602                now,
1603            )
1604            .0;
1605            assert_eq!(first, again);
1606        }
1607    }
1608
1609    // ------------------------------------------------------------------ the active task
1610
1611    /// The task is read whole rather than through the scalar flattening, because `scope` and
1612    /// `requires` are lists and a task without its scope is a task whose claim nobody can
1613    /// check. A record with no id is no task: several things create the file before anything
1614    /// is in it.
1615    #[test]
1616    fn a_task_is_read_with_its_lists_and_a_file_without_an_id_is_no_task() {
1617        let repo = Repo::new();
1618        assert!(read_task(&repo.root().join("nothing.yaml")).is_none());
1619        assert!(read_task(&repo.write("empty.yaml", "")).is_none());
1620        assert!(read_task(&repo.write("broken.yaml", "\t: [unclosed\n")).is_none());
1621        assert!(read_task(&repo.write("idless.yaml", "task: something\nprofile: x\n")).is_none());
1622        let p = repo.write(
1623            "current.yaml",
1624            "id: t-1\ntask: do it\nprofile: implementation\noutcome: active\n\
1625             started_at: 2026-01-01T00:00:00Z\nhead: abc\nscope:\n  - lib\n  - test\n\
1626             requires:\n  - committed\n",
1627        );
1628        let t = read_task(&p).expect("a task");
1629        assert_eq!(t.id, "t-1");
1630        assert_eq!(t.outcome, "active");
1631        assert_eq!(t.scope, vec!["lib".to_string(), "test".to_string()]);
1632        assert_eq!(t.requires, vec!["committed".to_string()]);
1633        assert_eq!(t.head, "abc");
1634        // a task that declares neither is a task with empty lists, never a missing one
1635        let q = repo.write("bare.yaml", "id: t-2\n");
1636        let t = read_task(&q).expect("a task");
1637        assert!(t.scope.is_empty() && t.requires.is_empty() && t.profile.is_empty());
1638    }
1639
1640    /// The thresholds, both sides of each and exactly on it. "At or beyond" and "beyond"
1641    /// are different contracts, and prose cannot be trusted to say which one is implemented.
1642    #[test]
1643    fn freshness_is_decided_at_the_thresholds_the_policy_declares() {
1644        let t = Thresholds {
1645            fresh_minutes: Some(720),
1646            stale_minutes: Some(2880),
1647        };
1648        let now = epoch_seconds("2026-06-15T12:00:00Z").expect("a parseable instant");
1649        let at = |minutes: i64| {
1650            let secs = now - minutes * 60;
1651            // Only the verdict is under test; the timestamp is built by the same arithmetic
1652            // the judgement uses, so a bug in one cancels in the other. Hence the round
1653            // trip through the formatter this crate already proves elsewhere.
1654            crate::peers::rfc3339(
1655                std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs as u64),
1656            )
1657        };
1658        let verdict = |minutes: i64| t.judge(&at(minutes), now).0;
1659
1660        assert_eq!(verdict(1), Freshness::Fresh);
1661        assert_eq!(verdict(719), Freshness::Fresh);
1662        assert_eq!(verdict(720), Freshness::Aging);
1663        assert_eq!(verdict(721), Freshness::Aging);
1664        assert_eq!(verdict(2879), Freshness::Aging);
1665        assert_eq!(verdict(2880), Freshness::Stale);
1666        assert_eq!(verdict(2881), Freshness::Stale);
1667        // the 2026-09-05 outage, to the day
1668        assert_eq!(verdict(6 * 24 * 60), Freshness::Stale);
1669    }
1670
1671    /// A verdict a reader cannot act on is the failure this subsystem is being corrected
1672    /// for, so every one of them carries a reason.
1673    #[test]
1674    fn every_verdict_says_why() {
1675        let t = Thresholds {
1676            fresh_minutes: Some(720),
1677            stale_minutes: Some(2880),
1678        };
1679        let now = epoch_seconds("2026-06-15T12:00:00Z").expect("a parseable instant");
1680        for ts in [
1681            "2026-06-15T11:59:00Z",
1682            "2026-06-14T00:00:00Z",
1683            "2026-06-01T00:00:00Z",
1684            "2026-06-15T13:00:00Z",
1685            "not a timestamp",
1686            "",
1687        ] {
1688            let (_, _, why) = t.judge(ts, now);
1689            assert!(!why.is_empty(), "no reason given for {ts:?}");
1690        }
1691    }
1692
1693    /// The three answers that are not an age. They are distinct on purpose: a record with
1694    /// no timestamp is not thereby old, one dated in the future is evidence that something
1695    /// wrote it wrongly, and a policy with no thresholds is a repository that has not said
1696    /// what it means by stale — none of which is a default this file may invent.
1697    #[test]
1698    fn what_cannot_be_judged_is_reported_as_itself() {
1699        let t = Thresholds {
1700            fresh_minutes: Some(720),
1701            stale_minutes: Some(2880),
1702        };
1703        let now = epoch_seconds("2026-06-15T12:00:00Z").expect("a parseable instant");
1704
1705        assert_eq!(t.judge("", now).0, Freshness::Unknown);
1706        assert_eq!(t.judge("not a timestamp", now).0, Freshness::Invalid);
1707
1708        let (f, _, why) = t.judge("2026-06-15T13:00:00Z", now);
1709        assert_eq!(f, Freshness::Invalid);
1710        assert!(
1711            why.contains("future"),
1712            "a future timestamp did not say so: {why}"
1713        );
1714
1715        let none = Thresholds::default();
1716        let (f, age, why) = none.judge("2026-06-01T00:00:00Z", now);
1717        assert_eq!(f, Freshness::Unknown);
1718        // 2026-06-01T00:00:00Z to 2026-06-15T12:00:00Z: 14 days and 12 hours.
1719        assert_eq!(
1720            age,
1721            Some(14 * 1440 + 720),
1722            "the age is known even when the verdict is not"
1723        );
1724        assert!(
1725            why.contains("session.freshness"),
1726            "the missing key is not named: {why}"
1727        );
1728    }
1729
1730    /// `stale` and `invalid` are the two a reader must refuse to present as current.
1731    /// `unknown` is not one of them.
1732    #[test]
1733    fn only_stale_and_invalid_are_history() {
1734        assert!(Freshness::Stale.history());
1735        assert!(Freshness::Invalid.history());
1736        assert!(!Freshness::Fresh.history());
1737        assert!(!Freshness::Aging.history());
1738        assert!(!Freshness::Unknown.history());
1739    }
1740
1741    /// The parser is the inverse of the formatter beside it, over instants this layer
1742    /// actually writes, and rejects what is not one rather than guessing.
1743    #[test]
1744    fn the_parser_inverts_the_formatter() {
1745        for secs in [0i64, 1_788_000_000, 1_757_000_000, 253_370_764_800] {
1746            let text = crate::peers::rfc3339(
1747                std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs as u64),
1748            );
1749            assert_eq!(
1750                epoch_seconds(&text),
1751                Some(secs),
1752                "round trip failed for {text}"
1753            );
1754        }
1755        for bad in [
1756            "",
1757            "2026-09-05",
1758            "2026-09-05T12:34:56",
1759            "2026-09-05T12:34:56+01:00",
1760            "2026-13-05T12:34:56Z",
1761            "2026-09-32T12:34:56Z",
1762            "2026-09-05T24:34:56Z",
1763            "xxxx-09-05T12:34:56Z",
1764        ] {
1765            assert_eq!(epoch_seconds(bad), None, "{bad:?} was accepted");
1766        }
1767    }
1768
1769    /// The declaration is the only place the id, the tool name, the resource URI and the
1770    /// route exist. A refactor that dropped one of them would still compile, and every
1771    /// suite that tests the report itself would still pass; this is the assertion that
1772    /// would not — which is what `project.rust-command-tested-in-file` asks of a command.
1773    #[test]
1774    fn the_declaration_yields_the_identity_and_the_projections_it_claims() {
1775        let m = module();
1776        assert_eq!(m.id.as_str(), "continuity");
1777        let ids: Vec<&str> = m
1778            .capabilities
1779            .iter()
1780            .map(|e| e.capability.id.as_str())
1781            .collect();
1782        assert_eq!(
1783            ids,
1784            [
1785                "continuity.state",
1786                "continuity.status",
1787                "continuity.device",
1788                "continuity.records",
1789                "continuity.plan",
1790                "continuity.publish",
1791                "continuity.sync",
1792                "continuity.resume",
1793            ]
1794        );
1795        // the cross-machine half: three reads, and the commands that write — over POST only,
1796        // with the effect that makes every surface ask before running them
1797        for e in &m.capabilities[1..] {
1798            let c = &e.capability;
1799            let cli = c.exposure.cli.as_ref().expect("a command-line projection");
1800            assert_eq!(cli.path[0], "continuity");
1801            assert_eq!(c.id.as_str(), format!("continuity.{}", cli.path[1]));
1802            let writes = c.execution.effect == crate::capability::model::Effect::RepositoryMutation;
1803            assert_eq!(
1804                writes,
1805                matches!(cli.path[1].as_str(), "publish" | "sync" | "resume"),
1806                "{} writes the repository exactly when it publishes, syncs or resumes",
1807                c.id
1808            );
1809            let method = c.exposure.http.as_ref().map(|h| h.method);
1810            assert_eq!(
1811                method == Some(crate::capability::model::HttpMethod::Post),
1812                !c.kind.is_read_only(),
1813                "{}: a command is a POST and a read is a GET",
1814                c.id
1815            );
1816        }
1817
1818        let c = &m.capabilities[0].capability;
1819        let mcp = c.exposure.mcp.as_ref().expect("an MCP projection");
1820        assert_eq!(mcp.tool.as_deref(), Some("majordomus_continuity"));
1821        assert_eq!(
1822            mcp.resource.as_ref().map(|r| r.uri.as_str()),
1823            Some(CONTINUITY_URI)
1824        );
1825        assert_eq!(
1826            c.exposure.http.as_ref().map(|h| h.path.as_str()),
1827            Some("/api/v1/continuity")
1828        );
1829        // read-only, and the registry refuses an executable capability that is not
1830        assert!(c.kind.is_read_only() && c.kind.is_executable());
1831        // and the module stamps its own namespace on what it composes
1832        assert!(ids.iter().all(|id| id.starts_with("continuity.")));
1833    }
1834}