@agent-loom/loom 1.0.2 → 1.0.3

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 (145) hide show
  1. package/README.md +69 -0
  2. package/dist/acp/client.d.ts +182 -0
  3. package/dist/acp/client.d.ts.map +1 -0
  4. package/dist/acp/client.js +432 -0
  5. package/dist/acp/client.js.map +1 -0
  6. package/dist/acp/index.d.ts +5 -0
  7. package/dist/acp/index.d.ts.map +1 -0
  8. package/dist/acp/index.js +3 -0
  9. package/dist/acp/index.js.map +1 -0
  10. package/dist/acp/run.d.ts +41 -0
  11. package/dist/acp/run.d.ts.map +1 -0
  12. package/dist/acp/run.js +32 -0
  13. package/dist/acp/run.js.map +1 -0
  14. package/dist/apply.d.ts +15 -6
  15. package/dist/apply.d.ts.map +1 -1
  16. package/dist/apply.js +78 -49
  17. package/dist/apply.js.map +1 -1
  18. package/dist/chat/chat.d.ts +108 -0
  19. package/dist/chat/chat.d.ts.map +1 -0
  20. package/dist/chat/chat.js +221 -0
  21. package/dist/chat/chat.js.map +1 -0
  22. package/dist/chat/discovery.d.ts +30 -0
  23. package/dist/chat/discovery.d.ts.map +1 -0
  24. package/dist/chat/discovery.js +68 -0
  25. package/dist/chat/discovery.js.map +1 -0
  26. package/dist/chat/frontmatter.d.ts +12 -0
  27. package/dist/chat/frontmatter.d.ts.map +1 -0
  28. package/dist/chat/frontmatter.js +11 -0
  29. package/dist/chat/frontmatter.js.map +1 -0
  30. package/dist/chat/index.d.ts +16 -0
  31. package/dist/chat/index.d.ts.map +1 -0
  32. package/dist/chat/index.js +11 -0
  33. package/dist/chat/index.js.map +1 -0
  34. package/dist/chat/registry.d.ts +73 -0
  35. package/dist/chat/registry.d.ts.map +1 -0
  36. package/dist/chat/registry.js +118 -0
  37. package/dist/chat/registry.js.map +1 -0
  38. package/dist/chat/resolve-agent.d.ts +39 -0
  39. package/dist/chat/resolve-agent.d.ts.map +1 -0
  40. package/dist/chat/resolve-agent.js +36 -0
  41. package/dist/chat/resolve-agent.js.map +1 -0
  42. package/dist/chat/suggest.d.ts +20 -0
  43. package/dist/chat/suggest.d.ts.map +1 -0
  44. package/dist/chat/suggest.js +55 -0
  45. package/dist/chat/suggest.js.map +1 -0
  46. package/dist/cli.js +627 -75
  47. package/dist/cli.js.map +1 -1
  48. package/dist/clone.d.ts +21 -3
  49. package/dist/clone.d.ts.map +1 -1
  50. package/dist/clone.js +240 -12
  51. package/dist/clone.js.map +1 -1
  52. package/dist/copilot/mcp.d.ts +48 -0
  53. package/dist/copilot/mcp.d.ts.map +1 -0
  54. package/dist/copilot/mcp.js +146 -0
  55. package/dist/copilot/mcp.js.map +1 -0
  56. package/dist/copilot/resolve.d.ts +33 -0
  57. package/dist/copilot/resolve.d.ts.map +1 -0
  58. package/dist/copilot/resolve.js +96 -0
  59. package/dist/copilot/resolve.js.map +1 -0
  60. package/dist/copilot/spawn.d.ts +51 -0
  61. package/dist/copilot/spawn.d.ts.map +1 -0
  62. package/dist/copilot/spawn.js +132 -0
  63. package/dist/copilot/spawn.js.map +1 -0
  64. package/dist/index.d.ts +19 -0
  65. package/dist/index.d.ts.map +1 -0
  66. package/dist/index.js +15 -0
  67. package/dist/index.js.map +1 -0
  68. package/dist/launch/index.d.ts +10 -0
  69. package/dist/launch/index.d.ts.map +1 -0
  70. package/dist/launch/index.js +9 -0
  71. package/dist/launch/index.js.map +1 -0
  72. package/dist/launch/stage.d.ts +62 -0
  73. package/dist/launch/stage.d.ts.map +1 -0
  74. package/dist/launch/stage.js +108 -0
  75. package/dist/launch/stage.js.map +1 -0
  76. package/dist/manifest.d.ts +165 -18
  77. package/dist/manifest.d.ts.map +1 -1
  78. package/dist/manifest.js +980 -225
  79. package/dist/manifest.js.map +1 -1
  80. package/dist/renderers/claude.d.ts +5 -0
  81. package/dist/renderers/claude.d.ts.map +1 -1
  82. package/dist/renderers/claude.js +17 -3
  83. package/dist/renderers/claude.js.map +1 -1
  84. package/dist/renderers/copilot.d.ts +1 -1
  85. package/dist/renderers/copilot.d.ts.map +1 -1
  86. package/dist/renderers/copilot.js +205 -22
  87. package/dist/renderers/copilot.js.map +1 -1
  88. package/dist/repo-clone.js +17 -11
  89. package/dist/repo-clone.js.map +1 -1
  90. package/dist/resolve-template.d.ts +12 -4
  91. package/dist/resolve-template.d.ts.map +1 -1
  92. package/dist/resolve-template.js +39 -8
  93. package/dist/resolve-template.js.map +1 -1
  94. package/dist/run/index.d.ts +4 -0
  95. package/dist/run/index.d.ts.map +1 -0
  96. package/dist/run/index.js +2 -0
  97. package/dist/run/index.js.map +1 -0
  98. package/dist/run/run.d.ts +143 -0
  99. package/dist/run/run.d.ts.map +1 -0
  100. package/dist/run/run.js +406 -0
  101. package/dist/run/run.js.map +1 -0
  102. package/dist/search-registry.d.ts +10 -3
  103. package/dist/search-registry.d.ts.map +1 -1
  104. package/dist/search-registry.js +16 -16
  105. package/dist/search-registry.js.map +1 -1
  106. package/dist/sessions/index.d.ts +16 -0
  107. package/dist/sessions/index.d.ts.map +1 -0
  108. package/dist/sessions/index.js +15 -0
  109. package/dist/sessions/index.js.map +1 -0
  110. package/dist/sessions/store.d.ts +56 -0
  111. package/dist/sessions/store.d.ts.map +1 -0
  112. package/dist/sessions/store.js +220 -0
  113. package/dist/sessions/store.js.map +1 -0
  114. package/dist/sessions/types.d.ts +62 -0
  115. package/dist/sessions/types.d.ts.map +1 -0
  116. package/dist/sessions/types.js +5 -0
  117. package/dist/sessions/types.js.map +1 -0
  118. package/dist/skill-fetcher.d.ts.map +1 -1
  119. package/dist/skill-fetcher.js +5 -6
  120. package/dist/skill-fetcher.js.map +1 -1
  121. package/dist/types.d.ts +123 -41
  122. package/dist/types.d.ts.map +1 -1
  123. package/dist/types.js +12 -0
  124. package/dist/types.js.map +1 -1
  125. package/dist/util/binary-cache.d.ts +53 -0
  126. package/dist/util/binary-cache.d.ts.map +1 -0
  127. package/dist/util/binary-cache.js +211 -0
  128. package/dist/util/binary-cache.js.map +1 -0
  129. package/dist/util/frontmatter.d.ts +53 -0
  130. package/dist/util/frontmatter.d.ts.map +1 -0
  131. package/dist/util/frontmatter.js +85 -0
  132. package/dist/util/frontmatter.js.map +1 -0
  133. package/dist/util/loom-home.d.ts +19 -0
  134. package/dist/util/loom-home.d.ts.map +1 -0
  135. package/dist/util/loom-home.js +37 -0
  136. package/dist/util/loom-home.js.map +1 -0
  137. package/dist/util/workspace-folder.d.ts +29 -0
  138. package/dist/util/workspace-folder.d.ts.map +1 -0
  139. package/dist/util/workspace-folder.js +43 -0
  140. package/dist/util/workspace-folder.js.map +1 -0
  141. package/dist/validate.d.ts +7 -1
  142. package/dist/validate.d.ts.map +1 -1
  143. package/dist/validate.js +90 -17
  144. package/dist/validate.js.map +1 -1
  145. package/package.json +31 -2
package/dist/manifest.js CHANGED
@@ -4,40 +4,368 @@
4
4
  * Parses manifest.yaml files and resolves $ref / file / registry references
5
5
  * to produce a fully resolved manifest ready for rendering.
6
6
  */
7
- import { readFile, readdir } from 'node:fs/promises';
8
- import { resolve, dirname, basename, extname, relative, join } from 'node:path';
7
+ import { readFile, readdir, stat, realpath } from 'node:fs/promises';
8
+ import { resolve, dirname, basename, extname, relative, join, isAbsolute } from 'node:path';
9
9
  import yaml from 'js-yaml';
10
- import { isSharedRef, isLocalFileRef, isRegistrySkillRef, isMcpInlineDef, } from './types.js';
10
+ import { isSharedRef, isLocalFileRef, isRegistrySkillRef, isDiscoverSkillsRef, isDiscoverAgentRef, isMcpInlineDef, } from './types.js';
11
+ import { parseFrontmatterRaw, stripFrontmatter } from './util/frontmatter.js';
12
+ import { resolveBinaryToCache } from './util/binary-cache.js';
13
+ // =============================================================================
14
+ // Path safety (security primitives)
15
+ //
16
+ // Manifest-controlled paths flow into renderers and writers. A malicious or
17
+ // hand-edited template can declare `$ref: ../../.vscode/pwned` or
18
+ // `name: ../../escape` and cause loom to read or write outside the workspace.
19
+ // These helpers close that vector at the resolver layer so downstream code
20
+ // (renderers, writers) never sees an unsafe path.
21
+ //
22
+ // Recovered from PR #134 cycle 1-3 review (commits dc9c4dd, 2432428, b16ef3c)
23
+ // during the foundation-first carve in 2026-05.
24
+ // =============================================================================
25
+ /**
26
+ * Validate that `name` is safe to use as a single filesystem path
27
+ * component (skill dir, prompt filename, agent slug). Frontmatter
28
+ * `name:` values flow into renderer paths; a malicious template can
29
+ * declare `name: ../../../.vscode/pwned` and have the renderer write
30
+ * outside the workspace. This helper closes that vector at the
31
+ * resolver layer so renderers never see an unsafe name.
32
+ */
33
+ export function isSafePathSegment(name) {
34
+ if (typeof name !== 'string' || name.length === 0)
35
+ return false;
36
+ if (name === '.' || name === '..')
37
+ return false;
38
+ if (name.includes('/') || name.includes('\\'))
39
+ return false;
40
+ // eslint-disable-next-line no-control-regex
41
+ if (name.includes('\0'))
42
+ return false;
43
+ // Reject Windows drive letters (e.g. `C:`) used as directory names.
44
+ if (/^[A-Za-z]:/.test(name))
45
+ return false;
46
+ return true;
47
+ }
48
+ function unsafePathSegmentMessage(kind, name, source) {
49
+ return `[loom] Unsafe ${kind} name "${name}" from ${source}: names must be a single filesystem path segment.`;
50
+ }
51
+ function assertSafePathSegment(kind, name, source) {
52
+ if (!isSafePathSegment(name)) {
53
+ throw new Error(unsafePathSegmentMessage(kind, name, source));
54
+ }
55
+ return name;
56
+ }
57
+ /**
58
+ * Resolve `ref` against `root`, then assert the result stays under
59
+ * `root` after symlink resolution. Throws on escape (absolute paths,
60
+ * `..` traversal, symlinks pointing out). Use for every manifest-
61
+ * controlled relative path before reading or writing it.
62
+ *
63
+ * Skips the realpath check if the file does not yet exist (e.g. a
64
+ * renderer about to write to it); returns the lexically-resolved path
65
+ * in that case. Callers that need realpath safety on an existing file
66
+ * should call after stat.
67
+ */
68
+ export async function safeJoinUnder(root, ref) {
69
+ if (typeof ref !== 'string' || ref.length === 0) {
70
+ throw new Error(`manifest path safety: empty reference`);
71
+ }
72
+ if (isAbsolute(ref)) {
73
+ throw new Error(`manifest path safety: absolute paths not allowed: ${ref}`);
74
+ }
75
+ const candidate = resolve(root, ref);
76
+ let realRoot;
77
+ try {
78
+ realRoot = await realpath(root);
79
+ }
80
+ catch {
81
+ realRoot = resolve(root);
82
+ }
83
+ let realCandidate;
84
+ try {
85
+ realCandidate = await realpath(candidate);
86
+ }
87
+ catch {
88
+ // Doesn't exist yet -- fall back to lexical containment check.
89
+ realCandidate = candidate;
90
+ }
91
+ const rel = relative(realRoot, realCandidate);
92
+ if (rel === '' || rel === '.' || (!rel.startsWith('..') && !isAbsolute(rel))) {
93
+ return realCandidate;
94
+ }
95
+ throw new Error(`manifest path safety: "${ref}" escapes its root (${root})`);
96
+ }
11
97
  // =============================================================================
12
98
  // Parse
13
99
  // =============================================================================
14
100
  /**
15
- * Parse a manifest.yaml file into a typed Manifest object.
101
+ * Parse a manifest into a typed Manifest object.
102
+ *
103
+ * Accepts either:
104
+ * - A path to a manifest file (`.../manifest.yaml` or `.../dobby.yaml`)
105
+ * - A path to a directory containing one of those filenames
106
+ *
107
+ * When given a directory, prefers `manifest.yaml`; falls back to
108
+ * `dobby.yaml` (with a one-time deprecation note). If both are
109
+ * present, prefers `manifest.yaml` and warns.
110
+ *
111
+ * Per U5/U6, field-name and layout aliases are normalized at parse time:
112
+ * - `mcpServers:` -> `mcp:`
113
+ * - `sparseCheckout:` -> `paths:` (+ `sparse: true`)
114
+ * - `team:` -> `template:`
115
+ * - flat top-level resources -> wrapped in synthetic `contents:`
116
+ * Each kind of alias triggers a once-per-session deprecation warning.
117
+ * See normalizeManifestAliases.
16
118
  */
17
- export async function parseManifest(manifestPath) {
119
+ export async function parseManifest(pathOrDir) {
120
+ const manifestPath = await resolveManifestPath(pathOrDir);
18
121
  const content = await readFile(manifestPath, 'utf-8');
19
122
  const raw = yaml.load(content);
20
- return raw;
123
+ const normalized = normalizeManifestAliases(raw, manifestPath);
124
+ return normalized;
125
+ }
126
+ /**
127
+ * Resolve a path-or-directory argument into the actual manifest file path.
128
+ *
129
+ * - If the path is a file, returns it unchanged.
130
+ * - If the path is a directory, probes `manifest.yaml` first (canonical),
131
+ * then `dobby.yaml` (legacy alias). Logs a one-time deprecation
132
+ * warning when falling back to `dobby.yaml`.
133
+ * - Throws if neither is found.
134
+ *
135
+ * Exported so callers (e.g. validate.ts) can probe for a manifest using
136
+ * the same rules without re-reading the file.
137
+ */
138
+ export async function resolveManifestPath(pathOrDir) {
139
+ let isDir = false;
140
+ try {
141
+ const s = await stat(pathOrDir);
142
+ isDir = s.isDirectory();
143
+ }
144
+ catch {
145
+ // Path doesn't exist; fall through and let the file read fail with the
146
+ // original ENOENT so the caller sees the same error as before.
147
+ return pathOrDir;
148
+ }
149
+ if (!isDir)
150
+ return pathOrDir;
151
+ const canonical = join(pathOrDir, 'manifest.yaml');
152
+ const dobby = join(pathOrDir, 'dobby.yaml');
153
+ const hasCanonical = await fileExists(canonical);
154
+ const hasDobby = await fileExists(dobby);
155
+ if (hasCanonical && hasDobby) {
156
+ console.warn(`[loom] Both manifest.yaml and dobby.yaml present in ${pathOrDir}; ` +
157
+ `using manifest.yaml. Remove dobby.yaml to silence this warning.`);
158
+ return canonical;
159
+ }
160
+ if (hasCanonical)
161
+ return canonical;
162
+ if (hasDobby) {
163
+ logFilenameDeprecation(pathOrDir);
164
+ return dobby;
165
+ }
166
+ throw new Error(`No manifest.yaml or dobby.yaml found in ${pathOrDir}`);
167
+ }
168
+ async function fileExists(path) {
169
+ try {
170
+ const s = await stat(path);
171
+ return s.isFile();
172
+ }
173
+ catch {
174
+ return false;
175
+ }
21
176
  }
22
177
  // =============================================================================
23
- // Resolve
178
+ // Field-name aliases (U5: dobby-style -> canonical)
24
179
  // =============================================================================
25
180
  /**
26
- * Resolve all $ref, file, and registry references in a parsed manifest.
181
+ * One-time deprecation log dedup, keyed by alias name. Each deprecated
182
+ * field is reported at most once per process, no matter how many
183
+ * manifests use it. Tests can clear the set via
184
+ * `_resetAliasDeprecationsForTest`.
185
+ */
186
+ const ALIAS_DEPRECATION_LOGGED = new Set();
187
+ /**
188
+ * Top-level fields that, when present at the manifest root and `contents:`
189
+ * is absent, signal a flat layout (dobby-style). These are hoisted into
190
+ * a synthetic `contents:` wrapper by normalizeManifestAliases so the
191
+ * resolver doesn't need a separate code path. `prerequisites:` stays at
192
+ * the root since that's the canonical location.
193
+ */
194
+ const FLAT_LAYOUT_RESOURCES = [
195
+ 'instructions',
196
+ 'skills',
197
+ 'agents',
198
+ 'mcp',
199
+ 'mcpServers',
200
+ 'repos',
201
+ 'prompts',
202
+ ];
203
+ /**
204
+ * Walk a freshly-loaded manifest YAML object and rename dobby-style
205
+ * field names to loom's canonical names. The input object is shallow-
206
+ * cloned; the caller's object is never mutated. Each alias triggers a
207
+ * one-time deprecation warning per session.
27
208
  *
28
- * @param manifest - Parsed manifest (from parseManifest)
29
- * @param templateDir - Absolute path to the template directory (contains manifest.yaml)
30
- * @param registryRoot - Absolute path to the registry root (contains shared/)
209
+ * Aliases supported:
210
+ * - top-level `team:` -> `template:` (only when `template:` absent)
211
+ * - top-level `mcpServers:` -> `mcp:` (also recognized inside `contents:`)
212
+ * - per-repo entry: `sparseCheckout:` -> `paths:` (also sets
213
+ * `sparse: true` when no explicit `sparse:` key is given)
214
+ * - flat layout: top-level `instructions:` / `skills:` / `agents:` /
215
+ * `mcp:` / `repos:` / `prompts:` are hoisted into a synthetic
216
+ * `contents:` block when `contents:` is absent. Per U6, this
217
+ * collapses the dobby-format flat layout into the canonical
218
+ * contents:-wrapped layout that resolveManifest expects.
219
+ *
220
+ * Filename-level aliases (e.g. dobby.yaml -> manifest.yaml) are
221
+ * handled in `resolveManifestPath` before this function runs.
222
+ */
223
+ export function normalizeManifestAliases(raw, sourcePath) {
224
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
225
+ return raw;
226
+ }
227
+ let out = { ...raw };
228
+ // U6: `team:` -> `template:` alias (used by dobby.yaml). Only fires
229
+ // when `template:` is absent so canonical manifests are untouched.
230
+ if ('team' in out && !('template' in out)) {
231
+ logAliasDeprecation('team', 'template', sourcePath);
232
+ out.template = out.team;
233
+ delete out.team;
234
+ }
235
+ // U6: flat layout -> synthetic `contents:` wrapper. Triggered when
236
+ // `contents:` is absent but at least one resource field lives at the
237
+ // root. After hoisting, the rest of normalize and resolveManifest
238
+ // operate on the unified shape.
239
+ if (!('contents' in out)) {
240
+ const hoisted = {};
241
+ let hadAny = false;
242
+ for (const field of FLAT_LAYOUT_RESOURCES) {
243
+ if (field in out) {
244
+ hoisted[field] = out[field];
245
+ delete out[field];
246
+ hadAny = true;
247
+ }
248
+ }
249
+ if (hadAny) {
250
+ logAliasDeprecation('flat-layout', 'contents', sourcePath);
251
+ // U6: implicit filesystem discovery for skills and agents in
252
+ // flat-layout dobby-style manifests. Matches the prior dobby
253
+ // adapter's behavior so existing dobby checkouts render skills
254
+ // and agents from disk without requiring dobby.yaml edits.
255
+ // Order encodes precedence: shared first, team second (later
256
+ // dirs override earlier on duplicate names; see
257
+ // resolveSkillEntries / discoverSkillsFromDirs).
258
+ if (!('skills' in hoisted)) {
259
+ hoisted.skills = [{ discover: ['../_shared/skills', './skills'] }];
260
+ }
261
+ if (!('agents' in hoisted)) {
262
+ hoisted.agents = [{ discover: ['./agents'] }];
263
+ }
264
+ out.contents = hoisted;
265
+ // Defaults for fields the canonical schema requires but flat-
266
+ // layout dobby.yaml typically omits. Existing values are kept.
267
+ if (!('version' in out))
268
+ out.version = '1.0.0';
269
+ if (!('targets' in out))
270
+ out.targets = ['copilot'];
271
+ }
272
+ }
273
+ // Top-level `mcpServers:` (legacy dobby flat layout, kept for
274
+ // safety in case flat hoisting was already done by an earlier pass).
275
+ if ('mcpServers' in out) {
276
+ logAliasDeprecation('mcpServers', 'mcp', sourcePath);
277
+ if (!('mcp' in out))
278
+ out.mcp = out.mcpServers;
279
+ delete out.mcpServers;
280
+ }
281
+ // Inside `contents:` (canonical nested layout, or freshly-hoisted
282
+ // flat layout): mcpServers -> mcp, walk repos for sparseCheckout
283
+ // -> paths.
284
+ if (typeof out.contents === 'object' &&
285
+ out.contents !== null &&
286
+ !Array.isArray(out.contents)) {
287
+ const contents = { ...out.contents };
288
+ if ('mcpServers' in contents) {
289
+ logAliasDeprecation('mcpServers', 'mcp', sourcePath);
290
+ if (!('mcp' in contents))
291
+ contents.mcp = contents.mcpServers;
292
+ delete contents.mcpServers;
293
+ }
294
+ if (Array.isArray(contents.repos)) {
295
+ contents.repos = contents.repos.map((entry) => normalizeRepoEntryAliases(entry, sourcePath));
296
+ }
297
+ out.contents = contents;
298
+ }
299
+ // Top-level `repos:` (e.g. canonical layout where repos: is at root,
300
+ // or a flat layout that was NOT hoisted because contents: was already
301
+ // present). Apply sparseCheckout normalization in place.
302
+ if (Array.isArray(out.repos)) {
303
+ out.repos = out.repos.map((entry) => normalizeRepoEntryAliases(entry, sourcePath));
304
+ }
305
+ return out;
306
+ }
307
+ function normalizeRepoEntryAliases(entry, sourcePath) {
308
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
309
+ return entry;
310
+ }
311
+ const r = { ...entry };
312
+ if ('sparseCheckout' in r) {
313
+ logAliasDeprecation('sparseCheckout', 'paths', sourcePath);
314
+ if (!('paths' in r))
315
+ r.paths = r.sparseCheckout;
316
+ if (!('sparse' in r) && r.sparseCheckout != null)
317
+ r.sparse = true;
318
+ delete r.sparseCheckout;
319
+ }
320
+ return r;
321
+ }
322
+ function logAliasDeprecation(aliasName, canonicalName, sourcePath) {
323
+ if (ALIAS_DEPRECATION_LOGGED.has(aliasName))
324
+ return;
325
+ ALIAS_DEPRECATION_LOGGED.add(aliasName);
326
+ const where = sourcePath ? ` (manifest: ${sourcePath})` : '';
327
+ if (aliasName === 'flat-layout') {
328
+ console.warn(`[loom] Top-level resources without a 'contents:' wrapper are deprecated; ` +
329
+ `nest 'instructions:', 'skills:', 'agents:', 'mcp:', 'repos:', 'prompts:' ` +
330
+ `under 'contents:' instead.${where}`);
331
+ return;
332
+ }
333
+ console.warn(`[loom] Field '${aliasName}' is deprecated; use '${canonicalName}' instead.${where}`);
334
+ }
335
+ /**
336
+ * One-time deprecation log dedup for the dobby.yaml filename. Tests
337
+ * can clear it via `_resetAliasDeprecationsForTest`.
338
+ */
339
+ function logFilenameDeprecation(dir) {
340
+ if (ALIAS_DEPRECATION_LOGGED.has('dobby.yaml-filename'))
341
+ return;
342
+ ALIAS_DEPRECATION_LOGGED.add('dobby.yaml-filename');
343
+ console.warn(`[loom] Found dobby.yaml in ${dir}; this filename is supported as an alias ` +
344
+ `but the canonical filename is 'manifest.yaml'. Rename to silence this warning.`);
345
+ }
346
+ /**
347
+ * Test-only: clear the once-per-session dedup so subsequent calls log
348
+ * again. Production code should never need to call this.
31
349
  */
32
- export async function resolveManifest(manifest, templateDir, registryRoot) {
350
+ export function _resetAliasDeprecationsForTest() {
351
+ ALIAS_DEPRECATION_LOGGED.clear();
352
+ }
353
+ export async function resolveManifest(manifest, templateDir, registryRoot, options = {}) {
354
+ const legacyAgents = await readLegacyAgentManifests(templateDir);
355
+ const selectedLegacyAgents = scopeLegacyAgents(legacyAgents, options.agents);
356
+ const contents = mergeLegacyAgentContents(manifest.contents, selectedLegacyAgents);
357
+ const prerequisiteEntries = deduplicateEntries([
358
+ ...(manifest.prerequisites ?? []),
359
+ ...selectedLegacyAgents.flatMap((a) => a.manifest.prerequisites ?? []),
360
+ ], prereqEntryKey);
33
361
  const [instructions, skills, agents, mcp, repos, prerequisites, prompts] = await Promise.all([
34
- resolveInstructions(manifest.contents.instructions ?? [], templateDir, registryRoot),
35
- resolveSkills(manifest.contents.skills ?? [], templateDir),
36
- resolveAgents(manifest.contents.agents ?? [], templateDir),
37
- resolveMcp(manifest.contents.mcp ?? [], registryRoot),
38
- resolveRepos(manifest.contents.repos ?? [], registryRoot),
39
- resolvePrerequisites(manifest.prerequisites ?? [], registryRoot),
40
- resolvePrompts(manifest.contents.prompts ?? [], templateDir, registryRoot),
362
+ resolveInstructions(contents.instructions ?? [], templateDir, registryRoot),
363
+ resolveSkillEntries(contents.skills ?? [], templateDir),
364
+ resolveAgentEntries(contents.agents ?? [], templateDir, () => { }, registryRoot),
365
+ resolveMcpEntries(contents.mcp ?? [], registryRoot),
366
+ resolveRepos(contents.repos ?? [], registryRoot),
367
+ resolvePrerequisites(prerequisiteEntries, registryRoot),
368
+ resolvePrompts(contents.prompts ?? [], templateDir, registryRoot),
41
369
  ]);
42
370
  return {
43
371
  version: manifest.version,
@@ -52,56 +380,286 @@ export async function resolveManifest(manifest, templateDir, registryRoot) {
52
380
  prerequisites,
53
381
  };
54
382
  }
383
+ function scopeLegacyAgents(legacyAgents, selectedAgents) {
384
+ const wanted = new Set((selectedAgents ?? []).filter(Boolean));
385
+ if (wanted.size === 0)
386
+ return legacyAgents;
387
+ return legacyAgents.filter((source) => {
388
+ const agentId = source.manifest.agent?.id;
389
+ return wanted.has(source.dir) || (agentId !== undefined && wanted.has(agentId));
390
+ });
391
+ }
392
+ async function readLegacyAgentManifests(templateDir) {
393
+ const agentsDir = join(templateDir, 'agents');
394
+ let entries;
395
+ try {
396
+ entries = (await readdir(agentsDir, { withFileTypes: true }));
397
+ }
398
+ catch {
399
+ return [];
400
+ }
401
+ const result = [];
402
+ for (const entry of entries.filter((e) => e.isDirectory()).sort((a, b) => String(a.name).localeCompare(String(b.name)))) {
403
+ const dir = String(entry.name);
404
+ const manifestPath = join(agentsDir, dir, 'manifest.yaml');
405
+ try {
406
+ const s = await stat(manifestPath);
407
+ if (!s.isFile())
408
+ continue;
409
+ const parsed = yaml.load(await readFile(manifestPath, 'utf-8'));
410
+ if (parsed?.agent)
411
+ result.push({ dir, manifest: parsed });
412
+ }
413
+ catch {
414
+ continue;
415
+ }
416
+ }
417
+ return result;
418
+ }
419
+ async function readLegacyAgentManifestFromDir(agentDir) {
420
+ const manifestPath = join(agentDir, 'manifest.yaml');
421
+ try {
422
+ const s = await stat(manifestPath);
423
+ if (!s.isFile())
424
+ return undefined;
425
+ const parsed = yaml.load(await readFile(manifestPath, 'utf-8'));
426
+ return parsed?.agent ? parsed : undefined;
427
+ }
428
+ catch {
429
+ return undefined;
430
+ }
431
+ }
432
+ function mergeLegacyAgentContents(contents, legacyAgents) {
433
+ if (legacyAgents.length === 0)
434
+ return contents;
435
+ const agentContents = legacyAgents.map((a) => a.manifest.contents ?? {});
436
+ const agentEntries = contents.agents ?? [{ discover: ['./agents'] }];
437
+ return {
438
+ ...contents,
439
+ instructions: [
440
+ ...(contents.instructions ?? []),
441
+ ...agentContents.flatMap((c) => c.instructions ?? []),
442
+ ],
443
+ skills: deduplicateEntries([
444
+ ...(contents.skills ?? []),
445
+ ...agentContents.flatMap((c) => c.skills ?? []),
446
+ ], skillEntryKey),
447
+ agents: agentEntries,
448
+ mcp: deduplicateEntries([
449
+ ...(contents.mcp ?? []),
450
+ ...agentContents.flatMap((c) => c.mcp ?? []),
451
+ ], mcpEntryKey),
452
+ repos: deduplicateEntries([
453
+ ...(contents.repos ?? []),
454
+ ...agentContents.flatMap((c) => c.repos ?? []),
455
+ ], repoEntryKey),
456
+ prompts: [
457
+ ...(contents.prompts ?? []),
458
+ ...agentContents.flatMap((c) => c.prompts ?? []),
459
+ ],
460
+ };
461
+ }
55
462
  // =============================================================================
56
463
  // Internal resolvers
57
464
  // =============================================================================
58
465
  async function resolveInstructions(entries, templateDir, registryRoot) {
59
- return Promise.all(entries.map(async (entry) => {
60
- if (isSharedRef(entry)) {
61
- const filePath = resolve(registryRoot, entry.$ref);
62
- const content = await readFile(filePath, 'utf-8');
63
- return { scope: entry.scope, content, sourcePath: entry.$ref };
466
+ // Per U6: instruction entries are resolved with best-effort
467
+ // semantics: missing `scope` defaults to '**' (was: dobby adapter
468
+ // hardcoded this for its catalog format) and missing files emit a
469
+ // warning instead of erroring. The strict reference check still
470
+ // lives in validate.ts for `loom validate` -- apply's job is to
471
+ // produce a usable workspace from the manifest it was given, even
472
+ // if a few files have rotted.
473
+ const resolved = await Promise.all(entries.map(async (entry) => {
474
+ const scope = (entry.scope ?? '**');
475
+ try {
476
+ if (isSharedRef(entry)) {
477
+ const filePath = await safeJoinUnder(registryRoot, entry.$ref);
478
+ const content = await readFile(filePath, 'utf-8');
479
+ return { scope, content, sourcePath: entry.$ref };
480
+ }
481
+ if (isLocalFileRef(entry)) {
482
+ const filePath = await safeJoinUnder(templateDir, entry.file);
483
+ const content = await readFile(filePath, 'utf-8');
484
+ return { scope, content, sourcePath: entry.file };
485
+ }
486
+ throw new Error(`Unknown instruction entry type: ${JSON.stringify(entry)}`);
64
487
  }
65
- if (isLocalFileRef(entry)) {
66
- const filePath = resolve(templateDir, entry.file);
67
- const content = await readFile(filePath, 'utf-8');
68
- return { scope: entry.scope, content, sourcePath: entry.file };
488
+ catch (err) {
489
+ if (err.code === 'ENOENT') {
490
+ const refLabel = isSharedRef(entry)
491
+ ? entry.$ref
492
+ : isLocalFileRef(entry)
493
+ ? entry.file
494
+ : JSON.stringify(entry);
495
+ console.warn(`[loom] Skipping missing instruction file: ${refLabel}`);
496
+ return null;
497
+ }
498
+ throw err;
69
499
  }
70
- throw new Error(`Unknown instruction entry type: ${JSON.stringify(entry)}`);
71
500
  }));
501
+ return resolved.filter((r) => r !== null);
72
502
  }
73
- async function resolveSkills(entries, templateDir) {
74
- return Promise.all(entries.map(async (entry) => {
503
+ /**
504
+ * Resolve a list of skill entries (registry / file / discover) into
505
+ * `ResolvedSkill[]`. Exposed so adapters (e.g. the dobby adapter) can
506
+ * pump entries through the same code path used by `resolveManifest`
507
+ * rather than re-implementing skill discovery.
508
+ *
509
+ * Each entry expands to zero, one, or many ResolvedSkill values:
510
+ * - `registry:` -> 1 entry (registry coordinates only, no content)
511
+ * - `file:` -> 1 entry (SKILL.md content + extra files)
512
+ * - `discover:` -> N entries (one per immediate subdirectory of each
513
+ * listed dir that contains a SKILL.md). Within a discover entry,
514
+ * later paths override earlier ones on duplicate skill names
515
+ * (preserving "team skills override shared skills" semantics).
516
+ *
517
+ * No cross-entry deduplication is performed -- callers that need it
518
+ * apply their own pass after concatenating the result.
519
+ */
520
+ export async function resolveSkillEntries(entries, templateDir, log = () => { }) {
521
+ const resolved = [];
522
+ for (const entry of entries) {
75
523
  if (isRegistrySkillRef(entry)) {
76
- return {
77
- type: 'registry',
524
+ resolved.push({
78
525
  registry: entry.registry,
79
526
  ref: entry.ref,
80
- };
527
+ name: entry.ref,
528
+ });
529
+ continue;
530
+ }
531
+ if (isDiscoverSkillsRef(entry)) {
532
+ const discovered = await discoverSkillsFromDirs(entry.discover, templateDir, log);
533
+ resolved.push(...discovered);
534
+ continue;
81
535
  }
82
536
  if (isLocalFileRef(entry)) {
83
- const filePath = resolve(templateDir, entry.file);
537
+ const filePath = await safeJoinUnder(templateDir, entry.file);
84
538
  const content = await readFile(filePath, 'utf-8');
85
- const name = parseFrontmatterField(content, 'name');
539
+ const frontmatterName = parseFrontmatterField(content, 'name');
540
+ const fallbackName = basename(filePath).toLowerCase() === 'skill.md'
541
+ ? basename(dirname(filePath))
542
+ : basename(filePath, extname(filePath)).replace(/\.skill$/, '');
543
+ const name = assertSafePathSegment('skill', frontmatterName === undefined ? fallbackName : frontmatterName, entry.file);
86
544
  const skillDir = dirname(filePath);
87
545
  const extraFiles = await collectExtraFiles(skillDir, filePath);
88
- return {
89
- type: 'local',
90
- name: name ?? entry.name,
546
+ resolved.push({
547
+ name,
91
548
  content,
92
549
  sourcePath: entry.file,
93
550
  extraFiles: extraFiles.length > 0 ? extraFiles : undefined,
94
- };
551
+ });
552
+ continue;
95
553
  }
96
554
  throw new Error(`Unknown skill entry type: ${JSON.stringify(entry)}`);
97
- }));
555
+ }
556
+ return resolved;
557
+ }
558
+ /**
559
+ * Walk each listed directory looking for `<name>/SKILL.md` and return a
560
+ * ResolvedSkill[] with content + extra files. Within `dirs`, later
561
+ * paths override earlier ones on duplicate skill names -- so caller
562
+ * order encodes precedence (e.g. `[shared, team]` -> team wins).
563
+ *
564
+ * Relative paths are resolved against `baseDir`; absolute paths are
565
+ * used as-is. Missing directories are silently skipped (matches dobby's
566
+ * "best effort" discovery).
567
+ */
568
+ async function discoverSkillsFromDirs(dirs, baseDir, log) {
569
+ const byName = new Map();
570
+ for (const rawDir of dirs) {
571
+ let skillsRoot;
572
+ if (isAbsolute(rawDir)) {
573
+ skillsRoot = rawDir;
574
+ }
575
+ else {
576
+ try {
577
+ skillsRoot = await safeJoinUnder(baseDir, rawDir);
578
+ }
579
+ catch (err) {
580
+ log(` Warning: skipping skill discover dir: ${err.message}`);
581
+ continue;
582
+ }
583
+ }
584
+ let entries;
585
+ try {
586
+ entries = (await readdir(skillsRoot, { withFileTypes: true }));
587
+ }
588
+ catch {
589
+ continue; // Directory doesn't exist -- that's fine, just skip
590
+ }
591
+ for (const entry of entries) {
592
+ if (!entry.isDirectory())
593
+ continue;
594
+ const skillDir = join(skillsRoot, String(entry.name));
595
+ const skillMdPath = join(skillDir, 'SKILL.md');
596
+ try {
597
+ const s = await stat(skillMdPath);
598
+ if (!s.isFile())
599
+ continue;
600
+ }
601
+ catch {
602
+ continue; // No SKILL.md -- not a skill directory
603
+ }
604
+ try {
605
+ const content = await readFile(skillMdPath, 'utf-8');
606
+ const frontmatter = parseFrontmatterRaw(content);
607
+ const name = typeof frontmatter.name === 'string' ? frontmatter.name : String(entry.name);
608
+ if (!isSafePathSegment(name)) {
609
+ log(` Warning: skipping skill ${entry.name}: ${unsafePathSegmentMessage('skill', name, skillMdPath)}`);
610
+ continue;
611
+ }
612
+ const extraFiles = await collectExtraFiles(skillDir, skillMdPath);
613
+ // Last-write-wins on duplicate names: later dirs override earlier.
614
+ byName.set(name, {
615
+ name,
616
+ content,
617
+ sourcePath: skillMdPath,
618
+ extraFiles: extraFiles.length > 0 ? extraFiles : undefined,
619
+ });
620
+ }
621
+ catch (err) {
622
+ log(` Warning: could not read skill ${entry.name}: ${err.message}`);
623
+ }
624
+ }
625
+ }
626
+ return Array.from(byName.values());
98
627
  }
628
+ // File extensions that are safe to read as UTF-8 text. Everything else
629
+ // is read as a Buffer so binary assets (.png, .ico, .zip, ...) survive
630
+ // the resolver intact. Recovered from PR #134 cycle 3.
631
+ const TEXT_EXTENSIONS = new Set([
632
+ '.md', '.markdown', '.txt', '.rst', '.json', '.yaml', '.yml',
633
+ '.toml', '.ini', '.cfg', '.conf', '.csv', '.tsv', '.log',
634
+ '.html', '.xml', '.xsd', '.js', '.mjs', '.cjs', '.ts', '.tsx',
635
+ '.jsx', '.py', '.rb', '.go', '.rs', '.c', '.cc', '.cpp', '.h',
636
+ '.hpp', '.cs', '.java', '.kt', '.swift', '.sh', '.bash', '.zsh',
637
+ '.fish', '.ps1', '.psm1', '.bat', '.cmd', '.sql',
638
+ ]);
639
+ // Bound traversal of pathological registries (symlink loops would be
640
+ // caught by `Dirent#isSymbolicLink()` below, but a deeply-nested or
641
+ // fan-out tree would still consume unbounded memory without these caps).
642
+ const MAX_EXTRA_FILES = 200;
643
+ const MAX_EXTRA_DEPTH = 8;
99
644
  /**
100
- * Recursively collect all files in a skill directory except the main SKILL.md.
645
+ * Recursively collect files in a skill directory except the main SKILL.md.
646
+ *
647
+ * Hardening (recovered from PR #134 cycle 3):
648
+ * - Symlinks (file or directory) are skipped. Following them would let
649
+ * a malicious skill point at e.g. `id_rsa -> ~/.ssh/id_rsa` and
650
+ * silently pull the key into the rendered workspace.
651
+ * - Non-text extensions (.png, .ico, .zip, etc.) are read as binary
652
+ * Buffer to avoid lossy UTF-8 decode of binary bytes.
653
+ * - Per-file readFile is wrapped in try/catch so one EACCES/EISDIR
654
+ * aborts only that entry, not the whole skill resolution.
655
+ * - Depth cap (8) and per-skill file cap (200) prevent runaway
656
+ * traversal of pathological registries.
101
657
  */
102
- async function collectExtraFiles(dir, mainFile, baseDir) {
658
+ async function collectExtraFiles(dir, mainFile, baseDir, depthLeft = MAX_EXTRA_DEPTH, countSoFar = { value: 0 }) {
103
659
  baseDir = baseDir ?? dir;
104
660
  const results = [];
661
+ if (depthLeft < 0)
662
+ return results;
105
663
  let dirEntries;
106
664
  try {
107
665
  dirEntries = await readdir(dir, { withFileTypes: true });
@@ -110,42 +668,345 @@ async function collectExtraFiles(dir, mainFile, baseDir) {
110
668
  return results;
111
669
  }
112
670
  for (const entry of dirEntries) {
671
+ if (countSoFar.value >= MAX_EXTRA_FILES)
672
+ break;
673
+ // Skip symlinks defensively. Dirent#isSymbolicLink() reflects lstat
674
+ // semantics (Node uses lstat to populate Dirent), so a symlinked
675
+ // file or symlinked directory is detected and skipped here.
676
+ if (entry.isSymbolicLink())
677
+ continue;
113
678
  const fullPath = join(dir, String(entry.name));
114
679
  if (entry.isDirectory()) {
115
- results.push(...(await collectExtraFiles(fullPath, mainFile, baseDir)));
680
+ results.push(...(await collectExtraFiles(fullPath, mainFile, baseDir, depthLeft - 1, countSoFar)));
681
+ }
682
+ else if (entry.isFile() && fullPath !== mainFile) {
683
+ try {
684
+ const ext = (entry.name.match(/\.[^.]+$/)?.[0] ?? '').toLowerCase();
685
+ const isText = TEXT_EXTENSIONS.has(ext);
686
+ const content = isText
687
+ ? await readFile(fullPath, 'utf-8')
688
+ : await readFile(fullPath); // Buffer; renderer writes raw
689
+ results.push({
690
+ relativePath: relative(baseDir, fullPath).replace(/\\/g, '/'),
691
+ content: content,
692
+ });
693
+ countSoFar.value++;
694
+ }
695
+ catch {
696
+ // EACCES / EISDIR / permission errors: skip the file but
697
+ // continue walking. One bad file shouldn't tank the whole
698
+ // skill resolution. (Pre-cycle-3 behavior aborted the resolve here.)
699
+ continue;
700
+ }
701
+ }
702
+ }
703
+ return results;
704
+ }
705
+ /**
706
+ * Resolve a list of agent entries (file: or discover:) into
707
+ * `ResolvedAgent[]`. Exposed so adapters (e.g. the dobby adapter) can
708
+ * pump entries through the same code path used by `resolveManifest`
709
+ * rather than re-implementing agent loading + frontmatter parsing.
710
+ *
711
+ * Each entry expands to zero, one, or many ResolvedAgent values:
712
+ * - `file:` -> 1 entry (read agent.md, parse frontmatter for ALL
713
+ * metadata: name, description, model, tools, mcp-servers).
714
+ * - `discover:` -> N entries (one per immediate subdir containing
715
+ * an `*.agent.md`). Within a discover entry, later paths
716
+ * override earlier ones on duplicate agent ids.
717
+ *
718
+ * Per U4, the .agent.md frontmatter is the only source of agent
719
+ * metadata. Manifest entries declare routing (the file path) only;
720
+ * model/tools overrides at the entry level are not honored. Per U4.5,
721
+ * the same applies to per-agent MCPs: an agent file's frontmatter
722
+ * `mcp-servers:` block is the single source of per-agent MCP
723
+ * declarations. The block is a map keyed by server id; each value
724
+ * carries the same fields as a top-level inline MCP entry (type,
725
+ * command, args, url, env, binary, ...).
726
+ *
727
+ * Across the entries array, later entries override earlier ones on
728
+ * duplicate agent ids -- so caller order encodes precedence
729
+ * (consistent with U2's skill-discovery rule).
730
+ */
731
+ export async function resolveAgentEntries(entries, templateDir, log = () => { }, registryRoot) {
732
+ const mcpRoot = registryRoot ?? templateDir;
733
+ const byId = new Map();
734
+ for (const entry of entries) {
735
+ if (isDiscoverAgentRef(entry)) {
736
+ const discovered = await discoverAgentsFromDirs(entry.discover, templateDir, log, mcpRoot);
737
+ for (const a of discovered)
738
+ byId.set(a.agentId, a);
739
+ continue;
116
740
  }
117
- else if (fullPath !== mainFile) {
118
- const content = await readFile(fullPath, 'utf-8');
119
- results.push({
120
- relativePath: relative(baseDir, fullPath).replace(/\\/g, '/'),
741
+ if (isLocalFileRef(entry)) {
742
+ const filePath = await safeJoinUnder(templateDir, entry.file);
743
+ const raw = await readFile(filePath, 'utf-8');
744
+ const fm = parseFrontmatterRaw(raw);
745
+ const legacy = await readLegacyAgentManifestFromDir(dirname(filePath));
746
+ const legacyAgent = legacy?.agent;
747
+ const agentId = assertSafePathSegment('agent id', deriveAgentIdFromSourcePath(entry.file), entry.file);
748
+ const name = assertSafePathSegment('agent', (typeof fm.name === 'string' ? fm.name : undefined) ?? legacyAgent?.name ?? agentId, entry.file);
749
+ const description = (typeof fm.description === 'string' ? fm.description : undefined) ?? legacyAgent?.description ?? '';
750
+ const content = stripFrontmatter(raw);
751
+ const sourcePath = basename(filePath) === 'agent.md'
752
+ ? join(dirname(filePath), `${agentId}.agent.md`)
753
+ : filePath;
754
+ // Per U4: all metadata comes from frontmatter. No entry-level
755
+ // model/tools overlay -- the .agent.md file is the single source
756
+ // of truth. Dobby's `model:` is canonically a YAML array; when
757
+ // we see one, take the first string element and warn loudly so
758
+ // the author knows loom requires a single string.
759
+ const model = modelFromFrontmatter(fm.model, `agent ${entry.file}`, log) ?? legacyAgent?.model;
760
+ const tools = toolsFromFrontmatter(fm.tools) ?? legacyAgent?.tools;
761
+ // Per U4.5: per-agent MCPs come from the `mcp-servers:` block in
762
+ // the agent file's frontmatter (replacing the legacy
763
+ // `agents/<id>/manifest.yaml` sibling YAML).
764
+ const mcp = await resolveAgentFrontmatterMcps(fm, mcpRoot, log);
765
+ byId.set(agentId, {
766
+ agentId,
767
+ name,
768
+ description,
769
+ model,
770
+ tools,
121
771
  content,
772
+ sourcePath,
773
+ mcp,
122
774
  });
775
+ continue;
123
776
  }
777
+ throw new Error(`Unknown agent entry type: ${JSON.stringify(entry)}`);
124
778
  }
125
- return results;
779
+ return Array.from(byId.values());
126
780
  }
127
- async function resolveAgents(entries, templateDir) {
128
- return Promise.all(entries.map(async (entry) => {
129
- const filePath = resolve(templateDir, entry.file);
130
- const raw = await readFile(filePath, 'utf-8');
131
- const name = parseFrontmatterField(raw, 'name') ?? '';
132
- const description = parseFrontmatterField(raw, 'description') ?? '';
133
- // Strip frontmatter from content
134
- const content = stripFrontmatter(raw);
135
- return {
136
- name,
137
- description,
138
- model: entry.model,
139
- tools: entry.tools ?? [],
140
- content,
141
- sourcePath: entry.file,
781
+ function modelFromFrontmatter(value, label, log) {
782
+ if (typeof value === 'string')
783
+ return value;
784
+ if (Array.isArray(value) && typeof value[0] === 'string') {
785
+ log(` Warning: ${label}: \`model:\` is an array; using first element "${value[0]}". Loom requires a single string.`);
786
+ return value[0];
787
+ }
788
+ return undefined;
789
+ }
790
+ function toolsFromFrontmatter(value) {
791
+ return Array.isArray(value) ? value.map(String) : undefined;
792
+ }
793
+ /**
794
+ * Walk each listed directory looking for `<id>/<id>.agent.md` (dobby
795
+ * convention), `<id>/<basename>.agent.md` (loom convention), or legacy
796
+ * `<id>/agent.md` with sibling manifest metadata, and return a
797
+ * ResolvedAgent[] with parsed metadata.
798
+ *
799
+ * Within `dirs`, later paths override earlier ones on duplicate agent
800
+ * ids -- so caller order encodes precedence (consistent with U2). The
801
+ * subdir name is the canonical agent id; multiple `.agent.md` files in
802
+ * one dir are ambiguous and trigger a warning (with deterministic
803
+ * fallback to `<id>.agent.md` if present, else first alphabetically).
804
+ *
805
+ * Relative paths are resolved against `baseDir`; absolute paths are
806
+ * used as-is. Missing directories are silently skipped.
807
+ *
808
+ * Per U4.5, per-agent MCPs are read from the agent file's frontmatter
809
+ * `mcp-servers:` block. Legacy sibling `manifest.yaml` files are still
810
+ * accepted as a deprecated metadata/content fallback during migration.
811
+ */
812
+ async function discoverAgentsFromDirs(dirs, baseDir, log, registryRoot) {
813
+ const byId = new Map();
814
+ for (const rawDir of dirs) {
815
+ let agentsRoot;
816
+ if (isAbsolute(rawDir)) {
817
+ agentsRoot = rawDir;
818
+ }
819
+ else {
820
+ try {
821
+ agentsRoot = await safeJoinUnder(baseDir, rawDir);
822
+ }
823
+ catch (err) {
824
+ log(` Warning: skipping agent discover dir: ${err.message}`);
825
+ continue;
826
+ }
827
+ }
828
+ let entries;
829
+ try {
830
+ entries = (await readdir(agentsRoot, { withFileTypes: true }));
831
+ }
832
+ catch {
833
+ continue; // Directory doesn't exist -- that's fine, just skip
834
+ }
835
+ for (const entry of entries) {
836
+ if (!entry.isDirectory())
837
+ continue;
838
+ const id = String(entry.name);
839
+ if (!isSafePathSegment(id)) {
840
+ log(` Warning: skipping agent ${id}: ${unsafePathSegmentMessage('agent id', id, agentsRoot)}`);
841
+ continue;
842
+ }
843
+ const agentDir = join(agentsRoot, id);
844
+ const agentMdPath = await findAgentFile(agentDir, id, log);
845
+ if (!agentMdPath)
846
+ continue;
847
+ try {
848
+ const raw = await readFile(agentMdPath, 'utf-8');
849
+ const fm = parseFrontmatterRaw(raw);
850
+ const legacy = await readLegacyAgentManifestFromDir(agentDir);
851
+ const legacyAgent = legacy?.agent;
852
+ const name = (typeof fm.name === 'string' ? fm.name : undefined) ?? legacyAgent?.name ?? id;
853
+ if (!isSafePathSegment(name)) {
854
+ log(` Warning: skipping agent ${id}: ${unsafePathSegmentMessage('agent', name, agentMdPath)}`);
855
+ continue;
856
+ }
857
+ const description = (typeof fm.description === 'string' ? fm.description : undefined) ?? legacyAgent?.description ?? '';
858
+ const model = modelFromFrontmatter(fm.model, `agent ${id}`, log) ?? legacyAgent?.model;
859
+ const tools = toolsFromFrontmatter(fm.tools) ?? legacyAgent?.tools;
860
+ const content = stripFrontmatter(raw);
861
+ const mcp = await resolveAgentFrontmatterMcps(fm, registryRoot, log);
862
+ const sourcePath = basename(agentMdPath) === 'agent.md'
863
+ ? join(agentDir, `${id}.agent.md`)
864
+ : agentMdPath;
865
+ // Last-write-wins on duplicate ids: later dirs override earlier.
866
+ byId.set(id, {
867
+ agentId: id,
868
+ name,
869
+ description,
870
+ model,
871
+ tools,
872
+ content,
873
+ sourcePath,
874
+ mcp,
875
+ });
876
+ }
877
+ catch (err) {
878
+ log(` Warning: could not read agent ${id}: ${err.message}`);
879
+ }
880
+ }
881
+ }
882
+ return Array.from(byId.values());
883
+ }
884
+ /**
885
+ * Convert an agent file's frontmatter `mcp-servers:` block (a map keyed
886
+ * by server id) into a `ResolvedMcp[]`. The block uses the same value
887
+ * shape as a top-level inline MCP entry (type, command, args, url,
888
+ * env, binary, ...); the map key supplies the `id`. Returns
889
+ * `undefined` if the block is missing, empty, or malformed -- matching
890
+ * "no per-agent MCPs declared" semantics.
891
+ *
892
+ * Inline-only: `$ref` is not supported inside frontmatter. Per-agent
893
+ * MCPs that need to live in shared/ should be declared at team level
894
+ * via the manifest's `mcp:` block instead.
895
+ */
896
+ async function resolveAgentFrontmatterMcps(fm, registryRoot, log) {
897
+ const block = fm['mcp-servers'];
898
+ if (!block || typeof block !== 'object' || Array.isArray(block))
899
+ return undefined;
900
+ const raw = block;
901
+ const mcpEntries = [];
902
+ for (const [id, value] of Object.entries(raw)) {
903
+ if (!value || typeof value !== 'object' || Array.isArray(value))
904
+ continue;
905
+ const def = value;
906
+ const type = def.type === 'http' ? 'http' : 'stdio';
907
+ const inline = {
908
+ id,
909
+ name: typeof def.name === 'string' ? def.name : undefined,
910
+ description: typeof def.description === 'string' ? def.description : undefined,
911
+ type,
912
+ command: typeof def.command === 'string' ? def.command : undefined,
913
+ args: Array.isArray(def.args) ? def.args.map(String) : undefined,
914
+ cwd: typeof def.cwd === 'string' ? def.cwd : undefined,
915
+ url: typeof def.url === 'string' ? def.url : undefined,
916
+ authType: typeof def.authType === 'string' ? def.authType : undefined,
917
+ headers: def.headers && typeof def.headers === 'object' && !Array.isArray(def.headers)
918
+ ? def.headers
919
+ : undefined,
920
+ env: def.env && typeof def.env === 'object' && !Array.isArray(def.env)
921
+ ? def.env
922
+ : undefined,
923
+ binary: def.binary && typeof def.binary === 'object' && !Array.isArray(def.binary)
924
+ ? def.binary
925
+ : undefined,
142
926
  };
143
- }));
927
+ mcpEntries.push(inline);
928
+ }
929
+ if (mcpEntries.length === 0)
930
+ return undefined;
931
+ return resolveMcpEntries(mcpEntries, registryRoot, log);
932
+ }
933
+ /**
934
+ * Find the agent markdown file inside an agent directory. Accepts both
935
+ * the dobby convention (`<id>.agent.md`), the loom convention (a single
936
+ * `*.agent.md` file with any basename), and legacy `agent.md` fallback.
937
+ * Multiple `.agent.md` candidates trigger a warning and a deterministic
938
+ * fallback.
939
+ */
940
+ async function findAgentFile(agentDir, id, log) {
941
+ let entries;
942
+ try {
943
+ entries = (await readdir(agentDir, { withFileTypes: true }));
944
+ }
945
+ catch {
946
+ return undefined;
947
+ }
948
+ const agentFiles = entries
949
+ .filter((e) => e.isFile() && String(e.name).endsWith('.agent.md'))
950
+ .map((e) => String(e.name))
951
+ .sort();
952
+ if (agentFiles.length === 0) {
953
+ const legacyAgentMd = join(agentDir, 'agent.md');
954
+ return (await fileExists(legacyAgentMd)) ? legacyAgentMd : undefined;
955
+ }
956
+ if (agentFiles.length === 1)
957
+ return join(agentDir, agentFiles[0]);
958
+ // Multiple candidates: prefer <id>.agent.md (no warning -- that's
959
+ // the dobby convention and the canonical pick). Otherwise warn and
960
+ // fall back to first alphabetically.
961
+ const idMatch = `${id}.agent.md`;
962
+ if (agentFiles.includes(idMatch)) {
963
+ return join(agentDir, idMatch);
964
+ }
965
+ log(` Warning: agent dir ${agentDir} has multiple .agent.md files ` +
966
+ `(${agentFiles.join(', ')}); using ${agentFiles[0]} alphabetically. ` +
967
+ `Rename one to ${idMatch} to make this deterministic.`);
968
+ return join(agentDir, agentFiles[0]);
144
969
  }
145
- async function resolveMcp(entries, registryRoot) {
970
+ /**
971
+ * Derive the stable agent identifier from a source file path. This
972
+ * is the slug callers use to look the agent up at launch time and
973
+ * the basename of the rendered `.agent.md` file. Two layouts:
974
+ *
975
+ * - Loom nested layout: `agents/<id>/agent.md` -> id = `<id>`
976
+ * - Dobby / flat layout: `<id>.agent.md` -> id = `<id>`
977
+ *
978
+ * The function picks the parent dir name when it looks like a
979
+ * loom-nested layout (parent dir is non-trivial and not `agents/`);
980
+ * otherwise it falls back to the basename without the `.agent.md`
981
+ * extension.
982
+ */
983
+ export function deriveAgentIdFromSourcePath(sourcePath) {
984
+ const stem = basename(sourcePath, '.agent.md').replace(/\.md$/, '');
985
+ const parentDir = basename(dirname(sourcePath));
986
+ if (parentDir && parentDir !== '.' && parentDir !== 'agents') {
987
+ return parentDir;
988
+ }
989
+ return stem;
990
+ }
991
+ /**
992
+ * Resolve a list of MCP entries (shared $ref or inline) into
993
+ * `ResolvedMcp[]`. Exposed for adapters that resolve per-agent MCP
994
+ * blocks separately from the team-level ones (loom#127).
995
+ *
996
+ * Inline entries with a `binary:` block (and no explicit `command`) are
997
+ * routed through the binary cache: on first use the asset is fetched
998
+ * from the GitHub release into ~/.loom/mcp-binaries/ and the absolute
999
+ * cache path becomes the resolved `command`. If resolution fails the
1000
+ * entry passes through with an undefined command and downstream
1001
+ * launch surfaces a clear error.
1002
+ *
1003
+ * Pure: reads YAML for $ref entries, no provenance tagging, no
1004
+ * dedup. The caller decides how to combine results.
1005
+ */
1006
+ export async function resolveMcpEntries(entries, registryRoot, log = () => { }) {
146
1007
  return Promise.all(entries.map(async (entry) => {
147
1008
  if (isSharedRef(entry)) {
148
- const filePath = resolve(registryRoot, entry.$ref);
1009
+ const filePath = await safeJoinUnder(registryRoot, entry.$ref);
149
1010
  const content = await readFile(filePath, 'utf-8');
150
1011
  const parsed = yaml.load(content);
151
1012
  return {
@@ -155,21 +1016,38 @@ async function resolveMcp(entries, registryRoot) {
155
1016
  type: parsed.type,
156
1017
  command: parsed.command,
157
1018
  args: parsed.args,
1019
+ cwd: parsed.cwd,
158
1020
  url: parsed.url,
159
1021
  authType: parsed.authType,
1022
+ // U6: forward static auth headers from shared MCP files so a
1023
+ // manifest referencing shared/mcp/foo.yaml with required
1024
+ // headers ends up with a working server definition.
1025
+ headers: parsed.headers,
160
1026
  env: parsed.env,
161
1027
  };
162
1028
  }
163
1029
  if (isMcpInlineDef(entry)) {
1030
+ let command = entry.command;
1031
+ // `binary:` resolution: download the release asset (or hit the
1032
+ // cache) and use the absolute cached path as the launch command.
1033
+ // Only fires when no explicit `command` was provided.
1034
+ if (entry.binary && !command) {
1035
+ const cached = await resolveBinaryToCache(entry.binary, log);
1036
+ if (cached) {
1037
+ command = cached.cachedPath;
1038
+ }
1039
+ }
164
1040
  return {
165
1041
  id: entry.id,
166
1042
  name: entry.name,
167
1043
  description: entry.description,
168
1044
  type: entry.type,
169
- command: entry.command,
1045
+ command,
170
1046
  args: entry.args,
1047
+ cwd: entry.cwd,
171
1048
  url: entry.url,
172
1049
  authType: entry.authType,
1050
+ headers: entry.headers,
173
1051
  env: entry.env,
174
1052
  };
175
1053
  }
@@ -179,7 +1057,7 @@ async function resolveMcp(entries, registryRoot) {
179
1057
  async function resolveRepos(entries, registryRoot) {
180
1058
  return Promise.all(entries.map(async (entry) => {
181
1059
  if (isSharedRef(entry)) {
182
- const filePath = resolve(registryRoot, entry.$ref);
1060
+ const filePath = await safeJoinUnder(registryRoot, entry.$ref);
183
1061
  const content = await readFile(filePath, 'utf-8');
184
1062
  const parsed = yaml.load(content);
185
1063
  return {
@@ -215,42 +1093,43 @@ async function resolvePrompts(entries, templateDir, registryRoot) {
215
1093
  let filePath;
216
1094
  let sourcePath;
217
1095
  if (isSharedRef(entry)) {
218
- filePath = resolve(registryRoot, entry.$ref);
1096
+ filePath = await safeJoinUnder(registryRoot, entry.$ref);
219
1097
  sourcePath = entry.$ref;
220
1098
  }
221
1099
  else if (isLocalFileRef(entry)) {
222
- filePath = resolve(templateDir, entry.file);
1100
+ filePath = await safeJoinUnder(templateDir, entry.file);
223
1101
  sourcePath = entry.file;
224
1102
  }
225
1103
  else {
226
1104
  throw new Error(`Unknown prompt entry type: ${JSON.stringify(entry)}`);
227
1105
  }
228
1106
  const raw = await readFile(filePath, 'utf-8');
229
- const name = isLocalFileRef(entry) && entry.name
230
- ? entry.name
231
- : parseFrontmatterField(raw, 'name')
232
- ?? basename(filePath, extname(filePath)).replace(/\.prompt$/, '');
233
- const description = isLocalFileRef(entry) && entry.description
234
- ? entry.description
235
- : parseFrontmatterField(raw, 'description');
236
- const localRef = isLocalFileRef(entry) ? entry : {};
1107
+ // Per U4: prompt metadata (name, description, agent, model,
1108
+ // tools) comes from the .prompt.md frontmatter only. The entry
1109
+ // declares routing (file path) and behavior flags (shared,
1110
+ // disableAutoInvoke) -- not metadata.
1111
+ const name = parseFrontmatterField(raw, 'name') ??
1112
+ basename(filePath, extname(filePath)).replace(/\.prompt$/, '');
1113
+ assertSafePathSegment('prompt', name, sourcePath);
1114
+ const description = parseFrontmatterField(raw, 'description');
1115
+ const localRef = isLocalFileRef(entry) ? entry : undefined;
237
1116
  return {
238
1117
  name,
239
1118
  description,
240
1119
  content: raw,
241
1120
  sourcePath,
242
- agent: localRef.agent ?? parseFrontmatterField(raw, 'agent'),
243
- model: localRef.model ?? parseFrontmatterField(raw, 'model'),
244
- tools: localRef.tools ?? parseFrontmatterArrayField(raw, 'tools'),
245
- shared: localRef.shared ?? false,
246
- disableAutoInvoke: localRef.disableAutoInvoke ?? false,
1121
+ agent: parseFrontmatterField(raw, 'agent'),
1122
+ model: parseFrontmatterField(raw, 'model'),
1123
+ tools: parseFrontmatterArrayField(raw, 'tools'),
1124
+ shared: localRef?.shared ?? false,
1125
+ disableAutoInvoke: localRef?.disableAutoInvoke ?? false,
247
1126
  };
248
1127
  }));
249
1128
  }
250
1129
  async function resolvePrerequisites(entries, registryRoot) {
251
1130
  return Promise.all(entries.map(async (entry) => {
252
1131
  if (isSharedRef(entry)) {
253
- const filePath = resolve(registryRoot, entry.$ref);
1132
+ const filePath = await safeJoinUnder(registryRoot, entry.$ref);
254
1133
  const content = await readFile(filePath, 'utf-8');
255
1134
  const parsed = yaml.load(content);
256
1135
  return {
@@ -267,120 +1146,15 @@ async function resolvePrerequisites(entries, registryRoot) {
267
1146
  return inline;
268
1147
  }));
269
1148
  }
270
- // =============================================================================
271
- // Nested agent support
272
- // =============================================================================
273
- /**
274
- * Parse an agent manifest.yaml file (per-agent within a team template).
275
- */
276
- export async function parseAgentManifest(manifestPath) {
277
- const content = await readFile(manifestPath, 'utf-8');
278
- const raw = yaml.load(content);
279
- return raw;
280
- }
281
- /**
282
- * Discover nested agent directories within a template.
283
- * Returns agent directory names if the template has an agents/ subdirectory
284
- * where each subdir contains a manifest.yaml.
285
- */
286
- export async function discoverNestedAgents(templateDir) {
287
- const { readdir, stat } = await import('node:fs/promises');
288
- const agentsDir = resolve(templateDir, 'agents');
289
- try {
290
- const entries = await readdir(agentsDir);
291
- const agentIds = [];
292
- for (const entry of entries) {
293
- const entryPath = resolve(agentsDir, entry);
294
- const manifestPath = resolve(entryPath, 'manifest.yaml');
295
- const s = await stat(entryPath).catch(() => null);
296
- if (s?.isDirectory()) {
297
- const hasManifest = await stat(manifestPath).then(() => true).catch(() => false);
298
- if (hasManifest) {
299
- agentIds.push(entry);
300
- }
301
- }
302
- }
303
- return agentIds.sort();
304
- }
305
- catch {
306
- return [];
307
- }
308
- }
309
- /**
310
- * Merge a team manifest with one or more agent manifests to produce
311
- * a combined manifest ready for resolving.
312
- *
313
- * Merge rules:
314
- * - repos: agent only (team repos are NOT included)
315
- * - mcp, instructions, skills, prompts, prerequisites: union (team + agent)
316
- * - agents: built from agent.md files in selected agents
317
- * - targets: inherited from team
318
- */
319
- export function mergeTeamAndAgentManifests(teamManifest, agentManifests, agentDirs) {
320
- // Build agent entries from the per-agent data
321
- const agentEntries = agentManifests.map((am, i) => ({
322
- file: `agents/${agentDirs[i]}/agent.md`,
323
- model: am.agent.model,
324
- tools: am.agent.tools,
325
- }));
326
- // Collect agent-specific MCP, instructions, skills, prompts, prerequisites
327
- const agentMcps = [];
328
- const agentInstructions = [];
329
- const agentSkills = [];
330
- const agentPrompts = [];
331
- const agentPrereqs = [];
332
- // Repos come ONLY from agents
333
- const agentRepos = [];
334
- for (const am of agentManifests) {
335
- if (am.contents?.mcp)
336
- agentMcps.push(...am.contents.mcp);
337
- if (am.contents?.instructions)
338
- agentInstructions.push(...am.contents.instructions);
339
- if (am.contents?.skills)
340
- agentSkills.push(...am.contents.skills);
341
- if (am.contents?.prompts)
342
- agentPrompts.push(...am.contents.prompts);
343
- if (am.contents?.repos)
344
- agentRepos.push(...am.contents.repos);
345
- if (am.prerequisites)
346
- agentPrereqs.push(...am.prerequisites);
347
- }
348
- // Deduplicate MCP by $ref value or id
349
- const teamMcps = teamManifest.contents.mcp ?? [];
350
- const allMcps = deduplicateEntries([...teamMcps, ...agentMcps], mcpEntryKey);
351
- // Deduplicate skills
352
- const teamSkills = teamManifest.contents.skills ?? [];
353
- const allSkills = deduplicateEntries([...teamSkills, ...agentSkills], skillEntryKey);
354
- // Deduplicate prerequisites
355
- const teamPrereqs = teamManifest.prerequisites ?? [];
356
- const allPrereqs = deduplicateEntries([...teamPrereqs, ...agentPrereqs], prereqEntryKey);
357
- // Deduplicate repos
358
- const allRepos = deduplicateEntries(agentRepos, repoEntryKey);
359
- return {
360
- version: teamManifest.version,
361
- template: teamManifest.template,
362
- targets: teamManifest.targets,
363
- contents: {
364
- instructions: [...(teamManifest.contents.instructions ?? []), ...agentInstructions],
365
- skills: allSkills,
366
- agents: agentEntries,
367
- mcp: allMcps,
368
- repos: allRepos,
369
- prompts: [...(teamManifest.contents.prompts ?? []), ...agentPrompts],
370
- },
371
- prerequisites: allPrereqs,
372
- };
373
- }
374
- // --- Deduplication helpers ---
375
1149
  function deduplicateEntries(entries, keyFn) {
376
1150
  const seen = new Set();
377
1151
  const result = [];
378
1152
  for (const entry of entries) {
379
1153
  const key = keyFn(entry);
380
- if (!seen.has(key)) {
381
- seen.add(key);
382
- result.push(entry);
383
- }
1154
+ if (seen.has(key))
1155
+ continue;
1156
+ seen.add(key);
1157
+ result.push(entry);
384
1158
  }
385
1159
  return result;
386
1160
  }
@@ -394,6 +1168,8 @@ function mcpEntryKey(entry) {
394
1168
  function skillEntryKey(entry) {
395
1169
  if (isRegistrySkillRef(entry))
396
1170
  return `registry:${entry.registry}:${entry.ref}`;
1171
+ if (isDiscoverSkillsRef(entry))
1172
+ return `discover:${entry.discover.join('|')}`;
397
1173
  if (isLocalFileRef(entry))
398
1174
  return `file:${entry.file}`;
399
1175
  return JSON.stringify(entry);
@@ -406,7 +1182,8 @@ function prereqEntryKey(entry) {
406
1182
  function repoEntryKey(entry) {
407
1183
  if (isSharedRef(entry))
408
1184
  return `ref:${entry.$ref}`;
409
- return `url:${entry.url}`;
1185
+ const inline = entry;
1186
+ return inline.id ? `id:${inline.id}` : `url:${inline.url ?? JSON.stringify(entry)}`;
410
1187
  }
411
1188
  // =============================================================================
412
1189
  // Frontmatter helpers
@@ -415,40 +1192,18 @@ function repoEntryKey(entry) {
415
1192
  * Parse a single field from YAML frontmatter (--- delimited).
416
1193
  */
417
1194
  function parseFrontmatterField(content, field) {
418
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
419
- if (!match)
420
- return undefined;
421
- try {
422
- const fm = yaml.load(match[1]);
423
- const value = fm[field];
424
- return typeof value === 'string' ? value : undefined;
425
- }
426
- catch {
427
- return undefined;
428
- }
1195
+ const fm = parseFrontmatterRaw(content);
1196
+ const value = fm[field];
1197
+ return typeof value === 'string' ? value : undefined;
429
1198
  }
430
1199
  /**
431
1200
  * Parse an array field from YAML frontmatter.
432
1201
  */
433
1202
  function parseFrontmatterArrayField(content, field) {
434
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
435
- if (!match)
436
- return undefined;
437
- try {
438
- const fm = yaml.load(match[1]);
439
- const value = fm[field];
440
- if (Array.isArray(value))
441
- return value.map(String);
442
- return undefined;
443
- }
444
- catch {
445
- return undefined;
446
- }
447
- }
448
- /**
449
- * Strip YAML frontmatter from content, returning only the body.
450
- */
451
- function stripFrontmatter(content) {
452
- return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n*/, '').trim();
1203
+ const fm = parseFrontmatterRaw(content);
1204
+ const value = fm[field];
1205
+ if (Array.isArray(value))
1206
+ return value.map(String);
1207
+ return undefined;
453
1208
  }
454
1209
  //# sourceMappingURL=manifest.js.map