agent-trellis 0.14.1 → 0.15.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.
Files changed (55) hide show
  1. package/dist/adapters/claude-code.js +4 -3
  2. package/dist/adapters/codex.js +4 -3
  3. package/dist/adapters/kimi-code.js +4 -3
  4. package/dist/adapters/kiro.js +5 -4
  5. package/dist/adapters/pi.js +4 -3
  6. package/dist/adapters/zcode.d.ts +12 -1
  7. package/dist/adapters/zcode.js +7 -6
  8. package/dist/cli.js +38 -5
  9. package/dist/commands/doctor.d.ts +9 -1
  10. package/dist/commands/doctor.js +43 -6
  11. package/dist/commands/init.d.ts +6 -0
  12. package/dist/commands/init.js +24 -0
  13. package/dist/commands/mcp.d.ts +44 -1
  14. package/dist/commands/mcp.js +145 -33
  15. package/dist/commands/migrate.d.ts +12 -0
  16. package/dist/commands/migrate.js +8 -4
  17. package/dist/commands/migrateWorkspace.d.ts +40 -0
  18. package/dist/commands/migrateWorkspace.js +197 -0
  19. package/dist/commands/rollback.js +65 -35
  20. package/dist/commands/skill.d.ts +14 -0
  21. package/dist/commands/skill.js +71 -0
  22. package/dist/commands/sync.d.ts +16 -1
  23. package/dist/commands/sync.js +48 -15
  24. package/dist/core/adapter.d.ts +14 -0
  25. package/dist/core/canonical.d.ts +20 -34
  26. package/dist/core/canonical.js +18 -92
  27. package/dist/core/mcpServerDef.d.ts +39 -0
  28. package/dist/core/mcpServerDef.js +93 -0
  29. package/dist/core/types.d.ts +39 -4
  30. package/dist/lib/mcpMigrateRead.d.ts +25 -0
  31. package/dist/lib/mcpMigrateRead.js +9 -2
  32. package/dist/lib/skillProvider.js +18 -5
  33. package/dist/lib/workspace.d.ts +39 -0
  34. package/dist/lib/workspace.js +158 -0
  35. package/dist/lib/workspaceConfig.d.ts +45 -0
  36. package/dist/lib/workspaceConfig.js +98 -0
  37. package/dist/lib/workspaceDoctor.d.ts +44 -0
  38. package/dist/lib/workspaceDoctor.js +94 -0
  39. package/dist/lib/workspaceFiles.d.ts +38 -0
  40. package/dist/lib/workspaceFiles.js +264 -0
  41. package/dist/lib/workspaceMcp.d.ts +57 -0
  42. package/dist/lib/workspaceMcp.js +277 -0
  43. package/dist/lib/workspaceMerge.d.ts +37 -0
  44. package/dist/lib/workspaceMerge.js +98 -0
  45. package/dist/lib/workspaceMigrate.d.ts +118 -0
  46. package/dist/lib/workspaceMigrate.js +423 -0
  47. package/dist/pi-bridge/bundle.js +258 -96
  48. package/docs/architecture.md +111 -49
  49. package/docs/getting-started.md +86 -14
  50. package/docs/mcp-oauth-matrix.md +12 -1
  51. package/docs/overview.zh-CN.md +17 -0
  52. package/docs/research.md +28 -6
  53. package/docs/roadmap.md +17 -5
  54. package/package.json +1 -1
  55. package/docs/implementation-plan.md +0 -341
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Workspace (project-level) MCP delivery (trellis-workspace-scope design
3
+ * D4/D6/D7): renders the workspace layer's servers into each managed
4
+ * agent's project-level native config, as direct entries only — never
5
+ * through the gateway, hub, or Runtime. Reuses each format's existing
6
+ * machinery (`jsonMcp.ts` for the JSON agents, `tomlSection.ts` for Codex,
7
+ * zcode.ts's nested `mcp.servers` shape).
8
+ *
9
+ * Ownership lives IN the workspace, not the global ledger: `.agents/
10
+ * .trellis-rendered.json` records exactly what Trellis rendered per agent
11
+ * per server. It's committable on purpose (rendered entries carry only
12
+ * `${VAR}` references, never values) — that's what lets a fresh clone
13
+ * re-attach cleanly: the committed manifest proves the committed
14
+ * `.mcp.json` was Trellis-rendered even though this machine's global
15
+ * ledger has never seen it. An entry that exists in the native file but
16
+ * not in the manifest is user-authored and never overwritten.
17
+ *
18
+ * Routing note: the workspace-only `McpConfig` handed to `resolveMcpPlan`
19
+ * carries no `hub`/`gateway`/`routes`/`runtime`, so it always takes the
20
+ * direct branch — where a Trellis-owned OAuth server already conflicts out
21
+ * with "requires gateway or runtime delivery", which is exactly the
22
+ * workspace rule (workspace servers are agent-owned, always).
23
+ */
24
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
25
+ import { dirname, join } from "node:path";
26
+ import { applyJsonMcp, planJsonMcp, renderJsonServerEntry } from "../adapters/jsonMcp.js";
27
+ import { resolveMcpPlan } from "../adapters/mcpPlan.js";
28
+ import { piRenderer } from "../adapters/piMcp.js";
29
+ import { applyZcodeMcp, planZcodeMcp } from "../adapters/zcode.js";
30
+ import { currentServerSectionText, removeSection, renderServerSection, upsertSection } from "./tomlSection.js";
31
+ /** `.agents/.trellis-rendered.json` — the in-repo ownership manifest. */
32
+ export const WORKSPACE_RENDERED_MANIFEST = ".trellis-rendered.json";
33
+ function manifestPath(workspaceRoot) {
34
+ return join(workspaceRoot, ".agents", WORKSPACE_RENDERED_MANIFEST);
35
+ }
36
+ export function loadWorkspaceRendered(workspaceRoot) {
37
+ const path = manifestPath(workspaceRoot);
38
+ if (!existsSync(path))
39
+ return {};
40
+ try {
41
+ return JSON.parse(readFileSync(path, "utf-8"));
42
+ }
43
+ catch {
44
+ return {}; // corrupt manifest — treat as "nothing recorded", never crash a sync
45
+ }
46
+ }
47
+ /** The project-level config file each agent reads. zcode's is selected by
48
+ * presence (existing `zcode.json` beats `.zcode/config.json`), defaulting
49
+ * to creating `zcode.json` at the root. */
50
+ export function workspaceMcpConfigPath(agentId, workspaceRoot) {
51
+ switch (agentId) {
52
+ case "claude-code":
53
+ return join(workspaceRoot, ".mcp.json");
54
+ case "codex":
55
+ return join(workspaceRoot, ".codex", "config.toml");
56
+ case "kiro":
57
+ return join(workspaceRoot, ".kiro", "settings", "mcp.json");
58
+ case "pi":
59
+ return join(workspaceRoot, ".pi", "mcp.json");
60
+ case "kimi-code":
61
+ return join(workspaceRoot, ".kimi-code", "mcp.json");
62
+ case "zcode": {
63
+ const flat = join(workspaceRoot, "zcode.json");
64
+ if (existsSync(flat))
65
+ return flat;
66
+ const nested = join(workspaceRoot, ".zcode", "config.json");
67
+ if (existsSync(nested))
68
+ return nested;
69
+ return flat;
70
+ }
71
+ }
72
+ }
73
+ /** The workspace-only view of canonical MCP: servers whose provenance is
74
+ * the workspace layer, no routing config — direct delivery, always. */
75
+ function workspaceMcpConfig(canonical) {
76
+ const provenance = canonical.workspace?.mcpProvenance ?? {};
77
+ const servers = Object.fromEntries(Object.entries(canonical.mcp.servers).filter(([name]) => provenance[name] === "workspace"));
78
+ return { servers, knownHostInjected: canonical.mcp.knownHostInjected };
79
+ }
80
+ /** zcode is the one agent whose USER-level config wins on same-name
81
+ * conflicts (research, 2026-10-11), so workspace-wins silently misfires
82
+ * there — surface it instead of implying the override worked. */
83
+ export function workspacePrecedenceWarnings(agentId, canonical) {
84
+ if (agentId !== "zcode" || !canonical.workspace)
85
+ return [];
86
+ return canonical.workspace.shadowed
87
+ .filter((entry) => entry.startsWith("mcp:"))
88
+ .map((entry) => entry.slice(4))
89
+ .filter((name) => canonical.workspace?.mcpProvenance[name] === "workspace")
90
+ .map((name) => `zcode: workspace server "${name}" shadows a global entry, but zcode resolves same-name conflicts user-level-first — the global entry is what zcode will actually use`);
91
+ }
92
+ /**
93
+ * The unmarked-overwrite guard every workspace planner applies: an entry
94
+ * that exists in the agent's file but not in the in-repo manifest is
95
+ * user-authored — report it, never overwrite (the workspace equivalent of
96
+ * symlinkPlan's foreign-content refusal).
97
+ */
98
+ function guardUnmarkedOverwrite(items, existingName) {
99
+ return items.map((item) => {
100
+ if (item.action !== "create" || !item.mcpWrite)
101
+ return item;
102
+ if (!existingName(item))
103
+ return item;
104
+ return {
105
+ action: "conflict",
106
+ kind: "mcp",
107
+ layer: "workspace",
108
+ target: item.target,
109
+ description: `${item.target} already defines "${item.mcpWrite.name}" and Trellis has no record of writing it — left untouched`,
110
+ remediation: `run \`trellis migrate --workspace\` to absorb the existing entry into .agents/, or remove it from the file and re-run sync`,
111
+ };
112
+ });
113
+ }
114
+ /**
115
+ * The rendered form `applyWorkspaceMcp` records in the manifest for one
116
+ * agent — exported so `migrate --workspace` can pre-seed ownership for an
117
+ * entry it is adopting out of a user-authored project file: the cascade
118
+ * render then owns the entry (updating it to Trellis's canonical render)
119
+ * instead of conflict-guarding on it as user-authored content
120
+ * (trellis-workspace-scope task 4.6).
121
+ */
122
+ export function renderWorkspaceManifestEntry(agentId, name, def) {
123
+ if (agentId === "codex")
124
+ return renderServerSection(name, def);
125
+ if (agentId === "pi")
126
+ return piRenderer(def, name);
127
+ return renderJsonServerEntry(def);
128
+ }
129
+ /** Records one ownership entry in `.agents/.trellis-rendered.json`
130
+ * through the run's backup session — migrate --workspace's adopt step.
131
+ * Delivery itself keeps writing the manifest wholesale in
132
+ * `applyWorkspaceMcp`; this exists only for adoption, where no delivery
133
+ * write to that agent's file has happened yet. */
134
+ export function recordWorkspaceRendered(workspaceRoot, agentId, name, rendered, backup) {
135
+ const manifest = loadWorkspaceRendered(workspaceRoot);
136
+ const agentEntries = { ...(manifest.mcp?.[agentId] ?? {}) };
137
+ agentEntries[name] = rendered;
138
+ const next = { ...manifest, mcp: { ...(manifest.mcp ?? {}), [agentId]: agentEntries } };
139
+ const path = manifestPath(workspaceRoot);
140
+ mkdirSync(dirname(path), { recursive: true });
141
+ backup.writeFile(path, `${JSON.stringify(next, null, 2)}\n`);
142
+ }
143
+ export function planWorkspaceMcp(agentId, canonical) {
144
+ const workspace = canonical.workspace;
145
+ if (!workspace)
146
+ return [];
147
+ const configPath = workspaceMcpConfigPath(agentId, workspace.root);
148
+ const mcp = workspaceMcpConfig(canonical);
149
+ const manifest = loadWorkspaceRendered(workspace.root);
150
+ const ownership = manifest.mcp?.[agentId] ?? {};
151
+ const layer = (item) => ({ ...item, layer: "workspace" });
152
+ if (agentId === "codex") {
153
+ const content = existsSync(configPath) ? readFileSync(configPath, "utf-8") : "";
154
+ const { desired, conflicts } = resolveMcpPlan(agentId, mcp, canonical.managedAgents, canonical.secretsPolicy);
155
+ const items = [];
156
+ for (const { name, def } of desired) {
157
+ const current = currentServerSectionText(content, name);
158
+ const rendered = renderServerSection(name, def);
159
+ if (current === rendered)
160
+ continue;
161
+ items.push({
162
+ action: "create",
163
+ kind: "mcp",
164
+ layer: "workspace",
165
+ target: configPath,
166
+ mcpWrite: { name, def },
167
+ description: `MCP server "${name}" ${current === null ? "created" : "updated"} in ${configPath} (workspace)`,
168
+ });
169
+ }
170
+ for (const conflict of conflicts) {
171
+ items.push({ action: "conflict", kind: "mcp", layer: "workspace", target: configPath, description: conflict.message, ...(conflict.remediation ? { remediation: conflict.remediation } : {}) });
172
+ }
173
+ const desiredNames = new Set(desired.map((d) => d.name));
174
+ for (const [name, expected] of Object.entries(ownership)) {
175
+ if (desiredNames.has(name))
176
+ continue;
177
+ const current = currentServerSectionText(content, name);
178
+ if (current === null || current !== expected)
179
+ continue;
180
+ items.push({ action: "remove", kind: "mcp", layer: "workspace", target: configPath, mcpRemove: { name }, description: `MCP server "${name}" removed from ${configPath} — no longer in workspace canonical` });
181
+ }
182
+ return guardUnmarkedOverwrite(items, (item) => currentServerSectionText(content, item.mcpWrite.name) !== null && ownership[item.mcpWrite.name] === undefined);
183
+ }
184
+ if (agentId === "zcode") {
185
+ const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
186
+ const items = planZcodeMcp({ configPath, parsed, mcp, managedAgents: canonical.managedAgents, policy: canonical.secretsPolicy, ownership }).map(layer);
187
+ const servers = (parsed?.mcp?.servers ?? {});
188
+ return guardUnmarkedOverwrite(items, (item) => servers[item.mcpWrite.name] !== undefined && ownership[item.mcpWrite.name] === undefined);
189
+ }
190
+ const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
191
+ const render = agentId === "pi" ? piRenderer : undefined;
192
+ const planned = planJsonMcp({
193
+ configPath,
194
+ parsed,
195
+ mcp,
196
+ agentId,
197
+ managedAgents: canonical.managedAgents,
198
+ policy: canonical.secretsPolicy,
199
+ ownership,
200
+ ...(render ? { render } : {}),
201
+ }).map(layer);
202
+ const existing = (parsed?.mcpServers ?? {});
203
+ const guarded = guardUnmarkedOverwrite(planned, (item) => existing[item.mcpWrite.name] !== undefined && ownership[item.mcpWrite.name] === undefined);
204
+ if (agentId !== "pi")
205
+ return guarded;
206
+ // pi speaks stdio and streamable HTTP only — an sse server gets an
207
+ // honest conflict instead of an entry pi could never connect (same
208
+ // mapping as the global path in adapters/pi.ts).
209
+ return guarded.map((item) => {
210
+ const write = item.mcpWrite;
211
+ if (write?.def.transport !== "sse")
212
+ return item;
213
+ return {
214
+ action: "conflict",
215
+ kind: "mcp",
216
+ layer: "workspace",
217
+ target: item.target,
218
+ description: `refusing to write MCP server "${write.name}" for pi: sse is not a pi transport`,
219
+ remediation: `use transport "http" for streamable HTTP, or keep this server global-only where gateway mode can terminate sse`,
220
+ };
221
+ });
222
+ }
223
+ export function applyWorkspaceMcp(agentId, canonical, items, backup) {
224
+ const workspace = canonical.workspace;
225
+ if (!workspace)
226
+ return;
227
+ const writes = items.filter((item) => item.kind === "mcp" && item.layer === "workspace" && (item.action === "create" || item.action === "remove"));
228
+ if (writes.length === 0)
229
+ return;
230
+ const configPath = workspaceMcpConfigPath(agentId, workspace.root);
231
+ let manifest = loadWorkspaceRendered(workspace.root);
232
+ const agentEntries = { ...(manifest.mcp?.[agentId] ?? {}) };
233
+ if (agentId === "codex") {
234
+ let content = existsSync(configPath) ? readFileSync(configPath, "utf-8") : "";
235
+ for (const item of writes) {
236
+ if (item.action === "create" && item.mcpWrite) {
237
+ content = upsertSection(content, item.mcpWrite.name, item.mcpWrite.def);
238
+ agentEntries[item.mcpWrite.name] = renderServerSection(item.mcpWrite.name, item.mcpWrite.def);
239
+ }
240
+ else if (item.action === "remove" && item.mcpRemove) {
241
+ content = removeSection(content, item.mcpRemove.name);
242
+ delete agentEntries[item.mcpRemove.name];
243
+ }
244
+ }
245
+ mkdirSync(dirname(configPath), { recursive: true });
246
+ backup.writeFile(configPath, content);
247
+ }
248
+ else if (agentId === "zcode") {
249
+ const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
250
+ const merged = applyZcodeMcp(parsed, writes);
251
+ mkdirSync(dirname(configPath), { recursive: true });
252
+ backup.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`);
253
+ for (const item of writes) {
254
+ if (item.action === "create" && item.mcpWrite)
255
+ agentEntries[item.mcpWrite.name] = renderJsonServerEntry(item.mcpWrite.def);
256
+ if (item.action === "remove" && item.mcpRemove)
257
+ delete agentEntries[item.mcpRemove.name];
258
+ }
259
+ }
260
+ else {
261
+ const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
262
+ const render = agentId === "pi" ? piRenderer : (def, _name) => renderJsonServerEntry(def);
263
+ const merged = applyJsonMcp(parsed, writes, render);
264
+ mkdirSync(dirname(configPath), { recursive: true });
265
+ backup.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`);
266
+ for (const item of writes) {
267
+ if (item.action === "create" && item.mcpWrite)
268
+ agentEntries[item.mcpWrite.name] = render(item.mcpWrite.def, item.mcpWrite.name);
269
+ if (item.action === "remove" && item.mcpRemove)
270
+ delete agentEntries[item.mcpRemove.name];
271
+ }
272
+ }
273
+ manifest = { ...manifest, mcp: { ...(manifest.mcp ?? {}), [agentId]: agentEntries } };
274
+ const path = manifestPath(workspace.root);
275
+ mkdirSync(dirname(path), { recursive: true });
276
+ backup.writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`);
277
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Merges a workspace `.agents/` layer over the global `CanonicalSource`
3
+ * (trellis-workspace-scope design D3): workspace wins by ID for MCP
4
+ * servers and skills, `enabled: false` tombstones disable a same-ID global
5
+ * entry, instructions are NOT content-merged (each layer is delivered to
6
+ * its own level's paths; agents layer them natively). The result carries
7
+ * per-entry provenance plus the shadowed/tombstoned list for doctor and
8
+ * status attribution.
9
+ */
10
+ import type { CanonicalSource, McpConfig, SkillRef } from "../core/types.js";
11
+ import type { WorkspaceLayer } from "./workspace.js";
12
+ /**
13
+ * The global layer's view of MCP config: exactly the servers the global
14
+ * (`~/.trellis`) layer declares, with their global definitions. Global
15
+ * (user-level) delivery MUST render from this view, never from the merged
16
+ * `canonical.mcp` — otherwise workspace servers leak into user-level files
17
+ * (the bug the workspace e2e caught: repo `.agents/` servers written into
18
+ * `~/.codex/config.toml`, `~/.pi/agent/mcp.json`, `~/.claude.json`).
19
+ * Tombstoned servers stay: a tombstone only removes the server from this
20
+ * workspace's effective view, and the shared user-level files serve every
21
+ * other context. Without a workspace layer this is `source.mcp` unchanged.
22
+ */
23
+ export declare function globalLayerMcp(source: CanonicalSource): McpConfig;
24
+ /**
25
+ * The global layer's view of skills: exactly the skills the global
26
+ * (`~/.trellis`) layer declares, with their global definitions. Global
27
+ * (user-level) skill delivery MUST symlink from this view, never from the
28
+ * merged `canonical.skills` — otherwise workspace skills leak into
29
+ * user-level dirs as symlinks pointing into one repo's `.agents/skills/`,
30
+ * which breaks the moment the repo is cloned elsewhere (the clone's
31
+ * `sync --check` then conflicts on the stale cross-repo link — found by
32
+ * the closed-loop e2e). Entries the workspace shadows keep their global
33
+ * definitions here, mirroring `globalLayerMcp`. Without a workspace layer
34
+ * this is `source.skills` unchanged.
35
+ */
36
+ export declare function globalLayerSkills(source: CanonicalSource): SkillRef[];
37
+ export declare function mergeWorkspaceLayer(base: CanonicalSource, layer: WorkspaceLayer): CanonicalSource;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Merges a workspace `.agents/` layer over the global `CanonicalSource`
3
+ * (trellis-workspace-scope design D3): workspace wins by ID for MCP
4
+ * servers and skills, `enabled: false` tombstones disable a same-ID global
5
+ * entry, instructions are NOT content-merged (each layer is delivered to
6
+ * its own level's paths; agents layer them natively). The result carries
7
+ * per-entry provenance plus the shadowed/tombstoned list for doctor and
8
+ * status attribution.
9
+ */
10
+ /**
11
+ * The global layer's view of MCP config: exactly the servers the global
12
+ * (`~/.trellis`) layer declares, with their global definitions. Global
13
+ * (user-level) delivery MUST render from this view, never from the merged
14
+ * `canonical.mcp` — otherwise workspace servers leak into user-level files
15
+ * (the bug the workspace e2e caught: repo `.agents/` servers written into
16
+ * `~/.codex/config.toml`, `~/.pi/agent/mcp.json`, `~/.claude.json`).
17
+ * Tombstoned servers stay: a tombstone only removes the server from this
18
+ * workspace's effective view, and the shared user-level files serve every
19
+ * other context. Without a workspace layer this is `source.mcp` unchanged.
20
+ */
21
+ export function globalLayerMcp(source) {
22
+ const info = source.workspace;
23
+ if (!info)
24
+ return source.mcp;
25
+ return { ...source.mcp, servers: info.mcpGlobalServers };
26
+ }
27
+ /**
28
+ * The global layer's view of skills: exactly the skills the global
29
+ * (`~/.trellis`) layer declares, with their global definitions. Global
30
+ * (user-level) skill delivery MUST symlink from this view, never from the
31
+ * merged `canonical.skills` — otherwise workspace skills leak into
32
+ * user-level dirs as symlinks pointing into one repo's `.agents/skills/`,
33
+ * which breaks the moment the repo is cloned elsewhere (the clone's
34
+ * `sync --check` then conflicts on the stale cross-repo link — found by
35
+ * the closed-loop e2e). Entries the workspace shadows keep their global
36
+ * definitions here, mirroring `globalLayerMcp`. Without a workspace layer
37
+ * this is `source.skills` unchanged.
38
+ */
39
+ export function globalLayerSkills(source) {
40
+ return source.workspace?.globalSkills ?? source.skills;
41
+ }
42
+ export function mergeWorkspaceLayer(base, layer) {
43
+ const shadowed = [];
44
+ // Captured before any mutation: the global delivery path renders from
45
+ // this map (see globalLayerMcp), so workspace entries never reach
46
+ // user-level config files.
47
+ const mcpGlobalServers = { ...base.mcp.servers };
48
+ const servers = { ...base.mcp.servers };
49
+ const mcpProvenance = Object.fromEntries(Object.keys(base.mcp.servers).map((name) => [name, "global"]));
50
+ for (const name of layer.tombstones) {
51
+ if (servers[name] !== undefined) {
52
+ delete servers[name];
53
+ delete mcpProvenance[name];
54
+ shadowed.push(`mcp:${name}`);
55
+ }
56
+ else {
57
+ layer.diagnostics.push(`.agents/config.json: tombstone "${name}" matches no global server — no-op`);
58
+ }
59
+ }
60
+ for (const [name, def] of Object.entries(layer.servers)) {
61
+ if (servers[name] !== undefined)
62
+ shadowed.push(`mcp:${name}`);
63
+ servers[name] = def;
64
+ mcpProvenance[name] = "workspace";
65
+ }
66
+ const skills = [...base.skills];
67
+ // Captured before any mutation: the global delivery path symlinks from
68
+ // this list (see globalLayerSkills), so workspace skills never reach
69
+ // user-level skill dirs.
70
+ const globalSkills = [...base.skills];
71
+ const skillProvenance = Object.fromEntries(base.skills.map((s) => [s.name, "global"]));
72
+ for (const wsSkill of layer.skills) {
73
+ const idx = skills.findIndex((s) => s.name === wsSkill.name);
74
+ if (idx >= 0) {
75
+ shadowed.push(`skill:${wsSkill.name}`);
76
+ skills[idx] = wsSkill;
77
+ }
78
+ else {
79
+ skills.push(wsSkill);
80
+ }
81
+ skillProvenance[wsSkill.name] = "workspace";
82
+ }
83
+ return {
84
+ ...base,
85
+ skills,
86
+ mcp: { ...base.mcp, servers },
87
+ diagnostics: [...base.diagnostics, ...layer.diagnostics],
88
+ workspace: {
89
+ root: layer.root,
90
+ ...(layer.instructionsFile !== undefined ? { instructionsFile: layer.instructionsFile } : {}),
91
+ mcpProvenance,
92
+ mcpGlobalServers,
93
+ skillProvenance,
94
+ globalSkills,
95
+ shadowed,
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `trellis migrate --workspace`'s scanner + planner (trellis-workspace-
3
+ * scope tasks 4.1–4.5): inventories every managed agent's project-level
4
+ * MCP config plus the root `AGENTS.md`, classifies each finding, and
5
+ * produces a pure plan — no writes anywhere, so `--dry-run` prints the
6
+ * byte-identical plan a real run would execute.
7
+ *
8
+ * Classification (one `action` per item):
9
+ * - `already-managed` — the in-repo manifest (`.agents/.trellis-
10
+ * rendered.json`) records Trellis having rendered
11
+ * this entry, or `.agents/` already holds it
12
+ * - `skip-local` — a `*.local.*` sibling of a scanned config path;
13
+ * listed, never read (task 4.5)
14
+ * - `skip-unsupported` — malformed entry (e.g. stdio without command)
15
+ * - `extract-secret` — a reject-pattern literal in `staticEnv`: value
16
+ * goes to the global secret store, the committed
17
+ * `.agents/config.json` only ever gets `${VAR}`
18
+ * - `identical-to-global` — deep-equal to the global canonical entry;
19
+ * default drop, `--dedup override` imports it as
20
+ * an intentional shadow (task 4.4)
21
+ * - `conflict` — same name with differing defs across agents
22
+ * (task 4.3, resolved only by `--choose
23
+ * name=agentId`), or a reject-pattern literal in
24
+ * a field other than `staticEnv` (unlike the
25
+ * global migrate, `.agents/config.json` is
26
+ * COMMITTED — a plaintext literal must never be
27
+ * written into it)
28
+ * - `create` — will import into `.agents/`
29
+ *
30
+ * Secret handling invariant (task 4.2): no literal secret value ever rides
31
+ * on a plan item — extraction re-reads the source file at apply time,
32
+ * exactly like the global migrate's design.md D9.
33
+ */
34
+ import type { AgentId, McpServerDef, SecretsPolicy } from "../core/types.js";
35
+ import type { McpMigrateReadResult } from "./mcpMigrateRead.js";
36
+ export type WorkspaceMigrateAction = "create" | "already-managed" | "skip-local" | "skip-unsupported" | "extract-secret" | "identical-to-global" | "conflict";
37
+ export interface WorkspaceMigrateItem {
38
+ kind: "mcp" | "instructions";
39
+ name: string;
40
+ action: WorkspaceMigrateAction;
41
+ detail: string;
42
+ /** Only set (and only meaningful) on `conflict`: the concrete next
43
+ * action, matching the global migrate's convention. */
44
+ remediation?: string;
45
+ /** Agents whose project config this name was found in. */
46
+ sources: AgentId[];
47
+ /** Set from `--choose name=agentId` — which agent's def won a cross-
48
+ * agent conflict. */
49
+ chosenSource?: AgentId;
50
+ /** Side-by-side defs for a cross-agent conflict, keyed by agent. */
51
+ defs?: Partial<Record<AgentId, McpServerDef>>;
52
+ /** The def to import. On `extract-secret` this is already the SAFE
53
+ * form — the flagged staticEnv value replaced by a name-only `env`
54
+ * reference; the literal never appears on this object. */
55
+ mcpDef?: McpServerDef;
56
+ /** Instructions body to import (root AGENTS.md content). */
57
+ sourceContent?: string;
58
+ /** `extract-secret` only — see the global migrate's D9/D11: the
59
+ * synthesized variable name, the resolved secret-store path, the
60
+ * original staticEnv dict key, and which agent's file to re-read the
61
+ * real value from at apply time. Never the value itself. */
62
+ extractVarName?: string;
63
+ extractTargetPath?: string;
64
+ extractSourceKey?: string;
65
+ extractSourceAgent?: AgentId;
66
+ /** Task 4.2: the source file holding the literal is git-tracked, so the
67
+ * value must be considered compromised — rotate it. */
68
+ rotationAdvisory?: string;
69
+ /** Task 4.4: how an `identical-to-global` item was (or would be)
70
+ * handled — `drop` (default) or `override` (`--dedup override`). */
71
+ dedupChoice?: "drop" | "override";
72
+ }
73
+ export interface WorkspaceMigratePlan {
74
+ workspaceRoot: string;
75
+ items: WorkspaceMigrateItem[];
76
+ }
77
+ export interface WorkspaceMigrateScanEntry {
78
+ agent: AgentId;
79
+ name: string;
80
+ def: McpServerDef;
81
+ sourcePath: string;
82
+ }
83
+ export interface WorkspaceMigrateScan {
84
+ entries: WorkspaceMigrateScanEntry[];
85
+ unsupported: {
86
+ agent: AgentId;
87
+ name: string;
88
+ reason: string;
89
+ sourcePath: string;
90
+ }[];
91
+ /** `*.local.*` siblings found next to scanned config paths — paths
92
+ * only, contents never read. */
93
+ localFiles: string[];
94
+ }
95
+ /**
96
+ * Apply-time re-read of one agent's project MCP defs (task 4.2, mirroring
97
+ * the global migrate's D9): an extraction's real value is recovered from
98
+ * the source file HERE, at apply time — it never rides on the plan object
99
+ * a `--json`/`--dry-run` caller might log or serialize.
100
+ */
101
+ export declare function readWorkspaceAgentMcpDefsForApply(workspaceRoot: string, agentId: AgentId): McpMigrateReadResult;
102
+ /**
103
+ * Inventories all six agents' project-level MCP config paths (the same
104
+ * paths workspace delivery writes, so migration and delivery can never
105
+ * disagree about where an agent reads) plus their `*.local.*` siblings.
106
+ * Pure: reads files, writes nothing.
107
+ */
108
+ export declare function scanWorkspaceMcp(workspaceRoot: string): WorkspaceMigrateScan;
109
+ export interface CollectWorkspaceMigrateOptions {
110
+ workspaceRoot: string;
111
+ homeDir: string;
112
+ secretsPolicy: SecretsPolicy;
113
+ /** `--choose name=agentId` resolutions for cross-agent conflicts. */
114
+ choose?: Record<string, AgentId>;
115
+ /** `--dedup` handling for `identical-to-global` items; default `drop`. */
116
+ dedup?: "drop" | "override";
117
+ }
118
+ export declare function collectWorkspaceMigratePlan(opts: CollectWorkspaceMigrateOptions): WorkspaceMigratePlan;