agent-trellis 0.14.1 → 0.15.1

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 (60) hide show
  1. package/dist/adapters/claude-code.js +4 -3
  2. package/dist/adapters/codex.js +4 -3
  3. package/dist/adapters/kimi-code.js +4 -3
  4. package/dist/adapters/kiro.js +5 -4
  5. package/dist/adapters/pi.js +4 -3
  6. package/dist/adapters/zcode.d.ts +12 -1
  7. package/dist/adapters/zcode.js +7 -6
  8. package/dist/cli.js +52 -37
  9. package/dist/commands/doctor.d.ts +9 -1
  10. package/dist/commands/doctor.js +43 -6
  11. package/dist/commands/init.d.ts +6 -0
  12. package/dist/commands/init.js +24 -0
  13. package/dist/commands/mcp.d.ts +69 -3
  14. package/dist/commands/mcp.js +255 -56
  15. package/dist/commands/mcpGateway.js +3 -3
  16. package/dist/commands/migrate.d.ts +12 -0
  17. package/dist/commands/migrate.js +8 -4
  18. package/dist/commands/migrateWorkspace.d.ts +40 -0
  19. package/dist/commands/migrateWorkspace.js +197 -0
  20. package/dist/commands/rollback.js +65 -35
  21. package/dist/commands/skill.d.ts +14 -0
  22. package/dist/commands/skill.js +71 -0
  23. package/dist/commands/sync.d.ts +16 -1
  24. package/dist/commands/sync.js +48 -15
  25. package/dist/core/adapter.d.ts +14 -0
  26. package/dist/core/canonical.d.ts +20 -34
  27. package/dist/core/canonical.js +18 -92
  28. package/dist/core/mcpServerDef.d.ts +39 -0
  29. package/dist/core/mcpServerDef.js +93 -0
  30. package/dist/core/types.d.ts +39 -4
  31. package/dist/lib/cliArgs.d.ts +17 -0
  32. package/dist/lib/cliArgs.js +36 -0
  33. package/dist/lib/mcpMigrateRead.d.ts +25 -0
  34. package/dist/lib/mcpMigrateRead.js +9 -2
  35. package/dist/lib/scopeSelection.d.ts +1 -1
  36. package/dist/lib/scopeSelection.js +4 -3
  37. package/dist/lib/skillProvider.js +18 -5
  38. package/dist/lib/workspace.d.ts +39 -0
  39. package/dist/lib/workspace.js +158 -0
  40. package/dist/lib/workspaceConfig.d.ts +51 -0
  41. package/dist/lib/workspaceConfig.js +117 -0
  42. package/dist/lib/workspaceDoctor.d.ts +44 -0
  43. package/dist/lib/workspaceDoctor.js +94 -0
  44. package/dist/lib/workspaceFiles.d.ts +38 -0
  45. package/dist/lib/workspaceFiles.js +264 -0
  46. package/dist/lib/workspaceMcp.d.ts +57 -0
  47. package/dist/lib/workspaceMcp.js +277 -0
  48. package/dist/lib/workspaceMerge.d.ts +37 -0
  49. package/dist/lib/workspaceMerge.js +98 -0
  50. package/dist/lib/workspaceMigrate.d.ts +118 -0
  51. package/dist/lib/workspaceMigrate.js +423 -0
  52. package/dist/pi-bridge/bundle.js +258 -96
  53. package/docs/architecture.md +111 -49
  54. package/docs/getting-started.md +86 -14
  55. package/docs/mcp-oauth-matrix.md +12 -1
  56. package/docs/overview.zh-CN.md +17 -0
  57. package/docs/research.md +28 -6
  58. package/docs/roadmap.md +17 -5
  59. package/package.json +1 -1
  60. package/docs/implementation-plan.md +0 -341
@@ -73,7 +73,11 @@ function splitJsonEnvMap(env) {
73
73
  result.staticEnv = literal;
74
74
  return result;
75
75
  }
76
- function fromRichJsonServerDef(raw) {
76
+ /** Also exported for the workspace-migration scanner's zcode reader
77
+ * (trellis-workspace-scope task 4.1): zcode's project config nests the
78
+ * same rich-JSON server shape under `mcp.servers`, so the walker there
79
+ * reuses this per-entry mapping unchanged. */
80
+ export function fromRichJsonServerDef(raw) {
77
81
  if (raw.url) {
78
82
  const transport = raw.type === "sse" ? "sse" : "http";
79
83
  const def = { transport, url: raw.url };
@@ -89,7 +93,10 @@ function fromRichJsonServerDef(raw) {
89
93
  Object.assign(def, splitJsonEnvMap(raw.env));
90
94
  return def;
91
95
  }
92
- function readRichJsonMcpDefs(configPath) {
96
+ /** Also the workspace-migration scanner's JSON reader (trellis-workspace-
97
+ * scope task 4.1): project-level `.mcp.json`-shape files parse through
98
+ * the exact same rules as their global counterparts. */
99
+ export function readRichJsonMcpDefs(configPath) {
93
100
  const parsed = readJsonFile(configPath);
94
101
  const entries = [];
95
102
  const unsupported = [];
@@ -27,7 +27,7 @@ export type ScopeSelection = {
27
27
  detail: string;
28
28
  };
29
29
  export declare const SCOPE_SELECTOR_USAGE = "exactly one of --agents <ids>, --all, --none is required";
30
- /** `argv.indexOf`-style reader, same idiom as every other command's flags. */
30
+ /** Shared reader — accepts `--agents <ids>` and `--agents=<ids>`. */
31
31
  export declare function parseScopeArgs(argv: readonly string[]): ScopeSelectorRaw;
32
32
  export declare function resolveScopeSelection(raw: ScopeSelectorRaw, managed: readonly AgentId[]): ScopeSelection;
33
33
  /** Order-insensitive equality between the scope on disk and the one about to
@@ -6,12 +6,13 @@
6
6
  * disagree about what a given selection means.
7
7
  */
8
8
  import { ALL_AGENTS } from "../core/types.js";
9
+ import { flagValue } from "./cliArgs.js";
9
10
  export const SCOPE_SELECTOR_USAGE = "exactly one of --agents <ids>, --all, --none is required";
10
- /** `argv.indexOf`-style reader, same idiom as every other command's flags. */
11
+ /** Shared reader — accepts `--agents <ids>` and `--agents=<ids>`. */
11
12
  export function parseScopeArgs(argv) {
12
- const i = argv.indexOf("--agents");
13
+ const agents = flagValue(argv, "--agents");
13
14
  return {
14
- ...(i >= 0 ? { agents: argv[i + 1] ?? "" } : {}),
15
+ ...(agents !== undefined ? { agents } : {}),
15
16
  all: argv.includes("--all"),
16
17
  none: argv.includes("--none"),
17
18
  };
@@ -4,10 +4,23 @@ import { resolve, relative, sep } from "node:path";
4
4
  import { parse as parseYaml } from "yaml";
5
5
  import { isInScope } from "../core/adapter.js";
6
6
  import { loadCanonicalSource } from "../core/canonical.js";
7
+ import { findWorkspaceRoot } from "./workspace.js";
7
8
  import { resolveScope } from "../core/types.js";
8
9
  import { buildSkillEntry, parseSkillUri, readSkillFileBytes, toResourceContent } from "./skillManifest.js";
9
10
  const MAX_FILE_BYTES = 256 * 1024;
10
11
  const SKILL_RESOURCE_PREFIX = "trellis://skills/";
12
+ /**
13
+ * The Runtime process is spawned by the agent with the agent's own cwd —
14
+ * inside a repo carrying `.agents/`, skills served over the Runtime
15
+ * (zcode's delivery path) include the workspace layer, exactly like the
16
+ * file-delivered agents get their project copies (trellis-workspace-scope
17
+ * task 2.2). MCP upstream connections are deliberately NOT layered this
18
+ * way (design D6: workspace servers are direct, agent-owned) — this
19
+ * helper is used only here, never in the gateway's upstream resolution.
20
+ */
21
+ function loadSkillCanonical(homeDir) {
22
+ return loadCanonicalSource({ homeDir, workspaceDir: findWorkspaceRoot(process.cwd()) });
23
+ }
11
24
  const SEARCH_TOOL = {
12
25
  name: "trellis.skills.search",
13
26
  description: "Search in-scope Trellis skills by name or description, then read one selected skill on demand.",
@@ -131,7 +144,7 @@ export class SkillProvider {
131
144
  }
132
145
  /** `skills/list` — one entry per servable, in-scope skill. */
133
146
  listSkillEntries(context) {
134
- return this.catalog(loadCanonicalSource(context.homeDir), context.agentId).map(({ entry }) => entry);
147
+ return this.catalog(loadSkillCanonical(context.homeDir), context.agentId).map(({ entry }) => entry);
135
148
  }
136
149
  /** `skills/get` — lookup by URI, independent of listing. Out-of-scope and
137
150
  * unknown URIs are indistinguishable by design. */
@@ -145,7 +158,7 @@ export class SkillProvider {
145
158
  return [SEARCH_TOOL, READ_TOOL, READ_FILE_TOOL];
146
159
  }
147
160
  async callTool(name, args, context) {
148
- const canonical = loadCanonicalSource(context.homeDir);
161
+ const canonical = loadSkillCanonical(context.homeDir);
149
162
  const input = (args ?? {});
150
163
  if (name === SEARCH_TOOL.name)
151
164
  return this.search(canonical, context.agentId, input);
@@ -156,7 +169,7 @@ export class SkillProvider {
156
169
  return errorResult(`unknown skill provider tool "${name}"`);
157
170
  }
158
171
  listResources(context) {
159
- const canonical = loadCanonicalSource(context.homeDir);
172
+ const canonical = loadSkillCanonical(context.homeDir);
160
173
  const legacy = canonical.skills
161
174
  .filter((skill) => hasSkillDocument(skill) && isInScope(context.agentId, skill.scope, canonical.managedAgents))
162
175
  .map((skill) => ({
@@ -181,7 +194,7 @@ export class SkillProvider {
181
194
  if (segments.length !== 2 || segments[1] !== "SKILL.md") {
182
195
  throw new Error(`unsupported skill resource URI "${uri.toString()}"`);
183
196
  }
184
- const canonical = loadCanonicalSource(context.homeDir);
197
+ const canonical = loadSkillCanonical(context.homeDir);
185
198
  const skill = skillFor(canonical, context.agentId, segments[0]);
186
199
  if (!skill)
187
200
  throw new Error(`skill "${segments[0]}" is not available to ${context.agentId}`);
@@ -192,7 +205,7 @@ export class SkillProvider {
192
205
  * computed its digests — the two cannot disagree. */
193
206
  readConventionalResource(uri, context) {
194
207
  const parsed = parseSkillUri(uri);
195
- const canonical = loadCanonicalSource(context.homeDir);
208
+ const canonical = loadSkillCanonical(context.homeDir);
196
209
  const skill = parsed ? skillFor(canonical, context.agentId, parsed.name) : undefined;
197
210
  const served = skill && parsed ? this.catalog(canonical, context.agentId).find(({ entry }) => entry.uri === `skill://${parsed.name}/SKILL.md`) : undefined;
198
211
  if (!parsed || !skill || !served || !served.entry.resources.some((file) => file.uri === uri)) {
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Workspace (project-level) canonical layer: discovery of the nearest
3
+ * `.agents/` directory and loading of its `config.json` / `AGENTS.md` /
4
+ * `skills/` into a `WorkspaceLayer` (openspec trellis-workspace-scope,
5
+ * design D1/D2). The shape follows the `.agents` Protocol draft's
6
+ * two-layer model; skills under `.agents/skills/` are additionally read
7
+ * natively by Codex, pi, and Kimi Code, so the canonical directory is
8
+ * itself the deliverable for those agents.
9
+ */
10
+ import type { McpServerDef, SecretsPolicy, SkillRef } from "../core/types.js";
11
+ export declare const WORKSPACE_DIR_NAME = ".agents";
12
+ /** The only `config.json` schema version this implementation understands. */
13
+ export declare const WORKSPACE_CONFIG_VERSION = 1;
14
+ /**
15
+ * Walk ancestors of `startDir` upward; the first directory containing a
16
+ * `.agents/` directory is the workspace root. The user's home directory
17
+ * itself is never a workspace root (a `~/.agents/` global convention, if
18
+ * one ever exists, is a separate decision), and reaching the filesystem
19
+ * root yields `null` — pure global-only behavior.
20
+ */
21
+ export declare function findWorkspaceRoot(startDir: string): string | null;
22
+ export interface WorkspaceLayer {
23
+ /** Absolute workspace root (the directory containing `.agents/`). */
24
+ root: string;
25
+ /** Validated MCP servers from `config.json`, minus tombstones. */
26
+ servers: Record<string, McpServerDef>;
27
+ /** Server names declared with `enabled: false` — they disable a same-ID
28
+ * global entry and render nothing themselves. */
29
+ tombstones: string[];
30
+ /** Skills from `.agents/skills/<name>/`. */
31
+ skills: SkillRef[];
32
+ /** `.agents/AGENTS.md`, when present. */
33
+ instructionsFile?: string;
34
+ diagnostics: string[];
35
+ }
36
+ /** Load `<workspaceDir>/.agents/` into a `WorkspaceLayer`. Every file is
37
+ * optional; a present-but-invalid `config.json` is a hard error (never a
38
+ * silent misread), with the secrets boundary enforced against `policy`. */
39
+ export declare function loadWorkspaceLayer(workspaceDir: string, policy: SecretsPolicy): WorkspaceLayer;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Workspace (project-level) canonical layer: discovery of the nearest
3
+ * `.agents/` directory and loading of its `config.json` / `AGENTS.md` /
4
+ * `skills/` into a `WorkspaceLayer` (openspec trellis-workspace-scope,
5
+ * design D1/D2). The shape follows the `.agents` Protocol draft's
6
+ * two-layer model; skills under `.agents/skills/` are additionally read
7
+ * natively by Codex, pi, and Kimi Code, so the canonical directory is
8
+ * itself the deliverable for those agents.
9
+ */
10
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { oauthClientMetadata } from "../core/types.js";
14
+ import { fromServerDefYaml } from "../core/mcpServerDef.js";
15
+ export const WORKSPACE_DIR_NAME = ".agents";
16
+ /** The only `config.json` schema version this implementation understands. */
17
+ export const WORKSPACE_CONFIG_VERSION = 1;
18
+ const CONFIG_SOURCE_LABEL = ".agents/config.json";
19
+ /**
20
+ * Walk ancestors of `startDir` upward; the first directory containing a
21
+ * `.agents/` directory is the workspace root. The user's home directory
22
+ * itself is never a workspace root (a `~/.agents/` global convention, if
23
+ * one ever exists, is a separate decision), and reaching the filesystem
24
+ * root yields `null` — pure global-only behavior.
25
+ */
26
+ export function findWorkspaceRoot(startDir) {
27
+ const home = resolve(homedir());
28
+ let dir = resolve(startDir);
29
+ while (true) {
30
+ if (dir === home)
31
+ return null;
32
+ const candidate = join(dir, WORKSPACE_DIR_NAME);
33
+ try {
34
+ if (statSync(candidate).isDirectory())
35
+ return dir;
36
+ }
37
+ catch {
38
+ // not present or unreadable — keep walking
39
+ }
40
+ const parent = dirname(dir);
41
+ if (parent === dir)
42
+ return null;
43
+ dir = parent;
44
+ }
45
+ }
46
+ function isTombstone(def) {
47
+ return def.enabled === false && def.transport === undefined;
48
+ }
49
+ /** Load `<workspaceDir>/.agents/` into a `WorkspaceLayer`. Every file is
50
+ * optional; a present-but-invalid `config.json` is a hard error (never a
51
+ * silent misread), with the secrets boundary enforced against `policy`. */
52
+ export function loadWorkspaceLayer(workspaceDir, policy) {
53
+ const layerRoot = join(workspaceDir, WORKSPACE_DIR_NAME);
54
+ const diagnostics = [];
55
+ const layer = { root: workspaceDir, servers: {}, tombstones: [], skills: [], diagnostics };
56
+ const configPath = join(layerRoot, "config.json");
57
+ if (existsSync(configPath)) {
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(readFileSync(configPath, "utf-8"));
61
+ }
62
+ catch (err) {
63
+ throw new Error(`${CONFIG_SOURCE_LABEL}: invalid JSON — ${err.message}`);
64
+ }
65
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
66
+ throw new Error(`${CONFIG_SOURCE_LABEL}: top level must be an object { "version", "mcp" }`);
67
+ }
68
+ const { version, mcp, ...extra } = parsed;
69
+ const configVersion = version === undefined ? WORKSPACE_CONFIG_VERSION : version;
70
+ if (typeof configVersion !== "number" || !Number.isInteger(configVersion) || configVersion < 1) {
71
+ throw new Error(`${CONFIG_SOURCE_LABEL}: "version" must be a positive integer, got ${JSON.stringify(version)}`);
72
+ }
73
+ if (configVersion > WORKSPACE_CONFIG_VERSION) {
74
+ throw new Error(`${CONFIG_SOURCE_LABEL}: schema version ${configVersion} is newer than this Trellis understands (${WORKSPACE_CONFIG_VERSION}) — upgrade Trellis`);
75
+ }
76
+ for (const key of Object.keys(extra)) {
77
+ diagnostics.push(`${CONFIG_SOURCE_LABEL}: unrecognized top-level key "${key}" — ignored`);
78
+ }
79
+ if (mcp !== undefined) {
80
+ if (typeof mcp !== "object" || mcp === null || typeof mcp.servers !== "object" || mcp.servers === null || Array.isArray(mcp.servers)) {
81
+ throw new Error(`${CONFIG_SOURCE_LABEL}: "mcp.servers" must be an object mapping server names to definitions`);
82
+ }
83
+ for (const [name, rawDef] of Object.entries(mcp.servers)) {
84
+ if (typeof rawDef !== "object" || rawDef === null) {
85
+ throw new Error(`${CONFIG_SOURCE_LABEL}: "${name}" must be a server definition object or { "enabled": false }`);
86
+ }
87
+ if (isTombstone(rawDef)) {
88
+ layer.tombstones.push(name);
89
+ continue;
90
+ }
91
+ // enabled:false WITH a transport is tombstone semantics too — it
92
+ // disables the same-ID global entry and renders nothing.
93
+ if (rawDef.enabled === false) {
94
+ layer.tombstones.push(name);
95
+ continue;
96
+ }
97
+ layer.servers[name] = fromServerDefYaml(name, rawDef, CONFIG_SOURCE_LABEL);
98
+ }
99
+ }
100
+ }
101
+ const instructionsPath = join(layerRoot, "AGENTS.md");
102
+ if (existsSync(instructionsPath)) {
103
+ layer.instructionsFile = instructionsPath;
104
+ }
105
+ const skillsRoot = join(layerRoot, "skills");
106
+ let skillEntries = [];
107
+ try {
108
+ skillEntries = readdirSync(skillsRoot);
109
+ }
110
+ catch {
111
+ // no skills/ — optional, same as every other layer file
112
+ }
113
+ for (const name of skillEntries) {
114
+ const dir = join(skillsRoot, name);
115
+ try {
116
+ if (statSync(dir).isDirectory())
117
+ layer.skills.push({ name, dir });
118
+ }
119
+ catch {
120
+ // unreadable/broken entry — skip, not a load failure
121
+ }
122
+ }
123
+ assertWorkspaceSecretsClean(layer.servers, policy);
124
+ return layer;
125
+ }
126
+ /** An exact `${NAME}` value is a reference to the global secret store, not
127
+ * a literal — the only credential-shaped content allowed in a committable
128
+ * project layer (same classification as mcpMigrateRead.ts). */
129
+ const VAR_REF_RE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
130
+ /**
131
+ * Load-time secrets boundary for the project layer (workspace-scope spec
132
+ * "Secrets stay global-only"): scans the fields a pasted secret could
133
+ * reach — `env` entries, `staticEnv` values, `headers` values, and OAuth
134
+ * client metadata — and refuses the load, naming the server and field but
135
+ * never echoing the value.
136
+ */
137
+ function assertWorkspaceSecretsClean(servers, policy) {
138
+ const check = (server, field, value) => {
139
+ if (value === undefined || VAR_REF_RE.test(value))
140
+ return;
141
+ for (const pattern of policy.rejectPatterns) {
142
+ if (pattern.test(value)) {
143
+ throw new Error(`${CONFIG_SOURCE_LABEL}: "${server}" ${field} matches a secrets.policy.yaml reject pattern — the project layer is committable and must carry only \${VAR} references; move the value to the global secret store (the offending value is intentionally not printed)`);
144
+ }
145
+ }
146
+ };
147
+ for (const [name, def] of Object.entries(servers)) {
148
+ for (const entry of def.env ?? [])
149
+ check(name, "env", entry);
150
+ for (const [key, value] of Object.entries(def.staticEnv ?? {}))
151
+ check(name, `static_env.${key}`, value);
152
+ for (const [key, value] of Object.entries(def.headers ?? {}))
153
+ check(name, `headers.${key}`, value);
154
+ const meta = oauthClientMetadata(def.auth);
155
+ check(name, "auth.client_id", meta.clientId);
156
+ check(name, "auth.client_secret_env", meta.clientSecretEnv);
157
+ }
158
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Write-side helpers for the workspace layer (trellis-workspace-scope
3
+ * tasks 3.3/3.4): scaffolding `.agents/` (`init --workspace`) and
4
+ * upserting/removing a server in `.agents/config.json`
5
+ * (`mcp add --workspace` / `mcp remove --workspace`).
6
+ *
7
+ * The read side lives in src/lib/workspace.ts (`loadWorkspaceLayer`) —
8
+ * same file, same schema, one validator (via `toServerDefYaml`, so a
9
+ * `McpServerDef` serializes to the exact snake_case shape the loader
10
+ * validates back). Everything written here is committable by design:
11
+ * only `${VAR}` references ever land in `config.json`, never values
12
+ * (the loader refuses literals at read time; these helpers never
13
+ * resolve anything in the first place).
14
+ */
15
+ import type { McpServerDef } from "../core/types.js";
16
+ import { type McpServerDefYaml } from "../core/mcpServerDef.js";
17
+ import type { BackupSession } from "./backup.js";
18
+ export interface WorkspaceConfigJson {
19
+ version?: number;
20
+ mcp?: {
21
+ servers?: Record<string, McpServerDefYaml>;
22
+ };
23
+ [key: string]: unknown;
24
+ }
25
+ export declare function workspaceConfigPath(workspaceRoot: string): string;
26
+ /** `{}` when the file doesn't exist yet (a workspace may carry only
27
+ * skills/AGENTS.md); a clear error naming the file when it exists but
28
+ * doesn't parse — never a raw JSON stack. */
29
+ export declare function readWorkspaceConfigJson(workspaceRoot: string): WorkspaceConfigJson;
30
+ export declare function writeWorkspaceConfigJson(workspaceRoot: string, config: WorkspaceConfigJson, backup?: BackupSession): void;
31
+ /** Insert or replace `mcp.servers.<name>` with the def serialized through
32
+ * the shared YAML/JSON translator. `version` is filled in when absent —
33
+ * an existing file's version (and any unknown keys) is preserved. */
34
+ export declare function upsertWorkspaceServer(workspaceRoot: string, name: string, def: McpServerDef, backup?: BackupSession): void;
35
+ /** Delete `mcp.servers.<name>`, preserving every other key (unknown keys
36
+ * included, same posture as the upsert). Returns false — and writes
37
+ * nothing — when the entry isn't there. The follow-up `mcp sync` is what
38
+ * propagates the deletion to the rendered project-level files. */
39
+ export declare function removeWorkspaceServer(workspaceRoot: string, name: string, backup?: BackupSession): boolean;
40
+ export interface WorkspaceScaffoldFile {
41
+ path: string;
42
+ action: "create" | "already-present";
43
+ }
44
+ /** Exported for `migrate --workspace`: a `.agents/AGENTS.md` holding
45
+ * exactly this template is treated as still-empty scaffolding that an
46
+ * instructions import may replace, not as user content to conflict on. */
47
+ export declare const WORKSPACE_AGENTS_MD_TEMPLATE = "# Project instructions\n\nShared instructions for every agent working in this repository. Trellis\ndelivers this file to agents that don't read `.agents/AGENTS.md`\nnatively (Codex/pi/Kimi do; Kiro gets a steering copy; Claude Code keeps\nits own CLAUDE.md).\n";
48
+ /** Fill-missing-only scaffold of `.agents/`: never overwrites anything
49
+ * that already exists, so re-running is a no-op (idempotent by
50
+ * construction, same posture as global `trellis init`). */
51
+ export declare function scaffoldWorkspace(workspaceRoot: string, write?: boolean): WorkspaceScaffoldFile[];
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Write-side helpers for the workspace layer (trellis-workspace-scope
3
+ * tasks 3.3/3.4): scaffolding `.agents/` (`init --workspace`) and
4
+ * upserting/removing a server in `.agents/config.json`
5
+ * (`mcp add --workspace` / `mcp remove --workspace`).
6
+ *
7
+ * The read side lives in src/lib/workspace.ts (`loadWorkspaceLayer`) —
8
+ * same file, same schema, one validator (via `toServerDefYaml`, so a
9
+ * `McpServerDef` serializes to the exact snake_case shape the loader
10
+ * validates back). Everything written here is committable by design:
11
+ * only `${VAR}` references ever land in `config.json`, never values
12
+ * (the loader refuses literals at read time; these helpers never
13
+ * resolve anything in the first place).
14
+ */
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+ import { toServerDefYaml } from "../core/mcpServerDef.js";
18
+ import { WORKSPACE_CONFIG_VERSION, WORKSPACE_DIR_NAME } from "./workspace.js";
19
+ export function workspaceConfigPath(workspaceRoot) {
20
+ return join(workspaceRoot, WORKSPACE_DIR_NAME, "config.json");
21
+ }
22
+ /** `{}` when the file doesn't exist yet (a workspace may carry only
23
+ * skills/AGENTS.md); a clear error naming the file when it exists but
24
+ * doesn't parse — never a raw JSON stack. */
25
+ export function readWorkspaceConfigJson(workspaceRoot) {
26
+ const path = workspaceConfigPath(workspaceRoot);
27
+ if (!existsSync(path))
28
+ return {};
29
+ try {
30
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
31
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
32
+ throw new Error("top level must be an object");
33
+ }
34
+ return parsed;
35
+ }
36
+ catch (err) {
37
+ throw new Error(`${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
38
+ }
39
+ }
40
+ export function writeWorkspaceConfigJson(workspaceRoot, config, backup) {
41
+ const path = workspaceConfigPath(workspaceRoot);
42
+ mkdirSync(dirname(path), { recursive: true });
43
+ const content = `${JSON.stringify(config, null, 2)}\n`;
44
+ if (backup) {
45
+ backup.writeFile(path, content);
46
+ return;
47
+ }
48
+ writeFileSync(path, content);
49
+ }
50
+ /** Insert or replace `mcp.servers.<name>` with the def serialized through
51
+ * the shared YAML/JSON translator. `version` is filled in when absent —
52
+ * an existing file's version (and any unknown keys) is preserved. */
53
+ export function upsertWorkspaceServer(workspaceRoot, name, def, backup) {
54
+ const config = readWorkspaceConfigJson(workspaceRoot);
55
+ const servers = { ...(config.mcp?.servers ?? {}), [name]: toServerDefYaml(def) };
56
+ const next = {
57
+ ...config,
58
+ version: config.version ?? WORKSPACE_CONFIG_VERSION,
59
+ mcp: { ...(config.mcp ?? {}), servers },
60
+ };
61
+ writeWorkspaceConfigJson(workspaceRoot, next, backup);
62
+ }
63
+ /** Delete `mcp.servers.<name>`, preserving every other key (unknown keys
64
+ * included, same posture as the upsert). Returns false — and writes
65
+ * nothing — when the entry isn't there. The follow-up `mcp sync` is what
66
+ * propagates the deletion to the rendered project-level files. */
67
+ export function removeWorkspaceServer(workspaceRoot, name, backup) {
68
+ const config = readWorkspaceConfigJson(workspaceRoot);
69
+ const servers = config.mcp?.servers;
70
+ if (!servers || !(name in servers))
71
+ return false;
72
+ const nextServers = { ...servers };
73
+ delete nextServers[name];
74
+ const next = {
75
+ ...config,
76
+ mcp: { ...(config.mcp ?? {}), servers: nextServers },
77
+ };
78
+ writeWorkspaceConfigJson(workspaceRoot, next, backup);
79
+ return true;
80
+ }
81
+ /** Exported for `migrate --workspace`: a `.agents/AGENTS.md` holding
82
+ * exactly this template is treated as still-empty scaffolding that an
83
+ * instructions import may replace, not as user content to conflict on. */
84
+ export const WORKSPACE_AGENTS_MD_TEMPLATE = `# Project instructions
85
+
86
+ Shared instructions for every agent working in this repository. Trellis
87
+ delivers this file to agents that don't read \`.agents/AGENTS.md\`
88
+ natively (Codex/pi/Kimi do; Kiro gets a steering copy; Claude Code keeps
89
+ its own CLAUDE.md).
90
+ `;
91
+ /** Fill-missing-only scaffold of `.agents/`: never overwrites anything
92
+ * that already exists, so re-running is a no-op (idempotent by
93
+ * construction, same posture as global `trellis init`). */
94
+ export function scaffoldWorkspace(workspaceRoot, write = true) {
95
+ const agentsDir = join(workspaceRoot, WORKSPACE_DIR_NAME);
96
+ const configPath = workspaceConfigPath(workspaceRoot);
97
+ const skillsDir = join(agentsDir, "skills");
98
+ const agentsMdPath = join(agentsDir, "AGENTS.md");
99
+ if (write) {
100
+ mkdirSync(agentsDir, { recursive: true });
101
+ mkdirSync(skillsDir, { recursive: true });
102
+ }
103
+ const files = [];
104
+ files.push(existsSync(configPath)
105
+ ? { path: configPath, action: "already-present" }
106
+ : { path: configPath, action: "create" });
107
+ if (!existsSync(configPath) && write) {
108
+ writeFileSync(configPath, `${JSON.stringify({ version: WORKSPACE_CONFIG_VERSION, mcp: { servers: {} } }, null, 2)}\n`);
109
+ }
110
+ files.push(existsSync(agentsMdPath)
111
+ ? { path: agentsMdPath, action: "already-present" }
112
+ : { path: agentsMdPath, action: "create" });
113
+ if (!existsSync(agentsMdPath) && write) {
114
+ writeFileSync(agentsMdPath, WORKSPACE_AGENTS_MD_TEMPLATE);
115
+ }
116
+ return files;
117
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Workspace attribution for `trellis doctor` (trellis-workspace-scope
3
+ * task 3.5): given the workspace-merged canonical source, reports which
4
+ * layer each MCP server / skill comes from, what the workspace shadowed
5
+ * or tombstoned, which `${VAR}` references in workspace server defs
6
+ * don't resolve under the current secrets policy, which project-level
7
+ * native config files hold entries Trellis never rendered (the
8
+ * `migrate --workspace` hint), and zcode's reverse-precedence deviation
9
+ * (task 2.5's doctor half). Read-only by construction — it computes
10
+ * from the already-loaded canonical source and file reads only.
11
+ */
12
+ import type { AgentId, CanonicalSource, LayerProvenance } from "../core/types.js";
13
+ export interface WorkspaceLayerEntry {
14
+ name: string;
15
+ layer: LayerProvenance;
16
+ }
17
+ export interface WorkspaceUnmigratedHint {
18
+ agent: AgentId;
19
+ path: string;
20
+ /** Entry names present in the file with no Trellis manifest record. */
21
+ entries: string[];
22
+ }
23
+ export interface WorkspaceDoctorSection {
24
+ root: string;
25
+ mcp: WorkspaceLayerEntry[];
26
+ skills: WorkspaceLayerEntry[];
27
+ /** `mcp:name` / `skill:name` entries the workspace layer overrode. */
28
+ shadowed: string[];
29
+ /** `mcp:name` entries the workspace layer tombstoned (global entry
30
+ * disabled for this project). Subset of `shadowed`. */
31
+ tombstoned: string[];
32
+ /** `${VAR}` references in workspace server defs that resolve to
33
+ * nothing under the current secrets policy. Names only — never
34
+ * values. */
35
+ unresolvedVars: {
36
+ server: string;
37
+ field: string;
38
+ name: string;
39
+ }[];
40
+ unmigrated: WorkspaceUnmigratedHint[];
41
+ /** zcode's user-level-first deviation notices (empty otherwise). */
42
+ precedenceWarnings: string[];
43
+ }
44
+ export declare function collectWorkspaceDoctorSection(canonical: CanonicalSource): WorkspaceDoctorSection | undefined;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Workspace attribution for `trellis doctor` (trellis-workspace-scope
3
+ * task 3.5): given the workspace-merged canonical source, reports which
4
+ * layer each MCP server / skill comes from, what the workspace shadowed
5
+ * or tombstoned, which `${VAR}` references in workspace server defs
6
+ * don't resolve under the current secrets policy, which project-level
7
+ * native config files hold entries Trellis never rendered (the
8
+ * `migrate --workspace` hint), and zcode's reverse-precedence deviation
9
+ * (task 2.5's doctor half). Read-only by construction — it computes
10
+ * from the already-loaded canonical source and file reads only.
11
+ */
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { resolveSecretEnv } from "./secretEnv.js";
14
+ import { loadWorkspaceRendered, workspaceMcpConfigPath, workspacePrecedenceWarnings } from "./workspaceMcp.js";
15
+ const VAR_REF_RE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
16
+ function collectVarRefs(canonical) {
17
+ const provenance = canonical.workspace?.mcpProvenance ?? {};
18
+ const refs = [];
19
+ for (const [name, def] of Object.entries(canonical.mcp.servers)) {
20
+ if (provenance[name] !== "workspace")
21
+ continue;
22
+ for (const envName of def.env ?? []) {
23
+ refs.push({ server: name, field: "env", name: envName });
24
+ }
25
+ for (const [key, value] of Object.entries(def.staticEnv ?? {})) {
26
+ const match = VAR_REF_RE.exec(value);
27
+ if (match)
28
+ refs.push({ server: name, field: `static_env.${key}`, name: match[1] });
29
+ }
30
+ for (const [key, value] of Object.entries(def.headers ?? {})) {
31
+ const match = VAR_REF_RE.exec(value);
32
+ if (match)
33
+ refs.push({ server: name, field: `headers.${key}`, name: match[1] });
34
+ }
35
+ }
36
+ return refs;
37
+ }
38
+ /** 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. */
42
+ function unmarkedEntries(agentId, configPath, recorded) {
43
+ if (!existsSync(configPath))
44
+ return [];
45
+ let names = [];
46
+ if (agentId === "codex") {
47
+ const content = readFileSync(configPath, "utf-8");
48
+ const re = /^\[mcp_servers\.([^\]]+)\]\s*$/gm;
49
+ let match;
50
+ while ((match = re.exec(content)) !== null)
51
+ names.push(match[1]);
52
+ }
53
+ else {
54
+ try {
55
+ const parsed = JSON.parse(readFileSync(configPath, "utf-8"));
56
+ const servers = agentId === "zcode"
57
+ ? (parsed.mcp?.servers ?? {})
58
+ : (parsed.mcpServers ?? {});
59
+ names = Object.keys(servers);
60
+ }
61
+ catch {
62
+ return []; // unparsable file is the sync guard's finding, not doctor's
63
+ }
64
+ }
65
+ return names.filter((name) => recorded[name] === undefined);
66
+ }
67
+ export function collectWorkspaceDoctorSection(canonical) {
68
+ const workspace = canonical.workspace;
69
+ if (!workspace)
70
+ return undefined;
71
+ const mcp = Object.keys(canonical.mcp.servers)
72
+ .map((name) => ({ name, layer: workspace.mcpProvenance[name] ?? "global" }))
73
+ .sort((a, b) => a.name.localeCompare(b.name));
74
+ const skills = canonical.skills
75
+ .map((s) => ({ name: s.name, layer: workspace.skillProvenance[s.name] ?? "global" }))
76
+ .sort((a, b) => a.name.localeCompare(b.name));
77
+ // A shadowed mcp entry that no longer exists in the merged source was
78
+ // tombstoned; one still present (with workspace provenance) was
79
+ // overridden.
80
+ const tombstoned = workspace.shadowed.filter((entry) => entry.startsWith("mcp:") && canonical.mcp.servers[entry.slice(4)] === undefined);
81
+ const refs = collectVarRefs(canonical);
82
+ const resolved = resolveSecretEnv([...new Set(refs.map((r) => r.name))], canonical.secretsPolicy);
83
+ const unresolvedVars = refs.filter((r) => resolved[r.name] === undefined);
84
+ const manifest = loadWorkspaceRendered(workspace.root);
85
+ const unmigrated = [];
86
+ for (const agentId of canonical.managedAgents) {
87
+ 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 });
91
+ }
92
+ const precedenceWarnings = canonical.managedAgents.flatMap((id) => workspacePrecedenceWarnings(id, canonical));
93
+ return { root: workspace.root, mcp, skills, shadowed: workspace.shadowed, tombstoned, unresolvedVars, unmigrated, precedenceWarnings };
94
+ }