agent-trellis 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +69 -17
  2. package/dist/adapters/claude-code.d.ts +5 -3
  3. package/dist/adapters/claude-code.js +27 -14
  4. package/dist/adapters/codex.d.ts +8 -4
  5. package/dist/adapters/codex.js +47 -16
  6. package/dist/adapters/jsonMcp.d.ts +16 -5
  7. package/dist/adapters/jsonMcp.js +38 -29
  8. package/dist/adapters/kiro.d.ts +5 -3
  9. package/dist/adapters/kiro.js +29 -16
  10. package/dist/adapters/mcpPlan.d.ts +11 -6
  11. package/dist/adapters/mcpPlan.js +40 -7
  12. package/dist/adapters/pi.d.ts +2 -1
  13. package/dist/adapters/pi.js +4 -4
  14. package/dist/adapters/symlinkPlan.d.ts +7 -3
  15. package/dist/adapters/symlinkPlan.js +42 -16
  16. package/dist/cli.js +161 -18
  17. package/dist/commands/init.js +11 -0
  18. package/dist/commands/mcp.d.ts +114 -7
  19. package/dist/commands/mcp.js +258 -17
  20. package/dist/commands/memory.d.ts +39 -0
  21. package/dist/commands/memory.js +78 -0
  22. package/dist/commands/migrate.d.ts +30 -4
  23. package/dist/commands/migrate.js +83 -16
  24. package/dist/commands/onboard.d.ts +52 -7
  25. package/dist/commands/onboard.js +318 -35
  26. package/dist/commands/rollback.d.ts +44 -0
  27. package/dist/commands/rollback.js +201 -0
  28. package/dist/commands/secretsAudit.d.ts +7 -0
  29. package/dist/commands/secretsAudit.js +14 -7
  30. package/dist/commands/skill.d.ts +51 -0
  31. package/dist/commands/skill.js +104 -0
  32. package/dist/commands/sync.d.ts +13 -0
  33. package/dist/commands/sync.js +31 -5
  34. package/dist/core/adapter.d.ts +28 -11
  35. package/dist/core/adapter.js +2 -2
  36. package/dist/core/canonical.d.ts +26 -1
  37. package/dist/core/canonical.js +103 -3
  38. package/dist/core/types.d.ts +29 -1
  39. package/dist/core/types.js +11 -2
  40. package/dist/lib/backup.d.ts +56 -0
  41. package/dist/lib/backup.js +98 -0
  42. package/dist/lib/deepEqual.d.ts +8 -0
  43. package/dist/lib/deepEqual.js +26 -0
  44. package/dist/lib/dirEquals.d.ts +9 -0
  45. package/dist/lib/dirEquals.js +15 -1
  46. package/dist/lib/installAgent.d.ts +26 -0
  47. package/dist/lib/installAgent.js +46 -0
  48. package/dist/lib/mcpMigrateRead.d.ts +69 -0
  49. package/dist/lib/mcpMigrateRead.js +188 -0
  50. package/dist/lib/mcpOwnership.d.ts +25 -0
  51. package/dist/lib/mcpOwnership.js +50 -0
  52. package/dist/lib/memoryGraph.d.ts +60 -0
  53. package/dist/lib/memoryGraph.js +101 -0
  54. package/dist/lib/realHomeSnapshot.d.ts +26 -0
  55. package/dist/lib/realHomeSnapshot.js +77 -0
  56. package/dist/lib/terminalPicker.d.ts +45 -0
  57. package/dist/lib/terminalPicker.js +193 -0
  58. package/dist/lib/tomlSection.d.ts +20 -6
  59. package/dist/lib/tomlSection.js +78 -12
  60. package/dist/pi-bridge/bundle.js +100 -51
  61. package/dist/pi-bridge/index.js +14 -2
  62. package/dist/probes/codex.js +10 -2
  63. package/docs/architecture.md +7 -4
  64. package/docs/getting-started.md +267 -33
  65. package/docs/roadmap.md +444 -0
  66. package/package.json +1 -1
  67. package/schema/servers.example.yaml +39 -2
package/README.md CHANGED
@@ -45,14 +45,29 @@ trellis onboard
45
45
  ```
46
46
 
47
47
  Creates canonical source, detects which of Claude Code/Codex/Kiro/pi are on
48
- this machine, picks a base agent to migrate from (auto-selected if only one
49
- is present; prompts if more than one pass `--agent <id>` to skip the
50
- prompt), then runs `migrate` and `sync`. If no agent is detected, it prints
51
- each supported agent's real install command/URL and stops it never
52
- installs anything itself. Add `--dry-run` to preview the whole thing with
53
- zero writes.
48
+ this machine, then resolves two independent choices: a **migration source**
49
+ (read from auto-selected if only one present agent has real content,
50
+ prompted with a numbered choice if more than one, skippable if starting
51
+ fresh; pass `--agent <id>` to skip the prompt) and a **managed set** (written
52
+ to zero or more agents, chosen explicitly; pass `--manage <ids>` or
53
+ `--manage none` to skip the prompt). The source is **not** managed by
54
+ default — importing from Claude Code doesn't mean Trellis starts writing to
55
+ Claude Code too, unless you explicitly include it. Selecting an agent that
56
+ isn't installed yet (e.g. pi) is itself the authorization to install it —
57
+ one confirmation, then a real `npm install -g <package>`; Kiro has no CLI
58
+ package and is refused with its download URL instead. Once resolved, it
59
+ runs `migrate`, `sync`, `mcp sync`, and `secrets audit` against the managed
60
+ set — the whole onboarding path in one command, no follow-up commands to
61
+ type by hand. If no agent is detected, it prints each supported agent's
62
+ real install command/URL and stops — it never installs anything on its own
63
+ initiative. Add `--dry-run` to preview the whole thing with zero writes.
54
64
 
55
- **Or step by step** (what `onboard` is actually doing under the hood):
65
+ ```
66
+ trellis onboard --agent claude-code --manage pi
67
+ ```
68
+
69
+ **Or step by step** (what `onboard` is actually doing under the hood, if you
70
+ want to run any one stage on its own):
56
71
 
57
72
  **Already using Claude Code, Codex, Kiro, or pi and want to migrate what you
58
73
  already have?**
@@ -65,24 +80,46 @@ already have?**
65
80
  overwrites: an already-identical skill is reported and skipped, a genuine
66
81
  conflict is reported and left for you to resolve by hand. Add `--dry-run`
67
82
  to preview first.
68
- 3. `trellis sync` — distributes canonical skills/instructions to every agent
69
- present on this machine (including the ones you didn't migrate from).
70
- Add `--dry-run` to preview first.
83
+ 3. `trellis sync` — distributes canonical skills/instructions to every
84
+ **managed** agent (`~/.trellis/managed.yaml` empty by default; edit it
85
+ by hand or let `trellis onboard` write it). An agent that's merely present
86
+ but not managed is never touched. Add `--dry-run` to preview first.
71
87
  4. `trellis mcp sync` — distributes `~/.trellis/mcp/servers.yaml` (see
72
88
  [`schema/servers.example.yaml`](schema/servers.example.yaml)) to every
73
- agent's native MCP config (create/repair only — see Known limitations).
74
- 5. `trellis secrets audit` — fails non-zero if any agent's real config holds
75
- a literal credential or an unexpected env var name.
76
- 6. `trellis doctor` read-only scan of every present agent's current state;
77
- run any time to check for drift.
89
+ managed agent's native MCP config (create/repair only — see Known
90
+ limitations).
91
+ 5. `trellis secrets audit` fails non-zero if any managed agent's real
92
+ config holds a literal credential or an unexpected env var name.
93
+ 6. `trellis doctor` read-only scan of every present agent's current state
94
+ (managed or not); run any time to check for drift.
78
95
 
79
96
  **Starting from nothing?** Skip step 2 — `trellis init`'s placeholder
80
97
  `agents.md` and empty `skills/` are a fine starting point; edit them by hand.
81
98
 
99
+ **Every real write `sync`/`mcp sync`/`onboard` perform is backed up
100
+ first, automatically — no flag to opt in.** Before creating, repairing,
101
+ or removing anything, each is recorded to a structured, timestamped run
102
+ under `~/.trellis/backups/`, with enough information to invert it exactly.
103
+ `trellis rollback` undoes one recorded run — omit a run id for the most
104
+ recent one:
105
+
106
+ ```
107
+ $ trellis rollback
108
+ rollback 2026-09-13T04-52-18-727Z-mcp-sync
109
+ ✅ 3 restored, 0 conflict(s), 0 already reverted
110
+ - [restore] restore /Users/you/.claude.json to its content before this run (...)
111
+ ```
112
+
113
+ If something else touched a path since the backed-up run, that one path
114
+ is a `conflict` — reported, left untouched, never force-restored over —
115
+ while every other path in the same rollback still restores. `--list`
116
+ shows available runs without touching anything; `--dry-run` previews a
117
+ restore with zero writes.
118
+
82
119
  ## Status
83
120
 
84
- **Early, pre-1.0.** All seven CLI commands above (`onboard`, `init`,
85
- `migrate`, `doctor`, `sync`, `mcp sync`, `secrets audit`) are implemented,
121
+ **Early, pre-1.0.** All eight CLI commands above (`onboard`, `init`,
122
+ `migrate`, `doctor`, `sync`, `mcp sync`, `secrets audit`, `rollback`) are implemented,
86
123
  unit-tested, and verified end-to-end against real Docker containers (never a
87
124
  developer's own dotfiles during development — see
88
125
  [`docs/architecture.md`](docs/architecture.md)'s testing philosophy). See
@@ -107,6 +144,21 @@ result is named future work, not built yet.
107
144
  - Automatic removal of an MCP server is deliberately unsupported (create/
108
145
  repair only) until an ownership-tracking mechanism exists — see
109
146
  `docs/roadmap.md`'s P2 note.
147
+ - `~/.trellis/backups/` has no automatic pruning yet — every `sync`/
148
+ `mcp sync`/`onboard` run that performs a real write adds one more run
149
+ directory, with no cap. Delete old ones by hand for now.
150
+
151
+ A canonical MCP server can also declare `enabled: false` (kept defined,
152
+ never written to any agent) and `static_env` (a value that was never a
153
+ secret — an email, an environment tag — written verbatim instead of
154
+ resolved by name; still scanned against `reject_patterns` like every
155
+ other literal field). Before ever writing a name-only `env` entry,
156
+ `mcp sync` now checks it actually resolves through the same source
157
+ `secrets audit` and the pi bridge use — an unresolvable name is refused
158
+ as a conflict, not silently written and left to break that server's
159
+ connection once the agent starts it. Both found via real-machine
160
+ dogfooding, not a synthetic fixture — see
161
+ [`schema/servers.example.yaml`](schema/servers.example.yaml).
110
162
 
111
163
  ## Design principles
112
164
 
@@ -5,11 +5,13 @@
5
5
  * before the other three adapters commit to reusing `symlinkPlan.ts`.
6
6
  *
7
7
  * MCP servers: plain JSON parse → merge under `mcpServers` → stringify
8
- * (trellis-mcp-sync-p2 design.md D4) create/repair only, no automatic
9
- * removal (D7).
8
+ * (trellis-mcp-sync-p2 design.md D4). Removal (trellis-mcp-lifecycle-
9
+ * parity) only happens when `src/lib/mcpOwnership.ts`'s ledger proves
10
+ * the current entry is still exactly what Trellis itself last wrote.
10
11
  */
11
12
  import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
12
13
  import type { CanonicalSource } from "../core/types.js";
14
+ import type { BackupSession } from "../lib/backup.js";
13
15
  export declare class ClaudeCodeAdapter implements TrellisAdapter {
14
16
  private readonly homeDir;
15
17
  readonly name = "Claude Code";
@@ -18,6 +20,6 @@ export declare class ClaudeCodeAdapter implements TrellisAdapter {
18
20
  probe(): Promise<AdapterProbeResult>;
19
21
  plan(canonical: CanonicalSource): Promise<AdapterPlanItem[]>;
20
22
  private planMcp;
21
- apply(plan: AdapterPlanItem[]): Promise<void>;
23
+ apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
22
24
  verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
23
25
  }
@@ -5,16 +5,18 @@
5
5
  * before the other three adapters commit to reusing `symlinkPlan.ts`.
6
6
  *
7
7
  * MCP servers: plain JSON parse → merge under `mcpServers` → stringify
8
- * (trellis-mcp-sync-p2 design.md D4) create/repair only, no automatic
9
- * removal (D7).
8
+ * (trellis-mcp-sync-p2 design.md D4). Removal (trellis-mcp-lifecycle-
9
+ * parity) only happens when `src/lib/mcpOwnership.ts`'s ledger proves
10
+ * the current entry is still exactly what Trellis itself last wrote.
10
11
  */
11
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { existsSync, readFileSync } from "node:fs";
12
13
  import { dirname, join } from "node:path";
13
14
  import { homedir } from "node:os";
14
15
  import { isInScope } from "../core/adapter.js";
15
16
  import * as claudeCodeProbe from "../probes/claude-code.js";
16
17
  import { applySymlinkPlan, planSymlinks } from "./symlinkPlan.js";
17
- import { applyJsonMcp, planJsonMcp } from "./jsonMcp.js";
18
+ import { applyJsonMcp, planJsonMcp, renderJsonServerEntry } from "./jsonMcp.js";
19
+ import { loadMcpOwnership, ownedByAgent, recordOwned, forgetOwned, saveMcpOwnership } from "../lib/mcpOwnership.js";
18
20
  export class ClaudeCodeAdapter {
19
21
  homeDir;
20
22
  name = "Claude Code";
@@ -30,7 +32,7 @@ export class ClaudeCodeAdapter {
30
32
  const canonicalRoot = dirname(canonical.instructionsFile);
31
33
  const skillsRoot = join(this.homeDir, ".claude", "skills");
32
34
  const desiredSkills = canonical.skills
33
- .filter((skill) => isInScope(this.id, skill.scope))
35
+ .filter((skill) => isInScope(this.id, skill.scope, canonical.managedAgents))
34
36
  .map((skill) => ({ name: skill.name, target: skill.dir }));
35
37
  const skillItems = planSymlinks({
36
38
  rootDir: skillsRoot,
@@ -50,18 +52,29 @@ export class ClaudeCodeAdapter {
50
52
  planMcp(canonical) {
51
53
  const configPath = join(this.homeDir, ".claude.json");
52
54
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
53
- return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id });
55
+ const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
56
+ return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id, managedAgents: canonical.managedAgents, policy: canonical.secretsPolicy, ownership });
54
57
  }
55
- async apply(plan) {
56
- await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"));
57
- const mcpCreates = plan.filter((item) => item.kind === "mcp" && item.action === "create" && item.mcpWrite);
58
- if (mcpCreates.length === 0) {
58
+ async apply(plan, backup) {
59
+ await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"), backup);
60
+ const mcpWrites = plan.filter((item) => item.kind === "mcp" && (item.action === "create" || item.action === "remove"));
61
+ if (mcpWrites.length === 0) {
59
62
  return;
60
63
  }
61
- const configPath = mcpCreates[0].target;
64
+ const configPath = mcpWrites[0].target;
62
65
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
63
- const merged = applyJsonMcp(parsed, mcpCreates);
64
- writeFileSync(configPath, `${JSON.stringify(merged, null, 2)}\n`);
66
+ const merged = applyJsonMcp(parsed, mcpWrites);
67
+ backup.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`);
68
+ let ownership = loadMcpOwnership(this.homeDir);
69
+ for (const item of mcpWrites) {
70
+ if (item.action === "create" && item.mcpWrite) {
71
+ ownership = recordOwned(ownership, this.id, item.mcpWrite.name, renderJsonServerEntry(item.mcpWrite.def));
72
+ }
73
+ else if (item.action === "remove" && item.mcpRemove) {
74
+ ownership = forgetOwned(ownership, this.id, item.mcpRemove.name);
75
+ }
76
+ }
77
+ saveMcpOwnership(this.homeDir, ownership);
65
78
  }
66
79
  async verify(canonical) {
67
80
  const snapshot = await claudeCodeProbe.probe(this.homeDir);
@@ -69,7 +82,7 @@ export class ClaudeCodeAdapter {
69
82
  return { ok: false, mismatches: ["claude-code is not present on this machine"] };
70
83
  }
71
84
  const mismatches = [];
72
- const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope)).map((s) => s.name));
85
+ const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
73
86
  const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
74
87
  for (const name of desiredNames) {
75
88
  if (!actualSkills.has(name)) {
@@ -8,12 +8,16 @@
8
8
  *
9
9
  * MCP servers are written via `src/lib/tomlSection.ts`'s line-based
10
10
  * section splicer, never a TOML library (trellis-mcp-sync-p2 design.md
11
- * D1/D2 — both rejected with evidence) create/repair only, no
12
- * automatic removal (D7: no ownership marker exists for a bare TOML key
13
- * the way a symlink's realpath provides for skills).
11
+ * D1/D2 — both rejected with evidence). Removal (trellis-mcp-lifecycle-
12
+ * parity) only happens when `src/lib/mcpOwnership.ts`'s ledger proves
13
+ * the current section text is still exactly what Trellis itself last
14
+ * wrote — a bare TOML key has no ownership marker of its own the way a
15
+ * symlink's realpath provides for skills, so this ledger is what makes
16
+ * it provably safe.
14
17
  */
15
18
  import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
16
19
  import type { CanonicalSource } from "../core/types.js";
20
+ import type { BackupSession } from "../lib/backup.js";
17
21
  export declare class CodexAdapter implements TrellisAdapter {
18
22
  private readonly homeDir;
19
23
  readonly name = "Codex";
@@ -22,6 +26,6 @@ export declare class CodexAdapter implements TrellisAdapter {
22
26
  probe(): Promise<AdapterProbeResult>;
23
27
  plan(canonical: CanonicalSource): Promise<AdapterPlanItem[]>;
24
28
  private planMcp;
25
- apply(plan: AdapterPlanItem[]): Promise<void>;
29
+ apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
26
30
  verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
27
31
  }
@@ -8,11 +8,14 @@
8
8
  *
9
9
  * MCP servers are written via `src/lib/tomlSection.ts`'s line-based
10
10
  * section splicer, never a TOML library (trellis-mcp-sync-p2 design.md
11
- * D1/D2 — both rejected with evidence) create/repair only, no
12
- * automatic removal (D7: no ownership marker exists for a bare TOML key
13
- * the way a symlink's realpath provides for skills).
11
+ * D1/D2 — both rejected with evidence). Removal (trellis-mcp-lifecycle-
12
+ * parity) only happens when `src/lib/mcpOwnership.ts`'s ledger proves
13
+ * the current section text is still exactly what Trellis itself last
14
+ * wrote — a bare TOML key has no ownership marker of its own the way a
15
+ * symlink's realpath provides for skills, so this ledger is what makes
16
+ * it provably safe.
14
17
  */
15
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { existsSync, readFileSync } from "node:fs";
16
19
  import { basename, dirname, join } from "node:path";
17
20
  import { homedir } from "node:os";
18
21
  import { isInScope } from "../core/adapter.js";
@@ -20,7 +23,8 @@ import * as codexProbe from "../probes/codex.js";
20
23
  import { readInstructionsPath } from "../probes/codex.js";
21
24
  import { applySymlinkPlan, planSymlinks } from "./symlinkPlan.js";
22
25
  import { resolveMcpPlan } from "./mcpPlan.js";
23
- import { currentServerSectionText, renderServerSection, upsertSection } from "../lib/tomlSection.js";
26
+ import { currentServerSectionText, removeSection, renderServerSection, upsertSection } from "../lib/tomlSection.js";
27
+ import { loadMcpOwnership, ownedByAgent, recordOwned, forgetOwned, saveMcpOwnership } from "../lib/mcpOwnership.js";
24
28
  export class CodexAdapter {
25
29
  homeDir;
26
30
  name = "Codex";
@@ -36,7 +40,7 @@ export class CodexAdapter {
36
40
  const canonicalRoot = dirname(canonical.instructionsFile);
37
41
  const skillsRoot = join(this.homeDir, ".agents", "skills");
38
42
  const desiredSkills = canonical.skills
39
- .filter((skill) => isInScope(this.id, skill.scope))
43
+ .filter((skill) => isInScope(this.id, skill.scope, canonical.managedAgents))
40
44
  .map((skill) => ({ name: skill.name, target: skill.dir }));
41
45
  const skillItems = planSymlinks({
42
46
  rootDir: skillsRoot,
@@ -62,7 +66,7 @@ export class CodexAdapter {
62
66
  planMcp(canonical) {
63
67
  const configTomlPath = join(this.homeDir, ".codex", "config.toml");
64
68
  const content = existsSync(configTomlPath) ? readFileSync(configTomlPath, "utf-8") : "";
65
- const { desired, conflicts } = resolveMcpPlan(this.id, canonical.mcp);
69
+ const { desired, conflicts } = resolveMcpPlan(this.id, canonical.mcp, canonical.managedAgents, canonical.secretsPolicy);
66
70
  const items = [];
67
71
  for (const { name, def } of desired) {
68
72
  const current = currentServerSectionText(content, name);
@@ -81,20 +85,47 @@ export class CodexAdapter {
81
85
  for (const conflict of conflicts) {
82
86
  items.push({ action: "conflict", kind: "mcp", target: configTomlPath, description: conflict.message });
83
87
  }
88
+ const desiredNames = new Set(desired.map((d) => d.name));
89
+ const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
90
+ for (const [name, expected] of Object.entries(ownership)) {
91
+ if (desiredNames.has(name))
92
+ continue; // still wanted — not a removal candidate
93
+ const current = currentServerSectionText(content, name);
94
+ if (current === null)
95
+ continue; // already gone
96
+ if (current !== expected)
97
+ continue; // hand-edited since Trellis wrote it — no longer ours to touch
98
+ items.push({
99
+ action: "remove",
100
+ kind: "mcp",
101
+ target: configTomlPath,
102
+ mcpRemove: { name },
103
+ description: `MCP server "${name}" removed from ${configTomlPath} — no longer in canonical`,
104
+ });
105
+ }
84
106
  return items;
85
107
  }
86
- async apply(plan) {
87
- await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"));
88
- const mcpCreates = plan.filter((item) => item.kind === "mcp" && item.action === "create" && item.mcpWrite);
89
- if (mcpCreates.length === 0) {
108
+ async apply(plan, backup) {
109
+ await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"), backup);
110
+ const mcpWrites = plan.filter((item) => item.kind === "mcp" && (item.action === "create" || item.action === "remove"));
111
+ if (mcpWrites.length === 0) {
90
112
  return;
91
113
  }
92
- const configTomlPath = mcpCreates[0].target;
114
+ const configTomlPath = mcpWrites[0].target;
93
115
  let content = existsSync(configTomlPath) ? readFileSync(configTomlPath, "utf-8") : "";
94
- for (const item of mcpCreates) {
95
- content = upsertSection(content, item.mcpWrite.name, item.mcpWrite.def);
116
+ let ownership = loadMcpOwnership(this.homeDir);
117
+ for (const item of mcpWrites) {
118
+ if (item.action === "create" && item.mcpWrite) {
119
+ content = upsertSection(content, item.mcpWrite.name, item.mcpWrite.def);
120
+ ownership = recordOwned(ownership, this.id, item.mcpWrite.name, renderServerSection(item.mcpWrite.name, item.mcpWrite.def));
121
+ }
122
+ else if (item.action === "remove" && item.mcpRemove) {
123
+ content = removeSection(content, item.mcpRemove.name);
124
+ ownership = forgetOwned(ownership, this.id, item.mcpRemove.name);
125
+ }
96
126
  }
97
- writeFileSync(configTomlPath, content);
127
+ backup.writeFile(configTomlPath, content);
128
+ saveMcpOwnership(this.homeDir, ownership);
98
129
  }
99
130
  async verify(canonical) {
100
131
  const snapshot = await codexProbe.probe(this.homeDir);
@@ -102,7 +133,7 @@ export class CodexAdapter {
102
133
  return { ok: false, mismatches: ["codex is not present on this machine"] };
103
134
  }
104
135
  const mismatches = [];
105
- const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope)).map((s) => s.name));
136
+ const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
106
137
  const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
107
138
  for (const name of desiredNames) {
108
139
  if (!actualSkills.has(name)) {
@@ -6,19 +6,30 @@
6
6
  * round-trip is safe as long as every sibling top-level key is carried
7
7
  * through untouched.
8
8
  */
9
- import type { AgentId, McpConfig, McpServerDef } from "../core/types.js";
9
+ import type { AgentId, McpConfig, McpServerDef, SecretsPolicy } from "../core/types.js";
10
10
  import type { AdapterPlanItem } from "../core/adapter.js";
11
11
  /** Renders the native JSON shape Claude Code/Kiro's `mcpServers` map
12
- * expects — `env` values are always `${VAR}` references, never literals
13
- * (docs/research.md "Secrets"). */
12
+ * expects — `env` names render as `${VAR}` references, `staticEnv`
13
+ * entries render as their literal values, both in the same map (JSON's
14
+ * `env` object has no structural distinction between the two the way
15
+ * Codex's TOML does — see tomlSection.ts's two-table rendering)
16
+ * (trellis-mcp-static-env-and-disabled-servers design.md D4). */
14
17
  export declare function renderJsonServerEntry(def: McpServerDef): Record<string, unknown>;
15
18
  export declare function planJsonMcp(opts: {
16
19
  configPath: string;
17
20
  parsed: Record<string, unknown> | undefined;
18
21
  mcp: McpConfig;
19
22
  agentId: AgentId;
23
+ managedAgents: readonly AgentId[];
24
+ policy: SecretsPolicy;
25
+ /** This agent's own slice of `src/lib/mcpOwnership.ts`'s ledger — what
26
+ * Trellis itself last wrote for each name, keyed by name. Omit (or
27
+ * pass `{}`) to get today's create/repair/conflict-only behavior with
28
+ * zero removal candidates. */
29
+ ownership?: Record<string, unknown>;
20
30
  }): AdapterPlanItem[];
21
31
  /** Merges every `"create"` MCP item into `parsed` (or a fresh `{}` if the
22
- * file didn't exist), returning the object to stringify every sibling
23
- * top-level key on `parsed` is spread through untouched. */
32
+ * file didn't exist) and deletes every `"remove"` item's key, returning
33
+ * the object to stringify — every sibling top-level key on `parsed` is
34
+ * spread through untouched. */
24
35
  export declare function applyJsonMcp(parsed: Record<string, unknown> | undefined, items: AdapterPlanItem[]): Record<string, unknown>;
@@ -6,29 +6,14 @@
6
6
  * round-trip is safe as long as every sibling top-level key is carried
7
7
  * through untouched.
8
8
  */
9
+ import { deepEqual } from "../lib/deepEqual.js";
9
10
  import { resolveMcpPlan } from "./mcpPlan.js";
10
- function deepEqual(a, b) {
11
- if (a === b)
12
- return true;
13
- if (a === null || b === null || typeof a !== typeof b)
14
- return false;
15
- if (Array.isArray(a) || Array.isArray(b)) {
16
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
17
- return false;
18
- return a.every((value, index) => deepEqual(value, b[index]));
19
- }
20
- if (typeof a === "object" && typeof b === "object") {
21
- const aKeys = Object.keys(a);
22
- const bKeys = Object.keys(b);
23
- if (aKeys.length !== bKeys.length)
24
- return false;
25
- return aKeys.every((key) => deepEqual(a[key], b[key]));
26
- }
27
- return false;
28
- }
29
11
  /** Renders the native JSON shape Claude Code/Kiro's `mcpServers` map
30
- * expects — `env` values are always `${VAR}` references, never literals
31
- * (docs/research.md "Secrets"). */
12
+ * expects — `env` names render as `${VAR}` references, `staticEnv`
13
+ * entries render as their literal values, both in the same map (JSON's
14
+ * `env` object has no structural distinction between the two the way
15
+ * Codex's TOML does — see tomlSection.ts's two-table rendering)
16
+ * (trellis-mcp-static-env-and-disabled-servers design.md D4). */
32
17
  export function renderJsonServerEntry(def) {
33
18
  if (def.transport === "http" || def.transport === "sse") {
34
19
  const entry = { type: def.transport, url: def.url };
@@ -38,14 +23,16 @@ export function renderJsonServerEntry(def) {
38
23
  return entry;
39
24
  }
40
25
  const entry = { type: "stdio", command: def.command, args: def.args ?? [] };
41
- if (def.env && def.env.length > 0) {
42
- entry.env = Object.fromEntries(def.env.map((name) => [name, `\${${name}}`]));
26
+ const nameRefs = Object.fromEntries((def.env ?? []).map((name) => [name, `\${${name}}`]));
27
+ const env = { ...nameRefs, ...(def.staticEnv ?? {}) };
28
+ if (Object.keys(env).length > 0) {
29
+ entry.env = env;
43
30
  }
44
31
  return entry;
45
32
  }
46
33
  export function planJsonMcp(opts) {
47
- const { configPath, parsed, mcp, agentId } = opts;
48
- const { desired, conflicts } = resolveMcpPlan(agentId, mcp);
34
+ const { configPath, parsed, mcp, agentId, managedAgents, policy, ownership } = opts;
35
+ const { desired, conflicts } = resolveMcpPlan(agentId, mcp, managedAgents, policy);
49
36
  const existingServers = parsed?.mcpServers ?? {};
50
37
  const items = [];
51
38
  for (const { name, def } of desired) {
@@ -65,20 +52,42 @@ export function planJsonMcp(opts) {
65
52
  for (const conflict of conflicts) {
66
53
  items.push({ action: "conflict", kind: "mcp", target: configPath, description: conflict.message });
67
54
  }
55
+ const desiredNames = new Set(desired.map((d) => d.name));
56
+ for (const [name, expected] of Object.entries(ownership ?? {})) {
57
+ if (desiredNames.has(name))
58
+ continue; // still wanted — not a removal candidate at all
59
+ const current = existingServers[name];
60
+ if (current === undefined)
61
+ continue; // already gone — nothing to remove, ledger is pruned separately
62
+ if (!deepEqual(current, expected))
63
+ continue; // hand-edited since Trellis wrote it — no longer ours to touch
64
+ items.push({
65
+ action: "remove",
66
+ kind: "mcp",
67
+ target: configPath,
68
+ mcpRemove: { name },
69
+ description: `MCP server "${name}" removed from ${configPath} — no longer in canonical`,
70
+ });
71
+ }
68
72
  return items;
69
73
  }
70
74
  /** Merges every `"create"` MCP item into `parsed` (or a fresh `{}` if the
71
- * file didn't exist), returning the object to stringify every sibling
72
- * top-level key on `parsed` is spread through untouched. */
75
+ * file didn't exist) and deletes every `"remove"` item's key, returning
76
+ * the object to stringify — every sibling top-level key on `parsed` is
77
+ * spread through untouched. */
73
78
  export function applyJsonMcp(parsed, items) {
74
79
  const base = parsed ?? {};
75
80
  const existingServers = base.mcpServers ?? {};
76
81
  const mergedServers = { ...existingServers };
77
82
  for (const item of items) {
78
- if (item.kind !== "mcp" || item.action !== "create" || !item.mcpWrite) {
83
+ if (item.kind !== "mcp")
79
84
  continue;
85
+ if (item.action === "create" && item.mcpWrite) {
86
+ mergedServers[item.mcpWrite.name] = renderJsonServerEntry(item.mcpWrite.def);
87
+ }
88
+ else if (item.action === "remove" && item.mcpRemove) {
89
+ delete mergedServers[item.mcpRemove.name];
80
90
  }
81
- mergedServers[item.mcpWrite.name] = renderJsonServerEntry(item.mcpWrite.def);
82
91
  }
83
92
  return { ...base, mcpServers: mergedServers };
84
93
  }
@@ -3,8 +3,9 @@
3
3
  * symlinks, `~/.kiro/steering/CLAUDE.md` symlinked to canonical `agents.md`.
4
4
  *
5
5
  * MCP servers: plain JSON parse → merge under `mcpServers` → stringify
6
- * (trellis-mcp-sync-p2 design.md D4) create/repair only, no automatic
7
- * removal (D7).
6
+ * (trellis-mcp-sync-p2 design.md D4). Removal (trellis-mcp-lifecycle-
7
+ * parity) only happens when `src/lib/mcpOwnership.ts`'s ledger proves
8
+ * the current entry is still exactly what Trellis itself last wrote.
8
9
  *
9
10
  * Kiro's own `${VAR}` substitution (real, found by reading Kiro's
10
11
  * installed extension source — trellis-kiro-approved-env-vars design.md
@@ -16,6 +17,7 @@
16
17
  */
17
18
  import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
18
19
  import type { CanonicalSource } from "../core/types.js";
20
+ import type { BackupSession } from "../lib/backup.js";
19
21
  export declare class KiroAdapter implements TrellisAdapter {
20
22
  private readonly homeDir;
21
23
  readonly name = "Kiro";
@@ -29,6 +31,6 @@ export declare class KiroAdapter implements TrellisAdapter {
29
31
  * known_host_injected collision, never contributes a name here either. */
30
32
  private desiredApprovedEnvVars;
31
33
  private planApprovedEnvVars;
32
- apply(plan: AdapterPlanItem[]): Promise<void>;
34
+ apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
33
35
  verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
34
36
  }
@@ -3,8 +3,9 @@
3
3
  * symlinks, `~/.kiro/steering/CLAUDE.md` symlinked to canonical `agents.md`.
4
4
  *
5
5
  * MCP servers: plain JSON parse → merge under `mcpServers` → stringify
6
- * (trellis-mcp-sync-p2 design.md D4) create/repair only, no automatic
7
- * removal (D7).
6
+ * (trellis-mcp-sync-p2 design.md D4). Removal (trellis-mcp-lifecycle-
7
+ * parity) only happens when `src/lib/mcpOwnership.ts`'s ledger proves
8
+ * the current entry is still exactly what Trellis itself last wrote.
8
9
  *
9
10
  * Kiro's own `${VAR}` substitution (real, found by reading Kiro's
10
11
  * installed extension source — trellis-kiro-approved-env-vars design.md
@@ -14,15 +15,16 @@
14
15
  * adapter keeps that list a superset of every env name it references,
15
16
  * additive only.
16
17
  */
17
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
18
19
  import { dirname, join } from "node:path";
19
20
  import { homedir } from "node:os";
20
21
  import { isInScope } from "../core/adapter.js";
21
22
  import * as kiroProbe from "../probes/kiro.js";
22
23
  import { applySymlinkPlan, planSymlinks } from "./symlinkPlan.js";
23
- import { applyJsonMcp, planJsonMcp } from "./jsonMcp.js";
24
+ import { applyJsonMcp, planJsonMcp, renderJsonServerEntry } from "./jsonMcp.js";
24
25
  import { resolveMcpPlan } from "./mcpPlan.js";
25
26
  import { declaredEnvNames } from "../lib/envVarNames.js";
27
+ import { loadMcpOwnership, ownedByAgent, recordOwned, forgetOwned, saveMcpOwnership } from "../lib/mcpOwnership.js";
26
28
  /** VS-Code-family global settings path. macOS only — see
27
29
  * trellis-kiro-approved-env-vars proposal.md Non-Goals: Linux/Windows
28
30
  * equivalents are the well-known convention but unverified against a
@@ -46,7 +48,7 @@ export class KiroAdapter {
46
48
  const canonicalRoot = dirname(canonical.instructionsFile);
47
49
  const skillsRoot = join(this.homeDir, ".kiro", "skills");
48
50
  const desiredSkills = canonical.skills
49
- .filter((skill) => isInScope(this.id, skill.scope))
51
+ .filter((skill) => isInScope(this.id, skill.scope, canonical.managedAgents))
50
52
  .map((skill) => ({ name: skill.name, target: skill.dir }));
51
53
  const skillItems = planSymlinks({
52
54
  rootDir: skillsRoot,
@@ -67,13 +69,14 @@ export class KiroAdapter {
67
69
  planMcp(canonical) {
68
70
  const configPath = join(this.homeDir, ".kiro", "settings", "mcp.json");
69
71
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
70
- return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id });
72
+ const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
73
+ return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id, managedAgents: canonical.managedAgents, policy: canonical.secretsPolicy, ownership });
71
74
  }
72
75
  /** Names come from `resolveMcpPlan`, not a raw scan of `canonical.mcp.servers`
73
76
  * (design.md D2) — a server scoped away from Kiro, or refused for a
74
77
  * known_host_injected collision, never contributes a name here either. */
75
78
  desiredApprovedEnvVars(canonical) {
76
- const { desired } = resolveMcpPlan(this.id, canonical.mcp);
79
+ const { desired } = resolveMcpPlan(this.id, canonical.mcp, canonical.managedAgents, canonical.secretsPolicy);
77
80
  const names = new Set();
78
81
  for (const { def } of desired) {
79
82
  for (const name of declaredEnvNames(def))
@@ -115,15 +118,25 @@ export class KiroAdapter {
115
118
  },
116
119
  ];
117
120
  }
118
- async apply(plan) {
119
- await applySymlinkPlan(plan.filter((item) => item.kind === "skill" || item.kind === "instructions"));
120
- const mcpCreates = plan.filter((item) => item.kind === "mcp" && item.action === "create" && item.mcpWrite);
121
- if (mcpCreates.length > 0) {
122
- const configPath = mcpCreates[0].target;
121
+ async apply(plan, backup) {
122
+ await applySymlinkPlan(plan.filter((item) => item.kind === "skill" || item.kind === "instructions"), backup);
123
+ const mcpWrites = plan.filter((item) => item.kind === "mcp" && (item.action === "create" || item.action === "remove"));
124
+ if (mcpWrites.length > 0) {
125
+ const configPath = mcpWrites[0].target;
123
126
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
124
- const merged = applyJsonMcp(parsed, mcpCreates);
127
+ const merged = applyJsonMcp(parsed, mcpWrites);
125
128
  mkdirSync(dirname(configPath), { recursive: true });
126
- writeFileSync(configPath, `${JSON.stringify(merged, null, 2)}\n`);
129
+ backup.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`);
130
+ let ownership = loadMcpOwnership(this.homeDir);
131
+ for (const item of mcpWrites) {
132
+ if (item.action === "create" && item.mcpWrite) {
133
+ ownership = recordOwned(ownership, this.id, item.mcpWrite.name, renderJsonServerEntry(item.mcpWrite.def));
134
+ }
135
+ else if (item.action === "remove" && item.mcpRemove) {
136
+ ownership = forgetOwned(ownership, this.id, item.mcpRemove.name);
137
+ }
138
+ }
139
+ saveMcpOwnership(this.homeDir, ownership);
127
140
  }
128
141
  const approvedEnvVarsCreate = plan.find((item) => item.kind === "kiro-approved-env-vars" && item.action === "create" && item.approvedEnvVars);
129
142
  if (approvedEnvVarsCreate) {
@@ -131,7 +144,7 @@ export class KiroAdapter {
131
144
  const parsed = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, "utf-8")) : {};
132
145
  const merged = { ...parsed, [APPROVED_ENV_VARS_KEY]: approvedEnvVarsCreate.approvedEnvVars };
133
146
  mkdirSync(dirname(settingsPath), { recursive: true });
134
- writeFileSync(settingsPath, `${JSON.stringify(merged, null, 2)}\n`);
147
+ backup.writeFile(settingsPath, `${JSON.stringify(merged, null, 2)}\n`);
135
148
  }
136
149
  }
137
150
  async verify(canonical) {
@@ -140,7 +153,7 @@ export class KiroAdapter {
140
153
  return { ok: false, mismatches: ["kiro is not present on this machine"] };
141
154
  }
142
155
  const mismatches = [];
143
- const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope)).map((s) => s.name));
156
+ const desiredNames = new Set(canonical.skills.filter((s) => isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
144
157
  const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
145
158
  for (const name of desiredNames) {
146
159
  if (!actualSkills.has(name)) {