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.
@@ -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
  }
@@ -17,17 +17,23 @@ $ trellis onboard
17
17
 
18
18
  Runs `init`, detects which of Claude Code/Codex/Kiro/pi are on this machine,
19
19
  then resolves two independent choices before running `migrate`, `sync`,
20
- `mcp sync`, and `secrets audit` — the whole onboarding path, no follow-up
21
- commands to type by hand:
20
+ `mcp sync`, `memory sync`, and `secrets audit` — the whole onboarding path,
21
+ no follow-up commands to type by hand:
22
22
 
23
23
  1. **Migration source** — read from, at most one, never written back to.
24
+ "Real content" means skills, custom instructions, *or* real MCP servers
25
+ — an agent whose only real content is its MCP servers is still a valid
26
+ source.
24
27
  - **No agent has real content**: skipped — canonical starts from `init`'s
25
28
  placeholder.
26
29
  - **Exactly one agent has real content**: auto-selected, no prompt.
27
30
  - **Two or more**: prompts you to choose (Up/Down or j/k, Enter to confirm,
28
31
  on a real terminal that supports it — falls back to a numbered
29
32
  type-a-digit prompt otherwise), or pass `--agent <id>` to skip the
30
- prompt entirely.
33
+ prompt entirely. Which *categories* to bring in from that source
34
+ (skills, instructions, mcp) is its own checkbox, shown only when the
35
+ source has real content in two or more of them — with just one, that
36
+ one is migrated unprompted.
31
37
  2. **Managed set** — zero or more agents to actually write to. Always an
32
38
  explicit choice: pass `--manage <ids>` (comma-separated, e.g. `--manage
33
39
  pi,codex`) or `--manage none`, or answer the interactive checkbox prompt
@@ -54,6 +60,9 @@ sync
54
60
  mcp sync
55
61
  ✅ pi — already in sync
56
62
 
63
+ memory sync
64
+ no "memory" MCP server with static_env.MEMORY_FILE_PATH configured in servers.yaml — see schema/servers.example.yaml
65
+
57
66
  secrets audit
58
67
  ✅ no findings — every present agent's real config and every declared env var passed all checks
59
68
  ```
@@ -63,9 +72,9 @@ it's present and was the migration source, but it isn't managed, so it's
63
72
  never even probed as a sync target, not just left with zero items.
64
73
 
65
74
  Add `--dry-run` to preview the entire chain — init/migrate/sync/mcp
66
- sync, including what would be written to `~/.trellis/managed.yaml` — with
67
- zero writes anywhere (secrets audit is always read-only, with or without
68
- the flag).
75
+ sync/memory sync, including what would be written to
76
+ `~/.trellis/managed.yaml` — with zero writes anywhere (secrets audit is
77
+ always read-only, with or without the flag).
69
78
 
70
79
  **A managed agent's own real content still isn't overwritten.** If you
71
80
  explicitly include the source in `--manage`, sync still never overwrites
@@ -345,6 +354,19 @@ not currently active anywhere; remove the line (or set it `true`) to
345
354
  turn it back on everywhere at once. See
346
355
  [`schema/servers.example.yaml`](../schema/servers.example.yaml) for both.
347
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
+
348
370
  ## `trellis memory sync`
349
371
 
350
372
  Ingests `~/.trellis/memories/*.md` into the actual on-disk file
@@ -369,12 +391,47 @@ the graph that Trellis didn't create, the command refuses that one entry
369
391
  (reported as a conflict) rather than overwriting it, same posture as
370
392
  every other conflict in this project.
371
393
 
372
- **Known limitation, named rather than silently worked around:** this only
373
- ingests canonical's *own* `memories/*.md` files into the shared store —
374
- it does not (yet) extract an agent's own already-accumulated memory
375
- content (e.g. Claude Code's own per-project memory feature) back into
376
- canonical. That extraction is a real, separate, still-open gap — see
377
- `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.
378
435
 
379
436
  ## `trellis secrets audit`
380
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
@@ -899,6 +899,35 @@ byte-identical for both transports, now covered by its own test), so
899
899
  that distinction was never stored in the first place. 350/350 tests
900
900
  passing.
901
901
 
902
+ **P17 (✅ done, archived
903
+ [2026-09-13-trellis-onboard-mcp-memory](../openspec/changes/archive/2026-09-13-trellis-onboard-mcp-memory/)):
904
+ `onboard` catches up to P15/P16 — mcp and memory were never wired into
905
+ its own orchestration.** Found by live dogfooding on a real machine:
906
+ `onboard`'s migrate-category picker hardcoded `["skills",
907
+ "instructions"]` — `mcp` was invisible to it regardless of how many real
908
+ MCP servers the source agent had — and `trellis memory sync` was never
909
+ called anywhere in `onboard`'s chain at all, despite its own docstring
910
+ naming a complete chain. `OnboardAgentSummary` gains an
911
+ `mcpServerCount`, computed via the already-exported
912
+ `collectMigratePlan(agent, homeDir, ["mcp"])` rather than a second reader
913
+ dispatch — so it always agrees with what `migrate` itself would do, and
914
+ an agent whose only real content is MCP servers is now a valid migration
915
+ source (previously invisible to `hasContent` entirely).
916
+ `resolveMigrateCategories` was rewritten from a fixed two-slot structure
917
+ to a dynamically-built list of whichever of skill/instructions/mcp have
918
+ real content, offering the checkbox only when two or more do — not a
919
+ bolted-on special case for a third option. `memory sync` runs as a new
920
+ final chained stage, after `mcp sync` and before `secrets audit`,
921
+ independent of the resolved managed-agent set (the memory server is
922
+ shared, not per-agent); `runMemorySync`'s inline printing was extracted
923
+ into an exported `printMemorySyncResult` so `onboard` reuses the exact
924
+ same output format rather than a second, drifting copy of it —
925
+ `memory.ts`'s own standalone command behavior is unchanged, confirmed by
926
+ its existing tests passing unmodified. No change to `migrate.ts`,
927
+ `mcp.ts`, or `memory.ts`'s own command behavior — purely `onboard`'s
928
+ orchestration layer catching up to commands that already existed. 357/357
929
+ tests passing.
930
+
902
931
  | Phase | Deliverable | Depends on |
903
932
  |---|---|---|
904
933
  | P0 | ✅ `trellis doctor` — read-only, opt-in-for-handshakes scan of all four agents' current skills/MCP/instructions state, reports drift and duplicates | nothing |
@@ -918,6 +947,10 @@ passing.
918
947
  | P14 | ✅ MCP server lifecycle parity with skills: ownership-tracked safe removal on sync (migrate-in closed separately by P16) | P2, P13 |
919
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 |
920
949
  | P16 | ✅ MCP migrate-in: `trellis migrate --only mcp` for claude-code/kiro/codex (codex remote transport + probe HOME-scoping closed same-day, see prose above), closing the gap P14 named | P12, P14 |
950
+ | P17 | ✅ `onboard` orchestration catches up to P15/P16: three-way migrate-category selection (mcp added), `memory sync` wired in as a chained stage | P15, P16 |
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 |
921
954
 
922
955
  No dates. This is scoped by verification milestones, not calendar time.
923
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.3.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