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
@@ -0,0 +1,197 @@
1
+ /**
2
+ * `trellis migrate --workspace` — absorbs a repo's existing project-level
3
+ * agent config (all six agents' MCP files + root AGENTS.md) into
4
+ * `.agents/`, then re-renders (trellis-workspace-scope group 4).
5
+ *
6
+ * Flow: plan (pure, `src/lib/workspaceMigrate.ts`) → refuse on unresolved
7
+ * conflicts → one backup session named `workspace-<repo-hash>` covering
8
+ * every write (task 4.6 — `.agents/` writes, the secret-store extraction,
9
+ * the marker-headed AGENTS.md rewrite, and the cascade render, so a
10
+ * single `trellis rollback` restores originals and removes generated
11
+ * `.agents/` content) → post-migration equivalence report (task 4.7).
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { mkdirSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+ import { ALL_AGENTS } from "../core/types.js";
18
+ import { ensureGitignoreEntry, loadCanonicalSource, writeSecretsPolicyExtraction } from "../core/canonical.js";
19
+ import { openBackupSession } from "../lib/backup.js";
20
+ import { writeLocalSecretValue } from "../lib/secretEnv.js";
21
+ import { findWorkspaceRoot } from "../lib/workspace.js";
22
+ import { upsertWorkspaceServer } from "../lib/workspaceConfig.js";
23
+ import { INSTRUCTIONS_MARKER } from "../lib/workspaceFiles.js";
24
+ import { recordWorkspaceRendered, renderWorkspaceManifestEntry } from "../lib/workspaceMcp.js";
25
+ import { collectWorkspaceMigratePlan, readWorkspaceAgentMcpDefsForApply } from "../lib/workspaceMigrate.js";
26
+ import { collectMcpSyncReport } from "./mcp.js";
27
+ import { collectSyncReport } from "./sync.js";
28
+ import { defaultLocalSecretsEnvFilePath, DEFAULT_LOCAL_SECRETS_ENV_FILE_TILDE } from "./migrate.js";
29
+ /** Task 4.6's `<repo-hash>`: stable per checkout path, short enough to
30
+ * stay readable in a backups listing. */
31
+ function repoHash(workspaceRoot) {
32
+ return createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 12);
33
+ }
34
+ /**
35
+ * Applies one plan item. MCP items go through `upsertWorkspaceServer`
36
+ * (which serializes the snake_case shape and fills `version`); the
37
+ * extract-secret companion write mirrors the global migrate's
38
+ * `applyStaticEnvExtraction` — real value re-read from the SOURCE file at
39
+ * apply time, never carried on the plan (D9). Instructions import writes
40
+ * `.agents/AGENTS.md` and rewrites the root file marker-headed in place
41
+ * (a `file-overwrite` op — rollback restores the original unmarked file).
42
+ */
43
+ function applyWorkspaceMigrateItem(item, plan, homeDir, backup) {
44
+ if (item.kind === "mcp" && (item.action === "create" || item.action === "extract-secret") && item.mcpDef) {
45
+ upsertWorkspaceServer(plan.workspaceRoot, item.name, item.mcpDef, backup);
46
+ // Adopt the source entries: every agent file this name came from gets
47
+ // a manifest ownership record, so the cascade render owns the entry
48
+ // (bringing it to Trellis's canonical render) instead of conflict-
49
+ // guarding on it as user-authored content. Without this the migration
50
+ // would import into .agents/ and then immediately conflict on the
51
+ // very files it absorbed.
52
+ for (const agent of item.sources) {
53
+ recordWorkspaceRendered(plan.workspaceRoot, agent, item.name, renderWorkspaceManifestEntry(agent, item.name, item.mcpDef), backup);
54
+ }
55
+ if (item.action === "extract-secret" && item.extractVarName && item.extractTargetPath && item.extractSourceKey && item.extractSourceAgent) {
56
+ const realValue = readWorkspaceAgentMcpDefsForApply(plan.workspaceRoot, item.extractSourceAgent)
57
+ .entries.find((e) => e.name === item.name)?.def.staticEnv?.[item.extractSourceKey];
58
+ if (realValue !== undefined) {
59
+ if (item.extractTargetPath === defaultLocalSecretsEnvFilePath(homeDir)) {
60
+ ensureGitignoreEntry(join(homeDir, ".trellis", ".gitignore"), "mcp/servers.local.env", backup);
61
+ }
62
+ writeLocalSecretValue(item.extractTargetPath, item.extractVarName, realValue, backup);
63
+ const policyResult = writeSecretsPolicyExtraction(join(homeDir, ".trellis", "secrets.policy.yaml"), { varName: item.extractVarName, envFilePath: DEFAULT_LOCAL_SECRETS_ENV_FILE_TILDE }, backup);
64
+ // A failed policy write leaves the extracted var unresolvable —
65
+ // the cascade render then refuses with a confusing conflict. Say
66
+ // why, plainly, while the run is still honest about it.
67
+ if (!policyResult.ok) {
68
+ console.error(`warning: extracted ${item.extractVarName} but could not record it in secrets.policy.yaml: ${policyResult.error}`);
69
+ }
70
+ }
71
+ // undefined = the source changed between plan and apply; the safe
72
+ // def (name-only reference) is still imported, matching the global
73
+ // migrate's "nothing left to extract" behavior.
74
+ }
75
+ return;
76
+ }
77
+ if (item.kind === "instructions" && item.action === "create" && item.sourceContent !== undefined) {
78
+ const target = join(plan.workspaceRoot, ".agents", "AGENTS.md");
79
+ mkdirSync(dirname(target), { recursive: true });
80
+ backup.writeFile(target, item.sourceContent);
81
+ // Rewrite the root file marker-headed NOW so the cascade instructions
82
+ // sync sees current === rendered (no-op) instead of conflicting on an
83
+ // unmarked file. Byte-identical to planWorkspaceInstructions' render.
84
+ backup.writeFile(join(plan.workspaceRoot, "AGENTS.md"), `${INSTRUCTIONS_MARKER}\n\n${item.sourceContent}`);
85
+ }
86
+ }
87
+ export async function runMigrateWorkspace(opts = {}) {
88
+ const homeDir = opts.homeDir ?? homedir();
89
+ const workspaceRoot = opts.workspaceDir ?? findWorkspaceRoot(process.cwd()) ?? process.cwd();
90
+ // --- flag validation (raw strings arrive from cli.ts) ---
91
+ const choose = {};
92
+ for (const raw of opts.choose ?? []) {
93
+ const eq = raw.indexOf("=");
94
+ const agent = eq === -1 ? "" : raw.slice(eq + 1);
95
+ if (eq === -1 || !ALL_AGENTS.includes(agent)) {
96
+ console.error(`--choose must be name=agentId with agentId one of: ${ALL_AGENTS.join(", ")} (got "${raw}")`);
97
+ return { exitCode: 1 };
98
+ }
99
+ choose[raw.slice(0, eq)] = agent;
100
+ }
101
+ if (opts.dedup !== undefined && opts.dedup !== "drop" && opts.dedup !== "override") {
102
+ console.error(`--dedup must be drop or override (got ${opts.dedup})`);
103
+ return { exitCode: 1 };
104
+ }
105
+ const secretsPolicy = loadCanonicalSource({ homeDir, workspaceDir: null }).secretsPolicy;
106
+ const plan = collectWorkspaceMigratePlan({ workspaceRoot, homeDir, secretsPolicy, choose, dedup: opts.dedup });
107
+ // Task 4.3/4.2: a real run with ANY unresolved conflict writes nothing —
108
+ // the user resolves (via --choose, or by fixing the source) and re-runs.
109
+ const conflicts = plan.items.filter((i) => i.action === "conflict");
110
+ if (conflicts.length > 0 && !opts.dryRun) {
111
+ printWorkspaceMigratePlan(plan, false);
112
+ console.error(`\n${conflicts.length} unresolved conflict(s) — nothing was written. Resolve each and re-run.`);
113
+ return { exitCode: 1 };
114
+ }
115
+ // Task 4.7's "before" half: what each agent's project config held.
116
+ const beforeByAgent = new Map();
117
+ for (const item of plan.items) {
118
+ if (item.kind !== "mcp" || item.action === "skip-local")
119
+ continue;
120
+ for (const agent of item.sources) {
121
+ beforeByAgent.set(agent, [...new Set([...(beforeByAgent.get(agent) ?? []), item.name])]);
122
+ }
123
+ }
124
+ let equivalence;
125
+ if (!opts.dryRun) {
126
+ const actionable = plan.items.filter((i) => i.action === "create" || i.action === "extract-secret");
127
+ const backup = openBackupSession(homeDir, `workspace-${repoHash(workspaceRoot)}`);
128
+ for (const item of actionable) {
129
+ applyWorkspaceMigrateItem(item, plan, homeDir, backup);
130
+ }
131
+ // Cascade render through the SAME session (task 4.6): MCP artifacts
132
+ // for every managed agent, so one `trellis rollback` undoes the whole
133
+ // migration. Instructions only cascade when an instructions item was
134
+ // actually applied — scoping the side effects to what migration
135
+ // touched, same discipline as `mcp add --workspace`'s MCP-only
136
+ // cascade.
137
+ await collectMcpSyncReport({ homeDir, workspaceDir: workspaceRoot, backupSession: backup });
138
+ if (actionable.some((i) => i.kind === "instructions")) {
139
+ await collectSyncReport({ target: "instructions", homeDir, workspaceDir: workspaceRoot, backupSession: backup });
140
+ }
141
+ backup.finalize();
142
+ // Task 4.7's "after" half: a fresh dry-run render against the
143
+ // migrated `.agents/` proves the absorbed config now delivers.
144
+ const after = await collectMcpSyncReport({ homeDir, workspaceDir: workspaceRoot, dryRun: true });
145
+ equivalence = {
146
+ agents: after.reports
147
+ .filter((r) => r.present)
148
+ .map((r) => ({
149
+ agent: r.agent,
150
+ before: (beforeByAgent.get(r.agent) ?? []).sort(),
151
+ after: r.items.filter((i) => i.kind === "mcp" && i.layer === "workspace" && i.mcpWrite).map((i) => i.mcpWrite.name).sort(),
152
+ }))
153
+ .filter((a) => a.before.length > 0 || a.after.length > 0),
154
+ };
155
+ }
156
+ if (opts.json) {
157
+ console.log(JSON.stringify({ ...plan, ...(equivalence ? { equivalence } : {}) }, null, 2));
158
+ }
159
+ else {
160
+ printWorkspaceMigratePlan(plan, opts.dryRun ?? false);
161
+ if (equivalence)
162
+ printEquivalence(equivalence);
163
+ }
164
+ return { exitCode: conflicts.length > 0 ? 1 : 0 };
165
+ }
166
+ export function printWorkspaceMigratePlan(plan, dryRun) {
167
+ console.log(`${dryRun ? "[dry run] " : ""}migrate --workspace ${plan.workspaceRoot}`);
168
+ if (plan.items.length === 0) {
169
+ console.log(" nothing to migrate");
170
+ return;
171
+ }
172
+ for (const item of plan.items) {
173
+ const label = item.kind === "mcp" ? `mcp server "${item.name}"` : "instructions (AGENTS.md)";
174
+ console.log(` [${item.action}] ${label} — ${item.detail}`);
175
+ if (item.defs) {
176
+ for (const [agent, def] of Object.entries(item.defs)) {
177
+ console.log(` ${agent}: ${JSON.stringify(def)}`);
178
+ }
179
+ }
180
+ if (item.rotationAdvisory) {
181
+ console.log(` ⚠ ${item.rotationAdvisory}`);
182
+ }
183
+ if (item.remediation) {
184
+ console.log(` → ${item.remediation}`);
185
+ }
186
+ }
187
+ }
188
+ function printEquivalence(equivalence) {
189
+ console.log("equivalence (before → rendered after):");
190
+ if (equivalence.agents.length === 0) {
191
+ console.log(" no managed agent renders workspace servers on this machine");
192
+ return;
193
+ }
194
+ for (const { agent, before, after } of equivalence.agents) {
195
+ console.log(` ${agent}: [${before.join(", ") || "—"}] → [${after.join(", ") || "—"}]`);
196
+ }
197
+ }
@@ -8,7 +8,7 @@
8
8
  * already hold themselves to for every other kind of conflict.
9
9
  */
10
10
  import { createHash } from "node:crypto";
11
- import { cpSync, existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
11
+ import { cpSync, existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
12
12
  import { readlink, rm, symlink } from "node:fs/promises";
13
13
  import { homedir } from "node:os";
14
14
  import { join } from "node:path";
@@ -63,84 +63,110 @@ async function currentLinkTarget(path) {
63
63
  return undefined;
64
64
  }
65
65
  }
66
- async function planOperation(homeDir, runId, op) {
66
+ /** Probes the path's real on-disk state once, then caches it in `sim`;
67
+ * later ops for the same path see the simulated rewinds, not the disk. */
68
+ async function simStateFor(path, sim) {
69
+ const cached = sim.get(path);
70
+ if (cached)
71
+ return cached;
72
+ let state;
73
+ // readlink first: it detects symlinks (even dangling ones) that
74
+ // existsSync would miss or misclassify.
75
+ const link = await currentLinkTarget(path);
76
+ if (link !== undefined) {
77
+ state = { kind: "symlink", target: link };
78
+ }
79
+ else if (!existsSync(path)) {
80
+ state = { kind: "absent" };
81
+ }
82
+ else if (statSync(path).isDirectory()) {
83
+ let digest;
84
+ try {
85
+ digest = directoryDigest(path);
86
+ }
87
+ catch {
88
+ digest = undefined;
89
+ }
90
+ state = { kind: "dir", digest };
91
+ }
92
+ else {
93
+ state = { kind: "file", hash: sha256(readFileSync(path, "utf-8")) };
94
+ }
95
+ sim.set(path, state);
96
+ return state;
97
+ }
98
+ async function planOperation(homeDir, runId, op, sim) {
67
99
  const runDir = join(backupsRoot(homeDir), runId);
100
+ const state = await simStateFor(op.path, sim);
68
101
  switch (op.kind) {
69
102
  case "file-create":
70
103
  case "file-overwrite": {
71
- if (!existsSync(op.path)) {
104
+ if (state.kind === "absent") {
72
105
  return { action: "already-reverted", path: op.path, description: `${op.path} no longer exists — nothing to undo` };
73
106
  }
74
- const currentHash = sha256(readFileSync(op.path, "utf-8"));
75
- if (currentHash !== op.afterHash) {
107
+ if (state.kind !== "file" || state.hash !== op.afterHash) {
76
108
  return { action: "conflict", path: op.path, description: `${op.path} has changed since this run — left untouched` };
77
109
  }
78
110
  if (op.kind === "file-create") {
111
+ sim.set(op.path, { kind: "absent" });
79
112
  return { action: "restore", path: op.path, description: `delete ${op.path} (created by this run)` };
80
113
  }
114
+ sim.set(op.path, { kind: "file", hash: op.beforeHash });
81
115
  return { action: "restore", path: op.path, description: `restore ${op.path} to its content before this run (${join(runDir, op.beforeFile)})` };
82
116
  }
83
117
  case "symlink-create":
84
118
  case "symlink-repair": {
85
- const current = await currentLinkTarget(op.path);
86
- if (current === undefined) {
119
+ if (state.kind === "absent") {
87
120
  return { action: "already-reverted", path: op.path, description: `${op.path} no longer exists — nothing to undo` };
88
121
  }
89
- if (current !== op.afterLinkTarget) {
122
+ if (state.kind !== "symlink" || state.target !== op.afterLinkTarget) {
90
123
  return { action: "conflict", path: op.path, description: `${op.path} points somewhere else now — left untouched` };
91
124
  }
92
125
  if (op.kind === "symlink-create") {
126
+ sim.set(op.path, { kind: "absent" });
93
127
  return { action: "restore", path: op.path, description: `remove ${op.path} (created by this run)` };
94
128
  }
129
+ sim.set(op.path, { kind: "symlink", target: op.beforeLinkTarget });
95
130
  return { action: "restore", path: op.path, description: `repoint ${op.path} back to ${op.beforeLinkTarget}` };
96
131
  }
97
132
  case "symlink-remove": {
98
- if (existsSync(op.path)) {
133
+ if (state.kind !== "absent") {
99
134
  return { action: "conflict", path: op.path, description: `${op.path} exists again since this run — left untouched` };
100
135
  }
136
+ sim.set(op.path, { kind: "symlink", target: op.beforeLinkTarget });
101
137
  return { action: "restore", path: op.path, description: `recreate ${op.path} -> ${op.beforeLinkTarget}` };
102
138
  }
103
139
  // Historical `dir-remove` operations remain existence-only because they
104
140
  // predate directory manifests. New directory creates/replacements carry a
105
141
  // deterministic digest, so remote Skill rollback refuses an edited tree.
106
142
  case "dir-create": {
107
- if (!existsSync(op.path)) {
143
+ if (state.kind === "absent") {
108
144
  return { action: "already-reverted", path: op.path, description: `${op.path} no longer exists — nothing to undo` };
109
145
  }
110
- if (op.afterDigest !== undefined) {
111
- let digest;
112
- try {
113
- digest = directoryDigest(op.path);
114
- }
115
- catch {
116
- return { action: "conflict", path: op.path, description: `${op.path} can no longer be read safely — left untouched` };
117
- }
118
- if (digest !== op.afterDigest) {
119
- return { action: "conflict", path: op.path, description: `${op.path} has changed since this run — left untouched` };
120
- }
146
+ if (op.afterDigest !== undefined && (state.kind !== "dir" || state.digest !== op.afterDigest)) {
147
+ return { action: "conflict", path: op.path, description: `${op.path} has changed since this run — left untouched` };
121
148
  }
149
+ sim.set(op.path, { kind: "absent" });
122
150
  return { action: "restore", path: op.path, description: `delete ${op.path} (created by this run)` };
123
151
  }
124
152
  case "dir-remove": {
125
- if (existsSync(op.path)) {
153
+ if (state.kind !== "absent") {
126
154
  return { action: "conflict", path: op.path, description: `${op.path} exists again since this run — left untouched` };
127
155
  }
156
+ // The rewind's content comes from the beforeDir snapshot at apply
157
+ // time; its digest isn't recorded, so mark it unknown for any
158
+ // (in practice nonexistent) earlier op on the same path.
159
+ sim.set(op.path, { kind: "dir", digest: undefined });
128
160
  return { action: "restore", path: op.path, description: `recreate ${op.path} from its content before this run (${join(runDir, op.beforeDir)})` };
129
161
  }
130
162
  case "dir-replace": {
131
- if (!existsSync(op.path)) {
163
+ if (state.kind === "absent") {
132
164
  return { action: "already-reverted", path: op.path, description: `${op.path} no longer exists — nothing to undo` };
133
165
  }
134
- let digest;
135
- try {
136
- digest = directoryDigest(op.path);
137
- }
138
- catch {
139
- return { action: "conflict", path: op.path, description: `${op.path} can no longer be read safely — left untouched` };
140
- }
141
- if (digest !== op.afterDigest) {
166
+ if (state.kind !== "dir" || state.digest !== op.afterDigest) {
142
167
  return { action: "conflict", path: op.path, description: `${op.path} has changed since this run — left untouched` };
143
168
  }
169
+ sim.set(op.path, { kind: "dir", digest: undefined });
144
170
  return { action: "restore", path: op.path, description: `restore ${op.path} to its content before this run (${join(runDir, op.beforeDir)})` };
145
171
  }
146
172
  }
@@ -149,11 +175,15 @@ export async function collectRollbackPlan(homeDirInput, runIdInput) {
149
175
  const homeDir = homeDirInput ?? homedir();
150
176
  const runId = resolveRunId(homeDir, runIdInput);
151
177
  const manifest = loadManifest(homeDir, runId);
152
- const items = [];
153
- for (const op of manifest.operations) {
154
- items.push(await planOperation(homeDir, runId, op));
178
+ // Plan newest-first against the simulated undo, then report in the
179
+ // manifest's original chronological order.
180
+ const sim = new Map();
181
+ const planned = [];
182
+ for (const op of [...manifest.operations].reverse()) {
183
+ planned.push(await planOperation(homeDir, runId, op, sim));
155
184
  }
156
- return { runId, items };
185
+ planned.reverse();
186
+ return { runId, items: planned };
157
187
  }
158
188
  async function restoreOperation(homeDir, runId, op) {
159
189
  const runDir = join(backupsRoot(homeDir), runId);
@@ -49,6 +49,18 @@ export interface SkillAddPlan {
49
49
  }
50
50
  export declare function collectSkillAddPlan(name: string, fromPath: string, homeDir?: string): SkillAddPlan;
51
51
  export declare function applySkillAddPlan(plan: SkillAddPlan, homeDir?: string, backup?: BackupSession): void;
52
+ /** The workspace twin of `collectSkillAddPlan` (trellis-workspace-scope
53
+ * task 3.4): same source validation, destination is `.agents/skills/`
54
+ * instead of the global canonical skills dir. */
55
+ export declare function collectSkillAddWorkspacePlan(name: string, fromPath: string, workspaceRoot: string): SkillAddPlan;
56
+ export declare function applySkillAddWorkspacePlan(plan: SkillAddPlan, workspaceRoot: string, backup?: BackupSession): void;
57
+ /** Same own-backup-session + cascade-sync posture as
58
+ * `applySkillAddWithSync`, scoped to the workspace layer. */
59
+ export declare function applySkillAddWorkspaceWithSync(plan: SkillAddPlan, workspaceRoot: string, opts?: {
60
+ homeDir?: string;
61
+ dryRun?: boolean;
62
+ backupSession?: BackupSession;
63
+ }): Promise<SkillAddOutcome>;
52
64
  export interface SkillAddOutcome {
53
65
  plan: SkillAddPlan;
54
66
  sync?: SyncReport;
@@ -70,6 +82,8 @@ export declare function runSkillAdd(name: string, fromPath: string, opts?: {
70
82
  homeDir?: string;
71
83
  json?: boolean;
72
84
  dryRun?: boolean;
85
+ workspace?: boolean;
86
+ workspaceDir?: string;
73
87
  }): Promise<{
74
88
  exitCode: number;
75
89
  }>;
@@ -21,6 +21,7 @@ import { openBackupSession } from "../lib/backup.js";
21
21
  import { collectSyncReport, printReport as printSyncReport } from "./sync.js";
22
22
  import { remoteSkillProvenance } from "./remoteSkill.js";
23
23
  import { describeScope, resolveScopeSelection, sameScope } from "../lib/scopeSelection.js";
24
+ import { findWorkspaceRoot } from "../lib/workspace.js";
24
25
  function builtinSkillTemplate() {
25
26
  const here = dirname(fileURLToPath(import.meta.url));
26
27
  return readFileSync(join(here, "..", "..", "schema", "builtin-skills", "trellis-runtime", "SKILL.md"), "utf8");
@@ -114,6 +115,55 @@ export function applySkillAddPlan(plan, homeDir = homedir(), backup) {
114
115
  mkdirSync(dest, { recursive: true });
115
116
  cpSync(plan.sourceDir, dest, { recursive: true });
116
117
  }
118
+ /** The workspace twin of `collectSkillAddPlan` (trellis-workspace-scope
119
+ * task 3.4): same source validation, destination is `.agents/skills/`
120
+ * instead of the global canonical skills dir. */
121
+ export function collectSkillAddWorkspacePlan(name, fromPath, workspaceRoot) {
122
+ const skillFile = findSkillFile(fromPath);
123
+ if (!skillFile) {
124
+ return { name, action: "invalid-source", detail: `${fromPath} has no SKILL.md` };
125
+ }
126
+ if (!skillFile.caseCorrect) {
127
+ return { name, action: "invalid-source", detail: `${fromPath} has skill.md, not case-correct SKILL.md — fix the case first` };
128
+ }
129
+ const dest = join(workspaceRoot, ".agents", "skills", name);
130
+ switch (decideDirImport(fromPath, dest)) {
131
+ case "create":
132
+ return { name, action: "create", detail: `will copy from ${fromPath} into .agents/skills/`, sourceDir: fromPath };
133
+ case "already-present":
134
+ return { name, action: "already-present", detail: ".agents/skills/ content is already byte-identical" };
135
+ case "conflict":
136
+ return { name, action: "conflict", detail: `.agents/skills/${name}/ already exists with different content — resolve by hand` };
137
+ }
138
+ }
139
+ export function applySkillAddWorkspacePlan(plan, workspaceRoot, backup) {
140
+ if (plan.action !== "create" || !plan.sourceDir)
141
+ return;
142
+ const dest = join(workspaceRoot, ".agents", "skills", plan.name);
143
+ if (backup) {
144
+ backup.createDirFromSource(dest, plan.sourceDir);
145
+ return;
146
+ }
147
+ mkdirSync(dest, { recursive: true });
148
+ cpSync(plan.sourceDir, dest, { recursive: true });
149
+ }
150
+ /** Same own-backup-session + cascade-sync posture as
151
+ * `applySkillAddWithSync`, scoped to the workspace layer. */
152
+ export async function applySkillAddWorkspaceWithSync(plan, workspaceRoot, opts = {}) {
153
+ const homeDir = opts.homeDir ?? homedir();
154
+ const ownSession = !opts.dryRun && !opts.backupSession && plan.action === "create" ? openBackupSession(homeDir, "skill-add") : undefined;
155
+ const session = opts.backupSession ?? ownSession;
156
+ if (!opts.dryRun && plan.action === "create") {
157
+ applySkillAddWorkspacePlan(plan, workspaceRoot, session);
158
+ }
159
+ ownSession?.finalize();
160
+ const failed = plan.action === "conflict" || plan.action === "invalid-source";
161
+ let syncReport;
162
+ if (!failed && plan.action === "create" && existsSync(join(homeDir, ".trellis"))) {
163
+ syncReport = await collectSyncReport({ target: "skills", homeDir, workspaceDir: workspaceRoot, dryRun: opts.dryRun });
164
+ }
165
+ return { plan, ...(syncReport ? { sync: syncReport } : {}) };
166
+ }
117
167
  /**
118
168
  * See `applyMcpAddWithSync`'s doc comment (mcp.ts) — same split, same
119
169
  * reason: a non-CLI caller needs the structured result a JSON-mode CLI
@@ -139,6 +189,27 @@ export async function applySkillAddWithSync(plan, opts = {}) {
139
189
  }
140
190
  export async function runSkillAdd(name, fromPath, opts = {}) {
141
191
  const homeDir = opts.homeDir ?? homedir();
192
+ if (opts.workspace) {
193
+ const workspaceRoot = opts.workspaceDir ?? findWorkspaceRoot(process.cwd());
194
+ if (!workspaceRoot) {
195
+ console.error("no .agents/ found here or above — run `trellis init --workspace` first (or pass --workspace from inside the repo)");
196
+ return { exitCode: 1 };
197
+ }
198
+ const plan = collectSkillAddWorkspacePlan(name, fromPath, workspaceRoot);
199
+ const { sync: syncReport } = await applySkillAddWorkspaceWithSync(plan, workspaceRoot, { homeDir, dryRun: opts.dryRun });
200
+ const failed = plan.action === "conflict" || plan.action === "invalid-source";
201
+ if (opts.json) {
202
+ console.log(JSON.stringify(syncReport ? { ...plan, workspace: workspaceRoot, sync: syncReport } : { ...plan, workspace: workspaceRoot }, null, 2));
203
+ }
204
+ else {
205
+ console.log(`${opts.dryRun ? "[dry run] " : ""}skill add ${name} --workspace`);
206
+ console.log(` [${plan.action}] ${plan.detail}`);
207
+ if (syncReport)
208
+ printSyncReport(syncReport, opts.dryRun ?? false);
209
+ }
210
+ const syncConflict = syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false;
211
+ return { exitCode: failed || syncConflict ? 1 : 0 };
212
+ }
142
213
  const plan = collectSkillAddPlan(name, fromPath, homeDir);
143
214
  const { sync: syncReport } = await applySkillAddWithSync(plan, { homeDir, dryRun: opts.dryRun });
144
215
  const failed = plan.action === "conflict" || plan.action === "invalid-source";
@@ -34,14 +34,29 @@ export interface RunSyncOptions {
34
34
  * does NOT finalize it — only whoever opened it does. Never a CLI
35
35
  * flag — standalone `trellis sync` always opens and finalizes its own. */
36
36
  backupSession?: BackupSession;
37
+ /** Force global-only behavior even inside a workspace (`--global-only`). */
38
+ globalOnly?: boolean;
39
+ /** Explicit workspace root — the test/sandbox seam. CLI discovery walks
40
+ * up from cwd via `findWorkspaceRoot`; this overrides it (null = none). */
41
+ workspaceDir?: string | null;
42
+ /** `--check`: compute the plan, write nothing, exit non-zero when any
43
+ * rendered artifact is stale or missing (CI/pre-commit freshness gate,
44
+ * trellis-workspace-scope design D7). Implies dry-run. */
45
+ check?: boolean;
37
46
  }
38
47
  export interface AgentSyncReport {
39
48
  agent: AgentId;
40
49
  present: boolean;
41
50
  items: AdapterPlanItem[];
51
+ /** zcode's reverse-precedence notices (workspace shadowing that zcode
52
+ * resolves user-level-first) — warnings, not conflicts: the write still
53
+ * happens. */
54
+ warnings?: string[];
42
55
  }
43
56
  export interface SyncReport {
44
57
  reports: AgentSyncReport[];
58
+ /** Workspace root when a `.agents/` layer participated in this run. */
59
+ workspaceRoot?: string;
45
60
  }
46
61
  export declare function collectSyncReport(opts?: RunSyncOptions): Promise<SyncReport>;
47
62
  export declare function runSync(opts?: RunSyncOptions): Promise<{
@@ -50,4 +65,4 @@ export declare function runSync(opts?: RunSyncOptions): Promise<{
50
65
  /** Exported so `onboard` prints a sync report identically to running
51
66
  * `sync` standalone, instead of a second, easily-drifting copy of this
52
67
  * formatting. */
53
- export declare function printReport(report: SyncReport, dryRun: boolean): void;
68
+ export declare function printReport(report: SyncReport, dryRun: boolean, check?: boolean): void;
@@ -14,6 +14,9 @@ import { PiAdapter } from "../adapters/pi.js";
14
14
  import { KimiCodeAdapter } from "../adapters/kimi-code.js";
15
15
  import { ZcodeAdapter } from "../adapters/zcode.js";
16
16
  import { openBackupSession } from "../lib/backup.js";
17
+ import { findWorkspaceRoot } from "../lib/workspace.js";
18
+ import { applyWorkspaceFiles, planWorkspaceFiles } from "../lib/workspaceFiles.js";
19
+ import { workspacePrecedenceWarnings } from "../lib/workspaceMcp.js";
17
20
  const ADAPTER_FACTORY = {
18
21
  "claude-code": (homeDir) => new ClaudeCodeAdapter(homeDir),
19
22
  codex: (homeDir) => new CodexAdapter(homeDir),
@@ -30,7 +33,11 @@ function buildAdapters(homeDir, managedAgents) {
30
33
  }
31
34
  export async function collectSyncReport(opts = {}) {
32
35
  const homeDir = opts.homeDir ?? homedir();
33
- const loaded = loadCanonicalSource(homeDir);
36
+ // Workspace participation is automatic inside a repo carrying `.agents/`
37
+ // and revocable via --global-only; outside any workspace (or when it
38
+ // resolves null) the load is byte-identical to before.
39
+ const workspaceDir = opts.globalOnly ? null : (opts.workspaceDir !== undefined ? opts.workspaceDir : findWorkspaceRoot(process.cwd()));
40
+ const loaded = loadCanonicalSource({ homeDir, workspaceDir });
34
41
  // A single override point, not two: every downstream read of "who's
35
42
  // managed" — the adapter list built here AND each adapter's own
36
43
  // in-scope filtering via `canonical.managedAgents` — must agree, or a
@@ -39,9 +46,10 @@ export async function collectSyncReport(opts = {}) {
39
46
  const canonical = opts.managedAgents ? { ...loaded, managedAgents: opts.managedAgents } : loaded;
40
47
  const reports = [];
41
48
  // Own session only when the caller didn't share one (trellis-backup-
42
- // rollback D2) — dry-run never opens or writes one, there's nothing to
43
- // record.
44
- const ownSession = !opts.dryRun && !opts.backupSession ? openBackupSession(homeDir, "sync") : undefined;
49
+ // rollback D2) — dry-run/check never opens or writes one, there's
50
+ // nothing to record.
51
+ const writeMode = !opts.dryRun && !opts.check;
52
+ const ownSession = writeMode && !opts.backupSession ? openBackupSession(homeDir, "sync") : undefined;
45
53
  const backup = opts.backupSession ?? ownSession;
46
54
  for (const adapter of buildAdapters(homeDir, canonical.managedAgents)) {
47
55
  const probeResult = await adapter.probe();
@@ -53,6 +61,7 @@ export async function collectSyncReport(opts = {}) {
53
61
  // `trellis sync` never touches MCP, that's `trellis mcp sync`'s own
54
62
  // command, so exclude it unconditionally before any target filter.
55
63
  let items = (await adapter.plan(canonical)).filter((item) => item.kind !== "mcp");
64
+ let wsItems = planWorkspaceFiles(adapter.id, canonical);
56
65
  if (opts.target) {
57
66
  // CLI/option vocabulary is plural ("skills"/"instructions",
58
67
  // matching `trellis sync skills`); AdapterPlanItem.kind is singular
@@ -63,14 +72,18 @@ export async function collectSyncReport(opts = {}) {
63
72
  // sync" even with real create items pending).
64
73
  const kind = opts.target === "skills" ? "skill" : "instructions";
65
74
  items = items.filter((item) => item.kind === kind || (kind === "skill" && item.kind === "zcode-skill-control"));
75
+ wsItems = wsItems.filter((item) => item.kind === kind);
66
76
  }
67
- if (!opts.dryRun) {
77
+ const allItems = [...items, ...wsItems];
78
+ if (writeMode) {
68
79
  await adapter.apply(items, backup);
80
+ applyWorkspaceFiles(adapter.id, canonical, wsItems, backup);
69
81
  }
70
- reports.push({ agent: adapter.id, present: true, items });
82
+ const warnings = workspacePrecedenceWarnings(adapter.id, canonical);
83
+ reports.push({ agent: adapter.id, present: true, items: allItems, ...(warnings.length > 0 ? { warnings } : {}) });
71
84
  }
72
85
  ownSession?.finalize();
73
- return { reports };
86
+ return { reports, ...(canonical.workspace ? { workspaceRoot: canonical.workspace.root } : {}) };
74
87
  }
75
88
  export async function runSync(opts = {}) {
76
89
  let report;
@@ -85,22 +98,34 @@ export async function runSync(opts = {}) {
85
98
  console.log(JSON.stringify(report, null, 2));
86
99
  }
87
100
  else {
88
- printReport(report, opts.dryRun ?? false);
101
+ printReport(report, opts.dryRun ?? false, opts.check ?? false);
89
102
  }
90
103
  const hasConflict = report.reports.some((r) => r.items.some((i) => i.action === "conflict"));
104
+ if (opts.check) {
105
+ // Freshness gate: any pending write (create/remove) or conflict means
106
+ // the rendered artifacts are stale — exit non-zero, having written
107
+ // nothing.
108
+ const hasPending = report.reports.some((r) => r.items.some((i) => i.action !== "conflict"));
109
+ return { exitCode: hasPending || hasConflict ? 1 : 0 };
110
+ }
91
111
  return { exitCode: hasConflict ? 1 : 0 };
92
112
  }
93
113
  /** Exported so `onboard` prints a sync report identically to running
94
114
  * `sync` standalone, instead of a second, easily-drifting copy of this
95
115
  * formatting. */
96
- export function printReport(report, dryRun) {
116
+ export function printReport(report, dryRun, check = false) {
97
117
  if (dryRun)
98
118
  console.log("[dry run]");
119
+ if (check)
120
+ console.log("[check]");
121
+ if (report.workspaceRoot) {
122
+ console.log(`Workspace layer: ${report.workspaceRoot} (.agents/)`);
123
+ }
99
124
  if (report.reports.length === 0) {
100
125
  console.log("No managed agents yet — run `trellis onboard` or list agent ids in ~/.trellis/managed.yaml.");
101
126
  return;
102
127
  }
103
- for (const { agent, present, items } of report.reports) {
128
+ for (const { agent, present, items, warnings } of report.reports) {
104
129
  if (!present) {
105
130
  console.log(`— ${agent} (not installed)`);
106
131
  continue;
@@ -110,12 +135,20 @@ export function printReport(report, dryRun) {
110
135
  const conflicts = items.filter((i) => i.action === "conflict");
111
136
  if (created.length === 0 && removed.length === 0 && conflicts.length === 0) {
112
137
  console.log(`✅ ${agent} — already in sync`);
113
- continue;
114
138
  }
115
- const icon = conflicts.length > 0 ? "⚠️ " : "✅";
116
- console.log(`${icon} ${agent} — ${created.length} created, ${removed.length} removed, ${conflicts.length} conflict(s)`);
117
- for (const item of [...created, ...removed, ...conflicts]) {
118
- console.log(` - [${item.action}] ${item.description}`);
139
+ else {
140
+ const icon = conflicts.length > 0 ? "⚠️ " : "✅";
141
+ console.log(`${icon} ${agent} — ${created.length} created, ${removed.length} removed, ${conflicts.length} conflict(s)`);
142
+ for (const item of [...created, ...removed, ...conflicts]) {
143
+ console.log(` - [${item.action}]${item.layer === "workspace" ? " (workspace)" : ""} ${item.description}`);
144
+ // Same as mcp.ts's printer: a conflict's remediation IS the fix —
145
+ // never leave the user at "left untouched" with no next step.
146
+ if (item.remediation)
147
+ console.log(` → ${item.remediation}`);
148
+ }
149
+ }
150
+ for (const warning of warnings ?? []) {
151
+ console.log(` ⚠ ${warning}`);
119
152
  }
120
153
  }
121
154
  }