@savvy-web/mcp 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,8 +49,8 @@ npx @modelcontextprotocol/inspector savvy-mcp .
49
49
  - `changeset_deps_detect` — read-only preview of the cumulative dependency diff (merge-base to working tree): one entry per affected workspace package with its resolved dependency-table rows, `catalog:`/`workspace:` specifiers resolved to concrete versions. It never writes a changeset. Backed by `silk-effects`' `Changesets.DepsRegen.plan`.
50
50
  - `changeset_deps_regen` — regenerates pure-dependency changesets: deletes stale ones and writes fresh single-package, patch-bump changesets from the cumulative dependency diff. Mutating unless `dryRun` is set, in which case it reports what it would delete and write without touching the filesystem. Backed by `silk-effects`' `Changesets.DepsRegen`.
51
51
  - `biome_check` — run Biome over a path and get structured diagnostics back: `mode=check` (lint, format and organize-imports) or `mode=lint`. Unlike most of the other tools it can mutate — pass `write` for safe fixes or `unsafe` for unsafe ones (both git-reversible) — so it returns the same diagnostics the Biome LSP surfaces for files you have edited.
52
- - `repos_inspect` — read-only inspection of vendored repositories: `mode=status` reports per-repo presence, the gitlink commit, working-tree dirtiness, and stale note ids; `mode=config` surfaces the validated `.repos/config.json` manifest and its entries. Returns markdown-escaped output since vendored-repo content is untrusted input. Backed by the same `silk-effects` `Repos` services the `savvy` CLI uses.
53
- - `repos_manage` — manages vendored repositories (mutating counterpart to repos_inspect): `action=sync` initializes any missing submodules (`git submodule update --init --depth 1`), applies sparse-checkout from the manifest, and clears stale git locks; `action=pin` fetches and checks out the new ref, staging the updated gitlink and manifest; `action=add` adds a new repo entry; `action=note` appends a short note to a repo. Pin and add stage git changes for the caller to commit. `sync`, `pin` and `add` unlock the vendored tree for the duration of their git work and leave it read-only again afterwards (files `0444`, directories `0555`), so an agent that hits `EACCES` writing into `.repos/` should route the change through this tool rather than `chmod`. Backed by the same `silk-effects` `Repos` services the `savvy` CLI uses.
52
+ - `repos_inspect` — read-only inspection of vendored repositories: `mode=status` reports per-repo presence, the gitlink commit, working-tree dirtiness, and stale note ids; `mode=config` surfaces the validated `.repos/config.json` manifest and its entries; `mode=drift` reconciles the manifest, `.gitmodules`, the worktree, and `git submodule status`, reporting any mismatch as a typed drift kind; `mode=gitmodules` lists the parsed `.gitmodules` entries. Returns markdown-escaped output since vendored-repo content is untrusted input. Backed by the same `silk-effects` `Repos` services the `savvy` CLI uses.
53
+ - `repos_manage` — manages vendored repositories (mutating counterpart to repos_inspect): `action=sync` initializes any missing submodules (`git submodule update --init --depth 1`), applies sparse-checkout from the manifest, and clears stale git locks; `action=pin` fetches and checks out the new ref, staging the updated gitlink and manifest; `action=add` adds a new repo entry; `action=note` appends a short note to a repo; `action=remove` unvendors an entry; `action=rename` renames one in place; `action=restore` hard-resets one or more dirty checkouts back to their pinned commit. Pin, add, remove and rename stage git changes for the caller to commit; restore only resets the submodule's own working tree to its already-staged or already-committed gitlink commit, so there is nothing new to stage. `sync`, `pin`, `add`, `remove`, `rename` and `restore` unlock the vendored tree for the duration of their git work and leave it read-only again afterwards (files `0444`, directories `0555` — an executable file locks at `0555` instead, unlocking back to `0755` rather than losing its executable bit), so an agent that hits `EACCES` writing into `.repos/` should route the change through this tool rather than `chmod`. Backed by the same `silk-effects` `Repos` services the `savvy` CLI uses.
54
54
 
55
55
  ## License
56
56
 
package/index.d.ts CHANGED
@@ -3,9 +3,21 @@ import { Changesets, Repos, SilkWorkspaceAnalyzer, Turbo } from "@savvy-web/silk
3
3
  import { FileSystem, Layer, ManagedRuntime, Path } from "effect";
4
4
  import { ChildProcessSpawner } from "effect/unstable/process";
5
5
  import "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import "zod";
6
7
  //#region src/context.d.ts
7
- /** Every service the MCP runtime provides to the tool handlers. */
8
- type McpServices = SilkWorkspaceAnalyzer | WorkspaceRoot | Turbo.TurboInspector | Changesets.BranchAnalyzer | Changesets.ConfigInspector | Changesets.ReleasePlanner | Changesets.DepsRegen | Repos.ReposManager | Repos.ReposConfigStore;
8
+ /**
9
+ * Every service the MCP runtime provides to the tool handlers.
10
+ *
11
+ * @remarks
12
+ * `FileSystem.FileSystem | Path.Path` are here so `repos_inspect`'s
13
+ * `gitmodules` mode can read `.gitmodules` through the ambient service
14
+ * (mirroring `Repos.ReposDrift.check`) rather than a bare `node:fs` import —
15
+ * `makeSilkRuntimeLayer` passes both through onto its own output via an
16
+ * `Effect.context` identity layer, so they survive `bin.ts`'s
17
+ * `Layer.provide(NodeServices.layer)` instead of being fully discharged by
18
+ * it.
19
+ */
20
+ type McpServices = SilkWorkspaceAnalyzer | WorkspaceRoot | Turbo.TurboInspector | Changesets.BranchAnalyzer | Changesets.ConfigInspector | Changesets.ReleasePlanner | Changesets.DepsRegen | Repos.ReposManager | Repos.ReposConfigStore | Repos.ReposDrift | FileSystem.FileSystem | Path.Path;
9
21
  /** The long-lived runtime and the project working directory. */
10
22
  interface McpContext {
11
23
  readonly runtime: ManagedRuntime.ManagedRuntime<McpServices, never>;
@@ -18,7 +30,7 @@ interface McpContext {
18
30
  * `SilkWorkspaceAnalyzer`, `WorkspaceRoot`, `Turbo.TurboInspector`,
19
31
  * `Changesets.BranchAnalyzer`, `Changesets.ConfigInspector`,
20
32
  * `Changesets.ReleasePlanner`, `Changesets.DepsRegen`, `Repos.ReposManager`,
21
- * and `Repos.ReposConfigStore`; requires `ChildProcessSpawner` + `FileSystem`
33
+ * `Repos.ReposConfigStore`, and `Repos.ReposDrift`; requires `ChildProcessSpawner` + `FileSystem`
22
34
  * + `Path` from the host's platform layer (`NodeServices.layer` in bin.ts).
23
35
  *
24
36
  * @remarks
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/mcp",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "private": false,
5
5
  "description": "The savvy MCP server — Silk Suite tooling and library knowledge for coding agents",
6
6
  "homepage": "https://github.com/savvy-web/systems/tree/main/packages/mcp",
@@ -31,12 +31,13 @@
31
31
  "savvy-mcp": "bin/savvy-mcp.js"
32
32
  },
33
33
  "dependencies": {
34
- "@effect/platform-node": "4.0.0-beta.101",
35
- "@effected/commands": "^0.3.1",
36
- "@effected/workspaces": "^0.10.0",
34
+ "@effect/platform-node": "4.0.0-beta.107",
35
+ "@effected/commands": "^0.4.0",
36
+ "@effected/git": "^0.7.0",
37
+ "@effected/workspaces": "^0.11.0",
37
38
  "@modelcontextprotocol/sdk": "^1.29.0",
38
- "@savvy-web/silk-effects": "5.4.0",
39
- "effect": "4.0.0-beta.101",
39
+ "@savvy-web/silk-effects": "5.5.1",
40
+ "effect": "4.0.0-beta.107",
40
41
  "zod": "^4.4.3"
41
42
  }
42
43
  }
package/runtime.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ToolDiscovery } from "@effected/commands";
2
2
  import { Workspaces } from "@effected/workspaces";
3
3
  import { ChangesetConfig, ChangesetConfigReader, Changesets, Repos, SilkPublishability, SilkWorkspaceAnalyzer, Turbo } from "@savvy-web/silk-effects";
4
- import { Layer } from "effect";
4
+ import { Effect, Layer } from "effect";
5
5
 
6
6
  //#region src/runtime.ts
7
7
  /**
@@ -21,7 +21,7 @@ import { Layer } from "effect";
21
21
  * `SilkWorkspaceAnalyzer`, `WorkspaceRoot`, `Turbo.TurboInspector`,
22
22
  * `Changesets.BranchAnalyzer`, `Changesets.ConfigInspector`,
23
23
  * `Changesets.ReleasePlanner`, `Changesets.DepsRegen`, `Repos.ReposManager`,
24
- * and `Repos.ReposConfigStore`; requires `ChildProcessSpawner` + `FileSystem`
24
+ * `Repos.ReposConfigStore`, and `Repos.ReposDrift`; requires `ChildProcessSpawner` + `FileSystem`
25
25
  * + `Path` from the host's platform layer (`NodeServices.layer` in bin.ts).
26
26
  *
27
27
  * @remarks
@@ -65,9 +65,14 @@ const makeSilkRuntimeLayer = (cwd) => {
65
65
  * Same reference — one store instance. `ReposLockdown.layer` needs only the
66
66
  * platform services, which flow up from the outer `NodeServices.layer`
67
67
  * provision like `ReposManager`'s own `FileSystem`/`Path` requirement does.
68
+ * `ReposDrift.layer` is read-only (no `ReposLockdown` interaction) and
69
+ * requires only `ReposConfigStore | Git | FileSystem | Path`; `Git` comes
70
+ * from `kitGraph` below via the same one-reference discipline, and
71
+ * `repos_inspect`'s drift mode resolves it directly.
68
72
  */
69
- const repos = Layer.mergeAll(Repos.ReposConfigStore.layer, Repos.ReposManager.layer.pipe(Layer.provide(Repos.ReposConfigStore.layer), Layer.provide(Repos.ReposLockdown.layer)));
70
- return Layer.mergeAll(SilkWorkspaceAnalyzer.layer.pipe(Layer.provide(analyzerDeps)), Turbo.TurboInspector.layer.pipe(Layer.provide(toolDiscovery)), inspectorAndAnalyzer, depsRegen, repos).pipe(Layer.provideMerge(kitGraph));
73
+ const repos = Layer.mergeAll(Repos.ReposConfigStore.layer, Repos.ReposManager.layer.pipe(Layer.provide(Repos.ReposConfigStore.layer), Layer.provide(Repos.ReposLockdown.layer)), Repos.ReposDrift.layer.pipe(Layer.provide(Repos.ReposConfigStore.layer)));
74
+ const platformPassthrough = Layer.effectContext(Effect.context());
75
+ return Layer.mergeAll(SilkWorkspaceAnalyzer.layer.pipe(Layer.provide(analyzerDeps)), Turbo.TurboInspector.layer.pipe(Layer.provide(toolDiscovery)), inspectorAndAnalyzer, depsRegen, repos, platformPassthrough).pipe(Layer.provideMerge(kitGraph));
71
76
  };
72
77
 
73
78
  //#endregion
package/server.js CHANGED
@@ -22,6 +22,17 @@ import { z } from "zod";
22
22
  *
23
23
  * @packageDocumentation
24
24
  */
25
+ /**
26
+ * The `repos_inspect` wire-level `mode` enum. Exported so tests can assert
27
+ * the boundary rejects an unknown mode without duplicating the enum's
28
+ * member list.
29
+ */
30
+ const ReposInspectModeSchema = z.enum([
31
+ "status",
32
+ "config",
33
+ "drift",
34
+ "gitmodules"
35
+ ]).describe("status = drift report; config = the full agent brief; drift = four-authority submodule reconciliation; gitmodules = decoded .gitmodules sections.");
25
36
  /** Wrap a markdown string + structured object in the dual-channel tool result. */
26
37
  const structuredResult = (text, structured) => ({
27
38
  content: [{
@@ -145,9 +156,9 @@ function buildServer(ctx) {
145
156
  });
146
157
  server.registerTool("repos_inspect", {
147
158
  title: "Inspect vendored repos",
148
- description: "Read-only: drift report or parsed .repos/config.json manifest with orientation and notes.",
159
+ description: "Read-only: drift report or parsed .repos/config.json manifest with orientation and notes. mode=status is the per-repo drift summary from ReposManager (present/dirty/commit); mode=config is the parsed manifest; mode=drift reconciles all four submodule authorities (manifest, .gitmodules, worktree, git submodule status) and reports every disagreement; mode=gitmodules decodes the raw .gitmodules file's submodule sections.",
149
160
  inputSchema: {
150
- mode: z.enum(["status", "config"]).describe("status = drift report; config = the full agent brief."),
161
+ mode: ReposInspectModeSchema,
151
162
  cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
152
163
  },
153
164
  outputSchema: effectToZodSchema(ReposInspectResult),
@@ -159,15 +170,19 @@ function buildServer(ctx) {
159
170
  });
160
171
  server.registerTool("repos_manage", {
161
172
  title: "Manage vendored repos",
162
- description: "Mutating: sync (initialize/reconcile submodules per the manifest), pin (re-pin a repo to a new ref), add (vendor a new repo), or note (add/remove/promote an agent note). Pass action plus the fields that action needs: pin needs name+ref; add needs url+ref+purpose (name/sparse optional); note needs name+op, plus note (op=add), id (op=remove), or id+into (op=promote). A decode failure names the missing field. The pin result's markdown surfaces commitMessage and staleNoteIds — review and commit after pinning.",
173
+ description: "Mutating: sync (initialize/reconcile submodules per the manifest), pin (re-pin a repo to a new ref), add (vendor a new repo), note (add/remove/promote an agent note), remove (unvendor a repo), rename (rename a vendored repo's manifest key and worktree), or restore (hard-reset a repo's worktree back to its pinned gitlink commit and re-apply sparse paths — DESTRUCTIVE to uncommitted worktree edits; never run implicitly). Pass action plus the fields that action needs: pin needs name+ref; add needs url+ref+purpose (name/sparse optional); note needs name+op, plus note (op=add), id (op=remove), or id+into (op=promote); remove needs name; rename needs name (the old name) + newName; restore takes an optional names list — omitted, it restores every dirty repo and reports the clean ones as skipped; given, it restores exactly those repos even if already clean. A decode failure names the missing field. The pin result's markdown surfaces commitMessage and staleNoteIds — review and commit after pinning. The remove result's markdown surfaces commitMessage and removedNotes — promote any durable notes elsewhere, then review and commit. The rename result's markdown surfaces commitMessage — review and commit after renaming. The restore result's markdown names exactly what was discarded.",
163
174
  inputSchema: {
164
175
  action: z.enum([
165
176
  "sync",
166
177
  "pin",
167
178
  "add",
168
- "note"
179
+ "note",
180
+ "remove",
181
+ "rename",
182
+ "restore"
169
183
  ]).describe("Which mutation to perform."),
170
- name: z.optional(z.string()).describe("Repo name (pin, note; optional override for add)."),
184
+ name: z.optional(z.string()).describe("Repo name (pin, note, remove, rename; optional override for add)."),
185
+ newName: z.optional(z.string()).describe("New repo name (rename)."),
171
186
  ref: z.optional(z.string()).describe("Git ref to pin/vendor to (pin, add)."),
172
187
  url: z.optional(z.string()).describe("Repo URL to vendor (add)."),
173
188
  purpose: z.optional(z.string()).describe("One-line purpose for the manifest (add)."),
@@ -180,6 +195,7 @@ function buildServer(ctx) {
180
195
  note: z.optional(z.string()).describe("Note text (note, op=add)."),
181
196
  id: z.optional(z.string()).describe("Note id (note, op=remove|promote)."),
182
197
  into: z.optional(z.enum(["layout", "startHere"])).describe("Orientation target (note, op=promote)."),
198
+ names: z.optional(z.array(z.string())).describe("Repo names to restore (restore); omitted restores every dirty repo."),
183
199
  cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
184
200
  },
185
201
  outputSchema: effectToZodSchema(ReposManageResult),
@@ -218,4 +234,4 @@ async function startMcpServer(ctx) {
218
234
  }
219
235
 
220
236
  //#endregion
221
- export { buildServer, startMcpServer };
237
+ export { ReposInspectModeSchema, buildServer, startMcpServer };
@@ -1,9 +1,34 @@
1
1
  import { mdInline } from "./md-inline.js";
2
2
  import { WorkspaceRoot } from "@effected/workspaces";
3
3
  import { Repos } from "@savvy-web/silk-effects";
4
- import { Effect, Schema, SchemaGetter } from "effect";
4
+ import { Effect, FileSystem, Option, Path, Result, Schema, SchemaGetter } from "effect";
5
+ import { Gitmodules } from "@effected/git";
5
6
 
6
7
  //#region src/tools/repos-inspect.ts
8
+ /**
9
+ * The `repos_inspect` MCP tool: a discriminated-union result keyed by `mode`
10
+ * (status | config), each variant embedding the corresponding resolved-output
11
+ * schema from silk-effects' Repos namespace, plus a one-way markdown
12
+ * transform. Read-only.
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+ /** One `.gitmodules` submodule section, decoded into typed fields. */
17
+ const GitmodulesEntrySchema = Schema.Struct({
18
+ name: Schema.String,
19
+ path: Schema.String,
20
+ url: Schema.String,
21
+ branch: Schema.optionalKey(Schema.String),
22
+ shallow: Schema.optionalKey(Schema.Boolean),
23
+ update: Schema.optionalKey(Schema.String),
24
+ ignore: Schema.optionalKey(Schema.Literals([
25
+ "all",
26
+ "dirty",
27
+ "untracked",
28
+ "none"
29
+ ])),
30
+ fetchRecurseSubmodules: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Literal("on-demand")]))
31
+ }).annotate({ identifier: "GitmodulesEntry" });
7
32
  /** Status-report variant. */
8
33
  const ReposStatusResult = Schema.Struct({
9
34
  mode: Schema.Literal("status"),
@@ -14,11 +39,27 @@ const ReposConfigResult = Schema.Struct({
14
39
  mode: Schema.Literal("config"),
15
40
  result: Repos.ReposManifestFile
16
41
  }).annotate({ identifier: "ReposConfigResult" });
42
+ /** Four-authority drift-reconciliation variant. */
43
+ const ReposDriftResult = Schema.Struct({
44
+ mode: Schema.Literal("drift"),
45
+ report: Repos.ReposDriftReport
46
+ }).annotate({ identifier: "ReposDriftResult" });
47
+ /** Raw `.gitmodules` variant: the decoded submodule sections, or a parse error. */
48
+ const ReposGitmodulesResult = Schema.Struct({
49
+ mode: Schema.Literal("gitmodules"),
50
+ entries: Schema.Array(GitmodulesEntrySchema),
51
+ parseError: Schema.optionalKey(Schema.String)
52
+ }).annotate({ identifier: "ReposGitmodulesResult" });
17
53
  /** The `repos_inspect` tool result — a discriminated union keyed by `mode`. */
18
- const ReposInspectResult = Schema.Union([ReposStatusResult, ReposConfigResult]).annotate({
54
+ const ReposInspectResult = Schema.Union([
55
+ ReposStatusResult,
56
+ ReposConfigResult,
57
+ ReposDriftResult,
58
+ ReposGitmodulesResult
59
+ ]).annotate({
19
60
  identifier: "ReposInspectResult",
20
61
  title: "repos_inspect result",
21
- description: "Drift report (status) or the parsed manifest with purposes, orientation, and notes (config)."
62
+ description: "Drift report (status), the parsed manifest (config), the four-authority reconciliation report (drift), or the decoded .gitmodules sections (gitmodules)."
22
63
  });
23
64
  /** Render the structured result as a markdown transcript. */
24
65
  const renderMarkdown = (data) => {
@@ -33,7 +74,7 @@ const renderMarkdown = (data) => {
33
74
  `## Repos`
34
75
  ];
35
76
  for (const entry of r.repos) {
36
- lines.push(`### ${mdInline(entry.name)}`, `- ref: ${mdInline(entry.ref)}`, `- purpose: ${mdInline(entry.purpose)}`, `- present: ${entry.present}`, `- commit: ${entry.commit ? mdInline(entry.commit) : "(none)"}`, `- dirty: ${entry.dirty}`);
77
+ lines.push(`### ${mdInline(entry.name)}`, `- ref: ${mdInline(entry.ref)}`, `- purpose: ${mdInline(entry.purpose)}`, `- present: ${entry.present}`, `- stagedCommit: ${entry.stagedCommit ? mdInline(entry.stagedCommit) : "(none)"}`, `- committedCommit: ${entry.committedCommit ? mdInline(entry.committedCommit) : "(none)"}`, `- checkedOutCommit: ${entry.checkedOutCommit ? mdInline(entry.checkedOutCommit) : "(none)"}`, `- dirty: ${entry.dirty}`);
37
78
  if (entry.staleNoteIds.length > 0) lines.push(`- staleNoteIds: ${entry.staleNoteIds.map(mdInline).join(", ")}`);
38
79
  }
39
80
  if (r.repos.length === 0) lines.push("(none)");
@@ -66,6 +107,28 @@ const renderMarkdown = (data) => {
66
107
  if (Object.keys(r.repos).length === 0) lines.push("(none)");
67
108
  return lines.join("\n");
68
109
  }
110
+ case "drift": {
111
+ const r = data.report;
112
+ const lines = [
113
+ `# repos drift`,
114
+ ``,
115
+ `clean: ${r.clean}`,
116
+ ``,
117
+ `| name | kind | detail |`,
118
+ `| --- | --- | --- |`
119
+ ];
120
+ for (const d of r.drifts) lines.push(`| ${mdInline(d.name)} | ${mdInline(d.kind)} | ${mdInline(d.detail)} |`);
121
+ if (r.drifts.length === 0) lines.push(``, `(none)`);
122
+ return lines.join("\n");
123
+ }
124
+ case "gitmodules": {
125
+ const lines = [`# repos gitmodules`, ``];
126
+ if (data.parseError) lines.push(`parse error: ${mdInline(data.parseError)}`, ``);
127
+ lines.push(`| name | path | url | branch | shallow |`, `| --- | --- | --- | --- | --- |`);
128
+ for (const entry of data.entries) lines.push(`| ${mdInline(entry.name)} | ${mdInline(entry.path)} | ${mdInline(entry.url)} | ${entry.branch === void 0 ? "(none)" : mdInline(entry.branch)} | ${entry.shallow === void 0 ? "(unset)" : entry.shallow} |`);
129
+ if (data.entries.length === 0) lines.push(``, `(none)`);
130
+ return lines.join("\n");
131
+ }
69
132
  }
70
133
  };
71
134
  /** One-way transform: result to markdown. Encoding back is forbidden. */
@@ -88,8 +151,41 @@ const reposInspect = (args, fallbackCwd) => Effect.gen(function* () {
88
151
  mode: "config",
89
152
  result: yield* (yield* Repos.ReposConfigStore).read(root)
90
153
  };
154
+ case "drift": return {
155
+ mode: "drift",
156
+ report: yield* (yield* Repos.ReposDrift).check(root)
157
+ };
158
+ case "gitmodules": {
159
+ const fs = yield* FileSystem.FileSystem;
160
+ const gitmodulesPath = (yield* Path.Path).join(root, ".gitmodules");
161
+ const text = yield* fs.readFileString(gitmodulesPath).pipe(Effect.map(Option.some), Effect.catchTag("PlatformError", (error) => error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(new Repos.GitSubmoduleError({
162
+ command: "read .gitmodules",
163
+ cwd: gitmodulesPath,
164
+ reason: error.message
165
+ }))));
166
+ if (Option.isNone(text)) return {
167
+ mode: "gitmodules",
168
+ entries: []
169
+ };
170
+ const parsed = Gitmodules.parseResult(text.value);
171
+ if (Result.isFailure(parsed)) return {
172
+ mode: "gitmodules",
173
+ entries: [],
174
+ parseError: parsed.failure.message
175
+ };
176
+ const decoded = Schema.decodeUnknownResult(Schema.Array(GitmodulesEntrySchema))(parsed.success.entries);
177
+ if (Result.isFailure(decoded)) return {
178
+ mode: "gitmodules",
179
+ entries: [],
180
+ parseError: `unexpected .gitmodules entry shape: ${decoded.failure.message}`
181
+ };
182
+ return {
183
+ mode: "gitmodules",
184
+ entries: decoded.success
185
+ };
186
+ }
91
187
  }
92
188
  });
93
189
 
94
190
  //#endregion
95
- export { ReposConfigResult, ReposInspectAsMarkdown, ReposInspectResult, ReposStatusResult, reposInspect };
191
+ export { ReposConfigResult, ReposDriftResult, ReposGitmodulesResult, ReposInspectAsMarkdown, ReposInspectResult, ReposStatusResult, reposInspect };
@@ -40,12 +40,24 @@ const NoteRequest = Schema.TaggedStruct("note", {
40
40
  if (request.op === "promote" && (request.id === void 0 || request.into === void 0)) return "note op \"promote\" requires both `id` and `into`";
41
41
  return true;
42
42
  }));
43
+ /** `remove` requires only `name`. */
44
+ const RemoveRequest = Schema.TaggedStruct("remove", { name: Schema.String });
45
+ /** `rename` requires `name` (the old name) and `newName`. */
46
+ const RenameRequest = Schema.TaggedStruct("rename", {
47
+ name: Schema.String,
48
+ newName: Schema.String
49
+ });
50
+ /** `restore`'s `names` is optional and repeatable, mirroring `add`'s `sparse`; omitted means "every dirty entry". */
51
+ const RestoreRequest = Schema.TaggedStruct("restore", { names: Schema.optional(Schema.Array(Schema.String)) });
43
52
  /** Internal tagged-union request the flat wire args decode into. */
44
53
  const ReposManageRequest = Schema.Union([
45
54
  SyncRequest,
46
55
  PinRequest,
47
56
  AddRequest,
48
- NoteRequest
57
+ NoteRequest,
58
+ RemoveRequest,
59
+ RenameRequest,
60
+ RestoreRequest
49
61
  ]);
50
62
  /** `sync` result variant. */
51
63
  const ReposManageSyncResult = Schema.Struct({
@@ -67,16 +79,34 @@ const ReposManageNoteResult = Schema.Struct({
67
79
  action: Schema.Literal("note"),
68
80
  result: Repos.ReposNoteResult
69
81
  }).annotate({ identifier: "ReposManageNoteResult" });
82
+ /** `remove` result variant. */
83
+ const ReposManageRemoveResult = Schema.Struct({
84
+ action: Schema.Literal("remove"),
85
+ result: Repos.ReposRemoveResult
86
+ }).annotate({ identifier: "ReposManageRemoveResult" });
87
+ /** `rename` result variant. */
88
+ const ReposManageRenameResult = Schema.Struct({
89
+ action: Schema.Literal("rename"),
90
+ result: Repos.ReposRenameResult
91
+ }).annotate({ identifier: "ReposManageRenameResult" });
92
+ /** `restore` result variant. */
93
+ const ReposManageRestoreResult = Schema.Struct({
94
+ action: Schema.Literal("restore"),
95
+ result: Repos.ReposRestoreResult
96
+ }).annotate({ identifier: "ReposManageRestoreResult" });
70
97
  /** The `repos_manage` tool result — a discriminated union keyed by `action`. */
71
98
  const ReposManageResult = Schema.Union([
72
99
  ReposManageSyncResult,
73
100
  ReposManagePinResult,
74
101
  ReposManageAddResult,
75
- ReposManageNoteResult
102
+ ReposManageNoteResult,
103
+ ReposManageRemoveResult,
104
+ ReposManageRenameResult,
105
+ ReposManageRestoreResult
76
106
  ]).annotate({
77
107
  identifier: "ReposManageResult",
78
108
  title: "repos_manage result",
79
- description: "Result of a mutating repos action: sync, pin, add, or note."
109
+ description: "Result of a mutating repos action: sync, pin, add, note, remove, rename, or restore."
80
110
  });
81
111
  /** Render the structured result as a markdown transcript. */
82
112
  const renderMarkdown = (data) => {
@@ -93,7 +123,11 @@ const renderMarkdown = (data) => {
93
123
  ``,
94
124
  ...section("Up to date", r.upToDate),
95
125
  ``,
96
- ...section("Cleared locks", r.clearedLocks)
126
+ ...section("Cleared locks", r.clearedLocks),
127
+ ``,
128
+ ...section("URL synced", r.urlSynced),
129
+ ``,
130
+ ...section("Registered", r.registered)
97
131
  ].join("\n");
98
132
  }
99
133
  case "pin": {
@@ -136,6 +170,54 @@ const renderMarkdown = (data) => {
136
170
  `noteCount: ${r.noteCount}`
137
171
  ].join("\n");
138
172
  }
173
+ case "remove": {
174
+ const r = data.result;
175
+ const lines = [
176
+ `# repos remove — ${mdInline(r.name)}`,
177
+ ``,
178
+ `path: ${mdInline(r.path)}`,
179
+ ``,
180
+ `## Commit message`,
181
+ ``,
182
+ mdInline(r.commitMessage),
183
+ ``,
184
+ `## Removed notes`,
185
+ ``
186
+ ];
187
+ if (r.removedNotes.length > 0) lines.push(`Promote any durable ones elsewhere before committing:`, ...r.removedNotes.map((note) => `- ${mdInline(note.id)} (${mdInline(note.ref)}): ${mdInline(note.note)}`));
188
+ else lines.push("(none)");
189
+ lines.push(``, `REVIEW AND COMMIT: the manifest, .gitmodules, and gitlink removal are already staged — review and commit using the message above.`);
190
+ return lines.join("\n");
191
+ }
192
+ case "rename": {
193
+ const r = data.result;
194
+ return [
195
+ `# repos rename — ${mdInline(r.oldName)} → ${mdInline(r.newName)}`,
196
+ ``,
197
+ `path: ${mdInline(r.path)}`,
198
+ ``,
199
+ `## Commit message`,
200
+ ``,
201
+ mdInline(r.commitMessage),
202
+ ``,
203
+ `REVIEW AND COMMIT: the moved worktree, .gitmodules section, and manifest key are already staged — review and commit using the message above.`
204
+ ].join("\n");
205
+ }
206
+ case "restore": {
207
+ const r = data.result;
208
+ const lines = [
209
+ `# repos restore`,
210
+ ``,
211
+ `## Restored`,
212
+ ``
213
+ ];
214
+ if (r.restored.length > 0) lines.push(`DESTRUCTIVE: any uncommitted worktree edits and untracked files in these repos were discarded — the working tree was hard-reset to the commit below and sparse paths re-applied.`, ``, ...r.restored.map((entry) => `- ${mdInline(entry.name)} → ${mdInline(entry.commit)}`));
215
+ else lines.push("(none)");
216
+ lines.push(``, `## Skipped (clean)`, ``);
217
+ if (r.skippedClean.length > 0) lines.push(...r.skippedClean.map((name) => `- ${mdInline(name)}`));
218
+ else lines.push("(none)");
219
+ return lines.join("\n");
220
+ }
139
221
  }
140
222
  };
141
223
  /** One-way transform: result to markdown. Encoding back is forbidden. */
@@ -192,8 +274,20 @@ const reposManage = (args, fallbackCwd) => Effect.gen(function* () {
192
274
  result: yield* manager.note(root, request.name, noteOp)
193
275
  };
194
276
  }
277
+ case "remove": return {
278
+ action: "remove",
279
+ result: yield* manager.remove(root, request.name)
280
+ };
281
+ case "rename": return {
282
+ action: "rename",
283
+ result: yield* manager.rename(root, request.name, request.newName)
284
+ };
285
+ case "restore": return {
286
+ action: "restore",
287
+ result: yield* manager.restore(root, request.names)
288
+ };
195
289
  }
196
290
  });
197
291
 
198
292
  //#endregion
199
- export { ReposManageAddResult, ReposManageAsMarkdown, ReposManageNoteResult, ReposManagePinResult, ReposManageResult, ReposManageSyncResult, reposManage };
293
+ export { ReposManageAddResult, ReposManageAsMarkdown, ReposManageNoteResult, ReposManagePinResult, ReposManageRemoveResult, ReposManageRenameResult, ReposManageRestoreResult, ReposManageResult, ReposManageSyncResult, reposManage };