Skip to main content

caboodle/agent_memory/
lifecycle.rs

1use anyhow::{bail, Context, Result};
2use fs2::FileExt;
3use serde::{Deserialize, Serialize};
4use serde_json::{json, Value};
5use sha2::{Digest, Sha256};
6use std::{
7    fs,
8    io::Write,
9    path::{Path, PathBuf},
10    process::Command,
11};
12
13use super::BUNDLE_NAME;
14
15pub trait Runner {
16    fn run(&mut self, program: &str, args: &[String]) -> Result<String>;
17    fn input(&mut self, _program: &str, _args: &[String], _body: &str) -> Result<String> {
18        bail!("runner cannot exchange stdio metadata");
19    }
20    fn report(&mut self, program: &str, args: &[String]) -> Result<String> {
21        self.run(program, args)
22    }
23}
24pub struct System;
25impl Runner for System {
26    fn run(&mut self, program: &str, args: &[String]) -> Result<String> {
27        let out = Command::new(program)
28            .args(args)
29            .output()
30            .with_context(|| format!("start {program}"))?;
31        if !out.status.success() {
32            bail!(
33                "{program} refused or failed (exit {:?}); no success claimed",
34                out.status.code()
35            );
36        }
37        String::from_utf8(out.stdout).context("command returned non-UTF8 output")
38    }
39    fn input(&mut self, program: &str, args: &[String], body: &str) -> Result<String> {
40        use std::{
41            io::Read,
42            process::Stdio,
43            time::{Duration, Instant},
44        };
45        let mut child = Command::new(program)
46            .args(args)
47            .stdin(Stdio::piped())
48            .stdout(Stdio::piped())
49            .stderr(Stdio::null())
50            .spawn()?;
51        if let Err(error) = child
52            .stdin
53            .take()
54            .context("no metadata stdin")?
55            .write_all(body.as_bytes())
56        {
57            let _ = child.kill();
58            let _ = child.wait();
59            return Err(error.into());
60        }
61        let stdout = child.stdout.take().context("no metadata stdout")?;
62        let reader = std::thread::spawn(move || {
63            let mut out = Vec::new();
64            stdout
65                .take(4 * 1024 * 1024 + 1)
66                .read_to_end(&mut out)
67                .map(|_| out)
68        });
69        let deadline = Instant::now() + Duration::from_secs(10);
70        let status = loop {
71            match child.try_wait() {
72                Ok(Some(status)) => break status,
73                Ok(None) if Instant::now() < deadline => {
74                    std::thread::sleep(Duration::from_millis(20))
75                }
76                _ => {
77                    let _ = child.kill();
78                    let _ = child.wait();
79                    let _ = reader.join();
80                    bail!("stdio metadata probe failed or exceeded deadline");
81                }
82            }
83        };
84        let out = reader
85            .join()
86            .map_err(|_| anyhow::anyhow!("metadata reader failed"))??;
87        if !status.success() || out.len() > 4 * 1024 * 1024 {
88            bail!("stdio metadata probe refused or exceeded output bound");
89        }
90        String::from_utf8(out).context("metadata response is not UTF8")
91    }
92    fn report(&mut self, program: &str, args: &[String]) -> Result<String> {
93        let out = Command::new(program)
94            .args(args)
95            .output()
96            .context("read owner report")?;
97        if !matches!(out.status.code(), Some(0..=2)) {
98            bail!("owner check could not run");
99        }
100        String::from_utf8(out.stdout).context("owner returned non-UTF8 report")
101    }
102}
103
104#[derive(Clone, Serialize, Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct Change {
107    pub path: PathBuf,
108    pub before: Option<String>,
109    pub after: String,
110}
111#[derive(Clone, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct Plan {
114    pub schema: u32,
115    pub root: PathBuf,
116    pub source_revision: String,
117    pub redirect_mode: super::RedirectMode,
118    pub harness_configs: Vec<PathBuf>,
119    pub skill_directories: Vec<PathBuf>,
120    pub changes: Vec<Change>,
121    pub bundles: Vec<Value>,
122    pub expected_tools: Value,
123    pub id: String,
124}
125
126pub fn digest(bytes: &[u8]) -> String {
127    format!("{:x}", Sha256::digest(bytes))
128}
129fn plan_id(p: &Plan) -> Result<String> {
130    let mut clone = p.clone();
131    clone.id.clear();
132    Ok(digest(&serde_json::to_vec(&clone)?))
133}
134fn read(path: &Path) -> Result<Option<String>> {
135    match fs::symlink_metadata(path) {
136        Ok(m) if !m.is_file() || m.file_type().is_symlink() => bail!(
137            "configuration destination must be a regular file: {}",
138            path.display()
139        ),
140        Ok(m) if m.len() > 4 * 1024 * 1024 => bail!("configuration exceeds 4MiB bound"),
141        Ok(_) => Ok(Some(
142            fs::read_to_string(path).context("read configuration")?,
143        )),
144        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
145        Err(e) => Err(e).context("inspect configuration destination"),
146    }
147}
148fn absolute(path: &Path) -> Result<PathBuf> {
149    let full = if path.is_absolute() {
150        path.to_path_buf()
151    } else {
152        std::env::current_dir()?.join(path)
153    };
154    if full
155        .components()
156        .any(|c| matches!(c, std::path::Component::ParentDir))
157    {
158        bail!("paths must not contain parent traversals");
159    }
160    if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink()) {
161        bail!("refusing symlink destination");
162    }
163    let mut ancestor = full.clone();
164    let mut tail = Vec::new();
165    while fs::symlink_metadata(&ancestor).is_err() {
166        tail.push(
167            ancestor
168                .file_name()
169                .context("path has no existing ancestor")?
170                .to_os_string(),
171        );
172        ancestor = ancestor
173            .parent()
174            .context("path has no existing ancestor")?
175            .to_path_buf();
176    }
177    let mut canonical = ancestor.canonicalize()?;
178    for part in tail.into_iter().rev() {
179        canonical.push(part);
180    }
181    Ok(canonical)
182}
183pub fn private_write(path: &Path, body: &[u8]) -> Result<()> {
184    use std::os::unix::fs::PermissionsExt;
185    let parent = path.parent().context("file has no parent")?;
186    fs::create_dir_all(parent)?;
187    if fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) {
188        bail!("refusing symlink destination");
189    }
190    let mut file = tempfile::NamedTempFile::new_in(parent)?;
191    let mode = fs::metadata(path)
192        .map(|m| m.permissions().mode() & 0o777)
193        .unwrap_or(0o600);
194    file.as_file()
195        .set_permissions(fs::Permissions::from_mode(mode))?;
196    file.write_all(body)?;
197    file.as_file().sync_all()?;
198    file.persist(path).map_err(|e| e.error)?;
199    fs::File::open(parent)?.sync_all()?;
200    Ok(())
201}
202
203pub struct Create<'a> {
204    pub root: &'a Path,
205    pub bobbin: &'a Path,
206    pub person: &'a str,
207    pub output: &'a Path,
208    pub source: &'a Path,
209    pub claude_home: &'a Path,
210    pub codex_home: &'a Path,
211    pub issuer_name: Option<&'a str>,
212    pub redirect_mode: super::RedirectMode,
213}
214pub fn create(options: Create<'_>, runner: &mut impl Runner) -> Result<()> {
215    let Create {
216        root,
217        bobbin,
218        person,
219        output,
220        source,
221        claude_home,
222        codex_home,
223        issuer_name,
224        redirect_mode,
225    } = options;
226    use std::os::unix::fs::DirBuilderExt;
227    let output = absolute(output)?;
228    if output.exists() {
229        bail!("plan output already exists; retain it for review/resume");
230    }
231    let root = absolute(root)?;
232    let harness_configs = vec![
233        absolute(&claude_home.join("settings.json"))?,
234        absolute(&codex_home.join("config.toml"))?,
235    ];
236    check_existing_redirect(&harness_configs)?;
237    let mut skill_directories = Vec::new();
238    let mut changes = Vec::new();
239    let crew_path = root.join("shantytown.toml");
240    let bobbin = absolute(bobbin)?;
241    let crew_before = read(&crew_path)?;
242    let bobbin_before = read(&bobbin)?;
243    let (mut crew, config) = super::render_with_mode(
244        crew_before.as_deref().unwrap_or(""),
245        bobbin_before.as_deref().unwrap_or(""),
246        person,
247        redirect_mode,
248    )?;
249    let capture_path = output.join("capture.json");
250    let mut document: toml_edit::DocumentMut = crew.parse()?;
251    document["env"]["CAMAYOC_CAPTURE_CONFIG"] =
252        toml_edit::value(capture_path.display().to_string());
253    crew = document.to_string();
254    let mut issuers = serde_json::Map::new();
255    if let Some(name) = issuer_name {
256        if name.trim().is_empty() || name.len() > 128 || name.chars().any(char::is_control) {
257            bail!("invalid issuer name");
258        }
259        issuers.insert(name.to_string(), json!(person));
260    }
261    changes.push(Change {
262        path: capture_path,
263        before: None,
264        after: serde_json::to_string_pretty(&json!({"prompt_author":person,"issuers":issuers}))?,
265    });
266    changes.push(Change {
267        path: crew_path,
268        before: crew_before,
269        after: crew,
270    });
271    changes.push(Change {
272        path: bobbin,
273        before: bobbin_before,
274        after: config,
275    });
276    // Read producer-generated surfaces from one exact immutable Git tree, not
277    // from mutable working-tree files or a separately maintained Tambo template.
278    let source = absolute(source)?;
279    let revision = runner
280        .run(
281            "git",
282            &[
283                "-C".into(),
284                source.display().to_string(),
285                "rev-parse".into(),
286                "HEAD".into(),
287            ],
288        )?
289        .trim()
290        .to_string();
291    if revision.len() != 40 || !revision.bytes().all(|b| b.is_ascii_hexdigit()) {
292        bail!("source has no full Git revision");
293    }
294    for (relative, destination) in [
295        (
296            "skills/camayoc-memory/SKILL.md",
297            claude_home.join("skills/camayoc-memory/SKILL.md"),
298        ),
299        (
300            ".agents/skills/camayoc-memory/SKILL.md",
301            codex_home.join("skills/camayoc-memory/SKILL.md"),
302        ),
303    ] {
304        let after = runner.run(
305            "git",
306            &[
307                "-C".into(),
308                source.display().to_string(),
309                "show".into(),
310                format!("{revision}:{relative}"),
311            ],
312        )?;
313        if after.len() > 1024 * 1024 || !after.contains("cy record") {
314            bail!("source does not contain the generated memory skill");
315        }
316        let path = absolute(&destination)?;
317        skill_directories.push(
318            path.parent()
319                .context("skill has no directory")?
320                .to_path_buf(),
321        );
322        changes.push(Change {
323            before: read(&path)?,
324            path,
325            after,
326        });
327    }
328    let catalogue = runner.run(
329        "git",
330        &[
331            "-C".into(),
332            source.display().to_string(),
333            "show".into(),
334            format!("{revision}:camayoc_cli/data/mcp-tools.json"),
335        ],
336    )?;
337    if catalogue.len() > 4 * 1024 * 1024 {
338        bail!("producer tool catalogue exceeds bound");
339    }
340    let catalogue: Value = serde_json::from_str(&catalogue)?;
341    if catalogue["schema"] != "camayoc-mcp-tools/1"
342        || catalogue["server"] != "camayoc-memory"
343        || !catalogue["tools"].as_array().is_some_and(|t| !t.is_empty())
344    {
345        bail!("unsupported producer catalogue");
346    }
347    let mut paths = std::collections::BTreeSet::new();
348    for change in &changes {
349        if !paths.insert(&change.path) {
350            bail!("memory destinations overlap");
351        }
352    }
353    let candidate = runner
354        .run("bobbin", &["hooks".into(), "bundle".into()])
355        .ok();
356    let bobbin_bundle =
357        crate::tool_bundles::resolve_with(&crate::hook_bundles::selected(["bobbin"]), |_| {
358            candidate.clone()
359        });
360    let mut plan = Plan {
361        schema: 1,
362        root,
363        source_revision: revision,
364        redirect_mode,
365        harness_configs,
366        skill_directories,
367        changes,
368        expected_tools: catalogue["tools"].clone(),
369        bundles: vec![
370            serde_json::from_str(&bobbin_bundle[0].1)?,
371            super::hooks::bundle(&output, redirect_mode),
372        ],
373        id: String::new(),
374    };
375    plan.id = plan_id(&plan)?;
376    fs::DirBuilder::new()
377        .recursive(true)
378        .mode(0o700)
379        .create(&output)?;
380    private_write(
381        &output.join("plan.json"),
382        &serde_json::to_vec_pretty(&plan)?,
383    )?;
384    for (i, bundle) in plan.bundles.iter().enumerate() {
385        private_write(
386            &output.join(format!("bundle-{i}.json")),
387            &serde_json::to_vec_pretty(bundle)?,
388        )?;
389    }
390    Ok(())
391}
392
393pub fn load(directory: &Path) -> Result<Plan> {
394    use std::os::unix::fs::{MetadataExt, PermissionsExt};
395    let path = directory.join("plan.json");
396    let meta = fs::symlink_metadata(&path)?;
397    let dir = fs::symlink_metadata(directory)?;
398    if meta.file_type().is_symlink()
399        || !meta.is_file()
400        || meta.len() > 16 * 1024 * 1024
401        || meta.permissions().mode() & 0o077 != 0
402        || dir.file_type().is_symlink()
403        || !dir.is_dir()
404        || dir.permissions().mode() & 0o077 != 0
405        || dir.uid() != meta.uid()
406    {
407        bail!("memory plan must be private regular data in its private directory");
408    }
409    let plan: Plan = serde_json::from_slice(&fs::read(path)?)?;
410    if plan.schema != 1 || plan.id != plan_id(&plan)? {
411        bail!("memory plan digest/schema does not match; regenerate for review");
412    }
413    Ok(plan)
414}
415fn st(plan: &Plan, args: &[&str], runner: &mut impl Runner) -> Result<String> {
416    let mut argv = vec!["--root".into(), plan.root.display().to_string()];
417    argv.extend(args.iter().map(|s| (*s).to_string()));
418    runner.run("st", &argv)
419}
420fn existing(plan: &Plan, runner: &mut impl Runner) -> Result<Vec<Value>> {
421    let v: Value = serde_json::from_str(&st(plan, &["ops", "hooks", "list", "--json"], runner)?)?;
422    if !v["errors"].as_array().is_some_and(Vec::is_empty) {
423        bail!("settings-owner registry unreadable");
424    }
425    Ok(v["bundles"]
426        .as_array()
427        .context("owner returned no bundles")?
428        .clone())
429}
430fn normalized(bundle: &Value) -> Value {
431    json!({"name":bundle["name"],"version":bundle["version"],"owner":bundle["owner"],"roles":bundle["roles"],
432        "hooks":bundle["hooks"].as_array().map(|rows| rows.iter().map(|r| json!({
433            "event":r["event"],"command":r["command"],"matcher":r["matcher"],"timeout":r["timeout"],"harnesses":r.get("harnesses").cloned().unwrap_or_else(|| json!(["claude","codex","opencode"]))})).collect::<Vec<_>>())})
434}
435fn journal(directory: &Path, phase: &str) -> Result<()> {
436    private_write(
437        &directory.join("state.json"),
438        &serde_json::to_vec(&json!({"schema":1,"phase":phase,
439        "at":std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs_f64()}))?,
440    )
441}
442fn lock(directory: &Path) -> Result<fs::File> {
443    use std::os::unix::fs::OpenOptionsExt;
444    let path = directory.join("apply.lock");
445    if fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_symlink() || !m.is_file()) {
446        bail!("invalid memory lifecycle lock");
447    }
448    let file = fs::OpenOptions::new()
449        .read(true)
450        .write(true)
451        .create(true)
452        .truncate(false)
453        .mode(0o600)
454        .open(path)?;
455    file.try_lock_exclusive()
456        .context("another memory lifecycle operation is running")?;
457    Ok(file)
458}
459pub fn check_tools(plan: &Plan, runner: &mut impl Runner) -> Result<()> {
460    let requests=concat!("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\"clientInfo\":{\"name\":\"tambo-memory-verify\",\"version\":\"1\"}}}\n",
461        "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n",
462        "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n");
463    let response = runner.input("cy", &["mcp".into()], requests)?;
464    let rows: Vec<Value> = response
465        .lines()
466        .filter(|l| !l.trim().is_empty())
467        .map(serde_json::from_str)
468        .collect::<std::result::Result<_, _>>()?;
469    let init = rows
470        .iter()
471        .find(|r| r["id"] == 1)
472        .context("MCP initialization response missing")?;
473    let list = rows
474        .iter()
475        .find(|r| r["id"] == 2)
476        .context("MCP tools response missing")?;
477    if init["result"]["serverInfo"]["name"] != "camayoc-memory"
478        || list["result"]["tools"] != plan.expected_tools
479    {
480        bail!("installed Camayoc tool schemas differ from reviewed producer revision");
481    }
482    Ok(())
483}
484fn exact_bundles(plan: &Plan, directory: &Path, current: &[Value], prior: &[Value]) -> Result<()> {
485    for (i, wanted) in plan.bundles.iter().enumerate() {
486        let name = wanted["name"].as_str().context("bundle name missing")?;
487        let old = prior.iter().find(|r| r["name"] == name);
488        if let Some(old) = old {
489            let backup: Value = serde_json::from_str(
490                &read(&directory.join(format!("prior-{i}.json")))?
491                    .context("prior bundle backup missing")?,
492            )?;
493            if &backup != old {
494                bail!("prior bundle backup changed; preserve it for inspection");
495            }
496        }
497        if current.iter().any(|r| r["name"] == name) {
498            let raw: Value = serde_json::from_str(
499                &read(&plan.root.join("hook-bundles").join(format!("{name}.json")))?
500                    .context("owner bundle bytes unavailable")?,
501            )?;
502            if raw != *wanted && old != Some(&raw) {
503                bail!("registered bundle bytes changed; preserve operator edits");
504            }
505        }
506    }
507    Ok(())
508}
509pub fn apply(directory: &Path, runner: &mut impl Runner) -> Result<()> {
510    let plan = load(directory)?;
511    let _lock = lock(directory)?;
512    check_existing_redirect(&plan.harness_configs)?;
513    let saved = directory.join("registry-before.json");
514    let current = existing(&plan, runner)?;
515    if !saved.exists() {
516        if current.iter().any(|r| r["name"] == BUNDLE_NAME) {
517            bail!("memory bundle already owned by another plan; restore it first");
518        }
519        let mut snapshot = current.clone();
520        for (i, wanted) in plan.bundles.iter().enumerate() {
521            if let Some(prior) = current.iter().find(|r| r["name"] == wanted["name"]) {
522                let name = prior["name"]
523                    .as_str()
524                    .context("owner bundle name missing")?;
525                if !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
526                    bail!("invalid bundle name");
527                }
528                let raw = read(&plan.root.join("hook-bundles").join(format!("{name}.json")))?
529                    .context("exact prior bundle unavailable; no lossless rollback")?;
530                let value: Value = serde_json::from_str(&raw)?;
531                if normalized(&value) != normalized(prior) {
532                    bail!("owner registry changed during snapshot");
533                }
534                if let Some(slot) = snapshot.iter_mut().find(|r| r["name"] == wanted["name"]) {
535                    *slot = value;
536                }
537                private_write(&directory.join(format!("prior-{i}.json")), raw.as_bytes())?;
538            }
539        }
540        private_write(&saved, &serde_json::to_vec(&snapshot)?)?;
541    }
542    let prior: Vec<Value> = serde_json::from_slice(&fs::read(&saved)?)?;
543    for (i, wanted) in plan.bundles.iter().enumerate() {
544        let path = directory.join(format!("bundle-{i}.json"));
545        if serde_json::from_slice::<Value>(&fs::read(&path)?)? != *wanted {
546            bail!("staged bundle differs from reviewed plan");
547        }
548        if let Some(now) = current.iter().find(|r| r["name"] == wanted["name"]) {
549            if normalized(now) != normalized(wanted)
550                && prior
551                    .iter()
552                    .find(|r| r["name"] == wanted["name"])
553                    .map(normalized)
554                    .as_ref()
555                    != Some(&normalized(now))
556            {
557                bail!("registered bundle changed since plan application");
558            }
559        }
560    }
561    exact_bundles(&plan, directory, &current, &prior)?;
562    // Every target is checked before the first write, including on resume.
563    for c in &plan.changes {
564        let now = read(&c.path)?;
565        if now != c.before && now.as_deref() != Some(c.after.as_str()) {
566            bail!(
567                "configuration changed since plan review: {}",
568                c.path.display()
569            );
570        }
571    }
572    for args in [
573        vec!["--version"],
574        vec!["record", "decision", "--help"],
575        vec!["record", "directive", "--help"],
576        vec!["record", "preference", "--help"],
577        vec!["record", "handoff", "--help"],
578        vec!["record", "reference", "--help"],
579        vec!["hook", "memory-redirect", "--help"],
580        vec!["flush", "--help"],
581    ] {
582        runner.run("cy", &args.iter().map(|s| (*s).to_string()).collect::<Vec<_>>())
583            .context("install a current Camayoc CLI with its generated memory tools before applying this plan")?;
584    }
585    let help = runner.run("cy", &["flush".into(), "--help".into()])?;
586    if !help.contains("--hook") {
587        bail!("Camayoc lacks bounded hook replay; update it before applying");
588    }
589    runner.run(
590        "cy",
591        &["hook".into(), "session-end".into(), "--help".into()],
592    )?;
593    runner.run(
594        "bobbin",
595        &["hook".into(), "memory-refresh".into(), "--help".into()],
596    )?;
597    check_tools(&plan, runner)?;
598    let created = directory.join("created-skill-directories.json");
599    let restored = read(&directory.join("state.json"))?
600        .and_then(|text| serde_json::from_str::<Value>(&text).ok())
601        .is_some_and(|state| state["phase"] == "restored");
602    if !created.exists() || restored {
603        let missing: Vec<_> = plan
604            .skill_directories
605            .iter()
606            .filter(|p| !p.exists())
607            .collect();
608        private_write(&created, &serde_json::to_vec(&missing)?)?;
609    }
610    journal(directory, "applying")?;
611    for c in &plan.changes {
612        if read(&c.path)?.as_deref() != Some(c.after.as_str()) {
613            private_write(&c.path, c.after.as_bytes())?;
614        }
615    }
616    for (i, bundle) in plan.bundles.iter().enumerate() {
617        let path = directory.join(format!("bundle-{i}.json"));
618        let _ = bundle;
619        st(
620            &plan,
621            &[
622                "ops",
623                "hooks",
624                "register",
625                path.to_str().context("non-UTF8 plan path")?,
626            ],
627            runner,
628        )?;
629    }
630    let receipt: Value =
631        serde_json::from_str(&st(&plan, &["ops", "provision", "--json"], runner)?)?;
632    if receipt["version"] != 1
633        || receipt["owner"] != "shantytown"
634        || !receipt["agents"].as_array().is_some_and(|a| !a.is_empty())
635    {
636        bail!("settings owner returned no verified nonempty registration receipt");
637    }
638    for c in &plan.changes {
639        if read(&c.path)?.as_deref() != Some(c.after.as_str()) {
640            bail!("configuration read-back differs");
641        }
642    }
643    for wanted in &plan.bundles {
644        let name = wanted["name"].as_str().context("bundle has no name")?;
645        let raw = read(&plan.root.join("hook-bundles").join(format!("{name}.json")))?
646            .context("registered bundle missing on read-back")?;
647        if serde_json::from_str::<Value>(&raw)? != *wanted {
648            bail!("registered bundle read-back differs");
649        }
650    }
651    journal(directory, "applied")
652}
653
654pub fn restore(directory: &Path, runner: &mut impl Runner) -> Result<()> {
655    let plan = load(directory)?;
656    let _lock = lock(directory)?;
657    let created_dirs: Vec<PathBuf> = match read(&directory.join("created-skill-directories.json"))?
658    {
659        Some(text) => serde_json::from_str(&text)?,
660        None => Vec::new(),
661    };
662    if created_dirs
663        .iter()
664        .any(|p| !plan.skill_directories.contains(p))
665    {
666        bail!("created skill directory is outside this plan");
667    }
668    let saved: Vec<Value> = serde_json::from_slice(
669        &fs::read(directory.join("registry-before.json")).context("plan was never applied")?,
670    )?;
671    let current = existing(&plan, runner)?;
672    for c in &plan.changes {
673        let now = read(&c.path)?;
674        if now != c.before && now.as_deref() != Some(c.after.as_str()) {
675            bail!(
676                "preserve edited configuration; restore refused: {}",
677                c.path.display()
678            );
679        }
680    }
681    for wanted in &plan.bundles {
682        if let Some(now) = current.iter().find(|r| r["name"] == wanted["name"]) {
683            let prior = saved.iter().find(|r| r["name"] == wanted["name"]);
684            if normalized(now) != normalized(wanted)
685                && prior.map(normalized).as_ref() != Some(&normalized(now))
686            {
687                bail!("registered bundle changed; restore refused");
688            }
689        }
690    }
691    exact_bundles(&plan, directory, &current, &saved)?;
692    journal(directory, "restoring")?;
693    for (i, wanted) in plan.bundles.iter().enumerate() {
694        let name = wanted["name"].as_str().context("bundle has no name")?;
695        if let Some(old) = saved.iter().find(|r| r["name"] == name) {
696            // list receipts omit evidence; restoring from them would lose it.
697            // The exact original bundle is saved separately before registration.
698            let path = directory.join(format!("prior-{i}.json"));
699            let _ = old;
700            if !path.exists() {
701                bail!("exact prior bundle required for lossless restore");
702            }
703            st(
704                &plan,
705                &[
706                    "ops",
707                    "hooks",
708                    "register",
709                    path.to_str().context("non-UTF8 path")?,
710                ],
711                runner,
712            )?;
713        } else if current.iter().any(|r| r["name"] == name) {
714            st(&plan, &["ops", "hooks", "unregister", name], runner)?;
715        }
716    }
717    for c in &plan.changes {
718        if read(&c.path)? == c.before {
719            continue;
720        }
721        if let Some(before) = &c.before {
722            private_write(&c.path, before.as_bytes())?;
723        } else if c.path.exists() {
724            fs::remove_file(&c.path)?;
725        }
726    }
727    for path in created_dirs {
728        match fs::symlink_metadata(&path) {
729            Ok(meta) if meta.is_dir() && fs::read_dir(&path)?.next().is_none() => {
730                fs::remove_dir(&path)?
731            }
732            Ok(_) => {}
733            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
734            Err(error) => return Err(error.into()),
735        }
736    }
737    st(&plan, &["ops", "provision", "--json"], runner)?;
738    journal(directory, "restored")
739}
740
741/// Refuse standalone #202 installations before adding an owner-managed hook.
742/// Parse commands, so JSON escapes or TOML quoting cannot hide a duplicate.
743pub fn check_existing_redirect(paths: &[PathBuf]) -> Result<()> {
744    fn command(value: &Value) -> bool {
745        match value {
746            Value::Object(fields) => fields.iter().any(|(key, val)| {
747                (key == "command"
748                    && val
749                        .as_str()
750                        .is_some_and(|s| s.contains("hook memory-redirect")))
751                    || command(val)
752            }),
753            Value::Array(values) => values.iter().any(command),
754            _ => false,
755        }
756    }
757    for path in paths {
758        let Some(text) = read(path)? else {
759            continue;
760        };
761        if text.contains("# >>> tambo: camayoc memory-redirect hook") {
762            bail!("standalone memory redirect installed in {}; uninstall it with scripts/memory-redirect-hook.py before planning/applying", path.display());
763        }
764        let value: Value = if path.extension().is_some_and(|s| s == "json") {
765            serde_json::from_str(&text).context("parse existing Claude settings")?
766        } else {
767            serde_json::to_value(
768                text.parse::<toml::Value>()
769                    .context("parse existing Codex settings")?,
770            )?
771        };
772        if command(&value) {
773            bail!("standalone memory redirect installed in {}; uninstall it with scripts/memory-redirect-hook.py before planning/applying", path.display());
774        }
775    }
776    Ok(())
777}