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