@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
@@ -0,0 +1,321 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { writeFileAtomic } from '../fs-atomic'
5
+ import { loadHashes } from './local-applied'
6
+ import { allTargets } from './local-dev-sync'
7
+ import { functionSlugOf } from './local-function-state'
8
+ import type { LocalProject } from './local-project'
9
+ import type { LocalCredentials } from './local-state'
10
+
11
+ /**
12
+ * What the local runtime holds that the project's files don't (e2e G3).
13
+ *
14
+ * `local dev` applies files; until now nothing happened when one was
15
+ * deleted: an Agent whose folder was removed stayed on the runtime, `status`
16
+ * never named it, and only `local reset` (every row gone) took it away.
17
+ *
18
+ * An Agent, workflow, Function or knowledge base on the runtime with no file
19
+ * in the project is an **orphan**. `status` lists each with its fix. One the
20
+ * project applied before (its key is in the applied record) had its file
21
+ * deleted, and `local dev` takes it out of service the safest way the runtime
22
+ * offers, reversibly, and puts it back when the file returns:
23
+ *
24
+ * Agent archived (the platform's delete is an archive: its versions
25
+ * and conversations stay) — restored when its folder returns
26
+ * workflow archived (versions and runs stay) — restored before its next push
27
+ * Function switched off: the runtime has no way to remove a Function —
28
+ * switched on again when its file is deployed again
29
+ * knowledge base left as it is: deleting one takes its documents with it and
30
+ * cannot be undone, so `status` only says it
31
+ *
32
+ * An orphan the project never had (made in Studio, say) is only said: it may
33
+ * be work someone means to pull into the files. A Function switched off with
34
+ * no file is not one: it doesn't run, and there is nothing left to do.
35
+ */
36
+
37
+ export type OrphanKind = 'agent' | 'workflow' | 'function' | 'knowledge'
38
+
39
+ export interface Orphan {
40
+ /** `agent/assistant`, `workflow/hello`, `function/word-count`, `knowledge/Field desk policies`. */
41
+ ref: string
42
+ kind: OrphanKind
43
+ name: string
44
+ /** The runtime's id, where the kind is addressed by one (an Agent, a knowledge base). */
45
+ id?: string
46
+ /** The project applied it from a file that is gone: `local dev` takes it out of service. */
47
+ deleted: boolean
48
+ /** What is wrong, in a sentence. */
49
+ problem: string
50
+ /** What fixes it. */
51
+ fix: string
52
+ }
53
+
54
+ /** What the runtime holds of the kinds files make, by name; a kind the runtime didn't list is absent. */
55
+ export interface RuntimeItems {
56
+ agents?: Array<{ id: string; slug: string }>
57
+ workflows?: Array<{ slug: string }>
58
+ functions?: Array<{ slug: string; enabled: boolean | null }>
59
+ knowledge?: Array<{ id: string; name: string }>
60
+ }
61
+
62
+ function rowsOf<T>(body: unknown): T[] {
63
+ let v = body
64
+ for (let i = 0; i < 3 && v && !Array.isArray(v) && typeof v === 'object' && 'data' in (v as object); i++) v = (v as { data: unknown }).data
65
+ if (v && !Array.isArray(v) && typeof v === 'object' && Array.isArray((v as { items?: unknown }).items)) v = (v as { items: unknown[] }).items
66
+ return Array.isArray(v) ? (v as T[]) : []
67
+ }
68
+
69
+ type Call = (path: string, init?: { method?: string; body?: unknown }) => Promise<{ ok: boolean; status: number; body: unknown }>
70
+
71
+ /** Calls to the runtime with the workspace key; null without one. */
72
+ export function orphanCalls(serviceUrl: string, creds: Pick<LocalCredentials, 'workspaceKey'>): Call | null {
73
+ if (!creds.workspaceKey) return null
74
+ return async (path, init = {}) => {
75
+ try {
76
+ const res = await fetch(new URL(path, serviceUrl), {
77
+ method: init.method ?? 'GET',
78
+ headers: { authorization: `Bearer ${creds.workspaceKey}`, 'content-type': 'application/json' },
79
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
80
+ signal: AbortSignal.timeout(10_000),
81
+ })
82
+ const text = await res.text()
83
+ let body: unknown = text
84
+ try {
85
+ body = text ? JSON.parse(text) : null
86
+ } catch {
87
+ // Not JSON: the text is the body.
88
+ }
89
+ return { ok: res.ok, status: res.status, body }
90
+ } catch (err) {
91
+ return { ok: false, status: 0, body: (err as Error).message }
92
+ }
93
+ }
94
+ }
95
+
96
+ /** The Agents, workflows, Functions and knowledge bases on the runtime; each kind that didn't answer is left out. */
97
+ export async function runtimeItems(call: Call, workspaceId: string): Promise<RuntimeItems> {
98
+ const [agents, workflows, functions, knowledge] = await Promise.all([
99
+ // The sync listing: drafts never published included, archived and platform Agents left out.
100
+ call('/v1/sync/agents'),
101
+ // Archived workflows are left out of the listing.
102
+ call('/v1/workflows'),
103
+ call('/v1/functions'),
104
+ call(`/v1/workspace-knowledge?workspaceId=${encodeURIComponent(workspaceId)}`),
105
+ ])
106
+ const out: RuntimeItems = {}
107
+ if (agents.ok) {
108
+ out.agents = rowsOf<{ id?: string; agentId?: string; lifecycleStatus?: string }>(agents.body)
109
+ // A preview clone is the runtime's machinery for a draft chat, never an Agent of the project's.
110
+ .filter((a) => a.id && a.agentId && !a.agentId.startsWith('preview_') && a.lifecycleStatus !== 'archived')
111
+ .map((a) => ({ id: a.id!, slug: a.agentId! }))
112
+ }
113
+ if (workflows.ok) out.workflows = rowsOf<{ slug?: string; archivedAt?: string | null }>(workflows.body).filter((w) => w.slug && !w.archivedAt).map((w) => ({ slug: w.slug! }))
114
+ if (functions.ok) out.functions = rowsOf<{ slug?: string; enabled?: boolean | null }>(functions.body).filter((f) => f.slug).map((f) => ({ slug: f.slug!, enabled: f.enabled ?? null }))
115
+ if (knowledge.ok) out.knowledge = rowsOf<{ id?: string; name?: string }>(knowledge.body).filter((k) => k.id && k.name).map((k) => ({ id: k.id!, name: k.name! }))
116
+ return out
117
+ }
118
+
119
+ /** The names the project's files give each kind: an Agent's folder, a workflow's file, a Function's file or folder, a knowledge base's `name`. */
120
+ export function projectNames(root: string): Record<OrphanKind, Set<string>> {
121
+ const out: Record<OrphanKind, Set<string>> = { agent: new Set(), workflow: new Set(), function: new Set(), knowledge: new Set() }
122
+ for (const t of allTargets(root)) {
123
+ if (t.kind === 'agent') out.agent.add(t.slug)
124
+ else if (t.kind === 'workflow') out.workflow.add(t.slug)
125
+ else if (t.kind === 'function') {
126
+ const slug = functionSlugOf(t.key)
127
+ if (slug) out.function.add(slug)
128
+ } else if (t.kind === 'knowledge') {
129
+ out.knowledge.add(t.name)
130
+ try {
131
+ const name = (JSON.parse(readFileSync(join(root, t.file), 'utf8')) as { name?: unknown }).name
132
+ if (typeof name === 'string' && name) out.knowledge.add(name)
133
+ } catch {
134
+ // Unreadable: its folder's name is the one it goes by.
135
+ }
136
+ }
137
+ }
138
+ return out
139
+ }
140
+
141
+ /** The applied-record keys a deleted item's files went by, of a kind and name. */
142
+ export function appliedKeysOf(kind: OrphanKind, name: string): string[] {
143
+ if (kind === 'agent') return [`agent:${name}`, `operating:${name}`, `bindings:${name}`, `lessons:${name}`]
144
+ if (kind === 'workflow') return [`workflow:${name}`]
145
+ if (kind === 'function') return [`function:${name}.ts`, `function:${name}/`]
146
+ return []
147
+ }
148
+
149
+ /** Whether the project applied this item from a file before: a key its file went by is in the applied record. */
150
+ function wasApplied(kind: OrphanKind, name: string, applied: ReadonlySet<string>): boolean {
151
+ // An Agent is its agent.json: behavior settings or tools alone never made one.
152
+ if (kind === 'agent') return applied.has(`agent:${name}`)
153
+ return appliedKeysOf(kind, name).some((k) => applied.has(k))
154
+ }
155
+
156
+ const WORDS: Record<OrphanKind, string> = { agent: 'Agent', workflow: 'workflow', function: 'Function', knowledge: 'knowledge base' }
157
+
158
+ /** The file an item of a kind would have in the project. */
159
+ function fileOf(kind: OrphanKind, name: string): string {
160
+ if (kind === 'agent') return `agents/${name}/agent.json`
161
+ if (kind === 'workflow') return `workflows/${name}.yaml`
162
+ if (kind === 'function') return `functions/${name}.ts`
163
+ return 'knowledge/<folder>/knowledge.json'
164
+ }
165
+
166
+ /**
167
+ * Each item on the runtime with no file in the project, with its fix.
168
+ * `applied`: the keys of the files the project applied (`applied-hashes.json`).
169
+ */
170
+ export function findOrphans(root: string, items: RuntimeItems, applied: ReadonlySet<string>): Orphan[] {
171
+ const names = projectNames(root)
172
+ const out: Orphan[] = []
173
+ const add = (kind: OrphanKind, name: string, id?: string) => {
174
+ if (names[kind].has(name)) return
175
+ const deleted = kind !== 'knowledge' && wasApplied(kind, name, applied)
176
+ const what = `${WORDS[kind]} ${name}`
177
+ const base = deleted ? `${what} is on the local runtime, but its file is gone from the project` : `${what} is on the local runtime, but the project has no ${fileOf(kind, name)}`
178
+ const problem = base
179
+ let fix: string
180
+ if (kind === 'agent') fix = deleted ? '`frontera local dev` archives it (or `frontera agent archive ' + name + '` now)' : `add its folder to the project, or \`frontera agent archive ${name}\` if it isn’t wanted`
181
+ else if (kind === 'workflow') fix = deleted ? '`frontera local dev` archives it (or `frontera workflow archive ' + name + '` now)' : `add its file to the project, or \`frontera workflow archive ${name}\` if it isn’t wanted`
182
+ else if (kind === 'function') {
183
+ fix = deleted
184
+ ? `\`frontera local dev\` switches it off (the runtime can’t remove a Function), or \`frontera function disable ${name}\` now`
185
+ : `add its file to the project, or \`frontera function disable ${name}\` to switch it off (the runtime can’t remove a Function)`
186
+ } else fix = 'add its folder to the project, or delete it from its page in Studio if it isn’t wanted: deleting a knowledge base takes its documents with it, so `frontera local dev` never does'
187
+ out.push({ ref: `${kind}/${name}`, kind, name, ...(id ? { id } : {}), deleted, problem, fix })
188
+ }
189
+ for (const a of items.agents ?? []) add('agent', a.slug, a.id)
190
+ for (const w of items.workflows ?? []) add('workflow', w.slug)
191
+ // A Function switched off and with no file: it doesn't run, and the runtime can't remove it, so there is nothing to fix.
192
+ for (const f of items.functions ?? []) if (f.enabled !== false) add('function', f.slug)
193
+ for (const k of items.knowledge ?? []) add('knowledge', k.name, k.id)
194
+ return out
195
+ }
196
+
197
+ /** The orphans of a running runtime, as `status` lists them; null when it didn't answer. */
198
+ export async function projectOrphans(project: LocalProject, serviceUrl: string, creds: LocalCredentials): Promise<Orphan[] | null> {
199
+ const call = orphanCalls(serviceUrl, creds)
200
+ if (!call) return null
201
+ const items = await runtimeItems(call, creds.workspaceId)
202
+ if (!Object.keys(items).length) return null
203
+ return findOrphans(project.root, items, new Set(loadHashes(project, creds.organizationId).keys()))
204
+ }
205
+
206
+ /** An orphan as `status` says it: the problem, then the fix. */
207
+ export function orphanLine(o: Orphan): string {
208
+ return `${o.problem}: ${o.fix}`
209
+ }
210
+
211
+ // ── What `local dev` took out of service, so a returning file puts it back ──
212
+
213
+ interface RetiredFile {
214
+ organizationId: string
215
+ /** By `kind/name`: when it was taken out, and how. */
216
+ items: Record<string, { how: 'archived' | 'switched-off'; id?: string; at: string }>
217
+ }
218
+
219
+ const retiredPath = (project: Pick<LocalProject, 'stateDir'>) => join(project.stateDir, 'retired.json')
220
+
221
+ export function readRetired(project: Pick<LocalProject, 'stateDir'>, organizationId: string): RetiredFile['items'] {
222
+ try {
223
+ const saved = JSON.parse(readFileSync(retiredPath(project), 'utf8')) as Partial<RetiredFile>
224
+ if (saved.organizationId === organizationId && saved.items) return saved.items
225
+ } catch {
226
+ // None yet.
227
+ }
228
+ return {}
229
+ }
230
+
231
+ function writeRetired(project: Pick<LocalProject, 'stateDir'>, organizationId: string, items: RetiredFile['items']): void {
232
+ try {
233
+ if (!Object.keys(items).length && !existsSync(retiredPath(project))) return
234
+ writeFileAtomic(retiredPath(project), `${JSON.stringify({ organizationId, items }, null, 2)}\n`, 0o644)
235
+ } catch {
236
+ // Losing it only means a returning Function is not switched on by itself.
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Take each orphan whose file was deleted out of service (see the top of the
242
+ * file). Returns the lines to say and the applied-record keys to forget, so
243
+ * the file, when it comes back, is applied again even unchanged.
244
+ */
245
+ export async function retireDeleted(project: Pick<LocalProject, 'stateDir'>, organizationId: string, call: Call, orphans: readonly Orphan[]): Promise<{ lines: string[]; forget: string[] }> {
246
+ const lines: string[] = []
247
+ const forget: string[] = []
248
+ const retired = { ...readRetired(project, organizationId) }
249
+ const at = new Date().toISOString()
250
+ const said = (res: { status: number; body: unknown }) => `${res.status || 'no answer'} ${typeof res.body === 'string' ? res.body.slice(0, 160) : JSON.stringify(res.body).slice(0, 160)}`
251
+ for (const o of orphans) {
252
+ if (!o.deleted) continue
253
+ if (o.kind === 'agent' && o.id) {
254
+ const res = await call(`/v1/config/agents/${encodeURIComponent(o.id)}`, { method: 'DELETE' })
255
+ if (!res.ok) {
256
+ lines.push(` warning: Agent ${o.name}’s folder is gone, but it couldn’t be archived here: ${said(res)}; \`frontera agent archive ${o.name}\` archives it`)
257
+ continue
258
+ }
259
+ retired[o.ref] = { how: 'archived', id: o.id, at }
260
+ lines.push(` archived Agent ${o.name}: its folder is gone from the project (its versions and conversations are kept; it is restored if agents/${o.name}/ comes back)`)
261
+ } else if (o.kind === 'workflow') {
262
+ const res = await call(`/v1/workflows/${encodeURIComponent(o.name)}/archive`, { method: 'POST' })
263
+ if (!res.ok) {
264
+ lines.push(` warning: workflow ${o.name}’s file is gone, but it couldn’t be archived here: ${said(res)}; \`frontera workflow archive ${o.name}\` archives it`)
265
+ continue
266
+ }
267
+ retired[o.ref] = { how: 'archived', at }
268
+ lines.push(` archived workflow ${o.name}: its file is gone from the project (its versions and runs are kept; it is restored if the file comes back)`)
269
+ } else if (o.kind === 'function') {
270
+ const res = await call(`/v1/functions/${encodeURIComponent(o.name)}/enabled`, { method: 'POST', body: { enabled: false } })
271
+ if (!res.ok) {
272
+ lines.push(` warning: Function ${o.name}’s file is gone, but it couldn’t be switched off here: ${said(res)}; \`frontera function disable ${o.name}\` switches it off`)
273
+ continue
274
+ }
275
+ retired[o.ref] = { how: 'switched-off', at }
276
+ lines.push(` switched off Function ${o.name}: its file is gone from the project, and the runtime can’t remove a Function (it is switched on again if the file comes back)`)
277
+ } else continue
278
+ forget.push(...appliedKeysOf(o.kind, o.name))
279
+ }
280
+ writeRetired(project, organizationId, retired)
281
+ return { lines, forget }
282
+ }
283
+
284
+ /**
285
+ * Before an Agent's or workflow's file is applied: one `local dev` archived
286
+ * (its file was deleted, and is back), or archived by hand, is restored, so
287
+ * the file reaches a working item. Returns the line to say, or null.
288
+ */
289
+ export async function restoreIfArchived(project: Pick<LocalProject, 'stateDir'>, organizationId: string, call: Call, kind: 'agent' | 'workflow', name: string): Promise<string | null> {
290
+ const retired = readRetired(project, organizationId)
291
+ const ref = `${kind}/${name}`
292
+ let line: string | null = null
293
+ if (kind === 'agent') {
294
+ const row = await call(`/v1/config/agents/${encodeURIComponent(name)}`)
295
+ const agent = (row.ok ? ((row.body as { data?: unknown })?.data ?? row.body) : null) as { id?: string; lifecycleStatus?: string } | null
296
+ if (agent?.id && agent.lifecycleStatus === 'archived') {
297
+ const res = await call(`/v1/config/agents/${encodeURIComponent(agent.id)}/restore`, { method: 'POST', body: {} })
298
+ line = res.ok ? ` note: restored Agent ${name}: it was archived here, and its folder is in the project` : ` warning: Agent ${name} is archived here and couldn’t be restored (${res.status}): \`frontera agent restore ${name}\``
299
+ }
300
+ } else if (retired[ref]) {
301
+ const res = await call(`/v1/workflows/${encodeURIComponent(name)}/restore`, { method: 'POST' })
302
+ line = res.ok ? ` note: restored workflow ${name}: it was archived here when its file was deleted, and the file is back` : null
303
+ }
304
+ if (retired[ref]) {
305
+ const { [ref]: _gone, ...rest } = retired
306
+ writeRetired(project, organizationId, rest)
307
+ }
308
+ return line
309
+ }
310
+
311
+ /** After a Function's file is deployed again: one `local dev` switched off when its file was deleted is switched on. */
312
+ export async function reviveFunction(project: Pick<LocalProject, 'stateDir'>, organizationId: string, call: Call, slug: string): Promise<string | null> {
313
+ const retired = readRetired(project, organizationId)
314
+ const ref = `function/${slug}`
315
+ if (retired[ref]?.how !== 'switched-off') return null
316
+ const res = await call(`/v1/functions/${encodeURIComponent(slug)}/enabled`, { method: 'POST', body: { enabled: true } })
317
+ if (!res.ok) return ` warning: Function ${slug} was switched off here when its file was deleted, and couldn’t be switched on again (${res.status}): \`frontera function enable ${slug}\``
318
+ const { [ref]: _gone, ...rest } = retired
319
+ writeRetired(project, organizationId, rest)
320
+ return ` note: switched on Function ${slug}: it was switched off here when its file was deleted, and the file is back`
321
+ }
@@ -0,0 +1,296 @@
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 { CliError, UsageError } from '../errors'
7
+ import { isIgnoredName } from './local-dev-sync'
8
+ import { localRuntimeAt } from './local-runtime-target'
9
+ import type { LocalCredentials } from './local-state'
10
+
11
+ /**
12
+ * Local personas: seeded people on the LOCAL runtime only, so an agent can
13
+ * walk a governed Action's approval chain end to end without a person's
14
+ * session (the service's `/v1/local/personas`, which answers 404 anywhere
15
+ * else and takes only the runtime's organization key).
16
+ *
17
+ * `local dev` seeds one persona per approval step the project's Action files
18
+ * name, keyed by the step (`dispatch_lead`), plus a `submitter`: the
19
+ * approver may not be the submitter, and a chain needs one person per step.
20
+ * The submitter is an `owner` (an Action grants its invoke capability to
21
+ * owners only); approvers are `admin`s. `frontera action available|submit|approve|reject --as
22
+ * <persona>` then runs as that persona. The session it uses is minted for the
23
+ * one command, sent only to the local runtime, and never printed or logged.
24
+ */
25
+
26
+ /** The persona that raises requests, apart from every approver. */
27
+ export const SUBMITTER = 'submitter'
28
+
29
+ /** The commands `--as` works on. */
30
+ export const ACT_AS_COMMANDS = ['action available', 'action submit', 'action approve', 'action reject'] as const
31
+
32
+ const KEY = /^[a-z][a-z0-9_-]{0,62}$/
33
+ /** The most personas one seed takes (the route's limit). */
34
+ const MAX_PERSONAS = 20
35
+
36
+ /** A persona key from what a person types or a step is named: `Dispatch lead` → `dispatch_lead`. */
37
+ export function personaKey(name: string): string {
38
+ const key = name.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '_').replace(/^[^a-z]+/, '').replace(/_+$/, '').slice(0, 63)
39
+ return KEY.test(key) ? key : ''
40
+ }
41
+
42
+ export interface PersonaSpec {
43
+ key: string
44
+ displayName: string
45
+ /**
46
+ * The organization role it holds: `owner` for the submitter (an Action
47
+ * grants its invoke capability to owners only), `admin` for an approver.
48
+ */
49
+ orgRole: 'owner' | 'admin'
50
+ /** What the persona is for, in words: "decides step dispatch_lead of escalateWorkOrder". */
51
+ decides: string[]
52
+ }
53
+
54
+ export interface PersonaPlan {
55
+ personas: PersonaSpec[]
56
+ /** What could not be seeded, one line each (a step decided by an App role). */
57
+ notes: string[]
58
+ }
59
+
60
+ const words = (key: string) => {
61
+ const w = key.replace(/[_-]+/g, ' ').trim()
62
+ return w.charAt(0).toUpperCase() + w.slice(1)
63
+ }
64
+
65
+ type Doc = Record<string, unknown>
66
+ const isRecord = (v: unknown): v is Doc => !!v && typeof v === 'object' && !Array.isArray(v)
67
+
68
+ /** The project's Action files' documents, parsed; unreadable ones are left out. */
69
+ function actionDocs(root: string): Array<{ file: string; doc: Doc }> {
70
+ const dir = join(root, 'blueprint', 'actions')
71
+ if (!existsSync(dir)) return []
72
+ return readdirSync(dir).sort().flatMap((name) => {
73
+ if (isIgnoredName(name) || !/\.(ya?ml|json)$/.test(name)) return []
74
+ try {
75
+ const doc = parseYaml(readFileSync(join(dir, name), 'utf8'))
76
+ return isRecord(doc) ? [{ file: join('blueprint', 'actions', name), doc }] : []
77
+ } catch {
78
+ return []
79
+ }
80
+ })
81
+ }
82
+
83
+ /**
84
+ * The personas the project's Actions need: one per approval step (a chain's
85
+ * stage key; `approver` for an Action approved in one step), and a submitter
86
+ * when any Action asks for approval. A step decided by an App role is said,
87
+ * not seeded: a persona holds an organization role, not an App's.
88
+ */
89
+ export function personaPlan(root: string): PersonaPlan {
90
+ const byKey = new Map<string, PersonaSpec>()
91
+ const notes: string[] = []
92
+ const add = (key: string, decides: string) => {
93
+ const at = byKey.get(key) ?? { key, displayName: words(key), orgRole: key === SUBMITTER ? ('owner' as const) : ('admin' as const), decides: [] }
94
+ at.decides.push(decides)
95
+ byKey.set(key, at)
96
+ }
97
+ for (const { file, doc } of actionDocs(root)) {
98
+ const apiName = typeof doc.apiName === 'string' ? doc.apiName : file
99
+ const approval = (isRecord(doc.governance) ? doc.governance.approval : undefined) ?? doc.approval
100
+ if (!isRecord(approval) || approval.mode === undefined || approval.mode === 'none') continue
101
+ if (approval.mode === 'chain' && Array.isArray(approval.stages)) {
102
+ for (const stage of approval.stages.filter(isRecord)) {
103
+ const raw = typeof stage.key === 'string' ? stage.key : ''
104
+ const key = personaKey(raw)
105
+ if (!key || key === SUBMITTER) {
106
+ notes.push(`${file}: step "${raw}" has no key a persona can take (a lowercase letter, then letters, digits, "_" or "-")`)
107
+ continue
108
+ }
109
+ if (isRecord(stage.approver) && typeof stage.approver.role === 'string') {
110
+ notes.push(`${file}: step ${raw} is decided by the App-user role ${stage.approver.role}, which local personas don't cover; decide it as an App user in the App`)
111
+ continue
112
+ }
113
+ add(key, `step ${raw} of ${apiName}`)
114
+ }
115
+ } else {
116
+ add('approver', `approval of ${apiName}`)
117
+ }
118
+ add(SUBMITTER, `requests for ${apiName}`)
119
+ }
120
+ // The submitter first, then the approvers as their steps come.
121
+ const personas = [...byKey.values()].sort((a, b) => Number(b.key === SUBMITTER) - Number(a.key === SUBMITTER))
122
+ if (personas.length > MAX_PERSONAS) notes.push(`${personas.length - MAX_PERSONAS} more approval steps than the ${MAX_PERSONAS} personas a runtime seeds at once: the rest were left out`)
123
+ return { personas: personas.slice(0, MAX_PERSONAS), notes }
124
+ }
125
+
126
+ export interface LocalPersona {
127
+ key: string
128
+ displayName: string
129
+ orgRole?: string
130
+ }
131
+
132
+ type Fetch = typeof fetch
133
+
134
+ /** Calls to `/v1/local/personas` with the runtime's organization key. */
135
+ function personaCall(serviceUrl: string, creds: LocalCredentials, fetcher: Fetch) {
136
+ return async (method: string, path: string, body?: unknown): Promise<{ status: number; body: unknown }> => {
137
+ const res = await fetcher(new URL(`/v1/local/personas${path}`, serviceUrl), {
138
+ method,
139
+ headers: {
140
+ authorization: `Bearer ${creds.orgKey}`,
141
+ 'x-org-id': creds.organizationId,
142
+ 'x-workspace-id': creds.workspaceId,
143
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
144
+ },
145
+ body: body === undefined ? undefined : JSON.stringify(body),
146
+ })
147
+ const text = await res.text()
148
+ let parsed: unknown = text
149
+ try {
150
+ parsed = text ? JSON.parse(text) : null
151
+ } catch {
152
+ // Keep the text.
153
+ }
154
+ return { status: res.status, body: parsed }
155
+ }
156
+ }
157
+
158
+ const inner = (body: unknown): unknown => (isRecord(body) && 'data' in body ? body.data : body)
159
+
160
+ function personasOf(body: unknown): LocalPersona[] {
161
+ const data = inner(body)
162
+ const rows = Array.isArray(data) ? data : isRecord(data) && Array.isArray(data.personas) ? data.personas : []
163
+ return rows.filter(isRecord).flatMap((p) => (typeof p.key === 'string' ? [{ key: p.key, displayName: typeof p.displayName === 'string' ? p.displayName : words(p.key), ...(typeof p.orgRole === 'string' ? { orgRole: p.orgRole } : {}) }] : []))
164
+ }
165
+
166
+ const messageOf = (answer: { status: number; body: unknown }) => {
167
+ const m = isRecord(answer.body) && typeof answer.body.message === 'string' ? answer.body.message : typeof answer.body === 'string' ? answer.body : ''
168
+ return `${answer.status}${m ? ` ${m.replace(/\s+/g, ' ').slice(0, 200)}` : ''}`
169
+ }
170
+
171
+ /** The personas on the runtime; null when it has none to list (it predates them) or did not answer. */
172
+ export async function listPersonas(serviceUrl: string, creds: LocalCredentials, fetcher: Fetch = fetch): Promise<LocalPersona[] | null> {
173
+ try {
174
+ const answer = await personaCall(serviceUrl, creds, fetcher)('GET', '/')
175
+ return answer.status < 300 ? personasOf(answer.body) : null
176
+ } catch {
177
+ return null
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Seed the project's personas on the runtime (idempotent). Lines for the
183
+ * apply's output: what was seeded, or why not; never a failure of the build.
184
+ */
185
+ export async function seedPersonas(root: string, serviceUrl: string, creds: LocalCredentials, fetcher: Fetch = fetch): Promise<string[]> {
186
+ const plan = personaPlan(root)
187
+ const lines = plan.notes.map((n) => ` note: ${n}`)
188
+ if (!plan.personas.length) return lines
189
+ let answer: { status: number; body: unknown }
190
+ try {
191
+ answer = await personaCall(serviceUrl, creds, fetcher)('POST', '/', { personas: plan.personas.map((p) => ({ key: p.key, displayName: p.displayName, orgRole: p.orgRole })) })
192
+ } catch (err) {
193
+ return [...lines, ` note: local personas could not be seeded: ${(err as Error).message}`]
194
+ }
195
+ if (answer.status === 404) return [...lines, ' note: this runtime has no local personas (its platform predates them), so approval chains can only be decided by a person here']
196
+ if (answer.status >= 300) return [...lines, ` note: local personas could not be seeded: ${messageOf(answer)}`]
197
+ const keys = plan.personas.map((p) => p.key)
198
+ const approver = keys.find((k) => k !== SUBMITTER)
199
+ return [...lines, ` local personas ${keys.join(', ')}: act as one with \`--as\`${approver ? ` (\`frontera action approve <requestId> --as ${approver}\`)` : ''}`]
200
+ }
201
+
202
+ export interface PersonaSession {
203
+ /** The persona's session: sent only as this command's bearer, never printed. */
204
+ token: string
205
+ persona: LocalPersona
206
+ organizationId: string
207
+ workspaceId: string
208
+ }
209
+
210
+ /**
211
+ * The session `--as <persona>` runs a command with: refused for any other
212
+ * command, and anywhere but the project's own local runtime (never a hosted
213
+ * deployment, whatever the credential). `wanted` is a persona key or the
214
+ * approval step it was seeded for.
215
+ */
216
+ export async function actAsLocalPersona(opts: {
217
+ meta: { noun: string; verb: string }
218
+ cwd: string
219
+ apiUrl: string
220
+ wanted: string
221
+ fetcher?: Fetch
222
+ }): Promise<PersonaSession> {
223
+ const label = `${opts.meta.noun} ${opts.meta.verb}`
224
+ if (!(ACT_AS_COMMANDS as readonly string[]).includes(label)) {
225
+ throw new UsageError(`--as does nothing on \`${label}\``, `--as acts as a local persona on ${ACT_AS_COMMANDS.map((c) => `\`${c}\``).join(', ')}`)
226
+ }
227
+ const local = localRuntimeAt(opts.cwd, opts.apiUrl)
228
+ if (!local) {
229
+ throw new CliError(`--as acts as a local persona, and ${opts.apiUrl} is not this project's local runtime`, {
230
+ code: 'LOCAL_ONLY',
231
+ hint: 'personas exist only on a local runtime: run the command in its project folder while it runs (`frontera local status`); on a deployment, a person signs in (`frontera login`)',
232
+ })
233
+ }
234
+ const key = personaKey(opts.wanted)
235
+ if (!key) throw new UsageError(`"${opts.wanted}" names no persona`, 'pass a persona key or approval step, as `frontera local status` lists them')
236
+ const call = personaCall(local.serviceUrl, local.creds, opts.fetcher ?? fetch)
237
+ const answer = await call('POST', `/${encodeURIComponent(key)}/session`)
238
+ if (answer.status === 404) {
239
+ const known = await listPersonas(local.serviceUrl, local.creds, opts.fetcher ?? fetch)
240
+ throw new CliError(known ? `no local persona is named ${key}` : 'this runtime has no local personas', {
241
+ code: 'PERSONA_NOT_FOUND',
242
+ hint: known
243
+ ? `${known.length ? `the personas here are ${known.map((p) => p.key).join(', ')}; ` : ''}\`frontera local dev\` seeds one per approval step in blueprint/actions/, plus ${SUBMITTER}`
244
+ : 'its platform predates them: start the runtime from a release that has them (`frontera local start`)',
245
+ })
246
+ }
247
+ const data = inner(answer.body)
248
+ const token = isRecord(data) && typeof data.token === 'string' ? data.token : ''
249
+ if (answer.status >= 300 || !token) throw new CliError(`a session for persona ${key} could not be started`, { code: 'PERSONA_SESSION_FAILED', hint: messageOf({ status: answer.status, body: isRecord(answer.body) ? { message: answer.body.message } : null }) })
250
+ const persona = isRecord(data) && isRecord(data.persona) ? personasOf({ data: [data.persona] })[0] : undefined
251
+ return { token, persona: persona ?? { key, displayName: words(key) }, organizationId: local.creds.organizationId, workspaceId: local.creds.workspaceId }
252
+ }
253
+
254
+ /** `value` with every occurrence of `secret` replaced: what an error says after a persona's session ran. */
255
+ export function redact<T>(value: T, secret: string, seen = new WeakSet<object>()): T {
256
+ if (!secret) return value
257
+ if (typeof value === 'string') return value.split(secret).join('[persona session]') as T
258
+ if (!value || typeof value !== 'object' || seen.has(value as object)) return value
259
+ seen.add(value as object)
260
+ if (Array.isArray(value)) {
261
+ value.forEach((v, i) => ((value as unknown[])[i] = redact(v, secret, seen)))
262
+ return value
263
+ }
264
+ for (const k of Object.getOwnPropertyNames(value)) {
265
+ try {
266
+ const v = (value as Record<string, unknown>)[k]
267
+ if (typeof v === 'string' || (v && typeof v === 'object')) (value as Record<string, unknown>)[k] = redact(v, secret, seen)
268
+ } catch {
269
+ // A read-only property keeps its value.
270
+ }
271
+ }
272
+ return value
273
+ }
274
+
275
+ /**
276
+ * What a key refused on a command that acts as a person is told on the
277
+ * project's own local runtime, instead of the deployment's "set FRONTERA_TOKEN
278
+ * to your session token": a local runtime has personas, and an agent is never
279
+ * sent to fetch a person's session. On a command `--as` works on it names the
280
+ * persona to act as (the submitter to see or raise requests, an approver to
281
+ * decide one) and the personas the runtime has (`personas`, null when they
282
+ * could not be read); on any other it says `--as` does not reach it.
283
+ */
284
+ export function localPersonaHint(meta: { noun: string; verb: string }, personas: readonly LocalPersona[] | null): string {
285
+ const label = `${meta.noun} ${meta.verb}`
286
+ const approvers = (personas ?? []).filter((p) => p.key !== SUBMITTER).map((p) => p.key)
287
+ const approver = approvers[0] ?? 'approver'
288
+ const listed = personas?.length ? `the personas here are ${personas.map((p) => p.key).join(', ')}` : '`frontera local status` lists the personas (Personas)'
289
+ const never = 'never a person’s session token'
290
+ if (!(ACT_AS_COMMANDS as readonly string[]).includes(label)) {
291
+ return `on this local runtime no person signs in, and \`--as <persona>\` acts as a local persona only on ${ACT_AS_COMMANDS.map((c) => `\`${c}\``).join(', ')}; \`${label}\` runs against a deployment, where a person signs in (${never} here)`
292
+ }
293
+ const deciding = meta.verb === 'approve' || meta.verb === 'reject'
294
+ const example = deciding ? `frontera ${label} <requestId> --as ${approver}` : `frontera ${label}${meta.verb === 'submit' ? ' <apiName>' : ''} --as ${SUBMITTER}`
295
+ return `on this local runtime, act as a local persona: \`--as ${SUBMITTER}\` sees and raises requests, \`--as ${approver}\` approves or rejects them (\`${example}\`); ${listed}: ${never}`
296
+ }