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