Skip to main content

memstead_mcp/
lifecycle.rs

1//! Parameter structs for the runtime mem-lifecycle tools —
2//! `memstead_mem_create` and `memstead_mem_delete`.
3//!
4//! These are the on-the-wire shapes agents send; the full MCP handlers
5//! translate them before calling the orchestrators. Full-only — lean
6//! `FilesystemMcpServer` does not expose mem lifecycle.
7
8use rmcp::schemars;
9
10/// On-the-wire shape mirroring `memstead_schema::VcsConfig` with a
11/// `JsonSchema` derivation for rmcp tool routing. Kept separate from the
12/// core type so the schema crate does not need a `schemars` dependency
13/// just to support one MCP-facing parameter. The fields and semantics
14/// match 1:1 — see `memstead_schema::VcsConfig` for the canonical
15/// documentation.
16#[derive(Debug, Clone, serde::Deserialize, schemars::JsonSchema)]
17#[serde(deny_unknown_fields)]
18#[serde(rename_all = "camelCase")]
19pub struct VcsConfigInput {
20    #[schemars(description = "Path to the gitdir relative to the new mem's root.")]
21    pub gitdir: String,
22    #[schemars(
23        description = "Path to the worktree relative to the new mem's root. Defaults to `\".\"` (mem root) when omitted."
24    )]
25    #[serde(default = "default_worktree")]
26    pub worktree: String,
27}
28
29fn default_worktree() -> String {
30    ".".to_string()
31}
32
33impl From<VcsConfigInput> for memstead_schema::VcsConfig {
34    fn from(v: VcsConfigInput) -> Self {
35        Self {
36            gitdir: v.gitdir,
37            worktree: v.worktree,
38        }
39    }
40}
41
42/// Wire-shape recovery action for `memstead_mem_create`. The
43/// storage-residue refusal path exposes three explicit
44/// recovery options the caller picks via this enum. The wire
45/// tokens (`reattach` / `force_overwrite` / `hard_cleanup_first`)
46/// match `memstead_engine::RecoveryAction::as_wire_str()` so the
47/// MCP serde shape and the CLI flag bridge converge on a single
48/// engine-side enum.
49#[derive(Debug, Clone, Copy, serde::Deserialize, schemars::JsonSchema)]
50#[serde(deny_unknown_fields)]
51#[serde(rename_all = "snake_case")]
52pub enum RecoveryActionInput {
53    /// Adopt the residual entities; skip the seed commit. Default
54    /// when the residue was left by a deliberate `memstead mem
55    /// unregister`. Explicit `reattach` overrides the default for
56    /// crash-residue scenarios where the operator has verified the
57    /// content is safe to adopt.
58    Reattach,
59    /// Destroy the residue, then proceed with the normal create
60    /// path: the residual branch and its `__MEMSTEAD` config blob are
61    /// pruned in one ref-edit transaction before the fresh seed
62    /// commit. Prior entities are gone by design.
63    ForceOverwrite,
64    /// Refuse with `MEM_STORAGE_RESIDUE_DETECTED`, instructing the
65    /// caller to run `memstead mem delete <name>` first. Hard barrier
66    /// against destructive auto-recovery — for operators who want
67    /// the cleanup to be a separate, named operation.
68    HardCleanupFirst,
69}
70
71impl From<RecoveryActionInput> for memstead_engine::RecoveryAction {
72    fn from(v: RecoveryActionInput) -> Self {
73        match v {
74            RecoveryActionInput::Reattach => memstead_engine::RecoveryAction::Reattach,
75            RecoveryActionInput::ForceOverwrite => memstead_engine::RecoveryAction::ForceOverwrite,
76            RecoveryActionInput::HardCleanupFirst => {
77                memstead_engine::RecoveryAction::HardCleanupFirst
78            }
79        }
80    }
81}
82
83/// Parameters for `memstead_mem_create`.
84#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
85#[serde(deny_unknown_fields)]
86pub struct MemCreateParams {
87    #[schemars(
88        description = "Unique name for the new mem — the full hierarchical identifier (e.g. `\"sub-mem\"` for flat layouts or `\"team/sub-mem\"` for hierarchical layouts); the value flows through verbatim. Grammar: lowercase ASCII letters, digits, hyphens; segments separated by `/`; no leading, trailing, or double slashes. Must not collide with any currently-registered mem."
89    )]
90    pub name: String,
91    #[schemars(
92        description = "Target filesystem location. Absolute path, or relative to the workspace root. Canonicalized before the allowlist check — `./a/../b` is reduced to `./b` prior to matching."
93    )]
94    pub location: String,
95    #[schemars(
96        description = "Schema pin for the new mem. Format: `name@x.y.z` — e.g. `default@1.0.0`. Resolved against the per-mem schema registry at init time."
97    )]
98    pub schema: String,
99    #[schemars(
100        description = "Optional VCS layout override. Shape: `{ \"gitdir\": \".git\", \"worktree\": \".\" }` (default isolated) or `{ \"gitdir\": \"../.git\", \"worktree\": \"..\" }` (shared-gitdir idiom). Paths are relative to the new mem's root. When absent, the engine uses the isolated default."
101    )]
102    pub vcs: Option<VcsConfigInput>,
103    #[schemars(
104        description = "Optional human-readable display title applied at creation — display text, not identity (the mem is always addressed by `name`). Same validation and storage as the CLI's `mem set-title`. Omit to leave the mem untitled."
105    )]
106    #[serde(default)]
107    pub title: Option<String>,
108    #[schemars(
109        description = "Optional one-line description applied at creation — embedded in `.mem` archive exports and surfaced on rosters. Same storage as the CLI's `mem set-description`."
110    )]
111    #[serde(default)]
112    pub description: Option<String>,
113    #[schemars(
114        description = "Optional subject block applied at creation — `{scope, method?, exclusions?}`: what the mem covers, how its content was arrived at, and what was deliberately left out. Same storage as the CLI's `mem set-subject`."
115    )]
116    #[serde(default)]
117    pub subject: Option<MemSubjectInput>,
118    #[schemars(
119        description = "Agent-authored provenance note recorded in the seed commit's body (≤280 chars). One sentence describing why this mem was created."
120    )]
121    pub note: Option<String>,
122    #[schemars(
123        description = "Explicit recovery action when on-disk storage residue is detected at the composed branch path. Three accepted values: `reattach` (adopt the residual entities, skip the seed commit), `force_overwrite` (destroy the residue — it is removed atomically, so either the residue is gone and the mem is created or nothing changed; the prior entities are gone by design), `hard_cleanup_first` (refuse with `MEM_STORAGE_RESIDUE_DETECTED`, instructing the caller to run `memstead_mem_delete` first). When omitted, the engine routes by whether the residue was left by a deliberate `memstead mem unregister`: such residue defaults to `reattach` and emits a `MEM_REATTACHED_AFTER_UNREGISTER` warning; residue from a crash refuses with `MEM_STORAGE_RESIDUE_DETECTED`. Bare create against a name with no residue ignores this field."
124    )]
125    pub recovery: Option<RecoveryActionInput>,
126    #[schemars(
127        description = "Inline the resolved schema body on the response (byte-identical to `memstead_schema(name=<resolved-schema>)` at the same verbosity). Default `false` — the response carries only `schema_ref`, `name`, `location`, and `seed_commit_sha`. Set to `true` for first-time-schema callers that want one round-trip instead of two; the schema is workspace-stable, so for the agent's second+ mem on the same schema the omitted default is the right call."
128    )]
129    #[serde(default)]
130    pub include_schema: bool,
131    #[schemars(
132        description = "Verbosity of the inlined schema body when `include_schema: true`. `\"lite\"` (default, absent) inlines the cheap cold-start skeleton (entity-type names + section keys + field shapes, relationship names + endpoints, the alias pointer; prose dropped) — the right pairing for a first-mem create that only needs to orient, and byte-identical to `memstead_schema`'s default reply. `\"full\"` inlines the complete schema — byte-identical to `memstead_schema(name=<resolved-schema>, verbosity=\"full\")`. Ignored when `include_schema` is false. Any value other than `\"full\"`/`\"lite\"` returns `INVALID_INPUT` naming the bad value."
133    )]
134    pub schema_verbosity: Option<String>,
135    #[schemars(
136        description = "Optional per-instance writing guidance, written verbatim into the new mem's config `writeGuidance` map in the seed commit. An opaque string-keyed JSON object — e.g. `{ \"phase_context\": \"early design\", \"stack\": \"Rust\" }`. The engine never interprets the keys (schema-strictness D8 — `writeGuidance` is client-owned vocabulary); a client that read the resolved schema package's `mem-template.json` fills the instance keys and passes them here. Omit (or pass `{}`) to seed no guidance."
137    )]
138    #[serde(default)]
139    pub write_guidance: std::collections::HashMap<String, serde_json::Value>,
140}
141
142/// Parameters for `memstead_mem_set_schema` — the integrity-driven
143/// schema-migration trigger.
144#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
145#[serde(deny_unknown_fields)]
146pub struct MemSetSchemaParams {
147    #[schemars(description = "Name of the writable mem whose schema pin is being set.")]
148    pub mem: String,
149    #[schemars(
150        description = "Target schema ref, exact `name@x.y.z`. Must resolve against the loaded schema catalogue (mem-pinned, workspace, built-in); unresolvable refs refuse with SCHEMA_NOT_FOUND, malformed refs with INVALID_INPUT."
151    )]
152    pub schema: String,
153    #[schemars(
154        description = "Optional provenance note (≤280 chars). Reserved: the pin lives in workspace state today (no mem commit is produced), so the note is accepted for wire-compat and recorded once the pin-relocation cut moves the schema pin into mem config."
155    )]
156    pub note: Option<String>,
157}
158
159/// Parameters for `memstead_mem_set_version`. F1.
160#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
161#[serde(deny_unknown_fields)]
162pub struct MemSetVersionParams {
163    #[schemars(description = "Name of the mem whose `version` field is being updated.")]
164    pub name: String,
165    #[schemars(
166        description = "New semver version (e.g. `0.2.0`, `1.0.0-beta.1`). Validated as semver; malformed values refuse with `INVALID_INPUT`. The version is consumed by `memstead_export --format mem` to stamp the archive filename and the `.mem` archive's published config — bump before publishing. Initial mem-create seeds `0.1.0` so this surface is the only path that needs to be invoked when an agent or operator is ready to ship."
167    )]
168    pub version: String,
169    #[schemars(
170        description = "Optional provenance note (≤280 chars) recorded on the version-bump commit body. When the workspace sets `require_notes`, omitting it rides a non-blocking `NOTE_MISSING` warning (the bump still lands)."
171    )]
172    pub note: Option<String>,
173}
174
175/// Parameters for `memstead_mem_delete`.
176///
177/// The MCP surface collapses to one verb that always means destructive.
178/// The earlier `delete_files: bool` parameter retired — agents have no legitimate
179/// need to "preserve storage but unregister"; the router-only
180/// unregister-preserve-storage workflow stays reachable via the CLI's
181/// `memstead mem unregister` verb (operator-only). The MCP wrapper
182/// hardcodes `delete_files: true` when invoking the engine, so the
183/// promised refusals (`MEM_REFERENCED_BY_POLICY`,
184/// `MEM_HAS_INCOMING_REFS`) and the policy scrub on success always
185/// fire.
186#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
187#[serde(deny_unknown_fields)]
188pub struct MemDeleteParams {
189    #[schemars(description = "Name of the mem to destroy.")]
190    pub name: String,
191    #[schemars(
192        description = "Agent-authored provenance note (≤280 chars). Surfaces in the outer-repo Stop-hook aggregation via the engine's trace surface; no per-mem commit is produced by delete."
193    )]
194    pub note: Option<String>,
195}
196
197// ---------------------------------------------------------------------------
198// Wire-shape param structs for the
199// six `memstead_workspace_*` tools wrapping the engine-located
200// `workspace_config_edit` writers. The MCP surface mirrors the CLI
201// verbs (`memstead workspace grant-cross-link`, etc.), so an
202// MCP-driven agent can complete the dynamic mem lifecycle
203// (`mem_create → workspace_grant_cross_link → relate → unrelate
204// → workspace_revoke_cross_link → mem_delete`) without dropping
205// to the CLI.
206// ---------------------------------------------------------------------------
207
208/// Parameters for `memstead_workspace_grant_cross_link`.
209#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
210#[serde(deny_unknown_fields)]
211pub struct WorkspaceGrantCrossLinkParams {
212    #[schemars(
213        description = "Source mem. The grantee — the mem permitted to author cross-mem edges into `to`."
214    )]
215    pub from: String,
216    #[schemars(
217        description = "Target mem. Pass a named mem (e.g. `\"specs\"`) to append to the named-allowlist shape, or the literal `\"*\"` to set the wildcard shape (any target). Wildcard vs. named is mutually exclusive per `from`-mem — switching between requires revoking the prior shape first; mixing surfaces `CROSS_LINK_CONFLICT`."
218    )]
219    pub to: String,
220}
221
222/// Parameters for `memstead_workspace_revoke_cross_link`.
223#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
224#[serde(deny_unknown_fields)]
225pub struct WorkspaceRevokeCrossLinkParams {
226    #[schemars(description = "Source mem. The grantee whose existing grant is being revoked.")]
227    pub from: String,
228    #[schemars(
229        description = "Target mem, or `\"*\"` to revoke the wildcard shape. When the underlying list becomes empty, the `from` key is dropped entirely."
230    )]
231    pub to: String,
232}
233
234/// Parameters for `memstead_workspace_allow_create`.
235#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
236#[serde(deny_unknown_fields)]
237pub struct WorkspaceAllowCreateParams {
238    #[schemars(
239        description = "Glob pattern matched against composed mem candidates (`<mem_path>/<name>` for hierarchical, bare `<name>` for flat). First-match-wins; lower index = higher priority."
240    )]
241    pub pattern: String,
242    #[schemars(
243        description = "Schema pins admitted by this rule. `[\"*\"]` is the any-schema escape. Each entry is a canonical `name@version` pin (e.g. `\"default@1.0.0\"`)."
244    )]
245    pub schemas: Vec<String>,
246    #[schemars(
247        description = "Existing pattern to insert before — lifts the new rule above the named pattern in the priority list. Omit to append at the end (lowest priority)."
248    )]
249    pub before: Option<String>,
250    #[schemars(
251        description = "Default cross-mem link grants for mems matching this rule. Each entry is a target-mem name (`\"specs\"`) or `\"*\"` (any). Pre-populates `[cross_mem_links]` for matching new mems so agents don't have to grant a second time."
252    )]
253    pub default_cross_links: Option<Vec<String>>,
254}
255
256/// Parameters for `memstead_workspace_revoke_create`.
257#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
258#[serde(deny_unknown_fields)]
259pub struct WorkspaceRevokeCreateParams {
260    #[schemars(
261        description = "Glob pattern of the `[[mem_management.create]]` rule to drop. Matched exactly against the rule's `pattern` field."
262    )]
263    pub pattern: String,
264}
265
266/// Parameters for `memstead_workspace_allow_delete`.
267#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
268#[serde(deny_unknown_fields)]
269pub struct WorkspaceAllowDeleteParams {
270    #[schemars(
271        description = "Glob pattern matched against composed mem candidates. Appended to `[[mem_management.delete]]` — the symmetric allowlist for `memstead_mem_delete`. Agent-creatable equals agent-deletable in spirit; mirror the create-side `pattern` to keep parity."
272    )]
273    pub pattern: String,
274}
275
276/// Parameters for `memstead_workspace_revoke_delete`.
277#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
278#[serde(deny_unknown_fields)]
279pub struct WorkspaceRevokeDeleteParams {
280    #[schemars(
281        description = "Glob pattern of the `[[mem_management.delete]]` rule to drop. Matched exactly against the rule's `pattern` field."
282    )]
283    pub pattern: String,
284}
285
286/// Subject block for mem curation — mirrors
287/// `memstead_schema::MemSubject`.
288#[derive(Debug, Clone, serde::Deserialize, schemars::JsonSchema)]
289#[serde(deny_unknown_fields)]
290pub struct MemSubjectInput {
291    #[schemars(description = "What this mem covers. Required to set the block.")]
292    pub scope: String,
293    #[schemars(description = "How the mem's content was arrived at.")]
294    #[serde(default)]
295    pub method: Option<String>,
296    #[schemars(
297        description = "What was considered and deliberately left out — prose statements, order preserved."
298    )]
299    #[serde(default)]
300    pub exclusions: Option<Vec<String>>,
301}
302
303impl MemSubjectInput {
304    /// Lower into the engine's config shape.
305    pub(crate) fn into_engine(self) -> memstead_schema::MemSubject {
306        memstead_schema::MemSubject {
307            scope: self.scope,
308            method: self.method,
309            exclusions: self.exclusions.unwrap_or_default(),
310        }
311    }
312}
313
314/// Parameters for `memstead_mem_configure` — set what is present.
315///
316/// One behaviour, no action discriminator: each optional field left
317/// absent is untouched; an empty string (`title` / `description`) or
318/// `clear_subject: true` clears that field. Gate-free like the sibling
319/// setters (`memstead_mem_set_version` / `_set_schema`): no
320/// `[[mem_management.*]]` allowlist applies — but every structural
321/// gate does (unknown mem refuses `UNKNOWN_MEM`, read-only mounts
322/// refuse `READ_ONLY_MOUNT`).
323#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
324#[serde(deny_unknown_fields)]
325pub struct MemConfigureParams {
326    #[schemars(description = "Name of the mem to configure (must be a registered writable mem).")]
327    pub name: String,
328    #[schemars(
329        description = "New display title. Absent = untouched; empty string = clear (the roster falls back to the mem name)."
330    )]
331    #[serde(default)]
332    pub title: Option<String>,
333    #[schemars(
334        description = "New one-line description. Absent = untouched; empty string = clear."
335    )]
336    #[serde(default)]
337    pub description: Option<String>,
338    #[schemars(
339        description = "New subject block `{scope, method?, exclusions?}`. Absent = untouched; to clear the block as a unit pass `clear_subject: true` instead."
340    )]
341    #[serde(default)]
342    pub subject: Option<MemSubjectInput>,
343    #[schemars(
344        description = "Clear the subject block as a unit. Mutually exclusive with `subject` (both set refuses `INVALID_INPUT`)."
345    )]
346    #[serde(default)]
347    pub clear_subject: bool,
348    #[schemars(
349        description = "Optional provenance note (≤280 chars) recorded on each field's config commit."
350    )]
351    #[serde(default)]
352    pub note: Option<String>,
353}