agent-trellis 0.15.1 → 0.16.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.
@@ -15,7 +15,7 @@ import { type WorkspaceDoctorSection } from "../lib/workspaceDoctor.js";
15
15
  * machine.
16
16
  */
17
17
  export declare const DEFAULT_KNOWN_HOST_INJECTED: readonly string[];
18
- export type FindingKind = "probe-failed" | "duplication" | "collision" | "drift" | "case-mismatch" | "parse-diagnostic" | "runtime-drift" | "oauth-expired" | "mcp-unreachable";
18
+ export type FindingKind = "probe-failed" | "duplication" | "collision" | "drift" | "case-mismatch" | "parse-diagnostic" | "runtime-drift" | "oauth-expired" | "mcp-unreachable" | "workspace-toml-invalid";
19
19
  export interface Finding {
20
20
  kind: FindingKind;
21
21
  /** Omitted for a finding that names more than one agent (drift). */
@@ -44,6 +44,10 @@ const EXIT_NONZERO_KINDS = new Set([
44
44
  // exists to catch *before* the session does (trellis-mcp-oauth-renew
45
45
  // design.md D4).
46
46
  "oauth-expired",
47
+ // A project-level Codex config that fails TOML parsing will fail the
48
+ // *agent*'s own startup regardless of ownership — catch it here before
49
+ // the next session does (trellis-doctor-workspace-toml-validation).
50
+ "workspace-toml-invalid",
47
51
  ]);
48
52
  /** Exported so tests can assert on this resolution directly, without
49
53
  * routing through real per-agent probes `runDoctor` also runs. */
@@ -120,7 +124,53 @@ export async function collectDoctorReport(homeDir = homedir(), knownHostInjected
120
124
  // canonical has not been initialized yet.
121
125
  }
122
126
  }
123
- const workspace = canonical?.workspace ? collectWorkspaceDoctorSection(canonical) : undefined;
127
+ // Workspace attribution runs whenever a workspace root is in scope —
128
+ // even when `~/.trellis/` itself failed to load or has never been
129
+ // initialized. The spec's third scenario: a user managing only project
130
+ // skills in `.agents/skills/` without a global Trellis install still
131
+ // gets project-level config validation
132
+ // (trellis-doctor-workspace-toml-validation design.md D5). When the
133
+ // canonical source is absent we synthesize the minimal workspace layer
134
+ // with every map empty — provenance is moot without a global layer, but
135
+ // `collectWorkspaceDoctorSection`'s per-file inspection is what we want.
136
+ let workspace;
137
+ if (canonical?.workspace) {
138
+ workspace = collectWorkspaceDoctorSection(canonical);
139
+ }
140
+ else {
141
+ const workspaceRoot = workspaceDir != null ? findWorkspaceRoot(workspaceDir) : null;
142
+ if (workspaceRoot) {
143
+ // `managedAgents` is driven by `~/.trellis/managed.yaml`, which is
144
+ // absent in this branch — sweep every known agent so codex's TOML
145
+ // file is still inspected (otherwise an empty list would skip the
146
+ // file entirely and the spec's scenario-3 finding would never fire).
147
+ const minimal = {
148
+ instructionsFile: "",
149
+ managedAgents: ALL_AGENTS,
150
+ skills: [],
151
+ agents: [],
152
+ memories: [],
153
+ mcp: { servers: {}, knownHostInjected: [] },
154
+ secretsPolicy: { allowedVars: [], rejectPatterns: [] },
155
+ diagnostics: [],
156
+ workspace: { root: workspaceRoot, mcpProvenance: {}, mcpGlobalServers: {}, skillProvenance: {}, globalSkills: [], shadowed: [] },
157
+ };
158
+ workspace = collectWorkspaceDoctorSection(minimal);
159
+ }
160
+ }
161
+ // Workspace TOML findings become top-level findings so the exit code
162
+ // and `--json` consumers see them alongside cross-agent findings, not
163
+ // only inside the workspace section. They keep `agent: "codex"` — the
164
+ // file layout tells us which agent the file breaks.
165
+ if (workspace) {
166
+ for (const finding of workspace.tomlFindings) {
167
+ findings.push({
168
+ kind: "workspace-toml-invalid",
169
+ agent: finding.agent,
170
+ message: `${finding.path}${finding.line !== undefined ? `:${finding.line}${finding.column !== undefined ? `:${finding.column}` : ""}` : ""} — ${finding.reason} (not rendered by Trellis; fix by hand)`,
171
+ });
172
+ }
173
+ }
124
174
  const oauth = canonical ? collectOAuthHealth(homeDir, canonical) : [];
125
175
  findings.push(...detectDuplication(snapshots), ...detectCollisions(snapshots, knownHostInjected), ...detectCrossAgentDrift(snapshots), ...detectCaseMismatches(snapshots), ...detectParseDiagnostics(snapshots), ...(canonical ? detectRuntimeDrift(snapshots, canonical) : []), ...detectMcpUnreachable(snapshots), ...oauth.flatMap((entry) => (entry.status === "valid" ? [] : [oauthFinding(entry)])));
126
176
  return { snapshots, findings, ...(oauth.length > 0 ? { oauth } : {}), ...(workspace ? { workspace } : {}) };
@@ -395,6 +445,10 @@ export function printReport(report) {
395
445
  for (const hint of ws.unmigrated) {
396
446
  console.log(` ⚠ ${hint.path} holds ${hint.entries.length} entr${hint.entries.length === 1 ? "y" : "ies"} Trellis didn't render (${hint.entries.join(", ")}) — run \`trellis migrate --workspace\``);
397
447
  }
448
+ for (const finding of ws.tomlFindings) {
449
+ const location = finding.line !== undefined ? `:${finding.line}${finding.column !== undefined ? `:${finding.column}` : ""}` : "";
450
+ console.log(` ⚠ ${finding.path}${location} — ${finding.reason} (not rendered by Trellis — fix by hand and re-run \`trellis doctor\`)`);
451
+ }
398
452
  for (const warning of ws.precedenceWarnings) {
399
453
  console.log(` ⚠ ${warning}`);
400
454
  }
@@ -20,6 +20,19 @@ export interface WorkspaceUnmigratedHint {
20
20
  /** Entry names present in the file with no Trellis manifest record. */
21
21
  entries: string[];
22
22
  }
23
+ /** One parse-level failure when inspecting an agent's project MCP file.
24
+ * Distinct from `parse-diagnostic` (agent-global probes). Even when Trellis
25
+ * has never rendered into this file, a project file that breaks the agent's
26
+ * own startup is exactly what doctor exists to surface
27
+ * (trellis-doctor-workspace-toml-validation). */
28
+ export interface WorkspaceTomlFinding {
29
+ agent: AgentId;
30
+ path: string;
31
+ /** Parser's 1-based line and column of the failure, when known. */
32
+ line?: number;
33
+ column?: number;
34
+ reason: string;
35
+ }
23
36
  export interface WorkspaceDoctorSection {
24
37
  root: string;
25
38
  mcp: WorkspaceLayerEntry[];
@@ -38,6 +51,8 @@ export interface WorkspaceDoctorSection {
38
51
  name: string;
39
52
  }[];
40
53
  unmigrated: WorkspaceUnmigratedHint[];
54
+ /** Parse-level failures on workspace agent MCP files (usually empty). */
55
+ tomlFindings: WorkspaceTomlFinding[];
41
56
  /** zcode's user-level-first deviation notices (empty otherwise). */
42
57
  precedenceWarnings: string[];
43
58
  }
@@ -10,6 +10,7 @@
10
10
  * from the already-loaded canonical source and file reads only.
11
11
  */
12
12
  import { existsSync, readFileSync } from "node:fs";
13
+ import { parse as parseToml } from "smol-toml";
13
14
  import { resolveSecretEnv } from "./secretEnv.js";
14
15
  import { loadWorkspaceRendered, workspaceMcpConfigPath, workspacePrecedenceWarnings } from "./workspaceMcp.js";
15
16
  const VAR_REF_RE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
@@ -36,15 +37,36 @@ function collectVarRefs(canonical) {
36
37
  return refs;
37
38
  }
38
39
  /** Entry names a JSON `mcpServers` file / Codex TOML / zcode nested
39
- * config holds beyond what the in-repo manifest records Trellis
40
- * rendering. Regex-parses Codex TOML section headers — doctor only
41
- * needs names for a hint, not a full TOML AST. */
40
+ * config holds beyond what the in-repo manifest records Trellis rendering.
41
+ *
42
+ * The Codex branch TOML-parses the file first — syntactically invalid TOML
43
+ * is indistinguishable from valid TOML to the section-header regex, but is
44
+ * fatal to Codex's own startup, so it is surfaced as `invalid` and never
45
+ * turned into an unmigrated hint. The parsed AST is discarded on success:
46
+ * header extraction keeps using the existing regex to avoid changing edge
47
+ * semantics (dotted quoted keys, single-quoted keys) that the regex was
48
+ * already validated against — smol-toml is used parse-only, never
49
+ * round-tripped, respecting lib/tomlSection.ts's write-path constraint. */
42
50
  function unmarkedEntries(agentId, configPath, recorded) {
43
51
  if (!existsSync(configPath))
44
- return [];
52
+ return { kind: "ok", names: [] };
45
53
  let names = [];
46
54
  if (agentId === "codex") {
47
55
  const content = readFileSync(configPath, "utf-8");
56
+ try {
57
+ parseToml(content);
58
+ }
59
+ catch (err) {
60
+ const tomLError = err;
61
+ const reason = tomLError.message?.split("\n")[0] ?? String(err);
62
+ return {
63
+ kind: "invalid",
64
+ path: configPath,
65
+ ...(typeof tomLError.line === "number" ? { line: tomLError.line } : {}),
66
+ ...(typeof tomLError.column === "number" ? { column: tomLError.column + 1 } : {}),
67
+ reason,
68
+ };
69
+ }
48
70
  const re = /^\[mcp_servers\.([^\]]+)\]\s*$/gm;
49
71
  let match;
50
72
  while ((match = re.exec(content)) !== null)
@@ -59,10 +81,10 @@ function unmarkedEntries(agentId, configPath, recorded) {
59
81
  names = Object.keys(servers);
60
82
  }
61
83
  catch {
62
- return []; // unparsable file is the sync guard's finding, not doctor's
84
+ return { kind: "ok", names: [] }; // unparsable file is the sync guard's finding, not doctor's
63
85
  }
64
86
  }
65
- return names.filter((name) => recorded[name] === undefined);
87
+ return { kind: "ok", names: names.filter((name) => recorded[name] === undefined) };
66
88
  }
67
89
  export function collectWorkspaceDoctorSection(canonical) {
68
90
  const workspace = canonical.workspace;
@@ -83,12 +105,17 @@ export function collectWorkspaceDoctorSection(canonical) {
83
105
  const unresolvedVars = refs.filter((r) => resolved[r.name] === undefined);
84
106
  const manifest = loadWorkspaceRendered(workspace.root);
85
107
  const unmigrated = [];
108
+ const tomlFindings = [];
86
109
  for (const agentId of canonical.managedAgents) {
87
110
  const configPath = workspaceMcpConfigPath(agentId, workspace.root);
88
- const entries = unmarkedEntries(agentId, configPath, manifest.mcp?.[agentId] ?? {});
89
- if (entries.length > 0)
90
- unmigrated.push({ agent: agentId, path: configPath, entries });
111
+ const result = unmarkedEntries(agentId, configPath, manifest.mcp?.[agentId] ?? {});
112
+ if (result.kind === "invalid") {
113
+ tomlFindings.push({ agent: agentId, path: result.path, ...(result.line !== undefined ? { line: result.line } : {}), ...(result.column !== undefined ? { column: result.column } : {}), reason: result.reason });
114
+ continue; // never report an unparseable file's entries as unmigrated — spec req 2
115
+ }
116
+ if (result.names.length > 0)
117
+ unmigrated.push({ agent: agentId, path: configPath, entries: result.names });
91
118
  }
92
119
  const precedenceWarnings = canonical.managedAgents.flatMap((id) => workspacePrecedenceWarnings(id, canonical));
93
- return { root: workspace.root, mcp, skills, shadowed: workspace.shadowed, tombstoned, unresolvedVars, unmigrated, precedenceWarnings };
120
+ return { root: workspace.root, mcp, skills, shadowed: workspace.shadowed, tombstoned, unresolvedVars, unmigrated, tomlFindings, precedenceWarnings };
94
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-trellis",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "A unified Code Agent Runtime for Skills, MCP, Memory, and shared instructions across Claude Code, Codex, Kiro, pi, Kimi Code, and ZCode.",
5
5
  "license": "MIT",
6
6
  "author": "Paul Leo",
@@ -64,6 +64,7 @@
64
64
  "@modelcontextprotocol/client": "^2.3.1",
65
65
  "@modelcontextprotocol/sdk": "^1.30.0",
66
66
  "@modelcontextprotocol/server": "^2.3.1",
67
+ "smol-toml": "^1.9.1",
67
68
  "yaml": "^2.9.1",
68
69
  "zod": "^4.6.5"
69
70
  }