1use std::path::PathBuf;
2use std::io::{Read, Write};
3
4use anyhow::{Context, Result};
5use clap::{Args, Parser, Subcommand, ValueEnum};
6use flate2::read::GzDecoder;
7use flate2::write::GzEncoder;
8use flate2::Compression;
9use uuid::Uuid;
10
11use crate::git;
12use crate::lore::prism::{PrismSignal, PRISM_STALE_TTL_SECONDS};
13use crate::lore::{AtomEditRequest, AtomState, LoreAtom, LoreKind, Workspace, WorkspaceState};
14use crate::mcp::{McpService, PreflightSeverity, ProposalRequest};
15
16#[derive(Parser, Debug)]
17#[command(
18 name = "git-lore",
19 version,
20 about = "Capture and synchronize project rationale"
21)]
22struct Cli {
23 #[command(subcommand)]
24 command: Commands,
25}
26
27#[derive(Subcommand, Debug)]
28enum Commands {
29 Init(InitArgs),
31 Mark(MarkArgs),
33 Status(StatusArgs),
35 Checkpoint(CheckpointArgs),
37 Commit(CommitArgs),
39 Signal(SignalArgs),
41 SessionStart(SessionStartArgs),
43 SessionFinish(SessionFinishArgs),
45 Context(ContextArgs),
47 Propose(ProposeArgs),
49 Mcp(McpArgs),
51 Explain(ExplainArgs),
53 Validate(ValidateArgs),
55 Sync(SyncArgs),
57 Install(InstallArgs),
59 Merge(MergeArgs),
61 SetState(SetStateArgs),
63 EditAtom(EditAtomArgs),
65 Generate(GenerateArgs),
67 Resolve(ResolveArgs),
69}
70
71#[derive(Args, Debug)]
72struct ResolveArgs {
73 #[arg(value_name = "LOCATION")]
75 location: String,
76 #[arg(long, default_value = ".")]
78 path: PathBuf,
79 #[arg(long)]
81 winner_id: Option<String>,
82 #[arg(long)]
84 reason: Option<String>,
85}
86
87#[derive(Args, Debug)]
88struct GenerateArgs {
89 #[arg(default_value = ".github/git-lore-skills.md")]
91 output: PathBuf,
92}
93
94#[derive(Args, Debug)]
95struct InitArgs {
96 #[arg(default_value = ".")]
98 path: PathBuf,
99}
100
101#[derive(Args, Debug)]
102struct MarkArgs {
103 #[arg(long)]
105 title: String,
106 #[arg(long)]
108 body: Option<String>,
109 #[arg(long)]
111 scope: Option<String>,
112 #[arg(long)]
114 path: Option<PathBuf>,
115 #[arg(long = "validation-script")]
117 validation_script: Option<String>,
118 #[arg(long, value_enum, default_value = "decision")]
120 kind: LoreKindArg,
121}
122
123#[derive(Args, Debug)]
124struct StatusArgs {
125 #[arg(default_value = ".")]
127 path: PathBuf,
128}
129
130#[derive(Args, Debug)]
131struct CheckpointArgs {
132 #[arg(default_value = ".")]
134 path: PathBuf,
135 #[arg(long)]
137 message: Option<String>,
138}
139
140#[derive(Args, Debug)]
141struct CommitArgs {
142 #[arg(default_value = ".")]
144 path: PathBuf,
145 #[arg(long)]
147 message: String,
148 #[arg(long, default_value_t = true)]
150 allow_empty: bool,
151}
152
153#[derive(Args, Debug)]
154struct SignalArgs {
155 #[arg(default_value = ".")]
157 workspace: PathBuf,
158 #[arg(long)]
160 session_id: Option<String>,
161 #[arg(long, alias = "clear", default_value_t = false)]
163 release: bool,
164 #[arg(long)]
166 agent: Option<String>,
167 #[arg(long)]
169 scope: Option<String>,
170 #[arg(long = "path", value_name = "GLOB")]
172 paths: Vec<String>,
173 #[arg(long = "assumption")]
175 assumptions: Vec<String>,
176 #[arg(long)]
178 decision: Option<String>,
179}
180
181#[derive(Args, Debug)]
182struct SessionStartArgs {
183 #[arg(default_value = ".")]
185 workspace: PathBuf,
186 #[arg(long)]
188 session_id: Option<String>,
189 #[arg(long)]
191 agent: Option<String>,
192 #[arg(long)]
194 scope: Option<String>,
195 #[arg(long = "path", value_name = "GLOB")]
197 paths: Vec<String>,
198 #[arg(long = "assumption")]
200 assumptions: Vec<String>,
201 #[arg(long)]
203 decision: Option<String>,
204 #[arg(long)]
206 reason: Option<String>,
207 #[arg(long = "checkpoint-message")]
209 checkpoint_message: Option<String>,
210}
211
212#[derive(Args, Debug)]
213struct SessionFinishArgs {
214 #[arg(default_value = ".")]
216 workspace: PathBuf,
217 #[arg(long)]
219 session_id: String,
220 #[arg(long)]
222 message: String,
223 #[arg(long, default_value_t = true)]
225 allow_empty: bool,
226 #[arg(long)]
228 agent: Option<String>,
229 #[arg(long)]
231 reason: Option<String>,
232 #[arg(long = "checkpoint-message")]
234 checkpoint_message: Option<String>,
235}
236
237#[derive(Args, Debug)]
238struct ContextArgs {
239 #[arg(default_value = ".")]
241 path: PathBuf,
242 #[arg(long)]
244 file: PathBuf,
245 #[arg(long)]
247 cursor_line: Option<usize>,
248}
249
250#[derive(Args, Debug)]
251struct ProposeArgs {
252 #[arg(default_value = ".")]
254 path: PathBuf,
255 #[arg(long)]
257 file: PathBuf,
258 #[arg(long)]
260 title: String,
261 #[arg(long)]
263 body: Option<String>,
264 #[arg(long = "validation-script")]
266 validation_script: Option<String>,
267 #[arg(long)]
269 cursor_line: Option<usize>,
270 #[arg(long, value_enum, default_value = "decision")]
272 kind: LoreKindArg,
273}
274
275#[derive(Args, Debug)]
276struct McpArgs {
277 #[arg(default_value = ".")]
279 path: PathBuf,
280}
281
282#[derive(Args, Debug)]
283struct ExplainArgs {
284 #[arg(default_value = ".")]
286 path: PathBuf,
287 #[arg(long)]
289 file: PathBuf,
290 #[arg(long)]
292 cursor_line: Option<usize>,
293}
294
295#[derive(Args, Debug)]
296struct ValidateArgs {
297 #[arg(default_value = ".")]
299 path: PathBuf,
300}
301
302#[derive(Args, Debug)]
303struct SyncArgs {
304 #[arg(default_value = ".")]
306 path: PathBuf,
307}
308
309#[derive(Args, Debug)]
310struct InstallArgs {
311 #[arg(default_value = ".")]
313 path: PathBuf,
314}
315
316#[derive(Args, Debug)]
317struct MergeArgs {
318 base: PathBuf,
320 current: PathBuf,
322 other: PathBuf,
324}
325
326#[derive(Args, Debug)]
327struct SetStateArgs {
328 #[arg(default_value = ".")]
330 path: PathBuf,
331 #[arg(long = "atom-id")]
333 atom_id: String,
334 #[arg(long, value_enum)]
336 state: AtomStateArg,
337 #[arg(long)]
339 reason: String,
340 #[arg(long)]
342 actor: Option<String>,
343}
344
345#[derive(Args, Debug)]
346struct EditAtomArgs {
347 #[arg(default_value = ".")]
349 path: PathBuf,
350 #[arg(long = "atom-id")]
352 atom_id: String,
353 #[arg(long, value_enum)]
355 kind: Option<LoreKindArg>,
356 #[arg(long)]
358 title: Option<String>,
359 #[arg(long)]
361 body: Option<String>,
362 #[arg(long, default_value_t = false)]
364 clear_body: bool,
365 #[arg(long)]
367 scope: Option<String>,
368 #[arg(long, default_value_t = false)]
370 clear_scope: bool,
371 #[arg(long = "atom-path")]
373 atom_path: Option<PathBuf>,
374 #[arg(long, default_value_t = false)]
376 clear_atom_path: bool,
377 #[arg(long = "validation-script")]
379 validation_script: Option<String>,
380 #[arg(long, default_value_t = false)]
382 clear_validation_script: bool,
383 #[arg(long = "trace-commit-sha")]
385 trace_commit_sha: Option<String>,
386 #[arg(long, default_value_t = false)]
388 clear_trace_commit: bool,
389 #[arg(long)]
391 reason: String,
392 #[arg(long)]
394 actor: Option<String>,
395}
396
397#[derive(Clone, Copy, Debug, ValueEnum)]
398enum LoreKindArg {
399 Decision,
400 Assumption,
401 OpenQuestion,
402 Signal,
403}
404
405#[derive(Clone, Copy, Debug, ValueEnum)]
406enum AtomStateArg {
407 Draft,
408 Proposed,
409 Accepted,
410 Deprecated,
411}
412
413pub fn run() -> Result<()> {
414 let cli = Cli::parse();
415
416 match cli.command {
417 Commands::Init(args) => init(args),
418 Commands::Mark(args) => mark(args),
419 Commands::Status(args) => status(args),
420 Commands::Checkpoint(args) => checkpoint(args),
421 Commands::Commit(args) => commit(args),
422 Commands::Signal(args) => signal(args),
423 Commands::SessionStart(args) => session_start(args),
424 Commands::SessionFinish(args) => session_finish(args),
425 Commands::Context(args) => context(args),
426 Commands::Propose(args) => propose(args),
427 Commands::Mcp(args) => mcp(args),
428 Commands::Explain(args) => explain(args),
429 Commands::Validate(args) => validate(args),
430 Commands::Sync(args) => sync(args),
431 Commands::Install(args) => install(args),
432 Commands::Merge(args) => merge(args),
433 Commands::SetState(args) => set_state(args),
434 Commands::EditAtom(args) => edit_atom(args),
435 Commands::Generate(args) => generate(args),
436 Commands::Resolve(args) => resolve(args),
437 }
438}
439
440fn generate(args: GenerateArgs) -> Result<()> {
441 if let Some(parent) = args.output.parent() {
442 if !parent.exists() {
443 std::fs::create_dir_all(parent).with_context(|| format!("failed to create dir: {}", parent.display()))?;
444 }
445 }
446
447 let content = r#"---
448description: >-
449 Git-Lore Integration Skill. Helps capture architectural rationale, rules, and assumptions tightly bound to the codebase via the Git-Lore CLI and MCP server tools. Use when a user asks to establish a new codebase rule, architectural decision, or convention.
450---
451
452# Git-Lore Skills
453
454Keep architectural decisions and knowledge strongly bound to codebase states.
455
456**When to Use:**
457- When adding notes/assumptions explicitly requested by the user.
458- When a user asks "document this pattern for later", "mark this assumption", or "save this rule".
459- Upon discovering a consistent convention not currently documented in `.lore`.
460
461## Instructions
462
463<instructions>
464You are an AI assistant empowered to use `git-lore`, a tool that anchors rationale as structured "lore atoms" directly bounded to codebase paths and scopes.
465
466### 1. Discovering Lore (Context)
467When you navigate to a new file or need to understand how it should be implemented, read the context using:
468- **MCP Tool:** `git_lore_context` (pass the file path) or `git_lore_memory_search` (pass a query).
469- **CLI Alternative:** Tell the user to run `git-lore context --file <file>` or `git-lore explain --file <file>`.
470
471### 2. Recording Lore (Propose / Mark)
472When the user and you make an important architectural decision, or establish a convention that other AI agents should know:
473- **MCP Tool:** Call `git_lore_propose`. **Crucial:** You must first call `git_lore_state_snapshot` to get the `state_checksum` and `snapshot_generated_unix_seconds` required for proposing.
474- **CLI Alternative:** Suggest the user run:
475 `git-lore mark --title "Your concise rule constraint" --body "The reason why this exists" --path "<relative_file_path>"`
476
477### 3. Git Workflows
478When the task is done, gently remind the user they can commit this knowledge firmly to Git by running `git-lore commit --message "feat: your task"`.
479
480# Flujo de Trabajo: Git-Lore + Git
481
482**¿En qué etapa de desarrollo te encuentras?**
483
484## 1. Modificando código críptico o legado
485Necesitas arreglar un bug o extender un módulo, pero el código es confuso y no sabes qué romperás si lo cambias.
486
487**Flujo:**
488* **Git:** Crear rama: `git checkout -b fix/module`
489* **Git-Lore:** Obtener reglas: `git-lore context --file module.rs`
490
491> **¿Cómo ayuda?** Resuelve la paradoja de "Chesterton's Fence". Antes de borrar o cambiar código, el sistema te expone *por qué* se hizo (las decisiones históricas que enmarcan ese archivo), evitantando que re-introduzcas bugs antiguos.
492
493## 2. Tomando decisiones arquitectónicas clave
494Estás liderando un nuevo feature y has decidido usar un patrón de diseño o herramienta específica para este módulo.
495
496**Flujo:**
497* **Git:** Programar la lógica principal y hacer `git add .`
498* **Git-Lore:** Marcar: `git-lore mark --kind decision --title "Usar Patrón Builder..."`
499* **Integración:** Confirmar: `git-lore commit -m "feat: xyz"`
500
501> **¿Cómo ayuda?** Al usar `git-lore commit`, el contexto no solo se queda local, sino que se inyecta como un *Git Trailer* en el historial puro de Git. Cualquiera (incluso sin tener git-lore) puede ver en `git log` la traza de la decisión junto al código que la implementó.
502
503## 3. Delegando código complejo a una IA (Copilot, Agentes)
504Le estás pidiendo a un Agente IA que genere un refactor masivo o construya un nuevo servicio desde tu editor (VS Code).
505
506**Flujo:**
507* **MCP Server:** La IA pide contexto silenciosamente: `git_lore_context(scope)`
508* **Desarrollo:** La IA genera código respetando las restricciones inyectadas.
509* **Evolución:** La IA sugiere reglas: `git_lore_propose(...)`
510
511> **¿Cómo ayuda?** Alimenta automáticamente a la IA (Zero-Shot compliance). Previene que el Agente alucine patrones equivocados o traiga dependencias prohibidas. La IA "nace" conociendo cómo funciona este equipo o proyecto.
512
513## 4. Revisión de un Pull Request
514Un colega sube su código para que lo apruebes y se funda con la rama principal.
515
516**Flujo:**
517* **Git / CI:** Se levanta la Pull Request en GitHub/GitLab.
518* **Git-Lore:** CI verifica o el humano ejecuta `git-lore validate`.
519
520> **¿Cómo ayuda?** Transforma las opiniones subjetivas en revisiones objetivas. El validador (o el revisor) puede chequear si el código en revisión rompe alguna regla que fue previamente "Acordada y Aceptada" en el lore del directorio afectado.
521
522## 5. Explorando la Memoria del Proyecto (Discovery)
523No recuerdas por qué se tomó una decisión hace meses, o le pides a una IA que investigue el proyecto antes de proponer código nuevo.
524
525**Flujo:**
526* **MCP Server:** La IA busca intenciones difusas: `git_lore_memory_search("auth architecture")`
527* **Git-Lore:** Obtener justificación detallada: `git-lore explain --file src/auth.rs`
528
529> **¿Cómo ayuda?** Democratiza el conocimiento histórico. A través del buscador léxico y semántico del MCP, puedes encontrar conocimiento por "intención" y "recencia", en lugar de buscar a ciegas en Slack o Jira.
530
531## 6. Evolución del Conocimiento (Estado y Ciclo de Vida)
532El código cambia, y las reglas también deben hacerlo. Una convención propuesta por IA necesita ser aceptada, o una regla antigua queda obsoleta.
533
534**Flujo:**
535* **MCP Server:** La IA sugiere cambios: `git_lore_propose(target_state="Proposed")`
536* **Git-Lore:** El humano formaliza: `git-lore set-state --state accepted`
537* **Git-Lore:** Las reglas viejas se retiran: `git-lore set-state --state deprecated`
538
539> **¿Cómo ayuda?** El canon (lore) nunca es inmutable y no se convierte en una Wiki zombie. Pasa por estados `Draft -> Proposed -> Accepted -> Deprecated`, dándole al equipo y agentes control explícito sobre la validez del conocimiento sobre el tiempo.
540
541## 7. Flujos Activos Autoriales (Signals & Preflight)
542Agentes IA autónomos necesitan verificar la seguridad de la memoria y alertar al equipo de sus intenciones transitorias antes de destruir estados del repositorio accidentalmente.
543
544**Flujo:**
545* **Git-Lore:** Crear instantánea segura: `git-lore checkpoint / git-lore status`
546* **MCP Server:** Validaciones de estado: `git_lore_memory_preflight("commit")`
547* **Git-Lore:** Agentes emiten eventos cortos: `git-lore signal --agent "Codegen"`
548
549> **¿Cómo ayuda?** Permite la colaboración segura (Safe Writes) con Inteligencia Artificial. Con verificaciones previas como `transition_preview` y `preflight`, se evita la sobrescritura y entropía donde la IA accidentalmente contradiga decisiones base de otras ramas.
550
551## 8. Congelando el Conocimiento (Checkpoints)
552Estás a punto de hacer un refactor masivo de reglas de negocio o estás orquestando múltiples agentes de IA simultáneos. Necesitas asegurar un punto de restauración seguro de las intenciones de tu equipo.
553
554**Flujo:**
555* **Git-Lore:** Congelar el estado base: `git-lore checkpoint --message "Pre-refactor de auth"`
556* **MCP Server:** Agentes IA validan checksums: `git_lore_state_snapshot()`
557* **Integración:** Fallo rápido (Fail-fast) preventivo en caso de discrepancias temporales.
558
559> **¿Cómo ayuda?** Resuelve la desalineación de estados o condiciones de carrera entre ramas, humanos y Agentes de IA. Un 'checkpoint' crea una fotografía instantánea del *Lore*. Si el código muta o un agente propone un cambio basándose en información desactualizada, el archivo bloquea la sobre-escritura (Strict State-First Check).
560
561## 9. Fusión y Reconciliación de Conocimiento (Merge)
562Trabajas en una rama feature donde propusiste nuevas decisiones, mientras que en la rama 'main' otra persona agregó o deprecó otras reglas. Ahora necesitas fusionar ambas ramas sin perder ni contradecir el Lore.
563
564**Flujo:**
565* **Git:** Comienza la fusión de archivos: `git merge feature/branch`
566* **Git-Lore:** Git dispara el merge driver: `git-lore merge <base> <current> <other>`
567* **Git-Lore:** Reconciliación: deduplica IDs, verifica estados (Ej. "Accepted" vence a "Proposed").
568
569> **¿Cómo ayuda?** Git-Lore se instala como un 'Merge Driver' personalizado (vía `git-lore install`). A diferencia de fusionar código o JSON manualmente, este previene colisiones semánticas. Si un átomo en 'main' fue marcado como `Deprecated`, pero en tu rama lo habías actualizado, el algoritmo de reconciliación lo fusionará inteligentemente.
570
571## 10. Proposiciones y Señales Contextuales (Propose & Signal)
572Durante un sprint rápido, un desarrollador o una IA lanza una "Suposición" temporal (Signal) al aire para que la IA que trabaje en el código asociado la tenga en cuenta temporalmente, o "proponga" formalmente (Propose) una nueva convención.
573
574**Flujo:**
575* **Git-Lore:** Crear señal efímera: `git-lore signal --assumption "Asumo que la API devuelve XML" --path src/`
576* **MCP Server:** Subagentes leen la señal: `git_lore_memory_search()` expone la suposición fresca.
577* **Git-Lore:** Validación: `git-lore propose --title "API responde JSON" --kind decision` reemplaza la suposición.
578
579> **¿Cómo funciona el salto de Señal a Decisión internamente?**
580>
581> 1. **La Señal (Conocimiento Efímero):** `git-lore signal` NO crea un Registro permanente. Crea un archivo temporal (PrismSignal) con un Tiempo de Vida (TTL) programado para expirar. Actúa como un cerrojo suave ("Soft-lock") para avisar a otros agentes: *"Ojo, estoy asumiendo esto en la memoria ahora mismo"*.
582> 2. **La Decisión (Conocimiento Canónico):** `git-lore propose --kind decision` crea un "Átomo" real, un archivo JSON estructurado con un UUID que entra formalmente al ciclo de evaluación (Proposed / Accepted).
583> 3. **El Reemplazo:** La "asunción" inicial no se sobre-escribe mágicamente código sobre código. En cambio, cuando el agente termina su trabajo y formaliza la regla con `propose`, el servidor inscribe el Átomo permanente. En procesos de guardado posteriores, Git-Lore invoca una limpieza (`prune_stale_prism_signals`) evaporando las señales vencidas de la carpeta `.lore/signals/`. El conocimiento fugaz muere, y el canon estructurado prevalece inmutable.
584</instructions>
585"#;
586 let mut file = std::fs::File::create(&args.output).context("failed to create output file")?;
587 file.write_all(content.as_bytes()).context("failed to write content")?;
588
589 println!("Successfully generated Git-Lore skill at: {}", args.output.display());
590 Ok(())
591}
592
593fn init(args: InitArgs) -> Result<()> {
594 let workspace = Workspace::init(&args.path)
595 .with_context(|| format!("failed to initialize workspace at {}", args.path.display()))?;
596
597 println!(
598 "Initialized Git-Lore workspace at {}",
599 workspace.root().display()
600 );
601 Ok(())
602}
603
604fn mark(args: MarkArgs) -> Result<()> {
605 let workspace = Workspace::discover(".")?;
606 enforce_cli_write_guard(workspace.root(), "edit")?;
607
608 let atom = LoreAtom::new(
609 args.kind.into(),
610 AtomState::Proposed,
611 args.title,
612 args.body,
613 args.scope,
614 args.path,
615 )
616 .with_validation_script(args.validation_script);
617 let atom_id = atom.id.clone();
618
619 workspace.record_atom(atom)?;
620 println!("Recorded proposed lore atom {atom_id}");
621 Ok(())
622}
623
624fn status(args: StatusArgs) -> Result<()> {
625 let workspace = Workspace::discover(&args.path)?;
626 let state = workspace.load_state()?;
627 let report = workspace.entropy_report()?;
628
629 println!("Workspace: {}", workspace.root().display());
630 println!("Total atoms: {}", state.atoms.len());
631 println!("Entropy score: {}/100", report.score);
632
633 for atom in state.atoms.iter().rev().take(5) {
634 println!(
635 "- [{}] {:?} {:?}: {}",
636 atom.id, atom.kind, atom.state, atom.title
637 );
638 }
639
640 if report.contradictions.is_empty() {
641 println!("Contradictions: none");
642 } else {
643 println!("Contradictions:");
644 for contradiction in report.contradictions.iter().take(5) {
645 println!("- {:?} {}: {}", contradiction.kind, contradiction.key, contradiction.message);
646 }
647 }
648
649 if !report.notes.is_empty() {
650 println!("Entropy notes:");
651 for note in report.notes {
652 println!("- {note}");
653 }
654 }
655
656 Ok(())
657}
658
659fn checkpoint(args: CheckpointArgs) -> Result<()> {
660 let workspace = Workspace::discover(&args.path)?;
661 enforce_cli_write_guard(workspace.root(), "commit")?;
662
663 let checkpoint = workspace.write_checkpoint(args.message)?;
664 let subject = checkpoint
665 .message
666 .as_deref()
667 .unwrap_or("git-lore checkpoint");
668 let commit_message = git::build_commit_message(subject, &checkpoint.atoms);
669
670 if let Ok(repository) = git::discover_repository(&workspace.root()) {
671 println!("Git repository: {}", git::repository_root(&repository).display());
672 }
673
674 println!("Checkpoint {} written", checkpoint.id);
675 if !commit_message.is_empty() {
676 println!();
677 println!("{commit_message}");
678 }
679
680 Ok(())
681}
682
683fn commit(args: CommitArgs) -> Result<()> {
684 let workspace = Workspace::discover(&args.path)?;
685 enforce_cli_write_guard(workspace.root(), "commit")?;
686
687 let hash = run_lore_commit(&workspace, &args.message, args.allow_empty)?;
688
689 println!("Committed lore checkpoint {}", hash);
690 Ok(())
691}
692
693fn run_lore_commit(workspace: &Workspace, message: impl AsRef<str>, allow_empty: bool) -> Result<String> {
694 let repository_root = git::discover_repository(workspace.root())?;
695 let state = workspace.load_state()?;
696
697 let validation_issues = git::validate_workspace_against_git(&repository_root, workspace)?;
698 if !validation_issues.is_empty() {
699 anyhow::bail!(
700 "validation failed: {}",
701 validation_issues.join("; ")
702 );
703 }
704
705 let commit_message = git::build_commit_message(message, &state.atoms);
706
707 let hash = git::commit_lore_message(&repository_root, commit_message, allow_empty)?;
708 workspace.accept_active_atoms(Some(&hash))?;
709
710 for atom in state.atoms.iter().filter(|atom| atom.state != AtomState::Deprecated) {
711 git::write_lore_ref(&repository_root, atom, &hash)?;
712 }
713
714 Ok(hash)
715}
716
717fn session_start(args: SessionStartArgs) -> Result<()> {
718 let workspace = Workspace::discover(&args.workspace)?;
719 enforce_cli_signal_guard(workspace.root())?;
720
721 let pruned_stale = workspace.prune_stale_prism_signals(PRISM_STALE_TTL_SECONDS)?;
722 if pruned_stale > 0 {
723 println!("Pruned {pruned_stale} stale PRISM signal(s) before starting session");
724 }
725
726 let mut paths = args.paths;
727 if paths.is_empty() {
728 paths.push(".".to_string());
729 }
730
731 let signal = PrismSignal::new(
732 args.session_id.unwrap_or_else(|| Uuid::new_v4().to_string()),
733 args.agent,
734 args.scope,
735 paths,
736 args.assumptions,
737 args.decision,
738 );
739
740 workspace.write_prism_signal(&signal)?;
741 let conflicts = workspace.scan_prism_conflicts(&signal)?;
742
743 enforce_cli_write_guard(workspace.root(), "commit")?;
744 let checkpoint_message = args.checkpoint_message.unwrap_or_else(|| {
745 build_session_checkpoint_message(
746 "pre-write",
747 &signal.session_id,
748 signal.agent.as_deref(),
749 args.reason.as_deref(),
750 None,
751 )
752 });
753 let checkpoint = workspace.write_checkpoint(Some(checkpoint_message))?;
754
755 println!("Session started: {}", signal.session_id);
756 println!("Pre-write checkpoint: {}", checkpoint.id);
757
758 if conflicts.is_empty() {
759 println!("No soft-lock conflicts detected.");
760 } else {
761 println!("Soft-lock warnings:");
762 for conflict in conflicts {
763 let agent = conflict.agent.as_deref().unwrap_or("unknown-agent");
764 let scope = conflict.scope.as_deref().unwrap_or("unknown-scope");
765 let decision = conflict.decision.as_deref().unwrap_or("no decision recorded");
766 println!(
767 "- session {} ({agent}, {scope}) overlaps on {}: {decision}",
768 conflict.session_id,
769 conflict.overlapping_paths.join(", "),
770 );
771 }
772 }
773
774 println!(
775 "Next: run propose/mark updates, then session-finish with --session-id {}",
776 signal.session_id
777 );
778
779 Ok(())
780}
781
782fn session_finish(args: SessionFinishArgs) -> Result<()> {
783 let workspace = Workspace::discover(&args.workspace)?;
784 enforce_cli_write_guard(workspace.root(), "commit")?;
785
786 let signal_owner = workspace
787 .load_prism_signals()?
788 .into_iter()
789 .find(|signal| signal.session_id == args.session_id)
790 .and_then(|signal| signal.agent);
791
792 let hash = run_lore_commit(&workspace, &args.message, args.allow_empty)?;
793 let repository_root = git::discover_repository(workspace.root())?;
794
795 enforce_cli_write_guard(workspace.root(), "sync")?;
796 let atoms = git::sync_workspace_from_git_history(&repository_root, &workspace)?;
797
798 let checkpoint_owner = args.agent.or(signal_owner);
799 let post_checkpoint_message = args.checkpoint_message.unwrap_or_else(|| {
800 build_session_checkpoint_message(
801 "post-sync",
802 &args.session_id,
803 checkpoint_owner.as_deref(),
804 args.reason.as_deref(),
805 Some(&hash),
806 )
807 });
808 let checkpoint = workspace.write_checkpoint(Some(post_checkpoint_message))?;
809
810 let released = workspace.remove_prism_signal(&args.session_id)?;
811
812 println!("Committed lore checkpoint {}", hash);
813 println!("Synchronized {} lore atoms from Git history", atoms.len());
814 println!("Post-sync checkpoint: {}", checkpoint.id);
815 if released {
816 println!("Released PRISM signal {}", args.session_id);
817 } else {
818 println!("No PRISM signal found for session {}", args.session_id);
819 }
820
821 Ok(())
822}
823
824fn build_session_checkpoint_message(
825 stage: &str,
826 session_id: &str,
827 owner: Option<&str>,
828 reason: Option<&str>,
829 commit_sha: Option<&str>,
830) -> String {
831 let owner = owner
832 .map(str::trim)
833 .filter(|value| !value.is_empty())
834 .unwrap_or("unknown");
835
836 let mut parts = vec![
837 format!("owner={owner}"),
838 format!("session={session_id}"),
839 format!("stage={stage}"),
840 ];
841
842 if let Some(reason) = reason.map(str::trim).filter(|value| !value.is_empty()) {
843 parts.push(format!("reason={reason}"));
844 }
845
846 if let Some(commit_sha) = commit_sha
847 .map(str::trim)
848 .filter(|value| !value.is_empty())
849 {
850 parts.push(format!("commit={commit_sha}"));
851 }
852
853 parts.join("; ")
854}
855fn signal(args: SignalArgs) -> Result<()> {
856 let workspace = Workspace::discover(&args.workspace)?;
857
858 if args.release {
859 let session_id = args
860 .session_id
861 .as_deref()
862 .ok_or_else(|| anyhow::anyhow!("--session-id is required when using --release"))?;
863
864 let removed = workspace.remove_prism_signal(session_id)?;
865 if removed {
866 println!("Released PRISM signal {session_id}");
867 } else {
868 println!("No PRISM signal found for session {session_id}");
869 }
870 return Ok(());
871 }
872
873 if args.paths.is_empty() {
874 anyhow::bail!("at least one --path glob is required to broadcast a PRISM signal");
875 }
876
877 enforce_cli_signal_guard(workspace.root())?;
878
879 let pruned_stale = workspace.prune_stale_prism_signals(PRISM_STALE_TTL_SECONDS)?;
880 if pruned_stale > 0 {
881 println!("Pruned {pruned_stale} stale PRISM signal(s) before broadcasting");
882 }
883
884 let signal = PrismSignal::new(
885 args.session_id.unwrap_or_else(|| Uuid::new_v4().to_string()),
886 args.agent,
887 args.scope,
888 args.paths,
889 args.assumptions,
890 args.decision,
891 );
892
893 workspace.write_prism_signal(&signal)?;
894 let conflicts = workspace.scan_prism_conflicts(&signal)?;
895
896 println!("Broadcast PRISM signal {}", signal.session_id);
897
898 if conflicts.is_empty() {
899 println!("No soft-lock conflicts detected.");
900 return Ok(());
901 }
902
903 println!("Soft-lock warnings:");
904 for conflict in conflicts {
905 let agent = conflict.agent.as_deref().unwrap_or("unknown-agent");
906 let scope = conflict.scope.as_deref().unwrap_or("unknown-scope");
907 let decision = conflict.decision.as_deref().unwrap_or("no decision recorded");
908 println!(
909 "- session {} ({agent}, {scope}) overlaps on {}: {decision}",
910 conflict.session_id,
911 conflict.overlapping_paths.join(", "),
912 );
913 }
914
915 Ok(())
916}
917
918fn context(args: ContextArgs) -> Result<()> {
919 let service = McpService::new(&args.path);
920 let snapshot = service.context(&args.file, args.cursor_line)?;
921
922 println!("Workspace: {}", snapshot.workspace_root.display());
923 println!("File: {}", snapshot.file_path.display());
924
925 if let Some(scope) = snapshot.scope {
926 println!("Scope: {} {} ({}-{})", scope.kind_label(), scope.name, scope.start_line, scope.end_line);
927 }
928
929 if snapshot.constraints.is_empty() {
930 println!("No matching lore constraints.");
931 } else {
932 println!("Constraints:");
933 for constraint in snapshot.constraints {
934 println!("- {constraint}");
935 }
936 }
937
938 Ok(())
939}
940
941fn propose(args: ProposeArgs) -> Result<()> {
942 let service = McpService::new(&args.path);
943 enforce_cli_write_guard(&args.path, "edit")?;
944
945 let result = service.propose(ProposalRequest {
946 file_path: args.file,
947 cursor_line: args.cursor_line,
948 kind: args.kind.into(),
949 title: args.title,
950 body: args.body,
951 scope: None,
952 validation_script: args.validation_script,
953 })?;
954
955 println!("Proposed lore atom {}", result.atom.id);
956 if let Some(scope) = result.scope {
957 println!("Scope: {} {} ({}-{})", scope.kind_label(), scope.name, scope.start_line, scope.end_line);
958 }
959
960 Ok(())
961}
962
963fn mcp(args: McpArgs) -> Result<()> {
964 let server = crate::mcp::McpServer::new(&args.path);
965 server.run_stdio()
966}
967
968fn explain(args: ExplainArgs) -> Result<()> {
969 let service = McpService::new(&args.path);
970 let snapshot = service.context(&args.file, args.cursor_line)?;
971
972 println!("Workspace: {}", snapshot.workspace_root.display());
973 println!("File: {}", snapshot.file_path.display());
974
975 if let Some(scope) = snapshot.scope {
976 println!("Scope: {} {} ({}-{})", scope.kind_label(), scope.name, scope.start_line, scope.end_line);
977 }
978
979 if snapshot.historical_decisions.is_empty() {
980 println!("Historical decisions: none");
981 } else {
982 println!("Historical decisions:");
983 for decision in snapshot.historical_decisions {
984 println!("- {} {}", decision.commit_hash, decision.trailer_value);
985 }
986 }
987
988 if snapshot.constraints.is_empty() {
989 println!("No matching constraints.");
990 } else {
991 println!("Constraints:");
992 for constraint in snapshot.constraints {
993 println!("- {constraint}");
994 }
995 }
996
997 Ok(())
998}
999
1000fn validate(args: ValidateArgs) -> Result<()> {
1001 let workspace = Workspace::discover(&args.path)?;
1002 let repository_root = git::discover_repository(workspace.root())?;
1003 let issues = git::validate_workspace_against_git(&repository_root, &workspace)?;
1004
1005 if issues.is_empty() {
1006 println!("Validation passed");
1007 return Ok(());
1008 }
1009
1010 println!("Validation issues:");
1011 for issue in issues {
1012 println!("- {issue}");
1013 }
1014
1015 anyhow::bail!("validation failed");
1016}
1017
1018fn sync(args: SyncArgs) -> Result<()> {
1019 let workspace = Workspace::discover(&args.path)?;
1020 enforce_cli_write_guard(workspace.root(), "sync")?;
1021
1022 let repository_root = git::discover_repository(workspace.root())?;
1023 let atoms = git::sync_workspace_from_git_history(&repository_root, &workspace)?;
1024
1025 #[cfg(feature = "semantic-search")]
1026 {
1027 let state = workspace.load_state()?;
1028 let accepted = workspace.load_accepted_atoms()?;
1029 crate::mcp::semantic::rebuild_index(workspace.root(), &state.atoms, &accepted)?;
1030 println!("Rebuilt Memvid semantic local index.");
1031 }
1032
1033 println!("Synchronized {} lore atoms from Git history", atoms.len());
1034 Ok(())
1035}
1036
1037fn install(args: InstallArgs) -> Result<()> {
1038 let workspace = Workspace::discover(&args.path)?;
1039 let repository_root = git::discover_repository(workspace.root())?;
1040 git::install_git_lore_integration(&repository_root)?;
1041
1042 println!("Installed Git-Lore hooks and merge driver in {}", repository_root.display());
1043 Ok(())
1044}
1045
1046fn merge(args: MergeArgs) -> Result<()> {
1047 let base = read_workspace_state_file(&args.base)
1048 .with_context(|| format!("failed to read base merge file {}", args.base.display()))?;
1049 let current = read_workspace_state_file(&args.current)
1050 .with_context(|| format!("failed to read current merge file {}", args.current.display()))?;
1051 let other = read_workspace_state_file(&args.other)
1052 .with_context(|| format!("failed to read other merge file {}", args.other.display()))?;
1053
1054 let merged_version = base
1055 .state
1056 .version
1057 .max(current.state.version)
1058 .max(other.state.version);
1059 let outcome = crate::lore::merge::reconcile_lore(&base.state, ¤t.state, &other.state);
1060 let merged_state = WorkspaceState {
1061 version: merged_version,
1062 atoms: outcome.merged,
1063 };
1064
1065 let write_gzip = base.was_gzip || current.was_gzip || other.was_gzip;
1066 write_workspace_state_file(&args.current, &merged_state, write_gzip)
1067 .with_context(|| format!("failed to write merged lore file {}", args.current.display()))?;
1068
1069 if outcome.conflicts.is_empty() {
1070 println!("Merged lore state with {} atom(s)", merged_state.atoms.len());
1071 return Ok(());
1072 }
1073
1074 eprintln!(
1075 "Lore merge produced {} conflict(s); manual review required",
1076 outcome.conflicts.len()
1077 );
1078 for conflict in outcome.conflicts {
1079 eprintln!("- {:?} {}: {}", conflict.kind, conflict.key, conflict.message);
1080 }
1081
1082 anyhow::bail!("lore merge requires manual resolution")
1083}
1084
1085fn set_state(args: SetStateArgs) -> Result<()> {
1086 let workspace = Workspace::discover(&args.path)?;
1087 enforce_cli_write_guard(workspace.root(), "edit")?;
1088
1089 let actor = args.actor.or_else(|| std::env::var("USER").ok());
1090 let updated = workspace.transition_atom_state(
1091 &args.atom_id,
1092 args.state.into(),
1093 args.reason,
1094 actor,
1095 )?;
1096
1097 println!(
1098 "Transitioned lore atom {} to {:?}",
1099 updated.id, updated.state
1100 );
1101 Ok(())
1102}
1103
1104fn edit_atom(args: EditAtomArgs) -> Result<()> {
1105 let workspace = Workspace::discover(&args.path)?;
1106 enforce_cli_write_guard(workspace.root(), "edit")?;
1107
1108 if args.body.is_some() && args.clear_body {
1109 anyhow::bail!("cannot use --body and --clear-body together");
1110 }
1111 if args.scope.is_some() && args.clear_scope {
1112 anyhow::bail!("cannot use --scope and --clear-scope together");
1113 }
1114 if args.atom_path.is_some() && args.clear_atom_path {
1115 anyhow::bail!("cannot use --atom-path and --clear-atom-path together");
1116 }
1117 if args.validation_script.is_some() && args.clear_validation_script {
1118 anyhow::bail!("cannot use --validation-script and --clear-validation-script together");
1119 }
1120 if args.trace_commit_sha.is_some() && args.clear_trace_commit {
1121 anyhow::bail!("cannot use --trace-commit-sha and --clear-trace-commit together");
1122 }
1123
1124 let body_update = if args.clear_body {
1125 Some(None)
1126 } else {
1127 args.body.map(Some)
1128 };
1129 let scope_update = if args.clear_scope {
1130 Some(None)
1131 } else {
1132 args.scope.map(Some)
1133 };
1134 let path_update = if args.clear_atom_path {
1135 Some(None)
1136 } else {
1137 args.atom_path.map(Some)
1138 };
1139 let validation_script_update = if args.clear_validation_script {
1140 Some(None)
1141 } else {
1142 args.validation_script.map(Some)
1143 };
1144 let trace_commit_update = if args.clear_trace_commit {
1145 Some(None)
1146 } else {
1147 args.trace_commit_sha.map(Some)
1148 };
1149
1150 let actor = args.actor.or_else(|| std::env::var("USER").ok());
1151 let result = workspace.edit_atom(
1152 &args.atom_id,
1153 AtomEditRequest {
1154 kind: args.kind.map(Into::into),
1155 title: args.title,
1156 body: body_update,
1157 scope: scope_update,
1158 path: path_update,
1159 validation_script: validation_script_update,
1160 trace_commit_sha: trace_commit_update,
1161 },
1162 args.reason,
1163 actor,
1164 )?;
1165
1166 if result.changed_fields.is_empty() {
1167 println!("No changes applied to lore atom {}", result.atom.id);
1168 return Ok(());
1169 }
1170
1171 println!(
1172 "Edited lore atom {} in-place ({})",
1173 result.atom.id,
1174 result.changed_fields.join(", "),
1175 );
1176 if let Some(source_commit) = result.source_commit {
1177 println!("Trace commit: {source_commit}");
1178 }
1179
1180 Ok(())
1181}
1182
1183impl From<LoreKindArg> for LoreKind {
1184 fn from(value: LoreKindArg) -> Self {
1185 match value {
1186 LoreKindArg::Decision => LoreKind::Decision,
1187 LoreKindArg::Assumption => LoreKind::Assumption,
1188 LoreKindArg::OpenQuestion => LoreKind::OpenQuestion,
1189 LoreKindArg::Signal => LoreKind::Signal,
1190 }
1191 }
1192}
1193
1194impl From<AtomStateArg> for AtomState {
1195 fn from(value: AtomStateArg) -> Self {
1196 match value {
1197 AtomStateArg::Draft => AtomState::Draft,
1198 AtomStateArg::Proposed => AtomState::Proposed,
1199 AtomStateArg::Accepted => AtomState::Accepted,
1200 AtomStateArg::Deprecated => AtomState::Deprecated,
1201 }
1202 }
1203}
1204
1205#[derive(Clone, Debug)]
1206struct EncodedWorkspaceState {
1207 state: WorkspaceState,
1208 was_gzip: bool,
1209}
1210
1211fn read_workspace_state_file(path: &std::path::Path) -> Result<EncodedWorkspaceState> {
1212 let bytes = std::fs::read(path)
1213 .with_context(|| format!("failed to read lore state file {}", path.display()))?;
1214 let is_gzip = bytes.starts_with(&[0x1f, 0x8b]);
1215
1216 let content = if is_gzip {
1217 let mut decoder = GzDecoder::new(bytes.as_slice());
1218 let mut decoded = Vec::new();
1219 decoder
1220 .read_to_end(&mut decoded)
1221 .with_context(|| format!("failed to decompress lore state file {}", path.display()))?;
1222 decoded
1223 } else {
1224 bytes
1225 };
1226
1227 let state: WorkspaceState = serde_json::from_slice(&content)
1228 .with_context(|| format!("failed to parse lore state file {}", path.display()))?;
1229
1230 Ok(EncodedWorkspaceState {
1231 state,
1232 was_gzip: is_gzip,
1233 })
1234}
1235
1236fn write_workspace_state_file(
1237 path: &std::path::Path,
1238 state: &WorkspaceState,
1239 write_gzip: bool,
1240) -> Result<()> {
1241 let encoded = serde_json::to_vec_pretty(state)
1242 .with_context(|| format!("failed to encode merged lore state {}", path.display()))?;
1243
1244 let bytes = if write_gzip {
1245 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
1246 encoder
1247 .write_all(&encoded)
1248 .with_context(|| format!("failed to gzip lore state {}", path.display()))?;
1249 encoder
1250 .finish()
1251 .with_context(|| format!("failed to finalize gzip lore state {}", path.display()))?
1252 } else {
1253 encoded
1254 };
1255
1256 std::fs::write(path, bytes)
1257 .with_context(|| format!("failed to write lore state file {}", path.display()))?;
1258
1259 Ok(())
1260}
1261
1262fn enforce_cli_write_guard(path: impl AsRef<std::path::Path>, operation: &str) -> Result<()> {
1263 let service = McpService::new(path);
1264 let snapshot = service.state_snapshot()?;
1265 let report = service.memory_preflight(operation)?;
1266
1267 if snapshot.state_checksum != report.state_checksum {
1268 anyhow::bail!(
1269 "state-first guard failed: state drift detected during preflight (snapshot {}, preflight {})",
1270 snapshot.state_checksum,
1271 report.state_checksum
1272 );
1273 }
1274
1275 if report
1276 .issues
1277 .iter()
1278 .any(|issue| issue.severity == PreflightSeverity::Block)
1279 {
1280 println!("Preflight issues:");
1281 for issue in report.issues {
1282 println!("- {:?} {}: {}", issue.severity, issue.code, issue.message);
1283 }
1284 anyhow::bail!(
1285 "state-first preflight blocked {} operation; resolve issues and retry",
1286 operation
1287 );
1288 }
1289
1290 for issue in report
1291 .issues
1292 .iter()
1293 .filter(|issue| issue.severity != PreflightSeverity::Info)
1294 {
1295 println!("Preflight {:?} {}: {}", issue.severity, issue.code, issue.message);
1296 }
1297
1298 Ok(())
1299}
1300
1301fn enforce_cli_signal_guard(path: impl AsRef<std::path::Path>) -> Result<()> {
1302 let service = McpService::new(path);
1303 let snapshot = service.state_snapshot()?;
1304 let report = service.memory_preflight("edit")?;
1305
1306 if snapshot.state_checksum != report.state_checksum {
1307 anyhow::bail!(
1308 "state-first guard failed: state drift detected during preflight (snapshot {}, preflight {})",
1309 snapshot.state_checksum,
1310 report.state_checksum
1311 );
1312 }
1313
1314 let blocking_issues = report
1315 .issues
1316 .iter()
1317 .filter(|issue| issue.severity == PreflightSeverity::Block)
1318 .filter(|issue| issue.code != "prism_hard_lock")
1319 .collect::<Vec<_>>();
1320
1321 if !blocking_issues.is_empty() {
1322 println!("Preflight issues:");
1323 for issue in report.issues {
1324 println!("- {:?} {}: {}", issue.severity, issue.code, issue.message);
1325 }
1326 anyhow::bail!(
1327 "state-first preflight blocked signal operation; resolve issues and retry"
1328 );
1329 }
1330
1331 for issue in report
1332 .issues
1333 .iter()
1334 .filter(|issue| issue.code != "prism_hard_lock")
1335 .filter(|issue| issue.severity != PreflightSeverity::Info)
1336 {
1337 println!("Preflight {:?} {}: {}", issue.severity, issue.code, issue.message);
1338 }
1339
1340 Ok(())
1341}
1342
1343fn resolve(args: ResolveArgs) -> Result<()> {
1344 let workspace = Workspace::discover(&args.path)?;
1345 enforce_cli_write_guard(workspace.root(), "edit")?;
1346
1347 let state = workspace.load_state()?;
1348 let target_location = args.location.clone();
1349
1350 let candidate_atoms: Vec<LoreAtom> = state.atoms.into_iter()
1351 .filter(|atom| {
1352 let p = atom.path.as_ref().map(|v| v.to_string_lossy().replace('\\', "/")).unwrap_or_else(|| "<no-path>".to_string());
1353 let s = atom.scope.as_deref().unwrap_or("<no-scope>");
1354 format!("{}::{}", p, s) == target_location && atom.state != AtomState::Deprecated
1355 })
1356 .collect();
1357
1358 if candidate_atoms.is_empty() {
1359 println!("No active contradictions found at location {target_location}");
1360 return Ok(());
1361 }
1362
1363 let winner_id = if let Some(id) = args.winner_id {
1364 if !candidate_atoms.iter().any(|a| a.id == id) {
1365 anyhow::bail!("Atom ID {} not found among active atoms at location {}", id, target_location);
1366 }
1367 id
1368 } else {
1369 println!("Found {} active atoms at {}:", candidate_atoms.len(), target_location);
1370 for (i, atom) in candidate_atoms.iter().enumerate() {
1371 println!("[{}] {} ({:?}) - {}", i, atom.id, atom.state, atom.title);
1372 }
1373
1374 let idx = loop {
1375 print!("Select the winning atom index (0-{}): ", candidate_atoms.len() - 1);
1376 std::io::stdout().flush()?;
1377 let mut input = String::new();
1378 std::io::stdin().read_line(&mut input)?;
1379 if let Ok(idx) = input.trim().parse::<usize>() {
1380 if idx < candidate_atoms.len() {
1381 break idx;
1382 }
1383 }
1384 println!("Invalid selection.");
1385 };
1386 candidate_atoms[idx].id.clone()
1387 };
1388
1389 let actor = std::env::var("USER").ok();
1390
1391 for atom in candidate_atoms {
1392 if atom.id != winner_id {
1393 println!("Deprecating lost atom {}...", atom.id);
1395 let res = workspace.transition_atom_state(
1396 &atom.id,
1397 AtomState::Deprecated,
1398 args.reason.clone().unwrap_or_else(|| "Resolved via CLI".to_string()),
1399 actor.clone(),
1400 );
1401 if let Err(e) = res {
1402 println!("Warning: failed to deprecate {}: {}", atom.id, e);
1403 }
1404 }
1405 }
1406
1407 let winner_atom = workspace.load_state()?.atoms.into_iter().find(|a| a.id == winner_id).unwrap();
1409 if winner_atom.state != AtomState::Accepted {
1410 println!("Accepting winning atom {}...", winner_id);
1411 let res = workspace.transition_atom_state(
1412 &winner_id,
1413 AtomState::Accepted,
1414 args.reason.clone().unwrap_or_else(|| "Resolved via CLI".to_string()),
1415 actor.clone(),
1416 );
1417 if let Err(e) = res {
1418 println!("Warning: failed to accept winner {}: {}", winner_id, e);
1419 }
1420 }
1421
1422 println!("Conflict at {} resolved successfully. [{}] is the winner.", target_location, winner_id);
1423 Ok(())
1424}
1425
1426#[cfg(test)]
1427mod tests {
1428 use super::build_session_checkpoint_message;
1429
1430 #[test]
1431 fn session_checkpoint_message_includes_required_fields() {
1432 let message = build_session_checkpoint_message(
1433 "pre-write",
1434 "session-123",
1435 Some("agent-x"),
1436 Some("capture evidence"),
1437 None,
1438 );
1439
1440 assert!(message.contains("owner=agent-x"));
1441 assert!(message.contains("session=session-123"));
1442 assert!(message.contains("stage=pre-write"));
1443 assert!(message.contains("reason=capture evidence"));
1444 assert!(!message.contains("commit="));
1445 }
1446
1447 #[test]
1448 fn session_checkpoint_message_includes_commit_when_present() {
1449 let message = build_session_checkpoint_message(
1450 "post-sync",
1451 "session-123",
1452 None,
1453 None,
1454 Some("abc123"),
1455 );
1456
1457 assert!(message.contains("owner=unknown"));
1458 assert!(message.contains("stage=post-sync"));
1459 assert!(message.contains("commit=abc123"));
1460 }
1461}