agent-trellis 0.5.2 → 0.6.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/codex.js +4 -1
- package/dist/adapters/jsonMcp.js +4 -1
- package/dist/adapters/kiro.js +9 -1
- package/dist/adapters/mcpPlan.d.ts +31 -0
- package/dist/adapters/mcpPlan.js +67 -3
- package/dist/adapters/symlinkPlan.js +3 -0
- package/dist/cli.js +65 -3
- package/dist/commands/doctor.d.ts +13 -3
- package/dist/commands/doctor.js +20 -10
- package/dist/commands/mcpAuth.d.ts +28 -0
- package/dist/commands/mcpAuth.js +99 -0
- package/dist/commands/mcpGateway.d.ts +53 -0
- package/dist/commands/mcpGateway.js +148 -0
- package/dist/commands/memory.d.ts +15 -0
- package/dist/commands/memory.js +19 -1
- package/dist/commands/migrate.d.ts +4 -0
- package/dist/commands/migrate.js +46 -4
- package/dist/commands/onboard.d.ts +67 -0
- package/dist/commands/onboard.js +283 -14
- package/dist/commands/onboardVerdict.d.ts +55 -0
- package/dist/commands/onboardVerdict.js +175 -0
- package/dist/core/adapter.d.ts +7 -0
- package/dist/core/canonical.d.ts +27 -1
- package/dist/core/canonical.js +62 -0
- package/dist/core/types.d.ts +30 -4
- package/dist/lib/backup.js +32 -3
- package/dist/lib/envVarNames.d.ts +23 -6
- package/dist/lib/envVarNames.js +26 -7
- package/dist/lib/gatewayBackend.d.ts +75 -0
- package/dist/lib/gatewayBackend.js +131 -0
- package/dist/lib/mcpConnect.d.ts +53 -0
- package/dist/lib/mcpConnect.js +123 -0
- package/dist/lib/mcpToolRegistry.d.ts +89 -0
- package/dist/lib/mcpToolRegistry.js +94 -0
- package/dist/lib/oauth/discovery.d.ts +53 -0
- package/dist/lib/oauth/discovery.js +95 -0
- package/dist/lib/oauth/flow.d.ts +47 -0
- package/dist/lib/oauth/flow.js +193 -0
- package/dist/lib/oauth/lock.d.ts +25 -0
- package/dist/lib/oauth/lock.js +98 -0
- package/dist/lib/oauth/pkce.d.ts +27 -0
- package/dist/lib/oauth/pkce.js +40 -0
- package/dist/lib/oauth/refresh.d.ts +33 -0
- package/dist/lib/oauth/refresh.js +93 -0
- package/dist/lib/oauth/store.d.ts +55 -0
- package/dist/lib/oauth/store.js +85 -0
- package/dist/pi-bridge/bundle.js +14376 -14336
- package/dist/pi-bridge/index.d.ts +5 -9
- package/dist/pi-bridge/index.js +6 -106
- package/dist/pi-bridge/schemaTranslate.d.ts +8 -2
- package/dist/pi-bridge/schemaTranslate.js +8 -4
- package/docs/architecture.md +108 -25
- package/docs/getting-started.md +156 -7
- package/docs/research.md +166 -0
- package/docs/roadmap.md +18 -0
- package/package.json +4 -3
- package/schema/servers.example.yaml +33 -11
package/dist/commands/onboard.js
CHANGED
|
@@ -22,15 +22,17 @@ import * as codexProbe from "../probes/codex.js";
|
|
|
22
22
|
import * as kiroProbe from "../probes/kiro.js";
|
|
23
23
|
import * as piProbe from "../probes/pi.js";
|
|
24
24
|
import { ALL_AGENTS } from "../core/types.js";
|
|
25
|
-
import { loadCanonicalSource } from "../core/canonical.js";
|
|
25
|
+
import { loadCanonicalSource, removeServerYaml, upsertServerYaml, writeMcpModeYaml } from "../core/canonical.js";
|
|
26
26
|
import { INSTALL_HINTS, collectInitReport } from "./init.js";
|
|
27
27
|
import { applyMigratePlan, collectMigratePlan, printPlan as printMigratePlan } from "./migrate.js";
|
|
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
|
+
import { applyMemorySync, collectMemorySyncResult, printMemorySyncResult, DEFAULT_MEMORY_SERVER_DEF, MEMORY_SERVER_NAME } 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}`;
|
|
@@ -213,6 +230,120 @@ function parseManagedSelection(raw, candidates) {
|
|
|
213
230
|
}
|
|
214
231
|
return { agents: [...new Set(agents)] };
|
|
215
232
|
}
|
|
233
|
+
/** Comma-separated agent ids only — unlike `parseManagedSelection`, no
|
|
234
|
+
* numbered-index shorthand, since `--gateway-agents` never has a
|
|
235
|
+
* numbered list on screen to refer back to (it's flag-only, never
|
|
236
|
+
* prompted; design.md D1/D4). */
|
|
237
|
+
function parseAgentIdList(raw) {
|
|
238
|
+
const tokens = raw.split(",").map((t) => t.trim()).filter((t) => t.length > 0);
|
|
239
|
+
const agents = [];
|
|
240
|
+
for (const token of tokens) {
|
|
241
|
+
if (!ALL_AGENTS.includes(token)) {
|
|
242
|
+
return { error: `"${token}" is not a valid agent id — use one of ${ALL_AGENTS.join(", ")}` };
|
|
243
|
+
}
|
|
244
|
+
agents.push(token);
|
|
245
|
+
}
|
|
246
|
+
return [...new Set(agents)];
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Validates `--mcp-mode`/`--hub-url`/`--gateway-agents` together, before
|
|
250
|
+
* any other work (trellis-onboard-mcp-mode design.md D4) — a bad
|
|
251
|
+
* combination refuses the whole run rather than being discovered
|
|
252
|
+
* partway through. `mode` is only set when `mcpMode` was actually
|
|
253
|
+
* given; omitted resolves to `{}`, meaning "no change requested," not
|
|
254
|
+
* an error (design.md D1) — distinct from every other field on
|
|
255
|
+
* `RunOnboardOptions`, where omission usually requires a prompt.
|
|
256
|
+
*/
|
|
257
|
+
function validateMcpModeOption(opts) {
|
|
258
|
+
const { mcpMode, hubUrl, gatewayAgents } = opts;
|
|
259
|
+
if (mcpMode === undefined) {
|
|
260
|
+
if (hubUrl !== undefined)
|
|
261
|
+
return { error: "--hub-url was given without --mcp-mode hub" };
|
|
262
|
+
if (gatewayAgents !== undefined)
|
|
263
|
+
return { error: "--gateway-agents was given without --mcp-mode gateway" };
|
|
264
|
+
return {};
|
|
265
|
+
}
|
|
266
|
+
if (mcpMode !== "direct" && mcpMode !== "hub" && mcpMode !== "gateway") {
|
|
267
|
+
return { error: `--mcp-mode must be one of direct, hub, gateway (got "${mcpMode}")` };
|
|
268
|
+
}
|
|
269
|
+
if (mcpMode !== "hub" && hubUrl !== undefined) {
|
|
270
|
+
return { error: "--hub-url was given without --mcp-mode hub" };
|
|
271
|
+
}
|
|
272
|
+
if (mcpMode !== "gateway" && gatewayAgents !== undefined) {
|
|
273
|
+
return { error: "--gateway-agents was given without --mcp-mode gateway" };
|
|
274
|
+
}
|
|
275
|
+
if (mcpMode === "direct")
|
|
276
|
+
return { mode: { kind: "direct" } };
|
|
277
|
+
if (mcpMode === "hub") {
|
|
278
|
+
if (!hubUrl)
|
|
279
|
+
return { error: "--mcp-mode hub requires --hub-url <url>" };
|
|
280
|
+
return { mode: { kind: "hub", url: hubUrl } };
|
|
281
|
+
}
|
|
282
|
+
// mcpMode === "gateway"
|
|
283
|
+
if (gatewayAgents === undefined)
|
|
284
|
+
return { mode: { kind: "gateway" } };
|
|
285
|
+
const agents = parseAgentIdList(gatewayAgents);
|
|
286
|
+
if ("error" in agents)
|
|
287
|
+
return agents;
|
|
288
|
+
return { mode: { kind: "gateway", agents } };
|
|
289
|
+
}
|
|
290
|
+
/** Same "omitted means no change requested" posture as
|
|
291
|
+
* `validateMcpModeOption` (design.md D12). */
|
|
292
|
+
function validateMemoryOption(memory) {
|
|
293
|
+
if (memory === undefined)
|
|
294
|
+
return {};
|
|
295
|
+
if (memory !== "on" && memory !== "off")
|
|
296
|
+
return { error: `--memory must be "on" or "off" (got "${memory}")` };
|
|
297
|
+
return { value: memory };
|
|
298
|
+
}
|
|
299
|
+
function mcpModeValueOf(mcp) {
|
|
300
|
+
if (mcp.gateway?.enabled)
|
|
301
|
+
return "gateway";
|
|
302
|
+
if (mcp.hub)
|
|
303
|
+
return "hub";
|
|
304
|
+
return "direct";
|
|
305
|
+
}
|
|
306
|
+
function resolveMcpModeChange(mode, currentValue) {
|
|
307
|
+
if (!mode)
|
|
308
|
+
return { current: currentValue, previous: currentValue, changed: false };
|
|
309
|
+
return { current: mode.kind, previous: currentValue, changed: mode.kind !== currentValue, write: mode };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* `value === "on"` against an already-host-injected `"memory"` refuses
|
|
313
|
+
* outright rather than writing a definition `mcp sync` would refuse to
|
|
314
|
+
* propagate to any agent a moment later (design.md D10) — the same fact
|
|
315
|
+
* `schema/servers.example.yaml`'s own comment already explains to a
|
|
316
|
+
* human reading it by hand.
|
|
317
|
+
*/
|
|
318
|
+
function resolveMemoryToggle(value, configuredBefore, knownHostInjected) {
|
|
319
|
+
const previous = configuredBefore ? "on" : "off";
|
|
320
|
+
if (value === undefined)
|
|
321
|
+
return { current: previous, previous, changed: false };
|
|
322
|
+
if (value === "on") {
|
|
323
|
+
if (configuredBefore)
|
|
324
|
+
return { current: "on", previous, changed: false };
|
|
325
|
+
if (knownHostInjected.includes(MEMORY_SERVER_NAME)) {
|
|
326
|
+
return {
|
|
327
|
+
refusal: `refusing to enable the memory server: "${MEMORY_SERVER_NAME}" is already listed in known_host_injected — a host on this machine is presumed to already inject its own memory connector under that name, so a static definition here would only collide the next time \`mcp sync\` runs`,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
return { current: "on", previous, changed: true };
|
|
331
|
+
}
|
|
332
|
+
// value === "off"
|
|
333
|
+
if (!configuredBefore)
|
|
334
|
+
return { current: "off", previous, changed: false };
|
|
335
|
+
return { current: "off", previous, changed: true };
|
|
336
|
+
}
|
|
337
|
+
function mcpModeStatusLine(mode) {
|
|
338
|
+
if (!mode.changed)
|
|
339
|
+
return `mcp mode: ${mode.current} (unchanged) — pass --mcp-mode direct|hub|gateway to change`;
|
|
340
|
+
return `mcp mode: ${mode.current} (changed from ${mode.previous})`;
|
|
341
|
+
}
|
|
342
|
+
function memoryStatusLine(memory) {
|
|
343
|
+
if (!memory.changed)
|
|
344
|
+
return `memory: ${memory.current} (unchanged) — pass --memory ${memory.current === "on" ? "off" : "on"} to change`;
|
|
345
|
+
return `memory: ${memory.current} (changed from ${memory.previous})`;
|
|
346
|
+
}
|
|
216
347
|
async function resolveManagedAgents(opts, summary, alreadyManaged) {
|
|
217
348
|
if (opts.manage !== undefined) {
|
|
218
349
|
const parsed = parseManagedSelection(opts.manage, summary);
|
|
@@ -243,7 +374,21 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
243
374
|
const summary = await collectOnboardSummary(homeDir);
|
|
244
375
|
const present = summary.filter((s) => s.present);
|
|
245
376
|
if (present.length === 0) {
|
|
246
|
-
return { summary, installHints: { ...INSTALL_HINTS } };
|
|
377
|
+
return { summary, installHints: { ...INSTALL_HINTS }, verdict: [] };
|
|
378
|
+
}
|
|
379
|
+
// Validated up front, before any of this run's own writes (managed.yaml
|
|
380
|
+
// included) — a bad `--mcp-mode`/`--hub-url`/`--gateway-agents`/
|
|
381
|
+
// `--memory` combination refuses the whole run cleanly rather than
|
|
382
|
+
// being discovered partway through (design.md D4). The parsed results
|
|
383
|
+
// are reused later, once managed-set resolution has run (design.md
|
|
384
|
+
// D5/D11).
|
|
385
|
+
const mcpModeValidation = validateMcpModeOption(opts);
|
|
386
|
+
if ("error" in mcpModeValidation) {
|
|
387
|
+
return { summary, refusal: mcpModeValidation.error, verdict: [] };
|
|
388
|
+
}
|
|
389
|
+
const memoryValidation = validateMemoryOption(opts.memory);
|
|
390
|
+
if ("error" in memoryValidation) {
|
|
391
|
+
return { summary, refusal: memoryValidation.error, verdict: [] };
|
|
247
392
|
}
|
|
248
393
|
const sourceCandidates = present.filter(hasContent);
|
|
249
394
|
let source;
|
|
@@ -254,6 +399,7 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
254
399
|
return {
|
|
255
400
|
summary,
|
|
256
401
|
refusal: `"${opts.agent}" is not one of the present agents (${present.map((s) => s.agent).join(", ")})`,
|
|
402
|
+
verdict: [],
|
|
257
403
|
};
|
|
258
404
|
}
|
|
259
405
|
source = match.agent;
|
|
@@ -269,6 +415,7 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
269
415
|
return {
|
|
270
416
|
summary,
|
|
271
417
|
refusal: `multiple agents detected (${sourceCandidates.map((s) => s.agent).join(", ")}) and no terminal to prompt in — pass --agent <id>`,
|
|
418
|
+
verdict: [],
|
|
272
419
|
};
|
|
273
420
|
}
|
|
274
421
|
const prompt = opts.promptForAgent ?? promptForAgentReal;
|
|
@@ -276,7 +423,7 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
276
423
|
source = (await prompt(sourceCandidates));
|
|
277
424
|
}
|
|
278
425
|
catch (err) {
|
|
279
|
-
return { summary, refusal: err instanceof Error ? err.message : String(err) };
|
|
426
|
+
return { summary, refusal: err instanceof Error ? err.message : String(err), verdict: [] };
|
|
280
427
|
}
|
|
281
428
|
sourceReason = "prompt";
|
|
282
429
|
}
|
|
@@ -285,7 +432,7 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
285
432
|
const alreadyManaged = readManagedYaml(homeDir);
|
|
286
433
|
const managedResult = await resolveManagedAgents(opts, summary, alreadyManaged);
|
|
287
434
|
if ("refusal" in managedResult) {
|
|
288
|
-
return { summary, source, sourceReason, refusal: managedResult.refusal };
|
|
435
|
+
return { summary, source, sourceReason, refusal: managedResult.refusal, verdict: [] };
|
|
289
436
|
}
|
|
290
437
|
const installResults = [];
|
|
291
438
|
const resolvedNew = [];
|
|
@@ -307,6 +454,7 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
307
454
|
source,
|
|
308
455
|
sourceReason,
|
|
309
456
|
refusal: `"${agent}" is not installed — installing it requires a confirmation, which --json never prompts for. Inject a confirm handler or install ${agent} first.`,
|
|
457
|
+
verdict: [],
|
|
310
458
|
};
|
|
311
459
|
}
|
|
312
460
|
const result = await confirmAndInstall(agent, opts.install);
|
|
@@ -321,8 +469,37 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
321
469
|
if (!opts.dryRun) {
|
|
322
470
|
writeManagedYaml(homeDir, managedAgents);
|
|
323
471
|
}
|
|
472
|
+
// MCP mode + memory-server resolution (design.md D5/D11) — grouped
|
|
473
|
+
// with managed-set above as "how is this machine configured"
|
|
474
|
+
// decisions resolved once per run, ahead of migrate/sync/mcp-sync/
|
|
475
|
+
// memory-sync below, which act on the result. Both flags were already
|
|
476
|
+
// validated before managed-set was even touched; omitting either
|
|
477
|
+
// resolves to "no change requested" here too (design.md D1/D12) — no
|
|
478
|
+
// writer call at all, on either a first run or a later one.
|
|
479
|
+
const canonicalForModeAndMemory = loadCanonicalSource(homeDir);
|
|
480
|
+
const mcpModeResolution = resolveMcpModeChange(mcpModeValidation.mode, mcpModeValueOf(canonicalForModeAndMemory.mcp));
|
|
481
|
+
const mcpMode = { current: mcpModeResolution.current, previous: mcpModeResolution.previous, changed: mcpModeResolution.changed };
|
|
482
|
+
const serversYamlPath = join(homeDir, ".trellis", "mcp", "servers.yaml");
|
|
483
|
+
if (mcpModeResolution.write && !opts.dryRun) {
|
|
484
|
+
writeMcpModeYaml(serversYamlPath, mcpModeResolution.write);
|
|
485
|
+
}
|
|
486
|
+
const memoryConfiguredBefore = canonicalForModeAndMemory.mcp.servers[MEMORY_SERVER_NAME] !== undefined;
|
|
487
|
+
const memoryResolution = resolveMemoryToggle(memoryValidation.value, memoryConfiguredBefore, canonicalForModeAndMemory.mcp.knownHostInjected);
|
|
488
|
+
if ("refusal" in memoryResolution) {
|
|
489
|
+
return { summary, source, sourceReason, managedAgents, mcpMode, refusal: memoryResolution.refusal, verdict: [] };
|
|
490
|
+
}
|
|
491
|
+
const memory = { current: memoryResolution.current, previous: memoryResolution.previous, changed: memoryResolution.changed };
|
|
492
|
+
if (memory.changed && !opts.dryRun) {
|
|
493
|
+
if (memory.current === "on") {
|
|
494
|
+
upsertServerYaml(serversYamlPath, MEMORY_SERVER_NAME, DEFAULT_MEMORY_SERVER_DEF);
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
removeServerYaml(serversYamlPath, MEMORY_SERVER_NAME);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
324
500
|
let migratePlan;
|
|
325
501
|
let migrateSkipped;
|
|
502
|
+
logProgress(opts, "migrate");
|
|
326
503
|
if (source) {
|
|
327
504
|
const sourceSummary = summary.find((s) => s.agent === source);
|
|
328
505
|
const categories = await resolveMigrateCategories(sourceSummary, opts);
|
|
@@ -341,14 +518,26 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
341
518
|
// Neither collectSyncReport nor collectMcpSyncReport finalizes a
|
|
342
519
|
// session they were handed; only this caller does, once, after both.
|
|
343
520
|
const backupSession = opts.dryRun ? undefined : openBackupSession(homeDir, "onboard");
|
|
521
|
+
logProgress(opts, "sync");
|
|
344
522
|
const syncReport = await collectSyncReport({ homeDir, dryRun: opts.dryRun, managedAgents, backupSession });
|
|
523
|
+
// The self-verification re-plan (design.md D2b): immediately after a
|
|
524
|
+
// real apply, re-run the exact same plan computation, dry-run, against
|
|
525
|
+
// what was just written. A remaining "create"/"conflict" item means
|
|
526
|
+
// the write did not hold — the mechanism that actually closes the
|
|
527
|
+
// loop, since doctor (below) never reads canonical and cannot catch
|
|
528
|
+
// this even in principle. Skipped entirely on --dry-run: nothing was
|
|
529
|
+
// written, so there is nothing to verify.
|
|
530
|
+
const syncVerification = opts.dryRun ? undefined : await collectSyncReport({ homeDir, dryRun: true, managedAgents });
|
|
531
|
+
logProgress(opts, "mcp sync");
|
|
345
532
|
const mcpSyncReport = await collectMcpSyncReport({ homeDir, dryRun: opts.dryRun, managedAgents, backupSession });
|
|
533
|
+
const mcpSyncVerification = opts.dryRun ? undefined : await collectMcpSyncReport({ homeDir, dryRun: true, managedAgents });
|
|
346
534
|
backupSession?.finalize();
|
|
347
535
|
// Independent of `managedAgents` — the shared memory server is not
|
|
348
536
|
// per-agent (design.md D4, trellis-onboard-mcp-memory), unlike
|
|
349
537
|
// sync/mcp-sync above. Runs unconditionally once reached; an
|
|
350
538
|
// unconfigured `memory` server is a legitimate "nothing to do yet"
|
|
351
539
|
// state (design.md D5), not gated on any prior step here.
|
|
540
|
+
logProgress(opts, "memory sync");
|
|
352
541
|
const memorySyncResult = collectMemorySyncResult(homeDir);
|
|
353
542
|
if (!opts.dryRun) {
|
|
354
543
|
applyMemorySync(memorySyncResult);
|
|
@@ -356,7 +545,29 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
356
545
|
// Read-only, no dryRun concept — same report either way, run last since
|
|
357
546
|
// it audits the config mcp sync just wrote (or, on --dry-run, whatever
|
|
358
547
|
// was already there before this run).
|
|
548
|
+
logProgress(opts, "secrets audit");
|
|
359
549
|
const secretsAuditReport = await collectSecretsAuditReport({ homeDir, managedAgents });
|
|
550
|
+
// The broader health scan (design.md D2/D3/D4) — last, since it's the
|
|
551
|
+
// only stage whose job is to observe the result of every other one.
|
|
552
|
+
// Never --probe-mcp: the worst possible moment to start spawning every
|
|
553
|
+
// configured MCP server is a user's first-ever run of this command.
|
|
554
|
+
// Scoped to every agent, not just managedAgents — filtering here would
|
|
555
|
+
// hide exactly the finding onboarding is most likely to create (a
|
|
556
|
+
// newly-relevant agent drifting, or colliding with an unmanaged one);
|
|
557
|
+
// normalizeDoctorVerdict is what turns "unmanaged" into a warning
|
|
558
|
+
// rather than hiding it outright.
|
|
559
|
+
logProgress(opts, "doctor");
|
|
560
|
+
const doctorReport = await collectDoctorReport(homeDir, resolveKnownHostInjected({ homeDir }));
|
|
561
|
+
const verdict = [
|
|
562
|
+
...normalizeMigrateVerdict(migratePlan),
|
|
563
|
+
...normalizeSyncVerdict(syncReport),
|
|
564
|
+
...normalizeSelfVerificationVerdict("sync", syncVerification?.reports),
|
|
565
|
+
...normalizeMcpSyncVerdict(mcpSyncReport),
|
|
566
|
+
...normalizeSelfVerificationVerdict("mcp sync", mcpSyncVerification?.reports),
|
|
567
|
+
...normalizeMemorySyncVerdict(memorySyncResult),
|
|
568
|
+
...normalizeSecretsAuditVerdict(secretsAuditReport),
|
|
569
|
+
...normalizeDoctorVerdict(doctorReport, managedAgents),
|
|
570
|
+
];
|
|
360
571
|
return {
|
|
361
572
|
summary,
|
|
362
573
|
source,
|
|
@@ -367,29 +578,69 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
367
578
|
migrateSkipped,
|
|
368
579
|
syncReport,
|
|
369
580
|
mcpSyncReport,
|
|
581
|
+
syncVerification,
|
|
582
|
+
mcpSyncVerification,
|
|
370
583
|
memorySyncResult,
|
|
371
584
|
secretsAuditReport,
|
|
585
|
+
doctorReport,
|
|
586
|
+
mcpMode,
|
|
587
|
+
memory,
|
|
588
|
+
verdict,
|
|
372
589
|
};
|
|
373
590
|
}
|
|
591
|
+
/**
|
|
592
|
+
* Declining is the default (design.md D9): "No" is the first, highlighted
|
|
593
|
+
* option, so Enter — the reflex keystroke — declines. Only ever offered
|
|
594
|
+
* on a real, raw-mode-capable terminal (`canUseInteractivePicker`, the
|
|
595
|
+
* same gate every other onboard prompt already uses); `--json` and
|
|
596
|
+
* non-interactive runs never reach this at all.
|
|
597
|
+
*/
|
|
598
|
+
async function offerToApply(opts) {
|
|
599
|
+
if (opts.promptToApply)
|
|
600
|
+
return opts.promptToApply();
|
|
601
|
+
if (!canUseInteractivePicker())
|
|
602
|
+
return false;
|
|
603
|
+
console.log("");
|
|
604
|
+
const choice = await runSingleSelectPicker(["No, don't apply", "Yes, apply now"]);
|
|
605
|
+
return choice === 1;
|
|
606
|
+
}
|
|
374
607
|
export async function runOnboard(opts = {}) {
|
|
608
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
375
609
|
const result = await collectOnboardPlan(opts);
|
|
376
610
|
if (opts.json) {
|
|
377
611
|
console.log(JSON.stringify(result, null, 2));
|
|
378
612
|
}
|
|
379
613
|
else {
|
|
380
|
-
printResult(result, opts.dryRun ?? false);
|
|
614
|
+
printResult(result, opts.dryRun ?? false, shouldShowProgress(opts));
|
|
615
|
+
// Always last — including on a fully clean run and on a refusal —
|
|
616
|
+
// so the terminal state of the run is never a green line from
|
|
617
|
+
// whichever stage happened to print last (spec: "Every run
|
|
618
|
+
// terminates in a verdict that matches its exit code"). A refusal
|
|
619
|
+
// never populates `result.verdict` (every early-return path sets it
|
|
620
|
+
// to `[]`), so it has to be folded in here explicitly — otherwise
|
|
621
|
+
// the verdict would print "nothing needs attention, exit code: 0"
|
|
622
|
+
// directly underneath a real refusal that returns exit code 1: the
|
|
623
|
+
// exact contradiction this whole mechanism exists to prevent, just
|
|
624
|
+
// reached from a different direction.
|
|
625
|
+
const verdictForDisplay = result.refusal ? [...result.verdict, { stage: "onboard", severity: "blocked", message: result.refusal }] : result.verdict;
|
|
626
|
+
printVerdict(verdictForDisplay, homeDir);
|
|
381
627
|
}
|
|
382
628
|
if (result.refusal)
|
|
383
629
|
return { exitCode: 1 };
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
630
|
+
if (opts.dryRun && !opts.json) {
|
|
631
|
+
const accepted = await offerToApply(opts);
|
|
632
|
+
if (accepted) {
|
|
633
|
+
// Re-plan and apply for real rather than replaying this exact
|
|
634
|
+
// plan — the user may have changed state while reading the
|
|
635
|
+
// preview, and re-planning against current state is cheap next to
|
|
636
|
+
// applying a plan that's gone stale (design.md D9).
|
|
637
|
+
return runOnboard({ ...opts, dryRun: false });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const hasBlocked = result.verdict.some((item) => item.severity === "blocked");
|
|
641
|
+
return { exitCode: hasBlocked ? 1 : 0 };
|
|
391
642
|
}
|
|
392
|
-
function printResult(result, dryRun) {
|
|
643
|
+
function printResult(result, dryRun, showStatusLine) {
|
|
393
644
|
if (dryRun)
|
|
394
645
|
console.log("[dry run]");
|
|
395
646
|
const present = result.summary.filter((s) => s.present);
|
|
@@ -424,6 +675,17 @@ function printResult(result, dryRun) {
|
|
|
424
675
|
: `${install.agent} has no npm package to install (see \`trellis init\`'s install hint) — left out of this run's managed set.`);
|
|
425
676
|
}
|
|
426
677
|
console.log(`Managed agents: ${result.managedAgents && result.managedAgents.length > 0 ? result.managedAgents.join(", ") : "(none)"}`);
|
|
678
|
+
// Informational only, never a prompt (design.md D6/D12,
|
|
679
|
+
// trellis-onboard-mcp-mode) — this is what keeps `--mcp-mode`/
|
|
680
|
+
// `--memory` discoverable without costing every run a keystroke.
|
|
681
|
+
// Suppressed under `--json` and non-TTY, same gate stage progress
|
|
682
|
+
// already uses.
|
|
683
|
+
if (showStatusLine && result.mcpMode) {
|
|
684
|
+
console.log(mcpModeStatusLine(result.mcpMode));
|
|
685
|
+
}
|
|
686
|
+
if (showStatusLine && result.memory) {
|
|
687
|
+
console.log(memoryStatusLine(result.memory));
|
|
688
|
+
}
|
|
427
689
|
// Reuse `migrate`/`sync`'s own printing verbatim (including the
|
|
428
690
|
// "nothing to migrate" / "already in sync" cases) rather than a second,
|
|
429
691
|
// easily-drifting copy of this formatting. `dryRun: false` here since
|
|
@@ -452,4 +714,11 @@ function printResult(result, dryRun) {
|
|
|
452
714
|
console.log("\nsecrets audit");
|
|
453
715
|
printSecretsAuditReport(result.secretsAuditReport);
|
|
454
716
|
}
|
|
717
|
+
if (result.doctorReport) {
|
|
718
|
+
// Distinct from the self-verification re-plan above: this is a
|
|
719
|
+
// broader health scan across every agent, not proof that this run's
|
|
720
|
+
// own writes took effect (design.md D2b).
|
|
721
|
+
console.log("\nhealth scan (trellis doctor)");
|
|
722
|
+
printDoctorReport(result.doctorReport);
|
|
723
|
+
}
|
|
455
724
|
}
|
|
@@ -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[];
|
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
function itemMessage(stage, agent, description) {
|
|
15
|
+
const item = { stage, severity: "blocked", message: description };
|
|
16
|
+
if (agent)
|
|
17
|
+
item.agent = agent;
|
|
18
|
+
return item;
|
|
19
|
+
}
|
|
20
|
+
/** Shared by `sync` and `mcp sync` — `AgentSyncReport` and
|
|
21
|
+
* `AgentMcpSyncReport` are structurally identical
|
|
22
|
+
* (`{ agent, present, items: AdapterPlanItem[] }`), so one function
|
|
23
|
+
* serves both rather than two copies that could drift. */
|
|
24
|
+
function normalizePlanItems(stage, reports) {
|
|
25
|
+
if (!reports)
|
|
26
|
+
return [];
|
|
27
|
+
const items = [];
|
|
28
|
+
for (const report of reports) {
|
|
29
|
+
for (const item of report.items) {
|
|
30
|
+
if (item.action !== "conflict")
|
|
31
|
+
continue;
|
|
32
|
+
const verdict = itemMessage(stage, report.agent, item.description);
|
|
33
|
+
if (item.remediation)
|
|
34
|
+
verdict.remediation = item.remediation;
|
|
35
|
+
items.push(verdict);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return items;
|
|
39
|
+
}
|
|
40
|
+
export function normalizeMigrateVerdict(plan) {
|
|
41
|
+
if (!plan)
|
|
42
|
+
return [];
|
|
43
|
+
const items = [];
|
|
44
|
+
for (const item of plan.items) {
|
|
45
|
+
if (item.action !== "conflict")
|
|
46
|
+
continue;
|
|
47
|
+
const verdict = { stage: "migrate", severity: "blocked", message: item.detail };
|
|
48
|
+
if (item.remediation)
|
|
49
|
+
verdict.remediation = item.remediation;
|
|
50
|
+
items.push(verdict);
|
|
51
|
+
}
|
|
52
|
+
return items;
|
|
53
|
+
}
|
|
54
|
+
export function normalizeSyncVerdict(report) {
|
|
55
|
+
return normalizePlanItems("sync", report?.reports);
|
|
56
|
+
}
|
|
57
|
+
export function normalizeMcpSyncVerdict(report) {
|
|
58
|
+
return normalizePlanItems("mcp sync", report?.reports);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The self-verification re-plan (design.md D2b): a re-plan run
|
|
62
|
+
* immediately after a real apply, against the state that apply just
|
|
63
|
+
* wrote. Any remaining `"create"`/`"conflict"` item here means the
|
|
64
|
+
* write itself did not hold — distinct from, and always `blocked`
|
|
65
|
+
* regardless of what the original apply reported, because it directly
|
|
66
|
+
* contradicts this run's own claim of having written it.
|
|
67
|
+
*/
|
|
68
|
+
export function normalizeSelfVerificationVerdict(stage, reports) {
|
|
69
|
+
if (!reports)
|
|
70
|
+
return [];
|
|
71
|
+
const items = [];
|
|
72
|
+
for (const report of reports) {
|
|
73
|
+
for (const item of report.items) {
|
|
74
|
+
if (item.action !== "create" && item.action !== "conflict")
|
|
75
|
+
continue;
|
|
76
|
+
items.push({
|
|
77
|
+
stage: `${stage} (verify)`,
|
|
78
|
+
severity: "blocked",
|
|
79
|
+
agent: report.agent,
|
|
80
|
+
message: `expected to have written "${item.target}" but a re-plan still finds it outstanding: ${item.description}`,
|
|
81
|
+
remediation: `re-run \`trellis ${stage === "sync" ? "sync" : "mcp sync"}\` and, if this repeats, check for a filesystem permission or symlink problem at ${item.target}`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return items;
|
|
86
|
+
}
|
|
87
|
+
export function normalizeMemorySyncVerdict(result) {
|
|
88
|
+
if (!result)
|
|
89
|
+
return [];
|
|
90
|
+
if (!result.configured) {
|
|
91
|
+
return [{ stage: "memory sync", severity: "warning", message: result.reason }];
|
|
92
|
+
}
|
|
93
|
+
const items = [];
|
|
94
|
+
for (const item of result.plan.items) {
|
|
95
|
+
if (item.action !== "conflict")
|
|
96
|
+
continue;
|
|
97
|
+
items.push({ stage: "memory sync", severity: "blocked", message: item.detail });
|
|
98
|
+
}
|
|
99
|
+
return items;
|
|
100
|
+
}
|
|
101
|
+
export function normalizeSecretsAuditVerdict(report) {
|
|
102
|
+
if (!report)
|
|
103
|
+
return [];
|
|
104
|
+
return report.findings.map((finding) => {
|
|
105
|
+
const item = { stage: "secrets audit", severity: "blocked", message: `${finding.file}: ${finding.detail}` };
|
|
106
|
+
if (finding.agent !== "environment")
|
|
107
|
+
item.agent = finding.agent;
|
|
108
|
+
return item;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A finding naming an agent outside the managed set is a `warning`, not
|
|
113
|
+
* `blocked` — onboard did not touch that agent, so its drift is not this
|
|
114
|
+
* run's failure (design.md D3). A finding with no `agent` at all (only
|
|
115
|
+
* cross-agent `"drift"`, per `src/commands/doctor.ts`'s own findings
|
|
116
|
+
* construction) is conservatively `blocked`: it inherently spans more
|
|
117
|
+
* than one agent, and there is no way to prove none of them are managed.
|
|
118
|
+
*/
|
|
119
|
+
/** Any path under `homeDir` prints as `~/…` rather than an absolute
|
|
120
|
+
* scratch/real path — the run's own `homeDir`, never `homedir()`, so a
|
|
121
|
+
* test's scratch home abbreviates the same way a real run does. */
|
|
122
|
+
function abbreviate(text, homeDir) {
|
|
123
|
+
return text.split(homeDir).join("~");
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Always the last thing `printResult` prints, on every run including a
|
|
127
|
+
* fully clean one — the exact regression this change exists to fix: a
|
|
128
|
+
* run with a real early-stage conflict must not end on a later stage's
|
|
129
|
+
* unrelated success line (trellis-onboard-closed-loop spec: "Every run
|
|
130
|
+
* terminates in a verdict that matches its exit code").
|
|
131
|
+
*/
|
|
132
|
+
export function printVerdict(verdict, homeDir) {
|
|
133
|
+
const blocked = verdict.filter((item) => item.severity === "blocked");
|
|
134
|
+
const warnings = verdict.filter((item) => item.severity === "warning");
|
|
135
|
+
console.log("");
|
|
136
|
+
console.log("verdict");
|
|
137
|
+
if (blocked.length === 0 && warnings.length === 0) {
|
|
138
|
+
console.log("✅ nothing needs attention — every stage completed cleanly and verified");
|
|
139
|
+
console.log("exit code: 0 — nothing blocked this run");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (blocked.length > 0) {
|
|
143
|
+
console.log(`❌ ${blocked.length} blocking issue(s):`);
|
|
144
|
+
for (const item of blocked) {
|
|
145
|
+
const where = item.agent ? `${item.stage}/${item.agent}` : item.stage;
|
|
146
|
+
console.log(` - [${where}] ${abbreviate(item.message, homeDir)}`);
|
|
147
|
+
if (item.remediation)
|
|
148
|
+
console.log(` → ${abbreviate(item.remediation, homeDir)}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (warnings.length > 0) {
|
|
152
|
+
console.log(`⚠️ ${warnings.length} warning(s) — did not block this run:`);
|
|
153
|
+
for (const item of warnings) {
|
|
154
|
+
const where = item.agent ? `${item.stage}/${item.agent}` : item.stage;
|
|
155
|
+
console.log(` - [${where}] ${abbreviate(item.message, homeDir)}`);
|
|
156
|
+
if (item.remediation)
|
|
157
|
+
console.log(` → ${abbreviate(item.remediation, homeDir)}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
console.log(blocked.length > 0 ? "exit code: 1 — at least one blocking issue above" : "exit code: 0 — warnings only, nothing blocked this run");
|
|
161
|
+
}
|
|
162
|
+
export function normalizeDoctorVerdict(report, managedAgents) {
|
|
163
|
+
if (!report)
|
|
164
|
+
return [];
|
|
165
|
+
const managed = new Set(managedAgents);
|
|
166
|
+
return report.findings.map((finding) => {
|
|
167
|
+
const concernsOnlyUnmanaged = finding.agent !== undefined && !managed.has(finding.agent);
|
|
168
|
+
const item = { stage: "doctor", severity: concernsOnlyUnmanaged ? "warning" : "blocked", message: finding.message };
|
|
169
|
+
if (finding.agent)
|
|
170
|
+
item.agent = finding.agent;
|
|
171
|
+
if (concernsOnlyUnmanaged)
|
|
172
|
+
item.remediation = `concerns "${finding.agent}", which this run does not manage — run \`trellis doctor\` directly if you want to look into it`;
|
|
173
|
+
return item;
|
|
174
|
+
});
|
|
175
|
+
}
|