agent-trellis 0.4.0 → 0.6.0

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.
Files changed (62) hide show
  1. package/dist/adapters/codex.js +4 -1
  2. package/dist/adapters/jsonMcp.js +6 -3
  3. package/dist/adapters/kiro.js +9 -1
  4. package/dist/adapters/mcpPlan.d.ts +15 -0
  5. package/dist/adapters/mcpPlan.js +59 -3
  6. package/dist/adapters/symlinkPlan.js +3 -0
  7. package/dist/cli.js +56 -8
  8. package/dist/commands/doctor.d.ts +13 -3
  9. package/dist/commands/doctor.js +20 -10
  10. package/dist/commands/mcpAuth.d.ts +28 -0
  11. package/dist/commands/mcpAuth.js +99 -0
  12. package/dist/commands/mcpGateway.d.ts +53 -0
  13. package/dist/commands/mcpGateway.js +148 -0
  14. package/dist/commands/memory.d.ts +42 -19
  15. package/dist/commands/memory.js +99 -14
  16. package/dist/commands/migrate.d.ts +18 -1
  17. package/dist/commands/migrate.js +52 -6
  18. package/dist/commands/onboard.d.ts +34 -0
  19. package/dist/commands/onboard.js +110 -10
  20. package/dist/commands/onboardVerdict.d.ts +55 -0
  21. package/dist/commands/onboardVerdict.js +175 -0
  22. package/dist/core/adapter.d.ts +7 -0
  23. package/dist/core/canonical.d.ts +8 -6
  24. package/dist/core/canonical.js +26 -3
  25. package/dist/core/types.d.ts +42 -4
  26. package/dist/lib/backup.js +32 -3
  27. package/dist/lib/gatewayBackend.d.ts +75 -0
  28. package/dist/lib/gatewayBackend.js +131 -0
  29. package/dist/lib/mcpConnect.d.ts +53 -0
  30. package/dist/lib/mcpConnect.js +123 -0
  31. package/dist/lib/mcpMigrateRead.d.ts +12 -0
  32. package/dist/lib/mcpMigrateRead.js +47 -12
  33. package/dist/lib/mcpToolRegistry.d.ts +89 -0
  34. package/dist/lib/mcpToolRegistry.js +94 -0
  35. package/dist/lib/memoryGraph.d.ts +35 -0
  36. package/dist/lib/memoryGraph.js +62 -0
  37. package/dist/lib/oauth/discovery.d.ts +53 -0
  38. package/dist/lib/oauth/discovery.js +95 -0
  39. package/dist/lib/oauth/flow.d.ts +47 -0
  40. package/dist/lib/oauth/flow.js +193 -0
  41. package/dist/lib/oauth/lock.d.ts +25 -0
  42. package/dist/lib/oauth/lock.js +98 -0
  43. package/dist/lib/oauth/pkce.d.ts +27 -0
  44. package/dist/lib/oauth/pkce.js +40 -0
  45. package/dist/lib/oauth/refresh.d.ts +33 -0
  46. package/dist/lib/oauth/refresh.js +93 -0
  47. package/dist/lib/oauth/store.d.ts +55 -0
  48. package/dist/lib/oauth/store.js +85 -0
  49. package/dist/lib/slug.d.ts +9 -0
  50. package/dist/lib/slug.js +15 -0
  51. package/dist/lib/tomlSection.js +7 -1
  52. package/dist/pi-bridge/bundle.js +14383 -14338
  53. package/dist/pi-bridge/index.d.ts +5 -9
  54. package/dist/pi-bridge/index.js +6 -99
  55. package/dist/pi-bridge/schemaTranslate.d.ts +8 -2
  56. package/dist/pi-bridge/schemaTranslate.js +8 -4
  57. package/docs/architecture.md +88 -19
  58. package/docs/getting-started.md +174 -11
  59. package/docs/research.md +190 -0
  60. package/docs/roadmap.md +20 -0
  61. package/package.json +4 -3
  62. package/schema/servers.example.yaml +42 -7
@@ -0,0 +1,148 @@
1
+ /**
2
+ * `trellis mcp-gateway --agent <id>` — the stdio MCP server an agent
3
+ * spawns in gateway mode (trellis-mcp-gateway-hosting).
4
+ *
5
+ * Not a user-facing command in the usual sense: nobody types this. An
6
+ * agent's own MCP client spawns it exactly the way it spawns any other
7
+ * stdio server, talks to it for the session, and closes the pipe on exit
8
+ * (design.md D2). There is no daemon, no lifecycle command, and nothing
9
+ * for the user to notice.
10
+ *
11
+ * This file depends on `GatewayBackend` and nothing below it — no
12
+ * connection, no transport, no server definition (design.md D11). That is
13
+ * what lets v2 point the same stdio edge at one shared service instead of
14
+ * an in-process backend without this file, or any agent's config,
15
+ * changing.
16
+ */
17
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
18
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
20
+ import { homedir } from "node:os";
21
+ import { loadCanonicalSource } from "../core/canonical.js";
22
+ import { resolveMcpPlan } from "../adapters/mcpPlan.js";
23
+ import { LocalBackend } from "../lib/gatewayBackend.js";
24
+ import { DEFAULT_CONNECT_TIMEOUT_MS } from "../lib/mcpConnect.js";
25
+ import { ALL_AGENTS } from "../core/types.js";
26
+ const CLIENT_INFO = { name: "trellis-mcp-gateway", version: "0.0.0" };
27
+ const SERVER_INFO = { name: "trellis-mcp-gateway", version: "0.0.0" };
28
+ export function parseMcpGatewayArgs(argv) {
29
+ const index = argv.indexOf("--agent");
30
+ if (index === -1 || index === argv.length - 1) {
31
+ return { error: `mcp-gateway requires --agent <${ALL_AGENTS.join("|")}>` };
32
+ }
33
+ const value = argv[index + 1];
34
+ if (!ALL_AGENTS.includes(value)) {
35
+ return { error: `unknown agent "${value}" — expected one of ${ALL_AGENTS.join(", ")}` };
36
+ }
37
+ return { agentId: value };
38
+ }
39
+ /**
40
+ * The in-scope upstream set for this agent, resolved through the very same
41
+ * `resolveMcpPlan` that direct mode uses — per-server `agents:` scope,
42
+ * `enabled: false`, host-injected collisions, literal-secret refusals and
43
+ * unresolved `env` names all apply here identically, because it is
44
+ * literally the same function (design.md D6).
45
+ *
46
+ * The one thing that must be stripped is gateway mode itself: asking
47
+ * `resolveMcpPlan` for this agent's plan while gateway mode is on would
48
+ * answer "one entry, pointing at the gateway" — the gateway being told to
49
+ * connect to itself. Resolving against a gateway-less view of the same
50
+ * canonical gives the set gateway mode replaced.
51
+ */
52
+ export function resolveGatewayUpstreams(agentId, canonical) {
53
+ const direct = { ...canonical.mcp, gateway: undefined };
54
+ // The managed set is passed through unchanged, so an unmanaged agent
55
+ // reaches nothing. Being spawned is NOT treated as consent here, which
56
+ // is a deliberate divergence from `src/pi-bridge/index.ts` (which passes
57
+ // ALL_AGENTS on the grounds that pi loading the extension is itself the
58
+ // signal). The triggers differ: pi's bridge only exists on disk because
59
+ // Trellis symlinked it into pi's own directory while pi was managed, so
60
+ // its presence IS the marker. A gateway entry, by contrast, outlives
61
+ // un-managing — it sits in the agent's config until a later sync's
62
+ // ownership-gated removal clears it. Serving that stale entry would hand
63
+ // every server, and every secret resolved into it, to the one agent the
64
+ // user just said to stop involving.
65
+ const { desired, conflicts } = resolveMcpPlan(agentId, direct, canonical.managedAgents, canonical.secretsPolicy);
66
+ return {
67
+ upstreams: desired.map(({ name, def }) => ({ name, def })),
68
+ conflicts: conflicts.map((conflict) => conflict.message),
69
+ };
70
+ }
71
+ export async function runMcpGateway(opts) {
72
+ const homeDir = opts.homeDir ?? homedir();
73
+ const connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
74
+ let canonical;
75
+ try {
76
+ canonical = loadCanonicalSource(homeDir);
77
+ }
78
+ catch (err) {
79
+ // stderr, never stdout: stdout IS the MCP protocol stream here, and a
80
+ // stray line on it corrupts the agent's parser rather than informing
81
+ // anyone.
82
+ console.error(`trellis-mcp-gateway: ${err instanceof Error ? err.message : String(err)}`);
83
+ return { exitCode: 1 };
84
+ }
85
+ const { upstreams, conflicts } = resolveGatewayUpstreams(opts.agentId, canonical);
86
+ for (const message of conflicts) {
87
+ console.error(`trellis-mcp-gateway: ${message}`);
88
+ }
89
+ const backend = opts.backendFactory
90
+ ? await opts.backendFactory(upstreams, homeDir)
91
+ : await LocalBackend.connect(upstreams, {
92
+ secretsPolicy: canonical.secretsPolicy,
93
+ clientInfo: CLIENT_INFO,
94
+ connectTimeoutMs,
95
+ // Enables silent OAuth refresh-before-connect. Never an
96
+ // interactive grant: that is `trellis mcp auth`'s job, and this
97
+ // process has no terminal to run one in (design.md D9).
98
+ homeDir,
99
+ });
100
+ const server = new Server(SERVER_INFO, { capabilities: { tools: {} } });
101
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: await backend.listTools() }));
102
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
103
+ const result = await backend.callTool(request.params.name, request.params.arguments);
104
+ return result;
105
+ });
106
+ const transport = new StdioServerTransport();
107
+ await server.connect(transport);
108
+ await waitForShutdown(backend, server);
109
+ return { exitCode: 0 };
110
+ }
111
+ /**
112
+ * Teardown on client disconnect — the piece neither layer below provides.
113
+ *
114
+ * `StdioServerTransport.start()` registers listeners for `'data'` and
115
+ * `'error'` only, so stdin reaching EOF never reaches `onclose`; and POSIX
116
+ * re-parents an orphan to launchd rather than killing it, while this
117
+ * process's own event loop is held open by every upstream it connected.
118
+ * Without this, each agent session would permanently leak a gateway plus
119
+ * its entire upstream process set (design.md D13).
120
+ */
121
+ function waitForShutdown(backend, server) {
122
+ return new Promise((resolve) => {
123
+ let settled = false;
124
+ const shutdown = async () => {
125
+ if (settled)
126
+ return;
127
+ settled = true;
128
+ try {
129
+ await backend.close();
130
+ }
131
+ catch (err) {
132
+ console.error(`trellis-mcp-gateway: failed to close backend: ${err instanceof Error ? err.message : String(err)}`);
133
+ }
134
+ try {
135
+ await server.close();
136
+ }
137
+ catch {
138
+ // best-effort; the process is going away regardless
139
+ }
140
+ resolve();
141
+ };
142
+ process.stdin.on("end", shutdown);
143
+ process.stdin.on("close", shutdown);
144
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
145
+ process.once(signal, shutdown);
146
+ }
147
+ });
148
+ }
@@ -1,13 +1,18 @@
1
1
  /**
2
- * `trellis memory sync` — ingests canonical `memories/*.md` into the
3
- * on-disk knowledge-graph file `@modelcontextprotocol/server-memory`
4
- * itself reads at startup, closing P6's explicitly-left-open "auto-
5
- * ingesting memories/*.md content into the running memory server's
6
- * store" gap (trellis-memory-sync). Never spawns or talks to a running
7
- * server process — this is a plain file write, same posture as every
8
- * other Trellis write.
2
+ * `trellis memory sync`/`trellis memory extract` — the two one-directional
3
+ * halves of Trellis's shared-memory story: sync ingests canonical
4
+ * `memories/*.md` into the on-disk knowledge-graph file
5
+ * `@modelcontextprotocol/server-memory` itself reads at startup, closing
6
+ * P6's explicitly-left-open "auto-ingesting memories/*.md content into
7
+ * the running memory server's store" gap (trellis-memory-sync); extract
8
+ * reads the graph's own real, non-Trellis entities back into new
9
+ * canonical files, closing P15's own explicitly-named "per-agent
10
+ * extraction into canonical remains a separate, open gap"
11
+ * (trellis-memory-extraction). Neither spawns or talks to a running
12
+ * server process — both are plain file operations, same posture as
13
+ * every other Trellis write.
9
14
  */
10
- import { type MemorySyncPlan } from "../lib/memoryGraph.js";
15
+ import { type MemoryExtractionPlan, type MemorySyncPlan } from "../lib/memoryGraph.js";
11
16
  export interface RunMemorySyncOptions {
12
17
  homeDir?: string;
13
18
  json?: boolean;
@@ -21,17 +26,6 @@ export type MemorySyncResult = {
21
26
  configured: false;
22
27
  reason: string;
23
28
  };
24
- /**
25
- * Looks up `mcp.servers["memory"]` specifically (matching `schema/
26
- * servers.example.yaml`'s own naming convention for the default shared-
27
- * memory backend) — not a heuristic scan for any server whose command
28
- * happens to mention `@modelcontextprotocol/server-memory`, since a
29
- * user could reasonably name or configure it differently and this
30
- * would then silently miss it. `static_env.MEMORY_FILE_PATH` must be
31
- * set explicitly: the server's own unset-env default resolves relative
32
- * to wherever `npx` cached the package, a location Trellis has no
33
- * reliable way to predict.
34
- */
35
29
  export declare function collectMemorySyncResult(homeDir?: string): MemorySyncResult;
36
30
  export declare function applyMemorySync(result: MemorySyncResult): void;
37
31
  /**
@@ -45,3 +39,32 @@ export declare function printMemorySyncResult(result: MemorySyncResult, dryRun:
45
39
  export declare function runMemorySync(opts?: RunMemorySyncOptions): {
46
40
  exitCode: number;
47
41
  };
42
+ export interface RunMemoryExtractionOptions {
43
+ homeDir?: string;
44
+ json?: boolean;
45
+ dryRun?: boolean;
46
+ }
47
+ export type MemoryExtractionResult = {
48
+ configured: true;
49
+ graphPath: string;
50
+ plan: MemoryExtractionPlan;
51
+ } | {
52
+ configured: false;
53
+ reason: string;
54
+ };
55
+ /**
56
+ * The reverse direction of `collectMemorySyncResult`: reads the same
57
+ * graph file, but plans which of its real, non-`trellis-memory` entities
58
+ * (`planMemoryExtraction`) should become new canonical files — never the
59
+ * other way around, and never touching an entity `memory sync` itself
60
+ * owns (trellis-memory-extraction).
61
+ */
62
+ export declare function collectMemoryExtractionResult(homeDir?: string): MemoryExtractionResult;
63
+ export declare function applyMemoryExtraction(result: MemoryExtractionResult, homeDir?: string): void;
64
+ /** Mirrors `printMemorySyncResult`'s own export-pair convention — reused
65
+ * verbatim if `onboard` ever chains this stage too, not a second,
66
+ * drifting copy. */
67
+ export declare function printMemoryExtractionResult(result: MemoryExtractionResult, dryRun: boolean): void;
68
+ export declare function runMemoryExtraction(opts?: RunMemoryExtractionOptions): {
69
+ exitCode: number;
70
+ };
@@ -1,17 +1,22 @@
1
1
  /**
2
- * `trellis memory sync` — ingests canonical `memories/*.md` into the
3
- * on-disk knowledge-graph file `@modelcontextprotocol/server-memory`
4
- * itself reads at startup, closing P6's explicitly-left-open "auto-
5
- * ingesting memories/*.md content into the running memory server's
6
- * store" gap (trellis-memory-sync). Never spawns or talks to a running
7
- * server process — this is a plain file write, same posture as every
8
- * other Trellis write.
2
+ * `trellis memory sync`/`trellis memory extract` — the two one-directional
3
+ * halves of Trellis's shared-memory story: sync ingests canonical
4
+ * `memories/*.md` into the on-disk knowledge-graph file
5
+ * `@modelcontextprotocol/server-memory` itself reads at startup, closing
6
+ * P6's explicitly-left-open "auto-ingesting memories/*.md content into
7
+ * the running memory server's store" gap (trellis-memory-sync); extract
8
+ * reads the graph's own real, non-Trellis entities back into new
9
+ * canonical files, closing P15's own explicitly-named "per-agent
10
+ * extraction into canonical remains a separate, open gap"
11
+ * (trellis-memory-extraction). Neither spawns or talks to a running
12
+ * server process — both are plain file operations, same posture as
13
+ * every other Trellis write.
9
14
  */
10
15
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
16
  import { homedir } from "node:os";
12
- import { dirname } from "node:path";
17
+ import { dirname, join } from "node:path";
13
18
  import { loadCanonicalSource } from "../core/canonical.js";
14
- import { planMemorySync, renderMemoryGraph } from "../lib/memoryGraph.js";
19
+ import { parseMemoryGraph, planMemoryExtraction, planMemorySync, renderMemoryGraph } from "../lib/memoryGraph.js";
15
20
  const MEMORY_SERVER_NAME = "memory";
16
21
  /**
17
22
  * Looks up `mcp.servers["memory"]` specifically (matching `schema/
@@ -22,9 +27,12 @@ const MEMORY_SERVER_NAME = "memory";
22
27
  * would then silently miss it. `static_env.MEMORY_FILE_PATH` must be
23
28
  * set explicitly: the server's own unset-env default resolves relative
24
29
  * to wherever `npx` cached the package, a location Trellis has no
25
- * reliable way to predict.
30
+ * reliable way to predict. Shared by `collectMemorySyncResult` and
31
+ * `collectMemoryExtractionResult` — both directions must never disagree
32
+ * about which graph file they're reading/writing
33
+ * (trellis-memory-extraction).
26
34
  */
27
- export function collectMemorySyncResult(homeDir = homedir()) {
35
+ function resolveMemoryServerGraphPath(homeDir) {
28
36
  const canonical = loadCanonicalSource(homeDir);
29
37
  const server = canonical.mcp.servers[MEMORY_SERVER_NAME];
30
38
  const rawPath = server?.staticEnv?.MEMORY_FILE_PATH;
@@ -34,10 +42,16 @@ export function collectMemorySyncResult(homeDir = homedir()) {
34
42
  reason: `no "${MEMORY_SERVER_NAME}" MCP server with static_env.MEMORY_FILE_PATH configured in servers.yaml — see schema/servers.example.yaml`,
35
43
  };
36
44
  }
37
- const graphPath = rawPath.replace(/^~(?=$|\/)/, homeDir);
38
- const currentContent = existsSync(graphPath) ? readFileSync(graphPath, "utf-8") : undefined;
45
+ return { configured: true, graphPath: rawPath.replace(/^~(?=$|\/)/, homeDir) };
46
+ }
47
+ export function collectMemorySyncResult(homeDir = homedir()) {
48
+ const lookup = resolveMemoryServerGraphPath(homeDir);
49
+ if (!lookup.configured)
50
+ return lookup;
51
+ const canonical = loadCanonicalSource(homeDir);
52
+ const currentContent = existsSync(lookup.graphPath) ? readFileSync(lookup.graphPath, "utf-8") : undefined;
39
53
  const plan = planMemorySync(canonical, currentContent);
40
- return { configured: true, graphPath, plan };
54
+ return { configured: true, graphPath: lookup.graphPath, plan };
41
55
  }
42
56
  export function applyMemorySync(result) {
43
57
  if (!result.configured || !result.plan.nextGraph)
@@ -87,3 +101,74 @@ export function runMemorySync(opts = {}) {
87
101
  const hasConflict = result.configured && result.plan.items.some((i) => i.action === "conflict");
88
102
  return { exitCode: hasConflict ? 1 : 0 };
89
103
  }
104
+ function canonicalMemoryFilePath(homeDir, slug) {
105
+ return join(homeDir, ".trellis", "memories", `${slug}.md`);
106
+ }
107
+ /**
108
+ * The reverse direction of `collectMemorySyncResult`: reads the same
109
+ * graph file, but plans which of its real, non-`trellis-memory` entities
110
+ * (`planMemoryExtraction`) should become new canonical files — never the
111
+ * other way around, and never touching an entity `memory sync` itself
112
+ * owns (trellis-memory-extraction).
113
+ */
114
+ export function collectMemoryExtractionResult(homeDir = homedir()) {
115
+ const lookup = resolveMemoryServerGraphPath(homeDir);
116
+ if (!lookup.configured)
117
+ return lookup;
118
+ const graphContent = existsSync(lookup.graphPath) ? readFileSync(lookup.graphPath, "utf-8") : undefined;
119
+ const graphLines = graphContent !== undefined ? parseMemoryGraph(graphContent) : [];
120
+ const plan = planMemoryExtraction(graphLines, (slug) => {
121
+ const file = canonicalMemoryFilePath(homeDir, slug);
122
+ return existsSync(file) ? readFileSync(file, "utf-8") : undefined;
123
+ });
124
+ return { configured: true, graphPath: lookup.graphPath, plan };
125
+ }
126
+ export function applyMemoryExtraction(result, homeDir = homedir()) {
127
+ if (!result.configured)
128
+ return;
129
+ for (const item of result.plan.items) {
130
+ if (item.action !== "create" || item.content === undefined)
131
+ continue;
132
+ const file = canonicalMemoryFilePath(homeDir, item.slug);
133
+ mkdirSync(dirname(file), { recursive: true });
134
+ writeFileSync(file, item.content);
135
+ }
136
+ }
137
+ /** Mirrors `printMemorySyncResult`'s own export-pair convention — reused
138
+ * verbatim if `onboard` ever chains this stage too, not a second,
139
+ * drifting copy. */
140
+ export function printMemoryExtractionResult(result, dryRun) {
141
+ if (!result.configured) {
142
+ console.log(result.reason);
143
+ return;
144
+ }
145
+ console.log(`${dryRun ? "[dry run] " : ""}memory extract — ${result.graphPath}`);
146
+ if (result.plan.items.length === 0) {
147
+ console.log(" nothing to extract — no non-trellis-memory entities with observations in the graph");
148
+ }
149
+ for (const item of result.plan.items) {
150
+ console.log(` [${item.action}] "${item.name}" (${item.slug}.md) — ${item.detail}`);
151
+ }
152
+ }
153
+ export function runMemoryExtraction(opts = {}) {
154
+ const homeDir = opts.homeDir ?? homedir();
155
+ let result;
156
+ try {
157
+ result = collectMemoryExtractionResult(homeDir);
158
+ }
159
+ catch (err) {
160
+ console.error(err instanceof Error ? err.message : String(err));
161
+ return { exitCode: 1 };
162
+ }
163
+ if (!opts.dryRun) {
164
+ applyMemoryExtraction(result, homeDir);
165
+ }
166
+ if (opts.json) {
167
+ console.log(JSON.stringify(result, null, 2));
168
+ }
169
+ else {
170
+ printMemoryExtractionResult(result, opts.dryRun ?? false);
171
+ }
172
+ const hasConflict = result.configured && result.plan.items.some((i) => i.action === "conflict");
173
+ return { exitCode: hasConflict ? 1 : 0 };
174
+ }
@@ -5,7 +5,7 @@
5
5
  * plan-then-apply split every adapter already uses.
6
6
  */
7
7
  import type { AgentId, McpServerDef } from "../core/types.js";
8
- export type MigrateAction = "create" | "skip-symlink" | "skip-case-broken" | "skip-unsupported" | "already-migrated" | "conflict";
8
+ export type MigrateAction = "create" | "skip-symlink" | "skip-case-broken" | "skip-unsupported" | "already-migrated" | "reclassify" | "conflict";
9
9
  /** Internal kind naming, unchanged since before `--only` existed
10
10
  * (trellis-migrate-category-selection design.md D2) — the CLI-facing
11
11
  * flag value is the plural `"skills"`, mapped to this singular `"skill"`
@@ -17,6 +17,10 @@ export interface MigratePlanItem {
17
17
  name: string;
18
18
  action: MigrateAction;
19
19
  detail: string;
20
+ /** Only set (and only meaningful) when `action === "conflict"`: the
21
+ * concrete next action, distinct from `detail`'s restatement of why
22
+ * (trellis-onboard-closed-loop design.md D7). */
23
+ remediation?: string;
20
24
  /** Only set when action === "create"; consumed by applyMigratePlan. */
21
25
  sourceDir?: string;
22
26
  sourceContent?: string;
@@ -28,6 +32,19 @@ export interface MigratePlan {
28
32
  present: boolean;
29
33
  items: MigratePlanItem[];
30
34
  }
35
+ /**
36
+ * True when `existing` and `def` differ ONLY in which of `env`/
37
+ * `envAliases`/`staticEnv` an env value is filed under, while every
38
+ * other field and every value's resolved literal/reference text stays
39
+ * identical — the exact shape a migrate-read classification fix (like
40
+ * `trellis-migrate-env-var-alias`) leaves behind on already-migrated
41
+ * canonical data. Not a real conflict: the underlying value never
42
+ * changed, only Trellis's own bucketing of it got more precise
43
+ * (trellis-migrate-reclassify-repair). Anything this doesn't cover — a
44
+ * real value change, a different command, an added/removed field —
45
+ * still conflicts, unchanged.
46
+ */
47
+ export declare function isSafeReclassification(existing: McpServerDef, def: McpServerDef): boolean;
31
48
  /**
32
49
  * `only` restricts which kind(s) are even considered — not a post-hoc
33
50
  * filter on a fully-computed plan (trellis-migrate-category-selection
@@ -14,7 +14,7 @@ import * as piProbe from "../probes/pi.js";
14
14
  import { AGENTS_MD_TEMPLATE } from "./init.js";
15
15
  import { decideDirImport } from "../lib/dirEquals.js";
16
16
  import { deepEqual } from "../lib/deepEqual.js";
17
- import { readClaudeCodeMcpDefs, readCodexMcpDefs, readKiroMcpDefs } from "../lib/mcpMigrateRead.js";
17
+ import { readClaudeCodeMcpDefs, readCodexMcpDefs, readKiroMcpDefs, resolvedEnvTextMap } from "../lib/mcpMigrateRead.js";
18
18
  import { loadCanonicalSource, upsertServerYaml } from "../core/canonical.js";
19
19
  import { ALL_AGENTS } from "../core/types.js";
20
20
  const PROBES = {
@@ -44,7 +44,13 @@ function planSkill(name, sourceDir, isSymlink, caseCorrect, canonicalDir) {
44
44
  case "already-present":
45
45
  return { kind: "skill", name, action: "already-migrated", detail: "canonical content is byte-identical" };
46
46
  case "conflict":
47
- return { kind: "skill", name, action: "conflict", detail: `canonical skills/${name}/ already exists with different content — resolve by hand` };
47
+ return {
48
+ kind: "skill",
49
+ name,
50
+ action: "conflict",
51
+ detail: `canonical skills/${name}/ already exists with different content — resolve by hand`,
52
+ remediation: `compare \`~/.trellis/skills/${name}/\` against the source agent's copy and either update canonical by hand or delete the source's copy if canonical's is the one to keep`,
53
+ };
48
54
  }
49
55
  }
50
56
  function planInstructions(snapshot, canonicalAgentsMd) {
@@ -58,7 +64,13 @@ function planInstructions(snapshot, canonicalAgentsMd) {
58
64
  sourceContent = readFileSync(snapshot.instructionsFile.path, "utf-8");
59
65
  }
60
66
  catch (err) {
61
- return { kind: "instructions", name: "agents.md", action: "conflict", detail: `could not read source instructions: ${err instanceof Error ? err.message : String(err)}` };
67
+ return {
68
+ kind: "instructions",
69
+ name: "agents.md",
70
+ action: "conflict",
71
+ detail: `could not read source instructions: ${err instanceof Error ? err.message : String(err)}`,
72
+ remediation: `check that ${snapshot.instructionsFile.path} exists and is readable, then re-run migrate`,
73
+ };
62
74
  }
63
75
  if (!existsSync(canonicalAgentsMd)) {
64
76
  return { kind: "instructions", name: "agents.md", action: "create", detail: "canonical agents.md does not exist yet", sourceContent };
@@ -70,7 +82,32 @@ function planInstructions(snapshot, canonicalAgentsMd) {
70
82
  if (current === sourceContent) {
71
83
  return { kind: "instructions", name: "agents.md", action: "already-migrated", detail: "canonical content is byte-identical" };
72
84
  }
73
- return { kind: "instructions", name: "agents.md", action: "conflict", detail: "canonical agents.md already has different real content — resolve by hand" };
85
+ return {
86
+ kind: "instructions",
87
+ name: "agents.md",
88
+ action: "conflict",
89
+ detail: "canonical agents.md already has different real content — resolve by hand",
90
+ remediation: `compare \`~/.trellis/agents.md\` against ${snapshot.instructionsFile.path} and either merge by hand or delete whichever copy you don't want to keep`,
91
+ };
92
+ }
93
+ /**
94
+ * True when `existing` and `def` differ ONLY in which of `env`/
95
+ * `envAliases`/`staticEnv` an env value is filed under, while every
96
+ * other field and every value's resolved literal/reference text stays
97
+ * identical — the exact shape a migrate-read classification fix (like
98
+ * `trellis-migrate-env-var-alias`) leaves behind on already-migrated
99
+ * canonical data. Not a real conflict: the underlying value never
100
+ * changed, only Trellis's own bucketing of it got more precise
101
+ * (trellis-migrate-reclassify-repair). Anything this doesn't cover — a
102
+ * real value change, a different command, an added/removed field —
103
+ * still conflicts, unchanged.
104
+ */
105
+ export function isSafeReclassification(existing, def) {
106
+ const { env: _e1, envAliases: _ea1, staticEnv: _se1, ...restExisting } = existing;
107
+ const { env: _e2, envAliases: _ea2, staticEnv: _se2, ...restDef } = def;
108
+ if (!deepEqual(restExisting, restDef))
109
+ return false;
110
+ return deepEqual(resolvedEnvTextMap(existing), resolvedEnvTextMap(def));
74
111
  }
75
112
  function planMcpServer(name, def, existing) {
76
113
  if (existing === undefined) {
@@ -79,7 +116,16 @@ function planMcpServer(name, def, existing) {
79
116
  if (deepEqual(existing, def)) {
80
117
  return { kind: "mcp", name, action: "already-migrated", detail: "canonical definition is already identical" };
81
118
  }
82
- return { kind: "mcp", name, action: "conflict", detail: `canonical mcp/servers.yaml already has a different definition for "${name}" — resolve by hand` };
119
+ if (isSafeReclassification(existing, def)) {
120
+ return { kind: "mcp", name, action: "reclassify", detail: "same value, only its internal classification changed — safe to update", mcpDef: def };
121
+ }
122
+ return {
123
+ kind: "mcp",
124
+ name,
125
+ action: "conflict",
126
+ detail: `canonical mcp/servers.yaml already has a different definition for "${name}" — resolve by hand`,
127
+ remediation: `compare the source agent's own definition for "${name}" against \`~/.trellis/mcp/servers.yaml\` and update canonical by hand if the source's is the one to keep`,
128
+ };
83
129
  }
84
130
  /**
85
131
  * `only` restricts which kind(s) are even considered — not a post-hoc
@@ -127,7 +173,7 @@ export async function collectMigratePlan(agent, homeDir = homedir(), only) {
127
173
  export function applyMigratePlan(plan, homeDir = homedir()) {
128
174
  const canonicalRoot = join(homeDir, ".trellis");
129
175
  for (const item of plan.items) {
130
- if (item.action !== "create")
176
+ if (item.action !== "create" && item.action !== "reclassify")
131
177
  continue;
132
178
  if (item.kind === "skill" && item.sourceDir) {
133
179
  const dest = join(canonicalRoot, "skills", item.name);
@@ -18,7 +18,9 @@ import type { SyncReport } from "./sync.js";
18
18
  import type { McpSyncReport } from "./mcp.js";
19
19
  import type { SecretsAuditReport } from "./secretsAudit.js";
20
20
  import type { MemorySyncResult } from "./memory.js";
21
+ import type { DoctorReport } from "./doctor.js";
21
22
  import type { ConfirmAndInstallOptions } from "../lib/installAgent.js";
23
+ import type { VerdictItem } from "./onboardVerdict.js";
22
24
  export interface OnboardAgentSummary {
23
25
  agent: AgentId;
24
26
  present: boolean;
@@ -64,6 +66,10 @@ export interface RunOnboardOptions {
64
66
  * answer — "skip migrate for this run" (design.md D6) — distinct from
65
67
  * `source` being unresolved at all. */
66
68
  promptForMigrateCategories?: (source: OnboardAgentSummary) => Promise<MigrateKind[]>;
69
+ /** Test-only: replaces the real end-of-`--dry-run` "apply now?" picker
70
+ * (design.md D9). `true` means "apply for real", matching what
71
+ * accepting the real picker's "Yes, apply now" option means. */
72
+ promptToApply?: () => Promise<boolean>;
67
73
  /** Test-only: injected into every `confirmAndInstall` call for a
68
74
  * selected, not-yet-present agent. Never a real terminal prompt or a
69
75
  * real `npm install` in a unit test. */
@@ -90,13 +96,41 @@ export interface OnboardResult {
90
96
  migrateSkipped?: string;
91
97
  syncReport?: SyncReport;
92
98
  mcpSyncReport?: McpSyncReport;
99
+ /**
100
+ * The self-verification re-plan (design.md D2b): a dry-run re-plan of
101
+ * `sync`/`mcp sync`, run immediately after their real apply, against
102
+ * the state that apply just wrote. Absent on `--dry-run` (nothing was
103
+ * written, so nothing to verify) and on every early-refusal path.
104
+ * Present, and empty, on a real run whose write actually held.
105
+ */
106
+ syncVerification?: SyncReport;
107
+ mcpSyncVerification?: McpSyncReport;
93
108
  memorySyncResult?: MemorySyncResult;
94
109
  secretsAuditReport?: SecretsAuditReport;
110
+ /**
111
+ * A broader, whole-machine health scan — the same detectors
112
+ * `trellis doctor` itself runs, against every agent (not scoped to
113
+ * `managedAgents`, design.md D3), never with MCP handshake probing
114
+ * (design.md D4). Complementary to, and distinct from, the
115
+ * write-verification above: this cannot itself prove a write took
116
+ * effect (it never reads canonical), only that something looks
117
+ * inconsistent across agents independent of this run.
118
+ */
119
+ doctorReport?: DoctorReport;
95
120
  refusal?: string;
96
121
  /** Only set when no agent is present — the same values `--json` and
97
122
  * text output both surface, so a machine caller doesn't have to
98
123
  * hardcode them a second time. */
99
124
  installHints?: Record<AgentId, string>;
125
+ /**
126
+ * Every stage's conflicts and findings, normalized to one shape
127
+ * (trellis-onboard-closed-loop design.md D5) — the single source
128
+ * `runOnboard`'s exit code and the terminal verdict block both derive
129
+ * from, so the two can never disagree. Always present, empty on a
130
+ * fully clean run (including refusal paths, where it reflects
131
+ * whatever partial data was collected before the refusal).
132
+ */
133
+ verdict: VerdictItem[];
100
134
  }
101
135
  export declare function collectOnboardPlan(opts?: RunOnboardOptions): Promise<OnboardResult>;
102
136
  export declare function runOnboard(opts?: RunOnboardOptions): Promise<{