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.
- package/dist/adapters/codex.js +4 -1
- package/dist/adapters/jsonMcp.js +6 -3
- package/dist/adapters/kiro.js +9 -1
- package/dist/adapters/mcpPlan.d.ts +15 -0
- package/dist/adapters/mcpPlan.js +59 -3
- package/dist/adapters/symlinkPlan.js +3 -0
- package/dist/cli.js +56 -8
- 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 +42 -19
- package/dist/commands/memory.js +99 -14
- package/dist/commands/migrate.d.ts +18 -1
- package/dist/commands/migrate.js +52 -6
- package/dist/commands/onboard.d.ts +34 -0
- package/dist/commands/onboard.js +110 -10
- 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 +8 -6
- package/dist/core/canonical.js +26 -3
- package/dist/core/types.d.ts +42 -4
- package/dist/lib/backup.js +32 -3
- 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/mcpMigrateRead.d.ts +12 -0
- package/dist/lib/mcpMigrateRead.js +47 -12
- package/dist/lib/mcpToolRegistry.d.ts +89 -0
- package/dist/lib/mcpToolRegistry.js +94 -0
- package/dist/lib/memoryGraph.d.ts +35 -0
- package/dist/lib/memoryGraph.js +62 -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/lib/slug.d.ts +9 -0
- package/dist/lib/slug.js +15 -0
- package/dist/lib/tomlSection.js +7 -1
- package/dist/pi-bridge/bundle.js +14383 -14338
- package/dist/pi-bridge/index.d.ts +5 -9
- package/dist/pi-bridge/index.js +6 -99
- package/dist/pi-bridge/schemaTranslate.d.ts +8 -2
- package/dist/pi-bridge/schemaTranslate.js +8 -4
- package/docs/architecture.md +88 -19
- package/docs/getting-started.md +174 -11
- package/docs/research.md +190 -0
- package/docs/roadmap.md +20 -0
- package/package.json +4 -3
- package/schema/servers.example.yaml +42 -7
package/dist/commands/onboard.js
CHANGED
|
@@ -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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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[];
|
|
@@ -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
|
+
}
|
package/dist/core/adapter.d.ts
CHANGED
|
@@ -50,6 +50,13 @@ export interface AdapterPlanItem {
|
|
|
50
50
|
/** Human-readable description of one change this adapter would make
|
|
51
51
|
* ("create" / "remove") or why it refused to ("conflict"). */
|
|
52
52
|
description: string;
|
|
53
|
+
/** Only set (and only meaningful) when `action === "conflict"`: the
|
|
54
|
+
* concrete next action a user can take, distinct from restating why
|
|
55
|
+
* it conflicted (`description` already does that) — surfaced in
|
|
56
|
+
* `trellis onboard`'s verdict block (trellis-onboard-closed-loop
|
|
57
|
+
* design.md D7). Optional so existing conflict sites keep compiling
|
|
58
|
+
* and rendering cleanly before each is given one. */
|
|
59
|
+
remediation?: string;
|
|
53
60
|
/** What's being touched, for the collision/audit checks to reason
|
|
54
61
|
* about. For `kind: "mcp"`, the config file being modified (e.g.
|
|
55
62
|
* `~/.codex/config.toml`), not a per-server path — there isn't one. */
|
package/dist/core/canonical.d.ts
CHANGED
|
@@ -6,14 +6,16 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { CanonicalSource, McpServerDef } from "./types.js";
|
|
8
8
|
/**
|
|
9
|
-
* The on-disk shape for one server entry — `static_env`
|
|
10
|
-
* every other multi-word key across `.trellis/*.yaml`)
|
|
11
|
-
* `McpServerDef.staticEnv` (camelCase)
|
|
12
|
-
* to already be a single word, so no
|
|
13
|
-
* treatment before
|
|
9
|
+
* The on-disk shape for one server entry — `static_env`/`env_aliases`
|
|
10
|
+
* (snake_case, like every other multi-word key across `.trellis/*.yaml`)
|
|
11
|
+
* are translated to `McpServerDef.staticEnv`/`envAliases` (camelCase)
|
|
12
|
+
* below; every other field happens to already be a single word, so no
|
|
13
|
+
* server-def field needed this treatment before
|
|
14
|
+
* (trellis-mcp-static-env-and-disabled-servers, trellis-migrate-env-var-alias).
|
|
14
15
|
*/
|
|
15
|
-
type McpServerDefYaml = Omit<McpServerDef, "staticEnv"> & {
|
|
16
|
+
type McpServerDefYaml = Omit<McpServerDef, "staticEnv" | "envAliases"> & {
|
|
16
17
|
static_env?: Record<string, string>;
|
|
18
|
+
env_aliases?: Record<string, string>;
|
|
17
19
|
};
|
|
18
20
|
/** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
|
|
19
21
|
* `undefined` fields so the written YAML never gets a literal `null`
|
package/dist/core/canonical.js
CHANGED
|
@@ -10,17 +10,24 @@ import { basename, join } from "node:path";
|
|
|
10
10
|
import { isMap, parse as parseYaml, parseDocument } from "yaml";
|
|
11
11
|
import { ALL_AGENTS } from "./types.js";
|
|
12
12
|
function fromServerDefYaml(def) {
|
|
13
|
-
const { static_env, ...rest } = def;
|
|
14
|
-
|
|
13
|
+
const { static_env, env_aliases, ...rest } = def;
|
|
14
|
+
const out = { ...rest };
|
|
15
|
+
if (static_env)
|
|
16
|
+
out.staticEnv = static_env;
|
|
17
|
+
if (env_aliases)
|
|
18
|
+
out.envAliases = env_aliases;
|
|
19
|
+
return out;
|
|
15
20
|
}
|
|
16
21
|
/** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
|
|
17
22
|
* `undefined` fields so the written YAML never gets a literal `null`
|
|
18
23
|
* for an omitted optional. */
|
|
19
24
|
export function toServerDefYaml(def) {
|
|
20
|
-
const { staticEnv, ...rest } = def;
|
|
25
|
+
const { staticEnv, envAliases, ...rest } = def;
|
|
21
26
|
const out = { ...rest };
|
|
22
27
|
if (staticEnv)
|
|
23
28
|
out.static_env = staticEnv;
|
|
29
|
+
if (envAliases)
|
|
30
|
+
out.env_aliases = envAliases;
|
|
24
31
|
for (const key of Object.keys(out)) {
|
|
25
32
|
if (out[key] === undefined)
|
|
26
33
|
delete out[key];
|
|
@@ -81,8 +88,24 @@ function loadServersYaml(path) {
|
|
|
81
88
|
servers,
|
|
82
89
|
knownHostInjected: parsed.known_host_injected ?? [],
|
|
83
90
|
hub: parsed.hub,
|
|
91
|
+
gateway: fromGatewayYaml(parsed.gateway),
|
|
84
92
|
};
|
|
85
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* A `gateway:` block with no `enabled` key reads as off, not as on — the
|
|
96
|
+
* only safe default for a field whose whole effect is to stop writing the
|
|
97
|
+
* per-server entries an agent is currently working with. Absent entirely,
|
|
98
|
+
* the field stays `undefined` so `resolveMcpPlan` can tell "never
|
|
99
|
+
* configured" from "explicitly disabled" if that ever matters.
|
|
100
|
+
*/
|
|
101
|
+
function fromGatewayYaml(gateway) {
|
|
102
|
+
if (!gateway)
|
|
103
|
+
return undefined;
|
|
104
|
+
const out = { enabled: gateway.enabled === true };
|
|
105
|
+
if (gateway.agents)
|
|
106
|
+
out.agents = gateway.agents;
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
86
109
|
/**
|
|
87
110
|
* Writes or replaces one server entry in `servers.yaml`, preserving
|
|
88
111
|
* every other entry's and comment's exact formatting (trellis-
|
package/dist/core/types.d.ts
CHANGED
|
@@ -53,6 +53,18 @@ export interface McpServerDef {
|
|
|
53
53
|
* environment tag), not an escape hatch for real credentials.
|
|
54
54
|
*/
|
|
55
55
|
staticEnv?: Record<string, string>;
|
|
56
|
+
/**
|
|
57
|
+
* Target env var name -> source variable name, for a reference whose
|
|
58
|
+
* key differs from the name it resolves (`OPENAPI_MCP_HEADERS:
|
|
59
|
+
* "${NOTION_OPENAPI_MCP_HEADERS}"` becomes `{ OPENAPI_MCP_HEADERS:
|
|
60
|
+
* "NOTION_OPENAPI_MCP_HEADERS" }`) — `env`'s self-referencing shape
|
|
61
|
+
* can't express a differently-named source, and treating this case as
|
|
62
|
+
* `staticEnv` silently ships the literal, unexpanded placeholder text
|
|
63
|
+
* to any consumer with no native `${VAR}` runtime of its own
|
|
64
|
+
* (trellis-migrate-env-var-alias). Resolved through the exact same
|
|
65
|
+
* mechanism as `env`, never a raw literal.
|
|
66
|
+
*/
|
|
67
|
+
envAliases?: Record<string, string>;
|
|
56
68
|
/**
|
|
57
69
|
* Defaults to `true`. `false` keeps the definition in canonical
|
|
58
70
|
* without writing it to any agent — matches a real host config's own
|
|
@@ -75,20 +87,46 @@ export interface McpServerDef {
|
|
|
75
87
|
export interface HubConfig {
|
|
76
88
|
url: string;
|
|
77
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Converge an agent's MCP config onto one local stdio process Trellis
|
|
92
|
+
* itself spawns (`trellis mcp-gateway --agent <id>`), instead of writing
|
|
93
|
+
* one native entry per server.
|
|
94
|
+
*
|
|
95
|
+
* Deliberately NOT the same field as `hub`: `hub` names an HTTP endpoint
|
|
96
|
+
* the user operates somewhere else and Trellis never starts; this names a
|
|
97
|
+
* subprocess Trellis owns. Overloading one field to mean both would force
|
|
98
|
+
* every future reader to disambiguate two unrelated concepts by context
|
|
99
|
+
* (trellis-mcp-gateway-hosting design.md D5).
|
|
100
|
+
*/
|
|
101
|
+
export interface GatewayConfig {
|
|
102
|
+
enabled: boolean;
|
|
103
|
+
/**
|
|
104
|
+
* Which agents converge through the gateway. Omit — the expected normal
|
|
105
|
+
* case — and `enabled` applies to every managed agent; which agents
|
|
106
|
+
* converge is not a question a user should have to answer to get the
|
|
107
|
+
* feature. Present, it narrows to exactly these, leaving the rest in
|
|
108
|
+
* direct or hub mode.
|
|
109
|
+
*/
|
|
110
|
+
agents?: AgentId[];
|
|
111
|
+
}
|
|
78
112
|
export interface McpConfig {
|
|
79
113
|
servers: Record<string, McpServerDef>;
|
|
80
114
|
/**
|
|
81
115
|
* Server names a host environment (e.g. mirasim) is known to inject at
|
|
82
116
|
* runtime. Checked against every server name Trellis would write per
|
|
83
|
-
* agent
|
|
84
|
-
*
|
|
85
|
-
* "Codex — three hard constraints" for why a same-name
|
|
86
|
-
* a soft failure on every agent.
|
|
117
|
+
* agent in direct mode; against the single converged entry name only in
|
|
118
|
+
* hub or gateway mode (there's nothing else to collide) — see
|
|
119
|
+
* docs/research.md "Codex — three hard constraints" for why a same-name
|
|
120
|
+
* collision is not a soft failure on every agent.
|
|
87
121
|
*/
|
|
88
122
|
knownHostInjected: string[];
|
|
89
123
|
/** Omit for direct mode (today's default: every agent gets all N server
|
|
90
124
|
* definitions written into its native config). See `HubConfig`. */
|
|
91
125
|
hub?: HubConfig;
|
|
126
|
+
/** Omit for direct mode. See `GatewayConfig`. Independent of `hub`: the
|
|
127
|
+
* two mean different things and may both be set, in which case gateway
|
|
128
|
+
* wins per agent (trellis-mcp-gateway-hosting design.md D5/D6). */
|
|
129
|
+
gateway?: GatewayConfig;
|
|
92
130
|
}
|
|
93
131
|
export interface SkillRef {
|
|
94
132
|
name: string;
|