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.
- 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 +42 -19
- package/dist/commands/memory.js +99 -14
- package/dist/commands/migrate.d.ts +14 -1
- package/dist/commands/migrate.js +24 -2
- 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 +54 -6
- package/docs/research.md +24 -0
- package/docs/roadmap.md +3 -0
- package/package.json +1 -1
- package/schema/servers.example.yaml +18 -0
package/dist/adapters/jsonMcp.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/adapters/mcpPlan.js
CHANGED
|
@@ -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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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` —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
+
};
|
package/dist/commands/memory.js
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `trellis memory sync` —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
package/dist/commands/migrate.js
CHANGED
|
@@ -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);
|
package/dist/core/canonical.d.ts
CHANGED
|
@@ -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`
|
|
10
|
-
* every other multi-word key across `.trellis/*.yaml`)
|
|
11
|
-
* `McpServerDef.staticEnv` (camelCase)
|
|
12
|
-
* to already be a single word, so no
|
|
13
|
-
* treatment before
|
|
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`
|
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
|
@@ -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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
`
|
|
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.
|
|
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
|