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/v0"`).
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    let warnings = workspace_config_edit::revoke_cross_link(&root, &args.from, &target)
443        .map_err(lift_edit_error)?;
444    let heading = format!(
445        "Workspace revoke-cross-link `{}` → `{}`",
446        args.from, args.to
447    );
448    let bullets = vec![
449        format!("From: `{}`", args.from),
450        format!("To: `{}`", args.to),
451    ];
452    confirm_block(
453        ctx,
454        "revoke-cross-link",
455        serde_json::json!({ "from": args.from, "to": args.to }),
456        &heading,
457        bullets,
458        &warnings,
459    )
460}
461
462fn show(ctx: &CliContext, _args: ShowArgs) -> anyhow::Result<()> {
463    use memstead_base::{FileWorkspaceStore, WorkspaceStoreAdapter};
464
465    let root = require_workspace_root()?;
466    let workspace = FileWorkspaceStore::new().load(&root).map_err(|e| {
467        CliError::new(
468            ExitKind::Generic,
469            "WORKSPACE_CONFIG_READ_FAILED",
470            format!("workspace show: load `{}`: {e}", root.display()),
471        )
472    })?;
473
474    let settings = &workspace.settings;
475    let json_mode = ctx.json;
476    if json_mode {
477        let create_rules: Vec<serde_json::Value> = settings
478            .mem_create_rules
479            .iter()
480            .map(|r| {
481                let mut obj = serde_json::Map::new();
482                obj.insert(
483                    "pattern".to_string(),
484                    serde_json::Value::String(r.pattern.clone()),
485                );
486                obj.insert(
487                    "schemas".to_string(),
488                    serde_json::Value::Array(
489                        r.schemas
490                            .iter()
491                            .map(|s| serde_json::Value::String(s.clone()))
492                            .collect(),
493                    ),
494                );
495                if let Some(cl) = &r.default_cross_links {
496                    obj.insert(
497                        "default_cross_links".to_string(),
498                        cross_link_value_to_json(cl),
499                    );
500                }
501                serde_json::Value::Object(obj)
502            })
503            .collect();
504        let delete_rules: Vec<serde_json::Value> = settings
505            .mem_delete_rules
506            .iter()
507            .map(|r| serde_json::json!({ "pattern": r.pattern }))
508            .collect();
509        let mut cross_links_obj = serde_json::Map::new();
510        for (k, v) in &settings.cross_mem_links {
511            cross_links_obj.insert(k.clone(), cross_link_value_to_json(v));
512        }
513        let mut mutations_obj = serde_json::Map::new();
514        if let Some(rn) = settings.mutations.require_notes {
515            mutations_obj.insert("require_notes".to_string(), serde_json::Value::Bool(rn));
516        }
517        let mut plugin_obj = serde_json::Map::new();
518        for (k, v) in &settings.plugin {
519            plugin_obj.insert(k.clone(), serde_json::Value::String(v.to_string()));
520        }
521        let document = serde_json::json!({
522            "workspace_root": root.display().to_string(),
523            "mem_management": {
524                "create": create_rules,
525                "delete": delete_rules,
526            },
527            "cross_mem_links": cross_links_obj,
528            "mutations": mutations_obj,
529            "plugin": plugin_obj,
530        });
531        return print_json(&document);
532    }
533
534    let mut lines = Vec::new();
535    lines.push("# Workspace configuration".to_string());
536    lines.push(String::new());
537    lines.push(format!("- Root: `{}`", root.display()));
538    lines.push(String::new());
539
540    lines.push("## Mem management".to_string());
541    lines.push(String::new());
542    if settings.mem_create_rules.is_empty() {
543        lines.push(
544            "- `[[mem_management.create]]`: (none — no agent-driven mem creation allowed)"
545                .to_string(),
546        );
547    } else {
548        lines.push("- `[[mem_management.create]]`:".to_string());
549        for r in &settings.mem_create_rules {
550            let cross = match &r.default_cross_links {
551                None => String::new(),
552                Some(v) => format!(" → cross-links: {}", render_cross_link_value(v)),
553            };
554            lines.push(format!(
555                "  - `{}` schemas=[{}]{cross}",
556                r.pattern,
557                r.schemas.join(", "),
558            ));
559        }
560    }
561    if settings.mem_delete_rules.is_empty() {
562        lines.push("- `[[mem_management.delete]]`: (none)".to_string());
563    } else {
564        lines.push("- `[[mem_management.delete]]`:".to_string());
565        for r in &settings.mem_delete_rules {
566            lines.push(format!("  - `{}`", r.pattern));
567        }
568    }
569    lines.push(String::new());
570
571    lines.push("## Cross-mem links".to_string());
572    lines.push(String::new());
573    if settings.cross_mem_links.is_empty() {
574        lines.push("- `[cross_mem_links]`: (none — default-deny)".to_string());
575    } else {
576        for (from, value) in &settings.cross_mem_links {
577            lines.push(format!("- `{from}` → {}", render_cross_link_value(value)));
578        }
579    }
580    lines.push(String::new());
581
582    lines.push("## Mutations".to_string());
583    lines.push(String::new());
584    match settings.mutations.require_notes {
585        Some(true) => lines.push("- `require_notes`: `true`".to_string()),
586        Some(false) => lines.push("- `require_notes`: `false`".to_string()),
587        None => lines.push("- `require_notes`: (unset — best-effort)".to_string()),
588    }
589    lines.push(String::new());
590
591    if !settings.plugin.is_empty() {
592        lines.push("## Plugin (opaque pass-through)".to_string());
593        lines.push(String::new());
594        let mut keys: Vec<&String> = settings.plugin.keys().collect();
595        keys.sort();
596        for k in keys {
597            lines.push(format!(
598                "- `[plugin.{k}]`: (operator-managed; CLI does not edit)"
599            ));
600        }
601    }
602
603    crate::output::print_markdown(&lines.join("\n"));
604    Ok(())
605}
606
607fn cross_link_value_to_json(
608    v: &memstead_schema::workspace_config::CrossLinkValue,
609) -> serde_json::Value {
610    use memstead_schema::workspace_config::CrossLinkValue;
611    match v {
612        CrossLinkValue::Wildcard => serde_json::Value::String("*".to_string()),
613        CrossLinkValue::List(names) => serde_json::Value::Array(
614            names
615                .iter()
616                .map(|n| serde_json::Value::String(n.clone()))
617                .collect(),
618        ),
619    }
620}
621
622fn render_cross_link_value(v: &memstead_schema::workspace_config::CrossLinkValue) -> String {
623    use memstead_schema::workspace_config::CrossLinkValue;
624    match v {
625        CrossLinkValue::Wildcard => "*".to_string(),
626        CrossLinkValue::List(names) => format!("[{}]", names.join(", ")),
627    }
628}
629
630fn set_mutations(ctx: &CliContext, args: SetMutationsArgs) -> anyhow::Result<()> {
631    let root = require_workspace_root()?;
632    if let Some(value) = args.require_notes {
633        workspace_config_edit::set_mutation_require_notes(&root, value).map_err(lift_edit_error)?;
634        let heading = "Workspace set-mutations".to_string();
635        let bullets = vec![format!("`require_notes`: `{value}`")];
636        confirm_block(
637            ctx,
638            "set-mutations",
639            serde_json::json!({ "require_notes": value }),
640            &heading,
641            bullets,
642            &[],
643        )
644    } else {
645        Err(CliError::new(
646            ExitKind::Validation,
647            "INVALID_INPUT",
648            "set-mutations requires at least one of: --require-notes <bool>",
649        )
650        .into())
651    }
652}
653
654/// Format string emitted into the `format` field of the dump.
655///
656/// Plugin gates on this exact value. Any structurally-breaking change
657/// (renamed key, dropped key, changed value type) must bump to
658/// `workspace-dump/v1` and the consumer's gate must be widened to
659/// accept the new value as part of the same change set.
660const DUMP_FORMAT: &str = "workspace-dump/v0";
661
662#[derive(Serialize)]
663struct DumpMem {
664    name: String,
665    /// Mount capability — `"writable"` or `"read_only"`. RO mounts
666    /// have no gitdir / snapshot_token; consumers branch on this to
667    /// know which conditional fields are populated.
668    capability: &'static str,
669    /// Schema pin as it appears in the mem config (`"software"` or
670    /// `"software@1.0.0"`). `None` is preserved as JSON `null` so
671    /// consumers can branch on the unset case without inferring it from
672    /// an absent key. Serialized as `schema_ref` for consistency with
673    /// `memstead_mem_create`'s response (where `schema_ref` is the short
674    /// name string and `schema` is the inlined schema body).
675    #[serde(rename = "schema_ref")]
676    schema: Option<String>,
677    /// One-line description from the mem config; `None` when unset.
678    description: Option<String>,
679    /// Opaque pass-through of `MemConfig.write_guidance` — the same
680    /// `HashMap<String, Value>` shape the engine carries on disk and on
681    /// the wire.
682    ///
683    /// Serialized snake_case (`write_guidance`), uniform with every
684    /// neighbouring key in the dump envelope — even though the on-disk
685    /// `MemConfig` carries it camelCase.
686    write_guidance: Map<String, Value>,
687    /// Opaque token that changes iff the mem content has changed
688    /// since the previous dump. Consumers' only legal operation is
689    /// byte-equality. Today the token is the per-mem content branch's
690    /// fully-peeled HEAD oid for git-branch mounts; `None` for folder
691    /// and archive mounts whose backends have no head-of-branch
692    /// concept (the archive's bytes are immutable post-install; folder
693    /// mems aren't change-tracked by the engine).
694    #[serde(skip_serializing_if = "Option::is_none")]
695    snapshot_token: Option<String>,
696    /// Verbatim pass-through of `MemConfig.sync_state` — the ingest
697    /// layer's durable "last synced source state" baseline, keyed per
698    /// `(ingest, facet)`. Each value is an opaque token the engine
699    /// never interprets (git → commit id, graph → snapshot token,
700    /// filesystem → a JSON-stringified stat digest). This is the read
701    /// pipe the ingest loop diffs against to steer at the changed
702    /// slice; writes route through `memstead mem set-sync-state`.
703    /// Omitted from the wire when empty, like `write_guidance` —
704    /// existing minimal configs don't gain an empty `{}`.
705    #[serde(skip_serializing_if = "Map::is_empty")]
706    sync_state: Map<String, Value>,
707}
708
709#[derive(Serialize)]
710struct DumpSchema {
711    /// Schema-level writing-guidance defaults. Always present (possibly
712    /// empty) so consumers don't need a key-existence check.
713    #[serde(rename = "default_writing_guidance")]
714    default_writing_guidance: SchemaWritingGuidance,
715}
716
717#[derive(Serialize, Default)]
718struct SchemaWritingGuidance {
719    avoid: Option<String>,
720    goal: Option<String>,
721}
722
723fn dump(_ctx: &CliContext, _args: DumpArgs) -> anyhow::Result<()> {
724    let setup_ctx = CliContext {
725        json: true,
726        quiet: false,
727        role: Default::default(),
728    };
729    // Engine init can fail for several reasons; `WORKSPACE_NOT_INITIALISED`
730    // is the dominant one for cold-start usage and the only one the test
731    // contract pins. Pre-fix the boot error was wrapped under a generic
732    // INTERNAL string — wire envelope drifted from the typed code.
733    let engine = crate::setup::full_engine(&setup_ctx).map_err(|e| {
734        CliError::new(
735            ExitKind::Generic,
736            "WORKSPACE_NOT_INITIALISED",
737            format!("workspace dump: could not initialize engine: {e}"),
738        )
739    })?;
740
741    let mut mems: Vec<DumpMem> = Vec::new();
742    let mut schemas: Map<String, Value> = Map::new();
743
744    for (name, config) in engine.mem_configs_named() {
745        // F24: branch on mount capability. Git-branch (writable)
746        // mounts emit the gitdir-derived snapshot token; folder and
747        // archive (RO) mounts omit the token entirely. Calling
748        // `gitdir_for(name)` unconditionally would trip
749        // `EngineError::Mem` for archive mounts, crashing the whole
750        // dump with `MEM_ERROR` on the first RO mount encountered.
751        let mount = engine.mount(name);
752        let capability = match mount.map(|m| m.capability) {
753            Some(memstead_base::MountCapability::ReadOnly) => "read_only",
754            _ => "writable",
755        };
756        let storage = mount.map(|m| &m.storage);
757        let snapshot_token: Option<String> = match storage {
758            Some(memstead_base::MountStorage::GitBranch { .. }) => {
759                let gitdir = engine.gitdir_for(name).map_err(|e| {
760                    CliError::new(
761                        ExitKind::Generic,
762                        "MEM_ERROR",
763                        format!("workspace dump: gitdir for mem '{name}': {e}"),
764                    )
765                })?;
766                Some(read_branch_head_oid(&gitdir, name).map_err(|e| {
767                    CliError::new(
768                        ExitKind::Generic,
769                        "MEM_ERROR",
770                        format!("workspace dump: snapshot token for mem '{name}': {e}"),
771                    )
772                })?)
773            }
774            // Folder and Archive mounts have no head-of-branch — the
775            // token is intentionally absent so `Option::is_none` skips
776            // serialisation. Consumers branch on `capability` plus the
777            // field's presence.
778            _ => None,
779        };
780
781        let schema_pin = config
782            .schema
783            .as_ref()
784            .map(|p| {
785                serde_json::to_value(p)
786                    .ok()
787                    .and_then(|v| v.as_str().map(String::from))
788            })
789            .unwrap_or(None);
790
791        let mut write_guidance = Map::new();
792        for (k, v) in &config.write_guidance {
793            write_guidance.insert(k.clone(), v.clone());
794        }
795
796        // Sync-state tokens are opaque strings; surface them verbatim
797        // as JSON string values. The consumer (ingest loop) owns their
798        // interpretation per medium type.
799        let mut sync_state = Map::new();
800        for (k, v) in &config.sync_state {
801            sync_state.insert(k.clone(), Value::String(v.clone()));
802        }
803
804        mems.push(DumpMem {
805            name: name.to_string(),
806            capability,
807            schema: schema_pin.clone(),
808            description: config.description.clone(),
809            write_guidance,
810            snapshot_token,
811            sync_state,
812        });
813
814        // Record the schema body for this pin if we haven't seen it yet.
815        if let Some(pin) = schema_pin
816            && !schemas.contains_key(&pin)
817            && let Some(schema) = engine.schema_for(name)
818        {
819            let dwg = schema
820                .manifest
821                .default_writing_guidance
822                .as_ref()
823                .map(|d| SchemaWritingGuidance {
824                    avoid: d.avoid.clone(),
825                    goal: d.goal.clone(),
826                })
827                .unwrap_or_default();
828            let body = DumpSchema {
829                default_writing_guidance: dwg,
830            };
831            schemas.insert(pin, serde_json::to_value(body)?);
832        }
833    }
834
835    mems.sort_by(|a, b| a.name.cmp(&b.name));
836
837    let workspace_root = std::env::current_dir()
838        .ok()
839        .and_then(|cwd| crate::setup::find_workspace_root(&cwd).map(|p| p.display().to_string()));
840
841    let document = serde_json::json!({
842        "format": DUMP_FORMAT,
843        "workspace_root": workspace_root,
844        "mems": mems,
845        "schemas": schemas,
846    });
847
848    print_json(&document)?;
849    Ok(())
850}
851
852/// Read the fully-peeled HEAD oid of the per-mem content branch as a
853/// hex string. This is the dump's snapshot-token primitive — the value
854/// changes iff a new commit lands on the mem's branch, which is iff
855/// the mem content changed.
856///
857/// `mem_name` is the leaf; the helper walks `__MEMSTEAD:mems/` to find
858/// the hierarchical branch ref (`refs/heads/<path>/<leaf>`) and reads
859/// the oid from there.
860fn read_branch_head_oid(gitdir: &Path, mem_name: &str) -> Result<String, String> {
861    if !gitdir.is_dir() {
862        return Err(format!("gitdir not found at {}", gitdir.display()));
863    }
864    let repo = gix::open(gitdir).map_err(|e| format!("gix open: {e}"))?;
865    let branch_ref = branch_ref_for_mem_at_gitdir(gitdir, mem_name);
866    let reference = repo
867        .find_reference(&branch_ref)
868        .map_err(|e| format!("find ref {branch_ref}: {e}"))?;
869    let oid = reference
870        .into_fully_peeled_id()
871        .map_err(|e| format!("peel {branch_ref}: {e}"))?;
872    Ok(oid.to_string())
873}