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 if is_graph {
268 lines.push(format!(
269 " - Read the source baseline with `memstead_search mem={}` \
270 (add `entity_type=` to match a `type:` selector). The changed \
271 slice below is a delta against the last pass — it is not the \
272 whole source, and an entity absent from it may still be \
273 unprojected.",
274 p.pointer
275 ));
276 }
277 }
278 ResolvedSource::Reference { mem } => {
279 lines.push(format!("- **graph** (reference) — mem: {mem}"));
280 reference_mems.push(mem.clone());
281 }
282 }
283 }
284 lines.push(String::new());
285 if !reference_mems.is_empty() {
286 lines.push(
287 "Sources tagged `(reference)` are read-only context for cross-mem edges — search \
288 them, never write into them. Only `(primary)` sources are ingested into the \
289 destination."
290 .to_string(),
291 );
292 lines.push(String::new());
293 let mem_list = reference_mems
294 .iter()
295 .map(|v| format!("`memstead_search mem={v}`"))
296 .collect::<Vec<_>>()
297 .join(", ");
298 lines.push(format!(
299 "**Cross-mem references:** consult {mem_list} before authoring cross-mem edges. \
300 The target entity must exist — a wiki-link or relationship to a missing target \
301 either auto-stubs (silent) or fails authorization (`CROSS_MEM_RELATION`)."
302 ));
303 lines.push(String::new());
304 }
305 }
306
307 lines.push("### Destination".to_string());
309 lines.push(String::new());
310 let schema_bit = destination_schema
311 .map(|s| format!(" — schema: `{s}`"))
312 .unwrap_or_default();
313 lines.push(format!("- **{}**{schema_bit}", resolved.destination_mem));
314 if let Some(note) = destination_note {
325 lines.push(format!(" - {note}"));
326 }
327 lines.push(String::new());
328
329 if process_mem.present {
331 lines.push("### Paired process mem".to_string());
332 lines.push(String::new());
333 lines.push(format!(
334 "- **{}** — schema: `{PROCESS_MEM_SCHEMA}`. Inspect via `memstead_overview` / \
335 `memstead_search mem={}`.",
336 process_mem.mem_label, process_mem.leaf_name
337 ));
338 lines.push(String::new());
339 }
340
341 format!("{}\n", lines.join("\n"))
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct SyncCommand {
351 pub key: String,
353 pub token: String,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct NoSignalNote {
364 pub source: String,
367 pub reason: NoSignalReason,
369 pub medium_type: Option<MediumType>,
375}
376
377#[derive(Debug, Clone, PartialEq, Eq)]
382pub struct SourceCursor {
383 pub union: Slice,
385 pub write_commands: Vec<SyncCommand>,
387 pub reseed: Vec<SyncCommand>,
389 pub no_signal: Vec<NoSignalNote>,
395 pub any_changes: bool,
397 pub degraded: bool,
399 pub dead_denies: Vec<String>,
408 pub dest_mem: String,
410 pub binding_id: String,
414 pub delivery: Vec<DeliverySequence>,
419}
420
421#[derive(Debug, Clone, PartialEq, Eq)]
423pub struct DeliveredUnit {
424 pub id: String,
426 pub order_key: String,
428 pub change: crate::preparation::UnitChange,
430 pub disposed: bool,
433}
434
435#[derive(Debug, Clone, PartialEq, Eq)]
439pub struct DeliverySequence {
440 pub source: String,
442 pub preparation: String,
444 pub first_run: bool,
446 pub degraded: bool,
449 pub batch: usize,
452 pub units: Vec<DeliveredUnit>,
454}
455
456fn shell_quote(s: &str) -> String {
460 format!("'{}'", s.replace('\'', "'\\''"))
461}
462
463fn render_delivery_sequence(lines: &mut Vec<String>, seq: &DeliverySequence) {
471 use crate::preparation::UnitChange;
472 lines.push(format!(
473 "### Delivery sequence: `{}` (`{}`)\n",
474 seq.source, seq.preparation
475 ));
476 let opening = if seq.first_run {
477 "First delivery of this source: every unit, in the source's own order."
478 } else {
479 "The units that changed since the last pass, at their positions in the source's own \
480 order."
481 };
482 lines.push(format!(
483 "{opening} Work them top to bottom: the order derives from the units' own keys, never \
484 from discovery or directory order, it is identical on every pass, and a unit assumes \
485 only the units numbered before it. Address a unit as `<path>#<key>` in anchors and \
486 dispositions.\n"
487 ));
488 if seq.degraded {
489 lines.push(
490 "_(No baseline content was retrievable for one or more changed files, so every unit \
491 of those files is listed; precision is coarser this pass only.)_\n"
492 .to_string(),
493 );
494 }
495 let pending: Vec<(usize, &DeliveredUnit)> = seq
496 .units
497 .iter()
498 .enumerate()
499 .filter(|(_, u)| !u.disposed)
500 .collect();
501 let disposed = seq.units.len() - pending.len();
502 let shown = if seq.batch == 0 {
503 pending.len()
504 } else {
505 pending.len().min(seq.batch)
506 };
507 for (position, unit) in &pending[..shown] {
508 let label = match unit.change {
509 UnitChange::Added => "new",
510 UnitChange::Modified => "changed",
511 UnitChange::Deleted => "deleted",
512 };
513 lines.push(format!("{}. `{}` ({label})", position + 1, unit.id));
514 }
515 if pending.len() > shown {
516 lines.push(format!(
517 "- …and {} more, presented in order once these are disposed",
518 pending.len() - shown
519 ));
520 }
521 if disposed > 0 {
522 lines.push(format!(
523 "_({disposed} unit{} of this sequence already disposed this pass.)_",
524 if disposed == 1 { "" } else { "s" }
525 ));
526 }
527 if pending.is_empty() {
528 lines.push(
529 "_(Every unit of this sequence is disposed; the baseline advances when the pass \
530 completes.)_"
531 .to_string(),
532 );
533 }
534 lines.push(String::new());
535}
536
537fn render_slice_class(lines: &mut Vec<String>, label: &str, paths: &[String]) {
538 if paths.is_empty() {
539 return;
540 }
541 let shown = paths.len().min(SLICE_CAP);
542 lines.push(format!("**{label}:**"));
543 for path in &paths[..shown] {
544 lines.push(format!("- `{path}`"));
545 }
546 if paths.len() > shown {
547 lines.push(format!(
548 "- …and {} more {}",
549 paths.len() - shown,
550 label.to_lowercase()
551 ));
552 }
553 lines.push(String::new());
554}
555
556fn no_signal_reason_text(reason: NoSignalReason, medium: Option<MediumType>) -> &'static str {
561 match reason {
562 NoSignalReason::Unscoped => match medium {
566 Some(MediumType::Graph) => {
567 "unscoped facet (no allow patterns) — nothing is monitored; write `*` in the \
568 facet scope to watch the whole mem, or `type:<entity_type>` / `id:<glob>` \
569 to narrow it (a graph source selects entities, not paths)"
570 }
571 _ => {
572 "unscoped facet (no allow patterns) — nothing is monitored; write `**/*` in the \
573 facet scope to watch the whole medium"
574 }
575 },
576 NoSignalReason::DetectionNone => {
577 "`signal:none` — change detection is disabled for this source (declared `none`)"
578 }
579 NoSignalReason::GitUnavailable => {
580 "git signal unavailable — no work tree, an unreadable `HEAD`, or an unknown baseline; \
581 a full re-roam is warranted this pass"
582 }
583 NoSignalReason::GraphSnapshotMissing => {
584 "graph snapshot missing — the source mem has no comparable baseline this pass"
585 }
586 }
587}
588
589pub fn render_changed_slice(cursor: &SourceCursor) -> String {
596 if !cursor.any_changes
597 && cursor.reseed.is_empty()
598 && cursor.no_signal.is_empty()
599 && cursor.dead_denies.is_empty()
600 {
601 return String::new();
602 }
603 let mut lines: Vec<String> = Vec::new();
604 lines.push("## Source changes since the last sync\n".to_string());
605
606 if cursor.any_changes {
607 lines.push(
608 "The source moved since this graph was last synced. Steer this pass at these changed \
609 artifacts **first** — they are where the graph is most likely now wrong.\n"
610 .to_string(),
611 );
612 for seq in &cursor.delivery {
616 render_delivery_sequence(&mut lines, seq);
617 }
618 let unit_ids: std::collections::BTreeSet<&str> = cursor
619 .delivery
620 .iter()
621 .flat_map(|s| s.units.iter().map(|u| u.id.as_str()))
622 .collect();
623 let without_units = |v: &[String]| -> Vec<String> {
624 v.iter()
625 .filter(|p| !unit_ids.contains(p.as_str()))
626 .cloned()
627 .collect()
628 };
629 render_slice_class(&mut lines, "Deleted", &without_units(&cursor.union.deleted));
631 render_slice_class(
632 &mut lines,
633 "Modified",
634 &without_units(&cursor.union.modified),
635 );
636 render_slice_class(&mut lines, "Added", &without_units(&cursor.union.added));
637 if cursor.degraded {
638 lines.push(
639 "_(Precise change history for one or more facets was unavailable, so its full \
640 current file set is listed above. Detection still fired from the durable baseline; \
641 targeting is coarser this pass only.)_\n"
642 .to_string(),
643 );
644 }
645 }
646
647 if !cursor.reseed.is_empty() {
648 let keys = cursor
649 .reseed
650 .iter()
651 .map(|r| format!("`{}`", r.key))
652 .collect::<Vec<_>>()
653 .join(", ");
654 let it = if cursor.reseed.len() == 1 {
655 "it"
656 } else {
657 "them"
658 };
659 lines.push(format!(
660 "No usable sync baseline exists for {keys} — none was recorded, or the recorded one \
661 is not a commit of the source's repo (foreign or garbage-collected). Treating the \
662 current source state as the baseline. No priority slice from {it} this pass; \
663 proceed as usual.\n"
664 ));
665 }
666
667 if !cursor.no_signal.is_empty() {
668 lines.push(
669 "Some sources produced **no change signal** this pass — detection could not compare \
670 them against a baseline, so they were not steered (roam them as usual). This is \
671 distinct from a source that was checked and had not moved:\n"
672 .to_string(),
673 );
674 for note in &cursor.no_signal {
675 lines.push(format!(
676 "- `{}`: {}",
677 note.source,
678 no_signal_reason_text(note.reason, note.medium_type)
679 ));
680 }
681 lines.push(String::new());
682 }
683
684 if !cursor.dead_denies.is_empty() {
685 lines.push(
686 "**Warning — some `deny_paths` entries match nothing.** The following ingest \
687 `deny_paths` selected **no file** in the project tree, so they exclude nothing from \
688 the slice and hide nothing from the ingest agent. This is usually a typo or a legacy \
689 bare name that never migrated to the workspace-relative glob dialect (e.g. `dev` → \
690 `dev/**`, `VISION.md` → `**/VISION.md`). Fix or remove them:\n"
691 .to_string(),
692 );
693 for entry in &cursor.dead_denies {
694 lines.push(format!("- `{entry}`"));
695 }
696 lines.push(String::new());
697 }
698
699 let has_baseline_to_advance = !cursor.write_commands.is_empty() || !cursor.reseed.is_empty();
707 if has_baseline_to_advance {
708 lines.push("### Recording your dispositions (do this LAST)\n".to_string());
709 lines.push(
710 "Only after you have worked the changed artifacts above — and only for the artifacts \
711 you actually judged — record a disposition for each, so the next pass targets just \
712 what changes next. This advance is resumable and non-stalling: a partial pass is \
713 honored, and if the source moves mid-pass the remaining slice re-presents \
714 (remaining + new) without losing your recorded work.\n"
715 .to_string(),
716 );
717 lines.push(
718 "Anchored work disposes itself: at advance time, every listed artifact that an \
719 anchor in the destination mem references is marked `worked` automatically (an \
720 explicit disposition you pass wins over the auto-mark). Supply dispositions only \
721 for the residue — artifacts you skipped, judged out of intent, or worked without \
722 anchors. The gate accepts only artifact ids listed above — an unknown id refuses \
723 the whole call. When every artifact is disposed, the sync baseline advances \
724 automatically. Run:\n"
725 .to_string(),
726 );
727 lines.push("```sh".to_string());
728 lines.push(format!(
729 "memstead projection advance {} --dispositions {}",
730 cursor.binding_id,
731 shell_quote(r#"{"<artifact>": "<disposition>", ...}"#)
732 ));
733 lines.push("```".to_string());
734 lines.push(
735 "If you were interrupted before finishing, that is fine — your recorded dispositions \
736 persist, and the next run re-presents only what is left.\n"
737 .to_string(),
738 );
739 }
740
741 format!("{}\n", lines.join("\n"))
742}
743
744pub fn render_anchor_instruction(resolved: &ResolvedIngest) -> String {
757 let mut block = "## Provenance — anchor your writes\n\n\
758 Attach an `anchors` list to every `memstead_create` / `memstead_update`, naming the \
759 source artifact(s) the entity is drawn from (the mutation tools document the element \
760 shape). Anchored writes are what verify measures coverage and drift against, and — on \
761 cursor-driven passes — what the advance gate auto-marks `worked`; an unanchored write \
762 leaves the fidelity report and the disposition window blind to your work.\n\n"
763 .to_string();
764 let primary_names: Vec<&str> = resolved
768 .sources
769 .iter()
770 .filter_map(|s| match s {
771 crate::ingest::resolve::ResolvedSource::Primary(src) => Some(src.name.as_str()),
772 crate::ingest::resolve::ResolvedSource::Reference { .. } => None,
773 })
774 .collect();
775 if !primary_names.is_empty() {
776 block.push_str(&format!(
777 "Set each anchor's `source` to the binding source name you drew the artifact \
778 from — this binding declares: {}. The name selects the pointer the \
779 artifact path is joined onto, so the wrong one usually refuses \
780 `INVALID_ANCHOR` (the path resolves under no candidate join). A name \
781 outside the list is NOT itself refused when the path happens to \
782 resolve workspace-relative — that tolerance exists for anchors whose \
783 binding was later renamed — so getting it right is on you, not on a \
784 gate.\n\n",
785 primary_names
786 .iter()
787 .map(|n| format!("`{n}`"))
788 .collect::<Vec<_>>()
789 .join(", ")
790 ));
791 }
792 for source in &resolved.sources {
795 let crate::ingest::resolve::ResolvedSource::Primary(src) = source else {
796 continue;
797 };
798 let Some(prep) = src
799 .preparation
800 .as_deref()
801 .and_then(crate::preparation::lookup)
802 else {
803 continue;
804 };
805 let what = match prep.id {
806 crate::preparation::CODE_MAP => {
807 "the file's interface digest (imports, exports, signatures; comments, \
808 formatting and bodies invisible), and a `tree` anchor the code map of every \
809 scoped file under it"
810 }
811 crate::preparation::DATED_ENTRIES => {
812 "the unit's own text for a `<path>#<key>` span, the file's bytes otherwise"
813 }
814 crate::preparation::ENTITY_LOAD_BEARING => "the entity's load-bearing sections",
815 _ => prep.description,
816 };
817 block.push_str(&format!(
818 "Anchors on `{}` hash a prepared form (`{}`): {what}. Never compute `hash` \
819 yourself for this source — leave it empty (verify records it on first \
820 observation), or for a `file` or `span` anchor pass the artifact's `content` \
821 and the engine hashes the prepared form (a `tree` anchor takes no content).\n\n",
822 src.name, prep.id
823 ));
824 }
825 block
826}
827
828#[allow(clippy::too_many_arguments)]
829pub fn assemble_discovery_brief(
830 resolved: &ResolvedIngest,
831 guidance: &ResolvedGuidance,
832 process_mem: &ProcessMemInfo,
833 destination_schema: Option<&str>,
834 destination_note: Option<&str>,
835 absent_sources: &[String],
836 changed_slice_preface: &str,
837) -> String {
838 let parts = [
839 render_situation(resolved, process_mem),
840 render_intent(resolved),
841 render_goal_and_avoid(guidance),
842 render_operative_data(
843 resolved,
844 process_mem,
845 destination_schema,
846 destination_note,
847 absent_sources,
848 ),
849 render_anchor_instruction(resolved),
850 changed_slice_preface.to_string(),
851 ];
852 parts
853 .into_iter()
854 .filter(|p| !p.is_empty())
855 .collect::<Vec<_>>()
856 .join("")
857}
858
859pub fn render_one_shot_lens(
865 resolved: &ResolvedIngest,
866 destination_schema: Option<&str>,
867 destination_purpose: Option<&str>,
868) -> String {
869 let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
870 let mut lines: Vec<String> = vec![
871 "## Mode: one-shot — lens routing".to_string(),
872 String::new(),
873 "A lens iterates entities once and writes per-destination, then exits. The agent decides \
874 per-entity which destinations to target (Routing rule). Re-runs use `memstead_update`; \
875 never duplicate."
876 .to_string(),
877 String::new(),
878 ];
879
880 lines.push("### Destination set".to_string());
881 lines.push(String::new());
882 lines.push("| Mem | Schema | Purpose |".to_string());
883 lines.push("|-------|--------|---------|".to_string());
884 let schema = destination_schema.unwrap_or("(none)");
885 let purpose = destination_purpose
886 .filter(|s| !s.is_empty())
887 .unwrap_or("(no purpose declared)");
888 lines.push(format!(
889 "| {} | {} | {} |",
890 cell(&resolved.destination_mem),
891 cell(schema),
892 cell(purpose)
893 ));
894 lines.push(String::new());
895
896 if let Some(routing) = resolved
897 .rules
898 .as_ref()
899 .and_then(|r| r.get("routing"))
900 .and_then(|v| v.as_str())
901 .map(str::trim)
902 .filter(|s| !s.is_empty())
903 {
904 lines.push("### Routing rule".to_string());
905 lines.push(String::new());
906 lines.push("```".to_string());
907 lines.push(routing.to_string());
908 lines.push("```".to_string());
909 lines.push(String::new());
910 }
911
912 lines.push("### Idempotency".to_string());
913 lines.push(String::new());
914 lines.push("- Search the destination before writing; route changes through `memstead_update` against the existing entity if present.".to_string());
915 lines.push("- Skip writes when the lifted content matches what is already there (record as `skipped: already-up-to-date`).".to_string());
916 lines.push(
917 "- Use `memstead_create` only when no entity for that concept exists yet.".to_string(),
918 );
919 lines.push(String::new());
920
921 lines.push("### End-of-run report".to_string());
922 lines.push(String::new());
923 lines.push("After every destination is processed, emit one block per destination on stdout, in Destination-set order:".to_string());
924 lines.push(String::new());
925 lines.push("```".to_string());
926 lines.push(format!("### Report: {}", resolved.name));
927 lines.push(String::new());
928 lines.push("Destination: <mem>".to_string());
929 lines.push(" created: <count>".to_string());
930 lines.push(" updated: <count>".to_string());
931 lines.push(" skipped: <count>".to_string());
932 lines.push(" failed: <count>".to_string());
933 lines.push(" failures:".to_string());
934 lines.push(" - <entity-key>: <error verbatim>".to_string());
935 lines.push(" skipped-detail:".to_string());
936 lines.push(" - <entity-key>: <one-line reason>".to_string());
937 lines.push("```".to_string());
938 lines.push(String::new());
939 lines.push("Per-destination commits are independent — partial success is the accepted failure mode. No rollback.".to_string());
940 lines.push(String::new());
941
942 let archive = resolved
943 .post_actions
944 .as_ref()
945 .and_then(|p| p.get("archive_source"))
946 .and_then(serde_json::Value::as_bool)
947 .unwrap_or(false);
948 if archive {
949 lines.push("### Archive after run".to_string());
950 lines.push(String::new());
951 lines.push("After the report has been emitted, archive the source planning mem — `post_actions.archive_source` is set on this ingest.".to_string());
952 lines.push(String::new());
953 }
954
955 format!("{}\n", lines.join("\n"))
956}
957
958#[allow(clippy::too_many_arguments)]
963pub fn assemble_one_shot_brief(
964 resolved: &ResolvedIngest,
965 guidance: &ResolvedGuidance,
966 process_mem: &ProcessMemInfo,
967 destination_schema: Option<&str>,
968 destination_note: Option<&str>,
969 absent_sources: &[String],
970 destination_purpose: Option<&str>,
971) -> String {
972 let parts = [
973 render_situation(resolved, process_mem),
974 render_intent(resolved),
975 render_goal_and_avoid(guidance),
976 render_operative_data(
977 resolved,
978 process_mem,
979 destination_schema,
980 destination_note,
981 absent_sources,
982 ),
983 render_anchor_instruction(resolved),
984 render_one_shot_lens(resolved, destination_schema, destination_purpose),
985 ];
986 parts
987 .into_iter()
988 .filter(|p| !p.is_empty())
989 .collect::<Vec<_>>()
990 .join("")
991}
992
993use super::findings::{Finding, FindingClass, FindingTarget};
1003use super::prune::{PruneDisposition, PruneProposal};
1004
1005const FINDINGS_CAP: usize = SLICE_CAP;
1007
1008pub fn render_verify_brief(resolved: &ResolvedIngest, backlog: usize) -> String {
1017 let mut lines: Vec<String> = vec![
1018 "## Verify — measure fidelity, do not mutate".to_string(),
1019 String::new(),
1020 ];
1021 lines.push(format!(
1022 "You are measuring the fidelity of `{}` — how faithfully the destination mem \
1023 `{}` still matches its source. This pass **only measures**: read the source \
1024 and the mem's anchors, judge whether the graph still holds, and record what \
1025 you find. **You** write nothing into the destination mem — the run itself \
1026 records its findings store, backfills observed anchor hashes, and writes a \
1027 `#verified` baseline, which is engine bookkeeping, not your edits.",
1028 resolved.name, resolved.destination_mem
1029 ));
1030 lines.push(String::new());
1031
1032 lines.push(
1033 "Anchors may carry a `source` naming the binding entry point that produced them — \
1034 note it when recording findings, so fidelity stays measurable per source."
1035 .to_string(),
1036 );
1037 lines.push(String::new());
1038
1039 lines.push("### Adjudicate the queued findings (capped)".to_string());
1040 lines.push(String::new());
1041 if backlog == 0 {
1042 lines.push(
1043 "No findings are queued for adjudication this pass. Spot-check the resolving \
1044 anchors and the uncovered-artifact sample the fidelity report lists, and \
1045 record any drift you observe as a finding."
1046 .to_string(),
1047 );
1048 } else {
1049 lines.push(format!(
1050 "{backlog} finding(s) are queued for adjudication. Working up to the per-run \
1051 adjudication cap (an operations knob — the remainder stays queued and \
1052 re-presents on a later pass), take each queued finding and compare the \
1053 anchored source content against what the entity records. Classify it: still \
1054 accurate, or drifted. **Record the verdict — this is a measurement, not a \
1055 repair.** A drift you record becomes a finding the sync pass repairs; you do \
1056 not fix it here."
1057 ));
1058 }
1059 lines.push(String::new());
1060
1061 lines.push("### Out of scope for verify — no mutation".to_string());
1062 lines.push(String::new());
1063 lines.push(
1064 "Verify writes **no entity content**. Do not update a \
1065 `specifies` / `constraints` section, do not create or delete an entity, do not \
1066 add or remove a relationship. When measurement shows the graph is wrong, that \
1067 is a **finding** — the sync brief (`memstead projection brief --sync`) is the \
1068 one place those repairs are made. Leave every fix to it. (The run itself does \
1069 record its findings store, backfill observed anchor hashes, and write a \
1070 `#verified` baseline — engine bookkeeping, not your edits.)"
1071 .to_string(),
1072 );
1073 lines.push(String::new());
1074
1075 format!("{}\n", lines.join("\n"))
1076}
1077
1078fn finding_target_label(target: &FindingTarget) -> String {
1080 match target {
1081 FindingTarget::Anchor { entity, artifact } => format!("`{entity}` → `{artifact}`"),
1082 FindingTarget::Artifact { artifact } => format!("`{artifact}`"),
1083 }
1084}
1085
1086fn render_findings_group(
1089 lines: &mut Vec<String>,
1090 heading: &str,
1091 guidance: &str,
1092 items: &[&Finding],
1093) {
1094 if items.is_empty() {
1095 return;
1096 }
1097 lines.push(format!("### {heading}"));
1098 lines.push(String::new());
1099 lines.push(guidance.to_string());
1100 lines.push(String::new());
1101 let shown = items.len().min(FINDINGS_CAP);
1102 for f in &items[..shown] {
1103 lines.push(format!(
1104 "- {} — {}",
1105 finding_target_label(&f.target),
1106 f.detail
1107 ));
1108 }
1109 if items.len() > shown {
1110 lines.push(format!("- …and {} more", items.len() - shown));
1111 }
1112 lines.push(String::new());
1113}
1114
1115fn render_open_findings(findings: &[Finding]) -> String {
1120 if findings.is_empty() {
1121 return String::new();
1122 }
1123 let mut lines: Vec<String> = vec![
1124 "## Open findings to repair".to_string(),
1125 String::new(),
1126 "The verify pass recorded these against the current source state. Repair them \
1127 conservatively (see the rules below); a finding you judge already correct needs \
1128 no write."
1129 .to_string(),
1130 String::new(),
1131 ];
1132
1133 let group = |class: FindingClass| -> Vec<&Finding> {
1134 findings.iter().filter(|f| f.class == class).collect()
1135 };
1136
1137 render_findings_group(
1140 &mut lines,
1141 "Drifted — the anchored content changed",
1142 "The source the entity describes moved. Update the affected section to match — \
1143 only the part that changed. If the entity is still accurate, leave it. Either \
1144 way, re-declare the anchor on the entity (same artifact, grain, class and \
1145 source, no hash): the next verify backfills the freshly observed hash and the \
1146 drift clears. Updating the entity alone, or advancing the baseline, leaves \
1147 the anchor drifted.",
1148 &group(FindingClass::Drifted),
1149 );
1150 render_findings_group(
1151 &mut lines,
1152 "Wrong — an adjudicated content mismatch",
1153 "Adjudication found the entity no longer matches its source. Correct the \
1154 mismatched section; do not rewrite what still holds.",
1155 &group(FindingClass::Wrong),
1156 );
1157 render_findings_group(
1160 &mut lines,
1161 "Unresolvable anchor — the artifact is gone",
1162 "The source artifact an anchor references is no longer present. Delete the entity \
1163 **only** if the concept is removed entirely; otherwise leave it. Concept-level \
1164 removals are a prune concern with its own never-clobber / conflict-flag rules — \
1165 do not delete on a hunch here.",
1166 &group(FindingClass::UnresolvableAnchor),
1167 );
1168 render_findings_group(
1171 &mut lines,
1172 "Uncovered — a source artifact with no entity",
1173 "An in-scope source artifact has no anchor in the mem. Create an entity for it \
1174 **only** if it is a clearly-new concept with no existing entity; otherwise \
1175 extend the entity that already owns the concept, or leave it for a discovery \
1176 build.",
1177 &group(FindingClass::Uncovered),
1178 );
1179 render_findings_group(
1181 &mut lines,
1182 "Queued for adjudication — not yet judged",
1183 "These are not adjudicated yet — that is the verify pass's job, not sync's. \
1184 **Skip them here**; they become repairable only after verify classifies them as \
1185 drifted.",
1186 &group(FindingClass::QueuedForAdjudication),
1187 );
1188
1189 format!("{}\n", lines.join("\n"))
1190}
1191
1192fn render_prune_proposals(proposals: &[PruneProposal]) -> String {
1203 if proposals.is_empty() {
1204 return String::new();
1205 }
1206 let mut lines: Vec<String> = vec![
1207 "## Prune — proposed removals (you decide; nothing is auto-deleted)".to_string(),
1208 String::new(),
1209 "The source removed the artifacts these entities describe. Each item below is a \
1210 **proposal**: prune writes nothing — you enact (or reject) the removal through the \
1211 normal MCP mutation surface. An `authored` entity is never proposed here; a `derived` \
1212 entity is flagged, never proposed for deletion."
1213 .to_string(),
1214 String::new(),
1215 ];
1216
1217 let group = |d: PruneDisposition| -> Vec<&PruneProposal> {
1218 proposals.iter().filter(|p| p.disposition == d).collect()
1219 };
1220
1221 let clean = group(PruneDisposition::CleanDelete);
1224 if !clean.is_empty() {
1225 lines.push("### Clean delete — never-clobber three-way merge is clean".to_string());
1226 lines.push(String::new());
1227 lines.push(
1228 "The source base leg was retrievable and the three-way merge found no model-side \
1229 divergence, so removal is safe. **Confirm, then delete via the mutation surface** — \
1230 this is still your call, not an auto-delete."
1231 .to_string(),
1232 );
1233 lines.push(String::new());
1234 let shown = clean.len().min(FINDINGS_CAP);
1235 for p in &clean[..shown] {
1236 lines.push(format!(
1237 "- `{}` — source artifact(s) gone: {}",
1238 p.entity,
1239 artifact_list(&p.artifacts)
1240 ));
1241 }
1242 if clean.len() > shown {
1243 lines.push(format!("- …and {} more", clean.len() - shown));
1244 }
1245 lines.push(String::new());
1246 }
1247
1248 let conflict = group(PruneDisposition::ConflictFlag);
1250 if !conflict.is_empty() {
1251 lines.push("### Conflict-flag — decide, never overwrite a model-side edit".to_string());
1252 lines.push(String::new());
1253 lines.push(
1254 "No retrievable base leg to merge against (a non-git source, or an anchor with no \
1255 pinned version). **Both sides are shown — decide deliberately.** If the concept is \
1256 truly gone, delete via the mutation surface; if the model side was edited on \
1257 purpose, keep it. Prune never overwrites a model-side edit for you."
1258 .to_string(),
1259 );
1260 lines.push(String::new());
1261 let shown = conflict.len().min(FINDINGS_CAP);
1262 for p in &conflict[..shown] {
1263 lines.push(format!(
1264 "- `{}` — **source side:** artifact(s) gone: {}; **model side:** the entity is \
1265 still present (may carry edits) — you decide.",
1266 p.entity,
1267 artifact_list(&p.artifacts)
1268 ));
1269 }
1270 if conflict.len() > shown {
1271 lines.push(format!("- …and {} more", conflict.len() - shown));
1272 }
1273 lines.push(String::new());
1274 }
1275
1276 let derived = group(PruneDisposition::DerivedFlagged);
1278 if !derived.is_empty() {
1279 lines.push("### Derived — flagged, NOT proposed for deletion".to_string());
1280 lines.push(String::new());
1281 lines.push(
1282 "These entities were **derived** from other inputs. A derived entity is flagged, \
1283 never auto-proposed for deletion — its inputs may still hold even though one source \
1284 artifact vanished. Re-examine the inputs before removing anything."
1285 .to_string(),
1286 );
1287 lines.push(String::new());
1288 let shown = derived.len().min(FINDINGS_CAP);
1289 for p in &derived[..shown] {
1290 let inputs = if p.derived_inputs.is_empty() {
1291 "(no recorded inputs)".to_string()
1292 } else {
1293 artifact_list(&p.derived_inputs)
1294 };
1295 lines.push(format!(
1296 "- `{}` — derived from: {}; source artifact(s) gone: {}.",
1297 p.entity,
1298 inputs,
1299 artifact_list(&p.artifacts)
1300 ));
1301 }
1302 if derived.len() > shown {
1303 lines.push(format!("- …and {} more", derived.len() - shown));
1304 }
1305 lines.push(String::new());
1306 }
1307
1308 format!("{}\n", lines.join("\n"))
1309}
1310
1311fn artifact_list(artifacts: &[String]) -> String {
1313 if artifacts.is_empty() {
1314 return "(none)".to_string();
1315 }
1316 artifacts
1317 .iter()
1318 .map(|a| format!("`{a}`"))
1319 .collect::<Vec<_>>()
1320 .join(", ")
1321}
1322
1323fn render_sync_situation(resolved: &ResolvedIngest) -> String {
1326 format!(
1327 "## Sync — repair the graph to match the source\n\n\
1328 You are running the sync pass for `{}`. Sync is the graph's **sole maintenance \
1329 writer**: the only place the destination mem `{}` is repaired to match its \
1330 source. Two inputs steer this pass — the source changes since the last sync, and \
1331 the open verify findings — both below. Work them: update, create, relate, and \
1332 (rarely) delete entities so the graph again matches the source.\n\n\
1333 Every mutation routes through the normal MCP mutation surface, and the engine \
1334 commits each one **per-mutation** to the mem's own gitdir. You **stage nothing \
1335 and commit nothing yourself** — not the graph, not the code. Sync commits \
1336 nothing.\n\n",
1337 resolved.name, resolved.destination_mem
1338 )
1339}
1340
1341fn render_adopt_framing(resolved: &ResolvedIngest) -> String {
1345 format!(
1346 "## First sync — adopting `{}`\n\n\
1347 This mem predates its binding: it has no anchors and no prior sync baseline, so \
1348 **0% anchored is expected — this is onboarding, not a failure.** Do not read it \
1349 as drift or a red verdict. There is no cursor to diff against, so the baseline is \
1350 the **current** source HEAD — do **not** replay the whole history; treat the \
1351 current source state as the starting point, and this is a **first sync**.\n\n\
1352 **Backfill path:** run `memstead projection verify {}` to enumerate the in-scope \
1353 source artifacts that carry no entity yet, then cover the clearly-new concepts \
1354 among them through the normal MCP mutation surface — the same conservative rules \
1355 below apply. Backfilling is incremental: a partial pass is fine, and the next \
1356 sync continues where you left off.\n\n",
1357 resolved.destination_mem, resolved.name
1358 )
1359}
1360
1361fn render_stale_claim_search(resolved: &ResolvedIngest) -> String {
1373 format!(
1374 "## Stale claims beyond the slice — search, then judge\n\n\
1375 A changed fact can be claimed by an entity whose anchors are all outside the \
1376 changed slice — anchor-steered repairs alone would leave that claim standing \
1377 falsified. Extract the **changed facts** from the changed artifacts above: \
1378 renamed identifiers, changed values or defaults, changed behaviors (e.g. an \
1379 exit code, a flag's meaning), removed or moved concepts. For each changed \
1380 fact, search the destination mem `{}` for claims about it (`memstead_search` \
1381 and its variants — try the new name, the old name/value, and close synonyms), \
1382 and judge **only** the entities whose claims actually mention a changed fact: \
1383 repair a claim the change falsifies, leave everything else untouched.\n\n\
1384 This is a bounded fact-search, not a live-verify of every entity and not a \
1385 rewrite license. If the changes carry no factual claims (formatting, \
1386 comments, cosmetic moves), the fact set is empty and this step ends with no \
1387 search and no edits.\n\n",
1388 resolved.destination_mem
1389 )
1390}
1391
1392fn render_sync_conservatism() -> String {
1396 let lines: Vec<&str> = vec![
1397 "## How to repair — be conservative",
1398 "",
1399 "Repair only what the source changes and the findings above actually justify:",
1400 "",
1401 "- **Unsure whether an entity is affected — skip it.** A missed update is a later \
1403 finding; a wrong rewrite is damage.",
1404 "- **Do not create a new entity unless the change clearly introduces a new concept \
1405 with no existing entity.** Prefer updating the entity that already owns the \
1406 concept.",
1407 "- **Do not delete an entity unless the change removes the concept entirely.** \
1408 Deletions a prune pass surfaces follow prune's own never-clobber / conflict-flag \
1409 rules — never delete on a hunch here.",
1410 "- **Never rewrite a section that has not changed** — touch only the part the \
1411 change or finding actually affects.",
1412 "- **No speculative edges — add only relationships the diff literally introduces** \
1413 (a new `use` / `import` / dependency you can point at in the change).",
1414 "- **A dropped dependency FLAGS, it does not auto-remove.** If the change removes an \
1416 import or dependency, leave the matching edge intact and note it for a later \
1417 audit — removals are ambiguous (temporary refactor vs. permanent cut), and a \
1418 stale edge is less damaging than an erased real one. **Edge removal is out of \
1419 scope for sync.**",
1420 "- **Rationale is reasoning, not a changelog.** When you record why a change was \
1422 made, append the *reasoning* (why this approach, which trade-offs) — never \
1423 `[commit <hash>]` log-style entries.",
1424 "",
1425 ];
1426
1427 format!("{}\n", lines.join("\n"))
1428}
1429
1430pub fn render_sync_brief(
1457 resolved: &ResolvedIngest,
1458 cursor: &SourceCursor,
1459 findings: &[Finding],
1460 prune: &[PruneProposal],
1461 adopt: bool,
1462) -> String {
1463 let preface = render_changed_slice(cursor);
1464 let open_findings = render_open_findings(findings);
1465 let prune_block = render_prune_proposals(prune);
1466 let has_work =
1467 adopt || !preface.is_empty() || !open_findings.is_empty() || !prune_block.is_empty();
1468
1469 let mut parts: Vec<String> = vec![render_sync_situation(resolved)];
1470
1471 if !has_work {
1472 parts.push(
1473 "## Nothing to sync\n\nThe source has not moved since the last sync, no \
1474 verify findings are open, and no prune proposals stand. There is nothing to \
1475 repair this pass — reporting \"no changes\" is a valid outcome.\n\n"
1476 .to_string(),
1477 );
1478 return parts
1479 .into_iter()
1480 .filter(|p| !p.is_empty())
1481 .collect::<Vec<_>>()
1482 .join("");
1483 }
1484
1485 if adopt {
1486 parts.push(render_adopt_framing(resolved));
1487 }
1488 parts.push(preface);
1489 if cursor.any_changes {
1493 parts.push(render_stale_claim_search(resolved));
1494 }
1495 parts.push(open_findings);
1496 parts.push(prune_block);
1497 parts.push(render_anchor_instruction(resolved));
1498 parts.push(render_sync_conservatism());
1499
1500 parts
1501 .into_iter()
1502 .filter(|p| !p.is_empty())
1503 .collect::<Vec<_>>()
1504 .join("")
1505}
1506
1507#[cfg(test)]
1508mod tests {
1509 use super::*;
1510 use crate::ingest::resolve::Source;
1511 use crate::pipeline::{IngestTrigger, PatternEntry};
1512
1513 fn guidance(goal: Option<&str>, avoid: Option<&str>) -> ResolvedGuidance {
1514 ResolvedGuidance {
1515 goal: goal.map(str::to_string),
1516 avoid: avoid.map(str::to_string),
1517 }
1518 }
1519
1520 #[test]
1523 fn renders_goal_and_avoid_blocks() {
1524 let out = render_goal_and_avoid(&guidance(Some(" build coverage "), Some("no stubs")));
1525 assert_eq!(
1526 out,
1527 "## Goal\n\nbuild coverage\n\n## Failure modes to avoid\n\nno stubs\n\n"
1528 );
1529 }
1530
1531 #[test]
1533 fn renders_goal_only() {
1534 assert_eq!(
1535 render_goal_and_avoid(&guidance(Some("build coverage"), None)),
1536 "## Goal\n\nbuild coverage\n\n"
1537 );
1538 }
1539
1540 #[test]
1542 fn renders_avoid_only() {
1543 assert_eq!(
1544 render_goal_and_avoid(&guidance(None, Some("no stubs"))),
1545 "## Failure modes to avoid\n\nno stubs\n\n"
1546 );
1547 }
1548
1549 #[test]
1552 fn empty_guidance_yields_a_newline() {
1553 assert_eq!(render_goal_and_avoid(&guidance(None, None)), "\n");
1554 assert_eq!(render_goal_and_avoid(&guidance(Some(" "), None)), "\n");
1556 }
1557
1558 fn primary(medium_type: MediumType, scope: Vec<PatternEntry>) -> ResolvedSource {
1559 ResolvedSource::Primary(Source {
1560 name: "f".to_string(),
1561 medium_type,
1562 pointer: "../src".to_string(),
1563 change_detection: None,
1564 scope,
1565 engagement: None,
1566 preparation: None,
1567 })
1568 }
1569
1570 fn resolved(name: &str, intent: Option<&str>, sources: Vec<ResolvedSource>) -> ResolvedIngest {
1571 ResolvedIngest {
1572 name: name.to_string(),
1573 mode: BuildMode::Discovery,
1574 trigger: IngestTrigger::Loop,
1575 batch_size: 20,
1576 deny_paths: vec![],
1577 projection_ref: format!("{name}/p"),
1578 projection_mem: name.to_string(),
1579 projection_name: "p".to_string(),
1580 intent: intent.map(str::to_string),
1581 sources,
1582 destination_mem: name.to_string(),
1583 rules: None,
1584 post_actions: None,
1585 }
1586 }
1587
1588 fn process_present(name: &str) -> ProcessMemInfo {
1589 ProcessMemInfo {
1590 present: true,
1591 skipped: false,
1592 notice: None,
1593 leaf_name: name.to_string(),
1594 mem_label: format!("ingest/{name}"),
1595 }
1596 }
1597
1598 fn allow(path: &str) -> PatternEntry {
1599 PatternEntry {
1600 path: path.to_string(),
1601 mode: PatternMode::Allow,
1602 }
1603 }
1604
1605 fn deny(path: &str) -> PatternEntry {
1606 PatternEntry {
1607 path: path.to_string(),
1608 mode: PatternMode::Deny,
1609 }
1610 }
1611
1612 #[test]
1614 fn renders_intent() {
1615 let r = resolved("macos", Some(" Swift app source. "), vec![]);
1616 assert_eq!(
1617 render_intent(&r),
1618 "## About the source\n\nSwift app source.\n\n"
1619 );
1620 let none = resolved("macos", None, vec![]);
1621 assert_eq!(render_intent(&none), "");
1622 }
1623
1624 #[test]
1627 fn renders_situation_with_present_process_mem() {
1628 let r = resolved("macos", None, vec![]);
1629 let out = render_situation(&r, &process_present("macos"));
1630 assert!(out.starts_with("## Situation\n\nYou are running one iteration of `macos` (discovery mode) inside a loop."));
1631 assert!(out.contains("Mutating the destination is this run's mandate:"));
1632 assert!(out.contains("The `PreCompact` hook fires near the limit"));
1633 assert!(out.contains("A paired process mem `ingest/macos` (schema `ingest@0.5.0`) carries destination-quality debt"));
1634 assert!(
1635 out.ends_with("write rules.\n\n"),
1636 "block ends in a blank line"
1637 );
1638 }
1639
1640 #[test]
1643 fn situation_process_mem_branches() {
1644 let mut r = resolved("os", None, vec![]);
1645 r.mode = BuildMode::OneShot;
1646 let skipped = ProcessMemInfo {
1647 present: false,
1648 skipped: true,
1649 notice: None,
1650 leaf_name: "os".to_string(),
1651 mem_label: "ingest/os".to_string(),
1652 };
1653 assert!(
1654 render_situation(&r, &skipped)
1655 .contains("No process mem is paired with this ingest (mode=one-shot;")
1656 );
1657
1658 let failed = ProcessMemInfo {
1659 present: false,
1660 skipped: false,
1661 notice: Some("engine offline".to_string()),
1662 leaf_name: "os".to_string(),
1663 mem_label: "ingest/os".to_string(),
1664 };
1665 let out = render_situation(&resolved("os", None, vec![]), &failed);
1666 assert!(out.contains("could not be auto-created — engine offline."));
1667 assert!(out.contains("memstead mem init os --org-path ingest --schema ingest@0.5.0"));
1668 }
1669
1670 #[test]
1674 fn renders_operative_data_full() {
1675 let r = resolved(
1676 "macos",
1677 None,
1678 vec![
1679 primary(
1680 MediumType::Codebase,
1681 vec![allow("src/**/*.swift"), deny("src/gen/**")],
1682 ),
1683 ResolvedSource::Reference {
1684 mem: "engine".to_string(),
1685 },
1686 ],
1687 );
1688 let out = render_operative_data(
1689 &r,
1690 &process_present("macos"),
1691 Some("macos-code@0.1.0"),
1692 None,
1693 &[],
1694 );
1695 let expected = "\
1696## Operative data
1697
1698### Sources
1699
1700- **f** (codebase, primary) — `../src`
1701 - Paths: src/**/*.swift
1702 - Ignore: src/gen/**
1703- **graph** (reference) — mem: engine
1704
1705Sources tagged `(reference)` are read-only context for cross-mem edges — search them, never write into them. Only `(primary)` sources are ingested into the destination.
1706
1707**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`).
1708
1709### Destination
1710
1711- **macos** — schema: `macos-code@0.1.0`
1712
1713### Paired process mem
1714
1715- **ingest/macos** — schema: `ingest@0.5.0`. Inspect via `memstead_overview` / `memstead_search mem=macos`.
1716\n";
1717 assert_eq!(out, expected);
1718 }
1719
1720 #[test]
1723 fn renders_operative_data_minimal() {
1724 let r = resolved("g", None, vec![primary(MediumType::Filesystem, vec![])]);
1725 let skipped = ProcessMemInfo {
1726 present: false,
1727 skipped: true,
1728 notice: None,
1729 leaf_name: "g".to_string(),
1730 mem_label: "ingest/g".to_string(),
1731 };
1732 let out = render_operative_data(&r, &skipped, None, Some("**absent** — probe"), &[]);
1733 assert!(out.contains("- **f** (filesystem, primary) — `"));
1736 assert!(!out.contains("Cross-mem references"), "no reference note");
1737 assert!(out.contains("### Destination\n\n- **g**\n"));
1738 assert!(
1741 out.contains("**absent** — probe"),
1742 "the caller's destination note must be rendered: {out}",
1743 );
1744 assert!(
1745 !out.contains("Paired process mem"),
1746 "skipped process mem omitted"
1747 );
1748 }
1749
1750 #[test]
1753 fn assembles_discovery_brief() {
1754 let r = resolved(
1755 "macos",
1756 Some("Swift source."),
1757 vec![primary(MediumType::Codebase, vec![allow("src/**")])],
1758 );
1759 let g = guidance(Some("build coverage"), None);
1760 let pm = process_present("macos");
1761 let brief = assemble_discovery_brief(&r, &g, &pm, Some("s@1"), None, &[], "");
1762
1763 let sit = brief.find("## Situation").unwrap();
1765 let src = brief.find("## About the source").unwrap();
1766 let goal = brief.find("## Goal").unwrap();
1767 let op = brief.find("## Operative data").unwrap();
1768 let anchors = brief.find("## Provenance — anchor your writes").unwrap();
1769 assert!(
1770 sit < src && src < goal && goal < op && op < anchors,
1771 "blocks in brief order"
1772 );
1773 assert!(
1774 !brief.contains("## Source changes"),
1775 "no changed-slice block when preface empty"
1776 );
1777
1778 let with_slice = assemble_discovery_brief(
1780 &r,
1781 &g,
1782 &pm,
1783 Some("s@1"),
1784 None,
1785 &[],
1786 "## Source changes\n\n…\n\n",
1787 );
1788 assert!(with_slice.ends_with("## Source changes\n\n…\n\n"));
1789 }
1790
1791 fn slice(deleted: &[&str], modified: &[&str], added: &[&str]) -> Slice {
1792 Slice {
1793 deleted: deleted.iter().map(|s| s.to_string()).collect(),
1794 modified: modified.iter().map(|s| s.to_string()).collect(),
1795 added: added.iter().map(|s| s.to_string()).collect(),
1796 }
1797 }
1798
1799 fn cmd(key: &str, token: &str) -> SyncCommand {
1800 SyncCommand {
1801 key: key.to_string(),
1802 token: token.to_string(),
1803 }
1804 }
1805
1806 fn note(source: &str, reason: NoSignalReason) -> NoSignalNote {
1807 NoSignalNote {
1808 medium_type: None,
1809 source: source.to_string(),
1810 reason,
1811 }
1812 }
1813
1814 #[test]
1818 fn anchor_instruction_names_prepared_form_sources() {
1819 let mut resolved = resolved("home", None, vec![primary(MediumType::Codebase, vec![])]);
1820 let plain = render_anchor_instruction(&resolved);
1821 assert!(!plain.contains("hash a prepared form"));
1822 if let Some(ResolvedSource::Primary(src)) = resolved.sources.first_mut() {
1823 src.preparation = Some(crate::preparation::CODE_MAP.to_string());
1824 }
1825 let prepared = render_anchor_instruction(&resolved);
1826 assert!(
1827 prepared.contains("hash a prepared form (`code-map`)"),
1828 "{prepared}"
1829 );
1830 assert!(prepared.contains("interface digest"));
1831 assert!(prepared.contains("for a `file` or `span` anchor pass the artifact's `content`"));
1832 assert!(prepared.contains("a `tree` anchor takes no content"));
1833 }
1834
1835 #[test]
1840 fn changed_slice_renders_delivery_sequences_in_order() {
1841 use crate::preparation::UnitChange;
1842 let unit = |id: &str, order: &str, change: UnitChange, disposed: bool| DeliveredUnit {
1843 id: id.to_string(),
1844 order_key: order.to_string(),
1845 change,
1846 disposed,
1847 };
1848 let units = vec![
1849 unit(
1850 "log/b.md#2026-08-20T00:00:00",
1851 "2026-08-20T00:00:00",
1852 UnitChange::Added,
1853 true,
1854 ),
1855 unit(
1856 "log/a.md#2026-08-21T00:00:00",
1857 "2026-08-21T00:00:00",
1858 UnitChange::Deleted,
1859 false,
1860 ),
1861 unit(
1862 "log/b.md#2026-08-22T00:00:00",
1863 "2026-08-22T00:00:00",
1864 UnitChange::Modified,
1865 false,
1866 ),
1867 unit(
1868 "log/a.md#2026-08-23T00:00:00",
1869 "2026-08-23T00:00:00",
1870 UnitChange::Added,
1871 false,
1872 ),
1873 ];
1874 let cursor = SourceCursor {
1875 union: slice(
1877 &["log/a.md#2026-08-21T00:00:00"],
1878 &["log/b.md#2026-08-22T00:00:00"],
1879 &[
1880 "log/a.md#2026-08-23T00:00:00",
1881 "log/b.md#2026-08-20T00:00:00",
1882 "other/x.rs",
1883 ],
1884 ),
1885 write_commands: vec![],
1886 reseed: vec![],
1887 no_signal: vec![],
1888 any_changes: true,
1889 degraded: false,
1890 dead_denies: vec![],
1891 dest_mem: "home".to_string(),
1892 binding_id: "home/log".to_string(),
1893 delivery: vec![DeliverySequence {
1894 source: "log".to_string(),
1895 preparation: "dated-entries".to_string(),
1896 first_run: false,
1897 degraded: true,
1898 batch: 2,
1899 units,
1900 }],
1901 };
1902 let out = render_changed_slice(&cursor);
1903 assert!(
1904 out.contains("### Delivery sequence: `log` (`dated-entries`)"),
1905 "{out}"
1906 );
1907 assert!(out.contains("The units that changed since the last pass"));
1908 assert!(out.contains("No baseline content was retrievable"));
1909 let listed: Vec<&str> = out
1910 .lines()
1911 .filter(|l| l.starts_with(|c: char| c.is_ascii_digit()))
1912 .collect();
1913 assert_eq!(
1914 listed,
1915 vec![
1916 "2. `log/a.md#2026-08-21T00:00:00` (deleted)",
1917 "3. `log/b.md#2026-08-22T00:00:00` (changed)",
1918 ],
1919 "positions are total-order positions; the disposed first unit is skipped"
1920 );
1921 assert!(out.contains("…and 1 more, presented in order once these are disposed"));
1922 assert!(out.contains("1 unit of this sequence already disposed"));
1923 assert!(out.contains("**Added:**\n- `other/x.rs`\n"), "{out}");
1925 assert!(!out.contains("**Modified:**"));
1926 assert!(!out.contains("**Deleted:**"));
1927 }
1928
1929 #[test]
1931 fn changed_slice_empty_when_nothing_moved() {
1932 let cursor = SourceCursor {
1933 union: slice(&[], &[], &[]),
1934 write_commands: vec![],
1935 reseed: vec![],
1936 no_signal: vec![],
1937 any_changes: false,
1938 degraded: false,
1939 dead_denies: vec![],
1940 dest_mem: "engine".to_string(),
1941 binding_id: "engine/graph".to_string(),
1942 delivery: vec![],
1943 };
1944 assert_eq!(render_changed_slice(&cursor), "");
1945 }
1946
1947 #[test]
1951 fn changed_slice_renders_dead_deny_warning() {
1952 let cursor = SourceCursor {
1953 union: slice(&[], &[], &[]),
1954 write_commands: vec![],
1955 reseed: vec![],
1956 no_signal: vec![],
1957 any_changes: false,
1958 degraded: false,
1959 dead_denies: vec!["dev".to_string(), "typo/**".to_string()],
1960 dest_mem: "engine".to_string(),
1961 binding_id: "engine/graph".to_string(),
1962 delivery: vec![],
1963 };
1964 let out = render_changed_slice(&cursor);
1965 assert!(out.contains("deny_paths` entries match nothing"));
1966 assert!(out.contains("- `dev`"));
1967 assert!(out.contains("- `typo/**`"));
1968 }
1969
1970 #[test]
1974 fn changed_slice_renders_slice_and_recording() {
1975 let cursor = SourceCursor {
1976 union: slice(&["a.rs"], &["b.rs"], &[]),
1977 write_commands: vec![cmd("engine-graph/source", "HEADSHA")],
1978 reseed: vec![],
1979 no_signal: vec![],
1980 any_changes: true,
1981 degraded: false,
1982 dead_denies: vec![],
1983 dest_mem: "engine".to_string(),
1984 binding_id: "engine/graph".to_string(),
1985 delivery: vec![],
1986 };
1987 let expected_lines = [
1988 "## Source changes since the last sync\n",
1989 "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",
1990 "**Deleted:**",
1991 "- `a.rs`",
1992 "",
1993 "**Modified:**",
1994 "- `b.rs`",
1995 "",
1996 "### Recording your dispositions (do this LAST)\n",
1997 "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",
1998 "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",
1999 "```sh",
2000 r#"memstead projection advance engine/graph --dispositions '{"<artifact>": "<disposition>", ...}'"#,
2001 "```",
2002 "If you were interrupted before finishing, that is fine — your recorded dispositions persist, and the next run re-presents only what is left.\n",
2003 ];
2004 assert_eq!(
2005 render_changed_slice(&cursor),
2006 format!("{}\n", expected_lines.join("\n"))
2007 );
2008 }
2009
2010 #[test]
2013 fn changed_slice_reseed_only() {
2014 let cursor = SourceCursor {
2015 union: slice(&[], &[], &[]),
2016 write_commands: vec![],
2017 reseed: vec![cmd("ing/f", "TOK")],
2018 no_signal: vec![],
2019 any_changes: false,
2020 degraded: false,
2021 dead_denies: vec![],
2022 dest_mem: "d".to_string(),
2023 binding_id: "d/p".to_string(),
2024 delivery: vec![],
2025 };
2026 let out = render_changed_slice(&cursor);
2027 assert!(out.starts_with("## Source changes since the last sync\n\n"));
2028 assert!(out.contains(
2029 "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."
2030 ));
2031 assert!(out.contains(
2032 r#"memstead projection advance d/p --dispositions '{"<artifact>": "<disposition>", ...}'"#
2033 ));
2034 assert!(
2035 !out.contains("The source moved"),
2036 "no 'moved' copy when only reseeding"
2037 );
2038 }
2039
2040 #[test]
2046 fn changed_slice_renders_no_signal_reasons_distinguishably() {
2047 let cursor = SourceCursor {
2048 union: slice(&[], &[], &[]),
2049 write_commands: vec![],
2050 reseed: vec![],
2051 no_signal: vec![
2052 note("code-facet", NoSignalReason::Unscoped),
2053 note("plan-facet", NoSignalReason::DetectionNone),
2054 note("git-facet", NoSignalReason::GitUnavailable),
2055 note("ref-mem", NoSignalReason::GraphSnapshotMissing),
2056 ],
2057 any_changes: false,
2058 degraded: false,
2059 dead_denies: vec![],
2060 dest_mem: "d".to_string(),
2061 binding_id: "d/p".to_string(),
2062 delivery: vec![],
2063 };
2064 let out = render_changed_slice(&cursor);
2065 assert!(out.starts_with("## Source changes since the last sync\n"));
2066 assert!(out.contains("Some sources produced **no change signal**"));
2067 assert!(out.contains("- `code-facet`: unscoped facet (no allow patterns)"));
2069 assert!(
2070 out.contains("- `plan-facet`: `signal:none`"),
2071 "detection-none renders the literal signal:none state"
2072 );
2073 assert!(out.contains("- `git-facet`: git signal unavailable"));
2074 assert!(out.contains("- `ref-mem`: graph snapshot missing"));
2075 let texts = [
2077 no_signal_reason_text(NoSignalReason::Unscoped, None),
2078 no_signal_reason_text(NoSignalReason::DetectionNone, None),
2079 no_signal_reason_text(NoSignalReason::GitUnavailable, None),
2080 no_signal_reason_text(NoSignalReason::GraphSnapshotMissing, None),
2081 ];
2082 for (i, a) in texts.iter().enumerate() {
2083 for b in &texts[i + 1..] {
2084 assert_ne!(a, b, "each no-signal reason must render distinctly");
2085 }
2086 }
2087 assert!(!out.contains("### Recording your dispositions"));
2089 assert!(!out.contains("The source moved"));
2090 }
2091
2092 #[test]
2096 fn changed_slice_mixes_changes_and_no_signal() {
2097 let cursor = SourceCursor {
2098 union: slice(&[], &["b.rs"], &[]),
2099 write_commands: vec![cmd("ing/f", "HEAD")],
2100 reseed: vec![],
2101 no_signal: vec![note("other", NoSignalReason::Unscoped)],
2102 any_changes: true,
2103 degraded: false,
2104 dead_denies: vec![],
2105 dest_mem: "d".to_string(),
2106 binding_id: "d/p".to_string(),
2107 delivery: vec![],
2108 };
2109 let out = render_changed_slice(&cursor);
2110 assert!(out.contains("The source moved"));
2111 assert!(out.contains("**Modified:**"));
2112 assert!(out.contains("- `other`: unscoped facet"));
2113 assert!(out.contains("### Recording your dispositions"));
2114 assert!(out.contains(
2115 r#"memstead projection advance d/p --dispositions '{"<artifact>": "<disposition>", ...}'"#
2116 ));
2117 }
2118
2119 #[test]
2122 fn renders_one_shot_lens_block() {
2123 let mut r = resolved("os", Some("plan source"), vec![]);
2124 r.rules = Some(serde_json::json!({ "routing": "route each entity to its spec" }));
2125 r.post_actions = Some(serde_json::json!({ "archive_source": true }));
2126
2127 let out = render_one_shot_lens(&r, Some("planning@0.1.0"), Some("the plan graph"));
2128 assert!(out.starts_with("## Mode: one-shot — lens routing\n\n"));
2129 assert!(out.contains(
2130 "### Destination set\n\n| Mem | Schema | Purpose |\n|-------|--------|---------|\n| os | planning@0.1.0 | the plan graph |\n"
2131 ));
2132 assert!(out.contains("### Routing rule\n\n```\nroute each entity to its spec\n```\n"));
2133 assert!(out.contains("### Idempotency"));
2134 assert!(out.contains("### Report: os"));
2135 assert!(out.contains("### Archive after run"));
2136 assert!(out.ends_with("is set on this ingest.\n\n"));
2137
2138 let bare = resolved("os", None, vec![]);
2141 let out2 = render_one_shot_lens(&bare, None, None);
2142 assert!(out2.contains("| os | (none) | (no purpose declared) |"));
2143 assert!(!out2.contains("### Routing rule"));
2144 assert!(!out2.contains("### Archive after run"));
2145 assert!(out2.contains("### End-of-run report"));
2146 }
2147
2148 #[test]
2151 fn assembles_one_shot_brief() {
2152 let mut r = resolved(
2153 "os",
2154 Some("src"),
2155 vec![primary(MediumType::Filesystem, vec![])],
2156 );
2157 r.mode = BuildMode::OneShot;
2158 let g = guidance(Some("goal"), None);
2159 let skipped = ProcessMemInfo {
2160 present: false,
2161 skipped: true,
2162 notice: None,
2163 leaf_name: "os".to_string(),
2164 mem_label: "ingest/os".to_string(),
2165 };
2166 let brief =
2167 assemble_one_shot_brief(&r, &g, &skipped, Some("s@1"), None, &[], Some("purpose"));
2168 assert!(brief.contains("(one-shot mode)"));
2169 assert!(brief.contains("No process mem is paired with this ingest (mode=one-shot;"));
2170 assert!(brief.contains("## Mode: one-shot — lens routing"));
2171 assert!(
2172 brief.contains("## Provenance — anchor your writes"),
2173 "one-shot carries the anchor instruction"
2174 );
2175 assert!(
2176 !brief.contains("## Source changes"),
2177 "one-shot has no changed-slice"
2178 );
2179 }
2180
2181 #[test]
2185 fn changed_slice_caps_and_degrades_and_quotes() {
2186 let many: Vec<String> = (0..SLICE_CAP + 3).map(|i| format!("f{i}.rs")).collect();
2187 let cursor = SourceCursor {
2188 union: Slice {
2189 deleted: vec![],
2190 modified: vec![],
2191 added: many,
2192 },
2193 write_commands: vec![cmd("ing/f", r#"{"v":1,"aggregate":"x"}"#)],
2194 reseed: vec![],
2195 no_signal: vec![],
2196 any_changes: true,
2197 degraded: true,
2198 dead_denies: vec![],
2199 dest_mem: "d".to_string(),
2200 binding_id: "d/p".to_string(),
2201 delivery: vec![],
2202 };
2203 let out = render_changed_slice(&cursor);
2204 assert!(out.contains(&format!("- …and {} more added", 3)));
2205 assert!(out.contains("Precise change history for one or more facets was unavailable"));
2206 assert!(out.contains(
2209 r#"memstead projection advance d/p --dispositions '{"<artifact>": "<disposition>", ...}'"#
2210 ));
2211 }
2212
2213 fn finding(class: FindingClass, target: FindingTarget, detail: &str) -> Finding {
2216 Finding {
2217 key: crate::ingest::findings::FindingKey {
2218 binding_hash: "h".to_string(),
2219 source_head: "s".to_string(),
2220 },
2221 facet: "src".to_string(),
2222 target,
2223 class,
2224 detail: detail.to_string(),
2225 created_at: "1".to_string(),
2226 }
2227 }
2228
2229 fn anchor_target(entity: &str, artifact: &str) -> FindingTarget {
2230 FindingTarget::Anchor {
2231 entity: entity.to_string(),
2232 artifact: artifact.to_string(),
2233 }
2234 }
2235
2236 fn artifact_target(artifact: &str) -> FindingTarget {
2237 FindingTarget::Artifact {
2238 artifact: artifact.to_string(),
2239 }
2240 }
2241
2242 fn empty_cursor() -> SourceCursor {
2243 SourceCursor {
2244 union: slice(&[], &[], &[]),
2245 write_commands: vec![],
2246 reseed: vec![],
2247 no_signal: vec![],
2248 any_changes: false,
2249 degraded: false,
2250 dead_denies: vec![],
2251 dest_mem: "engine".to_string(),
2252 binding_id: "engine/graph".to_string(),
2253 delivery: vec![],
2254 }
2255 }
2256
2257 #[test]
2261 fn verify_brief_measures_and_refuses_mutation() {
2262 let r = resolved("engine", None, vec![]);
2263 let out = render_verify_brief(&r, 3);
2264 assert!(out.starts_with("## Verify — measure fidelity, do not mutate"));
2266 assert!(out.contains("3 finding(s) are queued for adjudication"));
2267 assert!(out.contains("per-run adjudication cap"));
2268 assert!(out.contains("this is a measurement, not a repair"));
2269 assert!(out.contains("Verify writes **no entity content**"));
2282 assert!(out.contains("`#verified` baseline"));
2283 assert!(out.contains("memstead projection brief --sync"));
2284 assert!(out.contains("do not create or delete an entity"));
2287 assert!(!out.contains("via `memstead_create`"));
2288 assert!(!out.contains("Run `memstead_update`"));
2289
2290 let zero = render_verify_brief(&r, 0);
2292 assert!(zero.contains("No findings are queued for adjudication"));
2293 assert!(zero.contains("record any drift you observe as a finding"));
2294 assert!(zero.contains("Verify writes **no entity content**"));
2295 }
2296
2297 #[test]
2301 fn sync_brief_carries_both_cursor_and_findings() {
2302 let r = resolved("engine", None, vec![]);
2303 let cursor = SourceCursor {
2304 union: slice(&["gone.rs"], &["moved.rs"], &[]),
2305 write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
2306 reseed: vec![],
2307 no_signal: vec![],
2308 any_changes: true,
2309 degraded: false,
2310 dead_denies: vec![],
2311 dest_mem: "engine".to_string(),
2312 binding_id: "engine/graph".to_string(),
2313 delivery: vec![],
2314 };
2315 let findings = vec![
2316 finding(
2317 FindingClass::Drifted,
2318 anchor_target("engine--e", "src/moved.rs"),
2319 "prepared-content hash drifted",
2320 ),
2321 finding(
2322 FindingClass::Uncovered,
2323 artifact_target("src/new.rs"),
2324 "in scope, no anchor",
2325 ),
2326 ];
2327 let out = render_sync_brief(&r, &cursor, &findings, &[], false);
2328 assert!(out.contains("## Source changes since the last sync"));
2330 assert!(out.contains("`moved.rs`"));
2331 assert!(out.contains("## Open findings to repair"));
2332 assert!(out.contains("`engine--e` → `src/moved.rs`"));
2333 assert!(out.contains("`src/new.rs`"));
2334 assert!(out.contains("sole maintenance writer"));
2336 assert!(out.contains("commits each one **per-mutation**"));
2337 assert!(out.contains("Sync commits nothing."));
2338 }
2339
2340 #[test]
2345 fn sync_brief_absorbs_reconcile_conservatism() {
2346 let r = resolved("engine", None, vec![]);
2347 let findings = vec![finding(
2348 FindingClass::Uncovered,
2349 artifact_target("src/x.rs"),
2350 "d",
2351 )];
2352 let out = render_sync_brief(&r, &empty_cursor(), &findings, &[], false);
2353 assert!(out.contains("Unsure whether an entity is affected — skip it."));
2355 assert!(out.contains(
2356 "Do not create a new entity unless the change clearly introduces a new concept"
2357 ));
2358 assert!(
2359 out.contains("Do not delete an entity unless the change removes the concept entirely.")
2360 );
2361 assert!(out.contains("Never rewrite a section that has not changed"));
2362 assert!(out.contains(
2363 "No speculative edges — add only relationships the diff literally introduces"
2364 ));
2365 assert!(out.contains("A dropped dependency FLAGS, it does not auto-remove."));
2367 assert!(out.contains("Edge removal is out of scope for sync."));
2368 assert!(out.contains("Rationale is reasoning, not a changelog."));
2370 assert!(out.contains("`[commit <hash>]` log-style entries"));
2371 }
2372
2373 #[test]
2377 fn sync_brief_renders_adopt_framing() {
2378 let mut r = resolved("engine", None, vec![]);
2379 r.name = "engine/graph".to_string();
2383 let out = render_sync_brief(&r, &empty_cursor(), &[], &[], true);
2384 assert!(out.contains("## First sync — adopting `engine`"));
2385 assert!(out.contains("0% anchored is expected — this is onboarding, not a failure."));
2386 assert!(out.contains("do **not** replay the whole history"));
2387 assert!(out.contains("**Backfill path:**"));
2388 assert!(out.contains("memstead projection verify engine/graph"));
2389 }
2390
2391 #[test]
2394 fn sync_brief_inherits_first_sync_reseed_framing() {
2395 let r = resolved("engine", None, vec![]);
2396 let mut cursor = empty_cursor();
2397 cursor.reseed = vec![cmd("engine/graph/src#synced", "TOK")];
2398 let out = render_sync_brief(&r, &cursor, &[], &[], false);
2399 assert!(out.contains("No usable sync baseline exists for"));
2400 assert!(out.contains("Treating the current source state as the baseline"));
2401 }
2402
2403 #[test]
2406 fn sync_brief_nothing_to_sync() {
2407 let r = resolved("engine", None, vec![]);
2408 let out = render_sync_brief(&r, &empty_cursor(), &[], &[], false);
2409 assert!(out.contains("## Nothing to sync"));
2410 assert!(!out.contains("## How to repair"));
2411 assert!(!out.contains("## Open findings"));
2412 }
2413
2414 #[test]
2419 fn only_sync_brief_carries_repair_instructions() {
2420 let r = resolved("engine", None, vec![]);
2421 let findings = vec![finding(
2422 FindingClass::Drifted,
2423 anchor_target("engine--e", "src/a.rs"),
2424 "d",
2425 )];
2426 let verify = render_verify_brief(&r, 1);
2427 let sync = render_sync_brief(&r, &empty_cursor(), &findings, &[], false);
2428 assert!(!verify.contains("## How to repair"));
2430 assert!(!verify.contains("Update the affected section"));
2431 assert!(sync.contains("## How to repair — be conservative"));
2433 assert!(sync.contains("## Open findings to repair"));
2434 assert!(sync.contains("Update the affected section to match"));
2435 }
2436
2437 #[test]
2441 fn sync_brief_changed_slice_renders_stale_claim_search() {
2442 let r = resolved("engine", None, vec![]);
2443 let cursor = SourceCursor {
2444 union: slice(&[], &["moved.rs"], &[]),
2445 write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
2446 reseed: vec![],
2447 no_signal: vec![],
2448 any_changes: true,
2449 degraded: false,
2450 dead_denies: vec![],
2451 dest_mem: "engine".to_string(),
2452 binding_id: "engine/graph".to_string(),
2453 delivery: vec![],
2454 };
2455 let out = render_sync_brief(&r, &cursor, &[], &[], false);
2456 assert!(out.contains("## Stale claims beyond the slice — search, then judge"));
2457 assert!(out.contains("Extract the **changed facts** from the changed artifacts above"));
2459 assert!(out.contains("search the destination mem `engine`"));
2460 assert!(out.contains("`memstead_search`"));
2461 assert!(out.contains("judge **only** the entities whose claims actually mention"));
2462 assert!(out.contains("not a live-verify of every entity"));
2465 assert!(out.contains("not a rewrite license"));
2466 assert!(out.contains("the fact set is empty and this step ends with no"));
2467 assert!(out.contains("Never rewrite a section that has not changed"));
2470 }
2471
2472 #[test]
2476 fn sync_brief_without_changes_renders_no_stale_claim_search() {
2477 let r = resolved("engine", None, vec![]);
2478 let heading = "## Stale claims beyond the slice";
2479
2480 let findings = vec![finding(
2482 FindingClass::Uncovered,
2483 artifact_target("src/x.rs"),
2484 "d",
2485 )];
2486 let out = render_sync_brief(&r, &empty_cursor(), &findings, &[], false);
2487 assert!(!out.contains(heading), "findings-only pass must not search");
2488
2489 let mut reseed_cursor = empty_cursor();
2491 reseed_cursor.reseed = vec![cmd("engine/graph/src#synced", "TOK")];
2492 let out = render_sync_brief(&r, &reseed_cursor, &[], &[], false);
2493 assert!(!out.contains(heading), "reseed-only pass must not search");
2494
2495 let out = render_sync_brief(&r, &empty_cursor(), &[], &[], false);
2497 assert!(!out.contains(heading));
2498 }
2499
2500 #[test]
2503 fn sync_brief_caps_large_findings_group() {
2504 let r = resolved("engine", None, vec![]);
2505 let findings: Vec<Finding> = (0..FINDINGS_CAP + 4)
2506 .map(|i| {
2507 finding(
2508 FindingClass::Uncovered,
2509 artifact_target(&format!("src/f{i}.rs")),
2510 "d",
2511 )
2512 })
2513 .collect();
2514 let out = render_sync_brief(&r, &empty_cursor(), &findings, &[], false);
2515 assert!(out.contains("- …and 4 more"));
2516 assert!(!out.contains(&format!("src/f{}.rs", FINDINGS_CAP + 3)));
2518 }
2519
2520 #[test]
2531 fn sync_brief_block_sequence_locked_for_changed_slice() {
2532 let r = resolved("engine", None, vec![]);
2533 let cursor = SourceCursor {
2534 union: slice(&["gone.rs"], &["moved.rs"], &["new.rs"]),
2535 write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
2536 reseed: vec![],
2537 no_signal: vec![],
2538 any_changes: true,
2539 degraded: false,
2540 dead_denies: vec![],
2541 dest_mem: "engine".to_string(),
2542 binding_id: "engine/graph".to_string(),
2543 delivery: vec![],
2544 };
2545 let findings = vec![
2546 finding(
2547 FindingClass::Drifted,
2548 anchor_target("engine--e", "src/moved.rs"),
2549 "prepared-content hash drifted",
2550 ),
2551 finding(
2552 FindingClass::Uncovered,
2553 artifact_target("src/new.rs"),
2554 "in scope, no anchor",
2555 ),
2556 ];
2557 let out = render_sync_brief(&r, &cursor, &findings, &[], false);
2558 let headings: Vec<&str> = out
2559 .lines()
2560 .filter(|l| l.starts_with("## ") || l.starts_with("### "))
2561 .collect();
2562 assert_eq!(
2563 headings,
2564 vec![
2565 "## Sync — repair the graph to match the source",
2566 "## Source changes since the last sync",
2567 "### Recording your dispositions (do this LAST)",
2568 "## Stale claims beyond the slice — search, then judge",
2569 "## Open findings to repair",
2570 "### Drifted — the anchored content changed",
2571 "### Uncovered — a source artifact with no entity",
2572 "## Provenance — anchor your writes",
2576 "## How to repair — be conservative",
2577 ],
2578 "the loop-path sync brief carries exactly these blocks, in this order"
2579 );
2580 assert!(out.ends_with("`[commit <hash>]` log-style entries.\n\n"));
2583 }
2584
2585 #[test]
2596 fn no_default_path_brief_carries_inventory_machinery() {
2597 let inventory_terms = [
2600 "--full",
2601 "inventory",
2602 "full measurement",
2603 "did not converge",
2604 "quiescence",
2605 ];
2606 let assert_clean = |label: &str, text: &str| {
2607 let lower = text.to_lowercase();
2608 for term in inventory_terms {
2609 assert!(
2610 !lower.contains(term),
2611 "{label} must carry no inventory machinery (found {term:?})"
2612 );
2613 }
2614 };
2615
2616 let r = resolved("engine", None, vec![]);
2617 let g = guidance(Some("build coverage"), None);
2618 let pm = process_present("engine");
2619
2620 let changed_cursor = SourceCursor {
2622 union: slice(&[], &["moved.rs"], &[]),
2623 write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
2624 reseed: vec![],
2625 no_signal: vec![],
2626 any_changes: true,
2627 degraded: false,
2628 dead_denies: vec![],
2629 dest_mem: "engine".to_string(),
2630 binding_id: "engine/graph".to_string(),
2631 delivery: vec![],
2632 };
2633 let preface = render_changed_slice(&changed_cursor);
2634 assert_clean(
2635 "discovery build brief (plain roam)",
2636 &assemble_discovery_brief(&r, &g, &pm, Some("s@1"), None, &[], ""),
2637 );
2638 assert_clean(
2639 "discovery build brief (changed slice)",
2640 &assemble_discovery_brief(&r, &g, &pm, Some("s@1"), None, &[], &preface),
2641 );
2642 assert_clean(
2643 "one-shot build brief",
2644 &assemble_one_shot_brief(&r, &g, &pm, Some("s@1"), None, &[], Some("purpose")),
2645 );
2646
2647 assert_clean("verify brief (backlog)", &render_verify_brief(&r, 3));
2649 assert_clean("verify brief (no backlog)", &render_verify_brief(&r, 0));
2650
2651 let findings = vec![finding(
2653 FindingClass::Drifted,
2654 anchor_target("engine--e", "src/moved.rs"),
2655 "d",
2656 )];
2657 assert_clean(
2658 "sync brief (changed slice + findings)",
2659 &render_sync_brief(&r, &changed_cursor, &findings, &[], false),
2660 );
2661 assert_clean(
2662 "sync brief (findings-only)",
2663 &render_sync_brief(&r, &empty_cursor(), &findings, &[], false),
2664 );
2665 assert_clean(
2666 "sync brief (nothing to sync)",
2667 &render_sync_brief(&r, &empty_cursor(), &[], &[], false),
2668 );
2669 assert_clean(
2670 "sync brief (adopt)",
2671 &render_sync_brief(&r, &empty_cursor(), &[], &[], true),
2672 );
2673 }
2674}