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                    if is_graph {
268                        lines.push(format!(
269                            "  - Read the source baseline with `memstead_search mem={}` \
270                             (add `entity_type=` to match a `type:` selector). The changed \
271                             slice below is a delta against the last pass — it is not the \
272                             whole source, and an entity absent from it may still be \
273                             unprojected.",
274                            p.pointer
275                        ));
276                    }
277                }
278                ResolvedSource::Reference { mem } => {
279                    lines.push(format!("- **graph** (reference) — mem: {mem}"));
280                    reference_mems.push(mem.clone());
281                }
282            }
283        }
284        lines.push(String::new());
285        if !reference_mems.is_empty() {
286            lines.push(
287                "Sources tagged `(reference)` are read-only context for cross-mem edges — search \
288                 them, never write into them. Only `(primary)` sources are ingested into the \
289                 destination."
290                    .to_string(),
291            );
292            lines.push(String::new());
293            let mem_list = reference_mems
294                .iter()
295                .map(|v| format!("`memstead_search mem={v}`"))
296                .collect::<Vec<_>>()
297                .join(", ");
298            lines.push(format!(
299                "**Cross-mem references:** consult {mem_list} before authoring cross-mem edges. \
300                 The target entity must exist — a wiki-link or relationship to a missing target \
301                 either auto-stubs (silent) or fails authorization (`CROSS_MEM_RELATION`)."
302            ));
303            lines.push(String::new());
304        }
305    }
306
307    // Destination — four-primitive projections carry exactly one, no role.
308    lines.push("### Destination".to_string());
309    lines.push(String::new());
310    let schema_bit = destination_schema
311        .map(|s| format!(" — schema: `{s}`"))
312        .unwrap_or_default();
313    lines.push(format!("- **{}**{schema_bit}", resolved.destination_mem));
314    // No pinned schema means the engine could not resolve the destination as
315    // a mem of this workspace — a binding scaffolded before its mem exists,
316    // which `projection init` deliberately allows. Say so here rather than
317    // describing a destination that is not there: the brief's mandate is to
318    // mutate this mem, and an agent that discovers its absence on the first
319    // create has been told something untrue by the surface that sent it.
320    // The caller supplies this: whether the destination resolves, and what
321    // to do about it, both depend on the workspace shape — which this
322    // renderer cannot see. A remedy naming a command that refuses in the
323    // reader's own workspace is the defect this note exists to prevent.
324    if let Some(note) = destination_note {
325        lines.push(format!("  - {note}"));
326    }
327    lines.push(String::new());
328
329    // Paired process mem
330    if process_mem.present {
331        lines.push("### Paired process mem".to_string());
332        lines.push(String::new());
333        lines.push(format!(
334            "- **{}** — schema: `{PROCESS_MEM_SCHEMA}`. Inspect via `memstead_overview` / \
335             `memstead_search mem={}`.",
336            process_mem.mem_label, process_mem.leaf_name
337        ));
338        lines.push(String::new());
339    }
340
341    format!("{}\n", lines.join("\n"))
342}
343
344/// One baseline token a facet's cursor advances to after a full pass — the
345/// `(sync_state key, medium-typed token)` pair the engine records via the
346/// `set_mem_sync_state` writer. Produced by the cursor; the brief no longer
347/// renders it as an operator command (the agent runs `projection advance`,
348/// which computes and records the token engine-side — D4/D7).
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct SyncCommand {
351    /// The sync-state key, `"<binding-id>/<facet>#synced"` (D4).
352    pub key: String,
353    /// The opaque new-baseline token.
354    pub token: String,
355}
356
357/// A source whose change detection produced **no usable signal** this pass,
358/// with the classified [`NoSignalReason`]. Rendered as a distinct per-source
359/// note in the changed-slice preface, so the agent can tell a *blind* source
360/// (no baseline comparison happened) from a *genuinely-unchanged* one (checked,
361/// did not move — which stays silent).
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct NoSignalNote {
364    /// The source's label — the facet ref (primary) or mem id (reference), the
365    /// same token the `<ingest>/<label>` sync-state key is built from.
366    pub source: String,
367    /// Why detection produced no signal.
368    pub reason: NoSignalReason,
369    /// The source's medium, when it is a primary source. Carried so the
370    /// remedy the note prints is one this medium actually accepts — a
371    /// medium-agnostic remedy told a graph source's agent to write `**/*`,
372    /// which the engine then refuses as not an entity selector. `None` for a
373    /// reference mem, which has no facet scope to remedy.
374    pub medium_type: Option<MediumType>,
375}
376
377/// The combined source-cursor across a projection's source facets — the
378/// engine-side of the plugin's `cursor` object that `changedSliceBlock`
379/// consumes. Assembled by [`super::cursor::compute_source_cursor`] from the
380/// per-facet [`super::slice::SliceOutcome`]s.
381#[derive(Debug, Clone, PartialEq, Eq)]
382pub struct SourceCursor {
383    /// The combined changed slice across all source facets.
384    pub union: Slice,
385    /// New-baseline commands for facets that changed.
386    pub write_commands: Vec<SyncCommand>,
387    /// New-baseline commands for facets seen for the first time (reseed).
388    pub reseed: Vec<SyncCommand>,
389    /// Per-source no-signal notes — sources whose detection could not produce a
390    /// slice this pass (unscoped facet, `signal:none`, git failure, missing
391    /// graph snapshot). Rendered distinctly from changed/reseed; a
392    /// genuinely-unchanged source contributes nothing here, so an all-unchanged
393    /// brief still renders no preface (byte-identical to a plain roam).
394    pub no_signal: Vec<NoSignalNote>,
395    /// Whether any facet reported changes (drives the "source moved" copy).
396    pub any_changes: bool,
397    /// Whether any facet's slice was degraded (mtime memo miss → full scan).
398    pub degraded: bool,
399    /// Ingest `deny_paths` entries that matched **no file** anywhere the agent
400    /// can reach (the project tree). A zero-selecting deny is surfaced as a
401    /// rendered warning rather than silently no-op'ing — it catches typos and
402    /// un-migrated legacy bare names (which, as globs, match nothing). Never a
403    /// hard error: the ingest still runs, the entry just does nothing. The
404    /// scaffold's own default hygiene entries are exempt at collection
405    /// (`cursor::dead_deny_entries`) — the engine never calls its own output
406    /// a typo.
407    pub dead_denies: Vec<String>,
408    /// The destination mem whose `sync_state` the baseline tokens live on.
409    pub dest_mem: String,
410    /// The canonical binding id `<mem>/<stem>` (D3) — rendered into the
411    /// `memstead projection advance <binding-id> …` line the changed-slice
412    /// preface now emits instead of a raw `mem set-sync-state` command (D4/D7).
413    pub binding_id: String,
414}
415
416/// Single-quote a value for the emitted shell command, escaping embedded
417/// single quotes. The digest token is JSON (contains `"` and `:`), so it
418/// must be quoted to survive the shell. Mirrors the plugin's `shellQuote`.
419fn shell_quote(s: &str) -> String {
420    format!("'{}'", s.replace('\'', "'\\''"))
421}
422
423/// Render one changed-slice class (Deleted / Modified / Added), capped at
424/// [`SLICE_CAP`] with a `…and N more` overflow line.
425fn render_slice_class(lines: &mut Vec<String>, label: &str, paths: &[String]) {
426    if paths.is_empty() {
427        return;
428    }
429    let shown = paths.len().min(SLICE_CAP);
430    lines.push(format!("**{label}:**"));
431    for path in &paths[..shown] {
432        lines.push(format!("- `{path}`"));
433    }
434    if paths.len() > shown {
435        lines.push(format!(
436            "- …and {} more {}",
437            paths.len() - shown,
438            label.to_lowercase()
439        ));
440    }
441    lines.push(String::new());
442}
443
444/// The one-line explanation the brief prints for a [`NoSignalReason`] — each
445/// reason renders as distinct text, so the agent can tell the no-signal
446/// conditions apart (and all apart from a genuinely-unchanged source, which
447/// renders nothing at all).
448fn no_signal_reason_text(reason: NoSignalReason, medium: Option<MediumType>) -> &'static str {
449    match reason {
450        // The remedy is medium-shaped, because scope is. Naming a path glob at
451        // a graph source sent the agent to write the one thing the engine
452        // refuses — the brief instructing a write it would then reject.
453        NoSignalReason::Unscoped => match medium {
454            Some(MediumType::Graph) => {
455                "unscoped facet (no allow patterns) — nothing is monitored; write `*` in the \
456                 facet scope to watch the whole mem, or `type:<entity_type>` / `id:<glob>` \
457                 to narrow it (a graph source selects entities, not paths)"
458            }
459            _ => {
460                "unscoped facet (no allow patterns) — nothing is monitored; write `**/*` in the \
461                 facet scope to watch the whole medium"
462            }
463        },
464        NoSignalReason::DetectionNone => {
465            "`signal:none` — change detection is disabled for this source (declared `none`)"
466        }
467        NoSignalReason::GitUnavailable => {
468            "git signal unavailable — no work tree, an unreadable `HEAD`, or an unknown baseline; \
469             a full re-roam is warranted this pass"
470        }
471        NoSignalReason::GraphSnapshotMissing => {
472            "graph snapshot missing — the source mem has no comparable baseline this pass"
473        }
474    }
475}
476
477/// Render the `## Source changes since the last sync` preface — the changed
478/// slice to steer at first, any no-signal sources, plus the `projection advance`
479/// "record your dispositions LAST" section. Extends the plugin's `changedSliceBlock`
480/// with the no-signal notes. Returns the empty string when nothing changed,
481/// nothing needs reseeding, and every source is genuinely unchanged (no
482/// no-signal notes) — making the brief byte-identical to a plain roam.
483pub fn render_changed_slice(cursor: &SourceCursor) -> String {
484    if !cursor.any_changes
485        && cursor.reseed.is_empty()
486        && cursor.no_signal.is_empty()
487        && cursor.dead_denies.is_empty()
488    {
489        return String::new();
490    }
491    let mut lines: Vec<String> = Vec::new();
492    lines.push("## Source changes since the last sync\n".to_string());
493
494    if cursor.any_changes {
495        lines.push(
496            "The source moved since this graph was last synced. Steer this pass at these changed \
497             artifacts **first** — they are where the graph is most likely now wrong.\n"
498                .to_string(),
499        );
500        // Deletions first — cheapest, highest-signal drift.
501        render_slice_class(&mut lines, "Deleted", &cursor.union.deleted);
502        render_slice_class(&mut lines, "Modified", &cursor.union.modified);
503        render_slice_class(&mut lines, "Added", &cursor.union.added);
504        if cursor.degraded {
505            lines.push(
506                "_(Precise change history for one or more facets was unavailable, so its full \
507                 current file set is listed above. Detection still fired from the durable baseline; \
508                 targeting is coarser this pass only.)_\n"
509                    .to_string(),
510            );
511        }
512    }
513
514    if !cursor.reseed.is_empty() {
515        let keys = cursor
516            .reseed
517            .iter()
518            .map(|r| format!("`{}`", r.key))
519            .collect::<Vec<_>>()
520            .join(", ");
521        let it = if cursor.reseed.len() == 1 {
522            "it"
523        } else {
524            "them"
525        };
526        lines.push(format!(
527            "No usable sync baseline exists for {keys} — none was recorded, or the recorded one \
528             is not a commit of the source's repo (foreign or garbage-collected). Treating the \
529             current source state as the baseline. No priority slice from {it} this pass; \
530             proceed as usual.\n"
531        ));
532    }
533
534    if !cursor.no_signal.is_empty() {
535        lines.push(
536            "Some sources produced **no change signal** this pass — detection could not compare \
537             them against a baseline, so they were not steered (roam them as usual). This is \
538             distinct from a source that was checked and had not moved:\n"
539                .to_string(),
540        );
541        for note in &cursor.no_signal {
542            lines.push(format!(
543                "- `{}`: {}",
544                note.source,
545                no_signal_reason_text(note.reason, note.medium_type)
546            ));
547        }
548        lines.push(String::new());
549    }
550
551    if !cursor.dead_denies.is_empty() {
552        lines.push(
553            "**Warning — some `deny_paths` entries match nothing.** The following ingest \
554             `deny_paths` selected **no file** in the project tree, so they exclude nothing from \
555             the slice and hide nothing from the ingest agent. This is usually a typo or a legacy \
556             bare name that never migrated to the workspace-relative glob dialect (e.g. `dev` → \
557             `dev/**`, `VISION.md` → `**/VISION.md`). Fix or remove them:\n"
558                .to_string(),
559        );
560        for entry in &cursor.dead_denies {
561            lines.push(format!("- `{entry}`"));
562        }
563        lines.push(String::new());
564    }
565
566    // Disposition-record instruction — the agent's FINAL step. The advance is
567    // resumable and non-stalling (D7): a partial pass is honored on disk, and a
568    // source that moves mid-pass re-presents (remaining + new) without losing
569    // recorded work. The agent runs `projection advance`, which computes and
570    // records the new baseline token engine-side — the brief no longer renders a
571    // raw `mem set-sync-state` command (D4). The block appears whenever there is
572    // a baseline to advance (a changed facet or a first-sync reseed).
573    let has_baseline_to_advance = !cursor.write_commands.is_empty() || !cursor.reseed.is_empty();
574    if has_baseline_to_advance {
575        lines.push("### Recording your dispositions (do this LAST)\n".to_string());
576        lines.push(
577            "Only after you have worked the changed artifacts above — and only for the artifacts \
578             you actually judged — record a disposition for each, so the next pass targets just \
579             what changes next. This advance is resumable and non-stalling: a partial pass is \
580             honored, and if the source moves mid-pass the remaining slice re-presents \
581             (remaining + new) without losing your recorded work.\n"
582                .to_string(),
583        );
584        lines.push(
585            "Anchored work disposes itself: at advance time, every listed artifact that an \
586             anchor in the destination mem references is marked `worked` automatically (an \
587             explicit disposition you pass wins over the auto-mark). Supply dispositions only \
588             for the residue — artifacts you skipped, judged out of intent, or worked without \
589             anchors. The gate accepts only artifact ids listed above — an unknown id refuses \
590             the whole call. When every artifact is disposed, the sync baseline advances \
591             automatically. Run:\n"
592                .to_string(),
593        );
594        lines.push("```sh".to_string());
595        lines.push(format!(
596            "memstead projection advance {} --dispositions {}",
597            cursor.binding_id,
598            shell_quote(r#"{"<artifact>": "<disposition>", ...}"#)
599        ));
600        lines.push("```".to_string());
601        lines.push(
602            "If you were interrupted before finishing, that is fine — your recorded dispositions \
603             persist, and the next run re-presents only what is left.\n"
604                .to_string(),
605        );
606    }
607
608    format!("{}\n", lines.join("\n"))
609}
610
611/// Assemble the discovery-mode brief — situation, about-the-source, goal/avoid,
612/// operative-data, and the changed-slice preface — concatenating the truthy
613/// blocks, matching the plugin's `parts.filter(Boolean).join('')`.
614/// `changed_slice_preface` is the rendered changed-slice block (empty when
615/// the source has not moved, making the brief byte-identical to a plain roam).
616/// Render the `## Provenance — anchor your writes` block — the build-brief
617/// instruction to attach `anchors[]` to every entity mutation. Rendered by the
618/// engine, never by skill prose: a binary old enough to reject the parameter
619/// never renders the instruction, so the brief cannot version-skew against its
620/// own mutation surface (the reason the plugin-side capability gate exists for
621/// skill-carried prose). The element shape is taught by the mutation tools'
622/// own descriptions; the brief carries only the job.
623pub fn render_anchor_instruction(resolved: &ResolvedIngest) -> String {
624    let mut block = "## Provenance — anchor your writes\n\n\
625     Attach an `anchors` list to every `memstead_create` / `memstead_update`, naming the \
626     source artifact(s) the entity is drawn from (the mutation tools document the element \
627     shape). Anchored writes are what verify measures coverage and drift against, and — on \
628     cursor-driven passes — what the advance gate auto-marks `worked`; an unanchored write \
629     leaves the fidelity report and the disposition window blind to your work.\n\n"
630        .to_string();
631    // Name the producing entry point: each anchor's `source` carries the
632    // binding source NAME it came from, so a discovery run is measurable
633    // per entry point (which entry carries, which delivers nothing).
634    let primary_names: Vec<&str> = resolved
635        .sources
636        .iter()
637        .filter_map(|s| match s {
638            crate::ingest::resolve::ResolvedSource::Primary(src) => Some(src.name.as_str()),
639            crate::ingest::resolve::ResolvedSource::Reference { .. } => None,
640        })
641        .collect();
642    if !primary_names.is_empty() {
643        block.push_str(&format!(
644            "Set each anchor's `source` to the binding source name you drew the artifact \
645             from — this binding declares: {}. The name selects the pointer the \
646             artifact path is joined onto, so the wrong one usually refuses \
647             `INVALID_ANCHOR` (the path resolves under no candidate join). A name \
648             outside the list is NOT itself refused when the path happens to \
649             resolve workspace-relative — that tolerance exists for anchors whose \
650             binding was later renamed — so getting it right is on you, not on a \
651             gate.\n\n",
652            primary_names
653                .iter()
654                .map(|n| format!("`{n}`"))
655                .collect::<Vec<_>>()
656                .join(", ")
657        ));
658    }
659    block
660}
661
662#[allow(clippy::too_many_arguments)]
663pub fn assemble_discovery_brief(
664    resolved: &ResolvedIngest,
665    guidance: &ResolvedGuidance,
666    process_mem: &ProcessMemInfo,
667    destination_schema: Option<&str>,
668    destination_note: Option<&str>,
669    absent_sources: &[String],
670    changed_slice_preface: &str,
671) -> String {
672    let parts = [
673        render_situation(resolved, process_mem),
674        render_intent(resolved),
675        render_goal_and_avoid(guidance),
676        render_operative_data(
677            resolved,
678            process_mem,
679            destination_schema,
680            destination_note,
681            absent_sources,
682        ),
683        render_anchor_instruction(resolved),
684        changed_slice_preface.to_string(),
685    ];
686    parts
687        .into_iter()
688        .filter(|p| !p.is_empty())
689        .collect::<Vec<_>>()
690        .join("")
691}
692
693/// Render the `## Mode: one-shot — lens routing` block — the destination-set
694/// table, optional routing rule, idempotency contract, end-of-run report
695/// template, and optional archive note. Byte-for-byte the plugin's
696/// `oneShotLensBlock`. `destination_schema` / `destination_purpose` describe
697/// the ingest's single destination (four-primitive projections have one).
698pub fn render_one_shot_lens(
699    resolved: &ResolvedIngest,
700    destination_schema: Option<&str>,
701    destination_purpose: Option<&str>,
702) -> String {
703    let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
704    let mut lines: Vec<String> = vec![
705        "## Mode: one-shot — lens routing".to_string(),
706        String::new(),
707        "A lens iterates entities once and writes per-destination, then exits. The agent decides \
708         per-entity which destinations to target (Routing rule). Re-runs use `memstead_update`; \
709         never duplicate."
710            .to_string(),
711        String::new(),
712    ];
713
714    lines.push("### Destination set".to_string());
715    lines.push(String::new());
716    lines.push("| Mem | Schema | Purpose |".to_string());
717    lines.push("|-------|--------|---------|".to_string());
718    let schema = destination_schema.unwrap_or("(none)");
719    let purpose = destination_purpose
720        .filter(|s| !s.is_empty())
721        .unwrap_or("(no purpose declared)");
722    lines.push(format!(
723        "| {} | {} | {} |",
724        cell(&resolved.destination_mem),
725        cell(schema),
726        cell(purpose)
727    ));
728    lines.push(String::new());
729
730    if let Some(routing) = resolved
731        .rules
732        .as_ref()
733        .and_then(|r| r.get("routing"))
734        .and_then(|v| v.as_str())
735        .map(str::trim)
736        .filter(|s| !s.is_empty())
737    {
738        lines.push("### Routing rule".to_string());
739        lines.push(String::new());
740        lines.push("```".to_string());
741        lines.push(routing.to_string());
742        lines.push("```".to_string());
743        lines.push(String::new());
744    }
745
746    lines.push("### Idempotency".to_string());
747    lines.push(String::new());
748    lines.push("- Search the destination before writing; route changes through `memstead_update` against the existing entity if present.".to_string());
749    lines.push("- Skip writes when the lifted content matches what is already there (record as `skipped: already-up-to-date`).".to_string());
750    lines.push(
751        "- Use `memstead_create` only when no entity for that concept exists yet.".to_string(),
752    );
753    lines.push(String::new());
754
755    lines.push("### End-of-run report".to_string());
756    lines.push(String::new());
757    lines.push("After every destination is processed, emit one block per destination on stdout, in Destination-set order:".to_string());
758    lines.push(String::new());
759    lines.push("```".to_string());
760    lines.push(format!("### Report: {}", resolved.name));
761    lines.push(String::new());
762    lines.push("Destination: <mem>".to_string());
763    lines.push("  created: <count>".to_string());
764    lines.push("  updated: <count>".to_string());
765    lines.push("  skipped: <count>".to_string());
766    lines.push("  failed:  <count>".to_string());
767    lines.push("  failures:".to_string());
768    lines.push("    - <entity-key>: <error verbatim>".to_string());
769    lines.push("  skipped-detail:".to_string());
770    lines.push("    - <entity-key>: <one-line reason>".to_string());
771    lines.push("```".to_string());
772    lines.push(String::new());
773    lines.push("Per-destination commits are independent — partial success is the accepted failure mode. No rollback.".to_string());
774    lines.push(String::new());
775
776    let archive = resolved
777        .post_actions
778        .as_ref()
779        .and_then(|p| p.get("archive_source"))
780        .and_then(serde_json::Value::as_bool)
781        .unwrap_or(false);
782    if archive {
783        lines.push("### Archive after run".to_string());
784        lines.push(String::new());
785        lines.push("After the report has been emitted, archive the source planning mem — `post_actions.archive_source` is set on this ingest.".to_string());
786        lines.push(String::new());
787    }
788
789    format!("{}\n", lines.join("\n"))
790}
791
792/// Assemble the one-shot brief — situation, about-the-source, goal/avoid,
793/// operative-data, and the lens-routing block. Mirrors the plugin's one-shot
794/// `parts`. A one-shot ingest has no paired process mem, so `process_mem`
795/// should carry `skipped = true`.
796#[allow(clippy::too_many_arguments)]
797pub fn assemble_one_shot_brief(
798    resolved: &ResolvedIngest,
799    guidance: &ResolvedGuidance,
800    process_mem: &ProcessMemInfo,
801    destination_schema: Option<&str>,
802    destination_note: Option<&str>,
803    absent_sources: &[String],
804    destination_purpose: Option<&str>,
805) -> String {
806    let parts = [
807        render_situation(resolved, process_mem),
808        render_intent(resolved),
809        render_goal_and_avoid(guidance),
810        render_operative_data(
811            resolved,
812            process_mem,
813            destination_schema,
814            destination_note,
815            absent_sources,
816        ),
817        render_anchor_instruction(resolved),
818        render_one_shot_lens(resolved, destination_schema, destination_purpose),
819    ];
820    parts
821        .into_iter()
822        .filter(|p| !p.is_empty())
823        .collect::<Vec<_>>()
824        .join("")
825}
826
827// ---------------------------------------------------------------------------
828// Verify + sync briefs (group C) — the measure/repair surface beside the build
829// briefs. Verify MEASURES (no destination mutation of any kind, C1); sync is the
830// SOLE maintenance writer, carrying BOTH the cursor slice and the open findings
831// in one brief (C2) with the whole of `/reconcile`'s absorbed judgment (C3). A
832// rule-by-rule absorption map records where each retired reconcile rule now
833// lives (bundle plan `05-verify-sync-engine`, C4).
834// ---------------------------------------------------------------------------
835
836use super::findings::{Finding, FindingClass, FindingTarget};
837use super::prune::{PruneDisposition, PruneProposal};
838
839/// Per-class cap on the rendered open-findings list — mirrors [`SLICE_CAP`].
840const FINDINGS_CAP: usize = SLICE_CAP;
841
842/// Render the **verify brief** (C1) — the measurement + capped-adjudication
843/// prompt an agent consumes to *measure* a binding's fidelity.
844///
845/// **Refusal (C1), structural:** this function emits **no destination-mutation
846/// instruction of any kind**. It tells the agent what to measure and adjudicate,
847/// never what to write into the destination mem — every repair is recorded as a
848/// finding for the sync brief ([`render_sync_brief`]) to act on. There is no
849/// create / update / relate / delete instruction anywhere in the rendered text.
850pub fn render_verify_brief(resolved: &ResolvedIngest, backlog: usize) -> String {
851    let mut lines: Vec<String> = vec![
852        "## Verify — measure fidelity, do not mutate".to_string(),
853        String::new(),
854    ];
855    lines.push(format!(
856        "You are measuring the fidelity of `{}` — how faithfully the destination mem \
857         `{}` still matches its source. This pass **only measures**: read the source \
858         and the mem's anchors, judge whether the graph still holds, and record what \
859         you find. **You** write nothing into the destination mem — the run itself \
860         records its findings store, backfills observed anchor hashes, and writes a \
861         `#verified` baseline, which is engine bookkeeping, not your edits.",
862        resolved.name, resolved.destination_mem
863    ));
864    lines.push(String::new());
865
866    lines.push(
867        "Anchors may carry a `source` naming the binding entry point that produced them — \
868         note it when recording findings, so fidelity stays measurable per source."
869            .to_string(),
870    );
871    lines.push(String::new());
872
873    lines.push("### Adjudicate the queued findings (capped)".to_string());
874    lines.push(String::new());
875    if backlog == 0 {
876        lines.push(
877            "No findings are queued for adjudication this pass. Spot-check the resolving \
878             anchors and the uncovered-artifact sample the fidelity report lists, and \
879             record any drift you observe as a finding."
880                .to_string(),
881        );
882    } else {
883        lines.push(format!(
884            "{backlog} finding(s) are queued for adjudication. Working up to the per-run \
885             adjudication cap (an operations knob — the remainder stays queued and \
886             re-presents on a later pass), take each queued finding and compare the \
887             anchored source content against what the entity records. Classify it: still \
888             accurate, or drifted. **Record the verdict — this is a measurement, not a \
889             repair.** A drift you record becomes a finding the sync pass repairs; you do \
890             not fix it here."
891        ));
892    }
893    lines.push(String::new());
894
895    lines.push("### Out of scope for verify — no mutation".to_string());
896    lines.push(String::new());
897    lines.push(
898        "Verify writes **no entity content**. Do not update a \
899         `specifies` / `constraints` section, do not create or delete an entity, do not \
900         add or remove a relationship. When measurement shows the graph is wrong, that \
901         is a **finding** — the sync brief (`memstead projection brief --sync`) is the \
902         one place those repairs are made. Leave every fix to it. (The run itself does \
903         record its findings store, backfill observed anchor hashes, and write a \
904         `#verified` baseline — engine bookkeeping, not your edits.)"
905            .to_string(),
906    );
907    lines.push(String::new());
908
909    format!("{}\n", lines.join("\n"))
910}
911
912/// A compact `entity → artifact` (or bare artifact) label for a finding target.
913fn finding_target_label(target: &FindingTarget) -> String {
914    match target {
915        FindingTarget::Anchor { entity, artifact } => format!("`{entity}` → `{artifact}`"),
916        FindingTarget::Artifact { artifact } => format!("`{artifact}`"),
917    }
918}
919
920/// Render one class-grouped findings section, capped at [`FINDINGS_CAP`] with a
921/// `…and N more` overflow line. Skips an empty group entirely.
922fn render_findings_group(
923    lines: &mut Vec<String>,
924    heading: &str,
925    guidance: &str,
926    items: &[&Finding],
927) {
928    if items.is_empty() {
929        return;
930    }
931    lines.push(format!("### {heading}"));
932    lines.push(String::new());
933    lines.push(guidance.to_string());
934    lines.push(String::new());
935    let shown = items.len().min(FINDINGS_CAP);
936    for f in &items[..shown] {
937        lines.push(format!(
938            "- {} — {}",
939            finding_target_label(&f.target),
940            f.detail
941        ));
942    }
943    if items.len() > shown {
944        lines.push(format!("- …and {} more", items.len() - shown));
945    }
946    lines.push(String::new());
947}
948
949/// Render the open-findings block for the sync brief (C2) — the findings
950/// `findings_store.current(key)` returned, grouped by class, each carrying the
951/// conservative repair guidance the reconcile rules (C3) mandate. Empty string
952/// when there are no open findings.
953fn render_open_findings(findings: &[Finding]) -> String {
954    if findings.is_empty() {
955        return String::new();
956    }
957    let mut lines: Vec<String> = vec![
958        "## Open findings to repair".to_string(),
959        String::new(),
960        "The verify pass recorded these against the current source state. Repair them \
961         conservatively (see the rules below); a finding you judge already correct needs \
962         no write."
963            .to_string(),
964        String::new(),
965    ];
966
967    let group = |class: FindingClass| -> Vec<&Finding> {
968        findings.iter().filter(|f| f.class == class).collect()
969    };
970
971    // Drifted / wrong — the anchored content changed: update only what moved
972    // (conservatism rule "never rewrite unchanged sections").
973    render_findings_group(
974        &mut lines,
975        "Drifted — the anchored content changed",
976        "The source the entity describes moved. Update the affected section to match — \
977         only the part that changed. If the entity is still accurate, leave it.",
978        &group(FindingClass::Drifted),
979    );
980    render_findings_group(
981        &mut lines,
982        "Wrong — an adjudicated content mismatch",
983        "Adjudication found the entity no longer matches its source. Correct the \
984         mismatched section; do not rewrite what still holds.",
985        &group(FindingClass::Wrong),
986    );
987    // Unresolvable anchor — the artifact is gone: delete only if the concept is
988    // removed entirely (conservatism rule "no deletion unless concept removed").
989    render_findings_group(
990        &mut lines,
991        "Unresolvable anchor — the artifact is gone",
992        "The source artifact an anchor references is no longer present. Delete the entity \
993         **only** if the concept is removed entirely; otherwise leave it. Concept-level \
994         removals are a prune concern with its own never-clobber / conflict-flag rules — \
995         do not delete on a hunch here.",
996        &group(FindingClass::UnresolvableAnchor),
997    );
998    // Uncovered — a source artifact with no entity: create only for a clearly-new
999    // concept (conservatism rule "no new entities unless clearly-new concept").
1000    render_findings_group(
1001        &mut lines,
1002        "Uncovered — a source artifact with no entity",
1003        "An in-scope source artifact has no anchor in the mem. Create an entity for it \
1004         **only** if it is a clearly-new concept with no existing entity; otherwise \
1005         extend the entity that already owns the concept, or leave it for a discovery \
1006         build.",
1007        &group(FindingClass::Uncovered),
1008    );
1009    // Queued — not yet adjudicated: verify owns these, not sync.
1010    render_findings_group(
1011        &mut lines,
1012        "Queued for adjudication — not yet judged",
1013        "These are not adjudicated yet — that is the verify pass's job, not sync's. \
1014         **Skip them here**; they become repairable only after verify classifies them as \
1015         drifted.",
1016        &group(FindingClass::QueuedForAdjudication),
1017    );
1018
1019    format!("{}\n", lines.join("\n"))
1020}
1021
1022/// Render the prune-proposals block for the sync brief (group F) — the deletion
1023/// proposals prune surfaced, each with its guarantee-appropriate treatment.
1024/// Empty string when there are no proposals.
1025///
1026/// **F3 / A5, structural:** every proposal here is exactly that — a *proposal*.
1027/// Nothing in this text (nor anywhere in the engine) deletes an entity; the
1028/// removal reaches the mem **only** when the agent acts on this brief through the
1029/// MCP mutation surface. `authored` entities never reach this block (prune
1030/// excludes them upstream); `derived` entities are flagged with their inputs,
1031/// never proposed for deletion.
1032fn render_prune_proposals(proposals: &[PruneProposal]) -> String {
1033    if proposals.is_empty() {
1034        return String::new();
1035    }
1036    let mut lines: Vec<String> = vec![
1037        "## Prune — proposed removals (you decide; nothing is auto-deleted)".to_string(),
1038        String::new(),
1039        "The source removed the artifacts these entities describe. Each item below is a \
1040         **proposal**: prune writes nothing — you enact (or reject) the removal through the \
1041         normal MCP mutation surface. An `authored` entity is never proposed here; a `derived` \
1042         entity is flagged, never proposed for deletion."
1043            .to_string(),
1044        String::new(),
1045    ];
1046
1047    let group = |d: PruneDisposition| -> Vec<&PruneProposal> {
1048        proposals.iter().filter(|p| p.disposition == d).collect()
1049    };
1050
1051    // Clean-delete — never-clobber, base retrieved, merge clean: a confident
1052    // (still agent-enacted) delete proposal.
1053    let clean = group(PruneDisposition::CleanDelete);
1054    if !clean.is_empty() {
1055        lines.push("### Clean delete — never-clobber three-way merge is clean".to_string());
1056        lines.push(String::new());
1057        lines.push(
1058            "The source base leg was retrievable and the three-way merge found no model-side \
1059             divergence, so removal is safe. **Confirm, then delete via the mutation surface** — \
1060             this is still your call, not an auto-delete."
1061                .to_string(),
1062        );
1063        lines.push(String::new());
1064        let shown = clean.len().min(FINDINGS_CAP);
1065        for p in &clean[..shown] {
1066            lines.push(format!(
1067                "- `{}` — source artifact(s) gone: {}",
1068                p.entity,
1069                artifact_list(&p.artifacts)
1070            ));
1071        }
1072        if clean.len() > shown {
1073            lines.push(format!("- …and {} more", clean.len() - shown));
1074        }
1075        lines.push(String::new());
1076    }
1077
1078    // Conflict-flag — both sides presented, never an auto-write over an edit.
1079    let conflict = group(PruneDisposition::ConflictFlag);
1080    if !conflict.is_empty() {
1081        lines.push("### Conflict-flag — decide, never overwrite a model-side edit".to_string());
1082        lines.push(String::new());
1083        lines.push(
1084            "No retrievable base leg to merge against (a non-git source, or an anchor with no \
1085             pinned version). **Both sides are shown — decide deliberately.** If the concept is \
1086             truly gone, delete via the mutation surface; if the model side was edited on \
1087             purpose, keep it. Prune never overwrites a model-side edit for you."
1088                .to_string(),
1089        );
1090        lines.push(String::new());
1091        let shown = conflict.len().min(FINDINGS_CAP);
1092        for p in &conflict[..shown] {
1093            lines.push(format!(
1094                "- `{}` — **source side:** artifact(s) gone: {}; **model side:** the entity is \
1095                 still present (may carry edits) — you decide.",
1096                p.entity,
1097                artifact_list(&p.artifacts)
1098            ));
1099        }
1100        if conflict.len() > shown {
1101            lines.push(format!("- …and {} more", conflict.len() - shown));
1102        }
1103        lines.push(String::new());
1104    }
1105
1106    // Derived-flagged — flagged with inputs, never proposed for deletion (F3).
1107    let derived = group(PruneDisposition::DerivedFlagged);
1108    if !derived.is_empty() {
1109        lines.push("### Derived — flagged, NOT proposed for deletion".to_string());
1110        lines.push(String::new());
1111        lines.push(
1112            "These entities were **derived** from other inputs. A derived entity is flagged, \
1113             never auto-proposed for deletion — its inputs may still hold even though one source \
1114             artifact vanished. Re-examine the inputs before removing anything."
1115                .to_string(),
1116        );
1117        lines.push(String::new());
1118        let shown = derived.len().min(FINDINGS_CAP);
1119        for p in &derived[..shown] {
1120            let inputs = if p.derived_inputs.is_empty() {
1121                "(no recorded inputs)".to_string()
1122            } else {
1123                artifact_list(&p.derived_inputs)
1124            };
1125            lines.push(format!(
1126                "- `{}` — derived from: {}; source artifact(s) gone: {}.",
1127                p.entity,
1128                inputs,
1129                artifact_list(&p.artifacts)
1130            ));
1131        }
1132        if derived.len() > shown {
1133            lines.push(format!("- …and {} more", derived.len() - shown));
1134        }
1135        lines.push(String::new());
1136    }
1137
1138    format!("{}\n", lines.join("\n"))
1139}
1140
1141/// A compact backtick-joined artifact list.
1142fn artifact_list(artifacts: &[String]) -> String {
1143    if artifacts.is_empty() {
1144        return "(none)".to_string();
1145    }
1146    artifacts
1147        .iter()
1148        .map(|a| format!("`{a}`"))
1149        .collect::<Vec<_>>()
1150        .join(", ")
1151}
1152
1153/// Render the sync brief's `## Situation` block — the sole-maintenance-writer
1154/// mandate and the commits-nothing / engine-commits-per-mutation posture (C3).
1155fn render_sync_situation(resolved: &ResolvedIngest) -> String {
1156    format!(
1157        "## Sync — repair the graph to match the source\n\n\
1158         You are running the sync pass for `{}`. Sync is the graph's **sole maintenance \
1159         writer**: the only place the destination mem `{}` is repaired to match its \
1160         source. Two inputs steer this pass — the source changes since the last sync, and \
1161         the open verify findings — both below. Work them: update, create, relate, and \
1162         (rarely) delete entities so the graph again matches the source.\n\n\
1163         Every mutation routes through the normal MCP mutation surface, and the engine \
1164         commits each one **per-mutation** to the mem's own gitdir. You **stage nothing \
1165         and commit nothing yourself** — not the graph, not the code. Sync commits \
1166         nothing.\n\n",
1167        resolved.name, resolved.destination_mem
1168    )
1169}
1170
1171/// Render the adopt / onboarding block (C3's first-sync/adopt framing; E1's
1172/// brief half): a mem that predates its binding is onboarding, expected-0%, with
1173/// the concrete backfill path — never a failure or red verdict.
1174fn render_adopt_framing(resolved: &ResolvedIngest) -> String {
1175    format!(
1176        "## First sync — adopting `{}`\n\n\
1177         This mem predates its binding: it has no anchors and no prior sync baseline, so \
1178         **0% anchored is expected — this is onboarding, not a failure.** Do not read it \
1179         as drift or a red verdict. There is no cursor to diff against, so the baseline is \
1180         the **current** source HEAD — do **not** replay the whole history; treat the \
1181         current source state as the starting point, and this is a **first sync**.\n\n\
1182         **Backfill path:** run `memstead projection verify {}` to enumerate the in-scope \
1183         source artifacts that carry no entity yet, then cover the clearly-new concepts \
1184         among them through the normal MCP mutation surface — the same conservative rules \
1185         below apply. Backfilling is incremental: a partial pass is fine, and the next \
1186         sync continues where you left off.\n\n",
1187        resolved.destination_mem, resolved.name
1188    )
1189}
1190
1191/// Render the **stale-claim search** block — the bounded step that closes the
1192/// slice-blinkering blind spot: a changed fact can be claimed by entities
1193/// whose anchors lie entirely outside the changed slice, so steering repairs
1194/// at slice-anchored entities alone leaves those claims standing falsified.
1195///
1196/// The shape is deliberately bounded, and the prose binds itself to **the
1197/// changed facts extracted from the slice**: a cosmetic change (formatting,
1198/// comments, moves that alter no fact) yields an empty fact set, and an empty
1199/// fact set instructs nothing — no whole-mem sweep, no live-verify of every
1200/// entity, no rewrite license. Rendered only when the cursor carries actual
1201/// changed artifacts (never for reseed-only / no-signal-only passes).
1202fn render_stale_claim_search(resolved: &ResolvedIngest) -> String {
1203    format!(
1204        "## Stale claims beyond the slice — search, then judge\n\n\
1205         A changed fact can be claimed by an entity whose anchors are all outside the \
1206         changed slice — anchor-steered repairs alone would leave that claim standing \
1207         falsified. Extract the **changed facts** from the changed artifacts above: \
1208         renamed identifiers, changed values or defaults, changed behaviors (e.g. an \
1209         exit code, a flag's meaning), removed or moved concepts. For each changed \
1210         fact, search the destination mem `{}` for claims about it (`memstead_search` \
1211         and its variants — try the new name, the old name/value, and close synonyms), \
1212         and judge **only** the entities whose claims actually mention a changed fact: \
1213         repair a claim the change falsifies, leave everything else untouched.\n\n\
1214         This is a bounded fact-search, not a live-verify of every entity and not a \
1215         rewrite license. If the changes carry no factual claims (formatting, \
1216         comments, cosmetic moves), the fact set is empty and this step ends with no \
1217         search and no edits.\n\n",
1218        resolved.destination_mem
1219    )
1220}
1221
1222/// Render the sync brief's conservatism block — the whole of `/reconcile`'s
1223/// absorbed judgment (C3): the five conservatism rules, edge-removal
1224/// conservatism, and rationale-not-changelog.
1225fn render_sync_conservatism() -> String {
1226    let lines: Vec<&str> = vec![
1227        "## How to repair — be conservative",
1228        "",
1229        "Repair only what the source changes and the findings above actually justify:",
1230        "",
1231        // The five conservatism rules.
1232        "- **Unsure whether an entity is affected — skip it.** A missed update is a later \
1233         finding; a wrong rewrite is damage.",
1234        "- **Do not create a new entity unless the change clearly introduces a new concept \
1235         with no existing entity.** Prefer updating the entity that already owns the \
1236         concept.",
1237        "- **Do not delete an entity unless the change removes the concept entirely.** \
1238         Deletions a prune pass surfaces follow prune's own never-clobber / conflict-flag \
1239         rules — never delete on a hunch here.",
1240        "- **Never rewrite a section that has not changed** — touch only the part the \
1241         change or finding actually affects.",
1242        "- **No speculative edges — add only relationships the diff literally introduces** \
1243         (a new `use` / `import` / dependency you can point at in the change).",
1244        // Edge-removal conservatism.
1245        "- **A dropped dependency FLAGS, it does not auto-remove.** If the change removes an \
1246         import or dependency, leave the matching edge intact and note it for a later \
1247         audit — removals are ambiguous (temporary refactor vs. permanent cut), and a \
1248         stale edge is less damaging than an erased real one. **Edge removal is out of \
1249         scope for sync.**",
1250        // Rationale-not-changelog.
1251        "- **Rationale is reasoning, not a changelog.** When you record why a change was \
1252         made, append the *reasoning* (why this approach, which trade-offs) — never \
1253         `[commit <hash>]` log-style entries.",
1254        "",
1255    ];
1256
1257    format!("{}\n", lines.join("\n"))
1258}
1259
1260/// Render the **sync brief** (C2/C3) — the *single* channel through which
1261/// maintenance-writing work reaches an agent.
1262///
1263/// One brief carries **both** inputs: the cursor slice (`cursor`, rendered via
1264/// [`render_changed_slice`], which also carries the first-sync reseed framing and
1265/// the disposition-recording step) and the open verify findings (`findings`, the
1266/// store's `current(key)` slice). It absorbs the whole of `/reconcile`'s judgment
1267/// (C3): the five conservatism rules, edge-removal conservatism,
1268/// rationale-not-changelog, the commits-nothing / engine-commits-per-mutation
1269/// posture, and — when `adopt` is set — the first-sync/adopt onboarding framing
1270/// (E1's brief half). A rule-by-rule absorption map records where each retired
1271/// reconcile rule now lives (bundle plan `05-verify-sync-engine`, C4).
1272///
1273/// A slice that carries actual changed artifacts additionally renders the
1274/// bounded **stale-claim search** step ([`render_stale_claim_search`]) — the
1275/// beyond-the-slice fact search that catches claims falsified by the change in
1276/// entities whose anchors never intersect the slice.
1277///
1278/// Prune proposals (group F) ride this same brief — F3's single-writer
1279/// invariant: every prune removal reaches the mem only via an agent acting on
1280/// this sync brief. They are rendered as proposals only; nothing is auto-deleted.
1281///
1282/// When nothing has moved, no findings are open, no prune proposals exist, and
1283/// this is not an adopt pass, the brief renders a compact "nothing to sync" note
1284/// instead of the repair machinery — a valid, silent outcome mirroring the build
1285/// brief's no-op roam.
1286pub fn render_sync_brief(
1287    resolved: &ResolvedIngest,
1288    cursor: &SourceCursor,
1289    findings: &[Finding],
1290    prune: &[PruneProposal],
1291    adopt: bool,
1292) -> String {
1293    let preface = render_changed_slice(cursor);
1294    let open_findings = render_open_findings(findings);
1295    let prune_block = render_prune_proposals(prune);
1296    let has_work =
1297        adopt || !preface.is_empty() || !open_findings.is_empty() || !prune_block.is_empty();
1298
1299    let mut parts: Vec<String> = vec![render_sync_situation(resolved)];
1300
1301    if !has_work {
1302        parts.push(
1303            "## Nothing to sync\n\nThe source has not moved since the last sync, no \
1304             verify findings are open, and no prune proposals stand. There is nothing to \
1305             repair this pass — reporting \"no changes\" is a valid outcome.\n\n"
1306                .to_string(),
1307        );
1308        return parts
1309            .into_iter()
1310            .filter(|p| !p.is_empty())
1311            .collect::<Vec<_>>()
1312            .join("");
1313    }
1314
1315    if adopt {
1316        parts.push(render_adopt_framing(resolved));
1317    }
1318    parts.push(preface);
1319    // The stale-claim search rides only a slice that carries actual changed
1320    // artifacts — its facts are extracted FROM those artifacts, so a pass
1321    // with no changes (findings-only, reseed-only, prune-only) renders none.
1322    if cursor.any_changes {
1323        parts.push(render_stale_claim_search(resolved));
1324    }
1325    parts.push(open_findings);
1326    parts.push(prune_block);
1327    parts.push(render_anchor_instruction(resolved));
1328    parts.push(render_sync_conservatism());
1329
1330    parts
1331        .into_iter()
1332        .filter(|p| !p.is_empty())
1333        .collect::<Vec<_>>()
1334        .join("")
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339    use super::*;
1340    use crate::ingest::resolve::Source;
1341    use crate::pipeline::{IngestTrigger, PatternEntry};
1342
1343    fn guidance(goal: Option<&str>, avoid: Option<&str>) -> ResolvedGuidance {
1344        ResolvedGuidance {
1345            goal: goal.map(str::to_string),
1346            avoid: avoid.map(str::to_string),
1347        }
1348    }
1349
1350    /// Goal and avoid both present: two headers, trimmed prose, block ends in
1351    /// a blank line — byte-for-byte the plugin's `goalAndAvoidBlock`.
1352    #[test]
1353    fn renders_goal_and_avoid_blocks() {
1354        let out = render_goal_and_avoid(&guidance(Some("  build coverage  "), Some("no stubs")));
1355        assert_eq!(
1356            out,
1357            "## Goal\n\nbuild coverage\n\n## Failure modes to avoid\n\nno stubs\n\n"
1358        );
1359    }
1360
1361    /// Goal only: a single header block ending in a blank line.
1362    #[test]
1363    fn renders_goal_only() {
1364        assert_eq!(
1365            render_goal_and_avoid(&guidance(Some("build coverage"), None)),
1366            "## Goal\n\nbuild coverage\n\n"
1367        );
1368    }
1369
1370    /// Avoid only: a single header block ending in a blank line.
1371    #[test]
1372    fn renders_avoid_only() {
1373        assert_eq!(
1374            render_goal_and_avoid(&guidance(None, Some("no stubs"))),
1375            "## Failure modes to avoid\n\nno stubs\n\n"
1376        );
1377    }
1378
1379    /// Neither present (and no pass-through): a lone newline, matching the
1380    /// plugin's `lines.join('\n') + '\n'` on an empty block.
1381    #[test]
1382    fn empty_guidance_yields_a_newline() {
1383        assert_eq!(render_goal_and_avoid(&guidance(None, None)), "\n");
1384        // An all-whitespace field is treated as absent.
1385        assert_eq!(render_goal_and_avoid(&guidance(Some("   "), None)), "\n");
1386    }
1387
1388    fn primary(medium_type: MediumType, scope: Vec<PatternEntry>) -> ResolvedSource {
1389        ResolvedSource::Primary(Source {
1390            name: "f".to_string(),
1391            medium_type,
1392            pointer: "../src".to_string(),
1393            change_detection: None,
1394            scope,
1395            engagement: None,
1396            preparation: None,
1397        })
1398    }
1399
1400    fn resolved(name: &str, intent: Option<&str>, sources: Vec<ResolvedSource>) -> ResolvedIngest {
1401        ResolvedIngest {
1402            name: name.to_string(),
1403            mode: BuildMode::Discovery,
1404            trigger: IngestTrigger::Loop,
1405            batch_size: 20,
1406            deny_paths: vec![],
1407            projection_ref: format!("{name}/p"),
1408            projection_mem: name.to_string(),
1409            projection_name: "p".to_string(),
1410            intent: intent.map(str::to_string),
1411            sources,
1412            destination_mem: name.to_string(),
1413            rules: None,
1414            post_actions: None,
1415        }
1416    }
1417
1418    fn process_present(name: &str) -> ProcessMemInfo {
1419        ProcessMemInfo {
1420            present: true,
1421            skipped: false,
1422            notice: None,
1423            leaf_name: name.to_string(),
1424            mem_label: format!("ingest/{name}"),
1425        }
1426    }
1427
1428    fn allow(path: &str) -> PatternEntry {
1429        PatternEntry {
1430            path: path.to_string(),
1431            mode: PatternMode::Allow,
1432        }
1433    }
1434
1435    fn deny(path: &str) -> PatternEntry {
1436        PatternEntry {
1437            path: path.to_string(),
1438            mode: PatternMode::Deny,
1439        }
1440    }
1441
1442    /// The about-the-source block trims the intent; no intent → empty string.
1443    #[test]
1444    fn renders_intent() {
1445        let r = resolved("macos", Some("  Swift app source.  "), vec![]);
1446        assert_eq!(
1447            render_intent(&r),
1448            "## About the source\n\nSwift app source.\n\n"
1449        );
1450        let none = resolved("macos", None, vec![]);
1451        assert_eq!(render_intent(&none), "");
1452    }
1453
1454    /// The situation block prints the name/mode, the three fixed paragraphs,
1455    /// and the present-process-mem line, ending in a blank line.
1456    #[test]
1457    fn renders_situation_with_present_process_mem() {
1458        let r = resolved("macos", None, vec![]);
1459        let out = render_situation(&r, &process_present("macos"));
1460        assert!(out.starts_with("## Situation\n\nYou are running one iteration of `macos` (discovery mode) inside a loop."));
1461        assert!(out.contains("Mutating the destination is this run's mandate:"));
1462        assert!(out.contains("The `PreCompact` hook fires near the limit"));
1463        assert!(out.contains("A paired process mem `ingest/macos` (schema `ingest@0.5.0`) carries destination-quality debt"));
1464        assert!(
1465            out.ends_with("write rules.\n\n"),
1466            "block ends in a blank line"
1467        );
1468    }
1469
1470    /// The skipped (one-shot) and failed-to-create process-mem branches each
1471    /// render their own note.
1472    #[test]
1473    fn situation_process_mem_branches() {
1474        let mut r = resolved("os", None, vec![]);
1475        r.mode = BuildMode::OneShot;
1476        let skipped = ProcessMemInfo {
1477            present: false,
1478            skipped: true,
1479            notice: None,
1480            leaf_name: "os".to_string(),
1481            mem_label: "ingest/os".to_string(),
1482        };
1483        assert!(
1484            render_situation(&r, &skipped)
1485                .contains("No process mem is paired with this ingest (mode=one-shot;")
1486        );
1487
1488        let failed = ProcessMemInfo {
1489            present: false,
1490            skipped: false,
1491            notice: Some("engine offline".to_string()),
1492            leaf_name: "os".to_string(),
1493            mem_label: "ingest/os".to_string(),
1494        };
1495        let out = render_situation(&resolved("os", None, vec![]), &failed);
1496        assert!(out.contains("could not be auto-created — engine offline."));
1497        assert!(out.contains("memstead mem init os --org-path ingest --schema ingest@0.5.0"));
1498    }
1499
1500    /// Operative data: a primary source with paths/ignore, a reference mem
1501    /// with its cross-mem note, the destination with its schema, and the
1502    /// paired process mem — byte-for-byte the plugin's block.
1503    #[test]
1504    fn renders_operative_data_full() {
1505        let r = resolved(
1506            "macos",
1507            None,
1508            vec![
1509                primary(
1510                    MediumType::Codebase,
1511                    vec![allow("src/**/*.swift"), deny("src/gen/**")],
1512                ),
1513                ResolvedSource::Reference {
1514                    mem: "engine".to_string(),
1515                },
1516            ],
1517        );
1518        let out = render_operative_data(
1519            &r,
1520            &process_present("macos"),
1521            Some("macos-code@0.1.0"),
1522            None,
1523            &[],
1524        );
1525        let expected = "\
1526## Operative data
1527
1528### Sources
1529
1530- **f** (codebase, primary) — `../src`
1531  - Paths: src/**/*.swift
1532  - Ignore: src/gen/**
1533- **graph** (reference) — mem: engine
1534
1535Sources tagged `(reference)` are read-only context for cross-mem edges — search them, never write into them. Only `(primary)` sources are ingested into the destination.
1536
1537**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`).
1538
1539### Destination
1540
1541- **macos** — schema: `macos-code@0.1.0`
1542
1543### Paired process mem
1544
1545- **ingest/macos** — schema: `ingest@0.5.0`. Inspect via `memstead_overview` / `memstead_search mem=macos`.
1546\n";
1547        assert_eq!(out, expected);
1548    }
1549
1550    /// Operative data without references or a destination schema: no cross-mem
1551    /// note, a bare destination line.
1552    #[test]
1553    fn renders_operative_data_minimal() {
1554        let r = resolved("g", None, vec![primary(MediumType::Filesystem, vec![])]);
1555        let skipped = ProcessMemInfo {
1556            present: false,
1557            skipped: true,
1558            notice: None,
1559            leaf_name: "g".to_string(),
1560            mem_label: "ingest/g".to_string(),
1561        };
1562        let out = render_operative_data(&r, &skipped, None, Some("**absent** — probe"), &[]);
1563        // The bullet carries the pointer: an agent told to read a source
1564        // must be able to see WHICH tree it was pointed at.
1565        assert!(out.contains("- **f** (filesystem, primary) — `"));
1566        assert!(!out.contains("Cross-mem references"), "no reference note");
1567        assert!(out.contains("### Destination\n\n- **g**\n"));
1568        // The caller decides the destination note — this renderer only
1569        // places it, because the remedy depends on the workspace shape.
1570        assert!(
1571            out.contains("**absent** — probe"),
1572            "the caller's destination note must be rendered: {out}",
1573        );
1574        assert!(
1575            !out.contains("Paired process mem"),
1576            "skipped process mem omitted"
1577        );
1578    }
1579
1580    /// The discovery assembly concatenates the truthy blocks in order; an
1581    /// empty changed-slice preface (source unmoved) drops out.
1582    #[test]
1583    fn assembles_discovery_brief() {
1584        let r = resolved(
1585            "macos",
1586            Some("Swift source."),
1587            vec![primary(MediumType::Codebase, vec![allow("src/**")])],
1588        );
1589        let g = guidance(Some("build coverage"), None);
1590        let pm = process_present("macos");
1591        let brief = assemble_discovery_brief(&r, &g, &pm, Some("s@1"), None, &[], "");
1592
1593        // Blocks appear in order and the empty preface is dropped.
1594        let sit = brief.find("## Situation").unwrap();
1595        let src = brief.find("## About the source").unwrap();
1596        let goal = brief.find("## Goal").unwrap();
1597        let op = brief.find("## Operative data").unwrap();
1598        let anchors = brief.find("## Provenance — anchor your writes").unwrap();
1599        assert!(
1600            sit < src && src < goal && goal < op && op < anchors,
1601            "blocks in brief order"
1602        );
1603        assert!(
1604            !brief.contains("## Source changes"),
1605            "no changed-slice block when preface empty"
1606        );
1607
1608        // A non-empty preface is appended verbatim at the end.
1609        let with_slice = assemble_discovery_brief(
1610            &r,
1611            &g,
1612            &pm,
1613            Some("s@1"),
1614            None,
1615            &[],
1616            "## Source changes\n\n…\n\n",
1617        );
1618        assert!(with_slice.ends_with("## Source changes\n\n…\n\n"));
1619    }
1620
1621    fn slice(deleted: &[&str], modified: &[&str], added: &[&str]) -> Slice {
1622        Slice {
1623            deleted: deleted.iter().map(|s| s.to_string()).collect(),
1624            modified: modified.iter().map(|s| s.to_string()).collect(),
1625            added: added.iter().map(|s| s.to_string()).collect(),
1626        }
1627    }
1628
1629    fn cmd(key: &str, token: &str) -> SyncCommand {
1630        SyncCommand {
1631            key: key.to_string(),
1632            token: token.to_string(),
1633        }
1634    }
1635
1636    fn note(source: &str, reason: NoSignalReason) -> NoSignalNote {
1637        NoSignalNote {
1638            medium_type: None,
1639            source: source.to_string(),
1640            reason,
1641        }
1642    }
1643
1644    /// No changes and no reseed → the block is empty (brief stays a plain roam).
1645    #[test]
1646    fn changed_slice_empty_when_nothing_moved() {
1647        let cursor = SourceCursor {
1648            union: slice(&[], &[], &[]),
1649            write_commands: vec![],
1650            reseed: vec![],
1651            no_signal: vec![],
1652            any_changes: false,
1653            degraded: false,
1654            dead_denies: vec![],
1655            dest_mem: "engine".to_string(),
1656            binding_id: "engine/graph".to_string(),
1657        };
1658        assert_eq!(render_changed_slice(&cursor), "");
1659    }
1660
1661    /// A zero-selecting deny entry surfaces as a rendered warning even when
1662    /// nothing else moved — it is never a silent no-op. The entry name and the
1663    /// migration hint both appear.
1664    #[test]
1665    fn changed_slice_renders_dead_deny_warning() {
1666        let cursor = SourceCursor {
1667            union: slice(&[], &[], &[]),
1668            write_commands: vec![],
1669            reseed: vec![],
1670            no_signal: vec![],
1671            any_changes: false,
1672            degraded: false,
1673            dead_denies: vec!["dev".to_string(), "typo/**".to_string()],
1674            dest_mem: "engine".to_string(),
1675            binding_id: "engine/graph".to_string(),
1676        };
1677        let out = render_changed_slice(&cursor);
1678        assert!(out.contains("deny_paths` entries match nothing"));
1679        assert!(out.contains("- `dev`"));
1680        assert!(out.contains("- `typo/**`"));
1681    }
1682
1683    /// A changed pass renders deleted-first, then the recording block — built
1684    /// here from single-line literals transcribed from the plugin so any
1685    /// line-continuation drift in the impl is caught.
1686    #[test]
1687    fn changed_slice_renders_slice_and_recording() {
1688        let cursor = SourceCursor {
1689            union: slice(&["a.rs"], &["b.rs"], &[]),
1690            write_commands: vec![cmd("engine-graph/source", "HEADSHA")],
1691            reseed: vec![],
1692            no_signal: vec![],
1693            any_changes: true,
1694            degraded: false,
1695            dead_denies: vec![],
1696            dest_mem: "engine".to_string(),
1697            binding_id: "engine/graph".to_string(),
1698        };
1699        let expected_lines = [
1700            "## Source changes since the last sync\n",
1701            "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",
1702            "**Deleted:**",
1703            "- `a.rs`",
1704            "",
1705            "**Modified:**",
1706            "- `b.rs`",
1707            "",
1708            "### Recording your dispositions (do this LAST)\n",
1709            "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",
1710            "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",
1711            "```sh",
1712            r#"memstead projection advance engine/graph --dispositions '{"<artifact>": "<disposition>", ...}'"#,
1713            "```",
1714            "If you were interrupted before finishing, that is fine — your recorded dispositions persist, and the next run re-presents only what is left.\n",
1715        ];
1716        assert_eq!(
1717            render_changed_slice(&cursor),
1718            format!("{}\n", expected_lines.join("\n"))
1719        );
1720    }
1721
1722    /// The reseed-only path names the first-sync keys and still emits the
1723    /// recording block (the reseed baselines).
1724    #[test]
1725    fn changed_slice_reseed_only() {
1726        let cursor = SourceCursor {
1727            union: slice(&[], &[], &[]),
1728            write_commands: vec![],
1729            reseed: vec![cmd("ing/f", "TOK")],
1730            no_signal: vec![],
1731            any_changes: false,
1732            degraded: false,
1733            dead_denies: vec![],
1734            dest_mem: "d".to_string(),
1735            binding_id: "d/p".to_string(),
1736        };
1737        let out = render_changed_slice(&cursor);
1738        assert!(out.starts_with("## Source changes since the last sync\n\n"));
1739        assert!(out.contains(
1740            "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."
1741        ));
1742        assert!(out.contains(
1743            r#"memstead projection advance d/p --dispositions '{"<artifact>": "<disposition>", ...}'"#
1744        ));
1745        assert!(
1746            !out.contains("The source moved"),
1747            "no 'moved' copy when only reseeding"
1748        );
1749    }
1750
1751    /// Every no-signal reason renders a distinct, named note under the preface,
1752    /// distinguishable from one another and from a genuinely-unchanged source
1753    /// (which renders nothing). With no changes and no reseed there is no
1754    /// recording block, but the preface is non-empty — a source's blindness is
1755    /// visible. `signal:none` renders literally.
1756    #[test]
1757    fn changed_slice_renders_no_signal_reasons_distinguishably() {
1758        let cursor = SourceCursor {
1759            union: slice(&[], &[], &[]),
1760            write_commands: vec![],
1761            reseed: vec![],
1762            no_signal: vec![
1763                note("code-facet", NoSignalReason::Unscoped),
1764                note("plan-facet", NoSignalReason::DetectionNone),
1765                note("git-facet", NoSignalReason::GitUnavailable),
1766                note("ref-mem", NoSignalReason::GraphSnapshotMissing),
1767            ],
1768            any_changes: false,
1769            degraded: false,
1770            dead_denies: vec![],
1771            dest_mem: "d".to_string(),
1772            binding_id: "d/p".to_string(),
1773        };
1774        let out = render_changed_slice(&cursor);
1775        assert!(out.starts_with("## Source changes since the last sync\n"));
1776        assert!(out.contains("Some sources produced **no change signal**"));
1777        // Each source is named and carries its own distinct reason text.
1778        assert!(out.contains("- `code-facet`: unscoped facet (no allow patterns)"));
1779        assert!(
1780            out.contains("- `plan-facet`: `signal:none`"),
1781            "detection-none renders the literal signal:none state"
1782        );
1783        assert!(out.contains("- `git-facet`: git signal unavailable"));
1784        assert!(out.contains("- `ref-mem`: graph snapshot missing"));
1785        // The four reason texts are mutually distinct.
1786        let texts = [
1787            no_signal_reason_text(NoSignalReason::Unscoped, None),
1788            no_signal_reason_text(NoSignalReason::DetectionNone, None),
1789            no_signal_reason_text(NoSignalReason::GitUnavailable, None),
1790            no_signal_reason_text(NoSignalReason::GraphSnapshotMissing, None),
1791        ];
1792        for (i, a) in texts.iter().enumerate() {
1793            for b in &texts[i + 1..] {
1794                assert_ne!(a, b, "each no-signal reason must render distinctly");
1795            }
1796        }
1797        // No baseline to advance → no recording block, no "moved" copy.
1798        assert!(!out.contains("### Recording your dispositions"));
1799        assert!(!out.contains("The source moved"));
1800    }
1801
1802    /// A changed source and a no-signal source coexist: the changed slice AND
1803    /// the no-signal note both render in the one preface, and the changed
1804    /// source still emits its recording command.
1805    #[test]
1806    fn changed_slice_mixes_changes_and_no_signal() {
1807        let cursor = SourceCursor {
1808            union: slice(&[], &["b.rs"], &[]),
1809            write_commands: vec![cmd("ing/f", "HEAD")],
1810            reseed: vec![],
1811            no_signal: vec![note("other", NoSignalReason::Unscoped)],
1812            any_changes: true,
1813            degraded: false,
1814            dead_denies: vec![],
1815            dest_mem: "d".to_string(),
1816            binding_id: "d/p".to_string(),
1817        };
1818        let out = render_changed_slice(&cursor);
1819        assert!(out.contains("The source moved"));
1820        assert!(out.contains("**Modified:**"));
1821        assert!(out.contains("- `other`: unscoped facet"));
1822        assert!(out.contains("### Recording your dispositions"));
1823        assert!(out.contains(
1824            r#"memstead projection advance d/p --dispositions '{"<artifact>": "<disposition>", ...}'"#
1825        ));
1826    }
1827
1828    /// The one-shot lens block: destination-set table, routing rule (when set),
1829    /// idempotency, report template, and archive note (when set).
1830    #[test]
1831    fn renders_one_shot_lens_block() {
1832        let mut r = resolved("os", Some("plan source"), vec![]);
1833        r.rules = Some(serde_json::json!({ "routing": "route each entity to its spec" }));
1834        r.post_actions = Some(serde_json::json!({ "archive_source": true }));
1835
1836        let out = render_one_shot_lens(&r, Some("planning@0.1.0"), Some("the plan graph"));
1837        assert!(out.starts_with("## Mode: one-shot — lens routing\n\n"));
1838        assert!(out.contains(
1839            "### Destination set\n\n| Mem | Schema | Purpose |\n|-------|--------|---------|\n| os | planning@0.1.0 | the plan graph |\n"
1840        ));
1841        assert!(out.contains("### Routing rule\n\n```\nroute each entity to its spec\n```\n"));
1842        assert!(out.contains("### Idempotency"));
1843        assert!(out.contains("### Report: os"));
1844        assert!(out.contains("### Archive after run"));
1845        assert!(out.ends_with("is set on this ingest.\n\n"));
1846
1847        // No routing / no archive → those sections are omitted; a bare schema
1848        // and default purpose fall back.
1849        let bare = resolved("os", None, vec![]);
1850        let out2 = render_one_shot_lens(&bare, None, None);
1851        assert!(out2.contains("| os | (none) | (no purpose declared) |"));
1852        assert!(!out2.contains("### Routing rule"));
1853        assert!(!out2.contains("### Archive after run"));
1854        assert!(out2.contains("### End-of-run report"));
1855    }
1856
1857    /// The one-shot brief assembles situation (one-shot mode) + intent +
1858    /// goal/avoid + operative-data + the lens block; no process mem, no slice.
1859    #[test]
1860    fn assembles_one_shot_brief() {
1861        let mut r = resolved(
1862            "os",
1863            Some("src"),
1864            vec![primary(MediumType::Filesystem, vec![])],
1865        );
1866        r.mode = BuildMode::OneShot;
1867        let g = guidance(Some("goal"), None);
1868        let skipped = ProcessMemInfo {
1869            present: false,
1870            skipped: true,
1871            notice: None,
1872            leaf_name: "os".to_string(),
1873            mem_label: "ingest/os".to_string(),
1874        };
1875        let brief =
1876            assemble_one_shot_brief(&r, &g, &skipped, Some("s@1"), None, &[], Some("purpose"));
1877        assert!(brief.contains("(one-shot mode)"));
1878        assert!(brief.contains("No process mem is paired with this ingest (mode=one-shot;"));
1879        assert!(brief.contains("## Mode: one-shot — lens routing"));
1880        assert!(
1881            brief.contains("## Provenance — anchor your writes"),
1882            "one-shot carries the anchor instruction"
1883        );
1884        assert!(
1885            !brief.contains("## Source changes"),
1886            "one-shot has no changed-slice"
1887        );
1888    }
1889
1890    /// Beyond SLICE_CAP entries an overflow line stands in; the degraded flag
1891    /// adds the coarse-targeting note. Also exercises shell-quoting a JSON
1892    /// digest token (embedded quotes).
1893    #[test]
1894    fn changed_slice_caps_and_degrades_and_quotes() {
1895        let many: Vec<String> = (0..SLICE_CAP + 3).map(|i| format!("f{i}.rs")).collect();
1896        let cursor = SourceCursor {
1897            union: Slice {
1898                deleted: vec![],
1899                modified: vec![],
1900                added: many,
1901            },
1902            write_commands: vec![cmd("ing/f", r#"{"v":1,"aggregate":"x"}"#)],
1903            reseed: vec![],
1904            no_signal: vec![],
1905            any_changes: true,
1906            degraded: true,
1907            dead_denies: vec![],
1908            dest_mem: "d".to_string(),
1909            binding_id: "d/p".to_string(),
1910        };
1911        let out = render_changed_slice(&cursor);
1912        assert!(out.contains(&format!("- …and {} more added", 3)));
1913        assert!(out.contains("Precise change history for one or more facets was unavailable"));
1914        // The brief renders the `projection advance` line (the token is no longer
1915        // an operator command — the engine computes and records it, D4/D7).
1916        assert!(out.contains(
1917            r#"memstead projection advance d/p --dispositions '{"<artifact>": "<disposition>", ...}'"#
1918        ));
1919    }
1920
1921    // ---- verify + sync briefs (group C) ----------------------------------
1922
1923    fn finding(class: FindingClass, target: FindingTarget, detail: &str) -> Finding {
1924        Finding {
1925            key: crate::ingest::findings::FindingKey {
1926                binding_hash: "h".to_string(),
1927                source_head: "s".to_string(),
1928            },
1929            facet: "src".to_string(),
1930            target,
1931            class,
1932            detail: detail.to_string(),
1933            created_at: "1".to_string(),
1934        }
1935    }
1936
1937    fn anchor_target(entity: &str, artifact: &str) -> FindingTarget {
1938        FindingTarget::Anchor {
1939            entity: entity.to_string(),
1940            artifact: artifact.to_string(),
1941        }
1942    }
1943
1944    fn artifact_target(artifact: &str) -> FindingTarget {
1945        FindingTarget::Artifact {
1946            artifact: artifact.to_string(),
1947        }
1948    }
1949
1950    fn empty_cursor() -> SourceCursor {
1951        SourceCursor {
1952            union: slice(&[], &[], &[]),
1953            write_commands: vec![],
1954            reseed: vec![],
1955            no_signal: vec![],
1956            any_changes: false,
1957            degraded: false,
1958            dead_denies: vec![],
1959            dest_mem: "engine".to_string(),
1960            binding_id: "engine/graph".to_string(),
1961        }
1962    }
1963
1964    /// C1 — the verify brief measures + adjudicates, and carries NO
1965    /// destination-mutation instruction of any kind. It names the sync brief as
1966    /// the repair home and prints its explicit no-mutation refusal.
1967    #[test]
1968    fn verify_brief_measures_and_refuses_mutation() {
1969        let r = resolved("engine", None, vec![]);
1970        let out = render_verify_brief(&r, 3);
1971        // Measurement + capped adjudication instructions.
1972        assert!(out.starts_with("## Verify — measure fidelity, do not mutate"));
1973        assert!(out.contains("3 finding(s) are queued for adjudication"));
1974        assert!(out.contains("per-run adjudication cap"));
1975        assert!(out.contains("this is a measurement, not a repair"));
1976        // C1 REFUSAL: structurally no destination-mutation instruction. The
1977        // brief never tells the agent to write into the mem — it says the
1978        // opposite, and hands repairs to the sync brief.
1979        //
1980        // Reworded 2026-08-20. This assertion used to pin "Verify writes
1981        // **nothing** into the destination mem", which was false: a completed
1982        // run records its findings store, backfills observed anchor hashes and
1983        // writes a `#verified` baseline. The refusal this test exists to
1984        // protect is about ENTITY CONTENT — that is what an agent reading the
1985        // brief must not touch — so the claim is narrowed to what is true
1986        // rather than deleted, and the bookkeeping is asserted alongside it so
1987        // the correction cannot silently regress.
1988        assert!(out.contains("Verify writes **no entity content**"));
1989        assert!(out.contains("`#verified` baseline"));
1990        assert!(out.contains("memstead projection brief --sync"));
1991        // No create/update/relate/delete *instruction* — the only occurrences of
1992        // those verbs are in the negated "do not …" refusal line.
1993        assert!(out.contains("do not create or delete an entity"));
1994        assert!(!out.contains("via `memstead_create`"));
1995        assert!(!out.contains("Run `memstead_update`"));
1996
1997        // Backlog 0 → the spot-check phrasing, still no mutation instruction.
1998        let zero = render_verify_brief(&r, 0);
1999        assert!(zero.contains("No findings are queued for adjudication"));
2000        assert!(zero.contains("record any drift you observe as a finding"));
2001        assert!(zero.contains("Verify writes **no entity content**"));
2002    }
2003
2004    /// C2 — the sync brief carries BOTH inputs in ONE render: the cursor slice
2005    /// (the changed artifacts) AND the open findings (`current(key)`), plus the
2006    /// commits-nothing posture.
2007    #[test]
2008    fn sync_brief_carries_both_cursor_and_findings() {
2009        let r = resolved("engine", None, vec![]);
2010        let cursor = SourceCursor {
2011            union: slice(&["gone.rs"], &["moved.rs"], &[]),
2012            write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
2013            reseed: vec![],
2014            no_signal: vec![],
2015            any_changes: true,
2016            degraded: false,
2017            dead_denies: vec![],
2018            dest_mem: "engine".to_string(),
2019            binding_id: "engine/graph".to_string(),
2020        };
2021        let findings = vec![
2022            finding(
2023                FindingClass::Drifted,
2024                anchor_target("engine--e", "src/moved.rs"),
2025                "prepared-content hash drifted",
2026            ),
2027            finding(
2028                FindingClass::Uncovered,
2029                artifact_target("src/new.rs"),
2030                "in scope, no anchor",
2031            ),
2032        ];
2033        let out = render_sync_brief(&r, &cursor, &findings, &[], false);
2034        // Both inputs present in one brief (C2).
2035        assert!(out.contains("## Source changes since the last sync"));
2036        assert!(out.contains("`moved.rs`"));
2037        assert!(out.contains("## Open findings to repair"));
2038        assert!(out.contains("`engine--e` → `src/moved.rs`"));
2039        assert!(out.contains("`src/new.rs`"));
2040        // Sole-writer + commits-nothing posture (C3).
2041        assert!(out.contains("sole maintenance writer"));
2042        assert!(out.contains("commits each one **per-mutation**"));
2043        assert!(out.contains("Sync commits nothing."));
2044    }
2045
2046    /// C3 — the sync brief carries the whole absorbed reconcile judgment: the
2047    /// five conservatism rules, edge-removal conservatism, and
2048    /// rationale-not-changelog. Each rule is quoted verbatim so absorption is
2049    /// verifiable against the C4 diff artifact.
2050    #[test]
2051    fn sync_brief_absorbs_reconcile_conservatism() {
2052        let r = resolved("engine", None, vec![]);
2053        let findings = vec![finding(
2054            FindingClass::Uncovered,
2055            artifact_target("src/x.rs"),
2056            "d",
2057        )];
2058        let out = render_sync_brief(&r, &empty_cursor(), &findings, &[], false);
2059        // Five conservatism rules.
2060        assert!(out.contains("Unsure whether an entity is affected — skip it."));
2061        assert!(out.contains(
2062            "Do not create a new entity unless the change clearly introduces a new concept"
2063        ));
2064        assert!(
2065            out.contains("Do not delete an entity unless the change removes the concept entirely.")
2066        );
2067        assert!(out.contains("Never rewrite a section that has not changed"));
2068        assert!(out.contains(
2069            "No speculative edges — add only relationships the diff literally introduces"
2070        ));
2071        // Edge-removal conservatism — flags, never auto-removes.
2072        assert!(out.contains("A dropped dependency FLAGS, it does not auto-remove."));
2073        assert!(out.contains("Edge removal is out of scope for sync."));
2074        // Rationale-not-changelog.
2075        assert!(out.contains("Rationale is reasoning, not a changelog."));
2076        assert!(out.contains("`[commit <hash>]` log-style entries"));
2077    }
2078
2079    /// C3 — the first-sync/adopt framing (E1's brief half): a mem predating its
2080    /// binding is onboarding, expected-0%, with the backfill path — never a
2081    /// failure. The changed-slice reseed carries the per-facet first-sync note.
2082    #[test]
2083    fn sync_brief_renders_adopt_framing() {
2084        let mut r = resolved("engine", None, vec![]);
2085        // In a real ResolvedIngest, `name` is the canonical binding id
2086        // `<mem>/<stem>` while `destination_mem` is the mem — the header uses the
2087        // mem, the backfill command uses the binding id.
2088        r.name = "engine/graph".to_string();
2089        let out = render_sync_brief(&r, &empty_cursor(), &[], &[], true);
2090        assert!(out.contains("## First sync — adopting `engine`"));
2091        assert!(out.contains("0% anchored is expected — this is onboarding, not a failure."));
2092        assert!(out.contains("do **not** replay the whole history"));
2093        assert!(out.contains("**Backfill path:**"));
2094        assert!(out.contains("memstead projection verify engine/graph"));
2095    }
2096
2097    /// The reseed (first-sync, no cursor) framing lives in the embedded
2098    /// changed-slice preface — the sync brief inherits it for free.
2099    #[test]
2100    fn sync_brief_inherits_first_sync_reseed_framing() {
2101        let r = resolved("engine", None, vec![]);
2102        let mut cursor = empty_cursor();
2103        cursor.reseed = vec![cmd("engine/graph/src#synced", "TOK")];
2104        let out = render_sync_brief(&r, &cursor, &[], &[], false);
2105        assert!(out.contains("No usable sync baseline exists for"));
2106        assert!(out.contains("Treating the current source state as the baseline"));
2107    }
2108
2109    /// A no-work sync pass (nothing moved, no findings, not adopt) renders a
2110    /// compact "nothing to sync" note and no repair machinery — a valid outcome.
2111    #[test]
2112    fn sync_brief_nothing_to_sync() {
2113        let r = resolved("engine", None, vec![]);
2114        let out = render_sync_brief(&r, &empty_cursor(), &[], &[], false);
2115        assert!(out.contains("## Nothing to sync"));
2116        assert!(!out.contains("## How to repair"));
2117        assert!(!out.contains("## Open findings"));
2118    }
2119
2120    /// C2 REFUSAL complement — the sync brief is the ONLY render carrying repair
2121    /// instructions; the verify brief carries none. The verify brief has no
2122    /// "## How to repair" / "## Open findings to repair" block; the sync brief
2123    /// has both.
2124    #[test]
2125    fn only_sync_brief_carries_repair_instructions() {
2126        let r = resolved("engine", None, vec![]);
2127        let findings = vec![finding(
2128            FindingClass::Drifted,
2129            anchor_target("engine--e", "src/a.rs"),
2130            "d",
2131        )];
2132        let verify = render_verify_brief(&r, 1);
2133        let sync = render_sync_brief(&r, &empty_cursor(), &findings, &[], false);
2134        // Verify: no repair section, no repair verbs as instructions.
2135        assert!(!verify.contains("## How to repair"));
2136        assert!(!verify.contains("Update the affected section"));
2137        // Sync: both repair sections present.
2138        assert!(sync.contains("## How to repair — be conservative"));
2139        assert!(sync.contains("## Open findings to repair"));
2140        assert!(sync.contains("Update the affected section to match"));
2141    }
2142
2143    /// Criterion — a changed slice renders the bounded **stale-claim search**
2144    /// step: extract the changed facts, search the destination mem for claims
2145    /// about them, judge only entities whose claims mention a changed fact.
2146    #[test]
2147    fn sync_brief_changed_slice_renders_stale_claim_search() {
2148        let r = resolved("engine", None, vec![]);
2149        let cursor = SourceCursor {
2150            union: slice(&[], &["moved.rs"], &[]),
2151            write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
2152            reseed: vec![],
2153            no_signal: vec![],
2154            any_changes: true,
2155            degraded: false,
2156            dead_denies: vec![],
2157            dest_mem: "engine".to_string(),
2158            binding_id: "engine/graph".to_string(),
2159        };
2160        let out = render_sync_brief(&r, &cursor, &[], &[], false);
2161        assert!(out.contains("## Stale claims beyond the slice — search, then judge"));
2162        // The search is bound to the changed facts and the destination mem.
2163        assert!(out.contains("Extract the **changed facts** from the changed artifacts above"));
2164        assert!(out.contains("search the destination mem `engine`"));
2165        assert!(out.contains("`memstead_search`"));
2166        assert!(out.contains("judge **only** the entities whose claims actually mention"));
2167        // Bounded shape, spelled out: not a live-verify, not a rewrite license,
2168        // and an empty fact set (cosmetic change) instructs nothing.
2169        assert!(out.contains("not a live-verify of every entity"));
2170        assert!(out.contains("not a rewrite license"));
2171        assert!(out.contains("the fact set is empty and this step ends with no"));
2172        // REFUSAL complement: the never-rewrite-unchanged-sections rule still
2173        // rides the same brief — idempotence stays protected.
2174        assert!(out.contains("Never rewrite a section that has not changed"));
2175    }
2176
2177    /// REFUSAL — the stale-claim search is absent from every pass whose cursor
2178    /// carries no changed artifacts: findings-only, reseed-only (first sync),
2179    /// and nothing-to-sync briefs instruct no fact search and no mem sweep.
2180    #[test]
2181    fn sync_brief_without_changes_renders_no_stale_claim_search() {
2182        let r = resolved("engine", None, vec![]);
2183        let heading = "## Stale claims beyond the slice";
2184
2185        // Findings-only pass (source unmoved).
2186        let findings = vec![finding(
2187            FindingClass::Uncovered,
2188            artifact_target("src/x.rs"),
2189            "d",
2190        )];
2191        let out = render_sync_brief(&r, &empty_cursor(), &findings, &[], false);
2192        assert!(!out.contains(heading), "findings-only pass must not search");
2193
2194        // Reseed-only pass (first sync, no diffable slice).
2195        let mut reseed_cursor = empty_cursor();
2196        reseed_cursor.reseed = vec![cmd("engine/graph/src#synced", "TOK")];
2197        let out = render_sync_brief(&r, &reseed_cursor, &[], &[], false);
2198        assert!(!out.contains(heading), "reseed-only pass must not search");
2199
2200        // Nothing-to-sync pass.
2201        let out = render_sync_brief(&r, &empty_cursor(), &[], &[], false);
2202        assert!(!out.contains(heading));
2203    }
2204
2205    /// A large findings group caps at FINDINGS_CAP with an overflow line —
2206    /// mirroring the changed-slice cap, so no facet renders unbounded.
2207    #[test]
2208    fn sync_brief_caps_large_findings_group() {
2209        let r = resolved("engine", None, vec![]);
2210        let findings: Vec<Finding> = (0..FINDINGS_CAP + 4)
2211            .map(|i| {
2212                finding(
2213                    FindingClass::Uncovered,
2214                    artifact_target(&format!("src/f{i}.rs")),
2215                    "d",
2216                )
2217            })
2218            .collect();
2219        let out = render_sync_brief(&r, &empty_cursor(), &findings, &[], false);
2220        assert!(out.contains("- …and 4 more"));
2221        // The last few beyond the cap are not rendered inline.
2222        assert!(!out.contains(&format!("src/f{}.rs", FINDINGS_CAP + 3)));
2223    }
2224
2225    /// Criterion 8 (loop economics) — the default loop path's sync brief is
2226    /// **locked block-by-block** for a representative changed-slice pass: the
2227    /// heading sequence below is the whole brief, in this order, and nothing
2228    /// else. The only blocks this plan added to the loop path are the
2229    /// stale-claim search (criterion 1) and the head-durable findings
2230    /// presentation (criterion 2) — both locked here in place. The inventory
2231    /// operation (`projection verify --full` + the `/sync --inventory` repair
2232    /// loop) added NO block and NO line to this render, so a new block
2233    /// appearing (or one moving) fails this test and must be a deliberate
2234    /// loop-economics decision.
2235    #[test]
2236    fn sync_brief_block_sequence_locked_for_changed_slice() {
2237        let r = resolved("engine", None, vec![]);
2238        let cursor = SourceCursor {
2239            union: slice(&["gone.rs"], &["moved.rs"], &["new.rs"]),
2240            write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
2241            reseed: vec![],
2242            no_signal: vec![],
2243            any_changes: true,
2244            degraded: false,
2245            dead_denies: vec![],
2246            dest_mem: "engine".to_string(),
2247            binding_id: "engine/graph".to_string(),
2248        };
2249        let findings = vec![
2250            finding(
2251                FindingClass::Drifted,
2252                anchor_target("engine--e", "src/moved.rs"),
2253                "prepared-content hash drifted",
2254            ),
2255            finding(
2256                FindingClass::Uncovered,
2257                artifact_target("src/new.rs"),
2258                "in scope, no anchor",
2259            ),
2260        ];
2261        let out = render_sync_brief(&r, &cursor, &findings, &[], false);
2262        let headings: Vec<&str> = out
2263            .lines()
2264            .filter(|l| l.starts_with("## ") || l.starts_with("### "))
2265            .collect();
2266        assert_eq!(
2267            headings,
2268            vec![
2269                "## Sync — repair the graph to match the source",
2270                "## Source changes since the last sync",
2271                "### Recording your dispositions (do this LAST)",
2272                "## Stale claims beyond the slice — search, then judge",
2273                "## Open findings to repair",
2274                "### Drifted — the anchored content changed",
2275                "### Uncovered — a source artifact with no entity",
2276                // Deliberate addition (anchor-source plan): the sync
2277                // brief now carries the provenance instruction so
2278                // repair writes are anchored — and name their source.
2279                "## Provenance — anchor your writes",
2280                "## How to repair — be conservative",
2281            ],
2282            "the loop-path sync brief carries exactly these blocks, in this order"
2283        );
2284        // The brief closes on the conservatism block's final rule — nothing
2285        // (inventory or otherwise) rides after it.
2286        assert!(out.ends_with("`[commit <hash>]` log-style entries.\n\n"));
2287    }
2288
2289    /// Criterion 8 REFUSAL — no brief on the default (non-inventory) path
2290    /// carries any inventory machinery: not the build briefs (discovery /
2291    /// one-shot), not the verify brief, not the sync brief in any of its
2292    /// shapes (changed slice, findings-only, nothing-to-sync, adopt). The
2293    /// inventory operation lives entirely in `projection verify --full` and
2294    /// the `/sync --inventory` skill routing; the engine-side byte-compat of
2295    /// the no-flag sampled verify is asserted in
2296    /// `findings::tests::full_verify_uncaps_adjudication_and_walks_whole_source`
2297    /// (extended there, not duplicated here). The minute-loop pays nothing
2298    /// for inventory.
2299    #[test]
2300    fn no_default_path_brief_carries_inventory_machinery() {
2301        // Terms that exist only on the inventory surface (flag, skill mode,
2302        // report framing, termination rule). Matched case-insensitively.
2303        let inventory_terms = [
2304            "--full",
2305            "inventory",
2306            "full measurement",
2307            "did not converge",
2308            "quiescence",
2309        ];
2310        let assert_clean = |label: &str, text: &str| {
2311            let lower = text.to_lowercase();
2312            for term in inventory_terms {
2313                assert!(
2314                    !lower.contains(term),
2315                    "{label} must carry no inventory machinery (found {term:?})"
2316                );
2317            }
2318        };
2319
2320        let r = resolved("engine", None, vec![]);
2321        let g = guidance(Some("build coverage"), None);
2322        let pm = process_present("engine");
2323
2324        // Build briefs — with and without a changed-slice preface.
2325        let changed_cursor = SourceCursor {
2326            union: slice(&[], &["moved.rs"], &[]),
2327            write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
2328            reseed: vec![],
2329            no_signal: vec![],
2330            any_changes: true,
2331            degraded: false,
2332            dead_denies: vec![],
2333            dest_mem: "engine".to_string(),
2334            binding_id: "engine/graph".to_string(),
2335        };
2336        let preface = render_changed_slice(&changed_cursor);
2337        assert_clean(
2338            "discovery build brief (plain roam)",
2339            &assemble_discovery_brief(&r, &g, &pm, Some("s@1"), None, &[], ""),
2340        );
2341        assert_clean(
2342            "discovery build brief (changed slice)",
2343            &assemble_discovery_brief(&r, &g, &pm, Some("s@1"), None, &[], &preface),
2344        );
2345        assert_clean(
2346            "one-shot build brief",
2347            &assemble_one_shot_brief(&r, &g, &pm, Some("s@1"), None, &[], Some("purpose")),
2348        );
2349
2350        // Verify brief — with and without an adjudication backlog.
2351        assert_clean("verify brief (backlog)", &render_verify_brief(&r, 3));
2352        assert_clean("verify brief (no backlog)", &render_verify_brief(&r, 0));
2353
2354        // Sync brief — every shape the loop renders.
2355        let findings = vec![finding(
2356            FindingClass::Drifted,
2357            anchor_target("engine--e", "src/moved.rs"),
2358            "d",
2359        )];
2360        assert_clean(
2361            "sync brief (changed slice + findings)",
2362            &render_sync_brief(&r, &changed_cursor, &findings, &[], false),
2363        );
2364        assert_clean(
2365            "sync brief (findings-only)",
2366            &render_sync_brief(&r, &empty_cursor(), &findings, &[], false),
2367        );
2368        assert_clean(
2369            "sync brief (nothing to sync)",
2370            &render_sync_brief(&r, &empty_cursor(), &[], &[], false),
2371        );
2372        assert_clean(
2373            "sync brief (adopt)",
2374            &render_sync_brief(&r, &empty_cursor(), &[], &[], true),
2375        );
2376    }
2377}