agent-trellis 0.14.0 → 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 (57) 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/piMcp.d.ts +6 -3
  7. package/dist/adapters/piMcp.js +10 -3
  8. package/dist/adapters/zcode.d.ts +12 -1
  9. package/dist/adapters/zcode.js +7 -6
  10. package/dist/cli.js +38 -5
  11. package/dist/commands/doctor.d.ts +9 -1
  12. package/dist/commands/doctor.js +43 -6
  13. package/dist/commands/init.d.ts +6 -0
  14. package/dist/commands/init.js +24 -0
  15. package/dist/commands/mcp.d.ts +44 -1
  16. package/dist/commands/mcp.js +145 -33
  17. package/dist/commands/migrate.d.ts +12 -0
  18. package/dist/commands/migrate.js +8 -4
  19. package/dist/commands/migrateWorkspace.d.ts +40 -0
  20. package/dist/commands/migrateWorkspace.js +197 -0
  21. package/dist/commands/rollback.js +65 -35
  22. package/dist/commands/skill.d.ts +14 -0
  23. package/dist/commands/skill.js +71 -0
  24. package/dist/commands/sync.d.ts +16 -1
  25. package/dist/commands/sync.js +48 -15
  26. package/dist/core/adapter.d.ts +14 -0
  27. package/dist/core/canonical.d.ts +20 -32
  28. package/dist/core/canonical.js +18 -86
  29. package/dist/core/mcpServerDef.d.ts +39 -0
  30. package/dist/core/mcpServerDef.js +93 -0
  31. package/dist/core/types.d.ts +43 -4
  32. package/dist/lib/mcpMigrateRead.d.ts +25 -0
  33. package/dist/lib/mcpMigrateRead.js +9 -2
  34. package/dist/lib/skillProvider.js +18 -5
  35. package/dist/lib/workspace.d.ts +39 -0
  36. package/dist/lib/workspace.js +158 -0
  37. package/dist/lib/workspaceConfig.d.ts +45 -0
  38. package/dist/lib/workspaceConfig.js +98 -0
  39. package/dist/lib/workspaceDoctor.d.ts +44 -0
  40. package/dist/lib/workspaceDoctor.js +94 -0
  41. package/dist/lib/workspaceFiles.d.ts +38 -0
  42. package/dist/lib/workspaceFiles.js +264 -0
  43. package/dist/lib/workspaceMcp.d.ts +57 -0
  44. package/dist/lib/workspaceMcp.js +277 -0
  45. package/dist/lib/workspaceMerge.d.ts +37 -0
  46. package/dist/lib/workspaceMerge.js +98 -0
  47. package/dist/lib/workspaceMigrate.d.ts +118 -0
  48. package/dist/lib/workspaceMigrate.js +423 -0
  49. package/dist/pi-bridge/bundle.js +262 -96
  50. package/docs/architecture.md +111 -49
  51. package/docs/getting-started.md +102 -14
  52. package/docs/mcp-oauth-matrix.md +12 -1
  53. package/docs/overview.zh-CN.md +17 -0
  54. package/docs/research.md +28 -6
  55. package/docs/roadmap.md +17 -5
  56. package/package.json +1 -1
  57. package/docs/implementation-plan.md +0 -341
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Workspace (project-level) skills & instructions delivery
3
+ * (trellis-workspace-scope design D4/D5). Unlike global delivery (symlinks
4
+ * into `~/.trellis/`), workspace skills are COPIED into the agent's
5
+ * project-level directory — rendered artifacts must be committable to the
6
+ * repo and usable on filesystems without symlink support.
7
+ *
8
+ * A copy has no symlink-realpath ownership proof, so ownership mirrors the
9
+ * MCP ledger instead: one `.trellis-workspace.json` per destination root
10
+ * maps skill name → { source, sourceDigest, destDigest }. A delivered
11
+ * directory is "ours" only while its current digest still equals the
12
+ * recorded `destDigest`; a hand-edited copy is a conflict, never touched —
13
+ * the same posture mcpOwnership gives bare JSON/TOML keys.
14
+ *
15
+ * Instructions are delivered as marker-headed file copies: root `AGENTS.md`
16
+ * (read natively by Codex, pi, and Kimi Code) and Kiro's
17
+ * `.kiro/steering/trellis-workspace.md`. Claude Code reads `CLAUDE.md`, not
18
+ * `AGENTS.md`, and a repo's `CLAUDE.md` is almost always user-authored —
19
+ * v1 deliberately writes no Claude instructions file (documented
20
+ * limitation; `migrate --workspace` is the path for absorbing one).
21
+ */
22
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
23
+ import { dirname, join } from "node:path";
24
+ import { isInScope } from "../core/adapter.js";
25
+ import { directoryDigest } from "./dirDigest.js";
26
+ const COPY_LEDGER_NAME = ".trellis-workspace.json";
27
+ /** Exported for `migrate --workspace` (trellis-workspace-scope task 4.x):
28
+ * absorbing a root `AGENTS.md` rewrites it marker-headed in place, which
29
+ * must be byte-identical to what a later sync would render. */
30
+ export const INSTRUCTIONS_MARKER = "<!-- generated by trellis from .agents/AGENTS.md — do not edit; run `trellis sync` to refresh -->";
31
+ /** Agents that read `.agents/skills/` natively get no copy at all — the
32
+ * canonical directory is itself the deliverable. zcode's Runtime-only
33
+ * delivery owns its skills through the Runtime provider (task 2.2). */
34
+ const COPY_TARGETS = {
35
+ "claude-code": join(".claude", "skills"),
36
+ kiro: join(".kiro", "skills"),
37
+ };
38
+ function instructionsTargets(agentId) {
39
+ switch (agentId) {
40
+ case "codex":
41
+ case "pi":
42
+ case "kimi-code":
43
+ return [{ relPath: "AGENTS.md", forAgents: "Codex/pi/Kimi Code" }];
44
+ case "kiro":
45
+ return [{ relPath: join(".kiro", "steering", "trellis-workspace.md"), forAgents: "Kiro" }];
46
+ default:
47
+ return [];
48
+ }
49
+ }
50
+ function loadCopyLedger(path) {
51
+ if (!existsSync(path))
52
+ return {};
53
+ try {
54
+ return JSON.parse(readFileSync(path, "utf-8"));
55
+ }
56
+ catch {
57
+ return {}; // corrupt ledger — treat as "nothing recorded", never crash a sync
58
+ }
59
+ }
60
+ function planWorkspaceSkillCopies(agentId, canonical) {
61
+ const workspace = canonical.workspace;
62
+ const relRoot = COPY_TARGETS[agentId];
63
+ if (!workspace || !relRoot)
64
+ return [];
65
+ const destRoot = join(workspace.root, relRoot);
66
+ const ledgerPath = join(destRoot, COPY_LEDGER_NAME);
67
+ const ledger = loadCopyLedger(ledgerPath);
68
+ const items = [];
69
+ const desired = canonical.skills.filter((skill) => workspace.skillProvenance[skill.name] === "workspace" && isInScope(agentId, skill.scope, canonical.managedAgents));
70
+ const desiredNames = new Set(desired.map((s) => s.name));
71
+ for (const skill of desired) {
72
+ const dest = join(destRoot, skill.name);
73
+ if (!existsSync(dest)) {
74
+ items.push({
75
+ action: "create",
76
+ kind: "skill",
77
+ layer: "workspace",
78
+ target: dest,
79
+ copyFrom: skill.dir,
80
+ description: `workspace skill "${skill.name}" copied to ${dest}`,
81
+ });
82
+ continue;
83
+ }
84
+ const recorded = ledger[skill.name];
85
+ if (!recorded) {
86
+ items.push({
87
+ action: "conflict",
88
+ kind: "skill",
89
+ layer: "workspace",
90
+ target: dest,
91
+ description: `${dest} exists and is not a Trellis-managed workspace copy — left untouched`,
92
+ remediation: `run \`trellis migrate --workspace\` to absorb it into .agents/, or remove it and re-run sync`,
93
+ });
94
+ continue;
95
+ }
96
+ if (directoryDigest(dest) !== recorded.destDigest) {
97
+ items.push({
98
+ action: "conflict",
99
+ kind: "skill",
100
+ layer: "workspace",
101
+ target: dest,
102
+ description: `${dest} was hand-edited since Trellis wrote it — left untouched`,
103
+ remediation: `merge your edits into .agents/skills/${skill.name}/ (the canonical copy), then remove ${dest} and re-run sync — Trellis will not overwrite hand edits`,
104
+ });
105
+ continue;
106
+ }
107
+ if (directoryDigest(skill.dir) === recorded.sourceDigest) {
108
+ continue; // fresh — no-op
109
+ }
110
+ items.push({
111
+ action: "create",
112
+ kind: "skill",
113
+ layer: "workspace",
114
+ target: dest,
115
+ copyFrom: skill.dir,
116
+ description: `workspace skill "${skill.name}" re-copied to ${dest} (source changed)`,
117
+ });
118
+ }
119
+ for (const name of Object.keys(ledger)) {
120
+ if (desiredNames.has(name))
121
+ continue;
122
+ const dest = join(destRoot, name);
123
+ if (!existsSync(dest)) {
124
+ // Stale ledger entry only — rewrite the ledger (no dir to remove).
125
+ items.push({
126
+ action: "remove",
127
+ kind: "skill",
128
+ layer: "workspace",
129
+ target: dest,
130
+ description: `stale workspace-copy ledger entry "${name}" pruned from ${ledgerPath}`,
131
+ });
132
+ continue;
133
+ }
134
+ if (directoryDigest(dest) !== ledger[name].destDigest) {
135
+ continue; // hand-edited since we wrote it — no longer ours to remove
136
+ }
137
+ items.push({
138
+ action: "remove",
139
+ kind: "skill",
140
+ layer: "workspace",
141
+ target: dest,
142
+ description: `workspace skill "${name}" removed from ${dest} — no longer in .agents/skills/`,
143
+ });
144
+ }
145
+ return items;
146
+ }
147
+ function planWorkspaceInstructions(agentId, canonical) {
148
+ const workspace = canonical.workspace;
149
+ if (!workspace)
150
+ return [];
151
+ const items = [];
152
+ for (const target of instructionsTargets(agentId)) {
153
+ const path = join(workspace.root, target.relPath);
154
+ const rendered = workspace.instructionsFile
155
+ ? `${INSTRUCTIONS_MARKER}\n\n${readFileSync(workspace.instructionsFile, "utf-8")}`
156
+ : undefined;
157
+ if (rendered === undefined) {
158
+ // Workspace no longer declares instructions — remove the delivered
159
+ // file only while it's still byte-exactly a marker-headed one.
160
+ if (!existsSync(path))
161
+ continue;
162
+ const current = readFileSync(path, "utf-8");
163
+ if (!current.startsWith(INSTRUCTIONS_MARKER))
164
+ continue; // user's file — never ours
165
+ items.push({
166
+ action: "remove",
167
+ kind: "instructions",
168
+ layer: "workspace",
169
+ target: path,
170
+ description: `${path} removed — .agents/AGENTS.md no longer exists`,
171
+ });
172
+ continue;
173
+ }
174
+ if (!existsSync(path)) {
175
+ items.push({
176
+ action: "create",
177
+ kind: "instructions",
178
+ layer: "workspace",
179
+ target: path,
180
+ fileContent: rendered,
181
+ description: `workspace instructions written to ${path} (${target.forAgents})`,
182
+ });
183
+ continue;
184
+ }
185
+ const current = readFileSync(path, "utf-8");
186
+ if (current === rendered)
187
+ continue;
188
+ if (!current.startsWith(INSTRUCTIONS_MARKER)) {
189
+ items.push({
190
+ action: "conflict",
191
+ kind: "instructions",
192
+ layer: "workspace",
193
+ target: path,
194
+ description: `${path} exists and is not Trellis-generated — left untouched`,
195
+ remediation: `merge its content into .agents/AGENTS.md (e.g. via \`trellis migrate --workspace\`) and remove the file, then re-run sync`,
196
+ });
197
+ continue;
198
+ }
199
+ items.push({
200
+ action: "create",
201
+ kind: "instructions",
202
+ layer: "workspace",
203
+ target: path,
204
+ fileContent: rendered,
205
+ description: `workspace instructions updated in ${path} (source changed)`,
206
+ });
207
+ }
208
+ return items;
209
+ }
210
+ export function planWorkspaceFiles(agentId, canonical) {
211
+ return [...planWorkspaceSkillCopies(agentId, canonical), ...planWorkspaceInstructions(agentId, canonical)];
212
+ }
213
+ /** Executes a `planWorkspaceFiles` result through the run's backup session
214
+ * and rewrites the copy ledger for every destination root it touched. */
215
+ export function applyWorkspaceFiles(agentId, canonical, items, backup) {
216
+ const workspace = canonical.workspace;
217
+ if (!workspace)
218
+ return;
219
+ for (const item of items) {
220
+ if (item.layer !== "workspace" || item.action === "conflict")
221
+ continue;
222
+ if (item.kind === "instructions") {
223
+ if (item.action === "create" && item.fileContent !== undefined) {
224
+ // Kiro's steering dir (and any future nested target) may not exist
225
+ // yet in a fresh project — create it before the atomic write.
226
+ mkdirSync(dirname(item.target), { recursive: true });
227
+ backup.writeFile(item.target, item.fileContent);
228
+ }
229
+ else if (item.action === "remove") {
230
+ // removeDir's snapshot+remove works for single files too (cpSync /
231
+ // rmSync both accept file paths) — rollback restores the file.
232
+ backup.removeDir(item.target);
233
+ }
234
+ continue;
235
+ }
236
+ if (item.kind === "skill") {
237
+ const destRoot = dirname(item.target);
238
+ const ledgerPath = join(destRoot, COPY_LEDGER_NAME);
239
+ let ledger = loadCopyLedger(ledgerPath);
240
+ const name = item.target.slice(destRoot.length + 1);
241
+ if (item.action === "create" && item.copyFrom) {
242
+ if (existsSync(item.target)) {
243
+ backup.replaceDirFromSource(item.target, item.copyFrom);
244
+ }
245
+ else {
246
+ backup.createDirFromSource(item.target, item.copyFrom);
247
+ }
248
+ ledger = {
249
+ ...ledger,
250
+ [name]: { source: item.copyFrom, sourceDigest: directoryDigest(item.copyFrom), destDigest: directoryDigest(item.target) },
251
+ };
252
+ }
253
+ else if (item.action === "remove") {
254
+ if (existsSync(item.target)) {
255
+ backup.removeDir(item.target);
256
+ }
257
+ const next = { ...ledger };
258
+ delete next[name];
259
+ ledger = next;
260
+ }
261
+ backup.writeFile(ledgerPath, `${JSON.stringify(ledger, null, 2)}\n`);
262
+ }
263
+ }
264
+ }
@@ -0,0 +1,57 @@
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 type { AdapterPlanItem } from "../core/adapter.js";
25
+ import type { AgentId, CanonicalSource, McpServerDef } from "../core/types.js";
26
+ import type { BackupSession } from "./backup.js";
27
+ /** `.agents/.trellis-rendered.json` — the in-repo ownership manifest. */
28
+ export declare const WORKSPACE_RENDERED_MANIFEST = ".trellis-rendered.json";
29
+ export interface WorkspaceRenderedManifest {
30
+ mcp?: Partial<Record<AgentId, Record<string, unknown>>>;
31
+ }
32
+ export declare function loadWorkspaceRendered(workspaceRoot: string): WorkspaceRenderedManifest;
33
+ /** The project-level config file each agent reads. zcode's is selected by
34
+ * presence (existing `zcode.json` beats `.zcode/config.json`), defaulting
35
+ * to creating `zcode.json` at the root. */
36
+ export declare function workspaceMcpConfigPath(agentId: AgentId, workspaceRoot: string): string;
37
+ /** zcode is the one agent whose USER-level config wins on same-name
38
+ * conflicts (research, 2026-10-11), so workspace-wins silently misfires
39
+ * there — surface it instead of implying the override worked. */
40
+ export declare function workspacePrecedenceWarnings(agentId: AgentId, canonical: CanonicalSource): string[];
41
+ /**
42
+ * The rendered form `applyWorkspaceMcp` records in the manifest for one
43
+ * agent — exported so `migrate --workspace` can pre-seed ownership for an
44
+ * entry it is adopting out of a user-authored project file: the cascade
45
+ * render then owns the entry (updating it to Trellis's canonical render)
46
+ * instead of conflict-guarding on it as user-authored content
47
+ * (trellis-workspace-scope task 4.6).
48
+ */
49
+ export declare function renderWorkspaceManifestEntry(agentId: AgentId, name: string, def: McpServerDef): unknown;
50
+ /** Records one ownership entry in `.agents/.trellis-rendered.json`
51
+ * through the run's backup session — migrate --workspace's adopt step.
52
+ * Delivery itself keeps writing the manifest wholesale in
53
+ * `applyWorkspaceMcp`; this exists only for adoption, where no delivery
54
+ * write to that agent's file has happened yet. */
55
+ export declare function recordWorkspaceRendered(workspaceRoot: string, agentId: AgentId, name: string, rendered: unknown, backup: BackupSession): void;
56
+ export declare function planWorkspaceMcp(agentId: AgentId, canonical: CanonicalSource): AdapterPlanItem[];
57
+ export declare function applyWorkspaceMcp(agentId: AgentId, canonical: CanonicalSource, items: AdapterPlanItem[], backup: BackupSession): void;
@@ -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;