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.
- package/dist/adapters/jsonMcp.js +2 -2
- package/dist/adapters/mcpPlan.js +8 -1
- package/dist/cli.js +19 -6
- package/dist/commands/memory.d.ts +50 -19
- package/dist/commands/memory.js +118 -22
- package/dist/commands/migrate.d.ts +14 -1
- package/dist/commands/migrate.js +24 -2
- package/dist/commands/onboard.d.ts +9 -0
- package/dist/commands/onboard.js +50 -33
- package/dist/core/canonical.d.ts +8 -6
- package/dist/core/canonical.js +10 -3
- package/dist/core/types.d.ts +12 -0
- package/dist/lib/mcpMigrateRead.d.ts +12 -0
- package/dist/lib/mcpMigrateRead.js +47 -12
- package/dist/lib/memoryGraph.d.ts +35 -0
- package/dist/lib/memoryGraph.js +62 -0
- package/dist/lib/slug.d.ts +9 -0
- package/dist/lib/slug.js +15 -0
- package/dist/lib/tomlSection.js +7 -1
- package/dist/pi-bridge/bundle.js +10 -5
- package/dist/pi-bridge/index.js +9 -2
- package/docs/getting-started.md +69 -12
- package/docs/research.md +24 -0
- package/docs/roadmap.md +33 -0
- package/package.json +1 -1
- package/schema/servers.example.yaml +18 -0
package/dist/core/canonical.js
CHANGED
|
@@ -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
|
-
|
|
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];
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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` (
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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 =
|
|
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
|
|
119
|
-
if (
|
|
120
|
-
|
|
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;
|
package/dist/lib/memoryGraph.js
CHANGED
|
@@ -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;
|
package/dist/lib/slug.js
ADDED
|
@@ -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
|
+
}
|
package/dist/lib/tomlSection.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/pi-bridge/bundle.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|
package/dist/pi-bridge/index.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/docs/getting-started.md
CHANGED
|
@@ -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,
|
|
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
|
|
67
|
-
zero writes anywhere (secrets audit is
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
`
|
|
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
|
+
"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
|