@namzu/sdk 5.1.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +361 -0
  2. package/dist/agents/__tests__/run-agent-forwarding.test.d.ts +2 -0
  3. package/dist/agents/__tests__/run-agent-forwarding.test.d.ts.map +1 -0
  4. package/dist/agents/__tests__/run-agent-forwarding.test.js +76 -0
  5. package/dist/agents/__tests__/run-agent-forwarding.test.js.map +1 -0
  6. package/dist/agents/__tests__/run-completed-stop-reason.test.d.ts +2 -0
  7. package/dist/agents/__tests__/run-completed-stop-reason.test.d.ts.map +1 -0
  8. package/dist/agents/__tests__/run-completed-stop-reason.test.js +50 -0
  9. package/dist/agents/__tests__/run-completed-stop-reason.test.js.map +1 -0
  10. package/dist/agents/index.d.ts +1 -0
  11. package/dist/agents/index.d.ts.map +1 -1
  12. package/dist/agents/index.js +4 -0
  13. package/dist/agents/index.js.map +1 -1
  14. package/dist/agents/runAgent.d.ts +34 -0
  15. package/dist/agents/runAgent.d.ts.map +1 -1
  16. package/dist/agents/runAgent.js +5 -3
  17. package/dist/agents/runAgent.js.map +1 -1
  18. package/dist/config/runtime.d.ts +30 -30
  19. package/dist/constants/tools/__tests__/dangerous-patterns.test.d.ts +2 -0
  20. package/dist/constants/tools/__tests__/dangerous-patterns.test.d.ts.map +1 -0
  21. package/dist/constants/tools/__tests__/dangerous-patterns.test.js +54 -0
  22. package/dist/constants/tools/__tests__/dangerous-patterns.test.js.map +1 -0
  23. package/dist/constants/tools/index.d.ts.map +1 -1
  24. package/dist/constants/tools/index.js +28 -1
  25. package/dist/constants/tools/index.js.map +1 -1
  26. package/dist/contracts/a2a.d.ts +44 -44
  27. package/dist/directory/__tests__/derive.test.d.ts +2 -0
  28. package/dist/directory/__tests__/derive.test.d.ts.map +1 -0
  29. package/dist/directory/__tests__/derive.test.js +111 -0
  30. package/dist/directory/__tests__/derive.test.js.map +1 -0
  31. package/dist/directory/__tests__/load-diagnostics.test.d.ts +2 -0
  32. package/dist/directory/__tests__/load-diagnostics.test.d.ts.map +1 -0
  33. package/dist/directory/__tests__/load-diagnostics.test.js +106 -0
  34. package/dist/directory/__tests__/load-diagnostics.test.js.map +1 -0
  35. package/dist/directory/__tests__/load-directory.test.d.ts +2 -0
  36. package/dist/directory/__tests__/load-directory.test.d.ts.map +1 -0
  37. package/dist/directory/__tests__/load-directory.test.js +196 -0
  38. package/dist/directory/__tests__/load-directory.test.js.map +1 -0
  39. package/dist/directory/__tests__/subagents.test.d.ts +2 -0
  40. package/dist/directory/__tests__/subagents.test.d.ts.map +1 -0
  41. package/dist/directory/__tests__/subagents.test.js +117 -0
  42. package/dist/directory/__tests__/subagents.test.js.map +1 -0
  43. package/dist/directory/__tests__/supervisor-budget.test.d.ts +2 -0
  44. package/dist/directory/__tests__/supervisor-budget.test.d.ts.map +1 -0
  45. package/dist/directory/__tests__/supervisor-budget.test.js +97 -0
  46. package/dist/directory/__tests__/supervisor-budget.test.js.map +1 -0
  47. package/dist/directory/derive-supervisor.d.ts +58 -0
  48. package/dist/directory/derive-supervisor.d.ts.map +1 -0
  49. package/dist/directory/derive-supervisor.js +88 -0
  50. package/dist/directory/derive-supervisor.js.map +1 -0
  51. package/dist/directory/derive.d.ts +12 -0
  52. package/dist/directory/derive.d.ts.map +1 -0
  53. package/dist/directory/derive.js +58 -0
  54. package/dist/directory/derive.js.map +1 -0
  55. package/dist/directory/index.d.ts +7 -0
  56. package/dist/directory/index.d.ts.map +1 -0
  57. package/dist/directory/index.js +5 -0
  58. package/dist/directory/index.js.map +1 -0
  59. package/dist/directory/load.d.ts +14 -0
  60. package/dist/directory/load.d.ts.map +1 -0
  61. package/dist/directory/load.js +488 -0
  62. package/dist/directory/load.js.map +1 -0
  63. package/dist/directory/scan.d.ts +36 -0
  64. package/dist/directory/scan.d.ts.map +1 -0
  65. package/dist/directory/scan.js +136 -0
  66. package/dist/directory/scan.js.map +1 -0
  67. package/dist/directory/types.d.ts +215 -0
  68. package/dist/directory/types.d.ts.map +1 -0
  69. package/dist/directory/types.js +8 -0
  70. package/dist/directory/types.js.map +1 -0
  71. package/dist/plugin/__tests__/path-containment.test.d.ts +2 -0
  72. package/dist/plugin/__tests__/path-containment.test.d.ts.map +1 -0
  73. package/dist/plugin/__tests__/path-containment.test.js +91 -0
  74. package/dist/plugin/__tests__/path-containment.test.js.map +1 -0
  75. package/dist/plugin/lifecycle.d.ts.map +1 -1
  76. package/dist/plugin/lifecycle.js +3 -3
  77. package/dist/plugin/lifecycle.js.map +1 -1
  78. package/dist/plugin/loader.d.ts.map +1 -1
  79. package/dist/plugin/loader.js +12 -2
  80. package/dist/plugin/loader.js.map +1 -1
  81. package/dist/provider/__tests__/model-version.test.d.ts +2 -0
  82. package/dist/provider/__tests__/model-version.test.d.ts.map +1 -0
  83. package/dist/provider/__tests__/model-version.test.js +80 -0
  84. package/dist/provider/__tests__/model-version.test.js.map +1 -0
  85. package/dist/provider/__tests__/strict-schema.test.d.ts +2 -0
  86. package/dist/provider/__tests__/strict-schema.test.d.ts.map +1 -0
  87. package/dist/provider/__tests__/strict-schema.test.js +109 -0
  88. package/dist/provider/__tests__/strict-schema.test.js.map +1 -0
  89. package/dist/provider/model-version.d.ts +60 -0
  90. package/dist/provider/model-version.d.ts.map +1 -0
  91. package/dist/provider/model-version.js +64 -0
  92. package/dist/provider/model-version.js.map +1 -0
  93. package/dist/provider/strict-schema.d.ts +49 -0
  94. package/dist/provider/strict-schema.d.ts.map +1 -0
  95. package/dist/provider/strict-schema.js +112 -0
  96. package/dist/provider/strict-schema.js.map +1 -0
  97. package/dist/public-runtime.d.ts +6 -1
  98. package/dist/public-runtime.d.ts.map +1 -1
  99. package/dist/public-runtime.js +21 -1
  100. package/dist/public-runtime.js.map +1 -1
  101. package/dist/public-types.d.ts +2 -0
  102. package/dist/public-types.d.ts.map +1 -1
  103. package/dist/registry/tool/__tests__/strict-registration.test.d.ts +2 -0
  104. package/dist/registry/tool/__tests__/strict-registration.test.d.ts.map +1 -0
  105. package/dist/registry/tool/__tests__/strict-registration.test.js +82 -0
  106. package/dist/registry/tool/__tests__/strict-registration.test.js.map +1 -0
  107. package/dist/registry/tool/execute.d.ts.map +1 -1
  108. package/dist/registry/tool/execute.js +16 -0
  109. package/dist/registry/tool/execute.js.map +1 -1
  110. package/dist/runtime/query/result.d.ts.map +1 -1
  111. package/dist/runtime/query/result.js +5 -0
  112. package/dist/runtime/query/result.js.map +1 -1
  113. package/dist/tools/builtins/__tests__/edit-insert-reachable.test.js +7 -1
  114. package/dist/tools/builtins/__tests__/edit-insert-reachable.test.js.map +1 -1
  115. package/dist/tools/builtins/__tests__/edit.test.js +1 -1
  116. package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
  117. package/dist/tools/builtins/edit.d.ts.map +1 -1
  118. package/dist/tools/builtins/edit.js +20 -2
  119. package/dist/tools/builtins/edit.js.map +1 -1
  120. package/dist/types/plugin/index.d.ts +4 -4
  121. package/dist/types/run/events.d.ts +17 -2
  122. package/dist/types/run/events.d.ts.map +1 -1
  123. package/dist/types/run/events.js.map +1 -1
  124. package/dist/types/verification/index.d.ts +6 -6
  125. package/package.json +1 -1
  126. package/src/agents/__tests__/run-agent-forwarding.test.ts +85 -0
  127. package/src/agents/__tests__/run-completed-stop-reason.test.ts +59 -0
  128. package/src/agents/index.ts +8 -0
  129. package/src/agents/runAgent.ts +30 -4
  130. package/src/constants/tools/__tests__/dangerous-patterns.test.ts +62 -0
  131. package/src/constants/tools/index.ts +28 -1
  132. package/src/directory/__tests__/derive.test.ts +135 -0
  133. package/src/directory/__tests__/load-diagnostics.test.ts +126 -0
  134. package/src/directory/__tests__/load-directory.test.ts +243 -0
  135. package/src/directory/__tests__/subagents.test.ts +155 -0
  136. package/src/directory/__tests__/supervisor-budget.test.ts +117 -0
  137. package/src/directory/derive-supervisor.ts +149 -0
  138. package/src/directory/derive.ts +70 -0
  139. package/src/directory/index.ts +27 -0
  140. package/src/directory/load.ts +556 -0
  141. package/src/directory/scan.ts +163 -0
  142. package/src/directory/types.ts +254 -0
  143. package/src/plugin/__tests__/path-containment.test.ts +107 -0
  144. package/src/plugin/lifecycle.ts +3 -3
  145. package/src/plugin/loader.ts +12 -2
  146. package/src/provider/__tests__/model-version.test.ts +93 -0
  147. package/src/provider/__tests__/strict-schema.test.ts +133 -0
  148. package/src/provider/model-version.ts +98 -0
  149. package/src/provider/strict-schema.ts +125 -0
  150. package/src/public-runtime.ts +31 -0
  151. package/src/public-types.ts +8 -0
  152. package/src/registry/tool/__tests__/strict-registration.test.ts +115 -0
  153. package/src/registry/tool/execute.ts +16 -0
  154. package/src/runtime/query/result.ts +5 -0
  155. package/src/tools/builtins/__tests__/edit-insert-reachable.test.ts +8 -2
  156. package/src/tools/builtins/__tests__/edit.test.ts +1 -1
  157. package/src/tools/builtins/edit.ts +20 -2
  158. package/src/types/run/events.ts +15 -2
@@ -0,0 +1,556 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { pathToFileURL } from 'node:url'
3
+
4
+ import { loadSkill } from '../skills/loader.js'
5
+ import type { ToolDefinition } from '../types/tool/index.js'
6
+
7
+ import { canonicalRoot, scanSlot } from './scan.js'
8
+ import type {
9
+ DirectoryConfig,
10
+ DirectoryDiagnostic,
11
+ DirectoryLoadResult,
12
+ DirectorySlot,
13
+ LoadDirectoryOptions,
14
+ ModuleImporter,
15
+ ModuleMode,
16
+ SkillEntry,
17
+ SourceOutcome,
18
+ SourceRef,
19
+ SubAgentEntry,
20
+ ToolEntry,
21
+ } from './types.js'
22
+ import { ALL_SLOTS } from './types.js'
23
+
24
+ const DEFAULT_MODULE_TIMEOUT_MS = 10_000
25
+
26
+ /**
27
+ * Import a module with a deadline that bounds the LOADER, not the module.
28
+ *
29
+ * `import()` cannot be cancelled. When this races out, the module is still
30
+ * executing: it may finish, its top-level side effects may land after
31
+ * `loadDirectory` has returned, and Node caches the result — so a second load in
32
+ * the same process can see the same file resolve instantly. That was verified
33
+ * rather than assumed, and it is why `'abandoned'` exists as an outcome
34
+ * separate from `'failed'`.
35
+ */
36
+ async function importWithDeadline(
37
+ fileUrl: string,
38
+ importModule: ModuleImporter,
39
+ timeoutMs: number,
40
+ ): Promise<
41
+ | { status: 'loaded'; module: unknown }
42
+ | { status: 'abandoned' }
43
+ /** `code` is Node's `err.code`, which its `message` does not contain. */
44
+ | { status: 'failed'; cause: string; code?: string }
45
+ > {
46
+ let timer: ReturnType<typeof setTimeout> | undefined
47
+ const deadline = new Promise<{ status: 'abandoned' }>((resolve) => {
48
+ timer = setTimeout(() => resolve({ status: 'abandoned' }), timeoutMs)
49
+ })
50
+ try {
51
+ const result = await Promise.race([
52
+ importModule(fileUrl).then((module) => ({
53
+ status: 'loaded' as const,
54
+ module,
55
+ })),
56
+ deadline,
57
+ ])
58
+ return result
59
+ } catch (err) {
60
+ const code = (err as { code?: unknown } | null)?.code
61
+ return {
62
+ status: 'failed',
63
+ cause: err instanceof Error ? err.message : String(err),
64
+ ...(typeof code === 'string' ? { code } : {}),
65
+ }
66
+ } finally {
67
+ if (timer) clearTimeout(timer)
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Translate Node's module-loading errors into something an author can act on.
73
+ *
74
+ * The codes matter more than the messages: `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`
75
+ * means the file used a construct type STRIPPING cannot express (an `enum`, a
76
+ * decorator, a parameter property) — stripping erases types, it does not
77
+ * transform code, and no flag rescues those. That is a different problem from
78
+ * a plain syntax error, and pointing at the wrong one costs an author an hour.
79
+ *
80
+ * That sentence was true and this function still matched on `cause`, which is
81
+ * `err.message` — and Node does not put the code in the message. Probed:
82
+ * `ERR_MODULE_NOT_FOUND` arrives as *"Cannot find module …"*, and
83
+ * `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` as *"TypeScript enum is not supported in
84
+ * strip-only mode"*. Neither contains its code, so every branch below was
85
+ * unreachable and every author got the bare message the doc-comment was
86
+ * explaining why not to give them. The code is read from `err.code` now, which
87
+ * is where it always was.
88
+ */
89
+ function explainImportFailure(cause: string, code?: string): string {
90
+ if (code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') {
91
+ return `${cause}\n\nNode strips types, it does not compile them: enums, decorators, parameter properties and runtime namespaces have no stripped form. Rewrite as plain TypeScript, or pass \`importModule\` with a transforming loader.`
92
+ }
93
+ if (code === 'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING') {
94
+ return `${cause}\n\nNode refuses to strip types from any .ts file resolved under node_modules/. If this is a workspace package, have it ship built .js.`
95
+ }
96
+ if (code === 'ERR_MODULE_NOT_FOUND') {
97
+ return `${cause}\n\nRelative imports need their real extension here — write "./util.ts", not "./util". tsconfig "paths" aliases are not resolved; pass \`importModule\` if you need them.`
98
+ }
99
+ // Node ≥20 but <22.6 has no type stripping at all, so the golden path — a
100
+ // `.ts` tool — dies here rather than at any of the codes above.
101
+ if (code === 'ERR_UNKNOWN_FILE_EXTENSION' && /\.[cm]?ts"?$/.test(cause)) {
102
+ return `${cause}\n\nThis Node cannot run TypeScript directly. Use Node 22.18 or newer, or pass \`importModule\` with a loader that transpiles.`
103
+ }
104
+ return cause
105
+ }
106
+
107
+ /**
108
+ * The fields `ToolDefinition` declares without a `?`.
109
+ *
110
+ * Checking `name` and `execute` alone was not a weaker version of this check,
111
+ * it was a check that let the failure through and moved it: a default export
112
+ * with no `inputSchema` passed here, registered clean, and then died inside
113
+ * `toLLMTools()` reading `inputSchema._def` — a `TypeError` naming a file this
114
+ * loader never mentions, at a point where the author is no longer looking at
115
+ * their tool. A loader whose job is to report bad files must reject the ones
116
+ * that crash later, not only the ones that are obviously not tools.
117
+ *
118
+ * Exactly the required set, and no more. `isReadOnly`, `isDestructive` and the
119
+ * other `defineTool` niceties are not on the interface, so demanding them would
120
+ * make this loader refuse a hand-written object that satisfies the SDK's own
121
+ * published type — the loader overruling the SDK.
122
+ */
123
+ function isToolDefinition(value: unknown): value is ToolDefinition {
124
+ if (typeof value !== 'object' || value === null) return false
125
+ const c = value as Record<string, unknown>
126
+ return (
127
+ typeof c.name === 'string' &&
128
+ typeof c.description === 'string' &&
129
+ typeof c.execute === 'function' &&
130
+ typeof c.inputSchema === 'object' &&
131
+ c.inputSchema !== null
132
+ )
133
+ }
134
+
135
+ /** The default export, tolerating a namespace that only has named exports. */
136
+ function defaultExport(module: unknown): unknown {
137
+ if (typeof module !== 'object' || module === null) return undefined
138
+ return (module as { default?: unknown }).default
139
+ }
140
+
141
+ function readConfig(
142
+ value: unknown,
143
+ diagnostics: DirectoryDiagnostic[],
144
+ path: string,
145
+ ): DirectoryConfig {
146
+ if (value === undefined) return {}
147
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
148
+ diagnostics.push({
149
+ code: 'invalid_config',
150
+ severity: 'error',
151
+ message: 'agent.ts must default-export a plain object.',
152
+ path,
153
+ })
154
+ return {}
155
+ }
156
+ // Read field by field rather than spread: an unknown key is the author's
157
+ // business, but a known key of the wrong type would reach `runAgent` and
158
+ // fail somewhere with no mention of this file.
159
+ const raw = value as Record<string, unknown>
160
+ const config: Record<string, unknown> = {}
161
+
162
+ const take = (key: string, ok: (v: unknown) => boolean, expected: string): void => {
163
+ const found = raw[key]
164
+ if (found === undefined) return
165
+ if (!ok(found)) {
166
+ diagnostics.push({
167
+ code: 'invalid_config',
168
+ severity: 'error',
169
+ message: `agent.ts: "${key}" must be a ${expected}, received ${typeof found}.`,
170
+ path,
171
+ })
172
+ return
173
+ }
174
+ config[key] = found
175
+ }
176
+
177
+ const isString = (v: unknown): boolean => typeof v === 'string'
178
+ // Rejects NaN and Infinity too. A budget of NaN compares false against
179
+ // every limit, so it would read as "no cap" rather than as a bad value.
180
+ const isNumber = (v: unknown): boolean => typeof v === 'number' && Number.isFinite(v)
181
+
182
+ take('model', isString, 'string')
183
+ take('name', isString, 'string')
184
+ take('temperature', isNumber, 'finite number')
185
+ take('maxIterations', isNumber, 'finite number')
186
+ take('tokenBudget', isNumber, 'finite number')
187
+ take('timeoutMs', isNumber, 'finite number')
188
+
189
+ // `metadata` is typed `Record<string, string>` and was admitted on
190
+ // `typeof === 'object'` alone — which an array also satisfies, and which
191
+ // says nothing about the values. So `{ tags: ['a'] }` and `{ n: 1 }` both
192
+ // reached a consumer that had been promised strings, and the type was a
193
+ // claim this loader did not keep. Every other field here is checked; this
194
+ // one is the reason to check the values too, since it is the only field
195
+ // whose contents are forwarded verbatim to an inspector.
196
+ if (raw.metadata !== undefined) {
197
+ const m = raw.metadata
198
+ if (typeof m !== 'object' || m === null || Array.isArray(m)) {
199
+ diagnostics.push({
200
+ code: 'invalid_config',
201
+ severity: 'error',
202
+ message: `agent.ts: "metadata" must be an object of string values, received ${Array.isArray(m) ? 'array' : typeof m}.`,
203
+ path,
204
+ })
205
+ } else {
206
+ const bad = Object.entries(m).filter(([, v]) => typeof v !== 'string')
207
+ if (bad.length > 0) {
208
+ diagnostics.push({
209
+ code: 'invalid_config',
210
+ severity: 'error',
211
+ message: `agent.ts: "metadata" values must be strings; ${bad
212
+ .map(([k, v]) => `"${k}" is ${v === null ? 'null' : typeof v}`)
213
+ .join(', ')}.`,
214
+ path,
215
+ })
216
+ } else {
217
+ config.metadata = m
218
+ }
219
+ }
220
+ }
221
+ return config as DirectoryConfig
222
+ }
223
+
224
+ /**
225
+ * Read a conventional agent directory into typed, inspectable definitions.
226
+ *
227
+ * A LOADER, not a runner: it produces what is there and hands it back. Running
228
+ * it — a turn, a server, a schedule — is the host's job, which is what keeps
229
+ * this package free of any hosting model.
230
+ *
231
+ * It never throws on authored content. A broken tool, a missing file, a
232
+ * malformed config all come back as diagnostics, because a loader that throws
233
+ * on the first bad file tells you about one problem when you wanted the list.
234
+ */
235
+ export async function loadDirectory(
236
+ dir: string,
237
+ options: LoadDirectoryOptions = {},
238
+ ): Promise<DirectoryLoadResult> {
239
+ return loadAt(dir, options, 0)
240
+ }
241
+
242
+ /**
243
+ * Depth 0 is the project the caller named; 1 is a delegate it declares.
244
+ *
245
+ * A delegate does not get delegates of its own. The cap is a real decision,
246
+ * not a missing feature: unbounded nesting is a topology question — who may
247
+ * spawn whom, and how deep a run may fan out — and answering it by default is
248
+ * how a directory layout ends up deciding a system's shape. It also removes
249
+ * the cycle: `agents/a/agents/b/agents/a` cannot be built if the second level
250
+ * is never read.
251
+ */
252
+ const MAX_DEPTH = 1
253
+
254
+ async function loadAt(
255
+ dir: string,
256
+ options: LoadDirectoryOptions,
257
+ depth: number,
258
+ ): Promise<DirectoryLoadResult> {
259
+ if (typeof dir !== 'string' || dir.length === 0) {
260
+ throw new TypeError('loadDirectory(dir): dir must be a non-empty string.')
261
+ }
262
+
263
+ const modules: ModuleMode = options.modules ?? 'evaluate'
264
+ const importModule: ModuleImporter = options.importModule ?? ((url) => import(url))
265
+ const timeoutMs = options.moduleTimeoutMs ?? DEFAULT_MODULE_TIMEOUT_MS
266
+ // `?? ALL_SLOTS`, never `|| ALL_SLOTS`: an empty list means "scan nothing",
267
+ // which is the closed reading of an allow-list. The tuple type keeps a
268
+ // TypeScript caller from writing it at all.
269
+ const included: readonly DirectorySlot[] = options.include ?? ALL_SLOTS
270
+
271
+ const diagnostics: DirectoryDiagnostic[] = []
272
+ const sources: SourceRef[] = []
273
+ const tools: ToolEntry[] = []
274
+ const skills: SkillEntry[] = []
275
+ const agents: SubAgentEntry[] = []
276
+ let config: DirectoryConfig = {}
277
+ let instructions = ''
278
+
279
+ const root = await canonicalRoot(dir)
280
+ if (!root) {
281
+ diagnostics.push({
282
+ code: 'root_missing',
283
+ severity: 'error',
284
+ message: `No directory at ${dir}.`,
285
+ })
286
+ return {
287
+ manifest: {
288
+ root: dir,
289
+ name: 'agent',
290
+ instructions: '',
291
+ config: {},
292
+ tools: [],
293
+ skills: [],
294
+ agents: [],
295
+ sources: [],
296
+ included,
297
+ modules,
298
+ },
299
+ diagnostics,
300
+ ok: false,
301
+ }
302
+ }
303
+
304
+ const record = (
305
+ file: { path: string; relativePath: string; id: string },
306
+ slot: DirectorySlot,
307
+ outcome: SourceOutcome,
308
+ ): SourceRef => {
309
+ const ref: SourceRef = { ...file, slot, outcome }
310
+ sources.push(ref)
311
+ return ref
312
+ }
313
+
314
+ // ─── instructions ─────────────────────────────────────────────────────
315
+ if (included.includes('instructions')) {
316
+ try {
317
+ const body = await readFile(`${root}/instructions.md`, 'utf8')
318
+ instructions = body.trimEnd()
319
+ if (instructions.length === 0) {
320
+ // Asymmetric on purpose: an absent file is a choice, a present
321
+ // file that says nothing is a mistake.
322
+ diagnostics.push({
323
+ code: 'instructions_empty',
324
+ severity: 'error',
325
+ message: 'instructions.md exists but is empty.',
326
+ path: `${root}/instructions.md`,
327
+ })
328
+ }
329
+ record(
330
+ {
331
+ path: `${root}/instructions.md`,
332
+ relativePath: 'instructions.md',
333
+ id: 'instructions',
334
+ },
335
+ 'instructions',
336
+ 'loaded',
337
+ )
338
+ } catch {
339
+ // A warning, not an error: `runAgent` treats instructions as fully
340
+ // optional, and this package does not get to overrule the kernel
341
+ // about what a valid agent is.
342
+ diagnostics.push({
343
+ code: 'instructions_missing',
344
+ severity: 'warning',
345
+ message: 'No instructions.md. The agent will run without a system prompt.',
346
+ })
347
+ }
348
+ }
349
+
350
+ // ─── agent.ts ─────────────────────────────────────────────────────────
351
+ if (included.includes('agent')) {
352
+ const found = (await scanSlot(root, 'agent', '.')).files.find((f) => f.id === 'agent')
353
+ if (found) {
354
+ if (modules === 'skip') {
355
+ record(found, 'agent', 'not_loaded')
356
+ } else {
357
+ const outcome = await importWithDeadline(
358
+ pathToFileURL(found.path).href,
359
+ importModule,
360
+ timeoutMs,
361
+ )
362
+ if (outcome.status === 'loaded') {
363
+ config = readConfig(defaultExport(outcome.module), diagnostics, found.path)
364
+ record(found, 'agent', 'loaded')
365
+ } else if (outcome.status === 'abandoned') {
366
+ diagnostics.push({
367
+ code: 'module_load_abandoned',
368
+ severity: 'error',
369
+ message: `agent.ts did not finish importing within ${timeoutMs}ms. The import was not cancelled and may still complete.`,
370
+ path: found.path,
371
+ })
372
+ record(found, 'agent', 'abandoned')
373
+ } else {
374
+ diagnostics.push({
375
+ code: 'module_load_failed',
376
+ severity: 'error',
377
+ message: 'agent.ts could not be imported.',
378
+ path: found.path,
379
+ cause: explainImportFailure(outcome.cause, outcome.code),
380
+ })
381
+ record(found, 'agent', 'failed')
382
+ }
383
+ }
384
+ }
385
+ }
386
+
387
+ // ─── tools ────────────────────────────────────────────────────────────
388
+ if (included.includes('tools')) {
389
+ const scan = await scanSlot(root, 'tools', 'tools')
390
+ diagnostics.push(...scan.diagnostics)
391
+ const seen = new Map<string, string>()
392
+
393
+ for (const file of scan.files) {
394
+ if (modules === 'skip') {
395
+ record(file, 'tools', 'not_loaded')
396
+ continue
397
+ }
398
+ const outcome = await importWithDeadline(
399
+ pathToFileURL(file.path).href,
400
+ importModule,
401
+ timeoutMs,
402
+ )
403
+ if (outcome.status === 'abandoned') {
404
+ diagnostics.push({
405
+ code: 'module_load_abandoned',
406
+ severity: 'error',
407
+ message: `${file.relativePath} did not finish importing within ${timeoutMs}ms. The import was not cancelled and may still complete.`,
408
+ path: file.path,
409
+ })
410
+ record(file, 'tools', 'abandoned')
411
+ continue
412
+ }
413
+ if (outcome.status === 'failed') {
414
+ diagnostics.push({
415
+ code: 'module_load_failed',
416
+ severity: 'error',
417
+ message: `${file.relativePath} could not be imported.`,
418
+ path: file.path,
419
+ cause: explainImportFailure(outcome.cause, outcome.code),
420
+ })
421
+ record(file, 'tools', 'failed')
422
+ continue
423
+ }
424
+
425
+ const exported = defaultExport(outcome.module)
426
+ if (exported === undefined) {
427
+ diagnostics.push({
428
+ code: 'no_default_export',
429
+ severity: 'error',
430
+ message: `${file.relativePath} has no default export. A tool file default-exports the result of defineTool().`,
431
+ path: file.path,
432
+ })
433
+ record(file, 'tools', 'failed')
434
+ continue
435
+ }
436
+ if (!isToolDefinition(exported)) {
437
+ diagnostics.push({
438
+ code: 'not_a_tool',
439
+ severity: 'error',
440
+ message: `${file.relativePath} default-exports something that is not a tool definition (needs a "name" and an "execute").`,
441
+ path: file.path,
442
+ })
443
+ record(file, 'tools', 'failed')
444
+ continue
445
+ }
446
+
447
+ const previous = seen.get(exported.name)
448
+ if (previous) {
449
+ // Refuse both rather than let one win. A registry would warn and
450
+ // overwrite; here the author has two files claiming one name and
451
+ // picking either silently is the thing that hides the mistake.
452
+ diagnostics.push({
453
+ code: 'duplicate_tool_name',
454
+ severity: 'error',
455
+ message: `Two tools both call themselves "${exported.name}": ${previous} and ${file.relativePath}. Neither was registered.`,
456
+ path: file.path,
457
+ })
458
+ record(file, 'tools', 'failed')
459
+ continue
460
+ }
461
+ seen.set(exported.name, file.relativePath)
462
+ tools.push({
463
+ source: record(file, 'tools', 'loaded'),
464
+ definition: exported,
465
+ })
466
+ }
467
+ }
468
+
469
+ // ─── skills ───────────────────────────────────────────────────────────
470
+ if (included.includes('skills')) {
471
+ const scan = await scanSlot(root, 'skills', 'skills')
472
+ diagnostics.push(...scan.diagnostics)
473
+ for (const file of scan.files) {
474
+ try {
475
+ // Delegated, not reimplemented. `Skill.dirPath` is required and
476
+ // the prompt assembler renders `<location>{dirPath}/SKILL.md`,
477
+ // so a flat `skills/*.md` would produce a skill the model is
478
+ // told to read at a path that does not exist. Directory form is
479
+ // the only form that survives to runtime.
480
+ // `loadSkill` returns a result wrapper; the Skill is inside it.
481
+ const { skill } = await loadSkill(file.path)
482
+ skills.push({ source: record(file, 'skills', 'loaded'), skill })
483
+ } catch (err) {
484
+ diagnostics.push({
485
+ code: 'skill_load_failed',
486
+ severity: 'error',
487
+ message: `${file.relativePath} is not a readable skill.`,
488
+ path: file.path,
489
+ cause: err instanceof Error ? err.message : String(err),
490
+ })
491
+ record(file, 'skills', 'failed')
492
+ }
493
+ }
494
+ }
495
+
496
+ // ─── agents (delegates) ───────────────────────────────────────────────
497
+ if (included.includes('agents')) {
498
+ const scan = await scanSlot(root, 'agents', 'agents')
499
+ diagnostics.push(...scan.diagnostics)
500
+ for (const entry of scan.files) {
501
+ if (depth >= MAX_DEPTH) {
502
+ diagnostics.push({
503
+ code: 'subagent_too_deep',
504
+ severity: 'warning',
505
+ message:
506
+ entry.relativePath +
507
+ ' was not loaded: a delegate may not declare delegates of its own.',
508
+ path: entry.path,
509
+ })
510
+ record(entry, 'agents', 'skipped')
511
+ continue
512
+ }
513
+ const child = await loadAt(entry.path, options, depth + 1)
514
+ // The child's diagnostics are the parent's. A delegate that could
515
+ // not load is a fact about THIS project, and a caller reading one
516
+ // list should not have to walk the tree to find out the run will be
517
+ // short a specialist.
518
+ for (const d of child.diagnostics) {
519
+ diagnostics.push({ ...d, message: entry.relativePath + ': ' + d.message })
520
+ }
521
+ if (!child.ok) {
522
+ record(entry, 'agents', 'failed')
523
+ continue
524
+ }
525
+ agents.push({
526
+ source: record(entry, 'agents', 'loaded'),
527
+ manifest: child.manifest,
528
+ id: entry.id,
529
+ })
530
+ }
531
+ }
532
+ const name = config.name ?? basenameOf(root)
533
+
534
+ return {
535
+ manifest: {
536
+ root,
537
+ name,
538
+ instructions,
539
+ config,
540
+ tools,
541
+ skills,
542
+ agents,
543
+ sources,
544
+ included,
545
+ modules,
546
+ },
547
+ diagnostics,
548
+ ok: diagnostics.every((d) => d.severity !== 'error'),
549
+ }
550
+ }
551
+
552
+ /** Basename, with the filesystem root falling back to a usable label. */
553
+ function basenameOf(root: string): string {
554
+ const parts = root.split(/[\\/]/).filter(Boolean)
555
+ return parts[parts.length - 1] ?? 'agent'
556
+ }