1use super::guidance::ResolvedGuidance;
19use super::resolve::{ResolvedIngest, ResolvedSource};
20use super::slice::{NoSignalReason, Slice};
21use crate::binding::BuildMode;
22use crate::pipeline::{MediumType, PatternMode};
23
24const SLICE_CAP: usize = 25;
27
28pub const PROCESS_MEM_SCHEMA: &str = "ingest@0.5.0";
32
33#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ProcessMemInfo {
39 pub present: bool,
41 pub skipped: bool,
43 pub notice: Option<String>,
45 pub leaf_name: String,
47 pub mem_label: String,
49}
50
51fn mode_label(mode: BuildMode) -> &'static str {
54 match mode {
55 BuildMode::Discovery => "discovery",
56 BuildMode::OneShot => "one-shot",
57 }
58}
59
60fn 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
71pub 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
110pub 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
170pub 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
185pub fn render_operative_data(
194 resolved: &ResolvedIngest,
195 process_mem: &ProcessMemInfo,
196 destination_schema: Option<&str>,
197 destination_note: Option<&str>,
198 absent_sources: &[String],
199) -> String {
200 let mut lines: Vec<String> = Vec::new();
201 lines.push("## Operative data".to_string());
202 lines.push(String::new());
203
204 if !resolved.sources.is_empty() {
206 lines.push("### Sources".to_string());
207 lines.push(String::new());
208 let mut reference_mems: Vec<String> = Vec::new();
209 for source in &resolved.sources {
210 match source {
211 ResolvedSource::Primary(p) => {
212 lines.push(format!(
218 "- **{}** ({}, primary) — `{}`",
219 p.name,
220 medium_type_label(p.medium_type),
221 p.pointer
222 ));
223 if absent_sources.iter().any(|n| n == &p.name) {
228 lines.push(
229 " - **This source does not resolve to anything on disk.** \
230 Nothing can be read from it until the path exists or the \
231 binding's pointer is corrected."
232 .to_string(),
233 );
234 }
235 let allows: Vec<&str> = p
236 .scope
237 .iter()
238 .filter(|r| r.mode == PatternMode::Allow)
239 .map(|r| r.path.as_str())
240 .collect();
241 let denies: Vec<&str> = p
242 .scope
243 .iter()
244 .filter(|r| r.mode == PatternMode::Deny)
245 .map(|r| r.path.as_str())
246 .collect();
247 let is_graph = p.medium_type == MediumType::Graph;
256 let (allow_label, deny_label) = if is_graph {
257 ("Entities", "Excluding")
258 } else {
259 ("Paths", "Ignore")
260 };
261 if !allows.is_empty() {
262 lines.push(format!(" - {allow_label}: {}", allows.join(", ")));
263 }
264 if !denies.is_empty() {
265 lines.push(format!(" - {deny_label}: {}", denies.join(", ")));
266 }
267 for note in super::cursor::scope_migration_notes(p) {
274 let rewrite = match ¬e.suggested {
275 Some(s) => format!(" — rewrite it as `{s}`"),
276 None => String::new(),
277 };
278 lines.push(format!(
279 " - **Scope pattern `{}` is written against the workspace root \
280 rather than the source pointer, so it selects nothing**{rewrite}.",
281 note.pattern
282 ));
283 }
284 if is_graph {
285 lines.push(format!(
286 " - Read the source baseline with `memstead_search mem={}` \
287 (add `entity_type=` to match a `type:` selector). The changed \
288 slice below is a delta against the last pass — it is not the \
289 whole source, and an entity absent from it may still be \
290 unprojected.",
291 p.pointer
292 ));
293 }
294 }
295 ResolvedSource::Reference { mem } => {
296 lines.push(format!("- **graph** (reference) — mem: {mem}"));
297 reference_mems.push(mem.clone());
298 }
299 }
300 }
301 lines.push(String::new());
302 if !reference_mems.is_empty() {
303 lines.push(
304 "Sources tagged `(reference)` are read-only context for cross-mem edges — search \
305 them, never write into them. Only `(primary)` sources are ingested into the \
306 destination."
307 .to_string(),
308 );
309 lines.push(String::new());
310 let mem_list = reference_mems
311 .iter()
312 .map(|v| format!("`memstead_search mem={v}`"))
313 .collect::<Vec<_>>()
314 .join(", ");
315 lines.push(format!(
316 "**Cross-mem references:** consult {mem_list} before authoring cross-mem edges. \
317 The target entity must exist — a wiki-link or relationship to a missing target \
318 either auto-stubs (silent) or fails authorization (`CROSS_MEM_RELATION`)."
319 ));
320 lines.push(String::new());
321 }
322 }
323
324 lines.push("### Destination".to_string());
326 lines.push(String::new());
327 let schema_bit = destination_schema
328 .map(|s| format!(" — schema: `{s}`"))
329 .unwrap_or_default();
330 lines.push(format!("- **{}**{schema_bit}", resolved.destination_mem));
331 if let Some(note) = destination_note {
342 lines.push(format!(" - {note}"));
343 }
344 lines.push(String::new());
345
346 if process_mem.present {
348 lines.push("### Paired process mem".to_string());
349 lines.push(String::new());
350 lines.push(format!(
351 "- **{}** — schema: `{PROCESS_MEM_SCHEMA}`. Inspect via `memstead_overview` / \
352 `memstead_search mem={}`.",
353 process_mem.mem_label, process_mem.leaf_name
354 ));
355 lines.push(String::new());
356 }
357
358 format!("{}\n", lines.join("\n"))
359}
360
361#[derive(Debug, Clone, PartialEq, Eq)]
367pub struct SyncCommand {
368 pub key: String,
370 pub token: String,
372}
373
374#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct NoSignalNote {
381 pub source: String,
384 pub reason: NoSignalReason,
386 pub medium_type: Option<MediumType>,
392}
393
394#[derive(Debug, Clone, PartialEq, Eq)]
399pub struct SourceCursor {
400 pub union: Slice,
402 pub write_commands: Vec<SyncCommand>,
404 pub reseed: Vec<SyncCommand>,
406 pub no_signal: Vec<NoSignalNote>,
412 pub any_changes: bool,
414 pub degraded: bool,
416 pub dead_denies: Vec<String>,
425 pub dest_mem: String,
427 pub binding_id: String,
431 pub delivery: Vec<DeliverySequence>,
436}
437
438#[derive(Debug, Clone, PartialEq, Eq)]
440pub struct DeliveredUnit {
441 pub id: String,
443 pub order_key: String,
445 pub change: crate::preparation::UnitChange,
447 pub disposed: bool,
450}
451
452#[derive(Debug, Clone, PartialEq, Eq)]
456pub struct DeliverySequence {
457 pub source: String,
459 pub preparation: String,
461 pub first_run: bool,
463 pub degraded: bool,
466 pub batch: usize,
469 pub units: Vec<DeliveredUnit>,
471}
472
473fn shell_quote(s: &str) -> String {
477 format!("'{}'", s.replace('\'', "'\\''"))
478}
479
480fn render_delivery_sequence(lines: &mut Vec<String>, seq: &DeliverySequence) {
488 use crate::preparation::UnitChange;
489 lines.push(format!(
490 "### Delivery sequence: `{}` (`{}`)\n",
491 seq.source, seq.preparation
492 ));
493 let opening = if seq.first_run {
494 "First delivery of this source: every unit, in the source's own order."
495 } else {
496 "The units that changed since the last pass, at their positions in the source's own \
497 order."
498 };
499 lines.push(format!(
500 "{opening} Work them top to bottom: the order derives from the units' own keys, never \
501 from discovery or directory order, it is identical on every pass, and a unit assumes \
502 only the units numbered before it. Address a unit as `<path>#<key>` in anchors and \
503 dispositions.\n"
504 ));
505 if seq.degraded {
506 lines.push(
507 "_(No baseline content was retrievable for one or more changed files, so every unit \
508 of those files is listed; precision is coarser this pass only.)_\n"
509 .to_string(),
510 );
511 }
512 let pending: Vec<(usize, &DeliveredUnit)> = seq
513 .units
514 .iter()
515 .enumerate()
516 .filter(|(_, u)| !u.disposed)
517 .collect();
518 let disposed = seq.units.len() - pending.len();
519 let shown = if seq.batch == 0 {
520 pending.len()
521 } else {
522 pending.len().min(seq.batch)
523 };
524 for (position, unit) in &pending[..shown] {
525 let label = match unit.change {
526 UnitChange::Added => "new",
527 UnitChange::Modified => "changed",
528 UnitChange::Deleted => "deleted",
529 };
530 lines.push(format!("{}. `{}` ({label})", position + 1, unit.id));
531 }
532 if pending.len() > shown {
533 lines.push(format!(
534 "- …and {} more, presented in order once these are disposed",
535 pending.len() - shown
536 ));
537 }
538 if disposed > 0 {
539 lines.push(format!(
540 "_({disposed} unit{} of this sequence already disposed this pass.)_",
541 if disposed == 1 { "" } else { "s" }
542 ));
543 }
544 if pending.is_empty() {
545 lines.push(
546 "_(Every unit of this sequence is disposed; the baseline advances when the pass \
547 completes.)_"
548 .to_string(),
549 );
550 }
551 lines.push(String::new());
552}
553
554fn render_slice_class(lines: &mut Vec<String>, label: &str, paths: &[String]) {
555 if paths.is_empty() {
556 return;
557 }
558 let shown = paths.len().min(SLICE_CAP);
559 lines.push(format!("**{label}:**"));
560 for path in &paths[..shown] {
561 lines.push(format!("- `{path}`"));
562 }
563 if paths.len() > shown {
564 lines.push(format!(
565 "- …and {} more {}",
566 paths.len() - shown,
567 label.to_lowercase()
568 ));
569 }
570 lines.push(String::new());
571}
572
573fn no_signal_reason_text(reason: NoSignalReason, medium: Option<MediumType>) -> &'static str {
578 match reason {
579 NoSignalReason::Unscoped => match medium {
583 Some(MediumType::Graph) => {
584 "unscoped facet (no allow patterns) — nothing is monitored; write `*` in the \
585 facet scope to watch the whole mem, or `type:<entity_type>` / `id:<glob>` \
586 to narrow it (a graph source selects entities, not paths)"
587 }
588 _ => {
589 "unscoped facet (no allow patterns) — nothing is monitored; write `**/*` in the \
590 facet scope to watch the whole medium"
591 }
592 },
593 NoSignalReason::DetectionNone => {
594 "`signal:none` — change detection is disabled for this source (declared `none`)"
595 }
596 NoSignalReason::GitUnavailable => {
597 "git signal unavailable — no work tree, an unreadable `HEAD`, or an unknown baseline; \
598 a full re-roam is warranted this pass"
599 }
600 NoSignalReason::GraphSnapshotMissing => {
601 "graph snapshot missing — the source mem has no comparable baseline this pass"
602 }
603 }
604}
605
606pub fn render_changed_slice(cursor: &SourceCursor) -> String {
613 if !cursor.any_changes
614 && cursor.reseed.is_empty()
615 && cursor.no_signal.is_empty()
616 && cursor.dead_denies.is_empty()
617 {
618 return String::new();
619 }
620 let mut lines: Vec<String> = Vec::new();
621 lines.push("## Source changes since the last sync\n".to_string());
622
623 if cursor.any_changes {
624 lines.push(
625 "The source moved since this graph was last synced. Steer this pass at these changed \
626 artifacts **first** — they are where the graph is most likely now wrong.\n"
627 .to_string(),
628 );
629 for seq in &cursor.delivery {
633 render_delivery_sequence(&mut lines, seq);
634 }
635 let unit_ids: std::collections::BTreeSet<&str> = cursor
636 .delivery
637 .iter()
638 .flat_map(|s| s.units.iter().map(|u| u.id.as_str()))
639 .collect();
640 let without_units = |v: &[String]| -> Vec<String> {
641 v.iter()
642 .filter(|p| !unit_ids.contains(p.as_str()))
643 .cloned()
644 .collect()
645 };
646 render_slice_class(&mut lines, "Deleted", &without_units(&cursor.union.deleted));
648 render_slice_class(
649 &mut lines,
650 "Modified",
651 &without_units(&cursor.union.modified),
652 );
653 render_slice_class(&mut lines, "Added", &without_units(&cursor.union.added));
654 if cursor.degraded {
655 lines.push(
656 "_(Precise change history for one or more facets was unavailable, so its full \
657 current file set is listed above. Detection still fired from the durable baseline; \
658 targeting is coarser this pass only.)_\n"
659 .to_string(),
660 );
661 }
662 }
663
664 if !cursor.reseed.is_empty() {
665 let keys = cursor
666 .reseed
667 .iter()
668 .map(|r| format!("`{}`", r.key))
669 .collect::<Vec<_>>()
670 .join(", ");
671 let it = if cursor.reseed.len() == 1 {
672 "it"
673 } else {
674 "them"
675 };
676 lines.push(format!(
677 "No usable sync baseline exists for {keys} — none was recorded, or the recorded one \
678 is not a commit of the source's repo (foreign or garbage-collected). Treating the \
679 current source state as the baseline. No priority slice from {it} this pass; \
680 proceed as usual.\n"
681 ));
682 }
683
684 if !cursor.no_signal.is_empty() {
685 lines.push(
686 "Some sources produced **no change signal** this pass — detection could not compare \
687 them against a baseline, so they were not steered (roam them as usual). This is \
688 distinct from a source that was checked and had not moved:\n"
689 .to_string(),
690 );
691 for note in &cursor.no_signal {
692 lines.push(format!(
693 "- `{}`: {}",
694 note.source,
695 no_signal_reason_text(note.reason, note.medium_type)
696 ));
697 }
698 lines.push(String::new());
699 }
700
701 if !cursor.dead_denies.is_empty() {
702 lines.push(
703 "**Warning — some `deny_paths` entries match nothing.** The following ingest \
704 `deny_paths` selected **no file** in the project tree, so they exclude nothing from \
705 the slice and hide nothing from the ingest agent. This is usually a typo or a legacy \
706 bare name that never migrated to the workspace-relative glob dialect (e.g. `dev` → \
707 `dev/**`, `VISION.md` → `**/VISION.md`). Fix or remove them:\n"
708 .to_string(),
709 );
710 for entry in &cursor.dead_denies {
711 lines.push(format!("- `{entry}`"));
712 }
713 lines.push(String::new());
714 }
715
716 let has_baseline_to_advance = !cursor.write_commands.is_empty() || !cursor.reseed.is_empty();
724 if has_baseline_to_advance {
725 lines.push("### Recording your dispositions (do this LAST)\n".to_string());
726 lines.push(
727 "Only after you have worked the changed artifacts above — and only for the artifacts \
728 you actually judged — record a disposition for each, so the next pass targets just \
729 what changes next. This advance is resumable and non-stalling: a partial pass is \
730 honored, and if the source moves mid-pass the remaining slice re-presents \
731 (remaining + new) without losing your recorded work.\n"
732 .to_string(),
733 );
734 lines.push(
735 "Anchored work disposes itself: at advance time, every listed artifact that an \
736 anchor in the destination mem references is marked `worked` automatically (an \
737 explicit disposition you pass wins over the auto-mark). Supply dispositions only \
738 for the residue — artifacts you skipped, judged out of intent, or worked without \
739 anchors. The gate accepts only artifact ids listed above — an unknown id refuses \
740 the whole call. When every artifact is disposed, the sync baseline advances \
741 automatically. Run:\n"
742 .to_string(),
743 );
744 lines.push("```sh".to_string());
745 lines.push(format!(
746 "memstead projection advance {} --dispositions {}",
747 cursor.binding_id,
748 shell_quote(r#"{"<artifact>": "<disposition>", ...}"#)
749 ));
750 lines.push("```".to_string());
751 lines.push(
752 "If you were interrupted before finishing, that is fine — your recorded dispositions \
753 persist, and the next run re-presents only what is left.\n"
754 .to_string(),
755 );
756 }
757
758 format!("{}\n", lines.join("\n"))
759}
760
761pub fn render_anchor_instruction(resolved: &ResolvedIngest) -> String {
774 let mut block = "## Provenance — anchor your writes\n\n\
775 Attach an `anchors` list to every `memstead_create` / `memstead_update`, naming the \
776 source artifact(s) the entity is drawn from (the mutation tools document the element \
777 shape). Anchored writes are what verify measures coverage and drift against, and — on \
778 cursor-driven passes — what the advance gate auto-marks `worked`; an unanchored write \
779 leaves the fidelity report and the disposition window blind to your work.\n\n"
780 .to_string();
781 let primary_names: Vec<&str> = resolved
785 .sources
786 .iter()
787 .filter_map(|s| match s {
788 crate::ingest::resolve::ResolvedSource::Primary(src) => Some(src.name.as_str()),
789 crate::ingest::resolve::ResolvedSource::Reference { .. } => None,
790 })
791 .collect();
792 if !primary_names.is_empty() {
793 block.push_str(&format!(
794 "Set each anchor's `source` to the binding source name you drew the artifact \
795 from — this binding declares: {}. The name selects the pointer the \
796 artifact path is joined onto, so the wrong one usually refuses \
797 `INVALID_ANCHOR` (the path resolves under no candidate join). A name \
798 outside the list is NOT itself refused when the path happens to \
799 resolve workspace-relative — that tolerance exists for anchors whose \
800 binding was later renamed — so getting it right is on you, not on a \
801 gate.\n\n",
802 primary_names
803 .iter()
804 .map(|n| format!("`{n}`"))
805 .collect::<Vec<_>>()
806 .join(", ")
807 ));
808 }
809 block.push_str(
814 "For a web document use `grain: url` with the URL as `artifact` and pass the retrieved \
815 text as `content` so the engine records its hash (the engine never fetches). Set \
816 `hash_stability: stable` on an IMMUTABLE document — a dated PDF, an archived page, a \
817 versioned standard — so a later changed hash reads as `drifted`; leave the default \
818 `unstable` for a living page, where a change is only a `recheck`. Url rows are \
819 re-adjudicated when someone supplies a fresh observation (`memstead verify-anchors \
820 --observations`), and every surface shows how long each has gone unobserved.\n\n",
821 );
822 for source in &resolved.sources {
825 let crate::ingest::resolve::ResolvedSource::Primary(src) = source else {
826 continue;
827 };
828 let Some(prep) = src
829 .preparation
830 .as_deref()
831 .and_then(crate::preparation::lookup)
832 else {
833 continue;
834 };
835 let what = match prep.id {
836 crate::preparation::CODE_MAP => {
837 "the file's interface digest (imports, exports, signatures; comments, \
838 formatting and bodies invisible), and a `tree` anchor the code map of every \
839 scoped file under it"
840 }
841 crate::preparation::DATED_ENTRIES => {
842 "the unit's own text for a `<path>#<key>` span, the file's bytes otherwise"
843 }
844 crate::preparation::ENTITY_LOAD_BEARING => "the entity's load-bearing sections",
845 _ => prep.description,
846 };
847 block.push_str(&format!(
848 "Anchors on `{}` hash a prepared form (`{}`): {what}. Never compute `hash` \
849 yourself for this source — leave it empty (verify records it on first \
850 observation), or for a `file` or `span` anchor pass the artifact's `content` \
851 and the engine hashes the prepared form (a `tree` anchor takes no content).\n\n",
852 src.name, prep.id
853 ));
854 }
855 block
856}
857
858#[allow(clippy::too_many_arguments)]
859pub fn assemble_discovery_brief(
860 resolved: &ResolvedIngest,
861 guidance: &ResolvedGuidance,
862 process_mem: &ProcessMemInfo,
863 destination_schema: Option<&str>,
864 destination_note: Option<&str>,
865 absent_sources: &[String],
866 changed_slice_preface: &str,
867) -> String {
868 let parts = [
869 render_situation(resolved, process_mem),
870 render_intent(resolved),
871 render_goal_and_avoid(guidance),
872 render_operative_data(
873 resolved,
874 process_mem,
875 destination_schema,
876 destination_note,
877 absent_sources,
878 ),
879 render_anchor_instruction(resolved),
880 changed_slice_preface.to_string(),
881 ];
882 parts
883 .into_iter()
884 .filter(|p| !p.is_empty())
885 .collect::<Vec<_>>()
886 .join("")
887}
888
889pub fn render_one_shot_lens(
895 resolved: &ResolvedIngest,
896 destination_schema: Option<&str>,
897 destination_purpose: Option<&str>,
898) -> String {
899 let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
900 let mut lines: Vec<String> = vec![
901 "## Mode: one-shot — lens routing".to_string(),
902 String::new(),
903 "A lens iterates entities once and writes per-destination, then exits. The agent decides \
904 per-entity which destinations to target (Routing rule). Re-runs use `memstead_update`; \
905 never duplicate."
906 .to_string(),
907 String::new(),
908 ];
909
910 lines.push("### Destination set".to_string());
911 lines.push(String::new());
912 lines.push("| Mem | Schema | Purpose |".to_string());
913 lines.push("|-------|--------|---------|".to_string());
914 let schema = destination_schema.unwrap_or("(none)");
915 let purpose = destination_purpose
916 .filter(|s| !s.is_empty())
917 .unwrap_or("(no purpose declared)");
918 lines.push(format!(
919 "| {} | {} | {} |",
920 cell(&resolved.destination_mem),
921 cell(schema),
922 cell(purpose)
923 ));
924 lines.push(String::new());
925
926 if let Some(routing) = resolved
927 .rules
928 .as_ref()
929 .and_then(|r| r.get("routing"))
930 .and_then(|v| v.as_str())
931 .map(str::trim)
932 .filter(|s| !s.is_empty())
933 {
934 lines.push("### Routing rule".to_string());
935 lines.push(String::new());
936 lines.push("```".to_string());
937 lines.push(routing.to_string());
938 lines.push("```".to_string());
939 lines.push(String::new());
940 }
941
942 lines.push("### Idempotency".to_string());
943 lines.push(String::new());
944 lines.push("- Search the destination before writing; route changes through `memstead_update` against the existing entity if present.".to_string());
945 lines.push("- Skip writes when the lifted content matches what is already there (record as `skipped: already-up-to-date`).".to_string());
946 lines.push(
947 "- Use `memstead_create` only when no entity for that concept exists yet.".to_string(),
948 );
949 lines.push(String::new());
950
951 lines.push("### End-of-run report".to_string());
952 lines.push(String::new());
953 lines.push("After every destination is processed, emit one block per destination on stdout, in Destination-set order:".to_string());
954 lines.push(String::new());
955 lines.push("```".to_string());
956 lines.push(format!("### Report: {}", resolved.name));
957 lines.push(String::new());
958 lines.push("Destination: <mem>".to_string());
959 lines.push(" created: <count>".to_string());
960 lines.push(" updated: <count>".to_string());
961 lines.push(" skipped: <count>".to_string());
962 lines.push(" failed: <count>".to_string());
963 lines.push(" failures:".to_string());
964 lines.push(" - <entity-key>: <error verbatim>".to_string());
965 lines.push(" skipped-detail:".to_string());
966 lines.push(" - <entity-key>: <one-line reason>".to_string());
967 lines.push("```".to_string());
968 lines.push(String::new());
969 lines.push("Per-destination commits are independent — partial success is the accepted failure mode. No rollback.".to_string());
970 lines.push(String::new());
971
972 let archive = resolved
973 .post_actions
974 .as_ref()
975 .and_then(|p| p.get("archive_source"))
976 .and_then(serde_json::Value::as_bool)
977 .unwrap_or(false);
978 if archive {
979 lines.push("### Archive after run".to_string());
980 lines.push(String::new());
981 lines.push("After the report has been emitted, archive the source planning mem — `post_actions.archive_source` is set on this ingest.".to_string());
982 lines.push(String::new());
983 }
984
985 format!("{}\n", lines.join("\n"))
986}
987
988#[allow(clippy::too_many_arguments)]
993pub fn assemble_one_shot_brief(
994 resolved: &ResolvedIngest,
995 guidance: &ResolvedGuidance,
996 process_mem: &ProcessMemInfo,
997 destination_schema: Option<&str>,
998 destination_note: Option<&str>,
999 absent_sources: &[String],
1000 destination_purpose: Option<&str>,
1001) -> String {
1002 let parts = [
1003 render_situation(resolved, process_mem),
1004 render_intent(resolved),
1005 render_goal_and_avoid(guidance),
1006 render_operative_data(
1007 resolved,
1008 process_mem,
1009 destination_schema,
1010 destination_note,
1011 absent_sources,
1012 ),
1013 render_anchor_instruction(resolved),
1014 render_one_shot_lens(resolved, destination_schema, destination_purpose),
1015 ];
1016 parts
1017 .into_iter()
1018 .filter(|p| !p.is_empty())
1019 .collect::<Vec<_>>()
1020 .join("")
1021}
1022
1023use super::findings::{Finding, FindingClass, FindingTarget};
1033use super::prune::{PruneDisposition, PruneProposal};
1034
1035const FINDINGS_CAP: usize = SLICE_CAP;
1037
1038pub fn render_verify_brief(resolved: &ResolvedIngest, backlog: usize) -> String {
1047 let mut lines: Vec<String> = vec![
1048 "## Verify — measure fidelity, do not mutate".to_string(),
1049 String::new(),
1050 ];
1051 lines.push(format!(
1052 "You are measuring the fidelity of `{}` — how faithfully the destination mem \
1053 `{}` still matches its source. This pass **only measures**: read the source \
1054 and the mem's anchors, judge whether the graph still holds, and record what \
1055 you find. **You** write nothing into the destination mem. The run itself records \
1056 its findings store, which is the verify surface's own state outside the mem, and \
1057 backfills observed anchor hashes, which is measurement machinery. Its one write \
1058 into the mem's config, the `#verified` baseline, rides `--advance` and is off by \
1059 default, so a bare verify leaves that config byte-identical.",
1060 resolved.name, resolved.destination_mem
1061 ));
1062 lines.push(String::new());
1063
1064 lines.push(
1065 "Anchors may carry a `source` naming the binding entry point that produced them — \
1066 note it when recording findings, so fidelity stays measurable per source."
1067 .to_string(),
1068 );
1069 lines.push(String::new());
1070
1071 lines.push("### Adjudicate the queued findings (capped)".to_string());
1072 lines.push(String::new());
1073 if backlog == 0 {
1074 lines.push(
1075 "No findings are queued for adjudication this pass. Spot-check the resolving \
1076 anchors and the uncovered-artifact sample the fidelity report lists, and \
1077 record any drift you observe as a finding."
1078 .to_string(),
1079 );
1080 } else {
1081 lines.push(format!(
1082 "{backlog} finding(s) are queued for adjudication. Working up to the per-run \
1083 adjudication cap (an operations knob — the remainder stays queued and \
1084 re-presents on a later pass), take each queued finding and compare the \
1085 anchored source content against what the entity records. Classify it: still \
1086 accurate, or drifted. **Record the verdict — this is a measurement, not a \
1087 repair.** A drift you record becomes a finding the sync pass repairs; you do \
1088 not fix it here."
1089 ));
1090 }
1091 lines.push(String::new());
1092
1093 lines.push("### Out of scope for verify — no mutation".to_string());
1094 lines.push(String::new());
1095 lines.push(
1096 "Verify writes **no entity content**. Do not update a \
1097 `specifies` / `constraints` section, do not create or delete an entity, do not \
1098 add or remove a relationship. When measurement shows the graph is wrong, that \
1099 is a **finding** — the sync brief (`memstead projection brief --sync`) is the \
1100 one place those repairs are made. Leave every fix to it. (The run itself records \
1101 its findings store, which lives outside the mem, and backfills observed anchor \
1102 hashes; it writes a `#verified` baseline only under `--advance` — engine \
1103 bookkeeping, not your edits.)"
1104 .to_string(),
1105 );
1106 lines.push(String::new());
1107
1108 format!("{}\n", lines.join("\n"))
1109}
1110
1111fn finding_target_label(target: &FindingTarget) -> String {
1113 match target {
1114 FindingTarget::Anchor { entity, artifact } => format!("`{entity}` → `{artifact}`"),
1115 FindingTarget::Artifact { artifact } => format!("`{artifact}`"),
1116 }
1117}
1118
1119fn render_findings_group(
1122 lines: &mut Vec<String>,
1123 heading: &str,
1124 guidance: &str,
1125 items: &[&Finding],
1126) {
1127 if items.is_empty() {
1128 return;
1129 }
1130 lines.push(format!("### {heading}"));
1131 lines.push(String::new());
1132 lines.push(guidance.to_string());
1133 lines.push(String::new());
1134 let shown = items.len().min(FINDINGS_CAP);
1135 for f in &items[..shown] {
1136 lines.push(format!(
1137 "- {} — {}",
1138 finding_target_label(&f.target),
1139 f.detail
1140 ));
1141 }
1142 if items.len() > shown {
1143 lines.push(format!("- …and {} more", items.len() - shown));
1144 }
1145 lines.push(String::new());
1146}
1147
1148fn render_open_findings(findings: &[Finding], binding_id: &str) -> String {
1154 if findings.is_empty() {
1155 return String::new();
1156 }
1157 let mut lines: Vec<String> = vec![
1158 "## Open findings to repair".to_string(),
1159 String::new(),
1160 "The verify pass recorded these against the current source state. Repair them \
1161 conservatively (see the rules below); a finding you judge already correct needs \
1162 no write."
1163 .to_string(),
1164 String::new(),
1165 ];
1166
1167 let group = |class: FindingClass| -> Vec<&Finding> {
1168 findings.iter().filter(|f| f.class == class).collect()
1169 };
1170
1171 render_findings_group(
1174 &mut lines,
1175 "Drifted — the anchored content changed",
1176 "The source the entity describes moved. Update the affected section to match — \
1177 only the part that changed. If the entity is still accurate, leave it. Either \
1178 way, reset the anchor on the entity in ONE update call: `anchors_unset` the \
1179 row, then write it fresh in the same call's `anchors` (same artifact, grain, \
1180 class and source, no hash) — the next verify backfills the freshly observed \
1181 hash and the drift clears. A hashless re-declare WITHOUT the unset keeps the \
1182 stored baseline by design and clears nothing, and updating the entity alone, \
1183 or advancing the baseline, leaves the anchor drifted just the same.",
1184 &group(FindingClass::Drifted),
1185 );
1186 render_findings_group(
1187 &mut lines,
1188 "Wrong — an adjudicated content mismatch",
1189 "Adjudication found the entity no longer matches its source. Correct the \
1190 mismatched section; do not rewrite what still holds.",
1191 &group(FindingClass::Wrong),
1192 );
1193 render_findings_group(
1196 &mut lines,
1197 "Unresolvable anchor — the artifact is gone",
1198 "The source artifact an anchor references is no longer present. Delete the entity \
1199 **only** if the concept is removed entirely; otherwise leave it. Concept-level \
1200 removals are a prune concern with its own never-clobber / conflict-flag rules — \
1201 do not delete on a hunch here.",
1202 &group(FindingClass::UnresolvableAnchor),
1203 );
1204 let uncovered_guidance = format!(
1211 "An in-scope source artifact has no anchor in the mem. Create an entity for it \
1212 **only** if it is a clearly-new concept with no existing entity; otherwise \
1213 extend the entity that already owns the concept, or leave it for a discovery \
1214 build. A third answer is legitimate: the artifact is mined and deliberately \
1215 warrants no entity. Record that with a rationale — it stops presenting here \
1216 from the next brief on:\n\n```bash\nmemstead projection exclude {binding_id} \
1217 --exclusions '{{\"<artifact>\": \"<rationale>\"}}'\n```"
1218 );
1219 render_findings_group(
1220 &mut lines,
1221 "Uncovered — a source artifact with no entity",
1222 &uncovered_guidance,
1223 &group(FindingClass::Uncovered),
1224 );
1225 render_findings_group(
1227 &mut lines,
1228 "Queued for adjudication — not yet judged",
1229 "These are not adjudicated yet — that is the verify pass's job, not sync's. \
1230 **Skip them here**; they become repairable only after verify classifies them as \
1231 drifted.",
1232 &group(FindingClass::QueuedForAdjudication),
1233 );
1234
1235 format!("{}\n", lines.join("\n"))
1236}
1237
1238fn render_exclusions(ledger: &crate::ingest::advance::ExclusionLedger) -> String {
1242 if ledger.active.is_empty() && ledger.dropped.is_empty() {
1243 return String::new();
1244 }
1245 let mut lines: Vec<String> = Vec::new();
1246 if !ledger.active.is_empty() {
1247 lines.push("## Excluded artifacts (authored)".to_string());
1248 lines.push(String::new());
1249 lines.push(
1250 "These in-scope artifacts are deliberately excluded with a recorded rationale; \
1251 they never present as uncovered and need no entity. An exclusion keys on the \
1252 artifact and its source, so it survives edits to the rest of the binding."
1253 .to_string(),
1254 );
1255 lines.push(String::new());
1256 for e in &ledger.active {
1257 lines.push(format!(
1258 "- `{}` (source `{}`): {}",
1259 e.artifact, e.source, e.rationale
1260 ));
1261 }
1262 lines.push(String::new());
1263 }
1264 if !ledger.dropped.is_empty() {
1265 lines.push("## Exclusions dropped — their source is no longer declared".to_string());
1266 lines.push(String::new());
1267 lines.push(
1268 "The source these exclusions were recorded under left the binding's declaration, \
1269 so they no longer apply; re-declare the source and record them again if they \
1270 still hold."
1271 .to_string(),
1272 );
1273 lines.push(String::new());
1274 for d in &ledger.dropped {
1275 lines.push(format!(
1276 "- `{}` (source `{}`, dropped {}): {}",
1277 d.artifact, d.source, d.dropped_at, d.rationale
1278 ));
1279 }
1280 lines.push(String::new());
1281 }
1282 format!("{}\n", lines.join("\n"))
1283}
1284
1285fn render_prune_proposals(proposals: &[PruneProposal]) -> String {
1296 if proposals.is_empty() {
1297 return String::new();
1298 }
1299 let mut lines: Vec<String> = vec![
1300 "## Prune — proposed removals (you decide; nothing is auto-deleted)".to_string(),
1301 String::new(),
1302 "The source removed the artifacts these entities describe. Each item below is a \
1303 **proposal**: prune writes nothing — you enact (or reject) the removal through the \
1304 normal MCP mutation surface. An `authored` entity is never proposed here; a `derived` \
1305 entity is flagged, never proposed for deletion."
1306 .to_string(),
1307 String::new(),
1308 ];
1309
1310 let group = |d: PruneDisposition| -> Vec<&PruneProposal> {
1311 proposals.iter().filter(|p| p.disposition == d).collect()
1312 };
1313
1314 let clean = group(PruneDisposition::CleanDelete);
1317 if !clean.is_empty() {
1318 lines.push("### Clean delete — never-clobber three-way merge is clean".to_string());
1319 lines.push(String::new());
1320 lines.push(
1321 "The source base leg was retrievable and the three-way merge found no model-side \
1322 divergence, so removal is safe. **Confirm, then delete via the mutation surface** — \
1323 this is still your call, not an auto-delete."
1324 .to_string(),
1325 );
1326 lines.push(String::new());
1327 let shown = clean.len().min(FINDINGS_CAP);
1328 for p in &clean[..shown] {
1329 lines.push(format!(
1330 "- `{}` — source artifact(s) gone: {}",
1331 p.entity,
1332 artifact_list(&p.artifacts)
1333 ));
1334 }
1335 if clean.len() > shown {
1336 lines.push(format!("- …and {} more", clean.len() - shown));
1337 }
1338 lines.push(String::new());
1339 }
1340
1341 let conflict = group(PruneDisposition::ConflictFlag);
1343 if !conflict.is_empty() {
1344 lines.push("### Conflict-flag — decide, never overwrite a model-side edit".to_string());
1345 lines.push(String::new());
1346 lines.push(
1347 "No retrievable base leg to merge against (a non-git source, or an anchor with no \
1348 pinned version). **Both sides are shown — decide deliberately.** If the concept is \
1349 truly gone, delete via the mutation surface; if the model side was edited on \
1350 purpose, keep it. Prune never overwrites a model-side edit for you."
1351 .to_string(),
1352 );
1353 lines.push(String::new());
1354 let shown = conflict.len().min(FINDINGS_CAP);
1355 for p in &conflict[..shown] {
1356 lines.push(format!(
1357 "- `{}` — **source side:** artifact(s) gone: {}; **model side:** the entity is \
1358 still present (may carry edits) — you decide.",
1359 p.entity,
1360 artifact_list(&p.artifacts)
1361 ));
1362 }
1363 if conflict.len() > shown {
1364 lines.push(format!("- …and {} more", conflict.len() - shown));
1365 }
1366 lines.push(String::new());
1367 }
1368
1369 let derived = group(PruneDisposition::DerivedFlagged);
1371 if !derived.is_empty() {
1372 lines.push("### Derived — flagged, NOT proposed for deletion".to_string());
1373 lines.push(String::new());
1374 lines.push(
1375 "These entities were **derived** from other inputs. A derived entity is flagged, \
1376 never auto-proposed for deletion — its inputs may still hold even though one source \
1377 artifact vanished. Re-examine the inputs before removing anything."
1378 .to_string(),
1379 );
1380 lines.push(String::new());
1381 let shown = derived.len().min(FINDINGS_CAP);
1382 for p in &derived[..shown] {
1383 let inputs = if p.derived_inputs.is_empty() {
1384 "(no recorded inputs)".to_string()
1385 } else {
1386 artifact_list(&p.derived_inputs)
1387 };
1388 lines.push(format!(
1389 "- `{}` — derived from: {}; source artifact(s) gone: {}.",
1390 p.entity,
1391 inputs,
1392 artifact_list(&p.artifacts)
1393 ));
1394 }
1395 if derived.len() > shown {
1396 lines.push(format!("- …and {} more", derived.len() - shown));
1397 }
1398 lines.push(String::new());
1399 }
1400
1401 format!("{}\n", lines.join("\n"))
1402}
1403
1404fn artifact_list(artifacts: &[String]) -> String {
1406 if artifacts.is_empty() {
1407 return "(none)".to_string();
1408 }
1409 artifacts
1410 .iter()
1411 .map(|a| format!("`{a}`"))
1412 .collect::<Vec<_>>()
1413 .join(", ")
1414}
1415
1416fn render_sync_situation(resolved: &ResolvedIngest) -> String {
1419 format!(
1420 "## Sync — repair the graph to match the source\n\n\
1421 You are running the sync pass for `{}`. Sync is the graph's **sole maintenance \
1422 writer**: the only place the destination mem `{}` is repaired to match its \
1423 source. Two inputs steer this pass — the source changes since the last sync, and \
1424 the open verify findings — both below. Work them: update, create, relate, and \
1425 (rarely) delete entities so the graph again matches the source.\n\n\
1426 Every mutation routes through the normal MCP mutation surface, and the engine \
1427 commits each one **per-mutation** to the mem's own gitdir. You **stage nothing \
1428 and commit nothing yourself** — not the graph, not the code. Sync commits \
1429 nothing.\n\n",
1430 resolved.name, resolved.destination_mem
1431 )
1432}
1433
1434fn render_adopt_framing(resolved: &ResolvedIngest) -> String {
1438 format!(
1439 "## First sync — adopting `{}`\n\n\
1440 This mem predates its binding: it has no anchors and no prior sync baseline, so \
1441 **0% anchored is expected — this is onboarding, not a failure.** Do not read it \
1442 as drift or a red verdict. There is no cursor to diff against, so the baseline is \
1443 the **current** source HEAD — do **not** replay the whole history; treat the \
1444 current source state as the starting point, and this is a **first sync**.\n\n\
1445 **Backfill path:** run `memstead projection verify {}` to enumerate the in-scope \
1446 source artifacts that carry no entity yet, then cover the clearly-new concepts \
1447 among them through the normal MCP mutation surface — the same conservative rules \
1448 below apply. Backfilling is incremental: a partial pass is fine, and the next \
1449 sync continues where you left off.\n\n",
1450 resolved.destination_mem, resolved.name
1451 )
1452}
1453
1454fn render_stale_claim_search(resolved: &ResolvedIngest) -> String {
1466 format!(
1467 "## Stale claims beyond the slice — search, then judge\n\n\
1468 A changed fact can be claimed by an entity whose anchors are all outside the \
1469 changed slice — anchor-steered repairs alone would leave that claim standing \
1470 falsified. Extract the **changed facts** from the changed artifacts above: \
1471 renamed identifiers, changed values or defaults, changed behaviors (e.g. an \
1472 exit code, a flag's meaning), removed or moved concepts. For each changed \
1473 fact, search the destination mem `{}` for claims about it (`memstead_search` \
1474 and its variants — try the new name, the old name/value, and close synonyms), \
1475 and judge **only** the entities whose claims actually mention a changed fact: \
1476 repair a claim the change falsifies, leave everything else untouched.\n\n\
1477 This is a bounded fact-search, not a live-verify of every entity and not a \
1478 rewrite license. If the changes carry no factual claims (formatting, \
1479 comments, cosmetic moves), the fact set is empty and this step ends with no \
1480 search and no edits.\n\n",
1481 resolved.destination_mem
1482 )
1483}
1484
1485fn render_sync_conservatism() -> String {
1489 let lines: Vec<&str> = vec![
1490 "## How to repair — be conservative",
1491 "",
1492 "Repair only what the source changes and the findings above actually justify:",
1493 "",
1494 "- **Unsure whether an entity is affected — skip it.** A missed update is a later \
1496 finding; a wrong rewrite is damage.",
1497 "- **Do not create a new entity unless the change clearly introduces a new concept \
1498 with no existing entity.** Prefer updating the entity that already owns the \
1499 concept.",
1500 "- **Do not delete an entity unless the change removes the concept entirely.** \
1501 Deletions a prune pass surfaces follow prune's own never-clobber / conflict-flag \
1502 rules — never delete on a hunch here.",
1503 "- **Never rewrite a section that has not changed** — touch only the part the \
1504 change or finding actually affects.",
1505 "- **No speculative edges — add only relationships the diff literally introduces** \
1506 (a new `use` / `import` / dependency you can point at in the change).",
1507 "- **A dropped dependency FLAGS, it does not auto-remove.** If the change removes an \
1509 import or dependency, leave the matching edge intact and note it for a later \
1510 audit — removals are ambiguous (temporary refactor vs. permanent cut), and a \
1511 stale edge is less damaging than an erased real one. **Edge removal is out of \
1512 scope for sync.**",
1513 "- **Rationale is reasoning, not a changelog.** When you record why a change was \
1515 made, append the *reasoning* (why this approach, which trade-offs) — never \
1516 `[commit <hash>]` log-style entries.",
1517 "",
1518 ];
1519
1520 format!("{}\n", lines.join("\n"))
1521}
1522
1523pub fn render_sync_brief(
1550 resolved: &ResolvedIngest,
1551 cursor: &SourceCursor,
1552 findings: &[Finding],
1553 prune: &[PruneProposal],
1554 adopt: bool,
1555 exclusions: &crate::ingest::advance::ExclusionLedger,
1556) -> String {
1557 let preface = render_changed_slice(cursor);
1558 let open_findings = render_open_findings(findings, &resolved.name);
1559 let prune_block = render_prune_proposals(prune);
1560 let has_work =
1561 adopt || !preface.is_empty() || !open_findings.is_empty() || !prune_block.is_empty();
1562
1563 let mut parts: Vec<String> = vec![
1567 render_sync_situation(resolved),
1568 render_exclusions(exclusions),
1569 ];
1570
1571 if !has_work {
1572 parts.push(
1573 "## Nothing to sync\n\nThe source has not moved since the last sync, no \
1574 verify findings are open, and no prune proposals stand. There is nothing to \
1575 repair this pass — reporting \"no changes\" is a valid outcome.\n\n"
1576 .to_string(),
1577 );
1578 return parts
1579 .into_iter()
1580 .filter(|p| !p.is_empty())
1581 .collect::<Vec<_>>()
1582 .join("");
1583 }
1584
1585 if adopt {
1586 parts.push(render_adopt_framing(resolved));
1587 }
1588 parts.push(preface);
1589 if cursor.any_changes {
1593 parts.push(render_stale_claim_search(resolved));
1594 }
1595 parts.push(open_findings);
1596 parts.push(prune_block);
1597 parts.push(render_anchor_instruction(resolved));
1598 parts.push(render_sync_conservatism());
1599
1600 parts
1601 .into_iter()
1602 .filter(|p| !p.is_empty())
1603 .collect::<Vec<_>>()
1604 .join("")
1605}
1606
1607#[cfg(test)]
1608mod tests {
1609 use super::*;
1610 use crate::ingest::resolve::Source;
1611 use crate::pipeline::{IngestTrigger, PatternEntry};
1612
1613 fn guidance(goal: Option<&str>, avoid: Option<&str>) -> ResolvedGuidance {
1614 ResolvedGuidance {
1615 goal: goal.map(str::to_string),
1616 avoid: avoid.map(str::to_string),
1617 }
1618 }
1619
1620 #[test]
1623 fn renders_goal_and_avoid_blocks() {
1624 let out = render_goal_and_avoid(&guidance(Some(" build coverage "), Some("no stubs")));
1625 assert_eq!(
1626 out,
1627 "## Goal\n\nbuild coverage\n\n## Failure modes to avoid\n\nno stubs\n\n"
1628 );
1629 }
1630
1631 #[test]
1633 fn renders_goal_only() {
1634 assert_eq!(
1635 render_goal_and_avoid(&guidance(Some("build coverage"), None)),
1636 "## Goal\n\nbuild coverage\n\n"
1637 );
1638 }
1639
1640 #[test]
1642 fn renders_avoid_only() {
1643 assert_eq!(
1644 render_goal_and_avoid(&guidance(None, Some("no stubs"))),
1645 "## Failure modes to avoid\n\nno stubs\n\n"
1646 );
1647 }
1648
1649 #[test]
1652 fn empty_guidance_yields_a_newline() {
1653 assert_eq!(render_goal_and_avoid(&guidance(None, None)), "\n");
1654 assert_eq!(render_goal_and_avoid(&guidance(Some(" "), None)), "\n");
1656 }
1657
1658 fn primary(medium_type: MediumType, scope: Vec<PatternEntry>) -> ResolvedSource {
1659 ResolvedSource::Primary(Source {
1660 name: "f".to_string(),
1661 medium_type,
1662 pointer: "../src".to_string(),
1663 change_detection: None,
1664 scope,
1665 engagement: None,
1666 preparation: None,
1667 })
1668 }
1669
1670 fn resolved(name: &str, intent: Option<&str>, sources: Vec<ResolvedSource>) -> ResolvedIngest {
1671 ResolvedIngest {
1672 name: name.to_string(),
1673 mode: BuildMode::Discovery,
1674 trigger: IngestTrigger::Loop,
1675 batch_size: 20,
1676 deny_paths: vec![],
1677 projection_ref: format!("{name}/p"),
1678 projection_mem: name.to_string(),
1679 projection_name: "p".to_string(),
1680 intent: intent.map(str::to_string),
1681 sources,
1682 destination_mem: name.to_string(),
1683 rules: None,
1684 post_actions: None,
1685 }
1686 }
1687
1688 fn process_present(name: &str) -> ProcessMemInfo {
1689 ProcessMemInfo {
1690 present: true,
1691 skipped: false,
1692 notice: None,
1693 leaf_name: name.to_string(),
1694 mem_label: format!("ingest/{name}"),
1695 }
1696 }
1697
1698 fn allow(path: &str) -> PatternEntry {
1699 PatternEntry {
1700 path: path.to_string(),
1701 mode: PatternMode::Allow,
1702 }
1703 }
1704
1705 fn deny(path: &str) -> PatternEntry {
1706 PatternEntry {
1707 path: path.to_string(),
1708 mode: PatternMode::Deny,
1709 }
1710 }
1711
1712 #[test]
1714 fn renders_intent() {
1715 let r = resolved("macos", Some(" Swift app source. "), vec![]);
1716 assert_eq!(
1717 render_intent(&r),
1718 "## About the source\n\nSwift app source.\n\n"
1719 );
1720 let none = resolved("macos", None, vec![]);
1721 assert_eq!(render_intent(&none), "");
1722 }
1723
1724 #[test]
1727 fn renders_situation_with_present_process_mem() {
1728 let r = resolved("macos", None, vec![]);
1729 let out = render_situation(&r, &process_present("macos"));
1730 assert!(out.starts_with("## Situation\n\nYou are running one iteration of `macos` (discovery mode) inside a loop."));
1731 assert!(out.contains("Mutating the destination is this run's mandate:"));
1732 assert!(out.contains("The `PreCompact` hook fires near the limit"));
1733 assert!(out.contains("A paired process mem `ingest/macos` (schema `ingest@0.5.0`) carries destination-quality debt"));
1734 assert!(
1735 out.ends_with("write rules.\n\n"),
1736 "block ends in a blank line"
1737 );
1738 }
1739
1740 #[test]
1743 fn situation_process_mem_branches() {
1744 let mut r = resolved("os", None, vec![]);
1745 r.mode = BuildMode::OneShot;
1746 let skipped = ProcessMemInfo {
1747 present: false,
1748 skipped: true,
1749 notice: None,
1750 leaf_name: "os".to_string(),
1751 mem_label: "ingest/os".to_string(),
1752 };
1753 assert!(
1754 render_situation(&r, &skipped)
1755 .contains("No process mem is paired with this ingest (mode=one-shot;")
1756 );
1757
1758 let failed = ProcessMemInfo {
1759 present: false,
1760 skipped: false,
1761 notice: Some("engine offline".to_string()),
1762 leaf_name: "os".to_string(),
1763 mem_label: "ingest/os".to_string(),
1764 };
1765 let out = render_situation(&resolved("os", None, vec![]), &failed);
1766 assert!(out.contains("could not be auto-created — engine offline."));
1767 assert!(out.contains("memstead mem init os --org-path ingest --schema ingest@0.5.0"));
1768 }
1769
1770 #[test]
1774 fn renders_operative_data_full() {
1775 let r = resolved(
1776 "macos",
1777 None,
1778 vec![
1779 primary(
1780 MediumType::Codebase,
1781 vec![allow("src/**/*.swift"), deny("src/gen/**")],
1782 ),
1783 ResolvedSource::Reference {
1784 mem: "engine".to_string(),
1785 },
1786 ],
1787 );
1788 let out = render_operative_data(
1789 &r,
1790 &process_present("macos"),
1791 Some("macos-code@0.1.0"),
1792 None,
1793 &[],
1794 );
1795 let expected = "\
1796## Operative data
1797
1798### Sources
1799
1800- **f** (codebase, primary) — `../src`
1801 - Paths: src/**/*.swift
1802 - Ignore: src/gen/**
1803- **graph** (reference) — mem: engine
1804
1805Sources tagged `(reference)` are read-only context for cross-mem edges — search them, never write into them. Only `(primary)` sources are ingested into the destination.
1806
1807**Cross-mem references:** consult `memstead_search mem=engine` before authoring cross-mem edges. The target entity must exist — a wiki-link or relationship to a missing target either auto-stubs (silent) or fails authorization (`CROSS_MEM_RELATION`).
1808
1809### Destination
1810
1811- **macos** — schema: `macos-code@0.1.0`
1812
1813### Paired process mem
1814
1815- **ingest/macos** — schema: `ingest@0.5.0`. Inspect via `memstead_overview` / `memstead_search mem=macos`.
1816\n";
1817 assert_eq!(out, expected);
1818 }
1819
1820 #[test]
1823 fn renders_operative_data_minimal() {
1824 let r = resolved("g", None, vec![primary(MediumType::Filesystem, vec![])]);
1825 let skipped = ProcessMemInfo {
1826 present: false,
1827 skipped: true,
1828 notice: None,
1829 leaf_name: "g".to_string(),
1830 mem_label: "ingest/g".to_string(),
1831 };
1832 let out = render_operative_data(&r, &skipped, None, Some("**absent** — probe"), &[]);
1833 assert!(out.contains("- **f** (filesystem, primary) — `"));
1836 assert!(!out.contains("Cross-mem references"), "no reference note");
1837 assert!(out.contains("### Destination\n\n- **g**\n"));
1838 assert!(
1841 out.contains("**absent** — probe"),
1842 "the caller's destination note must be rendered: {out}",
1843 );
1844 assert!(
1845 !out.contains("Paired process mem"),
1846 "skipped process mem omitted"
1847 );
1848 }
1849
1850 #[test]
1856 fn operative_data_warns_on_retired_scope_dialect() {
1857 let r = resolved(
1858 "g",
1859 None,
1860 vec![primary(
1863 MediumType::Filesystem,
1864 vec![allow("../src/**/*.md"), allow("notes/**")],
1865 )],
1866 );
1867 let skipped = ProcessMemInfo {
1868 present: false,
1869 skipped: true,
1870 notice: None,
1871 leaf_name: "g".to_string(),
1872 mem_label: "ingest/g".to_string(),
1873 };
1874 let out = render_operative_data(&r, &skipped, None, None, &[]);
1875 assert!(
1876 out.contains("workspace root"),
1877 "the block names the retired dialect: {out}"
1878 );
1879 assert!(
1880 out.contains("../src/**/*.md"),
1881 "the offending pattern is named: {out}"
1882 );
1883 assert!(
1884 out.contains("`**/*.md`"),
1885 "the mechanical rewrite is offered: {out}"
1886 );
1887
1888 let clean = resolved(
1890 "g",
1891 None,
1892 vec![primary(MediumType::Filesystem, vec![allow("**/*.md")])],
1893 );
1894 let out2 = render_operative_data(&clean, &skipped, None, None, &[]);
1895 assert!(
1896 !out2.contains("workspace root"),
1897 "no warning without a retired-dialect pattern: {out2}"
1898 );
1899 }
1900
1901 #[test]
1904 fn assembles_discovery_brief() {
1905 let r = resolved(
1906 "macos",
1907 Some("Swift source."),
1908 vec![primary(MediumType::Codebase, vec![allow("src/**")])],
1909 );
1910 let g = guidance(Some("build coverage"), None);
1911 let pm = process_present("macos");
1912 let brief = assemble_discovery_brief(&r, &g, &pm, Some("s@1"), None, &[], "");
1913
1914 let sit = brief.find("## Situation").unwrap();
1916 let src = brief.find("## About the source").unwrap();
1917 let goal = brief.find("## Goal").unwrap();
1918 let op = brief.find("## Operative data").unwrap();
1919 let anchors = brief.find("## Provenance — anchor your writes").unwrap();
1920 assert!(
1921 sit < src && src < goal && goal < op && op < anchors,
1922 "blocks in brief order"
1923 );
1924 assert!(
1925 !brief.contains("## Source changes"),
1926 "no changed-slice block when preface empty"
1927 );
1928
1929 let with_slice = assemble_discovery_brief(
1931 &r,
1932 &g,
1933 &pm,
1934 Some("s@1"),
1935 None,
1936 &[],
1937 "## Source changes\n\n…\n\n",
1938 );
1939 assert!(with_slice.ends_with("## Source changes\n\n…\n\n"));
1940 }
1941
1942 fn slice(deleted: &[&str], modified: &[&str], added: &[&str]) -> Slice {
1943 Slice {
1944 deleted: deleted.iter().map(|s| s.to_string()).collect(),
1945 modified: modified.iter().map(|s| s.to_string()).collect(),
1946 added: added.iter().map(|s| s.to_string()).collect(),
1947 }
1948 }
1949
1950 fn cmd(key: &str, token: &str) -> SyncCommand {
1951 SyncCommand {
1952 key: key.to_string(),
1953 token: token.to_string(),
1954 }
1955 }
1956
1957 fn note(source: &str, reason: NoSignalReason) -> NoSignalNote {
1958 NoSignalNote {
1959 medium_type: None,
1960 source: source.to_string(),
1961 reason,
1962 }
1963 }
1964
1965 #[test]
1969 fn anchor_instruction_names_prepared_form_sources() {
1970 let mut resolved = resolved("home", None, vec![primary(MediumType::Codebase, vec![])]);
1971 let plain = render_anchor_instruction(&resolved);
1972 assert!(!plain.contains("hash a prepared form"));
1973 if let Some(ResolvedSource::Primary(src)) = resolved.sources.first_mut() {
1974 src.preparation = Some(crate::preparation::CODE_MAP.to_string());
1975 }
1976 let prepared = render_anchor_instruction(&resolved);
1977 assert!(
1978 prepared.contains("hash a prepared form (`code-map`)"),
1979 "{prepared}"
1980 );
1981 assert!(prepared.contains("interface digest"));
1982 assert!(prepared.contains("for a `file` or `span` anchor pass the artifact's `content`"));
1983 assert!(prepared.contains("a `tree` anchor takes no content"));
1984 }
1985
1986 #[test]
1991 fn changed_slice_renders_delivery_sequences_in_order() {
1992 use crate::preparation::UnitChange;
1993 let unit = |id: &str, order: &str, change: UnitChange, disposed: bool| DeliveredUnit {
1994 id: id.to_string(),
1995 order_key: order.to_string(),
1996 change,
1997 disposed,
1998 };
1999 let units = vec![
2000 unit(
2001 "log/b.md#2026-08-20T00:00:00",
2002 "2026-08-20T00:00:00",
2003 UnitChange::Added,
2004 true,
2005 ),
2006 unit(
2007 "log/a.md#2026-08-21T00:00:00",
2008 "2026-08-21T00:00:00",
2009 UnitChange::Deleted,
2010 false,
2011 ),
2012 unit(
2013 "log/b.md#2026-08-22T00:00:00",
2014 "2026-08-22T00:00:00",
2015 UnitChange::Modified,
2016 false,
2017 ),
2018 unit(
2019 "log/a.md#2026-08-23T00:00:00",
2020 "2026-08-23T00:00:00",
2021 UnitChange::Added,
2022 false,
2023 ),
2024 ];
2025 let cursor = SourceCursor {
2026 union: slice(
2028 &["log/a.md#2026-08-21T00:00:00"],
2029 &["log/b.md#2026-08-22T00:00:00"],
2030 &[
2031 "log/a.md#2026-08-23T00:00:00",
2032 "log/b.md#2026-08-20T00:00:00",
2033 "other/x.rs",
2034 ],
2035 ),
2036 write_commands: vec![],
2037 reseed: vec![],
2038 no_signal: vec![],
2039 any_changes: true,
2040 degraded: false,
2041 dead_denies: vec![],
2042 dest_mem: "home".to_string(),
2043 binding_id: "home/log".to_string(),
2044 delivery: vec![DeliverySequence {
2045 source: "log".to_string(),
2046 preparation: "dated-entries".to_string(),
2047 first_run: false,
2048 degraded: true,
2049 batch: 2,
2050 units,
2051 }],
2052 };
2053 let out = render_changed_slice(&cursor);
2054 assert!(
2055 out.contains("### Delivery sequence: `log` (`dated-entries`)"),
2056 "{out}"
2057 );
2058 assert!(out.contains("The units that changed since the last pass"));
2059 assert!(out.contains("No baseline content was retrievable"));
2060 let listed: Vec<&str> = out
2061 .lines()
2062 .filter(|l| l.starts_with(|c: char| c.is_ascii_digit()))
2063 .collect();
2064 assert_eq!(
2065 listed,
2066 vec![
2067 "2. `log/a.md#2026-08-21T00:00:00` (deleted)",
2068 "3. `log/b.md#2026-08-22T00:00:00` (changed)",
2069 ],
2070 "positions are total-order positions; the disposed first unit is skipped"
2071 );
2072 assert!(out.contains("…and 1 more, presented in order once these are disposed"));
2073 assert!(out.contains("1 unit of this sequence already disposed"));
2074 assert!(out.contains("**Added:**\n- `other/x.rs`\n"), "{out}");
2076 assert!(!out.contains("**Modified:**"));
2077 assert!(!out.contains("**Deleted:**"));
2078 }
2079
2080 #[test]
2082 fn changed_slice_empty_when_nothing_moved() {
2083 let cursor = SourceCursor {
2084 union: slice(&[], &[], &[]),
2085 write_commands: vec![],
2086 reseed: vec![],
2087 no_signal: vec![],
2088 any_changes: false,
2089 degraded: false,
2090 dead_denies: vec![],
2091 dest_mem: "engine".to_string(),
2092 binding_id: "engine/graph".to_string(),
2093 delivery: vec![],
2094 };
2095 assert_eq!(render_changed_slice(&cursor), "");
2096 }
2097
2098 #[test]
2102 fn changed_slice_renders_dead_deny_warning() {
2103 let cursor = SourceCursor {
2104 union: slice(&[], &[], &[]),
2105 write_commands: vec![],
2106 reseed: vec![],
2107 no_signal: vec![],
2108 any_changes: false,
2109 degraded: false,
2110 dead_denies: vec!["dev".to_string(), "typo/**".to_string()],
2111 dest_mem: "engine".to_string(),
2112 binding_id: "engine/graph".to_string(),
2113 delivery: vec![],
2114 };
2115 let out = render_changed_slice(&cursor);
2116 assert!(out.contains("deny_paths` entries match nothing"));
2117 assert!(out.contains("- `dev`"));
2118 assert!(out.contains("- `typo/**`"));
2119 }
2120
2121 #[test]
2125 fn changed_slice_renders_slice_and_recording() {
2126 let cursor = SourceCursor {
2127 union: slice(&["a.rs"], &["b.rs"], &[]),
2128 write_commands: vec![cmd("engine-graph/source", "HEADSHA")],
2129 reseed: vec![],
2130 no_signal: vec![],
2131 any_changes: true,
2132 degraded: false,
2133 dead_denies: vec![],
2134 dest_mem: "engine".to_string(),
2135 binding_id: "engine/graph".to_string(),
2136 delivery: vec![],
2137 };
2138 let expected_lines = [
2139 "## Source changes since the last sync\n",
2140 "The source moved since this graph was last synced. Steer this pass at these changed artifacts **first** — they are where the graph is most likely now wrong.\n",
2141 "**Deleted:**",
2142 "- `a.rs`",
2143 "",
2144 "**Modified:**",
2145 "- `b.rs`",
2146 "",
2147 "### Recording your dispositions (do this LAST)\n",
2148 "Only after you have worked the changed artifacts above — and only for the artifacts you actually judged — record a disposition for each, so the next pass targets just what changes next. This advance is resumable and non-stalling: a partial pass is honored, and if the source moves mid-pass the remaining slice re-presents (remaining + new) without losing your recorded work.\n",
2149 "Anchored work disposes itself: at advance time, every listed artifact that an anchor in the destination mem references is marked `worked` automatically (an explicit disposition you pass wins over the auto-mark). Supply dispositions only for the residue — artifacts you skipped, judged out of intent, or worked without anchors. The gate accepts only artifact ids listed above — an unknown id refuses the whole call. When every artifact is disposed, the sync baseline advances automatically. Run:\n",
2150 "```sh",
2151 r#"memstead projection advance engine/graph --dispositions '{"<artifact>": "<disposition>", ...}'"#,
2152 "```",
2153 "If you were interrupted before finishing, that is fine — your recorded dispositions persist, and the next run re-presents only what is left.\n",
2154 ];
2155 assert_eq!(
2156 render_changed_slice(&cursor),
2157 format!("{}\n", expected_lines.join("\n"))
2158 );
2159 }
2160
2161 #[test]
2164 fn changed_slice_reseed_only() {
2165 let cursor = SourceCursor {
2166 union: slice(&[], &[], &[]),
2167 write_commands: vec![],
2168 reseed: vec![cmd("ing/f", "TOK")],
2169 no_signal: vec![],
2170 any_changes: false,
2171 degraded: false,
2172 dead_denies: vec![],
2173 dest_mem: "d".to_string(),
2174 binding_id: "d/p".to_string(),
2175 delivery: vec![],
2176 };
2177 let out = render_changed_slice(&cursor);
2178 assert!(out.starts_with("## Source changes since the last sync\n\n"));
2179 assert!(out.contains(
2180 "No usable sync baseline exists for `ing/f` — none was recorded, or the recorded one is not a commit of the source's repo (foreign or garbage-collected). Treating the current source state as the baseline. No priority slice from it this pass; proceed as usual."
2181 ));
2182 assert!(out.contains(
2183 r#"memstead projection advance d/p --dispositions '{"<artifact>": "<disposition>", ...}'"#
2184 ));
2185 assert!(
2186 !out.contains("The source moved"),
2187 "no 'moved' copy when only reseeding"
2188 );
2189 }
2190
2191 #[test]
2197 fn changed_slice_renders_no_signal_reasons_distinguishably() {
2198 let cursor = SourceCursor {
2199 union: slice(&[], &[], &[]),
2200 write_commands: vec![],
2201 reseed: vec![],
2202 no_signal: vec![
2203 note("code-facet", NoSignalReason::Unscoped),
2204 note("plan-facet", NoSignalReason::DetectionNone),
2205 note("git-facet", NoSignalReason::GitUnavailable),
2206 note("ref-mem", NoSignalReason::GraphSnapshotMissing),
2207 ],
2208 any_changes: false,
2209 degraded: false,
2210 dead_denies: vec![],
2211 dest_mem: "d".to_string(),
2212 binding_id: "d/p".to_string(),
2213 delivery: vec![],
2214 };
2215 let out = render_changed_slice(&cursor);
2216 assert!(out.starts_with("## Source changes since the last sync\n"));
2217 assert!(out.contains("Some sources produced **no change signal**"));
2218 assert!(out.contains("- `code-facet`: unscoped facet (no allow patterns)"));
2220 assert!(
2221 out.contains("- `plan-facet`: `signal:none`"),
2222 "detection-none renders the literal signal:none state"
2223 );
2224 assert!(out.contains("- `git-facet`: git signal unavailable"));
2225 assert!(out.contains("- `ref-mem`: graph snapshot missing"));
2226 let texts = [
2228 no_signal_reason_text(NoSignalReason::Unscoped, None),
2229 no_signal_reason_text(NoSignalReason::DetectionNone, None),
2230 no_signal_reason_text(NoSignalReason::GitUnavailable, None),
2231 no_signal_reason_text(NoSignalReason::GraphSnapshotMissing, None),
2232 ];
2233 for (i, a) in texts.iter().enumerate() {
2234 for b in &texts[i + 1..] {
2235 assert_ne!(a, b, "each no-signal reason must render distinctly");
2236 }
2237 }
2238 assert!(!out.contains("### Recording your dispositions"));
2240 assert!(!out.contains("The source moved"));
2241 }
2242
2243 #[test]
2247 fn changed_slice_mixes_changes_and_no_signal() {
2248 let cursor = SourceCursor {
2249 union: slice(&[], &["b.rs"], &[]),
2250 write_commands: vec![cmd("ing/f", "HEAD")],
2251 reseed: vec![],
2252 no_signal: vec![note("other", NoSignalReason::Unscoped)],
2253 any_changes: true,
2254 degraded: false,
2255 dead_denies: vec![],
2256 dest_mem: "d".to_string(),
2257 binding_id: "d/p".to_string(),
2258 delivery: vec![],
2259 };
2260 let out = render_changed_slice(&cursor);
2261 assert!(out.contains("The source moved"));
2262 assert!(out.contains("**Modified:**"));
2263 assert!(out.contains("- `other`: unscoped facet"));
2264 assert!(out.contains("### Recording your dispositions"));
2265 assert!(out.contains(
2266 r#"memstead projection advance d/p --dispositions '{"<artifact>": "<disposition>", ...}'"#
2267 ));
2268 }
2269
2270 #[test]
2273 fn renders_one_shot_lens_block() {
2274 let mut r = resolved("os", Some("plan source"), vec![]);
2275 r.rules = Some(serde_json::json!({ "routing": "route each entity to its spec" }));
2276 r.post_actions = Some(serde_json::json!({ "archive_source": true }));
2277
2278 let out = render_one_shot_lens(&r, Some("planning@0.1.0"), Some("the plan graph"));
2279 assert!(out.starts_with("## Mode: one-shot — lens routing\n\n"));
2280 assert!(out.contains(
2281 "### Destination set\n\n| Mem | Schema | Purpose |\n|-------|--------|---------|\n| os | planning@0.1.0 | the plan graph |\n"
2282 ));
2283 assert!(out.contains("### Routing rule\n\n```\nroute each entity to its spec\n```\n"));
2284 assert!(out.contains("### Idempotency"));
2285 assert!(out.contains("### Report: os"));
2286 assert!(out.contains("### Archive after run"));
2287 assert!(out.ends_with("is set on this ingest.\n\n"));
2288
2289 let bare = resolved("os", None, vec![]);
2292 let out2 = render_one_shot_lens(&bare, None, None);
2293 assert!(out2.contains("| os | (none) | (no purpose declared) |"));
2294 assert!(!out2.contains("### Routing rule"));
2295 assert!(!out2.contains("### Archive after run"));
2296 assert!(out2.contains("### End-of-run report"));
2297 }
2298
2299 #[test]
2302 fn assembles_one_shot_brief() {
2303 let mut r = resolved(
2304 "os",
2305 Some("src"),
2306 vec![primary(MediumType::Filesystem, vec![])],
2307 );
2308 r.mode = BuildMode::OneShot;
2309 let g = guidance(Some("goal"), None);
2310 let skipped = ProcessMemInfo {
2311 present: false,
2312 skipped: true,
2313 notice: None,
2314 leaf_name: "os".to_string(),
2315 mem_label: "ingest/os".to_string(),
2316 };
2317 let brief =
2318 assemble_one_shot_brief(&r, &g, &skipped, Some("s@1"), None, &[], Some("purpose"));
2319 assert!(brief.contains("(one-shot mode)"));
2320 assert!(brief.contains("No process mem is paired with this ingest (mode=one-shot;"));
2321 assert!(brief.contains("## Mode: one-shot — lens routing"));
2322 assert!(
2323 brief.contains("## Provenance — anchor your writes"),
2324 "one-shot carries the anchor instruction"
2325 );
2326 assert!(
2327 !brief.contains("## Source changes"),
2328 "one-shot has no changed-slice"
2329 );
2330 }
2331
2332 #[test]
2336 fn changed_slice_caps_and_degrades_and_quotes() {
2337 let many: Vec<String> = (0..SLICE_CAP + 3).map(|i| format!("f{i}.rs")).collect();
2338 let cursor = SourceCursor {
2339 union: Slice {
2340 deleted: vec![],
2341 modified: vec![],
2342 added: many,
2343 },
2344 write_commands: vec![cmd("ing/f", r#"{"v":1,"aggregate":"x"}"#)],
2345 reseed: vec![],
2346 no_signal: vec![],
2347 any_changes: true,
2348 degraded: true,
2349 dead_denies: vec![],
2350 dest_mem: "d".to_string(),
2351 binding_id: "d/p".to_string(),
2352 delivery: vec![],
2353 };
2354 let out = render_changed_slice(&cursor);
2355 assert!(out.contains(&format!("- …and {} more added", 3)));
2356 assert!(out.contains("Precise change history for one or more facets was unavailable"));
2357 assert!(out.contains(
2360 r#"memstead projection advance d/p --dispositions '{"<artifact>": "<disposition>", ...}'"#
2361 ));
2362 }
2363
2364 fn finding(class: FindingClass, target: FindingTarget, detail: &str) -> Finding {
2367 Finding {
2368 key: crate::ingest::findings::FindingKey {
2369 binding_hash: "h".to_string(),
2370 source_head: "s".to_string(),
2371 },
2372 facet: "src".to_string(),
2373 target,
2374 class,
2375 detail: detail.to_string(),
2376 created_at: "1".to_string(),
2377 }
2378 }
2379
2380 fn anchor_target(entity: &str, artifact: &str) -> FindingTarget {
2381 FindingTarget::Anchor {
2382 entity: entity.to_string(),
2383 artifact: artifact.to_string(),
2384 }
2385 }
2386
2387 fn artifact_target(artifact: &str) -> FindingTarget {
2388 FindingTarget::Artifact {
2389 artifact: artifact.to_string(),
2390 }
2391 }
2392
2393 fn empty_cursor() -> SourceCursor {
2394 SourceCursor {
2395 union: slice(&[], &[], &[]),
2396 write_commands: vec![],
2397 reseed: vec![],
2398 no_signal: vec![],
2399 any_changes: false,
2400 degraded: false,
2401 dead_denies: vec![],
2402 dest_mem: "engine".to_string(),
2403 binding_id: "engine/graph".to_string(),
2404 delivery: vec![],
2405 }
2406 }
2407
2408 #[test]
2412 fn verify_brief_measures_and_refuses_mutation() {
2413 let r = resolved("engine", None, vec![]);
2414 let out = render_verify_brief(&r, 3);
2415 assert!(out.starts_with("## Verify — measure fidelity, do not mutate"));
2417 assert!(out.contains("3 finding(s) are queued for adjudication"));
2418 assert!(out.contains("per-run adjudication cap"));
2419 assert!(out.contains("this is a measurement, not a repair"));
2420 assert!(out.contains("Verify writes **no entity content**"));
2441 assert!(out.contains("`#verified` baseline"));
2442 assert!(out.contains("`--advance`"));
2443 assert!(out.contains("memstead projection brief --sync"));
2444 assert!(out.contains("do not create or delete an entity"));
2447 assert!(!out.contains("via `memstead_create`"));
2448 assert!(!out.contains("Run `memstead_update`"));
2449
2450 let zero = render_verify_brief(&r, 0);
2452 assert!(zero.contains("No findings are queued for adjudication"));
2453 assert!(zero.contains("record any drift you observe as a finding"));
2454 assert!(zero.contains("Verify writes **no entity content**"));
2455 }
2456
2457 #[test]
2461 fn sync_brief_carries_both_cursor_and_findings() {
2462 let r = resolved("engine", None, vec![]);
2463 let cursor = SourceCursor {
2464 union: slice(&["gone.rs"], &["moved.rs"], &[]),
2465 write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
2466 reseed: vec![],
2467 no_signal: vec![],
2468 any_changes: true,
2469 degraded: false,
2470 dead_denies: vec![],
2471 dest_mem: "engine".to_string(),
2472 binding_id: "engine/graph".to_string(),
2473 delivery: vec![],
2474 };
2475 let findings = vec![
2476 finding(
2477 FindingClass::Drifted,
2478 anchor_target("engine--e", "src/moved.rs"),
2479 "prepared-content hash drifted",
2480 ),
2481 finding(
2482 FindingClass::Uncovered,
2483 artifact_target("src/new.rs"),
2484 "in scope, no anchor",
2485 ),
2486 ];
2487 let out = render_sync_brief(
2488 &r,
2489 &cursor,
2490 &findings,
2491 &[],
2492 false,
2493 &crate::ingest::advance::ExclusionLedger::default(),
2494 );
2495 assert!(out.contains("## Source changes since the last sync"));
2497 assert!(out.contains("`moved.rs`"));
2498 assert!(out.contains("## Open findings to repair"));
2499 assert!(out.contains("`engine--e` → `src/moved.rs`"));
2500 assert!(out.contains("`src/new.rs`"));
2501 assert!(out.contains("sole maintenance writer"));
2503 assert!(out.contains("commits each one **per-mutation**"));
2504 assert!(out.contains("Sync commits nothing."));
2505 }
2506
2507 #[test]
2512 fn sync_brief_absorbs_reconcile_conservatism() {
2513 let r = resolved("engine", None, vec![]);
2514 let findings = vec![finding(
2515 FindingClass::Uncovered,
2516 artifact_target("src/x.rs"),
2517 "d",
2518 )];
2519 let out = render_sync_brief(
2520 &r,
2521 &empty_cursor(),
2522 &findings,
2523 &[],
2524 false,
2525 &crate::ingest::advance::ExclusionLedger::default(),
2526 );
2527 assert!(out.contains("Unsure whether an entity is affected — skip it."));
2529 assert!(out.contains(
2530 "Do not create a new entity unless the change clearly introduces a new concept"
2531 ));
2532 assert!(
2533 out.contains("Do not delete an entity unless the change removes the concept entirely.")
2534 );
2535 assert!(out.contains("Never rewrite a section that has not changed"));
2536 assert!(out.contains(
2537 "No speculative edges — add only relationships the diff literally introduces"
2538 ));
2539 assert!(out.contains("A dropped dependency FLAGS, it does not auto-remove."));
2541 assert!(out.contains("Edge removal is out of scope for sync."));
2542 assert!(out.contains("Rationale is reasoning, not a changelog."));
2544 assert!(out.contains("`[commit <hash>]` log-style entries"));
2545 }
2546
2547 #[test]
2551 fn sync_brief_renders_adopt_framing() {
2552 let mut r = resolved("engine", None, vec![]);
2553 r.name = "engine/graph".to_string();
2557 let out = render_sync_brief(
2558 &r,
2559 &empty_cursor(),
2560 &[],
2561 &[],
2562 true,
2563 &crate::ingest::advance::ExclusionLedger::default(),
2564 );
2565 assert!(out.contains("## First sync — adopting `engine`"));
2566 assert!(out.contains("0% anchored is expected — this is onboarding, not a failure."));
2567 assert!(out.contains("do **not** replay the whole history"));
2568 assert!(out.contains("**Backfill path:**"));
2569 assert!(out.contains("memstead projection verify engine/graph"));
2570 }
2571
2572 #[test]
2575 fn sync_brief_inherits_first_sync_reseed_framing() {
2576 let r = resolved("engine", None, vec![]);
2577 let mut cursor = empty_cursor();
2578 cursor.reseed = vec![cmd("engine/graph/src#synced", "TOK")];
2579 let out = render_sync_brief(
2580 &r,
2581 &cursor,
2582 &[],
2583 &[],
2584 false,
2585 &crate::ingest::advance::ExclusionLedger::default(),
2586 );
2587 assert!(out.contains("No usable sync baseline exists for"));
2588 assert!(out.contains("Treating the current source state as the baseline"));
2589 }
2590
2591 #[test]
2594 fn sync_brief_nothing_to_sync() {
2595 let r = resolved("engine", None, vec![]);
2596 let out = render_sync_brief(
2597 &r,
2598 &empty_cursor(),
2599 &[],
2600 &[],
2601 false,
2602 &crate::ingest::advance::ExclusionLedger::default(),
2603 );
2604 assert!(out.contains("## Nothing to sync"));
2605 assert!(!out.contains("## How to repair"));
2606 assert!(!out.contains("## Open findings"));
2607 }
2608
2609 #[test]
2614 fn only_sync_brief_carries_repair_instructions() {
2615 let r = resolved("engine", None, vec![]);
2616 let findings = vec![finding(
2617 FindingClass::Drifted,
2618 anchor_target("engine--e", "src/a.rs"),
2619 "d",
2620 )];
2621 let verify = render_verify_brief(&r, 1);
2622 let sync = render_sync_brief(
2623 &r,
2624 &empty_cursor(),
2625 &findings,
2626 &[],
2627 false,
2628 &crate::ingest::advance::ExclusionLedger::default(),
2629 );
2630 assert!(!verify.contains("## How to repair"));
2632 assert!(!verify.contains("Update the affected section"));
2633 assert!(sync.contains("## How to repair — be conservative"));
2635 assert!(sync.contains("## Open findings to repair"));
2636 assert!(sync.contains("Update the affected section to match"));
2637 }
2638
2639 #[test]
2643 fn sync_brief_changed_slice_renders_stale_claim_search() {
2644 let r = resolved("engine", None, vec![]);
2645 let cursor = SourceCursor {
2646 union: slice(&[], &["moved.rs"], &[]),
2647 write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
2648 reseed: vec![],
2649 no_signal: vec![],
2650 any_changes: true,
2651 degraded: false,
2652 dead_denies: vec![],
2653 dest_mem: "engine".to_string(),
2654 binding_id: "engine/graph".to_string(),
2655 delivery: vec![],
2656 };
2657 let out = render_sync_brief(
2658 &r,
2659 &cursor,
2660 &[],
2661 &[],
2662 false,
2663 &crate::ingest::advance::ExclusionLedger::default(),
2664 );
2665 assert!(out.contains("## Stale claims beyond the slice — search, then judge"));
2666 assert!(out.contains("Extract the **changed facts** from the changed artifacts above"));
2668 assert!(out.contains("search the destination mem `engine`"));
2669 assert!(out.contains("`memstead_search`"));
2670 assert!(out.contains("judge **only** the entities whose claims actually mention"));
2671 assert!(out.contains("not a live-verify of every entity"));
2674 assert!(out.contains("not a rewrite license"));
2675 assert!(out.contains("the fact set is empty and this step ends with no"));
2676 assert!(out.contains("Never rewrite a section that has not changed"));
2679 }
2680
2681 #[test]
2685 fn sync_brief_without_changes_renders_no_stale_claim_search() {
2686 let r = resolved("engine", None, vec![]);
2687 let heading = "## Stale claims beyond the slice";
2688
2689 let findings = vec![finding(
2691 FindingClass::Uncovered,
2692 artifact_target("src/x.rs"),
2693 "d",
2694 )];
2695 let out = render_sync_brief(
2696 &r,
2697 &empty_cursor(),
2698 &findings,
2699 &[],
2700 false,
2701 &crate::ingest::advance::ExclusionLedger::default(),
2702 );
2703 assert!(!out.contains(heading), "findings-only pass must not search");
2704
2705 let mut reseed_cursor = empty_cursor();
2707 reseed_cursor.reseed = vec![cmd("engine/graph/src#synced", "TOK")];
2708 let out = render_sync_brief(
2709 &r,
2710 &reseed_cursor,
2711 &[],
2712 &[],
2713 false,
2714 &crate::ingest::advance::ExclusionLedger::default(),
2715 );
2716 assert!(!out.contains(heading), "reseed-only pass must not search");
2717
2718 let out = render_sync_brief(
2720 &r,
2721 &empty_cursor(),
2722 &[],
2723 &[],
2724 false,
2725 &crate::ingest::advance::ExclusionLedger::default(),
2726 );
2727 assert!(!out.contains(heading));
2728 }
2729
2730 #[test]
2733 fn sync_brief_caps_large_findings_group() {
2734 let r = resolved("engine", None, vec![]);
2735 let findings: Vec<Finding> = (0..FINDINGS_CAP + 4)
2736 .map(|i| {
2737 finding(
2738 FindingClass::Uncovered,
2739 artifact_target(&format!("src/f{i}.rs")),
2740 "d",
2741 )
2742 })
2743 .collect();
2744 let out = render_sync_brief(
2745 &r,
2746 &empty_cursor(),
2747 &findings,
2748 &[],
2749 false,
2750 &crate::ingest::advance::ExclusionLedger::default(),
2751 );
2752 assert!(out.contains("- …and 4 more"));
2753 assert!(!out.contains(&format!("src/f{}.rs", FINDINGS_CAP + 3)));
2755 }
2756
2757 #[test]
2768 fn sync_brief_block_sequence_locked_for_changed_slice() {
2769 let r = resolved("engine", None, vec![]);
2770 let cursor = SourceCursor {
2771 union: slice(&["gone.rs"], &["moved.rs"], &["new.rs"]),
2772 write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
2773 reseed: vec![],
2774 no_signal: vec![],
2775 any_changes: true,
2776 degraded: false,
2777 dead_denies: vec![],
2778 dest_mem: "engine".to_string(),
2779 binding_id: "engine/graph".to_string(),
2780 delivery: vec![],
2781 };
2782 let findings = vec![
2783 finding(
2784 FindingClass::Drifted,
2785 anchor_target("engine--e", "src/moved.rs"),
2786 "prepared-content hash drifted",
2787 ),
2788 finding(
2789 FindingClass::Uncovered,
2790 artifact_target("src/new.rs"),
2791 "in scope, no anchor",
2792 ),
2793 ];
2794 let out = render_sync_brief(
2795 &r,
2796 &cursor,
2797 &findings,
2798 &[],
2799 false,
2800 &crate::ingest::advance::ExclusionLedger::default(),
2801 );
2802 let headings: Vec<&str> = out
2803 .lines()
2804 .filter(|l| l.starts_with("## ") || l.starts_with("### "))
2805 .collect();
2806 assert_eq!(
2807 headings,
2808 vec![
2809 "## Sync — repair the graph to match the source",
2810 "## Source changes since the last sync",
2811 "### Recording your dispositions (do this LAST)",
2812 "## Stale claims beyond the slice — search, then judge",
2813 "## Open findings to repair",
2814 "### Drifted — the anchored content changed",
2815 "### Uncovered — a source artifact with no entity",
2816 "## Provenance — anchor your writes",
2820 "## How to repair — be conservative",
2821 ],
2822 "the loop-path sync brief carries exactly these blocks, in this order"
2823 );
2824 assert!(out.ends_with("`[commit <hash>]` log-style entries.\n\n"));
2827 }
2828
2829 #[test]
2840 fn no_default_path_brief_carries_inventory_machinery() {
2841 let inventory_terms = [
2844 "--full",
2845 "inventory",
2846 "full measurement",
2847 "did not converge",
2848 "quiescence",
2849 ];
2850 let assert_clean = |label: &str, text: &str| {
2851 let lower = text.to_lowercase();
2852 for term in inventory_terms {
2853 assert!(
2854 !lower.contains(term),
2855 "{label} must carry no inventory machinery (found {term:?})"
2856 );
2857 }
2858 };
2859
2860 let r = resolved("engine", None, vec![]);
2861 let g = guidance(Some("build coverage"), None);
2862 let pm = process_present("engine");
2863
2864 let changed_cursor = SourceCursor {
2866 union: slice(&[], &["moved.rs"], &[]),
2867 write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
2868 reseed: vec![],
2869 no_signal: vec![],
2870 any_changes: true,
2871 degraded: false,
2872 dead_denies: vec![],
2873 dest_mem: "engine".to_string(),
2874 binding_id: "engine/graph".to_string(),
2875 delivery: vec![],
2876 };
2877 let preface = render_changed_slice(&changed_cursor);
2878 assert_clean(
2879 "discovery build brief (plain roam)",
2880 &assemble_discovery_brief(&r, &g, &pm, Some("s@1"), None, &[], ""),
2881 );
2882 assert_clean(
2883 "discovery build brief (changed slice)",
2884 &assemble_discovery_brief(&r, &g, &pm, Some("s@1"), None, &[], &preface),
2885 );
2886 assert_clean(
2887 "one-shot build brief",
2888 &assemble_one_shot_brief(&r, &g, &pm, Some("s@1"), None, &[], Some("purpose")),
2889 );
2890
2891 assert_clean("verify brief (backlog)", &render_verify_brief(&r, 3));
2893 assert_clean("verify brief (no backlog)", &render_verify_brief(&r, 0));
2894
2895 let findings = vec![finding(
2897 FindingClass::Drifted,
2898 anchor_target("engine--e", "src/moved.rs"),
2899 "d",
2900 )];
2901 assert_clean(
2902 "sync brief (changed slice + findings)",
2903 &render_sync_brief(
2904 &r,
2905 &changed_cursor,
2906 &findings,
2907 &[],
2908 false,
2909 &crate::ingest::advance::ExclusionLedger::default(),
2910 ),
2911 );
2912 assert_clean(
2913 "sync brief (findings-only)",
2914 &render_sync_brief(
2915 &r,
2916 &empty_cursor(),
2917 &findings,
2918 &[],
2919 false,
2920 &crate::ingest::advance::ExclusionLedger::default(),
2921 ),
2922 );
2923 assert_clean(
2924 "sync brief (nothing to sync)",
2925 &render_sync_brief(
2926 &r,
2927 &empty_cursor(),
2928 &[],
2929 &[],
2930 false,
2931 &crate::ingest::advance::ExclusionLedger::default(),
2932 ),
2933 );
2934 assert_clean(
2935 "sync brief (adopt)",
2936 &render_sync_brief(
2937 &r,
2938 &empty_cursor(),
2939 &[],
2940 &[],
2941 true,
2942 &crate::ingest::advance::ExclusionLedger::default(),
2943 ),
2944 );
2945 }
2946}