Skip to main content

memstead_base/ingest/
brief.rs

1//! Run-brief rendering — the engine-side assembly of the Markdown brief an
2//! ingest agent consumes as its prompt.
3//!
4//! The brief is a **rendered string** by deliberate design: an agent reads
5//! it as a prompt, so a rendered Markdown contract (matching the plugin's
6//! `inject.mjs` stdout) is the natural boundary, and parity between clients
7//! is checked on the rendered bytes. Each block function returns a string
8//! ending in a blank line (or the empty string), and the full brief is the
9//! truthy blocks concatenated.
10//!
11//! All three modes are assembled here: [`assemble_discovery_brief`] (with the
12//! header blocks [`render_situation`], [`render_intent`],
13//! [`render_goal_and_avoid`], [`render_operative_data`]),
14//! and [`assemble_one_shot_brief`] — plus the
15//! changed-slice preface ([`render_changed_slice`], rendered from a
16//! [`SourceCursor`]).
17
18use super::guidance::ResolvedGuidance;
19use super::resolve::{ResolvedIngest, ResolvedSource};
20use super::slice::{NoSignalReason, Slice};
21use crate::binding::BuildMode;
22use crate::pipeline::{MediumType, PatternMode};
23
24/// Per-class cap on the rendered changed slice — mirrors the plugin's
25/// `SLICE_CAP`. Beyond it a `…and N more` line stands in.
26const SLICE_CAP: usize = 25;
27
28/// The schema every `ingest/<name>` process mem pins. (The historical
29/// plugin-side twin of this constant is retired — this is the single
30/// authority.)
31pub const PROCESS_MEM_SCHEMA: &str = "ingest@0.5.0";
32
33/// The paired-process-mem state the brief blocks read — the engine-side of
34/// the plugin's `processMem` object. Whether a process mem is present /
35/// skipped (one-shot) / failed-to-create is decided by the orchestration
36/// glue; the blocks render from this resolved view.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ProcessMemInfo {
39    /// A paired process mem exists and is usable.
40    pub present: bool,
41    /// No process mem is paired (one-shot ingests are ephemeral by design).
42    pub skipped: bool,
43    /// Auto-creation was attempted and failed; the notice explains why.
44    pub notice: Option<String>,
45    /// The process mem's leaf name (the ingest name) — its searchable id.
46    pub leaf_name: String,
47    /// The process mem's org-path label, `ingest/<name>`.
48    pub mem_label: String,
49}
50
51/// The mode string the situation block prints (`discovery` / `one-shot`) —
52/// the same tokens the plugin uses.
53fn mode_label(mode: BuildMode) -> &'static str {
54    match mode {
55        BuildMode::Discovery => "discovery",
56        BuildMode::OneShot => "one-shot",
57    }
58}
59
60/// The medium-type label a source line prints — the lowercase medium `type`.
61fn medium_type_label(t: MediumType) -> &'static str {
62    match t {
63        MediumType::Codebase => "codebase",
64        MediumType::Filesystem => "filesystem",
65        MediumType::Graph => "graph",
66        MediumType::Git => "git",
67        MediumType::Web => "web",
68    }
69}
70
71/// Render the `## Goal` and `## Failure modes to avoid` blocks from resolved
72/// guidance, matching the plugin's `goalAndAvoidBlock`. Each present field
73/// contributes a header, a blank line, its trimmed prose, and a trailing
74/// blank line; the block ends in a blank line. With neither field present
75/// this yields `"\n"` (the plugin's `lines.join('\n') + '\n'` for the
76/// no-pass-through case).
77///
78/// Pass-through-only guidance (a schema declaring `granularity`/`stack`/… but
79/// no goal/avoid) is not yet rendered here — that fallback
80/// (`renderResolvedGuidance`) lands with the pass-through modelling.
81pub fn render_goal_and_avoid(guidance: &ResolvedGuidance) -> String {
82    let mut lines: Vec<String> = Vec::new();
83
84    if let Some(goal) = guidance
85        .goal
86        .as_deref()
87        .map(str::trim)
88        .filter(|s| !s.is_empty())
89    {
90        lines.push("## Goal".to_string());
91        lines.push(String::new());
92        lines.push(goal.to_string());
93        lines.push(String::new());
94    }
95    if let Some(avoid) = guidance
96        .avoid
97        .as_deref()
98        .map(str::trim)
99        .filter(|s| !s.is_empty())
100    {
101        lines.push("## Failure modes to avoid".to_string());
102        lines.push(String::new());
103        lines.push(avoid.to_string());
104        lines.push(String::new());
105    }
106
107    format!("{}\n", lines.join("\n"))
108}
109
110/// Render the opening `## Situation` block — loop semantics, the mutation
111/// mandate, the context-budget signal, and the paired-process-mem line.
112/// Byte-for-byte the plugin's `situationBlock`.
113pub fn render_situation(resolved: &ResolvedIngest, process_mem: &ProcessMemInfo) -> String {
114    let mode = mode_label(resolved.mode);
115    let name = &resolved.name;
116    let mut lines: Vec<String> = Vec::new();
117    lines.push("## Situation".to_string());
118    lines.push(String::new());
119    lines.push(format!(
120        "You are running one iteration of `{name}` ({mode} mode) inside a loop. \
121         Each iteration is a fresh agent with no memory of prior runs; the destination \
122         graph persists between runs and is your continuity. Backoff is mechanical — \
123         when nothing has changed since the last run, the loop skips this ingest silently. \
124         Reporting \"no changes\" is therefore a valid outcome."
125    ));
126    lines.push(String::new());
127    lines.push(
128        "Mutating the destination is this run's mandate: within the destination mem(s) and \
129         paired process mem named under Operative data, create, update, relate, and delete \
130         entities without asking. Project-level instructions that make entity creation/deletion \
131         ask-first govern interactive dev sessions, not ingest iterations — parking creatable \
132         work as a coverage_gap because of that rule defeats the loop. Mems outside the declared \
133         destinations remain off-limits."
134            .to_string(),
135    );
136    lines.push(String::new());
137    lines.push(
138        "Context budget is finite. The `PreCompact` hook fires near the limit and asks you to \
139         stop and report. Multiple cycles inside one run are fine when context allows; depth on \
140         a coherent area beats breadth across unrelated ones."
141            .to_string(),
142    );
143    lines.push(String::new());
144    if process_mem.present {
145        lines.push(format!(
146            "A paired process mem `{}` (schema `{PROCESS_MEM_SCHEMA}`) carries destination-quality \
147             debt prior runs could not address. Its entries are objective claims about destination \
148             state — read them on orientation, write to it when this run also cannot fix some debt, \
149             delete entries the destination has since resolved. Call \
150             `memstead_schema(name={PROCESS_MEM_SCHEMA})` once for the type vocabulary and write rules.",
151            process_mem.mem_label
152        ));
153    } else if let Some(notice) = &process_mem.notice {
154        lines.push(format!(
155            "Note: paired process mem `{}` could not be auto-created — {notice}. The run continues \
156             without it; the operator can retry with `memstead mem init {name} --org-path ingest \
157             --schema {PROCESS_MEM_SCHEMA}`.",
158            process_mem.mem_label
159        ));
160    } else if process_mem.skipped {
161        lines.push(format!(
162            "No process mem is paired with this ingest (mode={mode}; one-shot ingests are \
163             by-design ephemeral)."
164        ));
165    }
166    lines.push(String::new());
167    format!("{}\n", lines.join("\n"))
168}
169
170/// Render the `## About the source` block from the projection's intent, or
171/// the empty string when there is no intent. Byte-for-byte the plugin's
172/// `intentBlock`.
173pub fn render_intent(resolved: &ResolvedIngest) -> String {
174    match resolved
175        .intent
176        .as_deref()
177        .map(str::trim)
178        .filter(|s| !s.is_empty())
179    {
180        Some(intent) => format!("## About the source\n\n{intent}\n\n"),
181        None => String::new(),
182    }
183}
184
185/// Render the `## Operative data` block — the sources (with their scope), the
186/// destination (with its schema), and the paired process mem. Byte-for-byte
187/// the plugin's `operativeDataBlock`. `destination_schema` is the schema ref
188/// the destination mem pins (from `memMeta`), rendered when present.
189///
190/// A source facet's `domains` (web mediums) is not rendered — the engine's
191/// facet scope models allow/deny paths only; the domains slot lands with web
192/// medium support.
193pub fn render_operative_data(
194    resolved: &ResolvedIngest,
195    process_mem: &ProcessMemInfo,
196    destination_schema: Option<&str>,
197    destination_note: Option<&str>,
198    absent_sources: &[String],
199) -> String {
200    let mut lines: Vec<String> = Vec::new();
201    lines.push("## Operative data".to_string());
202    lines.push(String::new());
203
204    // Sources
205    if !resolved.sources.is_empty() {
206        lines.push("### Sources".to_string());
207        lines.push(String::new());
208        let mut reference_mems: Vec<String> = Vec::new();
209        for source in &resolved.sources {
210            match source {
211                ResolvedSource::Primary(p) => {
212                    // Name first, medium type demoted to annotation — the
213                    // provenance section instructs `source` = the declared
214                    // NAME, so this section must teach the same token
215                    // (plan 03a: an agent copying this bullet verbatim
216                    // must not earn an INVALID_ANCHOR).
217                    lines.push(format!(
218                        "- **{}** ({}, primary) — `{}`",
219                        p.name,
220                        medium_type_label(p.medium_type),
221                        p.pointer
222                    ));
223                    // Same obligation as the destination note: an agent
224                    // told to read a tree that is not there has been sent
225                    // on work it cannot do, and cannot tell that from a
226                    // source that is merely empty.
227                    if absent_sources.iter().any(|n| n == &p.name) {
228                        lines.push(
229                            "  - **This source does not resolve to anything on disk.** \
230                             Nothing can be read from it until the path exists or the \
231                             binding's pointer is corrected."
232                                .to_string(),
233                        );
234                    }
235                    let allows: Vec<&str> = p
236                        .scope
237                        .iter()
238                        .filter(|r| r.mode == PatternMode::Allow)
239                        .map(|r| r.path.as_str())
240                        .collect();
241                    let denies: Vec<&str> = p
242                        .scope
243                        .iter()
244                        .filter(|r| r.mode == PatternMode::Deny)
245                        .map(|r| r.path.as_str())
246                        .collect();
247                    // Scope is medium-shaped, and so is the label. A graph
248                    // source selects entities, so calling its selectors
249                    // "Paths" sent the agent looking for a glob tool over a
250                    // mem — which does not exist. The changed slice alone is
251                    // a delta with no baseline; the reference-mem block below
252                    // is the precedent for directing an agent at a mem's
253                    // contents without dumping them, so a primary graph
254                    // source gets the same executable instruction.
255                    let is_graph = p.medium_type == MediumType::Graph;
256                    let (allow_label, deny_label) = if is_graph {
257                        ("Entities", "Excluding")
258                    } else {
259                        ("Paths", "Ignore")
260                    };
261                    if !allows.is_empty() {
262                        lines.push(format!("  - {allow_label}: {}", allows.join(", ")));
263                    }
264                    if !denies.is_empty() {
265                        lines.push(format!("  - {deny_label}: {}", denies.join(", ")));
266                    }
267                    // A scope pattern still in the retired workspace-relative
268                    // dialect selects nothing under the pointer join. The
269                    // brief is the one surface a binding running only build
270                    // and sync ever reads, so the warning must land HERE —
271                    // the verify report and the `--full` refusal reach only
272                    // bindings that verify.
273                    for note in super::cursor::scope_migration_notes(p) {
274                        let rewrite = match &note.suggested {
275                            Some(s) => format!(" — rewrite it as `{s}`"),
276                            None => String::new(),
277                        };
278                        lines.push(format!(
279                            "  - **Scope pattern `{}` is written against the workspace root \
280                             rather than the source pointer, so it selects nothing**{rewrite}.",
281                            note.pattern
282                        ));
283                    }
284                    if is_graph {
285                        lines.push(format!(
286                            "  - Read the source baseline with `memstead_search mem={}` \
287                             (add `entity_type=` to match a `type:` selector). The changed \
288                             slice below is a delta against the last pass — it is not the \
289                             whole source, and an entity absent from it may still be \
290                             unprojected.",
291                            p.pointer
292                        ));
293                    }
294                }
295                ResolvedSource::Reference { mem } => {
296                    lines.push(format!("- **graph** (reference) — mem: {mem}"));
297                    reference_mems.push(mem.clone());
298                }
299            }
300        }
301        lines.push(String::new());
302        if !reference_mems.is_empty() {
303            lines.push(
304                "Sources tagged `(reference)` are read-only context for cross-mem edges — search \
305                 them, never write into them. Only `(primary)` sources are ingested into the \
306                 destination."
307                    .to_string(),
308            );
309            lines.push(String::new());
310            let mem_list = reference_mems
311                .iter()
312                .map(|v| format!("`memstead_search mem={v}`"))
313                .collect::<Vec<_>>()
314                .join(", ");
315            lines.push(format!(
316                "**Cross-mem references:** consult {mem_list} before authoring cross-mem edges. \
317                 The target entity must exist — a wiki-link or relationship to a missing target \
318                 either auto-stubs (silent) or fails authorization (`CROSS_MEM_RELATION`)."
319            ));
320            lines.push(String::new());
321        }
322    }
323
324    // Destination — four-primitive projections carry exactly one, no role.
325    lines.push("### Destination".to_string());
326    lines.push(String::new());
327    let schema_bit = destination_schema
328        .map(|s| format!(" — schema: `{s}`"))
329        .unwrap_or_default();
330    lines.push(format!("- **{}**{schema_bit}", resolved.destination_mem));
331    // No pinned schema means the engine could not resolve the destination as
332    // a mem of this workspace — a binding scaffolded before its mem exists,
333    // which `projection init` deliberately allows. Say so here rather than
334    // describing a destination that is not there: the brief's mandate is to
335    // mutate this mem, and an agent that discovers its absence on the first
336    // create has been told something untrue by the surface that sent it.
337    // The caller supplies this: whether the destination resolves, and what
338    // to do about it, both depend on the workspace shape — which this
339    // renderer cannot see. A remedy naming a command that refuses in the
340    // reader's own workspace is the defect this note exists to prevent.
341    if let Some(note) = destination_note {
342        lines.push(format!("  - {note}"));
343    }
344    lines.push(String::new());
345
346    // Paired process mem
347    if process_mem.present {
348        lines.push("### Paired process mem".to_string());
349        lines.push(String::new());
350        lines.push(format!(
351            "- **{}** — schema: `{PROCESS_MEM_SCHEMA}`. Inspect via `memstead_overview` / \
352             `memstead_search mem={}`.",
353            process_mem.mem_label, process_mem.leaf_name
354        ));
355        lines.push(String::new());
356    }
357
358    format!("{}\n", lines.join("\n"))
359}
360
361/// One baseline token a facet's cursor advances to after a full pass — the
362/// `(sync_state key, medium-typed token)` pair the engine records via the
363/// `set_mem_sync_state` writer. Produced by the cursor; the brief no longer
364/// renders it as an operator command (the agent runs `projection advance`,
365/// which computes and records the token engine-side — D4/D7).
366#[derive(Debug, Clone, PartialEq, Eq)]
367pub struct SyncCommand {
368    /// The sync-state key, `"<binding-id>/<facet>#synced"` (D4).
369    pub key: String,
370    /// The opaque new-baseline token.
371    pub token: String,
372}
373
374/// A source whose change detection produced **no usable signal** this pass,
375/// with the classified [`NoSignalReason`]. Rendered as a distinct per-source
376/// note in the changed-slice preface, so the agent can tell a *blind* source
377/// (no baseline comparison happened) from a *genuinely-unchanged* one (checked,
378/// did not move — which stays silent).
379#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct NoSignalNote {
381    /// The source's label — the facet ref (primary) or mem id (reference), the
382    /// same token the `<ingest>/<label>` sync-state key is built from.
383    pub source: String,
384    /// Why detection produced no signal.
385    pub reason: NoSignalReason,
386    /// The source's medium, when it is a primary source. Carried so the
387    /// remedy the note prints is one this medium actually accepts — a
388    /// medium-agnostic remedy told a graph source's agent to write `**/*`,
389    /// which the engine then refuses as not an entity selector. `None` for a
390    /// reference mem, which has no facet scope to remedy.
391    pub medium_type: Option<MediumType>,
392}
393
394/// The combined source-cursor across a projection's source facets — the
395/// engine-side of the plugin's `cursor` object that `changedSliceBlock`
396/// consumes. Assembled by [`super::cursor::compute_source_cursor`] from the
397/// per-facet [`super::slice::SliceOutcome`]s.
398#[derive(Debug, Clone, PartialEq, Eq)]
399pub struct SourceCursor {
400    /// The combined changed slice across all source facets.
401    pub union: Slice,
402    /// New-baseline commands for facets that changed.
403    pub write_commands: Vec<SyncCommand>,
404    /// New-baseline commands for facets seen for the first time (reseed).
405    pub reseed: Vec<SyncCommand>,
406    /// Per-source no-signal notes — sources whose detection could not produce a
407    /// slice this pass (unscoped facet, `signal:none`, git failure, missing
408    /// graph snapshot). Rendered distinctly from changed/reseed; a
409    /// genuinely-unchanged source contributes nothing here, so an all-unchanged
410    /// brief still renders no preface (byte-identical to a plain roam).
411    pub no_signal: Vec<NoSignalNote>,
412    /// Whether any facet reported changes (drives the "source moved" copy).
413    pub any_changes: bool,
414    /// Whether any facet's slice was degraded (mtime memo miss → full scan).
415    pub degraded: bool,
416    /// Ingest `deny_paths` entries that matched **no file** anywhere the agent
417    /// can reach (the project tree). A zero-selecting deny is surfaced as a
418    /// rendered warning rather than silently no-op'ing — it catches typos and
419    /// un-migrated legacy bare names (which, as globs, match nothing). Never a
420    /// hard error: the ingest still runs, the entry just does nothing. The
421    /// scaffold's own default hygiene entries are exempt at collection
422    /// (`cursor::dead_deny_entries`) — the engine never calls its own output
423    /// a typo.
424    pub dead_denies: Vec<String>,
425    /// The destination mem whose `sync_state` the baseline tokens live on.
426    pub dest_mem: String,
427    /// The canonical binding id `<mem>/<stem>` (D3) — rendered into the
428    /// `memstead projection advance <binding-id> …` line the changed-slice
429    /// preface now emits instead of a raw `mem set-sync-state` command (D4/D7).
430    pub binding_id: String,
431    /// Touchpoint B: one ordered delivery sequence per primary source that
432    /// declares a delivery preparation. Their unit ids also ride `union`
433    /// (the advance gate accepts them); the class lists never repeat them,
434    /// because a class list is alphabetical and a sequence is not.
435    pub delivery: Vec<DeliverySequence>,
436}
437
438/// One unit of a [`DeliverySequence`], as presented.
439#[derive(Debug, Clone, PartialEq, Eq)]
440pub struct DeliveredUnit {
441    /// The unit's artifact id, `<path>#<key>` ([`crate::preparation::unit_id`]).
442    pub id: String,
443    /// The unit's intrinsic order key; the sequence sorts by it, then by id.
444    pub order_key: String,
445    /// How the unit changed (every unit of a first delivery is `Added`).
446    pub change: crate::preparation::UnitChange,
447    /// Already disposed in the binding's in-progress advance store, so it is
448    /// counted but not re-presented.
449    pub disposed: bool,
450}
451
452/// The ordered delivery sequence of one source under a delivery preparation
453/// (touchpoint B of [`crate::preparation`]): the same source state yields the
454/// same sequence on every pass, first run and change run alike.
455#[derive(Debug, Clone, PartialEq, Eq)]
456pub struct DeliverySequence {
457    /// The source's declared name.
458    pub source: String,
459    /// The declared delivery preparation identifier.
460    pub preparation: String,
461    /// No baseline existed: every unit of the source is delivered, in order.
462    pub first_run: bool,
463    /// For at least one changed file no baseline content was retrievable,
464    /// so every unit of that file is listed rather than only the changed ones.
465    pub degraded: bool,
466    /// How many not-yet-disposed units to present this pass (the build
467    /// operation's `batch_size`; `0` presents all).
468    pub batch: usize,
469    /// Every delivered unit, in the total order.
470    pub units: Vec<DeliveredUnit>,
471}
472
473/// Single-quote a value for the emitted shell command, escaping embedded
474/// single quotes. The digest token is JSON (contains `"` and `:`), so it
475/// must be quoted to survive the shell. Mirrors the plugin's `shellQuote`.
476fn shell_quote(s: &str) -> String {
477    format!("'{}'", s.replace('\'', "'\\''"))
478}
479
480/// Render one changed-slice class (Deleted / Modified / Added), capped at
481/// [`SLICE_CAP`] with a `…and N more` overflow line.
482/// Render one delivery sequence: the not-yet-disposed units in the total
483/// order, numbered by their position in that order (so a unit keeps its
484/// number across the passes of one delivery while earlier units are
485/// disposed), capped at the sequence's batch with the remainder counted,
486/// never reshuffled.
487fn render_delivery_sequence(lines: &mut Vec<String>, seq: &DeliverySequence) {
488    use crate::preparation::UnitChange;
489    lines.push(format!(
490        "### Delivery sequence: `{}` (`{}`)\n",
491        seq.source, seq.preparation
492    ));
493    let opening = if seq.first_run {
494        "First delivery of this source: every unit, in the source's own order."
495    } else {
496        "The units that changed since the last pass, at their positions in the source's own \
497         order."
498    };
499    lines.push(format!(
500        "{opening} Work them top to bottom: the order derives from the units' own keys, never \
501         from discovery or directory order, it is identical on every pass, and a unit assumes \
502         only the units numbered before it. Address a unit as `<path>#<key>` in anchors and \
503         dispositions.\n"
504    ));
505    if seq.degraded {
506        lines.push(
507            "_(No baseline content was retrievable for one or more changed files, so every unit \
508             of those files is listed; precision is coarser this pass only.)_\n"
509                .to_string(),
510        );
511    }
512    let pending: Vec<(usize, &DeliveredUnit)> = seq
513        .units
514        .iter()
515        .enumerate()
516        .filter(|(_, u)| !u.disposed)
517        .collect();
518    let disposed = seq.units.len() - pending.len();
519    let shown = if seq.batch == 0 {
520        pending.len()
521    } else {
522        pending.len().min(seq.batch)
523    };
524    for (position, unit) in &pending[..shown] {
525        let label = match unit.change {
526            UnitChange::Added => "new",
527            UnitChange::Modified => "changed",
528            UnitChange::Deleted => "deleted",
529        };
530        lines.push(format!("{}. `{}` ({label})", position + 1, unit.id));
531    }
532    if pending.len() > shown {
533        lines.push(format!(
534            "- …and {} more, presented in order once these are disposed",
535            pending.len() - shown
536        ));
537    }
538    if disposed > 0 {
539        lines.push(format!(
540            "_({disposed} unit{} of this sequence already disposed this pass.)_",
541            if disposed == 1 { "" } else { "s" }
542        ));
543    }
544    if pending.is_empty() {
545        lines.push(
546            "_(Every unit of this sequence is disposed; the baseline advances when the pass \
547             completes.)_"
548                .to_string(),
549        );
550    }
551    lines.push(String::new());
552}
553
554fn render_slice_class(lines: &mut Vec<String>, label: &str, paths: &[String]) {
555    if paths.is_empty() {
556        return;
557    }
558    let shown = paths.len().min(SLICE_CAP);
559    lines.push(format!("**{label}:**"));
560    for path in &paths[..shown] {
561        lines.push(format!("- `{path}`"));
562    }
563    if paths.len() > shown {
564        lines.push(format!(
565            "- …and {} more {}",
566            paths.len() - shown,
567            label.to_lowercase()
568        ));
569    }
570    lines.push(String::new());
571}
572
573/// The one-line explanation the brief prints for a [`NoSignalReason`] — each
574/// reason renders as distinct text, so the agent can tell the no-signal
575/// conditions apart (and all apart from a genuinely-unchanged source, which
576/// renders nothing at all).
577fn no_signal_reason_text(reason: NoSignalReason, medium: Option<MediumType>) -> &'static str {
578    match reason {
579        // The remedy is medium-shaped, because scope is. Naming a path glob at
580        // a graph source sent the agent to write the one thing the engine
581        // refuses — the brief instructing a write it would then reject.
582        NoSignalReason::Unscoped => match medium {
583            Some(MediumType::Graph) => {
584                "unscoped facet (no allow patterns) — nothing is monitored; write `*` in the \
585                 facet scope to watch the whole mem, or `type:<entity_type>` / `id:<glob>` \
586                 to narrow it (a graph source selects entities, not paths)"
587            }
588            _ => {
589                "unscoped facet (no allow patterns) — nothing is monitored; write `**/*` in the \
590                 facet scope to watch the whole medium"
591            }
592        },
593        NoSignalReason::DetectionNone => {
594            "`signal:none` — change detection is disabled for this source (declared `none`)"
595        }
596        NoSignalReason::GitUnavailable => {
597            "git signal unavailable — no work tree, an unreadable `HEAD`, or an unknown baseline; \
598             a full re-roam is warranted this pass"
599        }
600        NoSignalReason::GraphSnapshotMissing => {
601            "graph snapshot missing — the source mem has no comparable baseline this pass"
602        }
603    }
604}
605
606/// Render the `## Source changes since the last sync` preface — the changed
607/// slice to steer at first, any no-signal sources, plus the `projection advance`
608/// "record your dispositions LAST" section. Extends the plugin's `changedSliceBlock`
609/// with the no-signal notes. Returns the empty string when nothing changed,
610/// nothing needs reseeding, and every source is genuinely unchanged (no
611/// no-signal notes) — making the brief byte-identical to a plain roam.
612pub fn render_changed_slice(cursor: &SourceCursor) -> String {
613    if !cursor.any_changes
614        && cursor.reseed.is_empty()
615        && cursor.no_signal.is_empty()
616        && cursor.dead_denies.is_empty()
617    {
618        return String::new();
619    }
620    let mut lines: Vec<String> = Vec::new();
621    lines.push("## Source changes since the last sync\n".to_string());
622
623    if cursor.any_changes {
624        lines.push(
625            "The source moved since this graph was last synced. Steer this pass at these changed \
626             artifacts **first** — they are where the graph is most likely now wrong.\n"
627                .to_string(),
628        );
629        // Delivery sequences first: for a source under a delivery
630        // preparation the order IS the steering, and their unit ids never
631        // repeat in the alphabetical class lists below.
632        for seq in &cursor.delivery {
633            render_delivery_sequence(&mut lines, seq);
634        }
635        let unit_ids: std::collections::BTreeSet<&str> = cursor
636            .delivery
637            .iter()
638            .flat_map(|s| s.units.iter().map(|u| u.id.as_str()))
639            .collect();
640        let without_units = |v: &[String]| -> Vec<String> {
641            v.iter()
642                .filter(|p| !unit_ids.contains(p.as_str()))
643                .cloned()
644                .collect()
645        };
646        // Deletions first — cheapest, highest-signal drift.
647        render_slice_class(&mut lines, "Deleted", &without_units(&cursor.union.deleted));
648        render_slice_class(
649            &mut lines,
650            "Modified",
651            &without_units(&cursor.union.modified),
652        );
653        render_slice_class(&mut lines, "Added", &without_units(&cursor.union.added));
654        if cursor.degraded {
655            lines.push(
656                "_(Precise change history for one or more facets was unavailable, so its full \
657                 current file set is listed above. Detection still fired from the durable baseline; \
658                 targeting is coarser this pass only.)_\n"
659                    .to_string(),
660            );
661        }
662    }
663
664    if !cursor.reseed.is_empty() {
665        let keys = cursor
666            .reseed
667            .iter()
668            .map(|r| format!("`{}`", r.key))
669            .collect::<Vec<_>>()
670            .join(", ");
671        let it = if cursor.reseed.len() == 1 {
672            "it"
673        } else {
674            "them"
675        };
676        lines.push(format!(
677            "No usable sync baseline exists for {keys} — none was recorded, or the recorded one \
678             is not a commit of the source's repo (foreign or garbage-collected). Treating the \
679             current source state as the baseline. No priority slice from {it} this pass; \
680             proceed as usual.\n"
681        ));
682    }
683
684    if !cursor.no_signal.is_empty() {
685        lines.push(
686            "Some sources produced **no change signal** this pass — detection could not compare \
687             them against a baseline, so they were not steered (roam them as usual). This is \
688             distinct from a source that was checked and had not moved:\n"
689                .to_string(),
690        );
691        for note in &cursor.no_signal {
692            lines.push(format!(
693                "- `{}`: {}",
694                note.source,
695                no_signal_reason_text(note.reason, note.medium_type)
696            ));
697        }
698        lines.push(String::new());
699    }
700
701    if !cursor.dead_denies.is_empty() {
702        lines.push(
703            "**Warning — some `deny_paths` entries match nothing.** The following ingest \
704             `deny_paths` selected **no file** in the project tree, so they exclude nothing from \
705             the slice and hide nothing from the ingest agent. This is usually a typo or a legacy \
706             bare name that never migrated to the workspace-relative glob dialect (e.g. `dev` → \
707             `dev/**`, `VISION.md` → `**/VISION.md`). Fix or remove them:\n"
708                .to_string(),
709        );
710        for entry in &cursor.dead_denies {
711            lines.push(format!("- `{entry}`"));
712        }
713        lines.push(String::new());
714    }
715
716    // Disposition-record instruction — the agent's FINAL step. The advance is
717    // resumable and non-stalling (D7): a partial pass is honored on disk, and a
718    // source that moves mid-pass re-presents (remaining + new) without losing
719    // recorded work. The agent runs `projection advance`, which computes and
720    // records the new baseline token engine-side — the brief no longer renders a
721    // raw `mem set-sync-state` command (D4). The block appears whenever there is
722    // a baseline to advance (a changed facet or a first-sync reseed).
723    let has_baseline_to_advance = !cursor.write_commands.is_empty() || !cursor.reseed.is_empty();
724    if has_baseline_to_advance {
725        lines.push("### Recording your dispositions (do this LAST)\n".to_string());
726        lines.push(
727            "Only after you have worked the changed artifacts above — and only for the artifacts \
728             you actually judged — record a disposition for each, so the next pass targets just \
729             what changes next. This advance is resumable and non-stalling: a partial pass is \
730             honored, and if the source moves mid-pass the remaining slice re-presents \
731             (remaining + new) without losing your recorded work.\n"
732                .to_string(),
733        );
734        lines.push(
735            "Anchored work disposes itself: at advance time, every listed artifact that an \
736             anchor in the destination mem references is marked `worked` automatically (an \
737             explicit disposition you pass wins over the auto-mark). Supply dispositions only \
738             for the residue — artifacts you skipped, judged out of intent, or worked without \
739             anchors. The gate accepts only artifact ids listed above — an unknown id refuses \
740             the whole call. When every artifact is disposed, the sync baseline advances \
741             automatically. Run:\n"
742                .to_string(),
743        );
744        lines.push("```sh".to_string());
745        lines.push(format!(
746            "memstead projection advance {} --dispositions {}",
747            cursor.binding_id,
748            shell_quote(r#"{"<artifact>": "<disposition>", ...}"#)
749        ));
750        lines.push("```".to_string());
751        lines.push(
752            "If you were interrupted before finishing, that is fine — your recorded dispositions \
753             persist, and the next run re-presents only what is left.\n"
754                .to_string(),
755        );
756    }
757
758    format!("{}\n", lines.join("\n"))
759}
760
761/// Assemble the discovery-mode brief — situation, about-the-source, goal/avoid,
762/// operative-data, and the changed-slice preface — concatenating the truthy
763/// blocks, matching the plugin's `parts.filter(Boolean).join('')`.
764/// `changed_slice_preface` is the rendered changed-slice block (empty when
765/// the source has not moved, making the brief byte-identical to a plain roam).
766/// Render the `## Provenance — anchor your writes` block — the build-brief
767/// instruction to attach `anchors[]` to every entity mutation. Rendered by the
768/// engine, never by skill prose: a binary old enough to reject the parameter
769/// never renders the instruction, so the brief cannot version-skew against its
770/// own mutation surface (the reason the plugin-side capability gate exists for
771/// skill-carried prose). The element shape is taught by the mutation tools'
772/// own descriptions; the brief carries only the job.
773pub fn render_anchor_instruction(resolved: &ResolvedIngest) -> String {
774    let mut block = "## Provenance — anchor your writes\n\n\
775     Attach an `anchors` list to every `memstead_create` / `memstead_update`, naming the \
776     source artifact(s) the entity is drawn from (the mutation tools document the element \
777     shape). Anchored writes are what verify measures coverage and drift against, and — on \
778     cursor-driven passes — what the advance gate auto-marks `worked`; an unanchored write \
779     leaves the fidelity report and the disposition window blind to your work.\n\n"
780        .to_string();
781    // Name the producing entry point: each anchor's `source` carries the
782    // binding source NAME it came from, so a discovery run is measurable
783    // per entry point (which entry carries, which delivers nothing).
784    let primary_names: Vec<&str> = resolved
785        .sources
786        .iter()
787        .filter_map(|s| match s {
788            crate::ingest::resolve::ResolvedSource::Primary(src) => Some(src.name.as_str()),
789            crate::ingest::resolve::ResolvedSource::Reference { .. } => None,
790        })
791        .collect();
792    if !primary_names.is_empty() {
793        block.push_str(&format!(
794            "Set each anchor's `source` to the binding source name you drew the artifact \
795             from — this binding declares: {}. The name selects the pointer the \
796             artifact path is joined onto, so the wrong one usually refuses \
797             `INVALID_ANCHOR` (the path resolves under no candidate join). A name \
798             outside the list is NOT itself refused when the path happens to \
799             resolve workspace-relative — that tolerance exists for anchors whose \
800             binding was later renamed — so getting it right is on you, not on a \
801             gate.\n\n",
802            primary_names
803                .iter()
804                .map(|n| format!("`{n}`"))
805                .collect::<Vec<_>>()
806                .join(", ")
807        ));
808    }
809    // The url grain: the engine never fetches, so the observation is the
810    // author's, and so is the stability call — a page that may change reads
811    // `unstable` (a hash break is a recheck, not drift); an immutable
812    // document must say so to be adjudicated.
813    block.push_str(
814        "For a web document use `grain: url` with the URL as `artifact` and pass the retrieved \
815         text as `content` so the engine records its hash (the engine never fetches). Set \
816         `hash_stability: stable` on an IMMUTABLE document — a dated PDF, an archived page, a \
817         versioned standard — so a later changed hash reads as `drifted`; leave the default \
818         `unstable` for a living page, where a change is only a `recheck`. Url rows are \
819         re-adjudicated when someone supplies a fresh observation (`memstead verify-anchors \
820         --observations`), and every surface shows how long each has gone unobserved.\n\n",
821    );
822    // A source under a preparation hashes a PREPARED form, which no agent
823    // computes by hand: say so, and say what to do instead.
824    for source in &resolved.sources {
825        let crate::ingest::resolve::ResolvedSource::Primary(src) = source else {
826            continue;
827        };
828        let Some(prep) = src
829            .preparation
830            .as_deref()
831            .and_then(crate::preparation::lookup)
832        else {
833            continue;
834        };
835        let what = match prep.id {
836            crate::preparation::CODE_MAP => {
837                "the file's interface digest (imports, exports, signatures; comments, \
838                 formatting and bodies invisible), and a `tree` anchor the code map of every \
839                 scoped file under it"
840            }
841            crate::preparation::DATED_ENTRIES => {
842                "the unit's own text for a `<path>#<key>` span, the file's bytes otherwise"
843            }
844            crate::preparation::ENTITY_LOAD_BEARING => "the entity's load-bearing sections",
845            _ => prep.description,
846        };
847        block.push_str(&format!(
848            "Anchors on `{}` hash a prepared form (`{}`): {what}. Never compute `hash` \
849             yourself for this source — leave it empty (verify records it on first \
850             observation), or for a `file` or `span` anchor pass the artifact's `content` \
851             and the engine hashes the prepared form (a `tree` anchor takes no content).\n\n",
852            src.name, prep.id
853        ));
854    }
855    block
856}
857
858#[allow(clippy::too_many_arguments)]
859pub fn assemble_discovery_brief(
860    resolved: &ResolvedIngest,
861    guidance: &ResolvedGuidance,
862    process_mem: &ProcessMemInfo,
863    destination_schema: Option<&str>,
864    destination_note: Option<&str>,
865    absent_sources: &[String],
866    changed_slice_preface: &str,
867) -> String {
868    let parts = [
869        render_situation(resolved, process_mem),
870        render_intent(resolved),
871        render_goal_and_avoid(guidance),
872        render_operative_data(
873            resolved,
874            process_mem,
875            destination_schema,
876            destination_note,
877            absent_sources,
878        ),
879        render_anchor_instruction(resolved),
880        changed_slice_preface.to_string(),
881    ];
882    parts
883        .into_iter()
884        .filter(|p| !p.is_empty())
885        .collect::<Vec<_>>()
886        .join("")
887}
888
889/// Render the `## Mode: one-shot — lens routing` block — the destination-set
890/// table, optional routing rule, idempotency contract, end-of-run report
891/// template, and optional archive note. Byte-for-byte the plugin's
892/// `oneShotLensBlock`. `destination_schema` / `destination_purpose` describe
893/// the ingest's single destination (four-primitive projections have one).
894pub fn render_one_shot_lens(
895    resolved: &ResolvedIngest,
896    destination_schema: Option<&str>,
897    destination_purpose: Option<&str>,
898) -> String {
899    let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
900    let mut lines: Vec<String> = vec![
901        "## Mode: one-shot — lens routing".to_string(),
902        String::new(),
903        "A lens iterates entities once and writes per-destination, then exits. The agent decides \
904         per-entity which destinations to target (Routing rule). Re-runs use `memstead_update`; \
905         never duplicate."
906            .to_string(),
907        String::new(),
908    ];
909
910    lines.push("### Destination set".to_string());
911    lines.push(String::new());
912    lines.push("| Mem | Schema | Purpose |".to_string());
913    lines.push("|-------|--------|---------|".to_string());
914    let schema = destination_schema.unwrap_or("(none)");
915    let purpose = destination_purpose
916        .filter(|s| !s.is_empty())
917        .unwrap_or("(no purpose declared)");
918    lines.push(format!(
919        "| {} | {} | {} |",
920        cell(&resolved.destination_mem),
921        cell(schema),
922        cell(purpose)
923    ));
924    lines.push(String::new());
925
926    if let Some(routing) = resolved
927        .rules
928        .as_ref()
929        .and_then(|r| r.get("routing"))
930        .and_then(|v| v.as_str())
931        .map(str::trim)
932        .filter(|s| !s.is_empty())
933    {
934        lines.push("### Routing rule".to_string());
935        lines.push(String::new());
936        lines.push("```".to_string());
937        lines.push(routing.to_string());
938        lines.push("```".to_string());
939        lines.push(String::new());
940    }
941
942    lines.push("### Idempotency".to_string());
943    lines.push(String::new());
944    lines.push("- Search the destination before writing; route changes through `memstead_update` against the existing entity if present.".to_string());
945    lines.push("- Skip writes when the lifted content matches what is already there (record as `skipped: already-up-to-date`).".to_string());
946    lines.push(
947        "- Use `memstead_create` only when no entity for that concept exists yet.".to_string(),
948    );
949    lines.push(String::new());
950
951    lines.push("### End-of-run report".to_string());
952    lines.push(String::new());
953    lines.push("After every destination is processed, emit one block per destination on stdout, in Destination-set order:".to_string());
954    lines.push(String::new());
955    lines.push("```".to_string());
956    lines.push(format!("### Report: {}", resolved.name));
957    lines.push(String::new());
958    lines.push("Destination: <mem>".to_string());
959    lines.push("  created: <count>".to_string());
960    lines.push("  updated: <count>".to_string());
961    lines.push("  skipped: <count>".to_string());
962    lines.push("  failed:  <count>".to_string());
963    lines.push("  failures:".to_string());
964    lines.push("    - <entity-key>: <error verbatim>".to_string());
965    lines.push("  skipped-detail:".to_string());
966    lines.push("    - <entity-key>: <one-line reason>".to_string());
967    lines.push("```".to_string());
968    lines.push(String::new());
969    lines.push("Per-destination commits are independent — partial success is the accepted failure mode. No rollback.".to_string());
970    lines.push(String::new());
971
972    let archive = resolved
973        .post_actions
974        .as_ref()
975        .and_then(|p| p.get("archive_source"))
976        .and_then(serde_json::Value::as_bool)
977        .unwrap_or(false);
978    if archive {
979        lines.push("### Archive after run".to_string());
980        lines.push(String::new());
981        lines.push("After the report has been emitted, archive the source planning mem — `post_actions.archive_source` is set on this ingest.".to_string());
982        lines.push(String::new());
983    }
984
985    format!("{}\n", lines.join("\n"))
986}
987
988/// Assemble the one-shot brief — situation, about-the-source, goal/avoid,
989/// operative-data, and the lens-routing block. Mirrors the plugin's one-shot
990/// `parts`. A one-shot ingest has no paired process mem, so `process_mem`
991/// should carry `skipped = true`.
992#[allow(clippy::too_many_arguments)]
993pub fn assemble_one_shot_brief(
994    resolved: &ResolvedIngest,
995    guidance: &ResolvedGuidance,
996    process_mem: &ProcessMemInfo,
997    destination_schema: Option<&str>,
998    destination_note: Option<&str>,
999    absent_sources: &[String],
1000    destination_purpose: Option<&str>,
1001) -> String {
1002    let parts = [
1003        render_situation(resolved, process_mem),
1004        render_intent(resolved),
1005        render_goal_and_avoid(guidance),
1006        render_operative_data(
1007            resolved,
1008            process_mem,
1009            destination_schema,
1010            destination_note,
1011            absent_sources,
1012        ),
1013        render_anchor_instruction(resolved),
1014        render_one_shot_lens(resolved, destination_schema, destination_purpose),
1015    ];
1016    parts
1017        .into_iter()
1018        .filter(|p| !p.is_empty())
1019        .collect::<Vec<_>>()
1020        .join("")
1021}
1022
1023// ---------------------------------------------------------------------------
1024// Verify + sync briefs (group C) — the measure/repair surface beside the build
1025// briefs. Verify MEASURES (no destination mutation of any kind, C1); sync is the
1026// SOLE maintenance writer, carrying BOTH the cursor slice and the open findings
1027// in one brief (C2) with the whole of `/reconcile`'s absorbed judgment (C3). A
1028// rule-by-rule absorption map records where each retired reconcile rule now
1029// lives (bundle plan `05-verify-sync-engine`, C4).
1030// ---------------------------------------------------------------------------
1031
1032use super::findings::{Finding, FindingClass, FindingTarget};
1033use super::prune::{PruneDisposition, PruneProposal};
1034
1035/// Per-class cap on the rendered open-findings list — mirrors [`SLICE_CAP`].
1036const FINDINGS_CAP: usize = SLICE_CAP;
1037
1038/// Render the **verify brief** (C1) — the measurement + capped-adjudication
1039/// prompt an agent consumes to *measure* a binding's fidelity.
1040///
1041/// **Refusal (C1), structural:** this function emits **no destination-mutation
1042/// instruction of any kind**. It tells the agent what to measure and adjudicate,
1043/// never what to write into the destination mem — every repair is recorded as a
1044/// finding for the sync brief ([`render_sync_brief`]) to act on. There is no
1045/// create / update / relate / delete instruction anywhere in the rendered text.
1046pub fn render_verify_brief(resolved: &ResolvedIngest, backlog: usize) -> String {
1047    let mut lines: Vec<String> = vec![
1048        "## Verify — measure fidelity, do not mutate".to_string(),
1049        String::new(),
1050    ];
1051    lines.push(format!(
1052        "You are measuring the fidelity of `{}` — how faithfully the destination mem \
1053         `{}` still matches its source. This pass **only measures**: read the source \
1054         and the mem's anchors, judge whether the graph still holds, and record what \
1055         you find. **You** write nothing into the destination mem. The run itself records \
1056         its findings store, which is the verify surface's own state outside the mem, and \
1057         backfills observed anchor hashes, which is measurement machinery. Its one write \
1058         into the mem's config, the `#verified` baseline, rides `--advance` and is off by \
1059         default, so a bare verify leaves that config byte-identical.",
1060        resolved.name, resolved.destination_mem
1061    ));
1062    lines.push(String::new());
1063
1064    lines.push(
1065        "Anchors may carry a `source` naming the binding entry point that produced them — \
1066         note it when recording findings, so fidelity stays measurable per source."
1067            .to_string(),
1068    );
1069    lines.push(String::new());
1070
1071    lines.push("### Adjudicate the queued findings (capped)".to_string());
1072    lines.push(String::new());
1073    if backlog == 0 {
1074        lines.push(
1075            "No findings are queued for adjudication this pass. Spot-check the resolving \
1076             anchors and the uncovered-artifact sample the fidelity report lists, and \
1077             record any drift you observe as a finding."
1078                .to_string(),
1079        );
1080    } else {
1081        lines.push(format!(
1082            "{backlog} finding(s) are queued for adjudication. Working up to the per-run \
1083             adjudication cap (an operations knob — the remainder stays queued and \
1084             re-presents on a later pass), take each queued finding and compare the \
1085             anchored source content against what the entity records. Classify it: still \
1086             accurate, or drifted. **Record the verdict — this is a measurement, not a \
1087             repair.** A drift you record becomes a finding the sync pass repairs; you do \
1088             not fix it here."
1089        ));
1090    }
1091    lines.push(String::new());
1092
1093    lines.push("### Out of scope for verify — no mutation".to_string());
1094    lines.push(String::new());
1095    lines.push(
1096        "Verify writes **no entity content**. Do not update a \
1097         `specifies` / `constraints` section, do not create or delete an entity, do not \
1098         add or remove a relationship. When measurement shows the graph is wrong, that \
1099         is a **finding** — the sync brief (`memstead projection brief --sync`) is the \
1100         one place those repairs are made. Leave every fix to it. (The run itself records \
1101         its findings store, which lives outside the mem, and backfills observed anchor \
1102         hashes; it writes a `#verified` baseline only under `--advance` — engine \
1103         bookkeeping, not your edits.)"
1104            .to_string(),
1105    );
1106    lines.push(String::new());
1107
1108    format!("{}\n", lines.join("\n"))
1109}
1110
1111/// A compact `entity → artifact` (or bare artifact) label for a finding target.
1112fn finding_target_label(target: &FindingTarget) -> String {
1113    match target {
1114        FindingTarget::Anchor { entity, artifact } => format!("`{entity}` → `{artifact}`"),
1115        FindingTarget::Artifact { artifact } => format!("`{artifact}`"),
1116    }
1117}
1118
1119/// Render one class-grouped findings section, capped at [`FINDINGS_CAP`] with a
1120/// `…and N more` overflow line. Skips an empty group entirely.
1121fn render_findings_group(
1122    lines: &mut Vec<String>,
1123    heading: &str,
1124    guidance: &str,
1125    items: &[&Finding],
1126) {
1127    if items.is_empty() {
1128        return;
1129    }
1130    lines.push(format!("### {heading}"));
1131    lines.push(String::new());
1132    lines.push(guidance.to_string());
1133    lines.push(String::new());
1134    let shown = items.len().min(FINDINGS_CAP);
1135    for f in &items[..shown] {
1136        lines.push(format!(
1137            "- {} — {}",
1138            finding_target_label(&f.target),
1139            f.detail
1140        ));
1141    }
1142    if items.len() > shown {
1143        lines.push(format!("- …and {} more", items.len() - shown));
1144    }
1145    lines.push(String::new());
1146}
1147
1148/// Render the open-findings block for the sync brief (C2) — the findings
1149/// `findings_store.current(key)` returned, grouped by class, each carrying the
1150/// conservative repair guidance the reconcile rules (C3) mandate. Empty string
1151/// when there are no open findings. `binding_id` feeds the buildable
1152/// `projection exclude` line in the uncovered group's guidance.
1153fn render_open_findings(findings: &[Finding], binding_id: &str) -> String {
1154    if findings.is_empty() {
1155        return String::new();
1156    }
1157    let mut lines: Vec<String> = vec![
1158        "## Open findings to repair".to_string(),
1159        String::new(),
1160        "The verify pass recorded these against the current source state. Repair them \
1161         conservatively (see the rules below); a finding you judge already correct needs \
1162         no write."
1163            .to_string(),
1164        String::new(),
1165    ];
1166
1167    let group = |class: FindingClass| -> Vec<&Finding> {
1168        findings.iter().filter(|f| f.class == class).collect()
1169    };
1170
1171    // Drifted / wrong — the anchored content changed: update only what moved
1172    // (conservatism rule "never rewrite unchanged sections").
1173    render_findings_group(
1174        &mut lines,
1175        "Drifted — the anchored content changed",
1176        "The source the entity describes moved. Update the affected section to match — \
1177         only the part that changed. If the entity is still accurate, leave it. Either \
1178         way, reset the anchor on the entity in ONE update call: `anchors_unset` the \
1179         row, then write it fresh in the same call's `anchors` (same artifact, grain, \
1180         class and source, no hash) — the next verify backfills the freshly observed \
1181         hash and the drift clears. A hashless re-declare WITHOUT the unset keeps the \
1182         stored baseline by design and clears nothing, and updating the entity alone, \
1183         or advancing the baseline, leaves the anchor drifted just the same.",
1184        &group(FindingClass::Drifted),
1185    );
1186    render_findings_group(
1187        &mut lines,
1188        "Wrong — an adjudicated content mismatch",
1189        "Adjudication found the entity no longer matches its source. Correct the \
1190         mismatched section; do not rewrite what still holds.",
1191        &group(FindingClass::Wrong),
1192    );
1193    // Unresolvable anchor — the artifact is gone: delete only if the concept is
1194    // removed entirely (conservatism rule "no deletion unless concept removed").
1195    render_findings_group(
1196        &mut lines,
1197        "Unresolvable anchor — the artifact is gone",
1198        "The source artifact an anchor references is no longer present. Delete the entity \
1199         **only** if the concept is removed entirely; otherwise leave it. Concept-level \
1200         removals are a prune concern with its own never-clobber / conflict-flag rules — \
1201         do not delete on a hunch here.",
1202        &group(FindingClass::UnresolvableAnchor),
1203    );
1204    // Uncovered — a source artifact with no entity: create only for a clearly-new
1205    // concept (conservatism rule "no new entities unless clearly-new concept").
1206    // The third disposition — deliberately not modeled — routes to `projection
1207    // exclude`, the verb whose gate accepts a stable artifact (`advance` gates on
1208    // the changed slice, so a stable uncovered artifact is undispositionable
1209    // there; three campaign runs hit that wall before this line existed).
1210    let uncovered_guidance = format!(
1211        "An in-scope source artifact has no anchor in the mem. Create an entity for it \
1212         **only** if it is a clearly-new concept with no existing entity; otherwise \
1213         extend the entity that already owns the concept, or leave it for a discovery \
1214         build. A third answer is legitimate: the artifact is mined and deliberately \
1215         warrants no entity. Record that with a rationale — it stops presenting here \
1216         from the next brief on:\n\n```bash\nmemstead projection exclude {binding_id} \
1217         --exclusions '{{\"<artifact>\": \"<rationale>\"}}'\n```"
1218    );
1219    render_findings_group(
1220        &mut lines,
1221        "Uncovered — a source artifact with no entity",
1222        &uncovered_guidance,
1223        &group(FindingClass::Uncovered),
1224    );
1225    // Queued — not yet adjudicated: verify owns these, not sync.
1226    render_findings_group(
1227        &mut lines,
1228        "Queued for adjudication — not yet judged",
1229        "These are not adjudicated yet — that is the verify pass's job, not sync's. \
1230         **Skip them here**; they become repairable only after verify classifies them as \
1231         drifted.",
1232        &group(FindingClass::QueuedForAdjudication),
1233    );
1234
1235    format!("{}\n", lines.join("\n"))
1236}
1237
1238/// Render the authored-exclusion block for the sync brief: what is in force
1239/// (artifact, source, rationale) and what the reconcile dropped because its
1240/// source left the declaration. Empty when the ledger is empty.
1241fn render_exclusions(ledger: &crate::ingest::advance::ExclusionLedger) -> String {
1242    if ledger.active.is_empty() && ledger.dropped.is_empty() {
1243        return String::new();
1244    }
1245    let mut lines: Vec<String> = Vec::new();
1246    if !ledger.active.is_empty() {
1247        lines.push("## Excluded artifacts (authored)".to_string());
1248        lines.push(String::new());
1249        lines.push(
1250            "These in-scope artifacts are deliberately excluded with a recorded rationale; \
1251             they never present as uncovered and need no entity. An exclusion keys on the \
1252             artifact and its source, so it survives edits to the rest of the binding."
1253                .to_string(),
1254        );
1255        lines.push(String::new());
1256        for e in &ledger.active {
1257            lines.push(format!(
1258                "- `{}` (source `{}`): {}",
1259                e.artifact, e.source, e.rationale
1260            ));
1261        }
1262        lines.push(String::new());
1263    }
1264    if !ledger.dropped.is_empty() {
1265        lines.push("## Exclusions dropped — their source is no longer declared".to_string());
1266        lines.push(String::new());
1267        lines.push(
1268            "The source these exclusions were recorded under left the binding's declaration, \
1269             so they no longer apply; re-declare the source and record them again if they \
1270             still hold."
1271                .to_string(),
1272        );
1273        lines.push(String::new());
1274        for d in &ledger.dropped {
1275            lines.push(format!(
1276                "- `{}` (source `{}`, dropped {}): {}",
1277                d.artifact, d.source, d.dropped_at, d.rationale
1278            ));
1279        }
1280        lines.push(String::new());
1281    }
1282    format!("{}\n", lines.join("\n"))
1283}
1284
1285/// Render the prune-proposals block for the sync brief (group F) — the deletion
1286/// proposals prune surfaced, each with its guarantee-appropriate treatment.
1287/// Empty string when there are no proposals.
1288///
1289/// **F3 / A5, structural:** every proposal here is exactly that — a *proposal*.
1290/// Nothing in this text (nor anywhere in the engine) deletes an entity; the
1291/// removal reaches the mem **only** when the agent acts on this brief through the
1292/// MCP mutation surface. `authored` entities never reach this block (prune
1293/// excludes them upstream); `derived` entities are flagged with their inputs,
1294/// never proposed for deletion.
1295fn render_prune_proposals(proposals: &[PruneProposal]) -> String {
1296    if proposals.is_empty() {
1297        return String::new();
1298    }
1299    let mut lines: Vec<String> = vec![
1300        "## Prune — proposed removals (you decide; nothing is auto-deleted)".to_string(),
1301        String::new(),
1302        "The source removed the artifacts these entities describe. Each item below is a \
1303         **proposal**: prune writes nothing — you enact (or reject) the removal through the \
1304         normal MCP mutation surface. An `authored` entity is never proposed here; a `derived` \
1305         entity is flagged, never proposed for deletion."
1306            .to_string(),
1307        String::new(),
1308    ];
1309
1310    let group = |d: PruneDisposition| -> Vec<&PruneProposal> {
1311        proposals.iter().filter(|p| p.disposition == d).collect()
1312    };
1313
1314    // Clean-delete — never-clobber, base retrieved, merge clean: a confident
1315    // (still agent-enacted) delete proposal.
1316    let clean = group(PruneDisposition::CleanDelete);
1317    if !clean.is_empty() {
1318        lines.push("### Clean delete — never-clobber three-way merge is clean".to_string());
1319        lines.push(String::new());
1320        lines.push(
1321            "The source base leg was retrievable and the three-way merge found no model-side \
1322             divergence, so removal is safe. **Confirm, then delete via the mutation surface** — \
1323             this is still your call, not an auto-delete."
1324                .to_string(),
1325        );
1326        lines.push(String::new());
1327        let shown = clean.len().min(FINDINGS_CAP);
1328        for p in &clean[..shown] {
1329            lines.push(format!(
1330                "- `{}` — source artifact(s) gone: {}",
1331                p.entity,
1332                artifact_list(&p.artifacts)
1333            ));
1334        }
1335        if clean.len() > shown {
1336            lines.push(format!("- …and {} more", clean.len() - shown));
1337        }
1338        lines.push(String::new());
1339    }
1340
1341    // Conflict-flag — both sides presented, never an auto-write over an edit.
1342    let conflict = group(PruneDisposition::ConflictFlag);
1343    if !conflict.is_empty() {
1344        lines.push("### Conflict-flag — decide, never overwrite a model-side edit".to_string());
1345        lines.push(String::new());
1346        lines.push(
1347            "No retrievable base leg to merge against (a non-git source, or an anchor with no \
1348             pinned version). **Both sides are shown — decide deliberately.** If the concept is \
1349             truly gone, delete via the mutation surface; if the model side was edited on \
1350             purpose, keep it. Prune never overwrites a model-side edit for you."
1351                .to_string(),
1352        );
1353        lines.push(String::new());
1354        let shown = conflict.len().min(FINDINGS_CAP);
1355        for p in &conflict[..shown] {
1356            lines.push(format!(
1357                "- `{}` — **source side:** artifact(s) gone: {}; **model side:** the entity is \
1358                 still present (may carry edits) — you decide.",
1359                p.entity,
1360                artifact_list(&p.artifacts)
1361            ));
1362        }
1363        if conflict.len() > shown {
1364            lines.push(format!("- …and {} more", conflict.len() - shown));
1365        }
1366        lines.push(String::new());
1367    }
1368
1369    // Derived-flagged — flagged with inputs, never proposed for deletion (F3).
1370    let derived = group(PruneDisposition::DerivedFlagged);
1371    if !derived.is_empty() {
1372        lines.push("### Derived — flagged, NOT proposed for deletion".to_string());
1373        lines.push(String::new());
1374        lines.push(
1375            "These entities were **derived** from other inputs. A derived entity is flagged, \
1376             never auto-proposed for deletion — its inputs may still hold even though one source \
1377             artifact vanished. Re-examine the inputs before removing anything."
1378                .to_string(),
1379        );
1380        lines.push(String::new());
1381        let shown = derived.len().min(FINDINGS_CAP);
1382        for p in &derived[..shown] {
1383            let inputs = if p.derived_inputs.is_empty() {
1384                "(no recorded inputs)".to_string()
1385            } else {
1386                artifact_list(&p.derived_inputs)
1387            };
1388            lines.push(format!(
1389                "- `{}` — derived from: {}; source artifact(s) gone: {}.",
1390                p.entity,
1391                inputs,
1392                artifact_list(&p.artifacts)
1393            ));
1394        }
1395        if derived.len() > shown {
1396            lines.push(format!("- …and {} more", derived.len() - shown));
1397        }
1398        lines.push(String::new());
1399    }
1400
1401    format!("{}\n", lines.join("\n"))
1402}
1403
1404/// A compact backtick-joined artifact list.
1405fn artifact_list(artifacts: &[String]) -> String {
1406    if artifacts.is_empty() {
1407        return "(none)".to_string();
1408    }
1409    artifacts
1410        .iter()
1411        .map(|a| format!("`{a}`"))
1412        .collect::<Vec<_>>()
1413        .join(", ")
1414}
1415
1416/// Render the sync brief's `## Situation` block — the sole-maintenance-writer
1417/// mandate and the commits-nothing / engine-commits-per-mutation posture (C3).
1418fn render_sync_situation(resolved: &ResolvedIngest) -> String {
1419    format!(
1420        "## Sync — repair the graph to match the source\n\n\
1421         You are running the sync pass for `{}`. Sync is the graph's **sole maintenance \
1422         writer**: the only place the destination mem `{}` is repaired to match its \
1423         source. Two inputs steer this pass — the source changes since the last sync, and \
1424         the open verify findings — both below. Work them: update, create, relate, and \
1425         (rarely) delete entities so the graph again matches the source.\n\n\
1426         Every mutation routes through the normal MCP mutation surface, and the engine \
1427         commits each one **per-mutation** to the mem's own gitdir. You **stage nothing \
1428         and commit nothing yourself** — not the graph, not the code. Sync commits \
1429         nothing.\n\n",
1430        resolved.name, resolved.destination_mem
1431    )
1432}
1433
1434/// Render the adopt / onboarding block (C3's first-sync/adopt framing; E1's
1435/// brief half): a mem that predates its binding is onboarding, expected-0%, with
1436/// the concrete backfill path — never a failure or red verdict.
1437fn render_adopt_framing(resolved: &ResolvedIngest) -> String {
1438    format!(
1439        "## First sync — adopting `{}`\n\n\
1440         This mem predates its binding: it has no anchors and no prior sync baseline, so \
1441         **0% anchored is expected — this is onboarding, not a failure.** Do not read it \
1442         as drift or a red verdict. There is no cursor to diff against, so the baseline is \
1443         the **current** source HEAD — do **not** replay the whole history; treat the \
1444         current source state as the starting point, and this is a **first sync**.\n\n\
1445         **Backfill path:** run `memstead projection verify {}` to enumerate the in-scope \
1446         source artifacts that carry no entity yet, then cover the clearly-new concepts \
1447         among them through the normal MCP mutation surface — the same conservative rules \
1448         below apply. Backfilling is incremental: a partial pass is fine, and the next \
1449         sync continues where you left off.\n\n",
1450        resolved.destination_mem, resolved.name
1451    )
1452}
1453
1454/// Render the **stale-claim search** block — the bounded step that closes the
1455/// slice-blinkering blind spot: a changed fact can be claimed by entities
1456/// whose anchors lie entirely outside the changed slice, so steering repairs
1457/// at slice-anchored entities alone leaves those claims standing falsified.
1458///
1459/// The shape is deliberately bounded, and the prose binds itself to **the
1460/// changed facts extracted from the slice**: a cosmetic change (formatting,
1461/// comments, moves that alter no fact) yields an empty fact set, and an empty
1462/// fact set instructs nothing — no whole-mem sweep, no live-verify of every
1463/// entity, no rewrite license. Rendered only when the cursor carries actual
1464/// changed artifacts (never for reseed-only / no-signal-only passes).
1465fn render_stale_claim_search(resolved: &ResolvedIngest) -> String {
1466    format!(
1467        "## Stale claims beyond the slice — search, then judge\n\n\
1468         A changed fact can be claimed by an entity whose anchors are all outside the \
1469         changed slice — anchor-steered repairs alone would leave that claim standing \
1470         falsified. Extract the **changed facts** from the changed artifacts above: \
1471         renamed identifiers, changed values or defaults, changed behaviors (e.g. an \
1472         exit code, a flag's meaning), removed or moved concepts. For each changed \
1473         fact, search the destination mem `{}` for claims about it (`memstead_search` \
1474         and its variants — try the new name, the old name/value, and close synonyms), \
1475         and judge **only** the entities whose claims actually mention a changed fact: \
1476         repair a claim the change falsifies, leave everything else untouched.\n\n\
1477         This is a bounded fact-search, not a live-verify of every entity and not a \
1478         rewrite license. If the changes carry no factual claims (formatting, \
1479         comments, cosmetic moves), the fact set is empty and this step ends with no \
1480         search and no edits.\n\n",
1481        resolved.destination_mem
1482    )
1483}
1484
1485/// Render the sync brief's conservatism block — the whole of `/reconcile`'s
1486/// absorbed judgment (C3): the five conservatism rules, edge-removal
1487/// conservatism, and rationale-not-changelog.
1488fn render_sync_conservatism() -> String {
1489    let lines: Vec<&str> = vec![
1490        "## How to repair — be conservative",
1491        "",
1492        "Repair only what the source changes and the findings above actually justify:",
1493        "",
1494        // The five conservatism rules.
1495        "- **Unsure whether an entity is affected — skip it.** A missed update is a later \
1496         finding; a wrong rewrite is damage.",
1497        "- **Do not create a new entity unless the change clearly introduces a new concept \
1498         with no existing entity.** Prefer updating the entity that already owns the \
1499         concept.",
1500        "- **Do not delete an entity unless the change removes the concept entirely.** \
1501         Deletions a prune pass surfaces follow prune's own never-clobber / conflict-flag \
1502         rules — never delete on a hunch here.",
1503        "- **Never rewrite a section that has not changed** — touch only the part the \
1504         change or finding actually affects.",
1505        "- **No speculative edges — add only relationships the diff literally introduces** \
1506         (a new `use` / `import` / dependency you can point at in the change).",
1507        // Edge-removal conservatism.
1508        "- **A dropped dependency FLAGS, it does not auto-remove.** If the change removes an \
1509         import or dependency, leave the matching edge intact and note it for a later \
1510         audit — removals are ambiguous (temporary refactor vs. permanent cut), and a \
1511         stale edge is less damaging than an erased real one. **Edge removal is out of \
1512         scope for sync.**",
1513        // Rationale-not-changelog.
1514        "- **Rationale is reasoning, not a changelog.** When you record why a change was \
1515         made, append the *reasoning* (why this approach, which trade-offs) — never \
1516         `[commit <hash>]` log-style entries.",
1517        "",
1518    ];
1519
1520    format!("{}\n", lines.join("\n"))
1521}
1522
1523/// Render the **sync brief** (C2/C3) — the *single* channel through which
1524/// maintenance-writing work reaches an agent.
1525///
1526/// One brief carries **both** inputs: the cursor slice (`cursor`, rendered via
1527/// [`render_changed_slice`], which also carries the first-sync reseed framing and
1528/// the disposition-recording step) and the open verify findings (`findings`, the
1529/// store's `current(key)` slice). It absorbs the whole of `/reconcile`'s judgment
1530/// (C3): the five conservatism rules, edge-removal conservatism,
1531/// rationale-not-changelog, the commits-nothing / engine-commits-per-mutation
1532/// posture, and — when `adopt` is set — the first-sync/adopt onboarding framing
1533/// (E1's brief half). A rule-by-rule absorption map records where each retired
1534/// reconcile rule now lives (bundle plan `05-verify-sync-engine`, C4).
1535///
1536/// A slice that carries actual changed artifacts additionally renders the
1537/// bounded **stale-claim search** step ([`render_stale_claim_search`]) — the
1538/// beyond-the-slice fact search that catches claims falsified by the change in
1539/// entities whose anchors never intersect the slice.
1540///
1541/// Prune proposals (group F) ride this same brief — F3's single-writer
1542/// invariant: every prune removal reaches the mem only via an agent acting on
1543/// this sync brief. They are rendered as proposals only; nothing is auto-deleted.
1544///
1545/// When nothing has moved, no findings are open, no prune proposals exist, and
1546/// this is not an adopt pass, the brief renders a compact "nothing to sync" note
1547/// instead of the repair machinery — a valid, silent outcome mirroring the build
1548/// brief's no-op roam.
1549pub fn render_sync_brief(
1550    resolved: &ResolvedIngest,
1551    cursor: &SourceCursor,
1552    findings: &[Finding],
1553    prune: &[PruneProposal],
1554    adopt: bool,
1555    exclusions: &crate::ingest::advance::ExclusionLedger,
1556) -> String {
1557    let preface = render_changed_slice(cursor);
1558    let open_findings = render_open_findings(findings, &resolved.name);
1559    let prune_block = render_prune_proposals(prune);
1560    let has_work =
1561        adopt || !preface.is_empty() || !open_findings.is_empty() || !prune_block.is_empty();
1562
1563    // The exclusion ledger renders on every sync brief, work or not: an
1564    // exclusion in force is standing state the agent must not re-litigate,
1565    // and one the reconcile dropped is reported here, once, with its source.
1566    let mut parts: Vec<String> = vec![
1567        render_sync_situation(resolved),
1568        render_exclusions(exclusions),
1569    ];
1570
1571    if !has_work {
1572        parts.push(
1573            "## Nothing to sync\n\nThe source has not moved since the last sync, no \
1574             verify findings are open, and no prune proposals stand. There is nothing to \
1575             repair this pass — reporting \"no changes\" is a valid outcome.\n\n"
1576                .to_string(),
1577        );
1578        return parts
1579            .into_iter()
1580            .filter(|p| !p.is_empty())
1581            .collect::<Vec<_>>()
1582            .join("");
1583    }
1584
1585    if adopt {
1586        parts.push(render_adopt_framing(resolved));
1587    }
1588    parts.push(preface);
1589    // The stale-claim search rides only a slice that carries actual changed
1590    // artifacts — its facts are extracted FROM those artifacts, so a pass
1591    // with no changes (findings-only, reseed-only, prune-only) renders none.
1592    if cursor.any_changes {
1593        parts.push(render_stale_claim_search(resolved));
1594    }
1595    parts.push(open_findings);
1596    parts.push(prune_block);
1597    parts.push(render_anchor_instruction(resolved));
1598    parts.push(render_sync_conservatism());
1599
1600    parts
1601        .into_iter()
1602        .filter(|p| !p.is_empty())
1603        .collect::<Vec<_>>()
1604        .join("")
1605}
1606
1607#[cfg(test)]
1608mod tests {
1609    use super::*;
1610    use crate::ingest::resolve::Source;
1611    use crate::pipeline::{IngestTrigger, PatternEntry};
1612
1613    fn guidance(goal: Option<&str>, avoid: Option<&str>) -> ResolvedGuidance {
1614        ResolvedGuidance {
1615            goal: goal.map(str::to_string),
1616            avoid: avoid.map(str::to_string),
1617        }
1618    }
1619
1620    /// Goal and avoid both present: two headers, trimmed prose, block ends in
1621    /// a blank line — byte-for-byte the plugin's `goalAndAvoidBlock`.
1622    #[test]
1623    fn renders_goal_and_avoid_blocks() {
1624        let out = render_goal_and_avoid(&guidance(Some("  build coverage  "), Some("no stubs")));
1625        assert_eq!(
1626            out,
1627            "## Goal\n\nbuild coverage\n\n## Failure modes to avoid\n\nno stubs\n\n"
1628        );
1629    }
1630
1631    /// Goal only: a single header block ending in a blank line.
1632    #[test]
1633    fn renders_goal_only() {
1634        assert_eq!(
1635            render_goal_and_avoid(&guidance(Some("build coverage"), None)),
1636            "## Goal\n\nbuild coverage\n\n"
1637        );
1638    }
1639
1640    /// Avoid only: a single header block ending in a blank line.
1641    #[test]
1642    fn renders_avoid_only() {
1643        assert_eq!(
1644            render_goal_and_avoid(&guidance(None, Some("no stubs"))),
1645            "## Failure modes to avoid\n\nno stubs\n\n"
1646        );
1647    }
1648
1649    /// Neither present (and no pass-through): a lone newline, matching the
1650    /// plugin's `lines.join('\n') + '\n'` on an empty block.
1651    #[test]
1652    fn empty_guidance_yields_a_newline() {
1653        assert_eq!(render_goal_and_avoid(&guidance(None, None)), "\n");
1654        // An all-whitespace field is treated as absent.
1655        assert_eq!(render_goal_and_avoid(&guidance(Some("   "), None)), "\n");
1656    }
1657
1658    fn primary(medium_type: MediumType, scope: Vec<PatternEntry>) -> ResolvedSource {
1659        ResolvedSource::Primary(Source {
1660            name: "f".to_string(),
1661            medium_type,
1662            pointer: "../src".to_string(),
1663            change_detection: None,
1664            scope,
1665            engagement: None,
1666            preparation: None,
1667        })
1668    }
1669
1670    fn resolved(name: &str, intent: Option<&str>, sources: Vec<ResolvedSource>) -> ResolvedIngest {
1671        ResolvedIngest {
1672            name: name.to_string(),
1673            mode: BuildMode::Discovery,
1674            trigger: IngestTrigger::Loop,
1675            batch_size: 20,
1676            deny_paths: vec![],
1677            projection_ref: format!("{name}/p"),
1678            projection_mem: name.to_string(),
1679            projection_name: "p".to_string(),
1680            intent: intent.map(str::to_string),
1681            sources,
1682            destination_mem: name.to_string(),
1683            rules: None,
1684            post_actions: None,
1685        }
1686    }
1687
1688    fn process_present(name: &str) -> ProcessMemInfo {
1689        ProcessMemInfo {
1690            present: true,
1691            skipped: false,
1692            notice: None,
1693            leaf_name: name.to_string(),
1694            mem_label: format!("ingest/{name}"),
1695        }
1696    }
1697
1698    fn allow(path: &str) -> PatternEntry {
1699        PatternEntry {
1700            path: path.to_string(),
1701            mode: PatternMode::Allow,
1702        }
1703    }
1704
1705    fn deny(path: &str) -> PatternEntry {
1706        PatternEntry {
1707            path: path.to_string(),
1708            mode: PatternMode::Deny,
1709        }
1710    }
1711
1712    /// The about-the-source block trims the intent; no intent → empty string.
1713    #[test]
1714    fn renders_intent() {
1715        let r = resolved("macos", Some("  Swift app source.  "), vec![]);
1716        assert_eq!(
1717            render_intent(&r),
1718            "## About the source\n\nSwift app source.\n\n"
1719        );
1720        let none = resolved("macos", None, vec![]);
1721        assert_eq!(render_intent(&none), "");
1722    }
1723
1724    /// The situation block prints the name/mode, the three fixed paragraphs,
1725    /// and the present-process-mem line, ending in a blank line.
1726    #[test]
1727    fn renders_situation_with_present_process_mem() {
1728        let r = resolved("macos", None, vec![]);
1729        let out = render_situation(&r, &process_present("macos"));
1730        assert!(out.starts_with("## Situation\n\nYou are running one iteration of `macos` (discovery mode) inside a loop."));
1731        assert!(out.contains("Mutating the destination is this run's mandate:"));
1732        assert!(out.contains("The `PreCompact` hook fires near the limit"));
1733        assert!(out.contains("A paired process mem `ingest/macos` (schema `ingest@0.5.0`) carries destination-quality debt"));
1734        assert!(
1735            out.ends_with("write rules.\n\n"),
1736            "block ends in a blank line"
1737        );
1738    }
1739
1740    /// The skipped (one-shot) and failed-to-create process-mem branches each
1741    /// render their own note.
1742    #[test]
1743    fn situation_process_mem_branches() {
1744        let mut r = resolved("os", None, vec![]);
1745        r.mode = BuildMode::OneShot;
1746        let skipped = ProcessMemInfo {
1747            present: false,
1748            skipped: true,
1749            notice: None,
1750            leaf_name: "os".to_string(),
1751            mem_label: "ingest/os".to_string(),
1752        };
1753        assert!(
1754            render_situation(&r, &skipped)
1755                .contains("No process mem is paired with this ingest (mode=one-shot;")
1756        );
1757
1758        let failed = ProcessMemInfo {
1759            present: false,
1760            skipped: false,
1761            notice: Some("engine offline".to_string()),
1762            leaf_name: "os".to_string(),
1763            mem_label: "ingest/os".to_string(),
1764        };
1765        let out = render_situation(&resolved("os", None, vec![]), &failed);
1766        assert!(out.contains("could not be auto-created — engine offline."));
1767        assert!(out.contains("memstead mem init os --org-path ingest --schema ingest@0.5.0"));
1768    }
1769
1770    /// Operative data: a primary source with paths/ignore, a reference mem
1771    /// with its cross-mem note, the destination with its schema, and the
1772    /// paired process mem — byte-for-byte the plugin's block.
1773    #[test]
1774    fn renders_operative_data_full() {
1775        let r = resolved(
1776            "macos",
1777            None,
1778            vec![
1779                primary(
1780                    MediumType::Codebase,
1781                    vec![allow("src/**/*.swift"), deny("src/gen/**")],
1782                ),
1783                ResolvedSource::Reference {
1784                    mem: "engine".to_string(),
1785                },
1786            ],
1787        );
1788        let out = render_operative_data(
1789            &r,
1790            &process_present("macos"),
1791            Some("macos-code@0.1.0"),
1792            None,
1793            &[],
1794        );
1795        let expected = "\
1796## Operative data
1797
1798### Sources
1799
1800- **f** (codebase, primary) — `../src`
1801  - Paths: src/**/*.swift
1802  - Ignore: src/gen/**
1803- **graph** (reference) — mem: engine
1804
1805Sources tagged `(reference)` are read-only context for cross-mem edges — search them, never write into them. Only `(primary)` sources are ingested into the destination.
1806
1807**Cross-mem references:** consult `memstead_search mem=engine` before authoring cross-mem edges. The target entity must exist — a wiki-link or relationship to a missing target either auto-stubs (silent) or fails authorization (`CROSS_MEM_RELATION`).
1808
1809### Destination
1810
1811- **macos** — schema: `macos-code@0.1.0`
1812
1813### Paired process mem
1814
1815- **ingest/macos** — schema: `ingest@0.5.0`. Inspect via `memstead_overview` / `memstead_search mem=macos`.
1816\n";
1817        assert_eq!(out, expected);
1818    }
1819
1820    /// Operative data without references or a destination schema: no cross-mem
1821    /// note, a bare destination line.
1822    #[test]
1823    fn renders_operative_data_minimal() {
1824        let r = resolved("g", None, vec![primary(MediumType::Filesystem, vec![])]);
1825        let skipped = ProcessMemInfo {
1826            present: false,
1827            skipped: true,
1828            notice: None,
1829            leaf_name: "g".to_string(),
1830            mem_label: "ingest/g".to_string(),
1831        };
1832        let out = render_operative_data(&r, &skipped, None, Some("**absent** — probe"), &[]);
1833        // The bullet carries the pointer: an agent told to read a source
1834        // must be able to see WHICH tree it was pointed at.
1835        assert!(out.contains("- **f** (filesystem, primary) — `"));
1836        assert!(!out.contains("Cross-mem references"), "no reference note");
1837        assert!(out.contains("### Destination\n\n- **g**\n"));
1838        // The caller decides the destination note — this renderer only
1839        // places it, because the remedy depends on the workspace shape.
1840        assert!(
1841            out.contains("**absent** — probe"),
1842            "the caller's destination note must be rendered: {out}",
1843        );
1844        assert!(
1845            !out.contains("Paired process mem"),
1846            "skipped process mem omitted"
1847        );
1848    }
1849
1850    /// A source whose scope still speaks the retired workspace-relative
1851    /// dialect is warned about IN THE BRIEF — the operative-data block, the
1852    /// one surface a binding running only build and sync ever reads. Without
1853    /// it the notes reach only the verify report and the `--full` refusal,
1854    /// and such a binding is never told its scope selects nothing.
1855    #[test]
1856    fn operative_data_warns_on_retired_scope_dialect() {
1857        let r = resolved(
1858            "g",
1859            None,
1860            // Pointer `../src` (the helper's default): one pattern in the
1861            // retired dialect (begins with the pointer), one converged.
1862            vec![primary(
1863                MediumType::Filesystem,
1864                vec![allow("../src/**/*.md"), allow("notes/**")],
1865            )],
1866        );
1867        let skipped = ProcessMemInfo {
1868            present: false,
1869            skipped: true,
1870            notice: None,
1871            leaf_name: "g".to_string(),
1872            mem_label: "ingest/g".to_string(),
1873        };
1874        let out = render_operative_data(&r, &skipped, None, None, &[]);
1875        assert!(
1876            out.contains("workspace root"),
1877            "the block names the retired dialect: {out}"
1878        );
1879        assert!(
1880            out.contains("../src/**/*.md"),
1881            "the offending pattern is named: {out}"
1882        );
1883        assert!(
1884            out.contains("`**/*.md`"),
1885            "the mechanical rewrite is offered: {out}"
1886        );
1887
1888        // A converged scope renders no warning.
1889        let clean = resolved(
1890            "g",
1891            None,
1892            vec![primary(MediumType::Filesystem, vec![allow("**/*.md")])],
1893        );
1894        let out2 = render_operative_data(&clean, &skipped, None, None, &[]);
1895        assert!(
1896            !out2.contains("workspace root"),
1897            "no warning without a retired-dialect pattern: {out2}"
1898        );
1899    }
1900
1901    /// The discovery assembly concatenates the truthy blocks in order; an
1902    /// empty changed-slice preface (source unmoved) drops out.
1903    #[test]
1904    fn assembles_discovery_brief() {
1905        let r = resolved(
1906            "macos",
1907            Some("Swift source."),
1908            vec![primary(MediumType::Codebase, vec![allow("src/**")])],
1909        );
1910        let g = guidance(Some("build coverage"), None);
1911        let pm = process_present("macos");
1912        let brief = assemble_discovery_brief(&r, &g, &pm, Some("s@1"), None, &[], "");
1913
1914        // Blocks appear in order and the empty preface is dropped.
1915        let sit = brief.find("## Situation").unwrap();
1916        let src = brief.find("## About the source").unwrap();
1917        let goal = brief.find("## Goal").unwrap();
1918        let op = brief.find("## Operative data").unwrap();
1919        let anchors = brief.find("## Provenance — anchor your writes").unwrap();
1920        assert!(
1921            sit < src && src < goal && goal < op && op < anchors,
1922            "blocks in brief order"
1923        );
1924        assert!(
1925            !brief.contains("## Source changes"),
1926            "no changed-slice block when preface empty"
1927        );
1928
1929        // A non-empty preface is appended verbatim at the end.
1930        let with_slice = assemble_discovery_brief(
1931            &r,
1932            &g,
1933            &pm,
1934            Some("s@1"),
1935            None,
1936            &[],
1937            "## Source changes\n\n…\n\n",
1938        );
1939        assert!(with_slice.ends_with("## Source changes\n\n…\n\n"));
1940    }
1941
1942    fn slice(deleted: &[&str], modified: &[&str], added: &[&str]) -> Slice {
1943        Slice {
1944            deleted: deleted.iter().map(|s| s.to_string()).collect(),
1945            modified: modified.iter().map(|s| s.to_string()).collect(),
1946            added: added.iter().map(|s| s.to_string()).collect(),
1947        }
1948    }
1949
1950    fn cmd(key: &str, token: &str) -> SyncCommand {
1951        SyncCommand {
1952            key: key.to_string(),
1953            token: token.to_string(),
1954        }
1955    }
1956
1957    fn note(source: &str, reason: NoSignalReason) -> NoSignalNote {
1958        NoSignalNote {
1959            medium_type: None,
1960            source: source.to_string(),
1961            reason,
1962        }
1963    }
1964
1965    /// The provenance block names a prepared-form source and scopes the
1966    /// `content` advice to file and span anchors; a binding without a
1967    /// preparation carries no such paragraph.
1968    #[test]
1969    fn anchor_instruction_names_prepared_form_sources() {
1970        let mut resolved = resolved("home", None, vec![primary(MediumType::Codebase, vec![])]);
1971        let plain = render_anchor_instruction(&resolved);
1972        assert!(!plain.contains("hash a prepared form"));
1973        if let Some(ResolvedSource::Primary(src)) = resolved.sources.first_mut() {
1974            src.preparation = Some(crate::preparation::CODE_MAP.to_string());
1975        }
1976        let prepared = render_anchor_instruction(&resolved);
1977        assert!(
1978            prepared.contains("hash a prepared form (`code-map`)"),
1979            "{prepared}"
1980        );
1981        assert!(prepared.contains("interface digest"));
1982        assert!(prepared.contains("for a `file` or `span` anchor pass the artifact's `content`"));
1983        assert!(prepared.contains("a `tree` anchor takes no content"));
1984    }
1985
1986    /// A delivery sequence renders in its total order, numbered by position,
1987    /// capped at the batch with the remainder counted, disposed units
1988    /// subtracted but counted, and its unit ids kept out of the alphabetical
1989    /// class lists (a file-level id in the same slice still lists there).
1990    #[test]
1991    fn changed_slice_renders_delivery_sequences_in_order() {
1992        use crate::preparation::UnitChange;
1993        let unit = |id: &str, order: &str, change: UnitChange, disposed: bool| DeliveredUnit {
1994            id: id.to_string(),
1995            order_key: order.to_string(),
1996            change,
1997            disposed,
1998        };
1999        let units = vec![
2000            unit(
2001                "log/b.md#2026-08-20T00:00:00",
2002                "2026-08-20T00:00:00",
2003                UnitChange::Added,
2004                true,
2005            ),
2006            unit(
2007                "log/a.md#2026-08-21T00:00:00",
2008                "2026-08-21T00:00:00",
2009                UnitChange::Deleted,
2010                false,
2011            ),
2012            unit(
2013                "log/b.md#2026-08-22T00:00:00",
2014                "2026-08-22T00:00:00",
2015                UnitChange::Modified,
2016                false,
2017            ),
2018            unit(
2019                "log/a.md#2026-08-23T00:00:00",
2020                "2026-08-23T00:00:00",
2021                UnitChange::Added,
2022                false,
2023            ),
2024        ];
2025        let cursor = SourceCursor {
2026            // `slice(deleted, modified, added)`.
2027            union: slice(
2028                &["log/a.md#2026-08-21T00:00:00"],
2029                &["log/b.md#2026-08-22T00:00:00"],
2030                &[
2031                    "log/a.md#2026-08-23T00:00:00",
2032                    "log/b.md#2026-08-20T00:00:00",
2033                    "other/x.rs",
2034                ],
2035            ),
2036            write_commands: vec![],
2037            reseed: vec![],
2038            no_signal: vec![],
2039            any_changes: true,
2040            degraded: false,
2041            dead_denies: vec![],
2042            dest_mem: "home".to_string(),
2043            binding_id: "home/log".to_string(),
2044            delivery: vec![DeliverySequence {
2045                source: "log".to_string(),
2046                preparation: "dated-entries".to_string(),
2047                first_run: false,
2048                degraded: true,
2049                batch: 2,
2050                units,
2051            }],
2052        };
2053        let out = render_changed_slice(&cursor);
2054        assert!(
2055            out.contains("### Delivery sequence: `log` (`dated-entries`)"),
2056            "{out}"
2057        );
2058        assert!(out.contains("The units that changed since the last pass"));
2059        assert!(out.contains("No baseline content was retrievable"));
2060        let listed: Vec<&str> = out
2061            .lines()
2062            .filter(|l| l.starts_with(|c: char| c.is_ascii_digit()))
2063            .collect();
2064        assert_eq!(
2065            listed,
2066            vec![
2067                "2. `log/a.md#2026-08-21T00:00:00` (deleted)",
2068                "3. `log/b.md#2026-08-22T00:00:00` (changed)",
2069            ],
2070            "positions are total-order positions; the disposed first unit is skipped"
2071        );
2072        assert!(out.contains("…and 1 more, presented in order once these are disposed"));
2073        assert!(out.contains("1 unit of this sequence already disposed"));
2074        // The class lists carry only the file-level id.
2075        assert!(out.contains("**Added:**\n- `other/x.rs`\n"), "{out}");
2076        assert!(!out.contains("**Modified:**"));
2077        assert!(!out.contains("**Deleted:**"));
2078    }
2079
2080    /// No changes and no reseed → the block is empty (brief stays a plain roam).
2081    #[test]
2082    fn changed_slice_empty_when_nothing_moved() {
2083        let cursor = SourceCursor {
2084            union: slice(&[], &[], &[]),
2085            write_commands: vec![],
2086            reseed: vec![],
2087            no_signal: vec![],
2088            any_changes: false,
2089            degraded: false,
2090            dead_denies: vec![],
2091            dest_mem: "engine".to_string(),
2092            binding_id: "engine/graph".to_string(),
2093            delivery: vec![],
2094        };
2095        assert_eq!(render_changed_slice(&cursor), "");
2096    }
2097
2098    /// A zero-selecting deny entry surfaces as a rendered warning even when
2099    /// nothing else moved — it is never a silent no-op. The entry name and the
2100    /// migration hint both appear.
2101    #[test]
2102    fn changed_slice_renders_dead_deny_warning() {
2103        let cursor = SourceCursor {
2104            union: slice(&[], &[], &[]),
2105            write_commands: vec![],
2106            reseed: vec![],
2107            no_signal: vec![],
2108            any_changes: false,
2109            degraded: false,
2110            dead_denies: vec!["dev".to_string(), "typo/**".to_string()],
2111            dest_mem: "engine".to_string(),
2112            binding_id: "engine/graph".to_string(),
2113            delivery: vec![],
2114        };
2115        let out = render_changed_slice(&cursor);
2116        assert!(out.contains("deny_paths` entries match nothing"));
2117        assert!(out.contains("- `dev`"));
2118        assert!(out.contains("- `typo/**`"));
2119    }
2120
2121    /// A changed pass renders deleted-first, then the recording block — built
2122    /// here from single-line literals transcribed from the plugin so any
2123    /// line-continuation drift in the impl is caught.
2124    #[test]
2125    fn changed_slice_renders_slice_and_recording() {
2126        let cursor = SourceCursor {
2127            union: slice(&["a.rs"], &["b.rs"], &[]),
2128            write_commands: vec![cmd("engine-graph/source", "HEADSHA")],
2129            reseed: vec![],
2130            no_signal: vec![],
2131            any_changes: true,
2132            degraded: false,
2133            dead_denies: vec![],
2134            dest_mem: "engine".to_string(),
2135            binding_id: "engine/graph".to_string(),
2136            delivery: vec![],
2137        };
2138        let expected_lines = [
2139            "## Source changes since the last sync\n",
2140            "The source moved since this graph was last synced. Steer this pass at these changed artifacts **first** — they are where the graph is most likely now wrong.\n",
2141            "**Deleted:**",
2142            "- `a.rs`",
2143            "",
2144            "**Modified:**",
2145            "- `b.rs`",
2146            "",
2147            "### Recording your dispositions (do this LAST)\n",
2148            "Only after you have worked the changed artifacts above — and only for the artifacts you actually judged — record a disposition for each, so the next pass targets just what changes next. This advance is resumable and non-stalling: a partial pass is honored, and if the source moves mid-pass the remaining slice re-presents (remaining + new) without losing your recorded work.\n",
2149            "Anchored work disposes itself: at advance time, every listed artifact that an anchor in the destination mem references is marked `worked` automatically (an explicit disposition you pass wins over the auto-mark). Supply dispositions only for the residue — artifacts you skipped, judged out of intent, or worked without anchors. The gate accepts only artifact ids listed above — an unknown id refuses the whole call. When every artifact is disposed, the sync baseline advances automatically. Run:\n",
2150            "```sh",
2151            r#"memstead projection advance engine/graph --dispositions '{"<artifact>": "<disposition>", ...}'"#,
2152            "```",
2153            "If you were interrupted before finishing, that is fine — your recorded dispositions persist, and the next run re-presents only what is left.\n",
2154        ];
2155        assert_eq!(
2156            render_changed_slice(&cursor),
2157            format!("{}\n", expected_lines.join("\n"))
2158        );
2159    }
2160
2161    /// The reseed-only path names the first-sync keys and still emits the
2162    /// recording block (the reseed baselines).
2163    #[test]
2164    fn changed_slice_reseed_only() {
2165        let cursor = SourceCursor {
2166            union: slice(&[], &[], &[]),
2167            write_commands: vec![],
2168            reseed: vec![cmd("ing/f", "TOK")],
2169            no_signal: vec![],
2170            any_changes: false,
2171            degraded: false,
2172            dead_denies: vec![],
2173            dest_mem: "d".to_string(),
2174            binding_id: "d/p".to_string(),
2175            delivery: vec![],
2176        };
2177        let out = render_changed_slice(&cursor);
2178        assert!(out.starts_with("## Source changes since the last sync\n\n"));
2179        assert!(out.contains(
2180            "No usable sync baseline exists for `ing/f` — none was recorded, or the recorded one is not a commit of the source's repo (foreign or garbage-collected). Treating the current source state as the baseline. No priority slice from it this pass; proceed as usual."
2181        ));
2182        assert!(out.contains(
2183            r#"memstead projection advance d/p --dispositions '{"<artifact>": "<disposition>", ...}'"#
2184        ));
2185        assert!(
2186            !out.contains("The source moved"),
2187            "no 'moved' copy when only reseeding"
2188        );
2189    }
2190
2191    /// Every no-signal reason renders a distinct, named note under the preface,
2192    /// distinguishable from one another and from a genuinely-unchanged source
2193    /// (which renders nothing). With no changes and no reseed there is no
2194    /// recording block, but the preface is non-empty — a source's blindness is
2195    /// visible. `signal:none` renders literally.
2196    #[test]
2197    fn changed_slice_renders_no_signal_reasons_distinguishably() {
2198        let cursor = SourceCursor {
2199            union: slice(&[], &[], &[]),
2200            write_commands: vec![],
2201            reseed: vec![],
2202            no_signal: vec![
2203                note("code-facet", NoSignalReason::Unscoped),
2204                note("plan-facet", NoSignalReason::DetectionNone),
2205                note("git-facet", NoSignalReason::GitUnavailable),
2206                note("ref-mem", NoSignalReason::GraphSnapshotMissing),
2207            ],
2208            any_changes: false,
2209            degraded: false,
2210            dead_denies: vec![],
2211            dest_mem: "d".to_string(),
2212            binding_id: "d/p".to_string(),
2213            delivery: vec![],
2214        };
2215        let out = render_changed_slice(&cursor);
2216        assert!(out.starts_with("## Source changes since the last sync\n"));
2217        assert!(out.contains("Some sources produced **no change signal**"));
2218        // Each source is named and carries its own distinct reason text.
2219        assert!(out.contains("- `code-facet`: unscoped facet (no allow patterns)"));
2220        assert!(
2221            out.contains("- `plan-facet`: `signal:none`"),
2222            "detection-none renders the literal signal:none state"
2223        );
2224        assert!(out.contains("- `git-facet`: git signal unavailable"));
2225        assert!(out.contains("- `ref-mem`: graph snapshot missing"));
2226        // The four reason texts are mutually distinct.
2227        let texts = [
2228            no_signal_reason_text(NoSignalReason::Unscoped, None),
2229            no_signal_reason_text(NoSignalReason::DetectionNone, None),
2230            no_signal_reason_text(NoSignalReason::GitUnavailable, None),
2231            no_signal_reason_text(NoSignalReason::GraphSnapshotMissing, None),
2232        ];
2233        for (i, a) in texts.iter().enumerate() {
2234            for b in &texts[i + 1..] {
2235                assert_ne!(a, b, "each no-signal reason must render distinctly");
2236            }
2237        }
2238        // No baseline to advance → no recording block, no "moved" copy.
2239        assert!(!out.contains("### Recording your dispositions"));
2240        assert!(!out.contains("The source moved"));
2241    }
2242
2243    /// A changed source and a no-signal source coexist: the changed slice AND
2244    /// the no-signal note both render in the one preface, and the changed
2245    /// source still emits its recording command.
2246    #[test]
2247    fn changed_slice_mixes_changes_and_no_signal() {
2248        let cursor = SourceCursor {
2249            union: slice(&[], &["b.rs"], &[]),
2250            write_commands: vec![cmd("ing/f", "HEAD")],
2251            reseed: vec![],
2252            no_signal: vec![note("other", NoSignalReason::Unscoped)],
2253            any_changes: true,
2254            degraded: false,
2255            dead_denies: vec![],
2256            dest_mem: "d".to_string(),
2257            binding_id: "d/p".to_string(),
2258            delivery: vec![],
2259        };
2260        let out = render_changed_slice(&cursor);
2261        assert!(out.contains("The source moved"));
2262        assert!(out.contains("**Modified:**"));
2263        assert!(out.contains("- `other`: unscoped facet"));
2264        assert!(out.contains("### Recording your dispositions"));
2265        assert!(out.contains(
2266            r#"memstead projection advance d/p --dispositions '{"<artifact>": "<disposition>", ...}'"#
2267        ));
2268    }
2269
2270    /// The one-shot lens block: destination-set table, routing rule (when set),
2271    /// idempotency, report template, and archive note (when set).
2272    #[test]
2273    fn renders_one_shot_lens_block() {
2274        let mut r = resolved("os", Some("plan source"), vec![]);
2275        r.rules = Some(serde_json::json!({ "routing": "route each entity to its spec" }));
2276        r.post_actions = Some(serde_json::json!({ "archive_source": true }));
2277
2278        let out = render_one_shot_lens(&r, Some("planning@0.1.0"), Some("the plan graph"));
2279        assert!(out.starts_with("## Mode: one-shot — lens routing\n\n"));
2280        assert!(out.contains(
2281            "### Destination set\n\n| Mem | Schema | Purpose |\n|-------|--------|---------|\n| os | planning@0.1.0 | the plan graph |\n"
2282        ));
2283        assert!(out.contains("### Routing rule\n\n```\nroute each entity to its spec\n```\n"));
2284        assert!(out.contains("### Idempotency"));
2285        assert!(out.contains("### Report: os"));
2286        assert!(out.contains("### Archive after run"));
2287        assert!(out.ends_with("is set on this ingest.\n\n"));
2288
2289        // No routing / no archive → those sections are omitted; a bare schema
2290        // and default purpose fall back.
2291        let bare = resolved("os", None, vec![]);
2292        let out2 = render_one_shot_lens(&bare, None, None);
2293        assert!(out2.contains("| os | (none) | (no purpose declared) |"));
2294        assert!(!out2.contains("### Routing rule"));
2295        assert!(!out2.contains("### Archive after run"));
2296        assert!(out2.contains("### End-of-run report"));
2297    }
2298
2299    /// The one-shot brief assembles situation (one-shot mode) + intent +
2300    /// goal/avoid + operative-data + the lens block; no process mem, no slice.
2301    #[test]
2302    fn assembles_one_shot_brief() {
2303        let mut r = resolved(
2304            "os",
2305            Some("src"),
2306            vec![primary(MediumType::Filesystem, vec![])],
2307        );
2308        r.mode = BuildMode::OneShot;
2309        let g = guidance(Some("goal"), None);
2310        let skipped = ProcessMemInfo {
2311            present: false,
2312            skipped: true,
2313            notice: None,
2314            leaf_name: "os".to_string(),
2315            mem_label: "ingest/os".to_string(),
2316        };
2317        let brief =
2318            assemble_one_shot_brief(&r, &g, &skipped, Some("s@1"), None, &[], Some("purpose"));
2319        assert!(brief.contains("(one-shot mode)"));
2320        assert!(brief.contains("No process mem is paired with this ingest (mode=one-shot;"));
2321        assert!(brief.contains("## Mode: one-shot — lens routing"));
2322        assert!(
2323            brief.contains("## Provenance — anchor your writes"),
2324            "one-shot carries the anchor instruction"
2325        );
2326        assert!(
2327            !brief.contains("## Source changes"),
2328            "one-shot has no changed-slice"
2329        );
2330    }
2331
2332    /// Beyond SLICE_CAP entries an overflow line stands in; the degraded flag
2333    /// adds the coarse-targeting note. Also exercises shell-quoting a JSON
2334    /// digest token (embedded quotes).
2335    #[test]
2336    fn changed_slice_caps_and_degrades_and_quotes() {
2337        let many: Vec<String> = (0..SLICE_CAP + 3).map(|i| format!("f{i}.rs")).collect();
2338        let cursor = SourceCursor {
2339            union: Slice {
2340                deleted: vec![],
2341                modified: vec![],
2342                added: many,
2343            },
2344            write_commands: vec![cmd("ing/f", r#"{"v":1,"aggregate":"x"}"#)],
2345            reseed: vec![],
2346            no_signal: vec![],
2347            any_changes: true,
2348            degraded: true,
2349            dead_denies: vec![],
2350            dest_mem: "d".to_string(),
2351            binding_id: "d/p".to_string(),
2352            delivery: vec![],
2353        };
2354        let out = render_changed_slice(&cursor);
2355        assert!(out.contains(&format!("- …and {} more added", 3)));
2356        assert!(out.contains("Precise change history for one or more facets was unavailable"));
2357        // The brief renders the `projection advance` line (the token is no longer
2358        // an operator command — the engine computes and records it, D4/D7).
2359        assert!(out.contains(
2360            r#"memstead projection advance d/p --dispositions '{"<artifact>": "<disposition>", ...}'"#
2361        ));
2362    }
2363
2364    // ---- verify + sync briefs (group C) ----------------------------------
2365
2366    fn finding(class: FindingClass, target: FindingTarget, detail: &str) -> Finding {
2367        Finding {
2368            key: crate::ingest::findings::FindingKey {
2369                binding_hash: "h".to_string(),
2370                source_head: "s".to_string(),
2371            },
2372            facet: "src".to_string(),
2373            target,
2374            class,
2375            detail: detail.to_string(),
2376            created_at: "1".to_string(),
2377        }
2378    }
2379
2380    fn anchor_target(entity: &str, artifact: &str) -> FindingTarget {
2381        FindingTarget::Anchor {
2382            entity: entity.to_string(),
2383            artifact: artifact.to_string(),
2384        }
2385    }
2386
2387    fn artifact_target(artifact: &str) -> FindingTarget {
2388        FindingTarget::Artifact {
2389            artifact: artifact.to_string(),
2390        }
2391    }
2392
2393    fn empty_cursor() -> SourceCursor {
2394        SourceCursor {
2395            union: slice(&[], &[], &[]),
2396            write_commands: vec![],
2397            reseed: vec![],
2398            no_signal: vec![],
2399            any_changes: false,
2400            degraded: false,
2401            dead_denies: vec![],
2402            dest_mem: "engine".to_string(),
2403            binding_id: "engine/graph".to_string(),
2404            delivery: vec![],
2405        }
2406    }
2407
2408    /// C1 — the verify brief measures + adjudicates, and carries NO
2409    /// destination-mutation instruction of any kind. It names the sync brief as
2410    /// the repair home and prints its explicit no-mutation refusal.
2411    #[test]
2412    fn verify_brief_measures_and_refuses_mutation() {
2413        let r = resolved("engine", None, vec![]);
2414        let out = render_verify_brief(&r, 3);
2415        // Measurement + capped adjudication instructions.
2416        assert!(out.starts_with("## Verify — measure fidelity, do not mutate"));
2417        assert!(out.contains("3 finding(s) are queued for adjudication"));
2418        assert!(out.contains("per-run adjudication cap"));
2419        assert!(out.contains("this is a measurement, not a repair"));
2420        // C1 REFUSAL: structurally no destination-mutation instruction. The
2421        // brief never tells the agent to write into the mem — it says the
2422        // opposite, and hands repairs to the sync brief.
2423        //
2424        // Reworded 2026-08-20. This assertion used to pin "Verify writes
2425        // **nothing** into the destination mem", which was false at the time: a
2426        // completed run recorded its findings store, backfilled observed anchor
2427        // hashes and wrote a `#verified` baseline. The refusal this test exists
2428        // to protect is about ENTITY CONTENT — that is what an agent reading the
2429        // brief must not touch — so the claim was narrowed to what is true
2430        // rather than deleted, and the bookkeeping asserted alongside it so the
2431        // correction cannot silently regress.
2432        //
2433        // Amended 2026-09-03 (C6): the `#verified` baseline now rides
2434        // `--advance`, so a bare run leaves the mem's CONFIG alone again. The
2435        // findings store and the anchor-hash backfill still happen on every
2436        // run, so the narrowed entity-content claim stands unchanged — it was
2437        // the honest one either way — and the brief must now also name the
2438        // flag, so a reader is never told a bare verify records a baseline it
2439        // does not.
2440        assert!(out.contains("Verify writes **no entity content**"));
2441        assert!(out.contains("`#verified` baseline"));
2442        assert!(out.contains("`--advance`"));
2443        assert!(out.contains("memstead projection brief --sync"));
2444        // No create/update/relate/delete *instruction* — the only occurrences of
2445        // those verbs are in the negated "do not …" refusal line.
2446        assert!(out.contains("do not create or delete an entity"));
2447        assert!(!out.contains("via `memstead_create`"));
2448        assert!(!out.contains("Run `memstead_update`"));
2449
2450        // Backlog 0 → the spot-check phrasing, still no mutation instruction.
2451        let zero = render_verify_brief(&r, 0);
2452        assert!(zero.contains("No findings are queued for adjudication"));
2453        assert!(zero.contains("record any drift you observe as a finding"));
2454        assert!(zero.contains("Verify writes **no entity content**"));
2455    }
2456
2457    /// C2 — the sync brief carries BOTH inputs in ONE render: the cursor slice
2458    /// (the changed artifacts) AND the open findings (`current(key)`), plus the
2459    /// commits-nothing posture.
2460    #[test]
2461    fn sync_brief_carries_both_cursor_and_findings() {
2462        let r = resolved("engine", None, vec![]);
2463        let cursor = SourceCursor {
2464            union: slice(&["gone.rs"], &["moved.rs"], &[]),
2465            write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
2466            reseed: vec![],
2467            no_signal: vec![],
2468            any_changes: true,
2469            degraded: false,
2470            dead_denies: vec![],
2471            dest_mem: "engine".to_string(),
2472            binding_id: "engine/graph".to_string(),
2473            delivery: vec![],
2474        };
2475        let findings = vec![
2476            finding(
2477                FindingClass::Drifted,
2478                anchor_target("engine--e", "src/moved.rs"),
2479                "prepared-content hash drifted",
2480            ),
2481            finding(
2482                FindingClass::Uncovered,
2483                artifact_target("src/new.rs"),
2484                "in scope, no anchor",
2485            ),
2486        ];
2487        let out = render_sync_brief(
2488            &r,
2489            &cursor,
2490            &findings,
2491            &[],
2492            false,
2493            &crate::ingest::advance::ExclusionLedger::default(),
2494        );
2495        // Both inputs present in one brief (C2).
2496        assert!(out.contains("## Source changes since the last sync"));
2497        assert!(out.contains("`moved.rs`"));
2498        assert!(out.contains("## Open findings to repair"));
2499        assert!(out.contains("`engine--e` → `src/moved.rs`"));
2500        assert!(out.contains("`src/new.rs`"));
2501        // Sole-writer + commits-nothing posture (C3).
2502        assert!(out.contains("sole maintenance writer"));
2503        assert!(out.contains("commits each one **per-mutation**"));
2504        assert!(out.contains("Sync commits nothing."));
2505    }
2506
2507    /// C3 — the sync brief carries the whole absorbed reconcile judgment: the
2508    /// five conservatism rules, edge-removal conservatism, and
2509    /// rationale-not-changelog. Each rule is quoted verbatim so absorption is
2510    /// verifiable against the C4 diff artifact.
2511    #[test]
2512    fn sync_brief_absorbs_reconcile_conservatism() {
2513        let r = resolved("engine", None, vec![]);
2514        let findings = vec![finding(
2515            FindingClass::Uncovered,
2516            artifact_target("src/x.rs"),
2517            "d",
2518        )];
2519        let out = render_sync_brief(
2520            &r,
2521            &empty_cursor(),
2522            &findings,
2523            &[],
2524            false,
2525            &crate::ingest::advance::ExclusionLedger::default(),
2526        );
2527        // Five conservatism rules.
2528        assert!(out.contains("Unsure whether an entity is affected — skip it."));
2529        assert!(out.contains(
2530            "Do not create a new entity unless the change clearly introduces a new concept"
2531        ));
2532        assert!(
2533            out.contains("Do not delete an entity unless the change removes the concept entirely.")
2534        );
2535        assert!(out.contains("Never rewrite a section that has not changed"));
2536        assert!(out.contains(
2537            "No speculative edges — add only relationships the diff literally introduces"
2538        ));
2539        // Edge-removal conservatism — flags, never auto-removes.
2540        assert!(out.contains("A dropped dependency FLAGS, it does not auto-remove."));
2541        assert!(out.contains("Edge removal is out of scope for sync."));
2542        // Rationale-not-changelog.
2543        assert!(out.contains("Rationale is reasoning, not a changelog."));
2544        assert!(out.contains("`[commit <hash>]` log-style entries"));
2545    }
2546
2547    /// C3 — the first-sync/adopt framing (E1's brief half): a mem predating its
2548    /// binding is onboarding, expected-0%, with the backfill path — never a
2549    /// failure. The changed-slice reseed carries the per-facet first-sync note.
2550    #[test]
2551    fn sync_brief_renders_adopt_framing() {
2552        let mut r = resolved("engine", None, vec![]);
2553        // In a real ResolvedIngest, `name` is the canonical binding id
2554        // `<mem>/<stem>` while `destination_mem` is the mem — the header uses the
2555        // mem, the backfill command uses the binding id.
2556        r.name = "engine/graph".to_string();
2557        let out = render_sync_brief(
2558            &r,
2559            &empty_cursor(),
2560            &[],
2561            &[],
2562            true,
2563            &crate::ingest::advance::ExclusionLedger::default(),
2564        );
2565        assert!(out.contains("## First sync — adopting `engine`"));
2566        assert!(out.contains("0% anchored is expected — this is onboarding, not a failure."));
2567        assert!(out.contains("do **not** replay the whole history"));
2568        assert!(out.contains("**Backfill path:**"));
2569        assert!(out.contains("memstead projection verify engine/graph"));
2570    }
2571
2572    /// The reseed (first-sync, no cursor) framing lives in the embedded
2573    /// changed-slice preface — the sync brief inherits it for free.
2574    #[test]
2575    fn sync_brief_inherits_first_sync_reseed_framing() {
2576        let r = resolved("engine", None, vec![]);
2577        let mut cursor = empty_cursor();
2578        cursor.reseed = vec![cmd("engine/graph/src#synced", "TOK")];
2579        let out = render_sync_brief(
2580            &r,
2581            &cursor,
2582            &[],
2583            &[],
2584            false,
2585            &crate::ingest::advance::ExclusionLedger::default(),
2586        );
2587        assert!(out.contains("No usable sync baseline exists for"));
2588        assert!(out.contains("Treating the current source state as the baseline"));
2589    }
2590
2591    /// A no-work sync pass (nothing moved, no findings, not adopt) renders a
2592    /// compact "nothing to sync" note and no repair machinery — a valid outcome.
2593    #[test]
2594    fn sync_brief_nothing_to_sync() {
2595        let r = resolved("engine", None, vec![]);
2596        let out = render_sync_brief(
2597            &r,
2598            &empty_cursor(),
2599            &[],
2600            &[],
2601            false,
2602            &crate::ingest::advance::ExclusionLedger::default(),
2603        );
2604        assert!(out.contains("## Nothing to sync"));
2605        assert!(!out.contains("## How to repair"));
2606        assert!(!out.contains("## Open findings"));
2607    }
2608
2609    /// C2 REFUSAL complement — the sync brief is the ONLY render carrying repair
2610    /// instructions; the verify brief carries none. The verify brief has no
2611    /// "## How to repair" / "## Open findings to repair" block; the sync brief
2612    /// has both.
2613    #[test]
2614    fn only_sync_brief_carries_repair_instructions() {
2615        let r = resolved("engine", None, vec![]);
2616        let findings = vec![finding(
2617            FindingClass::Drifted,
2618            anchor_target("engine--e", "src/a.rs"),
2619            "d",
2620        )];
2621        let verify = render_verify_brief(&r, 1);
2622        let sync = render_sync_brief(
2623            &r,
2624            &empty_cursor(),
2625            &findings,
2626            &[],
2627            false,
2628            &crate::ingest::advance::ExclusionLedger::default(),
2629        );
2630        // Verify: no repair section, no repair verbs as instructions.
2631        assert!(!verify.contains("## How to repair"));
2632        assert!(!verify.contains("Update the affected section"));
2633        // Sync: both repair sections present.
2634        assert!(sync.contains("## How to repair — be conservative"));
2635        assert!(sync.contains("## Open findings to repair"));
2636        assert!(sync.contains("Update the affected section to match"));
2637    }
2638
2639    /// Criterion — a changed slice renders the bounded **stale-claim search**
2640    /// step: extract the changed facts, search the destination mem for claims
2641    /// about them, judge only entities whose claims mention a changed fact.
2642    #[test]
2643    fn sync_brief_changed_slice_renders_stale_claim_search() {
2644        let r = resolved("engine", None, vec![]);
2645        let cursor = SourceCursor {
2646            union: slice(&[], &["moved.rs"], &[]),
2647            write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
2648            reseed: vec![],
2649            no_signal: vec![],
2650            any_changes: true,
2651            degraded: false,
2652            dead_denies: vec![],
2653            dest_mem: "engine".to_string(),
2654            binding_id: "engine/graph".to_string(),
2655            delivery: vec![],
2656        };
2657        let out = render_sync_brief(
2658            &r,
2659            &cursor,
2660            &[],
2661            &[],
2662            false,
2663            &crate::ingest::advance::ExclusionLedger::default(),
2664        );
2665        assert!(out.contains("## Stale claims beyond the slice — search, then judge"));
2666        // The search is bound to the changed facts and the destination mem.
2667        assert!(out.contains("Extract the **changed facts** from the changed artifacts above"));
2668        assert!(out.contains("search the destination mem `engine`"));
2669        assert!(out.contains("`memstead_search`"));
2670        assert!(out.contains("judge **only** the entities whose claims actually mention"));
2671        // Bounded shape, spelled out: not a live-verify, not a rewrite license,
2672        // and an empty fact set (cosmetic change) instructs nothing.
2673        assert!(out.contains("not a live-verify of every entity"));
2674        assert!(out.contains("not a rewrite license"));
2675        assert!(out.contains("the fact set is empty and this step ends with no"));
2676        // REFUSAL complement: the never-rewrite-unchanged-sections rule still
2677        // rides the same brief — idempotence stays protected.
2678        assert!(out.contains("Never rewrite a section that has not changed"));
2679    }
2680
2681    /// REFUSAL — the stale-claim search is absent from every pass whose cursor
2682    /// carries no changed artifacts: findings-only, reseed-only (first sync),
2683    /// and nothing-to-sync briefs instruct no fact search and no mem sweep.
2684    #[test]
2685    fn sync_brief_without_changes_renders_no_stale_claim_search() {
2686        let r = resolved("engine", None, vec![]);
2687        let heading = "## Stale claims beyond the slice";
2688
2689        // Findings-only pass (source unmoved).
2690        let findings = vec![finding(
2691            FindingClass::Uncovered,
2692            artifact_target("src/x.rs"),
2693            "d",
2694        )];
2695        let out = render_sync_brief(
2696            &r,
2697            &empty_cursor(),
2698            &findings,
2699            &[],
2700            false,
2701            &crate::ingest::advance::ExclusionLedger::default(),
2702        );
2703        assert!(!out.contains(heading), "findings-only pass must not search");
2704
2705        // Reseed-only pass (first sync, no diffable slice).
2706        let mut reseed_cursor = empty_cursor();
2707        reseed_cursor.reseed = vec![cmd("engine/graph/src#synced", "TOK")];
2708        let out = render_sync_brief(
2709            &r,
2710            &reseed_cursor,
2711            &[],
2712            &[],
2713            false,
2714            &crate::ingest::advance::ExclusionLedger::default(),
2715        );
2716        assert!(!out.contains(heading), "reseed-only pass must not search");
2717
2718        // Nothing-to-sync pass.
2719        let out = render_sync_brief(
2720            &r,
2721            &empty_cursor(),
2722            &[],
2723            &[],
2724            false,
2725            &crate::ingest::advance::ExclusionLedger::default(),
2726        );
2727        assert!(!out.contains(heading));
2728    }
2729
2730    /// A large findings group caps at FINDINGS_CAP with an overflow line —
2731    /// mirroring the changed-slice cap, so no facet renders unbounded.
2732    #[test]
2733    fn sync_brief_caps_large_findings_group() {
2734        let r = resolved("engine", None, vec![]);
2735        let findings: Vec<Finding> = (0..FINDINGS_CAP + 4)
2736            .map(|i| {
2737                finding(
2738                    FindingClass::Uncovered,
2739                    artifact_target(&format!("src/f{i}.rs")),
2740                    "d",
2741                )
2742            })
2743            .collect();
2744        let out = render_sync_brief(
2745            &r,
2746            &empty_cursor(),
2747            &findings,
2748            &[],
2749            false,
2750            &crate::ingest::advance::ExclusionLedger::default(),
2751        );
2752        assert!(out.contains("- …and 4 more"));
2753        // The last few beyond the cap are not rendered inline.
2754        assert!(!out.contains(&format!("src/f{}.rs", FINDINGS_CAP + 3)));
2755    }
2756
2757    /// Criterion 8 (loop economics) — the default loop path's sync brief is
2758    /// **locked block-by-block** for a representative changed-slice pass: the
2759    /// heading sequence below is the whole brief, in this order, and nothing
2760    /// else. The only blocks this plan added to the loop path are the
2761    /// stale-claim search (criterion 1) and the head-durable findings
2762    /// presentation (criterion 2) — both locked here in place. The inventory
2763    /// operation (`projection verify --full` + the `/sync --inventory` repair
2764    /// loop) added NO block and NO line to this render, so a new block
2765    /// appearing (or one moving) fails this test and must be a deliberate
2766    /// loop-economics decision.
2767    #[test]
2768    fn sync_brief_block_sequence_locked_for_changed_slice() {
2769        let r = resolved("engine", None, vec![]);
2770        let cursor = SourceCursor {
2771            union: slice(&["gone.rs"], &["moved.rs"], &["new.rs"]),
2772            write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
2773            reseed: vec![],
2774            no_signal: vec![],
2775            any_changes: true,
2776            degraded: false,
2777            dead_denies: vec![],
2778            dest_mem: "engine".to_string(),
2779            binding_id: "engine/graph".to_string(),
2780            delivery: vec![],
2781        };
2782        let findings = vec![
2783            finding(
2784                FindingClass::Drifted,
2785                anchor_target("engine--e", "src/moved.rs"),
2786                "prepared-content hash drifted",
2787            ),
2788            finding(
2789                FindingClass::Uncovered,
2790                artifact_target("src/new.rs"),
2791                "in scope, no anchor",
2792            ),
2793        ];
2794        let out = render_sync_brief(
2795            &r,
2796            &cursor,
2797            &findings,
2798            &[],
2799            false,
2800            &crate::ingest::advance::ExclusionLedger::default(),
2801        );
2802        let headings: Vec<&str> = out
2803            .lines()
2804            .filter(|l| l.starts_with("## ") || l.starts_with("### "))
2805            .collect();
2806        assert_eq!(
2807            headings,
2808            vec![
2809                "## Sync — repair the graph to match the source",
2810                "## Source changes since the last sync",
2811                "### Recording your dispositions (do this LAST)",
2812                "## Stale claims beyond the slice — search, then judge",
2813                "## Open findings to repair",
2814                "### Drifted — the anchored content changed",
2815                "### Uncovered — a source artifact with no entity",
2816                // Deliberate addition (anchor-source plan): the sync
2817                // brief now carries the provenance instruction so
2818                // repair writes are anchored — and name their source.
2819                "## Provenance — anchor your writes",
2820                "## How to repair — be conservative",
2821            ],
2822            "the loop-path sync brief carries exactly these blocks, in this order"
2823        );
2824        // The brief closes on the conservatism block's final rule — nothing
2825        // (inventory or otherwise) rides after it.
2826        assert!(out.ends_with("`[commit <hash>]` log-style entries.\n\n"));
2827    }
2828
2829    /// Criterion 8 REFUSAL — no brief on the default (non-inventory) path
2830    /// carries any inventory machinery: not the build briefs (discovery /
2831    /// one-shot), not the verify brief, not the sync brief in any of its
2832    /// shapes (changed slice, findings-only, nothing-to-sync, adopt). The
2833    /// inventory operation lives entirely in `projection verify --full` and
2834    /// the `/sync --inventory` skill routing; the engine-side byte-compat of
2835    /// the no-flag sampled verify is asserted in
2836    /// `findings::tests::full_verify_uncaps_adjudication_and_walks_whole_source`
2837    /// (extended there, not duplicated here). The minute-loop pays nothing
2838    /// for inventory.
2839    #[test]
2840    fn no_default_path_brief_carries_inventory_machinery() {
2841        // Terms that exist only on the inventory surface (flag, skill mode,
2842        // report framing, termination rule). Matched case-insensitively.
2843        let inventory_terms = [
2844            "--full",
2845            "inventory",
2846            "full measurement",
2847            "did not converge",
2848            "quiescence",
2849        ];
2850        let assert_clean = |label: &str, text: &str| {
2851            let lower = text.to_lowercase();
2852            for term in inventory_terms {
2853                assert!(
2854                    !lower.contains(term),
2855                    "{label} must carry no inventory machinery (found {term:?})"
2856                );
2857            }
2858        };
2859
2860        let r = resolved("engine", None, vec![]);
2861        let g = guidance(Some("build coverage"), None);
2862        let pm = process_present("engine");
2863
2864        // Build briefs — with and without a changed-slice preface.
2865        let changed_cursor = SourceCursor {
2866            union: slice(&[], &["moved.rs"], &[]),
2867            write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
2868            reseed: vec![],
2869            no_signal: vec![],
2870            any_changes: true,
2871            degraded: false,
2872            dead_denies: vec![],
2873            dest_mem: "engine".to_string(),
2874            binding_id: "engine/graph".to_string(),
2875            delivery: vec![],
2876        };
2877        let preface = render_changed_slice(&changed_cursor);
2878        assert_clean(
2879            "discovery build brief (plain roam)",
2880            &assemble_discovery_brief(&r, &g, &pm, Some("s@1"), None, &[], ""),
2881        );
2882        assert_clean(
2883            "discovery build brief (changed slice)",
2884            &assemble_discovery_brief(&r, &g, &pm, Some("s@1"), None, &[], &preface),
2885        );
2886        assert_clean(
2887            "one-shot build brief",
2888            &assemble_one_shot_brief(&r, &g, &pm, Some("s@1"), None, &[], Some("purpose")),
2889        );
2890
2891        // Verify brief — with and without an adjudication backlog.
2892        assert_clean("verify brief (backlog)", &render_verify_brief(&r, 3));
2893        assert_clean("verify brief (no backlog)", &render_verify_brief(&r, 0));
2894
2895        // Sync brief — every shape the loop renders.
2896        let findings = vec![finding(
2897            FindingClass::Drifted,
2898            anchor_target("engine--e", "src/moved.rs"),
2899            "d",
2900        )];
2901        assert_clean(
2902            "sync brief (changed slice + findings)",
2903            &render_sync_brief(
2904                &r,
2905                &changed_cursor,
2906                &findings,
2907                &[],
2908                false,
2909                &crate::ingest::advance::ExclusionLedger::default(),
2910            ),
2911        );
2912        assert_clean(
2913            "sync brief (findings-only)",
2914            &render_sync_brief(
2915                &r,
2916                &empty_cursor(),
2917                &findings,
2918                &[],
2919                false,
2920                &crate::ingest::advance::ExclusionLedger::default(),
2921            ),
2922        );
2923        assert_clean(
2924            "sync brief (nothing to sync)",
2925            &render_sync_brief(
2926                &r,
2927                &empty_cursor(),
2928                &[],
2929                &[],
2930                false,
2931                &crate::ingest::advance::ExclusionLedger::default(),
2932            ),
2933        );
2934        assert_clean(
2935            "sync brief (adopt)",
2936            &render_sync_brief(
2937                &r,
2938                &empty_cursor(),
2939                &[],
2940                &[],
2941                true,
2942                &crate::ingest::advance::ExclusionLedger::default(),
2943            ),
2944        );
2945    }
2946}