From b1c9d590c8f8283299b01dca7aeab04c5d8f9775 Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Thu, 24 Sep 2026 17:01:00 -0700 Subject: [PATCH] memoryd: the edit journal, undo of the latest edit, and review Implemented-By: OpenCode session (model recorded in docs/implementer-log.md) --- crates/memoryd/src/journal.rs | 232 +++++++++++++++++++++++++++++ crates/memoryd/src/lib.rs | 2 + crates/memoryd/src/review.rs | 252 ++++++++++++++++++++++++++++++++ crates/memoryd/tests/journal.rs | 188 ++++++++++++++++++++++++ docs/implementer-log.md | 1 + 5 files changed, 675 insertions(+) create mode 100644 crates/memoryd/src/journal.rs create mode 100644 crates/memoryd/src/review.rs create mode 100644 crates/memoryd/tests/journal.rs diff --git a/crates/memoryd/src/journal.rs b/crates/memoryd/src/journal.rs new file mode 100644 index 0000000..38ac944 --- /dev/null +++ b/crates/memoryd/src/journal.rs @@ -0,0 +1,232 @@ +//! The edit journal: one JSON line per applied edit in `memory/edits/YYYY-MM.jsonl`, the owner's +//! record of every write, and `undo` of the latest edit of a file (M5a spec, section 5). + +use serde::{Deserialize, Serialize}; + +use proto::{MemoryFile, Timestamp}; + +use crate::edit::{Context, Outcome}; +use crate::files::Store; + +/// One line of the journal. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Record { + /// `YYYY-MM-DD-NNNN`, numbered from 1 each day. + pub id: String, + pub time: String, + pub session: String, + pub call: u64, + /// `add`, `replace`, `remove`, `consolidate`, `note`, or `undo`. + pub action: String, + /// The identity file changed, if any. + pub file: Option, + /// Where the write went: `memory.md`, `user.md` or `notes.md`. + pub to: String, + pub before: Vec, + pub after: Vec, + /// The `notes.md` entries appended, as written. + pub notes: Vec, + pub untrusted: bool, + /// For an undo, the edit it reverted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub undoes: Option, +} + +fn file_of(name: &str) -> Option { + match name { + "memory.md" => Some(MemoryFile::Memory), + "user.md" => Some(MemoryFile::User), + _ => None, + } +} + +/// Every record, oldest first, from every month's file. +pub fn records(store: &Store) -> Result, String> { + // Read the directory `store.path("edits")`: NotFound -> Ok(empty); another error -> + // Err(format!("{}: {e}", dir.display())). Take the names that end ".jsonl" and sort them. For + // each file, `read_to_string` and parse every line as a Record with serde_json; a bad line -> + // Err(format!("{}: line {}: {e}", path.display(), n + 1)) with n counted from 0. Return all + // records, oldest first. + let dir = store.path("edits"); + let mut paths: Vec = match std::fs::read_dir(&dir) { + Ok(rd) => rd + .filter_map(|entry| entry.ok().map(|e| e.path())) + .collect(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(format!("{}: {e}", dir.display())), + }; + paths.retain(|p| { + p.file_name() + .and_then(|s| s.to_str()) + .is_some_and(|n| n.ends_with(".jsonl")) + }); + paths.sort(); + let mut out: Vec = Vec::new(); + for path in paths { + let text = + std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; + for (n, line) in text.lines().enumerate() { + let record: Record = serde_json::from_str(line) + .map_err(|e| format!("{}: line {}: {e}", path.display(), n + 1))?; + out.push(record); + } + } + Ok(out) +} + +/// Append a record, giving it the next id of its day. +pub(crate) fn append(store: &Store, mut record: Record, now: Timestamp) -> Result { + // 1. `day` is the first 10 characters of `now.to_rfc3339()` ("0000-00-00" if shorter). + // 2. `taken` = how many of `records(store)?` have an id starting format!("{day}-"). + // 3. record.id = format!("{day}-{:04}", taken + 1). + // 4. `create_dir_all(store.path("edits"))` (an error names the directory), + // `serde_json::to_string(&record)`, then `store.append(&format!("edits/{month}.jsonl"), + // &format!("{line}\n"))?` where month is the first 7 characters of day. Ok(record.id). + let rfc = now.to_rfc3339(); + let day = if rfc.len() >= 10 { + &rfc[..10] + } else { + "0000-00-00" + }; + let taken = records(store)? + .iter() + .filter(|r| r.id.starts_with(&format!("{day}-"))) + .count(); + record.id = format!("{day}-{:04}", taken + 1); + let edits_dir = store.path("edits"); + std::fs::create_dir_all(&edits_dir).map_err(|e| format!("{}: {e}", edits_dir.display()))?; + let line = serde_json::to_string(&record).map_err(|e| e.to_string())?; + let month = &day[..7]; + store.append(&format!("edits/{month}.jsonl"), &format!("{line}\n"))?; + Ok(record.id) +} + +/// Journal an applied call; the id it was given. +pub fn record(store: &Store, ctx: &Context, outcome: &Outcome) -> Result { + // `append` a Record: id empty (append sets it), time `ctx.now.to_rfc3339()`, session and call + // from ctx, action `outcome.action`, file `change.file` as its name, to that name or "notes.md" + // when there is no file, before and after from the change, notes the change's notes each + // `render()`ed, untrusted `ctx.untrusted`, undoes None; `now` is ctx.now. + let change = &outcome.change; + let notes: Vec = change.notes.iter().map(|n| n.render()).collect(); + let file = change.file.map(|f| f.name().to_string()); + let to = match &file { + Some(name) => name.clone(), + None => "notes.md".to_string(), + }; + append( + store, + Record { + id: String::new(), + time: ctx.now.to_rfc3339(), + session: ctx.session.clone(), + call: ctx.call, + action: outcome.action.to_string(), + file, + to, + before: change.before.clone(), + after: change.after.clone(), + notes, + untrusted: ctx.untrusted, + undoes: None, + }, + ctx.now, + ) +} + +/// The last `count` records, one line each, oldest first. +pub fn log(store: &Store, count: usize) -> Result { + // The last `count` of `records(store)?` (all of them when fewer), oldest first, one line each: + // format!("{} {} {} {} {}{}", id, time, session, action, to, and " untrusted" when the + // record is untrusted), joined by newlines. With no records at all: "no memory edits yet". + let all = records(store)?; + if all.is_empty() { + return Ok("no memory edits yet".to_string()); + } + let selected = &all[all.len().saturating_sub(count)..]; + let lines: Vec = selected + .iter() + .map(|r| { + if r.untrusted { + format!( + "{} {} {} {} {} untrusted", + r.id, r.time, r.session, r.action, r.to + ) + } else { + format!( + "{} {} {} {} {}", + r.id, r.time, r.session, r.action, r.to + ) + } + }) + .collect(); + Ok(lines.join("\n")) +} + +/// Revert edit `id`, if it is the latest edit of its identity file and the file still holds +/// exactly what it left. Notes it appended stay (the file is append-only). The undo is journalled. +pub fn undo(store: &Store, id: &str, now: Timestamp) -> Result { + // 1. Find the record with this id in `records(store)?`, else Err(format!("no edit {id}")). + // 2. Its file must be Some and known to `file_of`, else Err(format!("{id} changed no identity + // file; notes are append-only")). + // 3. The latest record whose file is the same name must be this one, else Err(format!("{id} is + // not the latest edit of {name}")). + // 4. `store.entries(file)?` must equal record.after, else Err(format!("{name} has changed since + // {id}; nothing was undone")). + // 5. `store.write_entries(file, &record.before)?`, then `append` a Record: session "owner", + // call 0, action "undo", file and to the name, before the current entries, after + // record.before, no notes, untrusted false, undoes Some(id). + // 6. Ok(format!("undone: {id} ({name} restored; journalled as {undo_id})")). + let all = records(store)?; + let record = all + .iter() + .find(|r| r.id == id) + .ok_or_else(|| format!("no edit {id}"))?; + let name: &str = match &record.file { + Some(n) => n, + None => { + return Err(format!( + "{id} changed no identity file; notes are append-only" + )); + } + }; + let file = match file_of(name) { + Some(f) => f, + None => { + return Err(format!( + "{id} changed no identity file; notes are append-only" + )); + } + }; + let latest = all.iter().rev().find(|r| r.file.as_deref() == Some(name)); + if latest.is_none_or(|r| r.id != id) { + return Err(format!("{id} is not the latest edit of {name}")); + } + let current = store.entries(file)?; + if current != record.after { + return Err(format!("{name} has changed since {id}; nothing was undone")); + } + store.write_entries(file, &record.before)?; + let undo_id = append( + store, + Record { + id: String::new(), + time: now.to_rfc3339(), + session: "owner".to_string(), + call: 0, + action: "undo".to_string(), + file: Some(name.to_string()), + to: name.to_string(), + before: current, + after: record.before.clone(), + notes: Vec::new(), + untrusted: false, + undoes: Some(id.to_string()), + }, + now, + )?; + Ok(format!( + "undone: {id} ({name} restored; journalled as {undo_id})" + )) +} diff --git a/crates/memoryd/src/lib.rs b/crates/memoryd/src/lib.rs index 488da96..f72ab85 100644 --- a/crates/memoryd/src/lib.rs +++ b/crates/memoryd/src/lib.rs @@ -5,5 +5,7 @@ pub mod config; pub mod edit; pub mod entries; pub mod files; +pub mod journal; pub mod notes; +pub mod review; pub mod socket; diff --git a/crates/memoryd/src/review.rs b/crates/memoryd/src/review.rs new file mode 100644 index 0000000..82c5a12 --- /dev/null +++ b/crates/memoryd/src/review.rs @@ -0,0 +1,252 @@ +//! Review (off by default, `[memory] review = true`): identity writes wait in +//! `memory/pending.jsonl` for the owner's `accept` or `reject` instead of being applied (M5a spec, +//! section 5). Notes are never staged. + +use serde::{Deserialize, Serialize}; + +use proto::escape::escape_model_text; +use proto::{DataClass, MemoryArgs, Timestamp}; + +use crate::edit::{Context, apply}; +use crate::files::Store; +use crate::journal::{self, Record}; + +/// One line of `pending.jsonl`: a write waiting, or what became of one. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Line { + Waiting { + id: String, + time: String, + session: String, + call: u64, + taint: DataClass, + /// The call's arguments, as `brokerd` sent them. + arguments: String, + }, + Accepted { + id: String, + time: String, + edit: String, + }, + Rejected { + id: String, + time: String, + }, +} + +fn lines(store: &Store) -> Result, String> { + // Read `store.path("pending.jsonl")`: NotFound -> Ok(empty); another error -> Err(format!("{}: + // {e}", path.display())). Parse every line as a Line with serde_json; a bad one -> + // Err(format!("{}: line {}: {e}", path.display(), n + 1)). + let path = store.path("pending.jsonl"); + let text = match std::fs::read_to_string(&path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(format!("{}: {e}", path.display())), + }; + let mut out: Vec = Vec::new(); + for (n, line) in text.lines().enumerate() { + let entry: Line = serde_json::from_str(line) + .map_err(|e| format!("{}: line {}: {e}", path.display(), n + 1))?; + out.push(entry); + } + Ok(out) +} + +fn add(store: &Store, line: &Line) -> Result<(), String> { + // `serde_json::to_string(line)` (its error as a String), then `store.append("pending.jsonl", + // &format!("{text}\n"))`. + let text = serde_json::to_string(line).map_err(|e| e.to_string())?; + store.append("pending.jsonl", &format!("{text}\n")) +} + +/// The writes still waiting: `(id, session, arguments, taint, call)`. +fn waiting(store: &Store) -> Result, String> { + // The ids that have an Accepted or a Rejected line are done. Return a clone of every Waiting + // line whose id is not done, in file order. + let all = lines(store)?; + let done: Vec = all + .iter() + .filter_map(|line| match line { + Line::Accepted { id, .. } | Line::Rejected { id, .. } => Some(id.clone()), + Line::Waiting { .. } => None, + }) + .collect(); + Ok(all + .iter() + .filter(|line| match line { + Line::Waiting { id, .. } => !done.iter().any(|d| d == id), + Line::Accepted { .. } | Line::Rejected { .. } => false, + }) + .cloned() + .collect()) +} + +/// Stage an identity write. Its shape is checked now; its content when it is accepted. +pub fn submit( + store: &Store, + args: &MemoryArgs, + arguments: &str, + ctx: &Context, +) -> Result { + // 1. `args.check()?`. + // 2. `day` is the first 10 characters of `ctx.now.to_rfc3339()`. `taken` = how many Waiting + // lines have an id starting format!("p-{day}-"). The id is format!("p-{day}-{:04}", taken + + // 1). + // 3. `add` a Waiting line: id, time `ctx.now.to_rfc3339()`, session, call and taint from ctx, + // arguments as given. + // 4. Ok(format!("waiting for the owner's review ({id}); nothing is changed until it is + // accepted")). + args.check()?; + let rfc = ctx.now.to_rfc3339(); + let day = &rfc[..10]; + let taken = waiting(store)? + .iter() + .filter(|line| match line { + Line::Waiting { id, .. } => id.starts_with(&format!("p-{day}-")), + _ => false, + }) + .count(); + let id = format!("p-{day}-{:04}", taken + 1); + add( + store, + &Line::Waiting { + id: id.clone(), + time: rfc, + session: ctx.session.clone(), + call: ctx.call, + taint: ctx.taint, + arguments: arguments.to_string(), + }, + )?; + Ok(format!( + "waiting for the owner's review ({id}); nothing is changed until it is accepted" + )) +} + +/// The writes waiting, one per line, arguments escaped. +pub fn list(store: &Store) -> Result { + // One line per `waiting` entry: format!("{id} {session} {}", escape_model_text(arguments)), + // joined by newlines; with none, "nothing is waiting for review". + let waits = waiting(store)?; + let lines: Vec = waits + .iter() + .filter_map(|l| match l { + Line::Waiting { + id, + session, + arguments, + .. + } => Some(format!("{id} {session} {}", escape_model_text(arguments))), + Line::Accepted { .. } | Line::Rejected { .. } => None, + }) + .collect(); + if lines.is_empty() { + Ok("nothing is waiting for review".to_string()) + } else { + Ok(lines.join("\n")) + } +} + +/// Apply a waiting write, with the same checks, against the file as it is now, and journal it. +pub fn accept(store: &Store, id: &str, now: Timestamp) -> Result { + // 1. The Waiting line with this id among `waiting(store)?`, else Err(format!("nothing waiting + // as {id}")). + // 2. Parse its arguments as MemoryArgs (the error as a String). + // 3. A Context from its session, call and taint, untrusted false, now. + // 4. `apply(store, &args, &ctx)?`, then `journal::record(store, &ctx, &outcome)?` gives the + // edit id. + // 5. `add` an Accepted line { id, time now, edit }. + // 6. Ok(format!("accepted: {id} as {edit}: {}", outcome.answer)). + let waiting_lines = waiting(store)?; + let found = waiting_lines + .iter() + .find(|l| match l { + Line::Waiting { id: l_id, .. } => l_id == id, + Line::Accepted { .. } | Line::Rejected { .. } => false, + }) + .ok_or_else(|| format!("nothing waiting as {id}"))?; + let (args, session, call, taint) = match found { + Line::Waiting { + arguments, + session, + call, + taint, + .. + } => { + let args: MemoryArgs = serde_json::from_str(arguments).map_err(|e| e.to_string())?; + (args, session.clone(), *call, *taint) + } + Line::Accepted { .. } | Line::Rejected { .. } => { + return Err(format!("nothing waiting as {id}")); + } + }; + let ctx = Context { + session, + call, + taint, + untrusted: false, + now, + }; + let outcome = apply(store, &args, &ctx)?; + let edit = journal::record(store, &ctx, &outcome)?; + add( + store, + &Line::Accepted { + id: id.to_string(), + time: now.to_rfc3339(), + edit: edit.clone(), + }, + )?; + Ok(format!("accepted: {id} as {edit}: {}", outcome.answer)) +} + +/// Discard a waiting write; the journal records it. +pub fn reject(store: &Store, id: &str, now: Timestamp) -> Result { + // 1. The Waiting line with this id, else Err(format!("nothing waiting as {id}")). + // 2. `add` a Rejected line { id, time now }. + // 3. `journal::append` a Record: its session and call, action "reject", file None, to empty, no + // entries, no notes, untrusted false, undoes Some(id). + // 4. Ok(format!("rejected: {id}")). + let waiting_lines = waiting(store)?; + let found = waiting_lines + .iter() + .find(|l| match l { + Line::Waiting { id: l_id, .. } => l_id == id, + Line::Accepted { .. } | Line::Rejected { .. } => false, + }) + .ok_or_else(|| format!("nothing waiting as {id}"))?; + let (session, call) = match found { + Line::Waiting { session, call, .. } => (session.clone(), *call), + Line::Accepted { .. } | Line::Rejected { .. } => { + return Err(format!("nothing waiting as {id}")); + } + }; + add( + store, + &Line::Rejected { + id: id.to_string(), + time: now.to_rfc3339(), + }, + )?; + journal::append( + store, + Record { + id: String::new(), + time: now.to_rfc3339(), + session, + call, + action: "reject".to_string(), + file: None, + to: String::new(), + before: Vec::new(), + after: Vec::new(), + notes: Vec::new(), + untrusted: false, + undoes: Some(id.to_string()), + }, + now, + )?; + Ok(format!("rejected: {id}")) +} diff --git a/crates/memoryd/tests/journal.rs b/crates/memoryd/tests/journal.rs new file mode 100644 index 0000000..8233c02 --- /dev/null +++ b/crates/memoryd/tests/journal.rs @@ -0,0 +1,188 @@ +//! The edit journal, `log`, `undo`, and review (M5a spec, section 5). Do not edit. + +#[path = "support/tmp.rs"] +mod tmp; + +use memoryd::edit::{Context, apply}; +use memoryd::files::Store; +use memoryd::{journal, review}; +use proto::{DataClass, MemoryArgs, MemoryFile, Timestamp}; +use tmp::TempDir; + +fn now() -> Timestamp { + Timestamp::parse("2026-09-24T20:14:37.123Z").unwrap() +} + +fn setup(tag: &str) -> (TempDir, Store) { + let dir = TempDir::new(tag); + let starter = dir.write("etc/soul.md", "Soul.\n"); + let store = Store::new(&dir.path().join("memory")); + store.init(&starter).unwrap(); + (dir, store) +} + +fn ctx(untrusted: bool) -> Context { + Context { + session: "mm-s1".to_string(), + call: 7, + taint: DataClass::Private, + untrusted, + now: now(), + } +} + +/// Apply and journal, as `memoryd` does for every write. +fn write(store: &Store, json: &str, untrusted: bool) -> String { + let args: MemoryArgs = serde_json::from_str(json).unwrap(); + let c = ctx(untrusted); + let outcome = apply(store, &args, &c).unwrap(); + journal::record(store, &c, &outcome).unwrap() +} + +#[test] +fn every_write_is_journalled_with_a_daily_id() { + let (dir, store) = setup("journal-ids"); + assert_eq!( + write( + &store, + r#"{"action":"add","file":"memory","text":"a"}"#, + false + ), + "2026-09-24-0001" + ); + assert_eq!( + write(&store, r#"{"action":"note","text":"seen"}"#, true), + "2026-09-24-0002" + ); + let records = journal::records(&store).unwrap(); + assert_eq!(records.len(), 2); + assert_eq!( + ( + records[0].action.as_str(), + records[0].to.as_str(), + records[0].before.clone(), + records[0].after.clone() + ), + ("add", "memory.md", vec![], vec!["a".to_string()]) + ); + assert_eq!( + (records[1].to.as_str(), records[1].untrusted), + ("notes.md", true) + ); + assert!(records[1].notes[0].starts_with("§ noted 2026-09-24T20:14Z in session mm-s1")); + assert!(dir.path().join("memory/edits/2026-09.jsonl").exists()); + assert_eq!( + journal::log(&store, 10).unwrap(), + "2026-09-24-0001 2026-09-24T20:14:37.123Z mm-s1 add memory.md\n2026-09-24-0002 2026-09-24T20:14:37.123Z mm-s1 note notes.md untrusted" + ); + assert_eq!(journal::log(&store, 1).unwrap().lines().count(), 1); +} + +#[test] +fn undo_reverts_the_latest_edit_of_a_file_only() { + let (_dir, store) = setup("journal-undo"); + let first = write( + &store, + r#"{"action":"add","file":"memory","text":"a"}"#, + false, + ); + let second = write( + &store, + r#"{"action":"add","file":"memory","text":"b"}"#, + false, + ); + let note = write(&store, r#"{"action":"note","text":"n"}"#, false); + assert_eq!( + journal::undo(&store, &first, now()), + Err(format!("{first} is not the latest edit of memory.md")) + ); + assert_eq!( + journal::undo(&store, ¬e, now()), + Err(format!( + "{note} changed no identity file; notes are append-only" + )) + ); + assert_eq!( + journal::undo(&store, "2026-01-01-0001", now()), + Err("no edit 2026-01-01-0001".to_string()) + ); + let done = journal::undo(&store, &second, now()).unwrap(); + assert!( + done.starts_with(&format!("undone: {second} (memory.md restored")), + "{done}" + ); + assert_eq!(store.entries(MemoryFile::Memory).unwrap(), ["a"]); + let last = journal::records(&store).unwrap().pop().unwrap(); + assert_eq!( + (last.action.as_str(), last.undoes.as_deref()), + ("undo", Some(second.as_str())) + ); +} + +#[test] +fn undo_refuses_when_the_file_changed_since() { + let (dir, store) = setup("journal-changed"); + let id = write( + &store, + r#"{"action":"add","file":"user","text":"a"}"#, + false, + ); + std::fs::write(dir.path().join("memory/user.md"), "edited by hand\n").unwrap(); + assert_eq!( + journal::undo(&store, &id, now()), + Err(format!( + "user.md has changed since {id}; nothing was undone" + )) + ); + assert_eq!(store.entries(MemoryFile::User).unwrap(), ["edited by hand"]); +} + +#[test] +fn review_stages_accepts_and_rejects() { + let (_dir, store) = setup("review"); + let json = r#"{"action":"add","file":"memory","text":"a"}"#; + let args: MemoryArgs = serde_json::from_str(json).unwrap(); + let answer = review::submit(&store, &args, json, &ctx(false)).unwrap(); + assert_eq!( + answer, + "waiting for the owner's review (p-2026-09-24-0001); nothing is changed until it is accepted" + ); + let other = r#"{"action":"add","file":"memory","text":"b"}"#; + let args: MemoryArgs = serde_json::from_str(other).unwrap(); + review::submit(&store, &args, other, &ctx(false)).unwrap(); + assert_eq!( + store.entries(MemoryFile::Memory).unwrap(), + Vec::::new() + ); + let listed = review::list(&store).unwrap(); + assert_eq!(listed.lines().count(), 2); + assert!( + listed.starts_with("p-2026-09-24-0001 mm-s1 {"), + "{listed}" + ); + let accepted = review::accept(&store, "p-2026-09-24-0001", now()).unwrap(); + assert!( + accepted.starts_with("accepted: p-2026-09-24-0001 as 2026-09-24-0001: memory.md updated"), + "{accepted}" + ); + assert_eq!(store.entries(MemoryFile::Memory).unwrap(), ["a"]); + assert_eq!( + review::reject(&store, "p-2026-09-24-0002", now()), + Ok("rejected: p-2026-09-24-0002".to_string()) + ); + assert_eq!( + review::list(&store).unwrap(), + "nothing is waiting for review" + ); + assert_eq!( + review::accept(&store, "p-2026-09-24-0002", now()), + Err("nothing waiting as p-2026-09-24-0002".to_string()) + ); + let actions: Vec = journal::records(&store) + .unwrap() + .into_iter() + .map(|r| r.action) + .collect(); + assert_eq!(actions, ["add", "reject"]); + assert_eq!(store.entries(MemoryFile::Memory).unwrap(), ["a"]); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 90a5c6c..27b5cdc 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M5a/07-memoryd-journal | 2026-09-24 | done | 2 | fail | none | Copied the skeletons `crates/memoryd/src/journal.rs` and `crates/memoryd/src/review.rs` and the test `crates/memoryd/tests/journal.rs` into place, and added `pub mod journal;` after `pub mod files;` and `pub mod review;` after `pub mod notes;` in `lib.rs`. Filled `journal.rs`: `records` (read_dir the `edits` directory, NotFound -> empty, other error by path, keep the `.jsonl` names, sort, read each and parse every line as a Record with a bad line reported `path: line N: e`); `append` (day = first 10 chars of `to_rfc3339()` or "0000-00-00", `taken` = records whose id starts `"{day}-"`, id `"{day}-{:04}"`, `create_dir_all` the edits dir, `serde_json::to_string`, append to `edits/{month}.jsonl`); `record` (append a Record with id empty, the time, session/call from ctx, action, file as its name or None, `to` that name or "notes.md", before/after, each note `render()`ed, untrusted, undoes None); `log` (last `count` of records, each line `id time session action to` plus " untrusted" when untrusted, "no memory edits yet" when empty); `undo` (find by id, file must be Some and known to `file_of`, the latest record of that file must be this one, `entries` must equal `after`, write `before`, append an undo record with session "owner"). Filled `review.rs`: `lines` (read `pending.jsonl`, NotFound -> empty, parse each line); `add` (to_string, append); `waiting` (ids with an Accepted or Rejected line are done, return the not-done Waiting lines in file order); `submit` (check, the daily id `p-{day}-{:04}`, add a Waiting line); `list` (one line per waiting with `escape_model_text` on the arguments, "nothing is waiting for review"); `accept` (find the Waiting line, parse its arguments, a Context from its session/call/taint with untrusted false, apply, `journal::record`, add an Accepted line); `reject` (find the Waiting line, add a Rejected line, `journal::append` a reject Record with its session/call). Removed the skeleton's unused `use crate::notes::Note;` import. `cargo fmt --all`; the journal test went 3 pass/1 fail then 4 passed; `make gate` printed `gate: ok` on the second run -- the first failed on clippy `double_ended_iterator_last` (fixed with `.rev().find()`) and `unnecessary_map_or` (fixed with `is_none_or`). No new dependency, so `Cargo.lock` is unchanged. | ? | | M5a/06-memoryd-edits | 2026-09-24 | done | 3 | fail | The task pointed at `docs/plans/M5a/06-memoryd-edits/files/` which does not exist; the four files were copied from `docs/plans/M5a/files/` instead. | Filled `notes.rs` first: `minute` (first 16 chars of `to_rfc3339()` plus "Z"), `header` (Noted = "§ noted {time} in session {session} · {class}" with " · untrusted" appended when untrusted; Moved/Dropped = "§ {moved|dropped} {time} from {file} in session {session}"), `render` (header, text, newline), `parse_header` (strip "§ ", split word then time, then "noted" parses session/class/[untrusted] on " · " with the empty-and-space session rules, "moved"/"dropped" parses file via `file_of` then "in session {session}", anything else None) and `parse` (a header starts a note with empty text, other lines append to the note above, text trimmed at the end). Then `edit.rs`: `noted` (checked text, a Noted note labelled by the session's taint and untrusted flag, appended to notes.md, answer "noted" or "noted (untrusted)"), `consolidate` (walk the lines, each old matched by `position` and each place named once or "twice", Keep/rename to after, Move/Drop to notes, New checked, a missing name reported), `edited` (add/replace/remove/consolidate with the duplicate, stale and not-an-identity-edit refusals, the Note arm returning an error the comment says `apply` never reaches), and `apply` (check, take the file, route an untrusted Add to `noted` with SAVED_AS_NOTE and every other untrusted action to `untrusted_refusal`, then before/after via `edited`, the duplicate check, the limit, write then append the moved notes). Added `pub mod edit;` after `pub mod config;` and `pub mod notes;` after `pub mod files;` in `lib.rs`. `cargo fmt --all`; `notes` 3 and `edit` 8 passed on the third run; `make gate` printed `gate: ok` — the first run failed on rustfmt import order, the second on clippy `manual_contains` (fixed by `before.contains(&text)`). No new dependency, so `Cargo.lock` is unchanged. | ? | | M5a/05-memoryd-files | 2026-09-24 | done | 2 | fail | none | Copied the skeleton `crates/memoryd/src/files.rs` and the test `crates/memoryd/tests/files.rs` into place, and added `pub mod files;` after `pub mod entries;` in `lib.rs`. Filled the seven functions the skeleton left as `todo!()`: `grouped` (walk the digits' chars with their index, before each char where i > 0 and `(len - i)` is a multiple of 3 push a comma), `init` (recursive `DirBuilder` at 0700 then `set_permissions` to 0700, then `soul.md` from the starter when missing, then each of memory/user/notes empty when missing, nothing existing changed), `create` (`create_new` at 0600 so an existing file errors and is never overwritten), `entries` (read through `fail`, then `entries::parse`), `write_entries` (remove any stale temp, `create` the rendered list at 0600, then rename over the file), `append` (append+create at 0600, one write), and `block` (soul read and trimmed, over SOUL_LIMIT errors, the two identity files each: over limit errors, entries checked with `check` and the refused ones pushed onto `left_out`, the size counting the whole file). `cargo fmt --all`; `files` 8 passed; `make gate` printed `gate: ok` on the second run — the first failed on clippy `useless_format`, fixed by using `.to_string()` for the empty-soul line. No new dependency, so `Cargo.lock` is unchanged. | ? | | M5a/04-memoryd-entries-check | 2026-09-24 | done | 1 | pass | none | Copied the skeleton `crates/memoryd/src/entries.rs`, the complete `check.rs`, and the tests `entries.rs` and `check.rs` into place. Added `pub mod check;` before `pub mod config;` and `pub mod entries;` after it in `lib.rs`, so the modules now list `check`, `config`, `entries`, `socket`. Filled the four functions of `entries.rs`: `parse` (walk `text.lines()`, a line whose trim() equals SEPARATOR ends an entry through `push` then clears `current`, any other line is pushed onto `current`, and `push` runs once more after the last line), `push` (join the lines with "\n", trim(), push as a String only when non-empty), `render` ("" for an empty list, otherwise the entries joined by "\n§\n" with a trailing newline), and `size` (render without its final newline, counted in chars via `chars().count()`). No new dependency, so `Cargo.lock` is unchanged. `cargo fmt --all`; `entries` 3 passed and `check` 6 passed on the first run; `make gate` prints `gate: ok` on the first run. | ? |