@frontera-sdk/cli 1.52.39 → 1.52.40

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 (225) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +48 -4
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. package/src/vendor/sdk-sources.json +24 -21
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
 
3
+ import { issueLines } from '../api/governed-action-api'
3
4
  import { deriveOntologyEditPlan, PlanDerivationError, type ObjectTypeShape, type PublishedActionDefinition } from '../blueprint/ontology-edit-plan'
4
5
  import type { ActionFit } from './local-portability'
5
6
  import type { LocalCredentials } from './local-state'
@@ -71,13 +72,37 @@ function data<T>(answer: Answer): T {
71
72
  }
72
73
 
73
74
  function message(answer: Answer): string {
74
- const b = answer.body as { message?: unknown; error?: unknown } | null
75
+ const b = answer.body as { message?: unknown; error?: unknown; details?: unknown } | null
75
76
  const text = typeof b?.message === 'string' ? b.message : typeof b === 'string' ? b : JSON.stringify(b)
76
- return `${answer.status} ${String(text).replace(/\s+/g, ' ').slice(0, 240)}`
77
+ // The reason a generic refusal ("Mutation Plan is invalid.") keeps in its issues.
78
+ const issues = b && typeof b === 'object' ? issueLines(b.details) : []
79
+ return `${answer.status} ${String(text).replace(/\s+/g, ' ').slice(0, 240)}${issues.length ? ` ${issues.join('; ').slice(0, 400)}` : ''}`
77
80
  }
78
81
 
79
82
  const ADMIN = '/v1/blueprint/governed-actions/admin'
80
83
 
84
+ /**
85
+ * A validation report's failed findings, one line each, as the deployment
86
+ * router returns them (`findings: [{ source, code, passed, message }]`): the
87
+ * code and the platform's message. Empty when the report carries no such list
88
+ * (an older runtime), so the caller falls back to `failedChecks`.
89
+ */
90
+ export function validationFindings(report: unknown): string[] {
91
+ const findings = (report as { findings?: unknown } | null)?.findings
92
+ if (!Array.isArray(findings)) return []
93
+ return [
94
+ ...new Set(
95
+ findings.flatMap((f) => {
96
+ if (!f || typeof f !== 'object') return []
97
+ const o = f as { code?: unknown; passed?: unknown; message?: unknown }
98
+ if (o.passed !== false) return []
99
+ const code = typeof o.code === 'string' ? o.code : 'check'
100
+ return [typeof o.message === 'string' && o.message ? `${code}: ${o.message}` : code]
101
+ }),
102
+ ),
103
+ ]
104
+ }
105
+
81
106
  /** The checks a validation report failed, by name: its findings with `passed: false`, anywhere in it. */
82
107
  function failedChecks(report: unknown): string {
83
108
  const names: string[] = []
@@ -200,16 +225,33 @@ export interface ActionBuild {
200
225
  status: ActionBuildStatus
201
226
  /** Why it is not running, in words. */
202
227
  reason?: string
228
+ /** failed: the deployment validation's failed findings, `code: message` each, when the runtime gave them. */
229
+ findings?: string[]
230
+ /** left-off: the runtime refused the Action's own file (a failure to fix), not left off by design. */
231
+ refused?: boolean
232
+ /**
233
+ * Deployed as the workspace runs it, with these warnings inherited from the
234
+ * workspace: a value it offers that the semantic type it writes refuses.
235
+ */
236
+ inherited?: string[]
203
237
  }
204
238
 
205
239
  interface Deployable {
206
240
  definition: PublishedActionDefinition
207
241
  contractDigest: string
208
242
  deploymentStatus?: string
243
+ availability?: string
244
+ reason?: string | null
245
+ }
246
+
247
+ /** Why one Action's write path did not deploy: a sentence, and the validation's failed findings when it had any. */
248
+ interface DeployFailure {
249
+ reason: string
250
+ findings?: string[]
209
251
  }
210
252
 
211
253
  /** The write path for one Action that writes the Blueprint's records: built, validated, reviewed, activated. */
212
- async function deployOne(call: Call, published: Deployable): Promise<string | null> {
254
+ async function deployOne(call: Call, published: Deployable): Promise<string | DeployFailure | null> {
213
255
  const { definition, contractDigest } = published
214
256
  const types = data<Array<{ id?: string; apiName?: string }>>(await call('org', 'GET', '/v1/blueprint/object-types')) ?? []
215
257
  const subject = (Array.isArray(types) ? types : []).find((t) => t.id === definition.subject.objectTypeId)?.apiName
@@ -238,7 +280,11 @@ async function deployOne(call: Call, published: Deployable): Promise<string | nu
238
280
  const bindingRevisionId = data<{ id: string }>(binding).id
239
281
  const validation = await call('org', 'POST', `${ADMIN}/binding-revisions/${bindingRevisionId}/validate`, { expectedStateGeneration: 1 })
240
282
  const report = data<{ id?: string; status?: string; findings?: unknown }>(validation)
241
- if (validation.status >= 300 || report?.status !== 'valid') return `its write path did not validate: ${validation.status >= 300 ? message(validation) : failedChecks(report)}`
283
+ if (validation.status >= 300 || report?.status !== 'valid') {
284
+ const findings = validation.status >= 300 ? [] : validationFindings(report)
285
+ if (findings.length) return { reason: `its write path did not validate (${findings.length} failed check${findings.length === 1 ? '' : 's'})`, findings }
286
+ return `its write path did not validate: ${validation.status >= 300 ? message(validation) : failedChecks(report)}`
287
+ }
242
288
  // A different principal from the key that built it: the service insists.
243
289
  const reviewed = await call('session', 'POST', `${ADMIN}/binding-revisions/${bindingRevisionId}/review`, { validationReportId: report.id, expectedStateGeneration: 2 })
244
290
  if (reviewed.status >= 300) return `its review was refused: ${message(reviewed)}`
@@ -288,19 +334,66 @@ export async function deployActions(call: Call, actions: LocalAction[]): Promise
288
334
  continue
289
335
  }
290
336
  const published = data<Deployable>(read)
291
- let failure: string | null = null
292
- if (published.deploymentStatus !== 'active') {
337
+ let failure: DeployFailure | null = null
338
+ // Not active, or active on a write path whose validation no longer holds: built again.
339
+ if (published.deploymentStatus !== 'active' || published.reason === 'ACTION_BINDING_VALIDATION_STALE') {
293
340
  try {
294
- failure = await deployOne(call, published)
341
+ const why = await deployOne(call, published)
342
+ failure = typeof why === 'string' ? { reason: why } : why
295
343
  } catch (err) {
296
- failure = (err as Error).message
344
+ failure = { reason: (err as Error).message }
297
345
  }
298
346
  }
299
347
  if (!failure && action.invokeCapability) {
300
348
  const granted = await call('session', 'POST', `${ADMIN}/capability-grants`, { capability: action.invokeCapability, role: 'owner', revoke: false })
301
- if (granted.status >= 300) failure = `it is deployed, but the owner role could not be allowed to run it: ${message(granted)}`
349
+ if (granted.status >= 300) failure = { reason: `it is deployed, but the owner role could not be allowed to run it: ${message(granted)}` }
302
350
  }
303
- out[action.apiName] = failure ? { status: 'failed', reason: failure } : { status: 'runs' }
351
+ out[action.apiName] = failure ? { status: 'failed', reason: failure.reason, ...(failure.findings?.length ? { findings: failure.findings } : {}) } : { status: 'runs' }
352
+ }
353
+ return out
354
+ }
355
+
356
+ /** One published Action's deployment on the runtime, as `?view=deployable` answers it: what `frontera action list` reads too. */
357
+ export interface LiveAction {
358
+ deploymentStatus: string
359
+ availability?: string
360
+ reason?: string | null
361
+ }
362
+
363
+ /** Whether a live Action runs: deployed, active, and its deployment validated against its published contract. */
364
+ export function liveActionRuns(live: LiveAction | undefined): boolean {
365
+ return !!live && live.deploymentStatus === 'active' && (live.availability === undefined || live.availability === 'available')
366
+ }
367
+
368
+ /**
369
+ * Every published Action's deployment on the local runtime, by apiName, from
370
+ * the same view `frontera action list` reads (`/v1/blueprint/actions?view=deployable`),
371
+ * so `local status`, `local dev` and `action list` say one thing. Null when it
372
+ * could not be read.
373
+ */
374
+ export async function liveActionStates(call: Call): Promise<Map<string, LiveAction> | null> {
375
+ const listed = await call('org', 'GET', '/v1/blueprint/actions?view=deployable')
376
+ if (listed.status >= 300) return null
377
+ const rows = data<Array<{ definition?: { apiName?: string }; deploymentStatus?: string; availability?: string; reason?: string | null }>>(listed)
378
+ if (!Array.isArray(rows)) return null
379
+ const out = new Map<string, LiveAction>()
380
+ for (const r of rows) {
381
+ const name = r.definition?.apiName
382
+ if (typeof name === 'string' && typeof r.deploymentStatus === 'string') out.set(name, { deploymentStatus: r.deploymentStatus, ...(r.availability ? { availability: r.availability } : {}), reason: r.reason ?? null })
304
383
  }
305
384
  return out
306
385
  }
386
+
387
+ /**
388
+ * Of the Actions that should run here, the ones the runtime does not run, each
389
+ * with why: a file left unchanged after a failed apply still needs its Action
390
+ * deployed again. Empty when the runtime could not be read (the apply decides).
391
+ */
392
+ export function actionsNotRunning(expected: string[], live: Map<string, LiveAction> | null): Array<{ apiName: string; why: string }> {
393
+ if (!live) return []
394
+ return expected.flatMap((apiName) => {
395
+ const l = live.get(apiName)
396
+ if (liveActionRuns(l)) return []
397
+ return [{ apiName, why: !l ? 'it is not published on the runtime' : `its deployment is ${l.deploymentStatus}${l.availability && l.availability !== 'available' && l.availability !== l.deploymentStatus ? ` (${l.availability})` : ''}${l.reason ? `, ${l.reason}` : ''}` }]
398
+ })
399
+ }
@@ -0,0 +1,210 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { parse as parseYaml } from 'yaml'
5
+
6
+ import { unchangedSincePull } from './local-check-inherited'
7
+
8
+ /**
9
+ * Governed Actions an Agent's instructions tell it to submit that its
10
+ * Blueprint grants never offer it.
11
+ *
12
+ * An Agent is offered an Action only on an object type its grant lets it act
13
+ * on (`"act"` in `agents/<slug>/bindings.json`); with `read` alone it gets the
14
+ * query tools and no Action tool, here and on the platform alike. Nothing in
15
+ * the apply refuses an Agent whose instructions say "submit
16
+ * `draftBudgetScenario`" while its grant on BudgetScenario is `read`: the
17
+ * Agent simply never has the tool, and a conversation that needs it fails.
18
+ * This says so before a conversation does.
19
+ *
20
+ * Kept to few false alarms: only an Action's exact apiName counts (a word
21
+ * boundary on both sides, case kept), only Actions the project's Blueprint
22
+ * defines, and a sentence that names it with a negation ("never by submitting
23
+ * `raiseServiceRequest` yourself", "`publishExecutiveBrief` is not yours") or
24
+ * with one of the Agent's own Functions or workflows (which submit it in the
25
+ * Agent's place) does not count. An Agent whose grants the pull couldn't read
26
+ * (`blueprintUnread`), or whose grant on the type names no permissions (it
27
+ * keeps what the runtime already holds), is not judged.
28
+ */
29
+
30
+ export interface AgentActionGap {
31
+ /** `leo`, from `agents/leo/`. */
32
+ agent: string
33
+ /** The object type the Actions act on (`BudgetScenario`). */
34
+ objectType: string
35
+ /** The Actions on it the instructions name, in the order they first appear. */
36
+ actions: string[]
37
+ /** What the Agent holds on the type: its permissions, or null without a grant. */
38
+ permissions: string[] | null
39
+ /** Where the instructions name them: `agents/leo/agent.json`, `agents/nova/operating.json`. */
40
+ files: string[]
41
+ /** The Agent's files are as the last pull wrote them: the workspace has the same gap. */
42
+ inherited: boolean
43
+ }
44
+
45
+ type Doc = Record<string, unknown>
46
+
47
+ function entries(dir: string): string[] {
48
+ try {
49
+ return readdirSync(dir).filter((name) => !name.startsWith('.')).sort()
50
+ } catch {
51
+ return []
52
+ }
53
+ }
54
+
55
+ function readDoc(path: string): unknown {
56
+ try {
57
+ const text = readFileSync(path, 'utf8')
58
+ return path.endsWith('.json') ? JSON.parse(text) : parseYaml(text)
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+
64
+ const isDoc = (v: unknown): v is Doc => !!v && typeof v === 'object' && !Array.isArray(v)
65
+
66
+ /** Each governed Action the project's Blueprint defines, by apiName, with the object type it acts on. */
67
+ export function projectActionSubjects(root: string): Map<string, string> {
68
+ const out = new Map<string, string>()
69
+ const dir = join(root, 'blueprint', 'actions')
70
+ for (const file of entries(dir)) {
71
+ if (!/\.(ya?ml|json)$/.test(file)) continue
72
+ const doc = readDoc(join(dir, file))
73
+ if (!isDoc(doc)) continue
74
+ const apiName = typeof doc.apiName === 'string' && doc.apiName ? doc.apiName : file.replace(/\.(ya?ml|json)$/, '')
75
+ const subject = isDoc(doc.subject) && typeof doc.subject.objectType === 'string' ? doc.subject.objectType.replace(/^objectType\//, '') : null
76
+ if (subject && /^[A-Za-z_][\w]*$/.test(apiName)) out.set(apiName, subject)
77
+ }
78
+ return out
79
+ }
80
+
81
+ /** Every string under a value: a procedure's description, instructions, guardrails, field descriptions… */
82
+ function strings(v: unknown, out: string[] = []): string[] {
83
+ if (typeof v === 'string') out.push(v)
84
+ else if (Array.isArray(v)) for (const x of v) strings(x, out)
85
+ else if (isDoc(v)) for (const x of Object.values(v)) strings(x, out)
86
+ return out
87
+ }
88
+
89
+ /** The Agent's instructions (its enabled prompts) and its procedures' text, by the file each is in. */
90
+ function instructionTexts(root: string, slug: string): Array<{ file: string; text: string }> {
91
+ const out: Array<{ file: string; text: string }> = []
92
+ const agentFile = `agents/${slug}/agent.json`
93
+ const agent = readDoc(join(root, agentFile))
94
+ if (isDoc(agent) && Array.isArray(agent.prompts)) {
95
+ const text = agent.prompts
96
+ .filter((p): p is Doc => isDoc(p) && p.enabled !== false && typeof p.content === 'string')
97
+ .map((p) => p.content as string)
98
+ .join('\n\n')
99
+ if (text) out.push({ file: agentFile, text })
100
+ }
101
+ const operatingFile = `agents/${slug}/operating.json`
102
+ const operating = readDoc(join(root, operatingFile))
103
+ if (isDoc(operating) && Array.isArray(operating.procedures)) {
104
+ const text = strings(operating.procedures).join('\n\n')
105
+ if (text) out.push({ file: operatingFile, text })
106
+ }
107
+ return out
108
+ }
109
+
110
+ /** The sentences of a text: split at line breaks and at a full stop, question or exclamation mark before a space. */
111
+ function sentences(text: string): string[] {
112
+ return text.split(/\n+|(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean)
113
+ }
114
+
115
+ /** A sentence that says not to submit it, or that it isn't the Agent's to submit. */
116
+ const NEGATION = /\b(?:never|not|nor|cannot|without)\b|n['’]t\b/i
117
+
118
+ const word = (name: string) => new RegExp(`(?<![\\w-])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`)
119
+
120
+ /** The Functions and workflows the Agent may start, by slug: a sentence naming one is about that tool. */
121
+ function boundTools(bindings: Doc): string[] {
122
+ const refs: string[] = []
123
+ for (const key of ['functions', 'workflows'] as const) {
124
+ const rows = Array.isArray(bindings[key]) ? (bindings[key] as unknown[]) : []
125
+ for (const row of rows) {
126
+ const ref = isDoc(row) ? (row.function ?? row.workflow) : null
127
+ if (typeof ref === 'string' && ref.includes('/')) refs.push(ref.slice(ref.indexOf('/') + 1))
128
+ }
129
+ }
130
+ return refs.filter(Boolean)
131
+ }
132
+
133
+ /** The Actions a text tells the Agent to use, by apiName: named in a sentence that neither negates it nor hands it to a bound tool. */
134
+ export function namedActions(text: string, actions: Iterable<string>, tools: readonly string[] = []): string[] {
135
+ const names = [...actions]
136
+ const found: Array<{ name: string; at: number }> = []
137
+ const toolWords = tools.map(word)
138
+ for (const sentence of sentences(text)) {
139
+ if (NEGATION.test(sentence) || toolWords.some((w) => w.test(sentence))) continue
140
+ for (const name of names) {
141
+ const m = word(name).exec(sentence)
142
+ if (m && !found.some((f) => f.name === name)) found.push({ name, at: text.indexOf(sentence) + m.index })
143
+ }
144
+ }
145
+ return found.sort((a, b) => a.at - b.at).map((f) => f.name)
146
+ }
147
+
148
+ /** What a bindings.json grants on each object type, by apiName; null when it can't be told (grants the pull couldn't read). */
149
+ function grantsOf(bindings: Doc): Map<string, string[] | null> | null {
150
+ if (bindings.blueprintUnread === true && !Array.isArray(bindings.blueprint)) return null
151
+ const out = new Map<string, string[] | null>()
152
+ for (const row of Array.isArray(bindings.blueprint) ? bindings.blueprint : []) {
153
+ if (!isDoc(row) || typeof row.objectType !== 'string') continue
154
+ const type = row.objectType.replace(/^objectType\//, '')
155
+ out.set(type, Array.isArray(row.permissions) ? row.permissions.filter((p): p is string => typeof p === 'string') : null)
156
+ }
157
+ return out
158
+ }
159
+
160
+ /** Every Agent whose instructions or procedures name Actions it can't submit, one entry per Agent and object type. */
161
+ export function agentActionGaps(root: string): AgentActionGap[] {
162
+ const subjects = projectActionSubjects(root)
163
+ if (!subjects.size) return []
164
+ const out: AgentActionGap[] = []
165
+ let unchanged: ReturnType<typeof unchangedSincePull> | null = null
166
+ for (const slug of entries(join(root, 'agents'))) {
167
+ if (!existsSync(join(root, 'agents', slug, 'agent.json'))) continue
168
+ const bindingsFile = `agents/${slug}/bindings.json`
169
+ const bindings = readDoc(join(root, bindingsFile))
170
+ const doc: Doc = isDoc(bindings) ? bindings : {}
171
+ const grants = grantsOf(doc)
172
+ if (!grants) continue
173
+ const tools = boundTools(doc)
174
+ const byType = new Map<string, AgentActionGap>()
175
+ for (const { file, text } of instructionTexts(root, slug)) {
176
+ for (const action of namedActions(text, subjects.keys(), tools)) {
177
+ const type = subjects.get(action)!
178
+ const held = grants.has(type) ? grants.get(type)! : null
179
+ // A grant that names no permissions keeps what the runtime holds: it may act, so it isn't judged.
180
+ if (grants.has(type) && held === null) continue
181
+ if (held?.includes('act')) continue
182
+ const gap = byType.get(type) ?? { agent: slug, objectType: type, actions: [], permissions: held, files: [], inherited: false }
183
+ if (!gap.actions.includes(action)) gap.actions.push(action)
184
+ if (!gap.files.includes(file)) gap.files.push(file)
185
+ byType.set(type, gap)
186
+ }
187
+ }
188
+ if (!byType.size) continue
189
+ unchanged ??= unchangedSincePull(root)
190
+ for (const gap of byType.values()) {
191
+ gap.inherited = [...gap.files, bindingsFile].every((f) => unchanged!.files.has(f))
192
+ out.push(gap)
193
+ }
194
+ }
195
+ return out
196
+ }
197
+
198
+ const list = (names: string[]) => (names.length <= 2 ? names.join(' and ') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`)
199
+
200
+ /** One gap in a line, with its fix: what `local dev`, `local status` and the MCP status say. */
201
+ export function agentActionGapLine(g: AgentActionGap): string {
202
+ const one = g.actions.length === 1
203
+ const named = `${one ? 'the governed Action' : 'the governed Actions'} ${list(g.actions.map((a) => `\`${a}\``))}`
204
+ const held = g.permissions === null ? `it has no grant on ${g.objectType}` : `its grant on ${g.objectType} is ${list(g.permissions.length ? g.permissions : ['empty'])} without act`
205
+ const where = g.files.map((f) => (f.endsWith('operating.json') ? 'procedures (operating.json)' : 'instructions (agent.json)')).join(' and ')
206
+ const fix = g.permissions === null
207
+ ? `add { "objectType": "objectType/${g.objectType}", "permissions": ["read", "act"] } to agents/${g.agent}/bindings.json`
208
+ : `add "act" to objectType/${g.objectType}'s permissions in agents/${g.agent}/bindings.json`
209
+ return `agent/${g.agent}: its ${where} tell it to submit ${named}, but ${held}, so it is offered no Action on ${g.objectType}, here and on the platform. Fix: ${fix}, or take ${one ? 'it' : 'them'} out of its instructions${g.inherited ? ' (inherited: these files are as the pull wrote them, so the workspace’s Agent has the same gap)' : ''}`
210
+ }
@@ -0,0 +1,232 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { parseRef } from './local-sync-refs'
5
+
6
+ /**
7
+ * What an Agent's files say that `local dev` would otherwise apply without a
8
+ * word and the Agent would only trip over in a chat: a key in agent.json
9
+ * whose content belongs in another file, and a procedure's tool list naming
10
+ * a tool in a form the Agent cannot use, or one it is not bound to.
11
+ */
12
+
13
+ const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
14
+
15
+ /** The tool groups a procedure may keep (`TOOL_GROUPS` in the service's operating config). */
16
+ export const PROCEDURE_TOOL_GROUPS = ['plugins', 'blueprint', 'blueprint-actions', 'web'] as const
17
+
18
+ /** The forms a procedure's `tools.ids` take, in words, for a schema description and an error. */
19
+ export const PROCEDURE_TOOL_FORMS =
20
+ '`function:<slug>` (a Function bound to the Agent in agents/<slug>/bindings.json), `workflow:<slug>` (a workflow bound there), or `tool:plugins`, `tool:blueprint`, `tool:blueprint-actions`, `tool:web` (the Agent’s Plugins; its Blueprint query tools only; its governed Action tools, which list, submit and read Action requests; web search)'
21
+
22
+ /**
23
+ * Keys in agent.json whose content does not do there what was meant: each a
24
+ * `warning:` line naming the file it belongs in. The Agent is still applied.
25
+ */
26
+ export function agentFileWarnings(doc: unknown, slug: string): string[] {
27
+ if (!isRecord(doc)) return []
28
+ const out: string[] = []
29
+ const bindings = `agents/${slug}/bindings.json`
30
+ const file = `agents/${slug}/agent.json`
31
+ const rows = (key: string) => (Array.isArray(doc[key]) ? (doc[key] as unknown[]) : [])
32
+ // `objectBindings` binds workspace object definitions; an object type there grants nothing.
33
+ rows('objectBindings').forEach((row, i) => {
34
+ if (!isRecord(row)) return
35
+ const id = typeof row.objectDefinitionId === 'string' ? parseRef(row.objectDefinitionId) : null
36
+ if (id?.kind === 'object') return
37
+ const named = [row.objectType, row.objectTypeApiName, row.apiName, row.objectDefinitionId].find((v) => typeof v === 'string') as string | undefined
38
+ const type = named ? (parseRef(named)?.name ?? named) : '<ApiName>'
39
+ out.push(
40
+ ` warning: ${file} objectBindings.${i} is not applied: objectBindings binds an object definition (\`objectDefinitionId: object/<slug>\` with its \`definition\`). For Blueprint records the Agent may query, list the object type in ${bindings}: "blueprint": [{ "objectType": "objectType/${type}", "permissions": ["read"] }]`,
41
+ )
42
+ })
43
+ if (rows('functions').length) {
44
+ out.push(` warning: ${file} functions is not applied: nothing runs those rows any more. The Functions the Agent may call are its bindings: ${bindings} "functions": [{ "function": "function/<slug>" }]`)
45
+ }
46
+ rows('knowledgeAttachments').forEach((row, i) => {
47
+ if (!isRecord(row)) return
48
+ const id = row.knowledgeConfigId
49
+ if (typeof id !== 'string' || (!parseRef(id) && !/^[0-9a-f-]{36}$/i.test(id))) {
50
+ out.push(` warning: ${file} knowledgeAttachments.${i} names no knowledge base: write { "knowledgeConfigId": "knowledge/<name>" } (knowledge/<name>/knowledge.json)`)
51
+ }
52
+ })
53
+ return out
54
+ }
55
+
56
+ /** The keys agent.json may not carry at all: refused before the runtime says "unrecognized key". */
57
+ export function agentFileErrors(doc: unknown, slug: string): string[] {
58
+ if (!isRecord(doc)) return []
59
+ const bindings = `agents/${slug}/bindings.json`
60
+ return ['workflows', 'workflowBindings', 'functionBindings', 'bindings', 'blueprint', 'grants', 'blueprintGrants', 'objectTypes']
61
+ .filter((key) => key in doc)
62
+ .map((key) => `agents/${slug}/agent.json: \`${key}\` is not an agent.json key; what an Agent may start and read goes in ${bindings} (\`frontera local schema bindings\`)`)
63
+ }
64
+
65
+ /** What `agents/<slug>/bindings.json` binds, by slug: the Functions and workflows. */
66
+ export function boundTools(root: string, slug: string): { functions: Set<string>; workflows: Set<string>; blueprint: boolean; objectTypes?: Set<string>; acting?: Set<string> } | null {
67
+ const path = join(root, 'agents', slug, 'bindings.json')
68
+ if (!existsSync(path)) return { functions: new Set(), workflows: new Set(), blueprint: false, objectTypes: new Set(), acting: new Set() }
69
+ try {
70
+ const doc = JSON.parse(readFileSync(path, 'utf8')) as { functions?: Array<{ function?: unknown }>; workflows?: Array<{ workflow?: unknown }>; blueprint?: unknown[] }
71
+ const names = (rows: Array<Record<string, unknown>> | undefined, key: string, kind: string) =>
72
+ new Set((rows ?? []).flatMap((r) => {
73
+ const ref = typeof r?.[key] === 'string' ? parseRef(r[key] as string) : null
74
+ return ref?.kind === kind ? [ref.name] : []
75
+ }))
76
+ return {
77
+ functions: names(doc.functions as Array<Record<string, unknown>>, 'function', 'function'),
78
+ workflows: names(doc.workflows as Array<Record<string, unknown>>, 'workflow', 'workflow'),
79
+ blueprint: Array.isArray(doc.blueprint) && doc.blueprint.length > 0,
80
+ // The object types granted, by apiName (`objectType/WorkOrder`).
81
+ objectTypes: names(doc.blueprint as Array<Record<string, unknown>>, 'objectType', 'objectType'),
82
+ // Those it may submit governed Actions on (`act` in the grant's permissions); unknown when an entry
83
+ // names none (a pull that read names only), since that entry keeps whatever the grant holds.
84
+ acting: (doc.blueprint as Array<Record<string, unknown>> | undefined)?.some((g) => !Array.isArray(g?.permissions)) ? undefined : names((doc.blueprint as Array<Record<string, unknown>> | undefined)?.filter((g) => Array.isArray(g?.permissions) && (g.permissions as unknown[]).includes('act')), 'objectType', 'objectType'),
85
+ }
86
+ } catch {
87
+ return null
88
+ }
89
+ }
90
+
91
+ /**
92
+ * A procedure's `tools.ids` checked before the behavior settings are staged:
93
+ * an id in a form the Agent cannot use fails the apply (the runtime accepts
94
+ * it and the chat then refuses the procedure with "keeps a tool that does not
95
+ * exist"); one that names a Function or workflow the Agent is not bound to is
96
+ * a warning naming the bindings file. `bound` is null when bindings.json could
97
+ * not be read, and then only the forms are checked.
98
+ */
99
+ /**
100
+ * The warning for an operating.json without `execution.toolScopes: 2`, or
101
+ * null when it has it. Without the marker the platform reads the file as
102
+ * before Blueprint Actions were their own choice: `tool:blueprint` keeps the
103
+ * Action tools, and a service Agent has them outside procedures.
104
+ */
105
+ export function toolScopesProblem(doc: unknown, file: string): string | null {
106
+ if (!isRecord(doc)) return null
107
+ const execution = isRecord(doc.execution) ? doc.execution : {}
108
+ if (execution.toolScopes === 2) return null
109
+ const said = execution.toolScopes === undefined ? 'has no `execution.toolScopes`' : `has \`execution.toolScopes: ${JSON.stringify(execution.toolScopes)}\``
110
+ return ` warning: ${file} ${said}, so it is read as before Blueprint Actions were their own choice: \`tool:blueprint\` keeps the Action tools, and a service Agent has them outside procedures; add "toolScopes": 2 to "execution" to use the new meaning (\`tool:blueprint\` the query tools only, \`tool:blueprint-actions\` the Action tools, \`execution.blueprintActions\` for outside procedures)`
111
+ }
112
+
113
+ export function procedureToolProblems(doc: unknown, slug: string, bound: ReturnType<typeof boundTools>): { errors: string[]; warnings: string[] } {
114
+ const errors: string[] = []
115
+ const warnings: string[] = []
116
+ const procedures = isRecord(doc) && Array.isArray(doc.procedures) ? (doc.procedures as unknown[]) : []
117
+ const file = `agents/${slug}/operating.json`
118
+ // Once per Agent: a config without the marker keeps the meaning tool ids had before.
119
+ const scopes = toolScopesProblem(doc, file)
120
+ if (scopes) warnings.push(scopes)
121
+ // A data scope narrows reads of an object type the Agent is granted: one it is not granted is read by nothing.
122
+ const dataScopes = isRecord(doc) && Array.isArray(doc.dataScopes) ? (doc.dataScopes as unknown[]) : []
123
+ dataScopes.forEach((d, i) => {
124
+ if (!isRecord(d) || typeof d.objectType !== 'string' || !bound?.objectTypes) return
125
+ const type = parseRef(d.objectType)?.name ?? d.objectType
126
+ if (!bound.objectTypes.has(type)) warnings.push(` warning: ${file}: dataScopes.${i} scopes ${type}, which agents/${slug}/bindings.json does not grant, so the Agent reads none of it and the scope does nothing: add { "objectType": "objectType/${type}", "permissions": ["read"] } to its "blueprint"`)
127
+ })
128
+ // The Action tools asked for (outside procedures, or kept by one) while no grant lets the Agent act: it is offered none.
129
+ const execution = isRecord(doc) && isRecord(doc.execution) ? doc.execution : {}
130
+ const keepsActions = procedures.some((p) => isRecord(p) && isRecord(p.tools) && p.tools.inherit === false && Array.isArray(p.tools.ids) && p.tools.ids.includes('tool:blueprint-actions'))
131
+ if ((execution.blueprintActions === true || keepsActions) && bound?.blueprint && bound.acting && bound.acting.size === 0) {
132
+ const types = [...(bound.objectTypes ?? [])].sort()
133
+ const on = types.length ? (types.length > 3 ? `${types.slice(0, 3).join(', ')} or another type it reads` : types.join(' or ')) : '<Type>'
134
+ warnings.push(` warning: ${slug} can't submit Actions: give its grant on ${on} \`act\` in agents/${slug}/bindings.json ("permissions": ["read", "act"]). ${file} asks for the Action tools (${execution.blueprintActions === true ? '`execution.blueprintActions`' : '`tool:blueprint-actions`'}), and without act on the type an Action is on, it is offered none`)
135
+ }
136
+ procedures.forEach((p, i) => {
137
+ if (!isRecord(p)) return
138
+ const name = typeof p.name === 'string' ? p.name : `procedure ${i + 1}`
139
+ // An action that starts a Function or workflow needs the same binding as a kept tool.
140
+ if (bound && Array.isArray(p.actions)) {
141
+ ;(p.actions as unknown[]).forEach((a, k) => {
142
+ if (!isRecord(a)) return
143
+ for (const [key, kind] of [['functionId', 'function'], ['workflowId', 'workflow']] as const) {
144
+ const ref = typeof a[key] === 'string' ? parseRef(a[key] as string) : null
145
+ if (ref?.kind !== kind) continue
146
+ if (!(kind === 'function' ? bound.functions : bound.workflows).has(ref.name)) {
147
+ warnings.push(` warning: ${file}: procedures.${i}.actions.${k}.${key}: "${name}" starts ${kind} ${ref.name}, which agents/${slug}/bindings.json does not bind, so the chat refuses the procedure: add { "${kind}": "${kind}/${ref.name}" } to its "${kind}s"`)
148
+ }
149
+ }
150
+ })
151
+ }
152
+ // Only a procedure that keeps its own tools (`inherit: false`) uses the list.
153
+ if (!isRecord(p.tools) || p.tools.inherit !== false || !Array.isArray(p.tools.ids)) return
154
+ ;(p.tools.ids as unknown[]).forEach((id, j) => {
155
+ const at = `${file}: procedures.${i}.tools.ids.${j}`
156
+ if (typeof id !== 'string') {
157
+ errors.push(`${at}: must be text, one of ${PROCEDURE_TOOL_FORMS}`)
158
+ return
159
+ }
160
+ const m = /^(function|workflow|tool):(.+)$/.exec(id)
161
+ if (!m) {
162
+ // A reference written the file way (`function/x`) is the same tool in the procedure's form.
163
+ const ref = parseRef(id)
164
+ const hint = ref && (ref.kind === 'function' || ref.kind === 'workflow') ? `; write \`${ref.kind}:${ref.name}\`` : ''
165
+ errors.push(`${at}: "${id}" is not a tool "${name}" can keep${hint}. A tool is ${PROCEDURE_TOOL_FORMS}`)
166
+ return
167
+ }
168
+ const [, kind, target] = m as unknown as [string, 'function' | 'workflow' | 'tool', string]
169
+ if (kind === 'tool') {
170
+ if (!(PROCEDURE_TOOL_GROUPS as readonly string[]).includes(target)) errors.push(`${at}: "${id}" is not a tool group; the groups are ${PROCEDURE_TOOL_GROUPS.map((g) => `tool:${g}`).join(', ')}`)
171
+ else if ((target === 'blueprint' || target === 'blueprint-actions') && bound && !bound.blueprint) warnings.push(` warning: ${at}: "${name}" keeps the ${target === 'blueprint' ? 'Blueprint query tools' : 'governed Action tools'}, but agents/${slug}/bindings.json grants no object type ("blueprint": [{ "objectType": "objectType/<ApiName>" }]), so the Agent has none`)
172
+ return
173
+ }
174
+ // A runtime id passes: `local dev` writes names, and a pulled file may still hold one.
175
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(target) || !bound) return
176
+ const set = kind === 'function' ? bound.functions : bound.workflows
177
+ if (!set.has(target)) {
178
+ warnings.push(
179
+ ` warning: ${at}: "${name}" keeps ${kind} ${target}, which agents/${slug}/bindings.json does not bind, so the chat refuses the procedure: add { "${kind}": "${kind}/${target}" } to its "${kind}s"`,
180
+ )
181
+ }
182
+ })
183
+ })
184
+ return { errors, warnings }
185
+ }
186
+
187
+ /**
188
+ * What `requiresApproval: false` on a binding does, said where it is written
189
+ * (agents/<slug>/bindings.json), so it is read as "never asks": the binding
190
+ * is Unattended, and the Agent runs the call without a card in every approval
191
+ * mode, attended or not, with or without an operating.json. Only a dataset run
192
+ * on the published Agent still asks before one that changes something. A
193
+ * Function that asks the person itself still shows its own question. Each
194
+ * line starts `<file>: requiresApproval false`, which `isApprovalNote` tells
195
+ * apart from an adaptation to this runtime: it holds everywhere.
196
+ */
197
+ export function approvalBindingNotes(root: string, slug: string, doc: unknown): string[] {
198
+ if (!isRecord(doc)) return []
199
+ const file = `agents/${slug}/bindings.json`
200
+ const rows = (key: string) => (Array.isArray(doc[key]) ? (doc[key] as unknown[]).filter(isRecord) : [])
201
+ const ALWAYS = 'Runs without a card in every approval mode, whether or not someone is in the chat; only a dataset run on the published Agent still asks before it changes something. Keep it for an action that is safe to run unasked.'
202
+ const out: string[] = []
203
+ for (const row of rows('functions')) {
204
+ if (row.requiresApproval !== false || typeof row.function !== 'string') continue
205
+ const name = parseRef(row.function)?.name ?? row.function
206
+ const extra = functionAsksPerson(root, name) ? ['The Function still asks the person itself (its confirmation card).'] : []
207
+ out.push(` warning: ${file}: requiresApproval false on function/${name}: ${[ALWAYS, ...extra].join(' ')}`)
208
+ }
209
+ for (const row of rows('workflows')) {
210
+ if (row.requiresApproval !== false || typeof row.workflow !== 'string') continue
211
+ const name = parseRef(row.workflow)?.name ?? row.workflow
212
+ out.push(` warning: ${file}: requiresApproval false on workflow/${name}: ${ALWAYS} Each Workflow binding asks on its own.`)
213
+ }
214
+ return out
215
+ }
216
+
217
+ /** A note `approvalBindingNotes` made: said, but not an adaptation to this runtime. */
218
+ export function isApprovalNote(line: string): boolean {
219
+ return /^\s*warning: agents\/[^/]+\/bindings\.json: requiresApproval false on /.test(line)
220
+ }
221
+
222
+ /** Whether a Function's source asks the person itself (`ctx.person.confirm`): a source search, as its file holds it. */
223
+ function functionAsksPerson(root: string, name: string): boolean {
224
+ for (const path of [join(root, 'functions', `${name}.ts`), join(root, 'functions', name, 'index.ts'), join(root, 'functions', name, 'src', 'index.ts')]) {
225
+ try {
226
+ if (/\bperson\s*\.\s*confirm\s*\(/.test(readFileSync(path, 'utf8'))) return true
227
+ } catch {
228
+ // Not this path.
229
+ }
230
+ }
231
+ return false
232
+ }