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}