1use super::guidance::ResolvedGuidance;
19use super::resolve::{ResolvedIngest, ResolvedSource};
20use super::slice::{NoSignalReason, Slice};
21use crate::binding::BuildMode;
22use crate::pipeline::{MediumType, PatternMode};
23
24const SLICE_CAP: usize = 25;
27
28pub const PROCESS_MEM_SCHEMA: &str = "ingest@0.5.0";
32
33#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ProcessMemInfo {
39 pub present: bool,
41 pub skipped: bool,
43 pub notice: Option<String>,
45 pub leaf_name: String,
47 pub mem_label: String,
49}
50
51fn mode_label(mode: BuildMode) -> &'static str {
54 match mode {
55 BuildMode::Discovery => "discovery",
56 BuildMode::OneShot => "one-shot",
57 }
58}
59
60fn medium_type_label(t: MediumType) -> &'static str {
62 match t {
63 MediumType::Codebase => "codebase",
64 MediumType::Filesystem => "filesystem",
65 MediumType::Graph => "graph",
66 MediumType::Git => "git",
67 MediumType::Web => "web",
68 }
69}
70
71pub fn render_goal_and_avoid(guidance: &ResolvedGuidance) -> String {
82 let mut lines: Vec<String> = Vec::new();
83
84 if let Some(goal) = guidance
85 .goal
86 .as_deref()
87 .map(str::trim)
88 .filter(|s| !s.is_empty())
89 {
90 lines.push("## Goal".to_string());
91 lines.push(String::new());
92 lines.push(goal.to_string());
93 lines.push(String::new());
94 }
95 if let Some(avoid) = guidance
96 .avoid
97 .as_deref()
98 .map(str::trim)
99 .filter(|s| !s.is_empty())
100 {
101 lines.push("## Failure modes to avoid".to_string());
102 lines.push(String::new());
103 lines.push(avoid.to_string());
104 lines.push(String::new());
105 }
106
107 format!("{}\n", lines.join("\n"))
108}
109
110pub fn render_situation(resolved: &ResolvedIngest, process_mem: &ProcessMemInfo) -> String {
114 let mode = mode_label(resolved.mode);
115 let name = &resolved.name;
116 let mut lines: Vec<String> = Vec::new();
117 lines.push("## Situation".to_string());
118 lines.push(String::new());
119 lines.push(format!(
120 "You are running one iteration of `{name}` ({mode} mode) inside a loop. \
121 Each iteration is a fresh agent with no memory of prior runs; the destination \
122 graph persists between runs and is your continuity. Backoff is mechanical — \
123 when nothing has changed since the last run, the loop skips this ingest silently. \
124 Reporting \"no changes\" is therefore a valid outcome."
125 ));
126 lines.push(String::new());
127 lines.push(
128 "Mutating the destination is this run's mandate: within the destination mem(s) and \
129 paired process mem named under Operative data, create, update, relate, and delete \
130 entities without asking. Project-level instructions that make entity creation/deletion \
131 ask-first govern interactive dev sessions, not ingest iterations — parking creatable \
132 work as a coverage_gap because of that rule defeats the loop. Mems outside the declared \
133 destinations remain off-limits."
134 .to_string(),
135 );
136 lines.push(String::new());
137 lines.push(
138 "Context budget is finite. The `PreCompact` hook fires near the limit and asks you to \
139 stop and report. Multiple cycles inside one run are fine when context allows; depth on \
140 a coherent area beats breadth across unrelated ones."
141 .to_string(),
142 );
143 lines.push(String::new());
144 if process_mem.present {
145 lines.push(format!(
146 "A paired process mem `{}` (schema `{PROCESS_MEM_SCHEMA}`) carries destination-quality \
147 debt prior runs could not address. Its entries are objective claims about destination \
148 state — read them on orientation, write to it when this run also cannot fix some debt, \
149 delete entries the destination has since resolved. Call \
150 `memstead_schema(name={PROCESS_MEM_SCHEMA})` once for the type vocabulary and write rules.",
151 process_mem.mem_label
152 ));
153 } else if let Some(notice) = &process_mem.notice {
154 lines.push(format!(
155 "Note: paired process mem `{}` could not be auto-created — {notice}. The run continues \
156 without it; the operator can retry with `memstead mem init {name} --org-path ingest \
157 --schema {PROCESS_MEM_SCHEMA}`.",
158 process_mem.mem_label
159 ));
160 } else if process_mem.skipped {
161 lines.push(format!(
162 "No process mem is paired with this ingest (mode={mode}; one-shot ingests are \
163 by-design ephemeral)."
164 ));
165 }
166 lines.push(String::new());
167 format!("{}\n", lines.join("\n"))
168}
169
170pub fn render_intent(resolved: &ResolvedIngest) -> String {
174 match resolved
175 .intent
176 .as_deref()
177 .map(str::trim)
178 .filter(|s| !s.is_empty())
179 {
180 Some(intent) => format!("## About the source\n\n{intent}\n\n"),
181 None => String::new(),
182 }
183}
184
185pub fn render_operative_data(
194 resolved: &ResolvedIngest,
195 process_mem: &ProcessMemInfo,
196 destination_schema: Option<&str>,
197 destination_note: Option<&str>,
198 absent_sources: &[String],
199) -> String {
200 let mut lines: Vec<String> = Vec::new();
201 lines.push("## Operative data".to_string());
202 lines.push(String::new());
203
204 if !resolved.sources.is_empty() {
206 lines.push("### Sources".to_string());
207 lines.push(String::new());
208 let mut reference_mems: Vec<String> = Vec::new();
209 for source in &resolved.sources {
210 match source {
211 ResolvedSource::Primary(p) => {
212 lines.push(format!(
218 "- **{}** ({}, primary) — `{}`",
219 p.name,
220 medium_type_label(p.medium_type),
221 p.pointer
222 ));
223 if absent_sources.iter().any(|n| n == &p.name) {
228 lines.push(
229 " - **This source does not resolve to anything on disk.** \
230 Nothing can be read from it until the path exists or the \
231 binding's pointer is corrected."
232 .to_string(),
233 );
234 }
235 let allows: Vec<&str> = p
236 .scope
237 .iter()
238 .filter(|r| r.mode == PatternMode::Allow)
239 .map(|r| r.path.as_str())
240 .collect();
241 let denies: Vec<&str> = p
242 .scope
243 .iter()
244 .filter(|r| r.mode == PatternMode::Deny)
245 .map(|r| r.path.as_str())
246 .collect();
247 let is_graph = p.medium_type == MediumType::Graph;
256 let (allow_label, deny_label) = if is_graph {
257 ("Entities", "Excluding")
258 } else {
259 ("Paths", "Ignore")
260 };
261 if !allows.is_empty() {
262 lines.push(format!(" - {allow_label}: {}", allows.join(", ")));
263 }
264 if !denies.is_empty() {
265 lines.push(format!(" - {deny_label}: {}", denies.join(", ")));
266 }
267 if is_graph {
268 lines.push(format!(
269 " - Read the source baseline with `memstead_search mem={}` \
270 (add `entity_type=` to match a `type:` selector). The changed \
271 slice below is a delta against the last pass — it is not the \
272 whole source, and an entity absent from it may still be \
273 unprojected.",
274 p.pointer
275 ));
276 }
277 }
278 ResolvedSource::Reference { mem } => {
279 lines.push(format!("- **graph** (reference) — mem: {mem}"));
280 reference_mems.push(mem.clone());
281 }
282 }
283 }
284 lines.push(String::new());
285 if !reference_mems.is_empty() {
286 lines.push(
287 "Sources tagged `(reference)` are read-only context for cross-mem edges — search \
288 them, never write into them. Only `(primary)` sources are ingested into the \
289 destination."
290 .to_string(),
291 );
292 lines.push(String::new());
293 let mem_list = reference_mems
294 .iter()
295 .map(|v| format!("`memstead_search mem={v}`"))
296 .collect::<Vec<_>>()
297 .join(", ");
298 lines.push(format!(
299 "**Cross-mem references:** consult {mem_list} before authoring cross-mem edges. \
300 The target entity must exist — a wiki-link or relationship to a missing target \
301 either auto-stubs (silent) or fails authorization (`CROSS_MEM_RELATION`)."
302 ));
303 lines.push(String::new());
304 }
305 }
306
307 lines.push("### Destination".to_string());
309 lines.push(String::new());
310 let schema_bit = destination_schema
311 .map(|s| format!(" — schema: `{s}`"))
312 .unwrap_or_default();
313 lines.push(format!("- **{}**{schema_bit}", resolved.destination_mem));
314 if let Some(note) = destination_note {
325 lines.push(format!(" - {note}"));
326 }
327 lines.push(String::new());
328
329 if process_mem.present {
331 lines.push("### Paired process mem".to_string());
332 lines.push(String::new());
333 lines.push(format!(
334 "- **{}** — schema: `{PROCESS_MEM_SCHEMA}`. Inspect via `memstead_overview` / \
335 `memstead_search mem={}`.",
336 process_mem.mem_label, process_mem.leaf_name
337 ));
338 lines.push(String::new());
339 }
340
341 format!("{}\n", lines.join("\n"))
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct SyncCommand {
351 pub key: String,
353 pub token: String,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct NoSignalNote {
364 pub source: String,
367 pub reason: NoSignalReason,
369 pub medium_type: Option<MediumType>,
375}
376
377#[derive(Debug, Clone, PartialEq, Eq)]
382pub struct SourceCursor {
383 pub union: Slice,
385 pub write_commands: Vec<SyncCommand>,
387 pub reseed: Vec<SyncCommand>,
389 pub no_signal: Vec<NoSignalNote>,
395 pub any_changes: bool,
397 pub degraded: bool,
399 pub dead_denies: Vec<String>,
408 pub dest_mem: String,
410 pub binding_id: String,
414}
415
416fn shell_quote(s: &str) -> String {
420 format!("'{}'", s.replace('\'', "'\\''"))
421}
422
423fn render_slice_class(lines: &mut Vec<String>, label: &str, paths: &[String]) {
426 if paths.is_empty() {
427 return;
428 }
429 let shown = paths.len().min(SLICE_CAP);
430 lines.push(format!("**{label}:**"));
431 for path in &paths[..shown] {
432 lines.push(format!("- `{path}`"));
433 }
434 if paths.len() > shown {
435 lines.push(format!(
436 "- …and {} more {}",
437 paths.len() - shown,
438 label.to_lowercase()
439 ));
440 }
441 lines.push(String::new());
442}
443
444fn no_signal_reason_text(reason: NoSignalReason, medium: Option<MediumType>) -> &'static str {
449 match reason {
450 NoSignalReason::Unscoped => match medium {
454 Some(MediumType::Graph) => {
455 "unscoped facet (no allow patterns) — nothing is monitored; write `*` in the \
456 facet scope to watch the whole mem, or `type:<entity_type>` / `id:<glob>` \
457 to narrow it (a graph source selects entities, not paths)"
458 }
459 _ => {
460 "unscoped facet (no allow patterns) — nothing is monitored; write `**/*` in the \
461 facet scope to watch the whole medium"
462 }
463 },
464 NoSignalReason::DetectionNone => {
465 "`signal:none` — change detection is disabled for this source (declared `none`)"
466 }
467 NoSignalReason::GitUnavailable => {
468 "git signal unavailable — no work tree, an unreadable `HEAD`, or an unknown baseline; \
469 a full re-roam is warranted this pass"
470 }
471 NoSignalReason::GraphSnapshotMissing => {
472 "graph snapshot missing — the source mem has no comparable baseline this pass"
473 }
474 }
475}
476
477pub fn render_changed_slice(cursor: &SourceCursor) -> String {
484 if !cursor.any_changes
485 && cursor.reseed.is_empty()
486 && cursor.no_signal.is_empty()
487 && cursor.dead_denies.is_empty()
488 {
489 return String::new();
490 }
491 let mut lines: Vec<String> = Vec::new();
492 lines.push("## Source changes since the last sync\n".to_string());
493
494 if cursor.any_changes {
495 lines.push(
496 "The source moved since this graph was last synced. Steer this pass at these changed \
497 artifacts **first** — they are where the graph is most likely now wrong.\n"
498 .to_string(),
499 );
500 render_slice_class(&mut lines, "Deleted", &cursor.union.deleted);
502 render_slice_class(&mut lines, "Modified", &cursor.union.modified);
503 render_slice_class(&mut lines, "Added", &cursor.union.added);
504 if cursor.degraded {
505 lines.push(
506 "_(Precise change history for one or more facets was unavailable, so its full \
507 current file set is listed above. Detection still fired from the durable baseline; \
508 targeting is coarser this pass only.)_\n"
509 .to_string(),
510 );
511 }
512 }
513
514 if !cursor.reseed.is_empty() {
515 let keys = cursor
516 .reseed
517 .iter()
518 .map(|r| format!("`{}`", r.key))
519 .collect::<Vec<_>>()
520 .join(", ");
521 let it = if cursor.reseed.len() == 1 {
522 "it"
523 } else {
524 "them"
525 };
526 lines.push(format!(
527 "No usable sync baseline exists for {keys} — none was recorded, or the recorded one \
528 is not a commit of the source's repo (foreign or garbage-collected). Treating the \
529 current source state as the baseline. No priority slice from {it} this pass; \
530 proceed as usual.\n"
531 ));
532 }
533
534 if !cursor.no_signal.is_empty() {
535 lines.push(
536 "Some sources produced **no change signal** this pass — detection could not compare \
537 them against a baseline, so they were not steered (roam them as usual). This is \
538 distinct from a source that was checked and had not moved:\n"
539 .to_string(),
540 );
541 for note in &cursor.no_signal {
542 lines.push(format!(
543 "- `{}`: {}",
544 note.source,
545 no_signal_reason_text(note.reason, note.medium_type)
546 ));
547 }
548 lines.push(String::new());
549 }
550
551 if !cursor.dead_denies.is_empty() {
552 lines.push(
553 "**Warning — some `deny_paths` entries match nothing.** The following ingest \
554 `deny_paths` selected **no file** in the project tree, so they exclude nothing from \
555 the slice and hide nothing from the ingest agent. This is usually a typo or a legacy \
556 bare name that never migrated to the workspace-relative glob dialect (e.g. `dev` → \
557 `dev/**`, `VISION.md` → `**/VISION.md`). Fix or remove them:\n"
558 .to_string(),
559 );
560 for entry in &cursor.dead_denies {
561 lines.push(format!("- `{entry}`"));
562 }
563 lines.push(String::new());
564 }
565
566 let has_baseline_to_advance = !cursor.write_commands.is_empty() || !cursor.reseed.is_empty();
574 if has_baseline_to_advance {
575 lines.push("### Recording your dispositions (do this LAST)\n".to_string());
576 lines.push(
577 "Only after you have worked the changed artifacts above — and only for the artifacts \
578 you actually judged — record a disposition for each, so the next pass targets just \
579 what changes next. This advance is resumable and non-stalling: a partial pass is \
580 honored, and if the source moves mid-pass the remaining slice re-presents \
581 (remaining + new) without losing your recorded work.\n"
582 .to_string(),
583 );
584 lines.push(
585 "Anchored work disposes itself: at advance time, every listed artifact that an \
586 anchor in the destination mem references is marked `worked` automatically (an \
587 explicit disposition you pass wins over the auto-mark). Supply dispositions only \
588 for the residue — artifacts you skipped, judged out of intent, or worked without \
589 anchors. The gate accepts only artifact ids listed above — an unknown id refuses \
590 the whole call. When every artifact is disposed, the sync baseline advances \
591 automatically. Run:\n"
592 .to_string(),
593 );
594 lines.push("```sh".to_string());
595 lines.push(format!(
596 "memstead projection advance {} --dispositions {}",
597 cursor.binding_id,
598 shell_quote(r#"{"<artifact>": "<disposition>", ...}"#)
599 ));
600 lines.push("```".to_string());
601 lines.push(
602 "If you were interrupted before finishing, that is fine — your recorded dispositions \
603 persist, and the next run re-presents only what is left.\n"
604 .to_string(),
605 );
606 }
607
608 format!("{}\n", lines.join("\n"))
609}
610
611pub fn render_anchor_instruction(resolved: &ResolvedIngest) -> String {
624 let mut block = "## Provenance — anchor your writes\n\n\
625 Attach an `anchors` list to every `memstead_create` / `memstead_update`, naming the \
626 source artifact(s) the entity is drawn from (the mutation tools document the element \
627 shape). Anchored writes are what verify measures coverage and drift against, and — on \
628 cursor-driven passes — what the advance gate auto-marks `worked`; an unanchored write \
629 leaves the fidelity report and the disposition window blind to your work.\n\n"
630 .to_string();
631 let primary_names: Vec<&str> = resolved
635 .sources
636 .iter()
637 .filter_map(|s| match s {
638 crate::ingest::resolve::ResolvedSource::Primary(src) => Some(src.name.as_str()),
639 crate::ingest::resolve::ResolvedSource::Reference { .. } => None,
640 })
641 .collect();
642 if !primary_names.is_empty() {
643 block.push_str(&format!(
644 "Set each anchor's `source` to the binding source name you drew the artifact \
645 from — this binding declares: {}. The name selects the pointer the \
646 artifact path is joined onto, so the wrong one usually refuses \
647 `INVALID_ANCHOR` (the path resolves under no candidate join). A name \
648 outside the list is NOT itself refused when the path happens to \
649 resolve workspace-relative — that tolerance exists for anchors whose \
650 binding was later renamed — so getting it right is on you, not on a \
651 gate.\n\n",
652 primary_names
653 .iter()
654 .map(|n| format!("`{n}`"))
655 .collect::<Vec<_>>()
656 .join(", ")
657 ));
658 }
659 block
660}
661
662#[allow(clippy::too_many_arguments)]
663pub fn assemble_discovery_brief(
664 resolved: &ResolvedIngest,
665 guidance: &ResolvedGuidance,
666 process_mem: &ProcessMemInfo,
667 destination_schema: Option<&str>,
668 destination_note: Option<&str>,
669 absent_sources: &[String],
670 changed_slice_preface: &str,
671) -> String {
672 let parts = [
673 render_situation(resolved, process_mem),
674 render_intent(resolved),
675 render_goal_and_avoid(guidance),
676 render_operative_data(
677 resolved,
678 process_mem,
679 destination_schema,
680 destination_note,
681 absent_sources,
682 ),
683 render_anchor_instruction(resolved),
684 changed_slice_preface.to_string(),
685 ];
686 parts
687 .into_iter()
688 .filter(|p| !p.is_empty())
689 .collect::<Vec<_>>()
690 .join("")
691}
692
693pub fn render_one_shot_lens(
699 resolved: &ResolvedIngest,
700 destination_schema: Option<&str>,
701 destination_purpose: Option<&str>,
702) -> String {
703 let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
704 let mut lines: Vec<String> = vec![
705 "## Mode: one-shot — lens routing".to_string(),
706 String::new(),
707 "A lens iterates entities once and writes per-destination, then exits. The agent decides \
708 per-entity which destinations to target (Routing rule). Re-runs use `memstead_update`; \
709 never duplicate."
710 .to_string(),
711 String::new(),
712 ];
713
714 lines.push("### Destination set".to_string());
715 lines.push(String::new());
716 lines.push("| Mem | Schema | Purpose |".to_string());
717 lines.push("|-------|--------|---------|".to_string());
718 let schema = destination_schema.unwrap_or("(none)");
719 let purpose = destination_purpose
720 .filter(|s| !s.is_empty())
721 .unwrap_or("(no purpose declared)");
722 lines.push(format!(
723 "| {} | {} | {} |",
724 cell(&resolved.destination_mem),
725 cell(schema),
726 cell(purpose)
727 ));
728 lines.push(String::new());
729
730 if let Some(routing) = resolved
731 .rules
732 .as_ref()
733 .and_then(|r| r.get("routing"))
734 .and_then(|v| v.as_str())
735 .map(str::trim)
736 .filter(|s| !s.is_empty())
737 {
738 lines.push("### Routing rule".to_string());
739 lines.push(String::new());
740 lines.push("```".to_string());
741 lines.push(routing.to_string());
742 lines.push("```".to_string());
743 lines.push(String::new());
744 }
745
746 lines.push("### Idempotency".to_string());
747 lines.push(String::new());
748 lines.push("- Search the destination before writing; route changes through `memstead_update` against the existing entity if present.".to_string());
749 lines.push("- Skip writes when the lifted content matches what is already there (record as `skipped: already-up-to-date`).".to_string());
750 lines.push(
751 "- Use `memstead_create` only when no entity for that concept exists yet.".to_string(),
752 );
753 lines.push(String::new());
754
755 lines.push("### End-of-run report".to_string());
756 lines.push(String::new());
757 lines.push("After every destination is processed, emit one block per destination on stdout, in Destination-set order:".to_string());
758 lines.push(String::new());
759 lines.push("```".to_string());
760 lines.push(format!("### Report: {}", resolved.name));
761 lines.push(String::new());
762 lines.push("Destination: <mem>".to_string());
763 lines.push(" created: <count>".to_string());
764 lines.push(" updated: <count>".to_string());
765 lines.push(" skipped: <count>".to_string());
766 lines.push(" failed: <count>".to_string());
767 lines.push(" failures:".to_string());
768 lines.push(" - <entity-key>: <error verbatim>".to_string());
769 lines.push(" skipped-detail:".to_string());
770 lines.push(" - <entity-key>: <one-line reason>".to_string());
771 lines.push("```".to_string());
772 lines.push(String::new());
773 lines.push("Per-destination commits are independent — partial success is the accepted failure mode. No rollback.".to_string());
774 lines.push(String::new());
775
776 let archive = resolved
777 .post_actions
778 .as_ref()
779 .and_then(|p| p.get("archive_source"))
780 .and_then(serde_json::Value::as_bool)
781 .unwrap_or(false);
782 if archive {
783 lines.push("### Archive after run".to_string());
784 lines.push(String::new());
785 lines.push("After the report has been emitted, archive the source planning mem — `post_actions.archive_source` is set on this ingest.".to_string());
786 lines.push(String::new());
787 }
788
789 format!("{}\n", lines.join("\n"))
790}
791
792#[allow(clippy::too_many_arguments)]
797pub fn assemble_one_shot_brief(
798 resolved: &ResolvedIngest,
799 guidance: &ResolvedGuidance,
800 process_mem: &ProcessMemInfo,
801 destination_schema: Option<&str>,
802 destination_note: Option<&str>,
803 absent_sources: &[String],
804 destination_purpose: Option<&str>,
805) -> String {
806 let parts = [
807 render_situation(resolved, process_mem),
808 render_intent(resolved),
809 render_goal_and_avoid(guidance),
810 render_operative_data(
811 resolved,
812 process_mem,
813 destination_schema,
814 destination_note,
815 absent_sources,
816 ),
817 render_anchor_instruction(resolved),
818 render_one_shot_lens(resolved, destination_schema, destination_purpose),
819 ];
820 parts
821 .into_iter()
822 .filter(|p| !p.is_empty())
823 .collect::<Vec<_>>()
824 .join("")
825}
826
827use super::findings::{Finding, FindingClass, FindingTarget};
837use super::prune::{PruneDisposition, PruneProposal};
838
839const FINDINGS_CAP: usize = SLICE_CAP;
841
842pub fn render_verify_brief(resolved: &ResolvedIngest, backlog: usize) -> String {
851 let mut lines: Vec<String> = vec![
852 "## Verify — measure fidelity, do not mutate".to_string(),
853 String::new(),
854 ];
855 lines.push(format!(
856 "You are measuring the fidelity of `{}` — how faithfully the destination mem \
857 `{}` still matches its source. This pass **only measures**: read the source \
858 and the mem's anchors, judge whether the graph still holds, and record what \
859 you find. **You** write nothing into the destination mem — the run itself \
860 records its findings store, backfills observed anchor hashes, and writes a \
861 `#verified` baseline, which is engine bookkeeping, not your edits.",
862 resolved.name, resolved.destination_mem
863 ));
864 lines.push(String::new());
865
866 lines.push(
867 "Anchors may carry a `source` naming the binding entry point that produced them — \
868 note it when recording findings, so fidelity stays measurable per source."
869 .to_string(),
870 );
871 lines.push(String::new());
872
873 lines.push("### Adjudicate the queued findings (capped)".to_string());
874 lines.push(String::new());
875 if backlog == 0 {
876 lines.push(
877 "No findings are queued for adjudication this pass. Spot-check the resolving \
878 anchors and the uncovered-artifact sample the fidelity report lists, and \
879 record any drift you observe as a finding."
880 .to_string(),
881 );
882 } else {
883 lines.push(format!(
884 "{backlog} finding(s) are queued for adjudication. Working up to the per-run \
885 adjudication cap (an operations knob — the remainder stays queued and \
886 re-presents on a later pass), take each queued finding and compare the \
887 anchored source content against what the entity records. Classify it: still \
888 accurate, or drifted. **Record the verdict — this is a measurement, not a \
889 repair.** A drift you record becomes a finding the sync pass repairs; you do \
890 not fix it here."
891 ));
892 }
893 lines.push(String::new());
894
895 lines.push("### Out of scope for verify — no mutation".to_string());
896 lines.push(String::new());
897 lines.push(
898 "Verify writes **no entity content**. Do not update a \
899 `specifies` / `constraints` section, do not create or delete an entity, do not \
900 add or remove a relationship. When measurement shows the graph is wrong, that \
901 is a **finding** — the sync brief (`memstead projection brief --sync`) is the \
902 one place those repairs are made. Leave every fix to it. (The run itself does \
903 record its findings store, backfill observed anchor hashes, and write a \
904 `#verified` baseline — engine bookkeeping, not your edits.)"
905 .to_string(),
906 );
907 lines.push(String::new());
908
909 format!("{}\n", lines.join("\n"))
910}
911
912fn finding_target_label(target: &FindingTarget) -> String {
914 match target {
915 FindingTarget::Anchor { entity, artifact } => format!("`{entity}` → `{artifact}`"),
916 FindingTarget::Artifact { artifact } => format!("`{artifact}`"),
917 }
918}
919
920fn render_findings_group(
923 lines: &mut Vec<String>,
924 heading: &str,
925 guidance: &str,
926 items: &[&Finding],
927) {
928 if items.is_empty() {
929 return;
930 }
931 lines.push(format!("### {heading}"));
932 lines.push(String::new());
933 lines.push(guidance.to_string());
934 lines.push(String::new());
935 let shown = items.len().min(FINDINGS_CAP);
936 for f in &items[..shown] {
937 lines.push(format!(
938 "- {} — {}",
939 finding_target_label(&f.target),
940 f.detail
941 ));
942 }
943 if items.len() > shown {
944 lines.push(format!("- …and {} more", items.len() - shown));
945 }
946 lines.push(String::new());
947}
948
949fn render_open_findings(findings: &[Finding]) -> String {
954 if findings.is_empty() {
955 return String::new();
956 }
957 let mut lines: Vec<String> = vec![
958 "## Open findings to repair".to_string(),
959 String::new(),
960 "The verify pass recorded these against the current source state. Repair them \
961 conservatively (see the rules below); a finding you judge already correct needs \
962 no write."
963 .to_string(),
964 String::new(),
965 ];
966
967 let group = |class: FindingClass| -> Vec<&Finding> {
968 findings.iter().filter(|f| f.class == class).collect()
969 };
970
971 render_findings_group(
974 &mut lines,
975 "Drifted — the anchored content changed",
976 "The source the entity describes moved. Update the affected section to match — \
977 only the part that changed. If the entity is still accurate, leave it.",
978 &group(FindingClass::Drifted),
979 );
980 render_findings_group(
981 &mut lines,
982 "Wrong — an adjudicated content mismatch",
983 "Adjudication found the entity no longer matches its source. Correct the \
984 mismatched section; do not rewrite what still holds.",
985 &group(FindingClass::Wrong),
986 );
987 render_findings_group(
990 &mut lines,
991 "Unresolvable anchor — the artifact is gone",
992 "The source artifact an anchor references is no longer present. Delete the entity \
993 **only** if the concept is removed entirely; otherwise leave it. Concept-level \
994 removals are a prune concern with its own never-clobber / conflict-flag rules — \
995 do not delete on a hunch here.",
996 &group(FindingClass::UnresolvableAnchor),
997 );
998 render_findings_group(
1001 &mut lines,
1002 "Uncovered — a source artifact with no entity",
1003 "An in-scope source artifact has no anchor in the mem. Create an entity for it \
1004 **only** if it is a clearly-new concept with no existing entity; otherwise \
1005 extend the entity that already owns the concept, or leave it for a discovery \
1006 build.",
1007 &group(FindingClass::Uncovered),
1008 );
1009 render_findings_group(
1011 &mut lines,
1012 "Queued for adjudication — not yet judged",
1013 "These are not adjudicated yet — that is the verify pass's job, not sync's. \
1014 **Skip them here**; they become repairable only after verify classifies them as \
1015 drifted.",
1016 &group(FindingClass::QueuedForAdjudication),
1017 );
1018
1019 format!("{}\n", lines.join("\n"))
1020}
1021
1022fn render_prune_proposals(proposals: &[PruneProposal]) -> String {
1033 if proposals.is_empty() {
1034 return String::new();
1035 }
1036 let mut lines: Vec<String> = vec![
1037 "## Prune — proposed removals (you decide; nothing is auto-deleted)".to_string(),
1038 String::new(),
1039 "The source removed the artifacts these entities describe. Each item below is a \
1040 **proposal**: prune writes nothing — you enact (or reject) the removal through the \
1041 normal MCP mutation surface. An `authored` entity is never proposed here; a `derived` \
1042 entity is flagged, never proposed for deletion."
1043 .to_string(),
1044 String::new(),
1045 ];
1046
1047 let group = |d: PruneDisposition| -> Vec<&PruneProposal> {
1048 proposals.iter().filter(|p| p.disposition == d).collect()
1049 };
1050
1051 let clean = group(PruneDisposition::CleanDelete);
1054 if !clean.is_empty() {
1055 lines.push("### Clean delete — never-clobber three-way merge is clean".to_string());
1056 lines.push(String::new());
1057 lines.push(
1058 "The source base leg was retrievable and the three-way merge found no model-side \
1059 divergence, so removal is safe. **Confirm, then delete via the mutation surface** — \
1060 this is still your call, not an auto-delete."
1061 .to_string(),
1062 );
1063 lines.push(String::new());
1064 let shown = clean.len().min(FINDINGS_CAP);
1065 for p in &clean[..shown] {
1066 lines.push(format!(
1067 "- `{}` — source artifact(s) gone: {}",
1068 p.entity,
1069 artifact_list(&p.artifacts)
1070 ));
1071 }
1072 if clean.len() > shown {
1073 lines.push(format!("- …and {} more", clean.len() - shown));
1074 }
1075 lines.push(String::new());
1076 }
1077
1078 let conflict = group(PruneDisposition::ConflictFlag);
1080 if !conflict.is_empty() {
1081 lines.push("### Conflict-flag — decide, never overwrite a model-side edit".to_string());
1082 lines.push(String::new());
1083 lines.push(
1084 "No retrievable base leg to merge against (a non-git source, or an anchor with no \
1085 pinned version). **Both sides are shown — decide deliberately.** If the concept is \
1086 truly gone, delete via the mutation surface; if the model side was edited on \
1087 purpose, keep it. Prune never overwrites a model-side edit for you."
1088 .to_string(),
1089 );
1090 lines.push(String::new());
1091 let shown = conflict.len().min(FINDINGS_CAP);
1092 for p in &conflict[..shown] {
1093 lines.push(format!(
1094 "- `{}` — **source side:** artifact(s) gone: {}; **model side:** the entity is \
1095 still present (may carry edits) — you decide.",
1096 p.entity,
1097 artifact_list(&p.artifacts)
1098 ));
1099 }
1100 if conflict.len() > shown {
1101 lines.push(format!("- …and {} more", conflict.len() - shown));
1102 }
1103 lines.push(String::new());
1104 }
1105
1106 let derived = group(PruneDisposition::DerivedFlagged);
1108 if !derived.is_empty() {
1109 lines.push("### Derived — flagged, NOT proposed for deletion".to_string());
1110 lines.push(String::new());
1111 lines.push(
1112 "These entities were **derived** from other inputs. A derived entity is flagged, \
1113 never auto-proposed for deletion — its inputs may still hold even though one source \
1114 artifact vanished. Re-examine the inputs before removing anything."
1115 .to_string(),
1116 );
1117 lines.push(String::new());
1118 let shown = derived.len().min(FINDINGS_CAP);
1119 for p in &derived[..shown] {
1120 let inputs = if p.derived_inputs.is_empty() {
1121 "(no recorded inputs)".to_string()
1122 } else {
1123 artifact_list(&p.derived_inputs)
1124 };
1125 lines.push(format!(
1126 "- `{}` — derived from: {}; source artifact(s) gone: {}.",
1127 p.entity,
1128 inputs,
1129 artifact_list(&p.artifacts)
1130 ));
1131 }
1132 if derived.len() > shown {
1133 lines.push(format!("- …and {} more", derived.len() - shown));
1134 }
1135 lines.push(String::new());
1136 }
1137
1138 format!("{}\n", lines.join("\n"))
1139}
1140
1141fn artifact_list(artifacts: &[String]) -> String {
1143 if artifacts.is_empty() {
1144 return "(none)".to_string();
1145 }
1146 artifacts
1147 .iter()
1148 .map(|a| format!("`{a}`"))
1149 .collect::<Vec<_>>()
1150 .join(", ")
1151}
1152
1153fn render_sync_situation(resolved: &ResolvedIngest) -> String {
1156 format!(
1157 "## Sync — repair the graph to match the source\n\n\
1158 You are running the sync pass for `{}`. Sync is the graph's **sole maintenance \
1159 writer**: the only place the destination mem `{}` is repaired to match its \
1160 source. Two inputs steer this pass — the source changes since the last sync, and \
1161 the open verify findings — both below. Work them: update, create, relate, and \
1162 (rarely) delete entities so the graph again matches the source.\n\n\
1163 Every mutation routes through the normal MCP mutation surface, and the engine \
1164 commits each one **per-mutation** to the mem's own gitdir. You **stage nothing \
1165 and commit nothing yourself** — not the graph, not the code. Sync commits \
1166 nothing.\n\n",
1167 resolved.name, resolved.destination_mem
1168 )
1169}
1170
1171fn render_adopt_framing(resolved: &ResolvedIngest) -> String {
1175 format!(
1176 "## First sync — adopting `{}`\n\n\
1177 This mem predates its binding: it has no anchors and no prior sync baseline, so \
1178 **0% anchored is expected — this is onboarding, not a failure.** Do not read it \
1179 as drift or a red verdict. There is no cursor to diff against, so the baseline is \
1180 the **current** source HEAD — do **not** replay the whole history; treat the \
1181 current source state as the starting point, and this is a **first sync**.\n\n\
1182 **Backfill path:** run `memstead projection verify {}` to enumerate the in-scope \
1183 source artifacts that carry no entity yet, then cover the clearly-new concepts \
1184 among them through the normal MCP mutation surface — the same conservative rules \
1185 below apply. Backfilling is incremental: a partial pass is fine, and the next \
1186 sync continues where you left off.\n\n",
1187 resolved.destination_mem, resolved.name
1188 )
1189}
1190
1191fn render_stale_claim_search(resolved: &ResolvedIngest) -> String {
1203 format!(
1204 "## Stale claims beyond the slice — search, then judge\n\n\
1205 A changed fact can be claimed by an entity whose anchors are all outside the \
1206 changed slice — anchor-steered repairs alone would leave that claim standing \
1207 falsified. Extract the **changed facts** from the changed artifacts above: \
1208 renamed identifiers, changed values or defaults, changed behaviors (e.g. an \
1209 exit code, a flag's meaning), removed or moved concepts. For each changed \
1210 fact, search the destination mem `{}` for claims about it (`memstead_search` \
1211 and its variants — try the new name, the old name/value, and close synonyms), \
1212 and judge **only** the entities whose claims actually mention a changed fact: \
1213 repair a claim the change falsifies, leave everything else untouched.\n\n\
1214 This is a bounded fact-search, not a live-verify of every entity and not a \
1215 rewrite license. If the changes carry no factual claims (formatting, \
1216 comments, cosmetic moves), the fact set is empty and this step ends with no \
1217 search and no edits.\n\n",
1218 resolved.destination_mem
1219 )
1220}
1221
1222fn render_sync_conservatism() -> String {
1226 let lines: Vec<&str> = vec![
1227 "## How to repair — be conservative",
1228 "",
1229 "Repair only what the source changes and the findings above actually justify:",
1230 "",
1231 "- **Unsure whether an entity is affected — skip it.** A missed update is a later \
1233 finding; a wrong rewrite is damage.",
1234 "- **Do not create a new entity unless the change clearly introduces a new concept \
1235 with no existing entity.** Prefer updating the entity that already owns the \
1236 concept.",
1237 "- **Do not delete an entity unless the change removes the concept entirely.** \
1238 Deletions a prune pass surfaces follow prune's own never-clobber / conflict-flag \
1239 rules — never delete on a hunch here.",
1240 "- **Never rewrite a section that has not changed** — touch only the part the \
1241 change or finding actually affects.",
1242 "- **No speculative edges — add only relationships the diff literally introduces** \
1243 (a new `use` / `import` / dependency you can point at in the change).",
1244 "- **A dropped dependency FLAGS, it does not auto-remove.** If the change removes an \
1246 import or dependency, leave the matching edge intact and note it for a later \
1247 audit — removals are ambiguous (temporary refactor vs. permanent cut), and a \
1248 stale edge is less damaging than an erased real one. **Edge removal is out of \
1249 scope for sync.**",
1250 "- **Rationale is reasoning, not a changelog.** When you record why a change was \
1252 made, append the *reasoning* (why this approach, which trade-offs) — never \
1253 `[commit <hash>]` log-style entries.",
1254 "",
1255 ];
1256
1257 format!("{}\n", lines.join("\n"))
1258}
1259
1260pub fn render_sync_brief(
1287 resolved: &ResolvedIngest,
1288 cursor: &SourceCursor,
1289 findings: &[Finding],
1290 prune: &[PruneProposal],
1291 adopt: bool,
1292) -> String {
1293 let preface = render_changed_slice(cursor);
1294 let open_findings = render_open_findings(findings);
1295 let prune_block = render_prune_proposals(prune);
1296 let has_work =
1297 adopt || !preface.is_empty() || !open_findings.is_empty() || !prune_block.is_empty();
1298
1299 let mut parts: Vec<String> = vec![render_sync_situation(resolved)];
1300
1301 if !has_work {
1302 parts.push(
1303 "## Nothing to sync\n\nThe source has not moved since the last sync, no \
1304 verify findings are open, and no prune proposals stand. There is nothing to \
1305 repair this pass — reporting \"no changes\" is a valid outcome.\n\n"
1306 .to_string(),
1307 );
1308 return parts
1309 .into_iter()
1310 .filter(|p| !p.is_empty())
1311 .collect::<Vec<_>>()
1312 .join("");
1313 }
1314
1315 if adopt {
1316 parts.push(render_adopt_framing(resolved));
1317 }
1318 parts.push(preface);
1319 if cursor.any_changes {
1323 parts.push(render_stale_claim_search(resolved));
1324 }
1325 parts.push(open_findings);
1326 parts.push(prune_block);
1327 parts.push(render_anchor_instruction(resolved));
1328 parts.push(render_sync_conservatism());
1329
1330 parts
1331 .into_iter()
1332 .filter(|p| !p.is_empty())
1333 .collect::<Vec<_>>()
1334 .join("")
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339 use super::*;
1340 use crate::ingest::resolve::Source;
1341 use crate::pipeline::{IngestTrigger, PatternEntry};
1342
1343 fn guidance(goal: Option<&str>, avoid: Option<&str>) -> ResolvedGuidance {
1344 ResolvedGuidance {
1345 goal: goal.map(str::to_string),
1346 avoid: avoid.map(str::to_string),
1347 }
1348 }
1349
1350 #[test]
1353 fn renders_goal_and_avoid_blocks() {
1354 let out = render_goal_and_avoid(&guidance(Some(" build coverage "), Some("no stubs")));
1355 assert_eq!(
1356 out,
1357 "## Goal\n\nbuild coverage\n\n## Failure modes to avoid\n\nno stubs\n\n"
1358 );
1359 }
1360
1361 #[test]
1363 fn renders_goal_only() {
1364 assert_eq!(
1365 render_goal_and_avoid(&guidance(Some("build coverage"), None)),
1366 "## Goal\n\nbuild coverage\n\n"
1367 );
1368 }
1369
1370 #[test]
1372 fn renders_avoid_only() {
1373 assert_eq!(
1374 render_goal_and_avoid(&guidance(None, Some("no stubs"))),
1375 "## Failure modes to avoid\n\nno stubs\n\n"
1376 );
1377 }
1378
1379 #[test]
1382 fn empty_guidance_yields_a_newline() {
1383 assert_eq!(render_goal_and_avoid(&guidance(None, None)), "\n");
1384 assert_eq!(render_goal_and_avoid(&guidance(Some(" "), None)), "\n");
1386 }
1387
1388 fn primary(medium_type: MediumType, scope: Vec<PatternEntry>) -> ResolvedSource {
1389 ResolvedSource::Primary(Source {
1390 name: "f".to_string(),
1391 medium_type,
1392 pointer: "../src".to_string(),
1393 change_detection: None,
1394 scope,
1395 engagement: None,
1396 preparation: None,
1397 })
1398 }
1399
1400 fn resolved(name: &str, intent: Option<&str>, sources: Vec<ResolvedSource>) -> ResolvedIngest {
1401 ResolvedIngest {
1402 name: name.to_string(),
1403 mode: BuildMode::Discovery,
1404 trigger: IngestTrigger::Loop,
1405 batch_size: 20,
1406 deny_paths: vec![],
1407 projection_ref: format!("{name}/p"),
1408 projection_mem: name.to_string(),
1409 projection_name: "p".to_string(),
1410 intent: intent.map(str::to_string),
1411 sources,
1412 destination_mem: name.to_string(),
1413 rules: None,
1414 post_actions: None,
1415 }
1416 }
1417
1418 fn process_present(name: &str) -> ProcessMemInfo {
1419 ProcessMemInfo {
1420 present: true,
1421 skipped: false,
1422 notice: None,
1423 leaf_name: name.to_string(),
1424 mem_label: format!("ingest/{name}"),
1425 }
1426 }
1427
1428 fn allow(path: &str) -> PatternEntry {
1429 PatternEntry {
1430 path: path.to_string(),
1431 mode: PatternMode::Allow,
1432 }
1433 }
1434
1435 fn deny(path: &str) -> PatternEntry {
1436 PatternEntry {
1437 path: path.to_string(),
1438 mode: PatternMode::Deny,
1439 }
1440 }
1441
1442 #[test]
1444 fn renders_intent() {
1445 let r = resolved("macos", Some(" Swift app source. "), vec![]);
1446 assert_eq!(
1447 render_intent(&r),
1448 "## About the source\n\nSwift app source.\n\n"
1449 );
1450 let none = resolved("macos", None, vec![]);
1451 assert_eq!(render_intent(&none), "");
1452 }
1453
1454 #[test]
1457 fn renders_situation_with_present_process_mem() {
1458 let r = resolved("macos", None, vec![]);
1459 let out = render_situation(&r, &process_present("macos"));
1460 assert!(out.starts_with("## Situation\n\nYou are running one iteration of `macos` (discovery mode) inside a loop."));
1461 assert!(out.contains("Mutating the destination is this run's mandate:"));
1462 assert!(out.contains("The `PreCompact` hook fires near the limit"));
1463 assert!(out.contains("A paired process mem `ingest/macos` (schema `ingest@0.5.0`) carries destination-quality debt"));
1464 assert!(
1465 out.ends_with("write rules.\n\n"),
1466 "block ends in a blank line"
1467 );
1468 }
1469
1470 #[test]
1473 fn situation_process_mem_branches() {
1474 let mut r = resolved("os", None, vec![]);
1475 r.mode = BuildMode::OneShot;
1476 let skipped = ProcessMemInfo {
1477 present: false,
1478 skipped: true,
1479 notice: None,
1480 leaf_name: "os".to_string(),
1481 mem_label: "ingest/os".to_string(),
1482 };
1483 assert!(
1484 render_situation(&r, &skipped)
1485 .contains("No process mem is paired with this ingest (mode=one-shot;")
1486 );
1487
1488 let failed = ProcessMemInfo {
1489 present: false,
1490 skipped: false,
1491 notice: Some("engine offline".to_string()),
1492 leaf_name: "os".to_string(),
1493 mem_label: "ingest/os".to_string(),
1494 };
1495 let out = render_situation(&resolved("os", None, vec![]), &failed);
1496 assert!(out.contains("could not be auto-created — engine offline."));
1497 assert!(out.contains("memstead mem init os --org-path ingest --schema ingest@0.5.0"));
1498 }
1499
1500 #[test]
1504 fn renders_operative_data_full() {
1505 let r = resolved(
1506 "macos",
1507 None,
1508 vec![
1509 primary(
1510 MediumType::Codebase,
1511 vec![allow("src/**/*.swift"), deny("src/gen/**")],
1512 ),
1513 ResolvedSource::Reference {
1514 mem: "engine".to_string(),
1515 },
1516 ],
1517 );
1518 let out = render_operative_data(
1519 &r,
1520 &process_present("macos"),
1521 Some("macos-code@0.1.0"),
1522 None,
1523 &[],
1524 );
1525 let expected = "\
1526## Operative data
1527
1528### Sources
1529
1530- **f** (codebase, primary) — `../src`
1531 - Paths: src/**/*.swift
1532 - Ignore: src/gen/**
1533- **graph** (reference) — mem: engine
1534
1535Sources tagged `(reference)` are read-only context for cross-mem edges — search them, never write into them. Only `(primary)` sources are ingested into the destination.
1536
1537**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`).
1538
1539### Destination
1540
1541- **macos** — schema: `macos-code@0.1.0`
1542
1543### Paired process mem
1544
1545- **ingest/macos** — schema: `ingest@0.5.0`. Inspect via `memstead_overview` / `memstead_search mem=macos`.
1546\n";
1547 assert_eq!(out, expected);
1548 }
1549
1550 #[test]
1553 fn renders_operative_data_minimal() {
1554 let r = resolved("g", None, vec![primary(MediumType::Filesystem, vec![])]);
1555 let skipped = ProcessMemInfo {
1556 present: false,
1557 skipped: true,
1558 notice: None,
1559 leaf_name: "g".to_string(),
1560 mem_label: "ingest/g".to_string(),
1561 };
1562 let out = render_operative_data(&r, &skipped, None, Some("**absent** — probe"), &[]);
1563 assert!(out.contains("- **f** (filesystem, primary) — `"));
1566 assert!(!out.contains("Cross-mem references"), "no reference note");
1567 assert!(out.contains("### Destination\n\n- **g**\n"));
1568 assert!(
1571 out.contains("**absent** — probe"),
1572 "the caller's destination note must be rendered: {out}",
1573 );
1574 assert!(
1575 !out.contains("Paired process mem"),
1576 "skipped process mem omitted"
1577 );
1578 }
1579
1580 #[test]
1583 fn assembles_discovery_brief() {
1584 let r = resolved(
1585 "macos",
1586 Some("Swift source."),
1587 vec![primary(MediumType::Codebase, vec![allow("src/**")])],
1588 );
1589 let g = guidance(Some("build coverage"), None);
1590 let pm = process_present("macos");
1591 let brief = assemble_discovery_brief(&r, &g, &pm, Some("s@1"), None, &[], "");
1592
1593 let sit = brief.find("## Situation").unwrap();
1595 let src = brief.find("## About the source").unwrap();
1596 let goal = brief.find("## Goal").unwrap();
1597 let op = brief.find("## Operative data").unwrap();
1598 let anchors = brief.find("## Provenance — anchor your writes").unwrap();
1599 assert!(
1600 sit < src && src < goal && goal < op && op < anchors,
1601 "blocks in brief order"
1602 );
1603 assert!(
1604 !brief.contains("## Source changes"),
1605 "no changed-slice block when preface empty"
1606 );
1607
1608 let with_slice = assemble_discovery_brief(
1610 &r,
1611 &g,
1612 &pm,
1613 Some("s@1"),
1614 None,
1615 &[],
1616 "## Source changes\n\n…\n\n",
1617 );
1618 assert!(with_slice.ends_with("## Source changes\n\n…\n\n"));
1619 }
1620
1621 fn slice(deleted: &[&str], modified: &[&str], added: &[&str]) -> Slice {
1622 Slice {
1623 deleted: deleted.iter().map(|s| s.to_string()).collect(),
1624 modified: modified.iter().map(|s| s.to_string()).collect(),
1625 added: added.iter().map(|s| s.to_string()).collect(),
1626 }
1627 }
1628
1629 fn cmd(key: &str, token: &str) -> SyncCommand {
1630 SyncCommand {
1631 key: key.to_string(),
1632 token: token.to_string(),
1633 }
1634 }
1635
1636 fn note(source: &str, reason: NoSignalReason) -> NoSignalNote {
1637 NoSignalNote {
1638 medium_type: None,
1639 source: source.to_string(),
1640 reason,
1641 }
1642 }
1643
1644 #[test]
1646 fn changed_slice_empty_when_nothing_moved() {
1647 let cursor = SourceCursor {
1648 union: slice(&[], &[], &[]),
1649 write_commands: vec![],
1650 reseed: vec![],
1651 no_signal: vec![],
1652 any_changes: false,
1653 degraded: false,
1654 dead_denies: vec![],
1655 dest_mem: "engine".to_string(),
1656 binding_id: "engine/graph".to_string(),
1657 };
1658 assert_eq!(render_changed_slice(&cursor), "");
1659 }
1660
1661 #[test]
1665 fn changed_slice_renders_dead_deny_warning() {
1666 let cursor = SourceCursor {
1667 union: slice(&[], &[], &[]),
1668 write_commands: vec![],
1669 reseed: vec![],
1670 no_signal: vec![],
1671 any_changes: false,
1672 degraded: false,
1673 dead_denies: vec!["dev".to_string(), "typo/**".to_string()],
1674 dest_mem: "engine".to_string(),
1675 binding_id: "engine/graph".to_string(),
1676 };
1677 let out = render_changed_slice(&cursor);
1678 assert!(out.contains("deny_paths` entries match nothing"));
1679 assert!(out.contains("- `dev`"));
1680 assert!(out.contains("- `typo/**`"));
1681 }
1682
1683 #[test]
1687 fn changed_slice_renders_slice_and_recording() {
1688 let cursor = SourceCursor {
1689 union: slice(&["a.rs"], &["b.rs"], &[]),
1690 write_commands: vec![cmd("engine-graph/source", "HEADSHA")],
1691 reseed: vec![],
1692 no_signal: vec![],
1693 any_changes: true,
1694 degraded: false,
1695 dead_denies: vec![],
1696 dest_mem: "engine".to_string(),
1697 binding_id: "engine/graph".to_string(),
1698 };
1699 let expected_lines = [
1700 "## Source changes since the last sync\n",
1701 "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",
1702 "**Deleted:**",
1703 "- `a.rs`",
1704 "",
1705 "**Modified:**",
1706 "- `b.rs`",
1707 "",
1708 "### Recording your dispositions (do this LAST)\n",
1709 "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",
1710 "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",
1711 "```sh",
1712 r#"memstead projection advance engine/graph --dispositions '{"<artifact>": "<disposition>", ...}'"#,
1713 "```",
1714 "If you were interrupted before finishing, that is fine — your recorded dispositions persist, and the next run re-presents only what is left.\n",
1715 ];
1716 assert_eq!(
1717 render_changed_slice(&cursor),
1718 format!("{}\n", expected_lines.join("\n"))
1719 );
1720 }
1721
1722 #[test]
1725 fn changed_slice_reseed_only() {
1726 let cursor = SourceCursor {
1727 union: slice(&[], &[], &[]),
1728 write_commands: vec![],
1729 reseed: vec![cmd("ing/f", "TOK")],
1730 no_signal: vec![],
1731 any_changes: false,
1732 degraded: false,
1733 dead_denies: vec![],
1734 dest_mem: "d".to_string(),
1735 binding_id: "d/p".to_string(),
1736 };
1737 let out = render_changed_slice(&cursor);
1738 assert!(out.starts_with("## Source changes since the last sync\n\n"));
1739 assert!(out.contains(
1740 "No usable sync baseline exists for `ing/f` — none was recorded, or the recorded one is not a commit of the source's repo (foreign or garbage-collected). Treating the current source state as the baseline. No priority slice from it this pass; proceed as usual."
1741 ));
1742 assert!(out.contains(
1743 r#"memstead projection advance d/p --dispositions '{"<artifact>": "<disposition>", ...}'"#
1744 ));
1745 assert!(
1746 !out.contains("The source moved"),
1747 "no 'moved' copy when only reseeding"
1748 );
1749 }
1750
1751 #[test]
1757 fn changed_slice_renders_no_signal_reasons_distinguishably() {
1758 let cursor = SourceCursor {
1759 union: slice(&[], &[], &[]),
1760 write_commands: vec![],
1761 reseed: vec![],
1762 no_signal: vec![
1763 note("code-facet", NoSignalReason::Unscoped),
1764 note("plan-facet", NoSignalReason::DetectionNone),
1765 note("git-facet", NoSignalReason::GitUnavailable),
1766 note("ref-mem", NoSignalReason::GraphSnapshotMissing),
1767 ],
1768 any_changes: false,
1769 degraded: false,
1770 dead_denies: vec![],
1771 dest_mem: "d".to_string(),
1772 binding_id: "d/p".to_string(),
1773 };
1774 let out = render_changed_slice(&cursor);
1775 assert!(out.starts_with("## Source changes since the last sync\n"));
1776 assert!(out.contains("Some sources produced **no change signal**"));
1777 assert!(out.contains("- `code-facet`: unscoped facet (no allow patterns)"));
1779 assert!(
1780 out.contains("- `plan-facet`: `signal:none`"),
1781 "detection-none renders the literal signal:none state"
1782 );
1783 assert!(out.contains("- `git-facet`: git signal unavailable"));
1784 assert!(out.contains("- `ref-mem`: graph snapshot missing"));
1785 let texts = [
1787 no_signal_reason_text(NoSignalReason::Unscoped, None),
1788 no_signal_reason_text(NoSignalReason::DetectionNone, None),
1789 no_signal_reason_text(NoSignalReason::GitUnavailable, None),
1790 no_signal_reason_text(NoSignalReason::GraphSnapshotMissing, None),
1791 ];
1792 for (i, a) in texts.iter().enumerate() {
1793 for b in &texts[i + 1..] {
1794 assert_ne!(a, b, "each no-signal reason must render distinctly");
1795 }
1796 }
1797 assert!(!out.contains("### Recording your dispositions"));
1799 assert!(!out.contains("The source moved"));
1800 }
1801
1802 #[test]
1806 fn changed_slice_mixes_changes_and_no_signal() {
1807 let cursor = SourceCursor {
1808 union: slice(&[], &["b.rs"], &[]),
1809 write_commands: vec![cmd("ing/f", "HEAD")],
1810 reseed: vec![],
1811 no_signal: vec![note("other", NoSignalReason::Unscoped)],
1812 any_changes: true,
1813 degraded: false,
1814 dead_denies: vec![],
1815 dest_mem: "d".to_string(),
1816 binding_id: "d/p".to_string(),
1817 };
1818 let out = render_changed_slice(&cursor);
1819 assert!(out.contains("The source moved"));
1820 assert!(out.contains("**Modified:**"));
1821 assert!(out.contains("- `other`: unscoped facet"));
1822 assert!(out.contains("### Recording your dispositions"));
1823 assert!(out.contains(
1824 r#"memstead projection advance d/p --dispositions '{"<artifact>": "<disposition>", ...}'"#
1825 ));
1826 }
1827
1828 #[test]
1831 fn renders_one_shot_lens_block() {
1832 let mut r = resolved("os", Some("plan source"), vec![]);
1833 r.rules = Some(serde_json::json!({ "routing": "route each entity to its spec" }));
1834 r.post_actions = Some(serde_json::json!({ "archive_source": true }));
1835
1836 let out = render_one_shot_lens(&r, Some("planning@0.1.0"), Some("the plan graph"));
1837 assert!(out.starts_with("## Mode: one-shot — lens routing\n\n"));
1838 assert!(out.contains(
1839 "### Destination set\n\n| Mem | Schema | Purpose |\n|-------|--------|---------|\n| os | planning@0.1.0 | the plan graph |\n"
1840 ));
1841 assert!(out.contains("### Routing rule\n\n```\nroute each entity to its spec\n```\n"));
1842 assert!(out.contains("### Idempotency"));
1843 assert!(out.contains("### Report: os"));
1844 assert!(out.contains("### Archive after run"));
1845 assert!(out.ends_with("is set on this ingest.\n\n"));
1846
1847 let bare = resolved("os", None, vec![]);
1850 let out2 = render_one_shot_lens(&bare, None, None);
1851 assert!(out2.contains("| os | (none) | (no purpose declared) |"));
1852 assert!(!out2.contains("### Routing rule"));
1853 assert!(!out2.contains("### Archive after run"));
1854 assert!(out2.contains("### End-of-run report"));
1855 }
1856
1857 #[test]
1860 fn assembles_one_shot_brief() {
1861 let mut r = resolved(
1862 "os",
1863 Some("src"),
1864 vec![primary(MediumType::Filesystem, vec![])],
1865 );
1866 r.mode = BuildMode::OneShot;
1867 let g = guidance(Some("goal"), None);
1868 let skipped = ProcessMemInfo {
1869 present: false,
1870 skipped: true,
1871 notice: None,
1872 leaf_name: "os".to_string(),
1873 mem_label: "ingest/os".to_string(),
1874 };
1875 let brief =
1876 assemble_one_shot_brief(&r, &g, &skipped, Some("s@1"), None, &[], Some("purpose"));
1877 assert!(brief.contains("(one-shot mode)"));
1878 assert!(brief.contains("No process mem is paired with this ingest (mode=one-shot;"));
1879 assert!(brief.contains("## Mode: one-shot — lens routing"));
1880 assert!(
1881 brief.contains("## Provenance — anchor your writes"),
1882 "one-shot carries the anchor instruction"
1883 );
1884 assert!(
1885 !brief.contains("## Source changes"),
1886 "one-shot has no changed-slice"
1887 );
1888 }
1889
1890 #[test]
1894 fn changed_slice_caps_and_degrades_and_quotes() {
1895 let many: Vec<String> = (0..SLICE_CAP + 3).map(|i| format!("f{i}.rs")).collect();
1896 let cursor = SourceCursor {
1897 union: Slice {
1898 deleted: vec![],
1899 modified: vec![],
1900 added: many,
1901 },
1902 write_commands: vec![cmd("ing/f", r#"{"v":1,"aggregate":"x"}"#)],
1903 reseed: vec![],
1904 no_signal: vec![],
1905 any_changes: true,
1906 degraded: true,
1907 dead_denies: vec![],
1908 dest_mem: "d".to_string(),
1909 binding_id: "d/p".to_string(),
1910 };
1911 let out = render_changed_slice(&cursor);
1912 assert!(out.contains(&format!("- …and {} more added", 3)));
1913 assert!(out.contains("Precise change history for one or more facets was unavailable"));
1914 assert!(out.contains(
1917 r#"memstead projection advance d/p --dispositions '{"<artifact>": "<disposition>", ...}'"#
1918 ));
1919 }
1920
1921 fn finding(class: FindingClass, target: FindingTarget, detail: &str) -> Finding {
1924 Finding {
1925 key: crate::ingest::findings::FindingKey {
1926 binding_hash: "h".to_string(),
1927 source_head: "s".to_string(),
1928 },
1929 facet: "src".to_string(),
1930 target,
1931 class,
1932 detail: detail.to_string(),
1933 created_at: "1".to_string(),
1934 }
1935 }
1936
1937 fn anchor_target(entity: &str, artifact: &str) -> FindingTarget {
1938 FindingTarget::Anchor {
1939 entity: entity.to_string(),
1940 artifact: artifact.to_string(),
1941 }
1942 }
1943
1944 fn artifact_target(artifact: &str) -> FindingTarget {
1945 FindingTarget::Artifact {
1946 artifact: artifact.to_string(),
1947 }
1948 }
1949
1950 fn empty_cursor() -> SourceCursor {
1951 SourceCursor {
1952 union: slice(&[], &[], &[]),
1953 write_commands: vec![],
1954 reseed: vec![],
1955 no_signal: vec![],
1956 any_changes: false,
1957 degraded: false,
1958 dead_denies: vec![],
1959 dest_mem: "engine".to_string(),
1960 binding_id: "engine/graph".to_string(),
1961 }
1962 }
1963
1964 #[test]
1968 fn verify_brief_measures_and_refuses_mutation() {
1969 let r = resolved("engine", None, vec![]);
1970 let out = render_verify_brief(&r, 3);
1971 assert!(out.starts_with("## Verify — measure fidelity, do not mutate"));
1973 assert!(out.contains("3 finding(s) are queued for adjudication"));
1974 assert!(out.contains("per-run adjudication cap"));
1975 assert!(out.contains("this is a measurement, not a repair"));
1976 assert!(out.contains("Verify writes **no entity content**"));
1989 assert!(out.contains("`#verified` baseline"));
1990 assert!(out.contains("memstead projection brief --sync"));
1991 assert!(out.contains("do not create or delete an entity"));
1994 assert!(!out.contains("via `memstead_create`"));
1995 assert!(!out.contains("Run `memstead_update`"));
1996
1997 let zero = render_verify_brief(&r, 0);
1999 assert!(zero.contains("No findings are queued for adjudication"));
2000 assert!(zero.contains("record any drift you observe as a finding"));
2001 assert!(zero.contains("Verify writes **no entity content**"));
2002 }
2003
2004 #[test]
2008 fn sync_brief_carries_both_cursor_and_findings() {
2009 let r = resolved("engine", None, vec![]);
2010 let cursor = SourceCursor {
2011 union: slice(&["gone.rs"], &["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 findings = vec![
2022 finding(
2023 FindingClass::Drifted,
2024 anchor_target("engine--e", "src/moved.rs"),
2025 "prepared-content hash drifted",
2026 ),
2027 finding(
2028 FindingClass::Uncovered,
2029 artifact_target("src/new.rs"),
2030 "in scope, no anchor",
2031 ),
2032 ];
2033 let out = render_sync_brief(&r, &cursor, &findings, &[], false);
2034 assert!(out.contains("## Source changes since the last sync"));
2036 assert!(out.contains("`moved.rs`"));
2037 assert!(out.contains("## Open findings to repair"));
2038 assert!(out.contains("`engine--e` → `src/moved.rs`"));
2039 assert!(out.contains("`src/new.rs`"));
2040 assert!(out.contains("sole maintenance writer"));
2042 assert!(out.contains("commits each one **per-mutation**"));
2043 assert!(out.contains("Sync commits nothing."));
2044 }
2045
2046 #[test]
2051 fn sync_brief_absorbs_reconcile_conservatism() {
2052 let r = resolved("engine", None, vec![]);
2053 let findings = vec![finding(
2054 FindingClass::Uncovered,
2055 artifact_target("src/x.rs"),
2056 "d",
2057 )];
2058 let out = render_sync_brief(&r, &empty_cursor(), &findings, &[], false);
2059 assert!(out.contains("Unsure whether an entity is affected — skip it."));
2061 assert!(out.contains(
2062 "Do not create a new entity unless the change clearly introduces a new concept"
2063 ));
2064 assert!(
2065 out.contains("Do not delete an entity unless the change removes the concept entirely.")
2066 );
2067 assert!(out.contains("Never rewrite a section that has not changed"));
2068 assert!(out.contains(
2069 "No speculative edges — add only relationships the diff literally introduces"
2070 ));
2071 assert!(out.contains("A dropped dependency FLAGS, it does not auto-remove."));
2073 assert!(out.contains("Edge removal is out of scope for sync."));
2074 assert!(out.contains("Rationale is reasoning, not a changelog."));
2076 assert!(out.contains("`[commit <hash>]` log-style entries"));
2077 }
2078
2079 #[test]
2083 fn sync_brief_renders_adopt_framing() {
2084 let mut r = resolved("engine", None, vec![]);
2085 r.name = "engine/graph".to_string();
2089 let out = render_sync_brief(&r, &empty_cursor(), &[], &[], true);
2090 assert!(out.contains("## First sync — adopting `engine`"));
2091 assert!(out.contains("0% anchored is expected — this is onboarding, not a failure."));
2092 assert!(out.contains("do **not** replay the whole history"));
2093 assert!(out.contains("**Backfill path:**"));
2094 assert!(out.contains("memstead projection verify engine/graph"));
2095 }
2096
2097 #[test]
2100 fn sync_brief_inherits_first_sync_reseed_framing() {
2101 let r = resolved("engine", None, vec![]);
2102 let mut cursor = empty_cursor();
2103 cursor.reseed = vec![cmd("engine/graph/src#synced", "TOK")];
2104 let out = render_sync_brief(&r, &cursor, &[], &[], false);
2105 assert!(out.contains("No usable sync baseline exists for"));
2106 assert!(out.contains("Treating the current source state as the baseline"));
2107 }
2108
2109 #[test]
2112 fn sync_brief_nothing_to_sync() {
2113 let r = resolved("engine", None, vec![]);
2114 let out = render_sync_brief(&r, &empty_cursor(), &[], &[], false);
2115 assert!(out.contains("## Nothing to sync"));
2116 assert!(!out.contains("## How to repair"));
2117 assert!(!out.contains("## Open findings"));
2118 }
2119
2120 #[test]
2125 fn only_sync_brief_carries_repair_instructions() {
2126 let r = resolved("engine", None, vec![]);
2127 let findings = vec![finding(
2128 FindingClass::Drifted,
2129 anchor_target("engine--e", "src/a.rs"),
2130 "d",
2131 )];
2132 let verify = render_verify_brief(&r, 1);
2133 let sync = render_sync_brief(&r, &empty_cursor(), &findings, &[], false);
2134 assert!(!verify.contains("## How to repair"));
2136 assert!(!verify.contains("Update the affected section"));
2137 assert!(sync.contains("## How to repair — be conservative"));
2139 assert!(sync.contains("## Open findings to repair"));
2140 assert!(sync.contains("Update the affected section to match"));
2141 }
2142
2143 #[test]
2147 fn sync_brief_changed_slice_renders_stale_claim_search() {
2148 let r = resolved("engine", None, vec![]);
2149 let cursor = SourceCursor {
2150 union: slice(&[], &["moved.rs"], &[]),
2151 write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
2152 reseed: vec![],
2153 no_signal: vec![],
2154 any_changes: true,
2155 degraded: false,
2156 dead_denies: vec![],
2157 dest_mem: "engine".to_string(),
2158 binding_id: "engine/graph".to_string(),
2159 };
2160 let out = render_sync_brief(&r, &cursor, &[], &[], false);
2161 assert!(out.contains("## Stale claims beyond the slice — search, then judge"));
2162 assert!(out.contains("Extract the **changed facts** from the changed artifacts above"));
2164 assert!(out.contains("search the destination mem `engine`"));
2165 assert!(out.contains("`memstead_search`"));
2166 assert!(out.contains("judge **only** the entities whose claims actually mention"));
2167 assert!(out.contains("not a live-verify of every entity"));
2170 assert!(out.contains("not a rewrite license"));
2171 assert!(out.contains("the fact set is empty and this step ends with no"));
2172 assert!(out.contains("Never rewrite a section that has not changed"));
2175 }
2176
2177 #[test]
2181 fn sync_brief_without_changes_renders_no_stale_claim_search() {
2182 let r = resolved("engine", None, vec![]);
2183 let heading = "## Stale claims beyond the slice";
2184
2185 let findings = vec![finding(
2187 FindingClass::Uncovered,
2188 artifact_target("src/x.rs"),
2189 "d",
2190 )];
2191 let out = render_sync_brief(&r, &empty_cursor(), &findings, &[], false);
2192 assert!(!out.contains(heading), "findings-only pass must not search");
2193
2194 let mut reseed_cursor = empty_cursor();
2196 reseed_cursor.reseed = vec![cmd("engine/graph/src#synced", "TOK")];
2197 let out = render_sync_brief(&r, &reseed_cursor, &[], &[], false);
2198 assert!(!out.contains(heading), "reseed-only pass must not search");
2199
2200 let out = render_sync_brief(&r, &empty_cursor(), &[], &[], false);
2202 assert!(!out.contains(heading));
2203 }
2204
2205 #[test]
2208 fn sync_brief_caps_large_findings_group() {
2209 let r = resolved("engine", None, vec![]);
2210 let findings: Vec<Finding> = (0..FINDINGS_CAP + 4)
2211 .map(|i| {
2212 finding(
2213 FindingClass::Uncovered,
2214 artifact_target(&format!("src/f{i}.rs")),
2215 "d",
2216 )
2217 })
2218 .collect();
2219 let out = render_sync_brief(&r, &empty_cursor(), &findings, &[], false);
2220 assert!(out.contains("- …and 4 more"));
2221 assert!(!out.contains(&format!("src/f{}.rs", FINDINGS_CAP + 3)));
2223 }
2224
2225 #[test]
2236 fn sync_brief_block_sequence_locked_for_changed_slice() {
2237 let r = resolved("engine", None, vec![]);
2238 let cursor = SourceCursor {
2239 union: slice(&["gone.rs"], &["moved.rs"], &["new.rs"]),
2240 write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
2241 reseed: vec![],
2242 no_signal: vec![],
2243 any_changes: true,
2244 degraded: false,
2245 dead_denies: vec![],
2246 dest_mem: "engine".to_string(),
2247 binding_id: "engine/graph".to_string(),
2248 };
2249 let findings = vec![
2250 finding(
2251 FindingClass::Drifted,
2252 anchor_target("engine--e", "src/moved.rs"),
2253 "prepared-content hash drifted",
2254 ),
2255 finding(
2256 FindingClass::Uncovered,
2257 artifact_target("src/new.rs"),
2258 "in scope, no anchor",
2259 ),
2260 ];
2261 let out = render_sync_brief(&r, &cursor, &findings, &[], false);
2262 let headings: Vec<&str> = out
2263 .lines()
2264 .filter(|l| l.starts_with("## ") || l.starts_with("### "))
2265 .collect();
2266 assert_eq!(
2267 headings,
2268 vec![
2269 "## Sync — repair the graph to match the source",
2270 "## Source changes since the last sync",
2271 "### Recording your dispositions (do this LAST)",
2272 "## Stale claims beyond the slice — search, then judge",
2273 "## Open findings to repair",
2274 "### Drifted — the anchored content changed",
2275 "### Uncovered — a source artifact with no entity",
2276 "## Provenance — anchor your writes",
2280 "## How to repair — be conservative",
2281 ],
2282 "the loop-path sync brief carries exactly these blocks, in this order"
2283 );
2284 assert!(out.ends_with("`[commit <hash>]` log-style entries.\n\n"));
2287 }
2288
2289 #[test]
2300 fn no_default_path_brief_carries_inventory_machinery() {
2301 let inventory_terms = [
2304 "--full",
2305 "inventory",
2306 "full measurement",
2307 "did not converge",
2308 "quiescence",
2309 ];
2310 let assert_clean = |label: &str, text: &str| {
2311 let lower = text.to_lowercase();
2312 for term in inventory_terms {
2313 assert!(
2314 !lower.contains(term),
2315 "{label} must carry no inventory machinery (found {term:?})"
2316 );
2317 }
2318 };
2319
2320 let r = resolved("engine", None, vec![]);
2321 let g = guidance(Some("build coverage"), None);
2322 let pm = process_present("engine");
2323
2324 let changed_cursor = SourceCursor {
2326 union: slice(&[], &["moved.rs"], &[]),
2327 write_commands: vec![cmd("engine/graph/src#synced", "HEAD")],
2328 reseed: vec![],
2329 no_signal: vec![],
2330 any_changes: true,
2331 degraded: false,
2332 dead_denies: vec![],
2333 dest_mem: "engine".to_string(),
2334 binding_id: "engine/graph".to_string(),
2335 };
2336 let preface = render_changed_slice(&changed_cursor);
2337 assert_clean(
2338 "discovery build brief (plain roam)",
2339 &assemble_discovery_brief(&r, &g, &pm, Some("s@1"), None, &[], ""),
2340 );
2341 assert_clean(
2342 "discovery build brief (changed slice)",
2343 &assemble_discovery_brief(&r, &g, &pm, Some("s@1"), None, &[], &preface),
2344 );
2345 assert_clean(
2346 "one-shot build brief",
2347 &assemble_one_shot_brief(&r, &g, &pm, Some("s@1"), None, &[], Some("purpose")),
2348 );
2349
2350 assert_clean("verify brief (backlog)", &render_verify_brief(&r, 3));
2352 assert_clean("verify brief (no backlog)", &render_verify_brief(&r, 0));
2353
2354 let findings = vec![finding(
2356 FindingClass::Drifted,
2357 anchor_target("engine--e", "src/moved.rs"),
2358 "d",
2359 )];
2360 assert_clean(
2361 "sync brief (changed slice + findings)",
2362 &render_sync_brief(&r, &changed_cursor, &findings, &[], false),
2363 );
2364 assert_clean(
2365 "sync brief (findings-only)",
2366 &render_sync_brief(&r, &empty_cursor(), &findings, &[], false),
2367 );
2368 assert_clean(
2369 "sync brief (nothing to sync)",
2370 &render_sync_brief(&r, &empty_cursor(), &[], &[], false),
2371 );
2372 assert_clean(
2373 "sync brief (adopt)",
2374 &render_sync_brief(&r, &empty_cursor(), &[], &[], true),
2375 );
2376 }
2377}