@savvy-web/mcp 0.4.2 → 0.5.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.
package/README.md CHANGED
@@ -44,6 +44,7 @@ npx @modelcontextprotocol/inspector savvy-mcp .
44
44
  - `workspace_info` — returns a flat, structured projection of the workspace analysis: linked and fixed package groups as name arrays plus resolved registry targets. Backed by the same `silk-effects` analyzer the `savvy` CLI uses.
45
45
  - `silk_docs_search` — ranks documents in the corpus against a plain keyword or phrase query and returns hits with a normalized confidence score plus a high/medium/low label. It never returns empty: when nothing matches, it falls back to the priority-ordered top results.
46
46
  - `turbo_inspect` — read-only Turborepo inspection over `turbo --dry`: diagnose a task's per-package cache hits, derive the task graph or list the packages affected by recent changes. It never runs a task. Backed by the same `silk-effects` `Turbo` inspector an agent would otherwise drive by hand.
47
+ - `changeset_inspect` — read-only changeset analysis for release work: `mode=branch` diffs the current branch against its base and attributes every changed file to its owning package, `mode=config` surfaces the resolved `.changeset/config.json` with its release surfaces, version files and ignore list. It never writes a changeset. Backed by the same `silk-effects` changeset services the `savvy` CLI uses.
47
48
 
48
49
  ## Resources
49
50
 
package/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { SilkWorkspaceAnalyzer, Turbo } from "@savvy-web/silk-effects";
1
+ import { Changesets, SilkWorkspaceAnalyzer, Turbo } from "@savvy-web/silk-effects";
2
2
  import { Layer, ManagedRuntime, Schema } from "effect";
3
3
  import { WorkspaceDiscoveryError, WorkspaceRoot } from "workspaces-effect";
4
4
  //#region src/resources/schema.d.ts
@@ -84,7 +84,7 @@ declare class DocIndex {
84
84
  //#region src/context.d.ts
85
85
  /** The long-lived runtime, the project working directory, and the resource layer. */
86
86
  interface McpContext {
87
- readonly runtime: ManagedRuntime.ManagedRuntime<SilkWorkspaceAnalyzer | WorkspaceRoot | Turbo.TurboInspector, WorkspaceDiscoveryError>;
87
+ readonly runtime: ManagedRuntime.ManagedRuntime<SilkWorkspaceAnalyzer | WorkspaceRoot | Turbo.TurboInspector | Changesets.BranchAnalyzer | Changesets.ConfigInspector, WorkspaceDiscoveryError>;
88
88
  readonly cwd: string;
89
89
  readonly docIndex: DocIndex;
90
90
  readonly manifest: Manifest;
@@ -93,16 +93,17 @@ interface McpContext {
93
93
  //#endregion
94
94
  //#region src/runtime.d.ts
95
95
  /**
96
- * The MCP runtime layer. Provides `SilkWorkspaceAnalyzer`, `WorkspaceRoot`, and
97
- * `Turbo.TurboInspector`; requires `CommandExecutor` + `FileSystem` + `Path`
98
- * from the host's platform layer (`NodeContext.layer` in bin.ts).
96
+ * The MCP runtime layer. Provides `SilkWorkspaceAnalyzer`, `WorkspaceRoot`,
97
+ * `Turbo.TurboInspector`, `Changesets.BranchAnalyzer`, and
98
+ * `Changesets.ConfigInspector`; requires `CommandExecutor` + `FileSystem` +
99
+ * `Path` from the host's platform layer (`NodeContext.layer` in bin.ts).
99
100
  *
100
101
  * `TurboInspectorLive` is fed its own `ToolDiscoveryLive`, whose
101
102
  * `PackageManagerDetector` + `WorkspaceRoot` requirements are satisfied by
102
103
  * {@link DepsLive}; the leftover `CommandExecutor` + `FileSystem` flow up to the
103
104
  * host platform layer.
104
105
  */
105
- declare const SilkRuntimeLive: Layer.Layer<import("@savvy-web/silk-effects").SilkWorkspaceAnalyzer | import("workspaces-effect").WorkspaceRoot | Turbo.TurboInspector, import("workspaces-effect").WorkspaceDiscoveryError, import("@effect/platform/FileSystem").FileSystem | import("@effect/platform/Path").Path | import("@effect/platform/CommandExecutor").CommandExecutor>;
106
+ declare const SilkRuntimeLive: Layer.Layer<import("@savvy-web/silk-effects").SilkWorkspaceAnalyzer | import("workspaces-effect").WorkspaceRoot | Turbo.TurboInspector | Changesets.BranchAnalyzer | Changesets.ConfigInspector, import("workspaces-effect").WorkspaceDiscoveryError, import("@effect/platform/FileSystem").FileSystem | import("@effect/platform/Path").Path | import("@effect/platform/CommandExecutor").CommandExecutor>;
106
107
  //#endregion
107
108
  //#region src/server.d.ts
108
109
  /** Build the server and connect it over stdio. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/mcp",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
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",
@@ -23,17 +23,18 @@
23
23
  ".": {
24
24
  "types": "./index.d.ts",
25
25
  "import": "./index.js"
26
- }
26
+ },
27
+ "./package.json": "./package.json"
27
28
  },
28
29
  "bin": {
29
30
  "savvy-mcp": "bin/savvy-mcp.js"
30
31
  },
31
32
  "dependencies": {
32
33
  "@effect/platform": "^0.96.1",
33
- "@effect/platform-node": "^0.106.0",
34
+ "@effect/platform-node": "^0.107.0",
34
35
  "@modelcontextprotocol/sdk": "^1.29.0",
35
- "@savvy-web/silk-effects": "1.0.1",
36
- "effect": "^3.21.2",
36
+ "@savvy-web/silk-effects": "1.1.0",
37
+ "effect": "^3.21.3",
37
38
  "fuse.js": "^7.4.0",
38
39
  "workspaces-effect": "^1.2.0",
39
40
  "zod": "^4.4.3"
package/runtime.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ChangesetConfigReaderLive, SilkWorkspaceAnalyzerLive, TagStrategyLive, ToolDiscoveryLive, Turbo, VersioningStrategyLive } from "@savvy-web/silk-effects";
1
+ import { ChangesetConfigReaderLive, Changesets, SilkWorkspaceAnalyzerLive, TagStrategyLive, ToolDiscoveryLive, Turbo, VersioningStrategyLive } from "@savvy-web/silk-effects";
2
2
  import { Layer } from "effect";
3
3
  import { WorkspaceRootLive, WorkspacesLive } from "workspaces-effect";
4
4
 
@@ -22,17 +22,19 @@ import { WorkspaceRootLive, WorkspacesLive } from "workspaces-effect";
22
22
  * cross-feed sibling layers.
23
23
  */
24
24
  const DepsLive = Layer.mergeAll(WorkspacesLive, ChangesetConfigReaderLive, TagStrategyLive, VersioningStrategyLive.pipe(Layer.provide(ChangesetConfigReaderLive)));
25
+ const InspectorAndAnalyzerLive = Changesets.BranchAnalyzerLive.pipe(Layer.provideMerge(Changesets.ConfigInspectorLive));
25
26
  /**
26
- * The MCP runtime layer. Provides `SilkWorkspaceAnalyzer`, `WorkspaceRoot`, and
27
- * `Turbo.TurboInspector`; requires `CommandExecutor` + `FileSystem` + `Path`
28
- * from the host's platform layer (`NodeContext.layer` in bin.ts).
27
+ * The MCP runtime layer. Provides `SilkWorkspaceAnalyzer`, `WorkspaceRoot`,
28
+ * `Turbo.TurboInspector`, `Changesets.BranchAnalyzer`, and
29
+ * `Changesets.ConfigInspector`; requires `CommandExecutor` + `FileSystem` +
30
+ * `Path` from the host's platform layer (`NodeContext.layer` in bin.ts).
29
31
  *
30
32
  * `TurboInspectorLive` is fed its own `ToolDiscoveryLive`, whose
31
33
  * `PackageManagerDetector` + `WorkspaceRoot` requirements are satisfied by
32
34
  * {@link DepsLive}; the leftover `CommandExecutor` + `FileSystem` flow up to the
33
35
  * host platform layer.
34
36
  */
35
- const SilkRuntimeLive = Layer.mergeAll(SilkWorkspaceAnalyzerLive, WorkspaceRootLive, Turbo.TurboInspectorLive.pipe(Layer.provide(ToolDiscoveryLive))).pipe(Layer.provide(DepsLive));
37
+ const SilkRuntimeLive = Layer.mergeAll(SilkWorkspaceAnalyzerLive, WorkspaceRootLive, Turbo.TurboInspectorLive.pipe(Layer.provide(ToolDiscoveryLive)), InspectorAndAnalyzerLive).pipe(Layer.provide(DepsLive));
36
38
 
37
39
  //#endregion
38
40
  export { SilkRuntimeLive };
package/server.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { registerAllResources } from "./resources/index.js";
2
2
  import { stderrQueryLogger } from "./resources/query-log.js";
3
3
  import { effectToZodSchema } from "./schema/effect-to-zod.js";
4
+ import { BiomeCheckAsMarkdown, BiomeCheckResult, runBiomeCheck } from "./tools/biome-check.js";
5
+ import { ChangesetInspectAsMarkdown, ChangesetInspectResult, changesetInspect } from "./tools/changeset-inspect.js";
4
6
  import { DocsSearchResult, DocsSearchResultAsMarkdown, runDocsSearch } from "./tools/docs-search.js";
5
7
  import { TurboInspectAsMarkdown, TurboInspectResult, turboInspect } from "./tools/turbo-inspect.js";
6
8
  import { WorkspaceInfoAsMarkdown, WorkspaceInfoResult, workspaceInfo } from "./tools/workspace-info.js";
@@ -78,6 +80,33 @@ function buildServer(ctx) {
78
80
  const data = await ctx.runtime.runPromise(turboInspect(args, ctx.cwd));
79
81
  return structuredResult(Schema.decodeSync(TurboInspectAsMarkdown)(data), data);
80
82
  });
83
+ server.registerTool("changeset_inspect", {
84
+ description: "Read-only changeset analysis for the changeset-manager workflow. mode=branch diffs the current branch against its base and classifies every changed file by owning package (with packagesAffected and the unmapped paths to ask the user about). mode=config surfaces the resolved .changeset/config.json (release surfaces, versionFiles, ignore list). Prefer this over shelling out to the savvy CLI.",
85
+ inputSchema: {
86
+ mode: z.enum(["branch", "config"]).describe("Which inspection to run."),
87
+ base: z.optional(z.string()).describe("Override the base branch (branch mode only)."),
88
+ cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
89
+ },
90
+ outputSchema: effectToZodSchema(ChangesetInspectResult),
91
+ annotations: { readOnlyHint: true }
92
+ }, async (args) => {
93
+ const data = await ctx.runtime.runPromise(changesetInspect(args, ctx.cwd));
94
+ return structuredResult(Schema.decodeSync(ChangesetInspectAsMarkdown)(data), data);
95
+ });
96
+ server.registerTool("biome_check", {
97
+ description: "Run Biome over a path and get structured diagnostics back. mode=check (default; lint + format + organize-imports) or mode=lint. Set write=true to apply safe fixes (--write), unsafe=true for unsafe fixes (--write --unsafe). Prefer this over shelling out to biome; the LSP already covers files you've edited. Returns markdown in content[] and a typed object in structuredContent. NOTE: with write/unsafe this tool MUTATES files (git-reversible).",
98
+ inputSchema: {
99
+ paths: z.optional(z.array(z.string())).describe("Paths to check. Defaults to the whole workspace."),
100
+ mode: z.optional(z.enum(["check", "lint"])).describe("check = lint+format+imports (default); lint = lint only."),
101
+ write: z.optional(z.boolean()).describe("Apply safe fixes (--write)."),
102
+ unsafe: z.optional(z.boolean()).describe("Apply unsafe fixes (--write --unsafe); implies write."),
103
+ cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
104
+ },
105
+ outputSchema: effectToZodSchema(BiomeCheckResult)
106
+ }, async (args) => {
107
+ const data = await runBiomeCheck(args, ctx.cwd);
108
+ return structuredResult(Schema.decodeSync(BiomeCheckAsMarkdown)(data), data);
109
+ });
81
110
  registerAllResources(server, {
82
111
  manifest: ctx.manifest,
83
112
  contentRoot: ctx.contentRoot
@@ -0,0 +1,188 @@
1
+ import { Lint } from "@savvy-web/silk-effects";
2
+ import { ParseResult, Schema } from "effect";
3
+ import { realpathSync } from "node:fs";
4
+ import { relative, resolve, sep } from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+
7
+ //#region src/tools/biome-check.ts
8
+ /**
9
+ * The `biome_check` MCP tool: a thin proxy that runs Biome over a path with the
10
+ * gitlab reporter, parses it into a typed result, and can apply fixes. Unlike the
11
+ * other savvy-mcp tools this one MUTATES the working tree when `write`/`unsafe`
12
+ * is set — the first intentional exception to the read-only convention.
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+ /** Normalized diagnostic severity. */
17
+ const BiomeSeverity = Schema.Literal("error", "warning", "info");
18
+ /** A single normalized Biome diagnostic. */
19
+ const BiomeDiagnostic = Schema.Struct({
20
+ file: Schema.String,
21
+ line: Schema.Number,
22
+ severity: BiomeSeverity,
23
+ rule: Schema.String,
24
+ message: Schema.String
25
+ }).annotations({ identifier: "BiomeDiagnostic" });
26
+ /** The `biome_check` tool result. */
27
+ const BiomeCheckResult = Schema.Struct({
28
+ summary: Schema.Struct({
29
+ errors: Schema.Number,
30
+ warnings: Schema.Number
31
+ }),
32
+ diagnostics: Schema.Array(BiomeDiagnostic),
33
+ wrote: Schema.Boolean,
34
+ guidance: Schema.String
35
+ }).annotations({
36
+ identifier: "BiomeCheckResult",
37
+ title: "biome_check result",
38
+ description: "Structured Biome diagnostics, with a flag for whether a --write pass ran."
39
+ });
40
+ /** Guardrail shown to the agent so it fixes code rather than silencing rules. */
41
+ const GUIDANCE = "Fix the actual code. Do NOT disable rules or add config overrides to silence these.";
42
+ /** Shape of a single diagnostic in Biome's `--reporter=gitlab` output. */
43
+ const GitlabDiagnostic = Schema.Struct({
44
+ description: Schema.String,
45
+ check_name: Schema.String,
46
+ severity: Schema.Literal("info", "minor", "major", "critical", "blocker"),
47
+ location: Schema.Struct({
48
+ path: Schema.String,
49
+ lines: Schema.Struct({ begin: Schema.Number })
50
+ })
51
+ });
52
+ const GitlabArray = Schema.Array(GitlabDiagnostic);
53
+ const decodeGitlab = Schema.decodeUnknownSync(GitlabArray);
54
+ /** Map a gitlab severity onto our three-level scale. */
55
+ const mapSeverity = (s) => s === "info" ? "info" : s === "minor" ? "warning" : "error";
56
+ /**
57
+ * Parse Biome `--reporter=gitlab` stdout into normalized diagnostics. Returns []
58
+ * for empty, non-JSON, or shape-mismatched input (never throws).
59
+ */
60
+ const parseBiomeGitlab = (stdout) => {
61
+ const trimmed = stdout.trim();
62
+ if (!trimmed) return [];
63
+ let raw;
64
+ try {
65
+ raw = JSON.parse(trimmed);
66
+ } catch {
67
+ return [];
68
+ }
69
+ let decoded;
70
+ try {
71
+ decoded = decodeGitlab(raw);
72
+ } catch {
73
+ return [];
74
+ }
75
+ return decoded.map((d) => ({
76
+ file: d.location.path,
77
+ line: d.location.lines.begin,
78
+ severity: mapSeverity(d.severity),
79
+ rule: d.check_name,
80
+ message: d.description
81
+ }));
82
+ };
83
+ /** Assemble the structured result from normalized diagnostics + the write flag. */
84
+ const buildBiomeResult = (params) => {
85
+ return {
86
+ summary: {
87
+ errors: params.diagnostics.filter((d) => d.severity === "error").length,
88
+ warnings: params.diagnostics.filter((d) => d.severity === "warning").length
89
+ },
90
+ diagnostics: [...params.diagnostics],
91
+ wrote: params.wrote,
92
+ guidance: GUIDANCE
93
+ };
94
+ };
95
+ /** Render the structured result as markdown. */
96
+ const renderMarkdown = (data) => {
97
+ if (data.diagnostics.length === 0) return `# biome — clean\n\n✅ No remaining diagnostics.${data.wrote ? " A --write pass ran; check `git diff` for what changed." : ""}`;
98
+ const lines = [`# biome — ${data.summary.errors} error(s), ${data.summary.warnings} warning(s)`, ``];
99
+ if (data.wrote) lines.push(`A --write pass ran; the diagnostics below remain unfixed.`, ``);
100
+ for (const d of data.diagnostics) lines.push(`- \`${d.file}:${d.line}\` **${d.severity}** ${d.rule} — ${d.message}`);
101
+ lines.push(``, `---`, data.guidance);
102
+ return lines.join("\n");
103
+ };
104
+ /** One-way transform: result to markdown. Encoding back is forbidden. */
105
+ const BiomeCheckAsMarkdown = Schema.transformOrFail(BiomeCheckResult, Schema.String, {
106
+ strict: true,
107
+ decode: (data) => ParseResult.succeed(renderMarkdown(data)),
108
+ encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "BiomeCheckAsMarkdown is one-way: markdown cannot be parsed back."))
109
+ });
110
+ /**
111
+ * Run Biome and return structured diagnostics. When `write`/`unsafe` is set,
112
+ * runs a fix pass first, then a read-only gitlab pass to report what remains.
113
+ *
114
+ * @remarks Resolves the Biome binary via {@link Lint.Biome.findBiome} (global
115
+ * first, then the project's package manager). Throws if Biome is unavailable or
116
+ * exits with status > 1 (Biome itself failed, vs. status 1 = lint issues found).
117
+ */
118
+ const runBiomeCheck = async (args, fallbackCwd) => {
119
+ const mode = args.mode ?? "check";
120
+ const canonicalize = (p) => {
121
+ try {
122
+ return realpathSync(p);
123
+ } catch {
124
+ return resolve(p);
125
+ }
126
+ };
127
+ const root = canonicalize(fallbackCwd);
128
+ const within = (abs) => abs === root || abs.startsWith(`${root}${sep}`);
129
+ const cwd = canonicalize(args.cwd ?? fallbackCwd);
130
+ if (!within(cwd)) throw new Error(`cwd escapes the workspace root: ${args.cwd}`);
131
+ const paths = (args.paths && args.paths.length > 0 ? args.paths : ["."]).map((p) => {
132
+ const lexical = resolve(cwd, p);
133
+ if (!within(canonicalize(lexical))) throw new Error(`path escapes the workspace root: ${p}`);
134
+ return relative(cwd, lexical) || ".";
135
+ });
136
+ const doWrite = Boolean(args.write || args.unsafe);
137
+ const biomeCmd = Lint.Biome.findBiome();
138
+ if (!biomeCmd) throw new Error("Biome not found. Install @biomejs/biome globally (recommended) or as a devDependency.");
139
+ const parts = biomeCmd.split(" ");
140
+ const bin = parts[0];
141
+ const prefix = parts.slice(1);
142
+ const maxBuffer = 64 * 1024 * 1024;
143
+ const timeout = 12e4;
144
+ const killSignal = "SIGKILL";
145
+ let wrote = false;
146
+ if (doWrite) {
147
+ const fix = spawnSync(bin, [
148
+ ...prefix,
149
+ mode,
150
+ "--write",
151
+ ...args.unsafe ? ["--unsafe"] : [],
152
+ "--no-errors-on-unmatched",
153
+ ...paths
154
+ ], {
155
+ cwd,
156
+ encoding: "utf8",
157
+ maxBuffer,
158
+ timeout,
159
+ killSignal
160
+ });
161
+ if (fix.error) throw fix.error;
162
+ if ((fix.status ?? 0) > 1) throw new Error(`Biome --write failed (exit ${fix.status}): ${(fix.stderr ?? "").trim() || "unknown error"}`);
163
+ wrote = true;
164
+ }
165
+ const read = spawnSync(bin, [
166
+ ...prefix,
167
+ mode,
168
+ "--reporter=gitlab",
169
+ "--error-on-warnings",
170
+ "--no-errors-on-unmatched",
171
+ ...paths
172
+ ], {
173
+ cwd,
174
+ encoding: "utf8",
175
+ maxBuffer,
176
+ timeout,
177
+ killSignal
178
+ });
179
+ if (read.error) throw read.error;
180
+ if ((read.status ?? 0) > 1) throw new Error(`Biome failed (exit ${read.status}): ${(read.stderr ?? "").trim() || "unknown error"}`);
181
+ return buildBiomeResult({
182
+ diagnostics: parseBiomeGitlab(read.stdout ?? ""),
183
+ wrote
184
+ });
185
+ };
186
+
187
+ //#endregion
188
+ export { BiomeCheckAsMarkdown, BiomeCheckResult, runBiomeCheck };
@@ -0,0 +1,108 @@
1
+ import { Changesets } from "@savvy-web/silk-effects";
2
+ import { Effect, ParseResult, Schema } from "effect";
3
+ import { WorkspaceRoot } from "workspaces-effect";
4
+
5
+ //#region src/tools/changeset-inspect.ts
6
+ /**
7
+ * The `changeset_inspect` MCP tool: a discriminated-union result keyed by `mode`
8
+ * (branch | config), each variant embedding the corresponding resolved-output
9
+ * schema from silk-effects' Changesets namespace, plus a one-way markdown
10
+ * transform. Read-only.
11
+ *
12
+ * @packageDocumentation
13
+ */
14
+ /** Branch-analysis variant. */
15
+ const ChangesetBranchResult = Schema.Struct({
16
+ mode: Schema.Literal("branch"),
17
+ result: Changesets.BranchAnalysisSchema
18
+ }).annotations({ identifier: "ChangesetBranchResult" });
19
+ /** Config-inspection variant. */
20
+ const ChangesetConfigResult = Schema.Struct({
21
+ mode: Schema.Literal("config"),
22
+ result: Changesets.InspectedConfigSchema
23
+ }).annotations({ identifier: "ChangesetConfigResult" });
24
+ /** The `changeset_inspect` tool result — a discriminated union keyed by `mode`. */
25
+ const ChangesetInspectResult = Schema.Union(ChangesetBranchResult, ChangesetConfigResult).annotations({
26
+ identifier: "ChangesetInspectResult",
27
+ title: "changeset_inspect result",
28
+ description: "Read-only changeset analysis grouped by mode (branch | config)."
29
+ });
30
+ /**
31
+ * Render a repo/config-derived value as an inert markdown code span. Escapes
32
+ * backticks and backslashes so a crafted filename or package name cannot inject
33
+ * markdown structure into the transcript that an agent reads.
34
+ */
35
+ const mdInline = (value) => `\`${value.replace(/[`\\]/g, "\\$&")}\``;
36
+ /** Render the structured result as a markdown transcript. */
37
+ const renderMarkdown = (data) => {
38
+ switch (data.mode) {
39
+ case "branch": {
40
+ const r = data.result;
41
+ const lines = [
42
+ `# changeset branch analysis — base ${mdInline(r.baseBranch)}`,
43
+ ``,
44
+ `merge base: ${mdInline(r.mergeBaseSha)}`,
45
+ ``,
46
+ `## Packages affected`,
47
+ r.packagesAffected.map((p) => `- ${mdInline(p)}`).join("\n") || "(none)",
48
+ ``,
49
+ `## Files`
50
+ ];
51
+ for (const f of r.files) {
52
+ const owner = f.package ? mdInline(f.package) : "<unmapped>";
53
+ lines.push(`- ${mdInline(f.status)} ${mdInline(f.path)} -> ${owner}`);
54
+ }
55
+ if (r.unmappedFiles.length > 0) {
56
+ lines.push(``, `## Unmapped (ask the user)`);
57
+ for (const p of r.unmappedFiles) lines.push(`- ${mdInline(p)}`);
58
+ }
59
+ return lines.join("\n");
60
+ }
61
+ case "config": {
62
+ const r = data.result;
63
+ const lines = [
64
+ `# changeset config — ${mdInline(r.configPath)}`,
65
+ ``,
66
+ `base branch: ${mdInline(r.baseBranch)}`,
67
+ `access: ${r.access}`,
68
+ `changelog: ${r.changelog ? mdInline(r.changelog) : "(none)"}`,
69
+ `ignored: ${r.ignore.map(mdInline).join(", ") || "(none)"}`,
70
+ ``,
71
+ `## Packages`
72
+ ];
73
+ for (const p of r.packages) {
74
+ lines.push(`### ${mdInline(p.name)} (${mdInline(p.version)})`, `- dir: ${mdInline(p.workspaceDir)}`);
75
+ if (p.additionalScopes.length > 0) lines.push(`- additionalScopes: ${p.additionalScopes.map(mdInline).join(", ")}`);
76
+ if (p.versionFiles.length > 0) lines.push(`- versionFiles: ${p.versionFiles.map((v) => mdInline(v.glob)).join(", ")}`);
77
+ }
78
+ if (r.packages.length === 0) lines.push("(none resolved)");
79
+ return lines.join("\n");
80
+ }
81
+ }
82
+ };
83
+ /** One-way transform: result to markdown. Encoding back is forbidden. */
84
+ const ChangesetInspectAsMarkdown = Schema.transformOrFail(ChangesetInspectResult, Schema.String, {
85
+ strict: true,
86
+ decode: (data) => ParseResult.succeed(renderMarkdown(data)),
87
+ encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "ChangesetInspectAsMarkdown is one-way: markdown cannot be parsed back."))
88
+ });
89
+ /**
90
+ * Effect handler: resolve the workspace root, then dispatch to the matching
91
+ * Changesets service keyed by `mode`. Mirrors `turboInspect`.
92
+ */
93
+ const changesetInspect = (args, fallbackCwd) => Effect.gen(function* () {
94
+ const root = yield* (yield* WorkspaceRoot).find(args.cwd ?? fallbackCwd);
95
+ switch (args.mode) {
96
+ case "branch": return {
97
+ mode: "branch",
98
+ result: yield* (yield* Changesets.BranchAnalyzer).analyzeBranch(root, args.base ? { baseBranch: args.base } : void 0)
99
+ };
100
+ case "config": return {
101
+ mode: "config",
102
+ result: yield* (yield* Changesets.ConfigInspector).inspect(root)
103
+ };
104
+ }
105
+ });
106
+
107
+ //#endregion
108
+ export { ChangesetInspectAsMarkdown, ChangesetInspectResult, changesetInspect };