agent-trellis 0.1.0 → 0.2.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 (41) hide show
  1. package/README.md +57 -17
  2. package/dist/adapters/claude-code.d.ts +2 -1
  3. package/dist/adapters/claude-code.js +7 -7
  4. package/dist/adapters/codex.d.ts +2 -1
  5. package/dist/adapters/codex.js +7 -7
  6. package/dist/adapters/jsonMcp.d.ts +1 -0
  7. package/dist/adapters/jsonMcp.js +2 -2
  8. package/dist/adapters/kiro.d.ts +2 -1
  9. package/dist/adapters/kiro.js +9 -9
  10. package/dist/adapters/mcpPlan.d.ts +1 -1
  11. package/dist/adapters/mcpPlan.js +2 -2
  12. package/dist/adapters/pi.d.ts +2 -1
  13. package/dist/adapters/pi.js +4 -4
  14. package/dist/adapters/symlinkPlan.d.ts +7 -3
  15. package/dist/adapters/symlinkPlan.js +42 -16
  16. package/dist/cli.js +41 -11
  17. package/dist/commands/init.js +11 -0
  18. package/dist/commands/mcp.d.ts +13 -0
  19. package/dist/commands/mcp.js +31 -7
  20. package/dist/commands/onboard.d.ts +42 -7
  21. package/dist/commands/onboard.js +207 -34
  22. package/dist/commands/rollback.d.ts +44 -0
  23. package/dist/commands/rollback.js +201 -0
  24. package/dist/commands/secretsAudit.d.ts +7 -0
  25. package/dist/commands/secretsAudit.js +14 -7
  26. package/dist/commands/sync.d.ts +13 -0
  27. package/dist/commands/sync.js +31 -5
  28. package/dist/core/adapter.d.ts +10 -3
  29. package/dist/core/adapter.js +2 -2
  30. package/dist/core/canonical.js +22 -0
  31. package/dist/core/types.d.ts +12 -1
  32. package/dist/core/types.js +11 -2
  33. package/dist/lib/backup.d.ts +56 -0
  34. package/dist/lib/backup.js +98 -0
  35. package/dist/lib/installAgent.d.ts +26 -0
  36. package/dist/lib/installAgent.js +46 -0
  37. package/dist/pi-bridge/bundle.js +26 -7
  38. package/dist/pi-bridge/index.js +8 -1
  39. package/docs/getting-started.md +104 -26
  40. package/docs/roadmap.md +133 -0
  41. package/package.json +1 -1
@@ -0,0 +1,201 @@
1
+ /**
2
+ * `trellis rollback` — restores exactly what one recorded backup run
3
+ * changed (trellis-backup-rollback). Every operation is checked against
4
+ * its target path's *current* state before touching anything: if the
5
+ * path still matches what the run itself left behind, it's restored; if
6
+ * something else has touched it since, that's a conflict, reported and
7
+ * left untouched — same "verify, never guess" posture `sync`/`mcp sync`
8
+ * already hold themselves to for every other kind of conflict.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
12
+ import { readlink, rm, symlink } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { backupsRoot } from "../lib/backup.js";
16
+ function sha256(content) {
17
+ return `sha256:${createHash("sha256").update(content).digest("hex")}`;
18
+ }
19
+ /** Newest first — run ids are ISO-timestamp-prefixed, so lexical sort is
20
+ * chronological sort. */
21
+ export function listBackups(homeDir = homedir()) {
22
+ const root = backupsRoot(homeDir);
23
+ if (!existsSync(root))
24
+ return [];
25
+ const runIds = readdirSync(root).sort().reverse();
26
+ const summaries = [];
27
+ for (const runId of runIds) {
28
+ const manifest = tryLoadManifest(homeDir, runId);
29
+ if (!manifest)
30
+ continue;
31
+ summaries.push({ runId, command: manifest.command, startedAt: manifest.startedAt, operationCount: manifest.operations.length });
32
+ }
33
+ return summaries;
34
+ }
35
+ function tryLoadManifest(homeDir, runId) {
36
+ const manifestPath = join(backupsRoot(homeDir), runId, "manifest.json");
37
+ if (!existsSync(manifestPath))
38
+ return undefined;
39
+ return JSON.parse(readFileSync(manifestPath, "utf-8"));
40
+ }
41
+ export function loadManifest(homeDir, runId) {
42
+ const manifest = tryLoadManifest(homeDir, runId);
43
+ if (!manifest) {
44
+ throw new Error(`No backup run "${runId}" found under ${backupsRoot(homeDir)}`);
45
+ }
46
+ return manifest;
47
+ }
48
+ function resolveRunId(homeDir, requested) {
49
+ if (requested)
50
+ return requested;
51
+ const [mostRecent] = listBackups(homeDir);
52
+ if (!mostRecent) {
53
+ throw new Error(`No backup runs found under ${backupsRoot(homeDir)} — nothing to roll back`);
54
+ }
55
+ return mostRecent.runId;
56
+ }
57
+ async function currentLinkTarget(path) {
58
+ try {
59
+ return await readlink(path);
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
65
+ async function planOperation(homeDir, runId, op) {
66
+ const runDir = join(backupsRoot(homeDir), runId);
67
+ switch (op.kind) {
68
+ case "file-create":
69
+ case "file-overwrite": {
70
+ if (!existsSync(op.path)) {
71
+ return { action: "already-reverted", path: op.path, description: `${op.path} no longer exists — nothing to undo` };
72
+ }
73
+ const currentHash = sha256(readFileSync(op.path, "utf-8"));
74
+ if (currentHash !== op.afterHash) {
75
+ return { action: "conflict", path: op.path, description: `${op.path} has changed since this run — left untouched` };
76
+ }
77
+ if (op.kind === "file-create") {
78
+ return { action: "restore", path: op.path, description: `delete ${op.path} (created by this run)` };
79
+ }
80
+ return { action: "restore", path: op.path, description: `restore ${op.path} to its content before this run (${join(runDir, op.beforeFile)})` };
81
+ }
82
+ case "symlink-create":
83
+ case "symlink-repair": {
84
+ const current = await currentLinkTarget(op.path);
85
+ if (current === undefined) {
86
+ return { action: "already-reverted", path: op.path, description: `${op.path} no longer exists — nothing to undo` };
87
+ }
88
+ if (current !== op.afterLinkTarget) {
89
+ return { action: "conflict", path: op.path, description: `${op.path} points somewhere else now — left untouched` };
90
+ }
91
+ if (op.kind === "symlink-create") {
92
+ return { action: "restore", path: op.path, description: `remove ${op.path} (created by this run)` };
93
+ }
94
+ return { action: "restore", path: op.path, description: `repoint ${op.path} back to ${op.beforeLinkTarget}` };
95
+ }
96
+ case "symlink-remove": {
97
+ if (existsSync(op.path)) {
98
+ return { action: "conflict", path: op.path, description: `${op.path} exists again since this run — left untouched` };
99
+ }
100
+ return { action: "restore", path: op.path, description: `recreate ${op.path} -> ${op.beforeLinkTarget}` };
101
+ }
102
+ }
103
+ }
104
+ export async function collectRollbackPlan(homeDirInput, runIdInput) {
105
+ const homeDir = homeDirInput ?? homedir();
106
+ const runId = resolveRunId(homeDir, runIdInput);
107
+ const manifest = loadManifest(homeDir, runId);
108
+ const items = [];
109
+ for (const op of manifest.operations) {
110
+ items.push(await planOperation(homeDir, runId, op));
111
+ }
112
+ return { runId, items };
113
+ }
114
+ async function restoreOperation(homeDir, runId, op) {
115
+ const runDir = join(backupsRoot(homeDir), runId);
116
+ switch (op.kind) {
117
+ case "file-create":
118
+ rmSync(op.path, { force: true });
119
+ return;
120
+ case "file-overwrite":
121
+ writeFileSync(op.path, readFileSync(join(runDir, op.beforeFile), "utf-8"));
122
+ return;
123
+ case "symlink-create":
124
+ await rm(op.path, { force: true });
125
+ return;
126
+ case "symlink-repair":
127
+ await rm(op.path, { force: true });
128
+ await symlink(op.beforeLinkTarget, op.path);
129
+ return;
130
+ case "symlink-remove":
131
+ await symlink(op.beforeLinkTarget, op.path);
132
+ return;
133
+ }
134
+ }
135
+ export async function applyRollbackPlan(homeDir, runId, manifest, items) {
136
+ const restorePaths = new Set(items.filter((i) => i.action === "restore").map((i) => i.path));
137
+ for (const op of manifest.operations) {
138
+ if (!restorePaths.has(op.path))
139
+ continue;
140
+ await restoreOperation(homeDir, runId, op);
141
+ }
142
+ }
143
+ export async function runRollback(opts = {}) {
144
+ const homeDir = opts.homeDir ?? homedir();
145
+ if (opts.list) {
146
+ const runs = listBackups(homeDir);
147
+ if (opts.json) {
148
+ console.log(JSON.stringify(runs, null, 2));
149
+ }
150
+ else {
151
+ printList(runs);
152
+ }
153
+ return { exitCode: 0 };
154
+ }
155
+ let report;
156
+ try {
157
+ report = await collectRollbackPlan(homeDir, opts.runId);
158
+ }
159
+ catch (err) {
160
+ console.error(err instanceof Error ? err.message : String(err));
161
+ return { exitCode: 1 };
162
+ }
163
+ if (!opts.dryRun) {
164
+ const manifest = loadManifest(homeDir, report.runId);
165
+ await applyRollbackPlan(homeDir, report.runId, manifest, report.items);
166
+ }
167
+ if (opts.json) {
168
+ console.log(JSON.stringify(report, null, 2));
169
+ }
170
+ else {
171
+ printReport(report, opts.dryRun ?? false);
172
+ }
173
+ const hasConflict = report.items.some((i) => i.action === "conflict");
174
+ return { exitCode: hasConflict ? 1 : 0 };
175
+ }
176
+ function printList(runs) {
177
+ if (runs.length === 0) {
178
+ console.log("No backup runs found under ~/.trellis/backups/.");
179
+ return;
180
+ }
181
+ for (const run of runs) {
182
+ console.log(`${run.runId} — ${run.command}, ${run.operationCount} operation(s), ${run.startedAt}`);
183
+ }
184
+ }
185
+ export function printReport(report, dryRun) {
186
+ if (dryRun)
187
+ console.log("[dry run]");
188
+ console.log(`rollback ${report.runId}`);
189
+ const restored = report.items.filter((i) => i.action === "restore");
190
+ const conflicts = report.items.filter((i) => i.action === "conflict");
191
+ const alreadyReverted = report.items.filter((i) => i.action === "already-reverted");
192
+ if (report.items.length === 0) {
193
+ console.log(" nothing recorded in this run");
194
+ return;
195
+ }
196
+ const icon = conflicts.length > 0 ? "⚠️ " : "✅";
197
+ console.log(`${icon} ${restored.length} restored, ${conflicts.length} conflict(s), ${alreadyReverted.length} already reverted`);
198
+ for (const item of [...restored, ...conflicts, ...alreadyReverted]) {
199
+ console.log(` - [${item.action}] ${item.description}`);
200
+ }
201
+ }
@@ -17,6 +17,9 @@ export interface RunSecretsAuditOptions {
17
17
  /** Same test/sandbox-only seam as every other command — never a CLI
18
18
  * flag. See docs/architecture.md's testing philosophy. */
19
19
  homeDir?: string;
20
+ /** Onboard-only seam — see RunSyncOptions.managedAgents. Never a CLI
21
+ * flag. */
22
+ managedAgents?: readonly AgentId[];
20
23
  }
21
24
  export interface SecretsFinding {
22
25
  /** "environment" for `missing-env-value` — that check isn't scoped to
@@ -33,3 +36,7 @@ export declare function collectSecretsAuditReport(opts?: RunSecretsAuditOptions)
33
36
  export declare function runSecretsAudit(opts?: RunSecretsAuditOptions): Promise<{
34
37
  exitCode: number;
35
38
  }>;
39
+ /** Exported so `onboard` prints a secrets-audit report identically to
40
+ * running `secrets audit` standalone, instead of a second, easily-drifting
41
+ * copy of this formatting. */
42
+ export declare function printReport(report: SecretsAuditReport): void;
@@ -20,15 +20,18 @@ import { CodexAdapter } from "../adapters/codex.js";
20
20
  import { KiroAdapter } from "../adapters/kiro.js";
21
21
  import { declaredEnvNames, extractJsonEnvVarNames, extractTomlEnvVarNames } from "../lib/envVarNames.js";
22
22
  import { resolveSecretEnv } from "../lib/secretEnv.js";
23
- function auditedAgents(homeDir) {
24
- return [
23
+ /** Only agents in `managedAgents` — a present-but-unmanaged agent's config
24
+ * file is never even read (trellis-managed-agents), same restriction as
25
+ * `sync`/`mcp sync`. pi has no static, generated MCP config file to audit
26
+ * regardless of management — its bridge reads mcp/servers.yaml directly at
27
+ * its own runtime (P4). See design.md Non-Goals in trellis-secrets-audit-p3. */
28
+ function auditedAgents(homeDir, managedAgents) {
29
+ const all = [
25
30
  { id: "claude-code", probe: () => new ClaudeCodeAdapter(homeDir).probe(), configPath: (h) => join(h, ".claude.json"), extractNames: extractJsonEnvVarNames },
26
31
  { id: "codex", probe: () => new CodexAdapter(homeDir).probe(), configPath: (h) => join(h, ".codex", "config.toml"), extractNames: extractTomlEnvVarNames },
27
32
  { id: "kiro", probe: () => new KiroAdapter(homeDir).probe(), configPath: (h) => join(h, ".kiro", "settings", "mcp.json"), extractNames: extractJsonEnvVarNames },
28
- // pi has no static, generated MCP config file to audit — its bridge
29
- // reads mcp/servers.yaml directly at its own runtime (P4). See
30
- // design.md Non-Goals in trellis-secrets-audit-p3.
31
33
  ];
34
+ return all.filter((a) => managedAgents.includes(a.id));
32
35
  }
33
36
  /**
34
37
  * Not scoped to any agent, present or not — a name that can't resolve is
@@ -72,8 +75,9 @@ function auditFile(agent, file, content, policy, extractNames) {
72
75
  export async function collectSecretsAuditReport(opts = {}) {
73
76
  const homeDir = opts.homeDir ?? homedir();
74
77
  const canonical = loadCanonicalSource(homeDir);
78
+ const managedAgents = opts.managedAgents ?? canonical.managedAgents;
75
79
  const findings = [];
76
- for (const agent of auditedAgents(homeDir)) {
80
+ for (const agent of auditedAgents(homeDir, managedAgents)) {
77
81
  const probeResult = await agent.probe();
78
82
  if (!probeResult.present)
79
83
  continue;
@@ -103,7 +107,10 @@ export async function runSecretsAudit(opts = {}) {
103
107
  }
104
108
  return { exitCode: report.findings.length > 0 ? 1 : 0 };
105
109
  }
106
- function printReport(report) {
110
+ /** Exported so `onboard` prints a secrets-audit report identically to
111
+ * running `secrets audit` standalone, instead of a second, easily-drifting
112
+ * copy of this formatting. */
113
+ export function printReport(report) {
107
114
  if (report.findings.length === 0) {
108
115
  console.log("✅ no findings — every present agent's real config and every declared env var passed all checks");
109
116
  return;
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { AdapterPlanItem } from "../core/adapter.js";
9
9
  import type { AgentId } from "../core/types.js";
10
+ import { type BackupSession } from "../lib/backup.js";
10
11
  export interface RunSyncOptions {
11
12
  /** Omit to sync both. */
12
13
  target?: "skills" | "instructions";
@@ -21,6 +22,18 @@ export interface RunSyncOptions {
21
22
  homeDir?: string;
22
23
  /** Compute and report the plan without calling adapter.apply(). */
23
24
  dryRun?: boolean;
25
+ /** Onboard-only seam: preview/act against a managed-agent set that
26
+ * hasn't been written to `~/.trellis/managed.yaml` yet (its own
27
+ * `--dry-run` still needs a real plan against the set the user is
28
+ * about to select, not the one already on disk). Never a CLI flag —
29
+ * standalone `trellis sync` always reads canonical's own. */
30
+ managedAgents?: readonly AgentId[];
31
+ /** Onboard-only seam (trellis-backup-rollback): a session already open
32
+ * for the whole onboard run, so one `trellis rollback` undoes every
33
+ * stage together instead of one per stage. When given, this function
34
+ * does NOT finalize it — only whoever opened it does. Never a CLI
35
+ * flag — standalone `trellis sync` always opens and finalizes its own. */
36
+ backupSession?: BackupSession;
24
37
  }
25
38
  export interface AgentSyncReport {
26
39
  agent: AgentId;
@@ -11,14 +11,35 @@ import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
11
11
  import { CodexAdapter } from "../adapters/codex.js";
12
12
  import { KiroAdapter } from "../adapters/kiro.js";
13
13
  import { PiAdapter } from "../adapters/pi.js";
14
- function buildAdapters(homeDir) {
15
- return [new ClaudeCodeAdapter(homeDir), new CodexAdapter(homeDir), new KiroAdapter(homeDir), new PiAdapter(homeDir)];
14
+ import { openBackupSession } from "../lib/backup.js";
15
+ const ADAPTER_FACTORY = {
16
+ "claude-code": (homeDir) => new ClaudeCodeAdapter(homeDir),
17
+ codex: (homeDir) => new CodexAdapter(homeDir),
18
+ kiro: (homeDir) => new KiroAdapter(homeDir),
19
+ pi: (homeDir) => new PiAdapter(homeDir),
20
+ };
21
+ /** Only agents in `canonical.managedAgents` — an agent present on this
22
+ * machine but not managed gets no adapter at all, not a zero-item plan
23
+ * (trellis-managed-agents). */
24
+ function buildAdapters(homeDir, managedAgents) {
25
+ return managedAgents.map((id) => ADAPTER_FACTORY[id](homeDir));
16
26
  }
17
27
  export async function collectSyncReport(opts = {}) {
18
28
  const homeDir = opts.homeDir ?? homedir();
19
- const canonical = loadCanonicalSource(homeDir);
29
+ const loaded = loadCanonicalSource(homeDir);
30
+ // A single override point, not two: every downstream read of "who's
31
+ // managed" — the adapter list built here AND each adapter's own
32
+ // in-scope filtering via `canonical.managedAgents` — must agree, or a
33
+ // dry-run preview computed against a not-yet-written managed set would
34
+ // silently diverge from what onboard's own outer adapter list acted on.
35
+ const canonical = opts.managedAgents ? { ...loaded, managedAgents: opts.managedAgents } : loaded;
20
36
  const reports = [];
21
- for (const adapter of buildAdapters(homeDir)) {
37
+ // Own session only when the caller didn't share one (trellis-backup-
38
+ // rollback D2) — dry-run never opens or writes one, there's nothing to
39
+ // record.
40
+ const ownSession = !opts.dryRun && !opts.backupSession ? openBackupSession(homeDir, "sync") : undefined;
41
+ const backup = opts.backupSession ?? ownSession;
42
+ for (const adapter of buildAdapters(homeDir, canonical.managedAgents)) {
22
43
  const probeResult = await adapter.probe();
23
44
  if (!probeResult.present) {
24
45
  reports.push({ agent: adapter.id, present: false, items: [] });
@@ -40,10 +61,11 @@ export async function collectSyncReport(opts = {}) {
40
61
  items = items.filter((item) => item.kind === kind);
41
62
  }
42
63
  if (!opts.dryRun) {
43
- await adapter.apply(items);
64
+ await adapter.apply(items, backup);
44
65
  }
45
66
  reports.push({ agent: adapter.id, present: true, items });
46
67
  }
68
+ ownSession?.finalize();
47
69
  return { reports };
48
70
  }
49
71
  export async function runSync(opts = {}) {
@@ -70,6 +92,10 @@ export async function runSync(opts = {}) {
70
92
  export function printReport(report, dryRun) {
71
93
  if (dryRun)
72
94
  console.log("[dry run]");
95
+ if (report.reports.length === 0) {
96
+ console.log("No managed agents yet — run `trellis onboard` or list agent ids in ~/.trellis/managed.yaml.");
97
+ return;
98
+ }
73
99
  for (const { agent, present, items } of report.reports) {
74
100
  if (!present) {
75
101
  console.log(`— ${agent} (not installed)`);
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import type { AgentId, CanonicalSource, McpServerDef } from "./types.js";
12
12
  import { resolveScope } from "./types.js";
13
+ import type { BackupSession } from "../lib/backup.js";
13
14
  export interface AdapterProbeResult {
14
15
  present: boolean;
15
16
  version?: string;
@@ -91,7 +92,7 @@ export interface TrellisAdapter {
91
92
  * entries, MCP servers) through its `scope` field before planning any
92
93
  * change for it — an item scoped away from `this.id` must produce no
93
94
  * plan item at all, not a plan item that's later skipped. Use
94
- * `resolveScope(item.scope).includes(this.id)`. See docs/architecture.md
95
+ * `isInScope(this.id, item.scope, canonical.managedAgents)`. See docs/architecture.md
95
96
  * "Private / agent-specific capabilities" — the default (`scope`
96
97
  * omitted) is "all agents," so this filter is a no-op for the common
97
98
  * case and only actually excludes anything when a capability was
@@ -123,11 +124,17 @@ export interface TrellisAdapter {
123
124
  * item in the same plan from being applied. The caller (e.g. `trellis
124
125
  * sync`) is responsible for surfacing conflicts and failing the overall
125
126
  * command (non-zero exit), not `apply()` per item.
127
+ *
128
+ * MUST perform every real write through `backup` (`writeFile`/
129
+ * `createSymlink`/`repairSymlink`/`removeSymlink`), never directly —
130
+ * `backup` is the only thing that may call `fs`/`fs/promises` to
131
+ * mutate a path this method touches (trellis-backup-rollback design.md
132
+ * D3/D5). Mandatory, not optional: the caller always has one open.
126
133
  */
127
- apply(plan: AdapterPlanItem[]): Promise<void>;
134
+ apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
128
135
  /** Re-read the agent's own state and confirm it matches canonical. */
129
136
  verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
130
137
  }
131
138
  /** Convenience used by every adapter's `plan()` — see the scope-filtering
132
139
  * obligation documented above. */
133
- export declare function isInScope(id: AgentId, scope: Parameters<typeof resolveScope>[0]): boolean;
140
+ export declare function isInScope(id: AgentId, scope: Parameters<typeof resolveScope>[0], managedAgents: readonly AgentId[]): boolean;
@@ -11,6 +11,6 @@
11
11
  import { resolveScope } from "./types.js";
12
12
  /** Convenience used by every adapter's `plan()` — see the scope-filtering
13
13
  * obligation documented above. */
14
- export function isInScope(id, scope) {
15
- return resolveScope(scope).includes(id);
14
+ export function isInScope(id, scope, managedAgents) {
15
+ return resolveScope(scope, managedAgents).includes(id);
16
16
  }
@@ -78,6 +78,26 @@ function loadSecretsPolicyYaml(path, homeDir) {
78
78
  /** `undefined` if the name has no entry in scope.yaml's map — "shared with
79
79
  * all four agents," the default. A recognized but empty list is left as
80
80
  * authored (an explicitly agent-less scope), not coerced to "all". */
81
+ /** Missing file and `agents: []` both resolve to `[]` — zero managed
82
+ * agents (D1), never "everyone." An unrecognized id is dropped with a
83
+ * diagnostic, same posture as an unrecognized scope.yaml agent id. */
84
+ function loadManagedYaml(path, diagnostics) {
85
+ if (!existsSync(path)) {
86
+ return [];
87
+ }
88
+ const parsed = (parseYaml(readFileSync(path, "utf-8")) ?? {});
89
+ const raw = parsed.agents ?? [];
90
+ const valid = [];
91
+ for (const id of raw) {
92
+ if (ALL_AGENTS.includes(id)) {
93
+ valid.push(id);
94
+ }
95
+ else {
96
+ diagnostics.push(`managed.yaml: "${id}" is not a recognized agent id — ignored`);
97
+ }
98
+ }
99
+ return valid;
100
+ }
81
101
  function scopeFor(map, name) {
82
102
  return map?.[name];
83
103
  }
@@ -100,6 +120,7 @@ export function loadCanonicalSource(homeDir = homedir()) {
100
120
  throw new Error(`No canonical source at ${root}. Create it before running trellis sync — see docs/architecture.md's canonical schema.`);
101
121
  }
102
122
  const diagnostics = [];
123
+ const managedAgents = loadManagedYaml(join(root, "managed.yaml"), diagnostics);
103
124
  const scopeYaml = loadScopeYaml(join(root, "scope.yaml"));
104
125
  const skillDirs = listSkillDirs(join(root, "skills"));
105
126
  const knownSkillNames = new Set(skillDirs.map((s) => s.name));
@@ -135,6 +156,7 @@ export function loadCanonicalSource(homeDir = homedir()) {
135
156
  }
136
157
  return {
137
158
  instructionsFile: join(root, "agents.md"),
159
+ managedAgents,
138
160
  skills,
139
161
  agents,
140
162
  memories,
@@ -15,7 +15,15 @@ export declare const ALL_AGENTS: readonly AgentId[];
15
15
  * "Private / agent-specific capabilities".
16
16
  */
17
17
  export type Scope = AgentId[] | undefined;
18
- export declare function resolveScope(scope: Scope): readonly AgentId[];
18
+ /**
19
+ * `managedAgents` is the hard outer boundary (trellis-managed-agents
20
+ * design.md D6): no-scope items resolve to it instead of `ALL_AGENTS`, and
21
+ * an item WITH an explicit scope is intersected with it, never returned
22
+ * verbatim — a skill scoped to `[kiro]` must still not reach Kiro if Kiro
23
+ * isn't in the managed set. Presence on the machine is irrelevant here;
24
+ * only membership in `managedAgents` is.
25
+ */
26
+ export declare function resolveScope(scope: Scope, managedAgents: readonly AgentId[]): readonly AgentId[];
19
27
  export interface McpServerDef {
20
28
  transport: Transport;
21
29
  /** stdio only */
@@ -190,6 +198,9 @@ export interface CanonicalSource {
190
198
  * that decision.
191
199
  */
192
200
  instructionsFile: string;
201
+ /** From `~/.trellis/managed.yaml`. Missing file and `agents: []` both
202
+ * resolve to `[]` — zero managed agents, never "everyone" (D1). */
203
+ managedAgents: readonly AgentId[];
193
204
  skills: SkillRef[];
194
205
  agents: AgentProfile[];
195
206
  memories: MemoryEntry[];
@@ -10,6 +10,15 @@ export const ALL_AGENTS = [
10
10
  "kiro",
11
11
  "pi",
12
12
  ];
13
- export function resolveScope(scope) {
14
- return scope ?? ALL_AGENTS;
13
+ /**
14
+ * `managedAgents` is the hard outer boundary (trellis-managed-agents
15
+ * design.md D6): no-scope items resolve to it instead of `ALL_AGENTS`, and
16
+ * an item WITH an explicit scope is intersected with it, never returned
17
+ * verbatim — a skill scoped to `[kiro]` must still not reach Kiro if Kiro
18
+ * isn't in the managed set. Presence on the machine is irrelevant here;
19
+ * only membership in `managedAgents` is.
20
+ */
21
+ export function resolveScope(scope, managedAgents) {
22
+ const candidates = scope ?? managedAgents;
23
+ return candidates.filter((id) => managedAgents.includes(id));
15
24
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Structured, timestamped backup of every real write `sync`/`mcp sync`
3
+ * perform, and the only path any of them writes disk through — see
4
+ * openspec/changes/trellis-backup-rollback/design.md D3/D5 for why the
5
+ * write itself lives here rather than at each call site: a call site
6
+ * that only had to remember to *also* call a record function is exactly
7
+ * the class of bug trellis-managed-agents found in symlinkPlan.ts. A run
8
+ * directory is created lazily on the first recorded operation; a session
9
+ * that never records anything creates nothing on disk.
10
+ */
11
+ export type BackupOperation = {
12
+ kind: "file-create";
13
+ path: string;
14
+ afterHash: string;
15
+ } | {
16
+ kind: "file-overwrite";
17
+ path: string;
18
+ beforeFile: string;
19
+ beforeHash: string;
20
+ afterHash: string;
21
+ } | {
22
+ kind: "symlink-create";
23
+ path: string;
24
+ afterLinkTarget: string;
25
+ } | {
26
+ kind: "symlink-repair";
27
+ path: string;
28
+ beforeLinkTarget: string;
29
+ afterLinkTarget: string;
30
+ } | {
31
+ kind: "symlink-remove";
32
+ path: string;
33
+ beforeLinkTarget: string;
34
+ };
35
+ export interface BackupManifest {
36
+ runId: string;
37
+ command: string;
38
+ startedAt: string;
39
+ operations: BackupOperation[];
40
+ }
41
+ export interface BackupSession {
42
+ writeFile(path: string, content: string): void;
43
+ createSymlink(path: string, linkTarget: string): Promise<void>;
44
+ repairSymlink(path: string, oldLinkTarget: string, newLinkTarget: string): Promise<void>;
45
+ removeSymlink(path: string, oldLinkTarget: string): Promise<void>;
46
+ /** Writes manifest.json. No-op (creates nothing) if zero operations
47
+ * were ever recorded. */
48
+ finalize(): void;
49
+ }
50
+ export declare function backupsRoot(homeDir: string): string;
51
+ export declare function openBackupSession(homeDir: string, command: string): BackupSession;
52
+ /** Used by `applySymlinkPlan` and every adapter's own read-before-repair
53
+ * logic — it needs the symlink's current stored target before the
54
+ * session's own repair/remove overwrites it. Not part of `BackupSession`
55
+ * itself: it's a read, not a recorded write. */
56
+ export declare function currentLinkTarget(path: string): Promise<string | undefined>;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Structured, timestamped backup of every real write `sync`/`mcp sync`
3
+ * perform, and the only path any of them writes disk through — see
4
+ * openspec/changes/trellis-backup-rollback/design.md D3/D5 for why the
5
+ * write itself lives here rather than at each call site: a call site
6
+ * that only had to remember to *also* call a record function is exactly
7
+ * the class of bug trellis-managed-agents found in symlinkPlan.ts. A run
8
+ * directory is created lazily on the first recorded operation; a session
9
+ * that never records anything creates nothing on disk.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { readlink, rm, symlink } from "node:fs/promises";
14
+ import { basename, join } from "node:path";
15
+ function sha256(content) {
16
+ return `sha256:${createHash("sha256").update(content).digest("hex")}`;
17
+ }
18
+ /** `:` and `.` are legal in POSIX filenames but awkward across shells and
19
+ * some tooling; replaced so a run id is safe to pass around bare. */
20
+ function runIdFor(command) {
21
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
22
+ return `${ts}-${command}`;
23
+ }
24
+ export function backupsRoot(homeDir) {
25
+ return join(homeDir, ".trellis", "backups");
26
+ }
27
+ export function openBackupSession(homeDir, command) {
28
+ const runId = runIdFor(command);
29
+ const runDir = join(backupsRoot(homeDir), runId);
30
+ const startedAt = new Date().toISOString();
31
+ const operations = [];
32
+ let dirCreated = false;
33
+ let fileIndex = 0;
34
+ function ensureDir() {
35
+ if (dirCreated)
36
+ return;
37
+ mkdirSync(join(runDir, "files"), { recursive: true });
38
+ dirCreated = true;
39
+ }
40
+ return {
41
+ writeFile(path, content) {
42
+ ensureDir();
43
+ const afterHash = sha256(content);
44
+ if (existsSync(path)) {
45
+ const before = readFileSync(path, "utf-8");
46
+ const relSnapshot = join("files", `${fileIndex}-${basename(path)}`);
47
+ fileIndex += 1;
48
+ writeFileSync(join(runDir, relSnapshot), before);
49
+ operations.push({
50
+ kind: "file-overwrite",
51
+ path,
52
+ beforeFile: relSnapshot,
53
+ beforeHash: sha256(before),
54
+ afterHash,
55
+ });
56
+ }
57
+ else {
58
+ operations.push({ kind: "file-create", path, afterHash });
59
+ }
60
+ writeFileSync(path, content);
61
+ },
62
+ async createSymlink(path, linkTarget) {
63
+ ensureDir();
64
+ await rm(path, { force: true });
65
+ await symlink(linkTarget, path);
66
+ operations.push({ kind: "symlink-create", path, afterLinkTarget: linkTarget });
67
+ },
68
+ async repairSymlink(path, oldLinkTarget, newLinkTarget) {
69
+ ensureDir();
70
+ await rm(path, { force: true });
71
+ await symlink(newLinkTarget, path);
72
+ operations.push({ kind: "symlink-repair", path, beforeLinkTarget: oldLinkTarget, afterLinkTarget: newLinkTarget });
73
+ },
74
+ async removeSymlink(path, oldLinkTarget) {
75
+ ensureDir();
76
+ await rm(path, { force: true });
77
+ operations.push({ kind: "symlink-remove", path, beforeLinkTarget: oldLinkTarget });
78
+ },
79
+ finalize() {
80
+ if (!dirCreated)
81
+ return;
82
+ const manifest = { runId, command, startedAt, operations };
83
+ writeFileSync(join(runDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
84
+ },
85
+ };
86
+ }
87
+ /** Used by `applySymlinkPlan` and every adapter's own read-before-repair
88
+ * logic — it needs the symlink's current stored target before the
89
+ * session's own repair/remove overwrites it. Not part of `BackupSession`
90
+ * itself: it's a read, not a recorded write. */
91
+ export async function currentLinkTarget(path) {
92
+ try {
93
+ return await readlink(path);
94
+ }
95
+ catch {
96
+ return undefined;
97
+ }
98
+ }