@open-mercato/cli 0.7.1-develop.7193.1.910a5b0a1e → 0.7.1-develop.7194.1.ab4fc81f82

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 (169) hide show
  1. package/.turbo/turbo-build.log +6 -2
  2. package/dist/agentic/github-copilot/copilot-instructions.md.template +37 -0
  3. package/dist/agentic/github-copilot/instructions/entity-guard.instructions.md +27 -0
  4. package/dist/agentic/github-copilot/instructions/generated-guard.instructions.md +10 -0
  5. package/dist/agentic/github-copilot/mcp.json.example +13 -0
  6. package/dist/agentic/guides/module-facts.json +11527 -283
  7. package/dist/agentic/guides/module-facts.v2.json +11527 -283
  8. package/dist/agentic/guides/modules/agent_orchestrator/acl-features.md +31 -0
  9. package/dist/agentic/guides/modules/agent_orchestrator/active-extension-bindings.md +12 -0
  10. package/dist/agentic/guides/modules/agent_orchestrator/ai-agents.md +12 -0
  11. package/dist/agentic/guides/modules/agent_orchestrator/ai-tools-mcp-capabilities.md +13 -0
  12. package/dist/agentic/guides/modules/agent_orchestrator/backend-pages.md +28 -0
  13. package/dist/agentic/guides/modules/agent_orchestrator/cli-commands.md +14 -0
  14. package/dist/agentic/guides/modules/agent_orchestrator/contribution-resolutions.md +49 -0
  15. package/dist/agentic/guides/modules/agent_orchestrator/di-registrations-rich.md +42 -0
  16. package/dist/agentic/guides/modules/agent_orchestrator/di-service-tokens.md +24 -0
  17. package/dist/agentic/guides/modules/agent_orchestrator/domain-commands.md +28 -0
  18. package/dist/agentic/guides/modules/agent_orchestrator/encryption.md +20 -0
  19. package/dist/agentic/guides/modules/agent_orchestrator/entities.md +31 -0
  20. package/dist/agentic/guides/modules/agent_orchestrator/events.md +38 -0
  21. package/dist/agentic/guides/modules/agent_orchestrator/exact-override-targets.md +122 -0
  22. package/dist/agentic/guides/modules/agent_orchestrator/host-extension-points.md +10 -0
  23. package/dist/agentic/guides/modules/agent_orchestrator/index.md +31 -0
  24. package/dist/agentic/guides/modules/agent_orchestrator/owned-contract-module-metadata.md +11 -0
  25. package/dist/agentic/guides/modules/agent_orchestrator/setup.md +11 -0
  26. package/dist/agentic/guides/modules/agent_orchestrator/umes-contributions.md +49 -0
  27. package/dist/agentic/guides/modules/agent_orchestrator/umes-hosts.md +61 -0
  28. package/dist/agentic/guides/modules/agent_orchestrator/workers.md +14 -0
  29. package/dist/agentic/guides/modules/ai_assistant/di-registrations-rich.md +3 -2
  30. package/dist/agentic/guides/modules/ai_assistant/di-service-tokens.md +2 -1
  31. package/dist/agentic/guides/modules/ai_assistant/exact-override-targets.md +3 -2
  32. package/dist/agentic/guides/modules/ai_assistant/index.md +1 -1
  33. package/dist/agentic/guides/modules/api_docs/index.md +1 -1
  34. package/dist/agentic/guides/modules/api_keys/index.md +1 -1
  35. package/dist/agentic/guides/modules/attachments/index.md +1 -1
  36. package/dist/agentic/guides/modules/audit_logs/index.md +1 -1
  37. package/dist/agentic/guides/modules/auth/index.md +1 -1
  38. package/dist/agentic/guides/modules/business_rules/index.md +1 -1
  39. package/dist/agentic/guides/modules/catalog/index.md +1 -1
  40. package/dist/agentic/guides/modules/channel_apns/index.md +1 -1
  41. package/dist/agentic/guides/modules/channel_discord/index.md +1 -1
  42. package/dist/agentic/guides/modules/channel_expo/index.md +1 -1
  43. package/dist/agentic/guides/modules/channel_fcm/index.md +1 -1
  44. package/dist/agentic/guides/modules/channel_gmail/index.md +1 -1
  45. package/dist/agentic/guides/modules/channel_imap/index.md +1 -1
  46. package/dist/agentic/guides/modules/channel_resend/index.md +1 -1
  47. package/dist/agentic/guides/modules/channel_ses/index.md +1 -1
  48. package/dist/agentic/guides/modules/checkout/index.md +1 -1
  49. package/dist/agentic/guides/modules/communication_channels/index.md +1 -1
  50. package/dist/agentic/guides/modules/configs/index.md +1 -1
  51. package/dist/agentic/guides/modules/content/index.md +1 -1
  52. package/dist/agentic/guides/modules/currencies/index.md +1 -1
  53. package/dist/agentic/guides/modules/customer_accounts/cli-commands.md +11 -0
  54. package/dist/agentic/guides/modules/customer_accounts/exact-override-targets.md +5 -4
  55. package/dist/agentic/guides/modules/customer_accounts/index.md +3 -2
  56. package/dist/agentic/guides/modules/customer_accounts/setup.md +1 -1
  57. package/dist/agentic/guides/modules/customers/contribution-resolutions.md +1 -0
  58. package/dist/agentic/guides/modules/customers/exact-override-targets.md +1 -0
  59. package/dist/agentic/guides/modules/customers/incoming-installed-contributions.md +3 -0
  60. package/dist/agentic/guides/modules/customers/index.md +2 -2
  61. package/dist/agentic/guides/modules/customers/umes-contributions.md +1 -0
  62. package/dist/agentic/guides/modules/dashboards/index.md +1 -1
  63. package/dist/agentic/guides/modules/data_sync/index.md +1 -1
  64. package/dist/agentic/guides/modules/design_system/index.md +1 -1
  65. package/dist/agentic/guides/modules/devices/index.md +1 -1
  66. package/dist/agentic/guides/modules/dictionaries/index.md +1 -1
  67. package/dist/agentic/guides/modules/directory/index.md +1 -1
  68. package/dist/agentic/guides/modules/documents/index.md +1 -1
  69. package/dist/agentic/guides/modules/entities/index.md +1 -1
  70. package/dist/agentic/guides/modules/eudr/index.md +1 -1
  71. package/dist/agentic/guides/modules/events/index.md +1 -1
  72. package/dist/agentic/guides/modules/feature_toggles/index.md +1 -1
  73. package/dist/agentic/guides/modules/gateway_stripe/index.md +1 -1
  74. package/dist/agentic/guides/modules/generators/index.md +1 -1
  75. package/dist/agentic/guides/modules/inbox_ops/index.md +1 -1
  76. package/dist/agentic/guides/modules/integrations/index.md +1 -1
  77. package/dist/agentic/guides/modules/messages/index.md +1 -1
  78. package/dist/agentic/guides/modules/notifications/index.md +1 -1
  79. package/dist/agentic/guides/modules/onboarding/index.md +1 -1
  80. package/dist/agentic/guides/modules/payment_gateways/index.md +1 -1
  81. package/dist/agentic/guides/modules/perspectives/index.md +1 -1
  82. package/dist/agentic/guides/modules/phone_calls/index.md +1 -1
  83. package/dist/agentic/guides/modules/planner/index.md +1 -1
  84. package/dist/agentic/guides/modules/portal/index.md +1 -1
  85. package/dist/agentic/guides/modules/progress/index.md +1 -1
  86. package/dist/agentic/guides/modules/push_notifications/index.md +1 -1
  87. package/dist/agentic/guides/modules/query_index/index.md +1 -1
  88. package/dist/agentic/guides/modules/record_locks/index.md +1 -1
  89. package/dist/agentic/guides/modules/resources/index.md +1 -1
  90. package/dist/agentic/guides/modules/sales/index.md +1 -1
  91. package/dist/agentic/guides/modules/scheduler/index.md +1 -1
  92. package/dist/agentic/guides/modules/search/index.md +1 -1
  93. package/dist/agentic/guides/modules/security/index.md +1 -1
  94. package/dist/agentic/guides/modules/seeds/cli-commands.md +14 -0
  95. package/dist/agentic/guides/modules/seeds/exact-override-targets.md +14 -0
  96. package/dist/agentic/guides/modules/seeds/index.md +14 -0
  97. package/dist/agentic/guides/modules/seeds/owned-contract-module-metadata.md +11 -0
  98. package/dist/agentic/guides/modules/shipping_carriers/index.md +1 -1
  99. package/dist/agentic/guides/modules/sso/index.md +1 -1
  100. package/dist/agentic/guides/modules/staff/index.md +1 -1
  101. package/dist/agentic/guides/modules/storage_s3/index.md +1 -1
  102. package/dist/agentic/guides/modules/sync_akeneo/index.md +1 -1
  103. package/dist/agentic/guides/modules/sync_excel/index.md +1 -1
  104. package/dist/agentic/guides/modules/system_status_overlays/index.md +1 -1
  105. package/dist/agentic/guides/modules/tillio/index.md +1 -1
  106. package/dist/agentic/guides/modules/translations/index.md +1 -1
  107. package/dist/agentic/guides/modules/warranty_claims/index.md +1 -1
  108. package/dist/agentic/guides/modules/webhooks/index.md +1 -1
  109. package/dist/agentic/guides/modules/wms/index.md +1 -1
  110. package/dist/agentic/guides/modules/workflows/acl-features.md +11 -1
  111. package/dist/agentic/guides/modules/workflows/active-extension-bindings.md +4 -1
  112. package/dist/agentic/guides/modules/workflows/ai-tools-mcp-capabilities.md +17 -0
  113. package/dist/agentic/guides/modules/workflows/backend-pages.md +3 -0
  114. package/dist/agentic/guides/modules/workflows/contribution-resolutions.md +24 -0
  115. package/dist/agentic/guides/modules/workflows/di-registrations-rich.md +11 -7
  116. package/dist/agentic/guides/modules/workflows/di-service-tokens.md +11 -7
  117. package/dist/agentic/guides/modules/workflows/domain-commands.md +11 -0
  118. package/dist/agentic/guides/modules/workflows/entities.md +2 -0
  119. package/dist/agentic/guides/modules/workflows/events.md +14 -8
  120. package/dist/agentic/guides/modules/workflows/exact-override-targets.md +45 -10
  121. package/dist/agentic/guides/modules/workflows/frontend-pages.md +2 -0
  122. package/dist/agentic/guides/modules/workflows/incoming-installed-contributions.md +17 -0
  123. package/dist/agentic/guides/modules/workflows/index.md +15 -12
  124. package/dist/agentic/guides/modules/workflows/notifications.md +2 -0
  125. package/dist/agentic/guides/modules/workflows/setup.md +1 -1
  126. package/dist/agentic/guides/modules/workflows/umes-contributions.md +24 -0
  127. package/dist/agentic/guides/modules/workflows/umes-hosts.md +14 -6
  128. package/dist/agentic/guides/modules/workflows/workers.md +4 -1
  129. package/dist/agentic/guides/reference-module-facts.json +2 -2
  130. package/dist/agentic/guides/reference-modules/example/index.md +1 -1
  131. package/dist/agentic/guides/upstream/AGENTS.md +3 -1
  132. package/dist/agentic/guides/upstream/manifest.json +2 -2
  133. package/dist/agentic/shared/ai/harness/design-system-inventory.json +118 -106
  134. package/dist/agentic/shared/ai/harness/source-link-inventory.json +8 -8
  135. package/dist/lib/agentic-init.js +4 -0
  136. package/dist/lib/agentic-init.js.map +2 -2
  137. package/dist/lib/agentic-setup.js +19 -5
  138. package/dist/lib/agentic-setup.js.map +2 -2
  139. package/dist/lib/generators/extensions/agent-files.js +913 -0
  140. package/dist/lib/generators/extensions/agent-files.js.map +7 -0
  141. package/dist/lib/generators/extensions/index.js +2 -0
  142. package/dist/lib/generators/extensions/index.js.map +2 -2
  143. package/dist/lib/generators/index.js +3 -1
  144. package/dist/lib/generators/index.js.map +2 -2
  145. package/dist/lib/generators/module-registry.js +40 -11
  146. package/dist/lib/generators/module-registry.js.map +2 -2
  147. package/dist/lib/generators/route-collisions.js +35 -0
  148. package/dist/lib/generators/route-collisions.js.map +7 -0
  149. package/dist/lib/generators/web-research-adapters.js +95 -0
  150. package/dist/lib/generators/web-research-adapters.js.map +7 -0
  151. package/dist/mercato.js +5 -2
  152. package/dist/mercato.js.map +2 -2
  153. package/package.json +6 -6
  154. package/src/__tests__/agent-files.test.ts +199 -0
  155. package/src/__tests__/mercato.test.ts +14 -0
  156. package/src/lib/__tests__/agentic-init.test.ts +11 -0
  157. package/src/lib/__tests__/agentic-setup.ownership.test.ts +5 -0
  158. package/src/lib/agentic-init.ts +4 -0
  159. package/src/lib/agentic-setup.ts +30 -5
  160. package/src/lib/generators/__tests__/agent-files-extension.test.ts +477 -0
  161. package/src/lib/generators/__tests__/module-facts.bc-guard.test.ts +25 -8
  162. package/src/lib/generators/__tests__/route-collisions.test.ts +155 -0
  163. package/src/lib/generators/extensions/agent-files.ts +1332 -0
  164. package/src/lib/generators/extensions/index.ts +2 -0
  165. package/src/lib/generators/index.ts +1 -0
  166. package/src/lib/generators/module-registry.ts +66 -17
  167. package/src/lib/generators/route-collisions.ts +57 -0
  168. package/src/lib/generators/web-research-adapters.ts +133 -0
  169. package/src/mercato.ts +4 -1
@@ -0,0 +1,1332 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { countTokens } from '@open-mercato/shared/lib/ai/token-count'
4
+ import type { GeneratorExtension, ModuleScanContext } from '../extension'
5
+ import { resolveStandaloneSourceMirrorBase } from '../scanner'
6
+
7
+ /**
8
+ * Generator extension for the `agents/<id>/` file-defined-agent convention
9
+ * (AGENT.md + OUTCOME.md). For every enabled module it scans the module's
10
+ * `agents/` tree, validates each agent dir, and emits two artifacts as a
11
+ * deterministic fs side effect from `generateOutput()`:
12
+ *
13
+ * 1. The committed, git-tracked registry manifest
14
+ * `packages/enterprise/src/modules/agent_orchestrator/generated/file-agents.generated.ts`
15
+ * (raw JSON-Schema, recompiled to Zod at load by `ensureAgentsLoaded`).
16
+ * 2. OpenCode agent `.md` files under `docker/opencode/agents/` (container
17
+ * delivery: dev bind-mount, prod Dockerfile COPY).
18
+ *
19
+ * Both targets live OUTSIDE the app `.mercato/generated/` dir, so they are
20
+ * written directly (not via the Map<filename,content> return, which targets the
21
+ * app generated dir). `generateOutput()` therefore returns an empty Map.
22
+ *
23
+ * Generation FAILS (throws) on any malformed AGENT.md/OUTCOME.md/SKILL.md,
24
+ * naming the offending dir (spec §9). The CLI does not depend on
25
+ * `@open-mercato/core`, so the small AGENT.md/OUTCOME.md/SKILL.md parsers are
26
+ * reimplemented here; they MUST stay in sync with
27
+ * `lib/sdk/{agentMarkdown,skillMarkdown,defineFileAgent}.ts`.
28
+ *
29
+ * Phase 3 also emits NATIVE OpenCode skill files under `docker/opencode/skills/`
30
+ * (frontmatter `name` sanitized to `^[a-z0-9]+(-[a-z0-9]+)*$`, `description`
31
+ * required, body = skill instructions) and unions skill-contributed read-only
32
+ * tools into the agent allowlist (manifest + docker agent-file `tools` block).
33
+ */
34
+
35
+ /** One sandboxed script carried as plain data (Phase 5). */
36
+ type DiscoveredScript = {
37
+ name: string
38
+ source: string
39
+ }
40
+
41
+ type DiscoveredSkill = {
42
+ /** Skill id: frontmatter `id` or, when absent, the skill dir name. */
43
+ id: string
44
+ description: string
45
+ instructions: string
46
+ template?: string
47
+ examples: string[]
48
+ tools: string[]
49
+ /** Sandboxed helper scripts (`scripts/*.ts`), Phase 5. */
50
+ scripts: DiscoveredScript[]
51
+ /** OpenCode native skill name (sanitized to ^[a-z0-9]+(-[a-z0-9]+)*$). */
52
+ openCodeSkillName: string
53
+ }
54
+
55
+ type DiscoveredAgent = {
56
+ moduleId: string
57
+ dir: string
58
+ id: string
59
+ label: string
60
+ description: string
61
+ instructions: string
62
+ resultKind: 'research' | 'proposal' | 'artifact'
63
+ /** Absent for `kind: artifact`, whose result envelope is fixed. */
64
+ outcomeSchema?: Record<string, unknown>
65
+ /** OUTCOME.md prose after the JSON-Schema fence — injected into the agent prompt. */
66
+ outcomeProse: string
67
+ /** Effective allowlist: AGENT.md tools ∪ skill-contributed read-only tools. */
68
+ tools: string[]
69
+ skills: string[]
70
+ subAgents: string[]
71
+ openCodeAgentName: string
72
+ /** Resolved agent-local skill content (Phase 3). */
73
+ skillsContent: DiscoveredSkill[]
74
+ /**
75
+ * Resolved sub-agents under `sub-agents/<subid>/` (Phase 4). Each is a full
76
+ * file agent, constrained to research + non-delegating. Empty for a
77
+ * sub-agent itself (depth cap = 1) and for primaries with no sub-agents.
78
+ */
79
+ subAgentsContent: DiscoveredAgent[]
80
+ /** `'primary'` (default) or `'subagent'` for a discovered sub-agent. */
81
+ mode: 'primary' | 'subagent'
82
+ maxSteps?: number
83
+ provider?: string
84
+ model?: string
85
+ /** Optional `SAMPLE.json` example input for the Playground "Insert sample" button. */
86
+ sampleInput?: unknown
87
+ /** Optional `FACTS.json` declarations driving the Caseload facts panel. */
88
+ facts?: DiscoveredFact[]
89
+ /** Baked token-usage breakdown of the agent's construction files (Phase: token accounting). */
90
+ tokenUsage: FileAgentTokenUsage
91
+ /** Baked raw content of every construction file, for the read-only Files tab (#12 files). */
92
+ sourceFiles: FileAgentSourceFile[]
93
+ }
94
+
95
+ /**
96
+ * Raw definition file baked into the manifest so the Files tab reads agent source
97
+ * without runtime fs access. MIRRORS `FileAgentFile` in
98
+ * `packages/enterprise/.../lib/tokens/types.ts` — the CLI cannot import the
99
+ * enterprise package, so the shape is reimplemented here and MUST stay in sync.
100
+ */
101
+ type FileAgentSourceFile = {
102
+ path: string
103
+ content: string
104
+ tokens: number
105
+ inContext: boolean
106
+ }
107
+
108
+ /**
109
+ * Token-usage shape MIRRORED from
110
+ * `packages/enterprise/src/modules/agent_orchestrator/lib/tokens/types.ts`. The
111
+ * CLI cannot import `@open-mercato/enterprise`, so both the type and the walker
112
+ * below are reimplemented here and MUST stay in sync — a parity test in the
113
+ * enterprise module (`agent-token-usage.test.ts`) guards the numbers.
114
+ */
115
+ type TokenizedFile = { path: string; tokens: number }
116
+ type SkillTokenUsage = { id: string; tokens: number; files: TokenizedFile[] }
117
+ type ToolTokenUsage = { name: string; path: string; tokens: number }
118
+ type SubAgentTokenUsage = { id: string; tokens: number }
119
+ type FileAgentTokenUsage = {
120
+ total: number
121
+ self: number
122
+ agent: number
123
+ outcome: number
124
+ skills: SkillTokenUsage[]
125
+ tools: ToolTokenUsage[]
126
+ subAgents: SubAgentTokenUsage[]
127
+ }
128
+
129
+ type DiscoveredFact = {
130
+ label: string
131
+ source: 'input' | 'payload' | 'output'
132
+ path: string
133
+ format?: 'text' | 'number' | 'boolean' | 'percent'
134
+ }
135
+
136
+ const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/
137
+ const JSON_FENCE_RE = /```json\s*\n([\s\S]*?)\n```/
138
+ const LIST_KEYS = ['tools', 'skills', 'subAgents'] as const
139
+
140
+ function stripQuotes(value: string): string {
141
+ return value.trim().replace(/^['"]/, '').replace(/['"]$/, '').trim()
142
+ }
143
+
144
+ function parseInlineList(rawValue: string): string[] {
145
+ return rawValue
146
+ .replace(/^\[/, '')
147
+ .replace(/\]$/, '')
148
+ .split(',')
149
+ .map((entry) => stripQuotes(entry))
150
+ .filter(Boolean)
151
+ }
152
+
153
+ type AgentFrontmatter = {
154
+ id?: string
155
+ label?: string
156
+ description?: string
157
+ provider?: string
158
+ model?: string
159
+ tools: string[]
160
+ skills: string[]
161
+ subAgents: string[]
162
+ maxSteps?: number
163
+ instructions: string
164
+ }
165
+
166
+ function parseAgentMarkdown(raw: string): AgentFrontmatter | null {
167
+ const match = FRONTMATTER_RE.exec(raw)
168
+ if (!match) return null
169
+ const [, frontmatterBlock, body] = match
170
+ const meta: AgentFrontmatter = { tools: [], skills: [], subAgents: [], instructions: body.trim() }
171
+ const lines = frontmatterBlock.split('\n')
172
+ let index = 0
173
+ while (index < lines.length) {
174
+ const line = lines[index]
175
+ if (!line.trim()) {
176
+ index += 1
177
+ continue
178
+ }
179
+ const lineMatch = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line)
180
+ if (!lineMatch) {
181
+ index += 1
182
+ continue
183
+ }
184
+ const key = lineMatch[1]
185
+ const rawValue = lineMatch[2].trim()
186
+ if ((LIST_KEYS as readonly string[]).includes(key)) {
187
+ const listKey = key as (typeof LIST_KEYS)[number]
188
+ if (rawValue.startsWith('[')) {
189
+ meta[listKey] = parseInlineList(rawValue)
190
+ index += 1
191
+ continue
192
+ }
193
+ const items: string[] = []
194
+ index += 1
195
+ while (index < lines.length && /^\s*-\s+/.test(lines[index])) {
196
+ items.push(stripQuotes(lines[index].replace(/^\s*-\s+/, '')))
197
+ index += 1
198
+ }
199
+ meta[listKey] = items.filter(Boolean)
200
+ continue
201
+ }
202
+ if (key === 'maxSteps') {
203
+ const parsed = Number.parseInt(stripQuotes(rawValue), 10)
204
+ if (!Number.isNaN(parsed)) meta.maxSteps = parsed
205
+ index += 1
206
+ continue
207
+ }
208
+ if (key === 'id' || key === 'label' || key === 'description' || key === 'provider' || key === 'model') {
209
+ meta[key] = stripQuotes(rawValue)
210
+ }
211
+ index += 1
212
+ }
213
+ if (!meta.id || !meta.label || !meta.description) return null
214
+ return meta
215
+ }
216
+
217
+ function parseOutcomeKind(frontmatterBlock: string): 'research' | 'proposal' | 'artifact' | null {
218
+ for (const line of frontmatterBlock.split('\n')) {
219
+ const match = /^kind:\s*(.*)$/.exec(line.trim())
220
+ if (!match) continue
221
+ const value = stripQuotes(match[1])
222
+ if (value === 'research' || value === 'proposal' || value === 'artifact') return value
223
+ return null
224
+ }
225
+ return null
226
+ }
227
+
228
+ function parseOutcomeMarkdown(
229
+ raw: string,
230
+ ): {
231
+ kind: 'research' | 'proposal' | 'artifact'
232
+ schema?: Record<string, unknown>
233
+ prose: string
234
+ } | null {
235
+ const frontmatterMatch = FRONTMATTER_RE.exec(raw)
236
+ if (!frontmatterMatch) return null
237
+ const [, frontmatterBlock, body] = frontmatterMatch
238
+ const kind = parseOutcomeKind(frontmatterBlock)
239
+ if (!kind) return null
240
+ const fenceMatch = JSON_FENCE_RE.exec(body)
241
+ // An artifact agent declares no schema — its envelope is the fixed file list —
242
+ // so its whole OUTCOME.md is guidance.
243
+ if (!fenceMatch) {
244
+ return kind === 'artifact' ? { kind, prose: body.trim() } : null
245
+ }
246
+ let parsed: unknown
247
+ try {
248
+ parsed = JSON.parse(fenceMatch[1])
249
+ } catch {
250
+ return null
251
+ }
252
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null
253
+ const prose = body.slice(fenceMatch.index + fenceMatch[0].length).trim()
254
+ return { kind, schema: parsed as Record<string, unknown>, prose }
255
+ }
256
+
257
+ // Keep in sync with `agent_orchestrator/lib/sdk/outcomeSchema.ts` (the CLI cannot
258
+ // import @open-mercato/core). Mirrors `jsonSchemaToZod`'s supported subset so a
259
+ // schema that parses as JSON but could NOT compile to Zod fails generation LOUDLY
260
+ // here, instead of parsing fine and being silently dropped at load time (M2).
261
+ const OUTCOME_UNSUPPORTED_KEYWORDS = [
262
+ 'oneOf', 'anyOf', 'allOf', 'not', '$ref', 'format', 'patternProperties', 'pattern',
263
+ 'additionalItems', 'propertyNames', 'if', 'then', 'else',
264
+ ] as const
265
+ const OUTCOME_SUPPORTED_TYPES = ['object', 'array', 'string', 'number', 'integer', 'boolean']
266
+
267
+ /** Throw (failing `yarn generate`) when an OUTCOME schema node is outside the supported subset. */
268
+ function assertOutcomeSchemaSupported(node: unknown, where: string, path = '$'): void {
269
+ if (typeof node !== 'object' || node === null || Array.isArray(node)) {
270
+ throw new Error(`[internal] malformed OUTCOME.md at ${where}: schema node at ${path} must be an object`)
271
+ }
272
+ const schema = node as Record<string, unknown>
273
+ for (const keyword of OUTCOME_UNSUPPORTED_KEYWORDS) {
274
+ if (keyword in schema) {
275
+ throw new Error(`[internal] malformed OUTCOME.md at ${where}: unsupported keyword "${keyword}" at ${path}`)
276
+ }
277
+ }
278
+ if ('const' in schema) return
279
+ const type = schema.type
280
+ if (typeof type !== 'string' || !OUTCOME_SUPPORTED_TYPES.includes(type)) {
281
+ throw new Error(`[internal] malformed OUTCOME.md at ${where}: missing/unsupported "type" at ${path}`)
282
+ }
283
+ if (type === 'object' && schema.properties != null) {
284
+ if (typeof schema.properties !== 'object' || schema.properties === null || Array.isArray(schema.properties)) {
285
+ throw new Error(`[internal] malformed OUTCOME.md at ${where}: "properties" at ${path} must be an object`)
286
+ }
287
+ for (const [key, child] of Object.entries(schema.properties as Record<string, unknown>)) {
288
+ assertOutcomeSchemaSupported(child, where, `${path}.${key}`)
289
+ }
290
+ }
291
+ if (type === 'array') {
292
+ if (schema.items == null) {
293
+ throw new Error(`[internal] malformed OUTCOME.md at ${where}: array at ${path} requires "items"`)
294
+ }
295
+ assertOutcomeSchemaSupported(schema.items, where, `${path}[]`)
296
+ }
297
+ }
298
+
299
+ function sanitizeAgentName(id: string): string {
300
+ return id.replace(/[^a-z0-9_-]/gi, '_')
301
+ }
302
+
303
+ /**
304
+ * Read the optional `agents/<id>/SAMPLE.json` example input emitted into the
305
+ * manifest for the Playground "Insert sample" button. Pure JSON (no markdown),
306
+ * so a missing file is fine; a malformed one fails generation LOUDLY (naming the
307
+ * dir) rather than being silently dropped. Must stay in sync with
308
+ * `lib/sdk/defineFileAgent.ts` `loadSampleInput`.
309
+ */
310
+ function discoverSampleInput(dir: string): unknown {
311
+ const samplePath = path.join(dir, 'SAMPLE.json')
312
+ if (!fs.existsSync(samplePath)) return undefined
313
+ try {
314
+ return JSON.parse(fs.readFileSync(samplePath, 'utf8'))
315
+ } catch (err) {
316
+ const detail = err instanceof Error ? err.message : String(err)
317
+ throw new Error(`[internal] malformed SAMPLE.json at ${dir}: ${detail}`)
318
+ }
319
+ }
320
+
321
+ const FACT_SOURCES = ['input', 'payload', 'output'] as const
322
+ const FACT_FORMATS = ['text', 'number', 'boolean', 'percent'] as const
323
+
324
+ /**
325
+ * Read the optional `agents/<id>/FACTS.json` declarations that drive the
326
+ * Caseload decision panel's facts grid (label + dot-path into the run input,
327
+ * proposal payload, or run output). Accepts `{ "facts": [...] }` or a bare
328
+ * array. A missing file is fine; a malformed one fails generation LOUDLY
329
+ * (naming the dir). Must stay in sync with `lib/sdk/defineFileAgent.ts`
330
+ * `loadFacts`.
331
+ */
332
+ function discoverFacts(dir: string): DiscoveredFact[] | undefined {
333
+ const factsPath = path.join(dir, 'FACTS.json')
334
+ if (!fs.existsSync(factsPath)) return undefined
335
+ let parsed: unknown
336
+ try {
337
+ parsed = JSON.parse(fs.readFileSync(factsPath, 'utf8'))
338
+ } catch (err) {
339
+ const detail = err instanceof Error ? err.message : String(err)
340
+ throw new Error(`[internal] malformed FACTS.json at ${dir}: ${detail}`)
341
+ }
342
+ const entries = Array.isArray(parsed)
343
+ ? parsed
344
+ : parsed && typeof parsed === 'object' && Array.isArray((parsed as { facts?: unknown }).facts)
345
+ ? ((parsed as { facts: unknown[] }).facts)
346
+ : null
347
+ if (!entries) {
348
+ throw new Error(`[internal] malformed FACTS.json at ${dir}: expected an array or { "facts": [...] }`)
349
+ }
350
+ return entries.map((entry, index) => {
351
+ const fact = entry as Partial<DiscoveredFact> | null
352
+ if (
353
+ !fact ||
354
+ typeof fact.label !== 'string' ||
355
+ !fact.label.trim() ||
356
+ typeof fact.path !== 'string' ||
357
+ !fact.path.trim() ||
358
+ !FACT_SOURCES.includes(fact.source as (typeof FACT_SOURCES)[number])
359
+ ) {
360
+ throw new Error(
361
+ `[internal] malformed FACTS.json at ${dir}: entry ${index} needs label (string), source (${FACT_SOURCES.join('|')}), path (string)`,
362
+ )
363
+ }
364
+ if (fact.format !== undefined && !FACT_FORMATS.includes(fact.format as (typeof FACT_FORMATS)[number])) {
365
+ throw new Error(
366
+ `[internal] malformed FACTS.json at ${dir}: entry ${index} format must be one of ${FACT_FORMATS.join('|')}`,
367
+ )
368
+ }
369
+ return {
370
+ label: fact.label.trim(),
371
+ source: fact.source as DiscoveredFact['source'],
372
+ path: fact.path.trim(),
373
+ ...(fact.format !== undefined ? { format: fact.format as DiscoveredFact['format'] } : {}),
374
+ }
375
+ })
376
+ }
377
+
378
+ /**
379
+ * Sanitize a skill id into an OpenCode native skill `name`, which must match
380
+ * `^[a-z0-9]+(-[a-z0-9]+)*$`: lowercase, underscores/dots/spaces → hyphens,
381
+ * any other char → hyphen, collapse runs, trim leading/trailing hyphens. Must
382
+ * stay in sync with the loader's interpretation of skill ids.
383
+ */
384
+ function sanitizeSkillName(id: string): string {
385
+ const name = id
386
+ .toLowerCase()
387
+ .replace(/[^a-z0-9]+/g, '-')
388
+ .replace(/-+/g, '-')
389
+ .replace(/^-+/, '')
390
+ .replace(/-+$/, '')
391
+ return name || 'skill'
392
+ }
393
+
394
+ type SkillFrontmatter = {
395
+ id?: string
396
+ label?: string
397
+ description?: string
398
+ tools: string[]
399
+ }
400
+
401
+ /**
402
+ * Parse an agent-local SKILL.md frontmatter (in sync with
403
+ * `lib/sdk/skillMarkdown.ts`). Agent-local skills may omit `moduleId` and `id`
404
+ * (the dir name is then authoritative); only a parseable frontmatter block is
405
+ * required. Returns null when there is no frontmatter block.
406
+ */
407
+ function parseSkillMarkdown(raw: string): { meta: SkillFrontmatter; body: string } | null {
408
+ const match = FRONTMATTER_RE.exec(raw)
409
+ if (!match) return null
410
+ const [, frontmatterBlock, body] = match
411
+ const meta: SkillFrontmatter = { tools: [] }
412
+ const lines = frontmatterBlock.split('\n')
413
+ let index = 0
414
+ while (index < lines.length) {
415
+ const line = lines[index]
416
+ if (!line.trim()) {
417
+ index += 1
418
+ continue
419
+ }
420
+ const lineMatch = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line)
421
+ if (!lineMatch) {
422
+ index += 1
423
+ continue
424
+ }
425
+ const key = lineMatch[1]
426
+ const rawValue = lineMatch[2].trim()
427
+ if (key === 'tools') {
428
+ if (rawValue.startsWith('[')) {
429
+ meta.tools = parseInlineList(rawValue)
430
+ index += 1
431
+ continue
432
+ }
433
+ const items: string[] = []
434
+ index += 1
435
+ while (index < lines.length && /^\s*-\s+/.test(lines[index])) {
436
+ items.push(stripQuotes(lines[index].replace(/^\s*-\s+/, '')))
437
+ index += 1
438
+ }
439
+ meta.tools = items.filter(Boolean)
440
+ continue
441
+ }
442
+ if (key === 'id' || key === 'label' || key === 'description') {
443
+ meta[key] = stripQuotes(rawValue)
444
+ }
445
+ index += 1
446
+ }
447
+ return { meta, body: body.trim() }
448
+ }
449
+
450
+ function listExampleBodies(skillDir: string): string[] {
451
+ const examplesDir = path.join(skillDir, 'examples')
452
+ if (!fs.existsSync(examplesDir) || !fs.statSync(examplesDir).isDirectory()) return []
453
+ return fs
454
+ .readdirSync(examplesDir)
455
+ .filter((name) => name.endsWith('.md'))
456
+ .sort((a, b) => a.localeCompare(b))
457
+ .map((name) => fs.readFileSync(path.join(examplesDir, name), 'utf8').trim())
458
+ .filter(Boolean)
459
+ }
460
+
461
+ /**
462
+ * Basic parse validation of a script source (Phase 5). The script runs in the
463
+ * `isolated-vm` sandbox server-side (never copied to the container), so we only
464
+ * cheaply assert it defines a `run` function — a missing `run` would fail at
465
+ * runtime, so we fail generation early naming the file. We do NOT execute it.
466
+ */
467
+ function validateScriptSource(file: string, source: string): void {
468
+ const definesRun = /(^|\b)(async\s+)?function\s+run\b/.test(source) || /\brun\s*=/.test(source)
469
+ if (!definesRun) {
470
+ throw new Error(
471
+ `[internal] malformed agent script at ${file}: must define a \`run(args)\` function`,
472
+ )
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Read sandboxed scripts from a `scripts/` dir (`*.ts` / `*.js`), Phase 5. Each
478
+ * file's basename (no extension) is the script name; the raw source is carried
479
+ * as plain data. Validates each parses (basic). Ordered by filename.
480
+ */
481
+ function listScripts(scriptsDir: string): DiscoveredScript[] {
482
+ if (!fs.existsSync(scriptsDir) || !fs.statSync(scriptsDir).isDirectory()) return []
483
+ const scripts: DiscoveredScript[] = []
484
+ for (const name of fs
485
+ .readdirSync(scriptsDir)
486
+ .filter((entry) => entry.endsWith('.ts') || entry.endsWith('.js'))
487
+ .sort((a, b) => a.localeCompare(b))) {
488
+ const file = path.join(scriptsDir, name)
489
+ const source = fs.readFileSync(file, 'utf8')
490
+ validateScriptSource(file, source)
491
+ scripts.push({ name: name.replace(/\.(ts|js)$/, ''), source })
492
+ }
493
+ return scripts
494
+ }
495
+
496
+ /** Synthetic skill id under which an agent's LOCAL sandboxed tool files register. */
497
+ const AGENT_TOOLS_SKILL_ID = '__agent_tools__'
498
+ const TOOL_REF_RE = /^\s*\/\/\s*@ref:?\s+(\S+)/
499
+
500
+ /**
501
+ * Discover `agents/<id>/tools/*.ts` local tool files (Phase 5). Reference-form
502
+ * files (first line `// @ref <defineAiTool id>`) contribute the id to `refs`
503
+ * (unioned into the allowlist, flows through the central ACL + propose-only
504
+ * gate). Any other file is a LOCAL sandboxed tool: carried as a script run via
505
+ * `run_skill_script` under the synthetic `__agent_tools__` skill. Must stay in
506
+ * sync with `lib/sdk/defineFileAgent.ts` `loadToolFiles`.
507
+ */
508
+ function discoverToolFiles(agentDir: string): { refs: string[]; scripts: DiscoveredScript[] } {
509
+ const toolsBase = path.join(agentDir, 'tools')
510
+ if (!fs.existsSync(toolsBase) || !fs.statSync(toolsBase).isDirectory()) {
511
+ return { refs: [], scripts: [] }
512
+ }
513
+ const refs: string[] = []
514
+ const scripts: DiscoveredScript[] = []
515
+ for (const name of fs
516
+ .readdirSync(toolsBase)
517
+ .filter((entry) => entry.endsWith('.ts') || entry.endsWith('.js'))
518
+ .sort((a, b) => a.localeCompare(b))) {
519
+ const file = path.join(toolsBase, name)
520
+ const source = fs.readFileSync(file, 'utf8')
521
+ const firstLine = source.split('\n', 1)[0] ?? ''
522
+ const refMatch = TOOL_REF_RE.exec(firstLine)
523
+ if (refMatch) {
524
+ refs.push(refMatch[1])
525
+ continue
526
+ }
527
+ validateScriptSource(file, source)
528
+ scripts.push({ name: name.replace(/\.(ts|js)$/, ''), source })
529
+ }
530
+ return { refs, scripts }
531
+ }
532
+
533
+ /**
534
+ * Discover the agent's referenced skills under `agents/<id>/skills/<skill_id>/`.
535
+ * For each id in AGENT.md `skills:` we look up the matching dir (by frontmatter
536
+ * id or dir name). FAILS generation when a referenced SKILL.md is malformed
537
+ * (present dir but unparseable frontmatter). A referenced id with no dir is
538
+ * skipped (the loader warns identically at runtime).
539
+ */
540
+ function discoverAgentSkills(agentDir: string, skillIds: string[]): DiscoveredSkill[] {
541
+ if (skillIds.length === 0) return []
542
+ const skillsBase = path.join(agentDir, 'skills')
543
+ if (!fs.existsSync(skillsBase) || !fs.statSync(skillsBase).isDirectory()) return []
544
+
545
+ const bySkillId = new Map<string, DiscoveredSkill>()
546
+ for (const entry of fs.readdirSync(skillsBase, { withFileTypes: true })) {
547
+ if (!entry.isDirectory()) continue
548
+ const skillDir = path.join(skillsBase, entry.name)
549
+ const skillPath = path.join(skillDir, 'SKILL.md')
550
+ if (!fs.existsSync(skillPath)) continue
551
+ const parsed = parseSkillMarkdown(fs.readFileSync(skillPath, 'utf8'))
552
+ if (!parsed) {
553
+ throw new Error(`[internal] malformed SKILL.md at ${skillDir}: missing frontmatter block`)
554
+ }
555
+ const id = parsed.meta.id || entry.name
556
+ const templatePath = path.join(skillDir, 'TEMPLATE.md')
557
+ const template = fs.existsSync(templatePath)
558
+ ? fs.readFileSync(templatePath, 'utf8').trim() || undefined
559
+ : undefined
560
+ bySkillId.set(id, {
561
+ id,
562
+ description: parsed.meta.description ?? '',
563
+ instructions: parsed.body,
564
+ template,
565
+ examples: listExampleBodies(skillDir),
566
+ tools: parsed.meta.tools,
567
+ scripts: listScripts(path.join(skillDir, 'scripts')),
568
+ openCodeSkillName: sanitizeSkillName(id),
569
+ })
570
+ }
571
+
572
+ const resolved: DiscoveredSkill[] = []
573
+ for (const skillId of skillIds) {
574
+ const skill = bySkillId.get(skillId)
575
+ if (skill) resolved.push(skill)
576
+ }
577
+ return resolved
578
+ }
579
+
580
+ /**
581
+ * Discover and validate the sub-agents under `agents/<id>/sub-agents/<subid>/`
582
+ * (Phase 4). Each is a full file agent (AGENT.md + OUTCOME.md) constrained to:
583
+ * 1. OUTCOME `kind: research` (sub-agents inform; only the primary proposes);
584
+ * 2. NO `subAgents` of its own (depth cap = 1).
585
+ * FAILS generation (throws, naming the dir) on a malformed sub-agent OR a
586
+ * constraint violation — in sync with `lib/sdk/defineFileAgent.ts` `loadSubAgentDir`.
587
+ */
588
+ function discoverSubAgents(agentDir: string): DiscoveredAgent[] {
589
+ const base = path.join(agentDir, 'sub-agents')
590
+ if (!fs.existsSync(base) || !fs.statSync(base).isDirectory()) return []
591
+ const subAgents: DiscoveredAgent[] = []
592
+ for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
593
+ if (!entry.isDirectory() || entry.name === '__tests__') continue
594
+ const dir = path.join(base, entry.name)
595
+ const agentMdPath = path.join(dir, 'AGENT.md')
596
+ const outcomePath = path.join(dir, 'OUTCOME.md')
597
+ if (!fs.existsSync(agentMdPath) || !fs.existsSync(outcomePath)) {
598
+ throw new Error(
599
+ `[internal] malformed sub-agent at ${dir}: both AGENT.md and OUTCOME.md are required`,
600
+ )
601
+ }
602
+ const agent = parseAgentMarkdown(fs.readFileSync(agentMdPath, 'utf8'))
603
+ if (!agent) {
604
+ throw new Error(`[internal] malformed AGENT.md at ${dir}: missing id/label/description`)
605
+ }
606
+ const outcome = parseOutcomeMarkdown(fs.readFileSync(outcomePath, 'utf8'))
607
+ if (!outcome) {
608
+ throw new Error(`[internal] malformed OUTCOME.md at ${dir}: missing kind or JSON-Schema block`)
609
+ }
610
+ if (outcome.schema) assertOutcomeSchemaSupported(outcome.schema, dir)
611
+ if (outcome.kind !== 'research') {
612
+ throw new Error(
613
+ `[internal] sub-agent at ${dir} must be research (kind: research); only the primary proposes`,
614
+ )
615
+ }
616
+ if (agent.subAgents.length > 0) {
617
+ throw new Error(
618
+ `[internal] sub-agent at ${dir} may not declare subAgents (depth cap = 1); sub-agents may not delegate further`,
619
+ )
620
+ }
621
+ const skillsContent = discoverAgentSkills(dir, agent.skills)
622
+ const skillTools = skillsContent.flatMap((skill) => skill.tools)
623
+ const toolFiles = discoverToolFiles(dir)
624
+ const effectiveSkillsContent =
625
+ toolFiles.scripts.length > 0
626
+ ? [
627
+ ...skillsContent,
628
+ {
629
+ id: AGENT_TOOLS_SKILL_ID,
630
+ description: '',
631
+ instructions: '',
632
+ examples: [],
633
+ tools: [],
634
+ scripts: toolFiles.scripts,
635
+ openCodeSkillName: sanitizeSkillName(AGENT_TOOLS_SKILL_ID),
636
+ },
637
+ ]
638
+ : skillsContent
639
+ const effectiveTools = Array.from(
640
+ new Set([...agent.tools, ...skillTools, ...toolFiles.refs]),
641
+ )
642
+ subAgents.push({
643
+ moduleId: '',
644
+ dir,
645
+ id: agent.id!,
646
+ label: agent.label!,
647
+ description: agent.description!,
648
+ instructions: agent.instructions,
649
+ resultKind: outcome.kind,
650
+ outcomeSchema: outcome.schema,
651
+ outcomeProse: outcome.prose,
652
+ tools: effectiveTools,
653
+ skills: agent.skills,
654
+ subAgents: [],
655
+ openCodeAgentName: sanitizeAgentName(agent.id!),
656
+ skillsContent: effectiveSkillsContent,
657
+ subAgentsContent: [],
658
+ mode: 'subagent',
659
+ maxSteps: agent.maxSteps,
660
+ provider: agent.provider,
661
+ model: agent.model,
662
+ sampleInput: discoverSampleInput(dir),
663
+ facts: discoverFacts(dir),
664
+ tokenUsage: discoverAgentTokenUsage(dir),
665
+ sourceFiles: collectAgentFiles(dir),
666
+ })
667
+ }
668
+ return subAgents
669
+ }
670
+
671
+ // --- Token accounting (MIRRORS lib/tokens/computeAgentTokenUsage.ts) ---
672
+
673
+ const TOKEN_TOOL_EXTENSIONS = ['.ts', '.js']
674
+
675
+ /**
676
+ * MIRRORS `toPosixRelativePath` in lib/tokens/computeAgentTokenUsage.ts.
677
+ *
678
+ * These relative paths are baked into `file-agents.generated.ts` and split into
679
+ * a folder tree in the browser, so they must be `/`-separated whatever machine
680
+ * ran `yarn generate`. `path.join` answers with the native separator, which on
681
+ * Windows baked backslashes into the artifact and flattened the Files tab.
682
+ */
683
+ function toPosixRelativePath(relativePath: string): string {
684
+ return relativePath.split(path.sep).join('/')
685
+ }
686
+
687
+ function tokenCountFile(agentDir: string, relativePath: string): TokenizedFile | null {
688
+ const absolute = path.join(agentDir, relativePath)
689
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) return null
690
+ return { path: toPosixRelativePath(relativePath), tokens: countTokens(fs.readFileSync(absolute, 'utf8')) }
691
+ }
692
+
693
+ function tokenListFiles(dir: string, extensions?: string[]): string[] {
694
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return []
695
+ return fs
696
+ .readdirSync(dir, { withFileTypes: true })
697
+ .filter((entry) => entry.isFile())
698
+ .map((entry) => entry.name)
699
+ .filter((name) => !extensions || extensions.includes(path.extname(name)))
700
+ .sort((a, b) => a.localeCompare(b))
701
+ }
702
+
703
+ function tokenListDirs(dir: string): string[] {
704
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return []
705
+ return fs
706
+ .readdirSync(dir, { withFileTypes: true })
707
+ .filter((entry) => entry.isDirectory() && entry.name !== '__tests__')
708
+ .map((entry) => entry.name)
709
+ .sort((a, b) => a.localeCompare(b))
710
+ }
711
+
712
+ function tokenComputeSkills(agentDir: string): SkillTokenUsage[] {
713
+ return tokenListDirs(path.join(agentDir, 'skills')).map((skillId) => {
714
+ const skillRel = path.join('skills', skillId)
715
+ const files: TokenizedFile[] = []
716
+ for (const name of ['SKILL.md', 'TEMPLATE.md']) {
717
+ const file = tokenCountFile(agentDir, path.join(skillRel, name))
718
+ if (file) files.push(file)
719
+ }
720
+ for (const subdir of ['examples', 'scripts']) {
721
+ for (const name of tokenListFiles(path.join(agentDir, skillRel, subdir))) {
722
+ const file = tokenCountFile(agentDir, path.join(skillRel, subdir, name))
723
+ if (file) files.push(file)
724
+ }
725
+ }
726
+ return { id: skillId, tokens: files.reduce((sum, f) => sum + f.tokens, 0), files }
727
+ })
728
+ }
729
+
730
+ function tokenComputeTools(agentDir: string): ToolTokenUsage[] {
731
+ return tokenListFiles(path.join(agentDir, 'tools'), TOKEN_TOOL_EXTENSIONS).map((name) => {
732
+ const rel = path.join('tools', name)
733
+ return {
734
+ name: name.replace(/\.[^.]+$/, ''),
735
+ path: toPosixRelativePath(rel),
736
+ tokens: countTokens(fs.readFileSync(path.join(agentDir, rel), 'utf8')),
737
+ }
738
+ })
739
+ }
740
+
741
+ function discoverAgentTokenUsage(agentDir: string, depth = 0): FileAgentTokenUsage {
742
+ const agent = tokenCountFile(agentDir, 'AGENT.md')?.tokens ?? 0
743
+ const outcome = tokenCountFile(agentDir, 'OUTCOME.md')?.tokens ?? 0
744
+ const skills = tokenComputeSkills(agentDir)
745
+ const tools = tokenComputeTools(agentDir)
746
+
747
+ const subAgents: SubAgentTokenUsage[] = []
748
+ if (depth === 0) {
749
+ for (const subId of tokenListDirs(path.join(agentDir, 'sub-agents'))) {
750
+ const nested = discoverAgentTokenUsage(path.join(agentDir, 'sub-agents', subId), depth + 1)
751
+ subAgents.push({ id: subId, tokens: nested.total })
752
+ }
753
+ }
754
+
755
+ const self =
756
+ agent +
757
+ outcome +
758
+ skills.reduce((sum, s) => sum + s.tokens, 0) +
759
+ tools.reduce((sum, t) => sum + t.tokens, 0)
760
+ const total = self + subAgents.reduce((sum, s) => sum + s.tokens, 0)
761
+
762
+ return { total, self, agent, outcome, skills, tools, subAgents }
763
+ }
764
+
765
+ // --- Raw source-file collection (feeds the read-only Files tab, #12 files) ---
766
+
767
+ /** Files present at the agent root but NOT part of the constructed prompt. */
768
+ const AGENT_AUX_FILES = ['SAMPLE.json', 'FACTS.json']
769
+
770
+ /**
771
+ * Walks every construction file of a file-defined agent and returns its raw
772
+ * content + `o200k_base` token count, paths relative to the agent root. Recurses
773
+ * `sub-agents/<id>/` once (depth cap = 1, matching `discoverAgentTokenUsage`),
774
+ * prefixing nested paths with `sub-agents/<id>/`. The `inContext` sum of a tree
775
+ * equals `discoverAgentTokenUsage(...).total`, so the Files tab and the baked
776
+ * token estimate stay consistent.
777
+ */
778
+ function collectAgentFiles(agentDir: string, prefix = '', depth = 0): FileAgentSourceFile[] {
779
+ const files: FileAgentSourceFile[] = []
780
+ const add = (relativePath: string, inContext: boolean): void => {
781
+ const absolute = path.join(agentDir, relativePath)
782
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) return
783
+ const content = fs.readFileSync(absolute, 'utf8')
784
+ files.push({ path: toPosixRelativePath(path.join(prefix, relativePath)), content, tokens: countTokens(content), inContext })
785
+ }
786
+ add('AGENT.md', true)
787
+ add('OUTCOME.md', true)
788
+ for (const name of AGENT_AUX_FILES) add(name, false)
789
+ for (const skillId of tokenListDirs(path.join(agentDir, 'skills'))) {
790
+ const skillRel = path.join('skills', skillId)
791
+ for (const name of ['SKILL.md', 'TEMPLATE.md']) add(path.join(skillRel, name), true)
792
+ for (const subdir of ['examples', 'scripts']) {
793
+ for (const name of tokenListFiles(path.join(agentDir, skillRel, subdir))) {
794
+ add(path.join(skillRel, subdir, name), true)
795
+ }
796
+ }
797
+ }
798
+ for (const name of tokenListFiles(path.join(agentDir, 'tools'), TOKEN_TOOL_EXTENSIONS)) {
799
+ add(path.join('tools', name), true)
800
+ }
801
+ if (depth === 0) {
802
+ for (const subId of tokenListDirs(path.join(agentDir, 'sub-agents'))) {
803
+ files.push(
804
+ ...collectAgentFiles(
805
+ path.join(agentDir, 'sub-agents', subId),
806
+ path.join(prefix, 'sub-agents', subId),
807
+ depth + 1,
808
+ ),
809
+ )
810
+ }
811
+ }
812
+ return files
813
+ }
814
+
815
+ function renderOpenCodeSkillFile(skill: DiscoveredSkill): string {
816
+ const frontmatter = [
817
+ '---',
818
+ `name: ${skill.openCodeSkillName}`,
819
+ `description: ${JSON.stringify(skill.description)}`,
820
+ '---',
821
+ ]
822
+ return `${frontmatter.join('\n')}\n${skill.instructions.trim()}\n`
823
+ }
824
+
825
+ /** Walk up from a known in-repo path until a dir containing both `docker` and `packages` is found. */
826
+ function findRepoRoot(start: string): string | null {
827
+ let current = path.resolve(start)
828
+ for (let depth = 0; depth < 40; depth += 1) {
829
+ if (
830
+ fs.existsSync(path.join(current, 'docker', 'opencode')) &&
831
+ fs.existsSync(path.join(current, 'packages'))
832
+ ) {
833
+ return current
834
+ }
835
+ const parent = path.dirname(current)
836
+ if (parent === current) break
837
+ current = parent
838
+ }
839
+ return null
840
+ }
841
+
842
+ function listAgentDirs(agentsBase: string): string[] {
843
+ if (!fs.existsSync(agentsBase)) return []
844
+ return fs
845
+ .readdirSync(agentsBase, { withFileTypes: true })
846
+ .filter((entry) => entry.isDirectory() && entry.name !== '__tests__')
847
+ .map((entry) => path.join(agentsBase, entry.name))
848
+ }
849
+
850
+ function renderToolPermissionLine(toolName: string): string {
851
+ return ` ${JSON.stringify(toolName)}: true`
852
+ }
853
+
854
+ const ORCHESTRATOR_MODULE_ID = 'agent_orchestrator'
855
+ // OpenCode names every MCP tool `<serverKey>_<toolName with dots→underscores>`
856
+ // (verified against the running image). Keep in sync with
857
+ // `lib/sdk/defineFileAgent.ts`.
858
+ const OPENCODE_MCP_SERVER_KEY = 'open-mercato'
859
+ const CORE_FILE_AGENT_TOOL_IDS = [
860
+ 'agent_orchestrator.submit_outcome',
861
+ 'agent_orchestrator.load_skill',
862
+ 'agent_orchestrator.run_skill_script',
863
+ ]
864
+ /**
865
+ * MCP tool a file-agent orchestrator uses to run a sub-agent SERVER-SIDE. We
866
+ * route delegation through this tool instead of OpenCode's built-in `task` tool:
867
+ * `task` spawns each sub-agent in a fresh session that never receives the run's
868
+ * `_sessionToken`, so the sub-agent's MCP calls fail with `no_active_run`. The
869
+ * delegate tool runs the sub-agent via `agentRuntime.run`, which mints its own
870
+ * per-run session token + correlation, so sub-agent tool calls authenticate.
871
+ */
872
+ const DELEGATE_AGENT_TOOL_ID = 'agent_orchestrator.delegate_agent'
873
+ function toOpenCodeMcpToolId(omToolId: string): string {
874
+ return `${OPENCODE_MCP_SERVER_KEY}_${omToolId.replace(/\./g, '_')}`
875
+ }
876
+
877
+ /**
878
+ * Render an OpenCode agent .md file. Must stay in sync with
879
+ * `lib/sdk/defineFileAgent.ts` `renderOpenCodeAgentFile`. The built-in OpenCode
880
+ * `task` tool is NEVER granted (`permission.task: deny` for every agent): it
881
+ * spawns each sub-agent in a fresh session that does not inherit the run's
882
+ * `_sessionToken`, so the sub-agent's MCP calls fail with `no_active_run`.
883
+ * Instead, a primary that declares sub-agents is granted the
884
+ * `agent_orchestrator.delegate_agent` MCP tool (runs each sub-agent server-side
885
+ * under its own minted session token) and gains a "Sub-agents" prompt section
886
+ * nudging parallel fan-out through that tool. Sub-agent files (`mode: subagent`)
887
+ * get neither the delegate tool nor `task` (depth cap = 1).
888
+ */
889
+ function renderOpenCodeAgentFile(agent: DiscoveredAgent): string {
890
+ const subAgentIds =
891
+ agent.mode === 'primary' ? agent.subAgentsContent.map((sub) => sub.id) : []
892
+ const hasSubAgents = subAgentIds.length > 0
893
+ const omMcpToolIds = Array.from(
894
+ new Set([
895
+ ...agent.tools,
896
+ ...CORE_FILE_AGENT_TOOL_IDS,
897
+ ...(hasSubAgents ? [DELEGATE_AGENT_TOOL_ID] : []),
898
+ ]),
899
+ )
900
+ const allowedTools = omMcpToolIds.map(toOpenCodeMcpToolId)
901
+ const modelLine =
902
+ agent.provider && agent.model
903
+ ? `model: ${agent.provider}/${agent.model}`
904
+ : agent.model
905
+ ? `model: ${agent.model}`
906
+ : null
907
+ const frontmatterLines = [
908
+ '---',
909
+ `description: ${JSON.stringify(agent.description)}`,
910
+ ...(modelLine ? [modelLine] : []),
911
+ `mode: ${agent.mode}`,
912
+ 'tools:',
913
+ ' "*": false',
914
+ ...allowedTools.map(renderToolPermissionLine),
915
+ 'permission:',
916
+ ' write: deny',
917
+ ' edit: deny',
918
+ ' bash: deny',
919
+ ' task: deny',
920
+ '---',
921
+ ]
922
+ const terminalInstruction =
923
+ `Finish by calling the \`${toOpenCodeMcpToolId('agent_orchestrator.submit_outcome')}\` tool with a value matching the outcome contract (pass it as the \`outcome\` argument). You MUST call the tool — do not answer in prose or emit the result as a code block.`
924
+ const delegateToolName = toOpenCodeMcpToolId(DELEGATE_AGENT_TOOL_ID)
925
+ const subAgentSection = hasSubAgents
926
+ ? `## Sub-agents\nDelegate a sub-task by calling the \`${delegateToolName}\` tool with \`{ agentId: "<sub-agent id>", input: <sub-task input object> }\`. Issue multiple \`${delegateToolName}\` calls in the SAME step to fan out in parallel, then combine their results before submitting your outcome. Available sub-agents: ${subAgentIds.join(', ')}.`
927
+ : null
928
+ // Inject the OUTCOME contract so the agent SEES the exact shape it must submit
929
+ // (otherwise it guesses and learns the shape only from validation errors). Keep
930
+ // in sync with lib/sdk/defineFileAgent.ts renderOutcomeSection.
931
+ const outcomeTarget = agent.resultKind === 'proposal' ? 'the `proposal` object' : 'the `data` object'
932
+ const outcomeSection =
933
+ agent.resultKind === 'artifact'
934
+ ? [
935
+ '## Outcome contract',
936
+ "Write the files you produce into the run's `out/` directory, then pass this shape as the `outcome` argument of the submit_outcome tool:",
937
+ '',
938
+ '```json',
939
+ JSON.stringify(
940
+ {
941
+ artifacts: [
942
+ { fileName: 'report.pdf', mimeType: 'application/pdf', caption: 'What this file is' },
943
+ ],
944
+ summary: 'One sentence about what you produced.',
945
+ },
946
+ null,
947
+ 2,
948
+ ),
949
+ '```',
950
+ ...(agent.outcomeProse ? ['', agent.outcomeProse] : []),
951
+ ].join('\n')
952
+ : [
953
+ '## Outcome contract',
954
+ `Your result MUST match this JSON Schema (${outcomeTarget}). Pass it as the \`outcome\` argument of the submit_outcome tool, as a JSON object (not a string):`,
955
+ '',
956
+ '```json',
957
+ JSON.stringify(agent.outcomeSchema, null, 2),
958
+ '```',
959
+ ...(agent.outcomeProse ? ['', agent.outcomeProse] : []),
960
+ ].join('\n')
961
+ const body = [
962
+ agent.instructions.trim(),
963
+ ...(subAgentSection ? [subAgentSection] : []),
964
+ outcomeSection,
965
+ terminalInstruction,
966
+ ]
967
+ .filter(Boolean)
968
+ .join('\n\n')
969
+ return `${frontmatterLines.join('\n')}\n${body}\n`
970
+ }
971
+
972
+ /**
973
+ * Render one agent descriptor as a key-per-line object literal at `indent`.
974
+ * Used for both top-level agents and nested sub-agents (Phase 4). A primary that
975
+ * declares sub-agents emits them as a nested `subAgentDescriptors` array so
976
+ * `ensureAgentsLoaded` can register each sub-agent too (research, individually
977
+ * runnable file agents). A nested sub-agent carries no `subAgentDescriptors`
978
+ * (depth cap = 1).
979
+ */
980
+ function renderDescriptor(agent: DiscoveredAgent, indent: string): string {
981
+ const optional: string[] = []
982
+ if (agent.subAgentsContent.length > 0) {
983
+ const nested = agent.subAgentsContent
984
+ .map((sub) => renderDescriptor(sub, `${indent} `))
985
+ .join('\n')
986
+ optional.push(`${indent} subAgentDescriptors: [\n${nested}\n${indent} ],`)
987
+ }
988
+ if (agent.maxSteps != null) optional.push(`${indent} maxSteps: ${agent.maxSteps},`)
989
+ if (agent.provider != null) optional.push(`${indent} provider: ${JSON.stringify(agent.provider)},`)
990
+ if (agent.model != null) optional.push(`${indent} model: ${JSON.stringify(agent.model)},`)
991
+ if (agent.sampleInput !== undefined) {
992
+ optional.push(`${indent} sampleInput: ${JSON.stringify(agent.sampleInput)},`)
993
+ }
994
+ if (agent.facts !== undefined) {
995
+ optional.push(`${indent} facts: ${JSON.stringify(agent.facts)},`)
996
+ }
997
+ const skillsContent = agent.skillsContent.map((skill) => ({
998
+ id: skill.id,
999
+ instructions: skill.instructions,
1000
+ ...(skill.template != null ? { template: skill.template } : {}),
1001
+ examples: skill.examples,
1002
+ tools: skill.tools,
1003
+ ...(skill.scripts.length > 0 ? { scripts: skill.scripts } : {}),
1004
+ }))
1005
+ return [
1006
+ `${indent}{`,
1007
+ `${indent} id: ${JSON.stringify(agent.id)},`,
1008
+ `${indent} moduleId: ${JSON.stringify(agent.moduleId)},`,
1009
+ `${indent} label: ${JSON.stringify(agent.label)},`,
1010
+ `${indent} description: ${JSON.stringify(agent.description)},`,
1011
+ `${indent} instructions: ${JSON.stringify(agent.instructions)},`,
1012
+ `${indent} resultKind: ${JSON.stringify(agent.resultKind)},`,
1013
+ ...(agent.outcomeSchema ? [`${indent} outcomeSchema: ${JSON.stringify(agent.outcomeSchema)},`] : []),
1014
+ `${indent} tools: ${JSON.stringify(agent.tools)},`,
1015
+ `${indent} skills: ${JSON.stringify(agent.skills)},`,
1016
+ `${indent} subAgents: ${JSON.stringify(agent.subAgents)},`,
1017
+ `${indent} openCodeAgentName: ${JSON.stringify(agent.openCodeAgentName)},`,
1018
+ `${indent} skillsContent: ${JSON.stringify(skillsContent)},`,
1019
+ `${indent} tokenUsage: ${JSON.stringify(agent.tokenUsage)},`,
1020
+ `${indent} sourceFiles: ${JSON.stringify(agent.sourceFiles)},`,
1021
+ ...optional,
1022
+ `${indent}},`,
1023
+ ].join('\n')
1024
+ }
1025
+
1026
+ function renderManifest(agents: DiscoveredAgent[]): string {
1027
+ const descriptors = agents.map((agent) => renderDescriptor(agent, ' ')).join('\n')
1028
+
1029
+ return `// AUTO-GENERATED by mercato generate registry — DO NOT EDIT BY HAND.
1030
+ //
1031
+ // Committed, generator-owned manifest of file-defined (OpenCode) agents,
1032
+ // discovered from \`agents/<id>/\` directories across every enabled module. It
1033
+ // stores PLAIN data (raw JSON-Schema, not a Zod instance) so this file is pure
1034
+ // data and travels with the repo (survives \`yarn clean-generated\`).
1035
+ // \`ensureAgentsLoaded()\` recompiles each \`outcomeSchema\` to Zod via
1036
+ // \`compileOutcome\` at load time and registers it with \`runtime:'opencode'\`.
1037
+ //
1038
+ // Regenerate with \`yarn generate\`.
1039
+ import type { JsonSchemaNode, OutcomeKind } from '../lib/sdk/outcomeSchema'
1040
+ import type { AgentTokenUsage, FileAgentFile } from '../lib/tokens/types'
1041
+
1042
+ export type FileAgentScript = {
1043
+ name: string
1044
+ source: string
1045
+ }
1046
+
1047
+ export type FileAgentSkillContent = {
1048
+ id: string
1049
+ instructions: string
1050
+ template?: string
1051
+ examples: string[]
1052
+ tools: string[]
1053
+ /**
1054
+ * Sandboxed helper scripts (Phase 5). Carried as plain source; run server-side
1055
+ * in the Code Mode \`isolated-vm\` sandbox via the \`run_skill_script\` MCP tool.
1056
+ * Never copied to the OpenCode container. The synthetic skill id
1057
+ * \`__agent_tools__\` carries an agent's LOCAL \`tools/*.ts\` sources.
1058
+ */
1059
+ scripts?: FileAgentScript[]
1060
+ }
1061
+
1062
+ export type FileAgentFact = {
1063
+ /** Human label shown in the Caseload facts grid. */
1064
+ label: string
1065
+ /** Where to resolve the value: run input, proposal payload, or run output. */
1066
+ source: 'input' | 'payload' | 'output'
1067
+ /** Dot-path into the source (array indexes allowed), e.g. "actions.0.payload.amount". */
1068
+ path: string
1069
+ format?: 'text' | 'number' | 'boolean' | 'percent'
1070
+ }
1071
+
1072
+ export type FileAgentDescriptor = {
1073
+ id: string
1074
+ moduleId: string
1075
+ label: string
1076
+ description: string
1077
+ instructions: string
1078
+ resultKind: OutcomeKind
1079
+ outcomeSchema?: JsonSchemaNode
1080
+ tools: string[]
1081
+ skills: string[]
1082
+ subAgents: string[]
1083
+ openCodeAgentName: string
1084
+ skillsContent?: FileAgentSkillContent[]
1085
+ /**
1086
+ * Baked token-usage estimate of the agent's construction files (AGENT.md,
1087
+ * OUTCOME.md, skills, tools, sub-agents), counted with the shared
1088
+ * \`o200k_base\` tokenizer. Surfaced on the Agent detail page and the
1089
+ * \`agent_orchestrator token-usage\` CLI. An estimate, not an exact count.
1090
+ */
1091
+ tokenUsage?: AgentTokenUsage
1092
+ /**
1093
+ * Baked raw content of every construction file (AGENT.md, OUTCOME.md, skills,
1094
+ * tools, sub-agents, and auxiliary SAMPLE.json/FACTS.json), for the read-only
1095
+ * Files tab. Each carries its \`o200k_base\` token count; paths are relative to
1096
+ * the agent root (sub-agent files prefixed \`sub-agents/<id>/\`). Baked so the
1097
+ * runtime never reads agent dirs from disk.
1098
+ */
1099
+ sourceFiles?: FileAgentFile[]
1100
+ /**
1101
+ * Nested descriptors for this agent's sub-agents (Phase 4). Each is an
1102
+ * research-kind, non-delegating file agent registered individually (depth cap =
1103
+ * 1). Absent for agents without sub-agents and for sub-agents themselves.
1104
+ */
1105
+ subAgentDescriptors?: FileAgentDescriptor[]
1106
+ maxSteps?: number
1107
+ provider?: string
1108
+ model?: string
1109
+ /**
1110
+ * Optional example \`input\` for the Playground "Insert sample" button, read
1111
+ * from \`agents/<id>/SAMPLE.json\`. Any JSON value the agent accepts as input.
1112
+ */
1113
+ sampleInput?: unknown
1114
+ /**
1115
+ * Optional fact declarations for the Caseload decision panel, read from
1116
+ * \`agents/<id>/FACTS.json\`. Each maps a labelled dot-path into the run
1117
+ * input / proposal payload / run output.
1118
+ */
1119
+ facts?: FileAgentFact[]
1120
+ }
1121
+
1122
+ export const fileAgentDescriptors: FileAgentDescriptor[] = [${
1123
+ descriptors ? `\n${descriptors}\n` : ''
1124
+ }]
1125
+ `
1126
+ }
1127
+
1128
+ export function createAgentFilesExtension(): GeneratorExtension {
1129
+ const discovered: DiscoveredAgent[] = []
1130
+ const seenIds = new Set<string>()
1131
+ let repoRoot: string | null = null
1132
+ let sawOrchestratorModule = false
1133
+
1134
+ function scanAgentsTree(moduleId: string, baseDir: string): void {
1135
+ for (const dir of listAgentDirs(baseDir)) {
1136
+ const agentMdPath = path.join(dir, 'AGENT.md')
1137
+ const outcomePath = path.join(dir, 'OUTCOME.md')
1138
+ // Only treat a dir as an agent if at least one convention file exists.
1139
+ const hasAgentMd = fs.existsSync(agentMdPath)
1140
+ const hasOutcome = fs.existsSync(outcomePath)
1141
+ if (!hasAgentMd && !hasOutcome) continue
1142
+ if (!hasAgentMd || !hasOutcome) {
1143
+ throw new Error(
1144
+ `[internal] malformed file agent at ${dir}: both AGENT.md and OUTCOME.md are required`,
1145
+ )
1146
+ }
1147
+ const agent = parseAgentMarkdown(fs.readFileSync(agentMdPath, 'utf8'))
1148
+ if (!agent) {
1149
+ throw new Error(`[internal] malformed AGENT.md at ${dir}: missing id/label/description`)
1150
+ }
1151
+ const outcome = parseOutcomeMarkdown(fs.readFileSync(outcomePath, 'utf8'))
1152
+ if (!outcome) {
1153
+ throw new Error(`[internal] malformed OUTCOME.md at ${dir}: missing kind or JSON-Schema block`)
1154
+ }
1155
+ if (outcome.schema) assertOutcomeSchemaSupported(outcome.schema, dir)
1156
+ // The id is the registry/dup key AND the OpenCode `agent` message field —
1157
+ // constrain it to a safe charset (module.entity-style: lowercase alnum + . _ -).
1158
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(agent.id!)) {
1159
+ throw new Error(
1160
+ `[internal] invalid agent id "${agent.id}" at ${dir}: use lowercase [a-z0-9._-] (e.g. "module.agent")`,
1161
+ )
1162
+ }
1163
+ if (seenIds.has(agent.id!)) {
1164
+ // Surface accidental collisions at generate time instead of silently
1165
+ // dropping the later agent (the registry would only skip it at load).
1166
+ console.warn(`[internal] duplicate file-agent id "${agent.id}" at ${dir}; keeping the first, skipping this one.`)
1167
+ continue
1168
+ }
1169
+ seenIds.add(agent.id!)
1170
+ // Phase 3: resolve agent-local skills and UNION their read-only tools into
1171
+ // the agent allowlist (deduped), matching the loader.
1172
+ const skillsContent = discoverAgentSkills(dir, agent.skills)
1173
+ const skillTools = skillsContent.flatMap((skill) => skill.tools)
1174
+ // Phase 5: local tool files — reference ids union into the allowlist; local
1175
+ // sandboxed tool sources register under the synthetic `__agent_tools__` skill.
1176
+ const toolFiles = discoverToolFiles(dir)
1177
+ const effectiveSkillsContent =
1178
+ toolFiles.scripts.length > 0
1179
+ ? [
1180
+ ...skillsContent,
1181
+ {
1182
+ id: AGENT_TOOLS_SKILL_ID,
1183
+ description: '',
1184
+ instructions: '',
1185
+ examples: [],
1186
+ tools: [],
1187
+ scripts: toolFiles.scripts,
1188
+ openCodeSkillName: sanitizeSkillName(AGENT_TOOLS_SKILL_ID),
1189
+ },
1190
+ ]
1191
+ : skillsContent
1192
+ const effectiveTools = Array.from(
1193
+ new Set([...agent.tools, ...skillTools, ...toolFiles.refs]),
1194
+ )
1195
+ // Phase 4: discover + validate sub-agents (throws on a malformed/proposal/
1196
+ // self-delegating sub-agent).
1197
+ const subAgentsContent = discoverSubAgents(dir)
1198
+ discovered.push({
1199
+ moduleId,
1200
+ dir,
1201
+ id: agent.id!,
1202
+ label: agent.label!,
1203
+ description: agent.description!,
1204
+ instructions: agent.instructions,
1205
+ resultKind: outcome.kind,
1206
+ outcomeSchema: outcome.schema,
1207
+ outcomeProse: outcome.prose,
1208
+ tools: effectiveTools,
1209
+ skills: agent.skills,
1210
+ subAgents: agent.subAgents,
1211
+ openCodeAgentName: sanitizeAgentName(agent.id!),
1212
+ skillsContent: effectiveSkillsContent,
1213
+ subAgentsContent,
1214
+ mode: 'primary',
1215
+ maxSteps: agent.maxSteps,
1216
+ provider: agent.provider,
1217
+ model: agent.model,
1218
+ sampleInput: discoverSampleInput(dir),
1219
+ facts: discoverFacts(dir),
1220
+ tokenUsage: discoverAgentTokenUsage(dir),
1221
+ sourceFiles: collectAgentFiles(dir),
1222
+ })
1223
+ }
1224
+ }
1225
+
1226
+ return {
1227
+ id: 'registry.agent-files',
1228
+ outputFiles: [],
1229
+ scanModule(ctx: ModuleScanContext) {
1230
+ if (ctx.moduleId === ORCHESTRATOR_MODULE_ID) sawOrchestratorModule = true
1231
+ if (!repoRoot) {
1232
+ repoRoot = findRepoRoot(ctx.roots.pkgBase) ?? findRepoRoot(ctx.roots.appBase)
1233
+ }
1234
+ const pkgScanBase = resolveStandaloneSourceMirrorBase(ctx.roots.pkgBase) ?? ctx.roots.pkgBase
1235
+ scanAgentsTree(ctx.moduleId, path.join(pkgScanBase, 'agents'))
1236
+ scanAgentsTree(ctx.moduleId, path.join(ctx.roots.appBase, 'agents'))
1237
+ },
1238
+ generateOutput() {
1239
+ const sorted = [...discovered].sort((a, b) => a.id.localeCompare(b.id))
1240
+
1241
+ if (!repoRoot) {
1242
+ // No module roots resolved to a repo (e.g. an isolated unit test). Skip
1243
+ // the fs side effect; the empty-Map return keeps the contract intact.
1244
+ return new Map<string, string>()
1245
+ }
1246
+
1247
+ if (!sawOrchestratorModule && sorted.length === 0) {
1248
+ // Neither the orchestrator (the manifest's only consumer) nor any agent
1249
+ // module is part of this run — the enterprise agents flag is off. The
1250
+ // committed manifest and `docker/opencode/**` files belong to modules
1251
+ // that are merely switched off, so pruning them here would delete
1252
+ // tracked artifacts on every `yarn generate`. Leave the tree untouched.
1253
+ return new Map<string, string>()
1254
+ }
1255
+
1256
+ const manifestPath = path.join(
1257
+ repoRoot,
1258
+ 'packages',
1259
+ 'enterprise',
1260
+ 'src',
1261
+ 'modules',
1262
+ 'agent_orchestrator',
1263
+ 'generated',
1264
+ 'file-agents.generated.ts',
1265
+ )
1266
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true })
1267
+ fs.writeFileSync(manifestPath, renderManifest(sorted), 'utf8')
1268
+
1269
+ const dockerAgentsDir = path.join(repoRoot, 'docker', 'opencode', 'agents')
1270
+ fs.mkdirSync(dockerAgentsDir, { recursive: true })
1271
+ const desiredFiles = new Map<string, string>()
1272
+ for (const agent of sorted) {
1273
+ desiredFiles.set(`${agent.openCodeAgentName}.md`, renderOpenCodeAgentFile(agent))
1274
+ // Phase 4: emit each sub-agent as its own `mode: subagent` file so
1275
+ // OpenCode can reach it via the primary's whitelisted `task` tool.
1276
+ for (const sub of agent.subAgentsContent) {
1277
+ desiredFiles.set(`${sub.openCodeAgentName}.md`, renderOpenCodeAgentFile(sub))
1278
+ }
1279
+ }
1280
+ // Idempotent: remove stale generated agent files, write the current set.
1281
+ for (const existing of fs.existsSync(dockerAgentsDir) ? fs.readdirSync(dockerAgentsDir) : []) {
1282
+ if (existing.endsWith('.md') && !desiredFiles.has(existing)) {
1283
+ fs.rmSync(path.join(dockerAgentsDir, existing))
1284
+ }
1285
+ }
1286
+ for (const [fileName, content] of desiredFiles) {
1287
+ fs.writeFileSync(path.join(dockerAgentsDir, fileName), content, 'utf8')
1288
+ }
1289
+
1290
+ // Phase 3: emit NATIVE OpenCode skill files (one dir per skill name) under
1291
+ // `docker/opencode/skills/<sanitized-skill-name>/SKILL.md`. Idempotent:
1292
+ // remove stale skill dirs not in the current desired set. Sub-agents
1293
+ // (Phase 4) may carry their own skills too, so flatten them in.
1294
+ const allAgents = sorted.flatMap((agent) => [agent, ...agent.subAgentsContent])
1295
+ const dockerSkillsDir = path.join(repoRoot, 'docker', 'opencode', 'skills')
1296
+ // The synthetic `__agent_tools__` skill only carries an agent's local
1297
+ // `tools/*.ts` sources (run via `run_skill_script`); it has no instructions
1298
+ // and MUST NOT be emitted as a native OpenCode skill (OpenCode requires a
1299
+ // non-empty `description`, and it is not a progressive-disclosure skill).
1300
+ const isNativeSkill = (skill: { id: string }): boolean => skill.id !== AGENT_TOOLS_SKILL_ID
1301
+ const desiredSkillNames = new Set<string>()
1302
+ for (const agent of allAgents) {
1303
+ for (const skill of agent.skillsContent) {
1304
+ if (isNativeSkill(skill)) desiredSkillNames.add(skill.openCodeSkillName)
1305
+ }
1306
+ }
1307
+ if (desiredSkillNames.size > 0) fs.mkdirSync(dockerSkillsDir, { recursive: true })
1308
+ if (fs.existsSync(dockerSkillsDir)) {
1309
+ for (const existing of fs.readdirSync(dockerSkillsDir, { withFileTypes: true })) {
1310
+ if (existing.isDirectory() && !desiredSkillNames.has(existing.name)) {
1311
+ fs.rmSync(path.join(dockerSkillsDir, existing.name), { recursive: true, force: true })
1312
+ }
1313
+ }
1314
+ }
1315
+ const renderedSkillNames = new Set<string>()
1316
+ for (const agent of allAgents) {
1317
+ for (const skill of agent.skillsContent) {
1318
+ if (!isNativeSkill(skill)) continue
1319
+ // A skill name may be shared across agents; the first rendering wins
1320
+ // (content is keyed by name, deterministic by sorted agent order).
1321
+ if (renderedSkillNames.has(skill.openCodeSkillName)) continue
1322
+ renderedSkillNames.add(skill.openCodeSkillName)
1323
+ const skillDir = path.join(dockerSkillsDir, skill.openCodeSkillName)
1324
+ fs.mkdirSync(skillDir, { recursive: true })
1325
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), renderOpenCodeSkillFile(skill), 'utf8')
1326
+ }
1327
+ }
1328
+
1329
+ return new Map<string, string>()
1330
+ },
1331
+ }
1332
+ }