@savvy-web/mcp 3.2.4 → 3.3.0

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.
@@ -1,6 +1,6 @@
1
- import { McpToolError, invalidArgument, mapEngineError, truncateEchoed } from "../errors.js";
2
- import { SilkMarkdown } from "../markdown.js";
3
- import { Data, Effect, Schema, SchemaGetter } from "effect";
1
+ import { McpToolError, invalidArgument, mapEngineError } from "../errors.js";
2
+ import { ToolFailure } from "@effected/mcp";
3
+ import { Data, Effect, Schema } from "effect";
4
4
  import { WorkspaceRoot } from "@effected/workspaces";
5
5
  import { Changesets } from "@savvy-web/silk-effects";
6
6
  import { Tool } from "effect/unstable/ai";
@@ -36,29 +36,6 @@ const ChangesetValidateResult = Schema.Struct({
36
36
  description: "Read-only validation of changeset files against the section-aware rules."
37
37
  });
38
38
  /**
39
- * Render a repo/lint-derived value as an inert markdown code span. Escapes
40
- * backticks and backslashes so a crafted filename or message cannot inject
41
- * markdown structure into the transcript that an agent reads.
42
- */
43
- const mdInline = (value) => `\`${value.replace(/[`\\]/g, "\\$&")}\``;
44
- /** Render the structured result as a markdown transcript. */
45
- const renderMarkdown = (data) => {
46
- if (data.ok) return `# changeset validate — ${mdInline(data.dir)}\n\nNo changeset issues found.`;
47
- const lines = [
48
- `# changeset validate — ${mdInline(data.dir)}`,
49
- ``,
50
- `${data.errorCount} issue(s):`,
51
- ``
52
- ];
53
- for (const m of data.messages) lines.push(`- ${mdInline(`${m.file}:${m.line}:${m.column}`)} ${mdInline(m.rule)} — ${m.message}`);
54
- return lines.join("\n");
55
- };
56
- /** One-way transform: result to markdown. Encoding back is forbidden. */
57
- const ChangesetValidateAsMarkdown = ChangesetValidateResult.pipe(Schema.decodeTo(Schema.String, {
58
- decode: SchemaGetter.transform(renderMarkdown),
59
- encode: SchemaGetter.forbidden(() => "ChangesetValidateAsMarkdown is one-way: markdown cannot be parsed back.")
60
- }));
61
- /**
62
39
  * Effect handler: resolve the workspace root, then validate the changeset
63
40
  * directory via the pure {@link Changesets.ChangesetLinter.validate}. The
64
41
  * synchronous call is wrapped in {@link Effect.try} so a thrown error (e.g. a
@@ -90,12 +67,12 @@ const ChangesetValidateParams = Schema.Struct({
90
67
  const DIR_REMEDIATION = { hint: "Pass dir as a path (relative to the workspace root) to an existing changeset directory, or omit it for .changeset." };
91
68
  /** The `changeset_validate` tool value. */
92
69
  const changesetValidateTool = Tool.make("changeset_validate", {
93
- description: "Read-only validation of changeset files against the section-aware rules. Pass dir (default .changeset). Returns typed diagnostics (file, rule, line, column, message) plus ok/errorCount in structuredContent. Prefer this over shelling out to savvy changeset lint.",
70
+ description: "Read-only validation of changeset files against the section-aware rules. Pass dir (default .changeset). Returns typed diagnostics (file, rule, line, column, message) plus ok/errorCount in structuredContent. Prefer this over shelling out to savvy changeset lint. Returns a typed object in structuredContent (content[] carries the same object as JSON).",
94
71
  parameters: ChangesetValidateParams,
95
72
  success: ChangesetValidateResult,
96
73
  failure: McpToolError,
97
74
  dependencies: [WorkspaceRoot]
98
- }).annotate(Tool.Title, "Validate changesets").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.Idempotent, true).annotate(Tool.OpenWorld, false).annotate(SilkMarkdown, Schema.decodeUnknownSync(ChangesetValidateAsMarkdown));
75
+ }).annotate(Tool.Title, "Validate changesets").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.Idempotent, true).annotate(Tool.OpenWorld, false);
99
76
  /**
100
77
  * Wire handler: {@link changesetValidate} with its error channel mapped onto
101
78
  * {@link McpToolError}. The typed {@link ChangesetValidateError} (a thrown
@@ -103,8 +80,8 @@ const changesetValidateTool = Tool.make("changeset_validate", {
103
80
  * becomes {@link InvalidArgument} naming `dir`; the echoed directory is
104
81
  * truncated.
105
82
  */
106
- const handleChangesetValidate = (fallbackCwd, params) => changesetValidate(params, fallbackCwd).pipe(Effect.mapError((error) => error._tag === "ChangesetValidateError" ? invalidArgument("dir", `Changeset directory "${truncateEchoed(error.dir)}" could not be validated: ${describeCause(error.cause)}`, DIR_REMEDIATION) : mapEngineError(params.cwd ?? fallbackCwd, DIR_REMEDIATION)(error)));
107
- const describeCause = (cause) => cause instanceof Error ? truncateEchoed(cause.message) : truncateEchoed(String(cause));
83
+ const handleChangesetValidate = (fallbackCwd, params) => changesetValidate(params, fallbackCwd).pipe(Effect.mapError((error) => error._tag === "ChangesetValidateError" ? invalidArgument("dir", `Changeset directory "${ToolFailure.truncate(error.dir)}" could not be validated: ${describeCause(error.cause)}`, DIR_REMEDIATION) : mapEngineError(params.cwd ?? fallbackCwd, DIR_REMEDIATION)(error)));
84
+ const describeCause = (cause) => cause instanceof Error ? ToolFailure.truncate(cause.message) : ToolFailure.truncate(String(cause));
108
85
 
109
86
  //#endregion
110
- export { ChangesetLintMessage, ChangesetValidateAsMarkdown, ChangesetValidateError, ChangesetValidateParams, ChangesetValidateResult, changesetValidate, changesetValidateTool, handleChangesetValidate };
87
+ export { ChangesetLintMessage, ChangesetValidateError, ChangesetValidateParams, ChangesetValidateResult, changesetValidate, changesetValidateTool, handleChangesetValidate };
@@ -1,7 +1,5 @@
1
1
  import { McpToolError, mapEngineError } from "../errors.js";
2
- import { SilkMarkdown } from "../markdown.js";
3
- import { mdInline } from "./md-inline.js";
4
- import { Effect, FileSystem, Option, Path, Result, Schema, SchemaGetter } from "effect";
2
+ import { Effect, FileSystem, Option, Path, Result, Schema } from "effect";
5
3
  import { WorkspaceRoot } from "@effected/workspaces";
6
4
  import { Repos } from "@savvy-web/silk-effects";
7
5
  import { Tool } from "effect/unstable/ai";
@@ -11,8 +9,7 @@ import { Gitmodules } from "@effected/git";
11
9
  /**
12
10
  * The `repos_inspect` MCP tool: a discriminated-union result keyed by `mode`
13
11
  * (status | config), each variant embedding the corresponding resolved-output
14
- * schema from silk-effects' Repos namespace, plus a one-way markdown
15
- * transform. Read-only.
12
+ * schema from silk-effects' Repos namespace. Read-only.
16
13
  *
17
14
  * @packageDocumentation
18
15
  */
@@ -64,81 +61,6 @@ const ReposInspectResult = Schema.Union([
64
61
  title: "repos_inspect result",
65
62
  description: "Drift report (status), the parsed manifest (config), the five-authority reconciliation report (drift), or the decoded .gitmodules sections (gitmodules)."
66
63
  });
67
- /** Render the structured result as a markdown transcript. */
68
- const renderMarkdown = (data) => {
69
- switch (data.mode) {
70
- case "status": {
71
- const r = data.result;
72
- const lines = [
73
- `# repos status`,
74
- ``,
75
- `clean: ${r.clean}`,
76
- ``,
77
- `## Repos`
78
- ];
79
- for (const entry of r.repos) {
80
- 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}`);
81
- if (entry.staleNoteIds.length > 0) lines.push(`- staleNoteIds: ${entry.staleNoteIds.map(mdInline).join(", ")}`);
82
- }
83
- if (r.repos.length === 0) lines.push("(none)");
84
- return lines.join("\n");
85
- }
86
- case "config": {
87
- const r = data.result;
88
- const lines = [
89
- `# repos config`,
90
- ``,
91
- `## Repos`
92
- ];
93
- for (const [name, entry] of Object.entries(r.repos)) {
94
- lines.push(`### ${mdInline(name)}`, `- url: ${mdInline(entry.url)}`, `- ref: ${mdInline(entry.ref)}`, `- purpose: ${mdInline(entry.purpose)}`);
95
- if (entry.sparse && entry.sparse.length > 0) lines.push(`- sparse: ${entry.sparse.map(mdInline).join(", ")}`);
96
- if (entry.orientation) {
97
- const o = entry.orientation;
98
- if (o.layout) lines.push(`- layout: ${mdInline(o.layout)}`);
99
- if (o.startHere) lines.push(`- startHere: ${mdInline(o.startHere)}`);
100
- if (o.keyPaths) {
101
- lines.push(`- keyPaths:`);
102
- for (const [key, value] of Object.entries(o.keyPaths)) lines.push(` - ${mdInline(key)}: ${mdInline(value)}`);
103
- }
104
- }
105
- if (entry.notes && entry.notes.length > 0) {
106
- lines.push(`- notes:`);
107
- for (const note of entry.notes) lines.push(` - ${mdInline(note.id)} (${mdInline(note.date)}, ref ${mdInline(note.ref)}): ${mdInline(note.note)}`);
108
- }
109
- }
110
- if (Object.keys(r.repos).length === 0) lines.push("(none)");
111
- return lines.join("\n");
112
- }
113
- case "drift": {
114
- const r = data.report;
115
- const lines = [
116
- `# repos drift`,
117
- ``,
118
- `clean: ${r.clean}`,
119
- ``,
120
- `| name | kind | detail |`,
121
- `| --- | --- | --- |`
122
- ];
123
- for (const d of r.drifts) lines.push(`| ${mdInline(d.name)} | ${mdInline(d.kind)} | ${mdInline(d.detail)} |`);
124
- if (r.drifts.length === 0) lines.push(``, `(none)`);
125
- return lines.join("\n");
126
- }
127
- case "gitmodules": {
128
- const lines = [`# repos gitmodules`, ``];
129
- if (data.parseError) lines.push(`parse error: ${mdInline(data.parseError)}`, ``);
130
- lines.push(`| name | path | url | branch | shallow |`, `| --- | --- | --- | --- | --- |`);
131
- 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} |`);
132
- if (data.entries.length === 0) lines.push(``, `(none)`);
133
- return lines.join("\n");
134
- }
135
- }
136
- };
137
- /** One-way transform: result to markdown. Encoding back is forbidden. */
138
- const ReposInspectAsMarkdown = ReposInspectResult.pipe(Schema.decodeTo(Schema.String, {
139
- decode: SchemaGetter.transform(renderMarkdown),
140
- encode: SchemaGetter.forbidden(() => "ReposInspectAsMarkdown is one-way: markdown cannot be parsed back.")
141
- }));
142
64
  /**
143
65
  * Effect handler: resolve the workspace root, then dispatch to the matching
144
66
  * Repos service keyed by `mode`. Mirrors `changesetInspect`.
@@ -207,7 +129,7 @@ const ReposInspectParams = Schema.Struct({
207
129
  const REMEDIATION = { hint: "The vendored-repo state could not be read; check .repos/config.json and .gitmodules, and that git can run in the workspace." };
208
130
  /** The `repos_inspect` tool value. */
209
131
  const reposInspectTool = Tool.make("repos_inspect", {
210
- 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.",
132
+ 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. Returns a typed object in structuredContent (content[] carries the same object as JSON).",
211
133
  parameters: ReposInspectParams,
212
134
  success: ReposInspectResult,
213
135
  failure: McpToolError,
@@ -219,9 +141,9 @@ const reposInspectTool = Tool.make("repos_inspect", {
219
141
  FileSystem.FileSystem,
220
142
  Path.Path
221
143
  ]
222
- }).annotate(Tool.Title, "Inspect vendored repos").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.Idempotent, true).annotate(Tool.OpenWorld, false).annotate(SilkMarkdown, Schema.decodeUnknownSync(ReposInspectAsMarkdown));
144
+ }).annotate(Tool.Title, "Inspect vendored repos").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.Idempotent, true).annotate(Tool.OpenWorld, false);
223
145
  /** Wire handler: {@link reposInspect} with its error channel mapped onto {@link McpToolError}. */
224
146
  const handleReposInspect = (fallbackCwd, params) => reposInspect(params, fallbackCwd).pipe(Effect.mapError(mapEngineError(params.cwd ?? fallbackCwd, REMEDIATION)));
225
147
 
226
148
  //#endregion
227
- export { ReposConfigResult, ReposDriftResult, ReposGitmodulesResult, ReposInspectAsMarkdown, ReposInspectMode, ReposInspectParams, ReposInspectResult, ReposStatusResult, handleReposInspect, reposInspect, reposInspectTool };
149
+ export { ReposConfigResult, ReposDriftResult, ReposGitmodulesResult, ReposInspectMode, ReposInspectParams, ReposInspectResult, ReposStatusResult, handleReposInspect, reposInspect, reposInspectTool };
@@ -1,12 +1,21 @@
1
- import { ENGINE_ECHO_LIMIT, McpToolError, invalidArgument, mapEngineError, truncateEchoed } from "../errors.js";
2
- import { SilkMarkdown } from "../markdown.js";
3
- import { mdInline } from "./md-inline.js";
4
- import { Effect, Schema, SchemaGetter } from "effect";
1
+ import { McpToolError, invalidArgument, mapEngineError } from "../errors.js";
2
+ import { ToolFailure } from "@effected/mcp";
3
+ import { Effect, Schema } from "effect";
5
4
  import { WorkspaceRoot } from "@effected/workspaces";
6
5
  import { Repos } from "@savvy-web/silk-effects";
7
6
  import { Tool } from "effect/unstable/ai";
8
7
 
9
8
  //#region src/tools/repos-manage.ts
9
+ /**
10
+ * The `repos_manage` MCP tool: one action-discriminated mutating tool
11
+ * covering `sync`, `pin`, `add`, `note`, `remove`, `rename`, `restore`, and
12
+ * `deregister` against the vendored `.repos/` submodules. The wire schema is flat (no
13
+ * `oneOf`); the handler maps it into an internal `Schema.TaggedStruct`
14
+ * request union that names the missing field per action on decode failure.
15
+ * Mutating — no `readOnlyHint`.
16
+ *
17
+ * @packageDocumentation
18
+ */
10
19
  /** `sync` has no extra fields. */
11
20
  const SyncRequest = Schema.TaggedStruct("sync", {});
12
21
  /** `pin` requires both `name` and `ref`. */
@@ -129,139 +138,6 @@ const ReposManageResult = Schema.Union([
129
138
  title: "repos_manage result",
130
139
  description: "Result of a mutating repos action: sync, pin, add, note, remove, rename, restore, or deregister."
131
140
  });
132
- /** Render the structured result as a markdown transcript. */
133
- const renderMarkdown = (data) => {
134
- switch (data.action) {
135
- case "sync": {
136
- const r = data.result;
137
- const section = (title, names) => [`## ${title}`, ...names.length > 0 ? names.map((name) => `- ${mdInline(name)}`) : ["(none)"]];
138
- return [
139
- `# repos sync`,
140
- ``,
141
- ...section("Initialized", r.initialized),
142
- ``,
143
- ...section("Sparse applied", r.sparseApplied),
144
- ``,
145
- ...section("Up to date", r.upToDate),
146
- ``,
147
- ...section("Cleared locks", r.clearedLocks),
148
- ``,
149
- ...section("URL synced", r.urlSynced),
150
- ``,
151
- ...section("Registered", r.registered),
152
- ``,
153
- ...section("Boundary marked", r.boundaryMarked)
154
- ].join("\n");
155
- }
156
- case "pin": {
157
- const r = data.result;
158
- const lines = [
159
- `# repos pin — ${mdInline(r.name)}`,
160
- ``,
161
- `ref: ${mdInline(r.ref)}`,
162
- `oldCommit: ${r.oldCommit ? mdInline(r.oldCommit) : "(none)"}`,
163
- `newCommit: ${mdInline(r.newCommit)}`,
164
- ``,
165
- `## Commit message`,
166
- ``,
167
- mdInline(r.commitMessage),
168
- ``,
169
- `## Stale notes`,
170
- ``
171
- ];
172
- if (r.staleNoteIds.length > 0) lines.push(`These notes reference a ref other than the new pin and should be reviewed before committing:`, ...r.staleNoteIds.map((id) => `- ${mdInline(id)}`));
173
- else lines.push("(none)");
174
- lines.push(``, `REVIEW AND COMMIT: stage the updated manifest and submodule gitlink, then commit using the message above.`);
175
- return lines.join("\n");
176
- }
177
- case "add": {
178
- const r = data.result;
179
- return [
180
- `# repos add — ${mdInline(r.name)}`,
181
- ``,
182
- `ref: ${mdInline(r.ref)}`,
183
- `path: ${mdInline(r.path)}`
184
- ].join("\n");
185
- }
186
- case "note": {
187
- const r = data.result;
188
- return [
189
- `# repos note — ${mdInline(r.name)}`,
190
- ``,
191
- `op: ${mdInline(r.op)}`,
192
- `id: ${mdInline(r.id)}`,
193
- `noteCount: ${r.noteCount}`
194
- ].join("\n");
195
- }
196
- case "remove": {
197
- const r = data.result;
198
- const lines = [
199
- `# repos remove — ${mdInline(r.name)}`,
200
- ``,
201
- `path: ${mdInline(r.path)}`,
202
- ``,
203
- `## Commit message`,
204
- ``,
205
- mdInline(r.commitMessage),
206
- ``,
207
- `## Removed notes`,
208
- ``
209
- ];
210
- 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)}`));
211
- else lines.push("(none)");
212
- if (r.removedEntry.orientation) lines.push(``, `## Removed orientation`, ``, `\`add\` does NOT restore this. If you are re-vendoring ${mdInline(r.name)}, pass it back as the \`orientation\` argument:`, ``, "```json", JSON.stringify(r.removedEntry.orientation, null, 2), "```");
213
- lines.push(``, `REVIEW AND COMMIT: the manifest, .gitmodules, and gitlink removal are already staged — review and commit using the message above.`);
214
- return lines.join("\n");
215
- }
216
- case "rename": {
217
- const r = data.result;
218
- return [
219
- `# repos rename — ${mdInline(r.oldName)} → ${mdInline(r.newName)}`,
220
- ``,
221
- `path: ${mdInline(r.path)}`,
222
- ``,
223
- `## Commit message`,
224
- ``,
225
- mdInline(r.commitMessage),
226
- ``,
227
- `REVIEW AND COMMIT: the moved worktree, .gitmodules section, and manifest key are already staged — review and commit using the message above.`
228
- ].join("\n");
229
- }
230
- case "restore": {
231
- const r = data.result;
232
- const lines = [
233
- `# repos restore`,
234
- ``,
235
- `## Restored`,
236
- ``
237
- ];
238
- 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)}`));
239
- else lines.push("(none)");
240
- lines.push(``, `## Skipped (clean)`, ``);
241
- if (r.skippedClean.length > 0) lines.push(...r.skippedClean.map((name) => `- ${mdInline(name)}`));
242
- else lines.push("(none)");
243
- if (r.stillDirty.length > 0) lines.push(``, `## Still dirty — RESTORE DID NOT FULLY SUCCEED`, ``, `The reset ran but these worktrees remain dirty, so treat the restore as incomplete rather than done:`, ``, ...r.stillDirty.map((name) => `- ${mdInline(name)}`), ``, `Run repos_inspect (mode: drift) — a nestedSubmoduleDivergence or a permission problem is the usual cause.`);
244
- return lines.join("\n");
245
- }
246
- case "deregister": {
247
- const r = data.result;
248
- return [
249
- `# repos deregister — ${mdInline(r.section)}`,
250
- ``,
251
- `Removed the stale ${mdInline(`submodule.${r.section}`)} section from the superproject's LOCAL git config. Keys cleared:`,
252
- ``,
253
- ...r.removedKeys.map((key) => `- ${mdInline(key)}`),
254
- ``,
255
- `Local config only — nothing is staged and there is nothing to commit.`
256
- ].join("\n");
257
- }
258
- }
259
- };
260
- /** One-way transform: result to markdown. Encoding back is forbidden. */
261
- const ReposManageAsMarkdown = ReposManageResult.pipe(Schema.decodeTo(Schema.String, {
262
- decode: SchemaGetter.transform(renderMarkdown),
263
- encode: SchemaGetter.forbidden(() => "ReposManageAsMarkdown is one-way: markdown cannot be parsed back.")
264
- }));
265
141
  /**
266
142
  * Effect handler: resolve the workspace root, decode the flat wire args into
267
143
  * the internal per-action request (naming the missing field on failure), then
@@ -368,21 +244,21 @@ const REMEDIATION = {
368
244
  const REQUEST_REMEDIATION = { hint: "Pass the fields the chosen action needs (pin: name+ref; add: url+ref+purpose; note: name+op plus note/id/into; remove: name; rename: name+newName; deregister: section)." };
369
245
  /** The `repos_manage` tool value. Mutating: not read-only, not idempotent. */
370
246
  const reposManageTool = Tool.make("repos_manage", {
371
- 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), 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), or deregister (clear a STALE submodule.<section> registration from the superproject's local git config — the phantom entry repos_inspect drift reports as localRegistrationDivergence with no matching manifest entry; refuses a section outside .repos/ and any section still backing a live manifest entry — canonically named or gitdir-diverged — and touches local config only, so nothing is staged). Pass action plus the fields that action needs: pin needs name+ref; add needs url+ref+purpose (name/sparse/orientation optional — pass orientation back from a preceding remove's removedEntry to make a re-vendor lossless); 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; deregister needs section (the registration name exactly as the drift report states it, e.g. .repos/old-name — no submodule. prefix). 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, removedNotes and the removed entry's orientation block — promote any durable notes elsewhere, keep the orientation if you intend to re-vendor, 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. The deregister result's markdown lists the config keys the removed section carried — nothing to commit afterwards.",
247
+ 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), 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), or deregister (clear a STALE submodule.<section> registration from the superproject's local git config — the phantom entry repos_inspect drift reports as localRegistrationDivergence with no matching manifest entry; refuses a section outside .repos/ and any section still backing a live manifest entry — canonically named or gitdir-diverged — and touches local config only, so nothing is staged). Pass action plus the fields that action needs: pin needs name+ref; add needs url+ref+purpose (name/sparse/orientation optional — pass orientation back from a preceding remove's removedEntry to make a re-vendor lossless); 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; deregister needs section (the registration name exactly as the drift report states it, e.g. .repos/old-name — no submodule. prefix). A decode failure names the missing field. The pin result surfaces commitMessage and staleNoteIds — review and commit after pinning. The remove result surfaces commitMessage, removedNotes and the removed entry's orientation block — promote any durable notes elsewhere, keep the orientation if you intend to re-vendor, then review and commit. The rename result surfaces commitMessage — review and commit after renaming. The restore result names exactly what was discarded. The deregister result lists the config keys the removed section carried — nothing to commit afterwards. Returns a typed object in structuredContent (content[] carries the same object as JSON).",
372
248
  parameters: ReposManageParams,
373
249
  success: ReposManageResult,
374
250
  failure: McpToolError,
375
251
  dependencies: [Repos.ReposManager, WorkspaceRoot]
376
- }).annotate(Tool.Title, "Manage vendored repos").annotate(Tool.Readonly, false).annotate(Tool.Destructive, true).annotate(Tool.Idempotent, false).annotate(Tool.OpenWorld, false).annotate(SilkMarkdown, Schema.decodeUnknownSync(ReposManageAsMarkdown));
252
+ }).annotate(Tool.Title, "Manage vendored repos").annotate(Tool.Readonly, false).annotate(Tool.Destructive, true).annotate(Tool.Idempotent, false).annotate(Tool.OpenWorld, false);
377
253
  /**
378
254
  * Wire handler: {@link reposManage} with its error channel mapped onto
379
255
  * {@link McpToolError}. The per-action request decode failure (`SchemaError`,
380
256
  * which names the missing field, and echoes the decoded value) is an argument
381
257
  * problem and becomes {@link InvalidArgument} keyed on `action`, its text
382
- * truncated at {@link ENGINE_ECHO_LIMIT}; the engine's own errors go
258
+ * truncated at `ToolFailure.ENGINE_ECHO_LIMIT`; the engine's own errors go
383
259
  * through {@link mapEngineError}.
384
260
  */
385
- const handleReposManage = (fallbackCwd, params) => reposManage(params, fallbackCwd).pipe(Effect.mapError((error) => error._tag === "SchemaError" ? invalidArgument("action", `repos_manage ${params.action}: ${truncateEchoed(error.message, ENGINE_ECHO_LIMIT)}`, REQUEST_REMEDIATION) : mapEngineError(params.cwd ?? fallbackCwd, REMEDIATION)(error)));
261
+ const handleReposManage = (fallbackCwd, params) => reposManage(params, fallbackCwd).pipe(Effect.mapError((error) => error._tag === "SchemaError" ? invalidArgument("action", `repos_manage ${params.action}: ${ToolFailure.truncate(error.message, ToolFailure.ENGINE_ECHO_LIMIT)}`, REQUEST_REMEDIATION) : mapEngineError(params.cwd ?? fallbackCwd, REMEDIATION)(error)));
386
262
 
387
263
  //#endregion
388
- export { ReposManageAddResult, ReposManageAsMarkdown, ReposManageDeregisterResult, ReposManageNoteResult, ReposManageParams, ReposManagePinResult, ReposManageRemoveResult, ReposManageRenameResult, ReposManageRestoreResult, ReposManageResult, ReposManageSyncResult, handleReposManage, reposManage, reposManageTool };
264
+ export { ReposManageAddResult, ReposManageDeregisterResult, ReposManageNoteResult, ReposManageParams, ReposManagePinResult, ReposManageRemoveResult, ReposManageRenameResult, ReposManageRestoreResult, ReposManageResult, ReposManageSyncResult, handleReposManage, reposManage, reposManageTool };
@@ -1,6 +1,5 @@
1
1
  import { McpToolError, mapEngineError } from "../errors.js";
2
- import { SilkMarkdown } from "../markdown.js";
3
- import { Effect, Schema, SchemaGetter } from "effect";
2
+ import { Effect, Schema } from "effect";
4
3
  import { WorkspaceRoot } from "@effected/workspaces";
5
4
  import { Turbo } from "@savvy-web/silk-effects";
6
5
  import { Tool } from "effect/unstable/ai";
@@ -31,59 +30,6 @@ const TurboInspectResult = Schema.Union([
31
30
  title: "turbo_inspect result",
32
31
  description: "Read-only Turborepo inspection grouped by mode (cache | graph | affected)."
33
32
  });
34
- /** Render the structured result as a markdown transcript. */
35
- const renderMarkdown = (data) => {
36
- switch (data.mode) {
37
- case "cache": {
38
- const r = data.result;
39
- const lines = [
40
- `# turbo cache — ${r.task}`,
41
- ``,
42
- `**${r.hits}/${r.totalTasks} cached**, ${r.misses} miss(es).`,
43
- ``,
44
- `## Global hash`,
45
- `- rootKey: \`${r.global.rootKey}\``,
46
- `- global files: ${r.global.globalFileCount}`,
47
- `- external deps hash: \`${r.global.externalDependenciesHash}\``,
48
- `- internal deps hash: \`${r.global.internalDependenciesHash}\``,
49
- `- global env: ${r.global.globalEnvVars.join(", ") || "(none)"}`
50
- ];
51
- if (r.explanations.length > 0) {
52
- lines.push(``, `## Misses`);
53
- for (const m of r.explanations) lines.push(`### ${m.package} (\`${m.taskId}\`)`, `- hash: \`${m.hash}\``, `- input files: ${m.inputFileCount}`, `- hashed env: ${m.hashedEnvVars.join(", ") || "(none)"}`, `- external deps hash: \`${m.externalDependenciesHash}\``, `- depends on: ${m.dependsOn.join(", ") || "(none)"}`);
54
- }
55
- return lines.join("\n");
56
- }
57
- case "graph": {
58
- const r = data.result;
59
- return [
60
- `# turbo task graph${r.task ? ` — ${r.task}` : ""}`,
61
- ``,
62
- `${r.nodeCount} task node(s).`,
63
- ``,
64
- `## Critical path`,
65
- r.criticalPath.map((id, i) => `${i + 1}. \`${id}\``).join("\n") || "(empty)"
66
- ].join("\n");
67
- }
68
- case "affected": {
69
- const r = data.result;
70
- return [
71
- `# turbo affected — base ${r.base}`,
72
- ``,
73
- `## Changed packages`,
74
- r.packages.map((p) => `- ${p}`).join("\n") || "(none)",
75
- ``,
76
- `## Dependents`,
77
- r.dependents.map((p) => `- ${p}`).join("\n") || "(none)"
78
- ].join("\n");
79
- }
80
- }
81
- };
82
- /** One-way transform: result to markdown. Encoding back is forbidden. */
83
- const TurboInspectAsMarkdown = TurboInspectResult.pipe(Schema.decodeTo(Schema.String, {
84
- decode: SchemaGetter.transform(renderMarkdown),
85
- encode: SchemaGetter.forbidden(() => "TurboInspectAsMarkdown is one-way: markdown cannot be parsed back.")
86
- }));
87
33
  /**
88
34
  * Effect handler: resolve the workspace root by walking up from the requested
89
35
  * directory, then dispatch to the matching {@link Turbo.TurboInspector} method
@@ -121,14 +67,14 @@ const TurboInspectParams = Schema.Struct({
121
67
  const REMEDIATION = { hint: "turbo could not complete the dry run; check that the task exists in turbo.json and that turbo resolves from the workspace." };
122
68
  /** The `turbo_inspect` tool value. */
123
69
  const turboInspectTool = Tool.make("turbo_inspect", {
124
- description: "Read-only Turborepo inspection. mode=cache diagnoses why a task's cache is hitting/missing (per-package status plus the exact hash contributors: input files, env vars, external-dep hashes, global hash). mode=graph returns the task graph and critical path. mode=affected lists changed packages and their dependents. Never executes tasks (uses --dry).",
70
+ description: "Read-only Turborepo inspection. mode=cache diagnoses why a task's cache is hitting/missing (per-package status plus the exact hash contributors: input files, env vars, external-dep hashes, global hash). mode=graph returns the task graph and critical path. mode=affected lists changed packages and their dependents. Never executes tasks (uses --dry). Returns a typed object in structuredContent (content[] carries the same object as JSON).",
125
71
  parameters: TurboInspectParams,
126
72
  success: TurboInspectResult,
127
73
  failure: McpToolError,
128
74
  dependencies: [Turbo.TurboInspector, WorkspaceRoot]
129
- }).annotate(Tool.Title, "Inspect Turborepo").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.Idempotent, true).annotate(Tool.OpenWorld, false).annotate(SilkMarkdown, Schema.decodeUnknownSync(TurboInspectAsMarkdown));
75
+ }).annotate(Tool.Title, "Inspect Turborepo").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.Idempotent, true).annotate(Tool.OpenWorld, false);
130
76
  /** Wire handler: {@link turboInspect} with its error channel mapped onto {@link McpToolError}. */
131
77
  const handleTurboInspect = (fallbackCwd, params) => turboInspect(params, fallbackCwd).pipe(Effect.mapError(mapEngineError(params.cwd ?? fallbackCwd, REMEDIATION)));
132
78
 
133
79
  //#endregion
134
- export { TurboAffectedResult, TurboCacheResult, TurboGraphResult, TurboInspectAsMarkdown, TurboInspectParams, TurboInspectResult, handleTurboInspect, turboInspect, turboInspectTool };
80
+ export { TurboAffectedResult, TurboCacheResult, TurboGraphResult, TurboInspectParams, TurboInspectResult, handleTurboInspect, turboInspect, turboInspectTool };
@@ -1,6 +1,5 @@
1
1
  import { McpToolError, mapEngineError } from "../errors.js";
2
- import { SilkMarkdown } from "../markdown.js";
3
- import { Effect, Schema, SchemaGetter } from "effect";
2
+ import { Effect, Schema } from "effect";
4
3
  import { WorkspaceRoot } from "@effected/workspaces";
5
4
  import { SilkWorkspaceAnalyzer } from "@savvy-web/silk-effects";
6
5
  import { Tool } from "effect/unstable/ai";
@@ -65,27 +64,6 @@ const toWorkspaceInfoResult = (analysis) => ({
65
64
  workspaceCount: analysis.workspaces.length,
66
65
  workspaces: analysis.workspaces.map(toSummary)
67
66
  });
68
- /** Render the structured result as a markdown transcript. */
69
- const formatWorkspaceInfoMarkdown = (data) => {
70
- const pm = data.packageManager.version ? `${data.packageManager.type}@${data.packageManager.version}` : data.packageManager.type;
71
- const lines = [
72
- `# Workspace: ${data.root}`,
73
- "",
74
- `- runtime: ${data.runtime}`,
75
- `- package manager: ${pm}`,
76
- `- workspaces: ${data.workspaceCount}`,
77
- "",
78
- "| name | version | publishable | versioned | tagged | released |",
79
- "| --- | --- | --- | --- | --- | --- |"
80
- ];
81
- for (const w of data.workspaces) lines.push(`| ${w.name} | ${w.version ?? "—"} | ${w.publishable} | ${w.versioned} | ${w.tagged} | ${w.released} |`);
82
- return lines.join("\n");
83
- };
84
- /** One-way transform: result to markdown. Encoding back is forbidden. */
85
- const WorkspaceInfoAsMarkdown = WorkspaceInfoResult.pipe(Schema.decodeTo(Schema.String, {
86
- decode: SchemaGetter.transform(formatWorkspaceInfoMarkdown),
87
- encode: SchemaGetter.forbidden(() => "WorkspaceInfoAsMarkdown is one-way: markdown cannot be parsed back.")
88
- }));
89
67
  /**
90
68
  * Effect handler: resolve the workspace root by walking up from `base`, analyze
91
69
  * that root, and project to the tool result. Fails with `WorkspaceRootNotFoundError`
@@ -105,12 +83,12 @@ const REMEDIATION = { hint: "The workspace could not be analyzed; check the mani
105
83
  * handler record fails against `Toolkit.HandlersFrom`).
106
84
  */
107
85
  const workspaceInfoTool = Tool.make("workspace_info", {
108
- description: "Use when you need the Silk workspace layout: runtime, package manager, and a per-workspace summary (publishability, versioning, tag/release state). Prefer this over running shell commands to inspect the workspace. Returns markdown in content[] and a typed object in structuredContent.",
86
+ description: "Use when you need the Silk workspace layout: runtime, package manager, and a per-workspace summary (publishability, versioning, tag/release state). Prefer this over running shell commands to inspect the workspace. Returns a typed object in structuredContent (content[] carries the same object as JSON).",
109
87
  parameters: WorkspaceInfoParams,
110
88
  success: WorkspaceInfoResult,
111
89
  failure: McpToolError,
112
90
  dependencies: [SilkWorkspaceAnalyzer, WorkspaceRoot]
113
- }).annotate(Tool.Title, "Workspace info").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.Idempotent, true).annotate(Tool.OpenWorld, false).annotate(SilkMarkdown, Schema.decodeUnknownSync(WorkspaceInfoAsMarkdown));
91
+ }).annotate(Tool.Title, "Workspace info").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.Idempotent, true).annotate(Tool.OpenWorld, false);
114
92
  /** Wire handler: the existing {@link workspaceInfo} program with its error channel mapped onto {@link McpToolError}. */
115
93
  const handleWorkspaceInfo = (fallbackCwd, params) => {
116
94
  const cwd = params.cwd ?? fallbackCwd;
@@ -118,4 +96,4 @@ const handleWorkspaceInfo = (fallbackCwd, params) => {
118
96
  };
119
97
 
120
98
  //#endregion
121
- export { WorkspaceInfoAsMarkdown, WorkspaceInfoParams, WorkspaceInfoResult, WorkspaceSummary, formatWorkspaceInfoMarkdown, handleWorkspaceInfo, toWorkspaceInfoResult, workspaceInfo, workspaceInfoTool };
99
+ export { WorkspaceInfoParams, WorkspaceInfoResult, WorkspaceSummary, handleWorkspaceInfo, toWorkspaceInfoResult, workspaceInfo, workspaceInfoTool };
package/version.js CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * @packageDocumentation
8
8
  */
9
- const CURRENT_MCP_VERSION = "3.2.4";
9
+ const CURRENT_MCP_VERSION = "3.3.0";
10
10
 
11
11
  //#endregion
12
12
  export { CURRENT_MCP_VERSION };
@@ -1,25 +0,0 @@
1
- //#region src/internal/project-root.ts
2
- /**
3
- * Pure resolution of the MCP server's project working directory.
4
- *
5
- * @packageDocumentation
6
- */
7
- /**
8
- * Resolves the project directory the MCP server should root its runtime in.
9
- *
10
- * @remarks
11
- * Precedence: a non-empty, non-placeholder `argv[0]`, then
12
- * `env.SAVVY_MCP_PROJECT_DIR`, then `env.CLAUDE_PROJECT_DIR`, then `cwd()`. A
13
- * `${...}`-shaped `argv[0]` is an unresolved template placeholder (an MCP
14
- * client that failed to substitute a config variable) and is ignored, as is
15
- * a whitespace-only value. Takes `argv`/`env`/`cwd` as parameters rather than
16
- * reading `process` directly so it stays pure and unit-testable; `main.ts` is
17
- * the sole caller and supplies the real process bindings.
18
- */
19
- function resolveProjectDir(argv, env, cwd) {
20
- const trimmed = argv[0]?.trim();
21
- return (trimmed !== void 0 && trimmed.length > 0 && !(trimmed.startsWith("${") && trimmed.endsWith("}")) ? trimmed : void 0) ?? env.SAVVY_MCP_PROJECT_DIR ?? env.CLAUDE_PROJECT_DIR ?? cwd();
22
- }
23
-
24
- //#endregion
25
- export { resolveProjectDir };
package/markdown.js DELETED
@@ -1,33 +0,0 @@
1
- import { Context } from "effect";
2
-
3
- //#region src/markdown.ts
4
- /**
5
- * The tool annotation that carries each tool's markdown transcript renderer
6
- * to the server's registration step.
7
- *
8
- * @remarks
9
- * rc.116's `McpServer.registerToolkit` renders every success as
10
- * `content: [{ type: "text", text: JSON.stringify(encodedResult) }]` plus
11
- * `structuredContent` and offers no hook over the text
12
- * (`unstable/ai/McpServer.ts`, `toolResultContent`; Effect-TS/effect#8316
13
- * proposes one). `server.ts` therefore registers the
14
- * toolkit itself through the public `McpServer.addTool` and reads this
15
- * annotation to put the tool's markdown projection in `content[0].text`
16
- * while `structuredContent` stays the typed object — the dual channel every
17
- * tool description promises. A tool without the annotation falls back to the
18
- * framework's JSON text.
19
- *
20
- * @packageDocumentation
21
- */
22
- /**
23
- * Renders a tool's decoded success value as the markdown transcript an agent
24
- * reads. Typed over `unknown` because the annotation is read generically at
25
- * registration; each tool supplies `Schema.decodeUnknownSync(<X>AsMarkdown)`,
26
- * whose own decoder is the type guard.
27
- *
28
- * @public
29
- */
30
- var SilkMarkdown = class extends Context.Service()("@savvy-web/mcp/SilkMarkdown") {};
31
-
32
- //#endregion
33
- export { SilkMarkdown };
@@ -1,22 +0,0 @@
1
- //#region src/tools/md-inline.ts
2
- /**
3
- * Render a repo-derived value as an inert markdown code span. Backslash
4
- * escaping does NOT work inside code spans (CommonMark treats backslashes
5
- * literally there), so this uses the delimiter-run rule instead: wrap the
6
- * value in a backtick run strictly longer than any backtick run it contains,
7
- * padding with spaces when the value starts or ends with a backtick. This
8
- * keeps vendored-repo content (names, refs, purposes, orientation values,
9
- * commit messages, note text) — the definition of untrusted input — from
10
- * injecting markdown structure into the transcript an agent reads.
11
- *
12
- * Shared by every tool that interpolates untrusted repo content into a
13
- * markdown transcript so the escaping rule has exactly one implementation.
14
- */
15
- const mdInline = (value) => {
16
- const runs = value.match(/`+/g) ?? [];
17
- const delimiter = "`".repeat(Math.max(1, ...runs.map((run) => run.length + 1)));
18
- return `${delimiter}${value.startsWith("`") || value.endsWith("`") ? ` ${value} ` : value}${delimiter}`;
19
- };
20
-
21
- //#endregion
22
- export { mdInline };