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.
Files changed (55) hide show
  1. package/dist/adapters/claude-code.js +4 -3
  2. package/dist/adapters/codex.js +4 -3
  3. package/dist/adapters/kimi-code.js +4 -3
  4. package/dist/adapters/kiro.js +5 -4
  5. package/dist/adapters/pi.js +4 -3
  6. package/dist/adapters/zcode.d.ts +12 -1
  7. package/dist/adapters/zcode.js +7 -6
  8. package/dist/cli.js +38 -5
  9. package/dist/commands/doctor.d.ts +9 -1
  10. package/dist/commands/doctor.js +43 -6
  11. package/dist/commands/init.d.ts +6 -0
  12. package/dist/commands/init.js +24 -0
  13. package/dist/commands/mcp.d.ts +44 -1
  14. package/dist/commands/mcp.js +145 -33
  15. package/dist/commands/migrate.d.ts +12 -0
  16. package/dist/commands/migrate.js +8 -4
  17. package/dist/commands/migrateWorkspace.d.ts +40 -0
  18. package/dist/commands/migrateWorkspace.js +197 -0
  19. package/dist/commands/rollback.js +65 -35
  20. package/dist/commands/skill.d.ts +14 -0
  21. package/dist/commands/skill.js +71 -0
  22. package/dist/commands/sync.d.ts +16 -1
  23. package/dist/commands/sync.js +48 -15
  24. package/dist/core/adapter.d.ts +14 -0
  25. package/dist/core/canonical.d.ts +20 -34
  26. package/dist/core/canonical.js +18 -92
  27. package/dist/core/mcpServerDef.d.ts +39 -0
  28. package/dist/core/mcpServerDef.js +93 -0
  29. package/dist/core/types.d.ts +39 -4
  30. package/dist/lib/mcpMigrateRead.d.ts +25 -0
  31. package/dist/lib/mcpMigrateRead.js +9 -2
  32. package/dist/lib/skillProvider.js +18 -5
  33. package/dist/lib/workspace.d.ts +39 -0
  34. package/dist/lib/workspace.js +158 -0
  35. package/dist/lib/workspaceConfig.d.ts +45 -0
  36. package/dist/lib/workspaceConfig.js +98 -0
  37. package/dist/lib/workspaceDoctor.d.ts +44 -0
  38. package/dist/lib/workspaceDoctor.js +94 -0
  39. package/dist/lib/workspaceFiles.d.ts +38 -0
  40. package/dist/lib/workspaceFiles.js +264 -0
  41. package/dist/lib/workspaceMcp.d.ts +57 -0
  42. package/dist/lib/workspaceMcp.js +277 -0
  43. package/dist/lib/workspaceMerge.d.ts +37 -0
  44. package/dist/lib/workspaceMerge.js +98 -0
  45. package/dist/lib/workspaceMigrate.d.ts +118 -0
  46. package/dist/lib/workspaceMigrate.js +423 -0
  47. package/dist/pi-bridge/bundle.js +258 -96
  48. package/docs/architecture.md +111 -49
  49. package/docs/getting-started.md +86 -14
  50. package/docs/mcp-oauth-matrix.md +12 -1
  51. package/docs/overview.zh-CN.md +17 -0
  52. package/docs/research.md +28 -6
  53. package/docs/roadmap.md +17 -5
  54. package/package.json +1 -1
  55. package/docs/implementation-plan.md +0 -341
@@ -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
  }
@@ -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,40 +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
- * `client_name` the same way (trellis-mcp-static-env-and-disabled-servers,
15
- * trellis-migrate-env-var-alias, trellis-mcp-oauth-static-client,
16
- * oauth-client-name).
17
- */
18
- type McpServerDefYaml = Omit<McpServerDef, "staticEnv" | "envAliases" | "auth"> & {
19
- static_env?: Record<string, string>;
20
- env_aliases?: Record<string, string>;
21
- auth?: McpAuthYaml;
22
- };
23
- /** The on-disk `auth`: the `oauth` scalar exactly as before, or the object
24
- * form whose keys are snake_case like every other multi-word key in
25
- * `.trellis/*.yaml`. Values are `unknown` until `fromAuthYaml` validates
26
- * them — a YAML cast is optimistic, validation is this module's job. */
27
- type McpAuthYaml = string | {
28
- kind?: unknown;
29
- owner?: unknown;
30
- client_id?: unknown;
31
- client_secret_env?: unknown;
32
- client_name?: unknown;
33
- };
34
- /** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
35
- * `undefined` fields so the written YAML never gets a literal `null`
36
- * for an omitted optional. */
37
- export declare function toServerDefYaml(def: McpServerDef): McpServerDefYaml;
10
+ export { toServerDefYaml } from "./mcpServerDef.js";
11
+ export type { McpServerDefYaml } from "./mcpServerDef.js";
38
12
  export type ServersYamlWriteResult = {
39
13
  ok: true;
40
14
  } | {
@@ -140,6 +114,18 @@ export declare function ensureGitignoreEntry(gitignorePath: string, line: string
140
114
  * `export` prefix of its own.
141
115
  */
142
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
+ }
143
128
  /** Load the global canonical source from the supplied home directory. */
144
129
  export declare function loadCanonicalSource(homeDir?: string): CanonicalSource;
145
- export {};
130
+ /** Load the global canonical source, optionally layered with an explicit workspace's `.agents/`. */
131
+ export declare function loadCanonicalSource(options: LoadCanonicalOptions): CanonicalSource;