agent-trellis 0.4.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,17 +26,6 @@ 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;
37
31
  /**
@@ -45,3 +39,32 @@ export declare function printMemorySyncResult(result: MemorySyncResult, dryRun:
45
39
  export declare function runMemorySync(opts?: RunMemorySyncOptions): {
46
40
  exitCode: number;
47
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)
@@ -87,3 +101,74 @@ export function runMemorySync(opts = {}) {
87
101
  const hasConflict = result.configured && result.plan.items.some((i) => i.action === "conflict");
88
102
  return { exitCode: hasConflict ? 1 : 0 };
89
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) {
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));
168
+ }
169
+ else {
170
+ printMemoryExtractionResult(result, opts.dryRun ?? false);
171
+ }
172
+ const hasConflict = result.configured && result.plan.items.some((i) => i.action === "conflict");
173
+ return { exitCode: hasConflict ? 1 : 0 };
174
+ }
@@ -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);
@@ -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`
@@ -10,17 +10,24 @@ import { basename, join } from "node:path";
10
10
  import { isMap, parse as parseYaml, parseDocument } from "yaml";
11
11
  import { ALL_AGENTS } from "./types.js";
12
12
  function fromServerDefYaml(def) {
13
- const { static_env, ...rest } = def;
14
- return static_env ? { ...rest, staticEnv: static_env } : rest;
13
+ const { static_env, env_aliases, ...rest } = def;
14
+ const out = { ...rest };
15
+ if (static_env)
16
+ out.staticEnv = static_env;
17
+ if (env_aliases)
18
+ out.envAliases = env_aliases;
19
+ return out;
15
20
  }
16
21
  /** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
17
22
  * `undefined` fields so the written YAML never gets a literal `null`
18
23
  * for an omitted optional. */
19
24
  export function toServerDefYaml(def) {
20
- const { staticEnv, ...rest } = def;
25
+ const { staticEnv, envAliases, ...rest } = def;
21
26
  const out = { ...rest };
22
27
  if (staticEnv)
23
28
  out.static_env = staticEnv;
29
+ if (envAliases)
30
+ out.env_aliases = envAliases;
24
31
  for (const key of Object.keys(out)) {
25
32
  if (out[key] === undefined)
26
33
  delete out[key];
@@ -53,6 +53,18 @@ export interface McpServerDef {
53
53
  * environment tag), not an escape hatch for real credentials.
54
54
  */
55
55
  staticEnv?: Record<string, string>;
56
+ /**
57
+ * Target env var name -> source variable name, for a reference whose
58
+ * key differs from the name it resolves (`OPENAPI_MCP_HEADERS:
59
+ * "${NOTION_OPENAPI_MCP_HEADERS}"` becomes `{ OPENAPI_MCP_HEADERS:
60
+ * "NOTION_OPENAPI_MCP_HEADERS" }`) — `env`'s self-referencing shape
61
+ * can't express a differently-named source, and treating this case as
62
+ * `staticEnv` silently ships the literal, unexpanded placeholder text
63
+ * to any consumer with no native `${VAR}` runtime of its own
64
+ * (trellis-migrate-env-var-alias). Resolved through the exact same
65
+ * mechanism as `env`, never a raw literal.
66
+ */
67
+ envAliases?: Record<string, string>;
56
68
  /**
57
69
  * Defaults to `true`. `false` keeps the definition in canonical
58
70
  * without writing it to any agent — matches a real host config's own
@@ -23,6 +23,18 @@ export interface McpMigrateReadResult {
23
23
  entries: McpMigrateEntry[];
24
24
  unsupported: McpMigrateUnsupported[];
25
25
  }
26
+ /**
27
+ * Inverse of `splitJsonEnvMap`: the resolved literal/reference text every
28
+ * env-classification key would produce on disk, regardless of which of
29
+ * `env`/`envAliases`/`staticEnv` it's currently filed under — a self-
30
+ * referencing `env` name and a differently-named `envAliases` entry
31
+ * both resolve to a `${...}` placeholder, a `staticEnv` entry resolves
32
+ * to its own literal text as-is. Shared by `renderJsonServerEntry` (the
33
+ * JSON write path) and migrate's own safe-reclassification check
34
+ * (`trellis-migrate-reclassify-repair`) — the same mechanism, not two
35
+ * independent derivations of it.
36
+ */
37
+ export declare function resolvedEnvTextMap(def: Pick<McpServerDef, "env" | "envAliases" | "staticEnv">): Record<string, string>;
26
38
  export declare function readClaudeCodeMcpDefs(homeDir: string): McpMigrateReadResult;
27
39
  export declare function readKiroMcpDefs(homeDir: string): McpMigrateReadResult;
28
40
  export interface CodexMcpEntryRich {
@@ -17,25 +17,47 @@ import { readJsonFile } from "./probeCommon.js";
17
17
  import { readServerEnvTable } from "./tomlSection.js";
18
18
  const EMPTY_RESULT = { entries: [], unsupported: [] };
19
19
  /**
20
- * Trellis's own `env` (name-only reference) vs. `staticEnv` (literal
21
- * value) split lives in one JSON object on disk (`jsonMcp.ts`'s
22
- * `renderJsonServerEntry`) — a value is a "name" entry only when it's
23
- * exactly `${KEY}` referencing its OWN key, matching exactly what that
24
- * renderer ever produces; anything else (a literal value, or a `${...}`
25
- * referencing a *different* name) is treated as a literal `staticEnv`
26
- * value instead of guessed at.
20
+ * Trellis's own `env` (self-referencing name) vs. `envAliases`
21
+ * (differently-named reference) vs. `staticEnv` (literal value) split
22
+ * lives in one JSON object on disk (`jsonMcp.ts`'s
23
+ * `renderJsonServerEntry`) — any value matching exactly `${NAME}` is a
24
+ * reference needing resolution, regardless of whether `NAME` equals its
25
+ * own key (trellis-migrate-env-var-alias; previously only the
26
+ * same-name case was recognized, silently shipping a differently-named
27
+ * reference as an unresolved literal). Anything not shaped like
28
+ * `${NAME}` at all remains a literal `staticEnv` value.
27
29
  */
28
- const SELF_VAR_REF_RE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
30
+ const VAR_REF_RE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
31
+ /**
32
+ * Inverse of `splitJsonEnvMap`: the resolved literal/reference text every
33
+ * env-classification key would produce on disk, regardless of which of
34
+ * `env`/`envAliases`/`staticEnv` it's currently filed under — a self-
35
+ * referencing `env` name and a differently-named `envAliases` entry
36
+ * both resolve to a `${...}` placeholder, a `staticEnv` entry resolves
37
+ * to its own literal text as-is. Shared by `renderJsonServerEntry` (the
38
+ * JSON write path) and migrate's own safe-reclassification check
39
+ * (`trellis-migrate-reclassify-repair`) — the same mechanism, not two
40
+ * independent derivations of it.
41
+ */
42
+ export function resolvedEnvTextMap(def) {
43
+ const nameRefs = Object.fromEntries((def.env ?? []).map((name) => [name, `\${${name}}`]));
44
+ const aliasRefs = Object.fromEntries(Object.entries(def.envAliases ?? {}).map(([targetKey, sourceName]) => [targetKey, `\${${sourceName}}`]));
45
+ return { ...nameRefs, ...aliasRefs, ...(def.staticEnv ?? {}) };
46
+ }
29
47
  function splitJsonEnvMap(env) {
30
48
  if (!env)
31
49
  return {};
32
50
  const names = [];
51
+ const aliases = {};
33
52
  const literal = {};
34
53
  for (const [key, value] of Object.entries(env)) {
35
- const match = SELF_VAR_REF_RE.exec(value);
54
+ const match = VAR_REF_RE.exec(value);
36
55
  if (match && match[1] === key) {
37
56
  names.push(key);
38
57
  }
58
+ else if (match) {
59
+ aliases[key] = match[1];
60
+ }
39
61
  else {
40
62
  literal[key] = value;
41
63
  }
@@ -43,6 +65,8 @@ function splitJsonEnvMap(env) {
43
65
  const result = {};
44
66
  if (names.length > 0)
45
67
  result.env = names;
68
+ if (Object.keys(aliases).length > 0)
69
+ result.envAliases = aliases;
46
70
  if (Object.keys(literal).length > 0)
47
71
  result.staticEnv = literal;
48
72
  return result;
@@ -115,9 +139,20 @@ export function buildCodexMcpReadResult(mcpEntries, tomlContent) {
115
139
  if (entry.transport.env_vars && entry.transport.env_vars.length > 0)
116
140
  def.env = entry.transport.env_vars;
117
141
  if (tomlContent) {
118
- const staticEnv = readServerEnvTable(tomlContent, entry.name);
119
- if (staticEnv && Object.keys(staticEnv).length > 0)
120
- def.staticEnv = staticEnv;
142
+ const envTable = readServerEnvTable(tomlContent, entry.name);
143
+ if (envTable && Object.keys(envTable).length > 0) {
144
+ // Same `${NAME}`-reference-vs-literal classification as the
145
+ // JSON-source path (trellis-migrate-env-var-alias D2) — Codex's
146
+ // `[mcp_servers.<name>.env]` table can hold either, same as
147
+ // Claude Code/Kiro's `env` object can.
148
+ const classified = splitJsonEnvMap(envTable);
149
+ if (classified.env)
150
+ def.env = [...(def.env ?? []), ...classified.env];
151
+ if (classified.envAliases)
152
+ def.envAliases = classified.envAliases;
153
+ if (classified.staticEnv)
154
+ def.staticEnv = classified.staticEnv;
155
+ }
121
156
  }
122
157
  entries.push({ name: entry.name, def });
123
158
  }
@@ -58,3 +58,38 @@ export interface MemorySyncPlan {
58
58
  * regardless of what canonical wants.
59
59
  */
60
60
  export declare function planMemorySync(canonical: Pick<CanonicalSource, "memories">, currentGraphContent: string | undefined): MemorySyncPlan;
61
+ /**
62
+ * Renders one extraction candidate as readable markdown — the entity's
63
+ * real (unslugged) name as a heading, its type, its observations as a
64
+ * list, and (only when it participates in any) its relations as a short
65
+ * list. Not a serialization format meant to round-trip byte-for-byte
66
+ * back through `planMemorySync` (trellis-memory-extraction design.md
67
+ * D2/Non-Goals) — canonical's own memory model is a single flat blob per
68
+ * file, strictly less expressive than the graph's typed-entity-plus-
69
+ * relations shape.
70
+ */
71
+ export declare function renderExtractedMemoryFile(entity: MemoryEntity, relations: readonly MemoryRelation[]): string;
72
+ export type MemoryExtractionAction = "create" | "already-extracted" | "conflict";
73
+ export interface MemoryExtractionItem {
74
+ /** The entity's real, unslugged name. */
75
+ name: string;
76
+ /** The derived target filename, without the `.md` extension. */
77
+ slug: string;
78
+ action: MemoryExtractionAction;
79
+ detail: string;
80
+ /** Only set when `action === "create"` — the file content to write. */
81
+ content?: string;
82
+ }
83
+ export interface MemoryExtractionPlan {
84
+ items: MemoryExtractionItem[];
85
+ }
86
+ /**
87
+ * Pure: given the graph's parsed lines and a way to read an existing
88
+ * canonical memory file by its (already-slugged) name, computes which
89
+ * non-`trellis-memory` entities (D1) should become new canonical files.
90
+ * `readCanonicalFile` returning `undefined` means "no such file yet" —
91
+ * the same "create vs. already-there vs. differs" three-way split
92
+ * `planMcpServer`/skill-migrate already use elsewhere in this codebase,
93
+ * applied to a new content type (trellis-memory-extraction design.md D4).
94
+ */
95
+ export declare function planMemoryExtraction(graphLines: readonly MemoryGraphLine[], readCanonicalFile: (slug: string) => string | undefined): MemoryExtractionPlan;
@@ -22,6 +22,7 @@
22
22
  * completely untouched — reported as a conflict, never overwritten.
23
23
  */
24
24
  import { readFileSync } from "node:fs";
25
+ import { slugify } from "./slug.js";
25
26
  export const TRELLIS_MEMORY_ENTITY_TYPE = "trellis-memory";
26
27
  export function parseMemoryGraph(content) {
27
28
  const lines = [];
@@ -99,3 +100,64 @@ export function planMemorySync(canonical, currentGraphContent) {
99
100
  }
100
101
  return { items, nextGraph: [...untouchedLines, ...nextTrellisEntities] };
101
102
  }
103
+ /**
104
+ * Renders one extraction candidate as readable markdown — the entity's
105
+ * real (unslugged) name as a heading, its type, its observations as a
106
+ * list, and (only when it participates in any) its relations as a short
107
+ * list. Not a serialization format meant to round-trip byte-for-byte
108
+ * back through `planMemorySync` (trellis-memory-extraction design.md
109
+ * D2/Non-Goals) — canonical's own memory model is a single flat blob per
110
+ * file, strictly less expressive than the graph's typed-entity-plus-
111
+ * relations shape.
112
+ */
113
+ export function renderExtractedMemoryFile(entity, relations) {
114
+ const lines = [`# ${entity.name}`, "", `**Type:** ${entity.entityType}`, "", "## Observations"];
115
+ for (const observation of entity.observations) {
116
+ lines.push(`- ${observation}`);
117
+ }
118
+ const related = relations.filter((r) => r.from === entity.name || r.to === entity.name);
119
+ if (related.length > 0) {
120
+ lines.push("", "## Relations");
121
+ for (const r of related) {
122
+ lines.push(r.from === entity.name ? `- ${r.relationType} -> ${r.to}` : `- ${r.from} -> ${r.relationType} -> this entity`);
123
+ }
124
+ }
125
+ return `${lines.join("\n")}\n`;
126
+ }
127
+ /**
128
+ * Pure: given the graph's parsed lines and a way to read an existing
129
+ * canonical memory file by its (already-slugged) name, computes which
130
+ * non-`trellis-memory` entities (D1) should become new canonical files.
131
+ * `readCanonicalFile` returning `undefined` means "no such file yet" —
132
+ * the same "create vs. already-there vs. differs" three-way split
133
+ * `planMcpServer`/skill-migrate already use elsewhere in this codebase,
134
+ * applied to a new content type (trellis-memory-extraction design.md D4).
135
+ */
136
+ export function planMemoryExtraction(graphLines, readCanonicalFile) {
137
+ const entities = graphLines.filter((line) => line.type === "entity");
138
+ const relations = graphLines.filter((line) => line.type === "relation");
139
+ const candidates = entities.filter((e) => e.entityType !== TRELLIS_MEMORY_ENTITY_TYPE && e.observations.length > 0);
140
+ const items = [];
141
+ const claimedSlugs = new Map();
142
+ for (const entity of candidates) {
143
+ const slug = slugify(entity.name);
144
+ const claimedBy = claimedSlugs.get(slug);
145
+ if (claimedBy !== undefined) {
146
+ items.push({ name: entity.name, slug, action: "conflict", detail: `slug "${slug}" collides with entity "${claimedBy}" — resolve by hand` });
147
+ continue;
148
+ }
149
+ claimedSlugs.set(slug, entity.name);
150
+ const content = renderExtractedMemoryFile(entity, relations);
151
+ const existing = readCanonicalFile(slug);
152
+ if (existing === undefined) {
153
+ items.push({ name: entity.name, slug, action: "create", detail: "will create a new canonical memory file", content });
154
+ }
155
+ else if (existing === content) {
156
+ items.push({ name: entity.name, slug, action: "already-extracted", detail: "canonical file content is already identical" });
157
+ }
158
+ else {
159
+ items.push({ name: entity.name, slug, action: "conflict", detail: `~/.trellis/memories/${slug}.md already exists with different content — resolve by hand` });
160
+ }
161
+ }
162
+ return { items };
163
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Kebab-case, filesystem-safe slug for deriving a file/dir name from an
3
+ * arbitrary display name (e.g. a memory graph entity's own name, which
4
+ * can contain spaces, mixed case, or punctuation). Collision handling
5
+ * (two different inputs producing the same slug) is the caller's
6
+ * concern (trellis-memory-extraction design.md D3) — this function only
7
+ * ever normalizes one input at a time.
8
+ */
9
+ export declare function slugify(name: string): string;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Kebab-case, filesystem-safe slug for deriving a file/dir name from an
3
+ * arbitrary display name (e.g. a memory graph entity's own name, which
4
+ * can contain spaces, mixed case, or punctuation). Collision handling
5
+ * (two different inputs producing the same slug) is the caller's
6
+ * concern (trellis-memory-extraction design.md D3) — this function only
7
+ * ever normalizes one input at a time.
8
+ */
9
+ export function slugify(name) {
10
+ return name
11
+ .trim()
12
+ .toLowerCase()
13
+ .replace(/[^a-z0-9]+/g, "-")
14
+ .replace(/^-+|-+$/g, "");
15
+ }
@@ -155,7 +155,13 @@ export function renderServerSection(name, def) {
155
155
  if (bearerEnvVar)
156
156
  lines.push(`bearer_token_env_var = ${tomlString(bearerEnvVar)}`);
157
157
  }
158
- const staticEnvEntries = Object.entries(def.staticEnv ?? {});
158
+ // `envAliases`' target key gets a literal `${sourceName}` placeholder
159
+ // in the same env table `staticEnv` writes to — Codex's own runtime
160
+ // expands it from its own ambient environment, the same mechanism
161
+ // `env_vars` already relies on for a same-named reference
162
+ // (trellis-migrate-env-var-alias D3).
163
+ const aliasEntries = Object.entries(def.envAliases ?? {}).map(([targetKey, sourceName]) => [targetKey, `\${${sourceName}}`]);
164
+ const staticEnvEntries = [...aliasEntries, ...Object.entries(def.staticEnv ?? {})];
159
165
  if (staticEnvEntries.length > 0) {
160
166
  lines.push(`[${envHeader(name)}]`);
161
167
  for (const [key, value] of staticEnvEntries) {
@@ -27261,8 +27261,11 @@ function resolveScope(scope, managedAgents) {
27261
27261
 
27262
27262
  // src/core/canonical.ts
27263
27263
  function fromServerDefYaml(def) {
27264
- const { static_env, ...rest } = def;
27265
- return static_env ? { ...rest, staticEnv: static_env } : rest;
27264
+ const { static_env, env_aliases, ...rest } = def;
27265
+ const out = { ...rest };
27266
+ if (static_env) out.staticEnv = static_env;
27267
+ if (env_aliases) out.envAliases = env_aliases;
27268
+ return out;
27266
27269
  }
27267
27270
  function trellisRoot(homeDir) {
27268
27271
  return join(homeDir, ".trellis");
@@ -27474,7 +27477,7 @@ function findLiteralSecret(def) {
27474
27477
  return void 0;
27475
27478
  }
27476
27479
  function findUnresolvedEnvName(def, policy) {
27477
- const names = def.env ?? [];
27480
+ const names = [...def.env ?? [], ...Object.values(def.envAliases ?? {})];
27478
27481
  if (names.length === 0) return void 0;
27479
27482
  const resolved = resolveSecretEnv(names, policy);
27480
27483
  return names.find((name) => !resolved[name]);
@@ -31994,8 +31997,10 @@ async function connectWithCleanup(client, transport, timeoutMs, message) {
31994
31997
  }
31995
31998
  async function connectStdio(def, secretsPolicy, timeoutMs) {
31996
31999
  const client = new Client(CLIENT_INFO, { capabilities: {} });
31997
- const resolved = resolveSecretEnv(def.env ?? [], secretsPolicy);
32000
+ const envAliases = def.envAliases ?? {};
32001
+ const resolved = resolveSecretEnv([...def.env ?? [], ...Object.values(envAliases)], secretsPolicy);
31998
32002
  const namedEnv = Object.fromEntries((def.env ?? []).map((name) => [name, resolved[name] ?? ""]));
32003
+ const aliasEnv = Object.fromEntries(Object.entries(envAliases).map(([targetKey, sourceName]) => [targetKey, resolved[sourceName] ?? ""]));
31999
32004
  const transport = new StdioClientTransport({
32000
32005
  command: def.command,
32001
32006
  args: def.args,
@@ -32004,7 +32009,7 @@ async function connectStdio(def, secretsPolicy, timeoutMs) {
32004
32009
  // entry's empty-string fallback for the same key — though in practice
32005
32010
  // resolveMcpPlan's D6 refusal never lets an unresolved name reach
32006
32011
  // this point at all (trellis-mcp-static-env-and-disabled-servers).
32007
- env: { ...getDefaultEnvironment(), ...namedEnv, ...def.staticEnv ?? {} }
32012
+ env: { ...getDefaultEnvironment(), ...namedEnv, ...aliasEnv, ...def.staticEnv ?? {} }
32008
32013
  });
32009
32014
  return connectWithCleanup(client, transport, timeoutMs, `connect timed out after ${timeoutMs}ms`);
32010
32015
  }
@@ -70,8 +70,15 @@ async function connectWithCleanup(client, transport, timeoutMs, message) {
70
70
  }
71
71
  async function connectStdio(def, secretsPolicy, timeoutMs) {
72
72
  const client = new Client(CLIENT_INFO, { capabilities: {} });
73
- const resolved = resolveSecretEnv(def.env ?? [], secretsPolicy);
73
+ const envAliases = def.envAliases ?? {};
74
+ // `envAliases`' values are source variable names — resolved through the
75
+ // exact same call as `env`'s own names, never delivered as the raw
76
+ // `${sourceName}` placeholder text a consumer with no `${VAR}` runtime
77
+ // of its own (like this bridge) would otherwise crash on parsing
78
+ // (trellis-migrate-env-var-alias, the real notion-on-pi bug).
79
+ const resolved = resolveSecretEnv([...(def.env ?? []), ...Object.values(envAliases)], secretsPolicy);
74
80
  const namedEnv = Object.fromEntries((def.env ?? []).map((name) => [name, resolved[name] ?? ""]));
81
+ const aliasEnv = Object.fromEntries(Object.entries(envAliases).map(([targetKey, sourceName]) => [targetKey, resolved[sourceName] ?? ""]));
75
82
  const transport = new StdioClientTransport({
76
83
  command: def.command,
77
84
  args: def.args,
@@ -80,7 +87,7 @@ async function connectStdio(def, secretsPolicy, timeoutMs) {
80
87
  // entry's empty-string fallback for the same key — though in practice
81
88
  // resolveMcpPlan's D6 refusal never lets an unresolved name reach
82
89
  // this point at all (trellis-mcp-static-env-and-disabled-servers).
83
- env: { ...getDefaultEnvironment(), ...namedEnv, ...(def.staticEnv ?? {}) },
90
+ env: { ...getDefaultEnvironment(), ...namedEnv, ...aliasEnv, ...(def.staticEnv ?? {}) },
84
91
  });
85
92
  return connectWithCleanup(client, transport, timeoutMs, `connect timed out after ${timeoutMs}ms`);
86
93
  }
@@ -354,6 +354,19 @@ not currently active anywhere; remove the line (or set it `true`) to
354
354
  turn it back on everywhere at once. See
355
355
  [`schema/servers.example.yaml`](../schema/servers.example.yaml) for both.
356
356
 
357
+ If a server's own env var name differs from the one it should resolve
358
+ (a real example: Notion's MCP server wants `OPENAPI_MCP_HEADERS`, but
359
+ your shell/secret manager holds it under `NOTION_OPENAPI_MCP_HEADERS`),
360
+ use `env_aliases` instead of either `env` or `static_env` —
361
+ `target_key: source_variable_name`. It resolves through the exact same
362
+ mechanism as `env` (the same pre-write refusal check, the same agent
363
+ `${VAR}` reference on every target), just delivered under a different
364
+ key. Writing that kind of reference as `static_env` looks like it works
365
+ (the placeholder text is still just a string) but ships the literal,
366
+ unexpanded `${SOURCE_NAME}` text to any agent with no `${VAR}` runtime
367
+ of its own — which is exactly what broke pi's Notion connection before
368
+ this field existed.
369
+
357
370
  ## `trellis memory sync`
358
371
 
359
372
  Ingests `~/.trellis/memories/*.md` into the actual on-disk file
@@ -378,12 +391,47 @@ the graph that Trellis didn't create, the command refuses that one entry
378
391
  (reported as a conflict) rather than overwriting it, same posture as
379
392
  every other conflict in this project.
380
393
 
381
- **Known limitation, named rather than silently worked around:** this only
382
- ingests canonical's *own* `memories/*.md` files into the shared store —
383
- it does not (yet) extract an agent's own already-accumulated memory
384
- content (e.g. Claude Code's own per-project memory feature) back into
385
- canonical. That extraction is a real, separate, still-open gap — see
386
- `docs/roadmap.md`'s P15 entry.
394
+ This only ingests canonical's *own* `memories/*.md` files into the shared
395
+ store — the reverse direction (an agent's own already-accumulated content
396
+ in that same shared graph, not yet represented in canonical) is
397
+ `trellis memory extract`, below.
398
+
399
+ ## `trellis memory extract`
400
+
401
+ The other direction: reads the same graph file's real entities — not
402
+ Trellis's own (anything an agent created directly, via its own MCP tool
403
+ calls against the shared memory server, before or outside of canonical) —
404
+ and writes each as a new canonical `memories/*.md` file. Requires the
405
+ same `memory` server configuration `memory sync` does; refuses with the
406
+ identical message when it's missing.
407
+
408
+ ```
409
+ $ trellis memory extract
410
+ memory extract — /Users/you/.trellis/memories/graph.jsonl
411
+ [create] "Sprint Tasks Q2" (sprint-tasks-q2.md) — will create a new canonical memory file
412
+ ```
413
+
414
+ A rendered file is a readable markdown transcription — heading,
415
+ observations as a list, relations as a short list when the entity has
416
+ any — not a format meant to round-trip byte-for-byte back through
417
+ `memory sync`: re-syncing an extracted file later turns it into a single,
418
+ flat observation, the same as any other canonical memory file. This is a
419
+ one-way trip from "richer, live graph" to "durable, reviewable,
420
+ version-controllable prose," not a lossless mirror.
421
+
422
+ Same conflict discipline as everywhere else: a target file that already
423
+ exists with different content is reported as a conflict and left
424
+ untouched, never silently overwritten — including when two different
425
+ entities' names would slug to the same filename. Not wired into
426
+ `trellis onboard`'s automatic chain (unlike `memory sync`) — this is a
427
+ deliberate, occasional action you run, the same posture `migrate` already
428
+ has.
429
+
430
+ **Note:** this is unrelated to any project-level "auto memory" feature an
431
+ agent may have of its own (e.g. Claude Code's own per-project memory
432
+ files) — those are a different, agent-specific mechanism entirely, not
433
+ the shared `@modelcontextprotocol/server-memory` server Trellis manages
434
+ here.
387
435
 
388
436
  ## `trellis secrets audit`
389
437
 
package/docs/research.md CHANGED
@@ -120,6 +120,17 @@ clients." Mature, but needs Docker + an LLM key. Documented as the
120
120
  upgrade path for cross-machine or richer semantic memory, alongside
121
121
  `totalrecallai` as a second real option in that same category.
122
122
 
123
+ Two commands, one direction each: `trellis memory sync` (canonical
124
+ `memories/*.md` → the shared graph file) and `trellis memory extract`
125
+ (the graph's own real, non-Trellis entities → new canonical files) —
126
+ closing P15's own explicitly-named "per-agent extraction into canonical
127
+ remains a separate, open gap." The two are not symmetric round-trips:
128
+ canonical's memory model is one flat blob per file, the graph's native
129
+ model is typed entities plus a relation graph, so extraction is a
130
+ one-way, lossy-in-structure (not in substance) transcription, not a
131
+ serialization format — see `docs/getting-started.md`'s `memory extract`
132
+ section for the exact trade-off.
133
+
123
134
  ## Secrets (formalize existing practice, don't build new infra)
124
135
 
125
136
  The 2026 consensus across 1Password, Vault, Infisical writeups is uniform:
@@ -133,6 +144,19 @@ which was silently invalid). Trellis's contribution here is `trellis secrets
133
144
  audit` — a linter that fails a build if any adapter output contains a raw
134
145
  credential — not a new secret store.
135
146
 
147
+ A third real incident during migrate-in: a source config's `${VAR}`
148
+ reference doesn't always name itself (`OPENAPI_MCP_HEADERS:
149
+ "${NOTION_OPENAPI_MCP_HEADERS}"`, a real Notion MCP server entry). Migrate's
150
+ read path originally only recognized a same-named reference as one; a
151
+ differently-named reference silently fell through as a literal, unexpanded
152
+ value — harmless on Claude Code/Codex (both expand `${VAR}` themselves from
153
+ their own native config format), but a hard crash on pi, whose bridge has no
154
+ `${VAR}` runtime of its own and merges an unrecognized value verbatim.
155
+ `McpServerDef.envAliases` (target key → source variable name) closes this:
156
+ every agent's adapter renders it through the exact same reference mechanism
157
+ `env` already uses, and the pi bridge resolves it through the exact same
158
+ `resolveSecretEnv` call.
159
+
136
160
  ## pi coding agent — the one place we write real code, not config generation
137
161
 
138
162
  Reverse-engineered directly from the installed binary
package/docs/roadmap.md CHANGED
@@ -948,6 +948,9 @@ tests passing.
948
948
  | P15 | ✅ Shared memory: ingest canonical `memories/*.md` into the `server-memory` store's own graph file (per-agent extraction into canonical remains a separate, open gap) | P6 |
949
949
  | P16 | ✅ MCP migrate-in: `trellis migrate --only mcp` for claude-code/kiro/codex (codex remote transport + probe HOME-scoping closed same-day, see prose above), closing the gap P14 named | P12, P14 |
950
950
  | P17 | ✅ `onboard` orchestration catches up to P15/P16: three-way migrate-category selection (mcp added), `memory sync` wired in as a chained stage | P15, P16 |
951
+ | P18 | ✅ `envAliases`: a migrated `${NAME}` env reference whose name differs from its own key (found via live dogfooding — a real Notion MCP server crashing pi after migrate, not a hypothetical) is now recognized as a reference, not silently shipped as literal unexpanded placeholder text. New `McpServerDef.envAliases` field, threaded through migrate-read (both JSON and Codex TOML sources), every adapter's write path, the pre-write unresolvable-env refusal guard, the pi bridge's runtime resolution, and canonical's own `env_aliases`/`envAliases` on-disk round-trip | P16 |
952
+ | P19 | ✅ Migrate reclassify-repair: found immediately after P18 shipped — re-running migrate on data migrated *before* a classification fix didn't pick the fix up at all, since `planMcpServer`'s conflict check was plain `deepEqual` (any structural difference, real customization or not, was refused as "resolve by hand"). New narrow `isSafeReclassification` check + `"reclassify"` migrate action: applied automatically when a value only moved between `staticEnv`/`env`/`envAliases` while resolving to the exact same literal/reference text, everything else identical; any real difference still conflicts, unchanged. Verified for real on this machine: `migrate --from claude-code --only mcp` correctly reclassified notion's stale entry and left all 8 other already-migrated servers untouched | P18 |
953
+ | P20 | ✅ Memory extraction: closes P15's own explicitly-named "per-agent extraction into canonical remains a separate, open gap." New `trellis memory extract` — the reverse of `memory sync` — reads the shared graph's real, non-`trellis-memory` entities (the same ownership marker `memory sync` already uses to leave agent-created content untouched, read the other direction) and writes each as a new canonical `memories/*.md` file: readable markdown, not a serialization format, not meant to round-trip byte-for-byte back through `memory sync`. Same create/no-op/conflict discipline as every other Trellis write; not wired into `onboard`'s automatic chain (a deliberate, occasional action, like `migrate`) | P15 |
951
954
 
952
955
  No dates. This is scoped by verification milestones, not calendar time.
953
956
  Execution order for P11–P16: P11 → P12 → P13 → P14 → P15 → P16 — CRUD
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-trellis",
3
- "version": "0.4.0",
3
+ "version": "0.5.2",
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",
@@ -11,6 +11,12 @@
11
11
  # scanned against `reject_patterns` like everything else, so a real
12
12
  # credential accidentally placed there is still refused, not silently
13
13
  # written (trellis-mcp-static-env-and-disabled-servers).
14
+ # - `env_aliases` is for a var whose own name differs from the name it
15
+ # should resolve (`target_key: source_variable_name`) — resolved
16
+ # through the exact same mechanism as `env`, just delivered under a
17
+ # different key. Do NOT reach for `static_env` here: a "${SOURCE_NAME}"
18
+ # string looks like it works but ships unexpanded to any agent with no
19
+ # `${VAR}` runtime of its own (trellis-migrate-env-var-alias).
14
20
  # - `enabled: false` keeps a server's definition in canonical without
15
21
  # writing it to any agent — for a server you want to keep configured
16
22
  # but not currently active everywhere, matching how a real agent's
@@ -78,6 +84,18 @@ servers:
78
84
  args: ["-y", "@zereight/mcp-gitlab"]
79
85
  env: [GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_API_URL]
80
86
 
87
+ # `env_aliases`: the server wants `OPENAPI_MCP_HEADERS`, but your secret
88
+ # manager holds it under a differently-named variable — a real example
89
+ # found via migrating an actual Notion MCP server entry. Same resolution
90
+ # discipline as `env`, just target-key -> source-name instead of
91
+ # self-named.
92
+ notion:
93
+ transport: stdio
94
+ command: npx
95
+ args: ["-y", "@notionhq/notion-mcp-server"]
96
+ env_aliases:
97
+ OPENAPI_MCP_HEADERS: NOTION_OPENAPI_MCP_HEADERS
98
+
81
99
  # `enabled: false` keeps this definition in canonical without writing it
82
100
  # to any agent — for something you want configured but not currently
83
101
  # active anywhere. Remove the line (or set it to `true`) to turn it back