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 and MCP 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, ValueEnum};
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    /// Rename a mem: `<old> <new>`, complete across every surface
59    /// that carries the name — entity-id prefixes, cross-mem edges and
60    /// wiki-links in every writable mem, anchors, workspace grants,
61    /// bindings, sync-state, findings store — with the mem's commit
62    /// history preserved (a branch move, never a fresh seed). Agent
63    /// mode requires the old name to pass `[[mem_management.delete]]`
64    /// AND the new name to pass `[[mem_management.create]]` (schema
65    /// pin unchanged). An interrupted rename is completable by
66    /// re-issuing the same command. Read-only mounts refuse.
67    Rename(RenameArgs),
68    /// Update a mem's `version` field. The version is consumed by
69    /// `memstead export --format mem` to stamp the archive filename and
70    /// the `.mem` archive's published config. `version` is seeded at
71    /// init (`0.1.0`); bump via this command before publishing.
72    #[command(name = "set-version")]
73    SetVersion(SetVersionArgs),
74    /// Set a mem's schema pin — the integrity-driven schema-migration
75    /// trigger. Already-integral mems switch immediately; otherwise
76    /// the mem enters dual-pin migration (writes validate against
77    /// the target) and the response lists the non-integral entities.
78    /// Re-issue after repairing to complete the switch.
79    #[command(name = "set-schema")]
80    SetSchema(SetSchemaArgs),
81    /// Set a mem's one-line `description` — embedded in `.mem` archive
82    /// exports and surfaced on the registry card at publish time. An
83    /// empty string clears the field. Set it before `memstead export` /
84    /// `memstead publish` so the shared archive carries its card text.
85    #[command(name = "set-description")]
86    SetDescription(SetDescriptionArgs),
87    /// Set a mem's human-readable display `title` — display text, NOT
88    /// identity (the mem name stays the sole handle everywhere). Every
89    /// surface that prints a mem prefers the title and falls back to
90    /// the name. An empty string clears it.
91    #[command(name = "set-title")]
92    SetTitle(SetTitleArgs),
93    /// Set a mem's `subject` block — scope, optional method, and the
94    /// deliberate exclusions — published verbatim in archives and on
95    /// the registry mem page. Passing only the name with no fields
96    /// clears the block as a unit.
97    #[command(name = "set-subject")]
98    SetSubject(SetSubjectArgs),
99    /// Set (or clear) one opaque sync-state token in a mem's config —
100    /// the pipeline layer's durable "last synced source state" baseline.
101    /// `<KEY>` and `<TOKEN>` are opaque to the engine (the binding layer
102    /// keys per `<binding-id>/<facet>#synced` and owns the token's
103    /// meaning). An empty `<TOKEN>` clears the key. Written into the
104    /// per-mem config and surfaced verbatim on `memstead workspace dump`.
105    #[command(name = "set-sync-state")]
106    SetSyncState(SetSyncStateArgs),
107    /// Mark (or unmark) a mem as internal — hidden from the default
108    /// `memstead overview` roster and public projections, while staying a
109    /// real, inspectable (`overview --mem <name>`), deletable mem. Ingest
110    /// process-state mems are flagged this way.
111    #[command(name = "set-internal")]
112    SetInternal(SetInternalArgs),
113    /// Enumerate every mounted mem in the workspace with its
114    /// schema pin, version, entity count, and capability (writable
115    /// vs read-only). Markdown by default; pass `--json` (root flag)
116    /// for the structured envelope.
117    List(ListArgs),
118}
119
120/// `memstead mem list` — no positional args. The verb itself is the
121/// signal; `--json` (root-level) toggles the output shape.
122#[derive(Args, Debug)]
123pub struct ListArgs {}
124
125/// `memstead mem set-version <NAME> <VERSION>` arguments.
126#[derive(Args, Debug)]
127pub struct SetVersionArgs {
128    /// Mem name (the leaf-folder identifier the engine assigned at
129    /// init time). Must already be registered in the workspace.
130    pub name: String,
131
132    /// New semver version (e.g. `0.2.0`, `1.0.0-beta.1`). Malformed
133    /// values refuse with `INVALID_INPUT`. The engine bypasses the
134    /// mem-create allowlist for this surface — set-version is
135    /// gate-free.
136    pub version: String,
137
138    /// Optional provenance note (≤280 chars) recorded on the
139    /// version-bump commit body, like the other commit-producing
140    /// mem-lifecycle commands. When the workspace sets
141    /// `require_notes`, omitting it rides a non-blocking `NOTE_MISSING`
142    /// warning (the bump still lands).
143    #[arg(long)]
144    pub note: Option<String>,
145}
146
147/// `memstead mem set-description <NAME> <DESCRIPTION>` arguments.
148#[derive(Args, Debug)]
149pub struct SetDescriptionArgs {
150    /// Mem name (must be registered in the workspace).
151    pub name: String,
152
153    /// One-line description of the mem — what a registry visitor (or
154    /// an agent browsing the catalogue) should know before installing.
155    /// An empty string clears the field.
156    pub description: String,
157
158    /// Optional provenance note (≤280 chars) recorded on the commit
159    /// body, like the other commit-producing mem-lifecycle commands.
160    #[arg(long)]
161    pub note: Option<String>,
162}
163
164/// `memstead mem set-sync-state <NAME> <KEY> <TOKEN>` arguments.
165#[derive(Args, Debug)]
166pub struct SetSyncStateArgs {
167    /// Mem name (must be registered in the workspace).
168    pub name: String,
169
170    /// Opaque sync-state key. The binding layer keys per
171    /// `<binding-id>/<facet>#synced` (and `#verified`), but the engine
172    /// treats it as an arbitrary string.
173    pub key: String,
174
175    /// Opaque token recording the source state last synced under
176    /// `<KEY>` (git → commit id, graph → snapshot token, filesystem →
177    /// a JSON-stringified stat digest). An **empty** value clears the
178    /// key. The engine never parses it.
179    pub token: String,
180
181    /// Optional provenance note (≤280 chars) recorded on the commit
182    /// body, like the other commit-producing mem-lifecycle commands.
183    #[arg(long)]
184    pub note: Option<String>,
185}
186
187/// `memstead mem set-schema <NAME> <SCHEMA>` arguments.
188#[derive(Args, Debug)]
189pub struct SetSchemaArgs {
190    /// Mem name (must be registered in the workspace).
191    pub name: String,
192
193    /// Target schema ref, exact `name@x.y.z`. Must resolve against
194    /// the loaded schema catalogue; unresolvable refs refuse with
195    /// `SCHEMA_NOT_FOUND`, malformed refs with `INVALID_INPUT`.
196    pub schema: String,
197}
198
199/// `memstead mem init <path>` arguments.
200///
201/// `--vcs-shared` translates into the engine's `vcs` block;
202/// `--no-gitignore` suppresses the outer-repo `.gitignore` append. The
203/// `<path>` argument supplies the new mem's `location` (relative
204/// to the workspace root) plus its `name` (basename of the path); a
205/// slashed `<a>/<b>` form additionally derives `--org-path a` so
206/// `memstead mem init a/b` and `memstead mem init b --org-path a` produce
207/// identical engine calls. Cross-mem edge authorization is
208/// workspace-level policy (`[cross_mem_links]` in `.memstead/workspace.toml`); the
209/// previous `--belongs-to` flag is gone.
210#[derive(Args, Debug)]
211pub struct InitArgs {
212    /// Mem name — the full hierarchical identifier (e.g. `foo` for
213    /// a flat-layout mem, `team/sub-mem` for a hierarchical
214    /// layout). The value flows through to the engine verbatim with no
215    /// auto-split or composition step. Grammar:
216    /// `[a-z0-9-]+(/[a-z0-9-]+)*` — lowercase ASCII letters, digits,
217    /// hyphens; segments separated by `/`; no leading, trailing, or
218    /// double slashes (validated engine-side; bad names return
219    /// `INVALID_INPUT`).
220    pub path: PathBuf,
221
222    /// Schema pin (`name@x.y.z`) for the new mem. Defaults to
223    /// `default@1.3.0` — the same pin `memstead init` and `memstead
224    /// quickstart` write, so every bootstrap verb produces one schema.
225    #[arg(long, default_value = "default@1.3.0")]
226    pub schema: String,
227
228    /// Pass a shared-gitdir `vcs` block to `memstead_mem_create`:
229    /// `{ "gitdir": "../.git", "worktree": ".." }`. Without this flag the
230    /// engine uses the default isolated layout.
231    #[arg(long)]
232    pub vcs_shared: bool,
233
234    /// Skip outer-repo `.gitignore` auto-append. Useful when the user
235    /// intends to track the workspace as a git submodule, or when the
236    /// detection heuristic would pick the wrong outer repo.
237    #[arg(long)]
238    pub no_gitignore: bool,
239
240    /// Optional provenance note recorded in the seed commit's body
241    /// (≤280 chars). Forwarded as the MCP tool's `note` parameter.
242    #[arg(long)]
243    pub note: Option<String>,
244
245    /// Adopt residual entities left by a prior `memstead mem unregister`
246    /// at this mem's path instead of failing on detected residue.
247    /// Default when the residue carries an `unregistered_at` tombstone
248    /// (the deliberate unregister signal); pass `--reattach` explicitly
249    /// to override for crash-residue you have verified is safe to adopt.
250    /// Mutually exclusive with `--force-overwrite` and
251    /// `--hard-cleanup-first`.
252    #[arg(long, group = "recovery_action")]
253    pub reattach: bool,
254
255    /// Destroy residual storage at this mem's path and proceed with a
256    /// fresh create: the residue is removed atomically — either it is
257    /// gone and the mem is created, or nothing changed — and the prior
258    /// entities are gone by design.
259    /// Mutually exclusive with `--reattach` and `--hard-cleanup-first`.
260    #[arg(long = "force-overwrite", group = "recovery_action")]
261    pub force_overwrite: bool,
262
263    /// Refuse with `MEM_STORAGE_RESIDUE_DETECTED` instructing the
264    /// caller to run `memstead mem delete <name>` first — a hard barrier
265    /// that keeps residue cleanup a separate, named operation rather
266    /// than destructive auto-recovery. Mutually exclusive with
267    /// `--reattach` and `--force-overwrite`.
268    #[arg(long = "hard-cleanup-first", group = "recovery_action")]
269    pub hard_cleanup_first: bool,
270
271    /// Bypass the workspace `[[mem_management.create]]` allowlist
272    /// for this invocation. The CLI honours the allowlist by default
273    /// (matching the MCP-surface posture); operator-mode is explicit
274    /// opt-in. Also settable via the `MEMSTEAD_OPERATOR_MODE=1` env var for
275    /// script convenience; the flag wins when both are set. Use this
276    /// when the CLI invocation is the operator administering the
277    /// workspace itself (initial scaffold, recovery flows) rather than
278    /// scripted/agent usage.
279    #[arg(long = "operator-mode")]
280    pub operator_mode: bool,
281
282    /// Explicit storage backend for the new mem. Omit to use the
283    /// workspace-shape default (git-branch in a mem-repo workspace,
284    /// folder otherwise). `folder` creates a plain-markdown folder mem
285    /// at the mem's location even inside a mem-repo workspace — its
286    /// files sit visibly in the outer tree; `git-branch` requires a
287    /// mem-repo and refuses without one.
288    #[arg(long, value_enum)]
289    pub storage: Option<StorageArg>,
290
291    /// Explicit on-disk location for the new mem, overriding the
292    /// default `<workspace_root>/<name>`. Relative paths anchor at the
293    /// workspace root and may leave it (`--location ../public/engineering`
294    /// — the monorepo/submodule case); the expressed form is preserved
295    /// in `mounts.json`, so a relative location stays clone-portable
296    /// while an absolute one stays machine-pinned. The location's
297    /// basename must equal the mem name's last segment
298    /// (engine-enforced). Meaningful for folder-backed mems only —
299    /// git-branch storage derives its identity from the mem name and
300    /// ignores location. Out-of-root locations refuse for agent-mode
301    /// calls (`MEM_PATH_NOT_ALLOWED` / `outside_workspace`); pass
302    /// `--operator-mode` when the operator is placing the mem.
303    #[arg(long)]
304    pub location: Option<PathBuf>,
305
306    /// Optional per-instance writing guidance as a JSON object, written
307    /// verbatim into the new mem's config `writeGuidance` map — e.g.
308    /// `--write-guidance '{"phase_context":"early design","stack":"Rust"}'`.
309    /// Opaque to the engine (schema-strictness D8 — the keys are
310    /// client-owned vocabulary); a wrapper that read the schema
311    /// package's `mem-template.json` fills the instance keys. Omit to
312    /// seed no guidance. Must be a JSON object; anything else refuses
313    /// with `INVALID_INPUT`.
314    #[arg(long = "write-guidance")]
315    pub write_guidance: Option<String>,
316}
317
318/// `--storage` values for `memstead mem init` — the CLI face of
319/// [`mem_management::StorageKind`]. Kebab-case on the wire
320/// (`folder` / `git-branch`).
321#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
322pub enum StorageArg {
323    /// Plain-markdown folder mount at the mem's location — files
324    /// visible in the outer tree, even inside a mem-repo workspace.
325    Folder,
326    /// Per-mem branch in the workspace's mem-repo. Refuses when the
327    /// workspace has no `mem-repo/.git/`.
328    GitBranch,
329}
330
331impl From<StorageArg> for mem_management::StorageKind {
332    fn from(arg: StorageArg) -> Self {
333        match arg {
334            StorageArg::Folder => mem_management::StorageKind::Folder,
335            StorageArg::GitBranch => mem_management::StorageKind::GitBranch,
336        }
337    }
338}
339
340/// `memstead mem delete <name>` arguments — full destruction. The
341/// CLI honours the workspace `[[mem_management.delete]]`
342/// allowlist by default; pass `--operator-mode` or set
343/// `MEMSTEAD_OPERATOR_MODE=1` to skip the allowlist. The
344/// `MEM_REFERENCED_BY_POLICY` and `MEM_HAS_INCOMING_REFS`
345/// safeguards always fire regardless of operator-mode. The verb
346/// uniquely identifies the storage-destroying intent — use
347/// `memstead mem unregister` for router-only removal.
348///
349/// On success delete scrubs only the now-dangling
350/// `[cross_mem_links]` grants naming this mem on either side
351/// (reported in the `## Allowlist entries scrubbed` block of the
352/// response) — those reference the gone instance and would otherwise
353/// dangle. The workspace's `[[mem_management.create]]` /
354/// `[[mem_management.delete]]` allowlist rules are PRESERVED, exact
355/// name and glob alike: they are forward-looking permissions for the
356/// name, not references to the instance. Re-creating a mem of the
357/// same name afterward needs no fresh `allow-create` / `allow-delete`
358/// grant.
359#[derive(Args, Debug)]
360pub struct DeleteArgs {
361    /// Name of the mem to destroy.
362    pub name: String,
363
364    /// Optional provenance note (≤280 chars). Captured on the engine
365    /// trace surface; surfaces via the outer-repo Stop hook. No
366    /// per-mem commit is produced by delete.
367    #[arg(long)]
368    pub note: Option<String>,
369
370    /// Bypass the workspace `[[mem_management.delete]]` allowlist
371    /// for this invocation. See `InitArgs::operator_mode` for the
372    /// full design rationale. Also settable via `MEMSTEAD_OPERATOR_MODE=1`.
373    #[arg(long = "operator-mode")]
374    pub operator_mode: bool,
375
376    /// Mem-replacement affordance: skip the `MEM_HAS_INCOMING_REFS`
377    /// refusal and leave surviving Write-Mems' cross-mem edges into
378    /// this mem dangling as stubs. The referrers' files stay
379    /// untouched; a later `memstead mem init <same name>` re-adopts
380    /// the edges. Use when re-homing a mem (backend or location
381    /// change) under a stable name — the response lists every
382    /// detached referrer so re-adoption can be verified.
383    #[arg(long = "detach-incoming")]
384    pub detach_incoming: bool,
385}
386
387/// `memstead mem unregister <name>` arguments — router-only removal,
388/// storage preserved. The CLI honours the workspace
389/// `[[mem_management.delete]]` allowlist by default; pass
390/// `--operator-mode` or set `MEMSTEAD_OPERATOR_MODE=1` to skip the
391/// allowlist. The `MEM_REFERENCED_BY_POLICY` safeguard does not
392/// apply to unregister (storage is preserved), so unregistering a
393/// mem with cross-mem grants pointing at it succeeds without
394/// refusing — the data the grants rely on survives.
395///
396/// Refuses with `MEM_HAS_INCOMING_REFS` when an entity in another
397/// Write-Mem still carries a graph edge into this mem (`details.referrers`
398/// names each `{from_id, rel_types, mem}`) — remove those edges via
399/// `memstead relate --remove` / `memstead update` first. This guard fires for
400/// `unregister` just as it does for `delete`: the edge-graph axis is
401/// independent of the storage-preservation choice, so a gentle
402/// removal that left dangling cross-mem edges would be just as broken.
403#[derive(Args, Debug)]
404pub struct UnregisterArgs {
405    /// Name of the mem to unregister.
406    pub name: String,
407
408    /// Optional provenance note (≤280 chars). Captured on the engine
409    /// trace surface; surfaces via the outer-repo Stop hook.
410    #[arg(long)]
411    pub note: Option<String>,
412
413    /// Bypass the workspace `[[mem_management.delete]]` allowlist
414    /// for this invocation. See `InitArgs::operator_mode` for the
415    /// full design rationale. Also settable via `MEMSTEAD_OPERATOR_MODE=1`.
416    #[arg(long = "operator-mode")]
417    pub operator_mode: bool,
418}
419
420pub fn run(ctx: &CliContext, args: InitArgs) -> anyhow::Result<()> {
421    let cwd = std::env::current_dir()
422        .map_err(|e| generic_error(format!("determine current directory: {e}")))?;
423
424    // Locate the workspace via the post-rebuild marker
425    // (`.memstead/workspace.toml`). The presence of this file is the
426    // engine's own boot precondition — `memstead-mcp` walks for it too.
427    let workspace_root = find_workspace_root(&cwd).ok_or_else(|| {
428        validation_error(format!(
429            "no workspace found above {}. Run `memstead mem-repo init` first or \
430             change directory into an existing workspace.",
431            cwd.display(),
432        ))
433    })?;
434
435    // Hierarchical paths are first-class mem identifiers. The CLI forwards
436    // the `<PATH>` argument verbatim as `params.name` (`team/sub-mem`
437    // or just `sub-mem` — the engine's mem-name grammar
438    // validates the shape). There is no `--org-path` flag or path-vs-name
439    // auto-split — the value flows through unchanged.
440    let mem_name = args.path.to_str().map(|s| s.to_string()).ok_or_else(|| {
441        invalid_input_error(format!(
442            "mem name {:?} is not valid UTF-8 — mem names must be ASCII \
443                 (lowercase letters / digits / hyphens) optionally segmented by '/'.",
444            args.path.display(),
445        ))
446    })?;
447    // `--location` overrides the default `<name>` location (both are
448    // workspace-root-relative unless absolute); the engine's basename
449    // invariant keeps name leaf and on-disk basename aligned.
450    let location: PathBuf = args
451        .location
452        .clone()
453        .unwrap_or_else(|| PathBuf::from(&mem_name));
454
455    let schema_ref: memstead_schema::SchemaRef = args
456        .schema
457        .parse()
458        .map_err(|e| invalid_input_error(format!("invalid schema ref {:?}: {e}", args.schema)))?;
459    let vcs_config = if args.vcs_shared {
460        Some(memstead_schema::VcsConfig {
461            gitdir: "../.git".to_string(),
462            worktree: "..".to_string(),
463        })
464    } else {
465        None
466    };
467    let write_guidance = match &args.write_guidance {
468        None => std::collections::HashMap::new(),
469        Some(raw) => {
470            serde_json::from_str::<std::collections::HashMap<String, serde_json::Value>>(raw)
471                .map_err(|e| {
472                    invalid_input_error(format!("--write-guidance must be a JSON object: {e}"))
473                })?
474        }
475    };
476    let params = MemCreateParams {
477        name: mem_name.clone(),
478        location,
479        schema_ref,
480        vcs: vcs_config,
481        note: args.note.clone(),
482        write_guidance,
483        // The workspace `[[mem_management.create]]`
484        // allowlist applies to CLI calls by default; the operator
485        // opts into bypass explicitly via `--operator-mode` (flag
486        // wins) or `MEMSTEAD_OPERATOR_MODE=1` (env-var fallback).
487        operator_mode: resolve_operator_mode(args.operator_mode),
488        recovery: recovery_from_flags(args.reattach, args.force_overwrite, args.hard_cleanup_first),
489        // Explicit storage override (`--storage folder|git-branch`);
490        // omitted flag keeps the engine's workspace-shape heuristic.
491        storage: args.storage.map(Into::into),
492        // CLI-direct provenance, matching the entity mutations'
493        // `Actor::Cli, None` convention.
494        actor: memstead_base::vcs::Actor::Cli,
495        client: None,
496    };
497
498    let mut engine = match ctx.cli_engine()? {
499        CliEngine::MemRepo(e) => e,
500        CliEngine::Filesystem(_) => {
501            // Same situation, same code: every mem-repo-only verb
502            // refuses a filesystem-mem workspace with
503            // `UNSUPPORTED_WORKSPACE_SHAPE` (the code the cold-start
504            // disclosure teaches by name). The message text is
505            // unchanged; only the typed code routes differently.
506            return Err(CliError {
507                kind: ExitKind::Generic,
508                code: "UNSUPPORTED_WORKSPACE_SHAPE",
509                message: format!(
510                    "`memstead mem init` requires a mem-repo workspace; the workspace at {} is filesystem-shaped. Use `memstead mem-repo init` first to migrate.",
511                    workspace_root.display(),
512                ),
513                details: None,
514            }
515            .into());
516        }
517    };
518    let response =
519        mem_management::create_mem(&mut engine, params).map_err(full_engine_err_to_cli)?;
520    if ctx.json {
521        crate::output::print_json(&serde_json::json!({
522            "name": response.name,
523            "location": response.location,
524            "schema_ref": response.schema_ref.to_string(),
525            "seed_commit_sha": response.seed_commit_sha,
526            // The reattach branch surfaces `MEM_REATTACHED_AFTER_UNREGISTER`
527            // through the response envelope rather than dropping it on
528            // the floor. Fresh-create ships an empty array.
529            "warnings": response
530                .warnings
531                .iter()
532                .map(|w| serde_json::json!({"code": w.code(), "message": w.message()}))
533                .collect::<Vec<_>>(),
534        }))?;
535    } else {
536        crate::output::print_markdown(&render_mem_create_markdown(&response));
537    }
538
539    // Outer-repo gitignore handling. Append `mem-repo/` (the post-cutover
540    // gitignore target — every mem's content lives inside that one
541    // directory) to the outer repo's `.gitignore`. Idempotent on re-run;
542    // refuses when the outer is `$HOME`. Skipped for an explicit-folder
543    // create: a folder mem's visibility in the outer tree is the point,
544    // and `mem-repo/` is unrelated to it.
545    if !args.no_gitignore && args.storage != Some(StorageArg::Folder) {
546        let mem_repo_path = workspace_root.join("mem-repo");
547        // Walk from the workspace root itself so the workspace-IS-the-
548        // repo-root layout appends too; `mem-repo/.git` sits a level
549        // below and is never rediscovered as the outer.
550        let walk_start = workspace_root.clone();
551        // Outer-repo provenance is human-facing context, not part of the
552        // structured result. It goes to stderr — never stdout — so a `--json`
553        // caller's stdout stays exactly one JSON document (the contract
554        // `--help` advertises and steers callers to pipe through `jq`). A
555        // human still sees it on the terminal in normal runs; `--quiet`
556        // suppresses it, the first time this site consults the flag.
557        match apply_outer_gitignore(&walk_start, &mem_repo_path)? {
558            OuterRepoOutcome::Appended { outer_root, rel } => {
559                if !ctx.quiet {
560                    eprintln!(
561                        "  outer:    {} — added `{}` to .gitignore",
562                        outer_root.display(),
563                        rel,
564                    );
565                }
566            }
567            OuterRepoOutcome::AlreadyIgnored { outer_root, rel } => {
568                if !ctx.quiet {
569                    eprintln!(
570                        "  outer:    {} — `{}` already in .gitignore, no change",
571                        outer_root.display(),
572                        rel,
573                    );
574                }
575            }
576            OuterRepoOutcome::NoOuter | OuterRepoOutcome::Skipped => {}
577        }
578    }
579
580    // Client-side mem-template consumption: when the operator did not
581    // supply --write-guidance, surface the resolved schema's
582    // mem-template instance keys so they know what to fill. The engine
583    // treats `writeGuidance` opaquely — filling is the operator's job.
584    if let Some(note) =
585        mem_template_guidance_note(&response.schema_ref, args.write_guidance.is_some())
586        && !ctx.quiet
587    {
588        eprintln!("  template: {note}");
589    }
590
591    Ok(())
592}
593
594/// When the operator did not supply `--write-guidance`, surface the
595/// resolved (built-in) schema's `mem-template.json` instance guidance
596/// keys so they know what to fill. Returns the operator notice, or
597/// `None` when there is nothing to surface — guidance was already given,
598/// the schema ships no template, or its template carries no guidance.
599/// Reads only built-in templates; an installed/authored package's
600/// template is a follow-up.
601fn mem_template_guidance_note(
602    schema_ref: &memstead_schema::SchemaRef,
603    guidance_given: bool,
604) -> Option<String> {
605    if guidance_given {
606        return None;
607    }
608    let template = memstead_schema::builtins::builtin_mem_template(&schema_ref.name)?;
609    let wg = template.get("writeGuidance")?.as_object()?;
610    if wg.is_empty() {
611        return None;
612    }
613    let keys: Vec<&str> = wg.keys().map(String::as_str).collect();
614    let first = keys.first().copied().unwrap_or("key");
615    Some(format!(
616        "schema {schema_ref} ships a mem-template with instance guidance key(s) [{}] — \
617         the mem was created without guidance. Re-run with \
618         --write-guidance '{{\"{first}\": \"…\"}}' (or edit the mem config) to fill them.",
619        keys.join(", "),
620    ))
621}
622
623/// `memstead mem rename <old> <new>` arguments.
624#[derive(Args, Debug)]
625pub struct RenameArgs {
626    /// Current mem name.
627    pub old: String,
628    /// New mem name (mem-name grammar; must not be registered).
629    pub new: String,
630    /// Agent-authored provenance note (≤280 chars), carried on every
631    /// commit the rename produces.
632    #[arg(long)]
633    pub note: Option<String>,
634    /// Bypass both workspace allowlists (`[[mem_management.delete]]`
635    /// for the old name, `[[mem_management.create]]` for the new) for
636    /// this invocation — same posture as `mem init` / `mem delete`.
637    /// Also settable via `MEMSTEAD_OPERATOR_MODE=1`.
638    #[arg(long)]
639    pub operator_mode: bool,
640}
641
642pub fn run_rename(ctx: &CliContext, args: RenameArgs) -> anyhow::Result<()> {
643    let cwd = std::env::current_dir()
644        .map_err(|e| generic_error(format!("determine current directory: {e}")))?;
645    let workspace_root = find_workspace_root(&cwd).ok_or_else(|| {
646        validation_error(format!(
647            "no workspace found above {}. `memstead mem rename` must run \
648             inside a configured workspace.",
649            cwd.display(),
650        ))
651    })?;
652
653    let mut engine = match ctx.cli_engine()? {
654        CliEngine::MemRepo(e) => e,
655        CliEngine::Filesystem(_) => {
656            return Err(validation_error(format!(
657                "`memstead mem rename` requires a mem-repo workspace; the workspace at {} is filesystem-shaped.",
658                workspace_root.display(),
659            )));
660        }
661    };
662    let params = mem_management::MemRenameParams {
663        old: args.old,
664        new: args.new,
665        operator_mode: resolve_operator_mode(args.operator_mode),
666        note: args.note,
667    };
668    let response =
669        mem_management::rename_mem(&mut engine, params).map_err(full_engine_err_to_cli)?;
670    if ctx.json {
671        crate::output::print_json(&serde_json::json!({
672            "old": response.old,
673            "new": response.new,
674            "rewritten_mems": response.rewritten_mems,
675            "resumed": response.resumed,
676            "warnings": response
677                .warnings
678                .iter()
679                .map(|w| serde_json::json!({"code": w.code(), "message": w.message()}))
680                .collect::<Vec<_>>(),
681        }))?;
682    } else {
683        let mut out = if response.resumed {
684            format!(
685                "# Mem rename completed\n\n`{}` → `{}` — the identity flip had already \
686                 happened; the remaining reference sweep and store relocations ran.\n",
687                response.old, response.new,
688            )
689        } else {
690            format!("# Mem `{}` renamed to `{}`\n", response.old, response.new)
691        };
692        if !response.rewritten_mems.is_empty() {
693            out.push_str(&format!(
694                "\n- Reference rewrites committed in: {}\n",
695                response
696                    .rewritten_mems
697                    .iter()
698                    .map(|m| format!("`{m}`"))
699                    .collect::<Vec<_>>()
700                    .join(", "),
701            ));
702        }
703        if !response.warnings.is_empty() {
704            out.push_str("\n## Warnings\n\n");
705            for w in &response.warnings {
706                out.push_str(&format!("- **{}**: {}\n", w.code(), w.message()));
707            }
708        }
709        crate::output::print_markdown(&out);
710    }
711    Ok(())
712}
713
714pub fn run_delete(ctx: &CliContext, args: DeleteArgs) -> anyhow::Result<()> {
715    run_delete_inner(
716        ctx,
717        args.name,
718        args.note,
719        /* delete_files */ true,
720        "delete",
721        resolve_operator_mode(args.operator_mode),
722        args.detach_incoming,
723    )
724}
725
726pub fn run_unregister(ctx: &CliContext, args: UnregisterArgs) -> anyhow::Result<()> {
727    run_delete_inner(
728        ctx,
729        args.name,
730        args.note,
731        /* delete_files */ false,
732        "unregister",
733        resolve_operator_mode(args.operator_mode),
734        /* detach_incoming */ false,
735    )
736}
737
738/// Resolve the effective operator-mode for a CLI invocation. The
739/// workspace allowlist applies by default; the operator opts into
740/// bypass via `--operator-mode` (highest precedence) or the
741/// `MEMSTEAD_OPERATOR_MODE` env var. The env-var accepts `1`, `true`, `yes`
742/// (case-insensitive); any other value is treated as unset.
743fn resolve_operator_mode(flag: bool) -> bool {
744    if flag {
745        return true;
746    }
747    match std::env::var("MEMSTEAD_OPERATOR_MODE") {
748        Ok(v) => matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"),
749        Err(_) => false,
750    }
751}
752
753fn run_delete_inner(
754    ctx: &CliContext,
755    name: String,
756    note: Option<String>,
757    delete_files: bool,
758    verb: &str,
759    operator_mode: bool,
760    detach_incoming: bool,
761) -> anyhow::Result<()> {
762    let cwd = std::env::current_dir()
763        .map_err(|e| generic_error(format!("determine current directory: {e}")))?;
764    let workspace_root = find_workspace_root(&cwd).ok_or_else(|| {
765        validation_error(format!(
766            "no workspace found above {}. `memstead mem {verb}` must run \
767             inside a configured workspace.",
768            cwd.display(),
769        ))
770    })?;
771
772    let params = MemDeleteParams {
773        name: name.clone(),
774        delete_files,
775        note: note.clone(),
776        operator_mode,
777        detach_incoming,
778    };
779    let mut engine = match ctx.cli_engine()? {
780        CliEngine::MemRepo(e) => e,
781        CliEngine::Filesystem(_) => {
782            return Err(validation_error(format!(
783                "`memstead mem {verb}` requires a mem-repo workspace; the workspace at {} is filesystem-shaped.",
784                workspace_root.display(),
785            )));
786        }
787    };
788    let response =
789        mem_management::delete_mem(&mut engine, params).map_err(full_engine_err_to_cli)?;
790    if ctx.json {
791        crate::output::print_json(&serde_json::json!({
792            "name": response.name,
793            "deleted_from_router": response.deleted_from_router,
794            "files_deleted": response.files_deleted,
795            "warnings": response
796                .warnings
797                .iter()
798                .map(|w| serde_json::json!({"code": w.code(), "message": w.message()}))
799                .collect::<Vec<_>>(),
800            // Surface scrubbed `.memstead/workspace.toml` entries so the
801            // agent sees every policy side effect in one round-trip.
802            "allowlist_entries_removed": &response.allowlist_entries_removed,
803            // Referrers deliberately left dangling under
804            // `--detach-incoming` — empty without the flag.
805            "detached_referrers": response
806                .detached_referrers
807                .iter()
808                .map(|r| serde_json::json!({"from_id": r.from_id, "rel_types": r.rel_types, "mem": r.mem}))
809                .collect::<Vec<_>>(),
810        }))?;
811    } else {
812        crate::output::print_markdown(&render_mem_delete_markdown(&response, verb));
813    }
814    Ok(())
815}
816
817/// Render a successful `MemCreateResponse` as a CLI markdown block.
818/// The CLI owns its own prose rather than echoing the MCP subprocess's
819/// pre-rendered text channel.
820fn render_mem_create_markdown(r: &MemCreateResponse) -> String {
821    // The reattach
822    // branch surfaces a `MEM_REATTACHED_AFTER_UNREGISTER` warning on
823    // the response. Adjust the heading so an operator picking up an
824    // empty `seed_commit_sha` plus the reattach warning learns the
825    // branch tip kept its prior history rather than starting fresh.
826    let reattached = r.warnings.iter().any(|w| {
827        matches!(
828            w,
829            memstead_base::ops::WarningHint::MemReattachedAfterUnregister { .. }
830        )
831    });
832    let heading = if reattached {
833        format!("# Mem `{}` reattached\n\n", r.name)
834    } else {
835        format!("# Mem `{}` created\n\n", r.name)
836    };
837    let mut out = heading;
838    out.push_str(&format!("- Location: `{}`\n", r.location.display()));
839    out.push_str(&format!("- Schema: `{}`\n", r.schema_ref));
840    out.push_str(&format!("- Seed commit: `{}`\n", r.seed_commit_sha));
841    if !r.warnings.is_empty() {
842        out.push_str("\n## Warnings\n\n");
843        for w in &r.warnings {
844            out.push_str(&format!("- **{}**: {}\n", w.code(), w.message()));
845        }
846    }
847    out
848}
849
850/// Render a successful `MemDeleteResponse` as a CLI markdown block.
851/// `verb` is the CLI subcommand name (`"delete"` or `"unregister"`)
852/// — drives the heading prose so the output matches the user's
853/// invocation.
854fn render_mem_delete_markdown(r: &MemDeleteResponse, verb: &str) -> String {
855    let past_participle = match verb {
856        "unregister" => "unregistered",
857        _ => "deleted",
858    };
859    let mut out = format!("# Mem `{}` {past_participle}\n\n", r.name);
860    out.push_str(&format!(
861        "- Removed from router: {}\n",
862        r.deleted_from_router,
863    ));
864    out.push_str(&format!("- Files deleted: {}\n", r.files_deleted));
865    if !r.detached_referrers.is_empty() {
866        out.push_str("\n## Detached referrers (edges now dangle as stubs)\n\n");
867        for referrer in &r.detached_referrers {
868            out.push_str(&format!(
869                "- `{}` ({}) — {}\n",
870                referrer.from_id,
871                referrer.mem,
872                referrer.rel_types.join(", "),
873            ));
874        }
875    }
876    // Surface every scrubbed `.memstead/workspace.toml` entry so the
877    // operator sees what the destructive delete just cleaned up.
878    if !r.allowlist_entries_removed.is_empty() {
879        out.push_str("\n## Allowlist entries scrubbed\n\n");
880        for entry in &r.allowlist_entries_removed {
881            match (&entry.pattern, &entry.from, &entry.to) {
882                (Some(p), _, _) => {
883                    out.push_str(&format!("- `[{}]` pattern `{p}`\n", entry.table,));
884                }
885                (_, Some(from), Some(to)) => {
886                    out.push_str(&format!("- `[{}]` `{from} → {to}`\n", entry.table,));
887                }
888                _ => {
889                    out.push_str(&format!("- `[{}]`\n", entry.table));
890                }
891            }
892        }
893    }
894    if !r.warnings.is_empty() {
895        out.push_str("\n## Warnings\n\n");
896        for w in &r.warnings {
897            out.push_str(&format!("- **{}**: {}\n", w.code(), w.message()));
898        }
899    }
900    out
901}
902
903/// Lift a `FullEngineError` into a typed `CliError`. The lift sources
904/// every field from the engine error directly — `err.code()` for the
905/// wire token, `err.details()` for the structured payload,
906/// `err.prose_render()` for the text message. Wrapped lean errors
907/// delegate to [`crate::CliError::from_engine_op`] so the per-variant
908/// exit-kind mapping (`NotFound` → exit 3, `HashMismatch` → exit 4,
909/// validation → exit 5, generic → exit 1) is consumed in one place;
910/// lifecycle variants (`MEM_PATH_NOT_ALLOWED`,
911/// `MEM_SCHEMA_NOT_ALLOWED`, `MEM_REFERENCED_BY_POLICY`,
912/// `INVALID_MEM_NAME`, `CONFIG_ERROR`, `MEM_STORAGE_RESIDUE_DETECTED`)
913/// are user-recoverable validation refusals and land at exit 5.
914///
915/// Sourcing from the engine error directly means any new engine code
916/// automatically reaches the CLI envelope without a hand-maintained
917/// translation table to update.
918fn full_engine_err_to_cli(err: memstead_engine::FullEngineError) -> anyhow::Error {
919    match err {
920        memstead_engine::FullEngineError::Lean(inner) => CliError::from_engine_op(inner).into(),
921        lifecycle => {
922            let code = lifecycle.code();
923            let details = lifecycle.details();
924            let message = lifecycle.prose_render();
925            CliError {
926                kind: ExitKind::Validation,
927                code,
928                message,
929                details: Some(details),
930            }
931            .into()
932        }
933    }
934}
935
936/// `memstead mem set-version <NAME> <VERSION>` — bump the mem's
937/// `version` field via the in-process engine, persisting through the
938/// backend's `write_mem_config`. Unlike `init` / `delete`, this
939/// surface doesn't spawn the MCP subprocess: set-version is gate-free
940/// (no operator-mode bypass needed), so a direct engine call keeps
941/// the implementation simpler and faster.
942pub fn run_set_version(ctx: &CliContext, args: SetVersionArgs) -> anyhow::Result<()> {
943    let new_version = semver::Version::parse(&args.version).map_err(|e| {
944        invalid_input_error(format!(
945            "version {:?} is not a valid semver: {e}",
946            args.version,
947        ))
948    })?;
949
950    let note = args.note.as_deref();
951    let outcome = match ctx.cli_engine()? {
952        crate::setup::CliEngine::MemRepo(mut engine) => engine
953            .set_mem_version(&args.name, new_version, note)
954            .map_err(crate::CliError::from_engine_op)?,
955        crate::setup::CliEngine::Filesystem(mut engine) => engine
956            .set_mem_version(&args.name, new_version, note)
957            .map_err(crate::CliError::from_engine_op)?,
958    };
959
960    if ctx.json {
961        crate::output::print_json(&outcome)?;
962    } else {
963        let old = outcome
964            .old_version
965            .as_ref()
966            .map(|v| v.to_string())
967            .unwrap_or_else(|| "<none>".to_string());
968        let warnings = if outcome.warnings.is_empty() {
969            String::new()
970        } else {
971            let rendered: Vec<String> = outcome.warnings.iter().map(ToString::to_string).collect();
972            format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "))
973        };
974        crate::output::print_markdown(&format!(
975            "# Mem `{}` version updated\n\n- Old version: {}\n- New version: {}{}",
976            outcome.mem, old, outcome.new_version, warnings,
977        ));
978    }
979    Ok(())
980}
981
982/// `memstead mem set-description <NAME> <DESCRIPTION>` — set or clear
983/// the mem's one-line description via the in-process engine,
984/// persisting through the backend's `write_mem_config`. Like
985/// set-version, this surface is gate-free and calls the engine
986/// directly. An empty DESCRIPTION clears the field.
987pub fn run_set_description(ctx: &CliContext, args: SetDescriptionArgs) -> anyhow::Result<()> {
988    let new_description = {
989        let trimmed = args.description.trim();
990        if trimmed.is_empty() {
991            None
992        } else {
993            Some(trimmed.to_string())
994        }
995    };
996    let note = args.note.as_deref();
997    let outcome = match ctx.cli_engine()? {
998        crate::setup::CliEngine::MemRepo(mut engine) => engine
999            .set_mem_description(&args.name, new_description, note)
1000            .map_err(crate::CliError::from_engine_op)?,
1001        crate::setup::CliEngine::Filesystem(mut engine) => engine
1002            .set_mem_description(&args.name, new_description, note)
1003            .map_err(crate::CliError::from_engine_op)?,
1004    };
1005
1006    if ctx.json {
1007        crate::output::print_json(&outcome)?;
1008    } else {
1009        let old = outcome.old_description.as_deref().unwrap_or("<none>");
1010        let new = outcome.new_description.as_deref().unwrap_or("<cleared>");
1011        let warnings = if outcome.warnings.is_empty() {
1012            String::new()
1013        } else {
1014            let rendered: Vec<String> = outcome.warnings.iter().map(ToString::to_string).collect();
1015            format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "))
1016        };
1017        crate::output::print_markdown(&format!(
1018            "# Mem `{}` description updated\n\n- Old: {}\n- New: {}{}",
1019            outcome.mem, old, new, warnings,
1020        ));
1021    }
1022    Ok(())
1023}
1024
1025/// `memstead mem set-title <NAME> <TITLE>` arguments.
1026#[derive(Args, Debug)]
1027pub struct SetTitleArgs {
1028    /// Mem name (must be registered in the workspace).
1029    pub name: String,
1030
1031    /// Human-readable display title (free text — no slug grammar, no
1032    /// uniqueness rule). An empty string clears it.
1033    pub title: String,
1034
1035    /// Optional provenance note (≤280 chars) recorded on the commit
1036    /// body, like the other commit-producing mem-lifecycle commands.
1037    #[arg(long)]
1038    pub note: Option<String>,
1039}
1040
1041/// `memstead mem set-subject <NAME> --scope … [--method …]
1042/// [--exclusion …]…` arguments.
1043#[derive(Args, Debug)]
1044pub struct SetSubjectArgs {
1045    /// Mem name (must be registered in the workspace).
1046    pub name: String,
1047
1048    /// What this mem covers. Required to SET the block; omit every
1049    /// field to CLEAR the block as a unit.
1050    #[arg(long)]
1051    pub scope: Option<String>,
1052
1053    /// How the mem's content was arrived at.
1054    #[arg(long)]
1055    pub method: Option<String>,
1056
1057    /// What was considered and deliberately left out — repeatable;
1058    /// order preserved. May be omitted (empty exclusions).
1059    #[arg(long = "exclusion", value_name = "TEXT")]
1060    pub exclusions: Vec<String>,
1061
1062    /// Optional provenance note (≤280 chars) recorded on the commit
1063    /// body, like the other commit-producing mem-lifecycle commands.
1064    #[arg(long)]
1065    pub note: Option<String>,
1066}
1067
1068/// `memstead mem set-internal <NAME> [--off]` arguments.
1069#[derive(Args, Debug)]
1070pub struct SetInternalArgs {
1071    /// Mem name (must be registered in the workspace).
1072    pub name: String,
1073
1074    /// Unmark the mem as internal (make it visible in the default overview
1075    /// again). Without this flag, the mem is marked internal.
1076    #[arg(long)]
1077    pub off: bool,
1078
1079    /// Optional provenance note (≤280 chars) recorded on the commit body.
1080    #[arg(long)]
1081    pub note: Option<String>,
1082}
1083
1084pub fn run_set_title(ctx: &CliContext, args: SetTitleArgs) -> anyhow::Result<()> {
1085    let new_title = {
1086        let trimmed = args.title.trim();
1087        if trimmed.is_empty() {
1088            None
1089        } else {
1090            Some(trimmed.to_string())
1091        }
1092    };
1093    let note = args.note.as_deref();
1094    let outcome = match ctx.cli_engine()? {
1095        crate::setup::CliEngine::MemRepo(mut engine) => engine
1096            .set_mem_title(&args.name, new_title, note)
1097            .map_err(crate::CliError::from_engine_op)?,
1098        crate::setup::CliEngine::Filesystem(mut engine) => engine
1099            .set_mem_title(&args.name, new_title, note)
1100            .map_err(crate::CliError::from_engine_op)?,
1101    };
1102
1103    if ctx.json {
1104        crate::output::print_json(&outcome)?;
1105    } else {
1106        let old = outcome.old_title.as_deref().unwrap_or("<none>");
1107        let new = outcome.new_title.as_deref().unwrap_or("<cleared>");
1108        crate::output::print_markdown(&format!(
1109            "# Mem `{}` title updated\n\n- Old: {}\n- New: {}",
1110            outcome.mem, old, new,
1111        ));
1112    }
1113    Ok(())
1114}
1115
1116pub fn run_set_subject(ctx: &CliContext, args: SetSubjectArgs) -> anyhow::Result<()> {
1117    // A subject needs a scope; no fields at all clears the block as a
1118    // unit. `--method`/`--exclusion` without `--scope` is refused —
1119    // a subject block cannot exist without its scope member.
1120    let new_subject = match &args.scope {
1121        Some(scope) => Some(memstead_schema::MemSubject {
1122            scope: scope.clone(),
1123            method: args.method.clone(),
1124            exclusions: args.exclusions.clone(),
1125        }),
1126        None if args.method.is_none() && args.exclusions.is_empty() => None,
1127        None => {
1128            return Err(crate::CliError::new(
1129                crate::output::ExitKind::Validation,
1130                "INVALID_INPUT",
1131                "--method / --exclusion require --scope (a subject block cannot exist \
1132                 without its scope); pass no fields at all to clear the block as a unit",
1133            )
1134            .into());
1135        }
1136    };
1137    let note = args.note.as_deref();
1138    let outcome = match ctx.cli_engine()? {
1139        crate::setup::CliEngine::MemRepo(mut engine) => engine
1140            .set_mem_subject(&args.name, new_subject, note)
1141            .map_err(crate::CliError::from_engine_op)?,
1142        crate::setup::CliEngine::Filesystem(mut engine) => engine
1143            .set_mem_subject(&args.name, new_subject, note)
1144            .map_err(crate::CliError::from_engine_op)?,
1145    };
1146
1147    if ctx.json {
1148        crate::output::print_json(&outcome)?;
1149    } else {
1150        let describe = |s: &Option<memstead_schema::MemSubject>| match s {
1151            None => "<none>".to_string(),
1152            Some(sub) => format!(
1153                "scope: {}; method: {}; exclusions: {}",
1154                sub.scope,
1155                sub.method.as_deref().unwrap_or("<none>"),
1156                if sub.exclusions.is_empty() {
1157                    "<none>".to_string()
1158                } else {
1159                    sub.exclusions.join(" | ")
1160                }
1161            ),
1162        };
1163        crate::output::print_markdown(&format!(
1164            "# Mem `{}` subject updated\n\n- Old: {}\n- New: {}",
1165            outcome.mem,
1166            describe(&outcome.old_subject),
1167            describe(&outcome.new_subject),
1168        ));
1169    }
1170    Ok(())
1171}
1172
1173/// `memstead mem set-internal <NAME> [--off]` — mark or unmark a mem as
1174/// internal (hidden from the default overview roster + public projections).
1175pub fn run_set_internal(ctx: &CliContext, args: SetInternalArgs) -> anyhow::Result<()> {
1176    let internal = !args.off;
1177    let note = args.note.as_deref();
1178    let applied = match ctx.cli_engine()? {
1179        crate::setup::CliEngine::MemRepo(mut engine) => engine
1180            .set_mem_internal(&args.name, internal, note)
1181            .map_err(crate::CliError::from_engine_op)?,
1182        crate::setup::CliEngine::Filesystem(mut engine) => engine
1183            .set_mem_internal(&args.name, internal, note)
1184            .map_err(crate::CliError::from_engine_op)?,
1185    };
1186
1187    if ctx.json {
1188        crate::output::print_json(&serde_json::json!({ "mem": args.name, "internal": applied }))?;
1189    } else {
1190        crate::output::print_markdown(&format!(
1191            "# Mem `{}` {}\n\nHidden from the default overview: **{}**. Inspect with \
1192             `memstead overview --mem {}`.",
1193            args.name,
1194            if applied {
1195                "marked internal"
1196            } else {
1197                "un-marked internal"
1198            },
1199            applied,
1200            args.name,
1201        ));
1202    }
1203    Ok(())
1204}
1205
1206/// `memstead mem set-sync-state <NAME> <KEY> <TOKEN>` — set or clear
1207/// one opaque sync-state token in a mem's config via the in-process
1208/// engine, persisting through the backend's `write_mem_config`. Like
1209/// set-version, this surface is gate-free and calls the engine directly.
1210pub fn run_set_sync_state(ctx: &CliContext, args: SetSyncStateArgs) -> anyhow::Result<()> {
1211    let note = args.note.as_deref();
1212    let outcome = match ctx.cli_engine()? {
1213        crate::setup::CliEngine::MemRepo(mut engine) => engine
1214            .set_mem_sync_state(&args.name, &args.key, &args.token, note)
1215            .map_err(crate::CliError::from_engine_op)?,
1216        crate::setup::CliEngine::Filesystem(mut engine) => engine
1217            .set_mem_sync_state(&args.name, &args.key, &args.token, note)
1218            .map_err(crate::CliError::from_engine_op)?,
1219    };
1220
1221    if ctx.json {
1222        crate::output::print_json(&outcome)?;
1223    } else {
1224        let action = if outcome.removed {
1225            "cleared".to_string()
1226        } else if outcome.previous.is_some() {
1227            "overwrote".to_string()
1228        } else {
1229            "set".to_string()
1230        };
1231        let warnings = if outcome.warnings.is_empty() {
1232            String::new()
1233        } else {
1234            let rendered: Vec<String> = outcome.warnings.iter().map(ToString::to_string).collect();
1235            format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "))
1236        };
1237        crate::output::print_markdown(&format!(
1238            "# Mem `{}` sync state {}\n\n- Key: `{}`{}",
1239            outcome.mem, action, outcome.key, warnings,
1240        ));
1241    }
1242    Ok(())
1243}
1244
1245pub fn run_set_schema(ctx: &CliContext, args: SetSchemaArgs) -> anyhow::Result<()> {
1246    let target: memstead_schema::SchemaRef = args
1247        .schema
1248        .parse()
1249        .map_err(|e| invalid_input_error(format!("invalid schema ref {:?}: {e}", args.schema)))?;
1250    let outcome = match ctx.cli_engine() {
1251        Ok(crate::setup::CliEngine::MemRepo(mut engine)) => engine
1252            .set_mem_schema(&args.name, &target)
1253            .map_err(crate::CliError::from_engine_op)?,
1254        Ok(crate::setup::CliEngine::Filesystem(mut engine)) => engine
1255            .set_mem_schema(&args.name, &target)
1256            .map_err(crate::CliError::from_engine_op)?,
1257        // Below-boot repair: this verb is the named remedy for an
1258        // unresolvable schema pin, so a failing boot must not block it
1259        // (plenum 2026-08-06/07: both named remedies failed on the very
1260        // boot they were supposed to repair). Requires a workspace root
1261        // to exist — with none, the boot error (typically
1262        // WORKSPACE_NOT_INITIALISED) stands.
1263        Err(boot_err) => {
1264            let Some((_shape, root)) = ctx.workspace_shape() else {
1265                return Err(boot_err);
1266            };
1267            return run_set_schema_below_boot(ctx, &root, &args.name, &target);
1268        }
1269    };
1270    if ctx.json {
1271        crate::output::print_json(&outcome)?;
1272    } else {
1273        let findings = if outcome.findings.is_empty() {
1274            String::new()
1275        } else {
1276            let rendered: Vec<String> = outcome
1277                .findings
1278                .iter()
1279                .map(|f| format!("- {} — {}", f.id, f.code))
1280                .collect();
1281            format!("\n\n## Non-integral entities\n\n{}", rendered.join("\n"))
1282        };
1283        crate::output::print_markdown(&format!(
1284            "# Mem `{}` schema: {:?}\n\n- Pin: {}\n- Migration target: {}{}",
1285            outcome.mem,
1286            outcome.outcome,
1287            outcome.schema_pin,
1288            outcome.migration_target.as_deref().unwrap_or("<none>"),
1289            findings,
1290        ));
1291    }
1292    Ok(())
1293}
1294
1295/// The below-boot leg of `memstead mem set-schema` — runs when the
1296/// workspace boot failed. Routes through the engine's below-boot
1297/// repair surface (`memstead_git_branch::repair`), which shares the
1298/// booted path's target-ref resolution and pin-write implementation.
1299/// The booted path's conformance gate over loaded entities cannot run
1300/// here (entities are unreadable before boot); the output says so and
1301/// the next boot's health carries any findings.
1302fn run_set_schema_below_boot(
1303    ctx: &CliContext,
1304    root: &std::path::Path,
1305    mem: &str,
1306    target: &memstead_schema::SchemaRef,
1307) -> anyhow::Result<()> {
1308    let outcome = memstead_git_branch::repair::set_mem_schema_below_boot(root, mem, target)
1309        .map_err(|e| crate::setup::boot_error_to_cli(root, e))?;
1310    if ctx.json {
1311        crate::output::print_json(&serde_json::json!({
1312            "mem": outcome.mem,
1313            "schema_pin": outcome.schema_pin,
1314            "below_boot": true,
1315            "config_updated": outcome.config_updated,
1316            "conformance_checked": outcome.conformance_checked,
1317        }))?;
1318    } else {
1319        crate::output::print_markdown(&format!(
1320            "# Mem `{}` schema repinned below boot\n\n- Pin: {}\n- Backend config updated: {}\n\n\
1321             The workspace did not boot, so this repair switched the pin without the booted \
1322             path's entity-conformance gate. Boot again — health will surface any conformance \
1323             findings against the new schema.",
1324            outcome.mem, outcome.schema_pin, outcome.config_updated,
1325        ));
1326    }
1327    Ok(())
1328}
1329
1330fn generic_error(msg: String) -> anyhow::Error {
1331    CliError {
1332        code: "MEM_ERROR",
1333        kind: ExitKind::Generic,
1334        message: msg,
1335        details: None,
1336    }
1337    .into()
1338}
1339
1340fn validation_error(msg: String) -> anyhow::Error {
1341    CliError {
1342        code: "VALIDATION_FAILED",
1343        kind: ExitKind::Validation,
1344        message: msg,
1345        details: None,
1346    }
1347    .into()
1348}
1349
1350fn invalid_input_error(msg: String) -> anyhow::Error {
1351    CliError {
1352        code: "INVALID_INPUT",
1353        kind: ExitKind::Validation,
1354        message: msg,
1355        details: None,
1356    }
1357    .into()
1358}
1359
1360/// Bridge the three single-purpose CLI flags into a single
1361/// `RecoveryAction` enum value. clap's `group = "recovery_action"`
1362/// annotation on each flag enforces the mutex at parse time, so at most
1363/// one boolean is `true` here. Returns `None` for the bare invocation,
1364/// mapping to the engine's tombstone-driven default (residue with
1365/// tombstone → `Reattach`; residue without → refuse).
1366fn recovery_from_flags(
1367    reattach: bool,
1368    force_overwrite: bool,
1369    hard_cleanup_first: bool,
1370) -> Option<memstead_engine::RecoveryAction> {
1371    if reattach {
1372        Some(memstead_engine::RecoveryAction::Reattach)
1373    } else if force_overwrite {
1374        Some(memstead_engine::RecoveryAction::ForceOverwrite)
1375    } else if hard_cleanup_first {
1376        Some(memstead_engine::RecoveryAction::HardCleanupFirst)
1377    } else {
1378        None
1379    }
1380}
1381
1382pub fn run_list(ctx: &CliContext, _args: ListArgs) -> anyhow::Result<()> {
1383    let setup_ctx = CliContext {
1384        json: ctx.json,
1385        quiet: ctx.quiet,
1386        role: Default::default(),
1387    };
1388    let engine = crate::setup::full_engine(&setup_ctx)
1389        .map_err(|e| generic_error(format!("mem list: could not initialize engine: {e}")))?;
1390
1391    let mut rows: Vec<serde_json::Value> = Vec::new();
1392    for name in engine.mem_names() {
1393        let cfg = engine
1394            .mem_configs_named()
1395            .find(|(n, _)| *n == name)
1396            .map(|(_, c)| c);
1397        let entity_count = engine
1398            .store()
1399            .all_entities()
1400            .filter(|e| e.id.mem() == name && !e.stub)
1401            .count();
1402        let capability = if engine.mem_router().is_writable(name) {
1403            "write"
1404        } else {
1405            "read_only"
1406        };
1407        rows.push(serde_json::json!({
1408            "name": name,
1409            // Display title, when set — display text, not identity.
1410            "title": cfg.and_then(|c| c.title.clone()),
1411            "description": cfg.and_then(|c| c.description.clone()),
1412            "schema_ref": cfg.and_then(|c| c.schema.as_ref()).map(|s| s.to_string()),
1413            "version": cfg.and_then(|c| c.version.clone()),
1414            "entity_count": entity_count,
1415            "capability": capability,
1416        }));
1417    }
1418
1419    if ctx.json {
1420        crate::output::print_json(&serde_json::json!({ "mems": rows }))?;
1421        return Ok(());
1422    }
1423
1424    let mut lines: Vec<String> = vec![format!("# Mems ({})", rows.len()), String::new()];
1425    if rows.is_empty() {
1426        lines.push("_no mems mounted_".to_string());
1427    } else {
1428        for v in &rows {
1429            let name = v["name"].as_str().unwrap_or("?");
1430            let schema = v["schema_ref"].as_str().unwrap_or("—");
1431            let version = v["version"].as_str().unwrap_or("—");
1432            let count = v["entity_count"].as_u64().unwrap_or(0);
1433            let cap = v["capability"].as_str().unwrap_or("?");
1434            // Prefer the display title, fall back to the name — the
1435            // name (the identity) stays visible in the backticked slug.
1436            let display = match v["title"].as_str() {
1437                Some(t) => format!("{t} (`{name}`)"),
1438                None => format!("`{name}`"),
1439            };
1440            let mut line = format!(
1441                "- {display} ({cap}) — schema `{schema}`, version `{version}`, {count} entities"
1442            );
1443            if let Some(desc) = v["description"].as_str() {
1444                line.push_str(&format!(" — {desc}"));
1445            }
1446            lines.push(line);
1447        }
1448    }
1449    crate::output::print_markdown(&lines.join("\n"));
1450    Ok(())
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455    use super::*;
1456    use memstead_base::EngineError;
1457    use memstead_base::ReferrerInfo;
1458    use memstead_engine::FullEngineError;
1459    use std::path::PathBuf;
1460
1461    fn lifted_cli_error(err: FullEngineError) -> CliError {
1462        let any = full_engine_err_to_cli(err);
1463        any.downcast::<CliError>()
1464            .expect("full_engine_err_to_cli must lift to a CliError")
1465    }
1466
1467    /// The client-side mem-template consumer surfaces a built-in
1468    /// schema's instance guidance keys when `--write-guidance` is
1469    /// omitted, stays silent when guidance is given, and is silent for a
1470    /// schema that ships no template.
1471    #[test]
1472    fn mem_template_guidance_note_surfaces_builtin_keys() {
1473        let planning: memstead_schema::SchemaRef = "planning@0.1.0".parse().unwrap();
1474        let note = mem_template_guidance_note(&planning, false)
1475            .expect("planning ships a mem-template — a note is due");
1476        assert!(note.contains("phase_context"), "note names the key: {note}");
1477        assert!(
1478            note.contains("--write-guidance"),
1479            "note tells how to fill: {note}"
1480        );
1481        // Operator supplied guidance → nothing to surface.
1482        assert!(mem_template_guidance_note(&planning, false).is_some());
1483        assert!(mem_template_guidance_note(&planning, true).is_none());
1484        // A schema with no mem-template → no note.
1485        let default_: memstead_schema::SchemaRef = "default@1.0.0".parse().unwrap();
1486        assert!(mem_template_guidance_note(&default_, false).is_none());
1487    }
1488
1489    /// The CLI's mem command surface does not translate the engine's
1490    /// typed code through a static table — a code added on the engine
1491    /// side reaches the CLI envelope unchanged. Pins the regression
1492    /// where `MEM_HAS_INCOMING_REFS` silently degraded to
1493    /// `VALIDATION_FAILED`.
1494    #[test]
1495    fn mem_has_incoming_refs_keeps_typed_code_and_carries_details() {
1496        let err = FullEngineError::Lean(EngineError::MemHasIncomingRefs {
1497            mem: "other".to_string(),
1498            referrers: vec![ReferrerInfo {
1499                from_id: "test--source".to_string(),
1500                rel_types: vec!["USES".to_string()],
1501                mem: "test".to_string(),
1502            }],
1503        });
1504        let cli = lifted_cli_error(err);
1505        assert_eq!(cli.code, "MEM_HAS_INCOMING_REFS");
1506        assert_eq!(cli.kind, ExitKind::Validation);
1507        let details = cli.details.expect("details must reach the CLI envelope");
1508        assert_eq!(details["mem"], "other");
1509        let referrers = details["referrers"].as_array().expect("referrers array");
1510        assert_eq!(referrers.len(), 1);
1511        assert_eq!(referrers[0]["from_id"], "test--source");
1512        assert_eq!(referrers[0]["mem"], "test");
1513    }
1514
1515    /// Lifecycle refusal (a full-only variant) is
1516    /// promoted through with the same code + structured details the
1517    /// MCP wire ships. `MEM_PATH_NOT_ALLOWED` carries the candidate,
1518    /// the patterns list, and the typed reason discriminator.
1519    #[test]
1520    fn mem_path_not_allowed_carries_structured_details() {
1521        let err = FullEngineError::MemPathNotAllowed {
1522            attempted: PathBuf::from("/ws/bogus"),
1523            candidate: "bogus".to_string(),
1524            patterns: vec!["specs".to_string(), "team/*".to_string()],
1525            reason: "no_match",
1526            policy_table: "mem_management.create",
1527        };
1528        let cli = lifted_cli_error(err);
1529        assert_eq!(cli.code, "MEM_PATH_NOT_ALLOWED");
1530        assert_eq!(cli.kind, ExitKind::Validation);
1531        let details = cli.details.expect("details");
1532        assert_eq!(details["candidate"], "bogus");
1533        assert_eq!(details["reason"], "no_match");
1534        assert_eq!(details["patterns"][0], "specs");
1535        // The `policy_table` disambiguator reaches the CLI envelope.
1536        assert_eq!(details["policy_table"], "mem_management.create");
1537        assert_eq!(details["patterns"][1], "team/*");
1538        // The structured remedy reaches the CLI envelope too — the
1539        // caller can recover from `details` without parsing prose.
1540        assert!(
1541            details["remedy"]["cli"]
1542                .as_str()
1543                .expect("remedy.cli present")
1544                .contains("allow-create"),
1545            "got: {details}"
1546        );
1547    }
1548
1549    /// `VALIDATION_FAILED` is not
1550    /// used as the fallback for engine-sourced refusals. A
1551    /// typed lifecycle variant must not degrade to the catch-all.
1552    #[test]
1553    fn lifecycle_refusal_never_degrades_to_validation_failed_token() {
1554        let cases = [
1555            FullEngineError::MemPathNotAllowed {
1556                attempted: PathBuf::from("/x"),
1557                candidate: "x".to_string(),
1558                patterns: vec![],
1559                reason: "no_allowlist_configured",
1560                policy_table: "mem_management.create",
1561            },
1562            FullEngineError::MemReferencedByPolicy {
1563                name: "x".to_string(),
1564                referring_mems: vec!["y".to_string()],
1565            },
1566            FullEngineError::MemSchemaNotAllowed {
1567                candidate: "x".to_string(),
1568                matched_pattern: "p".to_string(),
1569                requested_schema: "default@1.0.0".to_string(),
1570                allowed_schemas: vec!["other@1.0.0".to_string()],
1571            },
1572            FullEngineError::InvalidMemName {
1573                name: "BadName".to_string(),
1574                reason: "invalid_char",
1575            },
1576        ];
1577        for err in cases {
1578            let cli = lifted_cli_error(err);
1579            assert_ne!(
1580                cli.code, "VALIDATION_FAILED",
1581                "engine-sourced refusal must carry its typed code: got {} with details {:?}",
1582                cli.code, cli.details,
1583            );
1584        }
1585    }
1586
1587    /// Wrapped lean errors keep the
1588    /// per-variant exit-kind mapping (`NotFound` → exit 3,
1589    /// `HashMismatch` → exit 4, etc.) by delegating to
1590    /// `CliError::from_engine_op`. The lift doesn't flatten every
1591    /// lean variant to `Validation`.
1592    #[test]
1593    fn wrapped_lean_error_preserves_per_variant_exit_kind() {
1594        let err = FullEngineError::Lean(EngineError::NotFound {
1595            id: "specs--missing".to_string(),
1596        });
1597        let cli = lifted_cli_error(err);
1598        assert_eq!(cli.code, "ENTITY_NOT_FOUND");
1599        assert_eq!(cli.kind, ExitKind::NotFound);
1600        assert_eq!(cli.details.as_ref().unwrap()["id"], "specs--missing");
1601    }
1602}