diff --git a/runtime/crates/muse-context/src/consolidation.rs b/runtime/crates/muse-context/src/consolidation.rs index a39719da..5f0d0efd 100644 --- a/runtime/crates/muse-context/src/consolidation.rs +++ b/runtime/crates/muse-context/src/consolidation.rs @@ -80,7 +80,11 @@ someone else's. from the transcript so future-you can trust the entry. 2. **Peers** — for anyone in the transcript you learned something about, update their file under `peers/` (the `## Summary` section - is yours to rewrite; `## Notes` is append-only). + is yours to rewrite; `## Notes` is append-only). A new peer gets a + new file `peers/.md` with the same front-matter shape + as its neighbours; leave `peer_id` out — the daemon mints one when + it next loads the workspace, and from then on your tools resolve + that peer by any identifier you listed. 3. **Blocks** — update files under `blocks/` only when something in the transcript genuinely changes who you are, what you're doing, or who matters (the pruning test: would future-you be worse off diff --git a/runtime/crates/muse-identity/src/workspace/mod.rs b/runtime/crates/muse-identity/src/workspace/mod.rs index c32a4ad1..b230c480 100644 --- a/runtime/crates/muse-identity/src/workspace/mod.rs +++ b/runtime/crates/muse-identity/src/workspace/mod.rs @@ -34,7 +34,7 @@ use std::path::{Path, PathBuf}; use az::SaturatingAs; use chrono::Utc; use rusqlite::{Connection, params}; -use tracing::warn; +use tracing::{info, warn}; use crate::IdentityError; use crate::archival::ArchivalEntry; @@ -548,9 +548,24 @@ impl Workspace { continue; } let raw = std::fs::read_to_string(&path)?; - match peer_file::parse(&raw) { - Ok(peer) => { + match peer_file::parse_adopting(&raw) { + Ok((peer, minted)) => { let relpath = PathBuf::from(PEERS).join(path.file_name().unwrap_or_default()); + if minted { + // Adopt a consolidation-born peer: pin the + // minted id into the existing front-matter by + // inserting one line — never re-render, so + // whatever sleep-Lumen wrote outside the + // Summary/Notes sections survives byte-for-byte. + let healed = + raw.replacen("---\n", &format!("---\npeer_id: {}\n", peer.id), 1); + std::fs::write(&path, healed)?; + self.commit( + &relpath, + &format!("workspace: adopt peer {} (minted peer_id)", peer.id), + ); + info!(file = %path.display(), peer_id = %peer.id, "workspace: adopted peer file without peer_id"); + } for ident in &peer.identities { self.by_identifier.insert(ident.id.clone(), peer.id.clone()); } @@ -806,6 +821,51 @@ mod tests { assert!(!series.contains("unrelated note")); } + #[test] + fn consolidation_born_peer_file_without_peer_id_is_adopted_on_open() { + // Lumen's #71: sleep-her writes peer files by hand with no + // UUID source. The loader must adopt them — mint an id, pin + // it into the file byte-preservingly, and index every + // identifier — instead of skipping the peer. + let dir = tempfile::tempdir().expect("tempdir"); + let ws = Workspace::open(dir.path()).expect("open"); + drop(ws); + let path = dir.path().join(PEERS).join("lunanova-love-bsky-social.md"); + let raw = "---\nnickname: Luna\nidentity: bluesky_handle:lunanova-love.bsky.social\nidentity: bluesky_did:did:plc:qwneutex4skl6dfgmhtfytn6\ncreated_at: 2026-08-18T00:00:00Z\n---\n## Summary\nLuna — AI, posts in Korean.\n\n## Notes\n- 2026-08-18: first sighting\n\n## Threads\nsomething sleep-me invented\n"; + std::fs::write(&path, raw).expect("write"); + + let ws = Workspace::open(dir.path()).expect("reopen"); + let by_did = ws + .lookup_peer_by_identifier("did:plc:qwneutex4skl6dfgmhtfytn6") + .expect("adopted peer resolves by did"); + let by_handle = ws + .lookup_peer_by_identifier("lunanova-love.bsky.social") + .expect("adopted peer resolves by handle"); + assert_eq!(by_did.id, by_handle.id); + assert_eq!(by_did.name.as_deref(), Some("Luna")); + assert_eq!(ws.lookup_peers_by_query("luna", 5).len(), 1); + + // The file gained exactly one line and kept everything else. + let healed = std::fs::read_to_string(&path).expect("read"); + assert!( + healed.starts_with(&format!("---\npeer_id: {}\n", by_did.id)), + "{healed}" + ); + assert_eq!( + healed.replacen(&format!("peer_id: {}\n", by_did.id), "", 1), + raw + ); + + // Stable across reopen: the minted id is now the file's id. + let again = Workspace::open(dir.path()).expect("reopen 2"); + assert_eq!( + again + .lookup_peer_by_identifier("lunanova-love.bsky.social") + .map(|p| p.id), + Some(by_did.id) + ); + } + #[test] fn index_rebuild_reproduces_search_results() { let (mut ws, _tmp) = ws(); diff --git a/runtime/crates/muse-identity/src/workspace/peer_file.rs b/runtime/crates/muse-identity/src/workspace/peer_file.rs index 88d9a5a4..72c8c84f 100644 --- a/runtime/crates/muse-identity/src/workspace/peer_file.rs +++ b/runtime/crates/muse-identity/src/workspace/peer_file.rs @@ -111,8 +111,30 @@ pub fn render(peer: &Peer) -> String { out } -/// Parse a peer file back into a [`Peer`]. +/// Parse a peer file back into a [`Peer`]. Strict: a file without +/// `peer_id` is a format error (see [`parse_adopting`] for the +/// loader's lenient path). pub fn parse(raw: &str) -> Result { + let (peer, minted) = parse_adopting(raw)?; + if minted { + return Err(WorkspaceError::Format { + what: "peer file: missing peer_id".to_string(), + }); + } + Ok(peer) +} + +/// Parse a peer file, minting a fresh `peer_id` when the front-matter +/// has none. Returns `(peer, minted)`; a minted id is only stable if +/// the caller writes it back (see `Workspace::load_peers`). +/// +/// Why lenient: consolidation sessions write peer files by hand and +/// have no UUID source, so a peer born in sleep arrives without an +/// id. The strict parser skipped those files entirely, making every +/// such peer invisible to the tool surface while the handbook called +/// the workspace her memory (Lumen's #71: Luna, met 08-18, unfindable +/// 08-28). +pub fn parse_adopting(raw: &str) -> Result<(Peer, bool), WorkspaceError> { let (fields, body) = super::front_matter(raw).ok_or_else(|| WorkspaceError::Format { what: "peer file: missing front-matter".to_string(), })?; @@ -122,11 +144,10 @@ pub fn parse(raw: &str) -> Result { .find(|(k, _)| k == key) .map(|(_, v)| v.as_str()) }; - let id = get("peer_id") - .ok_or_else(|| WorkspaceError::Format { - what: "peer file: missing peer_id".to_string(), - })? - .to_string(); + let (id, minted) = get("peer_id").map_or_else( + || (uuid::Uuid::new_v4().to_string(), true), + |v| (v.to_string(), false), + ); let name = get("nickname").map(ToString::to_string); let mut identities = Vec::new(); for (k, v) in &fields { @@ -168,14 +189,17 @@ pub fn parse(raw: &str) -> Result { _ => None, }; - Ok(Peer { - id, - name, - identities, - summary, - notes, - created_at, - }) + Ok(( + Peer { + id, + name, + identities, + summary, + notes, + created_at, + }, + minted, + )) } /// Split the body into (summary, notes) by the two known headers.