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