agent-trellis 0.1.0 → 0.2.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 (41) hide show
  1. package/README.md +57 -17
  2. package/dist/adapters/claude-code.d.ts +2 -1
  3. package/dist/adapters/claude-code.js +7 -7
  4. package/dist/adapters/codex.d.ts +2 -1
  5. package/dist/adapters/codex.js +7 -7
  6. package/dist/adapters/jsonMcp.d.ts +1 -0
  7. package/dist/adapters/jsonMcp.js +2 -2
  8. package/dist/adapters/kiro.d.ts +2 -1
  9. package/dist/adapters/kiro.js +9 -9
  10. package/dist/adapters/mcpPlan.d.ts +1 -1
  11. package/dist/adapters/mcpPlan.js +2 -2
  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 +41 -11
  17. package/dist/commands/init.js +11 -0
  18. package/dist/commands/mcp.d.ts +13 -0
  19. package/dist/commands/mcp.js +31 -7
  20. package/dist/commands/onboard.d.ts +42 -7
  21. package/dist/commands/onboard.js +207 -34
  22. package/dist/commands/rollback.d.ts +44 -0
  23. package/dist/commands/rollback.js +201 -0
  24. package/dist/commands/secretsAudit.d.ts +7 -0
  25. package/dist/commands/secretsAudit.js +14 -7
  26. package/dist/commands/sync.d.ts +13 -0
  27. package/dist/commands/sync.js +31 -5
  28. package/dist/core/adapter.d.ts +10 -3
  29. package/dist/core/adapter.js +2 -2
  30. package/dist/core/canonical.js +22 -0
  31. package/dist/core/types.d.ts +12 -1
  32. package/dist/core/types.js +11 -2
  33. package/dist/lib/backup.d.ts +56 -0
  34. package/dist/lib/backup.js +98 -0
  35. package/dist/lib/installAgent.d.ts +26 -0
  36. package/dist/lib/installAgent.js +46 -0
  37. package/dist/pi-bridge/bundle.js +26 -7
  38. package/dist/pi-bridge/index.js +8 -1
  39. package/docs/getting-started.md +104 -26
  40. package/docs/roadmap.md +133 -0
  41. package/package.json +1 -1
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Install-then-manage (trellis-managed-agents design.md D5): selecting a
3
+ * not-yet-present agent into the managed set is itself the authorization
4
+ * to install it, but never silently — one confirmation, then a real
5
+ * `npm install -g <package>` child process. Kiro has no npm package (a
6
+ * desktop download) and is refused before this module is ever reached.
7
+ */
8
+ import type { AgentId } from "../core/types.js";
9
+ /** Only agents with a real `npm install -g <pkg>` command — Kiro's own
10
+ * `INSTALL_HINTS` entry is a download URL, not a package, and is never
11
+ * looked up here. */
12
+ export declare const NPM_INSTALLABLE: Record<Exclude<AgentId, "kiro">, string>;
13
+ export interface ConfirmAndInstallOptions {
14
+ /** Test/real seam, same pattern as onboard's `promptForAgent` — a real
15
+ * terminal confirmation by default. */
16
+ confirm?: (agent: AgentId, pkg: string) => Promise<boolean>;
17
+ /** Test/real seam — never a real `npm install` in a unit test. */
18
+ runInstall?: (pkg: string) => void;
19
+ }
20
+ export interface ConfirmAndInstallResult {
21
+ installed: boolean;
22
+ /** False only when the agent has no npm package at all (Kiro) — the
23
+ * caller refuses this agent with its download URL instead of prompting. */
24
+ installable: boolean;
25
+ }
26
+ export declare function confirmAndInstall(agent: AgentId, opts?: ConfirmAndInstallOptions): Promise<ConfirmAndInstallResult>;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Install-then-manage (trellis-managed-agents design.md D5): selecting a
3
+ * not-yet-present agent into the managed set is itself the authorization
4
+ * to install it, but never silently — one confirmation, then a real
5
+ * `npm install -g <package>` child process. Kiro has no npm package (a
6
+ * desktop download) and is refused before this module is ever reached.
7
+ */
8
+ import { execFileSync } from "node:child_process";
9
+ import { createInterface } from "node:readline/promises";
10
+ /** Only agents with a real `npm install -g <pkg>` command — Kiro's own
11
+ * `INSTALL_HINTS` entry is a download URL, not a package, and is never
12
+ * looked up here. */
13
+ export const NPM_INSTALLABLE = {
14
+ "claude-code": "@anthropic-ai/claude-code",
15
+ codex: "@openai/codex",
16
+ pi: "@earendil-works/pi-coding-agent",
17
+ };
18
+ async function confirmReal(agent, pkg) {
19
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
20
+ try {
21
+ const answer = (await rl.question(`${agent} is not installed. Install it now (npm install -g ${pkg})? [y/N] `)).trim().toLowerCase();
22
+ return answer === "y" || answer === "yes";
23
+ }
24
+ finally {
25
+ rl.close();
26
+ }
27
+ }
28
+ function runInstallReal(pkg) {
29
+ // argv array, never a shell string — the package name is never
30
+ // interpolated into anything a shell parses.
31
+ execFileSync("npm", ["install", "-g", pkg], { stdio: "inherit" });
32
+ }
33
+ export async function confirmAndInstall(agent, opts = {}) {
34
+ if (agent === "kiro") {
35
+ return { installed: false, installable: false };
36
+ }
37
+ const pkg = NPM_INSTALLABLE[agent];
38
+ const confirm = opts.confirm ?? confirmReal;
39
+ const runInstall = opts.runInstall ?? runInstallReal;
40
+ const agreed = await confirm(agent, pkg);
41
+ if (!agreed) {
42
+ return { installed: false, installable: true };
43
+ }
44
+ runInstall(pkg);
45
+ return { installed: true, installable: true };
46
+ }
@@ -27254,8 +27254,9 @@ var ALL_AGENTS = [
27254
27254
  "kiro",
27255
27255
  "pi"
27256
27256
  ];
27257
- function resolveScope(scope) {
27258
- return scope ?? ALL_AGENTS;
27257
+ function resolveScope(scope, managedAgents) {
27258
+ const candidates = scope ?? managedAgents;
27259
+ return candidates.filter((id) => managedAgents.includes(id));
27259
27260
  }
27260
27261
 
27261
27262
  // src/core/canonical.ts
@@ -27319,6 +27320,22 @@ function loadSecretsPolicyYaml(path, homeDir) {
27319
27320
  envFile: parsed.env_file ? parsed.env_file.replace(/^~(?=$|\/)/, homeDir) : void 0
27320
27321
  };
27321
27322
  }
27323
+ function loadManagedYaml(path, diagnostics) {
27324
+ if (!existsSync(path)) {
27325
+ return [];
27326
+ }
27327
+ const parsed = (0, import_yaml.parse)(readFileSync(path, "utf-8")) ?? {};
27328
+ const raw = parsed.agents ?? [];
27329
+ const valid = [];
27330
+ for (const id of raw) {
27331
+ if (ALL_AGENTS.includes(id)) {
27332
+ valid.push(id);
27333
+ } else {
27334
+ diagnostics.push(`managed.yaml: "${id}" is not a recognized agent id \u2014 ignored`);
27335
+ }
27336
+ }
27337
+ return valid;
27338
+ }
27322
27339
  function scopeFor(map, name) {
27323
27340
  return map?.[name];
27324
27341
  }
@@ -27334,6 +27351,7 @@ function loadCanonicalSource(homeDir = homedir()) {
27334
27351
  );
27335
27352
  }
27336
27353
  const diagnostics = [];
27354
+ const managedAgents = loadManagedYaml(join(root, "managed.yaml"), diagnostics);
27337
27355
  const scopeYaml = loadScopeYaml(join(root, "scope.yaml"));
27338
27356
  const skillDirs = listSkillDirs(join(root, "skills"));
27339
27357
  const knownSkillNames = new Set(skillDirs.map((s) => s.name));
@@ -27368,6 +27386,7 @@ function loadCanonicalSource(homeDir = homedir()) {
27368
27386
  }
27369
27387
  return {
27370
27388
  instructionsFile: join(root, "agents.md"),
27389
+ managedAgents,
27371
27390
  skills,
27372
27391
  agents,
27373
27392
  memories,
@@ -27381,8 +27400,8 @@ function loadCanonicalSource(homeDir = homedir()) {
27381
27400
  }
27382
27401
 
27383
27402
  // src/core/adapter.ts
27384
- function isInScope(id, scope) {
27385
- return resolveScope(scope).includes(id);
27403
+ function isInScope(id, scope, managedAgents) {
27404
+ return resolveScope(scope, managedAgents).includes(id);
27386
27405
  }
27387
27406
 
27388
27407
  // src/lib/tomlSection.ts
@@ -27420,7 +27439,7 @@ function findLiteralSecret(def) {
27420
27439
  }
27421
27440
  return void 0;
27422
27441
  }
27423
- function resolveMcpPlan(agentId, mcp) {
27442
+ function resolveMcpPlan(agentId, mcp, managedAgents) {
27424
27443
  if (mcp.hub) {
27425
27444
  if (mcp.knownHostInjected.includes(HUB_ENTRY_NAME)) {
27426
27445
  return { desired: [], conflicts: [{ name: HUB_ENTRY_NAME, message: collisionMessage(HUB_ENTRY_NAME, agentId) }] };
@@ -27430,7 +27449,7 @@ function resolveMcpPlan(agentId, mcp) {
27430
27449
  const desired = [];
27431
27450
  const conflicts = [];
27432
27451
  for (const [name, def] of Object.entries(mcp.servers)) {
27433
- if (!isInScope(agentId, def.agents)) {
27452
+ if (!isInScope(agentId, def.agents, managedAgents)) {
27434
27453
  continue;
27435
27454
  }
27436
27455
  if (mcp.knownHostInjected.includes(name)) {
@@ -32010,7 +32029,7 @@ function registerServerTools(pi, serverName, client, timeoutMs) {
32010
32029
  }
32011
32030
  async function trellisMcpBridge(pi, homeDir = homedir2(), connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS) {
32012
32031
  const canonical = loadCanonicalSource(homeDir);
32013
- const { desired } = resolveMcpPlan("pi", canonical.mcp);
32032
+ const { desired } = resolveMcpPlan("pi", canonical.mcp, ALL_AGENTS);
32014
32033
  const clients = /* @__PURE__ */ new Set();
32015
32034
  const closeClient = async (client) => {
32016
32035
  if (!clients.delete(client)) return;
@@ -20,6 +20,7 @@ import { loadCanonicalSource } from "../core/canonical.js";
20
20
  import { resolveMcpPlan } from "../adapters/mcpPlan.js";
21
21
  import { resolveSecretEnv } from "../lib/secretEnv.js";
22
22
  import { extractTemplateVarNames } from "../lib/envVarNames.js";
23
+ import { ALL_AGENTS } from "../core/types.js";
23
24
  import { bridgedToolName, toParametersSchema, toPiContent } from "./schemaTranslate.js";
24
25
  const CLIENT_INFO = { name: "trellis-mcp-bridge", version: "0.0.0" };
25
26
  const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
@@ -128,7 +129,13 @@ function registerServerTools(pi, serverName, client, timeoutMs) {
128
129
  */
129
130
  export default async function trellisMcpBridge(pi, homeDir = homedir(), connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS) {
130
131
  const canonical = loadCanonicalSource(homeDir);
131
- const { desired } = resolveMcpPlan("pi", canonical.mcp);
132
+ // Deliberately ALL_AGENTS, not canonical.managedAgents: this bridge only
133
+ // ever runs because pi itself loaded the extension — that's already the
134
+ // strongest possible consent signal (trellis-managed-agents design.md),
135
+ // independent of whether `trellis onboard` was ever run to add pi to
136
+ // managed.yaml. managedAgents governs static config-file writes; this
137
+ // is pi reading canonical directly at its own runtime, P4's own concern.
138
+ const { desired } = resolveMcpPlan("pi", canonical.mcp, ALL_AGENTS);
132
139
  const clients = new Set();
133
140
  const closeClient = async (client) => {
134
141
  if (!clients.delete(client))
@@ -16,39 +16,59 @@ $ trellis onboard
16
16
  ```
17
17
 
18
18
  Runs `init`, detects which of Claude Code/Codex/Kiro/pi are on this machine,
19
- picks one as the migration base, then runs `migrate` and `sync` against it.
20
-
21
- - **No agent detected**: prints each agent's real install command/URL and
22
- stops. Never installs anything itself — that's your call.
23
- - **Exactly one agent detected**: auto-selected as the base, no prompt.
24
- - **Two or more detected**: prompts you to pick one (if you're at a real
25
- terminal), or pass `--agent <id>` to skip the prompt — useful in scripts,
26
- CI, or when running with `--json`, which never prompts.
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:
22
+
23
+ 1. **Migration source** read from, at most one, never written back to.
24
+ - **No agent has real content**: skipped canonical starts from `init`'s
25
+ placeholder.
26
+ - **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.
29
+ 2. **Managed set** — zero or more agents to actually write to. Always an
30
+ explicit choice: pass `--manage <ids>` (comma-separated, e.g. `--manage
31
+ pi,codex`) or `--manage none`, or answer the numbered multi-select prompt.
32
+ **The source is not included by default** — migrating from Claude Code
33
+ doesn't mean Trellis starts managing Claude Code too, unless you say so.
34
+ Selecting an agent that isn't installed yet is itself the authorization to
35
+ install it (one confirmation, then a real `npm install -g <package>`);
36
+ Kiro has no CLI package and is refused with its download URL instead.
27
37
 
28
38
  ```
29
- $ trellis onboard --agent claude-code
30
- Using claude-code as the migration base (--agent).
39
+ $ trellis onboard --agent claude-code --manage pi
40
+ Using claude-code as the migration source (--agent).
41
+ Managed agents: pi
31
42
 
32
43
  migrate --from claude-code
33
44
  [create] skill "my-skill" — will copy from /Users/you/.claude/skills/my-skill
34
45
  ...
35
46
 
36
47
  sync
37
- codex — 1 created, 0 removed, 0 conflict(s)
48
+ pi — 1 created, 0 removed, 0 conflict(s)
38
49
  ...
39
50
 
40
- Next: `trellis mcp sync` to distribute MCP servers, `trellis secrets audit` to check for leaked credentials.
51
+ mcp sync
52
+ ✅ pi — already in sync
53
+
54
+ secrets audit
55
+ ✅ no findings — every present agent's real config and every declared env var passed all checks
41
56
  ```
42
57
 
43
- Add `--dry-run` to preview the entire chain init/migrate/sync with zero
44
- writes anywhere.
58
+ No agent named `claude-code` appears in the `sync`/`mcp sync` output above
59
+ it's present and was the migration source, but it isn't managed, so it's
60
+ never even probed as a sync target, not just left with zero items.
61
+
62
+ 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).
45
66
 
46
- **Picking a base agent only picks one.** If you use two or more agents with
47
- genuinely different real content, onboard migrates from the one you (or it)
48
- chose; the others' own differing content is untouched, exactly as `migrate`
49
- would report it if run against them directly (see the conflict table
50
- below). Merging differing content across multiple agents into one result
51
- isn't built yet — see [README's Status](../README.md#status).
67
+ **A managed agent's own real content still isn't overwritten.** If you
68
+ explicitly include the source in `--manage`, sync still never overwrites
69
+ its real files see the conflict table below. Merging differing content
70
+ across multiple agents into one canonical result isn't built yet see
71
+ [README's Status](../README.md#status).
52
72
 
53
73
  The rest of this page is the same flow broken into its individual steps —
54
74
  useful if you want more control over any one part, or just want to
@@ -138,8 +158,10 @@ before moving on to `sync`.
138
158
 
139
159
  ## `trellis sync`
140
160
 
141
- Distributes canonical skills and instructions to every agent present on this
142
- machine:
161
+ Distributes canonical skills and instructions to every **managed** agent
162
+ (`~/.trellis/managed.yaml` — empty by default; `trellis onboard` writes it,
163
+ or edit it yourself). A present-but-unmanaged agent gets no report line at
164
+ all, not just zero items:
143
165
 
144
166
  ```
145
167
  $ trellis sync
@@ -162,12 +184,18 @@ canonical, that's what `migrate` is for.
162
184
  Run `trellis sync skills` or `trellis sync instructions` to distribute just
163
185
  one half. Add `--dry-run` (in any position — `trellis sync --dry-run` and
164
186
  `trellis sync skills --dry-run` both work) to preview the plan with zero
165
- writes.
187
+ writes (and, per the same rule, records nothing to back up — see
188
+ `trellis rollback` below).
189
+
190
+ Every create/repair/remove this actually performs is recorded first,
191
+ automatically, so `trellis rollback` can undo the whole run later — see
192
+ [`trellis rollback`](#trellis-rollback--undoing-a-syncmcp-synconboard-run)
193
+ below.
166
194
 
167
195
  ## `trellis mcp sync`
168
196
 
169
- Distributes `~/.trellis/mcp/servers.yaml` to every present agent's native
170
- MCP config. See [`schema/servers.example.yaml`](../schema/servers.example.yaml)
197
+ Distributes `~/.trellis/mcp/servers.yaml` to every **managed** agent's
198
+ native MCP config — same restriction as `sync`, see above. See [`schema/servers.example.yaml`](../schema/servers.example.yaml)
171
199
  for the full documented shape — server definitions, per-agent scoping,
172
200
  known-host-injected collision avoidance, and hub mode.
173
201
 
@@ -180,6 +208,11 @@ does not remove it from any agent's native config yet (see
180
208
  [README's Known limitations](../README.md#status)). Remove it by hand on
181
209
  each agent in the meantime.
182
210
 
211
+ Every native-config file this rewrites in place is snapshotted first,
212
+ automatically — see
213
+ [`trellis rollback`](#trellis-rollback--undoing-a-syncmcp-synconboard-run)
214
+ below to undo a run that turned out to be wrong.
215
+
183
216
  `env:` in `servers.yaml` lists variable **names** only, never literal
184
217
  values — the real values come from wherever your shell/secret manager
185
218
  already populates them. See
@@ -193,7 +226,8 @@ narrow exception (pi's bridge has to read a value into its own process).
193
226
  $ trellis secrets audit
194
227
  ```
195
228
 
196
- Scans every present agent's **real, on-disk** config (never canonical) for:
229
+ Scans every **managed** agent's **real, on-disk** config (never canonical)
230
+ for — same restriction as `sync`, see above:
197
231
 
198
232
  1. A literal value matching one of `secrets.policy.yaml`'s
199
233
  `reject_patterns` (a credential-shaped string that should have been a
@@ -220,6 +254,50 @@ Safe to run any time; nothing here writes anything.
220
254
  stdio MCP server (some reaching real external services) — not something a
221
255
  "just check my config" command should do by default.
222
256
 
257
+ ## `trellis rollback` — undoing a `sync`/`mcp sync`/`onboard` run
258
+
259
+ Every real write those three commands perform is recorded, before it
260
+ happens, to a structured run directory under `~/.trellis/backups/` — no
261
+ flag needed, this is always on for any run that actually writes
262
+ something. `--dry-run` never creates one, since nothing was written.
263
+
264
+ ```
265
+ $ trellis rollback
266
+ ```
267
+
268
+ Omitting a run id targets the most recent run. Pass one explicitly to
269
+ undo an older run — see `trellis rollback --list` for what's available:
270
+
271
+ ```
272
+ $ trellis rollback --list
273
+ 2026-09-13T04-52-18-727Z-mcp-sync — mcp-sync, 3 operation(s), 2026-09-13T04:52:18.727Z
274
+ 2026-09-13T04-44-56-967Z-sync — sync, 1 operation(s), 2026-09-13T04:44:56.967Z
275
+ ```
276
+
277
+ For each recorded operation, rollback checks whether that path's
278
+ **current** state still matches what the run itself left behind:
279
+
280
+ | Current state vs. recorded | Result |
281
+ |---|---|
282
+ | Unchanged since the run | `restore` — the file's exact prior bytes, or the symlink's exact prior target, or removed if the run created it |
283
+ | Something else touched it since | `conflict` — reported, left untouched, never force-restored over |
284
+
285
+ One path's `conflict` never blocks any other path in the same rollback
286
+ from restoring. Exit code is non-zero if any `conflict` occurred. Add
287
+ `--dry-run` to preview the restore/conflict plan with zero writes, or
288
+ `--json` for machine-readable output.
289
+
290
+ `onboard` shares one backup run across its whole chained `sync`/`mcp
291
+ sync` stages — one `trellis rollback` undoes an entire `onboard`
292
+ invocation, not just its last stage. `migrate` is not covered: it only
293
+ ever creates a new canonical entry or refuses on conflict, never
294
+ overwrites existing canonical content, so there's nothing a snapshot
295
+ would add — undoing a migrate mistake is just deleting the newly
296
+ created file under `~/.trellis/skills/`.
297
+
298
+ `~/.trellis/backups/` has no automatic pruning — delete old run
299
+ directories by hand once you're done with them.
300
+
223
301
  ## Troubleshooting
224
302
 
225
303
  - **A `conflict` I don't understand**: `migrate` and `sync` both name the
package/docs/roadmap.md CHANGED
@@ -467,6 +467,139 @@ symlink-safe — the reminder here: a real installed-package check is
467
467
  not a redundant formality alongside the unit suite, it's the only
468
468
  thing in this project that exercises the actual `bin` symlink at all.
469
469
 
470
+ **`trellis-managed-agents`, done and archived**
471
+ (`openspec/changes/archive/2026-09-13-trellis-managed-agents/`; adds
472
+ `agent-management-scope`, modifies `onboarding-flow`,
473
+ `skill-instructions-sync`, `mcp-server-sync`, `secrets-audit`). Fixes a
474
+ real gap found using `onboard` for its first real migration (Claude Code
475
+ → pi, on this project's own developer machine): `sync`/`mcp sync`/
476
+ `secrets audit` acted on **every present agent** unconditionally, with no
477
+ way to say "only manage these ones." New persisted state,
478
+ `~/.trellis/managed.yaml` — absent or `agents: []` both mean zero managed
479
+ agents, never "everyone" (a deliberate pre-1.0 default reversal, no
480
+ back-compat shim). `resolveScope`'s no-scope fallback changed from
481
+ `ALL_AGENTS` to the managed set, and an item's own explicit scope is now
482
+ intersected with it, never used verbatim — the managed set is the hard
483
+ outer boundary every other scoping decision lives inside.
484
+
485
+ `onboard` splits what used to be one "pick a base agent" choice into two
486
+ independent ones: a **migration source** (read-only, at most one, same
487
+ resolution rules as before) and a **managed set** (zero or more, written
488
+ to). The source is offered in the managed-set prompt but starts
489
+ unchecked by default — importing from Claude Code no longer implies
490
+ Trellis should also manage Claude Code. Selecting an agent that isn't
491
+ installed yet (e.g. pi) is itself the authorization to install it — one
492
+ confirmation, then a real `npm install -g <package>` (`src/lib/
493
+ installAgent.ts`), never silent even under `--manage`; Kiro has no CLI
494
+ package and is refused with its download URL instead. Re-running
495
+ `onboard`'s managed-set selection is a union with whatever was already
496
+ in `managed.yaml`, never a replacement — otherwise a later run adding
497
+ codex, forgetting to reselect pi out of habit, would silently unmanage
498
+ it. `~/.agents` (Codex's own `~/.ai-config`-sourced skill convention) is
499
+ now a directly tested non-goal rather than an incidental consequence of
500
+ the ownership-conflict fix below.
501
+
502
+ Found and fixed a second real bug in the same investigation, upstream of
503
+ this change's own scope but caught while diagnosing "did the first real
504
+ `onboard` run corrupt anything": `src/adapters/symlinkPlan.ts` treated
505
+ *any* existing symlink at a target path as safe to repair, without
506
+ checking whether its stored target was actually inside Trellis's own
507
+ canonical source. A real `onboard` run silently repointed
508
+ `~/.codex/instructions.md` and `~/.kiro/steering/CLAUDE.md` — both
509
+ previously symlinks into a separate, user-owned `~/.ai-config` setup —
510
+ at `~/.trellis/agents.md` instead, with zero warning. Fixed by checking
511
+ the existing symlink's raw `readlink` target against `canonicalRoot`
512
+ before treating it as repairable; anything pointing elsewhere is now a
513
+ `"conflict"`, left untouched, same as a real non-symlink file always
514
+ was. Verified against the real machine: the two symlinks were restored
515
+ by hand, the fix confirmed via `sync instructions --dry-run` reporting
516
+ conflicts instead of creates, and 194→197 tests passing throughout.
517
+
518
+ Verified in the real Docker sandbox: baseline `sync` with all four
519
+ fixture agents listed in `managed.yaml` reproduces the exact same output
520
+ this project's earlier sandbox runs documented (no regression), then a
521
+ narrowed `managed.yaml` (`agents: [pi]`) reproduces zero writes and zero
522
+ report lines for the other three, and a full `onboard --agent
523
+ claude-code --manage pi` run reproduces this session's own real
524
+ use case end to end — migrate from claude-code, manage only pi, source
525
+ left completely untouched.
526
+
527
+ Then run for real, once, on this project's own developer machine (not a
528
+ sandbox): `onboard --agent claude-code --manage pi` genuinely installed
529
+ pi (`npm install -g @earendil-works/pi-coding-agent`, real confirmation
530
+ prompt, real 132-package install), wrote `managed.yaml` as `agents:
531
+ [pi]`, re-ran `migrate --from claude-code` idempotently against already-
532
+ migrated canonical, and left claude-code, codex, and kiro completely
533
+ untouched — including confirming codex's and kiro's instructions files
534
+ are still real symlinks into the developer's own `~/.ai-config`, not
535
+ touched by this run. Surfaced one genuine, undocumented boundary in the
536
+ process: `sync`/`mcp sync` still reported pi as "not installed"
537
+ immediately after the install, because pi's own presence probe
538
+ (`src/probes/pi.ts`) checks for `~/.pi/agent/settings.json` or `~/.pi/
539
+ agent/skills` on disk — neither of which `npm install` creates. pi only
540
+ writes those itself on its own first real invocation (confirmed by
541
+ reading its installed source: `pi list` bootstraps `~/.pi/auth.json` and
542
+ `~/.pi/models-store.json`, but not the `agent/` subdirectory — that
543
+ appears to need pi's own first-time-setup flow, which is interactive and
544
+ out of scope for Trellis to force). This isn't a bug to patch around —
545
+ faking presence would violate this project's own "verify, don't assume"
546
+ principle — but it is a real onboarding-order gap worth documenting: run
547
+ the newly-installed agent once yourself before `trellis sync` can do
548
+ anything for it.
549
+
550
+ **`trellis-backup-rollback`, done and archived**
551
+ (`openspec/changes/archive/2026-09-13-trellis-backup-rollback/`; adds
552
+ `backup-and-rollback`, modifies `skill-instructions-sync`,
553
+ `mcp-server-sync`, `onboarding-flow`). Direct follow-up to
554
+ `trellis-managed-agents`' own real regression (a foreign symlink silently
555
+ repointed with zero warning): that fix made the *known* unsafe case a
556
+ `conflict` instead, but `mcp sync`'s native-config writes were never
557
+ provably safe the same way — `~/.claude.json`, `~/.codex/config.toml`,
558
+ and Kiro's two settings files are read, merged or TOML-section-patched,
559
+ and rewritten in place, and a bug in that merge/patch logic produces a
560
+ clean write with silently wrong output, not a `conflict` any existing
561
+ check would catch. Two earlier archived changes (`trellis-sync-p1`,
562
+ `trellis-mcp-sync-p2`) each waved at "rollback" by pointing at their own
563
+ create/remove mechanics; `mcp-sync-p2`'s story didn't actually hold —
564
+ automatic MCP server removal still isn't built, so there was no real
565
+ undo path for an `mcp sync` mistake besides hand-editing the file.
566
+
567
+ Every real write `sync`/`mcp sync` perform is now recorded, before it
568
+ happens, into a structured, timestamped run under `~/.trellis/backups/`
569
+ (new `src/lib/backup.ts`) — enough per operation to invert it exactly:
570
+ a file's prior bytes for an overwrite, a symlink's prior target for a
571
+ repair or removal, or just "this didn't exist before" for a create. The
572
+ write itself moved *into* the backup session (`session.writeFile`/
573
+ `createSymlink`/`repairSymlink`/`removeSymlink`) rather than the session
574
+ being an optional thing call sites remember to also invoke — the same
575
+ lesson `trellis-managed-agents`' symlinkPlan bug taught: a safety check
576
+ that's opt-in gets skipped eventually. `TrellisAdapter.apply()` gained a
577
+ mandatory `BackupSession` parameter across all four adapters; there is no
578
+ code path left that writes one of these files without going through it.
579
+
580
+ New `trellis rollback [<run-id>] [--list] [--dry-run] [--json]`: restores
581
+ one recorded run, but only where the current on-disk state still matches
582
+ what that run itself left behind — a path touched again since (another
583
+ sync, a hand edit) is a `conflict`, reported and left untouched, same
584
+ "verify, never guess" posture every other conflict in this project
585
+ already holds itself to. One path's conflict never blocks any other path
586
+ in the same rollback. `onboard` opens one session and shares it across
587
+ its whole chained `sync`+`mcp sync` run rather than one per stage, so a
588
+ single `trellis rollback` undoes an entire `onboard` invocation.
589
+ `migrate` is explicitly out of scope — it only ever creates a new
590
+ canonical entry or refuses on conflict, never overwrites existing
591
+ canonical content, so there's nothing real to lose there.
592
+
593
+ Verified in the real Docker sandbox against `test/fixtures/home`: a real
594
+ `mcp sync` run rewrote `.claude.json`/`.codex/config.toml`/
595
+ `.kiro/settings/mcp.json` with several new servers, `trellis rollback`
596
+ restored all three to their exact original bytes, confirmed byte-for-
597
+ byte; then a second run, followed by a hand-edit simulating something
598
+ else touching `.claude.json` after the fact, confirmed rollback reports
599
+ exactly that one path as a `conflict` (exit 1) while still correctly
600
+ restoring the other two, untouched, unaffected paths in the same
601
+ invocation.
602
+
470
603
  | Phase | Deliverable | Depends on |
471
604
  |---|---|---|
472
605
  | P0 | ✅ `trellis doctor` — read-only, opt-in-for-handshakes scan of all four agents' current skills/MCP/instructions state, reports drift and duplicates | nothing |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-trellis",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A single source of capability for every coding agent — skills, MCP, subagents, memory, and secret policy, adapted natively into Claude Code, Codex, Kiro, and pi.",
5
5
  "license": "MIT",
6
6
  "author": "Paul Leo",