agent-trellis 0.14.1 → 0.15.1
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 +52 -37
- 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 +69 -3
- package/dist/commands/mcp.js +255 -56
- package/dist/commands/mcpGateway.js +3 -3
- 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/cliArgs.d.ts +17 -0
- package/dist/lib/cliArgs.js +36 -0
- package/dist/lib/mcpMigrateRead.d.ts +25 -0
- package/dist/lib/mcpMigrateRead.js +9 -2
- package/dist/lib/scopeSelection.d.ts +1 -1
- package/dist/lib/scopeSelection.js +4 -3
- 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 +51 -0
- package/dist/lib/workspaceConfig.js +117 -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
package/dist/commands/migrate.js
CHANGED
|
@@ -29,8 +29,11 @@ import { discoverNativeMemory, MAX_NATIVE_MEMORY_BYTES, renderNativeMemory } fro
|
|
|
29
29
|
* a second, disconnected location. `~`-form for what gets written into
|
|
30
30
|
* `secrets.policy.yaml` itself (portable across machines); resolved form
|
|
31
31
|
* for real file I/O. */
|
|
32
|
-
const DEFAULT_LOCAL_SECRETS_ENV_FILE_TILDE = "~/.trellis/mcp/servers.local.env";
|
|
33
|
-
|
|
32
|
+
export const DEFAULT_LOCAL_SECRETS_ENV_FILE_TILDE = "~/.trellis/mcp/servers.local.env";
|
|
33
|
+
/** Exported for `migrate --workspace` (trellis-workspace-scope task 4.2) —
|
|
34
|
+
* its staticEnv extraction lands in the same default store under the same
|
|
35
|
+
* naming scheme, never a second location. */
|
|
36
|
+
export function defaultLocalSecretsEnvFilePath(homeDir) {
|
|
34
37
|
return join(homeDir, ".trellis", "mcp", "servers.local.env");
|
|
35
38
|
}
|
|
36
39
|
/** Picks the rc file `ensureShellEnvSource` writes its one pointer block
|
|
@@ -54,8 +57,9 @@ const PROBES = {
|
|
|
54
57
|
"kimi-code": (homeDir) => kimiCodeProbe.probe(homeDir),
|
|
55
58
|
zcode: (homeDir) => zcodeProbe.probe(homeDir),
|
|
56
59
|
};
|
|
57
|
-
/** pi
|
|
58
|
-
* trellis-migrate-mcp-servers
|
|
60
|
+
/** pi's mcp.json migrate-in is not implemented (roadmap.md P14/
|
|
61
|
+
* trellis-migrate-mcp-servers predates pi 1.1.0's native MCP config) —
|
|
62
|
+
* deliberately absent, not an oversight;
|
|
59
63
|
* `collectMigratePlan` skips the `mcp` category entirely for pi. */
|
|
60
64
|
const MCP_READERS = {
|
|
61
65
|
"claude-code": readClaudeCodeMcpDefs,
|
|
@@ -0,0 +1,40 @@
|
|
|
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 type { AgentId } from "../core/types.js";
|
|
14
|
+
import type { WorkspaceMigratePlan } from "../lib/workspaceMigrate.js";
|
|
15
|
+
export interface RunMigrateWorkspaceOptions {
|
|
16
|
+
/** Test/sandbox seam — CLI resolves via findWorkspaceRoot(cwd), falling
|
|
17
|
+
* back to cwd itself so a legacy project without `.agents/` yet can
|
|
18
|
+
* still be scanned (apply then creates `.agents/` through backup). */
|
|
19
|
+
workspaceDir?: string;
|
|
20
|
+
/** Raw `name=agentId` strings from repeated `--choose`. */
|
|
21
|
+
choose?: string[];
|
|
22
|
+
/** Raw `--dedup` value: `drop` (default) or `override`. */
|
|
23
|
+
dedup?: string;
|
|
24
|
+
dryRun?: boolean;
|
|
25
|
+
json?: boolean;
|
|
26
|
+
homeDir?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface WorkspaceMigrateEquivalence {
|
|
29
|
+
/** Per agent: server names found in its project config before the
|
|
30
|
+
* migration vs. what the post-migration render now delivers. */
|
|
31
|
+
agents: {
|
|
32
|
+
agent: AgentId;
|
|
33
|
+
before: string[];
|
|
34
|
+
after: string[];
|
|
35
|
+
}[];
|
|
36
|
+
}
|
|
37
|
+
export declare function runMigrateWorkspace(opts?: RunMigrateWorkspaceOptions): Promise<{
|
|
38
|
+
exitCode: number;
|
|
39
|
+
}>;
|
|
40
|
+
export declare function printWorkspaceMigratePlan(plan: WorkspaceMigratePlan, dryRun: boolean): void;
|
|
@@ -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
|
-
|
|
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;
|