agent-trellis 0.3.0 → 0.5.2

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.
@@ -7,6 +7,7 @@
7
7
  * through untouched.
8
8
  */
9
9
  import { deepEqual } from "../lib/deepEqual.js";
10
+ import { resolvedEnvTextMap } from "../lib/mcpMigrateRead.js";
10
11
  import { resolveMcpPlan } from "./mcpPlan.js";
11
12
  /** Renders the native JSON shape Claude Code/Kiro's `mcpServers` map
12
13
  * expects — `env` names render as `${VAR}` references, `staticEnv`
@@ -23,8 +24,7 @@ export function renderJsonServerEntry(def) {
23
24
  return entry;
24
25
  }
25
26
  const entry = { type: "stdio", command: def.command, args: def.args ?? [] };
26
- const nameRefs = Object.fromEntries((def.env ?? []).map((name) => [name, `\${${name}}`]));
27
- const env = { ...nameRefs, ...(def.staticEnv ?? {}) };
27
+ const env = resolvedEnvTextMap(def);
28
28
  if (Object.keys(env).length > 0) {
29
29
  entry.env = env;
30
30
  }
@@ -53,9 +53,16 @@ function findLiteralSecret(def) {
53
53
  * proposal.md "Why"). Uses the identical `resolveSecretEnv` `secrets
54
54
  * audit`/the pi bridge already call, so this and a later `secrets audit`
55
55
  * run can never disagree about what resolves.
56
+ *
57
+ * `envAliases`' values are themselves source variable names needing the
58
+ * exact same resolution — checked in the same pass so a differently-named
59
+ * reference gets the same pre-write refusal `env` already has, naming the
60
+ * unresolved *source* name (what the caller must find a value for), not
61
+ * the target key it would have been written under
62
+ * (trellis-migrate-env-var-alias).
56
63
  */
57
64
  function findUnresolvedEnvName(def, policy) {
58
- const names = def.env ?? [];
65
+ const names = [...(def.env ?? []), ...Object.values(def.envAliases ?? {})];
59
66
  if (names.length === 0)
60
67
  return undefined;
61
68
  const resolved = resolveSecretEnv(names, policy);
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import { runMcpSync, runMcpList, runMcpAdd, runMcpRemove, parseMcpAddArgs } from
13
13
  import { runSecretsAudit } from "./commands/secretsAudit.js";
14
14
  import { runRollback } from "./commands/rollback.js";
15
15
  import { runSkillList, runSkillAdd, runSkillRemove } from "./commands/skill.js";
16
- import { runMemorySync } from "./commands/memory.js";
16
+ import { runMemoryExtraction, runMemorySync } from "./commands/memory.js";
17
17
  import { parseSyncArgs } from "./lib/syncArgs.js";
18
18
  const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "skill", "memory", "secrets", "rollback"];
19
19
  function printUsage() {
@@ -104,6 +104,14 @@ Commands:
104
104
  or relation this didn't create.
105
105
  --dry-run preview the plan, write nothing
106
106
  --json machine-readable output, no report text
107
+ memory extract
108
+ The reverse direction: read the same graph file's real,
109
+ non-Trellis entities (agent-accumulated content, never
110
+ Trellis's own) and write each as a new canonical
111
+ memories/*.md file — readable markdown, not meant to
112
+ round-trip byte-for-byte back through memory sync.
113
+ --dry-run preview the plan, write nothing
114
+ --json machine-readable output, no report text
107
115
  secrets audit
108
116
  Scan each present agent's real MCP config for leaked
109
117
  credentials and unexpected env var names
@@ -240,13 +248,18 @@ async function main(argv) {
240
248
  }
241
249
  if (command === "memory") {
242
250
  const [subcommand, ...memoryRest] = rest;
243
- if (subcommand !== "sync") {
244
- console.error(`Unknown memory subcommand: ${subcommand ?? "(none)"}\nUsage: trellis memory sync\n`);
245
- process.exitCode = 1;
251
+ const json = memoryRest.includes("--json");
252
+ const dryRun = memoryRest.includes("--dry-run");
253
+ if (subcommand === "sync") {
254
+ process.exitCode = runMemorySync({ json, dryRun }).exitCode;
246
255
  return;
247
256
  }
248
- const { exitCode } = runMemorySync({ json: memoryRest.includes("--json"), dryRun: memoryRest.includes("--dry-run") });
249
- process.exitCode = exitCode;
257
+ if (subcommand === "extract") {
258
+ process.exitCode = runMemoryExtraction({ json, dryRun }).exitCode;
259
+ return;
260
+ }
261
+ console.error(`Unknown memory subcommand: ${subcommand ?? "(none)"}\nUsage: trellis memory sync|extract\n`);
262
+ process.exitCode = 1;
250
263
  return;
251
264
  }
252
265
  if (command === "secrets") {
@@ -1,13 +1,18 @@
1
1
  /**
2
- * `trellis memory sync` — ingests canonical `memories/*.md` into the
3
- * on-disk knowledge-graph file `@modelcontextprotocol/server-memory`
4
- * itself reads at startup, closing P6's explicitly-left-open "auto-
5
- * ingesting memories/*.md content into the running memory server's
6
- * store" gap (trellis-memory-sync). Never spawns or talks to a running
7
- * server process — this is a plain file write, same posture as every
8
- * other Trellis write.
2
+ * `trellis memory sync`/`trellis memory extract` — the two one-directional
3
+ * halves of Trellis's shared-memory story: sync ingests canonical
4
+ * `memories/*.md` into the on-disk knowledge-graph file
5
+ * `@modelcontextprotocol/server-memory` itself reads at startup, closing
6
+ * P6's explicitly-left-open "auto-ingesting memories/*.md content into
7
+ * the running memory server's store" gap (trellis-memory-sync); extract
8
+ * reads the graph's own real, non-Trellis entities back into new
9
+ * canonical files, closing P15's own explicitly-named "per-agent
10
+ * extraction into canonical remains a separate, open gap"
11
+ * (trellis-memory-extraction). Neither spawns or talks to a running
12
+ * server process — both are plain file operations, same posture as
13
+ * every other Trellis write.
9
14
  */
10
- import { type MemorySyncPlan } from "../lib/memoryGraph.js";
15
+ import { type MemoryExtractionPlan, type MemorySyncPlan } from "../lib/memoryGraph.js";
11
16
  export interface RunMemorySyncOptions {
12
17
  homeDir?: string;
13
18
  json?: boolean;
@@ -21,19 +26,45 @@ export type MemorySyncResult = {
21
26
  configured: false;
22
27
  reason: string;
23
28
  };
24
- /**
25
- * Looks up `mcp.servers["memory"]` specifically (matching `schema/
26
- * servers.example.yaml`'s own naming convention for the default shared-
27
- * memory backend) — not a heuristic scan for any server whose command
28
- * happens to mention `@modelcontextprotocol/server-memory`, since a
29
- * user could reasonably name or configure it differently and this
30
- * would then silently miss it. `static_env.MEMORY_FILE_PATH` must be
31
- * set explicitly: the server's own unset-env default resolves relative
32
- * to wherever `npx` cached the package, a location Trellis has no
33
- * reliable way to predict.
34
- */
35
29
  export declare function collectMemorySyncResult(homeDir?: string): MemorySyncResult;
36
30
  export declare function applyMemorySync(result: MemorySyncResult): void;
31
+ /**
32
+ * Extracted from `runMemorySync` (trellis-onboard-mcp-memory) so
33
+ * `onboard`'s own chained output can print this stage in the exact same
34
+ * format without a second, drifting copy — mirrors the
35
+ * `collect...Report`/`print...Report` export pair every other chained
36
+ * stage (`sync`, `mcp sync`, `secrets audit`) already has.
37
+ */
38
+ export declare function printMemorySyncResult(result: MemorySyncResult, dryRun: boolean): void;
37
39
  export declare function runMemorySync(opts?: RunMemorySyncOptions): {
38
40
  exitCode: number;
39
41
  };
42
+ export interface RunMemoryExtractionOptions {
43
+ homeDir?: string;
44
+ json?: boolean;
45
+ dryRun?: boolean;
46
+ }
47
+ export type MemoryExtractionResult = {
48
+ configured: true;
49
+ graphPath: string;
50
+ plan: MemoryExtractionPlan;
51
+ } | {
52
+ configured: false;
53
+ reason: string;
54
+ };
55
+ /**
56
+ * The reverse direction of `collectMemorySyncResult`: reads the same
57
+ * graph file, but plans which of its real, non-`trellis-memory` entities
58
+ * (`planMemoryExtraction`) should become new canonical files — never the
59
+ * other way around, and never touching an entity `memory sync` itself
60
+ * owns (trellis-memory-extraction).
61
+ */
62
+ export declare function collectMemoryExtractionResult(homeDir?: string): MemoryExtractionResult;
63
+ export declare function applyMemoryExtraction(result: MemoryExtractionResult, homeDir?: string): void;
64
+ /** Mirrors `printMemorySyncResult`'s own export-pair convention — reused
65
+ * verbatim if `onboard` ever chains this stage too, not a second,
66
+ * drifting copy. */
67
+ export declare function printMemoryExtractionResult(result: MemoryExtractionResult, dryRun: boolean): void;
68
+ export declare function runMemoryExtraction(opts?: RunMemoryExtractionOptions): {
69
+ exitCode: number;
70
+ };
@@ -1,17 +1,22 @@
1
1
  /**
2
- * `trellis memory sync` — ingests canonical `memories/*.md` into the
3
- * on-disk knowledge-graph file `@modelcontextprotocol/server-memory`
4
- * itself reads at startup, closing P6's explicitly-left-open "auto-
5
- * ingesting memories/*.md content into the running memory server's
6
- * store" gap (trellis-memory-sync). Never spawns or talks to a running
7
- * server process — this is a plain file write, same posture as every
8
- * other Trellis write.
2
+ * `trellis memory sync`/`trellis memory extract` — the two one-directional
3
+ * halves of Trellis's shared-memory story: sync ingests canonical
4
+ * `memories/*.md` into the on-disk knowledge-graph file
5
+ * `@modelcontextprotocol/server-memory` itself reads at startup, closing
6
+ * P6's explicitly-left-open "auto-ingesting memories/*.md content into
7
+ * the running memory server's store" gap (trellis-memory-sync); extract
8
+ * reads the graph's own real, non-Trellis entities back into new
9
+ * canonical files, closing P15's own explicitly-named "per-agent
10
+ * extraction into canonical remains a separate, open gap"
11
+ * (trellis-memory-extraction). Neither spawns or talks to a running
12
+ * server process — both are plain file operations, same posture as
13
+ * every other Trellis write.
9
14
  */
10
15
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
16
  import { homedir } from "node:os";
12
- import { dirname } from "node:path";
17
+ import { dirname, join } from "node:path";
13
18
  import { loadCanonicalSource } from "../core/canonical.js";
14
- import { planMemorySync, renderMemoryGraph } from "../lib/memoryGraph.js";
19
+ import { parseMemoryGraph, planMemoryExtraction, planMemorySync, renderMemoryGraph } from "../lib/memoryGraph.js";
15
20
  const MEMORY_SERVER_NAME = "memory";
16
21
  /**
17
22
  * Looks up `mcp.servers["memory"]` specifically (matching `schema/
@@ -22,9 +27,12 @@ const MEMORY_SERVER_NAME = "memory";
22
27
  * would then silently miss it. `static_env.MEMORY_FILE_PATH` must be
23
28
  * set explicitly: the server's own unset-env default resolves relative
24
29
  * to wherever `npx` cached the package, a location Trellis has no
25
- * reliable way to predict.
30
+ * reliable way to predict. Shared by `collectMemorySyncResult` and
31
+ * `collectMemoryExtractionResult` — both directions must never disagree
32
+ * about which graph file they're reading/writing
33
+ * (trellis-memory-extraction).
26
34
  */
27
- export function collectMemorySyncResult(homeDir = homedir()) {
35
+ function resolveMemoryServerGraphPath(homeDir) {
28
36
  const canonical = loadCanonicalSource(homeDir);
29
37
  const server = canonical.mcp.servers[MEMORY_SERVER_NAME];
30
38
  const rawPath = server?.staticEnv?.MEMORY_FILE_PATH;
@@ -34,10 +42,16 @@ export function collectMemorySyncResult(homeDir = homedir()) {
34
42
  reason: `no "${MEMORY_SERVER_NAME}" MCP server with static_env.MEMORY_FILE_PATH configured in servers.yaml — see schema/servers.example.yaml`,
35
43
  };
36
44
  }
37
- const graphPath = rawPath.replace(/^~(?=$|\/)/, homeDir);
38
- const currentContent = existsSync(graphPath) ? readFileSync(graphPath, "utf-8") : undefined;
45
+ return { configured: true, graphPath: rawPath.replace(/^~(?=$|\/)/, homeDir) };
46
+ }
47
+ export function collectMemorySyncResult(homeDir = homedir()) {
48
+ const lookup = resolveMemoryServerGraphPath(homeDir);
49
+ if (!lookup.configured)
50
+ return lookup;
51
+ const canonical = loadCanonicalSource(homeDir);
52
+ const currentContent = existsSync(lookup.graphPath) ? readFileSync(lookup.graphPath, "utf-8") : undefined;
39
53
  const plan = planMemorySync(canonical, currentContent);
40
- return { configured: true, graphPath, plan };
54
+ return { configured: true, graphPath: lookup.graphPath, plan };
41
55
  }
42
56
  export function applyMemorySync(result) {
43
57
  if (!result.configured || !result.plan.nextGraph)
@@ -45,6 +59,26 @@ export function applyMemorySync(result) {
45
59
  mkdirSync(dirname(result.graphPath), { recursive: true });
46
60
  writeFileSync(result.graphPath, renderMemoryGraph(result.plan.nextGraph));
47
61
  }
62
+ /**
63
+ * Extracted from `runMemorySync` (trellis-onboard-mcp-memory) so
64
+ * `onboard`'s own chained output can print this stage in the exact same
65
+ * format without a second, drifting copy — mirrors the
66
+ * `collect...Report`/`print...Report` export pair every other chained
67
+ * stage (`sync`, `mcp sync`, `secrets audit`) already has.
68
+ */
69
+ export function printMemorySyncResult(result, dryRun) {
70
+ if (!result.configured) {
71
+ console.log(result.reason);
72
+ return;
73
+ }
74
+ console.log(`${dryRun ? "[dry run] " : ""}memory sync — ${result.graphPath}`);
75
+ if (result.plan.items.length === 0) {
76
+ console.log(" nothing in canonical memories/ yet");
77
+ }
78
+ for (const item of result.plan.items) {
79
+ console.log(` [${item.action}] "${item.name}" — ${item.detail}`);
80
+ }
81
+ }
48
82
  export function runMemorySync(opts = {}) {
49
83
  const homeDir = opts.homeDir ?? homedir();
50
84
  let result;
@@ -61,17 +95,79 @@ export function runMemorySync(opts = {}) {
61
95
  if (opts.json) {
62
96
  console.log(JSON.stringify(result, null, 2));
63
97
  }
64
- else if (!result.configured) {
98
+ else {
99
+ printMemorySyncResult(result, opts.dryRun ?? false);
100
+ }
101
+ const hasConflict = result.configured && result.plan.items.some((i) => i.action === "conflict");
102
+ return { exitCode: hasConflict ? 1 : 0 };
103
+ }
104
+ function canonicalMemoryFilePath(homeDir, slug) {
105
+ return join(homeDir, ".trellis", "memories", `${slug}.md`);
106
+ }
107
+ /**
108
+ * The reverse direction of `collectMemorySyncResult`: reads the same
109
+ * graph file, but plans which of its real, non-`trellis-memory` entities
110
+ * (`planMemoryExtraction`) should become new canonical files — never the
111
+ * other way around, and never touching an entity `memory sync` itself
112
+ * owns (trellis-memory-extraction).
113
+ */
114
+ export function collectMemoryExtractionResult(homeDir = homedir()) {
115
+ const lookup = resolveMemoryServerGraphPath(homeDir);
116
+ if (!lookup.configured)
117
+ return lookup;
118
+ const graphContent = existsSync(lookup.graphPath) ? readFileSync(lookup.graphPath, "utf-8") : undefined;
119
+ const graphLines = graphContent !== undefined ? parseMemoryGraph(graphContent) : [];
120
+ const plan = planMemoryExtraction(graphLines, (slug) => {
121
+ const file = canonicalMemoryFilePath(homeDir, slug);
122
+ return existsSync(file) ? readFileSync(file, "utf-8") : undefined;
123
+ });
124
+ return { configured: true, graphPath: lookup.graphPath, plan };
125
+ }
126
+ export function applyMemoryExtraction(result, homeDir = homedir()) {
127
+ if (!result.configured)
128
+ return;
129
+ for (const item of result.plan.items) {
130
+ if (item.action !== "create" || item.content === undefined)
131
+ continue;
132
+ const file = canonicalMemoryFilePath(homeDir, item.slug);
133
+ mkdirSync(dirname(file), { recursive: true });
134
+ writeFileSync(file, item.content);
135
+ }
136
+ }
137
+ /** Mirrors `printMemorySyncResult`'s own export-pair convention — reused
138
+ * verbatim if `onboard` ever chains this stage too, not a second,
139
+ * drifting copy. */
140
+ export function printMemoryExtractionResult(result, dryRun) {
141
+ if (!result.configured) {
65
142
  console.log(result.reason);
143
+ return;
144
+ }
145
+ console.log(`${dryRun ? "[dry run] " : ""}memory extract — ${result.graphPath}`);
146
+ if (result.plan.items.length === 0) {
147
+ console.log(" nothing to extract — no non-trellis-memory entities with observations in the graph");
148
+ }
149
+ for (const item of result.plan.items) {
150
+ console.log(` [${item.action}] "${item.name}" (${item.slug}.md) — ${item.detail}`);
151
+ }
152
+ }
153
+ export function runMemoryExtraction(opts = {}) {
154
+ const homeDir = opts.homeDir ?? homedir();
155
+ let result;
156
+ try {
157
+ result = collectMemoryExtractionResult(homeDir);
158
+ }
159
+ catch (err) {
160
+ console.error(err instanceof Error ? err.message : String(err));
161
+ return { exitCode: 1 };
162
+ }
163
+ if (!opts.dryRun) {
164
+ applyMemoryExtraction(result, homeDir);
165
+ }
166
+ if (opts.json) {
167
+ console.log(JSON.stringify(result, null, 2));
66
168
  }
67
169
  else {
68
- console.log(`${opts.dryRun ? "[dry run] " : ""}memory sync — ${result.graphPath}`);
69
- if (result.plan.items.length === 0) {
70
- console.log(" nothing in canonical memories/ yet");
71
- }
72
- for (const item of result.plan.items) {
73
- console.log(` [${item.action}] "${item.name}" — ${item.detail}`);
74
- }
170
+ printMemoryExtractionResult(result, opts.dryRun ?? false);
75
171
  }
76
172
  const hasConflict = result.configured && result.plan.items.some((i) => i.action === "conflict");
77
173
  return { exitCode: hasConflict ? 1 : 0 };
@@ -5,7 +5,7 @@
5
5
  * plan-then-apply split every adapter already uses.
6
6
  */
7
7
  import type { AgentId, McpServerDef } from "../core/types.js";
8
- export type MigrateAction = "create" | "skip-symlink" | "skip-case-broken" | "skip-unsupported" | "already-migrated" | "conflict";
8
+ export type MigrateAction = "create" | "skip-symlink" | "skip-case-broken" | "skip-unsupported" | "already-migrated" | "reclassify" | "conflict";
9
9
  /** Internal kind naming, unchanged since before `--only` existed
10
10
  * (trellis-migrate-category-selection design.md D2) — the CLI-facing
11
11
  * flag value is the plural `"skills"`, mapped to this singular `"skill"`
@@ -28,6 +28,19 @@ export interface MigratePlan {
28
28
  present: boolean;
29
29
  items: MigratePlanItem[];
30
30
  }
31
+ /**
32
+ * True when `existing` and `def` differ ONLY in which of `env`/
33
+ * `envAliases`/`staticEnv` an env value is filed under, while every
34
+ * other field and every value's resolved literal/reference text stays
35
+ * identical — the exact shape a migrate-read classification fix (like
36
+ * `trellis-migrate-env-var-alias`) leaves behind on already-migrated
37
+ * canonical data. Not a real conflict: the underlying value never
38
+ * changed, only Trellis's own bucketing of it got more precise
39
+ * (trellis-migrate-reclassify-repair). Anything this doesn't cover — a
40
+ * real value change, a different command, an added/removed field —
41
+ * still conflicts, unchanged.
42
+ */
43
+ export declare function isSafeReclassification(existing: McpServerDef, def: McpServerDef): boolean;
31
44
  /**
32
45
  * `only` restricts which kind(s) are even considered — not a post-hoc
33
46
  * filter on a fully-computed plan (trellis-migrate-category-selection
@@ -14,7 +14,7 @@ import * as piProbe from "../probes/pi.js";
14
14
  import { AGENTS_MD_TEMPLATE } from "./init.js";
15
15
  import { decideDirImport } from "../lib/dirEquals.js";
16
16
  import { deepEqual } from "../lib/deepEqual.js";
17
- import { readClaudeCodeMcpDefs, readCodexMcpDefs, readKiroMcpDefs } from "../lib/mcpMigrateRead.js";
17
+ import { readClaudeCodeMcpDefs, readCodexMcpDefs, readKiroMcpDefs, resolvedEnvTextMap } from "../lib/mcpMigrateRead.js";
18
18
  import { loadCanonicalSource, upsertServerYaml } from "../core/canonical.js";
19
19
  import { ALL_AGENTS } from "../core/types.js";
20
20
  const PROBES = {
@@ -72,6 +72,25 @@ function planInstructions(snapshot, canonicalAgentsMd) {
72
72
  }
73
73
  return { kind: "instructions", name: "agents.md", action: "conflict", detail: "canonical agents.md already has different real content — resolve by hand" };
74
74
  }
75
+ /**
76
+ * True when `existing` and `def` differ ONLY in which of `env`/
77
+ * `envAliases`/`staticEnv` an env value is filed under, while every
78
+ * other field and every value's resolved literal/reference text stays
79
+ * identical — the exact shape a migrate-read classification fix (like
80
+ * `trellis-migrate-env-var-alias`) leaves behind on already-migrated
81
+ * canonical data. Not a real conflict: the underlying value never
82
+ * changed, only Trellis's own bucketing of it got more precise
83
+ * (trellis-migrate-reclassify-repair). Anything this doesn't cover — a
84
+ * real value change, a different command, an added/removed field —
85
+ * still conflicts, unchanged.
86
+ */
87
+ export function isSafeReclassification(existing, def) {
88
+ const { env: _e1, envAliases: _ea1, staticEnv: _se1, ...restExisting } = existing;
89
+ const { env: _e2, envAliases: _ea2, staticEnv: _se2, ...restDef } = def;
90
+ if (!deepEqual(restExisting, restDef))
91
+ return false;
92
+ return deepEqual(resolvedEnvTextMap(existing), resolvedEnvTextMap(def));
93
+ }
75
94
  function planMcpServer(name, def, existing) {
76
95
  if (existing === undefined) {
77
96
  return { kind: "mcp", name, action: "create", detail: "will add to servers.yaml", mcpDef: def };
@@ -79,6 +98,9 @@ function planMcpServer(name, def, existing) {
79
98
  if (deepEqual(existing, def)) {
80
99
  return { kind: "mcp", name, action: "already-migrated", detail: "canonical definition is already identical" };
81
100
  }
101
+ if (isSafeReclassification(existing, def)) {
102
+ return { kind: "mcp", name, action: "reclassify", detail: "same value, only its internal classification changed — safe to update", mcpDef: def };
103
+ }
82
104
  return { kind: "mcp", name, action: "conflict", detail: `canonical mcp/servers.yaml already has a different definition for "${name}" — resolve by hand` };
83
105
  }
84
106
  /**
@@ -127,7 +149,7 @@ export async function collectMigratePlan(agent, homeDir = homedir(), only) {
127
149
  export function applyMigratePlan(plan, homeDir = homedir()) {
128
150
  const canonicalRoot = join(homeDir, ".trellis");
129
151
  for (const item of plan.items) {
130
- if (item.action !== "create")
152
+ if (item.action !== "create" && item.action !== "reclassify")
131
153
  continue;
132
154
  if (item.kind === "skill" && item.sourceDir) {
133
155
  const dest = join(canonicalRoot, "skills", item.name);
@@ -17,6 +17,7 @@ import type { MigrateKind, MigratePlan } from "./migrate.js";
17
17
  import type { SyncReport } from "./sync.js";
18
18
  import type { McpSyncReport } from "./mcp.js";
19
19
  import type { SecretsAuditReport } from "./secretsAudit.js";
20
+ import type { MemorySyncResult } from "./memory.js";
20
21
  import type { ConfirmAndInstallOptions } from "../lib/installAgent.js";
21
22
  export interface OnboardAgentSummary {
22
23
  agent: AgentId;
@@ -27,6 +28,13 @@ export interface OnboardAgentSummary {
27
28
  * of that agent's own to potentially migrate, not a judgment about
28
29
  * its quality or length. */
29
30
  hasRealInstructions: boolean;
31
+ /** How many real MCP servers `migrate --only mcp` would find for this
32
+ * agent — computed via `collectMigratePlan(agent, homeDir, ["mcp"])`
33
+ * rather than a second reader dispatch, so this always agrees with
34
+ * what migrate itself will do (design.md D1, trellis-onboard-mcp-
35
+ * memory). 0 for pi, which has no MCP reader at all — not a gap here,
36
+ * the same fact `migrate.ts` already establishes. */
37
+ mcpServerCount: number;
30
38
  }
31
39
  export declare function collectOnboardSummary(homeDir?: string): Promise<OnboardAgentSummary[]>;
32
40
  export interface RunOnboardOptions {
@@ -82,6 +90,7 @@ export interface OnboardResult {
82
90
  migrateSkipped?: string;
83
91
  syncReport?: SyncReport;
84
92
  mcpSyncReport?: McpSyncReport;
93
+ memorySyncResult?: MemorySyncResult;
85
94
  secretsAuditReport?: SecretsAuditReport;
86
95
  refusal?: string;
87
96
  /** Only set when no agent is present — the same values `--json` and
@@ -28,6 +28,7 @@ import { applyMigratePlan, collectMigratePlan, printPlan as printMigratePlan } f
28
28
  import { collectSyncReport, printReport as printSyncReport } from "./sync.js";
29
29
  import { collectMcpSyncReport, printReport as printMcpSyncReport } from "./mcp.js";
30
30
  import { collectSecretsAuditReport, printReport as printSecretsAuditReport } from "./secretsAudit.js";
31
+ import { applyMemorySync, collectMemorySyncResult, printMemorySyncResult } from "./memory.js";
31
32
  import { openBackupSession } from "../lib/backup.js";
32
33
  import { confirmAndInstall } from "../lib/installAgent.js";
33
34
  const PROBES = {
@@ -40,19 +41,20 @@ export async function collectOnboardSummary(homeDir = homedir()) {
40
41
  return Promise.all(ALL_AGENTS.map(async (agent) => {
41
42
  const snapshot = await PROBES[agent](homeDir);
42
43
  if (!snapshot.present) {
43
- return { agent, present: false, skillCount: 0, skillNames: [], hasRealInstructions: false };
44
+ return { agent, present: false, skillCount: 0, skillNames: [], hasRealInstructions: false, mcpServerCount: 0 };
44
45
  }
45
46
  const skillNames = snapshot.skillRoots.flatMap((root) => root.skills.map((s) => s.name));
46
47
  const hasRealInstructions = snapshot.instructionsFile !== undefined && !snapshot.instructionsFile.isSymlink;
47
- return { agent, present: true, skillCount: skillNames.length, skillNames, hasRealInstructions };
48
+ const mcpPlan = await collectMigratePlan(agent, homeDir, ["mcp"]);
49
+ return { agent, present: true, skillCount: skillNames.length, skillNames, hasRealInstructions, mcpServerCount: mcpPlan.items.length };
48
50
  }));
49
51
  }
50
52
  function hasContent(s) {
51
- return s.skillCount > 0 || s.hasRealInstructions;
53
+ return s.skillCount > 0 || s.hasRealInstructions || s.mcpServerCount > 0;
52
54
  }
53
55
  function agentSummaryLabel(s) {
54
56
  const skills = s.skillCount > 0 ? ` (${s.skillNames.join(", ")})` : "";
55
- return `${s.agent} — ${s.skillCount} skill(s)${skills}, instructions: ${s.hasRealInstructions ? "yes" : "no"}`;
57
+ return `${s.agent} — ${s.skillCount} skill(s)${skills}, instructions: ${s.hasRealInstructions ? "yes" : "no"}, mcp: ${s.mcpServerCount}`;
56
58
  }
57
59
  /** Numbered-typing fallback (trellis-onboard-interactive-picker design.md
58
60
  * D2) — used only when the terminal can't support the raw-mode picker
@@ -144,48 +146,47 @@ async function promptForManagedAgentsReal(candidates, alreadyManaged) {
144
146
  }
145
147
  /**
146
148
  * Resolves which categories (skills, instructions) to migrate from a
147
- * resolved source (trellis-migrate-category-selection). Unlike the two
148
- * pickers above, there is no numbered-text fallback to preserve parity
149
- * with — this concept never existed before this change, so "can't
150
- * prompt" simply means "default to whichever kind(s) actually have real
151
- * content, silently" (design.md D4). The picker itself is only offered
152
- * when the choice is meaningful — both kinds present, a capable
153
- * terminal, and not a `--json` run (design.md D5). An empty result is a
154
- * valid answer: "skip migrate for this run" (design.md D6), left for
155
- * the caller to act on.
149
+ * resolved source (trellis-migrate-category-selection, extended to a
150
+ * third category by trellis-onboard-mcp-memory design.md D2). Unlike the
151
+ * two pickers above, there is no numbered-text fallback to preserve
152
+ * parity with — this concept never existed before this change, so
153
+ * "can't prompt" simply means "default to whichever kind(s) actually
154
+ * have real content, silently" (design.md D4). The picker itself is
155
+ * only offered when the choice is meaningful — two or more kinds
156
+ * present, a capable terminal, and not a `--json` run (design.md D5).
157
+ * An empty result is a valid answer: "skip migrate for this run"
158
+ * (design.md D6), left for the caller to act on.
156
159
  */
157
160
  async function resolveMigrateCategories(source, opts) {
158
- const wantsSkills = source.skillCount > 0;
159
- const wantsInstructions = source.hasRealInstructions;
160
- // The choice is only meaningful when both kinds are real — same gate
161
- // for the injected test seam as for the real picker, mirroring how
162
- // `promptForAgent`/`promptForManagedAgents` are only ever consulted
163
- // when their own real prompt would actually apply.
164
- if (wantsSkills && wantsInstructions && !opts.json) {
161
+ // Built dynamically, in skill/instructions/mcp display order — not a
162
+ // fixed two- or three-slot structure, so a fourth category some day
163
+ // would only need an entry here, not a rewritten branch (design.md D2).
164
+ const candidates = [];
165
+ if (source.skillCount > 0)
166
+ candidates.push({ kind: "skill", label: "skills" });
167
+ if (source.hasRealInstructions)
168
+ candidates.push({ kind: "instructions", label: "instructions" });
169
+ if (source.mcpServerCount > 0)
170
+ candidates.push({ kind: "mcp", label: "mcp" });
171
+ // The choice is only meaningful when two or more kinds are real — same
172
+ // gate for the injected test seam as for the real picker, mirroring
173
+ // how `promptForAgent`/`promptForManagedAgents` are only ever
174
+ // consulted when their own real prompt would actually apply.
175
+ if (candidates.length > 1 && !opts.json) {
165
176
  if (opts.promptForMigrateCategories) {
166
177
  return opts.promptForMigrateCategories(source);
167
178
  }
168
179
  if (canUseInteractivePicker()) {
169
180
  console.log(`Which categories should be migrated from ${source.agent}? Space to toggle, Enter to confirm:`);
170
- const indices = await runMultiSelectPicker(["skills", "instructions"], [true, true]);
181
+ const indices = await runMultiSelectPicker(candidates.map((c) => c.label), candidates.map(() => true));
171
182
  if (indices === null) {
172
183
  console.log("cancelled, no changes made");
173
184
  process.exit(1);
174
185
  }
175
- const kinds = [];
176
- if (indices.includes(0))
177
- kinds.push("skill");
178
- if (indices.includes(1))
179
- kinds.push("instructions");
180
- return kinds;
186
+ return indices.map((i) => candidates[i].kind);
181
187
  }
182
188
  }
183
- const kinds = [];
184
- if (wantsSkills)
185
- kinds.push("skill");
186
- if (wantsInstructions)
187
- kinds.push("instructions");
188
- return kinds;
189
+ return candidates.map((c) => c.kind);
189
190
  }
190
191
  /** Shared by `--manage` and the interactive prompt's answer — same
191
192
  * grammar either way (design.md D4): a real agent id list, comma- or
@@ -343,6 +344,15 @@ export async function collectOnboardPlan(opts = {}) {
343
344
  const syncReport = await collectSyncReport({ homeDir, dryRun: opts.dryRun, managedAgents, backupSession });
344
345
  const mcpSyncReport = await collectMcpSyncReport({ homeDir, dryRun: opts.dryRun, managedAgents, backupSession });
345
346
  backupSession?.finalize();
347
+ // Independent of `managedAgents` — the shared memory server is not
348
+ // per-agent (design.md D4, trellis-onboard-mcp-memory), unlike
349
+ // sync/mcp-sync above. Runs unconditionally once reached; an
350
+ // unconfigured `memory` server is a legitimate "nothing to do yet"
351
+ // state (design.md D5), not gated on any prior step here.
352
+ const memorySyncResult = collectMemorySyncResult(homeDir);
353
+ if (!opts.dryRun) {
354
+ applyMemorySync(memorySyncResult);
355
+ }
346
356
  // Read-only, no dryRun concept — same report either way, run last since
347
357
  // it audits the config mcp sync just wrote (or, on --dry-run, whatever
348
358
  // was already there before this run).
@@ -357,6 +367,7 @@ export async function collectOnboardPlan(opts = {}) {
357
367
  migrateSkipped,
358
368
  syncReport,
359
369
  mcpSyncReport,
370
+ memorySyncResult,
360
371
  secretsAuditReport,
361
372
  };
362
373
  }
@@ -370,9 +381,11 @@ export async function runOnboard(opts = {}) {
370
381
  }
371
382
  if (result.refusal)
372
383
  return { exitCode: 1 };
384
+ const memorySyncConflict = result.memorySyncResult?.configured === true && result.memorySyncResult.plan.items.some((i) => i.action === "conflict");
373
385
  const hasConflict = (result.migratePlan?.items.some((i) => i.action === "conflict") ?? false) ||
374
386
  (result.syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false) ||
375
387
  (result.mcpSyncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false) ||
388
+ memorySyncConflict ||
376
389
  (result.secretsAuditReport?.findings.length ?? 0) > 0;
377
390
  return { exitCode: hasConflict ? 1 : 0 };
378
391
  }
@@ -431,6 +444,10 @@ function printResult(result, dryRun) {
431
444
  console.log("\nmcp sync");
432
445
  printMcpSyncReport(result.mcpSyncReport, false);
433
446
  }
447
+ if (result.memorySyncResult) {
448
+ console.log("\nmemory sync");
449
+ printMemorySyncResult(result.memorySyncResult, false);
450
+ }
434
451
  if (result.secretsAuditReport) {
435
452
  console.log("\nsecrets audit");
436
453
  printSecretsAuditReport(result.secretsAuditReport);
@@ -6,14 +6,16 @@
6
6
  */
7
7
  import type { CanonicalSource, McpServerDef } from "./types.js";
8
8
  /**
9
- * The on-disk shape for one server entry — `static_env` (snake_case, like
10
- * every other multi-word key across `.trellis/*.yaml`) is translated to
11
- * `McpServerDef.staticEnv` (camelCase) below; every other field happens
12
- * to already be a single word, so no server-def field needed this
13
- * treatment before (trellis-mcp-static-env-and-disabled-servers).
9
+ * The on-disk shape for one server entry — `static_env`/`env_aliases`
10
+ * (snake_case, like every other multi-word key across `.trellis/*.yaml`)
11
+ * are translated to `McpServerDef.staticEnv`/`envAliases` (camelCase)
12
+ * below; every other field happens to already be a single word, so no
13
+ * server-def field needed this treatment before
14
+ * (trellis-mcp-static-env-and-disabled-servers, trellis-migrate-env-var-alias).
14
15
  */
15
- type McpServerDefYaml = Omit<McpServerDef, "staticEnv"> & {
16
+ type McpServerDefYaml = Omit<McpServerDef, "staticEnv" | "envAliases"> & {
16
17
  static_env?: Record<string, string>;
18
+ env_aliases?: Record<string, string>;
17
19
  };
18
20
  /** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
19
21
  * `undefined` fields so the written YAML never gets a literal `null`