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.
- package/dist/adapters/claude-code.js +4 -3
- package/dist/adapters/codex.js +4 -3
- package/dist/adapters/kimi-code.js +4 -3
- package/dist/adapters/kiro.js +5 -4
- package/dist/adapters/pi.js +4 -3
- package/dist/adapters/zcode.d.ts +12 -1
- package/dist/adapters/zcode.js +7 -6
- package/dist/cli.js +38 -5
- package/dist/commands/doctor.d.ts +9 -1
- package/dist/commands/doctor.js +43 -6
- package/dist/commands/init.d.ts +6 -0
- package/dist/commands/init.js +24 -0
- package/dist/commands/mcp.d.ts +44 -1
- package/dist/commands/mcp.js +145 -33
- package/dist/commands/migrate.d.ts +12 -0
- package/dist/commands/migrate.js +8 -4
- package/dist/commands/migrateWorkspace.d.ts +40 -0
- package/dist/commands/migrateWorkspace.js +197 -0
- package/dist/commands/rollback.js +65 -35
- package/dist/commands/skill.d.ts +14 -0
- package/dist/commands/skill.js +71 -0
- package/dist/commands/sync.d.ts +16 -1
- package/dist/commands/sync.js +48 -15
- package/dist/core/adapter.d.ts +14 -0
- package/dist/core/canonical.d.ts +20 -34
- package/dist/core/canonical.js +18 -92
- package/dist/core/mcpServerDef.d.ts +39 -0
- package/dist/core/mcpServerDef.js +93 -0
- package/dist/core/types.d.ts +39 -4
- package/dist/lib/mcpMigrateRead.d.ts +25 -0
- package/dist/lib/mcpMigrateRead.js +9 -2
- package/dist/lib/skillProvider.js +18 -5
- package/dist/lib/workspace.d.ts +39 -0
- package/dist/lib/workspace.js +158 -0
- package/dist/lib/workspaceConfig.d.ts +45 -0
- package/dist/lib/workspaceConfig.js +98 -0
- package/dist/lib/workspaceDoctor.d.ts +44 -0
- package/dist/lib/workspaceDoctor.js +94 -0
- package/dist/lib/workspaceFiles.d.ts +38 -0
- package/dist/lib/workspaceFiles.js +264 -0
- package/dist/lib/workspaceMcp.d.ts +57 -0
- package/dist/lib/workspaceMcp.js +277 -0
- package/dist/lib/workspaceMerge.d.ts +37 -0
- package/dist/lib/workspaceMerge.js +98 -0
- package/dist/lib/workspaceMigrate.d.ts +118 -0
- package/dist/lib/workspaceMigrate.js +423 -0
- package/dist/pi-bridge/bundle.js +258 -96
- package/docs/architecture.md +111 -49
- package/docs/getting-started.md +86 -14
- package/docs/mcp-oauth-matrix.md +12 -1
- package/docs/overview.zh-CN.md +17 -0
- package/docs/research.md +28 -6
- package/docs/roadmap.md +17 -5
- package/package.json +1 -1
- 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;
|