trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
//! Migrate Claude Code's auto-memory store into the project's palace (#7685).
//!
//! Why: the owner ruling of 2026-09-12 makes trusty-memory the memory and Claude
//! Code's auto memory a fallback. Turning auto memory off is only half of that —
//! the facts it already captured live in
//! `<claude_config_dir>/projects/<slug>/memory/`, and a project whose
//! `MEMORY.md` is merely zeroed has LOST them. This module is the other half:
//! it moves each fact into the palace first, and only then empties the index.
//! What: [`run_auto_memory_import`] reads every fact file in that directory,
//! stores it as a drawer tagged [`MIGRATION_TAG`] plus `type:<type>` and
//! `name:<name>`, moves the file into a dated archive beside the memory
//! directory, and — once every file has landed — archives and truncates
//! `MEMORY.md`.
//!
//! Two properties it is built around:
//!
//! * **Nothing is ever deleted.** Facts are MOVED into
//!   `memory.archived-<YYYYMMDD>/`, and `MEMORY.md` is copied there before it is
//!   truncated. A migration that turns out to have been wrong is recoverable
//!   with `mv`.
//! * **A failed store leaves its file alone.** The archive move happens per file,
//!   AFTER that file's drawer id comes back, and a single failure also stops the
//!   index from being truncated — so the index still names every file that is
//!   still on disk, and a re-run picks up exactly the remainder.
//! * **A fact is never stored twice.** "Stored" and "archived" are two states,
//!   and the second can fail on its own. The drawer id is written to a
//!   `<file>.stored` sidecar BEFORE the archive is attempted, so a re-run over a
//!   fact that was stored but not filed retries only the rename and reuses the
//!   drawer it already has. The sidecar alone cannot cover a crash between
//!   `memory_remember` returning and the sidecar landing, so every drawer also
//!   carries a deterministic [`IMPORT_KEY_PREFIX`] tag, and a fact with no sidecar
//!   is looked up by that tag before it is stored (#7685).
//! * **A marker is a claim, not proof.** #7752: a sidecar naming a drawer the
//!   palace no longer holds used to archive its file anyway, so a drawer deleted
//!   between the two runs took the fact's only other copy with it. Every marker
//!   is now checked against the palace before the file moves — the drawer is
//!   still there and the file is archived, it is gone and the marker is cleared
//!   with the file left live, or the palace cannot answer and nothing moves.
//!
//! Idempotent by construction: a second run finds no fact files (they are in the
//! archive) and an empty index, so it stores nothing — and a second run over a
//! half-migrated store issues no `memory_remember` for the half that landed.
//! Test: `core::auto_memory_import::tests`.

use std::path::{Path, PathBuf};

use anyhow::Context as _;
use serde::Serialize;

use crate::core::memory_import::{parse_memory_file, remember};

/// The tag every migrated drawer carries.
///
/// Why: one tag makes the whole migration addressable afterwards —
/// `memory_list { tag }` recovers exactly what this command wrote, which is what
/// an operator needs to audit or undo it.
/// What: the literal tag, applied to every drawer this module stores.
/// Test: `auto_memory_import_stores_and_archives_each_fact`.
pub const MIGRATION_TAG: &str = "migrated-from-auto-memory";

/// Prefix of the idempotency tag every migrated drawer carries (#7685).
///
/// Why: the `.stored` sidecar is written AFTER `memory_remember` returns, so a
/// process killed in between — or a client that timed out on a write the daemon
/// completed — leaves a drawer nothing local records, and the next run stored the
/// fact a second time. The key is written INTO the drawer, in the same palace
/// write as the fact, so no crash point can separate the two.
/// What: `import-key:` followed by [`import_key`]'s hash of the fact file's path
/// and contents.
/// Test: `auto_memory_import_never_duplicates_a_fact_whose_marker_was_lost`.
pub const IMPORT_KEY_PREFIX: &str = "import-key:";

/// Claude Code's index file inside an auto-memory directory.
pub const INDEX_FILE: &str = "MEMORY.md";

/// Everything [`run_auto_memory_import`] needs for one migration.
///
/// Why: the CLI layer stays a pure translation of clap args, and every input a
/// test must pin — the config dir, the palace, the socket, the archive date — is
/// a field rather than something the run reads from the host.
/// What: where the auto-memory store is, where the drawers go, and the date
/// stamp the archive directory is named for.
/// Test: `auto_memory_import_stores_and_archives_each_fact`,
/// `auto_memory_import_is_idempotent`,
/// `auto_memory_import_leaves_a_failed_file_in_place`.
#[derive(Debug, Clone)]
pub struct AutoImportOptions {
    /// The project whose auto-memory store is migrated.
    pub project_dir: PathBuf,
    /// The Claude config dir holding `projects/<slug>/memory/`.
    pub config_dir: PathBuf,
    /// Target palace slug.
    pub palace: String,
    /// Explicit trusty-memory socket. `None` uses daemon discovery.
    pub memory_socket: Option<PathBuf>,
    /// `YYYYMMDD` stamp the archive directory is named for.
    pub archive_stamp: String,
}

/// What happened to one fact file.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AutoImportStatus {
    /// Stored in the palace and moved into the archive.
    Stored,
    /// Not stored; the file was left exactly where it was.
    Failed,
}

/// One row of the migration report.
#[derive(Debug, Clone, Serialize)]
pub struct AutoFileResult {
    /// The fact file's basename.
    pub file: String,
    /// Stored, or failed.
    pub status: AutoImportStatus,
    /// The drawer the fact landed in, when it landed — set on a `Failed` row too
    /// when the store succeeded and only the archive move did not (#7685).
    pub drawer_id: Option<String>,
    /// The tags written with it.
    pub tags: Vec<String>,
    /// Why it failed, when it failed.
    pub error: Option<String>,
}

/// The machine-readable result of one migration.
#[derive(Debug, Clone, Serialize)]
pub struct AutoImportReport {
    /// The auto-memory directory that was read.
    pub dir: String,
    /// The palace the drawers were written to.
    pub palace: String,
    /// The archive directory, when anything was archived.
    pub archive: Option<String>,
    /// Fact files seen.
    pub total: usize,
    /// Fact files stored and archived.
    pub stored: usize,
    /// Fact files that failed and are still on disk.
    pub failed: usize,
    /// Whether `MEMORY.md` was archived and emptied by this run.
    pub index_cleared: bool,
    /// Per-file detail, in filename order.
    pub files: Vec<AutoFileResult>,
}

/// Resolve the options for a `tm memory import-auto-memory` run.
///
/// Why: the CLI layer stays a pure translation of clap args — every "where does
/// this come from" answer (the managed config dir, the palace slug, today's
/// date) is resolved here, once, beside the code that consumes it. A palace the
/// caller did not name is the one the launch path would pin for this project, so
/// the migrated drawers land where the session will look for them.
/// What: fills [`AutoImportOptions`] from `project_dir`, using
/// [`crate::core::trusty_tools_config::managed_claude_config_dir`] and
/// [`crate::core::session_launch::resolve_palace_slug`]. Errors when either
/// cannot be resolved, rather than migrating into a guessed palace.
/// Test: covered through [`resolve_in`], which is this function with the config
/// dir supplied — see `resolve_auto_import_options_keeps_an_explicit_palace`.
pub fn resolve_auto_import_options(
    project_dir: &Path,
    palace: Option<String>,
    memory_socket: Option<PathBuf>,
) -> anyhow::Result<AutoImportOptions> {
    let config_dir = crate::core::trusty_tools_config::managed_claude_config_dir().context(
        "cannot resolve the tm-managed Claude config dir, so there is no auto-memory store to read",
    )?;
    resolve_in(project_dir, config_dir, palace, memory_socket)
}

/// [`resolve_auto_import_options`] with the config dir supplied.
///
/// Why: the managed config dir is derived from the host's own install layout,
/// which a test cannot point anywhere — so the decision worth testing (an
/// explicit palace is taken verbatim, with today's stamp beside it) would
/// otherwise be untestable.
/// What: defaults the palace to the slug this project's sessions pin, and stamps
/// the archive directory with today's date. A palace that cannot be derived is
/// an error naming `--palace`, never a guess — migrating into the wrong palace
/// would scatter the facts somewhere no session looks.
/// Test: `resolve_auto_import_options_keeps_an_explicit_palace`.
pub(crate) fn resolve_in(
    project_dir: &Path,
    config_dir: PathBuf,
    palace: Option<String>,
    memory_socket: Option<PathBuf>,
) -> anyhow::Result<AutoImportOptions> {
    let palace = match palace {
        Some(palace) => palace,
        None => crate::core::session_launch::resolve_palace_slug(project_dir, None).with_context(
            || {
                format!(
                    "cannot derive a palace slug for {} — pass --palace",
                    project_dir.display()
                )
            },
        )?,
    };
    Ok(AutoImportOptions {
        project_dir: project_dir.to_path_buf(),
        config_dir,
        palace,
        memory_socket,
        archive_stamp: chrono::Local::now().format("%Y%m%d").to_string(),
    })
}

/// The auto-memory directory Claude Code keeps for `project_dir`.
///
/// Why: `tm doctor`'s `auto_memory` row and this migration must look at the SAME
/// directory, and the encoding of a workspace path into a project-directory name
/// is Claude Code's, reproduced once in
/// [`crate::runtime::encode_project_dir`].
/// What: `<config_dir>/projects/<encoded project dir>/memory`.
/// Test: `auto_memory_dir_uses_the_claude_project_encoding`.
pub fn auto_memory_dir(config_dir: &Path, project_dir: &Path) -> PathBuf {
    config_dir
        .join("projects")
        .join(crate::runtime::encode_project_dir(project_dir))
        .join("memory")
}

/// Whether `MEMORY.md` in `memory_dir` still holds anything.
///
/// Why: an emptied index is the observable end state of a migration, so the
/// doctor row grades it and this module decides when to produce it.
/// What: true when the file exists and its trimmed contents are non-empty. An
/// absent or unreadable file is false — there is nothing to migrate either way.
/// Test: `index_has_content_reads_the_index_file`.
pub fn index_has_content(memory_dir: &Path) -> bool {
    std::fs::read_to_string(memory_dir.join(INDEX_FILE))
        .map(|text| !text.trim().is_empty())
        .unwrap_or(false)
}

/// Migrate one project's auto-memory store into its palace.
///
/// Why: see the module doc — this is the command behind
/// `tm memory import-auto-memory`, and the reason zeroing `MEMORY.md` is safe.
/// What: stores each fact file as a drawer, moves it into
/// `memory.archived-<stamp>/`, and clears the index only when every file landed.
/// An absent auto-memory directory is an empty report, not an error, so the
/// command is safe to run against a project that never had one.
/// Test: `auto_memory_import_stores_and_archives_each_fact`,
/// `auto_memory_import_is_idempotent`,
/// `auto_memory_import_leaves_a_failed_file_in_place`,
/// `auto_memory_import_on_an_absent_store_is_a_no_op`.
pub async fn run_auto_memory_import(opts: &AutoImportOptions) -> anyhow::Result<AutoImportReport> {
    let memory_dir = auto_memory_dir(&opts.config_dir, &opts.project_dir);
    let mut report = AutoImportReport {
        dir: memory_dir.display().to_string(),
        palace: opts.palace.clone(),
        archive: None,
        total: 0,
        stored: 0,
        failed: 0,
        index_cleared: false,
        files: Vec::new(),
    };
    if !memory_dir.is_dir() {
        return Ok(report);
    }

    let facts = fact_files(&memory_dir)?;
    report.total = facts.len();
    if facts.is_empty() && !index_has_content(&memory_dir) {
        // The idempotent second run: everything is already in the archive.
        return Ok(report);
    }

    let socket = match &opts.memory_socket {
        Some(socket) => socket.clone(),
        None => trusty_common::memory_rpc::resolve_memory_socket()
            .context("resolve the trusty-memory daemon socket")?,
    };
    let archive = archive_dir(&memory_dir, &opts.archive_stamp);

    for path in facts {
        report
            .files
            .push(migrate_one(&socket, opts, &path, &archive).await);
    }
    report.stored = report
        .files
        .iter()
        .filter(|f| f.status == AutoImportStatus::Stored)
        .count();
    report.failed = report.files.len() - report.stored;
    if report.stored > 0 {
        report.archive = Some(archive.display().to_string());
    }

    // The index names the files; clearing it while any of them is still on disk
    // would strand exactly the ones that failed.
    if report.failed == 0 && index_has_content(&memory_dir) {
        clear_index(&memory_dir, &archive)?;
        report.archive = Some(archive.display().to_string());
        report.index_cleared = true;
    }
    Ok(report)
}

/// Suffix of the sidecar marker that records a fact's completed store (#7685).
///
/// Why: "stored" and "archived" are two states, and the gap between them used to
/// be invisible. A store that succeeded followed by an archive rename that failed
/// left a fact file still on disk with its drawer already written, so the next
/// run stored it a SECOND time and the palace grew a duplicate drawer. The marker
/// makes the first state durable, so a re-run can tell "not migrated yet" from
/// "migrated, not yet filed".
/// What: appended to the fact file's full name, so `a-fact.md` gets
/// `a-fact.md.stored`. The extension is not `.md`, so [`fact_files`] never reads
/// a marker back as a fact. #7752: what it records is durable, not authoritative
/// — [`verify_marker`] re-checks it against the palace before the file moves.
/// Test: `auto_memory_import_retries_only_the_archive_after_a_rename_failure`.
const STORED_MARKER_SUFFIX: &str = ".stored";

/// The marker path for one fact file.
fn stored_marker(path: &Path) -> PathBuf {
    let mut name = path.as_os_str().to_os_string();
    name.push(STORED_MARKER_SUFFIX);
    PathBuf::from(name)
}

/// The drawer id a previous run recorded for this fact, if it got that far.
///
/// Why: reading it is what turns the second run into an archive retry instead of
/// a second `remember`.
/// What: the trimmed marker contents; `None` for an absent, unreadable or empty
/// marker — all of which mean "nothing proven", so the store runs again.
/// Test: `auto_memory_import_retries_only_the_archive_after_a_rename_failure`.
fn stored_drawer_id(path: &Path) -> Option<String> {
    let text = std::fs::read_to_string(stored_marker(path)).ok()?;
    let id = text.trim();
    (!id.is_empty()).then(|| id.to_string())
}

/// What the palace says about a fact whose marker names a drawer (#7752).
#[derive(Debug)]
enum MarkerVerdict {
    /// The palace still holds this fact; archive it under this drawer id.
    Live(String),
    /// The palace no longer holds it, and the stale marker has been cleared.
    Gone,
}

/// Check a `.stored` marker against the live palace before trusting it (#7752).
///
/// Why: the marker records that a store SUCCEEDED, never that the drawer is
/// still there. A drawer deleted after the marker landed — a `memory_forget`, a
/// rebuilt palace, a palace slug that moved — used to archive the fact file
/// anyway, and the archive move is what put the fact out of the live set: the
/// content then survived nowhere. Verifying first is what keeps "archived" from
/// ever meaning "lost".
/// What: one [`drawer_with_tag`] lookup for this fact's [`import_key`], which is
/// the tag the drawer carries. A hit is [`MarkerVerdict::Live`] under the id the
/// palace actually reports — it supersedes the marker, which may name a drawer
/// the palace has since replaced. A miss clears the stale marker and answers
/// [`MarkerVerdict::Gone`], so the file stays live and the next run re-imports it
/// through the ordinary no-marker path; it is not re-stored here, because a
/// drawer the operator deliberately forgot must not be silently resurrected. A
/// palace that cannot answer is an `Err`: nothing is proven, so the caller moves
/// nothing.
/// Test: `auto_memory_import_does_not_archive_when_the_stored_drawer_is_gone`,
/// `auto_memory_import_leaves_the_file_alone_when_the_palace_cannot_be_reached`,
/// `auto_memory_import_retries_only_the_archive_after_a_rename_failure`.
async fn verify_marker(
    socket: &Path,
    palace: &str,
    path: &Path,
    key: &str,
) -> anyhow::Result<MarkerVerdict> {
    match drawer_with_tag(socket, palace, key).await? {
        Some(id) => Ok(MarkerVerdict::Live(id)),
        None => {
            // A marker nothing in the palace backs is worse than no marker: it
            // is what would archive the file. Remove it so the next run takes
            // the ordinary store path. A removal that fails leaves the marker,
            // and the next run reaches this same verdict again.
            let _ = std::fs::remove_file(stored_marker(path));
            Ok(MarkerVerdict::Gone)
        }
    }
}

/// Store one fact file, then move it into the archive.
///
/// Why: the ORDER is the fail-open guarantee — a file is archived only after its
/// drawer id came back, so a declined write, an unparseable file or a dead
/// daemon all leave the fact exactly where it was. The [`STORED_MARKER_SUFFIX`]
/// sidecar is what keeps that order from costing a DUPLICATE drawer: the drawer
/// id is on disk before the rename is attempted, so a rename that fails leaves a
/// re-run retrying only the rename.
/// What: parses, derives [`migration_tags`], writes through
/// [`crate::core::memory_import::remember`], records the drawer id in the marker,
/// then renames the file under `archive` and removes the marker. A fact whose
/// marker already names a drawer skips the store entirely — but only after
/// [`verify_marker`] confirms the palace still holds it (#7752). A rename failure
/// is still reported as a failure — the file is still there and a re-run must
/// see it — but now carries the drawer id it will reuse.
/// Test: `auto_memory_import_leaves_a_failed_file_in_place`,
/// `auto_memory_import_retries_only_the_archive_after_a_rename_failure`,
/// `auto_memory_import_does_not_archive_when_the_stored_drawer_is_gone`,
/// `auto_memory_import_leaves_the_file_alone_when_the_palace_cannot_be_reached`.
async fn migrate_one(
    socket: &Path,
    opts: &AutoImportOptions,
    path: &Path,
    archive: &Path,
) -> AutoFileResult {
    let file = path
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_else(|| path.display().to_string());
    let failed = |tags: Vec<String>, drawer_id: Option<String>, error: String| AutoFileResult {
        file: file.clone(),
        status: AutoImportStatus::Failed,
        drawer_id,
        tags,
        error: Some(error),
    };

    let source = match std::fs::read_to_string(path) {
        Ok(source) => source,
        Err(e) => return failed(Vec::new(), None, format!("read failed: {e}")),
    };
    let parsed = match parse_memory_file(&source) {
        Ok(Some(parsed)) => parsed,
        Ok(None) => return failed(Vec::new(), None, "no YAML frontmatter".to_string()),
        Err(e) => return failed(Vec::new(), None, format!("parse failed: {e:#}")),
    };
    let key = import_key(path, &source);
    let mut tags = migration_tags(&parsed.name, &parsed.kind);
    tags.push(key.clone());

    let drawer_id = match stored_drawer_id(path) {
        // A previous run already wrote this fact; storing it again would leave
        // the palace with two drawers for one fact. #7752: but the marker only
        // proves the store happened, so the palace is asked whether the drawer
        // is still there before the file is moved out of the live set.
        Some(marked) => match verify_marker(socket, &opts.palace, path, &key).await {
            Ok(MarkerVerdict::Live(id)) => id,
            Ok(MarkerVerdict::Gone) => {
                tracing::warn!(
                    file = %file,
                    drawer_id = %marked,
                    palace = %opts.palace,
                    "auto-memory fact was marked stored but its drawer is gone from the palace; \
                     the marker was cleared and the file left in place (#7752)"
                );
                return failed(
                    tags,
                    None,
                    format!(
                        "marked as stored in drawer {marked}, but palace {} no longer holds it — \
                         the marker was cleared and the file left in place; re-run to import it \
                         again",
                        opts.palace
                    ),
                );
            }
            // Fail-open: an unanswerable palace proves nothing either way, so
            // the marker stays and the file does not move.
            Err(e) => return failed(tags, Some(marked), format!("lookup failed: {e:#}")),
        },
        None => {
            // #7685: no sidecar is not proof of no drawer — a run killed between
            // the store and the sidecar leaves exactly that. Ask the palace first.
            match drawer_with_tag(socket, &opts.palace, &key).await {
                Ok(Some(id)) => return finish(path, archive, file.clone(), tags, id),
                Ok(None) => {}
                Err(e) => return failed(tags, None, format!("lookup failed: {e:#}")),
            }
            // `allow_secret_like`: these facts are already on this machine in
            // plain text; refusing to move one because its prose looks
            // token-shaped would strand it in the store this command exists to
            // empty.
            let id = match remember(socket, &opts.palace, &parsed.text, &tags, true).await {
                Ok(id) => id,
                Err(e) => return failed(tags, None, format!("store failed: {e:#}")),
            };
            if let Err(e) = std::fs::write(stored_marker(path), &id) {
                // The drawer exists but nothing local records it. The index
                // stays un-emptied, and the drawer's import-key tag is what the
                // next run finds it by (#7685).
                return failed(tags, Some(id), format!("stored but not marked: {e}"));
            }
            id
        }
    };
    finish(path, archive, file.clone(), tags, drawer_id)
}

/// Archive a fact whose drawer exists, and report it stored.
///
/// Why: both the fresh store and a drawer recovered by its import key end the
/// same way, and a second copy of the archive-or-report tail is where the two
/// would drift.
/// What: renames the file under `archive` and removes its marker; a rename
/// failure is reported as a failure carrying the drawer id a re-run will reuse.
/// Test: `auto_memory_import_retries_only_the_archive_after_a_rename_failure`,
/// `auto_memory_import_never_duplicates_a_fact_whose_marker_was_lost`.
fn finish(
    path: &Path,
    archive: &Path,
    file: String,
    tags: Vec<String>,
    drawer_id: String,
) -> AutoFileResult {
    if let Err(e) = archive_file(path, archive, &file) {
        let error = format!("stored as {drawer_id} but not archived: {e:#}");
        return AutoFileResult {
            file,
            status: AutoImportStatus::Failed,
            drawer_id: Some(drawer_id),
            tags,
            error: Some(error),
        };
    }
    // The fact is filed; the marker has nothing left to prove. A removal that
    // fails is harmless — `fact_files` never returns a marker, so the stray one
    // cannot cause a re-store.
    let _ = std::fs::remove_file(stored_marker(path));
    AutoFileResult {
        file,
        status: AutoImportStatus::Stored,
        drawer_id: Some(drawer_id),
        tags,
        error: None,
    }
}

/// The idempotency tag for one fact file (#7685).
///
/// Why: a retry must find the drawer a crashed run already wrote, and it can only
/// look for something both runs derive identically from what is on disk.
/// What: [`IMPORT_KEY_PREFIX`] plus the first 32 hex digits of SHA-256 over the
/// file's path, a NUL, and its contents — the same fact at the same path always
/// yields the same tag, and an edited fact yields a new one.
/// Test: `import_key_is_deterministic_per_path_and_content`.
fn import_key(path: &Path, source: &str) -> String {
    use sha2::{Digest as _, Sha256};
    let mut hasher = Sha256::new();
    hasher.update(path.to_string_lossy().as_bytes());
    hasher.update([0u8]);
    hasher.update(source.as_bytes());
    let digest = format!("{:x}", hasher.finalize());
    format!("{IMPORT_KEY_PREFIX}{}", &digest[..32])
}

/// The drawer in `palace` carrying `tag`, if one exists (#7685).
///
/// Why: see [`IMPORT_KEY_PREFIX`] — this is the lookup that turns a lost sidecar
/// into a reuse instead of a second store.
/// What: one `memory_list { palace, tag, limit: 1 }`; the daemon matches the tag
/// exactly before applying the limit. An answer with no `drawers` array is an
/// error rather than "none", because storing on a misread would duplicate.
/// Test: `auto_memory_import_never_duplicates_a_fact_whose_marker_was_lost`.
async fn drawer_with_tag(socket: &Path, palace: &str, tag: &str) -> anyhow::Result<Option<String>> {
    let result = trusty_common::memory_rpc::call_memory_tool_at(
        socket,
        "memory_list",
        serde_json::json!({ "palace": palace, "tag": tag, "limit": 1 }),
    )
    .await?;
    let drawers = result
        .get("drawers")
        .and_then(serde_json::Value::as_array)
        .context("memory_list answered without a drawers array")?;
    Ok(drawers
        .iter()
        .find_map(|d| d.get("drawer_id").and_then(serde_json::Value::as_str))
        .map(str::to_string))
}

/// The tag set one migrated fact carries.
///
/// Why: the migration tag makes the whole batch addressable; `name:` preserves
/// the fact's own slug as a lookup key; `type:` preserves Claude Code's
/// classification. All three are prefixed or literal so they cannot collide with
/// a tag a human wrote.
/// What: [`MIGRATION_TAG`], `name:<name>`, and `type:<kind>` when `kind` is
/// non-empty.
/// Test: `migration_tags_omit_an_empty_type`.
fn migration_tags(name: &str, kind: &str) -> Vec<String> {
    let mut tags = vec![MIGRATION_TAG.to_string(), format!("name:{name}")];
    if !kind.trim().is_empty() {
        tags.push(format!("type:{kind}"));
    }
    tags
}

/// Where migrated files go: `memory.archived-<stamp>` beside the memory dir.
///
/// Why: a sibling rather than a subdirectory of `memory/`, so Claude Code cannot
/// read the archived facts back as live memory.
/// What: `<memory_dir parent>/memory.archived-<stamp>`; falls back to a
/// subdirectory only if `memory_dir` somehow has no parent.
/// Test: `auto_memory_import_stores_and_archives_each_fact`.
fn archive_dir(memory_dir: &Path, stamp: &str) -> PathBuf {
    let name = format!("memory.archived-{stamp}");
    match memory_dir.parent() {
        Some(parent) => parent.join(name),
        None => memory_dir.join(name),
    }
}

/// Move one file into the archive, creating it on first use.
fn archive_file(path: &Path, archive: &Path, file: &str) -> anyhow::Result<()> {
    std::fs::create_dir_all(archive)
        .with_context(|| format!("create archive dir {}", archive.display()))?;
    std::fs::rename(path, archive.join(file))
        .with_context(|| format!("archive {} into {}", path.display(), archive.display()))
}

/// Archive a copy of `MEMORY.md`, then truncate it.
///
/// Why: the index is what Claude Code reads at session start, so emptying it is
/// the point of the migration — but "never delete" applies to it too, and the
/// copy is what makes the truncation reversible.
/// What: copies the index into `archive` under its own name, then writes an
/// empty file in its place. The file itself is kept: Claude Code re-creating it
/// is not something this command should race.
/// Test: `auto_memory_import_stores_and_archives_each_fact`.
fn clear_index(memory_dir: &Path, archive: &Path) -> anyhow::Result<()> {
    let index = memory_dir.join(INDEX_FILE);
    std::fs::create_dir_all(archive)
        .with_context(|| format!("create archive dir {}", archive.display()))?;
    std::fs::copy(&index, archive.join(INDEX_FILE))
        .with_context(|| format!("archive {}", index.display()))?;
    std::fs::write(&index, "").with_context(|| format!("empty {}", index.display()))
}

/// The fact files in an auto-memory directory, in filename order.
///
/// Why: deterministic order makes a partial run resumable and two runs
/// comparable — the same reason `memory_import::markdown_files` sorts.
/// What: `*.md` files directly inside `dir`, excluding [`INDEX_FILE`], which is
/// the index rather than a fact.
/// Test: `auto_memory_import_stores_and_archives_each_fact`.
fn fact_files(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
    let entries = std::fs::read_dir(dir)
        .with_context(|| format!("read auto-memory directory {}", dir.display()))?;
    let mut paths = Vec::new();
    for entry in entries {
        let path = entry
            .with_context(|| format!("read entry in {}", dir.display()))?
            .path();
        if !path.is_file() {
            continue;
        }
        let is_md = path
            .extension()
            .and_then(|e| e.to_str())
            .is_some_and(|e| e.eq_ignore_ascii_case("md"));
        let is_index = path.file_name().and_then(|n| n.to_str()) == Some(INDEX_FILE);
        if is_md && !is_index {
            paths.push(path);
        }
    }
    paths.sort();
    Ok(paths)
}

#[cfg(test)]
#[path = "auto_memory_import_tests.rs"]
mod tests;