agent-trellis 0.5.2 → 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 (52) hide show
  1. package/dist/adapters/codex.js +4 -1
  2. package/dist/adapters/jsonMcp.js +4 -1
  3. package/dist/adapters/kiro.js +9 -1
  4. package/dist/adapters/mcpPlan.d.ts +15 -0
  5. package/dist/adapters/mcpPlan.js +51 -2
  6. package/dist/adapters/symlinkPlan.js +3 -0
  7. package/dist/cli.js +37 -2
  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/migrate.d.ts +4 -0
  15. package/dist/commands/migrate.js +28 -4
  16. package/dist/commands/onboard.d.ts +34 -0
  17. package/dist/commands/onboard.js +110 -10
  18. package/dist/commands/onboardVerdict.d.ts +55 -0
  19. package/dist/commands/onboardVerdict.js +175 -0
  20. package/dist/core/adapter.d.ts +7 -0
  21. package/dist/core/canonical.js +16 -0
  22. package/dist/core/types.d.ts +30 -4
  23. package/dist/lib/backup.js +32 -3
  24. package/dist/lib/gatewayBackend.d.ts +75 -0
  25. package/dist/lib/gatewayBackend.js +131 -0
  26. package/dist/lib/mcpConnect.d.ts +53 -0
  27. package/dist/lib/mcpConnect.js +123 -0
  28. package/dist/lib/mcpToolRegistry.d.ts +89 -0
  29. package/dist/lib/mcpToolRegistry.js +94 -0
  30. package/dist/lib/oauth/discovery.d.ts +53 -0
  31. package/dist/lib/oauth/discovery.js +95 -0
  32. package/dist/lib/oauth/flow.d.ts +47 -0
  33. package/dist/lib/oauth/flow.js +193 -0
  34. package/dist/lib/oauth/lock.d.ts +25 -0
  35. package/dist/lib/oauth/lock.js +98 -0
  36. package/dist/lib/oauth/pkce.d.ts +27 -0
  37. package/dist/lib/oauth/pkce.js +40 -0
  38. package/dist/lib/oauth/refresh.d.ts +33 -0
  39. package/dist/lib/oauth/refresh.js +93 -0
  40. package/dist/lib/oauth/store.d.ts +55 -0
  41. package/dist/lib/oauth/store.js +85 -0
  42. package/dist/pi-bridge/bundle.js +14376 -14336
  43. package/dist/pi-bridge/index.d.ts +5 -9
  44. package/dist/pi-bridge/index.js +6 -106
  45. package/dist/pi-bridge/schemaTranslate.d.ts +8 -2
  46. package/dist/pi-bridge/schemaTranslate.js +8 -4
  47. package/docs/architecture.md +88 -19
  48. package/docs/getting-started.md +120 -5
  49. package/docs/research.md +166 -0
  50. package/docs/roadmap.md +17 -0
  51. package/package.json +4 -3
  52. package/schema/servers.example.yaml +24 -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
+ }
@@ -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;
@@ -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,13 @@ 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
+ };
74
92
  }
75
93
  /**
76
94
  * True when `existing` and `def` differ ONLY in which of `env`/
@@ -101,7 +119,13 @@ function planMcpServer(name, def, existing) {
101
119
  if (isSafeReclassification(existing, def)) {
102
120
  return { kind: "mcp", name, action: "reclassify", detail: "same value, only its internal classification changed — safe to update", mcpDef: def };
103
121
  }
104
- return { kind: "mcp", name, action: "conflict", detail: `canonical mcp/servers.yaml already has a different definition for "${name}" — resolve by hand` };
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
+ };
105
129
  }
106
130
  /**
107
131
  * `only` restricts which kind(s) are even considered — not a post-hoc
@@ -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<{
@@ -29,8 +29,10 @@ 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
31
  import { applyMemorySync, collectMemorySyncResult, printMemorySyncResult } from "./memory.js";
32
+ import { collectDoctorReport, printReport as printDoctorReport, resolveKnownHostInjected } from "./doctor.js";
32
33
  import { openBackupSession } from "../lib/backup.js";
33
34
  import { confirmAndInstall } from "../lib/installAgent.js";
35
+ import { normalizeDoctorVerdict, normalizeMcpSyncVerdict, normalizeMemorySyncVerdict, normalizeMigrateVerdict, normalizeSecretsAuditVerdict, normalizeSelfVerificationVerdict, normalizeSyncVerdict, printVerdict, } from "./onboardVerdict.js";
34
36
  const PROBES = {
35
37
  "claude-code": (homeDir) => claudeCodeProbe.probe(homeDir),
36
38
  codex: (homeDir) => codexProbe.probe(homeDir),
@@ -52,6 +54,21 @@ export async function collectOnboardSummary(homeDir = homedir()) {
52
54
  function hasContent(s) {
53
55
  return s.skillCount > 0 || s.hasRealInstructions || s.mcpServerCount > 0;
54
56
  }
57
+ const PROGRESS_STAGES = ["migrate", "sync", "mcp sync", "memory sync", "secrets audit", "doctor"];
58
+ /** Transient status, not part of the report — printed to stderr so
59
+ * `trellis onboard > report.txt` still captures exactly the report and
60
+ * verdict, nothing else (design.md D8). Silent for `--json` and for any
61
+ * non-interactive stdout, where nobody's watching a terminal march
62
+ * through stages in real time. */
63
+ function shouldShowProgress(opts) {
64
+ return !opts.json && (opts.isTTY ?? process.stdout.isTTY === true);
65
+ }
66
+ function logProgress(opts, stage) {
67
+ if (!shouldShowProgress(opts))
68
+ return;
69
+ const index = PROGRESS_STAGES.indexOf(stage) + 1;
70
+ console.error(`[${index}/${PROGRESS_STAGES.length}] ${stage}`);
71
+ }
55
72
  function agentSummaryLabel(s) {
56
73
  const skills = s.skillCount > 0 ? ` (${s.skillNames.join(", ")})` : "";
57
74
  return `${s.agent} — ${s.skillCount} skill(s)${skills}, instructions: ${s.hasRealInstructions ? "yes" : "no"}, mcp: ${s.mcpServerCount}`;
@@ -243,7 +260,7 @@ export async function collectOnboardPlan(opts = {}) {
243
260
  const summary = await collectOnboardSummary(homeDir);
244
261
  const present = summary.filter((s) => s.present);
245
262
  if (present.length === 0) {
246
- return { summary, installHints: { ...INSTALL_HINTS } };
263
+ return { summary, installHints: { ...INSTALL_HINTS }, verdict: [] };
247
264
  }
248
265
  const sourceCandidates = present.filter(hasContent);
249
266
  let source;
@@ -254,6 +271,7 @@ export async function collectOnboardPlan(opts = {}) {
254
271
  return {
255
272
  summary,
256
273
  refusal: `"${opts.agent}" is not one of the present agents (${present.map((s) => s.agent).join(", ")})`,
274
+ verdict: [],
257
275
  };
258
276
  }
259
277
  source = match.agent;
@@ -269,6 +287,7 @@ export async function collectOnboardPlan(opts = {}) {
269
287
  return {
270
288
  summary,
271
289
  refusal: `multiple agents detected (${sourceCandidates.map((s) => s.agent).join(", ")}) and no terminal to prompt in — pass --agent <id>`,
290
+ verdict: [],
272
291
  };
273
292
  }
274
293
  const prompt = opts.promptForAgent ?? promptForAgentReal;
@@ -276,7 +295,7 @@ export async function collectOnboardPlan(opts = {}) {
276
295
  source = (await prompt(sourceCandidates));
277
296
  }
278
297
  catch (err) {
279
- return { summary, refusal: err instanceof Error ? err.message : String(err) };
298
+ return { summary, refusal: err instanceof Error ? err.message : String(err), verdict: [] };
280
299
  }
281
300
  sourceReason = "prompt";
282
301
  }
@@ -285,7 +304,7 @@ export async function collectOnboardPlan(opts = {}) {
285
304
  const alreadyManaged = readManagedYaml(homeDir);
286
305
  const managedResult = await resolveManagedAgents(opts, summary, alreadyManaged);
287
306
  if ("refusal" in managedResult) {
288
- return { summary, source, sourceReason, refusal: managedResult.refusal };
307
+ return { summary, source, sourceReason, refusal: managedResult.refusal, verdict: [] };
289
308
  }
290
309
  const installResults = [];
291
310
  const resolvedNew = [];
@@ -307,6 +326,7 @@ export async function collectOnboardPlan(opts = {}) {
307
326
  source,
308
327
  sourceReason,
309
328
  refusal: `"${agent}" is not installed — installing it requires a confirmation, which --json never prompts for. Inject a confirm handler or install ${agent} first.`,
329
+ verdict: [],
310
330
  };
311
331
  }
312
332
  const result = await confirmAndInstall(agent, opts.install);
@@ -323,6 +343,7 @@ export async function collectOnboardPlan(opts = {}) {
323
343
  }
324
344
  let migratePlan;
325
345
  let migrateSkipped;
346
+ logProgress(opts, "migrate");
326
347
  if (source) {
327
348
  const sourceSummary = summary.find((s) => s.agent === source);
328
349
  const categories = await resolveMigrateCategories(sourceSummary, opts);
@@ -341,14 +362,26 @@ export async function collectOnboardPlan(opts = {}) {
341
362
  // Neither collectSyncReport nor collectMcpSyncReport finalizes a
342
363
  // session they were handed; only this caller does, once, after both.
343
364
  const backupSession = opts.dryRun ? undefined : openBackupSession(homeDir, "onboard");
365
+ logProgress(opts, "sync");
344
366
  const syncReport = await collectSyncReport({ homeDir, dryRun: opts.dryRun, managedAgents, backupSession });
367
+ // The self-verification re-plan (design.md D2b): immediately after a
368
+ // real apply, re-run the exact same plan computation, dry-run, against
369
+ // what was just written. A remaining "create"/"conflict" item means
370
+ // the write did not hold — the mechanism that actually closes the
371
+ // loop, since doctor (below) never reads canonical and cannot catch
372
+ // this even in principle. Skipped entirely on --dry-run: nothing was
373
+ // written, so there is nothing to verify.
374
+ const syncVerification = opts.dryRun ? undefined : await collectSyncReport({ homeDir, dryRun: true, managedAgents });
375
+ logProgress(opts, "mcp sync");
345
376
  const mcpSyncReport = await collectMcpSyncReport({ homeDir, dryRun: opts.dryRun, managedAgents, backupSession });
377
+ const mcpSyncVerification = opts.dryRun ? undefined : await collectMcpSyncReport({ homeDir, dryRun: true, managedAgents });
346
378
  backupSession?.finalize();
347
379
  // Independent of `managedAgents` — the shared memory server is not
348
380
  // per-agent (design.md D4, trellis-onboard-mcp-memory), unlike
349
381
  // sync/mcp-sync above. Runs unconditionally once reached; an
350
382
  // unconfigured `memory` server is a legitimate "nothing to do yet"
351
383
  // state (design.md D5), not gated on any prior step here.
384
+ logProgress(opts, "memory sync");
352
385
  const memorySyncResult = collectMemorySyncResult(homeDir);
353
386
  if (!opts.dryRun) {
354
387
  applyMemorySync(memorySyncResult);
@@ -356,7 +389,29 @@ export async function collectOnboardPlan(opts = {}) {
356
389
  // Read-only, no dryRun concept — same report either way, run last since
357
390
  // it audits the config mcp sync just wrote (or, on --dry-run, whatever
358
391
  // was already there before this run).
392
+ logProgress(opts, "secrets audit");
359
393
  const secretsAuditReport = await collectSecretsAuditReport({ homeDir, managedAgents });
394
+ // The broader health scan (design.md D2/D3/D4) — last, since it's the
395
+ // only stage whose job is to observe the result of every other one.
396
+ // Never --probe-mcp: the worst possible moment to start spawning every
397
+ // configured MCP server is a user's first-ever run of this command.
398
+ // Scoped to every agent, not just managedAgents — filtering here would
399
+ // hide exactly the finding onboarding is most likely to create (a
400
+ // newly-relevant agent drifting, or colliding with an unmanaged one);
401
+ // normalizeDoctorVerdict is what turns "unmanaged" into a warning
402
+ // rather than hiding it outright.
403
+ logProgress(opts, "doctor");
404
+ const doctorReport = await collectDoctorReport(homeDir, resolveKnownHostInjected({ homeDir }));
405
+ const verdict = [
406
+ ...normalizeMigrateVerdict(migratePlan),
407
+ ...normalizeSyncVerdict(syncReport),
408
+ ...normalizeSelfVerificationVerdict("sync", syncVerification?.reports),
409
+ ...normalizeMcpSyncVerdict(mcpSyncReport),
410
+ ...normalizeSelfVerificationVerdict("mcp sync", mcpSyncVerification?.reports),
411
+ ...normalizeMemorySyncVerdict(memorySyncResult),
412
+ ...normalizeSecretsAuditVerdict(secretsAuditReport),
413
+ ...normalizeDoctorVerdict(doctorReport, managedAgents),
414
+ ];
360
415
  return {
361
416
  summary,
362
417
  source,
@@ -367,27 +422,65 @@ export async function collectOnboardPlan(opts = {}) {
367
422
  migrateSkipped,
368
423
  syncReport,
369
424
  mcpSyncReport,
425
+ syncVerification,
426
+ mcpSyncVerification,
370
427
  memorySyncResult,
371
428
  secretsAuditReport,
429
+ doctorReport,
430
+ verdict,
372
431
  };
373
432
  }
433
+ /**
434
+ * Declining is the default (design.md D9): "No" is the first, highlighted
435
+ * option, so Enter — the reflex keystroke — declines. Only ever offered
436
+ * on a real, raw-mode-capable terminal (`canUseInteractivePicker`, the
437
+ * same gate every other onboard prompt already uses); `--json` and
438
+ * non-interactive runs never reach this at all.
439
+ */
440
+ async function offerToApply(opts) {
441
+ if (opts.promptToApply)
442
+ return opts.promptToApply();
443
+ if (!canUseInteractivePicker())
444
+ return false;
445
+ console.log("");
446
+ const choice = await runSingleSelectPicker(["No, don't apply", "Yes, apply now"]);
447
+ return choice === 1;
448
+ }
374
449
  export async function runOnboard(opts = {}) {
450
+ const homeDir = opts.homeDir ?? homedir();
375
451
  const result = await collectOnboardPlan(opts);
376
452
  if (opts.json) {
377
453
  console.log(JSON.stringify(result, null, 2));
378
454
  }
379
455
  else {
380
456
  printResult(result, opts.dryRun ?? false);
457
+ // Always last — including on a fully clean run and on a refusal —
458
+ // so the terminal state of the run is never a green line from
459
+ // whichever stage happened to print last (spec: "Every run
460
+ // terminates in a verdict that matches its exit code"). A refusal
461
+ // never populates `result.verdict` (every early-return path sets it
462
+ // to `[]`), so it has to be folded in here explicitly — otherwise
463
+ // the verdict would print "nothing needs attention, exit code: 0"
464
+ // directly underneath a real refusal that returns exit code 1: the
465
+ // exact contradiction this whole mechanism exists to prevent, just
466
+ // reached from a different direction.
467
+ const verdictForDisplay = result.refusal ? [...result.verdict, { stage: "onboard", severity: "blocked", message: result.refusal }] : result.verdict;
468
+ printVerdict(verdictForDisplay, homeDir);
381
469
  }
382
470
  if (result.refusal)
383
471
  return { exitCode: 1 };
384
- const memorySyncConflict = result.memorySyncResult?.configured === true && result.memorySyncResult.plan.items.some((i) => i.action === "conflict");
385
- const hasConflict = (result.migratePlan?.items.some((i) => i.action === "conflict") ?? false) ||
386
- (result.syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false) ||
387
- (result.mcpSyncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false) ||
388
- memorySyncConflict ||
389
- (result.secretsAuditReport?.findings.length ?? 0) > 0;
390
- return { exitCode: hasConflict ? 1 : 0 };
472
+ if (opts.dryRun && !opts.json) {
473
+ const accepted = await offerToApply(opts);
474
+ if (accepted) {
475
+ // Re-plan and apply for real rather than replaying this exact
476
+ // plan — the user may have changed state while reading the
477
+ // preview, and re-planning against current state is cheap next to
478
+ // applying a plan that's gone stale (design.md D9).
479
+ return runOnboard({ ...opts, dryRun: false });
480
+ }
481
+ }
482
+ const hasBlocked = result.verdict.some((item) => item.severity === "blocked");
483
+ return { exitCode: hasBlocked ? 1 : 0 };
391
484
  }
392
485
  function printResult(result, dryRun) {
393
486
  if (dryRun)
@@ -452,4 +545,11 @@ function printResult(result, dryRun) {
452
545
  console.log("\nsecrets audit");
453
546
  printSecretsAuditReport(result.secretsAuditReport);
454
547
  }
548
+ if (result.doctorReport) {
549
+ // Distinct from the self-verification re-plan above: this is a
550
+ // broader health scan across every agent, not proof that this run's
551
+ // own writes took effect (design.md D2b).
552
+ console.log("\nhealth scan (trellis doctor)");
553
+ printDoctorReport(result.doctorReport);
554
+ }
455
555
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Normalizes every chained stage's own report shape into one flat list
3
+ * (trellis-onboard-closed-loop design.md D5) — the single source both
4
+ * `runOnboard`'s exit code and the terminal verdict block derive from,
5
+ * so the two can never disagree the way the five-clause inline boolean
6
+ * this replaces once could.
7
+ *
8
+ * Each normalizer reads its stage's existing report type; none of them
9
+ * re-implement conflict detection — they translate an already-computed
10
+ * verdict (a plan item's `action`, a finding being present at all) into
11
+ * one shared shape. A stage whose report is absent (never ran, e.g. no
12
+ * migration source) contributes nothing, not an error.
13
+ */
14
+ import type { AdapterPlanItem } from "../core/adapter.js";
15
+ import type { AgentId } from "../core/types.js";
16
+ import type { MigratePlan } from "./migrate.js";
17
+ import type { SyncReport } from "./sync.js";
18
+ import type { McpSyncReport } from "./mcp.js";
19
+ import type { MemorySyncResult } from "./memory.js";
20
+ import type { SecretsAuditReport } from "./secretsAudit.js";
21
+ import type { DoctorReport } from "./doctor.js";
22
+ export type VerdictSeverity = "blocked" | "warning";
23
+ export interface VerdictItem {
24
+ stage: string;
25
+ severity: VerdictSeverity;
26
+ message: string;
27
+ remediation?: string;
28
+ agent?: AgentId;
29
+ }
30
+ export declare function normalizeMigrateVerdict(plan: MigratePlan | undefined): VerdictItem[];
31
+ export declare function normalizeSyncVerdict(report: SyncReport | undefined): VerdictItem[];
32
+ export declare function normalizeMcpSyncVerdict(report: McpSyncReport | undefined): VerdictItem[];
33
+ /**
34
+ * The self-verification re-plan (design.md D2b): a re-plan run
35
+ * immediately after a real apply, against the state that apply just
36
+ * wrote. Any remaining `"create"`/`"conflict"` item here means the
37
+ * write itself did not hold — distinct from, and always `blocked`
38
+ * regardless of what the original apply reported, because it directly
39
+ * contradicts this run's own claim of having written it.
40
+ */
41
+ export declare function normalizeSelfVerificationVerdict(stage: "sync" | "mcp sync", reports: readonly {
42
+ agent: AgentId;
43
+ items: readonly AdapterPlanItem[];
44
+ }[] | undefined): VerdictItem[];
45
+ export declare function normalizeMemorySyncVerdict(result: MemorySyncResult | undefined): VerdictItem[];
46
+ export declare function normalizeSecretsAuditVerdict(report: SecretsAuditReport | undefined): VerdictItem[];
47
+ /**
48
+ * Always the last thing `printResult` prints, on every run including a
49
+ * fully clean one — the exact regression this change exists to fix: a
50
+ * run with a real early-stage conflict must not end on a later stage's
51
+ * unrelated success line (trellis-onboard-closed-loop spec: "Every run
52
+ * terminates in a verdict that matches its exit code").
53
+ */
54
+ export declare function printVerdict(verdict: readonly VerdictItem[], homeDir: string): void;
55
+ export declare function normalizeDoctorVerdict(report: DoctorReport | undefined, managedAgents: readonly AgentId[]): VerdictItem[];