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 — mirrors the plugin's
29/// `PROCESS_MEM_SCHEMA`.
30pub const PROCESS_MEM_SCHEMA: &str = "ingest@0.1.0";
31
32/// The paired-process-mem state the brief blocks read — the engine-side of
33/// the plugin's `processMem` object. Whether a process mem is present /
34/// skipped (one-shot) / failed-to-create is decided by the orchestration
35/// glue; the blocks render from this resolved view.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ProcessMemInfo {
38    /// A paired process mem exists and is usable.
39    pub present: bool,
40    /// No process mem is paired (one-shot ingests are ephemeral by design).
41    pub skipped: bool,
42    /// Auto-creation was attempted and failed; the notice explains why.
43    pub notice: Option<String>,
44    /// The process mem's leaf name (the ingest name) — its searchable id.
45    pub leaf_name: String,
46    /// The process mem's org-path label, `ingest/<name>`.
47    pub mem_label: String,
48}
49
50/// The mode string the situation block prints (`discovery` / `refinement` /
51/// `one-shot`) — the same tokens the plugin uses.
52fn mode_label(mode: BuildMode) -> &'static str {
53    match mode {
54        BuildMode::Discovery => "discovery",
55        BuildMode::OneShot => "one-shot",
56    }
57}
58
59/// The medium-type label a source line prints — the lowercase medium `type`.
60fn medium_type_label(t: MediumType) -> &'static str {
61    match t {
62        MediumType::Codebase => "codebase",
63        MediumType::Filesystem => "filesystem",
64        MediumType::Graph => "graph",
65        MediumType::Git => "git",
66        MediumType::Web => "web",
67    }
68}
69
70/// Render the `## Goal` and `## Failure modes to avoid` blocks from resolved
71/// guidance, matching the plugin's `goalAndAvoidBlock`. Each present field
72/// contributes a header, a blank line, its trimmed prose, and a trailing
73/// blank line; the block ends in a blank line. With neither field present
74/// this yields `"\n"` (the plugin's `lines.join('\n') + '\n'` for the
75/// no-pass-through case).
76///
77/// Pass-through-only guidance (a schema declaring `granularity`/`stack`/… but
78/// no goal/avoid) is not yet rendered here — that fallback
79/// (`renderResolvedGuidance`) lands with the pass-through modelling.
80pub fn render_goal_and_avoid(guidance: &ResolvedGuidance) -> String {
81    let mut lines: Vec<String> = Vec::new();
82
83    if let Some(goal) = guidance
84        .goal
85        .as_deref()
86        .map(str::trim)
87        .filter(|s| !s.is_empty())
88    {
89        lines.push("## Goal".to_string());
90        lines.push(String::new());
91        lines.push(goal.to_string());
92        lines.push(String::new());
93    }
94    if let Some(avoid) = guidance
95        .avoid
96        .as_deref()
97        .map(str::trim)
98        .filter(|s| !s.is_empty())
99    {
100        lines.push("## Failure modes to avoid".to_string());
101        lines.push(String::new());
102        lines.push(avoid.to_string());
103        lines.push(String::new());
104    }
105
106    format!("{}\n", lines.join("\n"))
107}
108
109/// Render the opening `## Situation` block — loop semantics, the mutation
110/// mandate, the context-budget signal, and the paired-process-mem line.
111/// Byte-for-byte the plugin's `situationBlock`.
112pub fn render_situation(resolved: &ResolvedIngest, process_mem: &ProcessMemInfo) -> String {
113    let mode = mode_label(resolved.mode);
114    let name = &resolved.name;
115    let mut lines: Vec<String> = Vec::new();
116    lines.push("## Situation".to_string());
117    lines.push(String::new());
118    lines.push(format!(
119        "You are running one iteration of `{name}` ({mode} mode) inside a loop. \
120         Each iteration is a fresh agent with no memory of prior runs; the destination \
121         graph persists between runs and is your continuity. Backoff is mechanical — \
122         when nothing has changed since the last run, the loop skips this ingest silently. \
123         Reporting \"no changes\" is therefore a valid outcome."
124    ));
125    lines.push(String::new());
126    lines.push(
127        "Mutating the destination is this run's mandate: within the destination mem(s) and \
128         paired process mem named under Operative data, create, update, relate, and delete \
129         entities without asking. Project-level instructions that make entity creation/deletion \
130         ask-first govern interactive dev sessions, not ingest iterations — parking creatable \
131         work as a coverage_gap because of that rule defeats the loop. Mems outside the declared \
132         destinations remain off-limits."
133            .to_string(),
134    );
135    lines.push(String::new());
136    lines.push(
137        "Context budget is finite. The `PreCompact` hook fires near the limit and asks you to \
138         stop and report. Multiple cycles inside one run are fine when context allows; depth on \
139         a coherent area beats breadth across unrelated ones."
140            .to_string(),
141    );
142    lines.push(String::new());
143    if process_mem.present {
144        lines.push(format!(
145            "A paired process mem `{}` (schema `{PROCESS_MEM_SCHEMA}`) carries destination-quality \
146             debt prior runs could not address. Its entries are objective claims about destination \
147             state — read them on orientation, write to it when this run also cannot fix some debt, \
148             delete entries the destination has since resolved. Call \
149             `memstead_schema(name={PROCESS_MEM_SCHEMA})` once for the type vocabulary and write rules.",
150            process_mem.mem_label
151        ));
152    } else if let Some(notice) = &process_mem.notice {
153        lines.push(format!(
154            "Note: paired process mem `{}` could not be auto-created — {notice}. The run continues \
155             without it; the operator can retry with `memstead mem init {name} --org-path ingest \
156             --schema {PROCESS_MEM_SCHEMA}`.",
157            process_mem.mem_label
158        ));
159    } else if process_mem.skipped {
160        lines.push(format!(
161            "No process mem is paired with this ingest (mode={mode}; one-shot ingests are \
162             by-design ephemeral)."
163        ));
164    }
165    lines.push(String::new());
166    format!("{}\n", lines.join("\n"))
167}
168
169/// Render the `## About the source` block from the projection's intent, or
170/// the empty string when there is no intent. Byte-for-byte the plugin's
171/// `intentBlock`.
172pub fn render_intent(resolved: &ResolvedIngest) -> String {
173    match resolved
174        .intent
175        .as_deref()
176        .map(str::trim)
177        .filter(|s| !s.is_empty())
178    {
179        Some(intent) => format!("## About the source\n\n{intent}\n\n"),
180        None => String::new(),
181    }
182}
183
184/// Render the `## Operative data` block — the sources (with their scope), the
185/// destination (with its schema), and the paired process mem. Byte-for-byte
186/// the plugin's `operativeDataBlock`. `destination_schema` is the schema ref
187/// the destination mem pins (from `memMeta`), rendered when present.
188///
189/// A source facet's `domains` (web mediums) is not rendered — the engine's
190/// facet scope models allow/deny paths only; the domains slot lands with web
191/// medium support.
192pub fn render_operative_data(
193    resolved: &ResolvedIngest,
194    process_mem: &ProcessMemInfo,
195    destination_schema: Option<&str>,
196) -> String {
197    let mut lines: Vec<String> = Vec::new();
198    lines.push("## Operative data".to_string());
199    lines.push(String::new());
200
201    // Sources
202    if !resolved.sources.is_empty() {
203        lines.push("### Sources".to_string());
204        lines.push(String::new());
205        let mut reference_mems: Vec<String> = Vec::new();
206        for source in &resolved.sources {
207            match source {
208                ResolvedSource::Primary(p) => {
209                    lines.push(format!(
210                        "- **{}** (primary)",
211                        medium_type_label(p.medium_type)
212                    ));
213                    let allows: Vec<&str> = p
214                        .scope
215                        .iter()
216                        .filter(|r| r.mode == PatternMode::Allow)
217                        .map(|r| r.path.as_str())
218                        .collect();
219                    let denies: Vec<&str> = p
220                        .scope
221                        .iter()
222                        .filter(|r| r.mode == PatternMode::Deny)
223                        .map(|r| r.path.as_str())
224                        .collect();
225                    if !allows.is_empty() {
226                        lines.push(format!("  - Paths: {}", allows.join(", ")));
227                    }
228                    if !denies.is_empty() {
229                        lines.push(format!("  - Ignore: {}", denies.join(", ")));
230                    }
231                }
232                ResolvedSource::Reference { mem } => {
233                    lines.push(format!("- **graph** (reference) — mem: {mem}"));
234                    reference_mems.push(mem.clone());
235                }
236            }
237        }
238        lines.push(String::new());
239        if !reference_mems.is_empty() {
240            lines.push(
241                "Sources tagged `(reference)` are read-only context for cross-mem edges — search \
242                 them, never write into them. Only `(primary)` sources are ingested into the \
243                 destination."
244                    .to_string(),
245            );
246            lines.push(String::new());
247            let mem_list = reference_mems
248                .iter()
249                .map(|v| format!("`memstead_search mem={v}`"))
250                .collect::<Vec<_>>()
251                .join(", ");
252            lines.push(format!(
253                "**Cross-mem references:** consult {mem_list} before authoring cross-mem edges. \
254                 The target entity must exist — a wiki-link or relationship to a missing target \
255                 either auto-stubs (silent) or fails authorization (`CROSS_MEM_RELATION`)."
256            ));
257            lines.push(String::new());
258        }
259    }
260
261    // Destination — four-primitive projections carry exactly one, no role.
262    lines.push("### Destination".to_string());
263    lines.push(String::new());
264    let schema_bit = destination_schema
265        .map(|s| format!(" — schema: `{s}`"))
266        .unwrap_or_default();
267    lines.push(format!("- **{}**{schema_bit}", resolved.destination_mem));
268    lines.push(String::new());
269
270    // Paired process mem
271    if process_mem.present {
272        lines.push("### Paired process mem".to_string());
273        lines.push(String::new());
274        lines.push(format!(
275            "- **{}** — schema: `{PROCESS_MEM_SCHEMA}`. Inspect via `memstead_overview` / \
276             `memstead_search mem={}`.",
277            process_mem.mem_label, process_mem.leaf_name
278        ));
279        lines.push(String::new());
280    }
281
282    format!("{}\n", lines.join("\n"))
283}
284
285/// One baseline token a facet's cursor advances to after a full pass — the
286/// `(sync_state key, medium-typed token)` pair the engine records via the
287/// `set_mem_sync_state` writer. Produced by the cursor; the brief no longer
288/// renders it as an operator command (the agent runs `projection advance`,
289/// which computes and records the token engine-side — D4/D7).
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub struct SyncCommand {
292    /// The sync-state key, `"<binding-id>/<facet>#synced"` (D4).
293    pub key: String,
294    /// The opaque new-baseline token.
295    pub token: String,
296}
297
298/// A source whose change detection produced **no usable signal** this pass,
299/// with the classified [`NoSignalReason`]. Rendered as a distinct per-source
300/// note in the changed-slice preface, so the agent can tell a *blind* source
301/// (no baseline comparison happened) from a *genuinely-unchanged* one (checked,
302/// did not move — which stays silent).
303#[derive(Debug, Clone, PartialEq, Eq)]
304pub struct NoSignalNote {
305    /// The source's label — the facet ref (primary) or mem id (reference), the
306    /// same token the `<ingest>/<label>` sync-state key is built from.
307    pub source: String,
308    /// Why detection produced no signal.
309    pub reason: NoSignalReason,
310}
311
312/// The combined source-cursor across a projection's source facets — the
313/// engine-side of the plugin's `cursor` object that `changedSliceBlock`
314/// consumes. Assembled by [`super::cursor::compute_source_cursor`] from the
315/// per-facet [`super::slice::SliceOutcome`]s.
316#[derive(Debug, Clone, PartialEq, Eq)]
317pub struct SourceCursor {
318    /// The combined changed slice across all source facets.
319    pub union: Slice,
320    /// New-baseline commands for facets that changed.
321    pub write_commands: Vec<SyncCommand>,
322    /// New-baseline commands for facets seen for the first time (reseed).
323    pub reseed: Vec<SyncCommand>,
324    /// Per-source no-signal notes — sources whose detection could not produce a
325    /// slice this pass (unscoped facet, `signal:none`, git failure, missing
326    /// graph snapshot). Rendered distinctly from changed/reseed; a
327    /// genuinely-unchanged source contributes nothing here, so an all-unchanged
328    /// brief still renders no preface (byte-identical to a plain roam).
329    pub no_signal: Vec<NoSignalNote>,
330    /// Whether any facet reported changes (drives the "source moved" copy).
331    pub any_changes: bool,
332    /// Whether any facet's slice was degraded (mtime memo miss → full scan).
333    pub degraded: bool,
334    /// Ingest `deny_paths` entries that matched **no file** anywhere the agent
335    /// can reach (the project tree). A zero-selecting deny is surfaced as a
336    /// rendered warning rather than silently no-op'ing — it catches typos and
337    /// un-migrated legacy bare names (which, as globs, match nothing). Never a
338    /// hard error: the ingest still runs, the entry just does nothing.
339    pub dead_denies: Vec<String>,
340    /// The destination mem whose `sync_state` the baseline tokens live on.
341    pub dest_mem: String,
342    /// The canonical binding id `<mem>/<stem>` (D3) — rendered into the
343    /// `memstead projection advance <binding-id> …` line the changed-slice
344    /// preface now emits instead of a raw `mem set-sync-state` command (D4/D7).
345    pub binding_id: String,
346}
347
348/// Single-quote a value for the emitted shell command, escaping embedded
349/// single quotes. The digest token is JSON (contains `"` and `:`), so it
350/// must be quoted to survive the shell. Mirrors the plugin's `shellQuote`.
351fn shell_quote(s: &str) -> String {
352    format!("'{}'", s.replace('\'', "'\\''"))
353}
354
355/// Render one changed-slice class (Deleted / Modified / Added), capped at
356/// [`SLICE_CAP`] with a `…and N more` overflow line.
357fn render_slice_class(lines: &mut Vec<String>, label: &str, paths: &[String]) {
358    if paths.is_empty() {
359        return;
360    }
361    let shown = paths.len().min(SLICE_CAP);
362    lines.push(format!("**{label}:**"));
363    for path in &paths[..shown] {
364        lines.push(format!("- `{path}`"));
365    }
366    if paths.len() > shown {
367        lines.push(format!(
368            "- …and {} more {}",
369            paths.len() - shown,
370            label.to_lowercase()
371        ));
372    }
373    lines.push(String::new());
374}
375
376/// The one-line explanation the brief prints for a [`NoSignalReason`] — each
377/// reason renders as distinct text, so the agent can tell the no-signal
378/// conditions apart (and all apart from a genuinely-unchanged source, which
379/// renders nothing at all).
380fn no_signal_reason_text(reason: NoSignalReason) -> &'static str {
381    match reason {
382        NoSignalReason::Unscoped => {
383            "unscoped facet (no allow patterns) — nothing is monitored; write `**/*` in the \
384             facet scope to watch the whole medium"
385        }
386        NoSignalReason::DetectionNone => {
387            "`signal:none` — change detection is disabled for this source (declared `none`)"
388        }
389        NoSignalReason::GitUnavailable => {
390            "git signal unavailable — no work tree, an unreadable `HEAD`, or an unknown baseline; \
391             a full re-roam is warranted this pass"
392        }
393        NoSignalReason::GraphSnapshotMissing => {
394            "graph snapshot missing — the source mem has no comparable baseline this pass"
395        }
396    }
397}
398
399/// Render the `## Source changes since the last sync` preface — the changed
400/// slice to steer at first, any no-signal sources, plus the `projection advance`
401/// "record your dispositions LAST" section. Extends the plugin's `changedSliceBlock`
402/// with the no-signal notes. Returns the empty string when nothing changed,
403/// nothing needs reseeding, and every source is genuinely unchanged (no
404/// no-signal notes) — making the brief byte-identical to a plain roam.
405pub fn render_changed_slice(cursor: &SourceCursor) -> String {
406    if !cursor.any_changes
407        && cursor.reseed.is_empty()
408        && cursor.no_signal.is_empty()
409        && cursor.dead_denies.is_empty()
410    {
411        return String::new();
412    }
413    let mut lines: Vec<String> = Vec::new();
414    lines.push("## Source changes since the last sync\n".to_string());
415
416    if cursor.any_changes {
417        lines.push(
418            "The source moved since this graph was last synced. Steer this pass at these changed \
419             artifacts **first** — they are where the graph is most likely now wrong.\n"
420                .to_string(),
421        );
422        // Deletions first — cheapest, highest-signal drift.
423        render_slice_class(&mut lines, "Deleted", &cursor.union.deleted);
424        render_slice_class(&mut lines, "Modified", &cursor.union.modified);
425        render_slice_class(&mut lines, "Added", &cursor.union.added);
426        if cursor.degraded {
427            lines.push(
428                "_(Precise change history for one or more facets was unavailable, so its full \
429                 current file set is listed above. Detection still fired from the durable baseline; \
430                 targeting is coarser this pass only.)_\n"
431                    .to_string(),
432            );
433        }
434    }
435
436    if !cursor.reseed.is_empty() {
437        let keys = cursor
438            .reseed
439            .iter()
440            .map(|r| format!("`{}`", r.key))
441            .collect::<Vec<_>>()
442            .join(", ");
443        let it = if cursor.reseed.len() == 1 {
444            "it"
445        } else {
446            "them"
447        };
448        lines.push(format!(
449            "No prior sync baseline exists for {keys} — treating the current source state as the \
450             baseline (first sync). No priority slice from {it} this pass; proceed as usual.\n"
451        ));
452    }
453
454    if !cursor.no_signal.is_empty() {
455        lines.push(
456            "Some sources produced **no change signal** this pass — detection could not compare \
457             them against a baseline, so they were not steered (roam them as usual). This is \
458             distinct from a source that was checked and had not moved:\n"
459                .to_string(),
460        );
461        for note in &cursor.no_signal {
462            lines.push(format!(
463                "- `{}`: {}",
464                note.source,
465                no_signal_reason_text(note.reason)
466            ));
467        }
468        lines.push(String::new());
469    }
470
471    if !cursor.dead_denies.is_empty() {
472        lines.push(
473            "**Warning — some `deny_paths` entries match nothing.** The following ingest \
474             `deny_paths` selected **no file** in the project tree, so they exclude nothing from \
475             the slice and hide nothing from the ingest agent. This is usually a typo or a legacy \
476             bare name that never migrated to the workspace-relative glob dialect (e.g. `dev` → \
477             `dev/**`, `VISION.md` → `**/VISION.md`). Fix or remove them:\n"
478                .to_string(),
479        );
480        for entry in &cursor.dead_denies {
481            lines.push(format!("- `{entry}`"));
482        }
483        lines.push(String::new());
484    }
485
486    // Disposition-record instruction — the agent's FINAL step. The advance is
487    // resumable and non-stalling (D7): a partial pass is honored on disk, and a
488    // source that moves mid-pass re-presents (remaining + new) without losing
489    // recorded work. The agent runs `projection advance`, which computes and
490    // records the new baseline token engine-side — the brief no longer renders a
491    // raw `mem set-sync-state` command (D4). The block appears whenever there is
492    // a baseline to advance (a changed facet or a first-sync reseed).
493    let has_baseline_to_advance = !cursor.write_commands.is_empty() || !cursor.reseed.is_empty();
494    if has_baseline_to_advance {
495        lines.push("### Recording your dispositions (do this LAST)\n".to_string());
496        lines.push(
497            "Only after you have worked the changed artifacts above — and only for the artifacts \
498             you actually judged — record a disposition for each, so the next pass targets just \
499             what changes next. This advance is resumable and non-stalling: a partial pass is \
500             honored, and if the source moves mid-pass the remaining slice re-presents \
501             (remaining + new) without losing your recorded work.\n"
502                .to_string(),
503        );
504        lines.push(
505            "In this window you supply a disposition for **every** artifact explicitly \
506             (auto-derivation lands in a later cycle). The gate accepts only artifact ids listed \
507             above — an unknown id refuses the whole call. When every artifact is disposed, the \
508             sync baseline advances automatically. Run:\n"
509                .to_string(),
510        );
511        lines.push("```sh".to_string());
512        lines.push(format!(
513            "memstead projection advance {} --dispositions {}",
514            cursor.binding_id,
515            shell_quote(r#"{"<artifact>": "<disposition>", ...}"#)
516        ));
517        lines.push("```".to_string());
518        lines.push(
519            "If you were interrupted before finishing, that is fine — your recorded dispositions \
520             persist, and the next run re-presents only what is left.\n"
521                .to_string(),
522        );
523    }
524
525    format!("{}\n", lines.join("\n"))
526}
527
528/// Assemble the discovery-mode brief — situation, about-the-source, goal/avoid,
529/// operative-data, and the changed-slice preface — concatenating the truthy
530/// blocks, matching the plugin's `parts.filter(Boolean).join('')`.
531/// `changed_slice_preface` is the rendered changed-slice block (empty when
532/// the source has not moved, making the brief byte-identical to a plain roam).
533pub fn assemble_discovery_brief(
534    resolved: &ResolvedIngest,
535    guidance: &ResolvedGuidance,
536    process_mem: &ProcessMemInfo,
537    destination_schema: Option<&str>,
538    changed_slice_preface: &str,
539) -> String {
540    let parts = [
541        render_situation(resolved, process_mem),
542        render_intent(resolved),
543        render_goal_and_avoid(guidance),
544        render_operative_data(resolved, process_mem, destination_schema),
545        changed_slice_preface.to_string(),
546    ];
547    parts
548        .into_iter()
549        .filter(|p| !p.is_empty())
550        .collect::<Vec<_>>()
551        .join("")
552}
553
554/// Render the `## Mode: one-shot — lens routing` block — the destination-set
555/// table, optional routing rule, idempotency contract, end-of-run report
556/// template, and optional archive note. Byte-for-byte the plugin's
557/// `oneShotLensBlock`. `destination_schema` / `destination_purpose` describe
558/// the ingest's single destination (four-primitive projections have one).
559pub fn render_one_shot_lens(
560    resolved: &ResolvedIngest,
561    destination_schema: Option<&str>,
562    destination_purpose: Option<&str>,
563) -> String {
564    let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
565    let mut lines: Vec<String> = vec![
566        "## Mode: one-shot — lens routing".to_string(),
567        String::new(),
568        "A lens iterates entities once and writes per-destination, then exits. The agent decides \
569         per-entity which destinations to target (Routing rule). Re-runs use `memstead_update`; \
570         never duplicate."
571            .to_string(),
572        String::new(),
573    ];
574
575    lines.push("### Destination set".to_string());
576    lines.push(String::new());
577    lines.push("| Mem | Schema | Purpose |".to_string());
578    lines.push("|-------|--------|---------|".to_string());
579    let schema = destination_schema.unwrap_or("(none)");
580    let purpose = destination_purpose
581        .filter(|s| !s.is_empty())
582        .unwrap_or("(no purpose declared)");
583    lines.push(format!(
584        "| {} | {} | {} |",
585        cell(&resolved.destination_mem),
586        cell(schema),
587        cell(purpose)
588    ));
589    lines.push(String::new());
590
591    if let Some(routing) = resolved
592        .rules
593        .as_ref()
594        .and_then(|r| r.get("routing"))
595        .and_then(|v| v.as_str())
596        .map(str::trim)
597        .filter(|s| !s.is_empty())
598    {
599        lines.push("### Routing rule".to_string());
600        lines.push(String::new());
601        lines.push("```".to_string());
602        lines.push(routing.to_string());
603        lines.push("```".to_string());
604        lines.push(String::new());
605    }
606
607    lines.push("### Idempotency".to_string());
608    lines.push(String::new());
609    lines.push("- Search the destination before writing; route changes through `memstead_update` against the existing entity if present.".to_string());
610    lines.push("- Skip writes when the lifted content matches what is already there (record as `skipped: already-up-to-date`).".to_string());
611    lines.push(
612        "- Use `memstead_create` only when no entity for that concept exists yet.".to_string(),
613    );
614    lines.push(String::new());
615
616    lines.push("### End-of-run report".to_string());
617    lines.push(String::new());
618    lines.push("After every destination is processed, emit one block per destination on stdout, in Destination-set order:".to_string());
619    lines.push(String::new());
620    lines.push("```".to_string());
621    lines.push(format!("### Report: {}", resolved.name));
622    lines.push(String::new());
623    lines.push("Destination: <mem>".to_string());
624    lines.push("  created: <count>".to_string());
625    lines.push("  updated: <count>".to_string());
626    lines.push("  skipped: <count>".to_string());
627    lines.push("  failed:  <count>".to_string());
628    lines.push("  failures:".to_string());
629    lines.push("    - <entity-key>: <error verbatim>".to_string());
630    lines.push("  skipped-detail:".to_string());
631    lines.push("    - <entity-key>: <one-line reason>".to_string());
632    lines.push("```".to_string());
633    lines.push(String::new());
634    lines.push("Per-destination commits are independent — partial success is the accepted failure mode. No rollback.".to_string());
635    lines.push(String::new());
636
637    let archive = resolved
638        .post_actions
639        .as_ref()
640        .and_then(|p| p.get("archive_source"))
641        .and_then(serde_json::Value::as_bool)
642        .unwrap_or(false);
643    if archive {
644        lines.push("### Archive after run".to_string());
645        lines.push(String::new());
646        lines.push("After the report has been emitted, archive the source planning mem — `post_actions.archive_source` is set on this ingest.".to_string());
647        lines.push(String::new());
648    }
649
650    format!("{}\n", lines.join("\n"))
651}
652
653/// Assemble the one-shot brief — situation, about-the-source, goal/avoid,
654/// operative-data, and the lens-routing block. Mirrors the plugin's one-shot
655/// `parts`. A one-shot ingest has no paired process mem, so `process_mem`
656/// should carry `skipped = true`.
657pub fn assemble_one_shot_brief(
658    resolved: &ResolvedIngest,
659    guidance: &ResolvedGuidance,
660    process_mem: &ProcessMemInfo,
661    destination_schema: Option<&str>,
662    destination_purpose: Option<&str>,
663) -> String {
664    let parts = [
665        render_situation(resolved, process_mem),
666        render_intent(resolved),
667        render_goal_and_avoid(guidance),
668        render_operative_data(resolved, process_mem, destination_schema),
669        render_one_shot_lens(resolved, destination_schema, destination_purpose),
670    ];
671    parts
672        .into_iter()
673        .filter(|p| !p.is_empty())
674        .collect::<Vec<_>>()
675        .join("")
676}
677
678// ---------------------------------------------------------------------------
679// Verify + sync briefs (group C) — the measure/repair surface beside the build
680// briefs. Verify MEASURES (no destination mutation of any kind, C1); sync is the
681// SOLE maintenance writer, carrying BOTH the cursor slice and the open findings
682// in one brief (C2) with the whole of `/reconcile`'s absorbed judgment (C3). A
683// rule-by-rule absorption map records where each retired reconcile rule now
684// lives (bundle plan `05-verify-sync-engine`, C4).
685// ---------------------------------------------------------------------------
686
687use super::findings::{Finding, FindingClass, FindingTarget};
688use super::prune::{PruneDisposition, PruneProposal};
689
690/// Per-class cap on the rendered open-findings list — mirrors [`SLICE_CAP`].
691const FINDINGS_CAP: usize = SLICE_CAP;
692
693/// Render the **verify brief** (C1) — the measurement + capped-adjudication
694/// prompt an agent consumes to *measure* a binding's fidelity.
695///
696/// **Refusal (C1), structural:** this function emits **no destination-mutation
697/// instruction of any kind**. It tells the agent what to measure and adjudicate,
698/// never what to write into the destination mem — every repair is recorded as a
699/// finding for the sync brief ([`render_sync_brief`]) to act on. There is no
700/// create / update / relate / delete instruction anywhere in the rendered text.
701pub fn render_verify_brief(resolved: &ResolvedIngest, backlog: usize) -> String {
702    let mut lines: Vec<String> = vec![
703        "## Verify — measure fidelity, do not mutate".to_string(),
704        String::new(),
705    ];
706    lines.push(format!(
707        "You are measuring the fidelity of `{}` — how faithfully the destination mem \
708         `{}` still matches its source. This pass **only measures**: read the source \
709         and the mem's anchors, judge whether the graph still holds, and record what \
710         you find. Nothing here writes into the destination mem.",
711        resolved.name, resolved.destination_mem
712    ));
713    lines.push(String::new());
714
715    lines.push("### Adjudicate the queued findings (capped)".to_string());
716    lines.push(String::new());
717    if backlog == 0 {
718        lines.push(
719            "No findings are queued for adjudication this pass. Spot-check the resolving \
720             anchors and the uncovered-artifact sample the fidelity report lists, and \
721             record any drift you observe as a finding."
722                .to_string(),
723        );
724    } else {
725        lines.push(format!(
726            "{backlog} finding(s) are queued for adjudication. Working up to the per-run \
727             adjudication cap (an operations knob — the remainder stays queued and \
728             re-presents on a later pass), take each queued finding and compare the \
729             anchored source content against what the entity records. Classify it: still \
730             accurate, or drifted. **Record the verdict — this is a measurement, not a \
731             repair.** A drift you record becomes a finding the sync pass repairs; you do \
732             not fix it here."
733        ));
734    }
735    lines.push(String::new());
736
737    lines.push("### Out of scope for verify — no mutation".to_string());
738    lines.push(String::new());
739    lines.push(
740        "Verify writes **nothing** into the destination mem. Do not update a \
741         `specifies` / `constraints` section, do not create or delete an entity, do not \
742         add or remove a relationship. When measurement shows the graph is wrong, that \
743         is a **finding** — the sync brief (`memstead projection brief --sync`) is the \
744         one place those repairs are made. Leave every fix to it."
745            .to_string(),
746    );
747    lines.push(String::new());
748
749    format!("{}\n", lines.join("\n"))
750}
751
752/// A compact `entity → artifact` (or bare artifact) label for a finding target.
753fn finding_target_label(target: &FindingTarget) -> String {
754    match target {
755        FindingTarget::Anchor { entity, artifact } => format!("`{entity}` → `{artifact}`"),
756        FindingTarget::Artifact { artifact } => format!("`{artifact}`"),
757    }
758}
759
760/// Render one class-grouped findings section, capped at [`FINDINGS_CAP`] with a
761/// `…and N more` overflow line. Skips an empty group entirely.
762fn render_findings_group(
763    lines: &mut Vec<String>,
764    heading: &str,
765    guidance: &str,
766    items: &[&Finding],
767) {
768    if items.is_empty() {
769        return;
770    }
771    lines.push(format!("### {heading}"));
772    lines.push(String::new());
773    lines.push(guidance.to_string());
774    lines.push(String::new());
775    let shown = items.len().min(FINDINGS_CAP);
776    for f in &items[..shown] {
777        lines.push(format!(
778            "- {} — {}",
779            finding_target_label(&f.target),
780            f.detail
781        ));
782    }
783    if items.len() > shown {
784        lines.push(format!("- …and {} more", items.len() - shown));
785    }
786    lines.push(String::new());
787}
788
789/// Render the open-findings block for the sync brief (C2) — the findings
790/// `findings_store.current(key)` returned, grouped by class, each carrying the
791/// conservative repair guidance the reconcile rules (C3) mandate. Empty string
792/// when there are no open findings.
793fn render_open_findings(findings: &[Finding]) -> String {
794    if findings.is_empty() {
795        return String::new();
796    }
797    let mut lines: Vec<String> = vec![
798        "## Open findings to repair".to_string(),
799        String::new(),
800        "The verify pass recorded these against the current source state. Repair them \
801         conservatively (see the rules below); a finding you judge already correct needs \
802         no write."
803            .to_string(),
804        String::new(),
805    ];
806
807    let group = |class: FindingClass| -> Vec<&Finding> {
808        findings.iter().filter(|f| f.class == class).collect()
809    };
810
811    // Drifted / wrong — the anchored content changed: update only what moved
812    // (conservatism rule "never rewrite unchanged sections").
813    render_findings_group(
814        &mut lines,
815        "Drifted — the anchored content changed",
816        "The source the entity describes moved. Update the affected section to match — \
817         only the part that changed. If the entity is still accurate, leave it.",
818        &group(FindingClass::Drifted),
819    );
820    render_findings_group(
821        &mut lines,
822        "Wrong — an adjudicated content mismatch",
823        "Adjudication found the entity no longer matches its source. Correct the \
824         mismatched section; do not rewrite what still holds.",
825        &group(FindingClass::Wrong),
826    );
827    // Unresolvable anchor — the artifact is gone: delete only if the concept is
828    // removed entirely (conservatism rule "no deletion unless concept removed").
829    render_findings_group(
830        &mut lines,
831        "Unresolvable anchor — the artifact is gone",
832        "The source artifact an anchor references is no longer present. Delete the entity \
833         **only** if the concept is removed entirely; otherwise leave it. Concept-level \
834         removals are a prune concern with its own never-clobber / conflict-flag rules — \
835         do not delete on a hunch here.",
836        &group(FindingClass::UnresolvableAnchor),
837    );
838    // Uncovered — a source artifact with no entity: create only for a clearly-new
839    // concept (conservatism rule "no new entities unless clearly-new concept").
840    render_findings_group(
841        &mut lines,
842        "Uncovered — a source artifact with no entity",
843        "An in-scope source artifact has no anchor in the mem. Create an entity for it \
844         **only** if it is a clearly-new concept with no existing entity; otherwise \
845         extend the entity that already owns the concept, or leave it for a discovery \
846         build.",
847        &group(FindingClass::Uncovered),
848    );
849    // Queued — not yet adjudicated: verify owns these, not sync.
850    render_findings_group(
851        &mut lines,
852        "Queued for adjudication — not yet judged",
853        "These are not adjudicated yet — that is the verify pass's job, not sync's. \
854         **Skip them here**; they become repairable only after verify classifies them as \
855         drifted.",
856        &group(FindingClass::QueuedForAdjudication),
857    );
858
859    format!("{}\n", lines.join("\n"))
860}
861
862/// Render the prune-proposals block for the sync brief (group F) — the deletion
863/// proposals prune surfaced, each with its guarantee-appropriate treatment.
864/// Empty string when there are no proposals.
865///
866/// **F3 / A5, structural:** every proposal here is exactly that — a *proposal*.
867/// Nothing in this text (nor anywhere in the engine) deletes an entity; the
868/// removal reaches the mem **only** when the agent acts on this brief through the
869/// MCP mutation surface. `authored` entities never reach this block (prune
870/// excludes them upstream); `derived` entities are flagged with their inputs,
871/// never proposed for deletion.
872fn render_prune_proposals(proposals: &[PruneProposal]) -> String {
873    if proposals.is_empty() {
874        return String::new();
875    }
876    let mut lines: Vec<String> = vec![
877        "## Prune — proposed removals (you decide; nothing is auto-deleted)".to_string(),
878        String::new(),
879        "The source removed the artifacts these entities describe. Each item below is a \
880         **proposal**: prune writes nothing — you enact (or reject) the removal through the \
881         normal MCP mutation surface. An `authored` entity is never proposed here; a `derived` \
882         entity is flagged, never proposed for deletion."
883            .to_string(),
884        String::new(),
885    ];
886
887    let group = |d: PruneDisposition| -> Vec<&PruneProposal> {
888        proposals.iter().filter(|p| p.disposition == d).collect()
889    };
890
891    // Clean-delete — never-clobber, base retrieved, merge clean: a confident
892    // (still agent-enacted) delete proposal.
893    let clean = group(PruneDisposition::CleanDelete);
894    if !clean.is_empty() {
895        lines.push("### Clean delete — never-clobber three-way merge is clean".to_string());
896        lines.push(String::new());
897        lines.push(
898            "The source base leg was retrievable and the three-way merge found no model-side \
899             divergence, so removal is safe. **Confirm, then delete via the mutation surface** — \
900             this is still your call, not an auto-delete."
901                .to_string(),
902        );
903        lines.push(String::new());
904        let shown = clean.len().min(FINDINGS_CAP);
905        for p in &clean[..shown] {
906            lines.push(format!(
907                "- `{}` — source artifact(s) gone: {}",
908                p.entity,
909                artifact_list(&p.artifacts)
910            ));
911        }
912        if clean.len() > shown {
913            lines.push(format!("- …and {} more", clean.len() - shown));
914        }
915        lines.push(String::new());
916    }
917
918    // Conflict-flag — both sides presented, never an auto-write over an edit.
919    let conflict = group(PruneDisposition::ConflictFlag);
920    if !conflict.is_empty() {
921        lines.push("### Conflict-flag — decide, never overwrite a model-side edit".to_string());
922        lines.push(String::new());
923        lines.push(
924            "No retrievable base leg to merge against (a non-git source, or an anchor with no \
925             pinned version). **Both sides are shown — decide deliberately.** If the concept is \
926             truly gone, delete via the mutation surface; if the model side was edited on \
927             purpose, keep it. Prune never overwrites a model-side edit for you."
928                .to_string(),
929        );
930        lines.push(String::new());
931        let shown = conflict.len().min(FINDINGS_CAP);
932        for p in &conflict[..shown] {
933            lines.push(format!(
934                "- `{}` — **source side:** artifact(s) gone: {}; **model side:** the entity is \
935                 still present (may carry edits) — you decide.",
936                p.entity,
937                artifact_list(&p.artifacts)
938            ));
939        }
940        if conflict.len() > shown {
941            lines.push(format!("- …and {} more", conflict.len() - shown));
942        }
943        lines.push(String::new());
944    }
945
946    // Derived-flagged — flagged with inputs, never proposed for deletion (F3).
947    let derived = group(PruneDisposition::DerivedFlagged);
948    if !derived.is_empty() {
949        lines.push("### Derived — flagged, NOT proposed for deletion".to_string());
950        lines.push(String::new());
951        lines.push(
952            "These entities were **derived** from other inputs. A derived entity is flagged, \
953             never auto-proposed for deletion — its inputs may still hold even though one source \
954             artifact vanished. Re-examine the inputs before removing anything."
955                .to_string(),
956        );
957        lines.push(String::new());
958        let shown = derived.len().min(FINDINGS_CAP);
959        for p in &derived[..shown] {
960            let inputs = if p.derived_inputs.is_empty() {
961                "(no recorded inputs)".to_string()
962            } else {
963                artifact_list(&p.derived_inputs)
964            };
965            lines.push(format!(
966                "- `{}` — derived from: {}; source artifact(s) gone: {}.",
967                p.entity,
968                inputs,
969                artifact_list(&p.artifacts)
970            ));
971        }
972        if derived.len() > shown {
973            lines.push(format!("- …and {} more", derived.len() - shown));
974        }
975        lines.push(String::new());
976    }
977
978    format!("{}\n", lines.join("\n"))
979}
980
981/// A compact backtick-joined artifact list.
982fn artifact_list(artifacts: &[String]) -> String {
983    if artifacts.is_empty() {
984        return "(none)".to_string();
985    }
986    artifacts
987        .iter()
988        .map(|a| format!("`{a}`"))
989        .collect::<Vec<_>>()
990        .join(", ")
991}
992
993/// Render the sync brief's `## Situation` block — the sole-maintenance-writer
994/// mandate and the commits-nothing / engine-commits-per-mutation posture (C3).
995fn render_sync_situation(resolved: &ResolvedIngest) -> String {
996    format!(
997        "## Sync — repair the graph to match the source\n\n\
998         You are running the sync pass for `{}`. Sync is the graph's **sole maintenance \
999         writer**: the only place the destination mem `{}` is repaired to match its \
1000         source. Two inputs steer this pass — the source changes since the last sync, and \
1001         the open verify findings — both below. Work them: update, create, relate, and \
1002         (rarely) delete entities so the graph again matches the source.\n\n\
1003         Every mutation routes through the normal MCP mutation surface, and the engine \
1004         commits each one **per-mutation** to the mem's own gitdir. You **stage nothing \
1005         and commit nothing yourself** — not the graph, not the code. Sync commits \
1006         nothing.\n\n",
1007        resolved.name, resolved.destination_mem
1008    )
1009}
1010
1011/// Render the adopt / onboarding block (C3's first-sync/adopt framing; E1's
1012/// brief half): a mem that predates its binding is onboarding, expected-0%, with
1013/// the concrete backfill path — never a failure or red verdict.
1014fn render_adopt_framing(resolved: &ResolvedIngest) -> String {
1015    format!(
1016        "## First sync — adopting `{}`\n\n\
1017         This mem predates its binding: it has no anchors and no prior sync baseline, so \
1018         **0% anchored is expected — this is onboarding, not a failure.** Do not read it \
1019         as drift or a red verdict. There is no cursor to diff against, so the baseline is \
1020         the **current** source HEAD — do **not** replay the whole history; treat the \
1021         current source state as the starting point, and this is a **first sync**.\n\n\
1022         **Backfill path:** run `memstead projection verify {}` to enumerate the in-scope \
1023         source artifacts that carry no entity yet, then cover the clearly-new concepts \
1024         among them through the normal MCP mutation surface — the same conservative rules \
1025         below apply. Backfilling is incremental: a partial pass is fine, and the next \
1026         sync continues where you left off.\n\n",
1027        resolved.destination_mem, resolved.name
1028    )
1029}
1030
1031/// Render the sync brief's conservatism block — the whole of `/reconcile`'s
1032/// absorbed judgment (C3): the five conservatism rules, edge-removal
1033/// conservatism, and rationale-not-changelog.
1034fn render_sync_conservatism() -> String {
1035    let lines: Vec<&str> = vec![
1036        "## How to repair — be conservative",
1037        "",
1038        "Repair only what the source changes and the findings above actually justify:",
1039        "",
1040        // The five conservatism rules.
1041        "- **Unsure whether an entity is affected — skip it.** A missed update is a later \
1042         finding; a wrong rewrite is damage.",
1043        "- **Do not create a new entity unless the change clearly introduces a new concept \
1044         with no existing entity.** Prefer updating the entity that already owns the \
1045         concept.",
1046        "- **Do not delete an entity unless the change removes the concept entirely.** \
1047         Deletions a prune pass surfaces follow prune's own never-clobber / conflict-flag \
1048         rules — never delete on a hunch here.",
1049        "- **Never rewrite a section that has not changed** — touch only the part the \
1050         change or finding actually affects.",
1051        "- **No speculative edges — add only relationships the diff literally introduces** \
1052         (a new `use` / `import` / dependency you can point at in the change).",
1053        // Edge-removal conservatism.
1054        "- **A dropped dependency FLAGS, it does not auto-remove.** If the change removes an \
1055         import or dependency, leave the matching edge intact and note it for a later \
1056         audit — removals are ambiguous (temporary refactor vs. permanent cut), and a \
1057         stale edge is less damaging than an erased real one. **Edge removal is out of \
1058         scope for sync.**",
1059        // Rationale-not-changelog.
1060        "- **Rationale is reasoning, not a changelog.** When you record why a change was \
1061         made, append the *reasoning* (why this approach, which trade-offs) — never \
1062         `[commit <hash>]` log-style entries.",
1063        "",
1064    ];
1065
1066    format!("{}\n", lines.join("\n"))
1067}
1068
1069/// Render the **sync brief** (C2/C3) — the *single* channel through which
1070/// maintenance-writing work reaches an agent.
1071///
1072/// One brief carries **both** inputs: the cursor slice (`cursor`, rendered via
1073/// [`render_changed_slice`], which also carries the first-sync reseed framing and
1074/// the disposition-recording step) and the open verify findings (`findings`, the
1075/// store's `current(key)` slice). It absorbs the whole of `/reconcile`'s judgment
1076/// (C3): the five conservatism rules, edge-removal conservatism,
1077/// rationale-not-changelog, the commits-nothing / engine-commits-per-mutation
1078/// posture, and — when `adopt` is set — the first-sync/adopt onboarding framing
1079/// (E1's brief half). A rule-by-rule absorption map records where each retired
1080/// reconcile rule now lives (bundle plan `05-verify-sync-engine`, C4).
1081///
1082/// Prune proposals (group F) ride this same brief — F3's single-writer
1083/// invariant: every prune removal reaches the mem only via an agent acting on
1084/// this sync brief. They are rendered as proposals only; nothing is auto-deleted.
1085///
1086/// When nothing has moved, no findings are open, no prune proposals exist, and
1087/// this is not an adopt pass, the brief renders a compact "nothing to sync" note
1088/// instead of the repair machinery — a valid, silent outcome mirroring the build
1089/// brief's no-op roam.
1090pub fn render_sync_brief(
1091    resolved: &ResolvedIngest,
1092    cursor: &SourceCursor,
1093    findings: &[Finding],
1094    prune: &[PruneProposal],
1095    adopt: bool,
1096) -> String {
1097    let preface = render_changed_slice(cursor);
1098    let open_findings = render_open_findings(findings);
1099    let prune_block = render_prune_proposals(prune);
1100    let has_work =
1101        adopt || !preface.is_empty() || !open_findings.is_empty() || !prune_block.is_empty();
1102
1103    let mut parts: Vec<String> = vec![render_sync_situation(resolved)];
1104
1105    if !has_work {
1106        parts.push(
1107            "## Nothing to sync\n\nThe source has not moved since the last sync, no \
1108             verify findings are open, and no prune proposals stand. There is nothing to \
1109             repair this pass — reporting \"no changes\" is a valid outcome.\n\n"
1110                .to_string(),
1111        );
1112        return parts
1113            .into_iter()
1114            .filter(|p| !p.is_empty())
1115            .collect::<Vec<_>>()
1116            .join("");
1117    }
1118
1119    if adopt {
1120        parts.push(render_adopt_framing(resolved));
1121    }
1122    parts.push(preface);
1123    parts.push(open_findings);
1124    parts.push(prune_block);
1125    parts.push(render_sync_conservatism());
1126
1127    parts
1128        .into_iter()
1129        .filter(|p| !p.is_empty())
1130        .collect::<Vec<_>>()
1131        .join("")
1132}
1133
1134#[cfg(test)]
1135mod tests {
1136    use super::*;
1137    use crate::ingest::resolve::ResolvedPrimarySource;
1138    use crate::pipeline::{IngestTrigger, PatternEntry};
1139
1140    fn guidance(goal: Option<&str>, avoid: Option<&str>) -> ResolvedGuidance {
1141        ResolvedGuidance {
1142            goal: goal.map(str::to_string),
1143            avoid: avoid.map(str::to_string),
1144        }
1145    }
1146
1147    /// Goal and avoid both present: two headers, trimmed prose, block ends in
1148    /// a blank line — byte-for-byte the plugin's `goalAndAvoidBlock`.
1149    #[test]
1150    fn renders_goal_and_avoid_blocks() {
1151        let out = render_goal_and_avoid(&guidance(Some("  build coverage  "), Some("no stubs")));
1152        assert_eq!(
1153            out,
1154            "## Goal\n\nbuild coverage\n\n## Failure modes to avoid\n\nno stubs\n\n"
1155        );
1156    }
1157
1158    /// Goal only: a single header block ending in a blank line.
1159    #[test]
1160    fn renders_goal_only() {
1161        assert_eq!(
1162            render_goal_and_avoid(&guidance(Some("build coverage"), None)),
1163            "## Goal\n\nbuild coverage\n\n"
1164        );
1165    }
1166
1167    /// Avoid only: a single header block ending in a blank line.
1168    #[test]
1169    fn renders_avoid_only() {
1170        assert_eq!(
1171            render_goal_and_avoid(&guidance(None, Some("no stubs"))),
1172            "## Failure modes to avoid\n\nno stubs\n\n"
1173        );
1174    }
1175
1176    /// Neither present (and no pass-through): a lone newline, matching the
1177    /// plugin's `lines.join('\n') + '\n'` on an empty block.
1178    #[test]
1179    fn empty_guidance_yields_a_newline() {
1180        assert_eq!(render_goal_and_avoid(&guidance(None, None)), "\n");
1181        // An all-whitespace field is treated as absent.
1182        assert_eq!(render_goal_and_avoid(&guidance(Some("   "), None)), "\n");
1183    }
1184
1185    fn primary(medium_type: MediumType, scope: Vec<PatternEntry>) -> ResolvedSource {
1186        ResolvedSource::Primary(ResolvedPrimarySource {
1187            facet_ref: "f".to_string(),
1188            medium: "m".to_string(),
1189            medium_type,
1190            medium_pointer: "../src".to_string(),
1191            declared_change_detection: None,
1192            scope,
1193            preparation: None,
1194        })
1195    }
1196
1197    fn resolved(name: &str, intent: Option<&str>, sources: Vec<ResolvedSource>) -> ResolvedIngest {
1198        ResolvedIngest {
1199            name: name.to_string(),
1200            mode: BuildMode::Discovery,
1201            trigger: IngestTrigger::Loop,
1202            batch_size: 20,
1203            deny_paths: vec![],
1204            projection_ref: format!("{name}/p"),
1205            projection_mem: name.to_string(),
1206            projection_name: "p".to_string(),
1207            intent: intent.map(str::to_string),
1208            sources,
1209            destination_mem: name.to_string(),
1210            rules: None,
1211            post_actions: None,
1212        }
1213    }
1214
1215    fn process_present(name: &str) -> ProcessMemInfo {
1216        ProcessMemInfo {
1217            present: true,
1218            skipped: false,
1219            notice: None,
1220            leaf_name: name.to_string(),
1221            mem_label: format!("ingest/{name}"),
1222        }
1223    }
1224
1225    fn allow(path: &str) -> PatternEntry {
1226        PatternEntry {
1227            path: path.to_string(),
1228            mode: PatternMode::Allow,
1229        }
1230    }
1231
1232    fn deny(path: &str) -> PatternEntry {
1233        PatternEntry {
1234            path: path.to_string(),
1235            mode: PatternMode::Deny,
1236        }
1237    }
1238
1239    /// The about-the-source block trims the intent; no intent → empty string.
1240    #[test]
1241    fn renders_intent() {
1242        let r = resolved("macos", Some("  Swift app source.  "), vec![]);
1243        assert_eq!(
1244            render_intent(&r),
1245            "## About the source\n\nSwift app source.\n\n"
1246        );
1247        let none = resolved("macos", None, vec![]);
1248        assert_eq!(render_intent(&none), "");
1249    }
1250
1251    /// The situation block prints the name/mode, the three fixed paragraphs,
1252    /// and the present-process-mem line, ending in a blank line.
1253    #[test]
1254    fn renders_situation_with_present_process_mem() {
1255        let r = resolved("macos", None, vec![]);
1256        let out = render_situation(&r, &process_present("macos"));
1257        assert!(out.starts_with("## Situation\n\nYou are running one iteration of `macos` (discovery mode) inside a loop."));
1258        assert!(out.contains("Mutating the destination is this run's mandate:"));
1259        assert!(out.contains("The `PreCompact` hook fires near the limit"));
1260        assert!(out.contains("A paired process mem `ingest/macos` (schema `ingest@0.1.0`) carries destination-quality debt"));
1261        assert!(
1262            out.ends_with("write rules.\n\n"),
1263            "block ends in a blank line"
1264        );
1265    }
1266
1267    /// The skipped (one-shot) and failed-to-create process-mem branches each
1268    /// render their own note.
1269    #[test]
1270    fn situation_process_mem_branches() {
1271        let mut r = resolved("os", None, vec![]);
1272        r.mode = BuildMode::OneShot;
1273        let skipped = ProcessMemInfo {
1274            present: false,
1275            skipped: true,
1276            notice: None,
1277            leaf_name: "os".to_string(),
1278            mem_label: "ingest/os".to_string(),
1279        };
1280        assert!(
1281            render_situation(&r, &skipped)
1282                .contains("No process mem is paired with this ingest (mode=one-shot;")
1283        );
1284
1285        let failed = ProcessMemInfo {
1286            present: false,
1287            skipped: false,
1288            notice: Some("engine offline".to_string()),
1289            leaf_name: "os".to_string(),
1290            mem_label: "ingest/os".to_string(),
1291        };
1292        let out = render_situation(&resolved("os", None, vec![]), &failed);
1293        assert!(out.contains("could not be auto-created — engine offline."));
1294        assert!(out.contains("memstead mem init os --org-path ingest --schema ingest@0.1.0"));
1295    }
1296
1297    /// Operative data: a primary source with paths/ignore, a reference mem
1298    /// with its cross-mem note, the destination with its schema, and the
1299    /// paired process mem — byte-for-byte the plugin's block.
1300    #[test]
1301    fn renders_operative_data_full() {
1302        let r = resolved(
1303            "macos",
1304            None,
1305            vec![
1306                primary(
1307                    MediumType::Codebase,
1308                    vec![allow("src/**/*.swift"), deny("src/gen/**")],
1309                ),
1310                ResolvedSource::Reference {
1311                    mem: "engine".to_string(),
1312                },
1313            ],
1314        );
1315        let out = render_operative_data(&r, &process_present("macos"), Some("macos-code@0.1.0"));
1316        let expected = "\
1317## Operative data
1318
1319### Sources
1320
1321- **codebase** (primary)
1322  - Paths: src/**/*.swift
1323  - Ignore: src/gen/**
1324- **graph** (reference) — mem: engine
1325
1326Sources tagged `(reference)` are read-only context for cross-mem edges — search them, never write into them. Only `(primary)` sources are ingested into the destination.
1327
1328**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`).
1329
1330### Destination
1331
1332- **macos** — schema: `macos-code@0.1.0`
1333
1334### Paired process mem
1335
1336- **ingest/macos** — schema: `ingest@0.1.0`. Inspect via `memstead_overview` / `memstead_search mem=macos`.
1337\n";
1338        assert_eq!(out, expected);
1339    }
1340
1341    /// Operative data without references or a destination schema: no cross-mem
1342    /// note, a bare destination line.
1343    #[test]
1344    fn renders_operative_data_minimal() {
1345        let r = resolved("g", None, vec![primary(MediumType::Filesystem, vec![])]);
1346        let skipped = ProcessMemInfo {
1347            present: false,
1348            skipped: true,
1349            notice: None,
1350            leaf_name: "g".to_string(),
1351            mem_label: "ingest/g".to_string(),
1352        };
1353        let out = render_operative_data(&r, &skipped, None);
1354        assert!(out.contains("- **filesystem** (primary)\n"));
1355        assert!(!out.contains("Cross-mem references"), "no reference note");
1356        assert!(out.contains("### Destination\n\n- **g**\n"));
1357        assert!(
1358            !out.contains("Paired process mem"),
1359            "skipped process mem omitted"
1360        );
1361    }
1362
1363    /// The discovery assembly concatenates the truthy blocks in order; an
1364    /// empty changed-slice preface (source unmoved) drops out.
1365    #[test]
1366    fn assembles_discovery_brief() {
1367        let r = resolved(
1368            "macos",
1369            Some("Swift source."),
1370            vec![primary(MediumType::Codebase, vec![allow("src/**")])],
1371        );
1372        let g = guidance(Some("build coverage"), None);
1373        let pm = process_present("macos");
1374        let brief = assemble_discovery_brief(&r, &g, &pm, Some("s@1"), "");
1375
1376        // Blocks appear in order and the empty preface is dropped.
1377        let sit = brief.find("## Situation").unwrap();
1378        let src = brief.find("## About the source").unwrap();
1379        let goal = brief.find("## Goal").unwrap();
1380        let op = brief.find("## Operative data").unwrap();
1381        assert!(
1382            sit < src && src < goal && goal < op,
1383            "blocks in brief order"
1384        );
1385        assert!(
1386            !brief.contains("## Source changes"),
1387            "no changed-slice block when preface empty"
1388        );
1389
1390        // A non-empty preface is appended verbatim at the end.
1391        let with_slice =
1392            assemble_discovery_brief(&r, &g, &pm, Some("s@1"), "## Source changes\n\n…\n\n");
1393        assert!(with_slice.ends_with("## Source changes\n\n…\n\n"));
1394    }
1395
1396    fn slice(deleted: &[&str], modified: &[&str], added: &[&str]) -> Slice {
1397        Slice {
1398            deleted: deleted.iter().map(|s| s.to_string()).collect(),
1399            modified: modified.iter().map(|s| s.to_string()).collect(),
1400            added: added.iter().map(|s| s.to_string()).collect(),
1401        }
1402    }
1403
1404    fn cmd(key: &str, token: &str) -> SyncCommand {
1405        SyncCommand {
1406            key: key.to_string(),
1407            token: token.to_string(),
1408        }
1409    }
1410
1411    fn note(source: &str, reason: NoSignalReason) -> NoSignalNote {
1412        NoSignalNote {
1413            source: source.to_string(),
1414            reason,
1415        }
1416    }
1417
1418    /// No changes and no reseed → the block is empty (brief stays a plain roam).
1419    #[test]
1420    fn changed_slice_empty_when_nothing_moved() {
1421        let cursor = SourceCursor {
1422            union: slice(&[], &[], &[]),
1423            write_commands: vec![],
1424            reseed: vec![],
1425            no_signal: vec![],
1426            any_changes: false,
1427            degraded: false,
1428            dead_denies: vec![],
1429            dest_mem: "engine".to_string(),
1430            binding_id: "engine/graph".to_string(),
1431        };
1432        assert_eq!(render_changed_slice(&cursor), "");
1433    }
1434
1435    /// A zero-selecting deny entry surfaces as a rendered warning even when
1436    /// nothing else moved — it is never a silent no-op. The entry name and the
1437    /// migration hint both appear.
1438    #[test]
1439    fn changed_slice_renders_dead_deny_warning() {
1440        let cursor = SourceCursor {
1441            union: slice(&[], &[], &[]),
1442            write_commands: vec![],
1443            reseed: vec![],
1444            no_signal: vec![],
1445            any_changes: false,
1446            degraded: false,
1447            dead_denies: vec!["dev".to_string(), "typo/**".to_string()],
1448            dest_mem: "engine".to_string(),
1449            binding_id: "engine/graph".to_string(),
1450        };
1451        let out = render_changed_slice(&cursor);
1452        assert!(out.contains("deny_paths` entries match nothing"));
1453        assert!(out.contains("- `dev`"));
1454        assert!(out.contains("- `typo/**`"));
1455    }
1456
1457    /// A changed pass renders deleted-first, then the recording block — built
1458    /// here from single-line literals transcribed from the plugin so any
1459    /// line-continuation drift in the impl is caught.
1460    #[test]
1461    fn changed_slice_renders_slice_and_recording() {
1462        let cursor = SourceCursor {
1463            union: slice(&["a.rs"], &["b.rs"], &[]),
1464            write_commands: vec![cmd("engine-graph/source", "HEADSHA")],
1465            reseed: vec![],
1466            no_signal: vec![],
1467            any_changes: true,
1468            degraded: false,
1469            dead_denies: vec![],
1470            dest_mem: "engine".to_string(),
1471            binding_id: "engine/graph".to_string(),
1472        };
1473        let expected_lines = [
1474            "## Source changes since the last sync\n",
1475            "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",
1476            "**Deleted:**",
1477            "- `a.rs`",
1478            "",
1479            "**Modified:**",
1480            "- `b.rs`",
1481            "",
1482            "### Recording your dispositions (do this LAST)\n",
1483            "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",
1484            "In this window you supply a disposition for **every** artifact explicitly (auto-derivation lands in a later cycle). 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",
1485            "```sh",
1486            r#"memstead projection advance engine/graph --dispositions '{"<artifact>": "<disposition>", ...}'"#,
1487            "```",
1488            "If you were interrupted before finishing, that is fine — your recorded dispositions persist, and the next run re-presents only what is left.\n",
1489        ];
1490        assert_eq!(
1491            render_changed_slice(&cursor),
1492            format!("{}\n", expected_lines.join("\n"))
1493        );
1494    }
1495
1496    /// The reseed-only path names the first-sync keys and still emits the
1497    /// recording block (the reseed baselines).
1498    #[test]
1499    fn changed_slice_reseed_only() {
1500        let cursor = SourceCursor {
1501            union: slice(&[], &[], &[]),
1502            write_commands: vec![],
1503            reseed: vec![cmd("ing/f", "TOK")],
1504            no_signal: vec![],
1505            any_changes: false,
1506            degraded: false,
1507            dead_denies: vec![],
1508            dest_mem: "d".to_string(),
1509            binding_id: "d/p".to_string(),
1510        };
1511        let out = render_changed_slice(&cursor);
1512        assert!(out.starts_with("## Source changes since the last sync\n\n"));
1513        assert!(out.contains(
1514            "No prior sync baseline exists for `ing/f` — treating the current source state as the baseline (first sync). No priority slice from it this pass; proceed as usual."
1515        ));
1516        assert!(out.contains(
1517            r#"memstead projection advance d/p --dispositions '{"<artifact>": "<disposition>", ...}'"#
1518        ));
1519        assert!(
1520            !out.contains("The source moved"),
1521            "no 'moved' copy when only reseeding"
1522        );
1523    }
1524
1525    /// Every no-signal reason renders a distinct, named note under the preface,
1526    /// distinguishable from one another and from a genuinely-unchanged source
1527    /// (which renders nothing). With no changes and no reseed there is no
1528    /// recording block, but the preface is non-empty — a source's blindness is
1529    /// visible. `signal:none` renders literally.
1530    #[test]
1531    fn changed_slice_renders_no_signal_reasons_distinguishably() {
1532        let cursor = SourceCursor {
1533            union: slice(&[], &[], &[]),
1534            write_commands: vec![],
1535            reseed: vec![],
1536            no_signal: vec![
1537                note("code-facet", NoSignalReason::Unscoped),
1538                note("plan-facet", NoSignalReason::DetectionNone),
1539                note("git-facet", NoSignalReason::GitUnavailable),
1540                note("ref-mem", NoSignalReason::GraphSnapshotMissing),
1541            ],
1542            any_changes: false,
1543            degraded: false,
1544            dead_denies: vec![],
1545            dest_mem: "d".to_string(),
1546            binding_id: "d/p".to_string(),
1547        };
1548        let out = render_changed_slice(&cursor);
1549        assert!(out.starts_with("## Source changes since the last sync\n"));
1550        assert!(out.contains("Some sources produced **no change signal**"));
1551        // Each source is named and carries its own distinct reason text.
1552        assert!(out.contains("- `code-facet`: unscoped facet (no allow patterns)"));
1553        assert!(
1554            out.contains("- `plan-facet`: `signal:none`"),
1555            "detection-none renders the literal signal:none state"
1556        );
1557        assert!(out.contains("- `git-facet`: git signal unavailable"));
1558        assert!(out.contains("- `ref-mem`: graph snapshot missing"));
1559        // The four reason texts are mutually distinct.
1560        let texts = [
1561            no_signal_reason_text(NoSignalReason::Unscoped),
1562            no_signal_reason_text(NoSignalReason::DetectionNone),
1563            no_signal_reason_text(NoSignalReason::GitUnavailable),
1564            no_signal_reason_text(NoSignalReason::GraphSnapshotMissing),
1565        ];
1566        for (i, a) in texts.iter().enumerate() {
1567            for b in &texts[i + 1..] {
1568                assert_ne!(a, b, "each no-signal reason must render distinctly");
1569            }
1570        }
1571        // No baseline to advance → no recording block, no "moved" copy.
1572        assert!(!out.contains("### Recording your dispositions"));
1573        assert!(!out.contains("The source moved"));
1574    }
1575
1576    /// A changed source and a no-signal source coexist: the changed slice AND
1577    /// the no-signal note both render in the one preface, and the changed
1578    /// source still emits its recording command.
1579    #[test]
1580    fn changed_slice_mixes_changes_and_no_signal() {
1581        let cursor = SourceCursor {
1582            union: slice(&[], &["b.rs"], &[]),
1583            write_commands: vec![cmd("ing/f", "HEAD")],
1584            reseed: vec![],
1585            no_signal: vec![note("other", NoSignalReason::Unscoped)],
1586            any_changes: true,
1587            degraded: false,
1588            dead_denies: vec![],
1589            dest_mem: "d".to_string(),
1590            binding_id: "d/p".to_string(),
1591        };
1592        let out = render_changed_slice(&cursor);
1593        assert!(out.contains("The source moved"));
1594        assert!(out.contains("**Modified:**"));
1595        assert!(out.contains("- `other`: unscoped facet"));
1596        assert!(out.contains("### Recording your dispositions"));
1597        assert!(out.contains(
1598            r#"memstead projection advance d/p --dispositions '{"<artifact>": "<disposition>", ...}'"#
1599        ));
1600    }
1601
1602    /// The one-shot lens block: destination-set table, routing rule (when set),
1603    /// idempotency, report template, and archive note (when set).
1604    #[test]
1605    fn renders_one_shot_lens_block() {
1606        let mut r = resolved("os", Some("plan source"), vec![]);
1607        r.rules = Some(serde_json::json!({ "routing": "route each entity to its spec" }));
1608        r.post_actions = Some(serde_json::json!({ "archive_source": true }));
1609
1610        let out = render_one_shot_lens(&r, Some("planning@0.1.0"), Some("the plan graph"));
1611        assert!(out.starts_with("## Mode: one-shot — lens routing\n\n"));
1612        assert!(out.contains(
1613            "### Destination set\n\n| Mem | Schema | Purpose |\n|-------|--------|---------|\n| os | planning@0.1.0 | the plan graph |\n"
1614        ));
1615        assert!(out.contains("### Routing rule\n\n```\nroute each entity to its spec\n```\n"));
1616        assert!(out.contains("### Idempotency"));
1617        assert!(out.contains("### Report: os"));
1618        assert!(out.contains("### Archive after run"));
1619        assert!(out.ends_with("is set on this ingest.\n\n"));
1620
1621        // No routing / no archive → those sections are omitted; a bare schema
1622        // and default purpose fall back.
1623        let bare = resolved("os", None, vec![]);
1624        let out2 = render_one_shot_lens(&bare, None, None);
1625        assert!(out2.contains("| os | (none) | (no purpose declared) |"));
1626        assert!(!out2.contains("### Routing rule"));
1627        assert!(!out2.contains("### Archive after run"));
1628        assert!(out2.contains("### End-of-run report"));
1629    }
1630
1631    /// The one-shot brief assembles situation (one-shot mode) + intent +
1632    /// goal/avoid + operative-data + the lens block; no process mem, no slice.
1633    #[test]
1634    fn assembles_one_shot_brief() {
1635        let mut r = resolved(
1636            "os",
1637            Some("src"),
1638            vec![primary(MediumType::Filesystem, vec![])],
1639        );
1640        r.mode = BuildMode::OneShot;
1641        let g = guidance(Some("goal"), None);
1642        let skipped = ProcessMemInfo {
1643            present: false,
1644            skipped: true,
1645            notice: None,
1646            leaf_name: "os".to_string(),
1647            mem_label: "ingest/os".to_string(),
1648        };
1649        let brief = assemble_one_shot_brief(&r, &g, &skipped, Some("s@1"), Some("purpose"));
1650        assert!(brief.contains("(one-shot mode)"));
1651        assert!(brief.contains("No process mem is paired with this ingest (mode=one-shot;"));
1652        assert!(brief.contains("## Mode: one-shot — lens routing"));
1653        assert!(
1654            !brief.contains("## Source changes"),
1655            "one-shot has no changed-slice"
1656        );
1657    }
1658
1659    /// Beyond SLICE_CAP entries an overflow line stands in; the degraded flag
1660    /// adds the coarse-targeting note. Also exercises shell-quoting a JSON
1661    /// digest token (embedded quotes).
1662    #[test]
1663    fn changed_slice_caps_and_degrades_and_quotes() {
1664        let many: Vec<String> = (0..SLICE_CAP + 3).map(|i| format!("f{i}.rs")).collect();
1665        let cursor = SourceCursor {
1666            union: Slice {
1667                deleted: vec![],
1668                modified: vec![],
1669                added: many,
1670            },
1671            write_commands: vec![cmd("ing/f", r#"{"v":1,"aggregate":"x"}"#)],
1672            reseed: vec![],
1673            no_signal: vec![],
1674            any_changes: true,
1675            degraded: true,
1676            dead_denies: vec![],
1677            dest_mem: "d".to_string(),
1678            binding_id: "d/p".to_string(),
1679        };
1680        let out = render_changed_slice(&cursor);
1681        assert!(out.contains(&format!("- …and {} more added", 3)));
1682        assert!(out.contains("Precise change history for one or more facets was unavailable"));
1683        // The brief renders the `projection advance` line (the token is no longer
1684        // an operator command — the engine computes and records it, D4/D7).
1685        assert!(out.contains(
1686            r#"memstead projection advance d/p --dispositions '{"<artifact>": "<disposition>", ...}'"#
1687        ));
1688    }
1689
1690    // ---- verify + sync briefs (group C) ----------------------------------
1691
1692    fn finding(class: FindingClass, target: FindingTarget, detail: &str) -> Finding {
1693        Finding {
1694            key: crate::ingest::findings::FindingKey {
1695                binding_hash: "h".to_string(),
1696                source_head: "s".to_string(),
1697            },
1698            facet: "src".to_string(),
1699            target,
1700            class,
1701            detail: detail.to_string(),
1702            created_at: "1".to_string(),
1703        }
1704    }
1705
1706    fn anchor_target(entity: &str, artifact: &str) -> FindingTarget {
1707        FindingTarget::Anchor {
1708            entity: entity.to_string(),
1709            artifact: artifact.to_string(),
1710        }
1711    }
1712
1713    fn artifact_target(artifact: &str) -> FindingTarget {
1714        FindingTarget::Artifact {
1715            artifact: artifact.to_string(),
1716        }
1717    }
1718
1719    fn empty_cursor() -> SourceCursor {
1720        SourceCursor {
1721            union: slice(&[], &[], &[]),
1722            write_commands: vec![],
1723            reseed: vec![],
1724            no_signal: vec![],
1725            any_changes: false,
1726            degraded: false,
1727            dead_denies: vec![],
1728            dest_mem: "engine".to_string(),
1729            binding_id: "engine/graph".to_string(),
1730        }
1731    }
1732
1733    /// C1 — the verify brief measures + adjudicates, and carries NO
1734    /// destination-mutation instruction of any kind. It names the sync brief as
1735    /// the repair home and prints its explicit no-mutation refusal.
1736    #[test]
1737    fn verify_brief_measures_and_refuses_mutation() {
1738        let r = resolved("engine", None, vec![]);
1739        let out = render_verify_brief(&r, 3);
1740        // Measurement + capped adjudication instructions.
1741        assert!(out.starts_with("## Verify — measure fidelity, do not mutate"));
1742        assert!(out.contains("3 finding(s) are queued for adjudication"));
1743        assert!(out.contains("per-run adjudication cap"));
1744        assert!(out.contains("this is a measurement, not a repair"));
1745        // C1 REFUSAL: structurally no destination-mutation instruction. The
1746        // brief never tells the agent to write into the mem — it says the
1747        // opposite, and hands repairs to the sync brief.
1748        assert!(out.contains("Verify writes **nothing** into the destination mem"));
1749        assert!(out.contains("memstead projection brief --sync"));
1750        // No create/update/relate/delete *instruction* — the only occurrences of
1751        // those verbs are in the negated "do not …" refusal line.
1752        assert!(out.contains("do not create or delete an entity"));
1753        assert!(!out.contains("via `memstead_create`"));
1754        assert!(!out.contains("Run `memstead_update`"));
1755
1756        // Backlog 0 → the spot-check phrasing, still no mutation instruction.
1757        let zero = render_verify_brief(&r, 0);
1758        assert!(zero.contains("No findings are queued for adjudication"));
1759        assert!(zero.contains("record any drift you observe as a finding"));
1760        assert!(zero.contains("Verify writes **nothing**"));
1761    }
1762
1763    /// C2 — the sync brief carries BOTH inputs in ONE render: the cursor slice
1764    /// (the changed artifacts) AND the open findings (`current(key)`), plus the
1765    /// commits-nothing posture.
1766    #[test]
1767    fn sync_brief_carries_both_cursor_and_findings() {
1768        let r = resolved("engine", None, vec![]);
1769        let cursor = SourceCursor {
1770            union: slice(&["gone.rs"], &["moved.rs"], &[]),
1771            write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
1772            reseed: vec![],
1773            no_signal: vec![],
1774            any_changes: true,
1775            degraded: false,
1776            dead_denies: vec![],
1777            dest_mem: "engine".to_string(),
1778            binding_id: "engine/graph".to_string(),
1779        };
1780        let findings = vec![
1781            finding(
1782                FindingClass::Drifted,
1783                anchor_target("engine--e", "src/moved.rs"),
1784                "prepared-content hash drifted",
1785            ),
1786            finding(
1787                FindingClass::Uncovered,
1788                artifact_target("src/new.rs"),
1789                "in scope, no anchor",
1790            ),
1791        ];
1792        let out = render_sync_brief(&r, &cursor, &findings, &[], false);
1793        // Both inputs present in one brief (C2).
1794        assert!(out.contains("## Source changes since the last sync"));
1795        assert!(out.contains("`moved.rs`"));
1796        assert!(out.contains("## Open findings to repair"));
1797        assert!(out.contains("`engine--e` → `src/moved.rs`"));
1798        assert!(out.contains("`src/new.rs`"));
1799        // Sole-writer + commits-nothing posture (C3).
1800        assert!(out.contains("sole maintenance writer"));
1801        assert!(out.contains("commits each one **per-mutation**"));
1802        assert!(out.contains("Sync commits nothing."));
1803    }
1804
1805    /// C3 — the sync brief carries the whole absorbed reconcile judgment: the
1806    /// five conservatism rules, edge-removal conservatism, and
1807    /// rationale-not-changelog. Each rule is quoted verbatim so absorption is
1808    /// verifiable against the C4 diff artifact.
1809    #[test]
1810    fn sync_brief_absorbs_reconcile_conservatism() {
1811        let r = resolved("engine", None, vec![]);
1812        let findings = vec![finding(
1813            FindingClass::Uncovered,
1814            artifact_target("src/x.rs"),
1815            "d",
1816        )];
1817        let out = render_sync_brief(&r, &empty_cursor(), &findings, &[], false);
1818        // Five conservatism rules.
1819        assert!(out.contains("Unsure whether an entity is affected — skip it."));
1820        assert!(out.contains(
1821            "Do not create a new entity unless the change clearly introduces a new concept"
1822        ));
1823        assert!(
1824            out.contains("Do not delete an entity unless the change removes the concept entirely.")
1825        );
1826        assert!(out.contains("Never rewrite a section that has not changed"));
1827        assert!(out.contains(
1828            "No speculative edges — add only relationships the diff literally introduces"
1829        ));
1830        // Edge-removal conservatism — flags, never auto-removes.
1831        assert!(out.contains("A dropped dependency FLAGS, it does not auto-remove."));
1832        assert!(out.contains("Edge removal is out of scope for sync."));
1833        // Rationale-not-changelog.
1834        assert!(out.contains("Rationale is reasoning, not a changelog."));
1835        assert!(out.contains("`[commit <hash>]` log-style entries"));
1836    }
1837
1838    /// C3 — the first-sync/adopt framing (E1's brief half): a mem predating its
1839    /// binding is onboarding, expected-0%, with the backfill path — never a
1840    /// failure. The changed-slice reseed carries the per-facet first-sync note.
1841    #[test]
1842    fn sync_brief_renders_adopt_framing() {
1843        let mut r = resolved("engine", None, vec![]);
1844        // In a real ResolvedIngest, `name` is the canonical binding id
1845        // `<mem>/<stem>` while `destination_mem` is the mem — the header uses the
1846        // mem, the backfill command uses the binding id.
1847        r.name = "engine/graph".to_string();
1848        let out = render_sync_brief(&r, &empty_cursor(), &[], &[], true);
1849        assert!(out.contains("## First sync — adopting `engine`"));
1850        assert!(out.contains("0% anchored is expected — this is onboarding, not a failure."));
1851        assert!(out.contains("do **not** replay the whole history"));
1852        assert!(out.contains("**Backfill path:**"));
1853        assert!(out.contains("memstead projection verify engine/graph"));
1854    }
1855
1856    /// The reseed (first-sync, no cursor) framing lives in the embedded
1857    /// changed-slice preface — the sync brief inherits it for free.
1858    #[test]
1859    fn sync_brief_inherits_first_sync_reseed_framing() {
1860        let r = resolved("engine", None, vec![]);
1861        let mut cursor = empty_cursor();
1862        cursor.reseed = vec![cmd("engine/graph/src#synced", "TOK")];
1863        let out = render_sync_brief(&r, &cursor, &[], &[], false);
1864        assert!(out.contains("No prior sync baseline exists for"));
1865        assert!(out.contains("(first sync)"));
1866    }
1867
1868    /// A no-work sync pass (nothing moved, no findings, not adopt) renders a
1869    /// compact "nothing to sync" note and no repair machinery — a valid outcome.
1870    #[test]
1871    fn sync_brief_nothing_to_sync() {
1872        let r = resolved("engine", None, vec![]);
1873        let out = render_sync_brief(&r, &empty_cursor(), &[], &[], false);
1874        assert!(out.contains("## Nothing to sync"));
1875        assert!(!out.contains("## How to repair"));
1876        assert!(!out.contains("## Open findings"));
1877    }
1878
1879    /// C2 REFUSAL complement — the sync brief is the ONLY render carrying repair
1880    /// instructions; the verify brief carries none. The verify brief has no
1881    /// "## How to repair" / "## Open findings to repair" block; the sync brief
1882    /// has both.
1883    #[test]
1884    fn only_sync_brief_carries_repair_instructions() {
1885        let r = resolved("engine", None, vec![]);
1886        let findings = vec![finding(
1887            FindingClass::Drifted,
1888            anchor_target("engine--e", "src/a.rs"),
1889            "d",
1890        )];
1891        let verify = render_verify_brief(&r, 1);
1892        let sync = render_sync_brief(&r, &empty_cursor(), &findings, &[], false);
1893        // Verify: no repair section, no repair verbs as instructions.
1894        assert!(!verify.contains("## How to repair"));
1895        assert!(!verify.contains("Update the affected section"));
1896        // Sync: both repair sections present.
1897        assert!(sync.contains("## How to repair — be conservative"));
1898        assert!(sync.contains("## Open findings to repair"));
1899        assert!(sync.contains("Update the affected section to match"));
1900    }
1901
1902    /// A large findings group caps at FINDINGS_CAP with an overflow line —
1903    /// mirroring the changed-slice cap, so no facet renders unbounded.
1904    #[test]
1905    fn sync_brief_caps_large_findings_group() {
1906        let r = resolved("engine", None, vec![]);
1907        let findings: Vec<Finding> = (0..FINDINGS_CAP + 4)
1908            .map(|i| {
1909                finding(
1910                    FindingClass::Uncovered,
1911                    artifact_target(&format!("src/f{i}.rs")),
1912                    "d",
1913                )
1914            })
1915            .collect();
1916        let out = render_sync_brief(&r, &empty_cursor(), &findings, &[], false);
1917        assert!(out.contains("- …and 4 more"));
1918        // The last few beyond the cap are not rendered inline.
1919        assert!(!out.contains(&format!("src/f{}.rs", FINDINGS_CAP + 3)));
1920    }
1921}