agent-trellis 0.1.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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -0
  3. package/dist/adapters/claude-code.d.ts +23 -0
  4. package/dist/adapters/claude-code.js +86 -0
  5. package/dist/adapters/codex.d.ts +27 -0
  6. package/dist/adapters/codex.js +119 -0
  7. package/dist/adapters/jsonMcp.d.ts +24 -0
  8. package/dist/adapters/jsonMcp.js +84 -0
  9. package/dist/adapters/kiro.d.ts +34 -0
  10. package/dist/adapters/kiro.js +175 -0
  11. package/dist/adapters/mcpPlan.d.ts +28 -0
  12. package/dist/adapters/mcpPlan.js +83 -0
  13. package/dist/adapters/pi.d.ts +23 -0
  14. package/dist/adapters/pi.js +108 -0
  15. package/dist/adapters/symlinkPlan.d.ts +33 -0
  16. package/dist/adapters/symlinkPlan.js +120 -0
  17. package/dist/cli.d.ts +7 -0
  18. package/dist/cli.js +135 -0
  19. package/dist/commands/doctor.d.ts +88 -0
  20. package/dist/commands/doctor.js +269 -0
  21. package/dist/commands/init.d.ts +44 -0
  22. package/dist/commands/init.js +150 -0
  23. package/dist/commands/mcp.d.ts +28 -0
  24. package/dist/commands/mcp.js +70 -0
  25. package/dist/commands/migrate.d.ts +38 -0
  26. package/dist/commands/migrate.js +132 -0
  27. package/dist/commands/onboard.d.ts +50 -0
  28. package/dist/commands/onboard.js +155 -0
  29. package/dist/commands/secretsAudit.d.ts +35 -0
  30. package/dist/commands/secretsAudit.js +115 -0
  31. package/dist/commands/sync.d.ts +40 -0
  32. package/dist/commands/sync.js +91 -0
  33. package/dist/core/adapter.d.ts +133 -0
  34. package/dist/core/adapter.js +16 -0
  35. package/dist/core/canonical.d.ts +16 -0
  36. package/dist/core/canonical.js +148 -0
  37. package/dist/core/types.d.ts +201 -0
  38. package/dist/core/types.js +15 -0
  39. package/dist/lib/dirEquals.d.ts +7 -0
  40. package/dist/lib/dirEquals.js +39 -0
  41. package/dist/lib/envVarNames.d.ts +35 -0
  42. package/dist/lib/envVarNames.js +79 -0
  43. package/dist/lib/fsIdentity.d.ts +16 -0
  44. package/dist/lib/fsIdentity.js +53 -0
  45. package/dist/lib/mcpProbe.d.ts +14 -0
  46. package/dist/lib/mcpProbe.js +96 -0
  47. package/dist/lib/probeCommon.d.ts +24 -0
  48. package/dist/lib/probeCommon.js +108 -0
  49. package/dist/lib/secretEnv.d.ts +19 -0
  50. package/dist/lib/secretEnv.js +46 -0
  51. package/dist/lib/skillFile.d.ts +12 -0
  52. package/dist/lib/skillFile.js +26 -0
  53. package/dist/lib/syncArgs.d.ts +16 -0
  54. package/dist/lib/syncArgs.js +17 -0
  55. package/dist/lib/tomlSection.d.ts +57 -0
  56. package/dist/lib/tomlSection.js +162 -0
  57. package/dist/pi-bridge/bundle.js +32074 -0
  58. package/dist/pi-bridge/index.d.ts +48 -0
  59. package/dist/pi-bridge/index.js +188 -0
  60. package/dist/pi-bridge/schemaTranslate.d.ts +55 -0
  61. package/dist/pi-bridge/schemaTranslate.js +40 -0
  62. package/dist/probes/claude-code.d.ts +13 -0
  63. package/dist/probes/claude-code.js +48 -0
  64. package/dist/probes/codex.d.ts +24 -0
  65. package/dist/probes/codex.js +78 -0
  66. package/dist/probes/kiro.d.ts +12 -0
  67. package/dist/probes/kiro.js +48 -0
  68. package/dist/probes/pi.d.ts +14 -0
  69. package/dist/probes/pi.js +53 -0
  70. package/dist/sdk.d.ts +14 -0
  71. package/dist/sdk.js +13 -0
  72. package/docs/architecture.md +367 -0
  73. package/docs/getting-started.md +235 -0
  74. package/docs/implementation-plan.md +341 -0
  75. package/docs/research.md +175 -0
  76. package/docs/roadmap.md +484 -0
  77. package/package.json +59 -0
  78. package/schema/scope.example.yaml +33 -0
  79. package/schema/secrets.policy.example.yaml +43 -0
  80. package/schema/servers.example.yaml +87 -0
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `trellis secrets audit` — reads every present, MCP-capable agent's real
3
+ * config file (never the canonical source) and checks it against
4
+ * `CanonicalSource.secretsPolicy`: a literal-value scan against
5
+ * `rejectPatterns`, and a declared-env-var-name check against
6
+ * `allowedVars`. Also checks, agent-agnostically, whether every env var
7
+ * name declared across canonical `mcp.servers[*].env` actually resolves
8
+ * to a value via the same `resolveSecretEnv` the pi bridge uses
9
+ * (trellis-secrets-env-management) — authoritative for pi, a best-effort
10
+ * proxy for the other three (design.md D3 in that change). Read-only —
11
+ * never writes anything. Fails non-zero on any finding
12
+ * (trellis-secrets-audit-p3).
13
+ */
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { loadCanonicalSource } from "../core/canonical.js";
18
+ import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
19
+ import { CodexAdapter } from "../adapters/codex.js";
20
+ import { KiroAdapter } from "../adapters/kiro.js";
21
+ import { declaredEnvNames, extractJsonEnvVarNames, extractTomlEnvVarNames } from "../lib/envVarNames.js";
22
+ import { resolveSecretEnv } from "../lib/secretEnv.js";
23
+ function auditedAgents(homeDir) {
24
+ return [
25
+ { id: "claude-code", probe: () => new ClaudeCodeAdapter(homeDir).probe(), configPath: (h) => join(h, ".claude.json"), extractNames: extractJsonEnvVarNames },
26
+ { id: "codex", probe: () => new CodexAdapter(homeDir).probe(), configPath: (h) => join(h, ".codex", "config.toml"), extractNames: extractTomlEnvVarNames },
27
+ { 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
+ ];
32
+ }
33
+ /**
34
+ * Not scoped to any agent, present or not — a name that can't resolve is
35
+ * a problem regardless of which agents' `servers.yaml` `agents:` field
36
+ * would route it to (trellis-secrets-env-management design.md D3).
37
+ */
38
+ function findMissingEnvValues(servers, policy) {
39
+ const names = new Set();
40
+ for (const def of Object.values(servers)) {
41
+ for (const name of declaredEnvNames(def))
42
+ names.add(name);
43
+ }
44
+ if (names.size === 0)
45
+ return [];
46
+ const resolved = resolveSecretEnv([...names], policy);
47
+ const source = policy.envFile ?? "process environment";
48
+ return [...names]
49
+ .filter((name) => !resolved[name])
50
+ .map((name) => ({
51
+ agent: "environment",
52
+ file: source,
53
+ kind: "missing-env-value",
54
+ detail: `"${name}" is declared by a canonical MCP server's env but has no resolvable value`,
55
+ }));
56
+ }
57
+ function auditFile(agent, file, content, policy, extractNames) {
58
+ const findings = [];
59
+ for (const pattern of policy.rejectPatterns) {
60
+ if (pattern.test(content)) {
61
+ findings.push({ agent, file, kind: "literal-secret", detail: `matches reject pattern ${pattern}` });
62
+ }
63
+ }
64
+ const allowed = new Set(policy.allowedVars);
65
+ for (const name of new Set(extractNames(content))) {
66
+ if (!allowed.has(name)) {
67
+ findings.push({ agent, file, kind: "unexpected-var-name", detail: `"${name}" is not in secrets.policy.yaml's allowed_vars` });
68
+ }
69
+ }
70
+ return findings;
71
+ }
72
+ export async function collectSecretsAuditReport(opts = {}) {
73
+ const homeDir = opts.homeDir ?? homedir();
74
+ const canonical = loadCanonicalSource(homeDir);
75
+ const findings = [];
76
+ for (const agent of auditedAgents(homeDir)) {
77
+ const probeResult = await agent.probe();
78
+ if (!probeResult.present)
79
+ continue;
80
+ const file = agent.configPath(homeDir);
81
+ if (!existsSync(file))
82
+ continue;
83
+ const content = readFileSync(file, "utf-8");
84
+ findings.push(...auditFile(agent.id, file, content, canonical.secretsPolicy, agent.extractNames));
85
+ }
86
+ findings.push(...findMissingEnvValues(canonical.mcp.servers, canonical.secretsPolicy));
87
+ return { findings };
88
+ }
89
+ export async function runSecretsAudit(opts = {}) {
90
+ let report;
91
+ try {
92
+ report = await collectSecretsAuditReport(opts);
93
+ }
94
+ catch (err) {
95
+ console.error(err instanceof Error ? err.message : String(err));
96
+ return { exitCode: 1 };
97
+ }
98
+ if (opts.json) {
99
+ console.log(JSON.stringify(report, null, 2));
100
+ }
101
+ else {
102
+ printReport(report);
103
+ }
104
+ return { exitCode: report.findings.length > 0 ? 1 : 0 };
105
+ }
106
+ function printReport(report) {
107
+ if (report.findings.length === 0) {
108
+ console.log("✅ no findings — every present agent's real config and every declared env var passed all checks");
109
+ return;
110
+ }
111
+ console.log(`⚠️ ${report.findings.length} finding(s):`);
112
+ for (const finding of report.findings) {
113
+ console.log(` - [${finding.kind}] ${finding.agent} — ${finding.file}: ${finding.detail}`);
114
+ }
115
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `trellis sync` / `trellis sync skills` / `trellis sync instructions` —
3
+ * loads canonical once, runs every present agent's adapter, applies the
4
+ * plan, and reports what happened. Conflicts are report-only (never
5
+ * thrown) — see src/core/adapter.ts's `apply()` doc — so one conflicting
6
+ * item never blocks every other, unrelated item in the same run.
7
+ */
8
+ import type { AdapterPlanItem } from "../core/adapter.js";
9
+ import type { AgentId } from "../core/types.js";
10
+ export interface RunSyncOptions {
11
+ /** Omit to sync both. */
12
+ target?: "skills" | "instructions";
13
+ json?: boolean;
14
+ /** Defaults to the real `~`; overridable for tests and
15
+ * `scripts/sandbox.sh` only — same seam as every probe and
16
+ * `loadCanonicalSource` uses, and for the same reason: this is the first
17
+ * command in the repo that writes, and it must never be exercised
18
+ * against a developer's real dotfiles (docs/architecture.md's testing
19
+ * philosophy). Not a CLI flag — there is no product reason for an end
20
+ * user to ever point `trellis sync` at a fake home. */
21
+ homeDir?: string;
22
+ /** Compute and report the plan without calling adapter.apply(). */
23
+ dryRun?: boolean;
24
+ }
25
+ export interface AgentSyncReport {
26
+ agent: AgentId;
27
+ present: boolean;
28
+ items: AdapterPlanItem[];
29
+ }
30
+ export interface SyncReport {
31
+ reports: AgentSyncReport[];
32
+ }
33
+ export declare function collectSyncReport(opts?: RunSyncOptions): Promise<SyncReport>;
34
+ export declare function runSync(opts?: RunSyncOptions): Promise<{
35
+ exitCode: number;
36
+ }>;
37
+ /** Exported so `onboard` prints a sync report identically to running
38
+ * `sync` standalone, instead of a second, easily-drifting copy of this
39
+ * formatting. */
40
+ export declare function printReport(report: SyncReport, dryRun: boolean): void;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `trellis sync` / `trellis sync skills` / `trellis sync instructions` —
3
+ * loads canonical once, runs every present agent's adapter, applies the
4
+ * plan, and reports what happened. Conflicts are report-only (never
5
+ * thrown) — see src/core/adapter.ts's `apply()` doc — so one conflicting
6
+ * item never blocks every other, unrelated item in the same run.
7
+ */
8
+ import { homedir } from "node:os";
9
+ import { loadCanonicalSource } from "../core/canonical.js";
10
+ import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
11
+ import { CodexAdapter } from "../adapters/codex.js";
12
+ import { KiroAdapter } from "../adapters/kiro.js";
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)];
16
+ }
17
+ export async function collectSyncReport(opts = {}) {
18
+ const homeDir = opts.homeDir ?? homedir();
19
+ const canonical = loadCanonicalSource(homeDir);
20
+ const reports = [];
21
+ for (const adapter of buildAdapters(homeDir)) {
22
+ const probeResult = await adapter.probe();
23
+ if (!probeResult.present) {
24
+ reports.push({ agent: adapter.id, present: false, items: [] });
25
+ continue;
26
+ }
27
+ // adapter.plan() also returns "mcp" items now (trellis-mcp-sync-p2) —
28
+ // `trellis sync` never touches MCP, that's `trellis mcp sync`'s own
29
+ // command, so exclude it unconditionally before any target filter.
30
+ let items = (await adapter.plan(canonical)).filter((item) => item.kind !== "mcp");
31
+ if (opts.target) {
32
+ // CLI/option vocabulary is plural ("skills"/"instructions",
33
+ // matching `trellis sync skills`); AdapterPlanItem.kind is singular
34
+ // ("skill"/"instructions") since it describes one item. Map
35
+ // explicitly rather than assume the strings line up — they don't,
36
+ // and a caught-nowhere mismatch here silently filters everything
37
+ // out (found via the sandbox: every agent reported "already in
38
+ // sync" even with real create items pending).
39
+ const kind = opts.target === "skills" ? "skill" : "instructions";
40
+ items = items.filter((item) => item.kind === kind);
41
+ }
42
+ if (!opts.dryRun) {
43
+ await adapter.apply(items);
44
+ }
45
+ reports.push({ agent: adapter.id, present: true, items });
46
+ }
47
+ return { reports };
48
+ }
49
+ export async function runSync(opts = {}) {
50
+ let report;
51
+ try {
52
+ report = await collectSyncReport(opts);
53
+ }
54
+ catch (err) {
55
+ console.error(err instanceof Error ? err.message : String(err));
56
+ return { exitCode: 1 };
57
+ }
58
+ if (opts.json) {
59
+ console.log(JSON.stringify(report, null, 2));
60
+ }
61
+ else {
62
+ printReport(report, opts.dryRun ?? false);
63
+ }
64
+ const hasConflict = report.reports.some((r) => r.items.some((i) => i.action === "conflict"));
65
+ return { exitCode: hasConflict ? 1 : 0 };
66
+ }
67
+ /** Exported so `onboard` prints a sync report identically to running
68
+ * `sync` standalone, instead of a second, easily-drifting copy of this
69
+ * formatting. */
70
+ export function printReport(report, dryRun) {
71
+ if (dryRun)
72
+ console.log("[dry run]");
73
+ for (const { agent, present, items } of report.reports) {
74
+ if (!present) {
75
+ console.log(`— ${agent} (not installed)`);
76
+ continue;
77
+ }
78
+ const created = items.filter((i) => i.action === "create");
79
+ const removed = items.filter((i) => i.action === "remove");
80
+ const conflicts = items.filter((i) => i.action === "conflict");
81
+ if (created.length === 0 && removed.length === 0 && conflicts.length === 0) {
82
+ console.log(`✅ ${agent} — already in sync`);
83
+ continue;
84
+ }
85
+ const icon = conflicts.length > 0 ? "⚠️ " : "✅";
86
+ console.log(`${icon} ${agent} — ${created.length} created, ${removed.length} removed, ${conflicts.length} conflict(s)`);
87
+ for (const item of [...created, ...removed, ...conflicts]) {
88
+ console.log(` - [${item.action}] ${item.description}`);
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The adapter contract every agent integration (Claude Code, Codex, Kiro,
3
+ * pi) must implement. See docs/architecture.md "Adapter contract" for the
4
+ * rules each method must follow — in particular: `apply` must never
5
+ * overwrite fields it doesn't own, and `verify` must re-read the agent's
6
+ * own state rather than trust that `apply` succeeded.
7
+ *
8
+ * No adapter exists yet (see docs/roadmap.md P1/P2/P4). This interface is
9
+ * the contract they'll be built against.
10
+ */
11
+ import type { AgentId, CanonicalSource, McpServerDef } from "./types.js";
12
+ import { resolveScope } from "./types.js";
13
+ export interface AdapterProbeResult {
14
+ present: boolean;
15
+ version?: string;
16
+ detail?: string;
17
+ }
18
+ export interface AdapterPlanItem {
19
+ /**
20
+ * "create" also covers repair (wrong symlink target, or an MCP server
21
+ * definition that differs from canonical); "remove" is the delete half
22
+ * for skills/instructions — a Trellis-managed symlink whose canonical
23
+ * entry is gone or was just scoped away from this agent. MCP server
24
+ * items never use "remove" (see `kind: "mcp"` below — no ownership
25
+ * marker exists yet to make that provably safe, trellis-mcp-sync-p2
26
+ * design.md D7). "conflict" is either a real, non-symlink path
27
+ * occupying a spot Trellis would otherwise touch, or an MCP server
28
+ * refused for a collision/secrets-guard reason — reported, never acted
29
+ * on. See `plan()`'s doc below.
30
+ */
31
+ action: "create" | "remove" | "conflict";
32
+ /** Lets `trellis sync skills` / `trellis sync instructions` /
33
+ * `trellis mcp sync` filter a full plan without changing `plan()`'s
34
+ * signature — every adapter produces every kind it's responsible for in
35
+ * one pass; the CLI subcommand decides which to apply, not the adapter.
36
+ * `"extension"` is pi-only: the MCP bridge file itself
37
+ * (trellis-pi-mcp-bridge-p4) — Trellis's own packaged code, not a
38
+ * user-authored capability from `~/.trellis/`, delivered via the same
39
+ * symlink create/repair/remove semantics as skills/instructions.
40
+ * `"kiro-approved-env-vars"` is Kiro-only (trellis-kiro-approved-env-vars):
41
+ * a completely different target file and merge mechanism (see
42
+ * `approvedEnvVars` below) from `"mcp"`, not something the other
43
+ * three agents have an equivalent of. */
44
+ kind: "skill" | "instructions" | "mcp" | "extension" | "kiro-approved-env-vars";
45
+ /** Human-readable description of one change this adapter would make
46
+ * ("create" / "remove") or why it refused to ("conflict"). */
47
+ description: string;
48
+ /** What's being touched, for the collision/audit checks to reason
49
+ * about. For `kind: "mcp"`, the config file being modified (e.g.
50
+ * `~/.codex/config.toml`), not a per-server path — there isn't one. */
51
+ target: string;
52
+ /** Only set (and only meaningful) when `kind` is `"skill"` or
53
+ * `"instructions"` and `action === "create"`: the absolute path
54
+ * `target` should be symlinked to. Kept as a real field rather than
55
+ * embedded in `description` — `apply()` must never have to parse prose
56
+ * back into structured data. */
57
+ linkTarget?: string;
58
+ /** Only set (and only meaningful) when `kind === "mcp"` and
59
+ * `action === "create"`: the server name and definition to write into
60
+ * `target` (the config file) via that agent's own mechanism (JSON
61
+ * merge or, for Codex, `src/lib/tomlSection.ts`'s splice). */
62
+ mcpWrite?: {
63
+ name: string;
64
+ def: McpServerDef;
65
+ };
66
+ /** Only set (and only meaningful) when `kind === "kiro-approved-env-vars"`
67
+ * and `action === "create"`: the full, already-deduplicated array to
68
+ * write as `kiroAgent.mcpApprovedEnvVars` — a union of whatever was
69
+ * already there plus every name Trellis's canonical MCP config needs
70
+ * for Kiro, never a subtraction (trellis-kiro-approved-env-vars
71
+ * design.md D3/D4). */
72
+ approvedEnvVars?: string[];
73
+ }
74
+ export interface AdapterVerifyResult {
75
+ ok: boolean;
76
+ /** Present only when ok is false — what didn't match canonical. */
77
+ mismatches?: string[];
78
+ }
79
+ export interface TrellisAdapter {
80
+ readonly name: string;
81
+ readonly id: AgentId;
82
+ /** Does this agent exist on this machine, and what version. No side effects. */
83
+ probe(): Promise<AdapterProbeResult>;
84
+ /**
85
+ * Diffs canonical state against this agent's current on-disk state to
86
+ * produce a plan. Read-only against that state — it must never write —
87
+ * but it does read (e.g. `lstat` each target) because "create" vs.
88
+ * "remove" vs. no-op vs. conflict can't be decided from canonical alone.
89
+ *
90
+ * MUST filter every scopable item (skills, subagent profiles, memory
91
+ * entries, MCP servers) through its `scope` field before planning any
92
+ * change for it — an item scoped away from `this.id` must produce no
93
+ * 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
+ * "Private / agent-specific capabilities" — the default (`scope`
96
+ * omitted) is "all agents," so this filter is a no-op for the common
97
+ * case and only actually excludes anything when a capability was
98
+ * explicitly restricted.
99
+ *
100
+ * MUST also produce "remove" items: an on-disk symlink whose realpath
101
+ * resolves inside the canonical source, but whose corresponding entry no
102
+ * longer exists in `canonical` (or was just scoped away from `this.id`)
103
+ * is stale and belongs in the plan as a removal — not silently left
104
+ * behind. This is the delete half of "add once, remove once, reaches
105
+ * every agent"; a plan() that only ever emits "create" items has an
106
+ * add-only implementation regardless of what the roadmap says.
107
+ *
108
+ * MUST NOT plan removal of a path that isn't a symlink Trellis can prove
109
+ * it created (realpath outside the canonical source) — that's a real
110
+ * conflict, not a stale entry, and MUST instead produce a "conflict"
111
+ * plan item so it's visible in the same report as everything else,
112
+ * rather than only surfacing when `apply()` gets to it.
113
+ */
114
+ plan(canonical: CanonicalSource): Promise<AdapterPlanItem[]>;
115
+ /**
116
+ * Perform the diff. Must be idempotent and safely re-runnable: applying
117
+ * a "create" item against an already-correct symlink is a no-op, and
118
+ * applying a "remove" item against an already-gone path is a no-op.
119
+ *
120
+ * MUST treat every "conflict" item as report-only: no filesystem
121
+ * operation, and MUST NOT throw or abort the rest of the plan because
122
+ * of it — a conflict on one item must never prevent every other, unrelated
123
+ * item in the same plan from being applied. The caller (e.g. `trellis
124
+ * sync`) is responsible for surfacing conflicts and failing the overall
125
+ * command (non-zero exit), not `apply()` per item.
126
+ */
127
+ apply(plan: AdapterPlanItem[]): Promise<void>;
128
+ /** Re-read the agent's own state and confirm it matches canonical. */
129
+ verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
130
+ }
131
+ /** Convenience used by every adapter's `plan()` — see the scope-filtering
132
+ * obligation documented above. */
133
+ export declare function isInScope(id: AgentId, scope: Parameters<typeof resolveScope>[0]): boolean;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The adapter contract every agent integration (Claude Code, Codex, Kiro,
3
+ * pi) must implement. See docs/architecture.md "Adapter contract" for the
4
+ * rules each method must follow — in particular: `apply` must never
5
+ * overwrite fields it doesn't own, and `verify` must re-read the agent's
6
+ * own state rather than trust that `apply` succeeded.
7
+ *
8
+ * No adapter exists yet (see docs/roadmap.md P1/P2/P4). This interface is
9
+ * the contract they'll be built against.
10
+ */
11
+ import { resolveScope } from "./types.js";
12
+ /** Convenience used by every adapter's `plan()` — see the scope-filtering
13
+ * obligation documented above. */
14
+ export function isInScope(id, scope) {
15
+ return resolveScope(scope).includes(id);
16
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Loads `~/.trellis/` into a `CanonicalSource`. Global only — no `root`
3
+ * parameter, no workspace merge (docs/architecture.md "Global vs.
4
+ * workspace scope"). See openspec/changes/trellis-sync-p1/specs/
5
+ * canonical-source-loading/spec.md for the exact contract this implements.
6
+ */
7
+ import type { CanonicalSource } from "./types.js";
8
+ /**
9
+ * `homeDir` defaults to the real `~` and is only ever overridden for tests
10
+ * and `scripts/sandbox.sh` — the same seam P0's probes use
11
+ * (src/probes/*.ts) and for the same reason: never touch a developer's
12
+ * real dotfiles from a test. It is not a workspace/project root — see
13
+ * specs/canonical-source-loading's global-only requirement, which this
14
+ * parameter does not weaken.
15
+ */
16
+ export declare function loadCanonicalSource(homeDir?: string): CanonicalSource;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Loads `~/.trellis/` into a `CanonicalSource`. Global only — no `root`
3
+ * parameter, no workspace merge (docs/architecture.md "Global vs.
4
+ * workspace scope"). See openspec/changes/trellis-sync-p1/specs/
5
+ * canonical-source-loading/spec.md for the exact contract this implements.
6
+ */
7
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { basename, join } from "node:path";
10
+ import { parse as parseYaml } from "yaml";
11
+ import { ALL_AGENTS } from "./types.js";
12
+ function trellisRoot(homeDir) {
13
+ return join(homeDir, ".trellis");
14
+ }
15
+ function listMarkdownFiles(dir) {
16
+ let entries;
17
+ try {
18
+ entries = readdirSync(dir);
19
+ }
20
+ catch {
21
+ return [];
22
+ }
23
+ return entries
24
+ .filter((name) => name.endsWith(".md"))
25
+ .map((name) => ({ name: basename(name, ".md"), file: join(dir, name) }));
26
+ }
27
+ function listSkillDirs(dir) {
28
+ let entries;
29
+ try {
30
+ entries = readdirSync(dir);
31
+ }
32
+ catch {
33
+ return [];
34
+ }
35
+ const skills = [];
36
+ for (const name of entries) {
37
+ const skillDir = join(dir, name);
38
+ try {
39
+ if (statSync(skillDir).isDirectory()) {
40
+ skills.push({ name, dir: skillDir });
41
+ }
42
+ }
43
+ catch {
44
+ // unreadable/broken entry — skip, not a load failure
45
+ }
46
+ }
47
+ return skills;
48
+ }
49
+ function loadScopeYaml(path) {
50
+ if (!existsSync(path)) {
51
+ return {};
52
+ }
53
+ const parsed = parseYaml(readFileSync(path, "utf-8"));
54
+ return (parsed ?? {});
55
+ }
56
+ function loadServersYaml(path) {
57
+ if (!existsSync(path)) {
58
+ return { servers: {}, knownHostInjected: [] };
59
+ }
60
+ const parsed = (parseYaml(readFileSync(path, "utf-8")) ?? {});
61
+ return {
62
+ servers: parsed.servers ?? {},
63
+ knownHostInjected: parsed.known_host_injected ?? [],
64
+ hub: parsed.hub,
65
+ };
66
+ }
67
+ function loadSecretsPolicyYaml(path, homeDir) {
68
+ if (!existsSync(path)) {
69
+ return { allowedVars: [], rejectPatterns: [] };
70
+ }
71
+ const parsed = (parseYaml(readFileSync(path, "utf-8")) ?? {});
72
+ return {
73
+ allowedVars: parsed.allowed_vars ?? [],
74
+ rejectPatterns: (parsed.reject_patterns ?? []).map((pattern) => new RegExp(pattern)),
75
+ envFile: parsed.env_file ? parsed.env_file.replace(/^~(?=$|\/)/, homeDir) : undefined,
76
+ };
77
+ }
78
+ /** `undefined` if the name has no entry in scope.yaml's map — "shared with
79
+ * all four agents," the default. A recognized but empty list is left as
80
+ * authored (an explicitly agent-less scope), not coerced to "all". */
81
+ function scopeFor(map, name) {
82
+ return map?.[name];
83
+ }
84
+ function validAgentIds(scope) {
85
+ if (!scope)
86
+ return true;
87
+ return scope.every((id) => ALL_AGENTS.includes(id));
88
+ }
89
+ /**
90
+ * `homeDir` defaults to the real `~` and is only ever overridden for tests
91
+ * and `scripts/sandbox.sh` — the same seam P0's probes use
92
+ * (src/probes/*.ts) and for the same reason: never touch a developer's
93
+ * real dotfiles from a test. It is not a workspace/project root — see
94
+ * specs/canonical-source-loading's global-only requirement, which this
95
+ * parameter does not weaken.
96
+ */
97
+ export function loadCanonicalSource(homeDir = homedir()) {
98
+ const root = trellisRoot(homeDir);
99
+ if (!existsSync(root)) {
100
+ throw new Error(`No canonical source at ${root}. Create it before running trellis sync — see docs/architecture.md's canonical schema.`);
101
+ }
102
+ const diagnostics = [];
103
+ const scopeYaml = loadScopeYaml(join(root, "scope.yaml"));
104
+ const skillDirs = listSkillDirs(join(root, "skills"));
105
+ const knownSkillNames = new Set(skillDirs.map((s) => s.name));
106
+ const skills = skillDirs.map(({ name, dir }) => ({ name, dir, scope: scopeFor(scopeYaml.skills, name) }));
107
+ const agentFiles = listMarkdownFiles(join(root, "agents"));
108
+ const knownAgentProfileNames = new Set(agentFiles.map((a) => a.name));
109
+ const agents = agentFiles.map(({ name, file }) => ({
110
+ name,
111
+ file,
112
+ scope: scopeFor(scopeYaml.agents, name),
113
+ }));
114
+ const memoryFiles = listMarkdownFiles(join(root, "memories"));
115
+ const knownMemoryNames = new Set(memoryFiles.map((m) => m.name));
116
+ const memories = memoryFiles.map(({ name, file }) => ({
117
+ name,
118
+ file,
119
+ scope: scopeFor(scopeYaml.memories, name),
120
+ }));
121
+ for (const [section, known] of [
122
+ ["skills", knownSkillNames],
123
+ ["agents", knownAgentProfileNames],
124
+ ["memories", knownMemoryNames],
125
+ ]) {
126
+ const map = scopeYaml[section];
127
+ for (const name of Object.keys(map ?? {})) {
128
+ if (!known.has(name)) {
129
+ diagnostics.push(`scope.yaml: "${section}.${name}" does not match any known ${section.slice(0, -1)} — ignored`);
130
+ }
131
+ else if (!validAgentIds(map?.[name])) {
132
+ diagnostics.push(`scope.yaml: "${section}.${name}" lists an unrecognized agent id — ignored`);
133
+ }
134
+ }
135
+ }
136
+ return {
137
+ instructionsFile: join(root, "agents.md"),
138
+ skills,
139
+ agents,
140
+ memories,
141
+ mcp: loadServersYaml(join(root, "mcp", "servers.yaml")),
142
+ // P2's pre-write guard (src/adapters/mcpPlan.ts) uses its own narrow,
143
+ // hardcoded floor instead of this field — see design.md D1 in
144
+ // trellis-secrets-audit-p3.
145
+ secretsPolicy: loadSecretsPolicyYaml(join(root, "secrets.policy.yaml"), homeDir),
146
+ diagnostics,
147
+ };
148
+ }