From 484ae083f502fb53eb594b958f456870b096bff0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 18:26:17 +0000 Subject: [PATCH 1/2] Add KEEP mutate surface for bound node/edge writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #112. Dedicated spl mutate / spl_mutate applies one ops batch through existing ctxgit Stage/Commit (short-lived spool/mcp/… branch + PR). Bound-only FindBind fail-closed. Golden KEEP lists include mutate; REMOVE still excludes add/commit/status/branch/switch. Co-authored-by: Werner Swart --- .agents/skills/spool-architecture/SKILL.md | 6 +- .../spool-engineering-standards/SKILL.md | 6 +- .agents/skills/spool-product/SKILL.md | 6 +- .../spool-technical-implementation/SKILL.md | 6 +- .agents/skills/spool/SKILL.md | 12 +- .../spool/references/batch-authoring.md | 21 ++- .agents/skills/spool/references/cli-help.md | 19 ++- .agents/skills/spool/references/schemas.md | 3 +- .../spool/references/working-changes.md | 9 +- CHANGELOG.md | 13 +- README.md | 20 ++- cmd/spl/commands/mutate.go | 52 +++++++ cmd/spl/commands/mutate_test.go | 104 +++++++++++++ cmd/spl/root.go | 1 + cmd/spl/root_test.go | 2 + docs/architecture.md | 10 +- internal/AGENTS.md | 4 +- internal/ctxgit/mutate.go | 52 +++++++ internal/ctxgit/mutate_test.go | 140 ++++++++++++++++++ internal/mcp/context_write_test.go | 68 +++++++-- internal/mcp/server_test.go | 6 + internal/mcp/tool_mutate.go | 52 +++++++ internal/mcp/tools.go | 1 + internal/surface/surface.go | 3 +- internal/surface/surface_test.go | 17 +++ 25 files changed, 584 insertions(+), 49 deletions(-) create mode 100644 cmd/spl/commands/mutate.go create mode 100644 cmd/spl/commands/mutate_test.go create mode 100644 internal/ctxgit/mutate.go create mode 100644 internal/ctxgit/mutate_test.go create mode 100644 internal/mcp/tool_mutate.go diff --git a/.agents/skills/spool-architecture/SKILL.md b/.agents/skills/spool-architecture/SKILL.md index 1679747..6b39494 100644 --- a/.agents/skills/spool-architecture/SKILL.md +++ b/.agents/skills/spool-architecture/SKILL.md @@ -122,12 +122,12 @@ Create a mutation-batch JSON file: ] ``` -Write the batch through a schema migration (short-lived branch + PR): +Write the batch through `mutate` (short-lived branch + PR): -- **MCP (Default)**: Call `spl_schema_migrate` with the current schema and `operations`. +- **MCP (Default)**: Call `spl_mutate` with the `operations` array. - **CLI (Fallback)**: ```sh - spl schema migrate --schema schema.toml --batch arch-batch.json \ + spl mutate --batch arch-batch.json \ --author "Architect " --message "Record transactional outbox architecture decision" ``` diff --git a/.agents/skills/spool-engineering-standards/SKILL.md b/.agents/skills/spool-engineering-standards/SKILL.md index 379ba28..4d563c4 100644 --- a/.agents/skills/spool-engineering-standards/SKILL.md +++ b/.agents/skills/spool-engineering-standards/SKILL.md @@ -107,12 +107,12 @@ Create a mutation-batch JSON file: ] ``` -Write the batch through a schema migration (short-lived branch + PR): +Write the batch through `mutate` (short-lived branch + PR): -- **MCP (Default)**: Call `spl_schema_migrate` with the current schema and `operations`. +- **MCP (Default)**: Call `spl_mutate` with the `operations` array. - **CLI (Fallback)**: ```sh - spl schema migrate --schema schema.toml --batch standards-batch.json \ + spl mutate --batch standards-batch.json \ --author "Staff Engineer " --message "Record RFC 7807 error standard and anti-pattern" ``` diff --git a/.agents/skills/spool-product/SKILL.md b/.agents/skills/spool-product/SKILL.md index 728c6db..279fce4 100644 --- a/.agents/skills/spool-product/SKILL.md +++ b/.agents/skills/spool-product/SKILL.md @@ -113,12 +113,12 @@ Create a purely business-oriented mutation-batch JSON file: ] ``` -Write the batch through a schema migration (short-lived branch + PR): +Write the batch through `mutate` (short-lived branch + PR): -- **MCP (Default)**: Call `spl_schema_migrate` with the current `schema.toml` (or inline TOML) and `operations`. +- **MCP (Default)**: Call `spl_mutate` with the `operations` array. - **CLI (Fallback)**: ```sh - spl schema migrate --schema schema.toml --batch product-batch.json \ + spl mutate --batch product-batch.json \ --author "Product Manager " --message "Record deferred billing address requirement" ``` diff --git a/.agents/skills/spool-technical-implementation/SKILL.md b/.agents/skills/spool-technical-implementation/SKILL.md index 3da013a..c1a9f42 100644 --- a/.agents/skills/spool-technical-implementation/SKILL.md +++ b/.agents/skills/spool-technical-implementation/SKILL.md @@ -184,12 +184,12 @@ Create a mutation-batch JSON file demonstrating cross-role synthesis and `Epheme ] ``` -Write the batch through a schema migration (short-lived branch + PR): +Write the batch through `mutate` (short-lived branch + PR): -- **MCP (Default)**: Call `spl_schema_migrate` with the current schema and `operations`. +- **MCP (Default)**: Call `spl_mutate` with the `operations` array. - **CLI (Fallback)**: ```sh - spl schema migrate --schema schema.toml --batch implementation-batch.json \ + spl mutate --batch implementation-batch.json \ --author "Engineer " --message "Record ephemeral technical spec for outbox relay worker" ``` diff --git a/.agents/skills/spool/SKILL.md b/.agents/skills/spool/SKILL.md index 5c9f278..106960c 100644 --- a/.agents/skills/spool/SKILL.md +++ b/.agents/skills/spool/SKILL.md @@ -81,6 +81,7 @@ Spool includes a native MCP server via `spl mcp`. | **Reads** | `spl_query_context` | `spl query-context --query ` | Replaces former `spl_context` / `spl context` query. | | **Reads** | `spl_search_expand` | `spl search-expand --query ` | Seed retrieval + graph traversal. | | **Graph** | `spl_graph` | `spl graph` | Full bound snapshot. | +| **Mutate** | `spl_mutate` | `spl mutate --batch ` | One node/edge ops batch → short-lived branch + PR. Bound-only. | | **Merge** | `spl_merge_preview` | `spl merge preview --source --target ` | File-graph three-way preview. | | **Merge** | `spl_merge_apply` | `spl merge apply ...` | Clean apply → short-lived branch + PR. | | **Merge** | `spl_merge_conflicts` | `spl merge conflicts --transaction ` | Cache-backed conflict state. | @@ -110,6 +111,13 @@ Context-management KEEP tools require `.spool/context.toml`. If unbound, fail cl Writes never push the protected branch. They open `spool/mcp/-` and a host PR. Identical schema writes are a no-op (no empty PR). +Routine node/edge writes: + +- **MCP**: `spl_mutate(operations: [...], author, message)`. +- **CLI**: `spl mutate --batch mutations.json --author ... --message ...`. + +Do not call removed `spl_add` / `spl_commit`. Use `schema migrate` only when changing `schema.toml`. + Before pruning ephemeral planning data: - **MCP**: `spl_prune(dry_run: true)`, then `spl_prune(author, message)`. @@ -136,9 +144,9 @@ Before pruning ephemeral planning data: | Commands | Reference | | :--- | :--- | | `context init`, `context export` / `migrate-once` | [Working changes](references/working-changes.md) | -| Authoring mutation batches for `schema migrate` | [Batch authoring](references/batch-authoring.md) | +| Authoring mutation batches for `mutate` | [Batch authoring](references/batch-authoring.md) | | History and diff (stock git); file-graph `merge` | [Branches and history](references/branches-and-history.md) | -| `schema migrate`, `validate` | [Schemas](references/schemas.md) | +| `mutate`, `schema migrate`, `validate` | [Schemas](references/schemas.md) | | `resolve`, `search`, `filter`, `search-expand`, `query-context` | [Reading graphs](references/reading-graphs.md) | | `merge` cycle | [Merges](references/merges.md) | | `prune` | [Maintenance](references/maintenance.md) | diff --git a/.agents/skills/spool/references/batch-authoring.md b/.agents/skills/spool/references/batch-authoring.md index 2d95b1a..b20bd65 100644 --- a/.agents/skills/spool/references/batch-authoring.md +++ b/.agents/skills/spool/references/batch-authoring.md @@ -1,8 +1,9 @@ # Batch authoring -A mutation batch is a JSON array. Pass it to `spl schema migrate --batch` (or MCP -`spl_schema_migrate` `operations`) so the candidate graph is validated against the target schema -and written through a short-lived branch + PR. There is no public `spl add` / `spl commit`. +A mutation batch is a JSON array. Pass it to `spl mutate --batch` (or MCP `spl_mutate` +`operations`) so the bound context graph is written through a short-lived branch + PR. +There is no public `spl add` / `spl commit`. Use `schema migrate` only when changing +`schema.toml`. ```json [ @@ -20,7 +21,17 @@ and written through a short-lived branch + PR. There is no public `spl add` / `s ``` ```sh -spl schema migrate --schema schema.toml --batch mutations.json --message "Record rate limit" +spl mutate --batch mutations.json --message "Record rate limit" +``` + +MCP: + +```json +{ + "operations": [ ... ], + "author": "agent ", + "message": "Record rate limit" +} ``` Each node represents one atomic idea: one fact, decision, requirement, question, or task. Its title @@ -57,5 +68,5 @@ Use stable, descriptive IDs. Add edges when a relationship matters: ``` Properties are typed values: `null`, `bool`, `integer`, `float`, `string`, `list`, or `map`. -`list` and `map` values recursively contain typed values. Use `spl schema migrate --help` for the +`list` and `map` values recursively contain typed values. Use `spl mutate --help` for the current operation contract before authoring unfamiliar fields. diff --git a/.agents/skills/spool/references/cli-help.md b/.agents/skills/spool/references/cli-help.md index 64a2a6d..b285ec5 100644 --- a/.agents/skills/spool/references/cli-help.md +++ b/.agents/skills/spool/references/cli-help.md @@ -22,6 +22,7 @@ spl merge --help | `context export`, `context migrate-once` | Export leftover `.spl` into bound context git | | `query-context` | Evidence-focused bounded graph context | | `search`, `filter`, `search-expand`, `resolve`, `graph` | Query the bound checkout | +| `mutate` | Write node/edge mutations (one batch → branch+PR) | | `schema migrate`, `validate` | Write a schema (branch+PR) and validate | | `merge preview/apply/conflicts/resolve/finalize/abort` | File-graph merge | | `prune` | Remove `Ephemeral` nodes and cascading edges | @@ -63,6 +64,22 @@ context export / migrate-once The `context` namespace is **not** the query verb. Use `query-context`. +## Mutate + +```sh +spl mutate --batch mutations.json --message "Record requirement" +``` + +```text +mutate + --batch JSON mutation-operation array (required) + --author + --message +``` + +`mutate` is bound-only and refuses unbound workspaces. One ops batch becomes one git commit on a +short-lived branch plus pull request. Use `schema migrate` when changing `schema.toml`. + ## Reading graphs ```sh @@ -172,4 +189,4 @@ spl completion bash spl help query-context ``` -`mcp` exposes the KEEP tool set (19 tools), matching `spl --help`. +`mcp` exposes the KEEP tool set (20 tools), matching `spl --help`. diff --git a/.agents/skills/spool/references/schemas.md b/.agents/skills/spool/references/schemas.md index 0521810..293b8a6 100644 --- a/.agents/skills/spool/references/schemas.md +++ b/.agents/skills/spool/references/schemas.md @@ -9,6 +9,7 @@ spl validate ``` `schema migrate` validates the candidate graph against the target schema before opening a PR. -Identical schema content is a no-op (no empty PR). There is no separate `spl commit`. +Identical schema content is a no-op (no empty PR). Routine node/edge writes without a schema change +use `spl mutate` / `spl_mutate`. There is no public `spl add` / `spl commit`. `validate` checks the bound checkout against `schema.toml`. diff --git a/.agents/skills/spool/references/working-changes.md b/.agents/skills/spool/references/working-changes.md index 27c80c2..752dc0c 100644 --- a/.agents/skills/spool/references/working-changes.md +++ b/.agents/skills/spool/references/working-changes.md @@ -20,16 +20,19 @@ Rack or `.spl` as source of truth. ## Graph writes -There is no public `spl add` / `spl commit`. Bound writes go through KEEP commands that open a -short-lived `spool/mcp/-` branch and a pull request: +There is no public `spl add` / `spl commit`. Routine node and edge writes use `spl mutate` +(MCP: `spl_mutate`). Bound writes open a short-lived `spool/mcp/-` branch and a +pull request: ```sh +spl mutate --batch mutations.json --message "Record requirement" spl schema migrate --schema schema.toml --batch mutations.json spl asset add --file docs/architecture.md --title "Architecture notes" spl prune --author alice --message "Prune transient plan" ``` -Identical schema content is a no-op (no empty PR). +`mutate` is bound-only and refuses unbound workspaces. Use `schema migrate` when changing +`schema.toml`. Identical schema content is a no-op (no empty PR). ## History and diff diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f58a76..1a6ced5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ generated from commits since the preceding `v*` tag. Commits prefixed with `docs ### Added +- Dedicated KEEP `spl mutate` / MCP `spl_mutate` for routine bound node and + edge writes. One mutation-operation batch becomes one git commit on a + short-lived `spool/mcp/-` branch plus pull request via existing + `ctxgit` Stage/Commit. Bound-only (`FindBind` fail-closed). Schema changes + still use `schema migrate`. Does not restore `add` / `commit` / `status` / + `branch` / `switch`. - Documented `.spool/context.toml` bind format and the N-code-repos → one context git remote as the only durable SoT (`docs/context-bind.md`). - `spl context init --remote` seeds `CodeRepository` nodes from explicit binds @@ -36,9 +42,10 @@ generated from commits since the preceding `v*` tag. Commits prefixed with `docs cascading edges (short-lived branch + PR). It is not pack/CAS garbage collection. Unbound workspaces are refused. - Identical schema migrations are a no-op (no empty commit or PR). -- MCP advertises the KEEP tool set only. `spl --help` matches that surface. A golden KEEP/REMOVE - list fails `make check` if a removed CLI name or MCP tool reappears (including `spl_context` and - Spool VCS wrappers `add`/`status`/`commit`/`branch`/`switch`). +- MCP advertises the KEEP tool set only (including `spl_mutate`). `spl --help` matches that + surface. A golden KEEP/REMOVE list fails `make check` if a removed CLI name or MCP tool + reappears (including `spl_context` and Spool VCS wrappers `add`/`status`/`commit`/`branch`/ + `switch`). - **Sunset stop-list for solution context:** Spool-as-VCS, Rack sync, `.spl` as durable SoT, and pack wire-compat are stopped. Bind + stock git is the **only** durable SoT. Every agent write is a short-lived branch + PR (not push-clean to diff --git a/README.md b/README.md index 2ddbf93..3f99893 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,15 @@ spl validate Use stock git on the context remote for history and diff. Spool does not wrap `git log` or `git diff`. +Write routine node and edge mutations (one JSON batch → short-lived branch + PR): + +```sh +spl mutate --batch mutations.json --message "Record requirement" +``` + +`mutate` requires `.spool/context.toml` and refuses unbound workspaces. Use `schema migrate` when +changing `schema.toml`. + ## Context bind, export, and migrate-once ```sh @@ -93,7 +102,13 @@ VCS commands. `migrate-once` skips the write when titles are already present. The `context` namespace is **only** init/export/migrate-once. Graph queries use `query-context`. -## Schema, assets, merge, and prune +## Mutate, schema, assets, merge, and prune + +Write routine node and edge mutations as one JSON batch. Bound-only; opens a short-lived branch + PR: + +```sh +spl mutate --batch mutations.json --author alice --message "Record requirement" +``` Author a schema in TOML and apply conforming graph mutations: @@ -156,7 +171,7 @@ when it deletes ephemeral nodes and cascading edges. Spool includes a native Model Context Protocol server built on the official Go SDK ([`github.com/modelcontextprotocol/go-sdk`](https://github.com/modelcontextprotocol/go-sdk)). -`spl mcp` exposes the **KEEP** tool set (19 tools) over stdio. The tool list matches `spl --help`. +`spl mcp` exposes the **KEEP** tool set (20 tools) over stdio. The tool list matches `spl --help`. ### Client configuration @@ -186,6 +201,7 @@ The installed surface, including generated help, is documented in | `context export`, `context migrate-once` | Export leftover `.spl` into bound context git | | `query-context` | Evidence-focused bounded graph context (replaces query-`context`) | | `search`, `search-expand`, `filter`, `resolve`, `graph` | Bound checkout reads | +| `mutate` | Routine node/edge writes (one batch → branch+PR) | | `schema migrate`, `validate` | Schema write (branch+PR) and validation | | `asset add`, `asset read` | Reference assets on the bound checkout | | `merge preview/apply/conflicts/resolve/finalize/abort` | File-graph merge | diff --git a/cmd/spl/commands/mutate.go b/cmd/spl/commands/mutate.go new file mode 100644 index 0000000..5a35a7f --- /dev/null +++ b/cmd/spl/commands/mutate.go @@ -0,0 +1,52 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/autonomous-bits/spool/internal/ctxgit" + "github.com/autonomous-bits/spool/internal/repository" + "github.com/spf13/cobra" +) + +// NewMutateCommand writes one node/edge mutation batch via a short-lived branch + PR. +func NewMutateCommand(opts ctxgit.Options) *cobra.Command { + var batchPath, author, message string + command := &cobra.Command{ + Use: "mutate", + Short: "Mutate the bound context graph", + Long: "Apply one JSON mutation-operation batch (nodes and edges) to the bound context checkout and open a short-lived branch + PR. Unbound workspaces are refused. Use schema migrate when changing schema.toml.", + Example: " spl mutate --batch mutations.json --message \"Record requirement\"", + Args: cobra.NoArgs, + SilenceUsage: true, + RunE: func(command *cobra.Command, _ []string) error { + data, err := os.ReadFile(batchPath) + if err != nil { + return fmt.Errorf("read mutation batch: %w", err) + } + var operations []repository.MutationOperation + if err := json.Unmarshal(data, &operations); err != nil { + return fmt.Errorf("decode mutation batch: %w", err) + } + session, err := startBoundSession(command, opts) + if err != nil { + return err + } + result, err := session.Mutate(command.Context(), ctxgit.MutateRequest{ + Operations: operations, + Author: author, + Message: message, + }) + if err != nil { + return err + } + return json.NewEncoder(command.OutOrStdout()).Encode(result) + }, + } + command.Flags().StringVar(&batchPath, "batch", "", "path to a JSON mutation-operation array") + command.Flags().StringVar(&author, "author", "", "git author") + command.Flags().StringVar(&message, "message", "", "commit/PR message") + _ = command.MarkFlagRequired("batch") + return command +} diff --git a/cmd/spl/commands/mutate_test.go b/cmd/spl/commands/mutate_test.go new file mode 100644 index 0000000..1de8986 --- /dev/null +++ b/cmd/spl/commands/mutate_test.go @@ -0,0 +1,104 @@ +package commands + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/autonomous-bits/spool/internal/ctxgit" +) + +func TestMutateCLIUnboundRefused(t *testing.T) { + var output bytes.Buffer + command := NewMutateCommand(ctxgit.Options{WorkspaceDir: t.TempDir()}) + command.SetOut(&output) + batch := filepath.Join(t.TempDir(), "batch.json") + if err := os.WriteFile(batch, []byte(`[{"action":"add","entity":"node","id":"n1","title":"Nope"}]`), 0o600); err != nil { + t.Fatalf("write batch: %v", err) + } + command.SetArgs([]string{"--batch", batch}) + err := command.Execute() + if err == nil || !strings.Contains(err.Error(), "not bound") { + t.Fatalf("error = %v, want unbound", err) + } + if output.Len() != 0 { + t.Fatalf("unbound mutate wrote output: %q", output.String()) + } +} + +func TestMutateCLIWritesPR(t *testing.T) { + opts := boundCommandOptions(t) + batch := filepath.Join(t.TempDir(), "batch.json") + payload := `[ + {"action":"add","entity":"node","id":"idea-1","title":"Shared idea","labels":["Requirement"]}, + {"action":"add","entity":"node","id":"idea-2","title":"Related idea","labels":["Requirement"]}, + {"action":"add","entity":"edge","id":"idea-2-relates","source":"idea-2","target":"idea-1","type":"RELATES_TO"} +]` + if err := os.WriteFile(batch, []byte(payload), 0o600); err != nil { + t.Fatalf("write batch: %v", err) + } + var output bytes.Buffer + command := NewMutateCommand(opts) + command.SetOut(&output) + command.SetArgs([]string{"--batch", batch, "--author", "alice", "--message", "Record shared ideas"}) + if err := command.Execute(); err != nil { + t.Fatalf("execute mutate: %v\n%s", err, output.String()) + } + var result ctxgit.MutateResult + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatalf("decode mutate JSON: %v\n%s", err, output.String()) + } + if result.Operations != 3 { + t.Fatalf("operations = %d, want 3", result.Operations) + } + if !strings.HasPrefix(result.Branch, "spool/mcp/") { + t.Fatalf("branch = %q, want spool/mcp/ prefix; json=%s", result.Branch, output.String()) + } + if result.PR.URL == "" { + t.Fatalf("missing PR: %s", output.String()) + } +} + +func TestMutateCLIRejectsInvalidBatch(t *testing.T) { + opts := boundCommandOptions(t) + batch := filepath.Join(t.TempDir(), "batch.json") + if err := os.WriteFile(batch, []byte(`{"not":"an array"}`), 0o600); err != nil { + t.Fatalf("write batch: %v", err) + } + var output bytes.Buffer + command := NewMutateCommand(opts) + command.SetOut(&output) + command.SetArgs([]string{"--batch", batch}) + err := command.Execute() + if err == nil || !strings.Contains(err.Error(), "decode mutation batch") { + t.Fatalf("error = %v, want decode error", err) + } +} + +func TestMutateCLIHelpDescribesGraphWrite(t *testing.T) { + var output bytes.Buffer + command := NewMutateCommand(ctxgit.Options{}) + command.SetOut(&output) + command.SetArgs([]string{"--help"}) + if err := command.Execute(); err != nil { + t.Fatalf("execute mutate help: %v", err) + } + help := output.String() + for _, text := range []string{ + "spl mutate --batch mutations.json --message \"Record requirement\"", + "short-lived branch", + "schema migrate", + } { + if !strings.Contains(help, text) { + t.Errorf("mutate help does not contain %q:\n%s", text, help) + } + } + for _, name := range []string{"spl add", "spl commit", "spl status", "spl branch", "spl switch"} { + if strings.Contains(help, name) { + t.Errorf("mutate help must not teach restored VCS wrapper %q:\n%s", name, help) + } + } +} diff --git a/cmd/spl/root.go b/cmd/spl/root.go index 4cc5ac2..6825fca 100644 --- a/cmd/spl/root.go +++ b/cmd/spl/root.go @@ -32,6 +32,7 @@ func newRootCommandWithOptions(stdout io.Writer, opts ctxgit.Options) *cobra.Com root.AddCommand(commands.NewValidateCommand(opts)) root.AddCommand(commands.NewAssetCommand(opts)) root.AddCommand(commands.NewMergeCommand(opts)) + root.AddCommand(commands.NewMutateCommand(opts)) root.AddCommand(commands.NewPruneCommand(opts)) return root } diff --git a/cmd/spl/root_test.go b/cmd/spl/root_test.go index 7e808ff..20aa285 100644 --- a/cmd/spl/root_test.go +++ b/cmd/spl/root_test.go @@ -71,6 +71,7 @@ func TestKeepCommandsAreRegistered(t *testing.T) { {"asset", "add"}, {"asset", "read"}, {"merge", "preview"}, + {"mutate"}, {"prune"}, {"mcp"}, {"version"}, @@ -99,6 +100,7 @@ func TestCommandHelpIncludesExamples(t *testing.T) { {[]string{"search", "--help"}, "spl search --query incident"}, {[]string{"search-expand", "--help"}, "spl search-expand --query incident"}, {[]string{"prune", "--help"}, "spl prune"}, + {[]string{"mutate", "--help"}, "spl mutate --batch"}, } for _, testCase := range testCases { t.Run(strings.Join(testCase.path, " "), func(t *testing.T) { diff --git a/docs/architecture.md b/docs/architecture.md index dc2f2f8..09f1606 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,8 +36,8 @@ VCS commands and do not reopen Rack or `.spl` as source of truth. | Component | Responsibility | | --- | --- | | `cmd/spl` | KEEP Cobra commands. The `context` namespace is init/export/migrate-once only. Graph queries use `query-context`. | -| `internal/mcp` | KEEP MCP tools (19) over stdio. No Rack/workspace twins of removed commands. | -| `internal/ctxgit` | Bind file, checkout, graph load/store, projection rebuild, short-lived branch + PR writes, bound queries, file-graph merge, graph prune, leftover `.spl` export. | +| `internal/mcp` | KEEP MCP tools (20) over stdio. No Rack/workspace twins of removed commands. | +| `internal/ctxgit` | Bind file, checkout, graph load/store, projection rebuild, short-lived branch + PR writes (including `mutate`), bound queries, file-graph merge, graph prune, leftover `.spl` export. | | `internal/resolve` | Query-budget and retrieval result shapes used by bound ctxgit reads. | | `internal/contextual` | Direction and evidence-expansion types used by `query-context` / `search-expand`. | | `internal/repository` | **Private library.** Historical CAS/Rack graph storage. No public CLI/MCP wrappers. Used internally by leftover `.spl` export. | @@ -51,7 +51,7 @@ VCS commands and do not reopen Rack or `.spl` as source of truth. | --- | --- | | Happy path | `context init` (`--remote`), `context export` / `migrate-once`, `mcp`, `version` / `help` / `completion` | | Bound reads | `search`, `search-expand`, `filter`, `resolve`, `graph`, `query-context` | -| Bound writes | `schema migrate`, `validate`, `asset add` / `read`, `merge *` (file-graph), `prune` | +| Bound writes | `mutate`, `schema migrate`, `validate`, `asset add` / `read`, `merge *` (file-graph), `prune` | The former query verb `context` is **`query-context`** (MCP: `spl_query_context`). There is no alias. @@ -95,8 +95,8 @@ workspaces. ### Write (short-lived branch + PR) -`schema migrate`, `asset add`, file-graph `merge apply` / `finalize`, and `prune` persist a graph -diff, `git add`, and: +`mutate`, `schema migrate`, `asset add`, file-graph `merge apply` / `finalize`, and `prune` persist a +graph diff, `git add`, and: - if the tree is clean, return a no-op on the protected branch (no empty PR); - otherwise commit on a short-lived branch, push that branch only, and open a host PR. diff --git a/internal/AGENTS.md b/internal/AGENTS.md index e4c36c7..824d675 100644 --- a/internal/AGENTS.md +++ b/internal/AGENTS.md @@ -6,8 +6,8 @@ See the [architecture guide](../docs/architecture.md) for the system-level component boundaries, data model, and persistence design. - `ctxgit/` is the public durable solution-context path: bind file, checkout, file graph, - disposable projection, short-lived branch + PR writes, bound queries, file-graph merge, - graph prune, leftover `.spl` export. + disposable projection, short-lived branch + PR writes (including `mutate`), bound queries, + file-graph merge, graph prune, leftover `.spl` export. - `repository/` is a **private library** (CAS/Rack leftovers). Do not wrap it as public CLI/MCP. Private readers are allowed only for `context export`. - `resolve/` owns query-budget and retrieval result shapes used by bound ctxgit reads. diff --git a/internal/ctxgit/mutate.go b/internal/ctxgit/mutate.go new file mode 100644 index 0000000..1bedd66 --- /dev/null +++ b/internal/ctxgit/mutate.go @@ -0,0 +1,52 @@ +package ctxgit + +import ( + "context" + "strings" + + "github.com/autonomous-bits/spool/internal/repository" +) + +// MutateRequest is one bound node/edge mutation batch. +type MutateRequest struct { + Operations []repository.MutationOperation + Author string + Message string +} + +// MutateResult is the short-lived branch + PR from a bound graph mutation. +type MutateResult struct { + WriteResult + Operations int `json:"operations"` +} + +// Mutate applies one mutation-operation batch to the bound context graph and +// opens a short-lived branch + PR via Stage then Commit. Bound-only: FindBind +// fail-closed. Empty batches are rejected. This is not schema migrate. +func (s *Session) Mutate(ctx context.Context, request MutateRequest) (MutateResult, error) { + if s == nil || s.CodeRoot == "" { + return MutateResult{}, UnboundError() + } + if _, _, _, err := FindBind(s.CodeRoot); err != nil { + return MutateResult{}, err + } + if !s.Bound() { + return MutateResult{}, UnboundError() + } + if err := ctx.Err(); err != nil { + return MutateResult{}, err + } + staged, err := s.Stage(request.Operations) + if err != nil { + return MutateResult{}, err + } + message := strings.TrimSpace(request.Message) + if message == "" { + message = "Mutate context graph" + } + write, err := s.Commit(ctx, request.Author, message) + if err != nil { + return MutateResult{}, err + } + return MutateResult{WriteResult: write, Operations: staged.Operations}, nil +} diff --git a/internal/ctxgit/mutate_test.go b/internal/ctxgit/mutate_test.go new file mode 100644 index 0000000..6ae2752 --- /dev/null +++ b/internal/ctxgit/mutate_test.go @@ -0,0 +1,140 @@ +package ctxgit + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/autonomous-bits/spool/internal/repository" +) + +func TestMutateUnboundRefused(t *testing.T) { + t.Parallel() + var session *Session + _, err := session.Mutate(context.Background(), MutateRequest{ + Operations: []repository.MutationOperation{{ + Action: "add", Entity: "node", ID: "n1", Title: "Nope", + }}, + }) + if !errors.Is(err, ErrUnbound) { + t.Fatalf("error = %v, want ErrUnbound", err) + } +} + +func TestMutateFailClosedWhenBindRemoved(t *testing.T) { + ctx := context.Background() + codeRoot, _, cache := setupBoundWorkspace(t) + session, err := Start(ctx, Options{WorkspaceDir: codeRoot, CacheDir: cache, Git: isolatedGit(), PROpener: &RecordingPROpener{}}) + if err != nil { + t.Fatalf("Start: %v", err) + } + if err := os.Remove(session.BindPath); err != nil { + t.Fatalf("remove bind: %v", err) + } + _, err = session.Mutate(ctx, MutateRequest{ + Operations: []repository.MutationOperation{{ + Action: "add", Entity: "node", ID: "idea-1", Title: "Shared idea", Labels: []string{"Requirement"}, + }}, + Message: "must fail closed", + }) + if !errors.Is(err, ErrUnbound) { + t.Fatalf("error = %v, want ErrUnbound after bind removal", err) + } +} + +func TestMutateEmptyBatchRejected(t *testing.T) { + ctx := context.Background() + codeRoot, _, cache := setupBoundWorkspace(t) + recorder := &RecordingPROpener{} + session, err := Start(ctx, Options{WorkspaceDir: codeRoot, CacheDir: cache, Git: isolatedGit(), PROpener: recorder}) + if err != nil { + t.Fatalf("Start: %v", err) + } + _, err = session.Mutate(ctx, MutateRequest{Message: "empty"}) + if !errors.Is(err, repository.ErrInvalidMutationBatch) { + t.Fatalf("error = %v, want ErrInvalidMutationBatch", err) + } + if len(recorder.Requests) != 0 { + t.Fatalf("empty batch must not open a PR: %#v", recorder.Requests) + } +} + +func TestMutateWritesShortLivedBranchAndPR(t *testing.T) { + ctx := context.Background() + codeRoot, remote, cache := setupBoundWorkspace(t) + recorder := &RecordingPROpener{} + session, err := Start(ctx, Options{ + WorkspaceDir: codeRoot, + CacheDir: cache, + Git: isolatedGit(), + PROpener: recorder, + }) + if err != nil { + t.Fatalf("Start: %v", err) + } + + result, err := session.Mutate(ctx, MutateRequest{ + Operations: []repository.MutationOperation{ + {Action: "add", Entity: "node", ID: "idea-1", Title: "Shared idea", Labels: []string{"Requirement"}}, + {Action: "add", Entity: "node", ID: "idea-2", Title: "Related idea", Labels: []string{"Requirement"}}, + {Action: "add", Entity: "edge", ID: "idea-2-relates", Source: "idea-2", Target: "idea-1", Type: "RELATES_TO"}, + }, + Author: "agent ", + Message: "Record shared ideas", + }) + if err != nil { + t.Fatalf("Mutate: %v", err) + } + if result.Operations != 3 { + t.Fatalf("operations = %d, want 3", result.Operations) + } + if !strings.HasPrefix(result.Branch, "spool/mcp/") { + t.Fatalf("short-lived branch = %q", result.Branch) + } + if result.Commit == "" || result.PR.URL == "" { + t.Fatalf("result = %#v", result) + } + if result.ProtectedBranch != "main" { + t.Fatalf("protected branch = %q", result.ProtectedBranch) + } + if len(recorder.Requests) != 1 || recorder.Requests[0].Base != "main" || recorder.Requests[0].Head != result.Branch { + t.Fatalf("PR requests = %#v", recorder.Requests) + } + + node, ok := session.ResolveNode("idea-1") + if !ok || node.ID != "demo-repo/idea-1" { + t.Fatalf("namespaced resolve = %#v ok=%v", node, ok) + } + + checkout := cloneAt(t, remote, result.Branch) + nodePath := filepath.Join(checkout, "nodes", "demo-repo--idea-1.json") + data, err := os.ReadFile(nodePath) + if err != nil { + t.Fatalf("read node json: %v", err) + } + if !json.Valid(data) { + t.Fatalf("node file is not JSON: %s", data) + } + var decoded repository.Node + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("decode node: %v", err) + } + if decoded.ID != "demo-repo/idea-1" || decoded.Title != "Shared idea" { + t.Fatalf("decoded node = %#v", decoded) + } + if _, err := os.Stat(filepath.Join(checkout, "edges", "demo-repo--idea-2-relates.json")); err != nil { + t.Fatalf("edge file missing: %v", err) + } + if _, err := os.Stat(filepath.Join(checkout, "projection.db")); !os.IsNotExist(err) { + t.Fatal("projection.db must not be committed to the context remote") + } + + protected := cloneAt(t, remote, "main") + if _, err := os.Stat(filepath.Join(protected, "nodes", "demo-repo--idea-1.json")); !os.IsNotExist(err) { + t.Fatal("successful mutate must not push-clean to the protected branch") + } +} diff --git a/internal/mcp/context_write_test.go b/internal/mcp/context_write_test.go index 22cabb4..f8f7a56 100644 --- a/internal/mcp/context_write_test.go +++ b/internal/mcp/context_write_test.go @@ -20,18 +20,26 @@ func TestMCPWriteRequiresBind(t *testing.T) { session := connectMCP(t, ctx, server) defer func() { _ = session.Close() }() - res, err := session.CallTool(ctx, &officialmcp.CallToolParams{ - Name: "spl_prune", - Arguments: map[string]any{"dry_run": true}, - }) - if err != nil { - t.Fatalf("CallTool: %v", err) - } - if !res.IsError { - t.Fatal("unbound spl_prune must fail closed") - } - if !strings.Contains(toolText(res), "not bound") { - t.Fatalf("error = %s, want unbound message", toolText(res)) + for _, tool := range []struct { + name string + args map[string]any + }{ + {name: "spl_prune", args: map[string]any{"dry_run": true}}, + {name: "spl_mutate", args: map[string]any{"operations": []map[string]any{{"action": "add", "entity": "node", "id": "n1", "title": "Nope"}}}}, + } { + res, err := session.CallTool(ctx, &officialmcp.CallToolParams{ + Name: tool.name, + Arguments: tool.args, + }) + if err != nil { + t.Fatalf("%s CallTool: %v", tool.name, err) + } + if !res.IsError { + t.Fatalf("unbound %s must fail closed", tool.name) + } + if !strings.Contains(toolText(res), "not bound") { + t.Fatalf("%s error = %s, want unbound message", tool.name, toolText(res)) + } } } @@ -77,6 +85,42 @@ func TestMCPBoundQueryAndSchemaWriteOpensPR(t *testing.T) { } } +func TestMCPBoundMutateOpensPR(t *testing.T) { + ctx := context.Background() + codeRoot, _, cache, recorder := setupMCPBound(t) + server := NewSpoolServerWithOptions(ServerOptions{ + WorkspaceDir: func() (string, error) { return codeRoot, nil }, + CacheDir: cache, + PROpener: recorder, + Git: isolatedMCPGit(), + }) + session := connectMCP(t, ctx, server) + defer func() { _ = session.Close() }() + + res, err := session.CallTool(ctx, &officialmcp.CallToolParams{ + Name: "spl_mutate", + Arguments: map[string]any{ + "message": "Record shared idea", + "operations": []map[string]any{ + {"action": "add", "entity": "node", "id": "idea-1", "title": "Shared idea", "labels": []string{"Requirement"}}, + }, + }, + }) + if err != nil || res.IsError { + t.Fatalf("spl_mutate: err=%v res=%s", err, toolText(res)) + } + text := toolText(res) + if !strings.Contains(text, `"branch":"spool/mcp/`) { + t.Fatalf("mutate missing short-lived branch: %s", text) + } + if !strings.Contains(text, `"url"`) || len(recorder.Requests) != 1 { + t.Fatalf("mutate missing PR: %s requests=%#v", text, recorder.Requests) + } + if !strings.Contains(text, `"operations":1`) { + t.Fatalf("mutate missing operations count: %s", text) + } +} + func setupMCPBound(t *testing.T) (codeRoot, remote, cache string, recorder *ctxgit.RecordingPROpener) { t.Helper() root := t.TempDir() diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 6319e01..9a76aa8 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -64,9 +64,15 @@ func TestSpoolMCPServerKeepToolsOnly(t *testing.T) { if !containsTool(toolsList.Tools, "spl_query_context") { t.Fatal("KEEP tools must include spl_query_context") } + if !containsTool(toolsList.Tools, "spl_mutate") { + t.Fatal("KEEP tools must include spl_mutate") + } if containsTool(toolsList.Tools, "spl_context") { t.Fatal("old spl_context query tool must not be registered") } + if containsTool(toolsList.Tools, "spl_add") || containsTool(toolsList.Tools, "spl_commit") { + t.Fatal("removed add/commit MCP twins must not be registered") + } res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "spl_version"}) if err != nil || res.IsError { diff --git a/internal/mcp/tool_mutate.go b/internal/mcp/tool_mutate.go new file mode 100644 index 0000000..7d877e0 --- /dev/null +++ b/internal/mcp/tool_mutate.go @@ -0,0 +1,52 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + + "github.com/autonomous-bits/spool/internal/ctxgit" + "github.com/autonomous-bits/spool/internal/repository" +) + +func toolMutate(rt *runtime) Tool { + return Tool{ + Name: "spl_mutate", + Description: "Apply one node/edge mutation-operation batch to the bound context graph via a short-lived branch and PR. Bound-only. Use schema migrate when changing schema.toml.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "operations": map[string]any{ + "type": "array", + "description": "Mutation operations (add/update/delete of nodes and edges)", + "items": map[string]any{"type": "object"}, + }, + "author": map[string]any{"type": "string", "description": "Optional git author"}, + "message": map[string]any{"type": "string", "description": "Optional commit/PR message"}, + }, + "required": []string{"operations"}, + }, + Handler: func(ctx context.Context, args json.RawMessage) (any, error) { + var in struct { + Operations []repository.MutationOperation `json:"operations"` + Author string `json:"author"` + Message string `json:"message"` + } + if err := json.Unmarshal(args, &in); err != nil { + return nil, err + } + if len(in.Operations) == 0 { + return nil, errors.New("operations is required") + } + session, err := rt.requireSession(ctx) + if err != nil { + return nil, err + } + return session.Mutate(ctx, ctxgit.MutateRequest{ + Operations: in.Operations, + Author: in.Author, + Message: in.Message, + }) + }, + } +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index e800721..9197331 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -85,6 +85,7 @@ func RegisterAllTools(s *mcp.Server, rt *runtime) { toolMergeResolve(rt), toolMergeAbort(rt), toolMergeFinalize(rt), + toolMutate(rt), toolSchemaMigrate(rt), toolValidate(rt), toolPrune(rt), diff --git a/internal/surface/surface.go b/internal/surface/surface.go index 2ef877a..2b3d6be 100644 --- a/internal/surface/surface.go +++ b/internal/surface/surface.go @@ -5,7 +5,7 @@ package surface // KeepCLITopLevel is the exact set of top-level Cobra commands advertised by spl --help. var KeepCLITopLevel = []string{ "asset", "completion", "context", "filter", "graph", "help", "mcp", - "merge", "prune", "query-context", "resolve", "schema", "search", + "merge", "mutate", "prune", "query-context", "resolve", "schema", "search", "search-expand", "validate", "version", } @@ -30,6 +30,7 @@ var KeepMCPTools = []string{ "spl_merge_finalize", "spl_merge_preview", "spl_merge_resolve", + "spl_mutate", "spl_prune", "spl_query_context", "spl_resolve", diff --git a/internal/surface/surface_test.go b/internal/surface/surface_test.go index cda7c98..888c553 100644 --- a/internal/surface/surface_test.go +++ b/internal/surface/surface_test.go @@ -35,6 +35,23 @@ func TestQueryContextRenameHasNoOldNameOnKeepSurface(t *testing.T) { } } +func TestMutateIsKeepAndVCSWrappersStayRemoved(t *testing.T) { + if !contains(KeepCLITopLevel, "mutate") { + t.Fatal("KEEP CLI must include mutate") + } + if !contains(KeepMCPTools, "spl_mutate") { + t.Fatal("KEEP MCP must include spl_mutate") + } + for _, name := range []string{"spl_add", "spl_commit", "spl_status", "spl_branch_list", "spl_branch_create", "spl_branch_delete", "spl_switch"} { + if contains(KeepMCPTools, name) { + t.Errorf("removed MCP twin %q must not be KEEP", name) + } + if !contains(RemovedMCPTools, name) { + t.Errorf("removed MCP twin %q must stay on the REMOVE golden list", name) + } + } +} + func assertDisjoint(t *testing.T, kind string, keep, removed []string) { t.Helper() seen := map[string]struct{}{} From a5b8967e77d380e2cfe05c5d39eab417576f4296 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 18:33:25 +0000 Subject: [PATCH 2/2] Align mutate surface with Architect design on #112 Use --operations (file or stdin) instead of --batch, return WriteResult written/deleted, no-op identical diffs without an empty PR, and lock mutate against add/commit/status/stage/write aliases. Co-authored-by: Werner Swart --- .agents/skills/spool-architecture/SKILL.md | 2 +- .../spool-engineering-standards/SKILL.md | 2 +- .agents/skills/spool-product/SKILL.md | 2 +- .../spool-technical-implementation/SKILL.md | 2 +- .agents/skills/spool/SKILL.md | 10 +- .../spool/references/batch-authoring.md | 5 +- .agents/skills/spool/references/cli-help.md | 8 +- .../spool/references/working-changes.md | 5 +- CHANGELOG.md | 13 +- README.md | 8 +- cmd/spl/commands/mutate.go | 42 +++-- cmd/spl/commands/mutate_test.go | 75 +++++++-- cmd/spl/root_test.go | 21 ++- internal/ctxgit/mutate.go | 6 +- internal/ctxgit/mutate_test.go | 149 ++++++++++++++++++ internal/ctxgit/write.go | 8 +- internal/mcp/context_write_test.go | 6 + internal/mcp/server_test.go | 4 +- internal/surface/surface_test.go | 10 ++ 19 files changed, 317 insertions(+), 61 deletions(-) diff --git a/.agents/skills/spool-architecture/SKILL.md b/.agents/skills/spool-architecture/SKILL.md index 6b39494..371c172 100644 --- a/.agents/skills/spool-architecture/SKILL.md +++ b/.agents/skills/spool-architecture/SKILL.md @@ -127,7 +127,7 @@ Write the batch through `mutate` (short-lived branch + PR): - **MCP (Default)**: Call `spl_mutate` with the `operations` array. - **CLI (Fallback)**: ```sh - spl mutate --batch arch-batch.json \ + spl mutate --operations arch-batch.json \ --author "Architect " --message "Record transactional outbox architecture decision" ``` diff --git a/.agents/skills/spool-engineering-standards/SKILL.md b/.agents/skills/spool-engineering-standards/SKILL.md index 4d563c4..7d851c0 100644 --- a/.agents/skills/spool-engineering-standards/SKILL.md +++ b/.agents/skills/spool-engineering-standards/SKILL.md @@ -112,7 +112,7 @@ Write the batch through `mutate` (short-lived branch + PR): - **MCP (Default)**: Call `spl_mutate` with the `operations` array. - **CLI (Fallback)**: ```sh - spl mutate --batch standards-batch.json \ + spl mutate --operations standards-batch.json \ --author "Staff Engineer " --message "Record RFC 7807 error standard and anti-pattern" ``` diff --git a/.agents/skills/spool-product/SKILL.md b/.agents/skills/spool-product/SKILL.md index 279fce4..9c77af8 100644 --- a/.agents/skills/spool-product/SKILL.md +++ b/.agents/skills/spool-product/SKILL.md @@ -118,7 +118,7 @@ Write the batch through `mutate` (short-lived branch + PR): - **MCP (Default)**: Call `spl_mutate` with the `operations` array. - **CLI (Fallback)**: ```sh - spl mutate --batch product-batch.json \ + spl mutate --operations product-batch.json \ --author "Product Manager " --message "Record deferred billing address requirement" ``` diff --git a/.agents/skills/spool-technical-implementation/SKILL.md b/.agents/skills/spool-technical-implementation/SKILL.md index c1a9f42..22fc550 100644 --- a/.agents/skills/spool-technical-implementation/SKILL.md +++ b/.agents/skills/spool-technical-implementation/SKILL.md @@ -189,7 +189,7 @@ Write the batch through `mutate` (short-lived branch + PR): - **MCP (Default)**: Call `spl_mutate` with the `operations` array. - **CLI (Fallback)**: ```sh - spl mutate --batch implementation-batch.json \ + spl mutate --operations implementation-batch.json \ --author "Engineer " --message "Record ephemeral technical spec for outbox relay worker" ``` diff --git a/.agents/skills/spool/SKILL.md b/.agents/skills/spool/SKILL.md index 106960c..eaf501a 100644 --- a/.agents/skills/spool/SKILL.md +++ b/.agents/skills/spool/SKILL.md @@ -81,7 +81,7 @@ Spool includes a native MCP server via `spl mcp`. | **Reads** | `spl_query_context` | `spl query-context --query ` | Replaces former `spl_context` / `spl context` query. | | **Reads** | `spl_search_expand` | `spl search-expand --query ` | Seed retrieval + graph traversal. | | **Graph** | `spl_graph` | `spl graph` | Full bound snapshot. | -| **Mutate** | `spl_mutate` | `spl mutate --batch ` | One node/edge ops batch → short-lived branch + PR. Bound-only. | +| **Mutate** | `spl_mutate` | `spl mutate --operations ` | One node/edge ops batch → short-lived branch + PR. Bound-only. | | **Merge** | `spl_merge_preview` | `spl merge preview --source --target ` | File-graph three-way preview. | | **Merge** | `spl_merge_apply` | `spl merge apply ...` | Clean apply → short-lived branch + PR. | | **Merge** | `spl_merge_conflicts` | `spl merge conflicts --transaction ` | Cache-backed conflict state. | @@ -109,14 +109,14 @@ History and diff: use **stock git** on the context remote (`git log`, `git diff` Context-management KEEP tools require `.spool/context.toml`. If unbound, fail closed and tell the user to run `spl context init --remote`. -Writes never push the protected branch. They open `spool/mcp/-` and a host PR. Identical schema writes are a no-op (no empty PR). +Writes never push the protected branch. They open `spool/mcp/-` and a host PR. Identical schema or mutate diffs are a no-op (no empty PR). -Routine node/edge writes: +Routine node/edge writes (same `MutationOperation` shape as schema migrate): - **MCP**: `spl_mutate(operations: [...], author, message)`. -- **CLI**: `spl mutate --batch mutations.json --author ... --message ...`. +- **CLI**: `spl mutate --operations mutations.json --author ... --message ...` (`--operations -` reads stdin). -Do not call removed `spl_add` / `spl_commit`. Use `schema migrate` only when changing `schema.toml`. +Do not call removed `spl_add` / `spl_commit` / `spl_status`. There are no aliases to `add` / `commit` / `status` / `stage` / `write`. Use `schema migrate` only when changing `schema.toml`. Before pruning ephemeral planning data: diff --git a/.agents/skills/spool/references/batch-authoring.md b/.agents/skills/spool/references/batch-authoring.md index b20bd65..17b4672 100644 --- a/.agents/skills/spool/references/batch-authoring.md +++ b/.agents/skills/spool/references/batch-authoring.md @@ -1,6 +1,6 @@ # Batch authoring -A mutation batch is a JSON array. Pass it to `spl mutate --batch` (or MCP `spl_mutate` +A mutation batch is a JSON array. Pass it to `spl mutate --operations` (or MCP `spl_mutate` `operations`) so the bound context graph is written through a short-lived branch + PR. There is no public `spl add` / `spl commit`. Use `schema migrate` only when changing `schema.toml`. @@ -21,7 +21,8 @@ There is no public `spl add` / `spl commit`. Use `schema migrate` only when chan ``` ```sh -spl mutate --batch mutations.json --message "Record rate limit" +spl mutate --operations mutations.json --message "Record rate limit" +cat mutations.json | spl mutate --operations - --message "Record rate limit" ``` MCP: diff --git a/.agents/skills/spool/references/cli-help.md b/.agents/skills/spool/references/cli-help.md index b285ec5..5d6e205 100644 --- a/.agents/skills/spool/references/cli-help.md +++ b/.agents/skills/spool/references/cli-help.md @@ -67,18 +67,20 @@ The `context` namespace is **not** the query verb. Use `query-context`. ## Mutate ```sh -spl mutate --batch mutations.json --message "Record requirement" +spl mutate --operations mutations.json --message "Record requirement" ``` ```text mutate - --batch JSON mutation-operation array (required) + --operations JSON mutation-operation array (required; - reads stdin) --author --message ``` `mutate` is bound-only and refuses unbound workspaces. One ops batch becomes one git commit on a -short-lived branch plus pull request. Use `schema migrate` when changing `schema.toml`. +short-lived branch plus pull request. Pass `-` to `--operations` to read stdin. Identical or empty +effective diffs do not open a PR. Use `schema migrate` when changing `schema.toml`. There are no +aliases to `add` / `commit` / `status` / `stage` / `write`. ## Reading graphs diff --git a/.agents/skills/spool/references/working-changes.md b/.agents/skills/spool/references/working-changes.md index 752dc0c..05dfb87 100644 --- a/.agents/skills/spool/references/working-changes.md +++ b/.agents/skills/spool/references/working-changes.md @@ -25,14 +25,15 @@ There is no public `spl add` / `spl commit`. Routine node and edge writes use `s pull request: ```sh -spl mutate --batch mutations.json --message "Record requirement" +spl mutate --operations mutations.json --message "Record requirement" spl schema migrate --schema schema.toml --batch mutations.json spl asset add --file docs/architecture.md --title "Architecture notes" spl prune --author alice --message "Prune transient plan" ``` `mutate` is bound-only and refuses unbound workspaces. Use `schema migrate` when changing -`schema.toml`. Identical schema content is a no-op (no empty PR). +`schema.toml`. Identical mutate or schema content is a no-op (no empty PR). There are no aliases +to `add` / `commit` / `status` / `stage` / `write`. ## History and diff diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a6ced5..f147465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,14 @@ generated from commits since the preceding `v*` tag. Commits prefixed with `docs ### Added - Dedicated KEEP `spl mutate` / MCP `spl_mutate` for routine bound node and - edge writes. One mutation-operation batch becomes one git commit on a + edge writes. CLI: `spl mutate --operations ` (stdin via `-`). MCP: + `{ "operations": [...], "author"?, "message"? }`. One mutation-operation batch + (same shape as schema migrate / private Stage) becomes one git commit on a short-lived `spool/mcp/-` branch plus pull request via existing - `ctxgit` Stage/Commit. Bound-only (`FindBind` fail-closed). Schema changes - still use `schema migrate`. Does not restore `add` / `commit` / `status` / - `branch` / `switch`. + `ctxgit` Stage/Commit. Bound-only (`FindBind` fail-closed). Identical or empty + effective diffs do not open a PR. Result aligns with `WriteResult` (`branch`, + `commit`, `pullRequest`, `written`, `deleted`). Schema changes still use + `schema migrate`. No aliases to `add` / `commit` / `status` / `stage` / `write`. - Documented `.spool/context.toml` bind format and the N-code-repos → one context git remote as the only durable SoT (`docs/context-bind.md`). - `spl context init --remote` seeds `CodeRepository` nodes from explicit binds @@ -41,7 +44,7 @@ generated from commits since the preceding `v*` tag. Commits prefixed with `docs - `spl prune` / `spl_prune` is bound graph cleanup of `Ephemeral` nodes and cascading edges (short-lived branch + PR). It is not pack/CAS garbage collection. Unbound workspaces are refused. -- Identical schema migrations are a no-op (no empty commit or PR). +- Identical schema migrations and identical mutate diffs are a no-op (no empty commit or PR). - MCP advertises the KEEP tool set only (including `spl_mutate`). `spl --help` matches that surface. A golden KEEP/REMOVE list fails `make check` if a removed CLI name or MCP tool reappears (including `spl_context` and Spool VCS wrappers `add`/`status`/`commit`/`branch`/ diff --git a/README.md b/README.md index 3f99893..00efc15 100644 --- a/README.md +++ b/README.md @@ -82,11 +82,11 @@ Use stock git on the context remote for history and diff. Spool does not wrap `g Write routine node and edge mutations (one JSON batch → short-lived branch + PR): ```sh -spl mutate --batch mutations.json --message "Record requirement" +spl mutate --operations mutations.json --message "Record requirement" ``` -`mutate` requires `.spool/context.toml` and refuses unbound workspaces. Use `schema migrate` when -changing `schema.toml`. +`mutate` requires `.spool/context.toml` and refuses unbound workspaces. Identical or empty +effective diffs do not open a PR. Use `schema migrate` when changing `schema.toml`. ## Context bind, export, and migrate-once @@ -107,7 +107,7 @@ The `context` namespace is **only** init/export/migrate-once. Graph queries use Write routine node and edge mutations as one JSON batch. Bound-only; opens a short-lived branch + PR: ```sh -spl mutate --batch mutations.json --author alice --message "Record requirement" +spl mutate --operations mutations.json --author alice --message "Record requirement" ``` Author a schema in TOML and apply conforming graph mutations: diff --git a/cmd/spl/commands/mutate.go b/cmd/spl/commands/mutate.go index 5a35a7f..5f52948 100644 --- a/cmd/spl/commands/mutate.go +++ b/cmd/spl/commands/mutate.go @@ -3,7 +3,9 @@ package commands import ( "encoding/json" "fmt" + "io" "os" + "strings" "github.com/autonomous-bits/spool/internal/ctxgit" "github.com/autonomous-bits/spool/internal/repository" @@ -12,22 +14,18 @@ import ( // NewMutateCommand writes one node/edge mutation batch via a short-lived branch + PR. func NewMutateCommand(opts ctxgit.Options) *cobra.Command { - var batchPath, author, message string + var operationsPath, author, message string command := &cobra.Command{ Use: "mutate", Short: "Mutate the bound context graph", - Long: "Apply one JSON mutation-operation batch (nodes and edges) to the bound context checkout and open a short-lived branch + PR. Unbound workspaces are refused. Use schema migrate when changing schema.toml.", - Example: " spl mutate --batch mutations.json --message \"Record requirement\"", + Long: "Apply one JSON mutation-operation batch (nodes and edges) to the bound context checkout and open a short-lived branch + PR. Unbound workspaces are refused. Identical or empty diffs do not open a PR. Use schema migrate when changing schema.toml.", + Example: " spl mutate --operations mutations.json --message \"Record requirement\"\n cat mutations.json | spl mutate --operations - --message \"Record requirement\"", Args: cobra.NoArgs, SilenceUsage: true, RunE: func(command *cobra.Command, _ []string) error { - data, err := os.ReadFile(batchPath) + operations, err := readMutationOperations(command, operationsPath) if err != nil { - return fmt.Errorf("read mutation batch: %w", err) - } - var operations []repository.MutationOperation - if err := json.Unmarshal(data, &operations); err != nil { - return fmt.Errorf("decode mutation batch: %w", err) + return err } session, err := startBoundSession(command, opts) if err != nil { @@ -44,9 +42,31 @@ func NewMutateCommand(opts ctxgit.Options) *cobra.Command { return json.NewEncoder(command.OutOrStdout()).Encode(result) }, } - command.Flags().StringVar(&batchPath, "batch", "", "path to a JSON mutation-operation array") + command.Flags().StringVar(&operationsPath, "operations", "", "JSON mutation-operation array (file path, or - for stdin)") command.Flags().StringVar(&author, "author", "", "git author") command.Flags().StringVar(&message, "message", "", "commit/PR message") - _ = command.MarkFlagRequired("batch") + _ = command.MarkFlagRequired("operations") return command } + +func readMutationOperations(command *cobra.Command, path string) ([]repository.MutationOperation, error) { + path = strings.TrimSpace(path) + var data []byte + var err error + if path == "-" { + data, err = io.ReadAll(command.InOrStdin()) + if err != nil { + return nil, fmt.Errorf("read mutation operations from stdin: %w", err) + } + } else { + data, err = os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read mutation operations: %w", err) + } + } + var operations []repository.MutationOperation + if err := json.Unmarshal(data, &operations); err != nil { + return nil, fmt.Errorf("decode mutation operations: %w", err) + } + return operations, nil +} diff --git a/cmd/spl/commands/mutate_test.go b/cmd/spl/commands/mutate_test.go index 1de8986..b9a9d60 100644 --- a/cmd/spl/commands/mutate_test.go +++ b/cmd/spl/commands/mutate_test.go @@ -15,11 +15,11 @@ func TestMutateCLIUnboundRefused(t *testing.T) { var output bytes.Buffer command := NewMutateCommand(ctxgit.Options{WorkspaceDir: t.TempDir()}) command.SetOut(&output) - batch := filepath.Join(t.TempDir(), "batch.json") - if err := os.WriteFile(batch, []byte(`[{"action":"add","entity":"node","id":"n1","title":"Nope"}]`), 0o600); err != nil { - t.Fatalf("write batch: %v", err) + ops := filepath.Join(t.TempDir(), "ops.json") + if err := os.WriteFile(ops, []byte(`[{"action":"add","entity":"node","id":"n1","title":"Nope"}]`), 0o600); err != nil { + t.Fatalf("write operations: %v", err) } - command.SetArgs([]string{"--batch", batch}) + command.SetArgs([]string{"--operations", ops}) err := command.Execute() if err == nil || !strings.Contains(err.Error(), "not bound") { t.Fatalf("error = %v, want unbound", err) @@ -31,19 +31,19 @@ func TestMutateCLIUnboundRefused(t *testing.T) { func TestMutateCLIWritesPR(t *testing.T) { opts := boundCommandOptions(t) - batch := filepath.Join(t.TempDir(), "batch.json") + ops := filepath.Join(t.TempDir(), "ops.json") payload := `[ {"action":"add","entity":"node","id":"idea-1","title":"Shared idea","labels":["Requirement"]}, {"action":"add","entity":"node","id":"idea-2","title":"Related idea","labels":["Requirement"]}, {"action":"add","entity":"edge","id":"idea-2-relates","source":"idea-2","target":"idea-1","type":"RELATES_TO"} ]` - if err := os.WriteFile(batch, []byte(payload), 0o600); err != nil { - t.Fatalf("write batch: %v", err) + if err := os.WriteFile(ops, []byte(payload), 0o600); err != nil { + t.Fatalf("write operations: %v", err) } var output bytes.Buffer command := NewMutateCommand(opts) command.SetOut(&output) - command.SetArgs([]string{"--batch", batch, "--author", "alice", "--message", "Record shared ideas"}) + command.SetArgs([]string{"--operations", ops, "--author", "alice", "--message", "Record shared ideas"}) if err := command.Execute(); err != nil { t.Fatalf("execute mutate: %v\n%s", err, output.String()) } @@ -58,26 +58,66 @@ func TestMutateCLIWritesPR(t *testing.T) { t.Fatalf("branch = %q, want spool/mcp/ prefix; json=%s", result.Branch, output.String()) } if result.PR.URL == "" { - t.Fatalf("missing PR: %s", output.String()) + t.Fatalf("missing pullRequest: %s", output.String()) + } + if len(result.Written) == 0 { + t.Fatalf("missing written summary: %s", output.String()) } } -func TestMutateCLIRejectsInvalidBatch(t *testing.T) { +func TestMutateCLIReadsStdin(t *testing.T) { opts := boundCommandOptions(t) - batch := filepath.Join(t.TempDir(), "batch.json") - if err := os.WriteFile(batch, []byte(`{"not":"an array"}`), 0o600); err != nil { - t.Fatalf("write batch: %v", err) + payload := `[{"action":"add","entity":"node","id":"idea-1","title":"Shared idea","labels":["Requirement"]}]` + var output bytes.Buffer + command := NewMutateCommand(opts) + command.SetIn(bytes.NewReader([]byte(payload))) + command.SetOut(&output) + command.SetArgs([]string{"--operations", "-", "--message", "Record from stdin"}) + if err := command.Execute(); err != nil { + t.Fatalf("execute mutate stdin: %v\n%s", err, output.String()) + } + var result ctxgit.MutateResult + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatalf("decode mutate JSON: %v\n%s", err, output.String()) + } + if !strings.HasPrefix(result.Branch, "spool/mcp/") || result.PR.URL == "" { + t.Fatalf("stdin mutate = %s", output.String()) + } +} + +func TestMutateCLIRejectsInvalidOperations(t *testing.T) { + opts := boundCommandOptions(t) + ops := filepath.Join(t.TempDir(), "ops.json") + if err := os.WriteFile(ops, []byte(`{"not":"an array"}`), 0o600); err != nil { + t.Fatalf("write operations: %v", err) } var output bytes.Buffer command := NewMutateCommand(opts) command.SetOut(&output) - command.SetArgs([]string{"--batch", batch}) + command.SetArgs([]string{"--operations", ops}) err := command.Execute() - if err == nil || !strings.Contains(err.Error(), "decode mutation batch") { + if err == nil || !strings.Contains(err.Error(), "decode mutation operations") { t.Fatalf("error = %v, want decode error", err) } } +func TestMutateCLIHasNoVCSAliases(t *testing.T) { + command := NewMutateCommand(ctxgit.Options{}) + if len(command.Aliases) != 0 { + t.Fatalf("mutate aliases = %v, want none", command.Aliases) + } + for _, name := range []string{"add", "commit", "status", "stage", "write"} { + if command.Name() == name { + t.Fatalf("mutate must not be named %q", name) + } + for _, alias := range command.Aliases { + if alias == name { + t.Fatalf("mutate aliases to %q", name) + } + } + } +} + func TestMutateCLIHelpDescribesGraphWrite(t *testing.T) { var output bytes.Buffer command := NewMutateCommand(ctxgit.Options{}) @@ -88,7 +128,8 @@ func TestMutateCLIHelpDescribesGraphWrite(t *testing.T) { } help := output.String() for _, text := range []string{ - "spl mutate --batch mutations.json --message \"Record requirement\"", + "spl mutate --operations mutations.json --message \"Record requirement\"", + "--operations -", "short-lived branch", "schema migrate", } { @@ -96,7 +137,7 @@ func TestMutateCLIHelpDescribesGraphWrite(t *testing.T) { t.Errorf("mutate help does not contain %q:\n%s", text, help) } } - for _, name := range []string{"spl add", "spl commit", "spl status", "spl branch", "spl switch"} { + for _, name := range []string{"spl add", "spl commit", "spl status", "spl stage", "spl write", "spl branch", "spl switch"} { if strings.Contains(help, name) { t.Errorf("mutate help must not teach restored VCS wrapper %q:\n%s", name, help) } diff --git a/cmd/spl/root_test.go b/cmd/spl/root_test.go index 20aa285..2a2b5a2 100644 --- a/cmd/spl/root_test.go +++ b/cmd/spl/root_test.go @@ -100,7 +100,7 @@ func TestCommandHelpIncludesExamples(t *testing.T) { {[]string{"search", "--help"}, "spl search --query incident"}, {[]string{"search-expand", "--help"}, "spl search-expand --query incident"}, {[]string{"prune", "--help"}, "spl prune"}, - {[]string{"mutate", "--help"}, "spl mutate --batch"}, + {[]string{"mutate", "--help"}, "spl mutate --operations"}, } for _, testCase := range testCases { t.Run(strings.Join(testCase.path, " "), func(t *testing.T) { @@ -150,6 +150,25 @@ func TestContextNamespaceIsInitExportOnly(t *testing.T) { } } +func TestMutateHasNoVCSAliases(t *testing.T) { + command := newRootCommand(&bytes.Buffer{}) + found, _, err := command.Find([]string{"mutate"}) + if err != nil { + t.Fatalf("find mutate: %v", err) + } + if found.Name() != "mutate" { + t.Fatalf("mutate resolved to %q", found.Name()) + } + if len(found.Aliases) != 0 { + t.Fatalf("mutate aliases = %v, want none", found.Aliases) + } + for _, name := range []string{"add", "commit", "status", "stage", "write"} { + if _, _, findErr := command.Find([]string{name}); findErr == nil { + t.Errorf("%q must not be registered as a command or mutate alias", name) + } + } +} + func TestQueryContextIsBreakingRenameWithNoAlias(t *testing.T) { command := newRootCommand(&bytes.Buffer{}) found, _, err := command.Find([]string{"query-context"}) diff --git a/internal/ctxgit/mutate.go b/internal/ctxgit/mutate.go index 1bedd66..27d9281 100644 --- a/internal/ctxgit/mutate.go +++ b/internal/ctxgit/mutate.go @@ -21,8 +21,10 @@ type MutateResult struct { } // Mutate applies one mutation-operation batch to the bound context graph and -// opens a short-lived branch + PR via Stage then Commit. Bound-only: FindBind -// fail-closed. Empty batches are rejected. This is not schema migrate. +// opens a short-lived branch + PR via Stage then Commit (one-shot; no +// user-facing stage/commit). Bound-only: FindBind fail-closed. Empty batches +// are rejected. Identical / empty effective diffs do not open a PR. Projection +// is rebuilt after a successful write. This is not schema migrate. func (s *Session) Mutate(ctx context.Context, request MutateRequest) (MutateResult, error) { if s == nil || s.CodeRoot == "" { return MutateResult{}, UnboundError() diff --git a/internal/ctxgit/mutate_test.go b/internal/ctxgit/mutate_test.go index 6ae2752..3ed6359 100644 --- a/internal/ctxgit/mutate_test.go +++ b/internal/ctxgit/mutate_test.go @@ -101,6 +101,9 @@ func TestMutateWritesShortLivedBranchAndPR(t *testing.T) { if result.ProtectedBranch != "main" { t.Fatalf("protected branch = %q", result.ProtectedBranch) } + if len(result.Written) == 0 { + t.Fatalf("WriteResult.Written empty: %#v", result) + } if len(recorder.Requests) != 1 || recorder.Requests[0].Base != "main" || recorder.Requests[0].Head != result.Branch { t.Fatalf("PR requests = %#v", recorder.Requests) } @@ -138,3 +141,149 @@ func TestMutateWritesShortLivedBranchAndPR(t *testing.T) { t.Fatal("successful mutate must not push-clean to the protected branch") } } + +func TestMutateIdenticalDiffDoesNotOpenPR(t *testing.T) { + ctx := context.Background() + codeRoot, remote, cache := setupBoundWorkspace(t) + recorder := &RecordingPROpener{} + session, err := Start(ctx, Options{WorkspaceDir: codeRoot, CacheDir: cache, Git: isolatedGit(), PROpener: recorder}) + if err != nil { + t.Fatalf("Start: %v", err) + } + ops := []repository.MutationOperation{ + {Action: "add", Entity: "node", ID: "idea-1", Title: "Shared idea", Labels: []string{"Requirement"}}, + } + first, err := session.Mutate(ctx, MutateRequest{Operations: ops, Message: "Record idea"}) + if err != nil { + t.Fatalf("first Mutate: %v", err) + } + if first.PR.URL == "" { + t.Fatalf("first mutate must open a PR: %#v", first) + } + pushMerged(t, remote, first.Branch) + + session, err = Start(ctx, Options{WorkspaceDir: codeRoot, CacheDir: cache, Git: isolatedGit(), PROpener: recorder}) + if err != nil { + t.Fatalf("restart: %v", err) + } + second, err := session.Mutate(ctx, MutateRequest{Operations: ops, Message: "Record idea again"}) + if err != nil { + t.Fatalf("identical Mutate: %v", err) + } + if second.PR.URL != "" || len(recorder.Requests) != 1 { + t.Fatalf("identical mutate must not open a PR: %#v requests=%#v", second, recorder.Requests) + } + if second.Branch != "main" { + t.Fatalf("no-op branch = %q, want protected main", second.Branch) + } + foundNoChanges := false + for _, warning := range second.Warnings { + if strings.Contains(warning, "no changes") { + foundNoChanges = true + break + } + } + if !foundNoChanges { + t.Fatalf("no-op warnings = %#v, want no changes", second.Warnings) + } +} + +func TestMutateDeleteReportsDeletedAndRebuildsProjection(t *testing.T) { + ctx := context.Background() + codeRoot, remote, cache := setupBoundWorkspace(t) + leftover := filepath.Join(codeRoot, ".spl", "objects", "pack") + if err := os.MkdirAll(leftover, 0o755); err != nil { + t.Fatalf("mkdir leftover: %v", err) + } + marker := filepath.Join(leftover, "do-not-gc") + if err := os.WriteFile(marker, []byte("cas-pack-bytes"), 0o644); err != nil { + t.Fatalf("write leftover pack: %v", err) + } + + recorder := &RecordingPROpener{} + session, err := Start(ctx, Options{WorkspaceDir: codeRoot, CacheDir: cache, Git: isolatedGit(), PROpener: recorder}) + if err != nil { + t.Fatalf("Start: %v", err) + } + added, err := session.Mutate(ctx, MutateRequest{ + Operations: []repository.MutationOperation{ + {Action: "add", Entity: "node", ID: "temp-1", Title: "Temporary", Labels: []string{"Requirement"}}, + }, + Message: "Add temporary", + }) + if err != nil { + t.Fatalf("add Mutate: %v", err) + } + pushMerged(t, remote, added.Branch) + + session, err = Start(ctx, Options{WorkspaceDir: codeRoot, CacheDir: cache, Git: isolatedGit(), PROpener: recorder}) + if err != nil { + t.Fatalf("restart: %v", err) + } + if err := os.Remove(session.projectionPath); err != nil { + t.Fatalf("remove projection: %v", err) + } + deleted, err := session.Mutate(ctx, MutateRequest{ + Operations: []repository.MutationOperation{ + {Action: "delete", Entity: "node", ID: "temp-1"}, + }, + Message: "Remove temporary", + }) + if err != nil { + t.Fatalf("delete Mutate: %v", err) + } + if deleted.PR.URL == "" || !strings.HasPrefix(deleted.Branch, "spool/mcp/") { + t.Fatalf("delete mutate write path = %#v", deleted) + } + if len(deleted.Deleted) == 0 { + t.Fatalf("delete mutate missing Deleted paths: %#v", deleted) + } + found := false + for _, path := range deleted.Deleted { + if strings.Contains(path, "temp-1") { + found = true + break + } + } + if !found { + t.Fatalf("Deleted = %#v, want temp-1 node path", deleted.Deleted) + } + + status, err := session.ProjectionStatus() + if err != nil { + t.Fatalf("ProjectionStatus: %v", err) + } + if status.State != "ready" || status.Path == "" { + t.Fatalf("projection after mutate = %#v", status) + } + if _, err := os.Stat(status.Path); err != nil { + t.Fatalf("rebuilt projection missing: %v", err) + } + if _, ok := session.ResolveNode("temp-1"); ok { + t.Fatal("deleted node still resolvable after mutate") + } + got, err := os.ReadFile(marker) + if err != nil || string(got) != "cas-pack-bytes" { + t.Fatalf("leftover .spl pack mutated: err=%v content=%q", err, got) + } +} + +func TestMutateSourceNeverInvokesCASGC(t *testing.T) { + src, err := os.ReadFile("mutate.go") + if err != nil { + t.Fatalf("read mutate.go: %v", err) + } + text := string(src) + for _, needle := range []string{ + "internal/repository/prune", + "internal/repository/gc", + `"gc"`, + `"repack"`, + `"pack-objects"`, + "git gc", + } { + if strings.Contains(text, needle) { + t.Errorf("mutate must not invoke CAS/pack GC; found %q", needle) + } + } +} diff --git a/internal/ctxgit/write.go b/internal/ctxgit/write.go index aed6874..1bbe9c1 100644 --- a/internal/ctxgit/write.go +++ b/internal/ctxgit/write.go @@ -44,6 +44,7 @@ type WriteResult struct { PR PullRequest `json:"pullRequest"` Overlaps []Overlap `json:"overlaps,omitempty"` Written []string `json:"written,omitempty"` + Deleted []string `json:"deleted,omitempty"` Warnings []string `json:"warnings,omitempty"` } @@ -174,7 +175,7 @@ func (s *Session) Commit(ctx context.Context, author, message string) (WriteResu return s.commitGraphDiff(ctx, base, after, overlaps, author, message, s.Bind.ProtectedBranch) } -func (s *Session) finishWrite(ctx context.Context, after *Graph, branch, sha string, pr PullRequest, overlaps []Overlap, written, warnings []string) (WriteResult, error) { +func (s *Session) finishWrite(ctx context.Context, after *Graph, branch, sha string, pr PullRequest, overlaps []Overlap, written, deleted, warnings []string) (WriteResult, error) { s.graph = after if rebuildErr := s.rebuildProjection(ctx, sha); rebuildErr != nil { warnings = append(warnings, "projection rebuild after write: "+rebuildErr.Error()) @@ -187,6 +188,7 @@ func (s *Session) finishWrite(ctx context.Context, after *Graph, branch, sha str PR: pr, Overlaps: overlaps, Written: written, + Deleted: deleted, Warnings: warnings, }, nil } @@ -279,7 +281,7 @@ func (s *Session) commitGraphDiff(ctx context.Context, base, after *Graph, overl sha = resolved } } - return s.finishWrite(ctx, after, s.Bind.ProtectedBranch, sha, PullRequest{}, overlaps, written, warnings) + return s.finishWrite(ctx, after, s.Bind.ProtectedBranch, sha, PullRequest{}, overlaps, written, deleted, warnings) } commitArgs := []string{"-c", "commit.gpgsign=false"} if name, email := parseAuthor(author); name != "" { @@ -312,7 +314,7 @@ func (s *Session) commitGraphDiff(ctx context.Context, base, after *Graph, overl return WriteResult{}, err } s.staged = nil - return s.finishWrite(ctx, after, branch, sha, pr, overlaps, written, warnings) + return s.finishWrite(ctx, after, branch, sha, pr, overlaps, written, deleted, warnings) } func shortLivedBranch() (string, error) { diff --git a/internal/mcp/context_write_test.go b/internal/mcp/context_write_test.go index f8f7a56..9d8e2ae 100644 --- a/internal/mcp/context_write_test.go +++ b/internal/mcp/context_write_test.go @@ -119,6 +119,12 @@ func TestMCPBoundMutateOpensPR(t *testing.T) { if !strings.Contains(text, `"operations":1`) { t.Fatalf("mutate missing operations count: %s", text) } + if !strings.Contains(text, `"written"`) { + t.Fatalf("mutate missing written summary: %s", text) + } + if !strings.Contains(text, `"pullRequest"`) { + t.Fatalf("mutate missing pullRequest: %s", text) + } } func setupMCPBound(t *testing.T) (codeRoot, remote, cache string, recorder *ctxgit.RecordingPROpener) { diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 9a76aa8..bacb7f5 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -70,8 +70,8 @@ func TestSpoolMCPServerKeepToolsOnly(t *testing.T) { if containsTool(toolsList.Tools, "spl_context") { t.Fatal("old spl_context query tool must not be registered") } - if containsTool(toolsList.Tools, "spl_add") || containsTool(toolsList.Tools, "spl_commit") { - t.Fatal("removed add/commit MCP twins must not be registered") + if containsTool(toolsList.Tools, "spl_add") || containsTool(toolsList.Tools, "spl_commit") || containsTool(toolsList.Tools, "spl_status") || containsTool(toolsList.Tools, "spl_stage") || containsTool(toolsList.Tools, "spl_write") { + t.Fatal("removed or VCS-shaped MCP twins must not be registered") } res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "spl_version"}) diff --git a/internal/surface/surface_test.go b/internal/surface/surface_test.go index 888c553..9cfbe9d 100644 --- a/internal/surface/surface_test.go +++ b/internal/surface/surface_test.go @@ -42,6 +42,11 @@ func TestMutateIsKeepAndVCSWrappersStayRemoved(t *testing.T) { if !contains(KeepMCPTools, "spl_mutate") { t.Fatal("KEEP MCP must include spl_mutate") } + for _, name := range []string{"add", "commit", "status", "stage", "write"} { + if contains(KeepCLITopLevel, name) { + t.Errorf("VCS-shaped name %q must not be KEEP", name) + } + } for _, name := range []string{"spl_add", "spl_commit", "spl_status", "spl_branch_list", "spl_branch_create", "spl_branch_delete", "spl_switch"} { if contains(KeepMCPTools, name) { t.Errorf("removed MCP twin %q must not be KEEP", name) @@ -50,6 +55,11 @@ func TestMutateIsKeepAndVCSWrappersStayRemoved(t *testing.T) { t.Errorf("removed MCP twin %q must stay on the REMOVE golden list", name) } } + for _, name := range []string{"spl_stage", "spl_write"} { + if contains(KeepMCPTools, name) { + t.Errorf("VCS-shaped MCP name %q must not be KEEP", name) + } + } } func assertDisjoint(t *testing.T, kind string, keep, removed []string) {