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
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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);
|
package/dist/commands/skill.d.ts
CHANGED
|
@@ -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
|
}>;
|
package/dist/commands/skill.js
CHANGED
|
@@ -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";
|
package/dist/commands/sync.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
43
|
-
// record.
|
|
44
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
}
|
package/dist/core/adapter.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/canonical.d.ts
CHANGED
|
@@ -1,40 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Loads `~/.trellis/` into a `CanonicalSource`. Global
|
|
3
|
-
*
|
|
4
|
-
* workspace
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
130
|
+
/** Load the global canonical source, optionally layered with an explicit workspace's `.agents/`. */
|
|
131
|
+
export declare function loadCanonicalSource(options: LoadCanonicalOptions): CanonicalSource;
|