agent-trellis 0.2.0 → 0.4.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 (50) hide show
  1. package/README.md +12 -0
  2. package/dist/adapters/claude-code.d.ts +3 -2
  3. package/dist/adapters/claude-code.js +21 -8
  4. package/dist/adapters/codex.d.ts +6 -3
  5. package/dist/adapters/codex.js +41 -10
  6. package/dist/adapters/jsonMcp.d.ts +15 -5
  7. package/dist/adapters/jsonMcp.js +38 -29
  8. package/dist/adapters/kiro.d.ts +3 -2
  9. package/dist/adapters/kiro.js +22 -9
  10. package/dist/adapters/mcpPlan.d.ts +11 -6
  11. package/dist/adapters/mcpPlan.js +39 -6
  12. package/dist/cli.js +123 -10
  13. package/dist/commands/mcp.d.ts +101 -7
  14. package/dist/commands/mcp.js +227 -10
  15. package/dist/commands/memory.d.ts +47 -0
  16. package/dist/commands/memory.js +89 -0
  17. package/dist/commands/migrate.d.ts +30 -4
  18. package/dist/commands/migrate.js +83 -16
  19. package/dist/commands/onboard.d.ts +27 -8
  20. package/dist/commands/onboard.js +144 -17
  21. package/dist/commands/skill.d.ts +51 -0
  22. package/dist/commands/skill.js +104 -0
  23. package/dist/core/adapter.d.ts +18 -8
  24. package/dist/core/canonical.d.ts +26 -1
  25. package/dist/core/canonical.js +81 -3
  26. package/dist/core/types.d.ts +17 -0
  27. package/dist/lib/deepEqual.d.ts +8 -0
  28. package/dist/lib/deepEqual.js +26 -0
  29. package/dist/lib/dirEquals.d.ts +9 -0
  30. package/dist/lib/dirEquals.js +15 -1
  31. package/dist/lib/mcpMigrateRead.d.ts +69 -0
  32. package/dist/lib/mcpMigrateRead.js +188 -0
  33. package/dist/lib/mcpOwnership.d.ts +25 -0
  34. package/dist/lib/mcpOwnership.js +50 -0
  35. package/dist/lib/memoryGraph.d.ts +60 -0
  36. package/dist/lib/memoryGraph.js +101 -0
  37. package/dist/lib/realHomeSnapshot.d.ts +26 -0
  38. package/dist/lib/realHomeSnapshot.js +77 -0
  39. package/dist/lib/terminalPicker.d.ts +45 -0
  40. package/dist/lib/terminalPicker.js +193 -0
  41. package/dist/lib/tomlSection.d.ts +20 -6
  42. package/dist/lib/tomlSection.js +78 -12
  43. package/dist/pi-bridge/bundle.js +76 -46
  44. package/dist/pi-bridge/index.js +7 -2
  45. package/dist/probes/codex.js +10 -2
  46. package/docs/architecture.md +7 -4
  47. package/docs/getting-started.md +180 -15
  48. package/docs/roadmap.md +341 -0
  49. package/package.json +1 -1
  50. package/schema/servers.example.yaml +39 -2
@@ -36,16 +36,24 @@ export async function probe(homeDir = homedir(), opts = {}) {
36
36
  return { agent: "codex", present: false, skillRoots: [], mcpServers: [], diagnostics: [] };
37
37
  }
38
38
  const diagnostics = [];
39
+ // `codex` resolves its own config via $HOME (confirmed by running it with
40
+ // an overridden HOME against an empty scratch dir — it returns `[]`, not
41
+ // the real machine's servers), so every subprocess call here must be
42
+ // scoped to `homeDir` explicitly — otherwise a caller probing a non-
43
+ // default `homeDir` would silently get this real machine's real MCP
44
+ // server list instead (trellis-migrate-mcp-servers found this gap in
45
+ // src/lib/mcpMigrateRead.ts's own equivalent call; fixed there first).
46
+ const codexEnv = { ...process.env, HOME: homeDir };
39
47
  let version;
40
48
  try {
41
- version = execFileSync("codex", ["--version"], { encoding: "utf-8", timeout: 5_000 }).trim();
49
+ version = execFileSync("codex", ["--version"], { encoding: "utf-8", timeout: 5_000, env: codexEnv }).trim();
42
50
  }
43
51
  catch {
44
52
  // config exists without the binary on PATH — unusual, still probeable
45
53
  }
46
54
  let mcpEntries = [];
47
55
  try {
48
- const raw = execFileSync("codex", ["mcp", "list", "--json"], { encoding: "utf-8", timeout: 5_000 });
56
+ const raw = execFileSync("codex", ["mcp", "list", "--json"], { encoding: "utf-8", timeout: 5_000, env: codexEnv });
49
57
  mcpEntries = JSON.parse(raw);
50
58
  }
51
59
  catch (err) {
@@ -45,10 +45,13 @@ Aligned to the `.agents Protocol` draft, extended where the draft is silent:
45
45
  ├── memories/*.md # shared memory entries (server-memory backed)
46
46
  ├── scope.yaml # exceptions to "shared with all agents" —
47
47
  │ # see "Private / agent-specific capabilities"
48
- ├── secrets.policy.yaml # which var names are allowed, nothing else
49
- └── trellis.lock.json # NOT YET BUILT — planned ownership-tracking
50
- # file for MCP removal (docs/roadmap.md's P2
51
- # note); nothing in src/ reads or writes this
48
+ └── secrets.policy.yaml # which var names are allowed, nothing else
49
+
50
+ # Not user-authored, so not shown in the tree above — Trellis's own
51
+ # bookkeeping, written/read only by `trellis mcp sync` itself:
52
+ # mcp/ownership.json # what Trellis last wrote per (agent, server name) —
53
+ # # the ownership marker MCP removal needed
54
+ # # (docs/roadmap.md P14, src/lib/mcpOwnership.ts)
52
55
  ```
53
56
 
54
57
  ## Global vs. workspace scope
@@ -17,18 +17,27 @@ $ trellis onboard
17
17
 
18
18
  Runs `init`, detects which of Claude Code/Codex/Kiro/pi are on this machine,
19
19
  then resolves two independent choices before running `migrate`, `sync`,
20
- `mcp sync`, and `secrets audit` — the whole onboarding path, no follow-up
21
- commands to type by hand:
20
+ `mcp sync`, `memory sync`, and `secrets audit` — the whole onboarding path,
21
+ no follow-up commands to type by hand:
22
22
 
23
23
  1. **Migration source** — read from, at most one, never written back to.
24
+ "Real content" means skills, custom instructions, *or* real MCP servers
25
+ — an agent whose only real content is its MCP servers is still a valid
26
+ source.
24
27
  - **No agent has real content**: skipped — canonical starts from `init`'s
25
28
  placeholder.
26
29
  - **Exactly one agent has real content**: auto-selected, no prompt.
27
- - **Two or more**: prompts you with a numbered choice (if you're at a real
28
- terminal), or pass `--agent <id>` to skip the prompt.
30
+ - **Two or more**: prompts you to choose (Up/Down or j/k, Enter to confirm,
31
+ on a real terminal that supports it — falls back to a numbered
32
+ type-a-digit prompt otherwise), or pass `--agent <id>` to skip the
33
+ prompt entirely. Which *categories* to bring in from that source
34
+ (skills, instructions, mcp) is its own checkbox, shown only when the
35
+ source has real content in two or more of them — with just one, that
36
+ one is migrated unprompted.
29
37
  2. **Managed set** — zero or more agents to actually write to. Always an
30
38
  explicit choice: pass `--manage <ids>` (comma-separated, e.g. `--manage
31
- pi,codex`) or `--manage none`, or answer the numbered multi-select prompt.
39
+ pi,codex`) or `--manage none`, or answer the interactive checkbox prompt
40
+ (Space to toggle, Enter to confirm — same numbered fallback as above).
32
41
  **The source is not included by default** — migrating from Claude Code
33
42
  doesn't mean Trellis starts managing Claude Code too, unless you say so.
34
43
  Selecting an agent that isn't installed yet is itself the authorization to
@@ -51,6 +60,9 @@ sync
51
60
  mcp sync
52
61
  ✅ pi — already in sync
53
62
 
63
+ memory sync
64
+ no "memory" MCP server with static_env.MEMORY_FILE_PATH configured in servers.yaml — see schema/servers.example.yaml
65
+
54
66
  secrets audit
55
67
  ✅ no findings — every present agent's real config and every declared env var passed all checks
56
68
  ```
@@ -60,9 +72,9 @@ it's present and was the migration source, but it isn't managed, so it's
60
72
  never even probed as a sync target, not just left with zero items.
61
73
 
62
74
  Add `--dry-run` to preview the entire chain — init/migrate/sync/mcp
63
- sync, including what would be written to `~/.trellis/managed.yaml` — with
64
- zero writes anywhere (secrets audit is always read-only, with or without
65
- the flag).
75
+ sync/memory sync, including what would be written to
76
+ `~/.trellis/managed.yaml` — with zero writes anywhere (secrets audit is
77
+ always read-only, with or without the flag).
66
78
 
67
79
  **A managed agent's own real content still isn't overwritten.** If you
68
80
  explicitly include the source in `--manage`, sync still never overwrites
@@ -134,27 +146,133 @@ Add `--dry-run` to see the plan without writing anything:
134
146
  $ trellis migrate --from codex --dry-run
135
147
  ```
136
148
 
149
+ Add `--only skills`, `--only instructions`, or `--only mcp` to migrate
150
+ just one category — useful when you only want part of it brought in
151
+ right now. Omit it to migrate all three, exactly as above:
152
+
153
+ ```
154
+ $ trellis migrate --from codex --only instructions
155
+ ```
156
+
137
157
  **What each action means:**
138
158
 
139
159
  | Action | Meaning |
140
160
  |---|---|
141
161
  | `create` | New to canonical source — copied in. |
142
- | `already-migrated` | Canonical already has byte-identical content (safe re-run, nothing happens). |
162
+ | `already-migrated` | Canonical already has byte-identical (or, for MCP servers, structurally identical) content (safe re-run, nothing happens). |
143
163
  | `conflict` | Canonical already has *different* real content — **left untouched**, resolve by hand. |
144
164
  | `skip-symlink` | That agent's own copy is itself a symlink (already shared in from elsewhere) — nothing of that agent's own to import. |
145
165
  | `skip-case-broken` | Found as `skill.md` instead of `SKILL.md` — fix the case on the source agent first. |
166
+ | `skip-unsupported` | MCP servers only — that agent's real definition can't be safely represented (see below); nothing was written for it. |
146
167
 
147
168
  Migrate never overwrites a genuine conflict, and never scopes a migrated
148
- skill to just the source agent — once in canonical, it's visible to every
149
- agent by default (see `sync`, below). If migrate reports a `conflict`, open
150
- the two files it names and decide by hand which content should actually be
151
- canonical, then re-run.
169
+ skill (or MCP server) to just the source agent — once in canonical, it's
170
+ visible to every agent by default (see `sync`, below). If migrate reports
171
+ a `conflict`, open the two files/entries it names and decide by hand
172
+ which content should actually be canonical, then re-run.
173
+
174
+ **MCP servers** (claude-code, kiro, codex — not pi, which has no static
175
+ MCP config to read at all) migrate the same way, into
176
+ `~/.trellis/mcp/servers.yaml`:
177
+
178
+ ```
179
+ $ trellis migrate --from claude-code --only mcp
180
+ migrate --from claude-code
181
+ [create] mcp server "gitlab" — will add to servers.yaml
182
+ ```
183
+
184
+ Two known fidelity limits, named rather than silently worked around:
185
+
186
+ - **Codex remote servers migrate when they only use `url` and
187
+ `bearer_token_env_var`** — the one shape this codebase has verified
188
+ against a real `codex` binary, and the only shape Trellis's own writer
189
+ ever produces for Codex. Codex's own config schema has no way to tell
190
+ `http` apart from `sse`, so a migrated remote server always comes back
191
+ as `http` — not a guess, that distinction was never stored in the
192
+ first place. A server using Codex's other header mechanisms
193
+ (`http_headers`/`env_http_headers`/`http_headers_helper` — real fields
194
+ this project has no verified shape for) is reported `skip-unsupported`
195
+ rather than guessed at; use `trellis mcp add` for that one server as a
196
+ workaround.
197
+ - **`headers` recovery depends on that agent's own real on-disk shape.**
198
+ claude-code/kiro read `headers` from the exact same JSON field Trellis
199
+ itself writes (`schema/servers.example.yaml`'s `figma` example) — if a
200
+ server was hand-authored with some other shape, it migrates whatever
201
+ is actually there, same as any other field.
152
202
 
153
203
  ## Starting from nothing
154
204
 
155
205
  Skip migrate. Edit `~/.trellis/agents.md` and add skills under
156
- `~/.trellis/skills/<name>/SKILL.md` directly. There's nothing else to set up
157
- before moving on to `sync`.
206
+ `~/.trellis/skills/<name>/SKILL.md` directly, or use
207
+ `trellis skill add` (below) instead of hand-editing. There's nothing else
208
+ to set up before moving on to `sync`.
209
+
210
+ The same applies to MCP servers — `~/.trellis/mcp/servers.yaml` can be
211
+ hand-authored the same way (see
212
+ [`schema/servers.example.yaml`](../schema/servers.example.yaml) for the
213
+ full shape), or use `trellis mcp add` (below). Either way, move on to
214
+ [`trellis mcp sync`](#trellis-mcp-sync) once you've added what you want.
215
+
216
+ ## `trellis skill` / `trellis mcp` — canonical CRUD via the CLI
217
+
218
+ An alternative to hand-editing canonical files directly — useful for
219
+ scripting, or when you'd rather not open a text editor for a one-line
220
+ change. Both commands are canonical-side only: they never touch any
221
+ agent's native config (that stays `sync`/`mcp sync`'s job).
222
+
223
+ ```
224
+ $ trellis skill list
225
+ my-skill — claude-code, codex, pi
226
+
227
+ $ trellis skill add my-other-skill --from ./some/local/dir
228
+ skill add my-other-skill
229
+ [create] will copy from ./some/local/dir
230
+
231
+ $ trellis skill remove my-other-skill
232
+ removed skill "my-other-skill" from canonical source.
233
+ ```
234
+
235
+ `skill add` refuses (no write) if the name already exists with different
236
+ content — same conflict posture as `migrate`, never silently overwritten.
237
+ `skill remove` deletes the canonical directory; the *next* `trellis sync`
238
+ then auto-removes the now-stale symlink on every agent that had it (skills
239
+ carry their own ownership marker — the symlink itself — so this
240
+ propagates automatically, unlike MCP servers below).
241
+
242
+ ```
243
+ $ trellis mcp list
244
+ tanka (http) — codex, pi
245
+ env: TANKA_TOKEN (values never read/printed)
246
+
247
+ $ trellis mcp add local-server --transport stdio --command node --args server.js --env API_KEY
248
+ mcp add local-server
249
+ [create] will add to servers.yaml
250
+
251
+ $ trellis mcp remove local-server
252
+ removed MCP server "local-server" from canonical source.
253
+ ```
254
+
255
+ `mcp add` takes `--transport stdio|http|sse`; stdio requires `--command`
256
+ (plus optional `--args a,b`, `--env NAME,...`, `--static-env k=v,...`),
257
+ http/sse require `--url` (plus optional `--headers k=v,...`, values
258
+ expected as `${VAR}` references, never literal secrets). Both accept
259
+ `--agents id,...` (scope) and `--enabled true|false`. Same no-overwrite
260
+ conflict posture as `skill add` — there is no `--force`.
261
+
262
+ `mcp remove` is **canonical-only** — it does not remove the server from
263
+ any agent that already has it from an earlier `mcp sync` (the same
264
+ no-automatic-removal gap `mcp sync` itself has — see
265
+ [README's Known limitations](../README.md#status)); remove it by hand on
266
+ each agent in the meantime.
267
+
268
+ `mcp list` never resolves or prints a secret value: `env` names are shown
269
+ as bare names (the actual value is never read from your shell), and
270
+ `static_env` values are shown in full since those were never secrets in
271
+ the first place (see `mcp sync`'s own explanation of `env` vs.
272
+ `static_env`, further below).
273
+
274
+ All four of `skill add`/`skill remove`/`mcp add`/`mcp remove` support
275
+ `--dry-run` and `--json`, same convention as every other command.
158
276
 
159
277
  ## `trellis sync`
160
278
 
@@ -220,6 +338,53 @@ already populates them. See
220
338
  for exactly how that resolution works for each agent, including the one
221
339
  narrow exception (pi's bridge has to read a value into its own process).
222
340
 
341
+ Before writing any name-only `env` entry, `mcp sync` checks it actually
342
+ resolves — a name with no value anywhere `secrets audit` would also
343
+ check is refused as a conflict for that one server, not written and left
344
+ to silently break that server once the agent tries to use it. Every
345
+ other server, and every other agent, still syncs normally.
346
+
347
+ For a value that isn't a secret at all — an email address, an
348
+ environment tag — use `static_env` instead of `env`: written into the
349
+ agent's config verbatim, never treated as a name to resolve (still
350
+ scanned for an accidental real credential, same as every other literal
351
+ field). `enabled: false` keeps a server's definition in canonical
352
+ without writing it to any agent — for something you want configured but
353
+ not currently active anywhere; remove the line (or set it `true`) to
354
+ turn it back on everywhere at once. See
355
+ [`schema/servers.example.yaml`](../schema/servers.example.yaml) for both.
356
+
357
+ ## `trellis memory sync`
358
+
359
+ Ingests `~/.trellis/memories/*.md` into the actual on-disk file
360
+ `@modelcontextprotocol/server-memory` reads at its own startup — closing
361
+ the gap between "memory entries exist in canonical" and "the running
362
+ memory server actually knows about them." Requires a `memory` server in
363
+ `servers.yaml` with `static_env.MEMORY_FILE_PATH` set explicitly (see
364
+ [`schema/servers.example.yaml`](../schema/servers.example.yaml)); without
365
+ one, this is a no-op, not an error.
366
+
367
+ ```
368
+ $ trellis memory sync
369
+ memory sync — /Users/you/.trellis/memories/graph.jsonl
370
+ [create] "sprint-tasks" — will create a new entity
371
+ ```
372
+
373
+ Every entry Trellis creates is tagged internally so a later sync can
374
+ safely update or remove it; anything else already in that file — an
375
+ entity or relation an agent added itself while actually using the memory
376
+ server — is never touched. If a name collides with something already in
377
+ the graph that Trellis didn't create, the command refuses that one entry
378
+ (reported as a conflict) rather than overwriting it, same posture as
379
+ every other conflict in this project.
380
+
381
+ **Known limitation, named rather than silently worked around:** this only
382
+ ingests canonical's *own* `memories/*.md` files into the shared store —
383
+ it does not (yet) extract an agent's own already-accumulated memory
384
+ content (e.g. Claude Code's own per-project memory feature) back into
385
+ canonical. That extraction is a real, separate, still-open gap — see
386
+ `docs/roadmap.md`'s P15 entry.
387
+
223
388
  ## `trellis secrets audit`
224
389
 
225
390
  ```
package/docs/roadmap.md CHANGED
@@ -600,6 +600,334 @@ exactly that one path as a `conflict` (exit 1) while still correctly
600
600
  restoring the other two, untouched, unaffected paths in the same
601
601
  invocation.
602
602
 
603
+ **`trellis-mcp-static-env-and-disabled-servers`, implemented and
604
+ sandbox-verified** (modifies `mcp-server-sync`). Found by dogfooding
605
+ `mcp sync` against a real machine's actual, actively-used Codex
606
+ `config.toml` rather than a fixture: `[mcp_servers.supabase_db]` had
607
+ `enabled = false` (a definition kept on hand, deliberately off — no way
608
+ to represent that in canonical short of deleting it), and
609
+ `[mcp_servers.tanka]` used a hardcoded literal `env` table, not the
610
+ `env_vars` name-forwarding array every other real server on the same
611
+ machine uses. Migrating `tanka` as-is into the old names-only model
612
+ would have had `mcp sync` rewrite a working config into a broken one —
613
+ `TANKA_EMAIL`/`TANKA_ENV` were never real process env vars, just plain
614
+ values written straight into the file — and neither `mcp sync` nor
615
+ `secrets audit` would have caught it before the write happened.
616
+
617
+ `McpServerDef` gained `enabled?: boolean` (filtered in `resolveMcpPlan`
618
+ itself, the one choke point every adapter already funnels through — a
619
+ disabled server gets no write and no conflict, not just on Codex but on
620
+ every agent) and `staticEnv?: Record<string, string>` for a value that
621
+ was never a secret in the first place — Codex renders it as an adjacent
622
+ `[mcp_servers.<name>.env]` table (extending `tomlSection.ts`'s section
623
+ boundary logic to treat the pair as one atomic create/repair/remove
624
+ unit), Claude Code/Kiro merge it into the same `env` map their `${VAR}`
625
+ references already use. `resolveMcpPlan` also gained a mandatory fourth
626
+ `policy: SecretsPolicy` parameter: before writing any name-only `env`
627
+ entry, it now resolves it through the same `resolveSecretEnv` `secrets
628
+ audit`/the pi bridge already call, refusing (as a new conflict scoped to
629
+ just that server, on just that agent) a name that wouldn't actually
630
+ resolve — the exact silent-breakage scenario `tanka` would have hit.
631
+
632
+ The on-disk YAML key is `static_env` (snake_case, matching every other
633
+ multi-word key across `.trellis/*.yaml` — `known_host_injected`,
634
+ `allowed_vars` — translated to camelCase in `loadServersYaml`), a real
635
+ gap the design doc missed until implementation: `McpServerDef` had
636
+ always been parsed as-is with zero field translation, since every prior
637
+ field name happened to already be a single word.
638
+
639
+ Sandbox-verified, not just unit-tested: reproducing this machine's exact
640
+ `tanka`/`supabase-db` shapes in `test/fixtures/home` and running a real
641
+ `mcp sync` inside `scripts/sandbox.sh` produced Codex's two-table output
642
+ byte-for-byte identical to the real, working config this change was
643
+ motivated by — and surfaced one more real regression before it could
644
+ ship: the sandbox's own pre-existing fixture servers declare `env` names
645
+ that don't resolve inside the container, which the new pre-write check
646
+ would have refused outright. Fixed by exporting their fixture values in
647
+ `docker/entrypoint.sh`, matching what a real working setup would
648
+ actually have — found only because the fixture was actually run, not
649
+ just reasoned about.
650
+
651
+ Also removed a dead field from `schema/servers.example.yaml`: the
652
+ `figma` example's `auth: oauth` was never a real property on
653
+ `McpServerDef`, never read by any adapter, and never asserted by any
654
+ spec — the YAML parser has no field validation, so it silently did
655
+ nothing. No real OAuth support exists; the line taught a capability that
656
+ was never there.
657
+
658
+ **P11 is done and archived**
659
+ (`openspec/changes/archive/2026-09-13-trellis-migrate-category-selection/`;
660
+ modifies `canonical-source-migration` and `onboarding-flow`). `trellis
661
+ migrate --from <agent>` always planned skills and instructions
662
+ together, one unit, no subset selection — no CLI flag, and no
663
+ interactive picker for this choice either. `--only skills|instructions`
664
+ now restricts a run to just one category, filtered inside
665
+ `collectMigratePlan` itself (not computed then discarded) — the
666
+ excluded kind is never read for comparison and never appears in the
667
+ plan. `trellis onboard`'s migrate step gained a checkbox reusing
668
+ `src/lib/terminalPicker.ts` (the same module `trellis-onboard-
669
+ interactive-picker` built), but only offered when the resolved source
670
+ actually has both real skills and real instructions — a source with
671
+ only one real kind, or `--json`, or a terminal that can't support the
672
+ picker all default silently to migrating whichever kind(s) actually
673
+ have content, with no second, numbered-text fallback UI built, since
674
+ this choice never existed before this change to have a fallback for. An
675
+ empty selection is a valid, distinct outcome ("migrate skipped — no
676
+ categories selected"), not an error, and doesn't stop sync/mcp sync/
677
+ secrets audit from running. A real gap named rather than silently
678
+ folded in: `codex`/`pi`/`kiro` as migration sources remain untested —
679
+ `test/unit/migrate.test.ts` only ever exercises `claude-code`, despite
680
+ `collectMigratePlan`'s own dispatch being fully symmetric by design.
681
+ That's P13's job. Verified with 22 new unit tests (275/275 project-wide,
682
+ zero regressions) — no sandbox pass needed, this change touches no
683
+ adapter or native-config write path.
684
+
685
+ **P12 (✅ done, archived
686
+ [2026-09-13-trellis-canonical-cli-crud](../openspec/changes/archive/2026-09-13-trellis-canonical-cli-crud/)):
687
+ canonical CRUD via CLI.** Neither skills nor MCP servers had any
688
+ command-line add/remove/list surface — `cli.ts`'s `mcp` command
689
+ recognized exactly one subcommand, `sync`, and there was no `skill`
690
+ command at all. `trellis skill list/add/remove` and `trellis mcp
691
+ list/add/remove` are new, canonical-side-only commands (a new
692
+ `canonical-content-management` capability): `skill add`/`mcp add`
693
+ refuse (no write, no `--force`) on an existing name with different
694
+ content, mirroring `migrate`'s own conflict posture exactly, reusing
695
+ the same comparison logic via a newly-extracted
696
+ `decideDirImport(sourceDir, canonicalDir)` (src/lib/dirEquals.ts),
697
+ which also replaced `migrate.ts`'s own inline check
698
+ (behavior-preserving, confirmed by its full pre-existing suite passing
699
+ unmodified). `servers.yaml`'s new writer (`upsertServerYaml`/
700
+ `removeServerYaml` in `canonical.ts`) uses the `yaml` package's
701
+ `Document`-based `parseDocument`/`setIn`/`deleteIn`/`toString`, never a
702
+ full parse-then-restringify, specifically so a hand-authored file's
703
+ comments and untouched entries survive byte-for-byte — verified with a
704
+ dedicated test asserting exactly that. `skill remove` needed zero new
705
+ removal-propagation code: skills already carry an ownership marker (the
706
+ symlink itself), so `sync`'s pre-existing stale-symlink detection
707
+ un-syncs a removed skill automatically on the next run (proven
708
+ end-to-end in a test). `mcp remove` stays canonical-only by design —
709
+ MCP has no such marker yet, so an already-synced agent's native config
710
+ is untouched until P14 closes that gap. `mcp list` never resolves a
711
+ secret: `env` entries print as bare names (never read from
712
+ `process.env`), while `static_env` values print in full since they were
713
+ never secrets by `McpServerDef`'s own contract. Verified with 21 new
714
+ unit tests (296/296 project-wide, zero regressions) plus manual
715
+ smoke-testing of every subcommand (list/add/remove, conflict/
716
+ already-present/invalid-input, `--dry-run`, `--json`) against a
717
+ throwaway sandbox `$HOME` — no sandbox-container pass needed, this
718
+ change touches no adapter or native-config write path.
719
+
720
+ **P13 (✅ done, archived
721
+ [2026-09-13-trellis-real-sandbox-verification](../openspec/changes/archive/2026-09-13-trellis-real-sandbox-verification/)):
722
+ sandbox verification against this machine's real state.** Every sandbox
723
+ run before this (`scripts/sandbox.sh`, `docker/entrypoint.sh`) mounted
724
+ the same single, git-tracked synthetic fixture (`test/fixtures/home`).
725
+ Compounding this, `test/unit/migrate.test.ts` only ever exercised
726
+ `"claude-code"` as a migration source — codex/pi/kiro had never been
727
+ verified as sources even though `collectMigratePlan`'s `PROBES:
728
+ Record<AgentId, ...>` dispatch is fully symmetric by design (claude-code
729
+ was, however, already covered as a sync/mcp-sync *target*, in
730
+ `test/unit/sync.test.ts`/`test/unit/mcp.test.ts` — the earlier draft of
731
+ this entry claimed otherwise; corrected here after checking, not
732
+ assumed). `scripts/sandbox.sh --real` builds a throwaway snapshot from a
733
+ real `$HOME` using an **allowlist**, not the denylist first sketched here
734
+ — every path each probe (`src/probes/*.ts`) is already confirmed to
735
+ read, and nothing else, since this project has no complete knowledge of
736
+ where third-party agents' own real OAuth token flows store credentials
737
+ (design.md D1 explains the reasoning). Actually running this against a
738
+ real, in-use machine (not just reasoning about it) found two real bugs
739
+ before it could even complete: `sync`'s own real output is a symlink
740
+ back into `~/.trellis/`, which a naive symlink-preserving copy leaves
741
+ dangling once mounted into a container with no such path — fixed by
742
+ dereferencing during copy; and a case-insensitive filesystem (macOS
743
+ default) collides two of pi's own case-sensitive instructions-file
744
+ candidates (`AGENTS.md`/`AGENTS.MD`), which needed a
745
+ dest-already-exists guard to avoid a crash. Before any Docker build, the
746
+ snapshot is gated through `trellis secrets audit`'s own, already-shipped
747
+ `homeDir` seam — on the actual real-machine run, this correctly found 5
748
+ genuine `unexpected-var-name` findings and refused to proceed, exactly
749
+ as designed; clearing that machine's own `secrets.policy.yaml` gap and
750
+ running a full container pass against it remains a separate,
751
+ human-initiated action, not something this change forced through.
752
+ codex/kiro/pi as migrate sources are now covered in
753
+ `test/unit/migrateSources.test.ts`, using each probe's own confirmed
754
+ real dotfile paths. Verified with 10 new unit tests (306/306
755
+ project-wide) plus the fixture-based (default, non-`--real`)
756
+ `scripts/sandbox.sh` re-run end-to-end against Docker to confirm zero
757
+ regression to the existing path.
758
+
759
+ **P14 (✅ done — removal half only, archived
760
+ [2026-09-13-trellis-mcp-sync-removal](../openspec/changes/archive/2026-09-13-trellis-mcp-sync-removal/);
761
+ migrate-in remains open, see below): MCP server lifecycle parity with
762
+ skills.** Skills have a full migrate (import) + sync (create/repair) +
763
+ conflict story; MCP had sync only. This was always two distinct, real
764
+ gaps, not one — this change closed the harder, more clearly-specified
765
+ half: `resolveMcpPlan` was deliberately create/repair-only
766
+ (`mcpPlan.ts`'s own stated reasoning: a bare TOML/JSON key has no
767
+ ownership marker to prove Trellis, not the user, put it there), so
768
+ removing a server from `servers.yaml` never removed it from any
769
+ agent's native config. A new `src/lib/mcpOwnership.ts` ledger
770
+ (`~/.trellis/mcp/ownership.json`) records, per agent and server name,
771
+ the exact rendered value Trellis itself last wrote; on a later sync, a
772
+ name gone from canonical is only actually removed from an agent's
773
+ native config if that config's current entry still exactly matches
774
+ what the ledger recorded — a hand-edited entry is left alone,
775
+ indefinitely, never forced. Reused rather than rebuilt:
776
+ `src/lib/tomlSection.ts`'s `removeSection` (built earlier for the
777
+ static-env atomic-range work, never wired into an actual removal path
778
+ until now) for Codex, and each JSON agent's own existing `deepEqual`
779
+ for the "unchanged since" check. **Still genuinely open, not done
780
+ here:** MCP *migrate-in* (importing an already-hand-configured server
781
+ into canonical) — each static-config probe (`codex.ts`/`claude-code.ts`/
782
+ `kiro.ts`) still discards a server's complete real definition (e.g.
783
+ `codex mcp list --json`'s `CodexMcpEntry`, with `command`/`args`/
784
+ `env_vars` intact) down to `{name, transport, probe}`
785
+ (`AgentSnapshotMcpServer`) before anything downstream sees it, and
786
+ there is still no conversion from that discarded, richer shape into
787
+ canonical's `McpServerDef`. pi has no static config to read at all —
788
+ inherently import-less; P12's CLI remains its only route in. A real
789
+ gap, named rather than silently dropped, left for a future change.
790
+ Verified with 3 new/rewritten unit tests across both Claude Code's JSON
791
+ path and Codex's TOML path (308/308 project-wide, zero regressions).
792
+
793
+ **P15 (✅ done — ingestion half only, archived
794
+ [2026-09-13-trellis-memory-sync](../openspec/changes/archive/2026-09-13-trellis-memory-sync/);
795
+ per-agent extraction remains open, see below): shared memory — real
796
+ ingestion and per-agent extraction.** `memories` was parsed
797
+ (`canonical.ts`) into `CanonicalSource.memories: MemoryEntry[]` and
798
+ consumed nowhere — no adapter, no command, no `trellis memory` CLI
799
+ surface existed at all. P6 explicitly left "auto-ingesting
800
+ `~/.trellis/memories/*.md` content into the running memory server's
801
+ store" out of scope; `trellis memory sync` closes that gap (A): each
802
+ canonical memory file becomes one entity
803
+ (`entityType: "trellis-memory"`) in `@modelcontextprotocol/
804
+ server-memory`'s own on-disk JSON-lines graph file (the exact file that
805
+ server itself reads at startup — Trellis never spawns or talks to a
806
+ running server process, a plain file write like everything else this
807
+ project does), requiring `mcp/servers.yaml`'s `memory` server to set
808
+ `static_env.MEMORY_FILE_PATH` explicitly (the server's own unset-env
809
+ default resolves relative to wherever `npx` cached the package, not a
810
+ predictable location). The in-band `entityType` tag is the ownership
811
+ marker — deliberately not a separate ledger file like P14's MCP
812
+ removal, since (unlike per-agent MCP config) every agent connected to
813
+ this one server shares the exact same graph, so there's no per-agent
814
+ render to track. Every entity/relation Trellis didn't create is left
815
+ completely untouched, unconditionally; a name collision with a
816
+ non-Trellis-tagged entity is a conflict, never overwritten. **Still
817
+ genuinely open, not done here (B):** extracting an agent's own
818
+ already-accumulated memory content back into canonical — e.g. Claude
819
+ Code's own per-project memory feature. Investigated, not attempted, for
820
+ two concrete reasons: that content lives under
821
+ `~/.claude/projects/<project-slug>/memory/`, a path
822
+ `trellis-real-sandbox-verification`'s own allowlist already deliberately
823
+ excludes (mixed with real session transcripts); and the project-slug
824
+ encoding scheme Claude Code uses to derive that path from a working
825
+ directory has no authoritative documented source this project could
826
+ verify against, so it was not guessed at (this project's own "verify,
827
+ don't assume" discipline). Kiro's `totalrecallai` (SQLite + local vector
828
+ embeddings) is a further, genuinely different data shape, and was never
829
+ in scope for this half either. A real gap, named rather than silently
830
+ dropped, left for a future change. Verified with 14 new unit tests
831
+ (322/322 project-wide, zero regressions).
832
+
833
+ **P16 (✅ done, archived
834
+ [2026-09-13-trellis-migrate-mcp-servers](../openspec/changes/archive/2026-09-13-trellis-migrate-mcp-servers/)):
835
+ MCP migrate-in — the gap P14 named.** `trellis migrate --from <agent>`
836
+ gains a third `--only` value, `mcp`: claude-code, kiro, and codex each
837
+ get a new, purpose-built reader (`src/lib/mcpMigrateRead.ts`) that
838
+ converts that agent's real, already-configured MCP servers into
839
+ canonical's `McpServerDef` shape — kept entirely separate from each
840
+ probe's own thin `AgentSnapshotMcpServer` (`doctor`'s read path, left
841
+ untouched). Same conflict posture as skill/instructions migration
842
+ throughout: identical is a no-op, a differing definition under the same
843
+ name is a conflict, never overwritten. pi is still never a migrate-in
844
+ source — no static config to read, unchanged from P14's own framing.
845
+ Two fidelity limits, found by actually running each agent's real
846
+ tooling rather than assumed, are handled by refusing rather than
847
+ guessing: **Codex is stdio-transport only at first** (closed as a
848
+ same-day follow-up, see below) — `codex mcp list --json` had no
849
+ evidence in this codebase for any other transport shape at design time;
850
+ a real, one-off run against a locally-installed `codex-cli 0.154.0`
851
+ during implementation *did* observe a `streamable_http` shape (`url`,
852
+ `bearer_token_env_var`, plus three further undocumented fields) for a
853
+ hand-written `url`-based server — recorded as a concrete lead for a
854
+ future change, not built at first, since one data point from one
855
+ version isn't a contract; a non-stdio Codex server was reported
856
+ `skip-unsupported`, named, not silently dropped. **Codex's `static_env`**
857
+ is recovered by reading `[mcp_servers.<name>.env]` directly from
858
+ `config.toml` (a new `readServerEnvTable` in `tomlSection.ts`, the exact
859
+ inverse of that module's own existing writer) since `codex mcp list
860
+ --json` only ever reports variable *names*, never the literal table
861
+ Trellis itself renders separately. **`headers`** — missing from both
862
+ `claude-code.ts`'s and `kiro.ts`'s own probe-facing JSON types despite
863
+ being a real field Trellis's own writer (`jsonMcp.ts`) already produces
864
+ for http/sse servers — is recovered via a parallel, richer local type
865
+ scoped only to this new reader module, leaving both probes' existing,
866
+ `doctor`-tested types untouched. Verified with 19 new unit tests,
867
+ including one exercising the real, locally-installed `codex` binary
868
+ end-to-end against a scratch, HOME-scoped `.codex/config.toml` (341/341
869
+ project-wide, zero regressions). One related, pre-existing gap
870
+ surfaced but deliberately not fixed at first (out of scope for this
871
+ change, named instead): `src/probes/codex.ts`'s own `probe()` never
872
+ scoped its `codex mcp list --json` subprocess call to a passed-in
873
+ `homeDir` via `HOME` env override the way this change's own new reader
874
+ did — meaning that probe's MCP listing always reflected the real
875
+ machine's real codex config regardless of what `homeDir` a caller passed
876
+ it, an inconsistency with the rest of `probe()`'s own home-scoped reads.
877
+
878
+ **Same-day follow-up, both of the above closed (not a separately
879
+ numbered phase — direct fixes to already-shipped, already-specced
880
+ behavior, not new requirements, so no new OpenSpec change):**
881
+ `src/probes/codex.ts`'s `probe()` now scopes both of its `execFileSync`
882
+ calls to the passed-in `homeDir` the same way — verified with a new
883
+ regression test (`test/unit/codexProbe.test.ts`) using two distinct
884
+ scratch homes with two distinct MCP servers, confirming neither leaks
885
+ into the other; confirmed the test actually catches the bug by reverting
886
+ the fix and watching it fail against this real machine's real MCP
887
+ servers before restoring it. Separately, `buildCodexMcpReadResult`
888
+ (`src/lib/mcpMigrateRead.ts`) now converts a non-stdio Codex server when
889
+ it uses only `url` and, optionally, `bearer_token_env_var` — verified
890
+ this exact shape against the real `codex` binary a second time
891
+ (including with a real `bearer_token_env_var` set, not just a bare
892
+ `url`) — while a server using any of the three still-unexplained header
893
+ fields (`http_headers`/`env_http_headers`/`http_headers_helper`, all
894
+ `null` when unused, confirmed by the same real run) still refuses rather
895
+ than guesses. A migrated remote Codex server is always labeled `http`,
896
+ never `sse` — not a guess: Codex's own `config.toml` schema has no field
897
+ distinguishing the two (`upsertSection`'s own codex TOML rendering is
898
+ byte-identical for both transports, now covered by its own test), so
899
+ that distinction was never stored in the first place. 350/350 tests
900
+ passing.
901
+
902
+ **P17 (✅ done, archived
903
+ [2026-09-13-trellis-onboard-mcp-memory](../openspec/changes/archive/2026-09-13-trellis-onboard-mcp-memory/)):
904
+ `onboard` catches up to P15/P16 — mcp and memory were never wired into
905
+ its own orchestration.** Found by live dogfooding on a real machine:
906
+ `onboard`'s migrate-category picker hardcoded `["skills",
907
+ "instructions"]` — `mcp` was invisible to it regardless of how many real
908
+ MCP servers the source agent had — and `trellis memory sync` was never
909
+ called anywhere in `onboard`'s chain at all, despite its own docstring
910
+ naming a complete chain. `OnboardAgentSummary` gains an
911
+ `mcpServerCount`, computed via the already-exported
912
+ `collectMigratePlan(agent, homeDir, ["mcp"])` rather than a second reader
913
+ dispatch — so it always agrees with what `migrate` itself would do, and
914
+ an agent whose only real content is MCP servers is now a valid migration
915
+ source (previously invisible to `hasContent` entirely).
916
+ `resolveMigrateCategories` was rewritten from a fixed two-slot structure
917
+ to a dynamically-built list of whichever of skill/instructions/mcp have
918
+ real content, offering the checkbox only when two or more do — not a
919
+ bolted-on special case for a third option. `memory sync` runs as a new
920
+ final chained stage, after `mcp sync` and before `secrets audit`,
921
+ independent of the resolved managed-agent set (the memory server is
922
+ shared, not per-agent); `runMemorySync`'s inline printing was extracted
923
+ into an exported `printMemorySyncResult` so `onboard` reuses the exact
924
+ same output format rather than a second, drifting copy of it —
925
+ `memory.ts`'s own standalone command behavior is unchanged, confirmed by
926
+ its existing tests passing unmodified. No change to `migrate.ts`,
927
+ `mcp.ts`, or `memory.ts`'s own command behavior — purely `onboard`'s
928
+ orchestration layer catching up to commands that already existed. 357/357
929
+ tests passing.
930
+
603
931
  | Phase | Deliverable | Depends on |
604
932
  |---|---|---|
605
933
  | P0 | ✅ `trellis doctor` — read-only, opt-in-for-handshakes scan of all four agents' current skills/MCP/instructions state, reports drift and duplicates | nothing |
@@ -613,5 +941,18 @@ invocation.
613
941
  | P8 | ✅ Kiro `${VAR}` fix: adapter also manages `kiroAgent.mcpApprovedEnvVars`, without which Kiro silently never substitutes any env reference Trellis writes | P2 |
614
942
  | P9 | ✅ MCP transport/auth expansion: `headers` field for static bearer/API-key remote auth (Claude Code/Codex/Kiro/pi bridge, each via its own real schema), `sse` transport; real OAuth flows explicitly delegated to each agent's own native support, not reimplemented | P2, P4, P8 |
615
943
  | P10 | GUI: evaluate embedding into mcp-router's or skills-hub's existing interface before building anything new | P3–P9 |
944
+ | P11 | ✅ Migrate category selection: `--only skills\|instructions` on `migrate`, same interactive picker `onboard` already uses | P1 |
945
+ | P12 | ✅ Canonical CRUD via CLI: `trellis skill add/remove/list`, `trellis mcp add/remove/list` | P1, P2 |
946
+ | P13 | ✅ Sandbox verification against this machine's real, structurally-relevant state (not just fixtures); every agent verified as both migrate source and sync/mcp-sync target | P0–P2 |
947
+ | P14 | ✅ MCP server lifecycle parity with skills: ownership-tracked safe removal on sync (migrate-in closed separately by P16) | P2, P13 |
948
+ | P15 | ✅ Shared memory: ingest canonical `memories/*.md` into the `server-memory` store's own graph file (per-agent extraction into canonical remains a separate, open gap) | P6 |
949
+ | P16 | ✅ MCP migrate-in: `trellis migrate --only mcp` for claude-code/kiro/codex (codex remote transport + probe HOME-scoping closed same-day, see prose above), closing the gap P14 named | P12, P14 |
950
+ | P17 | ✅ `onboard` orchestration catches up to P15/P16: three-way migrate-category selection (mcp added), `memory sync` wired in as a chained stage | P15, P16 |
616
951
 
617
952
  No dates. This is scoped by verification milestones, not calendar time.
953
+ Execution order for P11–P16: P11 → P12 → P13 → P14 → P15 → P16 — CRUD
954
+ (P12) lands before the larger features (P14, P15, P16) so each can be
955
+ adjusted via command line instead of hand-edited files while it's being
956
+ built, and the realistic sandbox (P13) lands before all three so each
957
+ gets verified against real data once, immediately, instead of against
958
+ another synthetic fixture.