Skip to main content

memstead_cli/commands/
workspace.rs

1//! `memstead workspace ...` — workspace introspection and configuration commands.
2//!
3//! Subcommand families:
4//!
5//! - `dump` — emit a JSON document describing every writable mem, the
6//!   schema each is pinned to, and per-mem opaque snapshot tokens.
7//!   Storage-agnostic contract consumed by the Claude-Code ingest
8//!   plugin; versioned (`format = "workspace-dump/v1"`).
9//! - `allow-create / revoke-create / allow-delete / revoke-delete /
10//!   grant-cross-link / revoke-cross-link / set-mutations` — write
11//!   surface for `.memstead/workspace.toml`'s engine-bound sections.
12//!   Mirrors what an operator would hand-edit, with `toml_edit`
13//!   preserving comments and formatting on sections the CLI doesn't
14//!   touch. The principle from AGENTS.md is that every engine
15//!   operation should be reachable via the CLI; this family closes the
16//!   asymmetry for workspace configuration.
17//!
18//! `[plugin.*]` sections stay operator-edited — they are opaque
19//! pass-through to the engine and the CLI has no business knowing
20//! their key shapes.
21
22use std::path::{Path, PathBuf};
23
24use clap::{Args, Subcommand};
25use serde::Serialize;
26use serde_json::{Map, Value};
27
28use memstead_git_branch::mem_repo_config::branch_ref_for_mem_at_gitdir;
29
30use crate::CliError;
31use crate::output::{ExitKind, print_json};
32use crate::setup::{CliContext, find_workspace_root, workspace_not_initialised_error};
33use memstead_engine::workspace_config_edit::{
34    self, CrossLinkTarget, WorkspaceEditError, WorkspaceEditWarning,
35};
36
37/// Subcommands under `memstead workspace`.
38#[derive(Subcommand, Debug)]
39pub enum WorkspaceAction {
40    /// Emit a JSON document describing the workspace's mems, the
41    /// schema each is pinned to, and per-mem opaque snapshot tokens.
42    /// Output is always JSON (the global `--json` is a no-op here).
43    Dump(DumpArgs),
44
45    /// Render the active workspace configuration: mem-management
46    /// allowlists, cross-mem permissions, mutation policy, plugin
47    /// sections. Markdown by default; `--json` emits a structured
48    /// document. Counterpart to the `allow-create / grant-cross-link /
49    /// set-mutations` write surface — read what those commands have
50    /// composed.
51    Show(ShowArgs),
52
53    /// Add a `[[mem_management.create]]` allowlist rule. Pattern
54    /// uses gitignore-style globs (`*` does not cross `/`, `**`
55    /// matches zero-or-more segments). Schemas pin which schemas the
56    /// agent may bring into existence under this namespace; `--schema *`
57    /// allows any schema. Order: appended (lowest priority) by
58    /// default; `--before <pattern>` lifts it above the named pattern.
59    #[command(name = "allow-create")]
60    AllowCreate(AllowCreateArgs),
61
62    /// Remove a `[[mem_management.create]]` rule by pattern.
63    #[command(name = "revoke-create")]
64    RevokeCreate(PatternArg),
65
66    /// Add a `[[mem_management.delete]]` allowlist rule.
67    #[command(name = "allow-delete")]
68    AllowDelete(PatternArg),
69
70    /// Remove a `[[mem_management.delete]]` rule by pattern.
71    #[command(name = "revoke-delete")]
72    RevokeDelete(PatternArg),
73
74    /// Grant a `[cross_mem_links]` permission: `<from>` may write
75    /// edges into `<to>`. `<to>` is `*` for the wildcard shape or a
76    /// mem name for the allowlist shape. Mixing the two for one
77    /// `from`-mem is rejected.
78    #[command(name = "grant-cross-link")]
79    GrantCrossLink(CrossLinkArgs),
80
81    /// Revoke a `[cross_mem_links]` permission. Removes the named
82    /// target from the allowlist; drops the `from`-key entirely when
83    /// the allowlist becomes empty. `*` revokes the wildcard shape.
84    #[command(name = "revoke-cross-link")]
85    RevokeCrossLink(CrossLinkArgs),
86
87    /// Set a `[mutations]` field. Today exposes `--require-notes`
88    /// only; additional keys land additively.
89    #[command(name = "set-mutations")]
90    SetMutations(SetMutationsArgs),
91}
92
93/// `memstead workspace dump` arguments. `--json` is the root-level global
94/// flag (the dump is always emitted as JSON regardless).
95#[derive(Args, Debug)]
96pub struct DumpArgs {}
97
98/// `memstead workspace show` arguments. `--json` is the root-level global
99/// flag (Markdown by default, JSON when set).
100#[derive(Args, Debug)]
101pub struct ShowArgs {}
102
103/// Args for `allow-create`.
104#[derive(Args, Debug)]
105pub struct AllowCreateArgs {
106    /// Glob pattern (gitignore semantics) the rule matches against
107    /// the lifecycle candidate `<path>/<name>` (or `<name>` for
108    /// flat-layout mems).
109    pub pattern: String,
110
111    /// Schema pins the rule permits. Repeat or pass as a single
112    /// comma-separated value. `*` is the any-schema escape.
113    #[arg(long, required = true, value_delimiter = ',')]
114    pub schema: Vec<String>,
115
116    /// Cross-mem permission conferred on every mem matching this
117    /// rule. Rule-derived and evaluated lazily at relate time — not
118    /// written into `[cross_mem_links]`; `workspace show` and
119    /// `memstead_overview` surface it under the rule. Repeat or pass as a
120    /// single comma-separated value; `*` for wildcard.
121    #[arg(long, value_delimiter = ',')]
122    pub cross_link: Vec<String>,
123
124    /// Insert this rule before the named pattern (lifts it above the
125    /// target in the first-match-wins order). Omit to append at the
126    /// lowest priority.
127    #[arg(long)]
128    pub before: Option<String>,
129}
130
131/// Single-pattern args for `revoke-create / allow-delete / revoke-delete`.
132#[derive(Args, Debug)]
133pub struct PatternArg {
134    /// Pattern identifying the rule.
135    pub pattern: String,
136}
137
138/// Args for `grant-cross-link / revoke-cross-link`.
139#[derive(Args, Debug)]
140pub struct CrossLinkArgs {
141    /// Source mem (the `from` side of the permission).
142    pub from: String,
143    /// Target mem or `*` for the wildcard shape.
144    pub to: String,
145}
146
147/// Args for `set-mutations`.
148#[derive(Args, Debug)]
149pub struct SetMutationsArgs {
150    /// Toggle `[mutations] require_notes`. When set, mutations without
151    /// a `note` field surface a `note_missing` warning (the mutation
152    /// still lands — provenance is best-effort).
153    #[arg(long, value_name = "BOOL", value_parser = clap::value_parser!(bool))]
154    pub require_notes: Option<bool>,
155}
156
157pub fn run(ctx: &CliContext, action: WorkspaceAction) -> anyhow::Result<()> {
158    match action {
159        WorkspaceAction::Dump(args) => dump(ctx, args),
160        WorkspaceAction::Show(args) => show(ctx, args),
161        WorkspaceAction::AllowCreate(args) => allow_create(ctx, args),
162        WorkspaceAction::RevokeCreate(args) => revoke_create(ctx, args),
163        WorkspaceAction::AllowDelete(args) => allow_delete(ctx, args),
164        WorkspaceAction::RevokeDelete(args) => revoke_delete(ctx, args),
165        WorkspaceAction::GrantCrossLink(args) => grant_cross_link(ctx, args),
166        WorkspaceAction::RevokeCrossLink(args) => revoke_cross_link(ctx, args),
167        WorkspaceAction::SetMutations(args) => set_mutations(ctx, args),
168    }
169}
170
171/// Walk up from cwd to the workspace root that carries
172/// `.memstead/workspace.toml`. Returns a `CliError` shaped to match the
173/// rest of the workspace-not-initialised paths in `setup.rs`.
174fn require_workspace_root() -> anyhow::Result<PathBuf> {
175    let cwd = std::env::current_dir().map_err(|e| CliError {
176        code: crate::INTERNAL_CODE,
177        kind: ExitKind::Generic,
178        message: format!("could not determine current directory: {e}"),
179        details: None,
180    })?;
181    find_workspace_root(&cwd).ok_or_else(|| {
182        workspace_not_initialised_error(
183            "No workspace found. Run from a directory containing `.memstead/workspace.toml` (run `memstead mem-repo init` or `memstead init` to bootstrap).",
184        )
185        .into()
186    })
187}
188
189/// Convert a `WorkspaceEditError` into a typed CLI error. The
190/// stable `code` from the writer is lifted into the `--json` envelope.
191/// Idempotency variants (`RuleAlreadyPresent`, `RuleNotFoundNoop`,
192/// `GrantAlreadyPresent`, `GrantNotFound`) do not surface here — they
193/// ride on the success path as `WorkspaceEditWarning` instances,
194/// rendered via [`emit_warnings`].
195fn lift_edit_error(err: WorkspaceEditError) -> CliError {
196    let code = err.code();
197    let message = err.to_string();
198    // Match by ref so `err`'s fields can ride into a structured `details`
199    // payload without consuming the value before `to_string()`/`code()`.
200    let (kind, details) = match &err {
201        WorkspaceEditError::WorkspaceNotInitialised { .. } => (ExitKind::Generic, None),
202        WorkspaceEditError::InvalidToml { .. } => (ExitKind::Validation, None),
203        WorkspaceEditError::BeforePatternNotFound { .. } => (ExitKind::NotFound, None),
204        WorkspaceEditError::CrossLinkConflict { .. } => (ExitKind::Validation, None),
205        WorkspaceEditError::RuleExistsSchemasDiffer {
206            section,
207            pattern,
208            stored,
209            requested,
210        } => (
211            ExitKind::Validation,
212            Some(serde_json::json!({
213                "section": section,
214                "pattern": pattern,
215                "stored_schemas": stored,
216                "requested_schemas": requested,
217                "recovery": format!(
218                    "revoke-create {pattern} then allow-create {pattern} --schema … with the new schemas"
219                ),
220            })),
221        ),
222        WorkspaceEditError::Io { .. } => (ExitKind::Generic, None),
223    };
224    // The writer's `code()` returns a `&'static str` already — promote
225    // it into the CliError's typed `code` slot so the wire envelope
226    // carries it verbatim rather than burying it under `details`.
227    CliError {
228        kind,
229        code,
230        message,
231        details,
232    }
233}
234
235/// Render idempotency warnings on stderr — used in `--json` mode so
236/// the envelope's `{action, detail}` shape stays untouched. Markdown-
237/// default mode embeds the warnings under a `## Warnings` block via
238/// [`confirm_block`] instead, matching every other CLI mutation's
239/// rendering shape.
240///
241/// The engine writers return `Ok(Vec<WorkspaceEditWarning>)` for
242/// idempotency cases so scripts and agents can retry without branching
243/// on prior state.
244fn emit_warnings_stderr(warnings: &[WorkspaceEditWarning]) {
245    for w in warnings {
246        eprintln!("warning [{}]: {}", w.code(), w);
247    }
248}
249
250fn parse_schemas(schemas: &[String]) -> Vec<String> {
251    // `--schema` is `Vec<String>` from clap with `value_delimiter = ','`,
252    // so the comma-splitting is already handled. This helper exists so
253    // future per-pin validation can land in one place.
254    schemas.to_vec()
255}
256
257fn parse_cross_links(targets: &[String]) -> Vec<CrossLinkTarget> {
258    targets.iter().map(|t| CrossLinkTarget::parse(t)).collect()
259}
260
261/// Render a workspace-mutation response. The `--json` shape is the
262/// `{action, detail}` envelope; markdown-default
263/// mode emits a markdown block (heading + bullet list + optional
264/// `## Warnings` block) matching the shape every other CLI mutation
265/// (`mem init`, `relate`, `create`, …) uses.
266///
267/// `heading` is the top-level title (e.g. `"Workspace allow-create
268/// rule \`scratch-*\`"`). `bullets` is a list of pre-formatted bullet
269/// strings (already including the leading `"- "` is NOT required —
270/// the helper adds the dash). `warnings` mirrors the engine's
271/// idempotency notices; in `--json` mode they ride out on stderr to
272/// keep the envelope shape untouched.
273fn confirm_block(
274    ctx: &CliContext,
275    action: &str,
276    detail: serde_json::Value,
277    heading: &str,
278    bullets: Vec<String>,
279    warnings: &[WorkspaceEditWarning],
280) -> anyhow::Result<()> {
281    if ctx.json {
282        emit_warnings_stderr(warnings);
283        let payload = serde_json::json!({ "action": action, "detail": detail });
284        return print_json(&payload);
285    }
286    let mut lines: Vec<String> = Vec::with_capacity(2 + bullets.len() + 2 * warnings.len());
287    lines.push(format!("# {heading}"));
288    lines.push(String::new());
289    for b in bullets {
290        lines.push(format!("- {b}"));
291    }
292    if !warnings.is_empty() {
293        lines.push(String::new());
294        lines.push("## Warnings".to_string());
295        lines.push(String::new());
296        for w in warnings {
297            lines.push(format!("- **{}**: {}", w.code(), w));
298        }
299    }
300    crate::output::print_markdown(&lines.join("\n"));
301    Ok(())
302}
303
304/// Render a list of strings as a bracketed comma-joined inline list
305/// (e.g. `[a, b, c]`). Empty list renders as `(none)` — the markdown
306/// renderer uses this for both the `schemas` and `cross_links` bullet
307/// lines so an operator distinguishes "no entries" from "the list is
308/// the wildcard `*`" (which renders as `*`).
309fn render_list_inline(items: &[String]) -> String {
310    if items.is_empty() {
311        "(none)".to_string()
312    } else {
313        format!("[{}]", items.join(", "))
314    }
315}
316
317fn allow_create(ctx: &CliContext, args: AllowCreateArgs) -> anyhow::Result<()> {
318    let root = require_workspace_root()?;
319    let schemas = parse_schemas(&args.schema);
320    let cross_links = parse_cross_links(&args.cross_link);
321    let cross_links_opt = if cross_links.is_empty() {
322        None
323    } else {
324        Some(cross_links.as_slice())
325    };
326    let warnings = workspace_config_edit::add_create_rule(
327        &root,
328        &args.pattern,
329        &schemas,
330        cross_links_opt,
331        args.before.as_deref(),
332    )
333    .map_err(lift_edit_error)?;
334    let heading = format!("Workspace allow-create rule `{}`", args.pattern);
335    let position = args
336        .before
337        .as_deref()
338        .map(|p| format!("Position: before `{p}`"))
339        .unwrap_or_else(|| "Position: appended (lowest priority)".to_string());
340    let bullets = vec![
341        format!("Pattern: `{}`", args.pattern),
342        format!("Schemas: {}", render_list_inline(&schemas)),
343        format!(
344            "Default cross-links: {}",
345            render_list_inline(&args.cross_link)
346        ),
347        position,
348    ];
349    confirm_block(
350        ctx,
351        "allow-create",
352        serde_json::json!({
353            "pattern": args.pattern,
354            "schemas": schemas,
355            "before": args.before,
356            "cross_links": args.cross_link,
357        }),
358        &heading,
359        bullets,
360        &warnings,
361    )
362}
363
364fn revoke_create(ctx: &CliContext, args: PatternArg) -> anyhow::Result<()> {
365    let root = require_workspace_root()?;
366    let warnings =
367        workspace_config_edit::remove_create_rule(&root, &args.pattern).map_err(lift_edit_error)?;
368    let heading = format!("Workspace revoke-create rule `{}`", args.pattern);
369    let bullets = vec![format!("Pattern: `{}`", args.pattern)];
370    confirm_block(
371        ctx,
372        "revoke-create",
373        serde_json::json!({ "pattern": args.pattern }),
374        &heading,
375        bullets,
376        &warnings,
377    )
378}
379
380fn allow_delete(ctx: &CliContext, args: PatternArg) -> anyhow::Result<()> {
381    let root = require_workspace_root()?;
382    let warnings =
383        workspace_config_edit::add_delete_rule(&root, &args.pattern).map_err(lift_edit_error)?;
384    let heading = format!("Workspace allow-delete rule `{}`", args.pattern);
385    let bullets = vec![format!("Pattern: `{}`", args.pattern)];
386    confirm_block(
387        ctx,
388        "allow-delete",
389        serde_json::json!({ "pattern": args.pattern }),
390        &heading,
391        bullets,
392        &warnings,
393    )
394}
395
396fn revoke_delete(ctx: &CliContext, args: PatternArg) -> anyhow::Result<()> {
397    let root = require_workspace_root()?;
398    let warnings =
399        workspace_config_edit::remove_delete_rule(&root, &args.pattern).map_err(lift_edit_error)?;
400    let heading = format!("Workspace revoke-delete rule `{}`", args.pattern);
401    let bullets = vec![format!("Pattern: `{}`", args.pattern)];
402    confirm_block(
403        ctx,
404        "revoke-delete",
405        serde_json::json!({ "pattern": args.pattern }),
406        &heading,
407        bullets,
408        &warnings,
409    )
410}
411
412fn grant_cross_link(ctx: &CliContext, args: CrossLinkArgs) -> anyhow::Result<()> {
413    let root = require_workspace_root()?;
414    // Registered mems (any mount) drive the grant's target validation
415    // in the shared engine policy-edit layer — same warnings the MCP
416    // surface emits. Building the engine mirrors `workspace dump`.
417    let known_mems: Vec<String> = {
418        let engine = crate::setup::full_engine(ctx)?;
419        engine.mem_names().iter().map(|s| s.to_string()).collect()
420    };
421    let target = CrossLinkTarget::parse(&args.to);
422    let warnings = workspace_config_edit::grant_cross_link(&root, &args.from, &target, &known_mems)
423        .map_err(lift_edit_error)?;
424    let heading = format!("Workspace grant-cross-link `{}` → `{}`", args.from, args.to);
425    let bullets = vec![
426        format!("From: `{}`", args.from),
427        format!("To: `{}`", args.to),
428    ];
429    confirm_block(
430        ctx,
431        "grant-cross-link",
432        serde_json::json!({ "from": args.from, "to": args.to }),
433        &heading,
434        bullets,
435        &warnings,
436    )
437}
438
439fn revoke_cross_link(ctx: &CliContext, args: CrossLinkArgs) -> anyhow::Result<()> {
440    let root = require_workspace_root()?;
441    let target = CrossLinkTarget::parse(&args.to);
442
443    // What was ALREADY unbacked before this edit. Reported without it, the
444    // revocation would blame itself for every edge some earlier unrelated
445    // revocation left behind (04/07, criterion 5).
446    let before = ctx
447        .cli_engine()
448        .map(|mut e| e.base_mut().ungranted_cross_mem_edges())
449        .unwrap_or_default();
450
451    let mut warnings = workspace_config_edit::revoke_cross_link(&root, &args.from, &target)
452        .map_err(lift_edit_error)?;
453
454    // Revocation is never refused and needs no force flag: an operator who has
455    // decided two mems should no longer link can say so before cleaning up,
456    // and the cleanup is easier once the policy states the intent. But it is
457    // also the one moment the operator can act cheaply — the store is loaded
458    // and they remember what they changed — so the edges the revocation just
459    // orphaned are named here rather than discovered on a later gate run
460    // (04/07, criteria 5 and 6). Named, not counted: a count says something is
461    // wrong without saying what to look at.
462    //
463    // Booted AFTER the edit, so the scan answers against the policy as it now
464    // stands. The engine is not booted at all when the revoke was a no-op, so
465    // the ordinary case pays nothing and gains no noise (criterion 7).
466    let orphaned: Vec<String> = if warnings.is_empty() {
467        match ctx.cli_engine() {
468            Ok(mut engine) => memstead_base::Engine::newly_ungranted(
469                &before,
470                engine.base_mut().ungranted_cross_mem_edges(),
471            )
472            .iter()
473            .map(|f| {
474                let d = &f.detail;
475                format!(
476                    "`{}` --{}-> `{}`",
477                    d["from"].as_str().unwrap_or(&f.id),
478                    d["rel_type"].as_str().unwrap_or("?"),
479                    d["target_id"].as_str().unwrap_or("?"),
480                )
481            })
482            .collect(),
483            // A workspace that will not boot is a separate problem, and it is
484            // not this command's to report: the revocation already landed and
485            // saying so is more useful than failing after the fact.
486            Err(_) => Vec::new(),
487        }
488    } else {
489        Vec::new()
490    };
491
492    let heading = format!(
493        "Workspace revoke-cross-link `{}` → `{}`",
494        args.from, args.to
495    );
496    let mut bullets = vec![
497        format!("From: `{}`", args.from),
498        format!("To: `{}`", args.to),
499    ];
500    if !orphaned.is_empty() {
501        // Also on the warning channel, which every revoke surface already
502        // renders — the bullets below are this command's own presentation.
503        warnings.push(
504            workspace_config_edit::WorkspaceEditWarning::CrossLinkRevokeOrphanedEdges {
505                edges: orphaned.clone(),
506            },
507        );
508        bullets.push(format!(
509            "Edges left without a grant ({}) — they are NOT removed; \
510             `memstead health --include integrity --strict` now refuses \
511             until each is granted again or removed:",
512            orphaned.len()
513        ));
514        for edge in &orphaned {
515            bullets.push(format!("  {edge}"));
516        }
517    }
518    confirm_block(
519        ctx,
520        "revoke-cross-link",
521        serde_json::json!({
522            "from": args.from,
523            "to": args.to,
524            "orphaned_edges": orphaned,
525        }),
526        &heading,
527        bullets,
528        &warnings,
529    )
530}
531
532fn show(ctx: &CliContext, _args: ShowArgs) -> anyhow::Result<()> {
533    use memstead_base::{FileWorkspaceStore, WorkspaceStoreAdapter};
534
535    let root = require_workspace_root()?;
536    let workspace = FileWorkspaceStore::new().load(&root).map_err(|e| {
537        CliError::new(
538            ExitKind::Generic,
539            "WORKSPACE_CONFIG_READ_FAILED",
540            format!("workspace show: load `{}`: {e}", root.display()),
541        )
542    })?;
543
544    let settings = &workspace.settings;
545    let json_mode = ctx.json;
546    if json_mode {
547        let create_rules: Vec<serde_json::Value> = settings
548            .mem_create_rules
549            .iter()
550            .map(|r| {
551                let mut obj = serde_json::Map::new();
552                obj.insert(
553                    "pattern".to_string(),
554                    serde_json::Value::String(r.pattern.clone()),
555                );
556                obj.insert(
557                    "schemas".to_string(),
558                    serde_json::Value::Array(
559                        r.schemas
560                            .iter()
561                            .map(|s| serde_json::Value::String(s.clone()))
562                            .collect(),
563                    ),
564                );
565                if let Some(cl) = &r.default_cross_links {
566                    obj.insert(
567                        "default_cross_links".to_string(),
568                        cross_link_value_to_json(cl),
569                    );
570                }
571                serde_json::Value::Object(obj)
572            })
573            .collect();
574        let delete_rules: Vec<serde_json::Value> = settings
575            .mem_delete_rules
576            .iter()
577            .map(|r| serde_json::json!({ "pattern": r.pattern }))
578            .collect();
579        let mut cross_links_obj = serde_json::Map::new();
580        for (k, v) in &settings.cross_mem_links {
581            cross_links_obj.insert(k.clone(), cross_link_value_to_json(v));
582        }
583        let mut mutations_obj = serde_json::Map::new();
584        if let Some(rn) = settings.mutations.require_notes {
585            mutations_obj.insert("require_notes".to_string(), serde_json::Value::Bool(rn));
586        }
587        let mut plugin_obj = serde_json::Map::new();
588        for (k, v) in &settings.plugin {
589            plugin_obj.insert(k.clone(), serde_json::Value::String(v.to_string()));
590        }
591        let document = serde_json::json!({
592            "workspace_root": root.display().to_string(),
593            "mem_management": {
594                "create": create_rules,
595                "delete": delete_rules,
596            },
597            "cross_mem_links": cross_links_obj,
598            "mutations": mutations_obj,
599            "plugin": plugin_obj,
600        });
601        return print_json(&document);
602    }
603
604    let mut lines = Vec::new();
605    lines.push("# Workspace configuration".to_string());
606    lines.push(String::new());
607    lines.push(format!("- Root: `{}`", root.display()));
608    lines.push(String::new());
609
610    lines.push("## Mem management".to_string());
611    lines.push(String::new());
612    if settings.mem_create_rules.is_empty() {
613        lines.push(
614            "- `[[mem_management.create]]`: (none — no agent-driven mem creation allowed)"
615                .to_string(),
616        );
617    } else {
618        lines.push("- `[[mem_management.create]]`:".to_string());
619        for r in &settings.mem_create_rules {
620            let cross = match &r.default_cross_links {
621                None => String::new(),
622                Some(v) => format!(" → cross-links: {}", render_cross_link_value(v)),
623            };
624            lines.push(format!(
625                "  - `{}` schemas=[{}]{cross}",
626                r.pattern,
627                r.schemas.join(", "),
628            ));
629        }
630    }
631    if settings.mem_delete_rules.is_empty() {
632        lines.push("- `[[mem_management.delete]]`: (none)".to_string());
633    } else {
634        lines.push("- `[[mem_management.delete]]`:".to_string());
635        for r in &settings.mem_delete_rules {
636            lines.push(format!("  - `{}`", r.pattern));
637        }
638    }
639    lines.push(String::new());
640
641    lines.push("## Cross-mem links".to_string());
642    lines.push(String::new());
643    if settings.cross_mem_links.is_empty() {
644        lines.push("- `[cross_mem_links]`: (none — default-deny)".to_string());
645    } else {
646        for (from, value) in &settings.cross_mem_links {
647            lines.push(format!("- `{from}` → {}", render_cross_link_value(value)));
648        }
649    }
650    lines.push(String::new());
651
652    lines.push("## Mutations".to_string());
653    lines.push(String::new());
654    match settings.mutations.require_notes {
655        Some(true) => lines.push("- `require_notes`: `true`".to_string()),
656        Some(false) => lines.push("- `require_notes`: `false`".to_string()),
657        None => lines.push("- `require_notes`: (unset — best-effort)".to_string()),
658    }
659    lines.push(String::new());
660
661    if !settings.plugin.is_empty() {
662        lines.push("## Plugin (opaque pass-through)".to_string());
663        lines.push(String::new());
664        let mut keys: Vec<&String> = settings.plugin.keys().collect();
665        keys.sort();
666        for k in keys {
667            lines.push(format!(
668                "- `[plugin.{k}]`: (operator-managed; CLI does not edit)"
669            ));
670        }
671    }
672
673    crate::output::print_markdown(&lines.join("\n"));
674    Ok(())
675}
676
677fn cross_link_value_to_json(
678    v: &memstead_schema::workspace_config::CrossLinkValue,
679) -> serde_json::Value {
680    use memstead_schema::workspace_config::CrossLinkValue;
681    match v {
682        CrossLinkValue::Wildcard => serde_json::Value::String("*".to_string()),
683        CrossLinkValue::List(names) => serde_json::Value::Array(
684            names
685                .iter()
686                .map(|n| serde_json::Value::String(n.clone()))
687                .collect(),
688        ),
689    }
690}
691
692fn render_cross_link_value(v: &memstead_schema::workspace_config::CrossLinkValue) -> String {
693    use memstead_schema::workspace_config::CrossLinkValue;
694    match v {
695        CrossLinkValue::Wildcard => "*".to_string(),
696        CrossLinkValue::List(names) => format!("[{}]", names.join(", ")),
697    }
698}
699
700fn set_mutations(ctx: &CliContext, args: SetMutationsArgs) -> anyhow::Result<()> {
701    let root = require_workspace_root()?;
702    if let Some(value) = args.require_notes {
703        workspace_config_edit::set_mutation_require_notes(&root, value).map_err(lift_edit_error)?;
704        let heading = "Workspace set-mutations".to_string();
705        let bullets = vec![format!("`require_notes`: `{value}`")];
706        confirm_block(
707            ctx,
708            "set-mutations",
709            serde_json::json!({ "require_notes": value }),
710            &heading,
711            bullets,
712            &[],
713        )
714    } else {
715        Err(CliError::new(
716            ExitKind::Validation,
717            "INVALID_INPUT",
718            "set-mutations requires at least one of: --require-notes <bool>",
719        )
720        .into())
721    }
722}
723
724/// Format string emitted into the `format` field of the dump.
725///
726/// Plugin gates on this exact value. Any structurally-breaking change
727/// (renamed key, dropped key, changed value type) must bump the version and
728/// the consumer's gate must be widened to accept the new value as part of the
729/// same change set.
730///
731/// **v0 → v1 (2026-08-27, consistency-sweep 04/05).** The `mems` array is
732/// driven off the MOUNT list rather than the config-keyed query, so it now
733/// enumerates mounts a consumer never saw before: one whose config could not
734/// be read appears with its config-derived fields absent instead of being
735/// dropped. No key was renamed or retyped, so by the letter of the rule above
736/// this is additive — but the MEMBERSHIP of the collection changed, and a
737/// consumer that assumed every row carries a schema pin is broken by it just
738/// as surely. Membership is part of a collection's contract. Each such row
739/// additionally carries `serving`, absent when the mount serves.
740const DUMP_FORMAT: &str = "workspace-dump/v1";
741
742/// Why an enumerated mount serves nothing, or `None` when it serves.
743///
744/// The dump had no warnings channel at all (04/05, criterion 3), so even an
745/// enumerated broken mount would have been a bare row the reader had to
746/// interpret. This is per-mem rather than a workspace-level list, because the
747/// reader's question is about the row in front of them.
748#[derive(Serialize)]
749struct ServingState {
750    /// `quarantined` or `unbacked`.
751    state: &'static str,
752    reason_code: String,
753    reason: String,
754}
755
756/// The serving state of `name`, or `None` when the mount serves normally.
757fn serving_state(engine: &memstead_base::Engine, name: &str) -> Option<ServingState> {
758    if let Some(q) = engine
759        .quarantined_mems()
760        .iter()
761        .find(|q| q.mount.mem == name)
762    {
763        return Some(ServingState {
764            state: "quarantined",
765            reason_code: q.reason_code.clone(),
766            reason: q.reason_message.clone(),
767        });
768    }
769    engine
770        .health()
771        .warnings
772        .iter()
773        .find(|w| {
774            matches!(
775                w,
776                memstead_base::ops::WarningHint::MountUnbacked { mem, .. } if mem == name
777            )
778        })
779        .map(|w| ServingState {
780            state: "unbacked",
781            reason_code: w.code().to_string(),
782            reason: w.to_string(),
783        })
784}
785
786#[derive(Serialize)]
787struct DumpMem {
788    name: String,
789    /// Absent when the mount serves. Present, with a reason, when it does not.
790    #[serde(skip_serializing_if = "Option::is_none")]
791    serving: Option<ServingState>,
792    /// Mount capability — `"writable"` or `"read_only"`. RO mounts
793    /// have no gitdir / snapshot_token; consumers branch on this to
794    /// know which conditional fields are populated.
795    capability: &'static str,
796    /// Schema pin as it appears in the mem config (`"software"` or
797    /// `"software@1.0.0"`). `None` is preserved as JSON `null` so
798    /// consumers can branch on the unset case without inferring it from
799    /// an absent key. Serialized as `schema_ref` for consistency with
800    /// `memstead_mem_create`'s response (where `schema_ref` is the short
801    /// name string and `schema` is the inlined schema body).
802    #[serde(rename = "schema_ref")]
803    schema: Option<String>,
804    /// One-line description from the mem config; `None` when unset.
805    description: Option<String>,
806    /// Opaque pass-through of `MemConfig.write_guidance` — the same
807    /// `HashMap<String, Value>` shape the engine carries on disk and on
808    /// the wire.
809    ///
810    /// Serialized snake_case (`write_guidance`), uniform with every
811    /// neighbouring key in the dump envelope — even though the on-disk
812    /// `MemConfig` carries it camelCase.
813    write_guidance: Map<String, Value>,
814    /// Opaque token that changes iff the mem content has changed
815    /// since the previous dump. Consumers' only legal operation is
816    /// byte-equality. Today the token is the per-mem content branch's
817    /// fully-peeled HEAD oid for git-branch mounts; `None` for folder
818    /// and archive mounts whose backends have no head-of-branch
819    /// concept (the archive's bytes are immutable post-install; folder
820    /// mems aren't change-tracked by the engine).
821    #[serde(skip_serializing_if = "Option::is_none")]
822    snapshot_token: Option<String>,
823    /// Verbatim pass-through of `MemConfig.sync_state` — the ingest
824    /// layer's durable "last synced source state" baseline, keyed per
825    /// `(ingest, facet)`. Each value is an opaque token the engine
826    /// never interprets (git → commit id, graph → snapshot token,
827    /// filesystem → a JSON-stringified stat digest). This is the read
828    /// pipe the ingest loop diffs against to steer at the changed
829    /// slice; writes route through `memstead mem set-sync-state`.
830    /// Omitted from the wire when empty, like `write_guidance` —
831    /// existing minimal configs don't gain an empty `{}`.
832    #[serde(skip_serializing_if = "Map::is_empty")]
833    sync_state: Map<String, Value>,
834}
835
836#[derive(Serialize)]
837struct DumpSchema {
838    /// Schema-level writing-guidance defaults. Always present (possibly
839    /// empty) so consumers don't need a key-existence check.
840    #[serde(rename = "default_writing_guidance")]
841    default_writing_guidance: SchemaWritingGuidance,
842}
843
844#[derive(Serialize, Default)]
845struct SchemaWritingGuidance {
846    avoid: Option<String>,
847    goal: Option<String>,
848}
849
850fn dump(_ctx: &CliContext, _args: DumpArgs) -> anyhow::Result<()> {
851    let setup_ctx = CliContext {
852        json: true,
853        quiet: false,
854        role: Default::default(),
855    };
856    // `full_engine` already refuses with the right typed code per
857    // situation — `WORKSPACE_NOT_INITIALISED` when no workspace marker
858    // exists, `UNSUPPORTED_WORKSPACE_SHAPE` on a filesystem-mem
859    // workspace, and typed boot errors otherwise. Keep the code and
860    // details, prefix only the failing command's name: an earlier
861    // blanket re-wrap here collapsed the shape refusal into the
862    // not-initialised code, giving an agent branching on `code` a
863    // wrong cause.
864    let engine =
865        crate::setup::full_engine(&setup_ctx).map_err(|e| match e.downcast::<CliError>() {
866            Ok(mut cli) => {
867                cli.message = format!("workspace dump: {}", cli.message);
868                anyhow::Error::from(cli)
869            }
870            Err(other) => other,
871        })?;
872
873    let mut mems: Vec<DumpMem> = Vec::new();
874    let mut schemas: Map<String, Value> = Map::new();
875
876    // Driven off the MOUNT list, not the config-keyed query (04/05, criteria
877    // 1 and 2). A folder mount whose directory is gone yields no config, and
878    // the dump used to lose the mount entirely rather than showing it with its
879    // config-derived fields absent. A mount missing from this dump is now
880    // missing from the workspace configuration, and nothing else.
881    for (name, config) in engine.mounts_with_optional_config() {
882        // F24: branch on mount capability. Git-branch (writable)
883        // mounts emit the gitdir-derived snapshot token; folder and
884        // archive (RO) mounts omit the token entirely. Calling
885        // `gitdir_for(name)` unconditionally would trip
886        // `EngineError::Mem` for archive mounts, crashing the whole
887        // dump with `MEM_ERROR` on the first RO mount encountered.
888        let mount = engine.mount(name);
889        let capability = match mount.map(|m| m.capability) {
890            Some(memstead_base::MountCapability::ReadOnly) => "read_only",
891            _ => "writable",
892        };
893        let storage = mount.map(|m| &m.storage);
894        let snapshot_token: Option<String> = match storage {
895            Some(memstead_base::MountStorage::GitBranch { .. }) => {
896                let gitdir = engine.gitdir_for(name).map_err(|e| {
897                    CliError::new(
898                        ExitKind::Generic,
899                        "MEM_ERROR",
900                        format!("workspace dump: gitdir for mem '{name}': {e}"),
901                    )
902                })?;
903                Some(read_branch_head_oid(&gitdir, name).map_err(|e| {
904                    CliError::new(
905                        ExitKind::Generic,
906                        "MEM_ERROR",
907                        format!("workspace dump: snapshot token for mem '{name}': {e}"),
908                    )
909                })?)
910            }
911            // Folder and Archive mounts have no head-of-branch — the
912            // token is intentionally absent so `Option::is_none` skips
913            // serialisation. Consumers branch on `capability` plus the
914            // field's presence.
915            _ => None,
916        };
917
918        let schema_pin = config
919            .and_then(|c| c.schema.as_ref())
920            .map(|p| {
921                serde_json::to_value(p)
922                    .ok()
923                    .and_then(|v| v.as_str().map(String::from))
924            })
925            .unwrap_or(None);
926
927        let mut write_guidance = Map::new();
928        for (k, v) in config.iter().flat_map(|c| c.write_guidance.iter()) {
929            write_guidance.insert(k.clone(), v.clone());
930        }
931
932        // Sync-state tokens are opaque strings; surface them verbatim
933        // as JSON string values. The consumer (ingest loop) owns their
934        // interpretation per medium type.
935        let mut sync_state = Map::new();
936        for (k, v) in config.iter().flat_map(|c| c.sync_state.iter()) {
937            sync_state.insert(k.clone(), Value::String(v.clone()));
938        }
939
940        mems.push(DumpMem {
941            name: name.to_string(),
942            capability,
943            schema: schema_pin.clone(),
944            description: config.and_then(|c| c.description.clone()),
945            serving: serving_state(&engine, name),
946            write_guidance,
947            snapshot_token,
948            sync_state,
949        });
950
951        // Record the schema body for this pin if we haven't seen it yet.
952        if let Some(pin) = schema_pin
953            && !schemas.contains_key(&pin)
954            && let Some(schema) = engine.schema_for(name)
955        {
956            let dwg = schema
957                .manifest
958                .default_writing_guidance
959                .as_ref()
960                .map(|d| SchemaWritingGuidance {
961                    avoid: d.avoid.clone(),
962                    goal: d.goal.clone(),
963                })
964                .unwrap_or_default();
965            let body = DumpSchema {
966                default_writing_guidance: dwg,
967            };
968            schemas.insert(pin, serde_json::to_value(body)?);
969        }
970    }
971
972    mems.sort_by(|a, b| a.name.cmp(&b.name));
973
974    let workspace_root = std::env::current_dir()
975        .ok()
976        .and_then(|cwd| crate::setup::find_workspace_root(&cwd).map(|p| p.display().to_string()));
977
978    let document = serde_json::json!({
979        "format": DUMP_FORMAT,
980        // The coverage rule (memstead_base::ops::coverage): the dump
981        // answers for the mount roster and configuration, nothing
982        // else, and says so in the envelope.
983        "verdict_coverage": crate::coverage::WORKSPACE_DUMP
984            .axis_coverage()
985            .expect("workspace dump is a verdict surface")
986            .wire_line(),
987        "workspace_root": workspace_root,
988        "mems": mems,
989        "schemas": schemas,
990    });
991
992    print_json(&document)?;
993    Ok(())
994}
995
996/// Read the fully-peeled HEAD oid of the per-mem content branch as a
997/// hex string. This is the dump's snapshot-token primitive — the value
998/// changes iff a new commit lands on the mem's branch, which is iff
999/// the mem content changed.
1000///
1001/// `mem_name` is the leaf; the helper walks `__MEMSTEAD:mems/` to find
1002/// the hierarchical branch ref (`refs/heads/<path>/<leaf>`) and reads
1003/// the oid from there.
1004fn read_branch_head_oid(gitdir: &Path, mem_name: &str) -> Result<String, String> {
1005    if !gitdir.is_dir() {
1006        return Err(format!("gitdir not found at {}", gitdir.display()));
1007    }
1008    let repo = gix::open(gitdir).map_err(|e| format!("gix open: {e}"))?;
1009    let branch_ref = branch_ref_for_mem_at_gitdir(gitdir, mem_name);
1010    let reference = repo
1011        .find_reference(&branch_ref)
1012        .map_err(|e| format!("find ref {branch_ref}: {e}"))?;
1013    let oid = reference
1014        .into_fully_peeled_id()
1015        .map_err(|e| format!("peel {branch_ref}: {e}"))?;
1016    Ok(oid.to_string())
1017}