agent-trellis 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) 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/piMcp.d.ts +6 -3
  7. package/dist/adapters/piMcp.js +10 -3
  8. package/dist/adapters/zcode.d.ts +12 -1
  9. package/dist/adapters/zcode.js +7 -6
  10. package/dist/cli.js +38 -5
  11. package/dist/commands/doctor.d.ts +9 -1
  12. package/dist/commands/doctor.js +43 -6
  13. package/dist/commands/init.d.ts +6 -0
  14. package/dist/commands/init.js +24 -0
  15. package/dist/commands/mcp.d.ts +44 -1
  16. package/dist/commands/mcp.js +145 -33
  17. package/dist/commands/migrate.d.ts +12 -0
  18. package/dist/commands/migrate.js +8 -4
  19. package/dist/commands/migrateWorkspace.d.ts +40 -0
  20. package/dist/commands/migrateWorkspace.js +197 -0
  21. package/dist/commands/rollback.js +65 -35
  22. package/dist/commands/skill.d.ts +14 -0
  23. package/dist/commands/skill.js +71 -0
  24. package/dist/commands/sync.d.ts +16 -1
  25. package/dist/commands/sync.js +48 -15
  26. package/dist/core/adapter.d.ts +14 -0
  27. package/dist/core/canonical.d.ts +20 -32
  28. package/dist/core/canonical.js +18 -86
  29. package/dist/core/mcpServerDef.d.ts +39 -0
  30. package/dist/core/mcpServerDef.js +93 -0
  31. package/dist/core/types.d.ts +43 -4
  32. package/dist/lib/mcpMigrateRead.d.ts +25 -0
  33. package/dist/lib/mcpMigrateRead.js +9 -2
  34. package/dist/lib/skillProvider.js +18 -5
  35. package/dist/lib/workspace.d.ts +39 -0
  36. package/dist/lib/workspace.js +158 -0
  37. package/dist/lib/workspaceConfig.d.ts +45 -0
  38. package/dist/lib/workspaceConfig.js +98 -0
  39. package/dist/lib/workspaceDoctor.d.ts +44 -0
  40. package/dist/lib/workspaceDoctor.js +94 -0
  41. package/dist/lib/workspaceFiles.d.ts +38 -0
  42. package/dist/lib/workspaceFiles.js +264 -0
  43. package/dist/lib/workspaceMcp.d.ts +57 -0
  44. package/dist/lib/workspaceMcp.js +277 -0
  45. package/dist/lib/workspaceMerge.d.ts +37 -0
  46. package/dist/lib/workspaceMerge.js +98 -0
  47. package/dist/lib/workspaceMigrate.d.ts +118 -0
  48. package/dist/lib/workspaceMigrate.js +423 -0
  49. package/dist/pi-bridge/bundle.js +262 -96
  50. package/docs/architecture.md +111 -49
  51. package/docs/getting-started.md +102 -14
  52. package/docs/mcp-oauth-matrix.md +12 -1
  53. package/docs/overview.zh-CN.md +17 -0
  54. package/docs/research.md +28 -6
  55. package/docs/roadmap.md +17 -5
  56. package/package.json +1 -1
  57. package/docs/implementation-plan.md +0 -341
@@ -98,6 +98,20 @@ export interface AdapterPlanItem {
98
98
  skillsEnabled: boolean | null;
99
99
  };
100
100
  };
101
+ /** Set when this item renders the workspace (`.agents/`) layer rather
102
+ * than the global one (trellis-workspace-scope) — the command layer
103
+ * routes these to the workspace apply functions, never to the agent's
104
+ * global adapter apply. */
105
+ layer?: "workspace";
106
+ /** Only set when `layer === "workspace"`, `kind === "skill"`, and
107
+ * `action === "create"`: the canonical directory to copy into `target`
108
+ * (workspace skills are COPIED, never symlinked — rendered artifacts
109
+ * must be committable and Windows-safe, design D5). */
110
+ copyFrom?: string;
111
+ /** Only set when `layer === "workspace"`, `kind === "instructions"`, and
112
+ * `action === "create"`: the full rendered file content (managed marker
113
+ * header + workspace AGENTS.md body) to write at `target`. */
114
+ fileContent?: string;
101
115
  }
102
116
  export interface AdapterVerifyResult {
103
117
  ok: boolean;
@@ -1,38 +1,14 @@
1
1
  /**
2
- * Loads `~/.trellis/` into a `CanonicalSource`. Global only — no `root`
3
- * parameter, no workspace merge (docs/architecture.md "Global vs.
4
- * workspace scope"). See openspec/changes/trellis-sync-p1/specs/
5
- * canonical-source-loading/spec.md for the exact contract this implements.
2
+ * Loads `~/.trellis/` into a `CanonicalSource`. Global-only by default —
3
+ * no working-directory consultation; a caller that explicitly discovered a
4
+ * workspace root (src/lib/workspace.ts `findWorkspaceRoot`) may pass it in
5
+ * to layer `<workspace>/.agents/` over the global source
6
+ * (openspec/changes/trellis-workspace-scope, design D2).
6
7
  */
7
8
  import type { AgentId, CapabilityDelivery, CanonicalSource, McpRoute, McpServerDef, Scope } from "./types.js";
8
9
  import type { BackupSession } from "../lib/backup.js";
9
- /**
10
- * The on-disk shape for one server entry — `static_env`/`env_aliases`
11
- * (snake_case, like every other multi-word key across `.trellis/*.yaml`)
12
- * are translated to `McpServerDef.staticEnv`/`envAliases` (camelCase)
13
- * below, and `auth`'s object form carries `client_id`/`client_secret_env`
14
- * the same way (trellis-mcp-static-env-and-disabled-servers,
15
- * trellis-migrate-env-var-alias, trellis-mcp-oauth-static-client).
16
- */
17
- type McpServerDefYaml = Omit<McpServerDef, "staticEnv" | "envAliases" | "auth"> & {
18
- static_env?: Record<string, string>;
19
- env_aliases?: Record<string, string>;
20
- auth?: McpAuthYaml;
21
- };
22
- /** The on-disk `auth`: the `oauth` scalar exactly as before, or the object
23
- * form whose keys are snake_case like every other multi-word key in
24
- * `.trellis/*.yaml`. Values are `unknown` until `fromAuthYaml` validates
25
- * them — a YAML cast is optimistic, validation is this module's job. */
26
- type McpAuthYaml = string | {
27
- kind?: unknown;
28
- owner?: unknown;
29
- client_id?: unknown;
30
- client_secret_env?: unknown;
31
- };
32
- /** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
33
- * `undefined` fields so the written YAML never gets a literal `null`
34
- * for an omitted optional. */
35
- export declare function toServerDefYaml(def: McpServerDef): McpServerDefYaml;
10
+ export { toServerDefYaml } from "./mcpServerDef.js";
11
+ export type { McpServerDefYaml } from "./mcpServerDef.js";
36
12
  export type ServersYamlWriteResult = {
37
13
  ok: true;
38
14
  } | {
@@ -138,6 +114,18 @@ export declare function ensureGitignoreEntry(gitignorePath: string, line: string
138
114
  * `export` prefix of its own.
139
115
  */
140
116
  export declare function ensureShellEnvSource(rcPath: string, envFilePath: string, backup?: BackupSession): void;
117
+ /**
118
+ * Options form of `loadCanonicalSource` (trellis-workspace-scope design D2).
119
+ * `homeDir` anchors BOTH the global canonical root and user-home semantics
120
+ * (secrets `env_file` `~` expansion) at all times; `workspaceDir` — a root
121
+ * previously discovered via `findWorkspaceRoot` — only adds the `.agents/`
122
+ * project layer on top, never relocates either.
123
+ */
124
+ export interface LoadCanonicalOptions {
125
+ homeDir?: string;
126
+ workspaceDir?: string | null;
127
+ }
141
128
  /** Load the global canonical source from the supplied home directory. */
142
129
  export declare function loadCanonicalSource(homeDir?: string): CanonicalSource;
143
- export {};
130
+ /** Load the global canonical source, optionally layered with an explicit workspace's `.agents/`. */
131
+ export declare function loadCanonicalSource(options: LoadCanonicalOptions): CanonicalSource;
@@ -1,93 +1,19 @@
1
1
  /**
2
- * Loads `~/.trellis/` into a `CanonicalSource`. Global only — no `root`
3
- * parameter, no workspace merge (docs/architecture.md "Global vs.
4
- * workspace scope"). See openspec/changes/trellis-sync-p1/specs/
5
- * canonical-source-loading/spec.md for the exact contract this implements.
2
+ * Loads `~/.trellis/` into a `CanonicalSource`. Global-only by default —
3
+ * no working-directory consultation; a caller that explicitly discovered a
4
+ * workspace root (src/lib/workspace.ts `findWorkspaceRoot`) may pass it in
5
+ * to layer `<workspace>/.agents/` over the global source
6
+ * (openspec/changes/trellis-workspace-scope, design D2).
6
7
  */
7
8
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
8
9
  import { homedir } from "node:os";
9
10
  import { basename, dirname, join } from "node:path";
10
11
  import { isMap, isSeq, parse as parseYaml, parseDocument } from "yaml";
11
12
  import { ALL_AGENTS, oauthClientMetadata } from "./types.js";
12
- import { isValidSecretVarName } from "../lib/secretEnv.js";
13
- function fromServerDefYaml(serverName, def) {
14
- const { static_env, env_aliases, auth, ...rest } = def;
15
- const out = { ...rest };
16
- if (static_env)
17
- out.staticEnv = static_env;
18
- if (env_aliases)
19
- out.envAliases = env_aliases;
20
- if (auth !== undefined)
21
- out.auth = fromAuthYaml(serverName, auth);
22
- return out;
23
- }
24
- /**
25
- * `auth` translation and shape validation
26
- * (trellis-mcp-oauth-static-client tasks 1.1/1.3): the scalar and the
27
- * object form share one discriminator — `kind: oauth` — and the object
28
- * accepts exactly `kind`, `owner`, `client_id`, `client_secret_env`. Everything
29
- * else is refused here at load, naming the field but never echoing the
30
- * value: an invalid `client_secret_env` is at best a typo, at worst a
31
- * pasted secret.
32
- */
33
- function fromAuthYaml(serverName, auth) {
34
- if (auth === "oauth")
35
- return "oauth";
36
- if (typeof auth !== "object" || auth === null) {
37
- throw new Error(`mcp/servers.yaml: "${serverName}" auth must be the scalar "oauth" or { kind: oauth, client_id?, client_secret_env? }`);
38
- }
39
- const { kind, owner, client_id, client_secret_env, ...extra } = auth;
40
- const unknownKey = Object.keys(extra)[0];
41
- if (unknownKey !== undefined) {
42
- throw new Error(`mcp/servers.yaml: "${serverName}" auth has unrecognized key "${unknownKey}" — expected kind, owner, client_id, client_secret_env`);
43
- }
44
- if (kind !== "oauth") {
45
- throw new Error(`mcp/servers.yaml: "${serverName}" auth.kind must be "oauth" — kind is the only recognized discriminator`);
46
- }
47
- if (client_id !== undefined && typeof client_id !== "string") {
48
- throw new Error(`mcp/servers.yaml: "${serverName}" auth.client_id must be a string`);
49
- }
50
- if (client_secret_env !== undefined && (typeof client_secret_env !== "string" || !isValidSecretVarName(client_secret_env))) {
51
- throw new Error(`mcp/servers.yaml: "${serverName}" auth.client_secret_env must be a variable NAME (e.g. FIGMA_CLIENT_SECRET), never a secret value`);
52
- }
53
- const out = { kind: "oauth" };
54
- if (owner !== undefined && owner !== "agent" && owner !== "trellis")
55
- throw new Error(`mcp/servers.yaml: "${serverName}" auth.owner must be agent or trellis`);
56
- if (owner !== undefined)
57
- out.owner = owner;
58
- if (typeof client_id === "string")
59
- out.clientId = client_id;
60
- if (typeof client_secret_env === "string")
61
- out.clientSecretEnv = client_secret_env;
62
- return out;
63
- }
64
- /** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
65
- * `undefined` fields so the written YAML never gets a literal `null`
66
- * for an omitted optional. */
67
- export function toServerDefYaml(def) {
68
- const { staticEnv, envAliases, auth, ...rest } = def;
69
- const out = { ...rest };
70
- if (staticEnv)
71
- out.static_env = staticEnv;
72
- if (envAliases)
73
- out.env_aliases = envAliases;
74
- if (auth !== undefined) {
75
- out.auth =
76
- typeof auth === "string"
77
- ? auth
78
- : {
79
- kind: auth.kind,
80
- ...(auth.owner !== undefined ? { owner: auth.owner } : {}),
81
- ...(auth.clientId !== undefined ? { client_id: auth.clientId } : {}),
82
- ...(auth.clientSecretEnv !== undefined ? { client_secret_env: auth.clientSecretEnv } : {}),
83
- };
84
- }
85
- for (const key of Object.keys(out)) {
86
- if (out[key] === undefined)
87
- delete out[key];
88
- }
89
- return out;
90
- }
13
+ import { fromServerDefYaml, toServerDefYaml } from "./mcpServerDef.js";
14
+ import { loadWorkspaceLayer } from "../lib/workspace.js";
15
+ import { mergeWorkspaceLayer } from "../lib/workspaceMerge.js";
16
+ export { toServerDefYaml } from "./mcpServerDef.js";
91
17
  function trellisRoot(homeDir) {
92
18
  return join(homeDir, ".trellis");
93
19
  }
@@ -559,8 +485,9 @@ function assertAuthMetadataClean(mcp, policy) {
559
485
  }
560
486
  }
561
487
  }
562
- /** Load the global canonical source from the supplied home directory. */
563
- export function loadCanonicalSource(homeDir = homedir()) {
488
+ export function loadCanonicalSource(arg = {}) {
489
+ const options = typeof arg === "string" ? { homeDir: arg } : arg;
490
+ const homeDir = options.homeDir ?? homedir();
564
491
  const root = trellisRoot(homeDir);
565
492
  if (!existsSync(root)) {
566
493
  throw new Error(`No canonical source at ${root}. Run trellis init before trellis sync.`);
@@ -603,7 +530,7 @@ export function loadCanonicalSource(homeDir = homedir()) {
603
530
  const mcp = loadServersYaml(join(root, "mcp", "servers.yaml"));
604
531
  const secretsPolicy = loadSecretsPolicyYaml(join(root, "secrets.policy.yaml"), homeDir);
605
532
  assertAuthMetadataClean(mcp, secretsPolicy);
606
- return {
533
+ const source = {
607
534
  instructionsFile: join(root, "agents.md"),
608
535
  managedAgents,
609
536
  skills,
@@ -613,4 +540,9 @@ export function loadCanonicalSource(homeDir = homedir()) {
613
540
  secretsPolicy,
614
541
  diagnostics,
615
542
  };
543
+ if (options.workspaceDir) {
544
+ const layer = loadWorkspaceLayer(options.workspaceDir, secretsPolicy);
545
+ return mergeWorkspaceLayer(source, layer);
546
+ }
547
+ return source;
616
548
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * On-disk ⇄ canonical translation for one MCP server entry, shared by
3
+ * `~/.trellis/mcp/servers.yaml` (YAML) and `<workspace>/.agents/config.json`
4
+ * (JSON). Both use the identical snake_case field set so there is exactly
5
+ * one server schema and one validator (trellis-workspace-scope design D1);
6
+ * the `source` label exists only so diagnostics name the file the user
7
+ * actually edited.
8
+ */
9
+ import type { McpServerDef } from "./types.js";
10
+ /**
11
+ * The on-disk shape for one server entry — `static_env`/`env_aliases`
12
+ * (snake_case, like every other multi-word key across `.trellis/*.yaml`)
13
+ * are translated to `McpServerDef.staticEnv`/`envAliases` (camelCase)
14
+ * below, and `auth`'s object form carries `client_id`/`client_secret_env`/
15
+ * `client_name` the same way (trellis-mcp-static-env-and-disabled-servers,
16
+ * trellis-migrate-env-var-alias, trellis-mcp-oauth-static-client,
17
+ * oauth-client-name).
18
+ */
19
+ export type McpServerDefYaml = Omit<McpServerDef, "staticEnv" | "envAliases" | "auth"> & {
20
+ static_env?: Record<string, string>;
21
+ env_aliases?: Record<string, string>;
22
+ auth?: McpAuthYaml;
23
+ };
24
+ /** The on-disk `auth`: the `oauth` scalar exactly as before, or the object
25
+ * form whose keys are snake_case like every other multi-word key in
26
+ * `.trellis/*.yaml`. Values are `unknown` until `fromAuthYaml` validates
27
+ * them — a YAML/JSON cast is optimistic, validation is this module's job. */
28
+ export type McpAuthYaml = string | {
29
+ kind?: unknown;
30
+ owner?: unknown;
31
+ client_id?: unknown;
32
+ client_secret_env?: unknown;
33
+ client_name?: unknown;
34
+ };
35
+ export declare function fromServerDefYaml(serverName: string, def: McpServerDefYaml, source?: string): McpServerDef;
36
+ /** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
37
+ * `undefined` fields so the written file never gets a literal `null`
38
+ * for an omitted optional. */
39
+ export declare function toServerDefYaml(def: McpServerDef): McpServerDefYaml;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * On-disk ⇄ canonical translation for one MCP server entry, shared by
3
+ * `~/.trellis/mcp/servers.yaml` (YAML) and `<workspace>/.agents/config.json`
4
+ * (JSON). Both use the identical snake_case field set so there is exactly
5
+ * one server schema and one validator (trellis-workspace-scope design D1);
6
+ * the `source` label exists only so diagnostics name the file the user
7
+ * actually edited.
8
+ */
9
+ import { isValidSecretVarName } from "../lib/secretEnv.js";
10
+ export function fromServerDefYaml(serverName, def, source = "mcp/servers.yaml") {
11
+ const { static_env, env_aliases, auth, ...rest } = def;
12
+ const out = { ...rest };
13
+ if (static_env)
14
+ out.staticEnv = static_env;
15
+ if (env_aliases)
16
+ out.envAliases = env_aliases;
17
+ if (auth !== undefined)
18
+ out.auth = fromAuthYaml(serverName, auth, source);
19
+ return out;
20
+ }
21
+ /**
22
+ * `auth` translation and shape validation
23
+ * (trellis-mcp-oauth-static-client tasks 1.1/1.3): the scalar and the
24
+ * object form share one discriminator — `kind: oauth` — and the object
25
+ * accepts exactly `kind`, `owner`, `client_id`, `client_secret_env`. Everything
26
+ * else is refused here at load, naming the field but never echoing the
27
+ * value: an invalid `client_secret_env` is at best a typo, at worst a
28
+ * pasted secret.
29
+ */
30
+ function fromAuthYaml(serverName, auth, source) {
31
+ if (auth === "oauth")
32
+ return "oauth";
33
+ if (typeof auth !== "object" || auth === null) {
34
+ throw new Error(`${source}: "${serverName}" auth must be the scalar "oauth" or { kind: oauth, client_id?, client_secret_env?, client_name? }`);
35
+ }
36
+ const { kind, owner, client_id, client_secret_env, client_name, ...extra } = auth;
37
+ const unknownKey = Object.keys(extra)[0];
38
+ if (unknownKey !== undefined) {
39
+ throw new Error(`${source}: "${serverName}" auth has unrecognized key "${unknownKey}" — expected kind, owner, client_id, client_secret_env, client_name`);
40
+ }
41
+ if (kind !== "oauth") {
42
+ throw new Error(`${source}: "${serverName}" auth.kind must be "oauth" — kind is the only recognized discriminator`);
43
+ }
44
+ if (client_id !== undefined && typeof client_id !== "string") {
45
+ throw new Error(`${source}: "${serverName}" auth.client_id must be a string`);
46
+ }
47
+ if (client_secret_env !== undefined && (typeof client_secret_env !== "string" || !isValidSecretVarName(client_secret_env))) {
48
+ throw new Error(`${source}: "${serverName}" auth.client_secret_env must be a variable NAME (e.g. FIGMA_CLIENT_SECRET), never a secret value`);
49
+ }
50
+ if (client_name !== undefined && typeof client_name !== "string") {
51
+ throw new Error(`${source}: "${serverName}" auth.client_name must be a string`);
52
+ }
53
+ const out = { kind: "oauth" };
54
+ if (owner !== undefined && owner !== "agent" && owner !== "trellis")
55
+ throw new Error(`${source}: "${serverName}" auth.owner must be agent or trellis`);
56
+ if (owner !== undefined)
57
+ out.owner = owner;
58
+ if (typeof client_id === "string")
59
+ out.clientId = client_id;
60
+ if (typeof client_secret_env === "string")
61
+ out.clientSecretEnv = client_secret_env;
62
+ if (typeof client_name === "string")
63
+ out.clientName = client_name;
64
+ return out;
65
+ }
66
+ /** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
67
+ * `undefined` fields so the written file never gets a literal `null`
68
+ * for an omitted optional. */
69
+ export function toServerDefYaml(def) {
70
+ const { staticEnv, envAliases, auth, ...rest } = def;
71
+ const out = { ...rest };
72
+ if (staticEnv)
73
+ out.static_env = staticEnv;
74
+ if (envAliases)
75
+ out.env_aliases = envAliases;
76
+ if (auth !== undefined) {
77
+ out.auth =
78
+ typeof auth === "string"
79
+ ? auth
80
+ : {
81
+ kind: auth.kind,
82
+ ...(auth.owner !== undefined ? { owner: auth.owner } : {}),
83
+ ...(auth.clientId !== undefined ? { client_id: auth.clientId } : {}),
84
+ ...(auth.clientSecretEnv !== undefined ? { client_secret_env: auth.clientSecretEnv } : {}),
85
+ ...(auth.clientName !== undefined ? { client_name: auth.clientName } : {}),
86
+ };
87
+ }
88
+ for (const key of Object.keys(out)) {
89
+ if (out[key] === undefined)
90
+ delete out[key];
91
+ }
92
+ return out;
93
+ }
@@ -19,6 +19,10 @@ export interface McpAuthConfig {
19
19
  owner?: "agent" | "trellis";
20
20
  clientId?: string;
21
21
  clientSecretEnv?: string;
22
+ /** Self-asserted display name an Agent presents at dynamic client
23
+ * registration — for providers that gate DCR by client name (Figma).
24
+ * Public metadata, not a credential (oauth-client-name design.md D1). */
25
+ clientName?: string;
22
26
  }
23
27
  /** True when `auth` classifies the server as OAuth, in either form. Every
24
28
  * former `def.auth === "oauth"` check must go through this now that the
@@ -299,10 +303,11 @@ export interface AgentSnapshot {
299
303
  }
300
304
  export interface CanonicalSource {
301
305
  /**
302
- * Global (`~/.trellis`) only for now. Project-local `.trellis/` and its
303
- * merge-over-global precedence are an explicit non-goal until a later
304
- * phase — see docs/roadmap.md. Don't build workspace resolution ahead of
305
- * that decision.
306
+ * Global (`~/.trellis`) by default. When the caller explicitly supplies a
307
+ * discovered workspace root, the `<workspace>/.agents/` project layer is
308
+ * merged over the global layer (workspace wins by ID, `enabled: false`
309
+ * tombstones) and `workspace` below carries the merge's provenance — see
310
+ * openspec/changes/trellis-workspace-scope.
306
311
  */
307
312
  instructionsFile: string;
308
313
  /** From `~/.trellis/managed.yaml`. Missing file and `agents: []` both
@@ -313,7 +318,41 @@ export interface CanonicalSource {
313
318
  memories: MemoryEntry[];
314
319
  mcp: McpConfig;
315
320
  secretsPolicy: SecretsPolicy;
321
+ /** Present only when the source was loaded with a workspace layer. */
322
+ workspace?: WorkspaceLayerInfo;
316
323
  /** e.g. a `scope.yaml` entry naming a skill/agent/memory that doesn't
317
324
  * exist — recorded, not a load failure. See `loadCanonicalSource`. */
318
325
  diagnostics: string[];
319
326
  }
327
+ export type LayerProvenance = "global" | "workspace";
328
+ /** Merge metadata produced when a workspace `.agents/` layer is in scope. */
329
+ export interface WorkspaceLayerInfo {
330
+ /** Absolute path of the workspace root (the directory containing `.agents/`). */
331
+ root: string;
332
+ /** `<root>/.agents/AGENTS.md`, when the workspace declares instructions. */
333
+ instructionsFile?: string;
334
+ /** Per-server source layer for every entry in `mcp.servers`. */
335
+ mcpProvenance: Record<string, LayerProvenance>;
336
+ /** The pre-merge GLOBAL layer's server map — what global (user-level)
337
+ * delivery must render, layer-preserving (workspace-delivery spec:
338
+ * "global config files are untouched by the workspace entries"). Includes
339
+ * shadowed entries with their GLOBAL definitions (zcode resolves same-name
340
+ * conflicts user-first, so the global def is what zcode will actually use)
341
+ * and tombstoned entries (a tombstone only removes the server from the
342
+ * workspace's effective view; the shared user-level files still carry the
343
+ * global def for every other context). */
344
+ mcpGlobalServers: Record<string, McpServerDef>;
345
+ /** Per-skill source layer for every entry in `skills`. */
346
+ skillProvenance: Record<string, LayerProvenance>;
347
+ /** The pre-merge GLOBAL layer's skills — what global (user-level)
348
+ * delivery must symlink, layer-preserving, same invariant as
349
+ * `mcpGlobalServers`. Includes entries the workspace shadows, with their
350
+ * GLOBAL definitions: the user-level skill dirs are shared by every
351
+ * project, so a workspace override only governs project-level delivery.
352
+ * (zcode's Runtime SkillProvider is the deliberate exception — it serves
353
+ * the merged view by design, trellis-workspace-scope task 2.2.) */
354
+ globalSkills: SkillRef[];
355
+ /** Global entries the workspace overrode or tombstoned, as
356
+ * `"mcp:<name>"` / `"skill:<name>"` — surfaced by doctor/status. */
357
+ shadowed: string[];
358
+ }
@@ -35,6 +35,30 @@ export interface McpMigrateReadResult {
35
35
  * independent derivations of it.
36
36
  */
37
37
  export declare function resolvedEnvTextMap(def: Pick<McpServerDef, "env" | "envAliases" | "staticEnv">): Record<string, string>;
38
+ /** Shared by claude-code and kiro — both write the identical JSON shape
39
+ * (`jsonMcp.ts` is their common writer). Deliberately richer than either
40
+ * probe's own read-side type: adds `headers`, which both probes' own
41
+ * types currently omit despite it being a real field Trellis itself
42
+ * writes for http/sse servers into this exact shape (confirmed via
43
+ * `grep -n "headers" src/probes/kiro.ts src/probes/claude-code.ts
44
+ * src/adapters/jsonMcp.ts` — only the writer declares it). */
45
+ interface RichJsonServerDef {
46
+ type?: "stdio" | "http" | "sse";
47
+ command?: string;
48
+ args?: string[];
49
+ url?: string;
50
+ env?: Record<string, string>;
51
+ headers?: Record<string, string>;
52
+ }
53
+ /** Also exported for the workspace-migration scanner's zcode reader
54
+ * (trellis-workspace-scope task 4.1): zcode's project config nests the
55
+ * same rich-JSON server shape under `mcp.servers`, so the walker there
56
+ * reuses this per-entry mapping unchanged. */
57
+ export declare function fromRichJsonServerDef(raw: RichJsonServerDef): McpServerDef | undefined;
58
+ /** Also the workspace-migration scanner's JSON reader (trellis-workspace-
59
+ * scope task 4.1): project-level `.mcp.json`-shape files parse through
60
+ * the exact same rules as their global counterparts. */
61
+ export declare function readRichJsonMcpDefs(configPath: string): McpMigrateReadResult;
38
62
  export declare function readClaudeCodeMcpDefs(homeDir: string): McpMigrateReadResult;
39
63
  export declare function readKiroMcpDefs(homeDir: string): McpMigrateReadResult;
40
64
  export declare function readKimiCodeMcpDefs(homeDir: string): McpMigrateReadResult;
@@ -81,3 +105,4 @@ export declare function buildCodexMcpReadResult(mcpEntries: CodexMcpEntryRich[],
81
105
  * entry is reported as unsupported instead.
82
106
  */
83
107
  export declare function readCodexMcpDefs(homeDir: string): McpMigrateReadResult;
108
+ export {};
@@ -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 = [];
@@ -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;