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/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,19 +26,45 @@ export type MemorySyncResult = {
|
|
|
21
26
|
configured: false;
|
|
22
27
|
reason: string;
|
|
23
28
|
};
|
|
24
|
-
/**
|
|
25
|
-
* Looks up `mcp.servers["memory"]` specifically (matching `schema/
|
|
26
|
-
* servers.example.yaml`'s own naming convention for the default shared-
|
|
27
|
-
* memory backend) — not a heuristic scan for any server whose command
|
|
28
|
-
* happens to mention `@modelcontextprotocol/server-memory`, since a
|
|
29
|
-
* user could reasonably name or configure it differently and this
|
|
30
|
-
* would then silently miss it. `static_env.MEMORY_FILE_PATH` must be
|
|
31
|
-
* set explicitly: the server's own unset-env default resolves relative
|
|
32
|
-
* to wherever `npx` cached the package, a location Trellis has no
|
|
33
|
-
* reliable way to predict.
|
|
34
|
-
*/
|
|
35
29
|
export declare function collectMemorySyncResult(homeDir?: string): MemorySyncResult;
|
|
36
30
|
export declare function applyMemorySync(result: MemorySyncResult): void;
|
|
31
|
+
/**
|
|
32
|
+
* Extracted from `runMemorySync` (trellis-onboard-mcp-memory) so
|
|
33
|
+
* `onboard`'s own chained output can print this stage in the exact same
|
|
34
|
+
* format without a second, drifting copy — mirrors the
|
|
35
|
+
* `collect...Report`/`print...Report` export pair every other chained
|
|
36
|
+
* stage (`sync`, `mcp sync`, `secrets audit`) already has.
|
|
37
|
+
*/
|
|
38
|
+
export declare function printMemorySyncResult(result: MemorySyncResult, dryRun: boolean): void;
|
|
37
39
|
export declare function runMemorySync(opts?: RunMemorySyncOptions): {
|
|
38
40
|
exitCode: number;
|
|
39
41
|
};
|
|
42
|
+
export interface RunMemoryExtractionOptions {
|
|
43
|
+
homeDir?: string;
|
|
44
|
+
json?: boolean;
|
|
45
|
+
dryRun?: boolean;
|
|
46
|
+
}
|
|
47
|
+
export type MemoryExtractionResult = {
|
|
48
|
+
configured: true;
|
|
49
|
+
graphPath: string;
|
|
50
|
+
plan: MemoryExtractionPlan;
|
|
51
|
+
} | {
|
|
52
|
+
configured: false;
|
|
53
|
+
reason: string;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* The reverse direction of `collectMemorySyncResult`: reads the same
|
|
57
|
+
* graph file, but plans which of its real, non-`trellis-memory` entities
|
|
58
|
+
* (`planMemoryExtraction`) should become new canonical files — never the
|
|
59
|
+
* other way around, and never touching an entity `memory sync` itself
|
|
60
|
+
* owns (trellis-memory-extraction).
|
|
61
|
+
*/
|
|
62
|
+
export declare function collectMemoryExtractionResult(homeDir?: string): MemoryExtractionResult;
|
|
63
|
+
export declare function applyMemoryExtraction(result: MemoryExtractionResult, homeDir?: string): void;
|
|
64
|
+
/** Mirrors `printMemorySyncResult`'s own export-pair convention — reused
|
|
65
|
+
* verbatim if `onboard` ever chains this stage too, not a second,
|
|
66
|
+
* drifting copy. */
|
|
67
|
+
export declare function printMemoryExtractionResult(result: MemoryExtractionResult, dryRun: boolean): void;
|
|
68
|
+
export declare function runMemoryExtraction(opts?: RunMemoryExtractionOptions): {
|
|
69
|
+
exitCode: number;
|
|
70
|
+
};
|
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)
|
|
@@ -45,6 +59,26 @@ export function applyMemorySync(result) {
|
|
|
45
59
|
mkdirSync(dirname(result.graphPath), { recursive: true });
|
|
46
60
|
writeFileSync(result.graphPath, renderMemoryGraph(result.plan.nextGraph));
|
|
47
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Extracted from `runMemorySync` (trellis-onboard-mcp-memory) so
|
|
64
|
+
* `onboard`'s own chained output can print this stage in the exact same
|
|
65
|
+
* format without a second, drifting copy — mirrors the
|
|
66
|
+
* `collect...Report`/`print...Report` export pair every other chained
|
|
67
|
+
* stage (`sync`, `mcp sync`, `secrets audit`) already has.
|
|
68
|
+
*/
|
|
69
|
+
export function printMemorySyncResult(result, dryRun) {
|
|
70
|
+
if (!result.configured) {
|
|
71
|
+
console.log(result.reason);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
console.log(`${dryRun ? "[dry run] " : ""}memory sync — ${result.graphPath}`);
|
|
75
|
+
if (result.plan.items.length === 0) {
|
|
76
|
+
console.log(" nothing in canonical memories/ yet");
|
|
77
|
+
}
|
|
78
|
+
for (const item of result.plan.items) {
|
|
79
|
+
console.log(` [${item.action}] "${item.name}" — ${item.detail}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
48
82
|
export function runMemorySync(opts = {}) {
|
|
49
83
|
const homeDir = opts.homeDir ?? homedir();
|
|
50
84
|
let result;
|
|
@@ -61,17 +95,79 @@ export function runMemorySync(opts = {}) {
|
|
|
61
95
|
if (opts.json) {
|
|
62
96
|
console.log(JSON.stringify(result, null, 2));
|
|
63
97
|
}
|
|
64
|
-
else
|
|
98
|
+
else {
|
|
99
|
+
printMemorySyncResult(result, opts.dryRun ?? false);
|
|
100
|
+
}
|
|
101
|
+
const hasConflict = result.configured && result.plan.items.some((i) => i.action === "conflict");
|
|
102
|
+
return { exitCode: hasConflict ? 1 : 0 };
|
|
103
|
+
}
|
|
104
|
+
function canonicalMemoryFilePath(homeDir, slug) {
|
|
105
|
+
return join(homeDir, ".trellis", "memories", `${slug}.md`);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The reverse direction of `collectMemorySyncResult`: reads the same
|
|
109
|
+
* graph file, but plans which of its real, non-`trellis-memory` entities
|
|
110
|
+
* (`planMemoryExtraction`) should become new canonical files — never the
|
|
111
|
+
* other way around, and never touching an entity `memory sync` itself
|
|
112
|
+
* owns (trellis-memory-extraction).
|
|
113
|
+
*/
|
|
114
|
+
export function collectMemoryExtractionResult(homeDir = homedir()) {
|
|
115
|
+
const lookup = resolveMemoryServerGraphPath(homeDir);
|
|
116
|
+
if (!lookup.configured)
|
|
117
|
+
return lookup;
|
|
118
|
+
const graphContent = existsSync(lookup.graphPath) ? readFileSync(lookup.graphPath, "utf-8") : undefined;
|
|
119
|
+
const graphLines = graphContent !== undefined ? parseMemoryGraph(graphContent) : [];
|
|
120
|
+
const plan = planMemoryExtraction(graphLines, (slug) => {
|
|
121
|
+
const file = canonicalMemoryFilePath(homeDir, slug);
|
|
122
|
+
return existsSync(file) ? readFileSync(file, "utf-8") : undefined;
|
|
123
|
+
});
|
|
124
|
+
return { configured: true, graphPath: lookup.graphPath, plan };
|
|
125
|
+
}
|
|
126
|
+
export function applyMemoryExtraction(result, homeDir = homedir()) {
|
|
127
|
+
if (!result.configured)
|
|
128
|
+
return;
|
|
129
|
+
for (const item of result.plan.items) {
|
|
130
|
+
if (item.action !== "create" || item.content === undefined)
|
|
131
|
+
continue;
|
|
132
|
+
const file = canonicalMemoryFilePath(homeDir, item.slug);
|
|
133
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
134
|
+
writeFileSync(file, item.content);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Mirrors `printMemorySyncResult`'s own export-pair convention — reused
|
|
138
|
+
* verbatim if `onboard` ever chains this stage too, not a second,
|
|
139
|
+
* drifting copy. */
|
|
140
|
+
export function printMemoryExtractionResult(result, dryRun) {
|
|
141
|
+
if (!result.configured) {
|
|
65
142
|
console.log(result.reason);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
console.log(`${dryRun ? "[dry run] " : ""}memory extract — ${result.graphPath}`);
|
|
146
|
+
if (result.plan.items.length === 0) {
|
|
147
|
+
console.log(" nothing to extract — no non-trellis-memory entities with observations in the graph");
|
|
148
|
+
}
|
|
149
|
+
for (const item of result.plan.items) {
|
|
150
|
+
console.log(` [${item.action}] "${item.name}" (${item.slug}.md) — ${item.detail}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export function runMemoryExtraction(opts = {}) {
|
|
154
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
155
|
+
let result;
|
|
156
|
+
try {
|
|
157
|
+
result = collectMemoryExtractionResult(homeDir);
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
161
|
+
return { exitCode: 1 };
|
|
162
|
+
}
|
|
163
|
+
if (!opts.dryRun) {
|
|
164
|
+
applyMemoryExtraction(result, homeDir);
|
|
165
|
+
}
|
|
166
|
+
if (opts.json) {
|
|
167
|
+
console.log(JSON.stringify(result, null, 2));
|
|
66
168
|
}
|
|
67
169
|
else {
|
|
68
|
-
|
|
69
|
-
if (result.plan.items.length === 0) {
|
|
70
|
-
console.log(" nothing in canonical memories/ yet");
|
|
71
|
-
}
|
|
72
|
-
for (const item of result.plan.items) {
|
|
73
|
-
console.log(` [${item.action}] "${item.name}" — ${item.detail}`);
|
|
74
|
-
}
|
|
170
|
+
printMemoryExtractionResult(result, opts.dryRun ?? false);
|
|
75
171
|
}
|
|
76
172
|
const hasConflict = result.configured && result.plan.items.some((i) => i.action === "conflict");
|
|
77
173
|
return { exitCode: hasConflict ? 1 : 0 };
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* plan-then-apply split every adapter already uses.
|
|
6
6
|
*/
|
|
7
7
|
import type { AgentId, McpServerDef } from "../core/types.js";
|
|
8
|
-
export type MigrateAction = "create" | "skip-symlink" | "skip-case-broken" | "skip-unsupported" | "already-migrated" | "conflict";
|
|
8
|
+
export type MigrateAction = "create" | "skip-symlink" | "skip-case-broken" | "skip-unsupported" | "already-migrated" | "reclassify" | "conflict";
|
|
9
9
|
/** Internal kind naming, unchanged since before `--only` existed
|
|
10
10
|
* (trellis-migrate-category-selection design.md D2) — the CLI-facing
|
|
11
11
|
* flag value is the plural `"skills"`, mapped to this singular `"skill"`
|
|
@@ -28,6 +28,19 @@ export interface MigratePlan {
|
|
|
28
28
|
present: boolean;
|
|
29
29
|
items: MigratePlanItem[];
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* True when `existing` and `def` differ ONLY in which of `env`/
|
|
33
|
+
* `envAliases`/`staticEnv` an env value is filed under, while every
|
|
34
|
+
* other field and every value's resolved literal/reference text stays
|
|
35
|
+
* identical — the exact shape a migrate-read classification fix (like
|
|
36
|
+
* `trellis-migrate-env-var-alias`) leaves behind on already-migrated
|
|
37
|
+
* canonical data. Not a real conflict: the underlying value never
|
|
38
|
+
* changed, only Trellis's own bucketing of it got more precise
|
|
39
|
+
* (trellis-migrate-reclassify-repair). Anything this doesn't cover — a
|
|
40
|
+
* real value change, a different command, an added/removed field —
|
|
41
|
+
* still conflicts, unchanged.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isSafeReclassification(existing: McpServerDef, def: McpServerDef): boolean;
|
|
31
44
|
/**
|
|
32
45
|
* `only` restricts which kind(s) are even considered — not a post-hoc
|
|
33
46
|
* filter on a fully-computed plan (trellis-migrate-category-selection
|
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);
|
|
@@ -17,6 +17,7 @@ import type { MigrateKind, MigratePlan } from "./migrate.js";
|
|
|
17
17
|
import type { SyncReport } from "./sync.js";
|
|
18
18
|
import type { McpSyncReport } from "./mcp.js";
|
|
19
19
|
import type { SecretsAuditReport } from "./secretsAudit.js";
|
|
20
|
+
import type { MemorySyncResult } from "./memory.js";
|
|
20
21
|
import type { ConfirmAndInstallOptions } from "../lib/installAgent.js";
|
|
21
22
|
export interface OnboardAgentSummary {
|
|
22
23
|
agent: AgentId;
|
|
@@ -27,6 +28,13 @@ export interface OnboardAgentSummary {
|
|
|
27
28
|
* of that agent's own to potentially migrate, not a judgment about
|
|
28
29
|
* its quality or length. */
|
|
29
30
|
hasRealInstructions: boolean;
|
|
31
|
+
/** How many real MCP servers `migrate --only mcp` would find for this
|
|
32
|
+
* agent — computed via `collectMigratePlan(agent, homeDir, ["mcp"])`
|
|
33
|
+
* rather than a second reader dispatch, so this always agrees with
|
|
34
|
+
* what migrate itself will do (design.md D1, trellis-onboard-mcp-
|
|
35
|
+
* memory). 0 for pi, which has no MCP reader at all — not a gap here,
|
|
36
|
+
* the same fact `migrate.ts` already establishes. */
|
|
37
|
+
mcpServerCount: number;
|
|
30
38
|
}
|
|
31
39
|
export declare function collectOnboardSummary(homeDir?: string): Promise<OnboardAgentSummary[]>;
|
|
32
40
|
export interface RunOnboardOptions {
|
|
@@ -82,6 +90,7 @@ export interface OnboardResult {
|
|
|
82
90
|
migrateSkipped?: string;
|
|
83
91
|
syncReport?: SyncReport;
|
|
84
92
|
mcpSyncReport?: McpSyncReport;
|
|
93
|
+
memorySyncResult?: MemorySyncResult;
|
|
85
94
|
secretsAuditReport?: SecretsAuditReport;
|
|
86
95
|
refusal?: string;
|
|
87
96
|
/** Only set when no agent is present — the same values `--json` and
|
package/dist/commands/onboard.js
CHANGED
|
@@ -28,6 +28,7 @@ import { applyMigratePlan, collectMigratePlan, printPlan as printMigratePlan } f
|
|
|
28
28
|
import { collectSyncReport, printReport as printSyncReport } from "./sync.js";
|
|
29
29
|
import { collectMcpSyncReport, printReport as printMcpSyncReport } from "./mcp.js";
|
|
30
30
|
import { collectSecretsAuditReport, printReport as printSecretsAuditReport } from "./secretsAudit.js";
|
|
31
|
+
import { applyMemorySync, collectMemorySyncResult, printMemorySyncResult } from "./memory.js";
|
|
31
32
|
import { openBackupSession } from "../lib/backup.js";
|
|
32
33
|
import { confirmAndInstall } from "../lib/installAgent.js";
|
|
33
34
|
const PROBES = {
|
|
@@ -40,19 +41,20 @@ export async function collectOnboardSummary(homeDir = homedir()) {
|
|
|
40
41
|
return Promise.all(ALL_AGENTS.map(async (agent) => {
|
|
41
42
|
const snapshot = await PROBES[agent](homeDir);
|
|
42
43
|
if (!snapshot.present) {
|
|
43
|
-
return { agent, present: false, skillCount: 0, skillNames: [], hasRealInstructions: false };
|
|
44
|
+
return { agent, present: false, skillCount: 0, skillNames: [], hasRealInstructions: false, mcpServerCount: 0 };
|
|
44
45
|
}
|
|
45
46
|
const skillNames = snapshot.skillRoots.flatMap((root) => root.skills.map((s) => s.name));
|
|
46
47
|
const hasRealInstructions = snapshot.instructionsFile !== undefined && !snapshot.instructionsFile.isSymlink;
|
|
47
|
-
|
|
48
|
+
const mcpPlan = await collectMigratePlan(agent, homeDir, ["mcp"]);
|
|
49
|
+
return { agent, present: true, skillCount: skillNames.length, skillNames, hasRealInstructions, mcpServerCount: mcpPlan.items.length };
|
|
48
50
|
}));
|
|
49
51
|
}
|
|
50
52
|
function hasContent(s) {
|
|
51
|
-
return s.skillCount > 0 || s.hasRealInstructions;
|
|
53
|
+
return s.skillCount > 0 || s.hasRealInstructions || s.mcpServerCount > 0;
|
|
52
54
|
}
|
|
53
55
|
function agentSummaryLabel(s) {
|
|
54
56
|
const skills = s.skillCount > 0 ? ` (${s.skillNames.join(", ")})` : "";
|
|
55
|
-
return `${s.agent} — ${s.skillCount} skill(s)${skills}, instructions: ${s.hasRealInstructions ? "yes" : "no"}`;
|
|
57
|
+
return `${s.agent} — ${s.skillCount} skill(s)${skills}, instructions: ${s.hasRealInstructions ? "yes" : "no"}, mcp: ${s.mcpServerCount}`;
|
|
56
58
|
}
|
|
57
59
|
/** Numbered-typing fallback (trellis-onboard-interactive-picker design.md
|
|
58
60
|
* D2) — used only when the terminal can't support the raw-mode picker
|
|
@@ -144,48 +146,47 @@ async function promptForManagedAgentsReal(candidates, alreadyManaged) {
|
|
|
144
146
|
}
|
|
145
147
|
/**
|
|
146
148
|
* Resolves which categories (skills, instructions) to migrate from a
|
|
147
|
-
* resolved source (trellis-migrate-category-selection
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
149
|
+
* resolved source (trellis-migrate-category-selection, extended to a
|
|
150
|
+
* third category by trellis-onboard-mcp-memory design.md D2). Unlike the
|
|
151
|
+
* two pickers above, there is no numbered-text fallback to preserve
|
|
152
|
+
* parity with — this concept never existed before this change, so
|
|
153
|
+
* "can't prompt" simply means "default to whichever kind(s) actually
|
|
154
|
+
* have real content, silently" (design.md D4). The picker itself is
|
|
155
|
+
* only offered when the choice is meaningful — two or more kinds
|
|
156
|
+
* present, a capable terminal, and not a `--json` run (design.md D5).
|
|
157
|
+
* An empty result is a valid answer: "skip migrate for this run"
|
|
158
|
+
* (design.md D6), left for the caller to act on.
|
|
156
159
|
*/
|
|
157
160
|
async function resolveMigrateCategories(source, opts) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (
|
|
161
|
+
// Built dynamically, in skill/instructions/mcp display order — not a
|
|
162
|
+
// fixed two- or three-slot structure, so a fourth category some day
|
|
163
|
+
// would only need an entry here, not a rewritten branch (design.md D2).
|
|
164
|
+
const candidates = [];
|
|
165
|
+
if (source.skillCount > 0)
|
|
166
|
+
candidates.push({ kind: "skill", label: "skills" });
|
|
167
|
+
if (source.hasRealInstructions)
|
|
168
|
+
candidates.push({ kind: "instructions", label: "instructions" });
|
|
169
|
+
if (source.mcpServerCount > 0)
|
|
170
|
+
candidates.push({ kind: "mcp", label: "mcp" });
|
|
171
|
+
// The choice is only meaningful when two or more kinds are real — same
|
|
172
|
+
// gate for the injected test seam as for the real picker, mirroring
|
|
173
|
+
// how `promptForAgent`/`promptForManagedAgents` are only ever
|
|
174
|
+
// consulted when their own real prompt would actually apply.
|
|
175
|
+
if (candidates.length > 1 && !opts.json) {
|
|
165
176
|
if (opts.promptForMigrateCategories) {
|
|
166
177
|
return opts.promptForMigrateCategories(source);
|
|
167
178
|
}
|
|
168
179
|
if (canUseInteractivePicker()) {
|
|
169
180
|
console.log(`Which categories should be migrated from ${source.agent}? Space to toggle, Enter to confirm:`);
|
|
170
|
-
const indices = await runMultiSelectPicker(
|
|
181
|
+
const indices = await runMultiSelectPicker(candidates.map((c) => c.label), candidates.map(() => true));
|
|
171
182
|
if (indices === null) {
|
|
172
183
|
console.log("cancelled, no changes made");
|
|
173
184
|
process.exit(1);
|
|
174
185
|
}
|
|
175
|
-
|
|
176
|
-
if (indices.includes(0))
|
|
177
|
-
kinds.push("skill");
|
|
178
|
-
if (indices.includes(1))
|
|
179
|
-
kinds.push("instructions");
|
|
180
|
-
return kinds;
|
|
186
|
+
return indices.map((i) => candidates[i].kind);
|
|
181
187
|
}
|
|
182
188
|
}
|
|
183
|
-
|
|
184
|
-
if (wantsSkills)
|
|
185
|
-
kinds.push("skill");
|
|
186
|
-
if (wantsInstructions)
|
|
187
|
-
kinds.push("instructions");
|
|
188
|
-
return kinds;
|
|
189
|
+
return candidates.map((c) => c.kind);
|
|
189
190
|
}
|
|
190
191
|
/** Shared by `--manage` and the interactive prompt's answer — same
|
|
191
192
|
* grammar either way (design.md D4): a real agent id list, comma- or
|
|
@@ -343,6 +344,15 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
343
344
|
const syncReport = await collectSyncReport({ homeDir, dryRun: opts.dryRun, managedAgents, backupSession });
|
|
344
345
|
const mcpSyncReport = await collectMcpSyncReport({ homeDir, dryRun: opts.dryRun, managedAgents, backupSession });
|
|
345
346
|
backupSession?.finalize();
|
|
347
|
+
// Independent of `managedAgents` — the shared memory server is not
|
|
348
|
+
// per-agent (design.md D4, trellis-onboard-mcp-memory), unlike
|
|
349
|
+
// sync/mcp-sync above. Runs unconditionally once reached; an
|
|
350
|
+
// unconfigured `memory` server is a legitimate "nothing to do yet"
|
|
351
|
+
// state (design.md D5), not gated on any prior step here.
|
|
352
|
+
const memorySyncResult = collectMemorySyncResult(homeDir);
|
|
353
|
+
if (!opts.dryRun) {
|
|
354
|
+
applyMemorySync(memorySyncResult);
|
|
355
|
+
}
|
|
346
356
|
// Read-only, no dryRun concept — same report either way, run last since
|
|
347
357
|
// it audits the config mcp sync just wrote (or, on --dry-run, whatever
|
|
348
358
|
// was already there before this run).
|
|
@@ -357,6 +367,7 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
357
367
|
migrateSkipped,
|
|
358
368
|
syncReport,
|
|
359
369
|
mcpSyncReport,
|
|
370
|
+
memorySyncResult,
|
|
360
371
|
secretsAuditReport,
|
|
361
372
|
};
|
|
362
373
|
}
|
|
@@ -370,9 +381,11 @@ export async function runOnboard(opts = {}) {
|
|
|
370
381
|
}
|
|
371
382
|
if (result.refusal)
|
|
372
383
|
return { exitCode: 1 };
|
|
384
|
+
const memorySyncConflict = result.memorySyncResult?.configured === true && result.memorySyncResult.plan.items.some((i) => i.action === "conflict");
|
|
373
385
|
const hasConflict = (result.migratePlan?.items.some((i) => i.action === "conflict") ?? false) ||
|
|
374
386
|
(result.syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false) ||
|
|
375
387
|
(result.mcpSyncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false) ||
|
|
388
|
+
memorySyncConflict ||
|
|
376
389
|
(result.secretsAuditReport?.findings.length ?? 0) > 0;
|
|
377
390
|
return { exitCode: hasConflict ? 1 : 0 };
|
|
378
391
|
}
|
|
@@ -431,6 +444,10 @@ function printResult(result, dryRun) {
|
|
|
431
444
|
console.log("\nmcp sync");
|
|
432
445
|
printMcpSyncReport(result.mcpSyncReport, false);
|
|
433
446
|
}
|
|
447
|
+
if (result.memorySyncResult) {
|
|
448
|
+
console.log("\nmemory sync");
|
|
449
|
+
printMemorySyncResult(result.memorySyncResult, false);
|
|
450
|
+
}
|
|
434
451
|
if (result.secretsAuditReport) {
|
|
435
452
|
console.log("\nsecrets audit");
|
|
436
453
|
printSecretsAuditReport(result.secretsAuditReport);
|
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`
|