Skip to main content

memstead_cli/commands/
mem.rs

1//! `memstead mem init` / `memstead mem delete` — full-only mem-lifecycle
2//! CLI front-ends.
3//!
4//! Both subcommands call the
5//! engine in-process via `memstead_engine::mem_management::create_mem` /
6//! `delete_mem`. An earlier design spawned `memstead-mcp --operator-mode`
7//! as a child process and drove the matching MCP tool over JSON-RPC
8//! — wire-format parity with the agent path was the intent,
9//! but the CLI-as-MCP-consumer relationship cut against the layering
10//! posture that CLI, MCP, and UniFFI are sibling surfaces over the
11//! engine (AGENTS.md's parity rule). In-process
12//! collapses CLI and MCP onto the same Rust call, with `operator_mode:
13//! true` hardcoded at the call site so the engine bypasses the
14//! `[[mem_management.create]]` / `[[mem_management.delete]]`
15//! allowlists and the `MEM_REFERENCED_BY_POLICY` safeguard for these
16//! two operator-tool surfaces (matching the spirit of the
17//! transport-establishes-posture rule).
18//!
19//! Outer-repo gitignore: a CLI-only concern. The shared helper in
20//! [`crate::outer_gitignore`] walks upward from the workspace root
21//! looking for an enclosing `.git/`, then idempotently appends the
22//! workspace path (or `mem-repo/` inside it) to the outer repo's
23//! `.gitignore`. Refused for `$HOME` and disabled by `--no-gitignore`.
24
25use std::path::PathBuf;
26
27use clap::{Args, Subcommand};
28
29use crate::CliError;
30use crate::outer_gitignore::{OuterRepoOutcome, apply_outer_gitignore};
31use crate::output::ExitKind;
32use crate::setup::{CliContext, CliEngine, find_workspace_root};
33use memstead_engine::mem_management::{
34    self, MemCreateParams, MemCreateResponse, MemDeleteParams, MemDeleteResponse,
35};
36
37/// Subcommands under `memstead mem`.
38#[derive(Subcommand, Debug)]
39pub enum MemAction {
40    /// Register a new mem via the engine's mem-management
41    /// orchestrator.
42    Init(InitArgs),
43    /// Router-only removal — unregisters the mem from the workspace
44    /// but leaves its stored content in place for archive workflows.
45    /// Cross-mem grants pointing at the unregistered mem stay valid
46    /// (the data they rely on survives); a follow-up `memstead mem init
47    /// <same name>` re-attaches against the preserved storage. Refuses
48    /// with `MEM_HAS_INCOMING_REFS` when entities in other mems still
49    /// link into this one — remove those incoming cross-mem references
50    /// first (mirrors `mem delete`'s precondition).
51    Unregister(UnregisterArgs),
52    /// Storage-destroying removal — unregisters the mem AND deletes
53    /// its stored content. Refuses with `MEM_REFERENCED_BY_POLICY`
54    /// when any other writable mem has a `cross_mem_links` grant
55    /// pointing at the target (revoke the grant first). For router-only
56    /// removal that keeps the storage, use `memstead mem unregister`.
57    Delete(DeleteArgs),
58    /// Update a mem's `version` field. The version is consumed by
59    /// `memstead export --format mem` to stamp the archive filename and
60    /// the `.mem` archive's published config. `version` is seeded at
61    /// init (`0.1.0`); bump via this command before publishing.
62    #[command(name = "set-version")]
63    SetVersion(SetVersionArgs),
64    /// Set a mem's schema pin — the integrity-driven schema-migration
65    /// trigger. Already-integral mems switch immediately; otherwise
66    /// the mem enters dual-pin migration (writes validate against
67    /// the target) and the response lists the non-integral entities.
68    /// Re-issue after repairing to complete the switch.
69    #[command(name = "set-schema")]
70    SetSchema(SetSchemaArgs),
71    /// Set a mem's one-line `description` — embedded in `.mem` archive
72    /// exports and surfaced on the registry card at publish time. An
73    /// empty string clears the field. Set it before `memstead export` /
74    /// `memstead publish` so the shared archive carries its card text.
75    #[command(name = "set-description")]
76    SetDescription(SetDescriptionArgs),
77    /// Set (or clear) one opaque sync-state token in a mem's config —
78    /// the pipeline layer's durable "last synced source state" baseline.
79    /// `<KEY>` and `<TOKEN>` are opaque to the engine (the binding layer
80    /// keys per `<binding-id>/<facet>#synced` and owns the token's
81    /// meaning). An empty `<TOKEN>` clears the key. Written into the
82    /// per-mem config and surfaced verbatim on `memstead workspace dump`.
83    #[command(name = "set-sync-state")]
84    SetSyncState(SetSyncStateArgs),
85    /// Mark (or unmark) a mem as internal — hidden from the default
86    /// `memstead overview` roster and public projections, while staying a
87    /// real, inspectable (`overview --mem <name>`), deletable mem. Ingest
88    /// process-state mems are flagged this way.
89    #[command(name = "set-internal")]
90    SetInternal(SetInternalArgs),
91    /// Enumerate every mounted mem in the workspace with its
92    /// schema pin, version, entity count, and capability (writable
93    /// vs read-only). Markdown by default; pass `--json` (root flag)
94    /// for the structured envelope.
95    List(ListArgs),
96}
97
98/// `memstead mem list` — no positional args. The verb itself is the
99/// signal; `--json` (root-level) toggles the output shape.
100#[derive(Args, Debug)]
101pub struct ListArgs {}
102
103/// `memstead mem set-version <NAME> <VERSION>` arguments.
104#[derive(Args, Debug)]
105pub struct SetVersionArgs {
106    /// Mem name (the leaf-folder identifier the engine assigned at
107    /// init time). Must already be registered in the workspace.
108    pub name: String,
109
110    /// New semver version (e.g. `0.2.0`, `1.0.0-beta.1`). Malformed
111    /// values refuse with `INVALID_INPUT`. The engine bypasses the
112    /// mem-create allowlist for this surface — set-version is
113    /// gate-free.
114    pub version: String,
115
116    /// Optional provenance note (≤280 chars) recorded on the
117    /// version-bump commit body, like the other commit-producing
118    /// mem-lifecycle commands. When the workspace sets
119    /// `require_notes`, omitting it rides a non-blocking `NOTE_MISSING`
120    /// warning (the bump still lands).
121    #[arg(long)]
122    pub note: Option<String>,
123}
124
125/// `memstead mem set-description <NAME> <DESCRIPTION>` arguments.
126#[derive(Args, Debug)]
127pub struct SetDescriptionArgs {
128    /// Mem name (must be registered in the workspace).
129    pub name: String,
130
131    /// One-line description of the mem — what a registry visitor (or
132    /// an agent browsing the catalogue) should know before installing.
133    /// An empty string clears the field.
134    pub description: String,
135
136    /// Optional provenance note (≤280 chars) recorded on the commit
137    /// body, like the other commit-producing mem-lifecycle commands.
138    #[arg(long)]
139    pub note: Option<String>,
140}
141
142/// `memstead mem set-sync-state <NAME> <KEY> <TOKEN>` arguments.
143#[derive(Args, Debug)]
144pub struct SetSyncStateArgs {
145    /// Mem name (must be registered in the workspace).
146    pub name: String,
147
148    /// Opaque sync-state key. The binding layer keys per
149    /// `<binding-id>/<facet>#synced` (and `#verified`), but the engine
150    /// treats it as an arbitrary string.
151    pub key: String,
152
153    /// Opaque token recording the source state last synced under
154    /// `<KEY>` (git → commit id, graph → snapshot token, filesystem →
155    /// a JSON-stringified stat digest). An **empty** value clears the
156    /// key. The engine never parses it.
157    pub token: String,
158
159    /// Optional provenance note (≤280 chars) recorded on the commit
160    /// body, like the other commit-producing mem-lifecycle commands.
161    #[arg(long)]
162    pub note: Option<String>,
163}
164
165/// `memstead mem set-schema <NAME> <SCHEMA>` arguments.
166#[derive(Args, Debug)]
167pub struct SetSchemaArgs {
168    /// Mem name (must be registered in the workspace).
169    pub name: String,
170
171    /// Target schema ref, exact `name@x.y.z`. Must resolve against
172    /// the loaded schema catalogue; unresolvable refs refuse with
173    /// `SCHEMA_NOT_FOUND`, malformed refs with `INVALID_INPUT`.
174    pub schema: String,
175}
176
177/// `memstead mem init <path>` arguments.
178///
179/// `--vcs-shared` translates into the engine's `vcs` block;
180/// `--no-gitignore` suppresses the outer-repo `.gitignore` append. The
181/// `<path>` argument supplies the new mem's `location` (relative
182/// to the workspace root) plus its `name` (basename of the path); a
183/// slashed `<a>/<b>` form additionally derives `--org-path a` so
184/// `memstead mem init a/b` and `memstead mem init b --org-path a` produce
185/// identical engine calls. Cross-mem edge authorization is
186/// workspace-level policy (`[cross_mem_links]` in `.memstead/workspace.toml`); the
187/// previous `--belongs-to` flag is gone.
188#[derive(Args, Debug)]
189pub struct InitArgs {
190    /// Mem name — the full hierarchical identifier (e.g. `foo` for
191    /// a flat-layout mem, `team/sub-mem` for a hierarchical
192    /// layout). The value flows through to the engine verbatim with no
193    /// auto-split or composition step. Grammar:
194    /// `[a-z0-9-]+(/[a-z0-9-]+)*` — lowercase ASCII letters, digits,
195    /// hyphens; segments separated by `/`; no leading, trailing, or
196    /// double slashes (validated engine-side; bad names return
197    /// `INVALID_INPUT`).
198    pub path: PathBuf,
199
200    /// Schema pin (`name@x.y.z`) for the new mem. Defaults to
201    /// `default@1.0.0` so the common case stays one argument.
202    #[arg(long, default_value = "default@1.0.0")]
203    pub schema: String,
204
205    /// Pass a shared-gitdir `vcs` block to `memstead_mem_create`:
206    /// `{ "gitdir": "../.git", "worktree": ".." }`. Without this flag the
207    /// engine uses the default isolated layout.
208    #[arg(long)]
209    pub vcs_shared: bool,
210
211    /// Skip outer-repo `.gitignore` auto-append. Useful when the user
212    /// intends to track the workspace as a git submodule, or when the
213    /// detection heuristic would pick the wrong outer repo.
214    #[arg(long)]
215    pub no_gitignore: bool,
216
217    /// Optional provenance note recorded in the seed commit's body
218    /// (≤280 chars). Forwarded as the MCP tool's `note` parameter.
219    #[arg(long)]
220    pub note: Option<String>,
221
222    /// Adopt residual entities left by a prior `memstead mem unregister`
223    /// at this mem's path instead of failing on detected residue.
224    /// Default when the residue carries an `unregistered_at` tombstone
225    /// (the deliberate unregister signal); pass `--reattach` explicitly
226    /// to override for crash-residue you have verified is safe to adopt.
227    /// Mutually exclusive with `--force-overwrite` and
228    /// `--hard-cleanup-first`.
229    #[arg(long, group = "recovery_action")]
230    pub reattach: bool,
231
232    /// Destroy residual storage at this mem's path and proceed with a
233    /// fresh create. **Not yet implemented** — currently refuses with
234    /// `INVALID_INPUT` pointing at `memstead mem delete <name>`. Mutually
235    /// exclusive with `--reattach` and `--hard-cleanup-first`.
236    #[arg(long = "force-overwrite", group = "recovery_action")]
237    pub force_overwrite: bool,
238
239    /// Refuse with `MEM_STORAGE_RESIDUE_DETECTED` instructing the
240    /// caller to run `memstead mem delete <name>` first — a hard barrier
241    /// that keeps residue cleanup a separate, named operation rather
242    /// than destructive auto-recovery. Mutually exclusive with
243    /// `--reattach` and `--force-overwrite`.
244    #[arg(long = "hard-cleanup-first", group = "recovery_action")]
245    pub hard_cleanup_first: bool,
246
247    /// Bypass the workspace `[[mem_management.create]]` allowlist
248    /// for this invocation. The CLI honours the allowlist by default
249    /// (matching the MCP-surface posture); operator-mode is explicit
250    /// opt-in. Also settable via the `MEMSTEAD_OPERATOR_MODE=1` env var for
251    /// script convenience; the flag wins when both are set. Use this
252    /// when the CLI invocation is the operator administering the
253    /// workspace itself (initial scaffold, recovery flows) rather than
254    /// scripted/agent usage.
255    #[arg(long = "operator-mode")]
256    pub operator_mode: bool,
257
258    /// Optional per-instance writing guidance as a JSON object, written
259    /// verbatim into the new mem's config `writeGuidance` map — e.g.
260    /// `--write-guidance '{"phase_context":"early design","stack":"Rust"}'`.
261    /// Opaque to the engine (schema-strictness D8 — the keys are
262    /// client-owned vocabulary); a wrapper that read the schema
263    /// package's `mem-template.json` fills the instance keys. Omit to
264    /// seed no guidance. Must be a JSON object; anything else refuses
265    /// with `INVALID_INPUT`.
266    #[arg(long = "write-guidance")]
267    pub write_guidance: Option<String>,
268}
269
270/// `memstead mem delete <name>` arguments — full destruction. The
271/// CLI honours the workspace `[[mem_management.delete]]`
272/// allowlist by default; pass `--operator-mode` or set
273/// `MEMSTEAD_OPERATOR_MODE=1` to skip the allowlist. The
274/// `MEM_REFERENCED_BY_POLICY` and `MEM_HAS_INCOMING_REFS`
275/// safeguards always fire regardless of operator-mode. The verb
276/// uniquely identifies the storage-destroying intent — use
277/// `memstead mem unregister` for router-only removal.
278///
279/// On success delete scrubs only the now-dangling
280/// `[cross_mem_links]` grants naming this mem on either side
281/// (reported in the `## Allowlist entries scrubbed` block of the
282/// response) — those reference the gone instance and would otherwise
283/// dangle. The workspace's `[[mem_management.create]]` /
284/// `[[mem_management.delete]]` allowlist rules are PRESERVED, exact
285/// name and glob alike: they are forward-looking permissions for the
286/// name, not references to the instance. Re-creating a mem of the
287/// same name afterward needs no fresh `allow-create` / `allow-delete`
288/// grant.
289#[derive(Args, Debug)]
290pub struct DeleteArgs {
291    /// Name of the mem to destroy.
292    pub name: String,
293
294    /// Optional provenance note (≤280 chars). Captured on the engine
295    /// trace surface; surfaces via the outer-repo Stop hook. No
296    /// per-mem commit is produced by delete.
297    #[arg(long)]
298    pub note: Option<String>,
299
300    /// Bypass the workspace `[[mem_management.delete]]` allowlist
301    /// for this invocation. See `InitArgs::operator_mode` for the
302    /// full design rationale. Also settable via `MEMSTEAD_OPERATOR_MODE=1`.
303    #[arg(long = "operator-mode")]
304    pub operator_mode: bool,
305}
306
307/// `memstead mem unregister <name>` arguments — router-only removal,
308/// storage preserved. The CLI honours the workspace
309/// `[[mem_management.delete]]` allowlist by default; pass
310/// `--operator-mode` or set `MEMSTEAD_OPERATOR_MODE=1` to skip the
311/// allowlist. The `MEM_REFERENCED_BY_POLICY` safeguard does not
312/// apply to unregister (storage is preserved), so unregistering a
313/// mem with cross-mem grants pointing at it succeeds without
314/// refusing — the data the grants rely on survives.
315///
316/// Refuses with `MEM_HAS_INCOMING_REFS` when an entity in another
317/// Write-Mem still carries a graph edge into this mem (`details.referrers`
318/// names each `{from_id, rel_types, mem}`) — remove those edges via
319/// `memstead relate --remove` / `memstead update` first. This guard fires for
320/// `unregister` just as it does for `delete`: the edge-graph axis is
321/// independent of the storage-preservation choice, so a gentle
322/// removal that left dangling cross-mem edges would be just as broken.
323#[derive(Args, Debug)]
324pub struct UnregisterArgs {
325    /// Name of the mem to unregister.
326    pub name: String,
327
328    /// Optional provenance note (≤280 chars). Captured on the engine
329    /// trace surface; surfaces via the outer-repo Stop hook.
330    #[arg(long)]
331    pub note: Option<String>,
332
333    /// Bypass the workspace `[[mem_management.delete]]` allowlist
334    /// for this invocation. See `InitArgs::operator_mode` for the
335    /// full design rationale. Also settable via `MEMSTEAD_OPERATOR_MODE=1`.
336    #[arg(long = "operator-mode")]
337    pub operator_mode: bool,
338}
339
340pub fn run(ctx: &CliContext, args: InitArgs) -> anyhow::Result<()> {
341    let cwd = std::env::current_dir()
342        .map_err(|e| generic_error(format!("determine current directory: {e}")))?;
343
344    // Locate the workspace via the post-rebuild marker
345    // (`.memstead/workspace.toml`). The presence of this file is the
346    // engine's own boot precondition — `memstead-mcp` walks for it too.
347    let workspace_root = find_workspace_root(&cwd).ok_or_else(|| {
348        validation_error(format!(
349            "no workspace found above {}. Run `memstead mem-repo init` first or \
350             change directory into an existing workspace.",
351            cwd.display(),
352        ))
353    })?;
354
355    // Hierarchical paths are first-class mem identifiers. The CLI forwards
356    // the `<PATH>` argument verbatim as `params.name` (`team/sub-mem`
357    // or just `sub-mem` — the engine's mem-name grammar
358    // validates the shape). There is no `--org-path` flag or path-vs-name
359    // auto-split — the value flows through unchanged.
360    let mem_name = args.path.to_str().map(|s| s.to_string()).ok_or_else(|| {
361        invalid_input_error(format!(
362            "mem name {:?} is not valid UTF-8 — mem names must be ASCII \
363                 (lowercase letters / digits / hyphens) optionally segmented by '/'.",
364            args.path.display(),
365        ))
366    })?;
367    let location = mem_name.clone();
368
369    let schema_ref: memstead_schema::SchemaRef = args
370        .schema
371        .parse()
372        .map_err(|e| invalid_input_error(format!("invalid schema ref {:?}: {e}", args.schema)))?;
373    let vcs_config = if args.vcs_shared {
374        Some(memstead_schema::VcsConfig {
375            gitdir: "../.git".to_string(),
376            worktree: "..".to_string(),
377        })
378    } else {
379        None
380    };
381    let write_guidance = match &args.write_guidance {
382        None => std::collections::HashMap::new(),
383        Some(raw) => {
384            serde_json::from_str::<std::collections::HashMap<String, serde_json::Value>>(raw)
385                .map_err(|e| {
386                    invalid_input_error(format!("--write-guidance must be a JSON object: {e}"))
387                })?
388        }
389    };
390    let params = MemCreateParams {
391        name: mem_name.clone(),
392        location: PathBuf::from(&location),
393        schema_ref,
394        vcs: vcs_config,
395        note: args.note.clone(),
396        write_guidance,
397        // The workspace `[[mem_management.create]]`
398        // allowlist applies to CLI calls by default; the operator
399        // opts into bypass explicitly via `--operator-mode` (flag
400        // wins) or `MEMSTEAD_OPERATOR_MODE=1` (env-var fallback).
401        operator_mode: resolve_operator_mode(args.operator_mode),
402        recovery: recovery_from_flags(args.reattach, args.force_overwrite, args.hard_cleanup_first),
403    };
404
405    let mut engine = match ctx.cli_engine()? {
406        CliEngine::MemRepo(e) => e,
407        CliEngine::Filesystem(_) => {
408            return Err(validation_error(format!(
409                "`memstead mem init` requires a mem-repo workspace; the workspace at {} is filesystem-shaped. Use `memstead mem-repo init` first to migrate.",
410                workspace_root.display(),
411            )));
412        }
413    };
414    let response =
415        mem_management::create_mem(&mut engine, params).map_err(pro_engine_err_to_cli)?;
416    if ctx.json {
417        crate::output::print_json(&serde_json::json!({
418            "name": response.name,
419            "location": response.location,
420            "schema_ref": response.schema_ref.to_string(),
421            "seed_commit_sha": response.seed_commit_sha,
422            // The reattach branch surfaces `MEM_REATTACHED_AFTER_UNREGISTER`
423            // through the response envelope rather than dropping it on
424            // the floor. Fresh-create ships an empty array.
425            "warnings": response
426                .warnings
427                .iter()
428                .map(|w| serde_json::json!({"code": w.code(), "message": w.message()}))
429                .collect::<Vec<_>>(),
430        }))?;
431    } else {
432        crate::output::print_markdown(&render_mem_create_markdown(&response));
433    }
434
435    // Outer-repo gitignore handling. Append `mem-repo/` (the post-cutover
436    // gitignore target — every mem's content lives inside that one
437    // directory) to the outer repo's `.gitignore`. Idempotent on re-run;
438    // refuses when the outer is `$HOME`.
439    if !args.no_gitignore {
440        let mem_repo_path = workspace_root.join("mem-repo");
441        let walk_start = workspace_root
442            .parent()
443            .map(|p| p.to_path_buf())
444            .unwrap_or_else(|| workspace_root.clone());
445        // Outer-repo provenance is human-facing context, not part of the
446        // structured result. It goes to stderr — never stdout — so a `--json`
447        // caller's stdout stays exactly one JSON document (the contract
448        // `--help` advertises and steers callers to pipe through `jq`). A
449        // human still sees it on the terminal in normal runs; `--quiet`
450        // suppresses it, the first time this site consults the flag.
451        match apply_outer_gitignore(&walk_start, &mem_repo_path)? {
452            OuterRepoOutcome::Appended { outer_root, rel } => {
453                if !ctx.quiet {
454                    eprintln!(
455                        "  outer:    {} — added `{}` to .gitignore",
456                        outer_root.display(),
457                        rel,
458                    );
459                }
460            }
461            OuterRepoOutcome::AlreadyIgnored { outer_root, rel } => {
462                if !ctx.quiet {
463                    eprintln!(
464                        "  outer:    {} — `{}` already in .gitignore, no change",
465                        outer_root.display(),
466                        rel,
467                    );
468                }
469            }
470            OuterRepoOutcome::NoOuter | OuterRepoOutcome::Skipped => {}
471        }
472    }
473
474    // Client-side mem-template consumption: when the operator did not
475    // supply --write-guidance, surface the resolved schema's
476    // mem-template instance keys so they know what to fill. The engine
477    // treats `writeGuidance` opaquely — filling is the operator's job.
478    if let Some(note) =
479        mem_template_guidance_note(&response.schema_ref, args.write_guidance.is_some())
480        && !ctx.quiet
481    {
482        eprintln!("  template: {note}");
483    }
484
485    Ok(())
486}
487
488/// When the operator did not supply `--write-guidance`, surface the
489/// resolved (built-in) schema's `mem-template.json` instance guidance
490/// keys so they know what to fill. Returns the operator notice, or
491/// `None` when there is nothing to surface — guidance was already given,
492/// the schema ships no template, or its template carries no guidance.
493/// Reads only built-in templates; an installed/authored package's
494/// template is a follow-up.
495fn mem_template_guidance_note(
496    schema_ref: &memstead_schema::SchemaRef,
497    guidance_given: bool,
498) -> Option<String> {
499    if guidance_given {
500        return None;
501    }
502    let template = memstead_schema::builtins::builtin_mem_template(&schema_ref.name)?;
503    let wg = template.get("writeGuidance")?.as_object()?;
504    if wg.is_empty() {
505        return None;
506    }
507    let keys: Vec<&str> = wg.keys().map(String::as_str).collect();
508    let first = keys.first().copied().unwrap_or("key");
509    Some(format!(
510        "schema {schema_ref} ships a mem-template with instance guidance key(s) [{}] — \
511         the mem was created without guidance. Re-run with \
512         --write-guidance '{{\"{first}\": \"…\"}}' (or edit the mem config) to fill them.",
513        keys.join(", "),
514    ))
515}
516
517pub fn run_delete(ctx: &CliContext, args: DeleteArgs) -> anyhow::Result<()> {
518    run_delete_inner(
519        ctx,
520        args.name,
521        args.note,
522        /* delete_files */ true,
523        "delete",
524        resolve_operator_mode(args.operator_mode),
525    )
526}
527
528pub fn run_unregister(ctx: &CliContext, args: UnregisterArgs) -> anyhow::Result<()> {
529    run_delete_inner(
530        ctx,
531        args.name,
532        args.note,
533        /* delete_files */ false,
534        "unregister",
535        resolve_operator_mode(args.operator_mode),
536    )
537}
538
539/// Resolve the effective operator-mode for a CLI invocation. The
540/// workspace allowlist applies by default; the operator opts into
541/// bypass via `--operator-mode` (highest precedence) or the
542/// `MEMSTEAD_OPERATOR_MODE` env var. The env-var accepts `1`, `true`, `yes`
543/// (case-insensitive); any other value is treated as unset.
544fn resolve_operator_mode(flag: bool) -> bool {
545    if flag {
546        return true;
547    }
548    match std::env::var("MEMSTEAD_OPERATOR_MODE") {
549        Ok(v) => matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"),
550        Err(_) => false,
551    }
552}
553
554fn run_delete_inner(
555    ctx: &CliContext,
556    name: String,
557    note: Option<String>,
558    delete_files: bool,
559    verb: &str,
560    operator_mode: bool,
561) -> anyhow::Result<()> {
562    let cwd = std::env::current_dir()
563        .map_err(|e| generic_error(format!("determine current directory: {e}")))?;
564    let workspace_root = find_workspace_root(&cwd).ok_or_else(|| {
565        validation_error(format!(
566            "no workspace found above {}. `memstead mem {verb}` must run \
567             inside a configured workspace.",
568            cwd.display(),
569        ))
570    })?;
571
572    let params = MemDeleteParams {
573        name: name.clone(),
574        delete_files,
575        note: note.clone(),
576        operator_mode,
577    };
578    let mut engine = match ctx.cli_engine()? {
579        CliEngine::MemRepo(e) => e,
580        CliEngine::Filesystem(_) => {
581            return Err(validation_error(format!(
582                "`memstead mem {verb}` requires a mem-repo workspace; the workspace at {} is filesystem-shaped.",
583                workspace_root.display(),
584            )));
585        }
586    };
587    let response =
588        mem_management::delete_mem(&mut engine, params).map_err(pro_engine_err_to_cli)?;
589    if ctx.json {
590        crate::output::print_json(&serde_json::json!({
591            "name": response.name,
592            "deleted_from_router": response.deleted_from_router,
593            "files_deleted": response.files_deleted,
594            "warnings": response
595                .warnings
596                .iter()
597                .map(|w| serde_json::json!({"code": w.code(), "message": w.message()}))
598                .collect::<Vec<_>>(),
599            // Surface scrubbed `.memstead/workspace.toml` entries so the
600            // agent sees every policy side effect in one round-trip.
601            "allowlist_entries_removed": &response.allowlist_entries_removed,
602        }))?;
603    } else {
604        crate::output::print_markdown(&render_mem_delete_markdown(&response, verb));
605    }
606    Ok(())
607}
608
609/// Render a successful `MemCreateResponse` as a CLI markdown block.
610/// The CLI owns its own prose rather than echoing the MCP subprocess's
611/// pre-rendered text channel.
612fn render_mem_create_markdown(r: &MemCreateResponse) -> String {
613    // The reattach
614    // branch surfaces a `MEM_REATTACHED_AFTER_UNREGISTER` warning on
615    // the response. Adjust the heading so an operator picking up an
616    // empty `seed_commit_sha` plus the reattach warning learns the
617    // branch tip kept its prior history rather than starting fresh.
618    let reattached = r.warnings.iter().any(|w| {
619        matches!(
620            w,
621            memstead_base::ops::WarningHint::MemReattachedAfterUnregister { .. }
622        )
623    });
624    let heading = if reattached {
625        format!("# Mem `{}` reattached\n\n", r.name)
626    } else {
627        format!("# Mem `{}` created\n\n", r.name)
628    };
629    let mut out = heading;
630    out.push_str(&format!("- Location: `{}`\n", r.location.display()));
631    out.push_str(&format!("- Schema: `{}`\n", r.schema_ref));
632    out.push_str(&format!("- Seed commit: `{}`\n", r.seed_commit_sha));
633    if !r.warnings.is_empty() {
634        out.push_str("\n## Warnings\n\n");
635        for w in &r.warnings {
636            out.push_str(&format!("- **{}**: {}\n", w.code(), w.message()));
637        }
638    }
639    out
640}
641
642/// Render a successful `MemDeleteResponse` as a CLI markdown block.
643/// `verb` is the CLI subcommand name (`"delete"` or `"unregister"`)
644/// — drives the heading prose so the output matches the user's
645/// invocation.
646fn render_mem_delete_markdown(r: &MemDeleteResponse, verb: &str) -> String {
647    let past_participle = match verb {
648        "unregister" => "unregistered",
649        _ => "deleted",
650    };
651    let mut out = format!("# Mem `{}` {past_participle}\n\n", r.name);
652    out.push_str(&format!(
653        "- Removed from router: {}\n",
654        r.deleted_from_router,
655    ));
656    out.push_str(&format!("- Files deleted: {}\n", r.files_deleted));
657    // Surface every scrubbed `.memstead/workspace.toml` entry so the
658    // operator sees what the destructive delete just cleaned up.
659    if !r.allowlist_entries_removed.is_empty() {
660        out.push_str("\n## Allowlist entries scrubbed\n\n");
661        for entry in &r.allowlist_entries_removed {
662            match (&entry.pattern, &entry.from, &entry.to) {
663                (Some(p), _, _) => {
664                    out.push_str(&format!("- `[{}]` pattern `{p}`\n", entry.table,));
665                }
666                (_, Some(from), Some(to)) => {
667                    out.push_str(&format!("- `[{}]` `{from} → {to}`\n", entry.table,));
668                }
669                _ => {
670                    out.push_str(&format!("- `[{}]`\n", entry.table));
671                }
672            }
673        }
674    }
675    if !r.warnings.is_empty() {
676        out.push_str("\n## Warnings\n\n");
677        for w in &r.warnings {
678            out.push_str(&format!("- **{}**: {}\n", w.code(), w.message()));
679        }
680    }
681    out
682}
683
684/// Lift a `FullEngineError` into a typed `CliError`. The lift sources
685/// every field from the engine error directly — `err.code()` for the
686/// wire token, `err.details()` for the structured payload,
687/// `err.prose_render()` for the text message. Wrapped lean errors
688/// delegate to [`crate::CliError::from_engine_op`] so the per-variant
689/// exit-kind mapping (`NotFound` → exit 3, `HashMismatch` → exit 4,
690/// validation → exit 5, generic → exit 1) is consumed in one place;
691/// lifecycle variants (`MEM_PATH_NOT_ALLOWED`,
692/// `MEM_SCHEMA_NOT_ALLOWED`, `MEM_REFERENCED_BY_POLICY`,
693/// `INVALID_MEM_NAME`, `CONFIG_ERROR`, `MEM_STORAGE_RESIDUE_DETECTED`)
694/// are user-recoverable validation refusals and land at exit 5.
695///
696/// Sourcing from the engine error directly means any new engine code
697/// automatically reaches the CLI envelope without a hand-maintained
698/// translation table to update.
699fn pro_engine_err_to_cli(err: memstead_engine::FullEngineError) -> anyhow::Error {
700    match err {
701        memstead_engine::FullEngineError::Lean(inner) => CliError::from_engine_op(inner).into(),
702        lifecycle => {
703            let code = lifecycle.code();
704            let details = lifecycle.details();
705            let message = lifecycle.prose_render();
706            CliError {
707                kind: ExitKind::Validation,
708                code,
709                message,
710                details: Some(details),
711            }
712            .into()
713        }
714    }
715}
716
717/// `memstead mem set-version <NAME> <VERSION>` — bump the mem's
718/// `version` field via the in-process engine, persisting through the
719/// backend's `write_mem_config`. Unlike `init` / `delete`, this
720/// surface doesn't spawn the MCP subprocess: set-version is gate-free
721/// (no operator-mode bypass needed), so a direct engine call keeps
722/// the implementation simpler and faster.
723pub fn run_set_version(ctx: &CliContext, args: SetVersionArgs) -> anyhow::Result<()> {
724    let new_version = semver::Version::parse(&args.version).map_err(|e| {
725        invalid_input_error(format!(
726            "version {:?} is not a valid semver: {e}",
727            args.version,
728        ))
729    })?;
730
731    let note = args.note.as_deref();
732    let outcome = match ctx.cli_engine()? {
733        crate::setup::CliEngine::MemRepo(mut engine) => engine
734            .set_mem_version(&args.name, new_version, note)
735            .map_err(crate::CliError::from_engine_op)?,
736        crate::setup::CliEngine::Filesystem(mut engine) => engine
737            .set_mem_version(&args.name, new_version, note)
738            .map_err(crate::CliError::from_engine_op)?,
739    };
740
741    if ctx.json {
742        crate::output::print_json(&outcome)?;
743    } else {
744        let old = outcome
745            .old_version
746            .as_ref()
747            .map(|v| v.to_string())
748            .unwrap_or_else(|| "<none>".to_string());
749        let warnings = if outcome.warnings.is_empty() {
750            String::new()
751        } else {
752            let rendered: Vec<String> = outcome.warnings.iter().map(ToString::to_string).collect();
753            format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "))
754        };
755        crate::output::print_markdown(&format!(
756            "# Mem `{}` version updated\n\n- Old version: {}\n- New version: {}{}",
757            outcome.mem, old, outcome.new_version, warnings,
758        ));
759    }
760    Ok(())
761}
762
763/// `memstead mem set-description <NAME> <DESCRIPTION>` — set or clear
764/// the mem's one-line description via the in-process engine,
765/// persisting through the backend's `write_mem_config`. Like
766/// set-version, this surface is gate-free and calls the engine
767/// directly. An empty DESCRIPTION clears the field.
768pub fn run_set_description(ctx: &CliContext, args: SetDescriptionArgs) -> anyhow::Result<()> {
769    let new_description = {
770        let trimmed = args.description.trim();
771        if trimmed.is_empty() {
772            None
773        } else {
774            Some(trimmed.to_string())
775        }
776    };
777    let note = args.note.as_deref();
778    let outcome = match ctx.cli_engine()? {
779        crate::setup::CliEngine::MemRepo(mut engine) => engine
780            .set_mem_description(&args.name, new_description, note)
781            .map_err(crate::CliError::from_engine_op)?,
782        crate::setup::CliEngine::Filesystem(mut engine) => engine
783            .set_mem_description(&args.name, new_description, note)
784            .map_err(crate::CliError::from_engine_op)?,
785    };
786
787    if ctx.json {
788        crate::output::print_json(&outcome)?;
789    } else {
790        let old = outcome.old_description.as_deref().unwrap_or("<none>");
791        let new = outcome.new_description.as_deref().unwrap_or("<cleared>");
792        let warnings = if outcome.warnings.is_empty() {
793            String::new()
794        } else {
795            let rendered: Vec<String> = outcome.warnings.iter().map(ToString::to_string).collect();
796            format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "))
797        };
798        crate::output::print_markdown(&format!(
799            "# Mem `{}` description updated\n\n- Old: {}\n- New: {}{}",
800            outcome.mem, old, new, warnings,
801        ));
802    }
803    Ok(())
804}
805
806/// `memstead mem set-internal <NAME> [--off]` arguments.
807#[derive(Args, Debug)]
808pub struct SetInternalArgs {
809    /// Mem name (must be registered in the workspace).
810    pub name: String,
811
812    /// Unmark the mem as internal (make it visible in the default overview
813    /// again). Without this flag, the mem is marked internal.
814    #[arg(long)]
815    pub off: bool,
816
817    /// Optional provenance note (≤280 chars) recorded on the commit body.
818    #[arg(long)]
819    pub note: Option<String>,
820}
821
822/// `memstead mem set-internal <NAME> [--off]` — mark or unmark a mem as
823/// internal (hidden from the default overview roster + public projections).
824pub fn run_set_internal(ctx: &CliContext, args: SetInternalArgs) -> anyhow::Result<()> {
825    let internal = !args.off;
826    let note = args.note.as_deref();
827    let applied = match ctx.cli_engine()? {
828        crate::setup::CliEngine::MemRepo(mut engine) => engine
829            .set_mem_internal(&args.name, internal, note)
830            .map_err(crate::CliError::from_engine_op)?,
831        crate::setup::CliEngine::Filesystem(mut engine) => engine
832            .set_mem_internal(&args.name, internal, note)
833            .map_err(crate::CliError::from_engine_op)?,
834    };
835
836    if ctx.json {
837        crate::output::print_json(&serde_json::json!({ "mem": args.name, "internal": applied }))?;
838    } else {
839        crate::output::print_markdown(&format!(
840            "# Mem `{}` {}\n\nHidden from the default overview: **{}**. Inspect with \
841             `memstead overview --mem {}`.",
842            args.name,
843            if applied {
844                "marked internal"
845            } else {
846                "un-marked internal"
847            },
848            applied,
849            args.name,
850        ));
851    }
852    Ok(())
853}
854
855/// `memstead mem set-sync-state <NAME> <KEY> <TOKEN>` — set or clear
856/// one opaque sync-state token in a mem's config via the in-process
857/// engine, persisting through the backend's `write_mem_config`. Like
858/// set-version, this surface is gate-free and calls the engine directly.
859pub fn run_set_sync_state(ctx: &CliContext, args: SetSyncStateArgs) -> anyhow::Result<()> {
860    let note = args.note.as_deref();
861    let outcome = match ctx.cli_engine()? {
862        crate::setup::CliEngine::MemRepo(mut engine) => engine
863            .set_mem_sync_state(&args.name, &args.key, &args.token, note)
864            .map_err(crate::CliError::from_engine_op)?,
865        crate::setup::CliEngine::Filesystem(mut engine) => engine
866            .set_mem_sync_state(&args.name, &args.key, &args.token, note)
867            .map_err(crate::CliError::from_engine_op)?,
868    };
869
870    if ctx.json {
871        crate::output::print_json(&outcome)?;
872    } else {
873        let action = if outcome.removed {
874            "cleared".to_string()
875        } else if outcome.previous.is_some() {
876            "overwrote".to_string()
877        } else {
878            "set".to_string()
879        };
880        let warnings = if outcome.warnings.is_empty() {
881            String::new()
882        } else {
883            let rendered: Vec<String> = outcome.warnings.iter().map(ToString::to_string).collect();
884            format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "))
885        };
886        crate::output::print_markdown(&format!(
887            "# Mem `{}` sync state {}\n\n- Key: `{}`{}",
888            outcome.mem, action, outcome.key, warnings,
889        ));
890    }
891    Ok(())
892}
893
894pub fn run_set_schema(ctx: &CliContext, args: SetSchemaArgs) -> anyhow::Result<()> {
895    let target: memstead_schema::SchemaRef = args
896        .schema
897        .parse()
898        .map_err(|e| invalid_input_error(format!("invalid schema ref {:?}: {e}", args.schema)))?;
899    let outcome = match ctx.cli_engine()? {
900        crate::setup::CliEngine::MemRepo(mut engine) => engine
901            .set_mem_schema(&args.name, &target)
902            .map_err(crate::CliError::from_engine_op)?,
903        crate::setup::CliEngine::Filesystem(mut engine) => engine
904            .set_mem_schema(&args.name, &target)
905            .map_err(crate::CliError::from_engine_op)?,
906    };
907    if ctx.json {
908        crate::output::print_json(&outcome)?;
909    } else {
910        let findings = if outcome.findings.is_empty() {
911            String::new()
912        } else {
913            let rendered: Vec<String> = outcome
914                .findings
915                .iter()
916                .map(|f| format!("- {} — {}", f.id, f.code))
917                .collect();
918            format!("\n\n## Non-integral entities\n\n{}", rendered.join("\n"))
919        };
920        crate::output::print_markdown(&format!(
921            "# Mem `{}` schema: {:?}\n\n- Pin: {}\n- Migration target: {}{}",
922            outcome.mem,
923            outcome.outcome,
924            outcome.schema_pin,
925            outcome.migration_target.as_deref().unwrap_or("<none>"),
926            findings,
927        ));
928    }
929    Ok(())
930}
931
932fn generic_error(msg: String) -> anyhow::Error {
933    CliError {
934        code: "MEM_ERROR",
935        kind: ExitKind::Generic,
936        message: msg,
937        details: None,
938    }
939    .into()
940}
941
942fn validation_error(msg: String) -> anyhow::Error {
943    CliError {
944        code: "VALIDATION_FAILED",
945        kind: ExitKind::Validation,
946        message: msg,
947        details: None,
948    }
949    .into()
950}
951
952fn invalid_input_error(msg: String) -> anyhow::Error {
953    CliError {
954        code: "INVALID_INPUT",
955        kind: ExitKind::Validation,
956        message: msg,
957        details: None,
958    }
959    .into()
960}
961
962/// Bridge the three single-purpose CLI flags into a single
963/// `RecoveryAction` enum value. clap's `group = "recovery_action"`
964/// annotation on each flag enforces the mutex at parse time, so at most
965/// one boolean is `true` here. Returns `None` for the bare invocation,
966/// mapping to the engine's tombstone-driven default (residue with
967/// tombstone → `Reattach`; residue without → refuse).
968fn recovery_from_flags(
969    reattach: bool,
970    force_overwrite: bool,
971    hard_cleanup_first: bool,
972) -> Option<memstead_engine::RecoveryAction> {
973    if reattach {
974        Some(memstead_engine::RecoveryAction::Reattach)
975    } else if force_overwrite {
976        Some(memstead_engine::RecoveryAction::ForceOverwrite)
977    } else if hard_cleanup_first {
978        Some(memstead_engine::RecoveryAction::HardCleanupFirst)
979    } else {
980        None
981    }
982}
983
984pub fn run_list(ctx: &CliContext, _args: ListArgs) -> anyhow::Result<()> {
985    let setup_ctx = CliContext {
986        json: ctx.json,
987        quiet: ctx.quiet,
988    };
989    let engine = crate::setup::pro_engine(&setup_ctx)
990        .map_err(|e| generic_error(format!("mem list: could not initialize engine: {e}")))?;
991
992    let mut rows: Vec<serde_json::Value> = Vec::new();
993    for name in engine.mem_names() {
994        let cfg = engine
995            .mem_configs_named()
996            .find(|(n, _)| *n == name)
997            .map(|(_, c)| c);
998        let entity_count = engine
999            .store()
1000            .all_entities()
1001            .filter(|e| e.id.mem() == name && !e.stub)
1002            .count();
1003        let capability = if engine.mem_router().is_writable(name) {
1004            "write"
1005        } else {
1006            "read_only"
1007        };
1008        rows.push(serde_json::json!({
1009            "name": name,
1010            "schema_ref": cfg.and_then(|c| c.schema.as_ref()).map(|s| s.to_string()),
1011            "version": cfg.and_then(|c| c.version.clone()),
1012            "entity_count": entity_count,
1013            "capability": capability,
1014        }));
1015    }
1016
1017    if ctx.json {
1018        crate::output::print_json(&serde_json::json!({ "mems": rows }))?;
1019        return Ok(());
1020    }
1021
1022    let mut lines: Vec<String> = vec![format!("# Mems ({})", rows.len()), String::new()];
1023    if rows.is_empty() {
1024        lines.push("_no mems mounted_".to_string());
1025    } else {
1026        for v in &rows {
1027            let name = v["name"].as_str().unwrap_or("?");
1028            let schema = v["schema_ref"].as_str().unwrap_or("—");
1029            let version = v["version"].as_str().unwrap_or("—");
1030            let count = v["entity_count"].as_u64().unwrap_or(0);
1031            let cap = v["capability"].as_str().unwrap_or("?");
1032            lines.push(format!(
1033                "- `{name}` ({cap}) — schema `{schema}`, version `{version}`, {count} entities"
1034            ));
1035        }
1036    }
1037    crate::output::print_markdown(&lines.join("\n"));
1038    Ok(())
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044    use memstead_base::EngineError;
1045    use memstead_base::ReferrerInfo;
1046    use memstead_engine::FullEngineError;
1047    use std::path::PathBuf;
1048
1049    fn lifted_cli_error(err: FullEngineError) -> CliError {
1050        let any = pro_engine_err_to_cli(err);
1051        any.downcast::<CliError>()
1052            .expect("pro_engine_err_to_cli must lift to a CliError")
1053    }
1054
1055    /// The client-side mem-template consumer surfaces a built-in
1056    /// schema's instance guidance keys when `--write-guidance` is
1057    /// omitted, stays silent when guidance is given, and is silent for a
1058    /// schema that ships no template.
1059    #[test]
1060    fn mem_template_guidance_note_surfaces_builtin_keys() {
1061        let planning: memstead_schema::SchemaRef = "planning@0.1.0".parse().unwrap();
1062        let note = mem_template_guidance_note(&planning, false)
1063            .expect("planning ships a mem-template — a note is due");
1064        assert!(note.contains("phase_context"), "note names the key: {note}");
1065        assert!(
1066            note.contains("--write-guidance"),
1067            "note tells how to fill: {note}"
1068        );
1069        // Operator supplied guidance → nothing to surface.
1070        assert!(mem_template_guidance_note(&planning, false).is_some());
1071        assert!(mem_template_guidance_note(&planning, true).is_none());
1072        // A schema with no mem-template → no note.
1073        let default_: memstead_schema::SchemaRef = "default@1.0.0".parse().unwrap();
1074        assert!(mem_template_guidance_note(&default_, false).is_none());
1075    }
1076
1077    /// The CLI's mem command surface does not translate the engine's
1078    /// typed code through a static table — a code added on the engine
1079    /// side reaches the CLI envelope unchanged. Pins the regression
1080    /// where `MEM_HAS_INCOMING_REFS` silently degraded to
1081    /// `VALIDATION_FAILED`.
1082    #[test]
1083    fn mem_has_incoming_refs_keeps_typed_code_and_carries_details() {
1084        let err = FullEngineError::Lean(EngineError::MemHasIncomingRefs {
1085            mem: "other".to_string(),
1086            referrers: vec![ReferrerInfo {
1087                from_id: "test--source".to_string(),
1088                rel_types: vec!["USES".to_string()],
1089                mem: "test".to_string(),
1090            }],
1091        });
1092        let cli = lifted_cli_error(err);
1093        assert_eq!(cli.code, "MEM_HAS_INCOMING_REFS");
1094        assert_eq!(cli.kind, ExitKind::Validation);
1095        let details = cli.details.expect("details must reach the CLI envelope");
1096        assert_eq!(details["mem"], "other");
1097        let referrers = details["referrers"].as_array().expect("referrers array");
1098        assert_eq!(referrers.len(), 1);
1099        assert_eq!(referrers[0]["from_id"], "test--source");
1100        assert_eq!(referrers[0]["mem"], "test");
1101    }
1102
1103    /// Lifecycle refusal (a full-only variant) is
1104    /// promoted through with the same code + structured details the
1105    /// MCP wire ships. `MEM_PATH_NOT_ALLOWED` carries the candidate,
1106    /// the patterns list, and the typed reason discriminator.
1107    #[test]
1108    fn mem_path_not_allowed_carries_structured_details() {
1109        let err = FullEngineError::MemPathNotAllowed {
1110            attempted: PathBuf::from("/ws/bogus"),
1111            candidate: "bogus".to_string(),
1112            patterns: vec!["specs".to_string(), "team/*".to_string()],
1113            reason: "no_match",
1114            policy_table: "mem_management.create",
1115        };
1116        let cli = lifted_cli_error(err);
1117        assert_eq!(cli.code, "MEM_PATH_NOT_ALLOWED");
1118        assert_eq!(cli.kind, ExitKind::Validation);
1119        let details = cli.details.expect("details");
1120        assert_eq!(details["candidate"], "bogus");
1121        assert_eq!(details["reason"], "no_match");
1122        assert_eq!(details["patterns"][0], "specs");
1123        // The `policy_table` disambiguator reaches the CLI envelope.
1124        assert_eq!(details["policy_table"], "mem_management.create");
1125        assert_eq!(details["patterns"][1], "team/*");
1126    }
1127
1128    /// `VALIDATION_FAILED` is not
1129    /// used as the fallback for engine-sourced refusals. A
1130    /// typed lifecycle variant must not degrade to the catch-all.
1131    #[test]
1132    fn lifecycle_refusal_never_degrades_to_validation_failed_token() {
1133        let cases = [
1134            FullEngineError::MemPathNotAllowed {
1135                attempted: PathBuf::from("/x"),
1136                candidate: "x".to_string(),
1137                patterns: vec![],
1138                reason: "no_allowlist_configured",
1139                policy_table: "mem_management.create",
1140            },
1141            FullEngineError::MemReferencedByPolicy {
1142                name: "x".to_string(),
1143                referring_mems: vec!["y".to_string()],
1144            },
1145            FullEngineError::MemSchemaNotAllowed {
1146                candidate: "x".to_string(),
1147                matched_pattern: "p".to_string(),
1148                requested_schema: "default@1.0.0".to_string(),
1149                allowed_schemas: vec!["other@1.0.0".to_string()],
1150            },
1151            FullEngineError::InvalidMemName {
1152                name: "BadName".to_string(),
1153                reason: "invalid_char",
1154            },
1155        ];
1156        for err in cases {
1157            let cli = lifted_cli_error(err);
1158            assert_ne!(
1159                cli.code, "VALIDATION_FAILED",
1160                "engine-sourced refusal must carry its typed code: got {} with details {:?}",
1161                cli.code, cli.details,
1162            );
1163        }
1164    }
1165
1166    /// Wrapped lean errors keep the
1167    /// per-variant exit-kind mapping (`NotFound` → exit 3,
1168    /// `HashMismatch` → exit 4, etc.) by delegating to
1169    /// `CliError::from_engine_op`. The lift doesn't flatten every
1170    /// lean variant to `Validation`.
1171    #[test]
1172    fn wrapped_lean_error_preserves_per_variant_exit_kind() {
1173        let err = FullEngineError::Lean(EngineError::NotFound {
1174            id: "specs--missing".to_string(),
1175        });
1176        let cli = lifted_cli_error(err);
1177        assert_eq!(cli.code, "ENTITY_NOT_FOUND");
1178        assert_eq!(cli.kind, ExitKind::NotFound);
1179        assert_eq!(cli.details.as_ref().unwrap()["id"], "specs--missing");
1180    }
1181}