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,201 @@
1
+ /**
2
+ * Canonical schema types. These mirror the `.trellis/` directory layout
3
+ * documented in docs/architecture.md — this file is the source of truth
4
+ * for what a "canonical source" object looks like in memory; the YAML/MD
5
+ * files on disk are its serialization, not the other way around.
6
+ */
7
+ export type Transport = "stdio" | "http" | "sse";
8
+ export type AgentId = "claude-code" | "codex" | "kiro" | "pi";
9
+ export declare const ALL_AGENTS: readonly AgentId[];
10
+ /**
11
+ * Every scopable item (skill, subagent, memory entry, MCP server) defaults
12
+ * to "all four agents" when `scope` is omitted — sharing everywhere is the
13
+ * common case Trellis exists for; restricting to specific agents is the
14
+ * exception and must be declared explicitly. See docs/architecture.md
15
+ * "Private / agent-specific capabilities".
16
+ */
17
+ export type Scope = AgentId[] | undefined;
18
+ export declare function resolveScope(scope: Scope): readonly AgentId[];
19
+ export interface McpServerDef {
20
+ transport: Transport;
21
+ /** stdio only */
22
+ command?: string;
23
+ args?: string[];
24
+ /** http/sse only */
25
+ url?: string;
26
+ /**
27
+ * http/sse only. Values are `${VAR}` references, same discipline as
28
+ * `env` — never a real value. Codex has no generic headers concept;
29
+ * only the single shape `{ Authorization: "Bearer ${VAR}" }` is
30
+ * expressible there (trellis-mcp-transport-auth design.md D4) — any
31
+ * other shape is a Codex-only conflict, not silently dropped.
32
+ */
33
+ headers?: Record<string, string>;
34
+ /**
35
+ * Variable NAMES the server process needs, never values. See
36
+ * docs/research.md "Secrets" and schema/secrets.policy.example.yaml.
37
+ */
38
+ env?: string[];
39
+ /** Omit for "all agents" (the default). See `Scope`. */
40
+ agents?: Scope;
41
+ }
42
+ /**
43
+ * If set, every agent's adapter writes exactly ONE entry — this URL —
44
+ * instead of all N server definitions. What runs behind the URL (a
45
+ * self-hosted mcp-hub, a hosted mcp-router account, anything else
46
+ * speaking MCP over HTTP) is not Trellis's concern and not something
47
+ * adapter code branches on — there is no "engine" switch to maintain.
48
+ * See docs/architecture.md "MCP hub mode".
49
+ */
50
+ export interface HubConfig {
51
+ url: string;
52
+ }
53
+ export interface McpConfig {
54
+ servers: Record<string, McpServerDef>;
55
+ /**
56
+ * Server names a host environment (e.g. mirasim) is known to inject at
57
+ * runtime. Checked against every server name Trellis would write per
58
+ * agent when `hub` is unset; against the single hub entry name only when
59
+ * `hub` is set (there's nothing else to collide) — see docs/research.md
60
+ * "Codex — three hard constraints" for why a same-name collision is not
61
+ * a soft failure on every agent.
62
+ */
63
+ knownHostInjected: string[];
64
+ /** Omit for direct mode (today's default: every agent gets all N server
65
+ * definitions written into its native config). See `HubConfig`. */
66
+ hub?: HubConfig;
67
+ }
68
+ export interface SkillRef {
69
+ name: string;
70
+ /** Absolute path to the skill's directory. Must contain `SKILL.md` — the
71
+ * filename is case-sensitive on at least one target agent (Codex). */
72
+ dir: string;
73
+ /**
74
+ * Omit for "all agents" (the default). Deliberately NOT read from
75
+ * SKILL.md's own frontmatter — Codex validates SKILL.md frontmatter
76
+ * against an allow-list of known keys and rejects unknown ones (see
77
+ * docs/research.md), so a Trellis-only field embedded there would break
78
+ * the skill on Codex specifically. Scope lives in `scope.yaml` instead,
79
+ * outside every artifact the agents themselves parse.
80
+ */
81
+ scope?: Scope;
82
+ }
83
+ export interface AgentProfile {
84
+ name: string;
85
+ /** Path to the profile's markdown file (Claude-style frontmatter today). */
86
+ file: string;
87
+ /**
88
+ * Omit for "all agents that support subagents" — today that's
89
+ * Claude Code only (see docs/research.md: Codex has no persistent
90
+ * subagent concept, Kiro/pi unconfirmed), so this is normally implicit,
91
+ * not something you write. Only set explicitly once more than one agent
92
+ * supports subagents and a profile should NOT go to all of them.
93
+ */
94
+ scope?: Scope;
95
+ }
96
+ export interface MemoryEntry {
97
+ name: string;
98
+ file: string;
99
+ /** Omit for "all agents" (the default). See `Scope`. */
100
+ scope?: Scope;
101
+ }
102
+ export interface SecretsPolicy {
103
+ allowedVars: string[];
104
+ rejectPatterns: RegExp[];
105
+ /**
106
+ * Absolute path to a dotenv-format file. When set, it is the SOLE
107
+ * source `resolveSecretEnv` consults for a declared name — never
108
+ * merged with `process.env` (src/lib/secretEnv.ts). Unset preserves
109
+ * ambient-`process.env` resolution, the only behavior that existed
110
+ * before this field did.
111
+ */
112
+ envFile?: string;
113
+ }
114
+ /**
115
+ * Result of one MCP stdio handshake probe (src/lib/mcpProbe.ts). `ok:
116
+ * false` always carries `error` — "configured but unreachable" and "never
117
+ * attempted" must stay distinguishable in an AgentSnapshot, so there's no
118
+ * silent-false path.
119
+ */
120
+ export interface McpProbeResult {
121
+ ok: boolean;
122
+ serverInfo?: {
123
+ name?: string;
124
+ version?: string;
125
+ };
126
+ error?: string;
127
+ stderr?: string;
128
+ }
129
+ export interface AgentSnapshotSkillEntry {
130
+ name: string;
131
+ /** The skill's directory (what would be symlinked). */
132
+ dir: string;
133
+ /** Resolved realpath of `dir` — what duplication/drift comparison keys
134
+ * on (design.md D3), never `dir` itself. */
135
+ realDir: string;
136
+ isSymlink: boolean;
137
+ /** From `findSkillFile` — false means a same-named file exists with the
138
+ * wrong case and was NOT picked up by the target agent. */
139
+ caseCorrect: boolean;
140
+ }
141
+ export interface AgentSnapshotSkillRoot {
142
+ path: string;
143
+ isSymlink: boolean;
144
+ target?: string;
145
+ skills: AgentSnapshotSkillEntry[];
146
+ }
147
+ /**
148
+ * Every entry here came from an agent's own persisted, static config — a
149
+ * probe has no way to observe a host environment's runtime-only
150
+ * injection (e.g. mirasim's `-c` overrides never touch the config file
151
+ * probes read). The static-vs-known-host-injected classification spec.md
152
+ * describes is a comparison `trellis doctor` computes against
153
+ * `known_host_injected`, not a field a probe can set — see
154
+ * src/commands/doctor.ts's collision check.
155
+ */
156
+ export interface AgentSnapshotMcpServer {
157
+ name: string;
158
+ transport?: Transport;
159
+ probe?: McpProbeResult;
160
+ }
161
+ export interface AgentSnapshotPathRef {
162
+ path: string;
163
+ isSymlink: boolean;
164
+ target?: string;
165
+ }
166
+ /**
167
+ * One shape for every agent (design.md D4) — agent-specific detail lives
168
+ * inside each probe(), never in this type, so comparison logic in
169
+ * src/commands/doctor.ts never special-cases an agent.
170
+ */
171
+ export interface AgentSnapshot {
172
+ agent: AgentId;
173
+ present: boolean;
174
+ version?: string;
175
+ skillRoots: AgentSnapshotSkillRoot[];
176
+ mcpServers: AgentSnapshotMcpServer[];
177
+ instructionsFile?: AgentSnapshotPathRef;
178
+ subagentsDir?: AgentSnapshotPathRef & {
179
+ count: number;
180
+ };
181
+ /** Non-fatal issues hit while probing (e.g. a config file failed to
182
+ * parse) — a doctor finding, never silently swallowed. */
183
+ diagnostics: string[];
184
+ }
185
+ export interface CanonicalSource {
186
+ /**
187
+ * Global (`~/.trellis`) only for now. Project-local `.trellis/` and its
188
+ * merge-over-global precedence are an explicit non-goal until a later
189
+ * phase — see docs/roadmap.md. Don't build workspace resolution ahead of
190
+ * that decision.
191
+ */
192
+ instructionsFile: string;
193
+ skills: SkillRef[];
194
+ agents: AgentProfile[];
195
+ memories: MemoryEntry[];
196
+ mcp: McpConfig;
197
+ secretsPolicy: SecretsPolicy;
198
+ /** e.g. a `scope.yaml` entry naming a skill/agent/memory that doesn't
199
+ * exist — recorded, not a load failure. See `loadCanonicalSource`. */
200
+ diagnostics: string[];
201
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Canonical schema types. These mirror the `.trellis/` directory layout
3
+ * documented in docs/architecture.md — this file is the source of truth
4
+ * for what a "canonical source" object looks like in memory; the YAML/MD
5
+ * files on disk are its serialization, not the other way around.
6
+ */
7
+ export const ALL_AGENTS = [
8
+ "claude-code",
9
+ "codex",
10
+ "kiro",
11
+ "pi",
12
+ ];
13
+ export function resolveScope(scope) {
14
+ return scope ?? ALL_AGENTS;
15
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Byte-for-byte directory content comparison (trellis-cli-migrate design.md
3
+ * D2) — "already migrated, safe to skip" vs. "a real conflict" is decided
4
+ * by this, not by name/mtime/hash-shortcut. Same set of relative file
5
+ * paths, same bytes per file; anything else is unequal.
6
+ */
7
+ export declare function dirContentsEqual(a: string, b: string): boolean;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Byte-for-byte directory content comparison (trellis-cli-migrate design.md
3
+ * D2) — "already migrated, safe to skip" vs. "a real conflict" is decided
4
+ * by this, not by name/mtime/hash-shortcut. Same set of relative file
5
+ * paths, same bytes per file; anything else is unequal.
6
+ */
7
+ import { readFileSync, readdirSync, statSync } from "node:fs";
8
+ import { join, relative } from "node:path";
9
+ function listFilesRecursive(dir) {
10
+ const results = [];
11
+ const walk = (current) => {
12
+ for (const entry of readdirSync(current)) {
13
+ const full = join(current, entry);
14
+ if (statSync(full).isDirectory()) {
15
+ walk(full);
16
+ }
17
+ else {
18
+ results.push(relative(dir, full));
19
+ }
20
+ }
21
+ };
22
+ walk(dir);
23
+ return results.sort();
24
+ }
25
+ export function dirContentsEqual(a, b) {
26
+ const filesA = listFilesRecursive(a);
27
+ const filesB = listFilesRecursive(b);
28
+ if (filesA.length !== filesB.length)
29
+ return false;
30
+ for (let i = 0; i < filesA.length; i++) {
31
+ if (filesA[i] !== filesB[i])
32
+ return false;
33
+ }
34
+ for (const rel of filesA) {
35
+ if (!readFileSync(join(a, rel)).equals(readFileSync(join(b, rel))))
36
+ return false;
37
+ }
38
+ return true;
39
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Extracts the environment-variable *names* an agent's real MCP config
3
+ * declares — not a general parser for either format, just the one shape
4
+ * each needs (trellis-secrets-audit-p3 design.md D1). Read-only: unlike
5
+ * src/lib/tomlSection.ts (a write-path module with a different job),
6
+ * there is no round-trip-fidelity concern here, but a full parser is
7
+ * still more machinery than this one narrow extraction needs.
8
+ */
9
+ import type { McpServerDef } from "../core/types.js";
10
+ /** Every `${NAME}` occurrence in a string — a header value can in
11
+ * principle reference more than one name (trellis-mcp-transport-auth
12
+ * design.md D6), same extraction `env`'s single-name-per-value case
13
+ * already needed, just generalized. */
14
+ export declare function extractTemplateVarNames(value: string): string[];
15
+ /** `env` names ∪ names embedded in `headers` values — what both the
16
+ * secrets-audit `missing-env-value` check and Kiro's approved-env-vars
17
+ * list need to know a canonical server references (trellis-mcp-
18
+ * transport-auth design.md D6): Kiro's own `${VAR}` substitution
19
+ * recurses into `headers` the same as `env`, so both features need the
20
+ * same answer to "what names does this def reference," not two
21
+ * separately-maintained collections. */
22
+ export declare function declaredEnvNames(def: McpServerDef): string[];
23
+ /** Claude Code / Kiro's config is real JSON — parse it and walk each
24
+ * server's `env` object keys plus names embedded in `headers` object
25
+ * *values* (unlike `env`, a header's key is an arbitrary header name,
26
+ * not a variable name — the variable name lives inside the `${...}`
27
+ * value). Returns `[]` (not a throw) on invalid JSON; that's a
28
+ * diagnostic for a different command, not this extractor's job. */
29
+ export declare function extractJsonEnvVarNames(content: string): string[];
30
+ /** Codex's config is TOML. The shapes audit needs: every `env_vars = [...]`
31
+ * line's quoted string contents, plus a `bearer_token_env_var = "VAR"`
32
+ * line's bare name (a name directly, not a `${VAR}` template — Codex's
33
+ * own field holds a name, same as `env_vars`). A five-line regex each,
34
+ * not a parser (design.md D1). */
35
+ export declare function extractTomlEnvVarNames(content: string): string[];
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Extracts the environment-variable *names* an agent's real MCP config
3
+ * declares — not a general parser for either format, just the one shape
4
+ * each needs (trellis-secrets-audit-p3 design.md D1). Read-only: unlike
5
+ * src/lib/tomlSection.ts (a write-path module with a different job),
6
+ * there is no round-trip-fidelity concern here, but a full parser is
7
+ * still more machinery than this one narrow extraction needs.
8
+ */
9
+ const TEMPLATE_VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
10
+ /** Every `${NAME}` occurrence in a string — a header value can in
11
+ * principle reference more than one name (trellis-mcp-transport-auth
12
+ * design.md D6), same extraction `env`'s single-name-per-value case
13
+ * already needed, just generalized. */
14
+ export function extractTemplateVarNames(value) {
15
+ return [...value.matchAll(TEMPLATE_VAR_RE)].map((m) => m[1]);
16
+ }
17
+ /** `env` names ∪ names embedded in `headers` values — what both the
18
+ * secrets-audit `missing-env-value` check and Kiro's approved-env-vars
19
+ * list need to know a canonical server references (trellis-mcp-
20
+ * transport-auth design.md D6): Kiro's own `${VAR}` substitution
21
+ * recurses into `headers` the same as `env`, so both features need the
22
+ * same answer to "what names does this def reference," not two
23
+ * separately-maintained collections. */
24
+ export function declaredEnvNames(def) {
25
+ const names = new Set(def.env ?? []);
26
+ for (const value of Object.values(def.headers ?? {})) {
27
+ for (const name of extractTemplateVarNames(value))
28
+ names.add(name);
29
+ }
30
+ return [...names];
31
+ }
32
+ /** Claude Code / Kiro's config is real JSON — parse it and walk each
33
+ * server's `env` object keys plus names embedded in `headers` object
34
+ * *values* (unlike `env`, a header's key is an arbitrary header name,
35
+ * not a variable name — the variable name lives inside the `${...}`
36
+ * value). Returns `[]` (not a throw) on invalid JSON; that's a
37
+ * diagnostic for a different command, not this extractor's job. */
38
+ export function extractJsonEnvVarNames(content) {
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(content);
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ const names = [];
47
+ for (const server of Object.values(parsed.mcpServers ?? {})) {
48
+ names.push(...Object.keys(server.env ?? {}));
49
+ for (const value of Object.values(server.headers ?? {})) {
50
+ names.push(...extractTemplateVarNames(value));
51
+ }
52
+ }
53
+ return names;
54
+ }
55
+ const ENV_VARS_LINE_RE = /^\s*env_vars\s*=\s*\[(.*)\]\s*$/;
56
+ const BEARER_TOKEN_ENV_VAR_LINE_RE = /^\s*bearer_token_env_var\s*=\s*"([^"]*)"\s*$/;
57
+ const QUOTED_STRING_RE = /"((?:[^"\\]|\\.)*)"/g;
58
+ /** Codex's config is TOML. The shapes audit needs: every `env_vars = [...]`
59
+ * line's quoted string contents, plus a `bearer_token_env_var = "VAR"`
60
+ * line's bare name (a name directly, not a `${VAR}` template — Codex's
61
+ * own field holds a name, same as `env_vars`). A five-line regex each,
62
+ * not a parser (design.md D1). */
63
+ export function extractTomlEnvVarNames(content) {
64
+ const names = [];
65
+ for (const line of content.split("\n")) {
66
+ const envVarsMatch = ENV_VARS_LINE_RE.exec(line);
67
+ if (envVarsMatch) {
68
+ for (const stringMatch of envVarsMatch[1].matchAll(QUOTED_STRING_RE)) {
69
+ names.push(stringMatch[1]);
70
+ }
71
+ continue;
72
+ }
73
+ const bearerMatch = BEARER_TOKEN_ENV_VAR_LINE_RE.exec(line);
74
+ if (bearerMatch) {
75
+ names.push(bearerMatch[1]);
76
+ }
77
+ }
78
+ return names;
79
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Filesystem identity by realpath, never by string path or content hash.
3
+ * See design.md D3 (openspec/changes/trellis-doctor-p0): two physical
4
+ * copies of identical content are NOT the same thing to an agent's own
5
+ * discovery logic (Codex, pi both dedup this way independently) — only a
6
+ * symlink to the same target is.
7
+ */
8
+ export declare function isSymlinkTo(path: string, target: string): boolean;
9
+ /**
10
+ * Groups paths by realpath. Two ordinary directories with byte-identical
11
+ * content but no symlink between them land in separate buckets — that's
12
+ * the point, not a bug (see D3). A path that can't be resolved (broken
13
+ * symlink, missing) is bucketed on its own literal path instead, so it's
14
+ * never silently dropped from the result.
15
+ */
16
+ export declare function realpathDedupe(paths: string[]): Map<string, string[]>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Filesystem identity by realpath, never by string path or content hash.
3
+ * See design.md D3 (openspec/changes/trellis-doctor-p0): two physical
4
+ * copies of identical content are NOT the same thing to an agent's own
5
+ * discovery logic (Codex, pi both dedup this way independently) — only a
6
+ * symlink to the same target is.
7
+ */
8
+ import { lstatSync, realpathSync } from "node:fs";
9
+ export function isSymlinkTo(path, target) {
10
+ let stat;
11
+ try {
12
+ stat = lstatSync(path);
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ if (!stat.isSymbolicLink()) {
18
+ return false;
19
+ }
20
+ try {
21
+ return realpathSync(path) === realpathSync(target);
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ /**
28
+ * Groups paths by realpath. Two ordinary directories with byte-identical
29
+ * content but no symlink between them land in separate buckets — that's
30
+ * the point, not a bug (see D3). A path that can't be resolved (broken
31
+ * symlink, missing) is bucketed on its own literal path instead, so it's
32
+ * never silently dropped from the result.
33
+ */
34
+ export function realpathDedupe(paths) {
35
+ const buckets = new Map();
36
+ for (const path of paths) {
37
+ let real;
38
+ try {
39
+ real = realpathSync(path);
40
+ }
41
+ catch {
42
+ real = path;
43
+ }
44
+ const bucket = buckets.get(real);
45
+ if (bucket) {
46
+ bucket.push(path);
47
+ }
48
+ else {
49
+ buckets.set(real, [path]);
50
+ }
51
+ }
52
+ return buckets;
53
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * MCP stdio handshake probe: spawn, send `initialize`, resolve on the
3
+ * first `id: 1` response or process exit, whichever comes first. This is a
4
+ * direct port of the probe script used repeatedly by hand during this
5
+ * project's research (docs/research.md) — see design.md D1 for why this is
6
+ * hand-rolled JSON-RPC rather than `@modelcontextprotocol/sdk`'s client.
7
+ */
8
+ import type { McpProbeResult } from "../core/types.js";
9
+ export interface McpProbeTarget {
10
+ transport: "stdio" | "http";
11
+ command?: string;
12
+ args?: string[];
13
+ }
14
+ export declare function probeMcpServer(def: McpProbeTarget, env: NodeJS.ProcessEnv, timeoutMs?: number): Promise<McpProbeResult>;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * MCP stdio handshake probe: spawn, send `initialize`, resolve on the
3
+ * first `id: 1` response or process exit, whichever comes first. This is a
4
+ * direct port of the probe script used repeatedly by hand during this
5
+ * project's research (docs/research.md) — see design.md D1 for why this is
6
+ * hand-rolled JSON-RPC rather than `@modelcontextprotocol/sdk`'s client.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+ export function probeMcpServer(def, env, timeoutMs = 10_000) {
10
+ if (def.transport !== "stdio" || !def.command) {
11
+ return Promise.resolve({
12
+ ok: false,
13
+ error: `probeMcpServer only handshakes stdio servers with a command (got transport=${def.transport})`,
14
+ });
15
+ }
16
+ return new Promise((resolve) => {
17
+ const child = spawn(def.command, def.args ?? [], { env });
18
+ let settled = false;
19
+ let stdoutBuf = "";
20
+ let stderrBuf = "";
21
+ const finish = (result) => {
22
+ if (settled)
23
+ return;
24
+ settled = true;
25
+ clearTimeout(timer);
26
+ child.stdout?.removeAllListeners("data");
27
+ try {
28
+ child.kill("SIGTERM");
29
+ }
30
+ catch {
31
+ // already exited
32
+ }
33
+ // A wrapper (npx, a shell shim) can absorb SIGTERM without forwarding
34
+ // it to the real server it launched. Escalate once, short grace
35
+ // period, so a probe never leaves a live process behind.
36
+ setTimeout(() => {
37
+ try {
38
+ child.kill("SIGKILL");
39
+ }
40
+ catch {
41
+ // already exited
42
+ }
43
+ }, 500).unref();
44
+ resolve(result);
45
+ };
46
+ const timer = setTimeout(() => {
47
+ finish({ ok: false, error: `no response within ${timeoutMs}ms`, stderr: stderrBuf || undefined });
48
+ }, timeoutMs);
49
+ child.stdout?.on("data", (chunk) => {
50
+ stdoutBuf += chunk.toString("utf-8");
51
+ const lines = stdoutBuf.split("\n");
52
+ stdoutBuf = lines.pop() ?? "";
53
+ for (const line of lines) {
54
+ const trimmed = line.trim();
55
+ if (!trimmed)
56
+ continue;
57
+ let msg;
58
+ try {
59
+ msg = JSON.parse(trimmed);
60
+ }
61
+ catch {
62
+ continue;
63
+ }
64
+ if (msg.id === 1) {
65
+ if (msg.error) {
66
+ finish({ ok: false, error: JSON.stringify(msg.error), stderr: stderrBuf || undefined });
67
+ }
68
+ else {
69
+ finish({ ok: true, serverInfo: msg.result?.serverInfo, stderr: stderrBuf || undefined });
70
+ }
71
+ return;
72
+ }
73
+ }
74
+ });
75
+ child.stderr?.on("data", (chunk) => {
76
+ stderrBuf += chunk.toString("utf-8");
77
+ });
78
+ child.on("error", (err) => {
79
+ finish({ ok: false, error: err.message });
80
+ });
81
+ child.on("exit", (code) => {
82
+ finish({ ok: false, error: `process exited (code ${code}) before responding`, stderr: stderrBuf || undefined });
83
+ });
84
+ const request = {
85
+ jsonrpc: "2.0",
86
+ id: 1,
87
+ method: "initialize",
88
+ params: {
89
+ protocolVersion: "2025-06-18",
90
+ capabilities: {},
91
+ clientInfo: { name: "trellis-doctor", version: "0.0.0" },
92
+ },
93
+ };
94
+ child.stdin?.write(`${JSON.stringify(request)}\n`);
95
+ });
96
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Shared filesystem/JSON reading used by every per-agent probe
3
+ * (src/probes/*.ts) — kept here so all four agree on what "symlink status,"
4
+ * "skill root," and "resolve env var references" mean, rather than each
5
+ * probe reimplementing it slightly differently.
6
+ */
7
+ import type { AgentSnapshotPathRef, AgentSnapshotSkillRoot } from "../core/types.js";
8
+ export declare function pathRef(path: string): AgentSnapshotPathRef | undefined;
9
+ /**
10
+ * One skill root = a directory of skill subdirectories, each expected to
11
+ * hold `SKILL.md`. Every agent as of design.md D5 uses this shape.
12
+ */
13
+ export declare function scanSkillRoot(rootPath: string): AgentSnapshotSkillRoot | undefined;
14
+ export declare function readJsonFile<T>(path: string): T | undefined;
15
+ export declare function countDirEntries(dir: string): number;
16
+ /**
17
+ * Substitutes `${VAR}` against `base` (normally `process.env`) so a
18
+ * probe's handshake spawn gets the real value — configs only ever hold
19
+ * the variable NAME (docs/research.md "Secrets"), never a literal.
20
+ * Values given as a bare array (Codex's `env_vars` style: just names) need
21
+ * no substitution — the named var must already be in `base` for the
22
+ * spawned process to inherit it, which it will via object spread.
23
+ */
24
+ export declare function resolveEnvRefs(envSpec: Record<string, string> | string[] | undefined, base: NodeJS.ProcessEnv): NodeJS.ProcessEnv;