agent-trellis 0.14.1 → 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.
- package/dist/adapters/claude-code.js +4 -3
- package/dist/adapters/codex.js +4 -3
- package/dist/adapters/kimi-code.js +4 -3
- package/dist/adapters/kiro.js +5 -4
- package/dist/adapters/pi.js +4 -3
- package/dist/adapters/zcode.d.ts +12 -1
- package/dist/adapters/zcode.js +7 -6
- package/dist/cli.js +38 -5
- package/dist/commands/doctor.d.ts +9 -1
- package/dist/commands/doctor.js +43 -6
- package/dist/commands/init.d.ts +6 -0
- package/dist/commands/init.js +24 -0
- package/dist/commands/mcp.d.ts +44 -1
- package/dist/commands/mcp.js +145 -33
- package/dist/commands/migrate.d.ts +12 -0
- package/dist/commands/migrate.js +8 -4
- package/dist/commands/migrateWorkspace.d.ts +40 -0
- package/dist/commands/migrateWorkspace.js +197 -0
- package/dist/commands/rollback.js +65 -35
- package/dist/commands/skill.d.ts +14 -0
- package/dist/commands/skill.js +71 -0
- package/dist/commands/sync.d.ts +16 -1
- package/dist/commands/sync.js +48 -15
- package/dist/core/adapter.d.ts +14 -0
- package/dist/core/canonical.d.ts +20 -34
- package/dist/core/canonical.js +18 -92
- package/dist/core/mcpServerDef.d.ts +39 -0
- package/dist/core/mcpServerDef.js +93 -0
- package/dist/core/types.d.ts +39 -4
- package/dist/lib/mcpMigrateRead.d.ts +25 -0
- package/dist/lib/mcpMigrateRead.js +9 -2
- package/dist/lib/skillProvider.js +18 -5
- package/dist/lib/workspace.d.ts +39 -0
- package/dist/lib/workspace.js +158 -0
- package/dist/lib/workspaceConfig.d.ts +45 -0
- package/dist/lib/workspaceConfig.js +98 -0
- package/dist/lib/workspaceDoctor.d.ts +44 -0
- package/dist/lib/workspaceDoctor.js +94 -0
- package/dist/lib/workspaceFiles.d.ts +38 -0
- package/dist/lib/workspaceFiles.js +264 -0
- package/dist/lib/workspaceMcp.d.ts +57 -0
- package/dist/lib/workspaceMcp.js +277 -0
- package/dist/lib/workspaceMerge.d.ts +37 -0
- package/dist/lib/workspaceMerge.js +98 -0
- package/dist/lib/workspaceMigrate.d.ts +118 -0
- package/dist/lib/workspaceMigrate.js +423 -0
- package/dist/pi-bridge/bundle.js +258 -96
- package/docs/architecture.md +111 -49
- package/docs/getting-started.md +86 -14
- package/docs/mcp-oauth-matrix.md +12 -1
- package/docs/overview.zh-CN.md +17 -0
- package/docs/research.md +28 -6
- package/docs/roadmap.md +17 -5
- package/package.json +1 -1
- package/docs/implementation-plan.md +0 -341
|
@@ -0,0 +1,45 @@
|
|
|
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 a server into `.agents/config.json` (`mcp add --workspace`).
|
|
5
|
+
*
|
|
6
|
+
* The read side lives in src/lib/workspace.ts (`loadWorkspaceLayer`) —
|
|
7
|
+
* same file, same schema, one validator (via `toServerDefYaml`, so a
|
|
8
|
+
* `McpServerDef` serializes to the exact snake_case shape the loader
|
|
9
|
+
* validates back). Everything written here is committable by design:
|
|
10
|
+
* only `${VAR}` references ever land in `config.json`, never values
|
|
11
|
+
* (the loader refuses literals at read time; these helpers never
|
|
12
|
+
* resolve anything in the first place).
|
|
13
|
+
*/
|
|
14
|
+
import type { McpServerDef } from "../core/types.js";
|
|
15
|
+
import { type McpServerDefYaml } from "../core/mcpServerDef.js";
|
|
16
|
+
import type { BackupSession } from "./backup.js";
|
|
17
|
+
export interface WorkspaceConfigJson {
|
|
18
|
+
version?: number;
|
|
19
|
+
mcp?: {
|
|
20
|
+
servers?: Record<string, McpServerDefYaml>;
|
|
21
|
+
};
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
export declare function workspaceConfigPath(workspaceRoot: string): string;
|
|
25
|
+
/** `{}` when the file doesn't exist yet (a workspace may carry only
|
|
26
|
+
* skills/AGENTS.md); a clear error naming the file when it exists but
|
|
27
|
+
* doesn't parse — never a raw JSON stack. */
|
|
28
|
+
export declare function readWorkspaceConfigJson(workspaceRoot: string): WorkspaceConfigJson;
|
|
29
|
+
export declare function writeWorkspaceConfigJson(workspaceRoot: string, config: WorkspaceConfigJson, backup?: BackupSession): void;
|
|
30
|
+
/** Insert or replace `mcp.servers.<name>` with the def serialized through
|
|
31
|
+
* the shared YAML/JSON translator. `version` is filled in when absent —
|
|
32
|
+
* an existing file's version (and any unknown keys) is preserved. */
|
|
33
|
+
export declare function upsertWorkspaceServer(workspaceRoot: string, name: string, def: McpServerDef, backup?: BackupSession): void;
|
|
34
|
+
export interface WorkspaceScaffoldFile {
|
|
35
|
+
path: string;
|
|
36
|
+
action: "create" | "already-present";
|
|
37
|
+
}
|
|
38
|
+
/** Exported for `migrate --workspace`: a `.agents/AGENTS.md` holding
|
|
39
|
+
* exactly this template is treated as still-empty scaffolding that an
|
|
40
|
+
* instructions import may replace, not as user content to conflict on. */
|
|
41
|
+
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";
|
|
42
|
+
/** Fill-missing-only scaffold of `.agents/`: never overwrites anything
|
|
43
|
+
* that already exists, so re-running is a no-op (idempotent by
|
|
44
|
+
* construction, same posture as global `trellis init`). */
|
|
45
|
+
export declare function scaffoldWorkspace(workspaceRoot: string, write?: boolean): WorkspaceScaffoldFile[];
|
|
@@ -0,0 +1,98 @@
|
|
|
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 a server into `.agents/config.json` (`mcp add --workspace`).
|
|
5
|
+
*
|
|
6
|
+
* The read side lives in src/lib/workspace.ts (`loadWorkspaceLayer`) —
|
|
7
|
+
* same file, same schema, one validator (via `toServerDefYaml`, so a
|
|
8
|
+
* `McpServerDef` serializes to the exact snake_case shape the loader
|
|
9
|
+
* validates back). Everything written here is committable by design:
|
|
10
|
+
* only `${VAR}` references ever land in `config.json`, never values
|
|
11
|
+
* (the loader refuses literals at read time; these helpers never
|
|
12
|
+
* resolve anything in the first place).
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { toServerDefYaml } from "../core/mcpServerDef.js";
|
|
17
|
+
import { WORKSPACE_CONFIG_VERSION, WORKSPACE_DIR_NAME } from "./workspace.js";
|
|
18
|
+
export function workspaceConfigPath(workspaceRoot) {
|
|
19
|
+
return join(workspaceRoot, WORKSPACE_DIR_NAME, "config.json");
|
|
20
|
+
}
|
|
21
|
+
/** `{}` when the file doesn't exist yet (a workspace may carry only
|
|
22
|
+
* skills/AGENTS.md); a clear error naming the file when it exists but
|
|
23
|
+
* doesn't parse — never a raw JSON stack. */
|
|
24
|
+
export function readWorkspaceConfigJson(workspaceRoot) {
|
|
25
|
+
const path = workspaceConfigPath(workspaceRoot);
|
|
26
|
+
if (!existsSync(path))
|
|
27
|
+
return {};
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
30
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
31
|
+
throw new Error("top level must be an object");
|
|
32
|
+
}
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
throw new Error(`${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function writeWorkspaceConfigJson(workspaceRoot, config, backup) {
|
|
40
|
+
const path = workspaceConfigPath(workspaceRoot);
|
|
41
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
42
|
+
const content = `${JSON.stringify(config, null, 2)}\n`;
|
|
43
|
+
if (backup) {
|
|
44
|
+
backup.writeFile(path, content);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
writeFileSync(path, content);
|
|
48
|
+
}
|
|
49
|
+
/** Insert or replace `mcp.servers.<name>` with the def serialized through
|
|
50
|
+
* the shared YAML/JSON translator. `version` is filled in when absent —
|
|
51
|
+
* an existing file's version (and any unknown keys) is preserved. */
|
|
52
|
+
export function upsertWorkspaceServer(workspaceRoot, name, def, backup) {
|
|
53
|
+
const config = readWorkspaceConfigJson(workspaceRoot);
|
|
54
|
+
const servers = { ...(config.mcp?.servers ?? {}), [name]: toServerDefYaml(def) };
|
|
55
|
+
const next = {
|
|
56
|
+
...config,
|
|
57
|
+
version: config.version ?? WORKSPACE_CONFIG_VERSION,
|
|
58
|
+
mcp: { ...(config.mcp ?? {}), servers },
|
|
59
|
+
};
|
|
60
|
+
writeWorkspaceConfigJson(workspaceRoot, next, backup);
|
|
61
|
+
}
|
|
62
|
+
/** Exported for `migrate --workspace`: a `.agents/AGENTS.md` holding
|
|
63
|
+
* exactly this template is treated as still-empty scaffolding that an
|
|
64
|
+
* instructions import may replace, not as user content to conflict on. */
|
|
65
|
+
export const WORKSPACE_AGENTS_MD_TEMPLATE = `# Project instructions
|
|
66
|
+
|
|
67
|
+
Shared instructions for every agent working in this repository. Trellis
|
|
68
|
+
delivers this file to agents that don't read \`.agents/AGENTS.md\`
|
|
69
|
+
natively (Codex/pi/Kimi do; Kiro gets a steering copy; Claude Code keeps
|
|
70
|
+
its own CLAUDE.md).
|
|
71
|
+
`;
|
|
72
|
+
/** Fill-missing-only scaffold of `.agents/`: never overwrites anything
|
|
73
|
+
* that already exists, so re-running is a no-op (idempotent by
|
|
74
|
+
* construction, same posture as global `trellis init`). */
|
|
75
|
+
export function scaffoldWorkspace(workspaceRoot, write = true) {
|
|
76
|
+
const agentsDir = join(workspaceRoot, WORKSPACE_DIR_NAME);
|
|
77
|
+
const configPath = workspaceConfigPath(workspaceRoot);
|
|
78
|
+
const skillsDir = join(agentsDir, "skills");
|
|
79
|
+
const agentsMdPath = join(agentsDir, "AGENTS.md");
|
|
80
|
+
if (write) {
|
|
81
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
82
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
const files = [];
|
|
85
|
+
files.push(existsSync(configPath)
|
|
86
|
+
? { path: configPath, action: "already-present" }
|
|
87
|
+
: { path: configPath, action: "create" });
|
|
88
|
+
if (!existsSync(configPath) && write) {
|
|
89
|
+
writeFileSync(configPath, `${JSON.stringify({ version: WORKSPACE_CONFIG_VERSION, mcp: { servers: {} } }, null, 2)}\n`);
|
|
90
|
+
}
|
|
91
|
+
files.push(existsSync(agentsMdPath)
|
|
92
|
+
? { path: agentsMdPath, action: "already-present" }
|
|
93
|
+
: { path: agentsMdPath, action: "create" });
|
|
94
|
+
if (!existsSync(agentsMdPath) && write) {
|
|
95
|
+
writeFileSync(agentsMdPath, WORKSPACE_AGENTS_MD_TEMPLATE);
|
|
96
|
+
}
|
|
97
|
+
return files;
|
|
98
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace (project-level) skills & instructions delivery
|
|
3
|
+
* (trellis-workspace-scope design D4/D5). Unlike global delivery (symlinks
|
|
4
|
+
* into `~/.trellis/`), workspace skills are COPIED into the agent's
|
|
5
|
+
* project-level directory — rendered artifacts must be committable to the
|
|
6
|
+
* repo and usable on filesystems without symlink support.
|
|
7
|
+
*
|
|
8
|
+
* A copy has no symlink-realpath ownership proof, so ownership mirrors the
|
|
9
|
+
* MCP ledger instead: one `.trellis-workspace.json` per destination root
|
|
10
|
+
* maps skill name → { source, sourceDigest, destDigest }. A delivered
|
|
11
|
+
* directory is "ours" only while its current digest still equals the
|
|
12
|
+
* recorded `destDigest`; a hand-edited copy is a conflict, never touched —
|
|
13
|
+
* the same posture mcpOwnership gives bare JSON/TOML keys.
|
|
14
|
+
*
|
|
15
|
+
* Instructions are delivered as marker-headed file copies: root `AGENTS.md`
|
|
16
|
+
* (read natively by Codex, pi, and Kimi Code) and Kiro's
|
|
17
|
+
* `.kiro/steering/trellis-workspace.md`. Claude Code reads `CLAUDE.md`, not
|
|
18
|
+
* `AGENTS.md`, and a repo's `CLAUDE.md` is almost always user-authored —
|
|
19
|
+
* v1 deliberately writes no Claude instructions file (documented
|
|
20
|
+
* limitation; `migrate --workspace` is the path for absorbing one).
|
|
21
|
+
*/
|
|
22
|
+
import type { AdapterPlanItem } from "../core/adapter.js";
|
|
23
|
+
import type { AgentId, CanonicalSource } from "../core/types.js";
|
|
24
|
+
import type { BackupSession } from "./backup.js";
|
|
25
|
+
/** Per-destination-root ledger of Trellis-owned workspace copies. */
|
|
26
|
+
export type WorkspaceCopyLedger = Record<string, {
|
|
27
|
+
source: string;
|
|
28
|
+
sourceDigest: string;
|
|
29
|
+
destDigest: string;
|
|
30
|
+
}>;
|
|
31
|
+
/** Exported for `migrate --workspace` (trellis-workspace-scope task 4.x):
|
|
32
|
+
* absorbing a root `AGENTS.md` rewrites it marker-headed in place, which
|
|
33
|
+
* must be byte-identical to what a later sync would render. */
|
|
34
|
+
export declare const INSTRUCTIONS_MARKER = "<!-- generated by trellis from .agents/AGENTS.md \u2014 do not edit; run `trellis sync` to refresh -->";
|
|
35
|
+
export declare function planWorkspaceFiles(agentId: AgentId, canonical: CanonicalSource): AdapterPlanItem[];
|
|
36
|
+
/** Executes a `planWorkspaceFiles` result through the run's backup session
|
|
37
|
+
* and rewrites the copy ledger for every destination root it touched. */
|
|
38
|
+
export declare function applyWorkspaceFiles(agentId: AgentId, canonical: CanonicalSource, items: AdapterPlanItem[], backup: BackupSession): void;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace (project-level) skills & instructions delivery
|
|
3
|
+
* (trellis-workspace-scope design D4/D5). Unlike global delivery (symlinks
|
|
4
|
+
* into `~/.trellis/`), workspace skills are COPIED into the agent's
|
|
5
|
+
* project-level directory — rendered artifacts must be committable to the
|
|
6
|
+
* repo and usable on filesystems without symlink support.
|
|
7
|
+
*
|
|
8
|
+
* A copy has no symlink-realpath ownership proof, so ownership mirrors the
|
|
9
|
+
* MCP ledger instead: one `.trellis-workspace.json` per destination root
|
|
10
|
+
* maps skill name → { source, sourceDigest, destDigest }. A delivered
|
|
11
|
+
* directory is "ours" only while its current digest still equals the
|
|
12
|
+
* recorded `destDigest`; a hand-edited copy is a conflict, never touched —
|
|
13
|
+
* the same posture mcpOwnership gives bare JSON/TOML keys.
|
|
14
|
+
*
|
|
15
|
+
* Instructions are delivered as marker-headed file copies: root `AGENTS.md`
|
|
16
|
+
* (read natively by Codex, pi, and Kimi Code) and Kiro's
|
|
17
|
+
* `.kiro/steering/trellis-workspace.md`. Claude Code reads `CLAUDE.md`, not
|
|
18
|
+
* `AGENTS.md`, and a repo's `CLAUDE.md` is almost always user-authored —
|
|
19
|
+
* v1 deliberately writes no Claude instructions file (documented
|
|
20
|
+
* limitation; `migrate --workspace` is the path for absorbing one).
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
import { isInScope } from "../core/adapter.js";
|
|
25
|
+
import { directoryDigest } from "./dirDigest.js";
|
|
26
|
+
const COPY_LEDGER_NAME = ".trellis-workspace.json";
|
|
27
|
+
/** Exported for `migrate --workspace` (trellis-workspace-scope task 4.x):
|
|
28
|
+
* absorbing a root `AGENTS.md` rewrites it marker-headed in place, which
|
|
29
|
+
* must be byte-identical to what a later sync would render. */
|
|
30
|
+
export const INSTRUCTIONS_MARKER = "<!-- generated by trellis from .agents/AGENTS.md — do not edit; run `trellis sync` to refresh -->";
|
|
31
|
+
/** Agents that read `.agents/skills/` natively get no copy at all — the
|
|
32
|
+
* canonical directory is itself the deliverable. zcode's Runtime-only
|
|
33
|
+
* delivery owns its skills through the Runtime provider (task 2.2). */
|
|
34
|
+
const COPY_TARGETS = {
|
|
35
|
+
"claude-code": join(".claude", "skills"),
|
|
36
|
+
kiro: join(".kiro", "skills"),
|
|
37
|
+
};
|
|
38
|
+
function instructionsTargets(agentId) {
|
|
39
|
+
switch (agentId) {
|
|
40
|
+
case "codex":
|
|
41
|
+
case "pi":
|
|
42
|
+
case "kimi-code":
|
|
43
|
+
return [{ relPath: "AGENTS.md", forAgents: "Codex/pi/Kimi Code" }];
|
|
44
|
+
case "kiro":
|
|
45
|
+
return [{ relPath: join(".kiro", "steering", "trellis-workspace.md"), forAgents: "Kiro" }];
|
|
46
|
+
default:
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function loadCopyLedger(path) {
|
|
51
|
+
if (!existsSync(path))
|
|
52
|
+
return {};
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return {}; // corrupt ledger — treat as "nothing recorded", never crash a sync
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function planWorkspaceSkillCopies(agentId, canonical) {
|
|
61
|
+
const workspace = canonical.workspace;
|
|
62
|
+
const relRoot = COPY_TARGETS[agentId];
|
|
63
|
+
if (!workspace || !relRoot)
|
|
64
|
+
return [];
|
|
65
|
+
const destRoot = join(workspace.root, relRoot);
|
|
66
|
+
const ledgerPath = join(destRoot, COPY_LEDGER_NAME);
|
|
67
|
+
const ledger = loadCopyLedger(ledgerPath);
|
|
68
|
+
const items = [];
|
|
69
|
+
const desired = canonical.skills.filter((skill) => workspace.skillProvenance[skill.name] === "workspace" && isInScope(agentId, skill.scope, canonical.managedAgents));
|
|
70
|
+
const desiredNames = new Set(desired.map((s) => s.name));
|
|
71
|
+
for (const skill of desired) {
|
|
72
|
+
const dest = join(destRoot, skill.name);
|
|
73
|
+
if (!existsSync(dest)) {
|
|
74
|
+
items.push({
|
|
75
|
+
action: "create",
|
|
76
|
+
kind: "skill",
|
|
77
|
+
layer: "workspace",
|
|
78
|
+
target: dest,
|
|
79
|
+
copyFrom: skill.dir,
|
|
80
|
+
description: `workspace skill "${skill.name}" copied to ${dest}`,
|
|
81
|
+
});
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const recorded = ledger[skill.name];
|
|
85
|
+
if (!recorded) {
|
|
86
|
+
items.push({
|
|
87
|
+
action: "conflict",
|
|
88
|
+
kind: "skill",
|
|
89
|
+
layer: "workspace",
|
|
90
|
+
target: dest,
|
|
91
|
+
description: `${dest} exists and is not a Trellis-managed workspace copy — left untouched`,
|
|
92
|
+
remediation: `run \`trellis migrate --workspace\` to absorb it into .agents/, or remove it and re-run sync`,
|
|
93
|
+
});
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (directoryDigest(dest) !== recorded.destDigest) {
|
|
97
|
+
items.push({
|
|
98
|
+
action: "conflict",
|
|
99
|
+
kind: "skill",
|
|
100
|
+
layer: "workspace",
|
|
101
|
+
target: dest,
|
|
102
|
+
description: `${dest} was hand-edited since Trellis wrote it — left untouched`,
|
|
103
|
+
remediation: `merge your edits into .agents/skills/${skill.name}/ (the canonical copy), then remove ${dest} and re-run sync — Trellis will not overwrite hand edits`,
|
|
104
|
+
});
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (directoryDigest(skill.dir) === recorded.sourceDigest) {
|
|
108
|
+
continue; // fresh — no-op
|
|
109
|
+
}
|
|
110
|
+
items.push({
|
|
111
|
+
action: "create",
|
|
112
|
+
kind: "skill",
|
|
113
|
+
layer: "workspace",
|
|
114
|
+
target: dest,
|
|
115
|
+
copyFrom: skill.dir,
|
|
116
|
+
description: `workspace skill "${skill.name}" re-copied to ${dest} (source changed)`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
for (const name of Object.keys(ledger)) {
|
|
120
|
+
if (desiredNames.has(name))
|
|
121
|
+
continue;
|
|
122
|
+
const dest = join(destRoot, name);
|
|
123
|
+
if (!existsSync(dest)) {
|
|
124
|
+
// Stale ledger entry only — rewrite the ledger (no dir to remove).
|
|
125
|
+
items.push({
|
|
126
|
+
action: "remove",
|
|
127
|
+
kind: "skill",
|
|
128
|
+
layer: "workspace",
|
|
129
|
+
target: dest,
|
|
130
|
+
description: `stale workspace-copy ledger entry "${name}" pruned from ${ledgerPath}`,
|
|
131
|
+
});
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (directoryDigest(dest) !== ledger[name].destDigest) {
|
|
135
|
+
continue; // hand-edited since we wrote it — no longer ours to remove
|
|
136
|
+
}
|
|
137
|
+
items.push({
|
|
138
|
+
action: "remove",
|
|
139
|
+
kind: "skill",
|
|
140
|
+
layer: "workspace",
|
|
141
|
+
target: dest,
|
|
142
|
+
description: `workspace skill "${name}" removed from ${dest} — no longer in .agents/skills/`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return items;
|
|
146
|
+
}
|
|
147
|
+
function planWorkspaceInstructions(agentId, canonical) {
|
|
148
|
+
const workspace = canonical.workspace;
|
|
149
|
+
if (!workspace)
|
|
150
|
+
return [];
|
|
151
|
+
const items = [];
|
|
152
|
+
for (const target of instructionsTargets(agentId)) {
|
|
153
|
+
const path = join(workspace.root, target.relPath);
|
|
154
|
+
const rendered = workspace.instructionsFile
|
|
155
|
+
? `${INSTRUCTIONS_MARKER}\n\n${readFileSync(workspace.instructionsFile, "utf-8")}`
|
|
156
|
+
: undefined;
|
|
157
|
+
if (rendered === undefined) {
|
|
158
|
+
// Workspace no longer declares instructions — remove the delivered
|
|
159
|
+
// file only while it's still byte-exactly a marker-headed one.
|
|
160
|
+
if (!existsSync(path))
|
|
161
|
+
continue;
|
|
162
|
+
const current = readFileSync(path, "utf-8");
|
|
163
|
+
if (!current.startsWith(INSTRUCTIONS_MARKER))
|
|
164
|
+
continue; // user's file — never ours
|
|
165
|
+
items.push({
|
|
166
|
+
action: "remove",
|
|
167
|
+
kind: "instructions",
|
|
168
|
+
layer: "workspace",
|
|
169
|
+
target: path,
|
|
170
|
+
description: `${path} removed — .agents/AGENTS.md no longer exists`,
|
|
171
|
+
});
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (!existsSync(path)) {
|
|
175
|
+
items.push({
|
|
176
|
+
action: "create",
|
|
177
|
+
kind: "instructions",
|
|
178
|
+
layer: "workspace",
|
|
179
|
+
target: path,
|
|
180
|
+
fileContent: rendered,
|
|
181
|
+
description: `workspace instructions written to ${path} (${target.forAgents})`,
|
|
182
|
+
});
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const current = readFileSync(path, "utf-8");
|
|
186
|
+
if (current === rendered)
|
|
187
|
+
continue;
|
|
188
|
+
if (!current.startsWith(INSTRUCTIONS_MARKER)) {
|
|
189
|
+
items.push({
|
|
190
|
+
action: "conflict",
|
|
191
|
+
kind: "instructions",
|
|
192
|
+
layer: "workspace",
|
|
193
|
+
target: path,
|
|
194
|
+
description: `${path} exists and is not Trellis-generated — left untouched`,
|
|
195
|
+
remediation: `merge its content into .agents/AGENTS.md (e.g. via \`trellis migrate --workspace\`) and remove the file, then re-run sync`,
|
|
196
|
+
});
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
items.push({
|
|
200
|
+
action: "create",
|
|
201
|
+
kind: "instructions",
|
|
202
|
+
layer: "workspace",
|
|
203
|
+
target: path,
|
|
204
|
+
fileContent: rendered,
|
|
205
|
+
description: `workspace instructions updated in ${path} (source changed)`,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return items;
|
|
209
|
+
}
|
|
210
|
+
export function planWorkspaceFiles(agentId, canonical) {
|
|
211
|
+
return [...planWorkspaceSkillCopies(agentId, canonical), ...planWorkspaceInstructions(agentId, canonical)];
|
|
212
|
+
}
|
|
213
|
+
/** Executes a `planWorkspaceFiles` result through the run's backup session
|
|
214
|
+
* and rewrites the copy ledger for every destination root it touched. */
|
|
215
|
+
export function applyWorkspaceFiles(agentId, canonical, items, backup) {
|
|
216
|
+
const workspace = canonical.workspace;
|
|
217
|
+
if (!workspace)
|
|
218
|
+
return;
|
|
219
|
+
for (const item of items) {
|
|
220
|
+
if (item.layer !== "workspace" || item.action === "conflict")
|
|
221
|
+
continue;
|
|
222
|
+
if (item.kind === "instructions") {
|
|
223
|
+
if (item.action === "create" && item.fileContent !== undefined) {
|
|
224
|
+
// Kiro's steering dir (and any future nested target) may not exist
|
|
225
|
+
// yet in a fresh project — create it before the atomic write.
|
|
226
|
+
mkdirSync(dirname(item.target), { recursive: true });
|
|
227
|
+
backup.writeFile(item.target, item.fileContent);
|
|
228
|
+
}
|
|
229
|
+
else if (item.action === "remove") {
|
|
230
|
+
// removeDir's snapshot+remove works for single files too (cpSync /
|
|
231
|
+
// rmSync both accept file paths) — rollback restores the file.
|
|
232
|
+
backup.removeDir(item.target);
|
|
233
|
+
}
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (item.kind === "skill") {
|
|
237
|
+
const destRoot = dirname(item.target);
|
|
238
|
+
const ledgerPath = join(destRoot, COPY_LEDGER_NAME);
|
|
239
|
+
let ledger = loadCopyLedger(ledgerPath);
|
|
240
|
+
const name = item.target.slice(destRoot.length + 1);
|
|
241
|
+
if (item.action === "create" && item.copyFrom) {
|
|
242
|
+
if (existsSync(item.target)) {
|
|
243
|
+
backup.replaceDirFromSource(item.target, item.copyFrom);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
backup.createDirFromSource(item.target, item.copyFrom);
|
|
247
|
+
}
|
|
248
|
+
ledger = {
|
|
249
|
+
...ledger,
|
|
250
|
+
[name]: { source: item.copyFrom, sourceDigest: directoryDigest(item.copyFrom), destDigest: directoryDigest(item.target) },
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
else if (item.action === "remove") {
|
|
254
|
+
if (existsSync(item.target)) {
|
|
255
|
+
backup.removeDir(item.target);
|
|
256
|
+
}
|
|
257
|
+
const next = { ...ledger };
|
|
258
|
+
delete next[name];
|
|
259
|
+
ledger = next;
|
|
260
|
+
}
|
|
261
|
+
backup.writeFile(ledgerPath, `${JSON.stringify(ledger, null, 2)}\n`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace (project-level) MCP delivery (trellis-workspace-scope design
|
|
3
|
+
* D4/D6/D7): renders the workspace layer's servers into each managed
|
|
4
|
+
* agent's project-level native config, as direct entries only — never
|
|
5
|
+
* through the gateway, hub, or Runtime. Reuses each format's existing
|
|
6
|
+
* machinery (`jsonMcp.ts` for the JSON agents, `tomlSection.ts` for Codex,
|
|
7
|
+
* zcode.ts's nested `mcp.servers` shape).
|
|
8
|
+
*
|
|
9
|
+
* Ownership lives IN the workspace, not the global ledger: `.agents/
|
|
10
|
+
* .trellis-rendered.json` records exactly what Trellis rendered per agent
|
|
11
|
+
* per server. It's committable on purpose (rendered entries carry only
|
|
12
|
+
* `${VAR}` references, never values) — that's what lets a fresh clone
|
|
13
|
+
* re-attach cleanly: the committed manifest proves the committed
|
|
14
|
+
* `.mcp.json` was Trellis-rendered even though this machine's global
|
|
15
|
+
* ledger has never seen it. An entry that exists in the native file but
|
|
16
|
+
* not in the manifest is user-authored and never overwritten.
|
|
17
|
+
*
|
|
18
|
+
* Routing note: the workspace-only `McpConfig` handed to `resolveMcpPlan`
|
|
19
|
+
* carries no `hub`/`gateway`/`routes`/`runtime`, so it always takes the
|
|
20
|
+
* direct branch — where a Trellis-owned OAuth server already conflicts out
|
|
21
|
+
* with "requires gateway or runtime delivery", which is exactly the
|
|
22
|
+
* workspace rule (workspace servers are agent-owned, always).
|
|
23
|
+
*/
|
|
24
|
+
import type { AdapterPlanItem } from "../core/adapter.js";
|
|
25
|
+
import type { AgentId, CanonicalSource, McpServerDef } from "../core/types.js";
|
|
26
|
+
import type { BackupSession } from "./backup.js";
|
|
27
|
+
/** `.agents/.trellis-rendered.json` — the in-repo ownership manifest. */
|
|
28
|
+
export declare const WORKSPACE_RENDERED_MANIFEST = ".trellis-rendered.json";
|
|
29
|
+
export interface WorkspaceRenderedManifest {
|
|
30
|
+
mcp?: Partial<Record<AgentId, Record<string, unknown>>>;
|
|
31
|
+
}
|
|
32
|
+
export declare function loadWorkspaceRendered(workspaceRoot: string): WorkspaceRenderedManifest;
|
|
33
|
+
/** The project-level config file each agent reads. zcode's is selected by
|
|
34
|
+
* presence (existing `zcode.json` beats `.zcode/config.json`), defaulting
|
|
35
|
+
* to creating `zcode.json` at the root. */
|
|
36
|
+
export declare function workspaceMcpConfigPath(agentId: AgentId, workspaceRoot: string): string;
|
|
37
|
+
/** zcode is the one agent whose USER-level config wins on same-name
|
|
38
|
+
* conflicts (research, 2026-10-11), so workspace-wins silently misfires
|
|
39
|
+
* there — surface it instead of implying the override worked. */
|
|
40
|
+
export declare function workspacePrecedenceWarnings(agentId: AgentId, canonical: CanonicalSource): string[];
|
|
41
|
+
/**
|
|
42
|
+
* The rendered form `applyWorkspaceMcp` records in the manifest for one
|
|
43
|
+
* agent — exported so `migrate --workspace` can pre-seed ownership for an
|
|
44
|
+
* entry it is adopting out of a user-authored project file: the cascade
|
|
45
|
+
* render then owns the entry (updating it to Trellis's canonical render)
|
|
46
|
+
* instead of conflict-guarding on it as user-authored content
|
|
47
|
+
* (trellis-workspace-scope task 4.6).
|
|
48
|
+
*/
|
|
49
|
+
export declare function renderWorkspaceManifestEntry(agentId: AgentId, name: string, def: McpServerDef): unknown;
|
|
50
|
+
/** Records one ownership entry in `.agents/.trellis-rendered.json`
|
|
51
|
+
* through the run's backup session — migrate --workspace's adopt step.
|
|
52
|
+
* Delivery itself keeps writing the manifest wholesale in
|
|
53
|
+
* `applyWorkspaceMcp`; this exists only for adoption, where no delivery
|
|
54
|
+
* write to that agent's file has happened yet. */
|
|
55
|
+
export declare function recordWorkspaceRendered(workspaceRoot: string, agentId: AgentId, name: string, rendered: unknown, backup: BackupSession): void;
|
|
56
|
+
export declare function planWorkspaceMcp(agentId: AgentId, canonical: CanonicalSource): AdapterPlanItem[];
|
|
57
|
+
export declare function applyWorkspaceMcp(agentId: AgentId, canonical: CanonicalSource, items: AdapterPlanItem[], backup: BackupSession): void;
|