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