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