agent-trellis 0.6.3 → 0.8.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 (107) hide show
  1. package/README.md +97 -38
  2. package/dist/adapters/claude-code.js +3 -2
  3. package/dist/adapters/codex.js +3 -2
  4. package/dist/adapters/jsonMcp.d.ts +3 -1
  5. package/dist/adapters/jsonMcp.js +18 -4
  6. package/dist/adapters/kimi-code.d.ts +21 -0
  7. package/dist/adapters/kimi-code.js +107 -0
  8. package/dist/adapters/kiro.js +3 -2
  9. package/dist/adapters/mcpPlan.d.ts +26 -2
  10. package/dist/adapters/mcpPlan.js +135 -15
  11. package/dist/adapters/pi.js +22 -3
  12. package/dist/adapters/symlinkPlan.d.ts +7 -0
  13. package/dist/adapters/symlinkPlan.js +16 -7
  14. package/dist/cli.js +129 -20
  15. package/dist/commands/doctor.d.ts +7 -2
  16. package/dist/commands/doctor.js +38 -2
  17. package/dist/commands/init.d.ts +4 -2
  18. package/dist/commands/init.js +26 -13
  19. package/dist/commands/kimi.d.ts +16 -0
  20. package/dist/commands/kimi.js +64 -0
  21. package/dist/commands/manage.d.ts +39 -0
  22. package/dist/commands/manage.js +126 -0
  23. package/dist/commands/mcp.d.ts +22 -1
  24. package/dist/commands/mcp.js +65 -2
  25. package/dist/commands/mcpGateway.d.ts +6 -0
  26. package/dist/commands/mcpGateway.js +34 -14
  27. package/dist/commands/mcpImport.d.ts +27 -0
  28. package/dist/commands/mcpImport.js +336 -0
  29. package/dist/commands/memory.d.ts +5 -3
  30. package/dist/commands/memory.js +12 -8
  31. package/dist/commands/migrate.d.ts +37 -7
  32. package/dist/commands/migrate.js +253 -27
  33. package/dist/commands/onboard.d.ts +74 -2
  34. package/dist/commands/onboard.js +463 -72
  35. package/dist/commands/onboardVerdict.js +20 -7
  36. package/dist/commands/rollback.js +4 -1
  37. package/dist/commands/secretsAudit.d.ts +21 -12
  38. package/dist/commands/secretsAudit.js +39 -10
  39. package/dist/commands/skill.d.ts +17 -0
  40. package/dist/commands/skill.js +47 -2
  41. package/dist/commands/sync.js +2 -0
  42. package/dist/core/canonical.d.ts +58 -4
  43. package/dist/core/canonical.js +183 -11
  44. package/dist/core/capabilitySelection.d.ts +45 -0
  45. package/dist/core/capabilitySelection.js +124 -0
  46. package/dist/core/types.d.ts +25 -2
  47. package/dist/core/types.js +7 -0
  48. package/dist/lib/backup.d.ts +2 -0
  49. package/dist/lib/backup.js +4 -0
  50. package/dist/lib/builtinSkills.d.ts +2 -0
  51. package/dist/lib/builtinSkills.js +4 -0
  52. package/dist/lib/cliMetadata.d.ts +1 -0
  53. package/dist/lib/cliMetadata.js +14 -0
  54. package/dist/lib/gatewayBackend.d.ts +28 -0
  55. package/dist/lib/gatewayBackend.js +72 -1
  56. package/dist/lib/installAgent.d.ts +7 -5
  57. package/dist/lib/installAgent.js +4 -3
  58. package/dist/lib/mcpConnect.js +5 -1
  59. package/dist/lib/mcpMigrateRead.d.ts +1 -0
  60. package/dist/lib/mcpMigrateRead.js +3 -0
  61. package/dist/lib/mcpRuntime.d.ts +46 -0
  62. package/dist/lib/mcpRuntime.js +185 -0
  63. package/dist/lib/mcpStatusProvider.d.ts +10 -0
  64. package/dist/lib/mcpStatusProvider.js +25 -0
  65. package/dist/lib/mcpToolRegistry.d.ts +18 -0
  66. package/dist/lib/mcpToolRegistry.js +107 -14
  67. package/dist/lib/memoryGraph.d.ts +1 -1
  68. package/dist/lib/memoryGraph.js +9 -3
  69. package/dist/lib/memoryProvider.d.ts +58 -0
  70. package/dist/lib/memoryProvider.js +175 -0
  71. package/dist/lib/memoryStatus.d.ts +17 -0
  72. package/dist/lib/memoryStatus.js +88 -0
  73. package/dist/lib/nativeMemory.d.ts +19 -0
  74. package/dist/lib/nativeMemory.js +129 -0
  75. package/dist/lib/runtimeControlProvider.d.ts +16 -0
  76. package/dist/lib/runtimeControlProvider.js +168 -0
  77. package/dist/lib/secretEnv.d.ts +17 -0
  78. package/dist/lib/secretEnv.js +31 -1
  79. package/dist/lib/skillProvider.d.ts +13 -0
  80. package/dist/lib/skillProvider.js +210 -0
  81. package/dist/lib/taskProvider.d.ts +7 -0
  82. package/dist/lib/taskProvider.js +43 -0
  83. package/dist/lib/taskStore.d.ts +46 -0
  84. package/dist/lib/taskStore.js +156 -0
  85. package/dist/lib/terminalPicker.d.ts +23 -28
  86. package/dist/lib/terminalPicker.js +54 -164
  87. package/dist/pi-bridge/bundle.js +493 -51
  88. package/dist/pi-bridge/index.js +42 -21
  89. package/dist/pi-bridge/schemaTranslate.d.ts +4 -8
  90. package/dist/pi-bridge/schemaTranslate.js +4 -8
  91. package/dist/probes/kimi-code.d.ts +12 -0
  92. package/dist/probes/kimi-code.js +55 -0
  93. package/dist/sdk.d.ts +1 -1
  94. package/docs/architecture.md +224 -26
  95. package/docs/assets/agent-trellis-architecture-horizontal.svg +80 -0
  96. package/docs/assets/agent-trellis-architecture-vertical.svg +82 -0
  97. package/docs/assets/trellis-mark.svg +12 -0
  98. package/docs/getting-started.md +403 -35
  99. package/docs/overview.md +131 -0
  100. package/docs/overview.zh-CN.md +100 -0
  101. package/docs/research.md +59 -0
  102. package/docs/roadmap.md +10 -5
  103. package/package.json +5 -3
  104. package/schema/builtin-skills/trellis-runtime/SKILL.md +117 -0
  105. package/schema/capability-selection.example.yaml +23 -0
  106. package/schema/scope.example.yaml +1 -1
  107. package/schema/servers.example.yaml +15 -1
@@ -13,24 +13,32 @@
13
13
  * judgment is made here.
14
14
  */
15
15
  import { createInterface } from "node:readline/promises";
16
+ import { execFileSync } from "node:child_process";
16
17
  import { homedir } from "node:os";
17
- import { writeFileSync } from "node:fs";
18
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
18
19
  import { join } from "node:path";
19
- import { canUseInteractivePicker, runMultiSelectPicker, runSingleSelectPicker } from "../lib/terminalPicker.js";
20
+ import { canUseInteractivePicker, runMultiSelectPicker, runSearchMultiSelectPicker, runSingleSelectPicker } from "../lib/terminalPicker.js";
20
21
  import * as claudeCodeProbe from "../probes/claude-code.js";
21
22
  import * as codexProbe from "../probes/codex.js";
22
23
  import * as kiroProbe from "../probes/kiro.js";
23
24
  import * as piProbe from "../probes/pi.js";
24
- import { ALL_AGENTS } from "../core/types.js";
25
- import { loadCanonicalSource, removeServerYaml, upsertServerYaml, writeMcpModeYaml } from "../core/canonical.js";
25
+ import * as kimiCodeProbe from "../probes/kimi-code.js";
26
+ import { ALL_AGENTS, capabilityDeliveryForAgent } from "../core/types.js";
27
+ import { loadCanonicalSource, removeServerYaml, upsertServerYaml, writeMcpModeYaml, writeMcpRoutesYaml, writeMcpRuntimeDeliveryYaml } from "../core/canonical.js";
28
+ import { buildCapabilityInventory, parseCapabilitySelectionFile, resolveSelectedNames, selectionContains } from "../core/capabilitySelection.js";
26
29
  import { INSTALL_HINTS, collectInitReport } from "./init.js";
27
30
  import { applyMigratePlan, collectMigratePlan, printPlan as printMigratePlan } from "./migrate.js";
31
+ import { isGatewayAgent } from "../adapters/mcpPlan.js";
32
+ import { isInScope } from "../core/adapter.js";
33
+ import { TRELLIS_RUNTIME_SKILL_NAME } from "../lib/builtinSkills.js";
34
+ import { discoverNativeMemory } from "../lib/nativeMemory.js";
28
35
  import { collectSyncReport, printReport as printSyncReport } from "./sync.js";
29
36
  import { collectMcpSyncReport, printReport as printMcpSyncReport } from "./mcp.js";
30
37
  import { collectSecretsAuditReport, printReport as printSecretsAuditReport } from "./secretsAudit.js";
31
38
  import { applyMemorySync, collectMemorySyncResult, printMemorySyncResult, DEFAULT_MEMORY_SERVER_DEF, MEMORY_SERVER_NAME } from "./memory.js";
32
39
  import { collectDoctorReport, printReport as printDoctorReport, resolveKnownHostInjected } from "./doctor.js";
33
40
  import { openBackupSession } from "../lib/backup.js";
41
+ import { applyRollbackPlan, collectRollbackPlan, loadManifest } from "./rollback.js";
34
42
  import { confirmAndInstall } from "../lib/installAgent.js";
35
43
  import { normalizeDoctorVerdict, normalizeMcpSyncVerdict, normalizeMemorySyncVerdict, normalizeMigrateVerdict, normalizeSecretsAuditVerdict, normalizeSelfVerificationVerdict, normalizeSyncVerdict, printVerdict, } from "./onboardVerdict.js";
36
44
  const PROBES = {
@@ -38,21 +46,78 @@ const PROBES = {
38
46
  codex: (homeDir) => codexProbe.probe(homeDir),
39
47
  kiro: (homeDir) => kiroProbe.probe(homeDir),
40
48
  pi: (homeDir) => piProbe.probe(homeDir),
49
+ "kimi-code": (homeDir) => kimiCodeProbe.probe(homeDir),
41
50
  };
42
- export async function collectOnboardSummary(homeDir = homedir()) {
51
+ /**
52
+ * Scores only real source-side content. The recommendation is a hint for a
53
+ * first-time user, never a filter: every candidate remains selectable.
54
+ */
55
+ export function migrationSourceScore(summary) {
56
+ return summary.skillCount + summary.mcpServerCount + (summary.hasRealInstructions ? 1 : 0);
57
+ }
58
+ export function recommendedMigrationSource(summaries) {
59
+ return summaries.reduce((best, current) => {
60
+ if (!best || migrationSourceScore(current) > migrationSourceScore(best))
61
+ return current;
62
+ return best;
63
+ }, undefined)?.agent;
64
+ }
65
+ export const MCP_ROUTE_OPTIONS = [
66
+ {
67
+ mode: "gateway",
68
+ label: "gateway(推荐:本机统一托管) — Trellis 在本机会话中运行 MCP 入口,不需要额外部署服务",
69
+ },
70
+ {
71
+ mode: "direct",
72
+ label: "direct(简单直连) — Agent 分别连接每个 MCP,适合不需要统一托管的情况",
73
+ },
74
+ {
75
+ mode: "hub",
76
+ label: "hub(已有外部 Hub 时使用) — Agent 连接一个外部 HTTP MCP 服务",
77
+ },
78
+ ];
79
+ export function interactiveMcpRouteOptions(hubConfigured) {
80
+ return MCP_ROUTE_OPTIONS.map((option) => option.mode === "hub" && !hubConfigured
81
+ ? { ...option, label: "hub(暂不可选:需要先配置外部 Hub URL) — 不会自动部署 Hub", disabled: true }
82
+ : { ...option });
83
+ }
84
+ export async function collectOnboardSummary(homeDir = homedir(), workspaceDir = process.cwd()) {
43
85
  return Promise.all(ALL_AGENTS.map(async (agent) => {
44
86
  const snapshot = await PROBES[agent](homeDir);
45
87
  if (!snapshot.present) {
46
- return { agent, present: false, skillCount: 0, skillNames: [], hasRealInstructions: false, mcpServerCount: 0 };
88
+ return { agent, present: false, skillCount: 0, skillNames: [], hasRealInstructions: false, mcpServerCount: 0, mcpServerNames: [], nativeMemoryStatus: "unsupported", nativeMemoryCount: 0, nativeMemoryNames: [], nativeMemoryDetail: "agent is not installed" };
47
89
  }
48
90
  const skillNames = snapshot.skillRoots.flatMap((root) => root.skills.map((s) => s.name));
49
91
  const hasRealInstructions = snapshot.instructionsFile !== undefined && !snapshot.instructionsFile.isSymlink;
50
92
  const mcpPlan = await collectMigratePlan(agent, homeDir, ["mcp"]);
51
- return { agent, present: true, skillCount: skillNames.length, skillNames, hasRealInstructions, mcpServerCount: mcpPlan.items.length };
93
+ const nativeMemory = discoverNativeMemory(agent, homeDir, workspaceDir);
94
+ return { agent, present: true, skillCount: skillNames.length, skillNames, hasRealInstructions, mcpServerCount: mcpPlan.items.length, mcpServerNames: mcpPlan.items.map((item) => item.name), nativeMemoryStatus: nativeMemory.status, nativeMemoryCount: nativeMemory.candidates.length, nativeMemoryNames: nativeMemory.candidates.map((candidate) => candidate.displayName), nativeMemoryDetail: nativeMemory.detail };
52
95
  }));
53
96
  }
54
97
  function hasContent(s) {
55
- return s.skillCount > 0 || s.hasRealInstructions || s.mcpServerCount > 0;
98
+ return s.skillCount > 0 || s.hasRealInstructions || s.mcpServerCount > 0 || s.nativeMemoryCount > 0;
99
+ }
100
+ function hasSelectedSourceContent(s, selection) {
101
+ if (!selection)
102
+ return hasContent(s);
103
+ const hasSkills = selection.skills !== "none" && s.skillNames.some((name) => selectionContains(selection.skills, name));
104
+ const hasMcp = selection.mcpServers !== "none" && s.mcpServerNames.some((name) => selectionContains(selection.mcpServers, name));
105
+ const hasMemory = selection.memoryMigration !== "none" && s.nativeMemoryCount > 0;
106
+ return hasSkills || hasMcp || hasMemory || s.hasRealInstructions;
107
+ }
108
+ function filterMigratePlan(plan, selection) {
109
+ if (!selection)
110
+ return plan;
111
+ const items = plan.items.filter((item) => {
112
+ if (item.kind === "skill")
113
+ return selectionContains(selection.skills, item.name);
114
+ if (item.kind === "mcp")
115
+ return selectionContains(selection.mcpServers, item.name);
116
+ if (item.kind === "memory")
117
+ return selection.memoryMigration !== "none";
118
+ return true;
119
+ });
120
+ return { ...plan, items };
56
121
  }
57
122
  const PROGRESS_STAGES = ["migrate", "sync", "mcp sync", "memory sync", "secrets audit", "doctor"];
58
123
  /** Transient status, not part of the report — printed to stderr so
@@ -69,19 +134,26 @@ function logProgress(opts, stage) {
69
134
  const index = PROGRESS_STAGES.indexOf(stage) + 1;
70
135
  console.error(`[${index}/${PROGRESS_STAGES.length}] ${stage}`);
71
136
  }
72
- function agentSummaryLabel(s) {
73
- const skills = s.skillCount > 0 ? ` (${s.skillNames.join(", ")})` : "";
74
- return `${s.agent} — ${s.skillCount} skill(s)${skills}, instructions: ${s.hasRealInstructions ? "yes" : "no"}, mcp: ${s.mcpServerCount}`;
137
+ /** A count only, never the full skill name list — with 30+ skills this
138
+ * used to render as one unreadable, wrapping wall of text per row
139
+ * (found via a real mirasim terminal session). The full names are still
140
+ * available from `trellis doctor`/`--json`; a picker row's job is to
141
+ * let you tell agents apart at a glance, not enumerate everything. */
142
+ function agentSummaryLabel(s, recommendedAgent) {
143
+ const recommendation = s.agent === recommendedAgent ? "(推荐:可迁移内容最多)" : "";
144
+ const memory = s.nativeMemoryCount > 0 ? `Memory: ${s.nativeMemoryCount}` : `Memory: ${s.nativeMemoryStatus}`;
145
+ return `${s.agent} — Skills: ${s.skillCount}, global instructions: ${s.hasRealInstructions ? "yes" : "no"}, MCP: ${s.mcpServerCount}, ${memory} ${recommendation}`;
75
146
  }
76
147
  /** Numbered-typing fallback (trellis-onboard-interactive-picker design.md
77
148
  * D2) — used only when the terminal can't support the raw-mode picker
78
149
  * (`canUseInteractivePicker()` false). Unchanged from before that change. */
79
150
  async function promptForAgentNumbered(present) {
80
- const rl = createInterface({ input: process.stdin, output: process.stdout });
151
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
152
+ const recommendedAgent = recommendedMigrationSource(present);
81
153
  try {
82
- console.log("Multiple agents detected:");
154
+ console.error("检测到多个 Agent。推荐选择可迁移内容最多的 Agent 作为来源:");
83
155
  present.forEach((s, i) => {
84
- console.log(` ${i + 1}) ${agentSummaryLabel(s)}`);
156
+ console.error(` ${i + 1}) ${agentSummaryLabel(s, recommendedAgent)}`);
85
157
  });
86
158
  for (let attempt = 0; attempt < 2; attempt++) {
87
159
  const answer = (await rl.question(`Choose a migration source [1-${present.length}]: `)).trim();
@@ -93,7 +165,7 @@ async function promptForAgentNumbered(present) {
93
165
  return byIndex.agent;
94
166
  if (present.some((s) => s.agent === answer))
95
167
  return answer;
96
- console.log(`Not a valid choice: enter a number from 1-${present.length}, or one of ${present.map((s) => s.agent).join(", ")}`);
168
+ console.error(`Not a valid choice: enter a number from 1-${present.length}, or one of ${present.map((s) => s.agent).join(", ")}`);
97
169
  }
98
170
  throw new Error("no valid migration source chosen after 2 attempts");
99
171
  }
@@ -112,10 +184,11 @@ async function promptForAgentReal(present) {
112
184
  if (!canUseInteractivePicker()) {
113
185
  return promptForAgentNumbered(present);
114
186
  }
115
- console.log("Multiple agents detected — use Up/Down (or j/k) and Enter to choose a migration source:");
116
- const index = await runSingleSelectPicker(present.map(agentSummaryLabel));
187
+ const recommendedAgent = recommendedMigrationSource(present);
188
+ console.error("选择迁移来源。推荐选择可迁移内容最多的 Agent;这只决定从哪里读取,不会自动托管它:");
189
+ const index = await runSingleSelectPicker(present.map((summary) => agentSummaryLabel(summary, recommendedAgent)), undefined, "迁移来源");
117
190
  if (index === null) {
118
- console.log("cancelled, no changes made");
191
+ console.error("cancelled, no changes made");
119
192
  process.exit(1);
120
193
  }
121
194
  return present[index].agent;
@@ -123,13 +196,14 @@ async function promptForAgentReal(present) {
123
196
  /** Numbered-typing fallback — unchanged from before this change (see
124
197
  * `promptForAgentNumbered`'s doc comment). */
125
198
  async function promptForManagedAgentsNumbered(candidates, alreadyManaged) {
126
- const rl = createInterface({ input: process.stdin, output: process.stdout });
199
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
127
200
  try {
128
- console.log("Which agents should Trellis manage? (comma-separated numbers; enter for none new)");
201
+ console.error("选择 Trellis 要托管的 Agent(推荐选择已安装的 Agent;直接回车表示本次不新增):");
129
202
  candidates.forEach((s, i) => {
130
203
  const status = s.present ? `present, ${s.skillCount} skill(s)` : "not installed";
131
204
  const tag = alreadyManaged.includes(s.agent) ? " [already managed]" : "";
132
- console.log(` ${i + 1}) ${s.agent} — ${status}${tag}`);
205
+ const recommendation = s.present ? "(推荐:已安装)" : "";
206
+ console.error(` ${i + 1}) ${s.agent} — ${status}${recommendation}${tag}`);
133
207
  });
134
208
  const answer = (await rl.question("Select: ")).trim();
135
209
  return answer;
@@ -148,21 +222,23 @@ async function promptForManagedAgentsReal(candidates, alreadyManaged) {
148
222
  if (!canUseInteractivePicker()) {
149
223
  return promptForManagedAgentsNumbered(candidates, alreadyManaged);
150
224
  }
151
- console.log("Which agents should Trellis manage? Up/Down (or j/k) to move, Space to toggle, Enter to confirm:");
225
+ console.error("选择 Trellis 要托管的 Agent。推荐选择已安装的 Agent;选择未安装的 Agent 会先请求安装确认:");
152
226
  const labels = candidates.map((s) => {
153
227
  const status = s.present ? `present, ${s.skillCount} skill(s)` : "not installed";
154
- return `${s.agent} — ${status}`;
228
+ const recommendation = s.present ? "(推荐:已安装)" : "";
229
+ const managed = alreadyManaged.includes(s.agent) ? "(已托管)" : "";
230
+ return `${s.agent} — ${status}${recommendation}${managed}`;
155
231
  });
156
232
  const initiallyChecked = candidates.map((s) => alreadyManaged.includes(s.agent));
157
233
  const indices = await runMultiSelectPicker(labels, initiallyChecked);
158
234
  if (indices === null) {
159
- console.log("cancelled, no changes made");
235
+ console.error("cancelled, no changes made");
160
236
  process.exit(1);
161
237
  }
162
238
  return indices.map((i) => candidates[i].agent).join(",");
163
239
  }
164
240
  /**
165
- * Resolves which categories (skills, instructions) to migrate from a
241
+ * Resolves which categories (Skills, instructions, MCP, native Memory) to migrate from a
166
242
  * resolved source (trellis-migrate-category-selection, extended to a
167
243
  * third category by trellis-onboard-mcp-memory design.md D2). Unlike the
168
244
  * two pickers above, there is no numbered-text fallback to preserve
@@ -174,17 +250,32 @@ async function promptForManagedAgentsReal(candidates, alreadyManaged) {
174
250
  * An empty result is a valid answer: "skip migrate for this run"
175
251
  * (design.md D6), left for the caller to act on.
176
252
  */
177
- async function resolveMigrateCategories(source, opts) {
253
+ async function resolveMigrateCategories(source, opts, selection) {
178
254
  // Built dynamically, in skill/instructions/mcp display order — not a
179
- // fixed two- or three-slot structure, so a fourth category some day
255
+ // fixed category structure, so future categories
180
256
  // would only need an entry here, not a rewritten branch (design.md D2).
181
257
  const candidates = [];
182
- if (source.skillCount > 0)
183
- candidates.push({ kind: "skill", label: "skills" });
258
+ if (source.skillCount > 0 && (!selection || selection.skills !== "none") && (!selection || source.skillNames.some((name) => selectionContains(selection.skills, name)))) {
259
+ candidates.push({ kind: "skill", label: "Skills" });
260
+ }
184
261
  if (source.hasRealInstructions)
185
- candidates.push({ kind: "instructions", label: "instructions" });
186
- if (source.mcpServerCount > 0)
187
- candidates.push({ kind: "mcp", label: "mcp" });
262
+ candidates.push({ kind: "instructions", label: "global instructions" });
263
+ if (source.mcpServerCount > 0 && (!selection || selection.mcpServers !== "none") && (!selection || source.mcpServerNames.some((name) => selectionContains(selection.mcpServers, name)))) {
264
+ candidates.push({ kind: "mcp", label: "MCP servers" });
265
+ }
266
+ let migrateMemory = source.nativeMemoryCount > 0 && opts.memoryMigrate !== "off" && (!selection || selection.memoryMigration !== "none");
267
+ if (migrateMemory && opts.memoryMigrate === undefined && !opts.selectionFile && !opts.json && (opts.promptForMemoryMigration || canUseInteractivePicker())) {
268
+ const choice = opts.promptForMemoryMigration
269
+ ? await opts.promptForMemoryMigration(source.agent, source.nativeMemoryCount)
270
+ : await runSingleSelectPicker([
271
+ `迁移支持的记忆(推荐:${source.nativeMemoryCount} 项)`,
272
+ "暂不迁移记忆",
273
+ ], undefined, `选择是否从 ${source.agent} 迁移已有记忆`);
274
+ if (choice === null || choice === "off" || choice === 1)
275
+ migrateMemory = false;
276
+ }
277
+ if (migrateMemory)
278
+ candidates.push({ kind: "memory", label: `Memory(${source.nativeMemoryCount} 项)` });
188
279
  // The choice is only meaningful when two or more kinds are real — same
189
280
  // gate for the injected test seam as for the real picker, mirroring
190
281
  // how `promptForAgent`/`promptForManagedAgents` are only ever
@@ -194,10 +285,10 @@ async function resolveMigrateCategories(source, opts) {
194
285
  return opts.promptForMigrateCategories(source);
195
286
  }
196
287
  if (canUseInteractivePicker()) {
197
- console.log(`Which categories should be migrated from ${source.agent}? Space to toggle, Enter to confirm:`);
198
- const indices = await runMultiSelectPicker(candidates.map((c) => c.label), candidates.map(() => true));
288
+ console.error(`选择要从 ${source.agent} 迁移的内容(默认全选,推荐先完整迁移,之后再清理不需要的项):`);
289
+ const indices = await runMultiSelectPicker(candidates.map((c) => `${c.label}(推荐迁移)`), candidates.map(() => true), undefined, "迁移内容");
199
290
  if (indices === null) {
200
- console.log("cancelled, no changes made");
291
+ console.error("cancelled, no changes made");
201
292
  process.exit(1);
202
293
  }
203
294
  return indices.map((i) => candidates[i].kind);
@@ -205,6 +296,75 @@ async function resolveMigrateCategories(source, opts) {
205
296
  }
206
297
  return candidates.map((c) => c.kind);
207
298
  }
299
+ async function resolveInteractiveCapabilitySelection(source, opts) {
300
+ if (opts.json || opts.promptForMigrateCategories || !canUseInteractivePicker())
301
+ return undefined;
302
+ const skills = source.skillNames.length > 0
303
+ ? await runSearchMultiSelectPicker(source.skillNames, undefined, "选择要迁移的 Skills(默认全选,推荐先全部保留)")
304
+ : [];
305
+ if (skills === null) {
306
+ console.error("cancelled, no changes made");
307
+ process.exit(1);
308
+ }
309
+ const mcpServers = source.mcpServerNames.length > 0
310
+ ? await runSearchMultiSelectPicker(source.mcpServerNames, undefined, "选择要迁移的 MCP(默认全选,推荐先全部保留)")
311
+ : [];
312
+ if (mcpServers === null) {
313
+ console.error("cancelled, no changes made");
314
+ process.exit(1);
315
+ }
316
+ return { skills: skills ?? "none", mcpServers: mcpServers ?? "none", memories: "all", memoryMigration: "all", mcpRoutes: {}, runtimeDelivery: {} };
317
+ }
318
+ async function resolveInteractiveMemoryChoice(homeDir, opts) {
319
+ if (opts.memory !== undefined || opts.selectionFile || opts.json)
320
+ return undefined;
321
+ const current = loadCanonicalSource(homeDir).mcp.servers[MEMORY_SERVER_NAME] ? "on" : "off";
322
+ const canPrompt = Boolean(opts.promptForMemory) || (opts.isTTY ?? process.stdin.isTTY === true) && canUseInteractivePicker();
323
+ if (!canPrompt)
324
+ return undefined;
325
+ const choice = opts.promptForMemory
326
+ ? await opts.promptForMemory(current)
327
+ : (() => {
328
+ const options = current === "on"
329
+ ? ["保持开启(推荐:继续使用共享 Memory)", "关闭共享 Memory"]
330
+ : ["保持关闭(推荐:不改变现有环境)", "启用共享 Memory(推荐:Kimi/Pi 共享同一 graph)"];
331
+ return runSingleSelectPicker(options, undefined, "选择 Trellis Memory 托管方式").then((index) => {
332
+ if (index === null) {
333
+ console.error("cancelled, no changes made");
334
+ process.exit(1);
335
+ }
336
+ if (index === 0)
337
+ return "keep";
338
+ return current === "on" ? "off" : "on";
339
+ });
340
+ })();
341
+ if (choice === "on" || choice === "off")
342
+ return choice;
343
+ return undefined;
344
+ }
345
+ async function resolveInteractiveMcpRoutes(agentIds, serverNames, hubConfigured) {
346
+ const routes = {};
347
+ for (const agent of agentIds) {
348
+ const options = interactiveMcpRouteOptions(hubConfigured);
349
+ const modeIndex = await runSingleSelectPicker(options, undefined, `选择 ${agent} 的 MCP 模式(推荐 Gateway:Trellis 本机统一托管;Hub 只用于已有外部服务)`);
350
+ if (modeIndex === null) {
351
+ console.error("cancelled, no changes made");
352
+ process.exit(1);
353
+ }
354
+ const mode = options[modeIndex].mode;
355
+ if (mode === "hub") {
356
+ routes[agent] = { mode };
357
+ continue;
358
+ }
359
+ const selected = await runSearchMultiSelectPicker(serverNames, undefined, `选择 ${agent} 的 MCP 服务器(默认全选,推荐先全部启用)`);
360
+ if (selected === null) {
361
+ console.error("cancelled, no changes made");
362
+ process.exit(1);
363
+ }
364
+ routes[agent] = { mode, servers: selected ?? [] };
365
+ }
366
+ return routes;
367
+ }
208
368
  /** Shared by `--manage` and the interactive prompt's answer — same
209
369
  * grammar either way (design.md D4): a real agent id list, comma- or
210
370
  * whitespace-separated numbers referring to `candidates`' own order, or
@@ -296,6 +456,38 @@ function validateMemoryOption(memory) {
296
456
  return { error: `--memory must be "on" or "off" (got "${memory}")` };
297
457
  return { value: memory };
298
458
  }
459
+ function validateMemoryMigrationOption(memoryMigrate) {
460
+ if (memoryMigrate === undefined)
461
+ return {};
462
+ if (memoryMigrate !== "on" && memoryMigrate !== "off")
463
+ return { error: `--memory-migrate must be "on" or "off" (got "${memoryMigrate}")` };
464
+ return { value: memoryMigrate };
465
+ }
466
+ function preflightMcpMode(mode) {
467
+ if (!mode)
468
+ return undefined;
469
+ if (mode.kind === "hub") {
470
+ try {
471
+ const parsed = new URL(mode.url);
472
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
473
+ return `Hub URL must use http:// or https:// (got "${mode.url}")`;
474
+ }
475
+ }
476
+ catch {
477
+ return `Hub URL is not valid: "${mode.url}"`;
478
+ }
479
+ return undefined;
480
+ }
481
+ if (mode.kind === "gateway") {
482
+ try {
483
+ execFileSync("which", ["trellis"], { stdio: "ignore" });
484
+ }
485
+ catch {
486
+ return "gateway mode requires the `trellis` executable to be available on PATH before any Agent config is changed";
487
+ }
488
+ }
489
+ return undefined;
490
+ }
299
491
  function mcpModeValueOf(mcp) {
300
492
  if (mcp.gateway?.enabled)
301
493
  return "gateway";
@@ -344,6 +536,49 @@ function memoryStatusLine(memory) {
344
536
  return `memory: ${memory.current} (unchanged) — pass --memory ${memory.current === "on" ? "off" : "on"} to change`;
345
537
  return `memory: ${memory.current} (changed from ${memory.previous})`;
346
538
  }
539
+ /** Converges explicit per-Agent route lists with the shared Memory toggle.
540
+ * Legacy/top-level routes without a `servers` list already expose every
541
+ * eligible server and need no rewrite. Hub routes are intentionally opaque:
542
+ * the external hub owns its upstream set. */
543
+ export function mergeMemoryIntoExplicitRoutes(mcp, managedAgents, memoryEnabled) {
544
+ const routes = mcp.routes;
545
+ if (!routes)
546
+ return { mcp, changed: false };
547
+ const memoryServer = mcp.servers[MEMORY_SERVER_NAME];
548
+ const changedRoutes = { ...routes };
549
+ let changed = false;
550
+ for (const agent of managedAgents) {
551
+ const route = routes[agent];
552
+ if (!route || route.mode === "hub" || !route.servers)
553
+ continue;
554
+ const inScope = Boolean(memoryServer && memoryServer.enabled !== false && isInScope(agent, memoryServer.agents, managedAgents));
555
+ const nextServers = memoryEnabled && inScope
556
+ ? [...route.servers, ...(route.servers.includes(MEMORY_SERVER_NAME) ? [] : [MEMORY_SERVER_NAME])]
557
+ : route.servers.filter((name) => name !== MEMORY_SERVER_NAME);
558
+ if (nextServers.length === route.servers.length && nextServers.every((name, index) => name === route.servers[index]))
559
+ continue;
560
+ changedRoutes[agent] = { ...route, servers: nextServers };
561
+ changed = true;
562
+ }
563
+ return changed ? { mcp: { ...mcp, routes: changedRoutes }, changed } : { mcp, changed: false };
564
+ }
565
+ async function confirmMcpModeChange(previous, next, agents, opts) {
566
+ if (opts.promptForModeChange)
567
+ return opts.promptForModeChange(previous, next, agents);
568
+ if (!canUseInteractivePicker())
569
+ return true;
570
+ const affected = agents.length > 0 ? agents.join(", ") : "(none)";
571
+ console.error(`MCP 模式将从 ${previous} 切换到 ${next},影响托管 Agent:${affected}`);
572
+ if (next === "hub")
573
+ console.error("Hub 模式会让 Agent 连接外部 HTTP MCP 服务;Trellis 不会自动部署该服务。");
574
+ if (next === "gateway")
575
+ console.error("Gateway 模式会由 Trellis 在本机运行 MCP 入口并托管上游连接。");
576
+ const choice = await runSingleSelectPicker([
577
+ "取消切换(推荐:保持当前模式)",
578
+ "确认切换",
579
+ ], undefined, "确认 MCP 模式切换");
580
+ return choice === 1;
581
+ }
347
582
  async function resolveManagedAgents(opts, summary, alreadyManaged) {
348
583
  if (opts.manage !== undefined) {
349
584
  const parsed = parseManagedSelection(opts.manage, summary);
@@ -365,13 +600,35 @@ async function resolveManagedAgents(opts, summary, alreadyManaged) {
365
600
  function readManagedYaml(homeDir) {
366
601
  return loadCanonicalSource(homeDir).managedAgents;
367
602
  }
368
- function writeManagedYaml(homeDir, agents) {
369
- writeFileSync(join(homeDir, ".trellis", "managed.yaml"), `agents: [${agents.join(", ")}]\n`);
603
+ function writeManagedYaml(homeDir, agents, backup) {
604
+ const content = `agents: [${agents.join(", ")}]\n`;
605
+ const path = join(homeDir, ".trellis", "managed.yaml");
606
+ if (existsSync(path) && readFileSync(path, "utf-8") === content)
607
+ return;
608
+ if (backup)
609
+ backup.writeFile(path, content);
610
+ else
611
+ writeFileSync(path, content);
370
612
  }
371
613
  export async function collectOnboardPlan(opts = {}) {
372
614
  const homeDir = opts.homeDir ?? homedir();
373
- await collectInitReport(homeDir);
374
- const summary = await collectOnboardSummary(homeDir);
615
+ if (opts.dryRun && !existsSync(join(homeDir, ".trellis"))) {
616
+ return {
617
+ summary: [],
618
+ refusal: "dry-run does not bootstrap a missing ~/.trellis — run `trellis init` first, then re-run the preview",
619
+ verdict: [],
620
+ };
621
+ }
622
+ let capabilitySelection;
623
+ if (opts.selectionFile) {
624
+ const parsedSelection = parseCapabilitySelectionFile(opts.selectionFile);
625
+ if (!parsedSelection.ok)
626
+ return { summary: [], refusal: parsedSelection.error, verdict: [] };
627
+ capabilitySelection = parsedSelection.selection;
628
+ }
629
+ await collectInitReport(homeDir, { write: !opts.dryRun });
630
+ const workspaceDir = opts.workspaceDir ?? process.cwd();
631
+ const summary = await collectOnboardSummary(homeDir, workspaceDir);
375
632
  const present = summary.filter((s) => s.present);
376
633
  if (present.length === 0) {
377
634
  return { summary, installHints: { ...INSTALL_HINTS }, verdict: [] };
@@ -386,11 +643,19 @@ export async function collectOnboardPlan(opts = {}) {
386
643
  if ("error" in mcpModeValidation) {
387
644
  return { summary, refusal: mcpModeValidation.error, verdict: [] };
388
645
  }
389
- const memoryValidation = validateMemoryOption(opts.memory);
390
- if ("error" in memoryValidation) {
391
- return { summary, refusal: memoryValidation.error, verdict: [] };
646
+ const explicitMemoryValidation = validateMemoryOption(opts.memory);
647
+ if ("error" in explicitMemoryValidation) {
648
+ return { summary, refusal: explicitMemoryValidation.error, verdict: [] };
649
+ }
650
+ const explicitMemoryMigrationValidation = validateMemoryMigrationOption(opts.memoryMigrate);
651
+ if ("error" in explicitMemoryMigrationValidation) {
652
+ return { summary, refusal: explicitMemoryMigrationValidation.error, verdict: [] };
392
653
  }
393
- const sourceCandidates = present.filter(hasContent);
654
+ const modePreflightError = preflightMcpMode(mcpModeValidation.mode);
655
+ if (modePreflightError) {
656
+ return { summary, refusal: modePreflightError, verdict: [] };
657
+ }
658
+ const sourceCandidates = present.filter((candidate) => hasSelectedSourceContent(candidate, capabilitySelection));
394
659
  let source;
395
660
  let sourceReason;
396
661
  if (opts.agent) {
@@ -466,35 +731,65 @@ export async function collectOnboardPlan(opts = {}) {
466
731
  // managed set, not an abort of the rest of the flow.
467
732
  }
468
733
  const managedAgents = [...new Set([...alreadyManaged, ...resolvedNew])];
469
- if (!opts.dryRun) {
470
- writeManagedYaml(homeDir, managedAgents);
471
- }
472
- // MCP mode + memory-server resolution (design.md D5/D11) — grouped
473
- // with managed-set above as "how is this machine configured"
474
- // decisions resolved once per run, ahead of migrate/sync/mcp-sync/
475
- // memory-sync below, which act on the result. Both flags were already
476
- // validated before managed-set was even touched; omitting either
477
- // resolves to "no change requested" here too (design.md D1/D12) — no
478
- // writer call at all, on either a first run or a later one.
734
+ // Resolve all mode/memory decisions before opening the transaction. A
735
+ // refusal here must leave both canonical and managed state untouched.
479
736
  const canonicalForModeAndMemory = loadCanonicalSource(homeDir);
737
+ const interactiveMemory = await resolveInteractiveMemoryChoice(homeDir, opts);
738
+ const memoryValue = explicitMemoryValidation.value ?? interactiveMemory;
739
+ const memoryValidation = { value: memoryValue };
740
+ const sourceSummaryForInventory = source ? summary.find((item) => item.agent === source) : undefined;
741
+ const nativeMemory = sourceSummaryForInventory && sourceSummaryForInventory.nativeMemoryStatus !== "supported"
742
+ ? [{ name: `${source}:native`, detail: sourceSummaryForInventory.nativeMemoryDetail }]
743
+ : [];
744
+ const inventory = sourceSummaryForInventory
745
+ ? buildCapabilityInventory(sourceSummaryForInventory, canonicalForModeAndMemory, nativeMemory)
746
+ : undefined;
747
+ const capabilitySummary = {
748
+ awarenessSkill: canonicalForModeAndMemory.skills.some((skill) => skill.name === TRELLIS_RUNTIME_SKILL_NAME),
749
+ globalInstructions: { available: existsSync(canonicalForModeAndMemory.instructionsFile), native: true, runtimeReadable: true },
750
+ skills: canonicalForModeAndMemory.skills.length,
751
+ memories: canonicalForModeAndMemory.memories.length,
752
+ mcpServers: Object.keys(canonicalForModeAndMemory.mcp.servers).length,
753
+ };
754
+ const managedAgentRuntime = managedAgents.map((agent) => ({
755
+ agent,
756
+ delivery: capabilityDeliveryForAgent(agent, canonicalForModeAndMemory.mcp),
757
+ route: canonicalForModeAndMemory.mcp.routes?.[agent]?.mode
758
+ ?? (isGatewayAgent(agent, canonicalForModeAndMemory.mcp, managedAgents) ? "gateway" : canonicalForModeAndMemory.mcp.hub ? "hub" : "direct"),
759
+ }));
480
760
  const mcpModeResolution = resolveMcpModeChange(mcpModeValidation.mode, mcpModeValueOf(canonicalForModeAndMemory.mcp));
481
761
  const mcpMode = { current: mcpModeResolution.current, previous: mcpModeResolution.previous, changed: mcpModeResolution.changed };
482
762
  const serversYamlPath = join(homeDir, ".trellis", "mcp", "servers.yaml");
483
- if (mcpModeResolution.write && !opts.dryRun) {
484
- writeMcpModeYaml(serversYamlPath, mcpModeResolution.write);
485
- }
486
763
  const memoryConfiguredBefore = canonicalForModeAndMemory.mcp.servers[MEMORY_SERVER_NAME] !== undefined;
487
764
  const memoryResolution = resolveMemoryToggle(memoryValidation.value, memoryConfiguredBefore, canonicalForModeAndMemory.mcp.knownHostInjected);
488
765
  if ("refusal" in memoryResolution) {
489
766
  return { summary, source, sourceReason, managedAgents, mcpMode, refusal: memoryResolution.refusal, verdict: [] };
490
767
  }
491
768
  const memory = { current: memoryResolution.current, previous: memoryResolution.previous, changed: memoryResolution.changed };
769
+ if (!opts.dryRun
770
+ && !opts.json
771
+ && mcpMode.changed
772
+ && (opts.isTTY ?? process.stdin.isTTY === true)
773
+ && (opts.promptForModeChange || canUseInteractivePicker())) {
774
+ const accepted = await confirmMcpModeChange(mcpMode.previous, mcpMode.current, managedAgents, opts);
775
+ if (!accepted)
776
+ return { summary, source, sourceReason, managedAgents, mcpMode, memory, refusal: "MCP mode change cancelled — current mode was preserved", verdict: [] };
777
+ }
778
+ // One transaction for the entire onboarding run, including canonical
779
+ // state. A dry run opens no session and a no-op session creates no backup.
780
+ const backupSession = opts.dryRun ? undefined : openBackupSession(homeDir, "onboard");
781
+ if (!opts.dryRun) {
782
+ writeManagedYaml(homeDir, managedAgents, backupSession);
783
+ }
784
+ if (mcpModeResolution.write && !opts.dryRun) {
785
+ writeMcpModeYaml(serversYamlPath, mcpModeResolution.write, backupSession);
786
+ }
492
787
  if (memory.changed && !opts.dryRun) {
493
788
  if (memory.current === "on") {
494
- upsertServerYaml(serversYamlPath, MEMORY_SERVER_NAME, DEFAULT_MEMORY_SERVER_DEF);
789
+ upsertServerYaml(serversYamlPath, MEMORY_SERVER_NAME, DEFAULT_MEMORY_SERVER_DEF, backupSession);
495
790
  }
496
791
  else {
497
- removeServerYaml(serversYamlPath, MEMORY_SERVER_NAME);
792
+ removeServerYaml(serversYamlPath, MEMORY_SERVER_NAME, backupSession);
498
793
  }
499
794
  }
500
795
  let migratePlan;
@@ -502,22 +797,78 @@ export async function collectOnboardPlan(opts = {}) {
502
797
  logProgress(opts, "migrate");
503
798
  if (source) {
504
799
  const sourceSummary = summary.find((s) => s.agent === source);
505
- const categories = await resolveMigrateCategories(sourceSummary, opts);
800
+ const interactiveSelection = capabilitySelection ?? await resolveInteractiveCapabilitySelection(sourceSummary, opts);
801
+ const categories = await resolveMigrateCategories(sourceSummary, opts, interactiveSelection);
506
802
  if (categories.length > 0) {
507
- migratePlan = await collectMigratePlan(source, homeDir, categories);
803
+ migratePlan = filterMigratePlan(await collectMigratePlan(source, homeDir, categories, workspaceDir), interactiveSelection);
508
804
  if (!opts.dryRun) {
509
- applyMigratePlan(migratePlan, homeDir);
805
+ applyMigratePlan(migratePlan, homeDir, backupSession);
510
806
  }
511
807
  }
512
808
  else {
513
809
  migrateSkipped = "migrate skipped — no categories selected";
514
810
  }
515
811
  }
516
- // One session for the whole chained run (trellis-backup-rollback) —
517
- // `--dry-run` opens none, there's nothing either stage will write.
518
- // Neither collectSyncReport nor collectMcpSyncReport finalizes a
519
- // session they were handed; only this caller does, once, after both.
520
- const backupSession = opts.dryRun ? undefined : openBackupSession(homeDir, "onboard");
812
+ const currentMcpAfterMigration = loadCanonicalSource(homeDir).mcp;
813
+ const migratedMcpNames = migratePlan?.items.filter((item) => item.kind === "mcp").map((item) => item.name) ?? [];
814
+ const availableMcpNames = [...new Set([...Object.keys(currentMcpAfterMigration.servers), ...migratedMcpNames])];
815
+ let requestedRoutes = capabilitySelection?.mcpRoutes;
816
+ const shouldPromptForRoutes = !opts.selectionFile
817
+ && opts.mcpMode === undefined
818
+ && !currentMcpAfterMigration.routes
819
+ && !currentMcpAfterMigration.gateway
820
+ && !currentMcpAfterMigration.hub
821
+ && managedAgents.length > 0
822
+ && availableMcpNames.length > 0
823
+ && canUseInteractivePicker();
824
+ if (!requestedRoutes && shouldPromptForRoutes) {
825
+ requestedRoutes = await resolveInteractiveMcpRoutes(managedAgents, availableMcpNames, currentMcpAfterMigration.hub !== undefined);
826
+ }
827
+ const requestedRuntimeDelivery = capabilitySelection?.runtimeDelivery;
828
+ let mcpSyncOverride;
829
+ if ((requestedRoutes && Object.keys(requestedRoutes).length > 0) || (requestedRuntimeDelivery && Object.keys(requestedRuntimeDelivery).length > 0)) {
830
+ const currentMcp = currentMcpAfterMigration;
831
+ const routes = requestedRoutes && Object.keys(requestedRoutes).length > 0
832
+ ? { ...(currentMcp.routes ?? {}), ...requestedRoutes }
833
+ : currentMcp.routes;
834
+ const delivery = requestedRuntimeDelivery && Object.keys(requestedRuntimeDelivery).length > 0
835
+ ? { ...(currentMcp.runtime?.delivery ?? {}), ...requestedRuntimeDelivery }
836
+ : currentMcp.runtime?.delivery;
837
+ mcpSyncOverride = {
838
+ ...currentMcp,
839
+ ...(routes ? { routes } : {}),
840
+ ...(delivery ? { runtime: { ...(currentMcp.runtime ?? {}), delivery } } : {}),
841
+ };
842
+ if (!opts.dryRun && requestedRoutes && Object.keys(requestedRoutes).length > 0) {
843
+ writeMcpRoutesYaml(join(homeDir, ".trellis", "mcp", "servers.yaml"), routes ?? {}, backupSession);
844
+ }
845
+ if (!opts.dryRun && requestedRuntimeDelivery && Object.keys(requestedRuntimeDelivery).length > 0) {
846
+ writeMcpRuntimeDeliveryYaml(join(homeDir, ".trellis", "mcp", "servers.yaml"), delivery ?? {}, backupSession);
847
+ }
848
+ }
849
+ // Dry-run must plan the Memory server that this transaction would write;
850
+ // otherwise the following mcp-sync/memory-sync stages would inspect the
851
+ // pre-run canonical file and falsely report an unconfigured backend. The
852
+ // effective view is never persisted here — real writes above still use the
853
+ // normal backup transaction.
854
+ const effectiveMcpForRun = {
855
+ ...(mcpSyncOverride ?? currentMcpAfterMigration),
856
+ servers: { ...(mcpSyncOverride ?? currentMcpAfterMigration).servers },
857
+ };
858
+ if (memory.current === "on") {
859
+ effectiveMcpForRun.servers[MEMORY_SERVER_NAME] = effectiveMcpForRun.servers[MEMORY_SERVER_NAME] ?? DEFAULT_MEMORY_SERVER_DEF;
860
+ }
861
+ else {
862
+ delete effectiveMcpForRun.servers[MEMORY_SERVER_NAME];
863
+ }
864
+ const mergedMemoryRoutes = mergeMemoryIntoExplicitRoutes(effectiveMcpForRun, managedAgents, memory.current === "on");
865
+ const effectiveMcpWithRoutes = mergedMemoryRoutes.mcp;
866
+ if (mergedMemoryRoutes.changed && !opts.dryRun && effectiveMcpWithRoutes.routes) {
867
+ writeMcpRoutesYaml(serversYamlPath, effectiveMcpWithRoutes.routes, backupSession);
868
+ }
869
+ // The same session now covers canonical and native writes. Neither
870
+ // collectSyncReport nor collectMcpSyncReport finalizes it; this caller
871
+ // finalizes once after every verification stage has completed.
521
872
  logProgress(opts, "sync");
522
873
  const syncReport = await collectSyncReport({ homeDir, dryRun: opts.dryRun, managedAgents, backupSession });
523
874
  // The self-verification re-plan (design.md D2b): immediately after a
@@ -529,18 +880,18 @@ export async function collectOnboardPlan(opts = {}) {
529
880
  // written, so there is nothing to verify.
530
881
  const syncVerification = opts.dryRun ? undefined : await collectSyncReport({ homeDir, dryRun: true, managedAgents });
531
882
  logProgress(opts, "mcp sync");
532
- const mcpSyncReport = await collectMcpSyncReport({ homeDir, dryRun: opts.dryRun, managedAgents, backupSession });
883
+ const mcpSyncReport = await collectMcpSyncReport({ homeDir, dryRun: opts.dryRun, managedAgents, backupSession, mcp: effectiveMcpWithRoutes });
533
884
  const mcpSyncVerification = opts.dryRun ? undefined : await collectMcpSyncReport({ homeDir, dryRun: true, managedAgents });
534
- backupSession?.finalize();
535
885
  // Independent of `managedAgents` — the shared memory server is not
536
886
  // per-agent (design.md D4, trellis-onboard-mcp-memory), unlike
537
887
  // sync/mcp-sync above. Runs unconditionally once reached; an
538
888
  // unconfigured `memory` server is a legitimate "nothing to do yet"
539
889
  // state (design.md D5), not gated on any prior step here.
540
890
  logProgress(opts, "memory sync");
541
- const memorySyncResult = collectMemorySyncResult(homeDir);
891
+ const selectedMemoryNames = capabilitySelection ? resolveSelectedNames(capabilitySelection.memories, loadCanonicalSource(homeDir).memories.map((memory) => memory.name)) : undefined;
892
+ const memorySyncResult = collectMemorySyncResult(homeDir, selectedMemoryNames, effectiveMcpWithRoutes);
542
893
  if (!opts.dryRun) {
543
- applyMemorySync(memorySyncResult);
894
+ applyMemorySync(memorySyncResult, backupSession);
544
895
  }
545
896
  // Read-only, no dryRun concept — same report either way, run last since
546
897
  // it audits the config mcp sync just wrote (or, on --dry-run, whatever
@@ -568,8 +919,27 @@ export async function collectOnboardPlan(opts = {}) {
568
919
  ...normalizeSecretsAuditVerdict(secretsAuditReport),
569
920
  ...normalizeDoctorVerdict(doctorReport, managedAgents),
570
921
  ];
922
+ let rollback;
923
+ if (!opts.dryRun && backupSession?.hasOperations()) {
924
+ backupSession.finalize();
925
+ // Doctor intentionally scans the whole machine, including unmanaged
926
+ // Agents and pre-existing cross-agent drift. It is informative health
927
+ // state, not proof that this transaction failed. Roll back only stages
928
+ // whose writes/on-write verification belong to this onboard run.
929
+ const transactionBlocked = verdict.some((item) => item.severity === "blocked" && !item.stage.startsWith("doctor"));
930
+ if (transactionBlocked) {
931
+ rollback = await collectRollbackPlan(homeDir, backupSession.runId);
932
+ await applyRollbackPlan(homeDir, rollback.runId, loadManifest(homeDir, rollback.runId), rollback.items);
933
+ }
934
+ }
935
+ else {
936
+ backupSession?.finalize();
937
+ }
571
938
  return {
572
939
  summary,
940
+ inventory,
941
+ capabilitySummary,
942
+ managedAgentRuntime,
573
943
  source,
574
944
  sourceReason,
575
945
  managedAgents,
@@ -583,6 +953,7 @@ export async function collectOnboardPlan(opts = {}) {
583
953
  memorySyncResult,
584
954
  secretsAuditReport,
585
955
  doctorReport,
956
+ rollback,
586
957
  mcpMode,
587
958
  memory,
588
959
  verdict,
@@ -600,8 +971,11 @@ async function offerToApply(opts) {
600
971
  return opts.promptToApply();
601
972
  if (!canUseInteractivePicker())
602
973
  return false;
603
- console.log("");
604
- const choice = await runSingleSelectPicker(["No, don't apply", "Yes, apply now"]);
974
+ console.error("");
975
+ const choice = await runSingleSelectPicker([
976
+ "暂不应用(推荐:先检查上面的预览)",
977
+ "立即应用",
978
+ ], undefined, "预览已完成,是否写入配置?");
605
979
  return choice === 1;
606
980
  }
607
981
  export async function runOnboard(opts = {}) {
@@ -675,6 +1049,13 @@ function printResult(result, dryRun, showStatusLine) {
675
1049
  : `${install.agent} has no npm package to install (see \`trellis init\`'s install hint) — left out of this run's managed set.`);
676
1050
  }
677
1051
  console.log(`Managed agents: ${result.managedAgents && result.managedAgents.length > 0 ? result.managedAgents.join(", ") : "(none)"}`);
1052
+ if (showStatusLine && result.managedAgentRuntime?.length) {
1053
+ console.log(`Agent delivery: ${result.managedAgentRuntime.map((item) => `${item.agent}=${item.delivery}/${item.route}`).join(", ")}`);
1054
+ }
1055
+ if (showStatusLine && result.capabilitySummary) {
1056
+ const summary = result.capabilitySummary;
1057
+ console.log(`Trellis capabilities: awareness=${summary.awarenessSkill ? "ready" : "missing"}, global instructions=${summary.globalInstructions.native ? "native" : "unavailable"} + Runtime read, Skills=${summary.skills}, Memory=${summary.memories}, MCP=${summary.mcpServers}`);
1058
+ }
678
1059
  // Informational only, never a prompt (design.md D6/D12,
679
1060
  // trellis-onboard-mcp-mode) — this is what keeps `--mcp-mode`/
680
1061
  // `--memory` discoverable without costing every run a keystroke.
@@ -682,6 +1063,10 @@ function printResult(result, dryRun, showStatusLine) {
682
1063
  // already uses.
683
1064
  if (showStatusLine && result.mcpMode) {
684
1065
  console.log(mcpModeStatusLine(result.mcpMode));
1066
+ if (result.mcpMode.changed) {
1067
+ const affected = result.managedAgents && result.managedAgents.length > 0 ? result.managedAgents.join(", ") : "(none)";
1068
+ console.log(`mode transition impact: ${result.mcpMode.previous} → ${result.mcpMode.current}; managed agents: ${affected}`);
1069
+ }
685
1070
  }
686
1071
  if (showStatusLine && result.memory) {
687
1072
  console.log(memoryStatusLine(result.memory));
@@ -721,4 +1106,10 @@ function printResult(result, dryRun, showStatusLine) {
721
1106
  console.log("\nhealth scan (trellis doctor)");
722
1107
  printDoctorReport(result.doctorReport);
723
1108
  }
1109
+ if (result.rollback) {
1110
+ console.log("\nautomatic rollback");
1111
+ const restored = result.rollback.items.filter((item) => item.action === "restore").length;
1112
+ const conflicts = result.rollback.items.filter((item) => item.action === "conflict").length;
1113
+ console.log(`↩️ restored ${restored} path(s), ${conflicts} rollback conflict(s); backup: ${result.rollback.runId}`);
1114
+ }
724
1115
  }